Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Telegram in real time. One paired DM owner controls the bridge; optional group
chat access remains separately configured from the terminal, never by the model.

- **Inbound:** DMs / group mentions → injected as `<telegram-message …>` user turns (photos attached inline; other files downloaded to an inbox).
- **Outbound:** assistant output streams live — native message **drafts** for DMs (Bot API 9.3+), **edited-message** previews for groups — then one finalized MarkdownV2 message per turn. A headless host can turn all of that off with `set profile daemon`, leaving `telegram_send` / `telegram_ask` as the only way out.
- **Outbound:** assistant output streams live through native message drafts for DMs (Bot API 9.3+) and edited-message previews for groups. Each turn ends with a MarkdownV2 message by default, or optional rich Markdown on Bot API 10.1+. A headless host can turn automatic output off with `set profile daemon`, leaving `telegram_send` / `telegram_ask` as the only way out.
- **Control:** local `/telegram` configuration, owner-only Telegram commands (`/spawn`, `/sessions`, `/cleanup`, `/stop`, `/status`), and three model tools (`telegram_send`, `telegram_react`, `telegram_ask`).
- **Zero runtime dependencies** — the raw Bot API over Bun's `fetch`/`FormData`.

Expand Down Expand Up @@ -185,10 +185,11 @@ configured groups never receive process-spawning authority.
| Key | Values | Default |
|---|---|---|
| `streaming` | `true` (live preview) \| `false` (per turn) \| `final` (last message only) \| `explicit` (nothing automatic) | `true` |
| `richMessages` | `off` (MarkdownV2) \| `auto` (rich constructs) \| `on` (prefer rich Markdown) | `off` |
| `profile` | `daemon` (headless host: forces `explicit`, no idle notify post, `telegram_ask` always on) \| `default` | `default` |
| `deliverAs` | `steer` \| `followUp` — how inbound queues while the agent is busy | `followUp` |
| `chunkMode` | `length` \| `newline` | `newline` |
| `textChunkLimit` | `1`–`4096` | `4096` |
| `textChunkLimit` | `1`–`4096`; also caps rich source when explicitly set | unset (4096 legacy; 32768 whole rich) |
| `replyToMode` | `off` \| `first` \| `all` — threading for `telegram_send` replies | `first` |
| `ackReaction` | a whitelist emoji (empty to disable) — reaction on receipt | unset |
| `mentionPatterns` | JSON array of regexes that also satisfy group mention-gating, e.g. `["\\bassistant\\b"]` | unset |
Expand Down Expand Up @@ -261,8 +262,9 @@ count as a mention.
## Model tools

- **`telegram_send`** — send text and/or files to the active or durably claimed
chat (or a given `chat_id`). Text is chunked and rendered as MarkdownV2
(plain-text fallback on parse errors). `files` are absolute paths: images send
chat (or a given `chat_id`). Markdown follows `richMessages`, with MarkdownV2
as the default and plain-text fallback on parse errors. `format: "text"` stays
literal in every mode. `files` are absolute paths: images send
as photos, everything else as documents (≤ 50 MB each).
- **`telegram_react`** — react to a message with a Telegram whitelist emoji
(👍 👎 ❤ 🔥 👀 🎉 …).
Expand Down Expand Up @@ -370,12 +372,28 @@ transcript.
task's closing line and not an answer to anyone. A missing reply is visible to
the person waiting and can be asked again; a leaked internal turn cannot be
recalled. Ask for an answer, not a transcript.
- Any reply too long for one Telegram message (4096 chars, or `textChunkLimit`)
is split at a paragraph, line, or word boundary (`chunkMode`) and each part is
prefixed `(i/n)`, so it arrives complete and in order rather than cut off.
Code fences are closed and reopened across the split. This covers assistant
answers and command output alike — a long `/sessions` listing or `/cleanup`
preview splits too, with the keyboard on the final part.
- Legacy messages split at 4096 characters (or `textChunkLimit`), with room
reserved for formatting and labels. Splits prefer paragraph, line, or word
boundaries (`chunkMode`). Each part carries `(i/n)`; code fences close and
reopen across splits. Command output keeps this limit, with keyboards on the
final part.
- `set richMessages auto` selects rich Markdown for tables, task lists,
`<details>`, paired `$$` math, and `<tg-emoji>` outside code. Headings alone
don't select it. `on` prefers rich Markdown for all Markdown output; `off`
keeps MarkdownV2. Rich messages require Bot API 10.1+.
- Rich delivery sends the original Markdown, including Telegram's native task
syntax. Telegram controls rendering and checkbox interaction. This setting
adds no checklist-management commands or state store.
- Live drafts and edit previews stay unchanged. Final messages and final
preview edits use the selected format. An answer with no permanent preview or
committed prefix can arrive as one rich message up to 32768 UTF-16 source
units when `textChunkLimit` is unset. An explicit cap remains authoritative.
Longer answers and existing previews keep legacy chunk boundaries.
- A definitive rich rejection (400 or unsupported-method 404) falls back to
MarkdownV2, then plain text on a parse rejection. A rejected whole answer is
split again to fit legacy limits. Network failures, timeouts, server errors,
authorization failures, and exhausted rate limits don't trigger another-format
send. Constructs split across parts may lose formatting.
- A part Telegram rate-limits (`429`) is retried up to three times, honouring
`retry_after`, instead of dropping the rest of the answer.

