mirror of
https://github.com/wassname/pi-telegram.git
synced 2026-06-27 16:46:21 +08:00
0.2.0: refactor into domain modules
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Regression tests for Telegram API and config helpers
|
||||
* Verifies config persistence and direct helper behavior around missing tokens and callback-query failures
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
answerTelegramCallbackQuery,
|
||||
callTelegram,
|
||||
createTelegramApiClient,
|
||||
downloadTelegramFile,
|
||||
readTelegramConfig,
|
||||
writeTelegramConfig,
|
||||
} from "../lib/api.ts";
|
||||
|
||||
test("Telegram config helpers persist and reload config", async () => {
|
||||
const agentDir = await mkdtemp(join(tmpdir(), "pi-telegram-config-"));
|
||||
const configPath = join(agentDir, "telegram.json");
|
||||
const config = {
|
||||
botToken: "123:abc",
|
||||
botUsername: "demo_bot",
|
||||
allowedUserId: 42,
|
||||
};
|
||||
await writeTelegramConfig(agentDir, configPath, config);
|
||||
const reloaded = await readTelegramConfig(configPath);
|
||||
assert.deepEqual(reloaded, config);
|
||||
const raw = await readFile(configPath, "utf8");
|
||||
assert.match(raw, /demo_bot/);
|
||||
});
|
||||
|
||||
test("Telegram API helpers reject missing bot token for direct calls", async () => {
|
||||
await assert.rejects(() => callTelegram(undefined, "getMe", {}), {
|
||||
message: "Telegram bot token is not configured",
|
||||
});
|
||||
await assert.rejects(
|
||||
() =>
|
||||
downloadTelegramFile(
|
||||
undefined,
|
||||
"file-id",
|
||||
"demo.txt",
|
||||
join(tmpdir(), "pi-telegram-missing-token"),
|
||||
),
|
||||
{
|
||||
message: "Telegram bot token is not configured",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("answerTelegramCallbackQuery ignores Telegram API failures", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("network down");
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await assert.doesNotReject(() =>
|
||||
answerTelegramCallbackQuery("123:abc", "callback-id", "ok"),
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("Telegram API client resolves bot tokens lazily for wrapped calls", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
let botToken = "123:abc";
|
||||
globalThis.fetch = (async (input) => {
|
||||
calls.push(typeof input === "string" ? input : input.toString());
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, result: true }),
|
||||
} as Response;
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const client = createTelegramApiClient(() => botToken);
|
||||
await client.call("sendChatAction", { chat_id: 1, action: "typing" });
|
||||
botToken = "456:def";
|
||||
await client.answerCallbackQuery("cb-1", "ok");
|
||||
assert.match(calls[0] ?? "", /bot123:abc\/sendChatAction$/);
|
||||
assert.match(calls[1] ?? "", /bot456:def\/answerCallbackQuery$/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Regression tests for the Telegram attachments domain
|
||||
* Covers attachment queueing and attachment delivery behavior in one domain-level suite
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
queueTelegramAttachments,
|
||||
sendQueuedTelegramAttachments,
|
||||
} from "../lib/attachments.ts";
|
||||
|
||||
test("Attachment queueing adds files to the active Telegram turn", async () => {
|
||||
const activeTurn = {
|
||||
queuedAttachments: [],
|
||||
} as unknown as {
|
||||
queuedAttachments: Array<{ path: string; fileName: string }>;
|
||||
} & Parameters<typeof queueTelegramAttachments>[0]["activeTurn"];
|
||||
const result = await queueTelegramAttachments({
|
||||
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"]);
|
||||
});
|
||||
|
||||
test("Attachment queueing rejects missing turns, non-files, and full queues", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
queueTelegramAttachments({
|
||||
activeTurn: undefined,
|
||||
paths: ["/tmp/demo.txt"],
|
||||
maxAttachmentsPerTurn: 1,
|
||||
statPath: async () => ({ isFile: () => true }),
|
||||
}),
|
||||
{ message: /active Telegram turn/ },
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
queueTelegramAttachments({
|
||||
activeTurn: { queuedAttachments: [] } as never,
|
||||
paths: ["/tmp/demo.txt"],
|
||||
maxAttachmentsPerTurn: 1,
|
||||
statPath: async () => ({ isFile: () => false }),
|
||||
}),
|
||||
{ message: "Not a file: /tmp/demo.txt" },
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
queueTelegramAttachments({
|
||||
activeTurn: {
|
||||
queuedAttachments: [{ path: "/tmp/a.txt", fileName: "a.txt" }],
|
||||
} as never,
|
||||
paths: ["/tmp/demo.txt"],
|
||||
maxAttachmentsPerTurn: 1,
|
||||
statPath: async () => ({ isFile: () => true }),
|
||||
}),
|
||||
{ message: "Attachment limit reached (1)" },
|
||||
);
|
||||
});
|
||||
|
||||
test("Attachment delivery chooses photo vs document methods from file paths", async () => {
|
||||
const sent: Array<string> = [];
|
||||
await sendQueuedTelegramAttachments(
|
||||
{
|
||||
kind: "prompt",
|
||||
chatId: 1,
|
||||
replyToMessageId: 2,
|
||||
sourceMessageIds: [],
|
||||
queueOrder: 1,
|
||||
queueLane: "default",
|
||||
laneOrder: 1,
|
||||
queuedAttachments: [
|
||||
{ path: "/tmp/a.png", fileName: "a.png" },
|
||||
{ path: "/tmp/b.txt", fileName: "b.txt" },
|
||||
],
|
||||
content: [{ type: "text", text: "prompt" }],
|
||||
historyText: "history",
|
||||
statusSummary: "summary",
|
||||
},
|
||||
{
|
||||
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("Attachment delivery reports per-file failures via text replies", async () => {
|
||||
const replies: string[] = [];
|
||||
await sendQueuedTelegramAttachments(
|
||||
{
|
||||
kind: "prompt",
|
||||
chatId: 1,
|
||||
replyToMessageId: 2,
|
||||
sourceMessageIds: [],
|
||||
queueOrder: 1,
|
||||
queueLane: "default",
|
||||
laneOrder: 1,
|
||||
queuedAttachments: [{ path: "/tmp/a.png", fileName: "a.png" }],
|
||||
content: [{ type: "text", text: "prompt" }],
|
||||
historyText: "history",
|
||||
statusSummary: "summary",
|
||||
},
|
||||
{
|
||||
sendMultipart: async () => {
|
||||
throw new Error("upload failed");
|
||||
},
|
||||
sendTextReply: async (_chatId, _replyToMessageId, text) => {
|
||||
replies.push(text);
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(replies, ["Failed to send attachment a.png: upload failed"]);
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import test from "node:test";
|
||||
/**
|
||||
* Regression tests for Telegram setup prompt defaults
|
||||
* Covers token-prefill priority across stored config, environment variables, and placeholder fallback
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { __telegramTestUtils } from "../index.ts";
|
||||
|
||||
@@ -16,7 +21,6 @@ test("Bot token input prefers stored config over env vars", () => {
|
||||
assert.equal(value, "stored-token");
|
||||
});
|
||||
|
||||
|
||||
test("Bot token input prefers the first configured Telegram env var when no config exists", () => {
|
||||
const value = __telegramTestUtils.getTelegramBotTokenInputDefault({
|
||||
TELEGRAM_KEY: "key-last",
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Regression tests for Telegram media and text extraction helpers
|
||||
* Covers inbound file-info collection, text extraction, id collection, and history formatting
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
collectTelegramFileInfos,
|
||||
collectTelegramMessageIds,
|
||||
extractFirstTelegramMessageText,
|
||||
extractTelegramMessagesText,
|
||||
formatTelegramHistoryText,
|
||||
guessMediaType,
|
||||
} from "../lib/media.ts";
|
||||
|
||||
test("Media helpers collect file infos across Telegram message variants", () => {
|
||||
const files = collectTelegramFileInfos([
|
||||
{
|
||||
message_id: 1,
|
||||
text: "hello",
|
||||
photo: [
|
||||
{ file_id: "small", file_size: 1 },
|
||||
{ file_id: "large", file_size: 10 },
|
||||
],
|
||||
document: {
|
||||
file_id: "doc",
|
||||
file_name: "report.png",
|
||||
mime_type: "image/png",
|
||||
},
|
||||
voice: {
|
||||
file_id: "voice",
|
||||
mime_type: "audio/ogg",
|
||||
},
|
||||
sticker: {
|
||||
file_id: "sticker",
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
files.map((file) => ({
|
||||
id: file.file_id,
|
||||
name: file.fileName,
|
||||
image: file.isImage,
|
||||
})),
|
||||
[
|
||||
{ id: "large", name: "photo-1.jpg", image: true },
|
||||
{ id: "doc", name: "report.png", image: true },
|
||||
{ id: "voice", name: "voice-1.ogg", image: false },
|
||||
{ id: "sticker", name: "sticker-1.webp", image: true },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("Media helpers extract text, ids, and history summaries", () => {
|
||||
const messages = [
|
||||
{ message_id: 1, text: "first" },
|
||||
{ message_id: 2, caption: "second" },
|
||||
{ message_id: 2, text: "duplicate id" },
|
||||
];
|
||||
assert.equal(
|
||||
extractTelegramMessagesText(messages),
|
||||
"first\n\nsecond\n\nduplicate id",
|
||||
);
|
||||
assert.equal(extractFirstTelegramMessageText(messages), "first");
|
||||
assert.deepEqual(collectTelegramMessageIds(messages), [1, 2]);
|
||||
assert.equal(
|
||||
formatTelegramHistoryText("hello", [{ path: "/tmp/demo.txt" }]),
|
||||
"hello\nAttachments:\n- /tmp/demo.txt",
|
||||
);
|
||||
});
|
||||
|
||||
test("Media helpers infer outgoing image media types from file paths", () => {
|
||||
assert.equal(guessMediaType("/tmp/demo.png"), "image/png");
|
||||
assert.equal(guessMediaType("/tmp/demo.txt"), undefined);
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
applyTelegramModelPageSelection,
|
||||
applyTelegramModelScopeSelection,
|
||||
buildModelMenuReplyMarkup,
|
||||
buildStatusReplyMarkup,
|
||||
buildTelegramModelCallbackPlan,
|
||||
buildTelegramModelMenuRenderPayload,
|
||||
buildTelegramModelMenuState,
|
||||
buildTelegramStatusMenuRenderPayload,
|
||||
buildTelegramThinkingMenuRenderPayload,
|
||||
buildThinkingMenuReplyMarkup,
|
||||
buildThinkingMenuText,
|
||||
formatScopedModelButtonText,
|
||||
getCanonicalModelId,
|
||||
getTelegramModelMenuPage,
|
||||
getTelegramModelSelection,
|
||||
getModelMenuItems,
|
||||
handleTelegramMenuCallbackEntry,
|
||||
handleTelegramModelMenuCallbackAction,
|
||||
handleTelegramStatusMenuCallbackAction,
|
||||
handleTelegramThinkingMenuCallbackAction,
|
||||
isThinkingLevel,
|
||||
MODEL_MENU_TITLE,
|
||||
modelsMatch,
|
||||
parseTelegramMenuCallbackAction,
|
||||
resolveScopedModelPatterns,
|
||||
sendTelegramModelMenuMessage,
|
||||
sendTelegramStatusMessage,
|
||||
sortScopedModels,
|
||||
TELEGRAM_MODEL_PAGE_SIZE,
|
||||
updateTelegramModelMenuMessage,
|
||||
updateTelegramStatusMessage,
|
||||
updateTelegramThinkingMenuMessage,
|
||||
type TelegramModelMenuState,
|
||||
} from "../lib/menu.ts";
|
||||
|
||||
test("Menu helpers match models, detect thinking levels, and expose constants", () => {
|
||||
assert.equal(MODEL_MENU_TITLE, "<b>Choose a model:</b>");
|
||||
assert.equal(TELEGRAM_MODEL_PAGE_SIZE, 6);
|
||||
assert.equal(
|
||||
modelsMatch(
|
||||
{ provider: "openai", id: "gpt-5" },
|
||||
{ provider: "openai", id: "gpt-5" },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
modelsMatch(
|
||||
{ provider: "openai", id: "gpt-5" },
|
||||
{ provider: "anthropic", id: "gpt-5" },
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
getCanonicalModelId({ provider: "openai", id: "gpt-5" }),
|
||||
"openai/gpt-5",
|
||||
);
|
||||
assert.equal(isThinkingLevel("high"), true);
|
||||
assert.equal(isThinkingLevel("impossible"), false);
|
||||
});
|
||||
|
||||
test("Menu helpers resolve scoped model patterns and sort current models first", () => {
|
||||
const models = [
|
||||
{ provider: "openai", id: "gpt-5", name: "GPT 5" },
|
||||
{ provider: "openai", id: "gpt-5-latest", name: "GPT 5 Latest" },
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-20250101",
|
||||
name: "Claude Sonnet",
|
||||
},
|
||||
] as const;
|
||||
const resolved = resolveScopedModelPatterns(
|
||||
["gpt-5:high", "anthropic/*:low"],
|
||||
models as never,
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolved.map((entry) => ({
|
||||
id: entry.model.id,
|
||||
thinking: entry.thinkingLevel,
|
||||
})),
|
||||
[
|
||||
{ id: "gpt-5", thinking: "high" },
|
||||
{ id: "claude-sonnet-20250101", thinking: "low" },
|
||||
],
|
||||
);
|
||||
const sorted = sortScopedModels(resolved, models[0] as never);
|
||||
assert.equal(sorted[0]?.model.id, "gpt-5");
|
||||
});
|
||||
|
||||
test("Menu helpers build model menu state and parse callback actions", () => {
|
||||
const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const;
|
||||
const modelB = {
|
||||
provider: "anthropic",
|
||||
id: "claude-3",
|
||||
reasoning: false,
|
||||
} as const;
|
||||
const state = buildTelegramModelMenuState({
|
||||
chatId: 1,
|
||||
activeModel: modelA as never,
|
||||
availableModels: [modelA, modelB] as never,
|
||||
configuredScopedModelPatterns: ["missing-model"],
|
||||
cliScopedModelPatterns: ["missing-model"],
|
||||
});
|
||||
assert.equal(state.chatId, 1);
|
||||
assert.equal(state.scope, "all");
|
||||
assert.match(state.note ?? "", /No CLI scoped models matched/);
|
||||
assert.deepEqual(parseTelegramMenuCallbackAction("status:model"), {
|
||||
kind: "status",
|
||||
action: "model",
|
||||
});
|
||||
assert.deepEqual(parseTelegramMenuCallbackAction("thinking:set:high"), {
|
||||
kind: "thinking:set",
|
||||
level: "high",
|
||||
});
|
||||
assert.deepEqual(parseTelegramMenuCallbackAction("model:pick:2"), {
|
||||
kind: "model",
|
||||
action: "pick",
|
||||
value: "2",
|
||||
});
|
||||
assert.deepEqual(parseTelegramMenuCallbackAction("unknown"), {
|
||||
kind: "ignore",
|
||||
});
|
||||
});
|
||||
|
||||
test("Menu helpers apply menu mutations and resolve model selections", () => {
|
||||
const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const;
|
||||
const state = {
|
||||
chatId: 1,
|
||||
messageId: 2,
|
||||
page: 0,
|
||||
scope: "all" as const,
|
||||
scopedModels: [{ model: modelA, thinkingLevel: "high" as const }],
|
||||
allModels: [{ model: modelA }],
|
||||
mode: "status" as const,
|
||||
} as unknown as TelegramModelMenuState;
|
||||
assert.equal(applyTelegramModelScopeSelection(state, "scoped"), "changed");
|
||||
assert.equal(state.scope, "scoped");
|
||||
assert.equal(applyTelegramModelScopeSelection(state, "scoped"), "unchanged");
|
||||
assert.equal(applyTelegramModelScopeSelection(state, "bad"), "invalid");
|
||||
assert.equal(applyTelegramModelPageSelection(state, "2"), "changed");
|
||||
assert.equal(state.page, 2);
|
||||
assert.equal(applyTelegramModelPageSelection(state, "2"), "unchanged");
|
||||
assert.equal(applyTelegramModelPageSelection(state, "bad"), "invalid");
|
||||
assert.deepEqual(getTelegramModelSelection(state, "bad"), { kind: "invalid" });
|
||||
assert.deepEqual(getTelegramModelSelection(state, "9"), { kind: "missing" });
|
||||
assert.equal(getTelegramModelSelection(state, "0").kind, "selected");
|
||||
});
|
||||
|
||||
test("Menu helpers derive normalized menu pages without mutating state", () => {
|
||||
const modelA = { provider: "openai", id: "gpt-5" } as const;
|
||||
const modelB = { provider: "anthropic", id: "claude-3" } as const;
|
||||
const state = {
|
||||
chatId: 1,
|
||||
messageId: 2,
|
||||
page: 99,
|
||||
scope: "all" as const,
|
||||
scopedModels: [],
|
||||
allModels: [{ model: modelA }, { model: modelB }],
|
||||
mode: "model" as const,
|
||||
} as unknown as TelegramModelMenuState;
|
||||
const menuPage = getTelegramModelMenuPage(state, 1);
|
||||
assert.equal(menuPage.page, 1);
|
||||
assert.equal(menuPage.pageCount, 2);
|
||||
assert.equal(menuPage.start, 1);
|
||||
assert.deepEqual(menuPage.items, [{ model: modelB }]);
|
||||
assert.equal(state.page, 99);
|
||||
const markup = buildModelMenuReplyMarkup(state, modelA as never, 1);
|
||||
assert.equal(markup.inline_keyboard[1]?.[1]?.text, "2/2");
|
||||
assert.equal(state.page, 99);
|
||||
});
|
||||
|
||||
test("Menu helpers build model callback plans for paging, selection, and restart modes", () => {
|
||||
const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const;
|
||||
const modelB = { provider: "anthropic", id: "claude-3", reasoning: false } as const;
|
||||
const state = {
|
||||
chatId: 1,
|
||||
messageId: 2,
|
||||
page: 0,
|
||||
scope: "all" as const,
|
||||
scopedModels: [{ model: modelA, thinkingLevel: "high" as const }],
|
||||
allModels: [{ model: modelA }, { model: modelB }],
|
||||
mode: "model" as const,
|
||||
} as unknown as TelegramModelMenuState;
|
||||
assert.deepEqual(
|
||||
buildTelegramModelCallbackPlan({
|
||||
data: "model:page:1",
|
||||
state,
|
||||
activeModel: modelA as never,
|
||||
currentThinkingLevel: "medium",
|
||||
isIdle: true,
|
||||
canRestartBusyRun: false,
|
||||
hasActiveToolExecutions: false,
|
||||
}),
|
||||
{ kind: "update-menu" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildTelegramModelCallbackPlan({
|
||||
data: "model:pick:0",
|
||||
state,
|
||||
activeModel: modelA as never,
|
||||
currentThinkingLevel: "medium",
|
||||
isIdle: true,
|
||||
canRestartBusyRun: false,
|
||||
hasActiveToolExecutions: false,
|
||||
}),
|
||||
{
|
||||
kind: "refresh-status",
|
||||
selection: state.allModels[0],
|
||||
callbackText: "Model: gpt-5",
|
||||
shouldApplyThinkingLevel: false,
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildTelegramModelCallbackPlan({
|
||||
data: "model:pick:1",
|
||||
state,
|
||||
activeModel: modelA as never,
|
||||
currentThinkingLevel: "medium",
|
||||
isIdle: false,
|
||||
canRestartBusyRun: true,
|
||||
hasActiveToolExecutions: true,
|
||||
}),
|
||||
{
|
||||
kind: "switch-model",
|
||||
selection: state.allModels[1],
|
||||
mode: "restart-after-tool",
|
||||
callbackText: "Switched to claude-3. Restarting after the current tool finishes…",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildTelegramModelCallbackPlan({
|
||||
data: "model:pick:1",
|
||||
state,
|
||||
activeModel: modelA as never,
|
||||
currentThinkingLevel: "medium",
|
||||
isIdle: false,
|
||||
canRestartBusyRun: false,
|
||||
hasActiveToolExecutions: false,
|
||||
}),
|
||||
{ kind: "answer", text: "Pi is busy. Send /stop first." },
|
||||
);
|
||||
});
|
||||
|
||||
test("Menu helpers route callback entry states before action handlers", async () => {
|
||||
const events: string[] = [];
|
||||
await handleTelegramMenuCallbackEntry("callback-1", undefined, undefined, {
|
||||
handleStatusAction: async () => false,
|
||||
handleThinkingAction: async () => false,
|
||||
handleModelAction: async () => false,
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
events.push(`answer:${text ?? ""}`);
|
||||
},
|
||||
});
|
||||
await handleTelegramMenuCallbackEntry("callback-2", "status:model", undefined, {
|
||||
handleStatusAction: async () => false,
|
||||
handleThinkingAction: async () => false,
|
||||
handleModelAction: async () => false,
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
events.push(`answer:${text ?? ""}`);
|
||||
},
|
||||
});
|
||||
await handleTelegramMenuCallbackEntry(
|
||||
"callback-3",
|
||||
"status:model",
|
||||
{
|
||||
chatId: 1,
|
||||
messageId: 2,
|
||||
page: 0,
|
||||
scope: "all",
|
||||
scopedModels: [],
|
||||
allModels: [],
|
||||
mode: "status",
|
||||
},
|
||||
{
|
||||
handleStatusAction: async () => {
|
||||
events.push("status");
|
||||
return true;
|
||||
},
|
||||
handleThinkingAction: async () => false,
|
||||
handleModelAction: async () => false,
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
events.push(`answer:${text ?? ""}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
"answer:",
|
||||
"answer:Interactive message expired.",
|
||||
"status",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Menu helpers execute model callback actions across update, switch, and restart paths", async () => {
|
||||
const events: string[] = [];
|
||||
const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const;
|
||||
const modelB = { provider: "anthropic", id: "claude-3", reasoning: false } as const;
|
||||
const state = {
|
||||
chatId: 1,
|
||||
messageId: 2,
|
||||
page: 0,
|
||||
scope: "all" as const,
|
||||
scopedModels: [],
|
||||
allModels: [{ model: modelA }, { model: modelB }],
|
||||
mode: "model" as const,
|
||||
} as unknown as TelegramModelMenuState;
|
||||
assert.equal(
|
||||
await handleTelegramModelMenuCallbackAction(
|
||||
"callback-1",
|
||||
{
|
||||
data: "model:page:1",
|
||||
state,
|
||||
activeModel: modelA as never,
|
||||
currentThinkingLevel: "medium",
|
||||
isIdle: true,
|
||||
canRestartBusyRun: false,
|
||||
hasActiveToolExecutions: false,
|
||||
},
|
||||
{
|
||||
updateModelMenuMessage: async () => {
|
||||
events.push("update-menu");
|
||||
},
|
||||
updateStatusMessage: async () => {
|
||||
events.push("status");
|
||||
},
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
events.push(`answer:${text ?? ""}`);
|
||||
},
|
||||
setModel: async () => true,
|
||||
setCurrentModel: (model) => {
|
||||
events.push(`current:${model.id}`);
|
||||
},
|
||||
setThinkingLevel: (level) => {
|
||||
events.push(`thinking:${level}`);
|
||||
},
|
||||
stagePendingModelSwitch: (selection) => {
|
||||
events.push(`pending:${selection.model.id}`);
|
||||
},
|
||||
restartInterruptedTelegramTurn: (selection) => {
|
||||
events.push(`restart:${selection.model.id}`);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramModelMenuCallbackAction(
|
||||
"callback-2",
|
||||
{
|
||||
data: "model:pick:1",
|
||||
state,
|
||||
activeModel: modelA as never,
|
||||
currentThinkingLevel: "medium",
|
||||
isIdle: false,
|
||||
canRestartBusyRun: true,
|
||||
hasActiveToolExecutions: true,
|
||||
},
|
||||
{
|
||||
updateModelMenuMessage: async () => {
|
||||
events.push("unexpected:update");
|
||||
},
|
||||
updateStatusMessage: async () => {
|
||||
events.push("status");
|
||||
},
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
events.push(`answer:${text ?? ""}`);
|
||||
},
|
||||
setModel: async () => true,
|
||||
setCurrentModel: (model) => {
|
||||
events.push(`current:${model.id}`);
|
||||
},
|
||||
setThinkingLevel: (level) => {
|
||||
events.push(`thinking:${level}`);
|
||||
},
|
||||
stagePendingModelSwitch: (selection) => {
|
||||
events.push(`pending:${selection.model.id}`);
|
||||
},
|
||||
restartInterruptedTelegramTurn: (selection) => {
|
||||
events.push(`restart:${selection.model.id}`);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramModelMenuCallbackAction(
|
||||
"callback-3",
|
||||
{
|
||||
data: "model:pick:1",
|
||||
state,
|
||||
activeModel: modelA as never,
|
||||
currentThinkingLevel: "medium",
|
||||
isIdle: false,
|
||||
canRestartBusyRun: true,
|
||||
hasActiveToolExecutions: false,
|
||||
},
|
||||
{
|
||||
updateModelMenuMessage: async () => {
|
||||
events.push("unexpected:update");
|
||||
},
|
||||
updateStatusMessage: async () => {
|
||||
events.push("status");
|
||||
},
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
events.push(`answer:${text ?? ""}`);
|
||||
},
|
||||
setModel: async () => true,
|
||||
setCurrentModel: (model) => {
|
||||
events.push(`current:${model.id}`);
|
||||
},
|
||||
setThinkingLevel: (level) => {
|
||||
events.push(`thinking:${level}`);
|
||||
},
|
||||
stagePendingModelSwitch: (selection) => {
|
||||
events.push(`pending:${selection.model.id}`);
|
||||
},
|
||||
restartInterruptedTelegramTurn: (selection) => {
|
||||
events.push(`restart:${selection.model.id}`);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(events[0], "update-menu");
|
||||
assert.equal(events[1], "answer:");
|
||||
assert.equal(events[2], "current:claude-3");
|
||||
assert.equal(events[3], "status");
|
||||
assert.equal(events[4], "pending:claude-3");
|
||||
assert.equal(
|
||||
events[5],
|
||||
"answer:Switched to claude-3. Restarting after the current tool finishes…",
|
||||
);
|
||||
assert.equal(events[6], "current:claude-3");
|
||||
assert.equal(events[7], "status");
|
||||
assert.equal(events[8], "restart:claude-3");
|
||||
assert.equal(events[9], "answer:Switching to claude-3 and continuing…");
|
||||
});
|
||||
|
||||
test("Menu helpers handle status and thinking callback actions", async () => {
|
||||
const events: string[] = [];
|
||||
const reasoningModel = {
|
||||
provider: "openai",
|
||||
id: "gpt-5",
|
||||
reasoning: true,
|
||||
} as const;
|
||||
const plainModel = {
|
||||
provider: "openai",
|
||||
id: "gpt-4o",
|
||||
reasoning: false,
|
||||
} as const;
|
||||
assert.equal(
|
||||
await handleTelegramStatusMenuCallbackAction(
|
||||
"callback-1",
|
||||
"status:model",
|
||||
reasoningModel as never,
|
||||
{
|
||||
updateModelMenuMessage: async () => {
|
||||
events.push("status:model");
|
||||
},
|
||||
updateThinkingMenuMessage: async () => {
|
||||
events.push("status:thinking");
|
||||
},
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
events.push(`answer:${text ?? ""}`);
|
||||
},
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramThinkingMenuCallbackAction(
|
||||
"callback-2",
|
||||
"thinking:set:high",
|
||||
reasoningModel as never,
|
||||
{
|
||||
setThinkingLevel: (level) => {
|
||||
events.push(`set:${level}`);
|
||||
},
|
||||
getCurrentThinkingLevel: () => "high",
|
||||
updateStatusMessage: async () => {
|
||||
events.push("status:update");
|
||||
},
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
events.push(`answer:${text ?? ""}`);
|
||||
},
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await handleTelegramStatusMenuCallbackAction(
|
||||
"callback-3",
|
||||
"status:thinking",
|
||||
plainModel as never,
|
||||
{
|
||||
updateModelMenuMessage: async () => {
|
||||
events.push("unexpected:model");
|
||||
},
|
||||
updateThinkingMenuMessage: async () => {
|
||||
events.push("unexpected:thinking");
|
||||
},
|
||||
answerCallbackQuery: async (_id, text) => {
|
||||
events.push(`answer:${text ?? ""}`);
|
||||
},
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(events[0], "status:model");
|
||||
assert.equal(events[1], "answer:");
|
||||
assert.equal(events[2], "set:high");
|
||||
assert.equal(events[3], "status:update");
|
||||
assert.equal(events[4], "answer:Thinking: high");
|
||||
assert.equal(events[5], "answer:This model has no reasoning controls.");
|
||||
});
|
||||
|
||||
test("Menu helpers build pure render payloads before transport", () => {
|
||||
const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const;
|
||||
const state = {
|
||||
chatId: 1,
|
||||
messageId: 2,
|
||||
page: 0,
|
||||
scope: "all" as const,
|
||||
scopedModels: [],
|
||||
allModels: [{ model: modelA }],
|
||||
mode: "status" as const,
|
||||
} as unknown as TelegramModelMenuState;
|
||||
const modelPayload = buildTelegramModelMenuRenderPayload(state, modelA as never);
|
||||
const thinkingPayload = buildTelegramThinkingMenuRenderPayload(modelA as never, "medium");
|
||||
const statusPayload = buildTelegramStatusMenuRenderPayload(
|
||||
"<b>Status</b>",
|
||||
modelA as never,
|
||||
"medium",
|
||||
);
|
||||
assert.equal(modelPayload.nextMode, "model");
|
||||
assert.equal(modelPayload.text, "<b>Choose a model:</b>");
|
||||
assert.equal(modelPayload.mode, "html");
|
||||
assert.equal(thinkingPayload.nextMode, "thinking");
|
||||
assert.match(thinkingPayload.text, /^Choose a thinking level/);
|
||||
assert.equal(thinkingPayload.mode, "plain");
|
||||
assert.equal(statusPayload.nextMode, "status");
|
||||
assert.equal(statusPayload.text, "<b>Status</b>");
|
||||
assert.equal(statusPayload.mode, "html");
|
||||
assert.equal(state.mode, "status");
|
||||
});
|
||||
|
||||
test("Menu helpers update and send interactive menu messages", async () => {
|
||||
const events: string[] = [];
|
||||
const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const;
|
||||
const state = {
|
||||
chatId: 1,
|
||||
messageId: 2,
|
||||
page: 0,
|
||||
scope: "all" as const,
|
||||
scopedModels: [],
|
||||
allModels: [{ model: modelA }],
|
||||
mode: "status" as const,
|
||||
} as unknown as TelegramModelMenuState;
|
||||
const deps = {
|
||||
editInteractiveMessage: async (
|
||||
chatId: number,
|
||||
messageId: number,
|
||||
text: string,
|
||||
mode: "html" | "plain",
|
||||
) => {
|
||||
events.push(`edit:${chatId}:${messageId}:${mode}:${text}`);
|
||||
},
|
||||
sendInteractiveMessage: async (
|
||||
chatId: number,
|
||||
text: string,
|
||||
mode: "html" | "plain",
|
||||
) => {
|
||||
events.push(`send:${chatId}:${mode}:${text}`);
|
||||
return 99;
|
||||
},
|
||||
};
|
||||
await updateTelegramModelMenuMessage(state, modelA as never, deps);
|
||||
await updateTelegramThinkingMenuMessage(state, modelA as never, "medium", deps);
|
||||
await updateTelegramStatusMessage(
|
||||
state,
|
||||
"<b>Status</b>",
|
||||
modelA as never,
|
||||
"medium",
|
||||
deps,
|
||||
);
|
||||
const sentStatusId = await sendTelegramStatusMessage(
|
||||
state,
|
||||
"<b>Status</b>",
|
||||
modelA as never,
|
||||
"medium",
|
||||
deps,
|
||||
);
|
||||
const sentModelId = await sendTelegramModelMenuMessage(state, modelA as never, deps);
|
||||
assert.equal(sentStatusId, 99);
|
||||
assert.equal(sentModelId, 99);
|
||||
assert.equal(events[0], "edit:1:2:html:<b>Choose a model:</b>");
|
||||
assert.match(events[1] ?? "", /^edit:1:2:plain:Choose a thinking level/);
|
||||
assert.equal(events[2], "edit:1:2:html:<b>Status</b>");
|
||||
assert.equal(events[3], "send:1:html:<b>Status</b>");
|
||||
assert.equal(events[4], "send:1:html:<b>Choose a model:</b>");
|
||||
});
|
||||
|
||||
test("Menu helpers build model, thinking, and status UI payloads", () => {
|
||||
const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const;
|
||||
const modelB = {
|
||||
provider: "anthropic",
|
||||
id: "claude-3",
|
||||
reasoning: false,
|
||||
} as const;
|
||||
const state = {
|
||||
chatId: 1,
|
||||
messageId: 2,
|
||||
page: 0,
|
||||
scope: "scoped" as const,
|
||||
scopedModels: [{ model: modelA, thinkingLevel: "high" as const }],
|
||||
allModels: [{ model: modelB }],
|
||||
mode: "model" as const,
|
||||
} as unknown as TelegramModelMenuState;
|
||||
assert.deepEqual(getModelMenuItems(state), state.scopedModels);
|
||||
assert.match(
|
||||
formatScopedModelButtonText(state.scopedModels[0], modelA as never),
|
||||
/^✅ /,
|
||||
);
|
||||
const modelMarkup = buildModelMenuReplyMarkup(state, modelA as never, 6);
|
||||
assert.equal(
|
||||
modelMarkup.inline_keyboard[0]?.[0]?.callback_data,
|
||||
"model:pick:0",
|
||||
);
|
||||
const thinkingText = buildThinkingMenuText(modelA as never, "medium");
|
||||
assert.match(thinkingText, /Model: openai\/gpt-5/);
|
||||
const thinkingMarkup = buildThinkingMenuReplyMarkup("medium");
|
||||
assert.equal(
|
||||
thinkingMarkup.inline_keyboard.some((row) => row[0]?.text === "✅ medium"),
|
||||
true,
|
||||
);
|
||||
const statusMarkup = buildStatusReplyMarkup(modelA as never, "medium");
|
||||
assert.equal(statusMarkup.inline_keyboard.length, 2);
|
||||
const noReasoningMarkup = buildStatusReplyMarkup(modelB as never, "medium");
|
||||
assert.equal(noReasoningMarkup.inline_keyboard.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Regression tests for the Telegram polling domain
|
||||
* Covers polling request helpers, stop conditions, and the long-poll loop runtime in one suite
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
TELEGRAM_ALLOWED_UPDATES,
|
||||
buildTelegramInitialSyncRequest,
|
||||
buildTelegramLongPollRequest,
|
||||
getLatestTelegramUpdateId,
|
||||
runTelegramPollLoop,
|
||||
shouldStopTelegramPolling,
|
||||
} from "../lib/polling.ts";
|
||||
|
||||
test("Polling helpers build the initial sync request", () => {
|
||||
assert.deepEqual(buildTelegramInitialSyncRequest(), {
|
||||
offset: -1,
|
||||
limit: 1,
|
||||
timeout: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("Polling helpers build long-poll requests with and without lastUpdateId", () => {
|
||||
assert.deepEqual(buildTelegramLongPollRequest(), {
|
||||
offset: undefined,
|
||||
limit: 10,
|
||||
timeout: 30,
|
||||
allowed_updates: TELEGRAM_ALLOWED_UPDATES,
|
||||
});
|
||||
assert.deepEqual(buildTelegramLongPollRequest(41), {
|
||||
offset: 42,
|
||||
limit: 10,
|
||||
timeout: 30,
|
||||
allowed_updates: TELEGRAM_ALLOWED_UPDATES,
|
||||
});
|
||||
});
|
||||
|
||||
test("Polling helpers extract the latest update id", () => {
|
||||
assert.equal(getLatestTelegramUpdateId([]), undefined);
|
||||
assert.equal(
|
||||
getLatestTelegramUpdateId([{ update_id: 1 }, { update_id: 7 }]),
|
||||
7,
|
||||
);
|
||||
});
|
||||
|
||||
test("Polling helpers stop only for abort conditions", () => {
|
||||
assert.equal(shouldStopTelegramPolling(true, new Error("ignored")), true);
|
||||
assert.equal(
|
||||
shouldStopTelegramPolling(false, new DOMException("aborted", "AbortError")),
|
||||
true,
|
||||
);
|
||||
assert.equal(shouldStopTelegramPolling(false, new Error("network")), false);
|
||||
});
|
||||
|
||||
test("Poll loop initializes lastUpdateId and processes updates", async () => {
|
||||
const handled: number[] = [];
|
||||
const config: { botToken: string; lastUpdateId?: number } = {
|
||||
botToken: "123:abc",
|
||||
};
|
||||
let getUpdatesCalls = 0;
|
||||
let persistCount = 0;
|
||||
const signal = new AbortController().signal;
|
||||
await runTelegramPollLoop({
|
||||
ctx: {} as never,
|
||||
signal,
|
||||
config,
|
||||
deleteWebhook: async () => {},
|
||||
getUpdates: async () => {
|
||||
getUpdatesCalls += 1;
|
||||
if (getUpdatesCalls === 1) {
|
||||
return [{ update_id: 5 }];
|
||||
}
|
||||
if (getUpdatesCalls === 2) {
|
||||
return [{ update_id: 6 }, { update_id: 7 }];
|
||||
}
|
||||
throw new DOMException("stop", "AbortError");
|
||||
},
|
||||
persistConfig: async () => {
|
||||
persistCount += 1;
|
||||
},
|
||||
handleUpdate: async (update) => {
|
||||
handled.push(update.update_id);
|
||||
},
|
||||
onErrorStatus: () => {},
|
||||
onStatusReset: () => {},
|
||||
sleep: async () => {},
|
||||
});
|
||||
assert.equal(config.lastUpdateId, 7);
|
||||
assert.deepEqual(handled, [6, 7]);
|
||||
assert.equal(persistCount, 3);
|
||||
});
|
||||
|
||||
test("Poll loop reports retryable errors and sleeps before retrying", async () => {
|
||||
const config = { botToken: "123:abc", lastUpdateId: 1 };
|
||||
const statusMessages: string[] = [];
|
||||
let calls = 0;
|
||||
await runTelegramPollLoop({
|
||||
ctx: {} as never,
|
||||
signal: new AbortController().signal,
|
||||
config,
|
||||
deleteWebhook: async () => {},
|
||||
getUpdates: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
throw new Error("network down");
|
||||
}
|
||||
throw new DOMException("stop", "AbortError");
|
||||
},
|
||||
persistConfig: async () => {},
|
||||
handleUpdate: async () => {},
|
||||
onErrorStatus: (message) => {
|
||||
statusMessages.push(`error:${message}`);
|
||||
},
|
||||
onStatusReset: () => {
|
||||
statusMessages.push("reset");
|
||||
},
|
||||
sleep: async (ms) => {
|
||||
statusMessages.push(`sleep:${ms}`);
|
||||
},
|
||||
});
|
||||
assert.deepEqual(statusMessages, [
|
||||
"error:network down",
|
||||
"sleep:3000",
|
||||
"reset",
|
||||
]);
|
||||
});
|
||||
+2982
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Regression tests for the Telegram registration domain
|
||||
* Covers tool registration and command registration behavior without exercising the full extension runtime
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import telegramExtension from "../index.ts";
|
||||
import {
|
||||
registerTelegramAttachmentTool,
|
||||
registerTelegramCommands,
|
||||
registerTelegramLifecycleHooks,
|
||||
} from "../lib/registration.ts";
|
||||
|
||||
function createRegistrationApiHarness() {
|
||||
let tool: any;
|
||||
const commands = new Map<string, any>();
|
||||
const handlers = new Map<string, any>();
|
||||
return {
|
||||
tool: () => tool,
|
||||
commands,
|
||||
handlers,
|
||||
api: {
|
||||
on: (event: string, handler: unknown) => {
|
||||
handlers.set(event, handler);
|
||||
},
|
||||
registerTool: (definition: unknown) => {
|
||||
tool = definition;
|
||||
},
|
||||
registerCommand: (name: string, definition: unknown) => {
|
||||
commands.set(name, definition);
|
||||
},
|
||||
} as never,
|
||||
};
|
||||
}
|
||||
|
||||
test("Registration registers the attachment tool and delegates queueing", async () => {
|
||||
const harness = createRegistrationApiHarness();
|
||||
const activeTurn = {
|
||||
queuedAttachments: [],
|
||||
} as unknown as {
|
||||
queuedAttachments: Array<{ path: string; fileName: string }>;
|
||||
} & ReturnType<
|
||||
Parameters<typeof registerTelegramAttachmentTool>[1]["getActiveTurn"]
|
||||
>;
|
||||
registerTelegramAttachmentTool(harness.api, {
|
||||
maxAttachmentsPerTurn: 2,
|
||||
getActiveTurn: () => activeTurn,
|
||||
statPath: async () => ({ isFile: () => true }),
|
||||
});
|
||||
const tool = harness.tool();
|
||||
assert.equal(tool?.name, "telegram_attach");
|
||||
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("Registration commands expose setup and status behaviors", async () => {
|
||||
const harness = createRegistrationApiHarness();
|
||||
const events: string[] = [];
|
||||
registerTelegramCommands(harness.api, {
|
||||
promptForConfig: async () => {
|
||||
events.push("setup");
|
||||
},
|
||||
getStatusLines: () => ["bot: @demo", "polling: stopped"],
|
||||
reloadConfig: async () => {
|
||||
events.push("reload");
|
||||
},
|
||||
hasBotToken: () => false,
|
||||
startPolling: async () => {
|
||||
events.push("start");
|
||||
},
|
||||
stopPolling: async () => {
|
||||
events.push("stop");
|
||||
},
|
||||
updateStatus: () => {
|
||||
events.push("update-status");
|
||||
},
|
||||
});
|
||||
const setupCommand = harness.commands.get("telegram-setup");
|
||||
const statusCommand = harness.commands.get("telegram-status");
|
||||
const notifications: string[] = [];
|
||||
const ctx = {
|
||||
ui: {
|
||||
notify: (message: string) => {
|
||||
notifications.push(message);
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
await setupCommand.handler("", ctx);
|
||||
await statusCommand.handler("", ctx);
|
||||
assert.deepEqual(events, ["setup"]);
|
||||
assert.deepEqual(notifications, ["bot: @demo | polling: stopped"]);
|
||||
});
|
||||
|
||||
test("Registration connect and disconnect commands reload config and control polling", async () => {
|
||||
const harness = createRegistrationApiHarness();
|
||||
const events: string[] = [];
|
||||
let hasToken = false;
|
||||
registerTelegramCommands(harness.api, {
|
||||
promptForConfig: async () => {
|
||||
events.push("setup");
|
||||
},
|
||||
getStatusLines: () => [],
|
||||
reloadConfig: async () => {
|
||||
events.push("reload");
|
||||
},
|
||||
hasBotToken: () => hasToken,
|
||||
startPolling: async () => {
|
||||
events.push("start");
|
||||
},
|
||||
stopPolling: async () => {
|
||||
events.push("stop");
|
||||
},
|
||||
updateStatus: () => {
|
||||
events.push("update-status");
|
||||
},
|
||||
});
|
||||
const connectCommand = harness.commands.get("telegram-connect");
|
||||
const disconnectCommand = harness.commands.get("telegram-disconnect");
|
||||
const ctx = { ui: { notify: () => {} } } as never;
|
||||
await connectCommand.handler("", ctx);
|
||||
hasToken = true;
|
||||
await connectCommand.handler("", ctx);
|
||||
await disconnectCommand.handler("", ctx);
|
||||
assert.deepEqual(events, [
|
||||
"reload",
|
||||
"setup",
|
||||
"reload",
|
||||
"start",
|
||||
"update-status",
|
||||
"stop",
|
||||
"update-status",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Registration lifecycle hooks are registered and delegate to the provided handlers", async () => {
|
||||
const harness = createRegistrationApiHarness();
|
||||
const events: string[] = [];
|
||||
registerTelegramLifecycleHooks(harness.api, {
|
||||
onSessionStart: async () => {
|
||||
events.push("session-start");
|
||||
},
|
||||
onSessionShutdown: async () => {
|
||||
events.push("session-shutdown");
|
||||
},
|
||||
onBeforeAgentStart: () => {
|
||||
events.push("before-agent-start");
|
||||
return { systemPrompt: "prompt" };
|
||||
},
|
||||
onModelSelect: () => {
|
||||
events.push("model-select");
|
||||
},
|
||||
onAgentStart: async () => {
|
||||
events.push("agent-start");
|
||||
},
|
||||
onToolExecutionStart: () => {
|
||||
events.push("tool-start");
|
||||
},
|
||||
onToolExecutionEnd: () => {
|
||||
events.push("tool-end");
|
||||
},
|
||||
onMessageStart: async () => {
|
||||
events.push("message-start");
|
||||
},
|
||||
onMessageUpdate: async () => {
|
||||
events.push("message-update");
|
||||
},
|
||||
onAgentEnd: async () => {
|
||||
events.push("agent-end");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
[...harness.handlers.keys()],
|
||||
[
|
||||
"session_start",
|
||||
"session_shutdown",
|
||||
"before_agent_start",
|
||||
"model_select",
|
||||
"agent_start",
|
||||
"tool_execution_start",
|
||||
"tool_execution_end",
|
||||
"message_start",
|
||||
"message_update",
|
||||
"agent_end",
|
||||
],
|
||||
);
|
||||
const ctx = {} as never;
|
||||
await harness.handlers.get("session_start")({}, ctx);
|
||||
await harness.handlers.get("session_shutdown")({}, ctx);
|
||||
const beforeAgentStartResult = await harness.handlers.get(
|
||||
"before_agent_start",
|
||||
)({}, ctx);
|
||||
await harness.handlers.get("model_select")({}, ctx);
|
||||
await harness.handlers.get("agent_start")({}, ctx);
|
||||
await harness.handlers.get("tool_execution_start")({}, ctx);
|
||||
await harness.handlers.get("tool_execution_end")({}, ctx);
|
||||
await harness.handlers.get("message_start")({}, ctx);
|
||||
await harness.handlers.get("message_update")({}, ctx);
|
||||
await harness.handlers.get("agent_end")({}, ctx);
|
||||
assert.deepEqual(beforeAgentStartResult, { systemPrompt: "prompt" });
|
||||
assert.deepEqual(events, [
|
||||
"session-start",
|
||||
"session-shutdown",
|
||||
"before-agent-start",
|
||||
"model-select",
|
||||
"agent-start",
|
||||
"tool-start",
|
||||
"tool-end",
|
||||
"message-start",
|
||||
"message-update",
|
||||
"agent-end",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Extension entrypoint wires registration domains into the pi API", () => {
|
||||
const harness = createRegistrationApiHarness();
|
||||
telegramExtension(harness.api);
|
||||
assert.equal(harness.tool()?.name, "telegram_attach");
|
||||
assert.deepEqual(
|
||||
[...harness.commands.keys()],
|
||||
[
|
||||
"telegram-setup",
|
||||
"telegram-status",
|
||||
"telegram-connect",
|
||||
"telegram-disconnect",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...harness.handlers.keys()],
|
||||
[
|
||||
"session_start",
|
||||
"session_shutdown",
|
||||
"before_agent_start",
|
||||
"model_select",
|
||||
"agent_start",
|
||||
"tool_execution_start",
|
||||
"tool_execution_end",
|
||||
"message_start",
|
||||
"message_update",
|
||||
"agent_end",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("Extension before-agent-start hook appends Telegram-specific system prompt guidance", async () => {
|
||||
const harness = createRegistrationApiHarness();
|
||||
telegramExtension(harness.api);
|
||||
const handler = harness.handlers.get("before_agent_start");
|
||||
const basePrompt = "System base";
|
||||
const telegramResult = await handler(
|
||||
{ systemPrompt: basePrompt, prompt: "[telegram] hello" },
|
||||
{} as never,
|
||||
);
|
||||
const localResult = await handler(
|
||||
{ systemPrompt: basePrompt, prompt: "hello" },
|
||||
{} as never,
|
||||
);
|
||||
assert.match(
|
||||
telegramResult.systemPrompt,
|
||||
/current user message came from Telegram/,
|
||||
);
|
||||
assert.match(telegramResult.systemPrompt, /telegram_attach/);
|
||||
assert.equal(localResult.systemPrompt.includes("came from Telegram"), false);
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Regression tests for Telegram markdown rendering helpers
|
||||
* Covers nested lists, code blocks, tables, links, quotes, chunking, and other Telegram-specific render edge cases
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { __telegramTestUtils } from "../index.ts";
|
||||
|
||||
test("Nested lists stay out of code blocks", () => {
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(
|
||||
"- Level 1\n - Level 2\n - Level 3 with **bold** text",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.ok(chunks.length > 0);
|
||||
assert.equal(
|
||||
chunks.some((chunk) => chunk.text.includes("<pre><code>")),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
chunks.some((chunk) =>
|
||||
chunk.text.includes("<code>-</code> Level 3 with <b>bold</b> text"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Fenced code blocks preserve literal markdown", () => {
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(
|
||||
'~~~ts\nconst value = "**raw**";\n~~~',
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(chunks[0]?.text ?? "", /<pre><code class="language-ts">/);
|
||||
assert.match(chunks[0]?.text ?? "", /\*\*raw\*\*/);
|
||||
});
|
||||
|
||||
test("Underscores inside words do not become italic", () => {
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(
|
||||
"Path: foo_bar_baz.txt and **bold**",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("<i>bar</i>"), false);
|
||||
assert.match(chunks[0]?.text ?? "", /<b>bold<\/b>/);
|
||||
});
|
||||
|
||||
test("Quoted nested lists stay in blockquote rendering", () => {
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(
|
||||
"> Quoted intro\n> - nested item\n> - deeper item",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(chunks[0]?.text ?? "", /<blockquote>/);
|
||||
assert.match(chunks[0]?.text ?? "", /nested item/);
|
||||
assert.match(chunks[0]?.text ?? "", /<code>-<\/code> nested item/);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("<pre><code>"), false);
|
||||
});
|
||||
|
||||
test("Numbered lists use monospace numeric markers", () => {
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(
|
||||
"1. first\n 2. second",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(chunks[0]?.text ?? "", /<code>1\.<\/code> first/);
|
||||
assert.match(chunks[0]?.text ?? "", /<code>2\.<\/code> second/);
|
||||
});
|
||||
|
||||
test("Nested blockquotes flatten into one Telegram blockquote with indentation", () => {
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(
|
||||
"> outer\n>> inner\n>>> deepest",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.equal((chunks[0]?.text.match(/<blockquote>/g) ?? []).length, 1);
|
||||
assert.equal((chunks[0]?.text.match(/<\/blockquote>/g) ?? []).length, 1);
|
||||
assert.match(chunks[0]?.text ?? "", /outer/);
|
||||
assert.match(chunks[0]?.text ?? "", /\u00A0\u00A0inner/);
|
||||
assert.match(chunks[0]?.text ?? "", /\u00A0\u00A0\u00A0\u00A0deepest/);
|
||||
});
|
||||
|
||||
test("Markdown tables render as literal monospace blocks without outer side borders", () => {
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(
|
||||
"| Name | Value |\n| --- | --- |\n| **x** | `y` |",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(chunks[0]?.text ?? "", /<pre><code class="language-markdown">/);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("<b>x</b>"), false);
|
||||
assert.match(chunks[0]?.text ?? "", /Name\s+\|\s+Value/);
|
||||
assert.match(chunks[0]?.text ?? "", /x\s+\|\s+y/);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("| Name |"), false);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("| x |"), false);
|
||||
});
|
||||
|
||||
test("Links, code spans, and underscore-heavy text coexist safely", () => {
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(
|
||||
"See [docs](https://example.com), run `foo_bar()` and keep foo_bar.txt literal",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(
|
||||
chunks[0]?.text ?? "",
|
||||
/<a href="https:\/\/example.com">docs<\/a>/,
|
||||
);
|
||||
assert.match(chunks[0]?.text ?? "", /<code>foo_bar\(\)<\/code>/);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("<i>bar</i>"), false);
|
||||
});
|
||||
|
||||
test("Long quoted blocks stay chunked with balanced blockquote tags", () => {
|
||||
const markdown = Array.from(
|
||||
{ length: 500 },
|
||||
(_, index) => `> quoted **${index}** line`,
|
||||
).join("\n");
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<blockquote>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/blockquote>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Long markdown replies stay chunked below Telegram limits", () => {
|
||||
const markdown = Array.from(
|
||||
{ length: 600 },
|
||||
(_, index) => `- item **${index}**`,
|
||||
).join("\n");
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<b>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/b>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Long mixed links and code spans stay chunked with balanced inline tags", () => {
|
||||
const markdown = Array.from(
|
||||
{ length: 450 },
|
||||
(_, index) =>
|
||||
`Paragraph ${index}: see [docs ${index}](https://example.com/${index}), run \`code_${index}()\`, and keep foo_bar_${index}.txt literal`,
|
||||
).join("\n\n");
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<a /g) ?? []).length,
|
||||
(chunk.text.match(/<\/a>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<code>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/code>/g) ?? []).length,
|
||||
);
|
||||
assert.equal((chunk.text ?? "").includes("<i>bar</i>"), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("Long multi-block markdown keeps quotes and code fences structurally balanced", () => {
|
||||
const markdown = Array.from({ length: 120 }, (_, index) => {
|
||||
return [
|
||||
`## Section ${index}`,
|
||||
`> quoted **${index}** line`,
|
||||
`- item ${index}`,
|
||||
"```ts",
|
||||
`const value_${index} = \"**raw**\";`,
|
||||
"```",
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<blockquote>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/blockquote>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<pre><code/g) ?? []).length,
|
||||
(chunk.text.match(/<\/code><\/pre>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Chunked mixed block transitions keep quote and list structure balanced", () => {
|
||||
const markdown = Array.from({ length: 260 }, (_, index) => {
|
||||
return [
|
||||
`> quoted **${index}** intro`,
|
||||
`> continuation ${index}`,
|
||||
`- item ${index}`,
|
||||
`plain paragraph ${index} with [link](https://example.com/${index})`,
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<blockquote>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/blockquote>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<a /g) ?? []).length,
|
||||
(chunk.text.match(/<\/a>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Chunked code fence transitions keep code blocks closed before following prose", () => {
|
||||
const markdown = Array.from({ length: 220 }, (_, index) => {
|
||||
return [
|
||||
"```ts",
|
||||
`const block_${index} = \"value_${index}\";`,
|
||||
"```",
|
||||
`After code **${index}** and \`inline_${index}()\``,
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<pre><code/g) ?? []).length,
|
||||
(chunk.text.match(/<\/code><\/pre>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<code(?: class="[^"]+")?>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/code>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Long inline formatting paragraphs stay balanced across chunk boundaries", () => {
|
||||
const markdown = Array.from({ length: 500 }, (_, index) => {
|
||||
return `Segment ${index} keeps **bold_${index}** with \`code_${index}()\`, [link_${index}](https://example.com/${index}), and foo_bar_${index}.txt literal.`;
|
||||
}).join(" ");
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<b>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/b>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<a /g) ?? []).length,
|
||||
(chunk.text.match(/<\/a>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<code>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/code>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(chunk.text.includes("<i>bar</i>"), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("Chunked list, code, quote, and prose cycles stay balanced across transitions", () => {
|
||||
const markdown = Array.from({ length: 180 }, (_, index) => {
|
||||
return [
|
||||
`- list item **${index}**`,
|
||||
"```ts",
|
||||
`const cycle_${index} = \"value_${index}\";`,
|
||||
"```",
|
||||
`> quoted ${index} with [link](https://example.com/${index})`,
|
||||
`Plain paragraph ${index} with \`inline_${index}()\``,
|
||||
].join("\n");
|
||||
}).join("\n\n");
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<pre><code/g) ?? []).length,
|
||||
(chunk.text.match(/<\/code><\/pre>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<blockquote>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/blockquote>/g) ?? []).length,
|
||||
);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<a /g) ?? []).length,
|
||||
(chunk.text.match(/<\/a>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Regression tests for the Telegram replies domain
|
||||
* Covers preview decisions, rendered-message delivery, and plain or markdown reply sending in one suite
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildTelegramPreviewFinalText,
|
||||
buildTelegramPreviewFlushText,
|
||||
buildTelegramReplyTransport,
|
||||
clearTelegramPreview,
|
||||
editTelegramRenderedMessage,
|
||||
finalizeTelegramMarkdownPreview,
|
||||
finalizeTelegramPreview,
|
||||
flushTelegramPreview,
|
||||
sendTelegramMarkdownReply,
|
||||
sendTelegramPlainReply,
|
||||
sendTelegramRenderedChunks,
|
||||
shouldUseTelegramDraftPreview,
|
||||
} from "../lib/replies.ts";
|
||||
|
||||
function createPreviewRuntimeHarness(state?: {
|
||||
mode: "draft" | "message";
|
||||
draftId?: number;
|
||||
messageId?: number;
|
||||
pendingText: string;
|
||||
lastSentText: string;
|
||||
flushTimer?: ReturnType<typeof setTimeout>;
|
||||
}) {
|
||||
let previewState = state;
|
||||
let draftSupport: "unknown" | "supported" | "unsupported" = "unknown";
|
||||
let nextDraftId = 10;
|
||||
const events: string[] = [];
|
||||
return {
|
||||
events,
|
||||
getState: () => previewState,
|
||||
getDraftSupport: () => draftSupport,
|
||||
setDraftSupport: (support: "unknown" | "supported" | "unsupported") => {
|
||||
draftSupport = support;
|
||||
},
|
||||
deps: {
|
||||
getState: () => previewState,
|
||||
setState: (nextState: typeof previewState) => {
|
||||
previewState = nextState;
|
||||
},
|
||||
clearScheduledFlush: (nextState: NonNullable<typeof previewState>) => {
|
||||
if (!nextState.flushTimer) return;
|
||||
clearTimeout(nextState.flushTimer);
|
||||
nextState.flushTimer = undefined;
|
||||
events.push("clear-timer");
|
||||
},
|
||||
maxMessageLength: 5,
|
||||
renderPreviewText: (markdown: string) => markdown.replaceAll("*", ""),
|
||||
getDraftSupport: () => draftSupport,
|
||||
setDraftSupport: (support: "unknown" | "supported" | "unsupported") => {
|
||||
draftSupport = support;
|
||||
},
|
||||
allocateDraftId: () => nextDraftId++,
|
||||
sendDraft: async (chatId: number, draftId: number, text: string) => {
|
||||
events.push(`draft:${chatId}:${draftId}:${text}`);
|
||||
},
|
||||
sendMessage: async (chatId: number, text: string) => {
|
||||
events.push(`send:${chatId}:${text}`);
|
||||
return { message_id: 77 };
|
||||
},
|
||||
editMessageText: async (
|
||||
chatId: number,
|
||||
messageId: number,
|
||||
text: string,
|
||||
) => {
|
||||
events.push(`edit:${chatId}:${messageId}:${text}`);
|
||||
},
|
||||
renderTelegramMessage: (text: string, options?: { mode?: string }) => [
|
||||
{ text: `${options?.mode ?? "plain"}:${text}` },
|
||||
],
|
||||
sendRenderedChunks: async (
|
||||
chatId: number,
|
||||
chunks: Array<{ text: string }>,
|
||||
) => {
|
||||
events.push(
|
||||
`render-send:${chatId}:${chunks.map((chunk) => chunk.text).join("|")}`,
|
||||
);
|
||||
return 88;
|
||||
},
|
||||
editRenderedMessage: async (
|
||||
chatId: number,
|
||||
messageId: number,
|
||||
chunks: Array<{ text: string }>,
|
||||
) => {
|
||||
events.push(
|
||||
`render-edit:${chatId}:${messageId}:${chunks.map((chunk) => chunk.text).join("|")}`,
|
||||
);
|
||||
return messageId;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("Reply previews build flush text only when the preview changed", () => {
|
||||
assert.equal(
|
||||
buildTelegramPreviewFlushText({
|
||||
state: {
|
||||
mode: "draft",
|
||||
pendingText: "**hello**",
|
||||
lastSentText: "",
|
||||
},
|
||||
maxMessageLength: 4096,
|
||||
renderPreviewText: (markdown) => markdown.replaceAll("*", ""),
|
||||
}),
|
||||
"hello",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramPreviewFlushText({
|
||||
state: {
|
||||
mode: "draft",
|
||||
pendingText: "**hello**",
|
||||
lastSentText: "hello",
|
||||
},
|
||||
maxMessageLength: 4096,
|
||||
renderPreviewText: (markdown) => markdown.replaceAll("*", ""),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("Reply previews truncate long flush text and compute final text fallback", () => {
|
||||
assert.equal(
|
||||
buildTelegramPreviewFlushText({
|
||||
state: {
|
||||
mode: "message",
|
||||
pendingText: "abcdef",
|
||||
lastSentText: "",
|
||||
},
|
||||
maxMessageLength: 3,
|
||||
renderPreviewText: (markdown) => markdown,
|
||||
}),
|
||||
"abc",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramPreviewFinalText({
|
||||
mode: "message",
|
||||
pendingText: " ",
|
||||
lastSentText: "saved",
|
||||
}),
|
||||
"saved",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramPreviewFinalText({
|
||||
mode: "message",
|
||||
pendingText: " ",
|
||||
lastSentText: " ",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("Reply previews use drafts unless support is explicitly disabled", () => {
|
||||
assert.equal(
|
||||
shouldUseTelegramDraftPreview({ draftSupport: "unknown" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUseTelegramDraftPreview({ draftSupport: "supported" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUseTelegramDraftPreview({ draftSupport: "unsupported" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Reply preview runtime prefers draft updates and can clear draft previews", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
pendingText: "**hello**",
|
||||
lastSentText: "",
|
||||
flushTimer: setTimeout(() => {}, 1000),
|
||||
});
|
||||
await flushTelegramPreview(7, harness.deps);
|
||||
assert.deepEqual(harness.events, ["draft:7:10:hello"]);
|
||||
assert.equal(harness.getState()?.mode, "draft");
|
||||
assert.equal(harness.getState()?.draftId, 10);
|
||||
assert.equal(harness.getState()?.lastSentText, "hello");
|
||||
assert.equal(harness.getDraftSupport(), "supported");
|
||||
await clearTelegramPreview(7, harness.deps);
|
||||
assert.deepEqual(harness.events, ["draft:7:10:hello", "draft:7:10:"]);
|
||||
assert.equal(harness.getState(), undefined);
|
||||
});
|
||||
|
||||
test("Reply preview runtime falls back to editable messages when draft delivery fails", async () => {
|
||||
const harness = createPreviewRuntimeHarness({
|
||||
mode: "draft",
|
||||
pendingText: "abcdef",
|
||||
lastSentText: "",
|
||||
});
|
||||
harness.deps.sendDraft = async () => {
|
||||
throw new Error("draft unsupported");
|
||||
};
|
||||
await flushTelegramPreview(7, harness.deps);
|
||||
assert.deepEqual(harness.events, ["send:7:abcde"]);
|
||||
assert.equal(harness.getState()?.mode, "message");
|
||||
assert.equal(harness.getState()?.messageId, 77);
|
||||
assert.equal(harness.getDraftSupport(), "unsupported");
|
||||
});
|
||||
|
||||
test("Reply preview runtime finalizes plain and markdown previews", async () => {
|
||||
const plainHarness = createPreviewRuntimeHarness({
|
||||
mode: "message",
|
||||
messageId: 44,
|
||||
pendingText: "done",
|
||||
lastSentText: "",
|
||||
});
|
||||
plainHarness.setDraftSupport("unsupported");
|
||||
assert.equal(await finalizeTelegramPreview(7, plainHarness.deps), true);
|
||||
assert.deepEqual(plainHarness.events, ["edit:7:44:done"]);
|
||||
assert.equal(plainHarness.getState(), undefined);
|
||||
const markdownHarness = createPreviewRuntimeHarness({
|
||||
mode: "message",
|
||||
messageId: 55,
|
||||
pendingText: "done",
|
||||
lastSentText: "",
|
||||
});
|
||||
markdownHarness.setDraftSupport("unsupported");
|
||||
assert.equal(
|
||||
await finalizeTelegramMarkdownPreview(7, "**done**", markdownHarness.deps),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(markdownHarness.events, [
|
||||
"edit:7:55:done",
|
||||
"render-edit:7:55:markdown:**done**",
|
||||
]);
|
||||
assert.equal(markdownHarness.getState(), undefined);
|
||||
});
|
||||
|
||||
test("Reply transport forwards send and edit operations through delivery helpers", async () => {
|
||||
const events: string[] = [];
|
||||
const transport = buildTelegramReplyTransport({
|
||||
sendMessage: async (body) => {
|
||||
events.push(`send:${body.chat_id}:${body.text}`);
|
||||
return { message_id: 5 };
|
||||
},
|
||||
editMessage: async (body) => {
|
||||
events.push(`edit:${body.chat_id}:${body.message_id}:${body.text}`);
|
||||
},
|
||||
});
|
||||
assert.equal(await transport.sendRenderedChunks(7, [{ text: "one" }]), 5);
|
||||
assert.equal(await transport.editRenderedMessage(7, 9, [{ text: "two" }]), 9);
|
||||
assert.deepEqual(events, ["send:7:one", "edit:7:9:two"]);
|
||||
});
|
||||
|
||||
test("Reply delivery sends chunks and applies reply markup only to the last chunk", async () => {
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const messageId = await sendTelegramRenderedChunks(
|
||||
7,
|
||||
[{ text: "one" }, { text: "two", parseMode: "HTML" }],
|
||||
{
|
||||
sendMessage: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { message_id: sentBodies.length };
|
||||
},
|
||||
editMessage: async () => {},
|
||||
},
|
||||
{
|
||||
replyMarkup: {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(messageId, 2);
|
||||
assert.deepEqual(sentBodies, [
|
||||
{ chat_id: 7, text: "one", parse_mode: undefined, reply_markup: undefined },
|
||||
{
|
||||
chat_id: 7,
|
||||
text: "two",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Reply delivery edits the first chunk and sends remaining chunks separately", async () => {
|
||||
const editedBodies: Array<Record<string, unknown>> = [];
|
||||
const sentBodies: Array<Record<string, unknown>> = [];
|
||||
const result = await editTelegramRenderedMessage(
|
||||
7,
|
||||
99,
|
||||
[{ text: "first", parseMode: "HTML" }, { text: "second" }],
|
||||
{
|
||||
sendMessage: async (body) => {
|
||||
sentBodies.push(body);
|
||||
return { message_id: 123 };
|
||||
},
|
||||
editMessage: async (body) => {
|
||||
editedBodies.push(body);
|
||||
},
|
||||
},
|
||||
{
|
||||
replyMarkup: {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result, 123);
|
||||
assert.deepEqual(editedBodies, [
|
||||
{
|
||||
chat_id: 7,
|
||||
message_id: 99,
|
||||
text: "first",
|
||||
parse_mode: "HTML",
|
||||
reply_markup: undefined,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(sentBodies, [
|
||||
{
|
||||
chat_id: 7,
|
||||
text: "second",
|
||||
parse_mode: undefined,
|
||||
reply_markup: {
|
||||
inline_keyboard: [[{ text: "ok", callback_data: "noop" }]],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("Reply runtime sends plain replies using the requested parse mode", async () => {
|
||||
const sent: string[] = [];
|
||||
const messageId = await sendTelegramPlainReply(
|
||||
"hello",
|
||||
{
|
||||
renderTelegramMessage: (_text, options) => [
|
||||
{ text: options?.mode === "html" ? "html" : "plain" },
|
||||
],
|
||||
sendRenderedChunks: async (chunks) => {
|
||||
sent.push(chunks[0]?.text ?? "");
|
||||
return 7;
|
||||
},
|
||||
},
|
||||
{ parseMode: "HTML" },
|
||||
);
|
||||
assert.equal(messageId, 7);
|
||||
assert.deepEqual(sent, ["html"]);
|
||||
});
|
||||
|
||||
test("Reply runtime falls back to plain delivery when markdown rendering yields no chunks", async () => {
|
||||
const calls: Array<string> = [];
|
||||
const messageId = await sendTelegramMarkdownReply("hello", {
|
||||
renderTelegramMessage: (_text, options) => {
|
||||
if (options?.mode === "markdown") return [];
|
||||
return [{ text: options?.mode ?? "plain" }];
|
||||
},
|
||||
sendRenderedChunks: async (chunks) => {
|
||||
calls.push(chunks[0]?.text ?? "");
|
||||
return 9;
|
||||
},
|
||||
});
|
||||
assert.equal(messageId, 9);
|
||||
assert.deepEqual(calls, ["plain"]);
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { __telegramTestUtils } from "../index.ts";
|
||||
|
||||
test("Dispatch is allowed only when every guard is clear", () => {
|
||||
assert.equal(
|
||||
__telegramTestUtils.canDispatchTelegramTurnState({
|
||||
compactionInProgress: false,
|
||||
hasActiveTelegramTurn: false,
|
||||
hasPendingTelegramDispatch: false,
|
||||
isIdle: true,
|
||||
hasPendingMessages: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Dispatch is blocked during compaction", () => {
|
||||
assert.equal(
|
||||
__telegramTestUtils.canDispatchTelegramTurnState({
|
||||
compactionInProgress: true,
|
||||
hasActiveTelegramTurn: false,
|
||||
hasPendingTelegramDispatch: false,
|
||||
isIdle: true,
|
||||
hasPendingMessages: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Dispatch is blocked while a Telegram turn is active or pending", () => {
|
||||
assert.equal(
|
||||
__telegramTestUtils.canDispatchTelegramTurnState({
|
||||
compactionInProgress: false,
|
||||
hasActiveTelegramTurn: true,
|
||||
hasPendingTelegramDispatch: false,
|
||||
isIdle: true,
|
||||
hasPendingMessages: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
__telegramTestUtils.canDispatchTelegramTurnState({
|
||||
compactionInProgress: false,
|
||||
hasActiveTelegramTurn: false,
|
||||
hasPendingTelegramDispatch: true,
|
||||
isIdle: true,
|
||||
hasPendingMessages: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Dispatch is blocked when pi is busy or has pending messages", () => {
|
||||
assert.equal(
|
||||
__telegramTestUtils.canDispatchTelegramTurnState({
|
||||
compactionInProgress: false,
|
||||
hasActiveTelegramTurn: false,
|
||||
hasPendingTelegramDispatch: false,
|
||||
isIdle: false,
|
||||
hasPendingMessages: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
__telegramTestUtils.canDispatchTelegramTurnState({
|
||||
compactionInProgress: false,
|
||||
hasActiveTelegramTurn: false,
|
||||
hasPendingTelegramDispatch: false,
|
||||
isIdle: true,
|
||||
hasPendingMessages: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("In-flight model switch is allowed only for active Telegram turns with abort support", () => {
|
||||
assert.equal(
|
||||
__telegramTestUtils.canRestartTelegramTurnForModelSwitch({
|
||||
isIdle: false,
|
||||
hasActiveTelegramTurn: true,
|
||||
hasAbortHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
__telegramTestUtils.canRestartTelegramTurnForModelSwitch({
|
||||
isIdle: true,
|
||||
hasActiveTelegramTurn: true,
|
||||
hasAbortHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
__telegramTestUtils.canRestartTelegramTurnForModelSwitch({
|
||||
isIdle: false,
|
||||
hasActiveTelegramTurn: false,
|
||||
hasAbortHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
__telegramTestUtils.canRestartTelegramTurnForModelSwitch({
|
||||
isIdle: false,
|
||||
hasActiveTelegramTurn: true,
|
||||
hasAbortHandler: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Continuation prompt stays Telegram-scoped and resume-oriented", () => {
|
||||
const text = __telegramTestUtils.buildTelegramModelSwitchContinuationText(
|
||||
{ provider: "openai", id: "gpt-5", name: "GPT-5" },
|
||||
"high",
|
||||
);
|
||||
assert.match(text, /^\[telegram\]/);
|
||||
assert.match(text, /Continue the interrupted previous Telegram request/);
|
||||
assert.match(text, /openai\/gpt-5/);
|
||||
assert.match(text, /thinking level \(high\)/);
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { __telegramTestUtils } from "../index.ts";
|
||||
|
||||
test("Nested lists stay out of code blocks", () => {
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(
|
||||
"- Level 1\n - Level 2\n - Level 3 with **bold** text",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.ok(chunks.length > 0);
|
||||
assert.equal(
|
||||
chunks.some((chunk) => chunk.text.includes("<pre><code>")),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
chunks.some((chunk) =>
|
||||
chunk.text.includes("• Level 3 with <b>bold</b> text"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("Fenced code blocks preserve literal markdown", () => {
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(
|
||||
'~~~ts\nconst value = "**raw**";\n~~~',
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.match(chunks[0]?.text ?? "", /<pre><code class="language-ts">/);
|
||||
assert.match(chunks[0]?.text ?? "", /\*\*raw\*\*/);
|
||||
});
|
||||
|
||||
test("Underscores inside words do not become italic", () => {
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(
|
||||
"Path: foo_bar_baz.txt and **bold**",
|
||||
{ mode: "markdown" },
|
||||
);
|
||||
assert.equal(chunks.length, 1);
|
||||
assert.equal((chunks[0]?.text ?? "").includes("<i>bar</i>"), false);
|
||||
assert.match(chunks[0]?.text ?? "", /<b>bold<\/b>/);
|
||||
});
|
||||
|
||||
test("Long markdown replies stay chunked below Telegram limits", () => {
|
||||
const markdown = Array.from(
|
||||
{ length: 600 },
|
||||
(_, index) => `- item **${index}**`,
|
||||
).join("\n");
|
||||
const chunks = __telegramTestUtils.renderTelegramMessage(markdown, {
|
||||
mode: "markdown",
|
||||
});
|
||||
assert.ok(chunks.length > 1);
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(chunk.text.length <= __telegramTestUtils.MAX_MESSAGE_LENGTH);
|
||||
assert.equal(
|
||||
(chunk.text.match(/<b>/g) ?? []).length,
|
||||
(chunk.text.match(/<\/b>/g) ?? []).length,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Regression tests for the Telegram turn-building domain
|
||||
* Covers queue-summary formatting, prompt construction, and prompt-turn assembly from messages and downloaded files
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildTelegramPromptTurn,
|
||||
buildTelegramTurnPrompt,
|
||||
formatTelegramTurnStatusSummary,
|
||||
truncateTelegramQueueSummary,
|
||||
} from "../lib/turns.ts";
|
||||
|
||||
test("Turn helpers truncate queue summaries predictably", () => {
|
||||
assert.equal(
|
||||
truncateTelegramQueueSummary("one two three four"),
|
||||
"one two three four",
|
||||
);
|
||||
assert.equal(
|
||||
truncateTelegramQueueSummary("one two three four five six"),
|
||||
"one two three four five…",
|
||||
);
|
||||
assert.equal(truncateTelegramQueueSummary(" "), "");
|
||||
});
|
||||
|
||||
test("Turn helpers build prompt text with history and attachments", () => {
|
||||
const prompt = buildTelegramTurnPrompt({
|
||||
telegramPrefix: "[telegram]",
|
||||
rawText: "current message",
|
||||
files: [{ path: "/tmp/demo.png", fileName: "demo.png", isImage: true }],
|
||||
historyTurns: [{ historyText: "older message" }],
|
||||
});
|
||||
assert.match(prompt, /^\[telegram\]/);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Earlier Telegram messages arrived after an aborted turn/,
|
||||
);
|
||||
assert.match(prompt, /1\. older message/);
|
||||
assert.match(prompt, /Current Telegram message:\ncurrent message/);
|
||||
assert.match(
|
||||
prompt,
|
||||
/Telegram attachments were saved locally:\n- \/tmp\/demo.png/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn helpers summarize text and attachment-only turns", () => {
|
||||
assert.equal(
|
||||
formatTelegramTurnStatusSummary("hello there from telegram", []),
|
||||
"hello there from telegram",
|
||||
);
|
||||
assert.equal(
|
||||
formatTelegramTurnStatusSummary("", [
|
||||
{
|
||||
path: "/tmp/report-final-version.txt",
|
||||
fileName: "report-final-version.txt",
|
||||
isImage: false,
|
||||
},
|
||||
]),
|
||||
"📎 report-final-version.txt",
|
||||
);
|
||||
assert.equal(
|
||||
formatTelegramTurnStatusSummary("", [
|
||||
{ path: "/tmp/a.txt", fileName: "a.txt", isImage: false },
|
||||
{ path: "/tmp/b.txt", fileName: "b.txt", isImage: false },
|
||||
]),
|
||||
"📎 2 attachments",
|
||||
);
|
||||
});
|
||||
|
||||
test("Turn helpers assemble prompt turns with text, ids, history, and image payloads", async () => {
|
||||
const turn = await buildTelegramPromptTurn({
|
||||
telegramPrefix: "[telegram]",
|
||||
messages: [
|
||||
{ message_id: 10, chat: { id: 99 } },
|
||||
{ message_id: 11, chat: { id: 99 } },
|
||||
],
|
||||
historyTurns: [
|
||||
{
|
||||
kind: "prompt",
|
||||
chatId: 99,
|
||||
replyToMessageId: 1,
|
||||
sourceMessageIds: [1],
|
||||
queueOrder: 1,
|
||||
queueLane: "default",
|
||||
laneOrder: 1,
|
||||
queuedAttachments: [],
|
||||
content: [{ type: "text", text: "ignored" }],
|
||||
historyText: "older message",
|
||||
statusSummary: "older",
|
||||
},
|
||||
],
|
||||
queueOrder: 7,
|
||||
rawText: "current message",
|
||||
files: [
|
||||
{
|
||||
path: "/tmp/demo.png",
|
||||
fileName: "demo.png",
|
||||
isImage: true,
|
||||
mimeType: "image/png",
|
||||
},
|
||||
{
|
||||
path: "/tmp/report.txt",
|
||||
fileName: "report.txt",
|
||||
isImage: false,
|
||||
},
|
||||
],
|
||||
readBinaryFile: async () => new Uint8Array([1, 2, 3]),
|
||||
inferImageMimeType: () => undefined,
|
||||
});
|
||||
assert.equal(turn.chatId, 99);
|
||||
assert.equal(turn.replyToMessageId, 10);
|
||||
assert.deepEqual(turn.sourceMessageIds, [10, 11]);
|
||||
assert.equal(turn.queueOrder, 7);
|
||||
assert.equal(turn.statusSummary, "current message");
|
||||
assert.equal(
|
||||
turn.historyText,
|
||||
"current message\nAttachments:\n- /tmp/demo.png\n- /tmp/report.txt",
|
||||
);
|
||||
assert.equal(turn.content.length, 2);
|
||||
assert.equal(turn.content[0]?.type, "text");
|
||||
assert.match(
|
||||
(turn.content[0] as { type: "text"; text: string }).text,
|
||||
/Earlier Telegram messages arrived after an aborted turn/,
|
||||
);
|
||||
assert.deepEqual(turn.content[1], {
|
||||
type: "image",
|
||||
data: Buffer.from([1, 2, 3]).toString("base64"),
|
||||
mimeType: "image/png",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Regression tests for the Telegram updates domain
|
||||
* Covers extraction, authorization, flow classification, execution planning, and runtime execution in one suite
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildTelegramUpdateExecutionPlan,
|
||||
buildTelegramUpdateExecutionPlanFromUpdate,
|
||||
buildTelegramUpdateFlowAction,
|
||||
collectTelegramReactionEmojis,
|
||||
executeTelegramUpdate,
|
||||
executeTelegramUpdatePlan,
|
||||
extractDeletedTelegramMessageIds,
|
||||
getAuthorizedTelegramCallbackQuery,
|
||||
getAuthorizedTelegramMessage,
|
||||
getTelegramAuthorizationState,
|
||||
normalizeTelegramReactionEmoji,
|
||||
} from "../lib/updates.ts";
|
||||
|
||||
test("Update helpers normalize emoji reactions and collect emoji-only entries", () => {
|
||||
assert.equal(normalizeTelegramReactionEmoji("👍️"), "👍");
|
||||
const emojis = collectTelegramReactionEmojis([
|
||||
{ type: "emoji", emoji: "👍️" },
|
||||
{ type: "emoji", emoji: "👎" },
|
||||
{ type: "custom_emoji" },
|
||||
]);
|
||||
assert.deepEqual([...emojis], ["👍", "👎"]);
|
||||
});
|
||||
|
||||
test("Update helpers extract deleted message ids from Telegram update variants", () => {
|
||||
assert.deepEqual(
|
||||
extractDeletedTelegramMessageIds({
|
||||
_: "other",
|
||||
deleted_business_messages: { message_ids: [1, 2] },
|
||||
}),
|
||||
[1, 2],
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractDeletedTelegramMessageIds({
|
||||
_: "updateDeleteMessages",
|
||||
messages: [3, 4],
|
||||
}),
|
||||
[3, 4],
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractDeletedTelegramMessageIds({
|
||||
_: "updateDeleteMessages",
|
||||
messages: [3, "bad"],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("Update routing classifies authorization state for pair, allow, and deny", () => {
|
||||
assert.deepEqual(getTelegramAuthorizationState(10), {
|
||||
kind: "pair",
|
||||
userId: 10,
|
||||
});
|
||||
assert.deepEqual(getTelegramAuthorizationState(10, 10), { kind: "allow" });
|
||||
assert.deepEqual(getTelegramAuthorizationState(10, 11), { kind: "deny" });
|
||||
});
|
||||
|
||||
test("Update routing extracts only private human callback queries", () => {
|
||||
assert.equal(
|
||||
getAuthorizedTelegramCallbackQuery({
|
||||
callback_query: {
|
||||
from: { id: 1, is_bot: true },
|
||||
message: { chat: { type: "private" } },
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
const query = getAuthorizedTelegramCallbackQuery({
|
||||
callback_query: {
|
||||
from: { id: 1, is_bot: false },
|
||||
message: { chat: { type: "private" } },
|
||||
},
|
||||
});
|
||||
assert.ok(query);
|
||||
});
|
||||
|
||||
test("Update routing extracts private human messages from message or edited_message", () => {
|
||||
assert.equal(
|
||||
getAuthorizedTelegramMessage({
|
||||
message: {
|
||||
chat: { type: "group" },
|
||||
from: { id: 1, is_bot: false },
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
const directMessage = getAuthorizedTelegramMessage({
|
||||
edited_message: {
|
||||
chat: { type: "private" },
|
||||
from: { id: 1, is_bot: false },
|
||||
},
|
||||
});
|
||||
assert.ok(directMessage);
|
||||
});
|
||||
|
||||
test("Update flow prioritizes deleted-message handling over other update kinds", () => {
|
||||
const action = buildTelegramUpdateFlowAction(
|
||||
{
|
||||
_: "updateDeleteMessages",
|
||||
messages: [1, 2],
|
||||
message_reaction: {
|
||||
chat: { type: "private" },
|
||||
user: { id: 1, is_bot: false },
|
||||
},
|
||||
},
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(action, { kind: "deleted", messageIds: [1, 2] });
|
||||
});
|
||||
|
||||
test("Update flow returns authorized callback and message actions", () => {
|
||||
const callbackAction = buildTelegramUpdateFlowAction(
|
||||
{
|
||||
_: "other",
|
||||
callback_query: {
|
||||
from: { id: 7, is_bot: false },
|
||||
message: { chat: { type: "private" } },
|
||||
},
|
||||
},
|
||||
7,
|
||||
);
|
||||
assert.equal(callbackAction.kind, "callback");
|
||||
assert.deepEqual(
|
||||
callbackAction.kind === "callback" ? callbackAction.authorization : undefined,
|
||||
{ kind: "allow" },
|
||||
);
|
||||
const messageAction = buildTelegramUpdateFlowAction({
|
||||
_: "other",
|
||||
message: {
|
||||
chat: { type: "private" },
|
||||
from: { id: 9, is_bot: false },
|
||||
},
|
||||
});
|
||||
assert.equal(messageAction.kind, "message");
|
||||
assert.deepEqual(
|
||||
messageAction.kind === "message" ? messageAction.authorization : undefined,
|
||||
{ kind: "pair", userId: 9 },
|
||||
);
|
||||
});
|
||||
|
||||
test("Update flow ignores unauthorized transport shapes and preserves reaction events", () => {
|
||||
const reactionAction = buildTelegramUpdateFlowAction({
|
||||
_: "other",
|
||||
message_reaction: {
|
||||
chat: { type: "private" },
|
||||
user: { id: 1, is_bot: false },
|
||||
},
|
||||
});
|
||||
assert.equal(reactionAction.kind, "reaction");
|
||||
const ignored = buildTelegramUpdateFlowAction({
|
||||
_: "other",
|
||||
callback_query: {
|
||||
from: { id: 1, is_bot: true },
|
||||
message: { chat: { type: "private" } },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(ignored, { kind: "ignore" });
|
||||
});
|
||||
|
||||
test("Update execution plan maps callback and message authorization to side-effect flags", () => {
|
||||
const callbackPlan = buildTelegramUpdateExecutionPlan({
|
||||
kind: "callback",
|
||||
query: {
|
||||
from: { id: 1, is_bot: false },
|
||||
message: { chat: { type: "private" } },
|
||||
},
|
||||
authorization: { kind: "deny" },
|
||||
});
|
||||
assert.deepEqual(callbackPlan, {
|
||||
kind: "callback",
|
||||
query: {
|
||||
from: { id: 1, is_bot: false },
|
||||
message: { chat: { type: "private" } },
|
||||
},
|
||||
shouldPair: false,
|
||||
shouldDeny: true,
|
||||
});
|
||||
const messagePlan = buildTelegramUpdateExecutionPlan({
|
||||
kind: "message",
|
||||
message: {
|
||||
chat: { type: "private" },
|
||||
from: { id: 2, is_bot: false },
|
||||
},
|
||||
authorization: { kind: "pair", userId: 2 },
|
||||
});
|
||||
assert.equal(messagePlan.kind, "message");
|
||||
assert.equal(messagePlan.shouldPair, true);
|
||||
assert.equal(messagePlan.shouldNotifyPaired, true);
|
||||
assert.equal(messagePlan.shouldDeny, false);
|
||||
});
|
||||
|
||||
test("Update execution plan preserves deleted and reaction actions", () => {
|
||||
assert.deepEqual(
|
||||
buildTelegramUpdateExecutionPlan({ kind: "deleted", messageIds: [1, 2] }),
|
||||
{ kind: "deleted", messageIds: [1, 2] },
|
||||
);
|
||||
const reactionUpdate = {
|
||||
chat: { type: "private" },
|
||||
user: { id: 1, is_bot: false },
|
||||
};
|
||||
assert.deepEqual(
|
||||
buildTelegramUpdateExecutionPlan({
|
||||
kind: "reaction",
|
||||
reactionUpdate,
|
||||
}),
|
||||
{ kind: "reaction", reactionUpdate },
|
||||
);
|
||||
});
|
||||
|
||||
test("Update execution plan can be built directly from updates", () => {
|
||||
const plan = buildTelegramUpdateExecutionPlanFromUpdate(
|
||||
{
|
||||
_: "other",
|
||||
callback_query: {
|
||||
from: { id: 4, is_bot: false },
|
||||
message: { chat: { type: "private" } },
|
||||
},
|
||||
},
|
||||
5,
|
||||
);
|
||||
assert.equal(plan.kind, "callback");
|
||||
assert.equal(plan.kind === "callback" ? plan.shouldDeny : false, true);
|
||||
});
|
||||
|
||||
test("Update runtime executes delete and reaction plans through the right side effects", async () => {
|
||||
const events: string[] = [];
|
||||
await executeTelegramUpdatePlan(
|
||||
{ kind: "deleted", messageIds: [1, 2] },
|
||||
{
|
||||
ctx: {} as never,
|
||||
removePendingMediaGroupMessages: (ids) => {
|
||||
events.push(`media:${ids.join(',')}`);
|
||||
},
|
||||
removeQueuedTelegramTurnsByMessageIds: (ids) => {
|
||||
events.push(`queue:${ids.join(',')}`);
|
||||
return ids.length;
|
||||
},
|
||||
handleAuthorizedTelegramReactionUpdate: async () => {
|
||||
events.push("reaction");
|
||||
},
|
||||
pairTelegramUserIfNeeded: async () => false,
|
||||
answerCallbackQuery: async () => {},
|
||||
handleAuthorizedTelegramCallbackQuery: async () => {},
|
||||
sendTextReply: async () => undefined,
|
||||
handleAuthorizedTelegramMessage: async () => {},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(events, ["media:1,2", "queue:1,2"]);
|
||||
});
|
||||
|
||||
test("Update runtime can execute directly from raw updates", async () => {
|
||||
const events: string[] = [];
|
||||
await executeTelegramUpdate(
|
||||
{
|
||||
_: "other",
|
||||
message: {
|
||||
chat: { id: 10, type: "private" },
|
||||
message_id: 20,
|
||||
from: { id: 7, is_bot: false },
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
ctx: {} as never,
|
||||
removePendingMediaGroupMessages: () => {},
|
||||
removeQueuedTelegramTurnsByMessageIds: () => 0,
|
||||
handleAuthorizedTelegramReactionUpdate: async () => {},
|
||||
pairTelegramUserIfNeeded: async () => {
|
||||
events.push("pair");
|
||||
return true;
|
||||
},
|
||||
answerCallbackQuery: async () => {},
|
||||
handleAuthorizedTelegramCallbackQuery: async () => {},
|
||||
sendTextReply: async (_chatId, _replyToMessageId, text) => {
|
||||
events.push(`reply:${text}`);
|
||||
return undefined;
|
||||
},
|
||||
handleAuthorizedTelegramMessage: async () => {
|
||||
events.push("message");
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(events, ["pair", "reply:Telegram bridge paired with this account.", "message"]);
|
||||
});
|
||||
|
||||
test("Update runtime handles callback deny and message pair flows", async () => {
|
||||
const events: string[] = [];
|
||||
await executeTelegramUpdatePlan(
|
||||
{
|
||||
kind: "callback",
|
||||
query: {
|
||||
id: "cb",
|
||||
from: { id: 1, is_bot: false },
|
||||
message: { chat: { type: "private" } },
|
||||
},
|
||||
shouldPair: true,
|
||||
shouldDeny: true,
|
||||
},
|
||||
{
|
||||
ctx: {} as never,
|
||||
removePendingMediaGroupMessages: () => {},
|
||||
removeQueuedTelegramTurnsByMessageIds: () => 0,
|
||||
handleAuthorizedTelegramReactionUpdate: async () => {},
|
||||
pairTelegramUserIfNeeded: async (userId) => {
|
||||
events.push(`pair:${userId}`);
|
||||
return true;
|
||||
},
|
||||
answerCallbackQuery: async (id, text) => {
|
||||
events.push(`answer:${id}:${text}`);
|
||||
},
|
||||
handleAuthorizedTelegramCallbackQuery: async () => {
|
||||
events.push("callback");
|
||||
},
|
||||
sendTextReply: async (chatId, replyToMessageId, text) => {
|
||||
events.push(`reply:${chatId}:${replyToMessageId}:${text}`);
|
||||
return undefined;
|
||||
},
|
||||
handleAuthorizedTelegramMessage: async () => {
|
||||
events.push("message");
|
||||
},
|
||||
},
|
||||
);
|
||||
await executeTelegramUpdatePlan(
|
||||
{
|
||||
kind: "message",
|
||||
message: {
|
||||
chat: { id: 7, type: "private" },
|
||||
from: { id: 2, is_bot: false },
|
||||
message_id: 9,
|
||||
},
|
||||
shouldPair: true,
|
||||
shouldNotifyPaired: true,
|
||||
shouldDeny: false,
|
||||
},
|
||||
{
|
||||
ctx: {} as never,
|
||||
removePendingMediaGroupMessages: () => {},
|
||||
removeQueuedTelegramTurnsByMessageIds: () => 0,
|
||||
handleAuthorizedTelegramReactionUpdate: async () => {},
|
||||
pairTelegramUserIfNeeded: async () => true,
|
||||
answerCallbackQuery: async () => {},
|
||||
handleAuthorizedTelegramCallbackQuery: async () => {},
|
||||
sendTextReply: async (chatId, replyToMessageId, text) => {
|
||||
events.push(`reply:${chatId}:${replyToMessageId}:${text}`);
|
||||
return undefined;
|
||||
},
|
||||
handleAuthorizedTelegramMessage: async () => {
|
||||
events.push("message");
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
"pair:1",
|
||||
"answer:cb:This bot is not authorized for your account.",
|
||||
"reply:7:9:Telegram bridge paired with this account.",
|
||||
"message",
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user