opencode-telegram-mirror 0.3.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/src/main.ts ADDED
@@ -0,0 +1,1182 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * OpenCode Telegram Mirror
4
+ *
5
+ * Polls for Telegram updates from a Cloudflare Durable Object endpoint,
6
+ * runs opencode serve, and sends responses back.
7
+ *
8
+ * Usage: bun run src/main.ts [directory] [session-id]
9
+ *
10
+ * Environment variables:
11
+ * TELEGRAM_BOT_TOKEN - Bot token for sending messages
12
+ * TELEGRAM_CHAT_ID - Chat ID to operate in
13
+ * TELEGRAM_UPDATES_URL - URL to poll for updates (CF DO endpoint)
14
+ */
15
+
16
+ import {
17
+ startServer,
18
+ connectToServer,
19
+ stopServer,
20
+ getServer,
21
+ type OpenCodeServer,
22
+ } from "./opencode"
23
+ import { TelegramClient } from "./telegram"
24
+ import { loadConfig } from "./config"
25
+ import { createLogger } from "./log"
26
+ import {
27
+ getSessionId,
28
+ setSessionId,
29
+ getLastUpdateId,
30
+ setLastUpdateId,
31
+ } from "./database"
32
+ import { formatPart, type Part } from "./message-formatting"
33
+ import {
34
+ showQuestionButtons,
35
+ handleQuestionCallback,
36
+ handleFreetextAnswer,
37
+ isAwaitingFreetext,
38
+ cancelPendingQuestion,
39
+ type QuestionRequest,
40
+ } from "./question-handler"
41
+ import {
42
+ showPermissionButtons,
43
+ handlePermissionCallback,
44
+ cancelPendingPermission,
45
+ type PermissionRequest,
46
+ } from "./permission-handler"
47
+ import {
48
+ uploadDiff,
49
+ createDiffFromEdit,
50
+ generateInlineDiffPreview,
51
+ } from "./diff-service"
52
+
53
+ const log = createLogger()
54
+
55
+ /**
56
+ * Update the pinned status message with a new state
57
+ * Format: -----\n**Task**: taskName\nstate\n-----
58
+ */
59
+ async function updateStatusMessage(
60
+ telegram: TelegramClient,
61
+ state: string
62
+ ): Promise<void> {
63
+ const statusMessageId = process.env.STATUS_MESSAGE_ID
64
+ const taskDescription = process.env.TASK_DESCRIPTION
65
+ const branchName = process.env.BRANCH_NAME
66
+
67
+ if (!statusMessageId) {
68
+ log("debug", "No STATUS_MESSAGE_ID, skipping status update")
69
+ return
70
+ }
71
+
72
+ const taskName = taskDescription || branchName || "sandbox"
73
+ const text = `-----\n**Task**: ${taskName}\n${state}\n-----`
74
+
75
+ const messageId = Number.parseInt(statusMessageId, 10)
76
+ const editResult = await telegram.editMessage(messageId, text)
77
+ const success = editResult.status === "ok" && editResult.value
78
+ log("debug", "Status message update", { messageId, state, success })
79
+ }
80
+
81
+ interface BotState {
82
+ server: OpenCodeServer;
83
+ telegram: TelegramClient;
84
+ botToken: string;
85
+ directory: string;
86
+ chatId: string;
87
+ threadId: number | null;
88
+ threadTitle: string | null;
89
+ updatesUrl: string | null;
90
+ botUserId: number | null;
91
+ sessionId: string | null;
92
+
93
+ assistantMessageIds: Set<string>;
94
+ pendingParts: Map<string, Part[]>;
95
+ sentPartIds: Set<string>;
96
+ }
97
+
98
+ async function main() {
99
+ const path = await import("node:path")
100
+ const directory = path.resolve(process.argv[2] || process.cwd())
101
+ const sessionIdArg = process.argv[3]
102
+
103
+ log("info", "=== Telegram Mirror Bot Starting ===")
104
+ log("info", "Startup parameters", {
105
+ directory,
106
+ sessionIdArg: sessionIdArg || "(none)",
107
+ nodeVersion: process.version,
108
+ platform: process.platform,
109
+ pid: process.pid,
110
+ })
111
+
112
+ log("info", "Loading configuration...")
113
+ const configResult = await loadConfig(directory, log)
114
+ if (configResult.status === "error") {
115
+ log("error", "Configuration load failed", {
116
+ error: configResult.error.message,
117
+ path: configResult.error.path,
118
+ })
119
+ console.error("Failed to load Telegram config")
120
+ process.exit(1)
121
+ }
122
+
123
+ const config = configResult.value
124
+
125
+ log("info", "Configuration loaded", {
126
+ hasBotToken: !!config.botToken,
127
+ chatId: config.chatId || "(not set)",
128
+ threadId: config.threadId ?? "(none)",
129
+ hasUpdatesUrl: !!config.updatesUrl,
130
+ hasSendUrl: !!config.sendUrl,
131
+ })
132
+
133
+ if (!config.botToken || !config.chatId) {
134
+ log("error", "Missing required configuration", {
135
+ hasBotToken: !!config.botToken,
136
+ hasChatId: !!config.chatId,
137
+ })
138
+ console.error("Missing botToken or chatId in config")
139
+ console.error(
140
+ "Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID environment variables"
141
+ )
142
+ process.exit(1)
143
+ }
144
+
145
+ // Connect to OpenCode server (external URL or start our own)
146
+ const openCodeUrl = process.env.OPENCODE_URL
147
+ let server: OpenCodeServer
148
+
149
+ if (openCodeUrl) {
150
+ log("info", "Connecting to external OpenCode server...", {
151
+ url: openCodeUrl,
152
+ })
153
+ const serverResult = await connectToServer(openCodeUrl, directory)
154
+ if (serverResult.status === "error") {
155
+ log("error", "Failed to connect to OpenCode server", {
156
+ error: serverResult.error.message,
157
+ })
158
+ console.error("Failed to connect to OpenCode server")
159
+ process.exit(1)
160
+ }
161
+ server = serverResult.value
162
+ log("info", "Connected to OpenCode server", {
163
+ baseUrl: server.baseUrl,
164
+ directory,
165
+ })
166
+ } else {
167
+ log("info", "Starting OpenCode server...")
168
+ const serverResult = await startServer(directory)
169
+ if (serverResult.status === "error") {
170
+ log("error", "Failed to start OpenCode server", {
171
+ error: serverResult.error.message,
172
+ })
173
+ console.error("Failed to start OpenCode server")
174
+ process.exit(1)
175
+ }
176
+ server = serverResult.value
177
+ log("info", "OpenCode server started", {
178
+ port: server.port,
179
+ baseUrl: server.baseUrl,
180
+ directory,
181
+ })
182
+ }
183
+
184
+ // Initialize Telegram client for sending messages
185
+ const telegram = new TelegramClient({
186
+ botToken: config.botToken,
187
+ chatId: config.chatId,
188
+ threadId: config.threadId,
189
+ log,
190
+ baseUrl: config.sendUrl,
191
+ })
192
+
193
+ // Verify bot
194
+ log("info", "Verifying bot token...")
195
+ const botInfoResult = await telegram.getMe()
196
+ if (botInfoResult.status === "error") {
197
+ log("error", "Bot verification failed - invalid token", {
198
+ error: botInfoResult.error.message,
199
+ })
200
+ console.error("Invalid bot token")
201
+ process.exit(1)
202
+ }
203
+ const botInfo = botInfoResult.value
204
+ log("info", "Bot verified successfully", {
205
+ username: botInfo.username,
206
+ botId: botInfo.id,
207
+ })
208
+
209
+ // Register bot commands (menu)
210
+ const commandsResult = await telegram.setMyCommands([
211
+ { command: "interrupt", description: "Stop the current operation" },
212
+ { command: "plan", description: "Switch to plan mode" },
213
+ { command: "build", description: "Switch to build mode" },
214
+ { command: "review", description: "Review changes [commit|branch|pr]" },
215
+ ])
216
+ if (commandsResult.status === "error") {
217
+ log("warn", "Failed to set bot commands", { error: commandsResult.error.message })
218
+ }
219
+
220
+ // Determine session ID
221
+ log("info", "Checking for existing session...")
222
+ let sessionId: string | null = sessionIdArg || getSessionId(log)
223
+
224
+ if (sessionId) {
225
+ log("info", "Found existing session ID, validating...", { sessionId })
226
+ const sessionCheck = await server.client.session.get({
227
+ path: { id: sessionId },
228
+ })
229
+ if (!sessionCheck.data) {
230
+ log("warn", "Stored session not found on server, will create new", {
231
+ oldSessionId: sessionId,
232
+ })
233
+ sessionId = null
234
+ } else {
235
+ log("info", "Session validated successfully", { sessionId })
236
+ }
237
+ } else {
238
+ log("info", "No existing session found, will create on first message")
239
+ }
240
+
241
+ const state: BotState = {
242
+ server,
243
+ telegram,
244
+ botToken: config.botToken,
245
+ directory,
246
+ chatId: config.chatId,
247
+ threadId: config.threadId ?? null,
248
+ threadTitle: null,
249
+ updatesUrl: config.updatesUrl || null,
250
+ botUserId: botInfo.id,
251
+ sessionId,
252
+ assistantMessageIds: new Set(),
253
+ pendingParts: new Map(),
254
+ sentPartIds: new Set(),
255
+ }
256
+
257
+ log("info", "Bot state initialized", {
258
+ directory: state.directory,
259
+ chatId: state.chatId,
260
+ threadId: state.threadId ?? "(none)",
261
+ threadTitle: state.threadTitle ?? "(unknown)",
262
+ sessionId: state.sessionId || "(pending)",
263
+ pollSource: state.updatesUrl ? "Cloudflare DO" : "Telegram API",
264
+ })
265
+
266
+ // Start polling for updates
267
+ log("info", "Starting updates poller...")
268
+ startUpdatesPoller(state)
269
+
270
+ // Subscribe to OpenCode events
271
+ log("info", "Starting event subscription...")
272
+ subscribeToEvents(state)
273
+
274
+ process.on("SIGINT", async () => {
275
+ log("info", "Received SIGINT, shutting down gracefully...")
276
+ const stopResult = await stopServer()
277
+ if (stopResult.status === "error") {
278
+ log("error", "Shutdown failed", { error: stopResult.error.message })
279
+ }
280
+ log("info", "Shutdown complete")
281
+ process.exit(0)
282
+ })
283
+
284
+ process.on("SIGTERM", async () => {
285
+ log("info", "Received SIGTERM, shutting down gracefully...")
286
+ const stopResult = await stopServer()
287
+ if (stopResult.status === "error") {
288
+ log("error", "Shutdown failed", { error: stopResult.error.message })
289
+ }
290
+ log("info", "Shutdown complete")
291
+ process.exit(0)
292
+ })
293
+
294
+ log("info", "=== Bot Startup Complete ===")
295
+ log("info", "Bot is running", {
296
+ sessionId: state.sessionId || "(will create on first message)",
297
+ pollSource: state.updatesUrl ? "Cloudflare DO" : "Telegram API",
298
+ updatesUrl: state.updatesUrl || "(using Telegram API)",
299
+ })
300
+
301
+ // Signal the worker that we're ready - it will update the status message with tunnel URL
302
+ const workerWsUrl = process.env.WORKER_WS_URL
303
+ if (workerWsUrl && state.chatId && state.threadId) {
304
+ const workerBaseUrl = workerWsUrl
305
+ .replace("wss://", "https://")
306
+ .replace("ws://", "http://")
307
+ .replace(/\/ws$/, "")
308
+ .replace(/\/sandbox-ws$/, "")
309
+
310
+ const readyUrl = `${workerBaseUrl}/session-ready`
311
+ log("info", "Signaling worker that mirror is ready", { readyUrl })
312
+
313
+ try {
314
+ const response = await fetch(readyUrl, {
315
+ method: "POST",
316
+ headers: { "Content-Type": "application/json" },
317
+ body: JSON.stringify({
318
+ chatId: state.chatId,
319
+ threadId: state.threadId,
320
+ }),
321
+ })
322
+ log("info", "Worker ready signal response", {
323
+ status: response.status,
324
+ ok: response.ok,
325
+ })
326
+ } catch (error) {
327
+ log("error", "Failed to signal worker", { error: String(error) })
328
+ }
329
+ }
330
+
331
+ // Send initial prompt to OpenCode if context was provided
332
+ const initialContext = process.env.INITIAL_CONTEXT
333
+ const taskDescription = process.env.TASK_DESCRIPTION
334
+ const branchName = process.env.BRANCH_NAME
335
+
336
+ if (initialContext || taskDescription) {
337
+ log("info", "Sending initial context to OpenCode", {
338
+ hasContext: !!initialContext,
339
+ hasTask: !!taskDescription,
340
+ branchName,
341
+ })
342
+
343
+ // Build the instruction prompt
344
+ let prompt = `You are now connected to a Telegram thread for branch "${branchName || "unknown"}".\n\n`
345
+
346
+ if (initialContext) {
347
+ prompt += `## Task Context\n${initialContext}\n\n`
348
+ }
349
+
350
+ if (taskDescription && !initialContext) {
351
+ prompt += `## Task\n${taskDescription}\n\n`
352
+ }
353
+
354
+ prompt += `Read any context/description (if present). Then:
355
+ 1. If a clear task or action is provided, ask any clarifying questions you need before implementing.
356
+ 2. If no clear action/context is provided, ask how to proceed.
357
+
358
+ Do not start implementing until you have clarity on what needs to be done.`
359
+
360
+ // Create session and send prompt
361
+ try {
362
+ const sessionResult = await state.server.client.session.create({
363
+ body: { title: `Telegram: ${branchName || "session"}` },
364
+ })
365
+
366
+ if (sessionResult.data?.id) {
367
+ state.sessionId = sessionResult.data.id
368
+ setSessionId(sessionResult.data.id, log)
369
+ log("info", "Created OpenCode session", { sessionId: state.sessionId })
370
+
371
+ // Send the initial prompt
372
+ await state.server.client.session.prompt({
373
+ path: { id: state.sessionId },
374
+ body: { parts: [{ type: "text", text: prompt }] },
375
+ })
376
+ log("info", "Sent initial prompt to OpenCode")
377
+ }
378
+ } catch (error) {
379
+ log("error", "Failed to send initial context to OpenCode", {
380
+ error: String(error),
381
+ })
382
+ }
383
+ }
384
+ }
385
+
386
+ // =============================================================================
387
+ // Updates Polling (from CF DO or Telegram directly)
388
+ // =============================================================================
389
+
390
+ interface TelegramUpdate {
391
+ update_id: number
392
+ message?: {
393
+ message_id: number
394
+ message_thread_id?: number
395
+ date?: number
396
+ text?: string
397
+ caption?: string
398
+ photo?: Array<{
399
+ file_id: string
400
+ file_unique_id: string
401
+ width: number
402
+ height: number
403
+ }>
404
+ from?: { id: number; username?: string }
405
+ chat: { id: number }
406
+ }
407
+ callback_query?: import("./telegram").CallbackQuery
408
+ }
409
+
410
+ async function startUpdatesPoller(state: BotState) {
411
+ const pollSource = state.updatesUrl ? "Cloudflare DO" : "Telegram API"
412
+
413
+ // Only process messages after startup time to avoid replaying history
414
+ const startupTimestamp = process.env.STARTUP_TIMESTAMP
415
+ ? Number.parseInt(process.env.STARTUP_TIMESTAMP, 10)
416
+ : Math.floor(Date.now() / 1000)
417
+
418
+ log("info", "Updates poller started", {
419
+ source: pollSource,
420
+ chatId: state.chatId,
421
+ threadId: state.threadId ?? "(none)",
422
+ startupTimestamp,
423
+ startupTime: new Date(startupTimestamp * 1000).toISOString(),
424
+ })
425
+
426
+ let pollCount = 0
427
+ let totalUpdatesProcessed = 0
428
+
429
+ while (true) {
430
+ try {
431
+ pollCount++
432
+ const pollStart = Date.now()
433
+
434
+ let updates = state.updatesUrl
435
+ ? await pollFromDO(state)
436
+ : await pollFromTelegram(state)
437
+
438
+ const pollDuration = Date.now() - pollStart
439
+
440
+ // Filter out messages from before startup (they're included in initial context)
441
+ // For callback_query updates, use the embedded message date
442
+ const beforeFilter = updates.length
443
+ updates = updates.filter((u) => {
444
+ const messageDate =
445
+ u.message?.date ?? u.callback_query?.message?.date ?? 0
446
+ return messageDate >= startupTimestamp
447
+ })
448
+
449
+ if (beforeFilter > updates.length) {
450
+ log("debug", "Filtered old messages", {
451
+ before: beforeFilter,
452
+ after: updates.length,
453
+ startupTimestamp,
454
+ })
455
+ }
456
+
457
+ if (updates.length > 0) {
458
+ totalUpdatesProcessed += updates.length
459
+ log("info", "Received updates", {
460
+ count: updates.length,
461
+ totalProcessed: totalUpdatesProcessed,
462
+ pollDuration: `${pollDuration}ms`,
463
+ updateIds: updates.map((u) => u.update_id),
464
+ })
465
+ } else if (state.updatesUrl) {
466
+ // Add delay between polls when using DO (no long-polling)
467
+ await Bun.sleep(1000)
468
+ }
469
+
470
+ for (const update of updates) {
471
+ try {
472
+ const updateType = update.message
473
+ ? "message"
474
+ : update.callback_query
475
+ ? "callback_query"
476
+ : "unknown"
477
+ log("debug", "Processing update", {
478
+ updateId: update.update_id,
479
+ type: updateType,
480
+ raw: JSON.stringify(update),
481
+ })
482
+
483
+ if (update.message) {
484
+ await handleTelegramMessage(state, update.message)
485
+ } else if (update.callback_query) {
486
+ await handleTelegramCallback(state, update.callback_query)
487
+ }
488
+
489
+ log("debug", "Update processed successfully", {
490
+ updateId: update.update_id,
491
+ })
492
+ } catch (err) {
493
+ log("error", "Error processing update", {
494
+ updateId: update.update_id,
495
+ error: String(err),
496
+ })
497
+ }
498
+ }
499
+ } catch (error) {
500
+ log("error", "Poll error, retrying in 5s", {
501
+ pollNumber: pollCount,
502
+ error: String(error),
503
+ })
504
+ await Bun.sleep(5000)
505
+ }
506
+ }
507
+ }
508
+
509
+ async function pollFromDO(state: BotState): Promise<TelegramUpdate[]> {
510
+ if (!state.updatesUrl) return []
511
+
512
+ const since = getLastUpdateId(log)
513
+ const parsed = new URL(state.updatesUrl)
514
+ parsed.searchParams.set("since", String(since))
515
+ parsed.searchParams.set("chat_id", state.chatId)
516
+ if (state.threadId !== null) {
517
+ parsed.searchParams.set("thread_id", String(state.threadId))
518
+ }
519
+
520
+ const headers: Record<string, string> = {}
521
+
522
+ if (parsed.username || parsed.password) {
523
+ const credentials = btoa(`${parsed.username}:${parsed.password}`)
524
+ headers.Authorization = `Basic ${credentials}`
525
+ parsed.username = ""
526
+ parsed.password = ""
527
+ }
528
+
529
+ const response = await fetch(parsed.toString(), { headers })
530
+
531
+ if (!response.ok) {
532
+ log("error", "DO poll failed", {
533
+ status: response.status,
534
+ statusText: response.statusText,
535
+ })
536
+ throw new Error(`DO poll failed: ${response.status}`)
537
+ }
538
+
539
+ const data = (await response.json()) as {
540
+ updates?: Array<{ payload: TelegramUpdate; update_id: number }>
541
+ }
542
+ // DO wraps Telegram updates in { payload: {...}, update_id, chat_id, received_at }
543
+ // Extract the actual Telegram update from payload
544
+ const updates = (data.updates ?? []).map(
545
+ (u) => u.payload ?? u
546
+ ) as TelegramUpdate[]
547
+
548
+ if (updates.length > 0) {
549
+ const lastUpdate = updates[updates.length - 1]
550
+ log("info", "DO poll returned updates", {
551
+ previousId: since,
552
+ newId: lastUpdate.update_id,
553
+ updateCount: updates.length,
554
+ threadIds: updates.map((u) => u.message?.message_thread_id ?? "none"),
555
+ })
556
+ setLastUpdateId(lastUpdate.update_id, log)
557
+ }
558
+
559
+ return updates
560
+ }
561
+
562
+ async function pollFromTelegram(state: BotState): Promise<TelegramUpdate[]> {
563
+ const lastUpdateId = getLastUpdateId(log)
564
+ const baseUrl = `https://api.telegram.org/bot${state.botToken}`
565
+
566
+ const params = new URLSearchParams({
567
+ offset: String(lastUpdateId + 1),
568
+ timeout: "30",
569
+ allowed_updates: JSON.stringify(["message", "callback_query"]),
570
+ })
571
+
572
+ const response = await fetch(`${baseUrl}/getUpdates?${params}`)
573
+ const data = (await response.json()) as {
574
+ ok: boolean
575
+ result?: TelegramUpdate[]
576
+ }
577
+
578
+ if (!data.ok || !data.result) {
579
+ return []
580
+ }
581
+
582
+ // Filter to our chat and update last ID
583
+ const updates: TelegramUpdate[] = []
584
+ for (const update of data.result) {
585
+ setLastUpdateId(update.update_id, log)
586
+
587
+ const chatId =
588
+ update.message?.chat.id || update.callback_query?.message?.chat.id
589
+ if (String(chatId) === state.chatId) {
590
+ updates.push(update)
591
+ }
592
+ }
593
+
594
+ return updates
595
+ }
596
+
597
+ async function handleTelegramMessage(
598
+ state: BotState,
599
+ msg: NonNullable<TelegramUpdate["message"]>,
600
+ ) {
601
+ const messageText = msg.text || msg.caption
602
+ if (!messageText && !msg.photo) return
603
+
604
+ // Ignore all bot messages - context is sent directly via OpenCode API
605
+ if (msg.from?.id === state.botUserId) {
606
+ log("debug", "Ignoring bot message")
607
+ return
608
+ }
609
+
610
+ if (state.threadId && msg.message_thread_id !== state.threadId) {
611
+ log("debug", "Ignoring message from different thread", {
612
+ msgThreadId: msg.message_thread_id,
613
+ stateThreadId: state.threadId,
614
+ })
615
+ return
616
+ }
617
+
618
+ // Handle "x" as interrupt (like double-escape in opencode TUI)
619
+ if (messageText?.trim().toLowerCase() === "x") {
620
+ log("info", "Received interrupt command 'x'")
621
+ if (state.sessionId) {
622
+ const abortResult = await state.server.clientV2.session.abort({
623
+ sessionID: state.sessionId,
624
+ directory: state.directory,
625
+ })
626
+ if (abortResult.data) {
627
+ await state.telegram.sendMessage("Interrupted.")
628
+ } else {
629
+ log("error", "Failed to abort session", {
630
+ sessionId: state.sessionId,
631
+ error: abortResult.error,
632
+ })
633
+ await state.telegram.sendMessage("Failed to interrupt the session.")
634
+ }
635
+ } else {
636
+ await state.telegram.sendMessage("No active session to interrupt.")
637
+ }
638
+ return
639
+ }
640
+
641
+ if (messageText?.trim() === "/connect") {
642
+ const publicUrl = process.env.OPENCODE_PUBLIC_URL
643
+ if (publicUrl) {
644
+ const sendResult = await state.telegram.sendMessage(
645
+ `OpenCode server is ready:\n${publicUrl}`
646
+ )
647
+ if (sendResult.status === "error") {
648
+ log("error", "Failed to send connect response", {
649
+ error: sendResult.error.message,
650
+ })
651
+ }
652
+ } else {
653
+ const sendResult = await state.telegram.sendMessage(
654
+ "OpenCode URL is not available yet."
655
+ )
656
+ if (sendResult.status === "error") {
657
+ log("error", "Failed to send connect response", {
658
+ error: sendResult.error.message,
659
+ })
660
+ }
661
+ }
662
+ return
663
+ }
664
+
665
+ if (messageText?.trim() === "/interrupt") {
666
+ log("info", "Received /interrupt command")
667
+ if (state.sessionId) {
668
+ const abortResult = await state.server.clientV2.session.abort({
669
+ sessionID: state.sessionId,
670
+ directory: state.directory,
671
+ })
672
+ if (abortResult.data) {
673
+ await state.telegram.sendMessage("Interrupted.")
674
+ } else {
675
+ log("error", "Failed to abort session", {
676
+ sessionId: state.sessionId,
677
+ error: abortResult.error,
678
+ })
679
+ await state.telegram.sendMessage("Failed to interrupt.")
680
+ }
681
+ } else {
682
+ await state.telegram.sendMessage("No active session.")
683
+ }
684
+ return
685
+ }
686
+
687
+ const commandMatch = messageText?.trim().match(/^\/(build|plan|review)(?:\s+(.*))?$/)
688
+ if (commandMatch) {
689
+ const [, command, args] = commandMatch
690
+ log("info", "Received command", { command, args })
691
+
692
+ if (!state.sessionId) {
693
+ const result = await state.server.client.session.create({
694
+ body: { title: "Telegram" },
695
+ })
696
+ if (result.data) {
697
+ state.sessionId = result.data.id
698
+ setSessionId(result.data.id, log)
699
+ log("info", "Created session for command", { sessionId: result.data.id })
700
+ } else {
701
+ log("error", "Failed to create session for command")
702
+ await state.telegram.sendMessage("Failed to create session.")
703
+ return
704
+ }
705
+ }
706
+
707
+ state.server.clientV2.session
708
+ .command({
709
+ sessionID: state.sessionId,
710
+ directory: state.directory,
711
+ command,
712
+ arguments: args || "",
713
+ })
714
+ .catch((err) => {
715
+ log("error", "Command failed", { command, error: String(err) })
716
+ })
717
+
718
+ log("info", "Command sent", { command, sessionId: state.sessionId })
719
+ return
720
+ }
721
+
722
+ log("info", "Received message", {
723
+ from: msg.from?.username,
724
+ preview: messageText?.slice(0, 50) ?? "[photo]",
725
+ })
726
+
727
+ // Check for freetext answer
728
+ const threadId = state.threadId ?? 0
729
+
730
+ if (isAwaitingFreetext(msg.chat.id, threadId) && messageText) {
731
+ const result = await handleFreetextAnswer({
732
+ telegram: state.telegram,
733
+ chatId: msg.chat.id,
734
+ threadId,
735
+ text: messageText,
736
+ log,
737
+ })
738
+
739
+ if (result) {
740
+ await state.server.clientV2.question.reply({
741
+ requestID: result.requestId,
742
+ answers: result.answers,
743
+ })
744
+ }
745
+ return
746
+ }
747
+
748
+ // Cancel pending questions/permissions
749
+ const cancelledQ = cancelPendingQuestion(msg.chat.id, threadId)
750
+ if (cancelledQ) {
751
+ await state.server.clientV2.question.reject({
752
+ requestID: cancelledQ.requestId,
753
+ })
754
+ }
755
+
756
+ const cancelledP = cancelPendingPermission(msg.chat.id, threadId)
757
+ if (cancelledP) {
758
+ await state.server.clientV2.permission.reply({
759
+ requestID: cancelledP.requestId,
760
+ reply: "reject",
761
+ })
762
+ }
763
+
764
+ // Create session if needed
765
+ if (!state.sessionId) {
766
+ const result = await state.server.client.session.create({
767
+ body: { title: "Telegram" },
768
+ })
769
+
770
+ if (result.data) {
771
+ state.sessionId = result.data.id
772
+ setSessionId(result.data.id, log)
773
+ log("info", "Created session", { sessionId: result.data.id })
774
+ } else {
775
+ log("error", "Failed to create session")
776
+ return
777
+ }
778
+ }
779
+
780
+ // Build prompt parts
781
+ const parts: Array<
782
+ | { type: "text"; text: string }
783
+ | { type: "file"; mime: string; url: string; filename?: string }
784
+ > = []
785
+
786
+ if (msg.photo && msg.photo.length > 0) {
787
+ const bestPhoto = msg.photo[msg.photo.length - 1]
788
+ const dataUrlResult = await state.telegram.downloadFileAsDataUrl(
789
+ bestPhoto.file_id,
790
+ "image/jpeg"
791
+ )
792
+ if (dataUrlResult.status === "ok") {
793
+ parts.push({
794
+ type: "file",
795
+ mime: "image/jpeg",
796
+ url: dataUrlResult.value,
797
+ filename: `photo_${bestPhoto.file_unique_id}.jpg`,
798
+ })
799
+ } else {
800
+ log("error", "Failed to download photo", {
801
+ error: dataUrlResult.error.message,
802
+ fileId: bestPhoto.file_id,
803
+ })
804
+ }
805
+ }
806
+
807
+ if (messageText) {
808
+ parts.push({ type: "text", text: messageText })
809
+ }
810
+
811
+ if (parts.length === 0) return
812
+
813
+ // Send to OpenCode
814
+ state.server.clientV2.session
815
+ .prompt({
816
+ sessionID: state.sessionId,
817
+ directory: state.directory,
818
+ parts,
819
+ })
820
+ .catch((err) => {
821
+ log("error", "Prompt failed", { error: String(err) })
822
+ })
823
+
824
+ log("info", "Prompt sent", { sessionId: state.sessionId })
825
+ }
826
+
827
+ async function handleTelegramCallback(
828
+ state: BotState,
829
+ callback: import("./telegram").CallbackQuery,
830
+ ) {
831
+ log("info", "Received callback", { data: callback.data })
832
+
833
+ if (
834
+ state.threadId &&
835
+ callback.message?.message_thread_id !== state.threadId
836
+ ) {
837
+ return
838
+ }
839
+
840
+ const questionResult = await handleQuestionCallback({
841
+ telegram: state.telegram,
842
+ callback,
843
+ log,
844
+ })
845
+
846
+ if (questionResult) {
847
+ if ("awaitingFreetext" in questionResult) return
848
+
849
+ await state.server.clientV2.question.reply({
850
+ requestID: questionResult.requestId,
851
+ answers: questionResult.answers,
852
+ })
853
+ return
854
+ }
855
+
856
+ const permResult = await handlePermissionCallback({
857
+ telegram: state.telegram,
858
+ callback,
859
+ log,
860
+ })
861
+
862
+ if (permResult) {
863
+ await state.server.clientV2.permission.reply({
864
+ requestID: permResult.requestId,
865
+ reply: permResult.reply,
866
+ })
867
+ }
868
+ }
869
+
870
+
871
+
872
+ // =============================================================================
873
+ // OpenCode Events
874
+ // =============================================================================
875
+
876
+ interface OpenCodeEvent {
877
+ type: string
878
+ properties: {
879
+ sessionID?: string
880
+ info?: { id: string; sessionID: string; role: string }
881
+ part?: Part
882
+ session?: { id: string; title?: string }
883
+ [key: string]: unknown
884
+ }
885
+ }
886
+
887
+ async function subscribeToEvents(state: BotState) {
888
+ log("info", "Subscribing to OpenCode events")
889
+
890
+ try {
891
+ const eventsResult = await state.server.clientV2.event.subscribe(
892
+ { directory: state.directory },
893
+ {}
894
+ )
895
+
896
+ const stream = eventsResult.stream
897
+ if (!stream) throw new Error("No event stream")
898
+
899
+ log("info", "Event stream connected")
900
+
901
+ for await (const event of stream) {
902
+ try {
903
+ await handleOpenCodeEvent(state, event as OpenCodeEvent)
904
+ } catch (error) {
905
+ log("error", "Event error", { error: String(error) })
906
+ }
907
+ }
908
+
909
+ log("warn", "Event stream ended")
910
+ } catch (error) {
911
+ log("error", "Event subscription error", { error: String(error) })
912
+ }
913
+
914
+ // Retry
915
+ if (getServer()) {
916
+ await Bun.sleep(5000)
917
+ subscribeToEvents(state)
918
+ }
919
+ }
920
+
921
+ async function handleOpenCodeEvent(state: BotState, ev: OpenCodeEvent) {
922
+ const sessionId =
923
+ ev.properties?.sessionID ??
924
+ ev.properties?.info?.sessionID ??
925
+ ev.properties?.part?.sessionID
926
+ const sessionTitle = ev.properties?.session?.title
927
+
928
+ // Log errors in full and send to Telegram
929
+ if (ev.type === "session.error") {
930
+ const errorMsg = JSON.stringify(ev.properties, null, 2)
931
+ log("error", "OpenCode session error", {
932
+ sessionId,
933
+ error: ev.properties,
934
+ })
935
+ // Send error to Telegram for visibility
936
+ const sendResult = await state.telegram.sendMessage(
937
+ `OpenCode Error:\n${errorMsg.slice(0, 3500)}`
938
+ )
939
+ if (sendResult.status === "error") {
940
+ log("error", "Failed to send session error message", {
941
+ error: sendResult.error.message,
942
+ })
943
+ }
944
+ }
945
+
946
+ if (ev.type !== "session.updated") {
947
+ log("debug", "OpenCode event received", {
948
+ type: ev.type,
949
+ eventSessionId: sessionId,
950
+ stateSessionId: state.sessionId,
951
+ match: sessionId === state.sessionId,
952
+ })
953
+ }
954
+
955
+ if (!sessionId || sessionId !== state.sessionId) return
956
+
957
+ if (sessionTitle && state.threadId) {
958
+ const trimmedTitle = sessionTitle.trim()
959
+ const shouldUpdate = trimmedTitle && trimmedTitle !== state.threadTitle
960
+ if (shouldUpdate) {
961
+ const renameResult = await state.telegram.editForumTopic(
962
+ state.threadId,
963
+ trimmedTitle
964
+ )
965
+ if (renameResult.status === "ok") {
966
+ state.threadTitle = trimmedTitle
967
+ log("info", "Updated Telegram thread title", {
968
+ threadId: state.threadId,
969
+ title: trimmedTitle,
970
+ })
971
+ } else {
972
+ log("error", "Failed to update Telegram thread title", {
973
+ threadId: state.threadId,
974
+ title: trimmedTitle,
975
+ error: renameResult.error.message,
976
+ })
977
+ }
978
+ }
979
+ }
980
+
981
+ if (ev.type === "message.updated") {
982
+ const info = ev.properties.info
983
+ if (info?.role === "assistant") {
984
+ const key = `${info.sessionID}:${info.id}`
985
+ state.assistantMessageIds.add(key)
986
+ log("debug", "Registered assistant message", { key })
987
+ }
988
+ }
989
+
990
+ if (ev.type === "message.part.updated") {
991
+ const part = ev.properties.part
992
+ if (!part) return
993
+
994
+ const key = `${part.sessionID}:${part.messageID}`
995
+ if (!state.assistantMessageIds.has(key)) {
996
+ log("debug", "Ignoring part - not from assistant message", {
997
+ key,
998
+ registeredKeys: Array.from(state.assistantMessageIds),
999
+ partType: part.type,
1000
+ })
1001
+ return
1002
+ }
1003
+
1004
+ log("debug", "Processing message part", {
1005
+ key,
1006
+ partType: part.type,
1007
+ partId: part.id,
1008
+ })
1009
+
1010
+ const existing = state.pendingParts.get(key) ?? []
1011
+ const idx = existing.findIndex((p) => p.id === part.id)
1012
+ if (idx >= 0) existing[idx] = part
1013
+ else existing.push(part)
1014
+ state.pendingParts.set(key, existing)
1015
+
1016
+ if (part.type !== "step-finish") {
1017
+ const typingResult = await state.telegram.sendTypingAction()
1018
+ if (typingResult.status === "error") {
1019
+ log("debug", "Typing action failed", {
1020
+ error: typingResult.error.message,
1021
+ })
1022
+ }
1023
+ }
1024
+
1025
+ // Send tools/reasoning immediately (except edit/write tools - wait for completion to get diff data)
1026
+ const isEditOrWrite =
1027
+ part.type === "tool" && (part.tool === "edit" || part.tool === "write")
1028
+ if (
1029
+ (part.type === "tool" &&
1030
+ part.state?.status === "running" &&
1031
+ !isEditOrWrite) ||
1032
+ part.type === "reasoning"
1033
+ ) {
1034
+ if (!state.sentPartIds.has(part.id)) {
1035
+ const formatted = formatPart(part)
1036
+ if (formatted.trim()) {
1037
+ const sendResult = await state.telegram.sendMessage(formatted)
1038
+ if (sendResult.status === "error") {
1039
+ log("error", "Failed to send formatted part", {
1040
+ error: sendResult.error.message,
1041
+ })
1042
+ }
1043
+ state.sentPartIds.add(part.id)
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ // On step-finish, send remaining parts
1049
+ if (part.type === "step-finish") {
1050
+ for (const p of existing) {
1051
+ if (p.type === "step-start" || p.type === "step-finish") continue
1052
+ if (state.sentPartIds.has(p.id)) continue
1053
+
1054
+ // Handle edit tool diffs
1055
+ if (
1056
+ p.type === "tool" &&
1057
+ p.tool === "edit" &&
1058
+ p.state?.status === "completed"
1059
+ ) {
1060
+ const input = p.state.input ?? {}
1061
+ const filePath = (input.filePath as string) || ""
1062
+ const oldString = (input.oldString as string) || ""
1063
+ const newString = (input.newString as string) || ""
1064
+
1065
+ log("debug", "Edit tool completed", {
1066
+ filePath,
1067
+ hasOldString: !!oldString,
1068
+ hasNewString: !!newString,
1069
+ oldStringLen: oldString.length,
1070
+ newStringLen: newString.length,
1071
+ inputKeys: Object.keys(input),
1072
+ })
1073
+
1074
+ if (filePath && (oldString || newString)) {
1075
+ const diffFile = createDiffFromEdit({
1076
+ filePath,
1077
+ oldString,
1078
+ newString,
1079
+ })
1080
+ log("debug", "Uploading diff", {
1081
+ filePath,
1082
+ additions: diffFile.additions,
1083
+ deletions: diffFile.deletions,
1084
+ })
1085
+ const diffResult = await uploadDiff([diffFile], {
1086
+ title: filePath.split("/").pop() || "Edit",
1087
+ log,
1088
+ })
1089
+
1090
+ const diffUpload = diffResult.status === "ok" ? diffResult.value : null
1091
+ if (diffResult.status === "error") {
1092
+ log("error", "Diff upload failed", {
1093
+ error: diffResult.error.message,
1094
+ })
1095
+ }
1096
+
1097
+ log("debug", "Diff upload result", {
1098
+ success: !!diffUpload,
1099
+ url: diffUpload?.viewerUrl,
1100
+ })
1101
+ const formatted = formatPart(p)
1102
+ const preview = generateInlineDiffPreview(oldString, newString, 8)
1103
+ const message = preview ? `${formatted}\n\n${preview}` : formatted
1104
+
1105
+ if (diffUpload) {
1106
+ const sendResult = await state.telegram.sendMessage(message, {
1107
+ replyMarkup: {
1108
+ inline_keyboard: [
1109
+ [{ text: "View Diff", url: diffUpload.viewerUrl }],
1110
+ ],
1111
+ },
1112
+ })
1113
+ if (sendResult.status === "error") {
1114
+ log("error", "Failed to send diff message", {
1115
+ error: sendResult.error.message,
1116
+ })
1117
+ }
1118
+ } else {
1119
+ const sendResult = await state.telegram.sendMessage(message)
1120
+ if (sendResult.status === "error") {
1121
+ log("error", "Failed to send diff message", {
1122
+ error: sendResult.error.message,
1123
+ })
1124
+ }
1125
+ }
1126
+ state.sentPartIds.add(p.id)
1127
+ continue
1128
+ }
1129
+
1130
+ log("warn", "Edit tool missing filePath or content", {
1131
+ filePath,
1132
+ hasOld: !!oldString,
1133
+ hasNew: !!newString,
1134
+ })
1135
+ }
1136
+
1137
+ const formatted = formatPart(p)
1138
+ if (formatted.trim()) {
1139
+ const sendResult = await state.telegram.sendMessage(formatted)
1140
+ if (sendResult.status === "error") {
1141
+ log("error", "Failed to send formatted part", {
1142
+ error: sendResult.error.message,
1143
+ })
1144
+ }
1145
+ state.sentPartIds.add(p.id)
1146
+ }
1147
+ }
1148
+ state.pendingParts.delete(key)
1149
+ }
1150
+ }
1151
+
1152
+ const threadId = state.threadId ?? 0
1153
+
1154
+ if (ev.type === "question.asked") {
1155
+ await showQuestionButtons({
1156
+ telegram: state.telegram,
1157
+ chatId: Number(state.chatId),
1158
+ threadId,
1159
+ sessionId,
1160
+ request: ev.properties as unknown as QuestionRequest,
1161
+ directory: state.directory,
1162
+ log,
1163
+ })
1164
+ }
1165
+
1166
+ if (ev.type === "permission.asked") {
1167
+ await showPermissionButtons({
1168
+ telegram: state.telegram,
1169
+ chatId: Number(state.chatId),
1170
+ threadId,
1171
+ sessionId,
1172
+ request: ev.properties as unknown as PermissionRequest,
1173
+ directory: state.directory,
1174
+ log,
1175
+ })
1176
+ }
1177
+ }
1178
+
1179
+ main().catch((error) => {
1180
+ console.error("Fatal error:", error)
1181
+ process.exit(1)
1182
+ })