@blunking/codexlink 0.1.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/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/blun-codex.js +26 -0
- package/blun-codex.cmd +3 -0
- package/blun-codex.ps1 +110 -0
- package/package.json +37 -0
- package/profiles/default.json +20 -0
- package/start-codex-agent.ps1 +727 -0
- package/start-codex.cmd +2 -0
- package/telegram-doctor.ps1 +125 -0
- package/telegram-plugin/.codex-plugin/plugin.json +6 -0
- package/telegram-plugin/.env.example +9 -0
- package/telegram-plugin/.mcp.json +8 -0
- package/telegram-plugin/README.md +68 -0
- package/telegram-plugin/app-server-cli.js +98 -0
- package/telegram-plugin/dispatcher.js +37 -0
- package/telegram-plugin/lib/app-server-client.js +290 -0
- package/telegram-plugin/lib/bridge.js +944 -0
- package/telegram-plugin/lib/codex.js +185 -0
- package/telegram-plugin/lib/env.js +46 -0
- package/telegram-plugin/lib/paths.js +45 -0
- package/telegram-plugin/lib/sidecars.js +142 -0
- package/telegram-plugin/lib/storage.js +49 -0
- package/telegram-plugin/lib/telegram.js +37 -0
- package/telegram-plugin/package.json +10 -0
- package/telegram-plugin/poller.js +37 -0
- package/telegram-plugin/responder.js +37 -0
- package/telegram-plugin/server.js +140 -0
- package/telegram-plugin/sidecar-manager.js +8 -0
- package/telegram-status.ps1 +160 -0
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { listLoadedThreadsOverWs, readThreadOverWs } from "./app-server-client.js";
|
|
4
|
+
import { loadConfig } from "./env.js";
|
|
5
|
+
import { injectIntoThread } from "./codex.js";
|
|
6
|
+
import { getUpdates, sendMessage } from "./telegram.js";
|
|
7
|
+
import { appendJsonl, appendLog, defaultState, loadJson, nowIso, readTail, saveJson } from "./storage.js";
|
|
8
|
+
|
|
9
|
+
function loadState(config) {
|
|
10
|
+
return loadJson(config.paths.stateFile, defaultState());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function saveStateForConfig(config, state) {
|
|
14
|
+
saveJson(config.paths.stateFile, state);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function queueKey(entry) {
|
|
18
|
+
return `${entry.chatId}:${entry.messageId}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pendingReplyKey(entry) {
|
|
22
|
+
return entry.turnId || `${entry.threadId || ""}:${entry.chatId}:${entry.messageId}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function statusWeight(status) {
|
|
26
|
+
switch (status) {
|
|
27
|
+
case "delivered":
|
|
28
|
+
case "error":
|
|
29
|
+
return 4;
|
|
30
|
+
case "submitted":
|
|
31
|
+
return 2;
|
|
32
|
+
case "queued":
|
|
33
|
+
default:
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pickIsoLater(left, right) {
|
|
39
|
+
if (!left) {
|
|
40
|
+
return right || null;
|
|
41
|
+
}
|
|
42
|
+
if (!right) {
|
|
43
|
+
return left || null;
|
|
44
|
+
}
|
|
45
|
+
return left >= right ? left : right;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pickLatestRecord(current, incoming) {
|
|
49
|
+
if (!current && !incoming) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (!current) {
|
|
53
|
+
return { ...incoming };
|
|
54
|
+
}
|
|
55
|
+
if (!incoming) {
|
|
56
|
+
return { ...current };
|
|
57
|
+
}
|
|
58
|
+
const currentStamp = current.ts || current.deliveredAt || current.lastAttemptAt || "";
|
|
59
|
+
const incomingStamp = incoming.ts || incoming.deliveredAt || incoming.lastAttemptAt || "";
|
|
60
|
+
if (incomingStamp > currentStamp) {
|
|
61
|
+
return { ...incoming };
|
|
62
|
+
}
|
|
63
|
+
if (incomingStamp < currentStamp) {
|
|
64
|
+
return { ...current };
|
|
65
|
+
}
|
|
66
|
+
const currentId = Number(current.messageId || 0);
|
|
67
|
+
const incomingId = Number(incoming.messageId || 0);
|
|
68
|
+
return incomingId >= currentId ? { ...incoming } : { ...current };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mergeQueueEntry(current, incoming) {
|
|
72
|
+
if (!current && !incoming) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (!current) {
|
|
76
|
+
return { ...incoming };
|
|
77
|
+
}
|
|
78
|
+
if (!incoming) {
|
|
79
|
+
return { ...current };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const merged = {
|
|
83
|
+
...current,
|
|
84
|
+
...incoming
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (statusWeight(current.status) > statusWeight(incoming.status)) {
|
|
88
|
+
merged.status = current.status;
|
|
89
|
+
} else if (statusWeight(incoming.status) > statusWeight(current.status)) {
|
|
90
|
+
merged.status = incoming.status;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
merged.attempts = Math.max(Number(current.attempts || 0), Number(incoming.attempts || 0));
|
|
94
|
+
merged.lastAttemptAt = pickIsoLater(current.lastAttemptAt, incoming.lastAttemptAt);
|
|
95
|
+
merged.submittedAt = pickIsoLater(current.submittedAt, incoming.submittedAt);
|
|
96
|
+
merged.deliveredAt = pickIsoLater(current.deliveredAt, incoming.deliveredAt);
|
|
97
|
+
merged.ts = pickIsoLater(current.ts, incoming.ts);
|
|
98
|
+
|
|
99
|
+
for (const field of ["threadId", "responsePreview", "stderr", "stdout", "chatType", "conversationKey", "groupTitle", "telegramThreadId", "senderIsBot"]) {
|
|
100
|
+
if (!merged[field]) {
|
|
101
|
+
merged[field] = current[field] || incoming[field] || null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return merged;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function mergeQueueLists(baseQueue, incomingQueue) {
|
|
109
|
+
const mergedByKey = new Map();
|
|
110
|
+
const orderedKeys = [];
|
|
111
|
+
|
|
112
|
+
for (const entry of baseQueue || []) {
|
|
113
|
+
const key = queueKey(entry);
|
|
114
|
+
if (!mergedByKey.has(key)) {
|
|
115
|
+
orderedKeys.push(key);
|
|
116
|
+
mergedByKey.set(key, { ...entry });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
mergedByKey.set(key, mergeQueueEntry(mergedByKey.get(key), entry));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const entry of incomingQueue || []) {
|
|
123
|
+
const key = queueKey(entry);
|
|
124
|
+
if (!mergedByKey.has(key)) {
|
|
125
|
+
orderedKeys.push(key);
|
|
126
|
+
mergedByKey.set(key, { ...entry });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
mergedByKey.set(key, mergeQueueEntry(mergedByKey.get(key), entry));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return orderedKeys
|
|
133
|
+
.map((key) => mergedByKey.get(key))
|
|
134
|
+
.sort((left, right) => {
|
|
135
|
+
const leftTs = left?.ts || left?.deliveredAt || "";
|
|
136
|
+
const rightTs = right?.ts || right?.deliveredAt || "";
|
|
137
|
+
if (leftTs !== rightTs) {
|
|
138
|
+
return leftTs.localeCompare(rightTs);
|
|
139
|
+
}
|
|
140
|
+
return Number(left?.messageId || 0) - Number(right?.messageId || 0);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function mergePendingReplyEntry(current, incoming) {
|
|
145
|
+
if (!current && !incoming) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (!current) {
|
|
149
|
+
return { ...incoming };
|
|
150
|
+
}
|
|
151
|
+
if (!incoming) {
|
|
152
|
+
return { ...current };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
...current,
|
|
157
|
+
...incoming,
|
|
158
|
+
sentAt: pickIsoLater(current.sentAt, incoming.sentAt),
|
|
159
|
+
status: incoming.status || current.status || "pending",
|
|
160
|
+
responsePreview: incoming.responsePreview || current.responsePreview || "",
|
|
161
|
+
responseMessageIds: Array.from(new Set([...(current.responseMessageIds || []), ...(incoming.responseMessageIds || [])]))
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function looksLikeBotSender(entry) {
|
|
166
|
+
if (entry?.senderIsBot === true) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
return /_bot$/i.test(String(entry?.user || "").trim());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function reconcilePendingRepliesInPlace(pendingReplies) {
|
|
173
|
+
const replies = Array.isArray(pendingReplies) ? pendingReplies : [];
|
|
174
|
+
const groups = new Map();
|
|
175
|
+
|
|
176
|
+
for (const entry of replies) {
|
|
177
|
+
const key = `${entry.chatId || ""}:${entry.conversationKey || ""}`;
|
|
178
|
+
if (!groups.has(key)) {
|
|
179
|
+
groups.set(key, []);
|
|
180
|
+
}
|
|
181
|
+
groups.get(key).push(entry);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const entries of groups.values()) {
|
|
185
|
+
entries.sort((left, right) => {
|
|
186
|
+
const leftCreated = String(left?.createdAt || "");
|
|
187
|
+
const rightCreated = String(right?.createdAt || "");
|
|
188
|
+
if (leftCreated !== rightCreated) {
|
|
189
|
+
return leftCreated.localeCompare(rightCreated);
|
|
190
|
+
}
|
|
191
|
+
return String(left?.messageId || "").localeCompare(String(right?.messageId || ""));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const latestSent = [...entries].reverse().find((entry) => entry.sentAt);
|
|
195
|
+
if (!latestSent) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
if (entry === latestSent || entry.sentAt) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (String(entry.createdAt || "") > String(latestSent.createdAt || "")) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
entry.status = entry.status === "error" ? entry.status : "superseded";
|
|
207
|
+
entry.sentAt = latestSent.sentAt;
|
|
208
|
+
if (!entry.responsePreview) {
|
|
209
|
+
entry.responsePreview = latestSent.responsePreview || `Superseded by reply to message ${latestSent.messageId}`;
|
|
210
|
+
}
|
|
211
|
+
if ((!entry.responseMessageIds || entry.responseMessageIds.length === 0) && Array.isArray(latestSent.responseMessageIds)) {
|
|
212
|
+
entry.responseMessageIds = [...latestSent.responseMessageIds];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return replies;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function mergePendingReplyLists(baseReplies, incomingReplies) {
|
|
221
|
+
const merged = new Map();
|
|
222
|
+
const order = [];
|
|
223
|
+
|
|
224
|
+
for (const entry of baseReplies || []) {
|
|
225
|
+
const key = pendingReplyKey(entry);
|
|
226
|
+
if (!merged.has(key)) {
|
|
227
|
+
order.push(key);
|
|
228
|
+
merged.set(key, { ...entry });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
merged.set(key, mergePendingReplyEntry(merged.get(key), entry));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const entry of incomingReplies || []) {
|
|
235
|
+
const key = pendingReplyKey(entry);
|
|
236
|
+
if (!merged.has(key)) {
|
|
237
|
+
order.push(key);
|
|
238
|
+
merged.set(key, { ...entry });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
merged.set(key, mergePendingReplyEntry(merged.get(key), entry));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return order.map((key) => merged.get(key));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function syncRecordFromQueue(record, queue) {
|
|
248
|
+
if (!record) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const match = (queue || []).find((entry) => queueKey(entry) === queueKey(record));
|
|
252
|
+
return match ? mergeQueueEntry(record, match) : record;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function mergeStateSnapshots(currentState, incomingState) {
|
|
256
|
+
const merged = {
|
|
257
|
+
...currentState,
|
|
258
|
+
...incomingState
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
merged.offset = Math.max(Number(currentState.offset || 0), Number(incomingState.offset || 0));
|
|
262
|
+
merged.queue = mergeQueueLists(currentState.queue || [], incomingState.queue || []);
|
|
263
|
+
merged.pendingReplies = mergePendingReplyLists(currentState.pendingReplies || [], incomingState.pendingReplies || []);
|
|
264
|
+
merged.replyOffsets = {
|
|
265
|
+
...(currentState.replyOffsets || {}),
|
|
266
|
+
...(incomingState.replyOffsets || {})
|
|
267
|
+
};
|
|
268
|
+
merged.replyBuffers = {
|
|
269
|
+
...(currentState.replyBuffers || {}),
|
|
270
|
+
...(incomingState.replyBuffers || {})
|
|
271
|
+
};
|
|
272
|
+
merged.lastInbound = syncRecordFromQueue(
|
|
273
|
+
pickLatestRecord(currentState.lastInbound || null, incomingState.lastInbound || null),
|
|
274
|
+
merged.queue
|
|
275
|
+
);
|
|
276
|
+
merged.lastOutbound = pickLatestRecord(currentState.lastOutbound || null, incomingState.lastOutbound || null);
|
|
277
|
+
merged.lastPollAt = pickIsoLater(currentState.lastPollAt, incomingState.lastPollAt);
|
|
278
|
+
merged.lastInjectAt = pickIsoLater(currentState.lastInjectAt, incomingState.lastInjectAt);
|
|
279
|
+
merged.currentThreadId = incomingState.currentThreadId || currentState.currentThreadId || "";
|
|
280
|
+
return merged;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function normalizeInbound(message) {
|
|
284
|
+
const text = message.text ?? message.caption ?? "";
|
|
285
|
+
const chatType = String(message.chat?.type || "unknown");
|
|
286
|
+
const telegramThreadId = message.message_thread_id ? String(message.message_thread_id) : "";
|
|
287
|
+
const chatId = String(message.chat.id);
|
|
288
|
+
return {
|
|
289
|
+
chatId,
|
|
290
|
+
messageId: String(message.message_id),
|
|
291
|
+
replyToMessageId: message.reply_to_message ? String(message.reply_to_message.message_id) : "",
|
|
292
|
+
telegramThreadId,
|
|
293
|
+
chatType,
|
|
294
|
+
senderIsBot: Boolean(message.from?.is_bot),
|
|
295
|
+
conversationKey: chatType === "private"
|
|
296
|
+
? `${chatId}:dm`
|
|
297
|
+
: `${chatId}:${telegramThreadId || "root"}`,
|
|
298
|
+
groupTitle: message.chat?.title || "",
|
|
299
|
+
user: message.from?.username || message.from?.first_name || "unknown",
|
|
300
|
+
userId: message.from?.id ? String(message.from.id) : "",
|
|
301
|
+
text,
|
|
302
|
+
ts: nowIso(),
|
|
303
|
+
status: "queued",
|
|
304
|
+
attempts: 0,
|
|
305
|
+
lastAttemptAt: null
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function normalizeTelegramThreadId(value) {
|
|
310
|
+
return String(value || "").trim();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function splitTelegramText(text, maxLength = 3500) {
|
|
314
|
+
const value = String(text || "").trim();
|
|
315
|
+
if (!value) {
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
if (value.length <= maxLength) {
|
|
319
|
+
return [value];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const chunks = [];
|
|
323
|
+
let remaining = value;
|
|
324
|
+
while (remaining.length > maxLength) {
|
|
325
|
+
let cut = remaining.lastIndexOf("\n\n", maxLength);
|
|
326
|
+
if (cut < 0 || cut < maxLength * 0.5) {
|
|
327
|
+
cut = remaining.lastIndexOf("\n", maxLength);
|
|
328
|
+
}
|
|
329
|
+
if (cut < 0 || cut < maxLength * 0.5) {
|
|
330
|
+
cut = remaining.lastIndexOf(" ", maxLength);
|
|
331
|
+
}
|
|
332
|
+
if (cut < 0 || cut < maxLength * 0.5) {
|
|
333
|
+
cut = maxLength;
|
|
334
|
+
}
|
|
335
|
+
chunks.push(remaining.slice(0, cut).trim());
|
|
336
|
+
remaining = remaining.slice(cut).trim();
|
|
337
|
+
}
|
|
338
|
+
if (remaining) {
|
|
339
|
+
chunks.push(remaining);
|
|
340
|
+
}
|
|
341
|
+
return chunks.filter(Boolean);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function readJsonlDelta(path, offset, carry = "") {
|
|
345
|
+
if (!path || !existsSync(path)) {
|
|
346
|
+
return { nextOffset: 0, carry, items: [] };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const fd = openSync(path, "r");
|
|
350
|
+
try {
|
|
351
|
+
const stat = fstatSync(fd);
|
|
352
|
+
const safeOffset = Math.max(0, Math.min(Number(offset || 0), stat.size));
|
|
353
|
+
const byteLength = Math.max(0, stat.size - safeOffset);
|
|
354
|
+
let chunk = "";
|
|
355
|
+
if (byteLength > 0) {
|
|
356
|
+
const buffer = Buffer.alloc(byteLength);
|
|
357
|
+
readSync(fd, buffer, 0, byteLength, safeOffset);
|
|
358
|
+
chunk = buffer.toString("utf8");
|
|
359
|
+
}
|
|
360
|
+
const combined = `${carry || ""}${chunk}`;
|
|
361
|
+
const lines = combined.split(/\r?\n/);
|
|
362
|
+
const trailingCarry = combined.endsWith("\n") ? "" : (lines.pop() || "");
|
|
363
|
+
const items = [];
|
|
364
|
+
for (const line of lines) {
|
|
365
|
+
const trimmed = line.trim();
|
|
366
|
+
if (!trimmed) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
items.push(JSON.parse(trimmed));
|
|
371
|
+
} catch {
|
|
372
|
+
// Keep moving; malformed partial lines are handled by carry.
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
nextOffset: stat.size,
|
|
377
|
+
carry: trailingCarry,
|
|
378
|
+
items
|
|
379
|
+
};
|
|
380
|
+
} finally {
|
|
381
|
+
closeSync(fd);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function resolveThreadSessionPath(config, threadId) {
|
|
386
|
+
if (!threadId) {
|
|
387
|
+
return "";
|
|
388
|
+
}
|
|
389
|
+
if (config.appServerWsUrl) {
|
|
390
|
+
try {
|
|
391
|
+
const readResult = await readThreadOverWs({
|
|
392
|
+
wsUrl: config.appServerWsUrl,
|
|
393
|
+
threadId,
|
|
394
|
+
timeoutMs: 5000
|
|
395
|
+
});
|
|
396
|
+
return String(readResult.threadPath || "").trim();
|
|
397
|
+
} catch {
|
|
398
|
+
return "";
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return findRolloutFile(config.paths.sessionsDir, threadId) || "";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function buildPendingReplyEntry(message, threadId, turnId, sessionPath, sessionOffset) {
|
|
405
|
+
return {
|
|
406
|
+
turnId: String(turnId || "").trim(),
|
|
407
|
+
threadId: String(threadId || "").trim(),
|
|
408
|
+
sessionPath: String(sessionPath || "").trim(),
|
|
409
|
+
sessionOffset: Number(sessionOffset || 0),
|
|
410
|
+
chatId: String(message.chatId || "").trim(),
|
|
411
|
+
messageId: String(message.messageId || "").trim(),
|
|
412
|
+
replyToMessageId: String(message.messageId || "").trim(),
|
|
413
|
+
telegramThreadId: normalizeTelegramThreadId(message.telegramThreadId),
|
|
414
|
+
chatType: String(message.chatType || "").trim(),
|
|
415
|
+
senderIsBot: Boolean(message.senderIsBot),
|
|
416
|
+
conversationKey: String(message.conversationKey || "").trim(),
|
|
417
|
+
groupTitle: String(message.groupTitle || "").trim(),
|
|
418
|
+
user: String(message.user || "").trim(),
|
|
419
|
+
sourceText: String(message.text || ""),
|
|
420
|
+
createdAt: nowIso(),
|
|
421
|
+
status: "pending",
|
|
422
|
+
sentAt: null,
|
|
423
|
+
responsePreview: "",
|
|
424
|
+
responseMessageIds: []
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function parseUnixSeconds(isoString) {
|
|
429
|
+
const millis = Date.parse(isoString || "");
|
|
430
|
+
if (Number.isNaN(millis)) {
|
|
431
|
+
return Math.floor(Date.now() / 1000);
|
|
432
|
+
}
|
|
433
|
+
return Math.floor(millis / 1000);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function buildHistoryText(message) {
|
|
437
|
+
const text = String(message.text || "").trim();
|
|
438
|
+
const chatType = String(message.chatType || "");
|
|
439
|
+
if (chatType === "private" || !chatType) {
|
|
440
|
+
return text;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const title = String(message.groupTitle || message.chatId || "group").trim();
|
|
444
|
+
const actor = String(message.user || "unknown").trim();
|
|
445
|
+
const threadSuffix = message.telegramThreadId ? ` / thread ${message.telegramThreadId}` : "";
|
|
446
|
+
return `[Telegram ${title}${threadSuffix} @${actor}] ${text}`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function appendHistoryEntry(config, threadId, message) {
|
|
450
|
+
if (!config.paths.historyFile || !threadId) {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const text = buildHistoryText(message);
|
|
455
|
+
if (!text) {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const entry = {
|
|
460
|
+
session_id: threadId,
|
|
461
|
+
ts: parseUnixSeconds(message.ts),
|
|
462
|
+
text
|
|
463
|
+
};
|
|
464
|
+
appendJsonl(config.paths.historyFile, entry);
|
|
465
|
+
return entry;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function findRolloutFile(sessionsDir, threadId) {
|
|
469
|
+
if (!sessionsDir || !threadId || !existsSync(sessionsDir)) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const stack = [sessionsDir];
|
|
474
|
+
const matches = [];
|
|
475
|
+
while (stack.length > 0) {
|
|
476
|
+
const currentDir = stack.pop();
|
|
477
|
+
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
|
478
|
+
const absolutePath = join(currentDir, entry.name);
|
|
479
|
+
if (entry.isDirectory()) {
|
|
480
|
+
stack.push(absolutePath);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (!entry.isFile()) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (!entry.name.endsWith(".jsonl") || !entry.name.includes(threadId)) {
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
matches.push({
|
|
490
|
+
path: absolutePath,
|
|
491
|
+
mtimeMs: statSync(absolutePath).mtimeMs
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
matches.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
497
|
+
return matches[0]?.path || null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function hasVisibleInboundSubmission(config, threadId, message) {
|
|
501
|
+
const rolloutPath = findRolloutFile(config.paths.sessionsDir, threadId);
|
|
502
|
+
if (!rolloutPath) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const rolloutText = readFileSync(rolloutPath, "utf8");
|
|
507
|
+
return rolloutText.includes(`Chat ID: ${message.chatId}`)
|
|
508
|
+
&& rolloutText.includes(`Message ID: ${message.messageId}`)
|
|
509
|
+
&& rolloutText.includes(`Timestamp: ${message.ts}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function promoteVisibleQueuedEntry(config, state, threadId, message) {
|
|
513
|
+
if (!message?.historyLoggedAt || message.status !== "queued") {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
if (!hasVisibleInboundSubmission(config, threadId, message)) {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
message.status = "submitted";
|
|
521
|
+
message.submittedAt = message.submittedAt || nowIso();
|
|
522
|
+
message.threadId = threadId;
|
|
523
|
+
appendLog(config.paths.activityFile, `INJECT_SUBMITTED thread=${threadId} message=${message.messageId}`);
|
|
524
|
+
state.lastInjectAt = nowIso();
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function resolveActiveThreadId(config, state, preferredThreadId) {
|
|
529
|
+
const fallbackThreadId = String(preferredThreadId || state.currentThreadId || config.currentThreadId || "").trim();
|
|
530
|
+
if (!config.appServerWsUrl) {
|
|
531
|
+
return fallbackThreadId;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
const loaded = await listLoadedThreadsOverWs({
|
|
536
|
+
wsUrl: config.appServerWsUrl,
|
|
537
|
+
timeoutMs: 5000
|
|
538
|
+
});
|
|
539
|
+
const loadedIds = Array.isArray(loaded?.data) ? loaded.data.map((value) => String(value || "").trim()).filter(Boolean) : [];
|
|
540
|
+
if (loadedIds.length === 0) {
|
|
541
|
+
return fallbackThreadId;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
let bestThreadId = loadedIds[loadedIds.length - 1];
|
|
545
|
+
let bestScore = Number.NEGATIVE_INFINITY;
|
|
546
|
+
|
|
547
|
+
for (const candidateThreadId of loadedIds) {
|
|
548
|
+
let score = 0;
|
|
549
|
+
try {
|
|
550
|
+
const readResult = await readThreadOverWs({
|
|
551
|
+
wsUrl: config.appServerWsUrl,
|
|
552
|
+
threadId: candidateThreadId,
|
|
553
|
+
timeoutMs: 5000
|
|
554
|
+
});
|
|
555
|
+
const sessionPath = String(readResult?.response?.result?.thread?.path || "").trim();
|
|
556
|
+
if (sessionPath && existsSync(sessionPath)) {
|
|
557
|
+
score = statSync(sessionPath).mtimeMs;
|
|
558
|
+
}
|
|
559
|
+
} catch {
|
|
560
|
+
score = 0;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (score >= bestScore) {
|
|
564
|
+
bestScore = score;
|
|
565
|
+
bestThreadId = candidateThreadId;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (state.currentThreadId !== bestThreadId) {
|
|
570
|
+
state.currentThreadId = bestThreadId;
|
|
571
|
+
saveStateForConfig(config, state);
|
|
572
|
+
appendLog(config.paths.activityFile, `REMOTE_ACTIVE_THREAD thread=${bestThreadId}`);
|
|
573
|
+
}
|
|
574
|
+
return bestThreadId;
|
|
575
|
+
} catch (error) {
|
|
576
|
+
const message = String(error?.message || error).replace(/\s+/g, " ").slice(0, 180);
|
|
577
|
+
appendLog(config.paths.activityFile, `REMOTE_ACTIVE_THREAD_ERROR ${message}`);
|
|
578
|
+
return fallbackThreadId;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function bridgeStatus() {
|
|
583
|
+
const config = loadConfig();
|
|
584
|
+
const state = loadState(config);
|
|
585
|
+
const queued = state.queue.filter((item) => item.status === "queued");
|
|
586
|
+
const submitted = state.queue.filter((item) => item.status === "submitted");
|
|
587
|
+
const pendingReplies = (state.pendingReplies || []).filter((item) => !item.sentAt && item.status !== "error");
|
|
588
|
+
return {
|
|
589
|
+
agent: config.agentName,
|
|
590
|
+
allowedChatId: config.allowedChatId || null,
|
|
591
|
+
boundThreadId: state.currentThreadId || config.currentThreadId || null,
|
|
592
|
+
queueDepth: queued.length,
|
|
593
|
+
submittedDepth: submitted.length,
|
|
594
|
+
pendingReplyDepth: pendingReplies.length,
|
|
595
|
+
lastInbound: state.lastInbound,
|
|
596
|
+
lastOutbound: state.lastOutbound,
|
|
597
|
+
lastPollAt: state.lastPollAt,
|
|
598
|
+
lastInjectAt: state.lastInjectAt,
|
|
599
|
+
stateDir: config.paths.root,
|
|
600
|
+
note: "No autonomous shadow bot. Telegram is queued here and only injected into a real Codex thread on demand."
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function sendOutboundChunks(config, state, options) {
|
|
605
|
+
const chatId = String(options.chatId || "").trim();
|
|
606
|
+
const text = String(options.text || "").trim();
|
|
607
|
+
const replyToMessageId = String(options.replyToMessageId || "").trim();
|
|
608
|
+
const telegramThreadId = normalizeTelegramThreadId(options.telegramThreadId);
|
|
609
|
+
const source = String(options.source || "manual").trim();
|
|
610
|
+
const sourceTurnId = String(options.sourceTurnId || "").trim();
|
|
611
|
+
if (!chatId) {
|
|
612
|
+
throw new Error("No chat id available for Telegram outbound.");
|
|
613
|
+
}
|
|
614
|
+
if (!text) {
|
|
615
|
+
throw new Error("Outbound Telegram text is empty.");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const chunks = splitTelegramText(text);
|
|
619
|
+
const messageIds = [];
|
|
620
|
+
let lastOutbound = null;
|
|
621
|
+
|
|
622
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
623
|
+
const chunk = chunks[index];
|
|
624
|
+
const result = await sendMessage(config, {
|
|
625
|
+
chatId,
|
|
626
|
+
text: chunk,
|
|
627
|
+
replyToMessageId: index === 0 ? replyToMessageId : "",
|
|
628
|
+
telegramThreadId
|
|
629
|
+
});
|
|
630
|
+
const outbound = {
|
|
631
|
+
chatId,
|
|
632
|
+
messageId: String(result.message_id),
|
|
633
|
+
replyToMessageId: index === 0 ? replyToMessageId : "",
|
|
634
|
+
telegramThreadId: telegramThreadId || null,
|
|
635
|
+
text: chunk,
|
|
636
|
+
ts: nowIso(),
|
|
637
|
+
source,
|
|
638
|
+
sourceTurnId: sourceTurnId || null
|
|
639
|
+
};
|
|
640
|
+
state.lastOutbound = outbound;
|
|
641
|
+
appendJsonl(config.paths.outboxFile, outbound);
|
|
642
|
+
appendLog(config.paths.activityFile, `OUT_${source.toUpperCase()} chat=${chatId} reply_to=${outbound.replyToMessageId || "-"} thread=${telegramThreadId || "-"} message=${outbound.messageId}: ${chunk.replace(/\s+/g, " ").slice(0, 180)}`);
|
|
643
|
+
messageIds.push(outbound.messageId);
|
|
644
|
+
lastOutbound = outbound;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return {
|
|
648
|
+
ok: true,
|
|
649
|
+
outbound: lastOutbound,
|
|
650
|
+
messageIds
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export function bindCurrentThread(threadId) {
|
|
655
|
+
const config = loadConfig();
|
|
656
|
+
const state = loadState(config);
|
|
657
|
+
const resolved = (threadId || process.env.CODEX_THREAD_ID || config.currentThreadId || "").trim();
|
|
658
|
+
if (!resolved) {
|
|
659
|
+
throw new Error("No thread id provided and CODEX_THREAD_ID is not available.");
|
|
660
|
+
}
|
|
661
|
+
state.currentThreadId = resolved;
|
|
662
|
+
saveStateForConfig(config, state);
|
|
663
|
+
appendLog(config.paths.activityFile, `BOUND thread=${resolved}`);
|
|
664
|
+
return {
|
|
665
|
+
ok: true,
|
|
666
|
+
threadId: resolved
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export async function pollOnce() {
|
|
671
|
+
const config = loadConfig();
|
|
672
|
+
const state = loadState(config);
|
|
673
|
+
const startOffset = Number(state.offset || 0);
|
|
674
|
+
const updates = await getUpdates(config, startOffset);
|
|
675
|
+
let captured = 0;
|
|
676
|
+
let ignored = 0;
|
|
677
|
+
|
|
678
|
+
for (const update of updates) {
|
|
679
|
+
state.offset = Math.max(Number(state.offset || 0), Number(update.update_id) + 1);
|
|
680
|
+
if (!update.message) {
|
|
681
|
+
ignored += 1;
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const inbound = normalizeInbound(update.message);
|
|
685
|
+
if (config.allowedChatId && inbound.chatId !== config.allowedChatId) {
|
|
686
|
+
ignored += 1;
|
|
687
|
+
appendLog(config.paths.activityFile, `IGNORED chat=${inbound.chatId} message=${inbound.messageId}`);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (!inbound.text.trim()) {
|
|
691
|
+
ignored += 1;
|
|
692
|
+
appendLog(config.paths.activityFile, `IGNORED_EMPTY chat=${inbound.chatId} message=${inbound.messageId}`);
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
state.queue.push(inbound);
|
|
696
|
+
state.lastInbound = inbound;
|
|
697
|
+
appendJsonl(config.paths.inboxFile, inbound);
|
|
698
|
+
appendLog(config.paths.activityFile, `IN chat=${inbound.chatId} message=${inbound.messageId} user=${inbound.user}: ${inbound.text.replace(/\s+/g, " ").slice(0, 180)}`);
|
|
699
|
+
captured += 1;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
state.lastPollAt = nowIso();
|
|
703
|
+
const latestState = loadState(config);
|
|
704
|
+
saveStateForConfig(config, mergeStateSnapshots(latestState, state));
|
|
705
|
+
return {
|
|
706
|
+
ok: true,
|
|
707
|
+
startOffset,
|
|
708
|
+
nextOffset: state.offset,
|
|
709
|
+
captured,
|
|
710
|
+
ignored
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export function listQueue(limit = 10) {
|
|
715
|
+
const config = loadConfig();
|
|
716
|
+
const state = loadState(config);
|
|
717
|
+
return state.queue.slice(-Math.max(1, limit));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export async function injectNext(threadId) {
|
|
721
|
+
const config = loadConfig();
|
|
722
|
+
const state = loadState(config);
|
|
723
|
+
const useAppServer = Boolean(config.appServerWsUrl);
|
|
724
|
+
const preferredThreadId = (
|
|
725
|
+
threadId
|
|
726
|
+
|| (useAppServer ? config.currentThreadId : state.currentThreadId)
|
|
727
|
+
|| (useAppServer ? state.currentThreadId : config.currentThreadId)
|
|
728
|
+
|| ""
|
|
729
|
+
).trim();
|
|
730
|
+
const resolvedThreadId = await resolveActiveThreadId(config, state, preferredThreadId);
|
|
731
|
+
if (!resolvedThreadId) {
|
|
732
|
+
throw new Error("No bound thread id. Use bridge_bind_current_thread first.");
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
let promoted = 0;
|
|
736
|
+
if (!useAppServer) {
|
|
737
|
+
for (const entry of state.queue) {
|
|
738
|
+
if (promoteVisibleQueuedEntry(config, state, resolvedThreadId, entry)) {
|
|
739
|
+
promoted += 1;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (promoted > 0) {
|
|
744
|
+
saveStateForConfig(config, state);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const next = state.queue.find((item) => item.status === "queued");
|
|
748
|
+
if (!next) {
|
|
749
|
+
return {
|
|
750
|
+
ok: true,
|
|
751
|
+
status: promoted > 0 ? "submitted" : "empty"
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
next.attempts = Number(next.attempts || 0) + 1;
|
|
756
|
+
next.lastAttemptAt = nowIso();
|
|
757
|
+
let sessionPath = "";
|
|
758
|
+
let sessionOffset = 0;
|
|
759
|
+
if (useAppServer) {
|
|
760
|
+
sessionPath = await resolveThreadSessionPath(config, resolvedThreadId);
|
|
761
|
+
if (sessionPath && existsSync(sessionPath)) {
|
|
762
|
+
sessionOffset = statSync(sessionPath).size;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (!useAppServer && !next.historyLoggedAt) {
|
|
766
|
+
const historyEntry = appendHistoryEntry(config, resolvedThreadId, next);
|
|
767
|
+
if (historyEntry) {
|
|
768
|
+
next.historyLoggedAt = nowIso();
|
|
769
|
+
next.historyText = historyEntry.text;
|
|
770
|
+
appendLog(config.paths.activityFile, `HISTORY_APPEND thread=${resolvedThreadId} message=${next.messageId}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
saveStateForConfig(config, state);
|
|
774
|
+
appendLog(config.paths.activityFile, `INJECT_START thread=${resolvedThreadId} message=${next.messageId}`);
|
|
775
|
+
const result = await injectIntoThread(config, next, resolvedThreadId);
|
|
776
|
+
if (result.busy) {
|
|
777
|
+
const promotedThisAttempt = useAppServer ? false : promoteVisibleQueuedEntry(config, state, resolvedThreadId, next);
|
|
778
|
+
appendLog(config.paths.activityFile, `INJECT_BUSY thread=${resolvedThreadId} message=${next.messageId}`);
|
|
779
|
+
const latestState = loadState(config);
|
|
780
|
+
saveStateForConfig(config, mergeStateSnapshots(latestState, state));
|
|
781
|
+
return {
|
|
782
|
+
ok: false,
|
|
783
|
+
status: promotedThisAttempt ? "submitted" : "busy",
|
|
784
|
+
threadId: resolvedThreadId,
|
|
785
|
+
message: next
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
next.status = result.ok ? "delivered" : "error";
|
|
790
|
+
next.deliveredAt = nowIso();
|
|
791
|
+
next.threadId = resolvedThreadId;
|
|
792
|
+
next.turnId = String(result.turnId || "").trim() || null;
|
|
793
|
+
next.responsePreview = result.responseText.slice(0, 400);
|
|
794
|
+
next.stderr = result.stderr.slice(0, 400);
|
|
795
|
+
next.stdout = result.stdout.slice(0, 400);
|
|
796
|
+
if (useAppServer && result.ok) {
|
|
797
|
+
if (looksLikeBotSender(next)) {
|
|
798
|
+
appendLog(config.paths.activityFile, `REPLY_SKIP_BOT thread=${resolvedThreadId} turn=${next.turnId || "-"} message=${next.messageId} chat=${next.chatId}`);
|
|
799
|
+
} else {
|
|
800
|
+
const pendingReply = buildPendingReplyEntry(next, resolvedThreadId, next.turnId, sessionPath, sessionOffset);
|
|
801
|
+
state.pendingReplies = mergePendingReplyLists(state.pendingReplies || [], [pendingReply]);
|
|
802
|
+
appendLog(config.paths.activityFile, `REPLY_PENDING thread=${resolvedThreadId} turn=${next.turnId || "-"} message=${next.messageId} chat=${next.chatId}`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
state.lastInjectAt = nowIso();
|
|
806
|
+
const latestState = loadState(config);
|
|
807
|
+
saveStateForConfig(config, mergeStateSnapshots(latestState, state));
|
|
808
|
+
appendLog(config.paths.activityFile, `INJECT_${result.ok ? "OK" : "ERROR"} thread=${resolvedThreadId} message=${next.messageId}`);
|
|
809
|
+
return {
|
|
810
|
+
ok: result.ok,
|
|
811
|
+
status: result.ok ? "delivered" : "error",
|
|
812
|
+
threadId: resolvedThreadId,
|
|
813
|
+
message: next,
|
|
814
|
+
responsePreview: result.responseText.slice(0, 400),
|
|
815
|
+
stderr: result.stderr.slice(0, 400)
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export async function relayRepliesOnce() {
|
|
820
|
+
const config = loadConfig();
|
|
821
|
+
const state = loadState(config);
|
|
822
|
+
state.pendingReplies = reconcilePendingRepliesInPlace(state.pendingReplies || []);
|
|
823
|
+
|
|
824
|
+
for (const entry of state.pendingReplies || []) {
|
|
825
|
+
if (entry.sentAt || entry.status === "error") {
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (!looksLikeBotSender(entry)) {
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
entry.status = "ignored_bot";
|
|
832
|
+
entry.sentAt = nowIso();
|
|
833
|
+
entry.responsePreview = entry.responsePreview || "[ignored bot message]";
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const pendingReplies = (state.pendingReplies || []).filter((entry) => !entry.sentAt && entry.status !== "error");
|
|
837
|
+
if (pendingReplies.length === 0) {
|
|
838
|
+
saveStateForConfig(config, state);
|
|
839
|
+
return { ok: true, status: "empty", delivered: 0 };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
let delivered = 0;
|
|
843
|
+
const usedTurnIds = new Set();
|
|
844
|
+
const sessionCompletions = new Map();
|
|
845
|
+
|
|
846
|
+
for (const entry of pendingReplies) {
|
|
847
|
+
if (!entry.sessionPath) {
|
|
848
|
+
entry.sessionPath = await resolveThreadSessionPath(config, entry.threadId);
|
|
849
|
+
}
|
|
850
|
+
if (!entry.sessionPath || !existsSync(entry.sessionPath)) {
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (!sessionCompletions.has(entry.sessionPath)) {
|
|
855
|
+
const fallbackOffset = pendingReplies
|
|
856
|
+
.filter((candidate) => candidate.sessionPath === entry.sessionPath)
|
|
857
|
+
.reduce((lowest, candidate) => Math.min(lowest, Number(candidate.sessionOffset || 0)), Number(entry.sessionOffset || 0));
|
|
858
|
+
const currentOffset = Number((state.replyOffsets || {})[entry.sessionPath] ?? fallbackOffset);
|
|
859
|
+
const currentCarry = String((state.replyBuffers || {})[entry.sessionPath] || "");
|
|
860
|
+
const delta = readJsonlDelta(entry.sessionPath, currentOffset, currentCarry);
|
|
861
|
+
state.replyOffsets = {
|
|
862
|
+
...(state.replyOffsets || {}),
|
|
863
|
+
[entry.sessionPath]: delta.nextOffset
|
|
864
|
+
};
|
|
865
|
+
state.replyBuffers = {
|
|
866
|
+
...(state.replyBuffers || {}),
|
|
867
|
+
[entry.sessionPath]: delta.carry
|
|
868
|
+
};
|
|
869
|
+
const completions = delta.items
|
|
870
|
+
.filter((item) => item?.type === "event_msg" && item?.payload?.type === "task_complete")
|
|
871
|
+
.map((item) => ({
|
|
872
|
+
turnId: String(item?.payload?.turn_id || "").trim(),
|
|
873
|
+
message: String(item?.payload?.last_agent_message || "").trim(),
|
|
874
|
+
timestamp: String(item?.timestamp || "").trim()
|
|
875
|
+
}))
|
|
876
|
+
.filter((item) => item.message);
|
|
877
|
+
sessionCompletions.set(entry.sessionPath, completions);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const completions = sessionCompletions.get(entry.sessionPath) || [];
|
|
881
|
+
|
|
882
|
+
let match = null;
|
|
883
|
+
if (entry.turnId) {
|
|
884
|
+
match = completions.find((item) => item.turnId === entry.turnId);
|
|
885
|
+
} else {
|
|
886
|
+
match = completions.find((item) => item.timestamp >= entry.createdAt && !usedTurnIds.has(item.turnId));
|
|
887
|
+
}
|
|
888
|
+
if (!match) {
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const outboundResult = await sendOutboundChunks(config, state, {
|
|
893
|
+
chatId: entry.chatId,
|
|
894
|
+
text: match.message,
|
|
895
|
+
replyToMessageId: entry.replyToMessageId,
|
|
896
|
+
telegramThreadId: entry.telegramThreadId,
|
|
897
|
+
source: "auto",
|
|
898
|
+
sourceTurnId: match.turnId
|
|
899
|
+
});
|
|
900
|
+
entry.sentAt = nowIso();
|
|
901
|
+
entry.status = "sent";
|
|
902
|
+
entry.turnId = entry.turnId || match.turnId;
|
|
903
|
+
entry.responsePreview = match.message.slice(0, 400);
|
|
904
|
+
entry.responseMessageIds = outboundResult.messageIds;
|
|
905
|
+
usedTurnIds.add(match.turnId);
|
|
906
|
+
appendLog(config.paths.activityFile, `REPLY_SENT thread=${entry.threadId} turn=${entry.turnId || "-"} chat=${entry.chatId} source_message=${entry.messageId} outbound=${outboundResult.messageIds.join(",")}`);
|
|
907
|
+
delivered += 1;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
state.pendingReplies = reconcilePendingRepliesInPlace(state.pendingReplies || []);
|
|
911
|
+
saveStateForConfig(config, state);
|
|
912
|
+
return {
|
|
913
|
+
ok: true,
|
|
914
|
+
status: delivered > 0 ? "sent" : "pending",
|
|
915
|
+
delivered,
|
|
916
|
+
pending: (state.pendingReplies || []).filter((entry) => !entry.sentAt && entry.status !== "error").length
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
export async function reply(text, options = {}) {
|
|
921
|
+
const config = loadConfig();
|
|
922
|
+
const state = loadState(config);
|
|
923
|
+
const lastInbound = state.lastInbound;
|
|
924
|
+
const chatId = String(options.chatId || lastInbound?.chatId || config.allowedChatId || "").trim();
|
|
925
|
+
const replyToMessageId = String(options.replyToMessageId || lastInbound?.messageId || "").trim();
|
|
926
|
+
const telegramThreadId = normalizeTelegramThreadId(options.telegramThreadId || lastInbound?.telegramThreadId || "");
|
|
927
|
+
if (!chatId) {
|
|
928
|
+
throw new Error("No chat id available for reply.");
|
|
929
|
+
}
|
|
930
|
+
const result = await sendOutboundChunks(config, state, {
|
|
931
|
+
chatId,
|
|
932
|
+
text,
|
|
933
|
+
replyToMessageId,
|
|
934
|
+
telegramThreadId,
|
|
935
|
+
source: "manual"
|
|
936
|
+
});
|
|
937
|
+
saveStateForConfig(config, state);
|
|
938
|
+
return result;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
export function tailActivity(lines = 20) {
|
|
942
|
+
const config = loadConfig();
|
|
943
|
+
return readTail(config.paths.activityFile, lines);
|
|
944
|
+
}
|