@lapage/codex-telegram-bridge 0.1.0 → 0.1.1

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.
@@ -1,6 +1,11 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { basename, extname, join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
1
5
  import { Bot } from 'grammy';
2
6
  import { CodexSession } from './codex-session.js';
3
- import { chunkText, formatTelegramMarkdown, isCodexWorking, latestCodexResponse, latestCompletedCodexResponse, plainTelegramText } from './text.js';
7
+ import { formatTelegramMarkdownChunks, safePlainTelegramChunks, safePlainTelegramText } from './text.js';
8
+ const attachmentTmpDir = join(tmpdir(), 'codex-telegram-bridge');
4
9
  export class TelegramCodexBridge {
5
10
  config;
6
11
  bot;
@@ -8,24 +13,23 @@ export class TelegramCodexBridge {
8
13
  activeChatId = null;
9
14
  outputBuffer = '';
10
15
  lastOutputAt = null;
11
- flushTimer = null;
12
- pollTimer = null;
13
- lastPaneResponse = '';
14
- lastSentResponse = '';
15
- lastSnapshotSentAt = 0;
16
- streamMessageId = null;
17
- lastStreamText = '';
18
- lastStreamEditAt = 0;
16
+ turnActive = false;
17
+ renderItems = new Map();
18
+ renderOrder = 0;
19
+ renderMessageIds = [];
19
20
  typingTimer = null;
20
21
  sendQueue = Promise.resolve();
21
22
  constructor(config) {
22
23
  this.config = config;
23
24
  this.bot = new Bot(config.token);
24
25
  this.codex = new CodexSession(config);
26
+ this.codex.on('itemCompleted', (item) => void this.handleCodexItemCompleted(item));
27
+ this.codex.on('turnCompleted', () => void this.handleCodexTurnCompleted());
28
+ this.codex.on('error', (message) => console.error('Codex app-server:', message));
29
+ this.codex.on('exit', (code, signal) => console.error('Codex app-server exited:', code ?? signal ?? 'unknown'));
25
30
  }
26
31
  async start() {
27
32
  await this.codex.start();
28
- this.startPollingOutput();
29
33
  this.bot.on('message', async (context) => this.handleMessage(context));
30
34
  this.bot.catch((error) => {
31
35
  console.error('Telegram bot error:', error.message);
@@ -33,13 +37,8 @@ export class TelegramCodexBridge {
33
37
  this.bot.start();
34
38
  }
35
39
  async stop() {
36
- this.stopPollingOutput();
37
40
  this.stopTypingIndicator();
38
41
  await this.codex.stop();
39
- if (this.flushTimer) {
40
- clearTimeout(this.flushTimer);
41
- this.flushTimer = null;
42
- }
43
42
  await this.bot.stop();
44
43
  }
45
44
  async handleMessage(context) {
@@ -53,24 +52,123 @@ export class TelegramCodexBridge {
53
52
  return;
54
53
  }
55
54
  this.activeChatId = chatId;
56
- const text = context.message?.text ?? '';
57
- if (!text.trim()) {
58
- await context.reply('Send text to forward it to Codex.');
55
+ const text = context.message?.text ?? context.message?.caption ?? '';
56
+ const attachmentSources = this.extractAttachmentSources(context);
57
+ if (!text.trim() && attachmentSources.length === 0) {
58
+ await context.reply('Send text or an attachment to forward it to Codex.');
59
59
  return;
60
60
  }
61
- if (await this.handleCommand(context, text.trim())) {
61
+ if (context.message?.text && await this.handleCommand(context, text.trim())) {
62
62
  return;
63
63
  }
64
64
  if (!this.codex.isRunning) {
65
65
  await this.codex.start();
66
66
  }
67
- await this.codex.waitUntilReady(Boolean(this.lastOutputAt));
68
- await this.codex.sendText(text);
67
+ this.resetTurnRenderState();
68
+ this.turnActive = true;
69
69
  const streamMessage = await context.reply('Codex is working…');
70
- this.streamMessageId = streamMessage.message_id;
71
- this.lastStreamText = 'Codex is working…';
72
- this.lastStreamEditAt = Date.now();
70
+ this.renderMessageIds = [streamMessage.message_id];
73
71
  this.startTypingIndicator();
72
+ const attachments = await this.downloadAttachments(attachmentSources);
73
+ await this.codex.sendText(text, attachments);
74
+ }
75
+ extractAttachmentSources(context) {
76
+ const message = context.message;
77
+ if (!message) {
78
+ return [];
79
+ }
80
+ const sources = [];
81
+ const photo = message.photo?.at(-1);
82
+ if (photo) {
83
+ sources.push({
84
+ fileId: photo.file_id,
85
+ originalName: `telegram-photo-${photo.file_unique_id}.jpg`,
86
+ mimeType: 'image/jpeg',
87
+ kind: 'image',
88
+ });
89
+ }
90
+ if (message.document) {
91
+ const mimeType = message.document.mime_type;
92
+ sources.push({
93
+ fileId: message.document.file_id,
94
+ originalName: message.document.file_name ?? `telegram-document-${message.document.file_unique_id}${extensionForMime(mimeType)}`,
95
+ mimeType,
96
+ kind: mimeType?.startsWith('image/') ? 'image' : 'file',
97
+ });
98
+ }
99
+ if (message.video) {
100
+ sources.push({
101
+ fileId: message.video.file_id,
102
+ originalName: message.video.file_name ?? `telegram-video-${message.video.file_unique_id}.mp4`,
103
+ mimeType: message.video.mime_type,
104
+ kind: 'file',
105
+ });
106
+ }
107
+ if (message.animation) {
108
+ sources.push({
109
+ fileId: message.animation.file_id,
110
+ originalName: message.animation.file_name ?? `telegram-animation-${message.animation.file_unique_id}.mp4`,
111
+ mimeType: message.animation.mime_type,
112
+ kind: 'file',
113
+ });
114
+ }
115
+ if (message.audio) {
116
+ sources.push({
117
+ fileId: message.audio.file_id,
118
+ originalName: message.audio.file_name ?? `telegram-audio-${message.audio.file_unique_id}${extensionForMime(message.audio.mime_type)}`,
119
+ mimeType: message.audio.mime_type,
120
+ kind: 'file',
121
+ });
122
+ }
123
+ if (message.voice) {
124
+ sources.push({
125
+ fileId: message.voice.file_id,
126
+ originalName: `telegram-voice-${message.voice.file_unique_id}.ogg`,
127
+ mimeType: message.voice.mime_type,
128
+ kind: 'file',
129
+ });
130
+ }
131
+ return sources;
132
+ }
133
+ async downloadAttachments(sources) {
134
+ if (sources.length === 0) {
135
+ return [];
136
+ }
137
+ await mkdir(attachmentTmpDir, { recursive: true });
138
+ const attachments = [];
139
+ for (const source of sources) {
140
+ try {
141
+ attachments.push(await this.downloadAttachment(source));
142
+ }
143
+ catch (error) {
144
+ console.error('Telegram attachment download failed:', telegramErrorSummary(error));
145
+ }
146
+ }
147
+ return attachments;
148
+ }
149
+ async downloadAttachment(source) {
150
+ const file = await this.bot.api.getFile(source.fileId);
151
+ if (!file.file_path) {
152
+ throw new Error(`Telegram did not return file_path for ${source.originalName}`);
153
+ }
154
+ const token = this.config.token;
155
+ const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
156
+ const response = await fetch(url);
157
+ if (!response.ok) {
158
+ throw new Error(`Download failed with ${response.status} ${response.statusText}`);
159
+ }
160
+ const data = Buffer.from(await response.arrayBuffer());
161
+ const safeName = safeFileName(source.originalName);
162
+ const extension = extname(safeName) || extensionForMime(source.mimeType);
163
+ const fileName = `${randomUUID()}${extension}`;
164
+ const path = join(attachmentTmpDir, fileName);
165
+ await writeFile(path, data);
166
+ return {
167
+ path,
168
+ name: basename(safeName),
169
+ mimeType: source.mimeType,
170
+ kind: source.kind,
171
+ };
74
172
  }
75
173
  async handleCommand(context, text) {
76
174
  switch (text) {
@@ -82,41 +180,26 @@ export class TelegramCodexBridge {
82
180
  await context.reply(this.statusText());
83
181
  return true;
84
182
  case '/flush':
85
- await this.readNewOutput(true);
86
- await this.flushOutput(true);
183
+ await this.renderTurnCache(true);
87
184
  return true;
88
185
  case '/interrupt':
89
186
  await this.codex.interrupt();
90
- await context.reply('Sent Ctrl-C to Codex.');
187
+ await context.reply('Sent interrupt to Codex.');
91
188
  return true;
92
189
  case '/restart':
93
190
  await this.codex.restart();
94
191
  this.resetSnapshots();
95
- this.stopTypingIndicator();
96
- await context.reply('Restarted Codex.');
192
+ await context.reply('Restarted Codex app-server.');
97
193
  return true;
98
194
  case '/stop':
99
195
  await this.codex.stop();
100
196
  this.stopTypingIndicator();
101
- await context.reply('Stopped Codex. Send any message to start it again.');
197
+ await context.reply('Stopped Codex app-server. Send any message to start it again.');
102
198
  return true;
103
199
  default:
104
200
  return false;
105
201
  }
106
202
  }
107
- startPollingOutput() {
108
- this.stopPollingOutput();
109
- this.pollTimer = setInterval(() => {
110
- void this.readNewOutput();
111
- void this.refreshCodexRunningState();
112
- }, this.config.pollIntervalMs);
113
- }
114
- stopPollingOutput() {
115
- if (this.pollTimer) {
116
- clearInterval(this.pollTimer);
117
- this.pollTimer = null;
118
- }
119
- }
120
203
  startTypingIndicator() {
121
204
  if (!this.activeChatId) {
122
205
  return;
@@ -139,117 +222,106 @@ export class TelegramCodexBridge {
139
222
  }
140
223
  await this.bot.api.sendChatAction(this.activeChatId, 'typing').catch(() => undefined);
141
224
  }
142
- async readNewOutput(force = false) {
143
- if (!this.activeChatId || !await this.codex.exists()) {
225
+ async handleCodexItemCompleted(item) {
226
+ if (!this.turnActive) {
144
227
  return;
145
228
  }
146
- const pane = await this.codex.capturePane();
147
- const response = force ? latestCompletedCodexResponse(pane) : latestCodexResponse(pane);
148
- const working = isCodexWorking(pane);
149
- if (this.streamMessageId && response) {
150
- await this.editStreamMessage(response, !working || force);
151
- if (!working || force) {
152
- this.stopTypingIndicator();
153
- }
154
- }
155
- if (working && !force) {
156
- return;
157
- }
158
- if (!response || (!force && response === this.lastPaneResponse)) {
229
+ const rendered = renderCompletedItem(item);
230
+ if (!rendered) {
159
231
  return;
160
232
  }
161
- this.lastPaneResponse = response;
162
- if (!force && !this.shouldSendResponse(response)) {
163
- return;
164
- }
165
- this.lastSentResponse = response;
166
- this.lastSnapshotSentAt = Date.now();
167
- this.outputBuffer = response;
168
233
  this.lastOutputAt = new Date();
169
- if (!this.streamMessageId) {
170
- this.scheduleFlush();
171
- }
234
+ this.renderItems.set(rendered.id, rendered);
235
+ this.outputBuffer = this.renderCachedText();
236
+ await this.renderTurnCache(false);
172
237
  }
173
- async refreshCodexRunningState() {
174
- const exists = await this.codex.exists();
175
- if (!exists && this.codex.isRunning) {
176
- this.outputBuffer = '[Codex tmux session exited]';
177
- this.scheduleFlush();
178
- }
179
- }
180
- shouldSendResponse(response) {
181
- if (response === this.lastSentResponse) {
182
- return false;
183
- }
184
- return true;
185
- }
186
- scheduleFlush() {
187
- if (this.flushTimer) {
238
+ async handleCodexTurnCompleted() {
239
+ if (!this.turnActive) {
188
240
  return;
189
241
  }
190
- this.flushTimer = setTimeout(async () => {
191
- this.flushTimer = null;
192
- await this.flushOutput(false);
193
- }, this.config.flushIntervalMs);
242
+ await this.renderTurnCache(true);
243
+ this.turnActive = false;
244
+ this.outputBuffer = '';
245
+ this.resetTurnRenderState();
246
+ this.stopTypingIndicator();
194
247
  }
195
- async flushOutput(force) {
248
+ async renderTurnCache(force) {
196
249
  if (!this.activeChatId) {
197
250
  return;
198
251
  }
199
- const trimmed = this.outputBuffer.trimEnd();
200
- if (!trimmed) {
252
+ const text = this.renderCachedText();
253
+ if (!text) {
201
254
  if (force) {
202
- await this.queueTelegramSend(() => this.bot.api.sendMessage(this.activeChatId, 'No buffered output.'));
255
+ await this.queueTelegramSend(() => this.bot.api.sendMessage(this.activeChatId, 'No completed output yet.'));
203
256
  }
204
- this.outputBuffer = '';
205
257
  return;
206
258
  }
207
- this.outputBuffer = '';
208
- for (const chunk of chunkText(trimmed, this.config.maxTelegramChars)) {
209
- await this.sendFormattedMessage(chunk);
259
+ const markdownChunks = formatTelegramMarkdownChunks(text, this.config.maxTelegramChars);
260
+ const fallbackChunks = safePlainTelegramChunks(text, this.config.maxTelegramChars);
261
+ for (let index = 0; index < markdownChunks.length; index += 1) {
262
+ const markdown = markdownChunks[index];
263
+ const fallback = fallbackChunks[index] ?? safePlainTelegramText(markdown);
264
+ const messageId = this.renderMessageIds[index];
265
+ if (messageId) {
266
+ const edited = await this.editFormattedMarkdown(messageId, markdown, fallback);
267
+ if (!edited) {
268
+ const sent = await this.sendFormattedMarkdownAndReturn(markdown, fallback);
269
+ if (sent) {
270
+ this.renderMessageIds[index] = sent.message_id;
271
+ }
272
+ }
273
+ }
274
+ else {
275
+ const sent = await this.sendFormattedMarkdownAndReturn(markdown, fallback);
276
+ if (sent) {
277
+ this.renderMessageIds[index] = sent.message_id;
278
+ }
279
+ }
210
280
  }
281
+ this.renderMessageIds = this.renderMessageIds.slice(0, markdownChunks.length);
211
282
  }
212
- async editStreamMessage(text, force) {
213
- if (!this.activeChatId || !this.streamMessageId) {
214
- return;
215
- }
216
- const trimmed = text.trim();
217
- if (!trimmed || trimmed === this.lastStreamText) {
218
- return;
219
- }
220
- const now = Date.now();
221
- if (!force && !this.isMeaningfulStreamChange(trimmed, now)) {
222
- return;
223
- }
224
- const [chunk] = chunkText(trimmed, this.config.maxTelegramChars);
225
- await this.editFormattedMessage(chunk);
226
- this.lastStreamText = trimmed;
227
- this.lastStreamEditAt = now;
283
+ renderCachedText() {
284
+ return [...this.renderItems.values()]
285
+ .sort((first, second) => first.order - second.order)
286
+ .map((item) => item.text)
287
+ .filter(Boolean)
288
+ .join('\n\n')
289
+ .trim();
228
290
  }
229
- async sendFormattedMessage(text) {
291
+ async sendFormattedMarkdownAndReturn(markdown, fallback) {
230
292
  if (!this.activeChatId) {
231
- return;
293
+ return null;
232
294
  }
233
- const markdown = formatTelegramMarkdown(text);
234
295
  const chatId = this.activeChatId;
235
296
  const sent = await this.queueTelegramSend(() => this.bot.api.sendMessage(chatId, markdown, { parse_mode: 'MarkdownV2' }))
236
- .then(() => true)
237
- .catch(() => false);
238
- if (!sent) {
239
- await this.queueTelegramSend(() => this.bot.api.sendMessage(chatId, plainTelegramText(text)));
297
+ .catch((error) => {
298
+ console.error('Telegram Markdown send failed:', telegramErrorSummary(error));
299
+ return null;
300
+ });
301
+ if (sent) {
302
+ return sent;
240
303
  }
304
+ return this.queueTelegramSend(() => this.bot.api.sendMessage(chatId, fallback)).catch(() => null);
241
305
  }
242
- async editFormattedMessage(text) {
243
- if (!this.activeChatId || !this.streamMessageId) {
244
- return;
306
+ async editFormattedMarkdown(messageId, markdown, fallback) {
307
+ if (!this.activeChatId) {
308
+ return false;
245
309
  }
246
- const markdown = formatTelegramMarkdown(text);
247
- const edited = await this.bot.api.editMessageText(this.activeChatId, this.streamMessageId, markdown, {
310
+ const edited = await this.bot.api.editMessageText(this.activeChatId, messageId, markdown, {
248
311
  parse_mode: 'MarkdownV2',
249
- }).then(() => true).catch(() => false);
250
- if (!edited) {
251
- await this.bot.api.editMessageText(this.activeChatId, this.streamMessageId, plainTelegramText(text)).catch(() => undefined);
312
+ }).then(() => true).catch((error) => {
313
+ if (isTelegramMessageNotModified(error)) {
314
+ return true;
315
+ }
316
+ console.error('Telegram Markdown edit failed:', telegramErrorSummary(error));
317
+ return false;
318
+ });
319
+ if (edited) {
320
+ return true;
252
321
  }
322
+ return this.bot.api.editMessageText(this.activeChatId, messageId, fallback)
323
+ .then(() => true)
324
+ .catch(() => false);
253
325
  }
254
326
  queueTelegramSend(operation) {
255
327
  const run = async () => {
@@ -260,23 +332,15 @@ export class TelegramCodexBridge {
260
332
  this.sendQueue = next.then(() => undefined, () => undefined);
261
333
  return next;
262
334
  }
263
- isMeaningfulStreamChange(nextText, now) {
264
- if (now - this.lastStreamEditAt < this.config.streamEditIntervalMs) {
265
- return false;
266
- }
267
- if (nextText.length < this.lastStreamText.length) {
268
- return true;
269
- }
270
- return nextText.length - this.lastStreamText.length >= this.config.streamMinChangeChars;
271
- }
272
335
  statusText() {
273
336
  return [
274
337
  `Codex: ${this.codex.isRunning ? 'running' : 'stopped'}`,
275
- `tmux: ${this.config.tmuxSession}`,
338
+ 'Transport: stdio app-server',
276
339
  `CWD: ${this.config.codexCwd}`,
277
- `Command: ${[this.config.codexCommand, ...this.config.codexArgs].join(' ')}`,
278
- `Submit key: ${this.config.codexSubmitKey}`,
279
- `Submit delay: ${this.config.codexSubmitDelayMs}ms`,
340
+ `Command: ${this.config.codexCommand} app-server --stdio`,
341
+ `Approval policy: ${this.config.codexApprovalPolicy}`,
342
+ `Sandbox: ${this.config.codexSandbox}`,
343
+ `Completed items: ${this.renderItems.size}`,
280
344
  `Buffered chars: ${this.outputBuffer.length}`,
281
345
  `Last output: ${this.lastOutputAt?.toISOString() ?? 'none'}`,
282
346
  ].join('\n');
@@ -285,24 +349,55 @@ export class TelegramCodexBridge {
285
349
  return [
286
350
  'Telegram ↔ Codex bridge commands:',
287
351
  '/status - show bridge status',
288
- '/flush - send buffered Codex output now',
289
- '/interrupt - send Ctrl-C to Codex',
290
- '/restart - restart Codex session',
291
- '/stop - stop Codex session',
352
+ '/flush - send completed Codex output now',
353
+ '/interrupt - interrupt the active Codex turn',
354
+ '/restart - restart Codex app-server',
355
+ '/stop - stop Codex app-server',
292
356
  '',
293
- 'Any other text is sent directly to the Codex CLI.',
357
+ 'Any other text is sent directly to Codex app-server.',
294
358
  ].join('\n');
295
359
  }
296
360
  resetSnapshots() {
297
- this.lastPaneResponse = '';
298
- this.lastSentResponse = '';
299
- this.lastSnapshotSentAt = 0;
361
+ this.outputBuffer = '';
300
362
  this.lastOutputAt = null;
301
- this.streamMessageId = null;
302
- this.lastStreamText = '';
303
- this.lastStreamEditAt = 0;
363
+ this.resetTurnRenderState();
304
364
  this.stopTypingIndicator();
305
365
  }
366
+ resetTurnRenderState() {
367
+ this.turnActive = false;
368
+ this.renderItems.clear();
369
+ this.renderOrder = 0;
370
+ this.renderMessageIds = [];
371
+ }
372
+ }
373
+ function renderCompletedItem(item) {
374
+ const id = typeof item.id === 'string' ? item.id : `${item.type ?? 'item'}-${Date.now()}`;
375
+ const order = completedAtMs(item) ?? Date.now();
376
+ switch (item.type) {
377
+ case 'agentMessage': {
378
+ const text = typeof item.text === 'string' ? item.text.trim() : '';
379
+ return text ? { id, order, text } : null;
380
+ }
381
+ case 'commandExecution': {
382
+ const command = typeof item.command === 'string' ? compactCommand(item.command) : 'command';
383
+ const status = typeof item.status === 'string' ? item.status : 'completed';
384
+ const exitCode = typeof item.exitCode === 'number' ? `, exit ${item.exitCode}` : '';
385
+ const duration = typeof item.durationMs === 'number' ? `, ${Math.round(item.durationMs / 100) / 10}s` : '';
386
+ return {
387
+ id,
388
+ order,
389
+ text: [`🔧 Ran \`${command}\``, `Status: ${status}${exitCode}${duration}`].join('\n'),
390
+ };
391
+ }
392
+ case 'mcpToolCall': {
393
+ const server = typeof item.server === 'string' ? item.server : 'mcp';
394
+ const tool = typeof item.tool === 'string' ? item.tool : 'tool';
395
+ const status = typeof item.status === 'string' ? item.status : 'completed';
396
+ return { id, order, text: `🔌 Tool \`${server}/${tool}\`\nStatus: ${status}` };
397
+ }
398
+ default:
399
+ return null;
400
+ }
306
401
  }
307
402
  async function retryTelegramOperation(operation) {
308
403
  try {
@@ -329,6 +424,51 @@ function telegramRetryAfterMs(error) {
329
424
  }
330
425
  return null;
331
426
  }
427
+ function telegramErrorSummary(error) {
428
+ const description = error.description;
429
+ if (description) {
430
+ return description;
431
+ }
432
+ if (error instanceof Error) {
433
+ return error.message;
434
+ }
435
+ return String(error);
436
+ }
437
+ function isTelegramMessageNotModified(error) {
438
+ return /message is not modified/i.test(telegramErrorSummary(error));
439
+ }
440
+ function compactCommand(command) {
441
+ return command.replace(/\s+/g, ' ').slice(0, 160);
442
+ }
443
+ function safeFileName(name) {
444
+ return basename(name).replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-');
445
+ }
446
+ function extensionForMime(mimeType) {
447
+ switch (mimeType) {
448
+ case 'image/jpeg':
449
+ return '.jpg';
450
+ case 'image/png':
451
+ return '.png';
452
+ case 'image/webp':
453
+ return '.webp';
454
+ case 'image/gif':
455
+ return '.gif';
456
+ case 'application/pdf':
457
+ return '.pdf';
458
+ case 'video/mp4':
459
+ return '.mp4';
460
+ case 'audio/mpeg':
461
+ return '.mp3';
462
+ case 'audio/ogg':
463
+ return '.ogg';
464
+ default:
465
+ return '';
466
+ }
467
+ }
468
+ function completedAtMs(item) {
469
+ const value = item.completedAtMs;
470
+ return typeof value === 'number' ? value : null;
471
+ }
332
472
  function sleep(ms) {
333
473
  return new Promise((resolve) => setTimeout(resolve, ms));
334
474
  }