@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.
- package/.env.example +21 -9
- package/README.md +20 -46
- package/dist/codex-session.js +212 -39
- package/dist/config.js +2 -12
- package/dist/env.js +2 -9
- package/dist/telegram-bridge.js +290 -150
- package/dist/text.js +113 -137
- package/package.json +2 -2
- package/dist/tmux.js +0 -25
package/dist/telegram-bridge.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
68
|
-
|
|
67
|
+
this.resetTurnRenderState();
|
|
68
|
+
this.turnActive = true;
|
|
69
69
|
const streamMessage = await context.reply('Codex is working…');
|
|
70
|
-
this.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
143
|
-
if (!this.
|
|
225
|
+
async handleCodexItemCompleted(item) {
|
|
226
|
+
if (!this.turnActive) {
|
|
144
227
|
return;
|
|
145
228
|
}
|
|
146
|
-
const
|
|
147
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
234
|
+
this.renderItems.set(rendered.id, rendered);
|
|
235
|
+
this.outputBuffer = this.renderCachedText();
|
|
236
|
+
await this.renderTurnCache(false);
|
|
172
237
|
}
|
|
173
|
-
async
|
|
174
|
-
|
|
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.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
242
|
+
await this.renderTurnCache(true);
|
|
243
|
+
this.turnActive = false;
|
|
244
|
+
this.outputBuffer = '';
|
|
245
|
+
this.resetTurnRenderState();
|
|
246
|
+
this.stopTypingIndicator();
|
|
194
247
|
}
|
|
195
|
-
async
|
|
248
|
+
async renderTurnCache(force) {
|
|
196
249
|
if (!this.activeChatId) {
|
|
197
250
|
return;
|
|
198
251
|
}
|
|
199
|
-
const
|
|
200
|
-
if (!
|
|
252
|
+
const text = this.renderCachedText();
|
|
253
|
+
if (!text) {
|
|
201
254
|
if (force) {
|
|
202
|
-
await this.queueTelegramSend(() => this.bot.api.sendMessage(this.activeChatId, 'No
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
-
.
|
|
237
|
-
.
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
243
|
-
if (!this.activeChatId
|
|
244
|
-
return;
|
|
306
|
+
async editFormattedMarkdown(messageId, markdown, fallback) {
|
|
307
|
+
if (!this.activeChatId) {
|
|
308
|
+
return false;
|
|
245
309
|
}
|
|
246
|
-
const
|
|
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(() =>
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
338
|
+
'Transport: stdio app-server',
|
|
276
339
|
`CWD: ${this.config.codexCwd}`,
|
|
277
|
-
`Command: ${
|
|
278
|
-
`
|
|
279
|
-
`
|
|
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
|
|
289
|
-
'/interrupt -
|
|
290
|
-
'/restart - restart Codex
|
|
291
|
-
'/stop - stop Codex
|
|
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
|
|
357
|
+
'Any other text is sent directly to Codex app-server.',
|
|
294
358
|
].join('\n');
|
|
295
359
|
}
|
|
296
360
|
resetSnapshots() {
|
|
297
|
-
this.
|
|
298
|
-
this.lastSentResponse = '';
|
|
299
|
-
this.lastSnapshotSentAt = 0;
|
|
361
|
+
this.outputBuffer = '';
|
|
300
362
|
this.lastOutputAt = null;
|
|
301
|
-
this.
|
|
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
|
}
|