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/AGENTS.md +196 -0
- package/README.md +230 -0
- package/bun.lock +67 -0
- package/package.json +27 -0
- package/src/config.ts +99 -0
- package/src/database.ts +120 -0
- package/src/diff-service.ts +176 -0
- package/src/log.ts +23 -0
- package/src/main.ts +1182 -0
- package/src/message-formatting.ts +202 -0
- package/src/opencode.ts +306 -0
- package/src/permission-handler.ts +242 -0
- package/src/question-handler.ts +391 -0
- package/src/system-message.ts +73 -0
- package/src/telegram.ts +705 -0
- package/test/fixtures/commands-test.json +157 -0
- package/test/fixtures/sample-updates.json +9098 -0
- package/test/mock-server.ts +271 -0
- package/test/run-test.ts +160 -0
- package/tsconfig.json +26 -0
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
|
+
})
|