0.2.0: refactor into domain modules

This commit is contained in:
LLB
2026-04-11 10:31:25 +04:00
parent 233b20b089
commit 8dcf761937
34 changed files with 10450 additions and 2672 deletions
+89
View File
@@ -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;
}
});
+132
View File
@@ -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",
+77
View File
@@ -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);
});
+645
View File
@@ -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);
});
+129
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+268
View File
@@ -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);
});
+308
View File
@@ -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,
);
}
});
+362
View File
@@ -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"]);
});
-122
View File
@@ -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\)/);
});
-60
View File
@@ -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,
);
}
});
+132
View File
@@ -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",
});
});
+366
View File
@@ -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",
]);
});