pi-telebridge 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # pi-telebridge
2
+
3
+ A [pi](https://github.com/badlogic/pi-mono) extension that creates a two-way relay between your active pi coding agent session and a Telegram bot. Enable it per-session with `/telegram`, then interact with your session from your phone.
4
+
5
+ - **Agent → Phone**: Every final assistant response is forwarded to your Telegram chat
6
+ - **Phone → Agent**: Your Telegram replies are injected as user messages into the session
7
+
8
+ Both the pi TUI and Telegram inputs coexist — you can use either at any time.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pi install npm:pi-telebridge
14
+ ```
15
+
16
+ ## Setup
17
+
18
+ 1. Create a bot with [@BotFather](https://t.me/BotFather) on Telegram and copy the token
19
+ 2. In pi, run `/telegram setup`
20
+ 3. Enter your bot token when prompted
21
+ 4. Send any message to your bot on Telegram — this links your chat ID
22
+ 5. Done! Config is saved to `~/.pi/agent/telebridge.json`
23
+
24
+ ### Environment Variables (optional)
25
+
26
+ You can skip the interactive setup by setting these beforehand:
27
+
28
+ | Variable | Purpose |
29
+ |----------|---------|
30
+ | `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather |
31
+ | `TELEGRAM_CHAT_ID` | Your Telegram chat ID |
32
+
33
+ ## Usage
34
+
35
+ | Command | Description |
36
+ |---------|-------------|
37
+ | `/telegram` | Toggle relay on/off for this session |
38
+ | `/telegram setup` | Guided setup: enter bot token, discover chat ID |
39
+ | `/telegram status` | Show connection state, chat ID, relay status |
40
+
41
+ When the relay is enabled:
42
+ - A **📡 TG** indicator appears in the footer
43
+ - Every assistant response is forwarded to your Telegram chat
44
+ - Messages you send to the bot are injected into the pi session
45
+ - If the agent is idle, your message starts a new turn; if busy, it's queued as a follow-up
46
+
47
+ ## Security
48
+
49
+ - Only messages from your configured `chat_id` are accepted
50
+ - All other Telegram messages are silently ignored
51
+ - Bot token and chat ID are stored locally in `~/.pi/agent/telebridge.json`
52
+
53
+ ## License
54
+
55
+ MIT
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-telebridge",
3
+ "version": "1.0.0",
4
+ "description": "A pi extension that creates a two-way relay between your active pi coding agent session and a Telegram bot.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi",
10
+ "telegram",
11
+ "telegram-bot"
12
+ ],
13
+ "author": "acarerdinc",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/acarerdinc/pi-telebridge.git"
18
+ },
19
+ "homepage": "https://github.com/acarerdinc/pi-telebridge#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/acarerdinc/pi-telebridge/issues"
22
+ },
23
+ "pi": {
24
+ "extensions": ["./src/index.ts"]
25
+ },
26
+ "scripts": {
27
+ "clean": "echo 'nothing to clean'",
28
+ "build": "echo 'nothing to build'",
29
+ "check": "echo 'nothing to check'"
30
+ },
31
+ "dependencies": {
32
+ "grammy": "^1.35.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@mariozechner/pi-coding-agent": "*"
36
+ }
37
+ }
package/src/bot.ts ADDED
@@ -0,0 +1,141 @@
1
+ import { Bot } from "grammy";
2
+
3
+ let botInstance: Bot | null = null;
4
+ let currentToken: string | null = null;
5
+
6
+ export type IncomingMessageHandler = (chatId: number, text: string) => void;
7
+
8
+ let onIncomingMessage: IncomingMessageHandler | null = null;
9
+ let allowedChatId: number | null = null;
10
+ let chatIdDiscoveryResolve: ((chatId: number) => void) | null = null;
11
+
12
+ /**
13
+ * Get or create the grammy Bot singleton.
14
+ * If token changes, the old bot is stopped and a new one created.
15
+ */
16
+ export async function startBot(token: string): Promise<Bot> {
17
+ if (botInstance && currentToken === token) {
18
+ return botInstance;
19
+ }
20
+
21
+ // Stop old bot if token changed
22
+ if (botInstance) {
23
+ await stopBot();
24
+ }
25
+
26
+ const bot = new Bot(token);
27
+
28
+ bot.on("message:text", (ctx) => {
29
+ const chatId = ctx.chat.id;
30
+ const text = ctx.message.text;
31
+
32
+ // Chat ID discovery mode
33
+ if (chatIdDiscoveryResolve) {
34
+ chatIdDiscoveryResolve(chatId);
35
+ chatIdDiscoveryResolve = null;
36
+ return;
37
+ }
38
+
39
+ // Security: only accept messages from allowed chat
40
+ if (allowedChatId !== null && chatId !== allowedChatId) {
41
+ return;
42
+ }
43
+
44
+ // Forward to handler
45
+ if (onIncomingMessage) {
46
+ onIncomingMessage(chatId, text);
47
+ }
48
+ });
49
+
50
+ // Catch errors so they don't crash the process
51
+ bot.catch((err) => {
52
+ console.error("[telebridge] Bot error:", err.message);
53
+ });
54
+
55
+ // Start long polling (non-blocking)
56
+ bot.start({
57
+ onStart: () => {
58
+ // Bot is polling
59
+ },
60
+ });
61
+
62
+ botInstance = bot;
63
+ currentToken = token;
64
+
65
+ return bot;
66
+ }
67
+
68
+ export async function stopBot(): Promise<void> {
69
+ if (botInstance) {
70
+ try {
71
+ await botInstance.stop();
72
+ } catch {
73
+ // Ignore errors during shutdown
74
+ }
75
+ botInstance = null;
76
+ currentToken = null;
77
+ }
78
+ }
79
+
80
+ export function getBot(): Bot | null {
81
+ return botInstance;
82
+ }
83
+
84
+ export function setAllowedChatId(chatId: number | null): void {
85
+ allowedChatId = chatId;
86
+ }
87
+
88
+ export function setIncomingMessageHandler(handler: IncomingMessageHandler | null): void {
89
+ onIncomingMessage = handler;
90
+ }
91
+
92
+ /**
93
+ * Wait for the first message to arrive from any chat.
94
+ * Used during setup to discover the user's chat ID.
95
+ */
96
+ export function waitForChatId(): Promise<number> {
97
+ return new Promise<number>((resolve) => {
98
+ chatIdDiscoveryResolve = resolve;
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Send a text message. Falls back silently on error.
104
+ */
105
+ export async function sendText(chatId: number, text: string, parseMode?: "HTML"): Promise<void> {
106
+ if (!botInstance) return;
107
+ try {
108
+ await botInstance.api.sendMessage(chatId, text, {
109
+ parse_mode: parseMode,
110
+ });
111
+ } catch (err: any) {
112
+ console.error("[telebridge] Send error:", err.message);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Send a photo. Falls back silently on error.
118
+ */
119
+ export async function sendPhoto(chatId: number, url: string, caption?: string): Promise<void> {
120
+ if (!botInstance) return;
121
+ try {
122
+ await botInstance.api.sendPhoto(chatId, url, {
123
+ caption,
124
+ parse_mode: "HTML",
125
+ });
126
+ } catch (err: any) {
127
+ console.error("[telebridge] Photo send error:", err.message);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Send a typing indicator.
133
+ */
134
+ export async function sendTyping(chatId: number): Promise<void> {
135
+ if (!botInstance) return;
136
+ try {
137
+ await botInstance.api.sendChatAction(chatId, "typing");
138
+ } catch {
139
+ // Ignore
140
+ }
141
+ }
package/src/config.ts ADDED
@@ -0,0 +1,55 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+
5
+ export interface TelebridgeConfig {
6
+ botToken: string;
7
+ chatId: number | null;
8
+ }
9
+
10
+ const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent");
11
+ const CONFIG_FILE = path.join(CONFIG_DIR, "telebridge.json");
12
+
13
+ export function loadConfig(): TelebridgeConfig | null {
14
+ try {
15
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
16
+ const data = JSON.parse(raw);
17
+ if (typeof data.botToken === "string") {
18
+ return {
19
+ botToken: data.botToken,
20
+ chatId: typeof data.chatId === "number" ? data.chatId : null,
21
+ };
22
+ }
23
+ return null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export function saveConfig(config: TelebridgeConfig): void {
30
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
31
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
32
+ }
33
+
34
+ export function resolveToken(): string | null {
35
+ // 1. Environment variable takes priority
36
+ const envToken = process.env.TELEGRAM_BOT_TOKEN;
37
+ if (envToken) return envToken;
38
+
39
+ // 2. Fall back to config file
40
+ const config = loadConfig();
41
+ return config?.botToken ?? null;
42
+ }
43
+
44
+ export function resolveChatId(): number | null {
45
+ // 1. Environment variable takes priority
46
+ const envChatId = process.env.TELEGRAM_CHAT_ID;
47
+ if (envChatId) {
48
+ const parsed = parseInt(envChatId, 10);
49
+ if (!isNaN(parsed)) return parsed;
50
+ }
51
+
52
+ // 2. Fall back to config file
53
+ const config = loadConfig();
54
+ return config?.chatId ?? null;
55
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Converts markdown text to Telegram-compatible HTML.
3
+ *
4
+ * Handles:
5
+ * - Fenced code blocks → <pre><code>...</code></pre>
6
+ * - Inline code → <code>...</code>
7
+ * - Bold **text** → <b>text</b>
8
+ * - Italic *text* → <i>text</i>
9
+ * - Strikethrough ~~text~~ → <s>text</s>
10
+ * - Links [text](url) → <a href="url">text</a>
11
+ * - HTML entity escaping inside code
12
+ *
13
+ * Telegram limit: 4096 characters per message.
14
+ */
15
+
16
+ const TELEGRAM_MAX_LENGTH = 4096;
17
+ const TRUNCATION_NOTICE = "\n\n<i>[truncated — see pi terminal]</i>";
18
+
19
+ function escapeHtml(text: string): string {
20
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
21
+ }
22
+
23
+ export function markdownToTelegramHtml(md: string): string {
24
+ let html = "";
25
+ const lines = md.split("\n");
26
+ let inCodeBlock = false;
27
+ let codeLang = "";
28
+ let codeContent = "";
29
+
30
+ for (let i = 0; i < lines.length; i++) {
31
+ const line = lines[i];
32
+
33
+ // Fenced code block start/end
34
+ if (line.trimStart().startsWith("```")) {
35
+ if (!inCodeBlock) {
36
+ inCodeBlock = true;
37
+ codeLang = line.trimStart().slice(3).trim();
38
+ codeContent = "";
39
+ } else {
40
+ // Close code block
41
+ inCodeBlock = false;
42
+ if (codeLang) {
43
+ html += `<pre><code class="language-${escapeHtml(codeLang)}">`;
44
+ } else {
45
+ html += "<pre><code>";
46
+ }
47
+ html += escapeHtml(codeContent);
48
+ html += "</code></pre>\n";
49
+ codeLang = "";
50
+ }
51
+ continue;
52
+ }
53
+
54
+ if (inCodeBlock) {
55
+ if (codeContent) codeContent += "\n";
56
+ codeContent += line;
57
+ continue;
58
+ }
59
+
60
+ // Normal line — convert inline markdown
61
+ html += convertInlineMarkdown(line) + "\n";
62
+ }
63
+
64
+ // If code block was never closed, dump it anyway
65
+ if (inCodeBlock) {
66
+ html += "<pre><code>";
67
+ html += escapeHtml(codeContent);
68
+ html += "</code></pre>\n";
69
+ }
70
+
71
+ return html.trimEnd();
72
+ }
73
+
74
+ function convertInlineMarkdown(line: string): string {
75
+ // Escape HTML first in non-code parts
76
+ // We need to be careful: process inline code first, then escape the rest
77
+
78
+ // Extract inline code spans to protect them
79
+ const codeSpans: string[] = [];
80
+ let processed = line.replace(/`([^`]+)`/g, (_match, code) => {
81
+ const idx = codeSpans.length;
82
+ codeSpans.push(`<code>${escapeHtml(code)}</code>`);
83
+ return `\x00CODE${idx}\x00`;
84
+ });
85
+
86
+ // Now escape HTML in the remaining text
87
+ processed = escapeHtml(processed);
88
+
89
+ // Bold **text** or __text__
90
+ processed = processed.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
91
+ processed = processed.replace(/__(.+?)__/g, "<b>$1</b>");
92
+
93
+ // Italic *text* or _text_ (but not inside words for underscore)
94
+ processed = processed.replace(/\*(.+?)\*/g, "<i>$1</i>");
95
+
96
+ // Strikethrough ~~text~~
97
+ processed = processed.replace(/~~(.+?)~~/g, "<s>$1</s>");
98
+
99
+ // Links [text](url)
100
+ processed = processed.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
101
+
102
+ // Restore inline code spans
103
+ processed = processed.replace(/\x00CODE(\d+)\x00/g, (_match, idx) => {
104
+ return codeSpans[parseInt(idx, 10)];
105
+ });
106
+
107
+ return processed;
108
+ }
109
+
110
+ /**
111
+ * Split a message into chunks that fit Telegram's 4096 char limit.
112
+ * Tries to split at newlines when possible.
113
+ */
114
+ export function splitForTelegram(html: string): string[] {
115
+ if (html.length <= TELEGRAM_MAX_LENGTH) {
116
+ return [html];
117
+ }
118
+
119
+ const chunks: string[] = [];
120
+ let remaining = html;
121
+
122
+ while (remaining.length > 0) {
123
+ if (remaining.length <= TELEGRAM_MAX_LENGTH) {
124
+ chunks.push(remaining);
125
+ break;
126
+ }
127
+
128
+ // Reserve space for truncation notice on the first chunk if needed
129
+ const maxChunk = TELEGRAM_MAX_LENGTH - TRUNCATION_NOTICE.length;
130
+
131
+ // Try to find a newline to split at
132
+ let splitAt = remaining.lastIndexOf("\n", maxChunk);
133
+ if (splitAt <= 0) {
134
+ // No good newline, just split at max
135
+ splitAt = maxChunk;
136
+ }
137
+
138
+ const chunk = remaining.slice(0, splitAt);
139
+ chunks.push(chunk);
140
+ remaining = remaining.slice(splitAt).trimStart();
141
+ }
142
+
143
+ // Add truncation notice to last chunk if we split
144
+ if (chunks.length > 1) {
145
+ const last = chunks[chunks.length - 1];
146
+ if (last.length + TRUNCATION_NOTICE.length > TELEGRAM_MAX_LENGTH) {
147
+ // Trim last chunk to fit the notice
148
+ chunks[chunks.length - 1] = last.slice(0, TELEGRAM_MAX_LENGTH - TRUNCATION_NOTICE.length) + TRUNCATION_NOTICE;
149
+ } else {
150
+ chunks[chunks.length - 1] = last + TRUNCATION_NOTICE;
151
+ }
152
+ }
153
+
154
+ return chunks;
155
+ }
package/src/index.ts ADDED
@@ -0,0 +1,246 @@
1
+ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
+ import { loadConfig, saveConfig, resolveToken, resolveChatId } from "./config.js";
3
+ import { startBot, stopBot, getBot, setAllowedChatId, setIncomingMessageHandler, waitForChatId, sendText, sendTyping } from "./bot.js";
4
+ import { markdownToTelegramHtml, splitForTelegram } from "./formatter.js";
5
+
6
+ export default function (pi: ExtensionAPI) {
7
+ let relayEnabled = false;
8
+ let chatId: number | null = null;
9
+ let botToken: string | null = null;
10
+
11
+ // ── Setup Flow ──────────────────────────────────────────────
12
+
13
+ async function runSetup(ctx: ExtensionCommandContext): Promise<boolean> {
14
+ // 1. Resolve bot token
15
+ botToken = resolveToken();
16
+ if (!botToken) {
17
+ const input = await ctx.ui.input("Enter your Telegram bot token (from @BotFather):");
18
+ if (!input || !input.trim()) {
19
+ ctx.ui.notify("Setup cancelled — no token provided", "warning");
20
+ return false;
21
+ }
22
+ botToken = input.trim();
23
+ }
24
+
25
+ // 2. Start bot
26
+ ctx.ui.notify("Starting Telegram bot...", "info");
27
+ try {
28
+ await startBot(botToken);
29
+ } catch (err: any) {
30
+ ctx.ui.notify(`Failed to start bot: ${err.message}`, "error");
31
+ botToken = null;
32
+ return false;
33
+ }
34
+
35
+ // 3. Resolve chat ID
36
+ chatId = resolveChatId();
37
+ if (!chatId) {
38
+ ctx.ui.notify("Send any message to your bot on Telegram to link your chat...", "info");
39
+ chatId = await waitForChatId();
40
+ ctx.ui.notify(`Chat ID discovered: ${chatId}`, "info");
41
+ }
42
+
43
+ // 4. Persist config
44
+ saveConfig({ botToken, chatId });
45
+ setAllowedChatId(chatId);
46
+
47
+ // 5. Wire up incoming message handler
48
+ wireIncomingHandler(ctx);
49
+
50
+ ctx.ui.notify(`✅ Telegram connected! Chat ID: ${chatId}`, "info");
51
+ return true;
52
+ }
53
+
54
+ function isSetUp(): boolean {
55
+ return getBot() !== null && chatId !== null;
56
+ }
57
+
58
+ // ── Incoming Message Handler ────────────────────────────────
59
+
60
+ function wireIncomingHandler(ctx: ExtensionContext) {
61
+ setIncomingMessageHandler((_incomingChatId, text) => {
62
+ if (!relayEnabled) {
63
+ sendText(_incomingChatId, "⚠️ Relay is disabled. Enable with /telegram in pi.");
64
+ return;
65
+ }
66
+
67
+ // Notify in TUI
68
+ if (ctx.hasUI) {
69
+ ctx.ui.notify(`📱 Telegram: ${text.length > 60 ? text.slice(0, 60) + "…" : text}`, "info");
70
+ }
71
+
72
+ // Send to agent
73
+ if (ctx.isIdle()) {
74
+ pi.sendUserMessage(text);
75
+ } else {
76
+ pi.sendUserMessage(text, { deliverAs: "followUp" });
77
+ }
78
+ });
79
+ }
80
+
81
+ // ── Relay Toggle ────────────────────────────────────────────
82
+
83
+ async function enableRelay(ctx: ExtensionContext) {
84
+ relayEnabled = true;
85
+ pi.appendEntry("telebridge-state", { enabled: true });
86
+
87
+ if (ctx.hasUI) {
88
+ const theme = ctx.ui.theme;
89
+ ctx.ui.setStatus("telebridge", theme.fg("success", "📡 TG"));
90
+ ctx.ui.notify("🟢 Telegram relay enabled", "info");
91
+ }
92
+
93
+ if (chatId) {
94
+ await sendText(chatId, "📡 Connected to pi session");
95
+ }
96
+ }
97
+
98
+ async function disableRelay(ctx: ExtensionContext) {
99
+ relayEnabled = false;
100
+ pi.appendEntry("telebridge-state", { enabled: false });
101
+
102
+ if (ctx.hasUI) {
103
+ ctx.ui.setStatus("telebridge", undefined);
104
+ ctx.ui.notify("🔴 Telegram relay disabled", "info");
105
+ }
106
+
107
+ if (chatId) {
108
+ await sendText(chatId, "📴 Disconnected from pi session");
109
+ }
110
+ }
111
+
112
+ // ── Commands ────────────────────────────────────────────────
113
+
114
+ pi.registerCommand("telegram", {
115
+ description: "Toggle Telegram relay (setup | status | on/off)",
116
+ handler: async (args, ctx) => {
117
+ const subcommand = args?.trim().toLowerCase();
118
+
119
+ if (subcommand === "setup") {
120
+ await runSetup(ctx);
121
+ return;
122
+ }
123
+
124
+ if (subcommand === "status") {
125
+ const botRunning = getBot() !== null;
126
+ const lines = [
127
+ `Bot: ${botRunning ? "✅ running" : "❌ stopped"}`,
128
+ `Chat ID: ${chatId ?? "not set"}`,
129
+ `Relay: ${relayEnabled ? "🟢 enabled" : "🔴 disabled"}`,
130
+ ];
131
+ ctx.ui.notify(lines.join("\n"), "info");
132
+ return;
133
+ }
134
+
135
+ // Toggle: set up first if needed
136
+ if (!isSetUp()) {
137
+ const ok = await runSetup(ctx);
138
+ if (!ok) return;
139
+ }
140
+
141
+ // Toggle relay
142
+ if (relayEnabled) {
143
+ await disableRelay(ctx);
144
+ } else {
145
+ await enableRelay(ctx);
146
+ }
147
+ },
148
+ });
149
+
150
+ // ── Session Events ──────────────────────────────────────────
151
+
152
+ pi.on("session_start", async (_event, ctx) => {
153
+ // Restore relay state from session entries
154
+ relayEnabled = false;
155
+ for (const entry of ctx.sessionManager.getEntries()) {
156
+ if (entry.type === "custom" && entry.customType === "telebridge-state") {
157
+ const data = (entry as { data?: { enabled?: boolean } }).data;
158
+ relayEnabled = data?.enabled ?? false;
159
+ }
160
+ }
161
+
162
+ // If relay was enabled, try to reconnect the bot
163
+ if (relayEnabled) {
164
+ botToken = resolveToken();
165
+ chatId = resolveChatId();
166
+
167
+ if (botToken && chatId) {
168
+ try {
169
+ await startBot(botToken);
170
+ setAllowedChatId(chatId);
171
+ wireIncomingHandler(ctx);
172
+
173
+ if (ctx.hasUI) {
174
+ const theme = ctx.ui.theme;
175
+ ctx.ui.setStatus("telebridge", theme.fg("success", "📡 TG"));
176
+ }
177
+ } catch {
178
+ relayEnabled = false;
179
+ if (ctx.hasUI) {
180
+ ctx.ui.notify("⚠️ Telebridge: failed to reconnect bot", "warning");
181
+ }
182
+ }
183
+ } else {
184
+ relayEnabled = false;
185
+ }
186
+ }
187
+ });
188
+
189
+ pi.on("session_shutdown", async () => {
190
+ if (relayEnabled && chatId) {
191
+ await sendText(chatId, "📴 pi session ended");
192
+ }
193
+ await stopBot();
194
+ });
195
+
196
+ // ── Agent → Telegram (outgoing) ─────────────────────────────
197
+
198
+ pi.on("agent_start", async () => {
199
+ if (relayEnabled && chatId) {
200
+ await sendTyping(chatId);
201
+ }
202
+ });
203
+
204
+ pi.on("agent_end", async (event) => {
205
+ if (!relayEnabled || !chatId) return;
206
+
207
+ // Extract the last assistant message text
208
+ const messages = event.messages ?? [];
209
+ let assistantText = "";
210
+
211
+ for (let i = messages.length - 1; i >= 0; i--) {
212
+ const msg = messages[i];
213
+ if (msg.role === "assistant") {
214
+ // Extract text content blocks
215
+ if (typeof msg.content === "string") {
216
+ assistantText = msg.content;
217
+ } else if (Array.isArray(msg.content)) {
218
+ assistantText = msg.content
219
+ .filter((block: any) => block.type === "text")
220
+ .map((block: any) => block.text)
221
+ .join("\n");
222
+ }
223
+ break;
224
+ }
225
+ }
226
+
227
+ if (!assistantText.trim()) return;
228
+
229
+ // Convert markdown to Telegram HTML and split if needed
230
+ const html = markdownToTelegramHtml(assistantText);
231
+ const chunks = splitForTelegram(html);
232
+
233
+ for (const chunk of chunks) {
234
+ try {
235
+ await sendText(chatId!, chunk, "HTML");
236
+ } catch {
237
+ // If HTML parsing fails, try plain text
238
+ try {
239
+ await sendText(chatId!, assistantText.slice(0, 4096));
240
+ } catch {
241
+ // Give up silently
242
+ }
243
+ }
244
+ }
245
+ });
246
+ }