Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a drop-in extension for can1357/oh-my-pi (omp >= 16.5.2). Changes vs upstream: - Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram - Bump version: 0.22.0 -> 0.22.0-evandro.1 - Update repository/homepage/bugs URLs to evandrodevbr - Add omp field in package.json (displayName + compatibleWith) - README: prepend 'Oh My Pi (omp) Compatibility' section with install commands, verified-components table, upstream lineage, and rationale - CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased - docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram - tests/public-api.test.ts: rename package import paths Gates re-run on the fork: - npm run typecheck -> tsc --noEmit (success) - npm test -> 1229 pass / 0 fail / 1 platform-only skip - npm run audit -> found 0 vulnerabilities - npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz Verified: - omp install --link ./pi-telegram -> Linked OK - omp plugin list -> @evandrodevbr/[email protected].0-evandro.1 Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
834 lines
26 KiB
TypeScript
834 lines
26 KiB
TypeScript
/**
|
|
* Regression tests for the Telegram outbound attachments domain
|
|
* Covers outbound attachment queueing and delivery behavior in one domain-level suite
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
createTelegramQueuedOutboundAttachmentSender,
|
|
deliverTelegramGuestCachedAttachment,
|
|
getTelegramOutboundAttachmentByteLimitFromEnv,
|
|
queueTelegramOutboundAttachments,
|
|
registerTelegramOutboundAttachmentTool,
|
|
registerTelegramOutboundMessageTool,
|
|
sendQueuedTelegramOutboundAttachments,
|
|
sendTelegramOutboundFiles,
|
|
sendTelegramOutboundMessage,
|
|
TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES,
|
|
type TelegramOutboundAttachmentQueueTargetView,
|
|
type TelegramQueuedOutboundAttachmentTurnView,
|
|
} from "../lib/outbound-attachments.ts";
|
|
import type { ExtensionAPI } from "../lib/pi.ts";
|
|
import { createTelegramThreadTarget } from "../lib/target.ts";
|
|
|
|
function createAttachmentQueueTarget(
|
|
queuedAttachments: TelegramOutboundAttachmentQueueTargetView["queuedAttachments"] = [],
|
|
): TelegramOutboundAttachmentQueueTargetView {
|
|
return { queuedAttachments };
|
|
}
|
|
|
|
function createAttachmentTurn(
|
|
queuedAttachments = [{ path: "/tmp/a.png", fileName: "a.png" }],
|
|
): TelegramQueuedOutboundAttachmentTurnView {
|
|
return { chatId: 1, replyToMessageId: 2, queuedAttachments };
|
|
}
|
|
|
|
type RegisteredAttachmentTool = {
|
|
name?: string;
|
|
execute: (
|
|
toolCallId: string,
|
|
params: {
|
|
paths: string[];
|
|
chat_id?: number;
|
|
thread_id?: number;
|
|
caption?: string;
|
|
},
|
|
) => Promise<{ details: { paths: string[]; chatId?: number } }>;
|
|
};
|
|
|
|
type RegisteredAnyTool = {
|
|
name?: string;
|
|
execute: (
|
|
toolCallId: string,
|
|
params: Record<string, unknown>,
|
|
) => Promise<unknown>;
|
|
};
|
|
|
|
test("Outbound attachment byte-limit helpers own the outbound file default", () => {
|
|
assert.equal(
|
|
TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES,
|
|
50 * 1024 * 1024,
|
|
);
|
|
assert.equal(
|
|
getTelegramOutboundAttachmentByteLimitFromEnv(
|
|
{ PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES: "12345" },
|
|
["PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES"],
|
|
99,
|
|
),
|
|
12345,
|
|
);
|
|
assert.equal(
|
|
getTelegramOutboundAttachmentByteLimitFromEnv(
|
|
{
|
|
PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES: "0",
|
|
TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES: "bad",
|
|
},
|
|
[
|
|
"PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES",
|
|
"TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES",
|
|
],
|
|
99,
|
|
),
|
|
99,
|
|
);
|
|
});
|
|
|
|
test("Outbound attachment tool registration delegates queueing", async () => {
|
|
let tool: RegisteredAttachmentTool | undefined;
|
|
const api = {
|
|
registerTool: (definition: RegisteredAttachmentTool) => {
|
|
tool = definition;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
const activeTurn = createAttachmentQueueTarget();
|
|
registerTelegramOutboundAttachmentTool(api, {
|
|
maxAttachmentsPerTurn: 2,
|
|
getActiveTurn: () => activeTurn,
|
|
statPath: async () => ({ isFile: () => true }),
|
|
});
|
|
assert.equal(tool?.name, "telegram_attach");
|
|
assert.ok(tool);
|
|
const result = await tool.execute("tool-call", { paths: ["/tmp/report.md"] });
|
|
assert.deepEqual(activeTurn.queuedAttachments, [
|
|
{ path: "/tmp/report.md", fileName: "report.md" },
|
|
]);
|
|
assert.deepEqual(result.details.paths, ["/tmp/report.md"]);
|
|
});
|
|
|
|
test("Outbound attachment tool sends immediately when no Telegram turn is active", async () => {
|
|
let tool: RegisteredAttachmentTool | undefined;
|
|
const sent: string[] = [];
|
|
const api = {
|
|
registerTool: (definition: RegisteredAttachmentTool) => {
|
|
tool = definition;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
registerTelegramOutboundAttachmentTool(api, {
|
|
maxAttachmentsPerTurn: 2,
|
|
getActiveTurn: () => undefined,
|
|
getDefaultChatId: () => 77,
|
|
canSendDirect: () => true,
|
|
sendMultipart: async (method, fields, fileField, _filePath, fileName) => {
|
|
sent.push(
|
|
`${method}:${fields.chat_id}:${fields.message_thread_id ?? "none"}:${fields.caption}:${fileField}:${fileName}`,
|
|
);
|
|
},
|
|
statPath: async () => ({ isFile: () => true, size: 1 }),
|
|
});
|
|
const result = await tool?.execute("tool-call", {
|
|
paths: ["/tmp/report.md"],
|
|
caption: "done",
|
|
});
|
|
assert.deepEqual(sent, ["sendDocument:77:none:done:document:report.md"]);
|
|
assert.deepEqual(result?.details, { paths: ["/tmp/report.md"], chatId: 77 });
|
|
});
|
|
|
|
test("Outbound attachment tool sends to assigned thread target by default", async () => {
|
|
let tool: RegisteredAttachmentTool | undefined;
|
|
const sent: Array<{ chatId?: string; threadId?: string }> = [];
|
|
const api = {
|
|
registerTool: (definition: RegisteredAttachmentTool) => {
|
|
tool = definition;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
registerTelegramOutboundAttachmentTool(api, {
|
|
maxAttachmentsPerTurn: 2,
|
|
getActiveTurn: () => undefined,
|
|
getDefaultChatId: () => 7,
|
|
getDefaultTarget: () => createTelegramThreadTarget(-1007, 42),
|
|
canSendDirect: () => true,
|
|
sendMultipart: async (_method, fields) => {
|
|
sent.push({ chatId: fields.chat_id, threadId: fields.message_thread_id });
|
|
},
|
|
statPath: async () => ({ isFile: () => true, size: 1 }),
|
|
});
|
|
await tool?.execute("tool-call", { paths: ["/tmp/report.md"] });
|
|
assert.deepEqual(sent, [{ chatId: "-1007", threadId: "42" }]);
|
|
});
|
|
|
|
test("Outbound attachment tool sends explicit thread target immediately", async () => {
|
|
let tool: RegisteredAttachmentTool | undefined;
|
|
const sent: string[] = [];
|
|
const api = {
|
|
registerTool: (definition: RegisteredAttachmentTool) => {
|
|
tool = definition;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
registerTelegramOutboundAttachmentTool(api, {
|
|
maxAttachmentsPerTurn: 2,
|
|
getActiveTurn: () => undefined,
|
|
canSendDirect: () => true,
|
|
sendMultipart: async (_method, fields) => {
|
|
sent.push(`${fields.chat_id}:${fields.message_thread_id}`);
|
|
},
|
|
statPath: async () => ({ isFile: () => true, size: 1 }),
|
|
});
|
|
await tool?.execute("tool-call", {
|
|
paths: ["/tmp/report.md"],
|
|
chat_id: -1007,
|
|
thread_id: 42,
|
|
});
|
|
assert.deepEqual(sent, ["-1007:42"]);
|
|
});
|
|
|
|
test("Outbound message tool sends direct Telegram markdown with parsed buttons", async () => {
|
|
const tools = new Map<string, RegisteredAnyTool>();
|
|
const sent: Array<{
|
|
chatId: number;
|
|
markdown: string;
|
|
replyMarkup?: unknown;
|
|
target?: unknown;
|
|
}> = [];
|
|
const api = {
|
|
registerTool: (definition: RegisteredAnyTool) => {
|
|
if (definition.name) tools.set(definition.name, definition);
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
registerTelegramOutboundMessageTool(api, {
|
|
getDefaultChatId: () => 7,
|
|
canSendDirect: () => true,
|
|
planMessage: (markdown) => ({
|
|
markdown: markdown
|
|
.replace(/<!-- telegram_button: Continue -->/, "")
|
|
.trim(),
|
|
replyMarkup: {
|
|
inline_keyboard: [[{ text: "Continue", callback_data: "button:1" }]],
|
|
},
|
|
}),
|
|
sendMarkdownMessage: async (chatId, markdown, options) => {
|
|
sent.push({
|
|
chatId,
|
|
markdown,
|
|
replyMarkup: options?.replyMarkup,
|
|
target: options?.target,
|
|
});
|
|
return 9;
|
|
},
|
|
});
|
|
await tools.get("telegram_message")?.execute("tool-call", {
|
|
text: "**hello**\n\n<!-- telegram_button: Continue -->",
|
|
});
|
|
assert.deepEqual(sent, [
|
|
{
|
|
chatId: 7,
|
|
markdown: "**hello**",
|
|
replyMarkup: {
|
|
inline_keyboard: [[{ text: "Continue", callback_data: "button:1" }]],
|
|
},
|
|
target: undefined,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("Outbound message tool sends explicit thread target", async () => {
|
|
const tools = new Map<string, RegisteredAnyTool>();
|
|
const sent: Array<{ chatId: number; target?: unknown }> = [];
|
|
const api = {
|
|
registerTool: (definition: RegisteredAnyTool) => {
|
|
if (definition.name) tools.set(definition.name, definition);
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
registerTelegramOutboundMessageTool(api, {
|
|
getDefaultChatId: () => 7,
|
|
canSendDirect: () => true,
|
|
planMessage: (markdown) => ({ markdown }),
|
|
sendMarkdownMessage: async (chatId, _markdown, options) => {
|
|
sent.push({ chatId, target: options?.target });
|
|
return 9;
|
|
},
|
|
});
|
|
await tools.get("telegram_message")?.execute("tool-call", {
|
|
text: "hello",
|
|
chat_id: -1007,
|
|
thread_id: 42,
|
|
});
|
|
assert.deepEqual(sent, [
|
|
{ chatId: -1007, target: { chatId: -1007, threadId: 42 } },
|
|
]);
|
|
});
|
|
|
|
test("Direct outbound message carries internal thread target", async () => {
|
|
const target = createTelegramThreadTarget(-1007, 42);
|
|
const sent: Array<{ chatId: number; target?: unknown }> = [];
|
|
await sendTelegramOutboundMessage({
|
|
text: "hello",
|
|
chatId: -1007,
|
|
target,
|
|
canSendDirect: () => true,
|
|
planMessage: (markdown) => ({ markdown }),
|
|
sendMarkdownMessage: async (chatId, _markdown, options) => {
|
|
sent.push({ chatId, target: options?.target });
|
|
return 1;
|
|
},
|
|
});
|
|
assert.deepEqual(sent, [{ chatId: -1007, target }]);
|
|
});
|
|
|
|
test("Direct outbound message defaults to assigned thread target", async () => {
|
|
const target = createTelegramThreadTarget(-1007, 42);
|
|
const sent: Array<{ chatId: number; target?: unknown }> = [];
|
|
await sendTelegramOutboundMessage({
|
|
text: "hello",
|
|
getDefaultChatId: () => 7,
|
|
getDefaultTarget: () => target,
|
|
canSendDirect: () => true,
|
|
planMessage: (markdown) => ({ markdown }),
|
|
sendMarkdownMessage: async (chatId, _markdown, options) => {
|
|
sent.push({ chatId, target: options?.target });
|
|
return 1;
|
|
},
|
|
});
|
|
assert.deepEqual(sent, [{ chatId: -1007, target }]);
|
|
});
|
|
|
|
test("Direct outbound files carry internal thread target", async () => {
|
|
const sentFields: Array<Record<string, string>> = [];
|
|
await sendTelegramOutboundFiles({
|
|
paths: ["/tmp/report.md"],
|
|
chatId: -1007,
|
|
target: createTelegramThreadTarget(-1007, 42),
|
|
maxAttachmentsPerTurn: 1,
|
|
canSendDirect: () => true,
|
|
statPath: async () => ({ isFile: () => true, size: 1 }),
|
|
sendMultipart: async (_method, fields) => {
|
|
sentFields.push(fields);
|
|
},
|
|
});
|
|
assert.equal(sentFields[0]?.message_thread_id, "42");
|
|
});
|
|
|
|
test("Direct outbound files accept explicit tool thread target", async () => {
|
|
const sentFields: Array<Record<string, string>> = [];
|
|
await sendTelegramOutboundFiles({
|
|
paths: ["/tmp/report.md"],
|
|
chatId: -1007,
|
|
threadId: 42,
|
|
maxAttachmentsPerTurn: 1,
|
|
canSendDirect: () => true,
|
|
statPath: async () => ({ isFile: () => true, size: 1 }),
|
|
sendMultipart: async (_method, fields) => {
|
|
sentFields.push(fields);
|
|
},
|
|
});
|
|
assert.equal(sentFields[0]?.message_thread_id, "42");
|
|
});
|
|
|
|
test("Direct Telegram tools require local polling lock ownership", async () => {
|
|
await assert.rejects(
|
|
() =>
|
|
queueTelegramOutboundAttachments({
|
|
activeTurn: undefined,
|
|
paths: ["/tmp/report.md"],
|
|
maxAttachmentsPerTurn: 2,
|
|
sendMultipart: async () => undefined,
|
|
getDefaultChatId: () => 77,
|
|
canSendDirect: () => false,
|
|
statPath: async () => ({ isFile: () => true, size: 1 }),
|
|
}),
|
|
{
|
|
message:
|
|
/requires this Pi instance to own \/telegram-connect or be registered/,
|
|
},
|
|
);
|
|
await assert.rejects(() => toolsMessageWithoutOwnership(), {
|
|
message:
|
|
/requires this Pi instance to own \/telegram-connect or be registered/,
|
|
});
|
|
});
|
|
|
|
async function toolsMessageWithoutOwnership(): Promise<unknown> {
|
|
let tool: RegisteredAnyTool | undefined;
|
|
const api = {
|
|
registerTool: (definition: RegisteredAnyTool) => {
|
|
tool = definition;
|
|
},
|
|
} as unknown as ExtensionAPI;
|
|
registerTelegramOutboundMessageTool(api, {
|
|
getDefaultChatId: () => 7,
|
|
canSendDirect: () => false,
|
|
planMessage: (markdown) => ({ markdown }),
|
|
sendMarkdownMessage: async () => 9,
|
|
});
|
|
return tool?.execute("tool-call", { text: "hello" });
|
|
}
|
|
|
|
test("Outbound attachment queueing adds files to the active Telegram turn", async () => {
|
|
const activeTurn = createAttachmentQueueTarget();
|
|
const result = await queueTelegramOutboundAttachments({
|
|
activeTurn,
|
|
paths: ["/tmp/demo.txt"],
|
|
maxAttachmentsPerTurn: 2,
|
|
statPath: async () => ({ isFile: () => true }),
|
|
});
|
|
assert.deepEqual(activeTurn.queuedAttachments, [
|
|
{ path: "/tmp/demo.txt", fileName: "demo.txt" },
|
|
]);
|
|
assert.deepEqual(result.details.paths, ["/tmp/demo.txt"]);
|
|
assert.equal(result.content[0]?.text, "\nQueued 1 Telegram attachment(s).");
|
|
});
|
|
|
|
test("Outbound attachment queueing admits exactly one Guest Mode file", async () => {
|
|
const activeTurn = {
|
|
...createAttachmentQueueTarget(),
|
|
guestQueryId: "guest-query",
|
|
};
|
|
await queueTelegramOutboundAttachments({
|
|
activeTurn,
|
|
paths: ["/tmp/demo.txt"],
|
|
maxAttachmentsPerTurn: 2,
|
|
statPath: async () => ({ isFile: () => true }),
|
|
});
|
|
assert.deepEqual(activeTurn.queuedAttachments, [
|
|
{ path: "/tmp/demo.txt", fileName: "demo.txt" },
|
|
]);
|
|
|
|
let statCalled = false;
|
|
await assert.rejects(
|
|
() =>
|
|
queueTelegramOutboundAttachments({
|
|
activeTurn,
|
|
paths: ["/tmp/second.txt"],
|
|
maxAttachmentsPerTurn: 2,
|
|
statPath: async () => {
|
|
statCalled = true;
|
|
return { isFile: () => true };
|
|
},
|
|
}),
|
|
{
|
|
message:
|
|
"Telegram Guest Mode supports one attachment per reply; no attachment was queued",
|
|
},
|
|
);
|
|
assert.equal(statCalled, false);
|
|
assert.equal(activeTurn.queuedAttachments.length, 1);
|
|
});
|
|
|
|
test("Guest attachment staging builds cached media results and always deletes staging messages", async () => {
|
|
const cases = [
|
|
{
|
|
path: "/tmp/report.txt",
|
|
response: { message_id: 11, document: { file_id: "doc-id" } },
|
|
expectedMethod: "sendDocument",
|
|
expectedResult: {
|
|
type: "document",
|
|
id: "attachment-1",
|
|
title: "report.txt",
|
|
document_file_id: "doc-id",
|
|
caption: "caption",
|
|
},
|
|
},
|
|
{
|
|
path: "/tmp/photo.jpg",
|
|
response: {
|
|
message_id: 12,
|
|
photo: [
|
|
{ file_id: "small", file_size: 10 },
|
|
{ file_id: "large", file_size: 20 },
|
|
],
|
|
},
|
|
expectedMethod: "sendPhoto",
|
|
expectedResult: {
|
|
type: "photo",
|
|
id: "attachment-1",
|
|
photo_file_id: "large",
|
|
caption: "caption",
|
|
},
|
|
},
|
|
{
|
|
path: "/tmp/audio.mp3",
|
|
response: { message_id: 13, audio: { file_id: "audio-id" } },
|
|
expectedMethod: "sendAudio",
|
|
expectedResult: {
|
|
type: "audio",
|
|
id: "attachment-1",
|
|
audio_file_id: "audio-id",
|
|
caption: "caption",
|
|
},
|
|
},
|
|
{
|
|
path: "/tmp/voice.ogg",
|
|
response: { message_id: 14, voice: { file_id: "voice-id" } },
|
|
expectedMethod: "sendVoice",
|
|
expectedResult: {
|
|
type: "voice",
|
|
id: "attachment-1",
|
|
voice_file_id: "voice-id",
|
|
title: "voice.ogg",
|
|
caption: "caption",
|
|
},
|
|
},
|
|
] as const;
|
|
|
|
for (const item of cases) {
|
|
const events: unknown[] = [];
|
|
await deliverTelegramGuestCachedAttachment({
|
|
guestQueryId: "guest-1",
|
|
stagingChatId: 42,
|
|
attachment: { path: item.path, fileName: item.path.split("/").at(-1)! },
|
|
caption: "caption",
|
|
sendMultipart: async (method, fields, fileField) => {
|
|
events.push(["upload", method, fields, fileField]);
|
|
return item.response;
|
|
},
|
|
answerGuestQuery: async (guestQueryId, result) => {
|
|
events.push(["answer", guestQueryId, result]);
|
|
},
|
|
deleteMessage: async (chatId, messageId) => {
|
|
events.push(["delete", chatId, messageId]);
|
|
},
|
|
});
|
|
assert.deepEqual(events, [
|
|
[
|
|
"upload",
|
|
item.expectedMethod,
|
|
{ chat_id: "42" },
|
|
item.expectedMethod.replace("send", "").toLowerCase(),
|
|
],
|
|
["answer", "guest-1", item.expectedResult],
|
|
["delete", 42, item.response.message_id],
|
|
]);
|
|
}
|
|
});
|
|
|
|
test("Guest attachment staging cleans up extraction and answer failures", async () => {
|
|
for (const phase of ["extract", "answer"] as const) {
|
|
const events: string[] = [];
|
|
await assert.rejects(
|
|
() =>
|
|
deliverTelegramGuestCachedAttachment({
|
|
guestQueryId: "guest-1",
|
|
stagingChatId: 42,
|
|
attachment: { path: "/tmp/report.txt", fileName: "report.txt" },
|
|
sendMultipart: async () => ({
|
|
message_id: 20,
|
|
...(phase === "answer" ? { document: { file_id: "doc-id" } } : {}),
|
|
}),
|
|
answerGuestQuery: async () => {
|
|
events.push("answer");
|
|
throw new Error("answer failed");
|
|
},
|
|
deleteMessage: async () => {
|
|
events.push("delete");
|
|
},
|
|
}),
|
|
phase === "extract" ? /no document file_id/ : /answer failed/,
|
|
);
|
|
assert.deepEqual(events, phase === "extract" ? ["delete"] : ["answer", "delete"]);
|
|
}
|
|
});
|
|
|
|
test("Guest attachment staging falls back to one text answer before media answer", async () => {
|
|
const events: string[] = [];
|
|
await deliverTelegramGuestCachedAttachment({
|
|
guestQueryId: "guest-1",
|
|
stagingChatId: 42,
|
|
attachment: { path: "/tmp/report.txt", fileName: "report.txt" },
|
|
fallbackText: "fallback",
|
|
sendMultipart: async () => ({ message_id: 20 }),
|
|
answerGuestQuery: async () => {
|
|
events.push("unexpected:media");
|
|
},
|
|
answerGuestText: async (guestQueryId, text) => {
|
|
events.push(`text:${guestQueryId}:${text}`);
|
|
},
|
|
deleteMessage: async () => {
|
|
events.push("delete");
|
|
},
|
|
});
|
|
assert.deepEqual(events, ["text:guest-1:fallback", "delete"]);
|
|
});
|
|
|
|
test("Guest attachment staging records cleanup failure without retrying the answer", async () => {
|
|
const events: string[] = [];
|
|
await deliverTelegramGuestCachedAttachment({
|
|
guestQueryId: "guest-1",
|
|
stagingChatId: 42,
|
|
attachment: { path: "/tmp/report.txt", fileName: "report.txt" },
|
|
sendMultipart: async () => ({
|
|
message_id: 20,
|
|
document: { file_id: "doc-id" },
|
|
}),
|
|
answerGuestQuery: async () => {
|
|
events.push("answer");
|
|
},
|
|
deleteMessage: async () => {
|
|
events.push("delete");
|
|
throw new Error("cleanup failed");
|
|
},
|
|
recordRuntimeEvent: (category, error, details) => {
|
|
events.push(`${category}:${(error as Error).message}:${details?.phase}`);
|
|
},
|
|
});
|
|
assert.deepEqual(events, [
|
|
"answer",
|
|
"delete",
|
|
"attachment:cleanup failed:guest-staging-cleanup",
|
|
]);
|
|
});
|
|
|
|
test("Outbound attachment queueing uses the domain stat fallback", async () => {
|
|
const tempDir = await mkdtemp(
|
|
join(tmpdir(), "pi-telegram-attachment-queue-"),
|
|
);
|
|
const filePath = join(tempDir, "demo.txt");
|
|
await writeFile(filePath, "demo", "utf8");
|
|
const activeTurn = createAttachmentQueueTarget();
|
|
const result = await queueTelegramOutboundAttachments({
|
|
activeTurn,
|
|
paths: [filePath],
|
|
maxAttachmentsPerTurn: 1,
|
|
});
|
|
assert.deepEqual(result.details.paths, [filePath]);
|
|
});
|
|
|
|
test("Outbound attachment queueing rejects oversized files", async () => {
|
|
await assert.rejects(
|
|
() =>
|
|
queueTelegramOutboundAttachments({
|
|
activeTurn: createAttachmentQueueTarget(),
|
|
paths: ["/tmp/large.bin"],
|
|
maxAttachmentsPerTurn: 1,
|
|
maxAttachmentSizeBytes: 10,
|
|
statPath: async () => ({ isFile: () => true, size: 11 }),
|
|
}),
|
|
{
|
|
message:
|
|
"Attachment exceeds size limit (11 bytes > 10 bytes): /tmp/large.bin",
|
|
},
|
|
);
|
|
});
|
|
|
|
test("Outbound attachment queueing stays atomic when a later file is rejected", async () => {
|
|
const activeTurn = createAttachmentQueueTarget();
|
|
await assert.rejects(
|
|
() =>
|
|
queueTelegramOutboundAttachments({
|
|
activeTurn,
|
|
paths: ["/tmp/ok.txt", "/tmp/large.bin"],
|
|
maxAttachmentsPerTurn: 2,
|
|
maxAttachmentSizeBytes: 10,
|
|
statPath: async (path) => ({
|
|
isFile: () => true,
|
|
size: path.endsWith("large.bin") ? 11 : 1,
|
|
}),
|
|
}),
|
|
{
|
|
message:
|
|
"Attachment exceeds size limit (11 bytes > 10 bytes): /tmp/large.bin",
|
|
},
|
|
);
|
|
assert.deepEqual(activeTurn.queuedAttachments, []);
|
|
});
|
|
|
|
test("Outbound attachment queueing rejects missing turns, non-files, and full queues", async () => {
|
|
await assert.rejects(
|
|
() =>
|
|
queueTelegramOutboundAttachments({
|
|
activeTurn: undefined,
|
|
paths: ["/tmp/demo.txt"],
|
|
maxAttachmentsPerTurn: 1,
|
|
statPath: async () => ({ isFile: () => true }),
|
|
}),
|
|
{ message: /active Telegram turn/ },
|
|
);
|
|
await assert.rejects(
|
|
() =>
|
|
queueTelegramOutboundAttachments({
|
|
activeTurn: createAttachmentQueueTarget(),
|
|
paths: ["/tmp/demo.txt"],
|
|
maxAttachmentsPerTurn: 1,
|
|
statPath: async () => ({ isFile: () => false }),
|
|
}),
|
|
{ message: "Not a file: /tmp/demo.txt" },
|
|
);
|
|
await assert.rejects(
|
|
() =>
|
|
queueTelegramOutboundAttachments({
|
|
activeTurn: createAttachmentQueueTarget([
|
|
{ path: "/tmp/a.txt", fileName: "a.txt" },
|
|
]),
|
|
paths: ["/tmp/demo.txt"],
|
|
maxAttachmentsPerTurn: 1,
|
|
statPath: async () => ({ isFile: () => true }),
|
|
}),
|
|
{ message: "Attachment limit reached (1)" },
|
|
);
|
|
});
|
|
|
|
test("Outbound attachment delivery includes reply parameters for uploads", async () => {
|
|
const sentFields: Array<Record<string, string>> = [];
|
|
await sendQueuedTelegramOutboundAttachments(createAttachmentTurn(), {
|
|
sendMultipart: async (_method, fields) => {
|
|
sentFields.push(fields);
|
|
},
|
|
sendTextReply: async () => undefined,
|
|
});
|
|
assert.deepEqual(sentFields, [
|
|
{
|
|
chat_id: "1",
|
|
reply_parameters: JSON.stringify({
|
|
message_id: 2,
|
|
allow_sending_without_reply: true,
|
|
}),
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("Outbound attachment delivery includes thread target for uploads", async () => {
|
|
const sentFields: Array<Record<string, string>> = [];
|
|
await sendQueuedTelegramOutboundAttachments(
|
|
{
|
|
...createAttachmentTurn(),
|
|
target: createTelegramThreadTarget(1, 42),
|
|
},
|
|
{
|
|
sendMultipart: async (_method, fields) => {
|
|
sentFields.push(fields);
|
|
},
|
|
sendTextReply: async () => undefined,
|
|
},
|
|
);
|
|
assert.equal(sentFields[0]?.message_thread_id, "42");
|
|
});
|
|
|
|
test("Outbound attachment delivery chooses photo vs document methods from file paths", async () => {
|
|
const sent: Array<string> = [];
|
|
await sendQueuedTelegramOutboundAttachments(
|
|
createAttachmentTurn([
|
|
{ path: "/tmp/a.png", fileName: "a.png" },
|
|
{ path: "/tmp/b.txt", fileName: "b.txt" },
|
|
]),
|
|
{
|
|
sendMultipart: async (
|
|
method,
|
|
_fields,
|
|
fileField,
|
|
_filePath,
|
|
fileName,
|
|
) => {
|
|
sent.push(`${method}:${fileField}:${fileName}`);
|
|
},
|
|
sendTextReply: async () => undefined,
|
|
},
|
|
);
|
|
assert.deepEqual(sent, [
|
|
"sendPhoto:photo:a.png",
|
|
"sendDocument:document:b.txt",
|
|
]);
|
|
});
|
|
|
|
test("Outbound attachment delivery uses the domain stat fallback for size checks", async () => {
|
|
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-attachment-"));
|
|
const filePath = join(tempDir, "large.txt");
|
|
await writeFile(filePath, "too large", "utf8");
|
|
const replies: string[] = [];
|
|
await sendQueuedTelegramOutboundAttachments(
|
|
createAttachmentTurn([{ path: filePath, fileName: "large.txt" }]),
|
|
{
|
|
sendMultipart: async () => {
|
|
throw new Error("unexpected upload");
|
|
},
|
|
sendTextReply: async (_chatId, _replyToMessageId, text) => {
|
|
replies.push(text);
|
|
},
|
|
maxAttachmentSizeBytes: 4,
|
|
},
|
|
);
|
|
assert.deepEqual(replies, [
|
|
"Failed to send attachment large.txt: Attachment exceeds size limit (9 bytes > 4 bytes)",
|
|
]);
|
|
});
|
|
|
|
test("Outbound attachment delivery checks attachment sizes before upload", async () => {
|
|
const replies: string[] = [];
|
|
const sent: string[] = [];
|
|
await sendQueuedTelegramOutboundAttachments(createAttachmentTurn(), {
|
|
maxAttachmentSizeBytes: 10,
|
|
statPath: async () => ({ size: 11 }),
|
|
sendMultipart: async () => {
|
|
sent.push("sent");
|
|
},
|
|
sendTextReply: async (_chatId, _replyToMessageId, text) => {
|
|
replies.push(text);
|
|
return undefined;
|
|
},
|
|
});
|
|
assert.deepEqual(sent, []);
|
|
assert.deepEqual(replies, [
|
|
"Failed to send attachment a.png: Attachment exceeds size limit (11 bytes > 10 bytes)",
|
|
]);
|
|
});
|
|
|
|
test("Outbound attachment delivery reports per-file failures via text replies", async () => {
|
|
const replies: Array<{ text: string; target?: unknown }> = [];
|
|
const runtimeEvents: string[] = [];
|
|
const target = createTelegramThreadTarget(1, 42);
|
|
await sendQueuedTelegramOutboundAttachments(
|
|
{ ...createAttachmentTurn(), target },
|
|
{
|
|
sendMultipart: async () => {
|
|
throw new Error("upload failed");
|
|
},
|
|
sendTextReply: async (_chatId, _replyToMessageId, text, options) => {
|
|
replies.push({ text, target: options?.target });
|
|
return undefined;
|
|
},
|
|
recordRuntimeEvent: (category, error, details) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
runtimeEvents.push(`${category}:${message}:${details?.fileName}`);
|
|
},
|
|
},
|
|
);
|
|
assert.deepEqual(replies, [
|
|
{ text: "Failed to send attachment a.png: upload failed", target },
|
|
]);
|
|
assert.deepEqual(runtimeEvents, ["attachment:upload failed:a.png"]);
|
|
});
|
|
|
|
test("Outbound attachment sender runtime binds delivery ports", async () => {
|
|
const sent: string[] = [];
|
|
const sendQueuedAttachments = createTelegramQueuedOutboundAttachmentSender({
|
|
sendMultipart: async (method, _fields, fileField, _filePath, fileName) => {
|
|
sent.push(`${method}:${fileField}:${fileName}`);
|
|
},
|
|
sendTextReply: async () => undefined,
|
|
statPath: async () => ({ size: 1 }),
|
|
});
|
|
await sendQueuedAttachments(createAttachmentTurn());
|
|
assert.deepEqual(sent, ["sendPhoto:photo:a.png"]);
|
|
});
|
|
|
|
test("Outbound attachment sender runtime applies the default outbound size limit", async () => {
|
|
const replies: string[] = [];
|
|
const sendQueuedAttachments = createTelegramQueuedOutboundAttachmentSender({
|
|
sendMultipart: async () => {
|
|
throw new Error("unexpected upload");
|
|
},
|
|
sendTextReply: async (_chatId, _replyToMessageId, text) => {
|
|
replies.push(text);
|
|
},
|
|
statPath: async () => ({
|
|
size: TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES + 1,
|
|
}),
|
|
});
|
|
await sendQueuedAttachments(createAttachmentTurn());
|
|
assert.deepEqual(replies, [
|
|
`Failed to send attachment a.png: Attachment exceeds size limit (${TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES + 1} bytes > ${TELEGRAM_OUTBOUND_ATTACHMENT_DEFAULT_MAX_BYTES} bytes)`,
|
|
]);
|
|
});
|