Expand Down
14 changes: 10 additions & 4 deletions src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export type Access = {
ackReaction?: string;
/** Which chunks carry Telegram's reply reference. Default "first". */
replyToMode?: "off" | "first" | "all";
/** Max chars per outbound message before splitting. Default 4096, clamp 1..4096. */
/** Explicit source cap, clamped to 1..4096. Unset: 4096 legacy, 32768 whole rich. */
textChunkLimit?: number;
/** Split strategy. Default "newline". */
chunkMode?: "length" | "newline";
Expand All @@ -60,6 +60,8 @@ export type Access = {
* recalled.
*/
streaming?: boolean | "final" | "explicit";
/** Final Markdown formatting. Missing = off (MarkdownV2). */
richMessages?: "auto" | "on" | "off";
/**
* Headless-host contract, set via `/telegram set profile daemon`. Absent =
* `default` (an interactive laptop session).
Expand Down Expand Up @@ -92,9 +94,8 @@ export type Access = {
};

/**
* Per-message character budget for this config, clamped to Telegram's cap.
* Every outbound text path splits against this, so a long reply is never
* rejected whole or silently cut.
* Legacy per-message budget, also used for previews and rich fallback chunks.
* Whole rich messages may use a larger budget only when no explicit cap is set.
*/
export function messageLimit(access: Access): number {
return Math.max(1, Math.min(access.textChunkLimit ?? TELEGRAM_MAX_CHARS, TELEGRAM_MAX_CHARS));
Expand Down Expand Up @@ -272,6 +273,10 @@ export function loadAccess(warn?: (msg: string) => void): Access {
? parsed.streaming
: undefined;
const profile: Access["profile"] = parsed.profile === "daemon" ? "daemon" : undefined;
const richMessages: Access["richMessages"] =
parsed.richMessages === "auto" || parsed.richMessages === "on" || parsed.richMessages === "off"
? parsed.richMessages
: undefined;
return {
enabled: parsed.enabled ?? false,
dmPolicy: parsed.dmPolicy ?? "pairing",
Expand All @@ -285,6 +290,7 @@ export function loadAccess(warn?: (msg: string) => void): Access {
chunkMode: parsed.chunkMode,
deliverAs: parsed.deliverAs,
streaming,
richMessages,
profile,
transcribeCommand: Array.isArray(parsed.transcribeCommand) && parsed.transcribeCommand.every((arg) => typeof arg === "string")
? parsed.transcribeCommand
Expand Down
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ const TELEGRAM_ARGS: CompletionNode = {
mentionPatterns: null,
deliverAs: { steer: null, followUp: null },
streaming: { true: null, false: null, final: null, explicit: null },
richMessages: { auto: null, on: null, off: null },
profile: { daemon: null, default: null },
transcribeCommand: null,
},
Expand Down Expand Up @@ -192,6 +193,7 @@ const SET_KEY_HELP: Record<string, string> = {
mentionPatterns: "JSON array of mention regexes",
deliverAs: "steer | followUp delivery",
streaming: "output: true (stream) | false (per-turn) | final (one message) | explicit (tool calls only)",
richMessages: "formatting: auto (rich constructs) | on (prefer rich) | off (MarkdownV2)",
profile: "daemon (headless: explicit output + always-on telegram_ask) | default",
transcribeCommand: "JSON argv for voice transcription",
};
Expand Down Expand Up @@ -1493,6 +1495,7 @@ export default function telegramExtension(pi: ExtensionAPI): void {
// the streaming value is overridden, and "off" would have been read as
// "silent" when it only ever meant "no live preview".
`Streaming: ${STREAMING_LABEL[String(effectiveStreaming(a))]} · profile: ${a.profile ?? "default"} · deliverAs: ${a.deliverAs ?? "followUp"} · chunkMode: ${a.chunkMode ?? "newline"} · replyTo: ${a.replyToMode ?? "first"}`,
`Rich messages: ${a.richMessages ?? "off"}`,
`Notify: ${a.notifyMode ?? "off"}${a.notifyChat ? ` · chat ${a.notifyChat}` : ""}`,
`Voice transcription: ${a.transcribeCommand?.length ? a.transcribeCommand.join(" ") : "off"}`,
`Control topic: ${a.controlThreadId != null ? `#${a.controlThreadId}` : "not attached"}`,
Expand Down Expand Up @@ -1834,6 +1837,9 @@ export default function telegramExtension(pi: ExtensionAPI): void {
return ctx.ui.notify("streaming: true | false | final | explicit", "warning");
}
a.streaming = value === "final" || value === "explicit" ? value : value === "true";
} else if (key === "richMessages") {
if (value !== "auto" && value !== "on" && value !== "off") return ctx.ui.notify("richMessages: auto | on | off", "warning");
a.richMessages = value;
} else if (key === "profile") {
if (value !== "daemon" && value !== "default") return ctx.ui.notify("profile: daemon | default", "warning");
a.profile = value === "daemon" ? "daemon" : undefined;
Expand All @@ -1850,7 +1856,7 @@ export default function telegramExtension(pi: ExtensionAPI): void {
}
}
} else {
return ctx.ui.notify(`set: unknown key "${key}". Keys: ackReaction, replyToMode, textChunkLimit, chunkMode, mentionPatterns, deliverAs, streaming, profile, transcribeCommand`, "warning");
return ctx.ui.notify(`set: unknown key "${key}". Keys: ackReaction, replyToMode, textChunkLimit, chunkMode, mentionPatterns, deliverAs, streaming, richMessages, profile, transcribeCommand`, "warning");
}
saveAccess(a);
access = a;
Expand Down
55 changes: 55 additions & 0 deletions src/index.wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,61 @@ async function startBridge(h: Harness): Promise<void> {
}

describe("extension wiring", () => {
test("rich formatting survives command reload, rejects invalid input, and respects literal/file sends", async () => {
writeAccess({ enabled: true, allowFrom: ["42"], profile: "daemon" });
const first = harness(["read"]);
const ctx = { ui: { notify() {} } };
await first.commands.get("telegram")!.handler("set richMessages auto", ctx);
await first.commands.get("telegram")!.handler("set richMessages AUTO", ctx);
expect(loadAccess().richMessages).toBe("auto");

const h = harness(["read"]);
const calls: Array<{ method: string; payload?: Record<string, unknown> }> = [];
const filesDir = mkdtempSync(join(tmpdir(), "omp-tg-rich-files-"));
const previousFetch = globalThis.fetch;
globalThis.fetch = (async (input, init) => {
const method = String(input).split("/").pop()!;
calls.push({ method, payload: typeof init?.body === "string" ? JSON.parse(init.body) : undefined });
return new Response(JSON.stringify({ ok: true, result: { message_id: calls.length } }));
}) as typeof fetch;
try {
await startBridge(h);
const source = "| Name | State |\n| --- | --- |\n| build | ready |";
calls.length = 0;
const tool = h.tools.get("telegram_send")!;
await tool.execute("rich", { chat_id: "42", text: source }, undefined, undefined, {});
expect(calls).toEqual([{ method: "sendRichMessage", payload: { chat_id: "42", rich_message: { markdown: source } } }]);
await h.commands.get("telegram")!.handler("set richMessages on", ctx);
calls.length = 0;
await tool.execute("literal", { chat_id: "42", text: source, format: "text" }, undefined, undefined, {});
expect(calls).toEqual([{ method: "sendMessage", payload: { chat_id: "42", text: source } }]);

writeFileSync(join(dir, "access.json"), JSON.stringify({ ...loadAccess(), richMessages: "corrupt" }));
await h.commands.get("telegram")!.handler("status", ctx); // Reload hand-edited policy.
calls.length = 0;
await tool.execute("corrupt", { chat_id: "42", text: source }, undefined, undefined, {});
expect(calls[0].method).toBe("sendMessage");
expect(calls[0].payload?.parse_mode).toBe("MarkdownV2");

const file = join(filesDir, "report.txt");
writeFileSync(file, "report");
calls.length = 0;
const result = await tool.execute("file", { chat_id: "42", text: "", files: [file] }, undefined, undefined, {});
expect(result.isError).toBeUndefined();
expect(calls.map((c) => c.method)).toEqual(["sendDocument"]);
calls.length = 0;
const denied = await tool.execute("denied", { chat_id: "43", text: source }, undefined, undefined, {});
expect(denied.isError).toBe(true);
expect(calls).toEqual([]);
} finally {
rmSync(filesDir, { recursive: true, force: true });
await h.handlers.get("session_shutdown")?.[0]?.({ type: "session_shutdown" }, {
sessionManager: { getSessionId: () => "session-1", getSessionFile: () => "/tmp/session-1.jsonl" },
});
globalThis.fetch = previousFetch;
}
});

test("registers telegram_ask and the /away command", () => {
const h = harness(["ask", "read"]);
expect(h.tools.has("telegram_ask")).toBe(true);
Expand Down
31 changes: 30 additions & 1 deletion src/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,41 @@
import { test, expect, describe } from "bun:test";
import { escapeMdV2, mdToMarkdownV2, chunk, chunkLabeled, PART_LABEL_RESERVE, TELEGRAM_MAX_CHARS, MARKDOWN_HEADROOM } from "./markdown";
import { escapeMdV2, mdToMarkdownV2, chunk, chunkLabeled, PART_LABEL_RESERVE, TELEGRAM_MAX_CHARS, MARKDOWN_HEADROOM, hasRichConstructs } from "./markdown";

// The exact MarkdownV2 special set that escapeMdV2 must prefix with a backslash.
const SPECIALS = ["_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!", "\\"];

/** Count non-overlapping triple-backtick sequences — the fence-balance measure chunk() uses. */
const countFences = (s: string): number => (s.match(/```/g) ?? []).length;

describe("hasRichConstructs", () => {
test.each([
"| Name | State |\n| --- | :---: |\n| build | ready |",
"Name | State\n--- | ---:",
"| escaped \\| pipe | state |\n| --- | --- |",
"- [ ] Pending", "+ [x] Done", "* [X]",
"<details open><summary>More</summary></details>",
"<tg-emoji emoji-id=\"123\">star</tg-emoji>",
"$$E = mc^2$$", "$$\na + b\n$$",
"````\n```\n````\n- [x] outside",
"prefix `unmatched\n- [ ] outside",
])("detects rich syntax: %s", (source) => expect(hasRichConstructs(source)).toBe(true));

test.each([
"## Heading\n**ordinary bold**", "---", "Name\n---",
"| A | B |\n| --- |", "| A |\n| -- |",
"- [x]word", "\\- [x] literal", "- \\[x] literal",
"\\<details>", "\\<tg-emoji>", "\\$\\$math\\$\\$",
" - [x] indented", "\t<details>", " \t<details>",
"```md\n- [x] code\n```", "~~~\n<details>\n~~~",
"````\n```\n- [x] still code\n````",
"~~~\n```\n<tg-emoji>\n", "```\n- [x] unclosed",
"`<details>`", "`` ` <details> ``",
"`multiline\n<details>\nspan`", "$$$$", "$$ \n $$",
"| a \\| b |\n| --- | --- |",
"`| a | b |`\n| --- | --- |",
])("ignores literal or malformed syntax: %s", (source) => expect(hasRichConstructs(source)).toBe(false));
});

describe("escapeMdV2", () => {
test("escapes every MarkdownV2 special character with a backslash", () => {
for (const c of SPECIALS) {
Expand Down
60 changes: 60 additions & 0 deletions src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,69 @@

/** Telegram's hard per-message character cap (UTF-16 units). */
export const TELEGRAM_MAX_CHARS = 4096;
/** Conservative raw-source budget for Telegram rich messages. */
export const TELEGRAM_RICH_MAX_CHARS = 32768;
/** Headroom to reserve when a chunk will be MarkdownV2-escaped (escaping grows text). */
export const MARKDOWN_HEADROOM = 96;

/** Detect rich-only constructs without rewriting the source sent to Telegram. */
export function hasRichConstructs(markdown: string): boolean {
let fenceChar = "";
let fenceLength = 0;
const visible: string[] = [];
for (const line of markdown.split("\n")) {
const fence = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
if (fenceLength) {
if (fence && fence[1][0] === fenceChar && fence[1].length >= fenceLength && !fence[2].trim()) fenceLength = 0;
visible.push("");
continue;
}
if (fence && !(fence[1][0] === "`" && fence[2].includes("`"))) {
fenceChar = fence[1][0];
fenceLength = fence[1].length;
visible.push("");
continue;
}
// Mask escaped punctuation, preserving cell content but not its syntax.
visible.push(/^( {4}| {0,3}\t)/.test(line) ? "" : line.replace(/\\[!-/:-@[-`{-~]/g, "\0"));
}

const source = visible.join("\n");
const ticks = [...source.matchAll(/`+/g)];
const next = new Map<number, number>();
const closes: Array<number | undefined> = new Array(ticks.length);
for (let i = ticks.length - 1; i >= 0; i--) {
closes[i] = next.get(ticks[i][0].length);
next.set(ticks[i][0].length, i);
}
const fragments: string[] = [];
let offset = 0;
for (let i = 0; i < ticks.length; i++) {
const close = closes[i];
if (close === undefined) continue; // unmatched backticks are literal
const start = ticks[i].index;
const end = ticks[close].index + ticks[close][0].length;
fragments.push(source.slice(offset, start), source.slice(start, end).replace(/[^\n]/g, "\0"));
offset = end;
i = close;
}
fragments.push(source.slice(offset));
const text = fragments.join("");
if (/<(?:details|tg-emoji)(?:\s[^>]*|)>/i.test(text)) return true;
for (const match of text.matchAll(/\$\$((?:(?!\$\$)[\s\S])+)\$\$/g)) {
if (match[1].replace(/\0/g, "").trim()) return true;
}
let header: string[] | undefined;
for (const line of text.split("\n")) {
if (/^ {0,3}[-+*]\s+\[[ xX]\](?:\s|$)/.test(line)) return true;
const trimmed = line.trim();
const cells = trimmed.includes("|") ? trimmed.replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim()) : undefined;
if (header && cells && cells.length === header.length && cells.every((cell) => /^:?-{3,}:?$/.test(cell))) return true;
header = cells;
}
return false;
}

/** Escape every MarkdownV2 special character with a backslash. */
export function escapeMdV2(s: string): string {
return s.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, "\\$&");
Expand Down
11 changes: 11 additions & 0 deletions src/notify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ describe("loadAccess field preservation", () => {
expect(loadAccess().notifyMode).toBeUndefined();
});

test("reloads rich formatting modes and discards invalid saved values", () => {
for (const richMessages of ["auto", "on", "off"] as const) {
saveAccess({ ...defaultAccess(), richMessages });
expect(loadAccess().richMessages).toBe(richMessages);
}
for (const richMessages of ["ON", true, 1, null]) {
writeFileSync(join(dir, "access.json"), JSON.stringify({ ...defaultAccess(), richMessages }));
expect(loadAccess().richMessages ?? "off").toBe("off");
}
});

test("round-trips the streaming mode and daemon profile", () => {
saveAccess({ ...defaultAccess(), streaming: "explicit", profile: "daemon" });
const a = loadAccess();
Expand Down
Loading
Loading