Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a drop-in extension for can1357/oh-my-pi (omp >= 16.5.2). Changes vs upstream: - Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram - Bump version: 0.22.0 -> 0.22.0-evandro.1 - Update repository/homepage/bugs URLs to evandrodevbr - Add omp field in package.json (displayName + compatibleWith) - README: prepend 'Oh My Pi (omp) Compatibility' section with install commands, verified-components table, upstream lineage, and rationale - CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased - docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram - tests/public-api.test.ts: rename package import paths Gates re-run on the fork: - npm run typecheck -> tsc --noEmit (success) - npm test -> 1229 pass / 0 fail / 1 platform-only skip - npm run audit -> found 0 vulnerabilities - npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz Verified: - omp install --link ./pi-telegram -> Linked OK - omp plugin list -> @evandrodevbr/[email protected].0-evandro.1 Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
952 lines
28 KiB
TypeScript
952 lines
28 KiB
TypeScript
/**
|
|
* 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 { mkdtemp, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
buildTelegramPromptTurn,
|
|
buildTelegramPromptTurnRuntime,
|
|
buildTelegramTurnPrompt,
|
|
createTelegramPromptTurnRuntimeBuilder,
|
|
createTelegramQueuedPromptEditRuntime,
|
|
formatTelegramTurnStatusSummary,
|
|
getTelegramVoiceReplyMode,
|
|
truncateTelegramQueueSummary,
|
|
updateQueuedTelegramPromptTurnText,
|
|
updateTelegramPromptTurnText,
|
|
} 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, /\[attachments\] \/tmp\n- \/demo.png/);
|
|
});
|
|
|
|
test("Turn helpers omit [time] section by default", () => {
|
|
const prompt = buildTelegramTurnPrompt({
|
|
telegramPrefix: "[telegram]",
|
|
rawText: "hello",
|
|
files: [],
|
|
});
|
|
assert.equal(prompt, "[telegram] hello");
|
|
});
|
|
|
|
test("Turn helpers inject [time] as the final context section", () => {
|
|
const prompt = buildTelegramTurnPrompt({
|
|
telegramPrefix: "[telegram]",
|
|
rawText: "current message",
|
|
files: [{ path: "/tmp/demo.png", fileName: "demo.png", isImage: true }],
|
|
handlerOutputs: ["transcript"],
|
|
voiceContext: { "reply mode": "manual" },
|
|
timeLine: "2026-05-16 14:32:10 Europe/Berlin",
|
|
});
|
|
assert.match(
|
|
prompt,
|
|
/^\[telegram\] current message\n\n\[attachments\] \/tmp\n- \/demo\.png\n\n\[outputs\]\n- transcript\n\n\[voice\] reply mode: manual\n\n\[time\] 2026-05-16 14:32:10 Europe\/Berlin$/,
|
|
);
|
|
});
|
|
|
|
test("Turn helpers still inject [time] when raw text is empty", () => {
|
|
const prompt = buildTelegramTurnPrompt({
|
|
telegramPrefix: "[telegram]",
|
|
rawText: "",
|
|
files: [],
|
|
timeLine: "2026-05-16 14:32:10 UTC",
|
|
});
|
|
assert.equal(prompt, "[telegram]\n\n[time] 2026-05-16 14:32:10 UTC");
|
|
});
|
|
|
|
test("Turn runtime builder calls resolveTimeLine with chatId and embeds result", async () => {
|
|
const seen: number[] = [];
|
|
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
|
allocateQueueOrder: () => 1,
|
|
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
|
resolveTimeLine: (chatId) => {
|
|
seen.push(chatId);
|
|
return "2026-05-16 14:32:10 UTC";
|
|
},
|
|
});
|
|
const turn = await buildTurn([
|
|
{ message_id: 42, chat: { id: 7 }, text: "hi" },
|
|
]);
|
|
assert.deepEqual(seen, [7]);
|
|
assert.match(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
/\[time\] 2026-05-16 14:32:10 UTC/,
|
|
);
|
|
});
|
|
|
|
test("Turn runtime builder adds explicit thread attribute to Telegram prefix", async () => {
|
|
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
|
allocateQueueOrder: () => 1,
|
|
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
|
getTelegramThreadLabel: () => "🧭 Axial|bad]name",
|
|
});
|
|
const turn = await buildTurn([
|
|
{ message_id: 42, message_thread_id: 7, chat: { id: 7 }, text: "hi" },
|
|
]);
|
|
assert.equal(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
"[telegram|thread:🧭 Axial bad name] hi",
|
|
);
|
|
});
|
|
|
|
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 update queued prompt text for edited Telegram messages", () => {
|
|
const turn = {
|
|
kind: "prompt" as const,
|
|
chatId: 99,
|
|
replyToMessageId: 10,
|
|
sourceMessageIds: [10],
|
|
queueOrder: 1,
|
|
queueLane: "default" as const,
|
|
laneOrder: 1,
|
|
queuedAttachments: [],
|
|
content: [{ type: "text" as const, text: "[telegram] old" }],
|
|
historyText: "old",
|
|
statusSummary: "old",
|
|
};
|
|
const updated = updateTelegramPromptTurnText({
|
|
turn,
|
|
telegramPrefix: "[telegram]",
|
|
rawText: "new edited message",
|
|
});
|
|
assert.equal(updated.content[0]?.type, "text");
|
|
assert.equal(
|
|
(updated.content[0] as { type: "text"; text: string }).text,
|
|
"[telegram] new edited message",
|
|
);
|
|
assert.equal(updated.historyText, "new edited message");
|
|
assert.equal(updated.statusSummary, "new edited message");
|
|
assert.notEqual(updated, turn);
|
|
});
|
|
|
|
test("Turn runtime builder extracts text, downloads files, and allocates order", async () => {
|
|
let nextOrder = 3;
|
|
const downloaded: string[] = [];
|
|
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
|
allocateQueueOrder: () => nextOrder++,
|
|
downloadFile: async (fileId, fileName) => {
|
|
downloaded.push(`${fileId}:${fileName}`);
|
|
return `/tmp/${fileName}`;
|
|
},
|
|
});
|
|
const turn = await buildTurn([
|
|
{
|
|
message_id: 11,
|
|
chat: { id: 5 },
|
|
caption: "see file",
|
|
document: { file_id: "doc-1", file_name: "report.txt" },
|
|
},
|
|
]);
|
|
assert.equal(turn.queueOrder, 3);
|
|
assert.equal(turn.statusSummary, "see file");
|
|
assert.deepEqual(downloaded, ["doc-1:report.txt"]);
|
|
assert.match(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
/\[attachments\] \/tmp\n- \/report\.txt/,
|
|
);
|
|
});
|
|
|
|
test("Turn runtime builder injects Telegram reply context into prompt turns", async () => {
|
|
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
|
allocateQueueOrder: () => 1,
|
|
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
|
});
|
|
const turn = await buildTurn([
|
|
{
|
|
message_id: 12,
|
|
chat: { id: 5 },
|
|
text: "Not yet",
|
|
reply_to_message: { text: "Have you seen the latest Claude update?" },
|
|
},
|
|
]);
|
|
assert.equal(turn.statusSummary, "Not yet");
|
|
assert.equal(
|
|
turn.historyText,
|
|
"Not yet\n\n[reply] Have you seen the latest Claude update?",
|
|
);
|
|
assert.equal(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
"[telegram] Not yet\n\n[reply] Have you seen the latest Claude update?",
|
|
);
|
|
});
|
|
|
|
test("Turn runtime builder identifies forwarded messages from non-owner users", async () => {
|
|
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
|
allocateQueueOrder: () => 1,
|
|
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
|
getAllowedUserId: () => 100,
|
|
});
|
|
const turn = await buildTurn([
|
|
{
|
|
message_id: 12,
|
|
chat: { id: 5 },
|
|
from: { id: 100, first_name: "Owner" },
|
|
forward_origin: {
|
|
type: "user",
|
|
sender_user: { id: 200, first_name: "Alice", username: "alice" },
|
|
},
|
|
text: "forwarded text",
|
|
},
|
|
]);
|
|
assert.equal(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
"[telegram]\n\n[forward|from:alice] forwarded text",
|
|
);
|
|
});
|
|
|
|
test("Turn runtime builder places replied-message attachments in source attachment context", async () => {
|
|
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
|
allocateQueueOrder: () => 1,
|
|
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
|
});
|
|
const turn = await buildTurn([
|
|
{
|
|
message_id: 12,
|
|
chat: { id: 5 },
|
|
text: "look at this",
|
|
reply_to_message: {
|
|
message_id: 11,
|
|
from: { id: 200, first_name: "Alice" },
|
|
caption: "source image",
|
|
photo: [{ file_id: "photo-1", file_size: 100 }],
|
|
},
|
|
},
|
|
]);
|
|
assert.equal(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
"[telegram] look at this\n\n[reply|from:200] source image\n\n[attachments|from:200] /tmp\n- /photo-11.jpg",
|
|
);
|
|
});
|
|
|
|
test("Turn runtime builder keeps current attachments before source reply context", async () => {
|
|
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
|
allocateQueueOrder: () => 1,
|
|
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
|
});
|
|
const turn = await buildTurn([
|
|
{
|
|
message_id: 12,
|
|
chat: { id: 5 },
|
|
text: "current",
|
|
document: { file_id: "doc-1", file_name: "mine.txt" },
|
|
reply_to_message: {
|
|
message_id: 11,
|
|
from: { id: 200, username: "alice" },
|
|
photo: [{ file_id: "photo-1", file_size: 100 }],
|
|
},
|
|
},
|
|
]);
|
|
assert.equal(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
[
|
|
"[telegram] current",
|
|
"",
|
|
"[attachments] /tmp",
|
|
"- /mine.txt",
|
|
"",
|
|
"[reply|from:alice]",
|
|
"",
|
|
"[attachments|from:alice] /tmp",
|
|
"- /photo-11.jpg",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
test("Turn runtime builder includes reply block for attachment-only replied messages", async () => {
|
|
const buildTurn = createTelegramPromptTurnRuntimeBuilder({
|
|
allocateQueueOrder: () => 1,
|
|
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
|
});
|
|
const turn = await buildTurn([
|
|
{
|
|
message_id: 12,
|
|
chat: { id: 5 },
|
|
text: "what is this?",
|
|
reply_to_message: {
|
|
message_id: 11,
|
|
from: { id: 200, username: "alice" },
|
|
photo: [{ file_id: "photo-1", file_size: 100 }],
|
|
},
|
|
},
|
|
]);
|
|
assert.equal(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
"[telegram] what is this?\n\n[reply|from:alice]\n\n[attachments|from:alice] /tmp\n- /photo-11.jpg",
|
|
);
|
|
});
|
|
|
|
test("Turn runtime builder routes inbound handler output into prompt text", async () => {
|
|
const buildTurn = createTelegramPromptTurnRuntimeBuilder<
|
|
{
|
|
message_id: number;
|
|
chat: { id: number };
|
|
voice: { file_id: string; mime_type: string };
|
|
},
|
|
{ cwd: string }
|
|
>({
|
|
allocateQueueOrder: () => 1,
|
|
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
|
processAttachments: async (files, rawText, ctx) => ({
|
|
rawText,
|
|
promptFiles: files,
|
|
handlerOutputs: [`transcript from ${ctx.cwd}`],
|
|
}),
|
|
});
|
|
const turn = await buildTurn(
|
|
[
|
|
{
|
|
message_id: 12,
|
|
chat: { id: 5 },
|
|
voice: { file_id: "voice-1", mime_type: "audio/ogg" },
|
|
},
|
|
],
|
|
[],
|
|
{ cwd: "/work" },
|
|
);
|
|
assert.equal(turn.statusSummary, "transcript from /work");
|
|
assert.deepEqual(turn.sourceMessageIds, [12]);
|
|
assert.equal(
|
|
turn.historyText,
|
|
"(no text)\n\n[attachments] /tmp\n- /voice-12.ogg\n\n[outputs]\n- transcript from /work",
|
|
);
|
|
assert.equal(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
"[telegram]\n\n[attachments] /tmp\n- /voice-12.ogg\n\n[outputs]\n- transcript from /work",
|
|
);
|
|
});
|
|
|
|
test("Turn runtime omits voice context when reply mode is only the implicit default", async () => {
|
|
const buildTurn = createTelegramPromptTurnRuntimeBuilder<
|
|
{
|
|
message_id: number;
|
|
chat: { id: number };
|
|
voice: { file_id: string; mime_type: string };
|
|
},
|
|
unknown
|
|
>({
|
|
allocateQueueOrder: () => 1,
|
|
downloadFile: async (_fileId, fileName) => `/tmp/${fileName}`,
|
|
getVoiceReplyMode: () => "manual",
|
|
isVoiceReplyModeConfigured: () => false,
|
|
});
|
|
const turn = await buildTurn([
|
|
{
|
|
message_id: 13,
|
|
chat: { id: 5 },
|
|
voice: { file_id: "voice-1", mime_type: "audio/ogg" },
|
|
},
|
|
]);
|
|
assert.doesNotMatch(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
/\[voice\]/,
|
|
);
|
|
});
|
|
|
|
test("Turn builder preserves topic thread target", async () => {
|
|
const turn = await buildTelegramPromptTurnRuntime({
|
|
telegramPrefix: "[telegram]",
|
|
messages: [{ message_id: 14, message_thread_id: 42, chat: { id: -1007 } }],
|
|
queueOrder: 1,
|
|
rawText: "hello",
|
|
files: [],
|
|
statusText: "hello",
|
|
inferImageMimeType: () => undefined,
|
|
});
|
|
assert.deepEqual(turn.target, { chatId: -1007, threadId: 42 });
|
|
});
|
|
|
|
test("Turn builder keeps private bot topic metadata out of prompt prefix", async () => {
|
|
const turn = await buildTelegramPromptTurnRuntime({
|
|
telegramPrefix: "[telegram]",
|
|
messages: [
|
|
{
|
|
message_id: 14,
|
|
message_thread_id: 42,
|
|
chat: { id: 7, type: "private" },
|
|
},
|
|
],
|
|
queueOrder: 1,
|
|
rawText: "hello",
|
|
files: [],
|
|
statusText: "hello",
|
|
inferImageMimeType: () => undefined,
|
|
});
|
|
assert.equal(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
"[telegram] hello",
|
|
);
|
|
});
|
|
|
|
test("Turn builder keeps group topic metadata out of prompt prefix", async () => {
|
|
const turn = await buildTelegramPromptTurnRuntime({
|
|
telegramPrefix: "[telegram]",
|
|
messages: [
|
|
{
|
|
message_id: 14,
|
|
message_thread_id: 42,
|
|
chat: { id: -1007, type: "supergroup" },
|
|
},
|
|
],
|
|
queueOrder: 1,
|
|
rawText: "hello",
|
|
files: [],
|
|
statusText: "hello",
|
|
inferImageMimeType: () => undefined,
|
|
});
|
|
assert.equal(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
"[telegram] hello",
|
|
);
|
|
});
|
|
|
|
test("Voice reply mode tags turn when voice file present and mode is mirror", async () => {
|
|
const turn = await buildTelegramPromptTurnRuntime({
|
|
telegramPrefix: "[telegram]",
|
|
messages: [{ message_id: 14, chat: { id: 5 } }],
|
|
queueOrder: 1,
|
|
rawText: "transcribed voice message",
|
|
files: [
|
|
{
|
|
path: "/tmp/voice-14.ogg",
|
|
fileName: "voice-14.ogg",
|
|
mimeType: "audio/ogg",
|
|
kind: "voice" as import("../lib/media.ts").TelegramAttachmentKind,
|
|
isImage: false,
|
|
},
|
|
],
|
|
statusText: "transcribed voice message",
|
|
inferImageMimeType: () => undefined,
|
|
voiceReplyMode: "mirror",
|
|
});
|
|
assert.equal(turn.voiceReplyPreferred, true);
|
|
assert.equal(turn.voiceReplyRequired, false);
|
|
assert.match(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
/\[voice\] reply mode: mirror/,
|
|
);
|
|
});
|
|
|
|
test("Voice reply mode tags turn with voice-required when mode is always", async () => {
|
|
const turn = await buildTelegramPromptTurnRuntime({
|
|
telegramPrefix: "[telegram]",
|
|
messages: [{ message_id: 15, chat: { id: 5 } }],
|
|
queueOrder: 1,
|
|
rawText: "hello",
|
|
files: [],
|
|
statusText: "hello",
|
|
inferImageMimeType: () => undefined,
|
|
voiceReplyMode: "always",
|
|
});
|
|
assert.equal(turn.voiceReplyPreferred, false);
|
|
assert.equal(turn.voiceReplyRequired, true);
|
|
assert.match(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
/\[voice\] reply mode: always/,
|
|
);
|
|
});
|
|
|
|
test("Voice reply mode mirror with no voice file stays on the manual text path", async () => {
|
|
const turn = await buildTelegramPromptTurnRuntime({
|
|
telegramPrefix: "[telegram]",
|
|
messages: [{ message_id: 17, chat: { id: 5 } }],
|
|
queueOrder: 1,
|
|
rawText: "hello",
|
|
statusText: "hello",
|
|
files: [],
|
|
inferImageMimeType: () => undefined,
|
|
voiceReplyMode: "mirror",
|
|
});
|
|
assert.equal(turn.voiceReplyPreferred, false);
|
|
assert.equal(turn.voiceReplyRequired, false);
|
|
assert.doesNotMatch(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
/\[voice\]/,
|
|
);
|
|
});
|
|
|
|
test("Voice reply mode always with voice file present sets voiceReplyRequired only", async () => {
|
|
const turn = await buildTelegramPromptTurnRuntime({
|
|
telegramPrefix: "[telegram]",
|
|
messages: [{ message_id: 18, chat: { id: 5 } }],
|
|
queueOrder: 1,
|
|
rawText: "transcribed voice",
|
|
statusText: "transcribed voice",
|
|
files: [
|
|
{
|
|
path: "/tmp/voice.ogg",
|
|
fileName: "voice.ogg",
|
|
mimeType: "audio/ogg",
|
|
kind: "voice" as import("../lib/media.ts").TelegramAttachmentKind,
|
|
isImage: false,
|
|
},
|
|
],
|
|
inferImageMimeType: () => undefined,
|
|
voiceReplyMode: "always",
|
|
});
|
|
assert.equal(turn.voiceReplyPreferred, false);
|
|
assert.equal(turn.voiceReplyRequired, true);
|
|
assert.match(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
/\[voice\] reply mode: always/,
|
|
);
|
|
});
|
|
|
|
test("Turn runtime tags manual mode without voice flags", async () => {
|
|
const turn = await buildTelegramPromptTurnRuntime({
|
|
telegramPrefix: "[telegram]",
|
|
messages: [{ message_id: 16, chat: { id: 5 } }],
|
|
queueOrder: 1,
|
|
rawText: "hello",
|
|
statusText: "hello",
|
|
files: [
|
|
{
|
|
path: "/tmp/voice.ogg",
|
|
fileName: "voice.ogg",
|
|
mimeType: "audio/ogg",
|
|
kind: "voice" as import("../lib/media.ts").TelegramAttachmentKind,
|
|
isImage: false,
|
|
},
|
|
],
|
|
inferImageMimeType: () => undefined,
|
|
voiceReplyMode: "manual",
|
|
});
|
|
assert.equal(turn.voiceReplyPreferred, false);
|
|
assert.equal(turn.voiceReplyRequired, false);
|
|
assert.match(
|
|
(turn.content[0] as { type: "text"; text: string }).text,
|
|
/\[voice\] reply mode: manual/,
|
|
);
|
|
});
|
|
|
|
test("Turn runtime helper reads image payloads from local files", async () => {
|
|
const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-turn-runtime-"));
|
|
const imagePath = join(tempDir, "image.png");
|
|
await writeFile(imagePath, Buffer.from("demo-image"));
|
|
const turn = await buildTelegramPromptTurnRuntime({
|
|
telegramPrefix: "[telegram]",
|
|
messages: [{ message_id: 11, chat: { id: 5 } }],
|
|
queueOrder: 1,
|
|
rawText: "see image",
|
|
files: [
|
|
{
|
|
path: imagePath,
|
|
fileName: "image.png",
|
|
isImage: true,
|
|
mimeType: "image/png",
|
|
},
|
|
],
|
|
inferImageMimeType: () => undefined,
|
|
});
|
|
assert.deepEqual(turn.content[1], {
|
|
type: "image",
|
|
data: Buffer.from("demo-image").toString("base64"),
|
|
mimeType: "image/png",
|
|
});
|
|
});
|
|
|
|
test("Turn helpers update matching queued prompt items only", () => {
|
|
const prompt = {
|
|
kind: "prompt" as const,
|
|
chatId: 99,
|
|
replyToMessageId: 10,
|
|
sourceMessageIds: [10],
|
|
queueOrder: 1,
|
|
queueLane: "default" as const,
|
|
laneOrder: 1,
|
|
queuedAttachments: [],
|
|
content: [{ type: "text" as const, text: "[telegram] old" }],
|
|
historyText: "old",
|
|
statusSummary: "old",
|
|
};
|
|
const control = {
|
|
kind: "control" as const,
|
|
controlType: "status" as const,
|
|
chatId: 99,
|
|
replyToMessageId: 11,
|
|
queueOrder: 2,
|
|
queueLane: "control" as const,
|
|
laneOrder: 2,
|
|
statusSummary: "status",
|
|
execute: async () => {},
|
|
};
|
|
const result = updateQueuedTelegramPromptTurnText({
|
|
items: [control, prompt],
|
|
sourceMessageId: 10,
|
|
telegramPrefix: "[telegram]",
|
|
rawText: "edited",
|
|
});
|
|
assert.equal(result.changed, true);
|
|
assert.equal(result.items[0], control);
|
|
assert.equal(
|
|
((result.items[1] as typeof prompt).content[0] as { text: string }).text,
|
|
"[telegram] edited",
|
|
);
|
|
const unchanged = updateQueuedTelegramPromptTurnText({
|
|
items: [control, prompt],
|
|
sourceMessageId: 12,
|
|
telegramPrefix: "[telegram]",
|
|
rawText: "ignored",
|
|
});
|
|
assert.equal(unchanged.changed, false);
|
|
assert.deepEqual(unchanged.items, [control, prompt]);
|
|
});
|
|
|
|
test("Turn edit runtime binds queued prompt updates to status", () => {
|
|
const events: string[] = [];
|
|
let items = [
|
|
{
|
|
kind: "prompt" as const,
|
|
chatId: 99,
|
|
replyToMessageId: 10,
|
|
sourceMessageIds: [10],
|
|
queueOrder: 1,
|
|
queueLane: "default" as const,
|
|
laneOrder: 1,
|
|
queuedAttachments: [],
|
|
content: [{ type: "text" as const, text: "[telegram] old" }],
|
|
historyText: "old",
|
|
statusSummary: "old",
|
|
},
|
|
];
|
|
const runtime = createTelegramQueuedPromptEditRuntime<
|
|
{ message_id: number; text?: string },
|
|
string
|
|
>({
|
|
getQueuedItems: () => items,
|
|
setQueuedItems: (nextItems) => {
|
|
items = nextItems as typeof items;
|
|
events.push(`items:${nextItems.length}`);
|
|
},
|
|
updateStatus: (ctx) => {
|
|
events.push(`status:${ctx}`);
|
|
},
|
|
});
|
|
assert.equal(
|
|
runtime.updateFromEditedMessage({ message_id: 10, text: "edited" }, "ctx"),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
(items[0]?.content[0] as { text: string }).text,
|
|
"[telegram] edited",
|
|
);
|
|
assert.deepEqual(events, ["items:1", "status:ctx"]);
|
|
});
|
|
|
|
test("Turn edit runtime keeps reply context prompt-only when queued messages change", () => {
|
|
let items = [
|
|
{
|
|
kind: "prompt" as const,
|
|
chatId: 99,
|
|
replyToMessageId: 10,
|
|
sourceMessageIds: [10],
|
|
queueOrder: 1,
|
|
queueLane: "default" as const,
|
|
laneOrder: 1,
|
|
queuedAttachments: [],
|
|
content: [{ type: "text" as const, text: "[telegram] old" }],
|
|
historyText: "old",
|
|
statusSummary: "old",
|
|
},
|
|
];
|
|
const runtime = createTelegramQueuedPromptEditRuntime<
|
|
{
|
|
message_id: number;
|
|
text?: string;
|
|
reply_to_message?: { text?: string };
|
|
},
|
|
string
|
|
>({
|
|
getQueuedItems: () => items,
|
|
setQueuedItems: (nextItems) => {
|
|
items = nextItems as typeof items;
|
|
},
|
|
updateStatus: () => {},
|
|
});
|
|
runtime.updateFromEditedMessage(
|
|
{
|
|
message_id: 10,
|
|
text: "edited",
|
|
reply_to_message: { text: "quoted" },
|
|
},
|
|
"ctx",
|
|
);
|
|
assert.equal(
|
|
(items[0]?.content[0] as { text: string }).text,
|
|
"[telegram] edited\n\n[reply] quoted",
|
|
);
|
|
assert.equal(items[0]?.historyText, "edited\n\n[reply] quoted");
|
|
assert.equal(items[0]?.statusSummary, "edited");
|
|
});
|
|
|
|
test("Turn helpers preserve queued prompt attachments when captions are edited", () => {
|
|
const turn = {
|
|
kind: "prompt" as const,
|
|
chatId: 99,
|
|
replyToMessageId: 10,
|
|
sourceMessageIds: [10],
|
|
queueOrder: 1,
|
|
queueLane: "default" as const,
|
|
laneOrder: 1,
|
|
queuedAttachments: [],
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text:
|
|
"[telegram] old caption\n\n" +
|
|
"[attachments] /tmp\n" +
|
|
"- /demo.png\n" +
|
|
"- /report.txt",
|
|
},
|
|
{ type: "image" as const, data: "abc", mimeType: "image/png" },
|
|
],
|
|
historyText:
|
|
"old caption\n\n[attachments] /tmp\n- /demo.png\n- /report.txt",
|
|
statusSummary: "old caption",
|
|
};
|
|
const updated = updateTelegramPromptTurnText({
|
|
turn,
|
|
telegramPrefix: "[telegram]",
|
|
rawText: "new caption",
|
|
});
|
|
assert.equal(
|
|
(updated.content[0] as { type: "text"; text: string }).text,
|
|
"[telegram] new caption\n\n" +
|
|
"[attachments] /tmp\n" +
|
|
"- /demo.png\n" +
|
|
"- /report.txt",
|
|
);
|
|
assert.deepEqual(updated.content[1], turn.content[1]);
|
|
assert.equal(
|
|
updated.historyText,
|
|
"new caption\n\n[attachments] /tmp\n- /demo.png\n- /report.txt",
|
|
);
|
|
assert.equal(updated.statusSummary, "new caption");
|
|
});
|
|
|
|
test("Turn helpers preserve abort-history prompt context when queued turns are edited", () => {
|
|
const turn = {
|
|
kind: "prompt" as const,
|
|
chatId: 99,
|
|
replyToMessageId: 10,
|
|
sourceMessageIds: [10],
|
|
queueOrder: 1,
|
|
queueLane: "default" as const,
|
|
laneOrder: 1,
|
|
queuedAttachments: [],
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text:
|
|
"[telegram]\n\n" +
|
|
"Earlier Telegram messages arrived after an aborted turn. " +
|
|
"Treat them as prior user messages, in order:\n\n" +
|
|
"1. older Current Telegram message: quote\n\n" +
|
|
"Current Telegram message:\nold current",
|
|
},
|
|
],
|
|
historyText: "old current",
|
|
statusSummary: "old current",
|
|
};
|
|
const updated = updateTelegramPromptTurnText({
|
|
turn,
|
|
telegramPrefix: "[telegram]",
|
|
rawText: "new current",
|
|
});
|
|
assert.equal(
|
|
(updated.content[0] as { type: "text"; text: string }).text,
|
|
"[telegram]\n\n" +
|
|
"Earlier Telegram messages arrived after an aborted turn. " +
|
|
"Treat them as prior user messages, in order:\n\n" +
|
|
"1. older Current Telegram message: quote\n\n" +
|
|
"Current Telegram message:\nnew current",
|
|
);
|
|
assert.equal(updated.historyText, "new current");
|
|
assert.equal(updated.statusSummary, "new current");
|
|
});
|
|
|
|
test("Turn edit preserves voice reply tags", () => {
|
|
const turn = {
|
|
kind: "prompt" as const,
|
|
chatId: 1,
|
|
replyToMessageId: 10,
|
|
sourceMessageIds: [10],
|
|
queueOrder: 1,
|
|
queueLane: "default" as const,
|
|
laneOrder: 1,
|
|
queuedAttachments: [],
|
|
content: [{ type: "text" as const, text: "hello" }],
|
|
historyText: "hello",
|
|
statusSummary: "hello",
|
|
voiceReplyPreferred: true,
|
|
voiceReplyRequired: false,
|
|
};
|
|
const updated = updateTelegramPromptTurnText({
|
|
turn,
|
|
telegramPrefix: "[telegram]",
|
|
rawText: "edited",
|
|
});
|
|
assert.equal(updated.voiceReplyPreferred, true);
|
|
assert.equal(updated.voiceReplyRequired, false);
|
|
});
|
|
|
|
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\n\n[attachments] /tmp\n- /demo.png\n- /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",
|
|
});
|
|
});
|
|
|
|
test("getTelegramVoiceReplyMode returns default when no config provided", () => {
|
|
assert.equal(getTelegramVoiceReplyMode(), "manual");
|
|
assert.equal(getTelegramVoiceReplyMode(undefined), "manual");
|
|
});
|
|
|
|
test("getTelegramVoiceReplyMode reads frozen config with valid replyMode", () => {
|
|
const frozenConfig = Object.freeze({
|
|
voice: { replyMode: "always" as const },
|
|
});
|
|
assert.equal(getTelegramVoiceReplyMode(frozenConfig), "always");
|
|
});
|
|
|
|
test("getTelegramVoiceReplyMode ignores frozen config with invalid replyMode", () => {
|
|
const frozenConfig = Object.freeze({
|
|
voice: { replyMode: "bad-mode" as any },
|
|
});
|
|
assert.equal(getTelegramVoiceReplyMode(frozenConfig), "manual");
|
|
});
|
|
|
|
test("getTelegramVoiceReplyMode reads config voice.replyMode", () => {
|
|
assert.equal(
|
|
getTelegramVoiceReplyMode({ voice: { replyMode: "mirror" } }),
|
|
"mirror",
|
|
);
|
|
assert.equal(
|
|
getTelegramVoiceReplyMode({ voice: { replyMode: "always" } }),
|
|
"always",
|
|
);
|
|
assert.equal(
|
|
getTelegramVoiceReplyMode({ voice: { replyMode: "manual" } }),
|
|
"manual",
|
|
);
|
|
});
|
|
|
|
test("getTelegramVoiceReplyMode falls back to manual for invalid or missing config", () => {
|
|
assert.equal(
|
|
getTelegramVoiceReplyMode({ voice: { replyMode: "invalid" as any } }),
|
|
"manual",
|
|
);
|
|
assert.equal(getTelegramVoiceReplyMode({ voice: {} }), "manual");
|
|
assert.equal(getTelegramVoiceReplyMode({}), "manual");
|
|
assert.equal(getTelegramVoiceReplyMode(null as any), "manual");
|
|
assert.equal(getTelegramVoiceReplyMode(undefined), "manual");
|
|
});
|