polygram 0.4.2 → 0.4.6
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/.claude-plugin/plugin.json +1 -1
- package/lib/process-manager.js +30 -14
- package/lib/telegram.js +12 -1
- package/package.json +1 -1
- package/polygram.js +61 -14
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.6",
|
|
5
5
|
"description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands and a history skill.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
package/lib/process-manager.js
CHANGED
|
@@ -281,9 +281,17 @@ class ProcessManager {
|
|
|
281
281
|
entry.pending = { resolve, reject };
|
|
282
282
|
entry.streamText = '';
|
|
283
283
|
|
|
284
|
+
// Timer handles kept in closure vars (not entry.pending), because
|
|
285
|
+
// the result-event handler in rl.on('line') sets entry.pending = null
|
|
286
|
+
// BEFORE calling the wrapped resolve. Reading from entry.pending
|
|
287
|
+
// after null-out gave undefined → clearTimeout was never called →
|
|
288
|
+
// the default 30-min maxTurnMs timer stayed armed and held Node's
|
|
289
|
+
// event loop open, hanging the test runner on CI.
|
|
290
|
+
let idleTimer = null;
|
|
291
|
+
let maxTimer = null;
|
|
284
292
|
const clearTimers = () => {
|
|
285
|
-
if (
|
|
286
|
-
if (
|
|
293
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
|
|
294
|
+
if (maxTimer) { clearTimeout(maxTimer); maxTimer = null; }
|
|
287
295
|
};
|
|
288
296
|
|
|
289
297
|
// Timer fire path. New in 0.3.9: after rejecting, SIGTERM the
|
|
@@ -308,19 +316,25 @@ class ProcessManager {
|
|
|
308
316
|
// Idle timeout: counts N seconds of SILENCE from Claude. Reset on
|
|
309
317
|
// every assistant event so long productive turns (multi-tool
|
|
310
318
|
// reasoning) don't falsely trip.
|
|
311
|
-
// .unref()
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
319
|
+
// Note on .unref(): an earlier revision called unref() on both
|
|
320
|
+
// timers to avoid holding the node event loop open in tests. That
|
|
321
|
+
// broke Node's test runner on CI ("Promise resolution is still
|
|
322
|
+
// pending but the event loop has already resolved") — the runner
|
|
323
|
+
// detects unref'd timers as a drained loop and cancels awaiters
|
|
324
|
+
// before the timer can fire. Production polygram stays alive via
|
|
325
|
+
// grammy's poll loop + child process pipes; we don't need unref.
|
|
315
326
|
const armIdle = () => setTimeout(
|
|
316
327
|
() => fireTimeout(`Timeout: ${timeoutMs / 1000}s idle with no Claude activity`),
|
|
317
328
|
timeoutMs,
|
|
318
|
-
)
|
|
319
|
-
|
|
329
|
+
);
|
|
330
|
+
idleTimer = armIdle();
|
|
331
|
+
entry.pending.idleTimer = idleTimer;
|
|
320
332
|
entry.pending.resetIdleTimer = () => {
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
333
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
334
|
+
if (entry.pending) {
|
|
335
|
+
idleTimer = armIdle();
|
|
336
|
+
entry.pending.idleTimer = idleTimer;
|
|
337
|
+
}
|
|
324
338
|
};
|
|
325
339
|
|
|
326
340
|
// Wall-clock ceiling: fires at maxTurnMs regardless of activity.
|
|
@@ -328,13 +342,14 @@ class ProcessManager {
|
|
|
328
342
|
// idle timer alive) but never produce a result. OpenClaw's only
|
|
329
343
|
// timer was wall-clock; polygram's 0.3.5 change replaced it with
|
|
330
344
|
// idle-reset, creating a gap this restores as a last-resort.
|
|
331
|
-
|
|
345
|
+
maxTimer = setTimeout(
|
|
332
346
|
() => fireTimeout(`Turn exceeded ${maxTurnMs / 1000}s wall-clock ceiling`),
|
|
333
347
|
maxTurnMs,
|
|
334
|
-
)
|
|
348
|
+
);
|
|
349
|
+
entry.pending.maxTimer = maxTimer;
|
|
335
350
|
|
|
336
351
|
// Legacy alias: some callers / tests refer to entry.pending.timer.
|
|
337
|
-
entry.pending.timer =
|
|
352
|
+
entry.pending.timer = idleTimer;
|
|
338
353
|
|
|
339
354
|
const wrappedResolve = entry.pending.resolve;
|
|
340
355
|
const wrappedReject = entry.pending.reject;
|
|
@@ -351,6 +366,7 @@ class ProcessManager {
|
|
|
351
366
|
entry.pending = null;
|
|
352
367
|
entry.inFlight = false;
|
|
353
368
|
reject(err);
|
|
369
|
+
return;
|
|
354
370
|
}
|
|
355
371
|
});
|
|
356
372
|
}
|
package/lib/telegram.js
CHANGED
|
@@ -76,7 +76,18 @@ function nextPendingId() {
|
|
|
76
76
|
return -(v + 1);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
// Methods we don't insert a `messages` row for. Reactions/deletes/markup
|
|
80
|
+
// edits never produced a chat message in the first place. editMessageText
|
|
81
|
+
// DOES modify a message, but creating a new DB row per edit collides with
|
|
82
|
+
// the UNIQUE(chat_id, msg_id) constraint on the 2nd edit — the stream
|
|
83
|
+
// edits one bubble N times in a single turn. The initial sendMessage
|
|
84
|
+
// already persisted the row; edits just update the live bubble.
|
|
85
|
+
const METHODS_WITHOUT_MSG = new Set([
|
|
86
|
+
'setMessageReaction',
|
|
87
|
+
'deleteMessage',
|
|
88
|
+
'editMessageReplyMarkup',
|
|
89
|
+
'editMessageText',
|
|
90
|
+
]);
|
|
80
91
|
|
|
81
92
|
// Derive the row's `text` column. sendSticker has no text/caption, so we
|
|
82
93
|
// synthesize `[sticker:<name>]` (or file_id as fallback) — without this the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
|
|
5
5
|
"main": "lib/ipc-client.js",
|
|
6
6
|
"bin": {
|
package/polygram.js
CHANGED
|
@@ -555,6 +555,18 @@ async function enqueue(sessionKey, chatId, msg, bot) {
|
|
|
555
555
|
if (!processing[sessionKey]) processQueue(sessionKey);
|
|
556
556
|
}
|
|
557
557
|
|
|
558
|
+
// Sessions the operator just /stop'd (or natural-language "стоп"). Entries
|
|
559
|
+
// suppress the generic "Sorry, I couldn't process" reply below — the abort
|
|
560
|
+
// handler already sent its own "Остановлено." ack, and the subsequent
|
|
561
|
+
// handleMessage rejection from the killed subprocess would otherwise
|
|
562
|
+
// spam a second contradictory message. Cleared on first use; long-lived
|
|
563
|
+
// only if the abort kills something that never finishes rejecting.
|
|
564
|
+
const abortedSessions = new Set();
|
|
565
|
+
|
|
566
|
+
function markSessionAborted(sessionKey) {
|
|
567
|
+
abortedSessions.add(sessionKey);
|
|
568
|
+
}
|
|
569
|
+
|
|
558
570
|
async function processQueue(sessionKey) {
|
|
559
571
|
processing[sessionKey] = true;
|
|
560
572
|
while (queues[sessionKey]?.length > 0) {
|
|
@@ -562,6 +574,8 @@ async function processQueue(sessionKey) {
|
|
|
562
574
|
try {
|
|
563
575
|
await handleMessage(sessionKey, chatId, msg, bot);
|
|
564
576
|
} catch (err) {
|
|
577
|
+
const wasAborted = abortedSessions.has(sessionKey);
|
|
578
|
+
if (wasAborted) abortedSessions.delete(sessionKey);
|
|
565
579
|
// Raw err.message can carry host paths, DB columns, internal state.
|
|
566
580
|
// Surface a generic message to the user; log the detail to events
|
|
567
581
|
// so operators can still debug.
|
|
@@ -571,15 +585,18 @@ async function processQueue(sessionKey) {
|
|
|
571
585
|
msg_id: msg?.message_id,
|
|
572
586
|
error: err.message?.slice(0, 500),
|
|
573
587
|
stack: err.stack?.split('\n').slice(0, 5).join('\n'),
|
|
588
|
+
aborted: wasAborted || undefined,
|
|
574
589
|
}), 'log handler-error');
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
590
|
+
if (!wasAborted) {
|
|
591
|
+
try {
|
|
592
|
+
await tg(bot, 'sendMessage', {
|
|
593
|
+
chat_id: chatId,
|
|
594
|
+
text: `Sorry, I couldn't process that message. The operator has been notified.`,
|
|
595
|
+
reply_parameters: { message_id: msg.message_id },
|
|
596
|
+
}, { source: 'error-reply', botName: BOT_NAME });
|
|
597
|
+
} catch (replyErr) {
|
|
598
|
+
console.error(`[${sessionKey}] failed to send error reply: ${replyErr.message}`);
|
|
599
|
+
}
|
|
583
600
|
}
|
|
584
601
|
}
|
|
585
602
|
}
|
|
@@ -1150,11 +1167,21 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1150
1167
|
}, outMetaBase),
|
|
1151
1168
|
edit: async (messageId, text) => {
|
|
1152
1169
|
try {
|
|
1153
|
-
|
|
1170
|
+
// Route edits through tg() so applyFormatting runs (MarkdownV2
|
|
1171
|
+
// + escape). Going direct to bot.api.editMessageText would
|
|
1172
|
+
// skip formatting and leave every edit rendering literal
|
|
1173
|
+
// **bold** / `code` in the bubble — which was the visible bug
|
|
1174
|
+
// in 0.4.2 where the initial send was formatted and every
|
|
1175
|
+
// subsequent edit overwrote it with plain text.
|
|
1176
|
+
return await tg(bot, 'editMessageText', {
|
|
1177
|
+
chat_id: chatId,
|
|
1178
|
+
message_id: messageId,
|
|
1179
|
+
text,
|
|
1180
|
+
}, { source: 'bot-reply-stream-edit', botName: BOT_NAME });
|
|
1154
1181
|
} catch (err) {
|
|
1155
|
-
// Stream-edit failures would otherwise be invisible — edits
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1182
|
+
// Stream-edit failures would otherwise be invisible — edits
|
|
1183
|
+
// don't insert a messages row by default (tg() does, but we
|
|
1184
|
+
// want the failure path specifically surfaced). Log to events.
|
|
1158
1185
|
dbWrite(() => db.logEvent('telegram-edit-failed', {
|
|
1159
1186
|
chat_id: chatId, msg_id: messageId,
|
|
1160
1187
|
api_error: err.message?.slice(0, 200),
|
|
@@ -1428,15 +1455,35 @@ function createBot(token) {
|
|
|
1428
1455
|
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
1429
1456
|
const hadActive = pm.has(sessionKey) && !!pm.get(sessionKey)?.inFlight;
|
|
1430
1457
|
const dropped = drainQueuesForChat(chatId);
|
|
1458
|
+
// Mark BEFORE killing: the 'close' event fires almost immediately
|
|
1459
|
+
// after SIGTERM, and processQueue's catch needs to see the flag to
|
|
1460
|
+
// skip the generic error-reply. If we marked after, there'd be a
|
|
1461
|
+
// race where the error-reply slips through.
|
|
1462
|
+
if (hadActive) markSessionAborted(sessionKey);
|
|
1431
1463
|
await pm.killChat(chatId).catch(() => {});
|
|
1432
1464
|
dbWrite(() => db.logEvent('abort-requested', {
|
|
1433
1465
|
chat_id: chatId, user_id: msg.from?.id || null,
|
|
1434
1466
|
had_active: hadActive, queued_dropped: dropped,
|
|
1435
1467
|
trigger: cleanText.slice(0, 40),
|
|
1436
1468
|
}), 'log abort-requested');
|
|
1469
|
+
// Reply in the same language the user aborted in. Cyrillic-detection
|
|
1470
|
+
// is crude but reliable for ru/en (the only two cue sets we ship).
|
|
1471
|
+
const lang = /[а-яё]/i.test(cleanText) ? 'ru' : 'en';
|
|
1472
|
+
const strs = {
|
|
1473
|
+
en: {
|
|
1474
|
+
stopped: 'Stopped.',
|
|
1475
|
+
withDropped: (n) => `Stopped. Cleared ${n} queued message${n === 1 ? '' : 's'}.`,
|
|
1476
|
+
nothing: 'Nothing to stop.',
|
|
1477
|
+
},
|
|
1478
|
+
ru: {
|
|
1479
|
+
stopped: 'Остановлено.',
|
|
1480
|
+
withDropped: (n) => `Остановлено. Очередь очищена (${n}).`,
|
|
1481
|
+
nothing: 'Нечего останавливать.',
|
|
1482
|
+
},
|
|
1483
|
+
}[lang];
|
|
1437
1484
|
const reply = hadActive || dropped
|
|
1438
|
-
? (dropped ?
|
|
1439
|
-
:
|
|
1485
|
+
? (dropped ? strs.withDropped(dropped) : strs.stopped)
|
|
1486
|
+
: strs.nothing;
|
|
1440
1487
|
try {
|
|
1441
1488
|
await tg(bot, 'sendMessage', {
|
|
1442
1489
|
chat_id: chatId, text: reply,
|