polygram 0.6.4 → 0.6.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/lib/db.js +14 -1
- package/lib/prompt.js +10 -14
- package/package.json +1 -1
- package/polygram.js +60 -42
package/lib/db.js
CHANGED
|
@@ -337,10 +337,23 @@ function wrap(db) {
|
|
|
337
337
|
// Dedupe check: did we already send an outbound reply to this inbound?
|
|
338
338
|
// Prevents double-processing if a redelivered/replayed message has
|
|
339
339
|
// already been answered.
|
|
340
|
+
//
|
|
341
|
+
// We also count rows in the special 'failed crashed-mid-send' state
|
|
342
|
+
// as "probably sent" for dedupe. Those rows were created when polygram
|
|
343
|
+
// crashed AFTER inserting the pending row but before marking it sent
|
|
344
|
+
// — the API call may or may not have actually reached Telegram. The
|
|
345
|
+
// boot-time markStalePending sweep flips them to 'failed' with the
|
|
346
|
+
// 'crashed-mid-send' sentinel error. Treating them as un-replied
|
|
347
|
+
// (status='sent' only) caused boot replay to re-dispatch and Telegram
|
|
348
|
+
// delivered the SAME answer twice. Treating them as replied risks the
|
|
349
|
+
// opposite (the user never got a reply because the API truly failed
|
|
350
|
+
// before reaching Telegram), but a missed reply is recoverable —
|
|
351
|
+
// the user resends — while a duplicate reply is not.
|
|
340
352
|
hasOutboundReplyTo({ chat_id, msg_id }) {
|
|
341
353
|
const row = db.prepare(`
|
|
342
354
|
SELECT 1 FROM messages
|
|
343
|
-
WHERE chat_id = ? AND direction = 'out' AND reply_to_id = ?
|
|
355
|
+
WHERE chat_id = ? AND direction = 'out' AND reply_to_id = ?
|
|
356
|
+
AND (status = 'sent' OR (status = 'failed' AND error = 'crashed-mid-send'))
|
|
344
357
|
LIMIT 1
|
|
345
358
|
`).get(chat_id, msg_id);
|
|
346
359
|
return !!row;
|
package/lib/prompt.js
CHANGED
|
@@ -32,17 +32,6 @@ function truncateReplyText(s, max = REPLY_TO_MAX_CHARS) {
|
|
|
32
32
|
return `${s.slice(0, head)}…${s.slice(-tail)}`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
* Attachment summary for reply-to (never embed full content).
|
|
37
|
-
*/
|
|
38
|
-
function summarizeReplyAttachments(attachmentsJson) {
|
|
39
|
-
if (!attachmentsJson) return '';
|
|
40
|
-
let items;
|
|
41
|
-
try { items = JSON.parse(attachmentsJson); } catch { return ''; }
|
|
42
|
-
if (!Array.isArray(items) || !items.length) return '';
|
|
43
|
-
return items.map((a) => `[${a.kind}: ${a.name}]`).join(' ');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
35
|
/**
|
|
47
36
|
* Build a reply-to block. Callers pass either:
|
|
48
37
|
* - { telegram: msg.reply_to_message } (canonical Telegram payload), or
|
|
@@ -71,15 +60,22 @@ ${xmlEscape(body)}
|
|
|
71
60
|
}
|
|
72
61
|
|
|
73
62
|
if (dbRow) {
|
|
63
|
+
// Attachment summary for the reply-to block used to read
|
|
64
|
+
// dbRow.attachments_json, but that column was dropped in migration
|
|
65
|
+
// 008. Per-attachment rows live in the `attachments` table now;
|
|
66
|
+
// building a summary here would need a separate join. For reply-to
|
|
67
|
+
// context Claude already sees the canonical Telegram payload via
|
|
68
|
+
// the `telegram` branch above (the DB-row path is only the fallback
|
|
69
|
+
// for resurrected/replayed messages where the live payload is
|
|
70
|
+
// unavailable). Skipping the summary here is acceptable — text
|
|
71
|
+
// alone is enough context for "this is what they replied to".
|
|
74
72
|
const ts = dbRow.ts ? new Date(dbRow.ts).toISOString() : '';
|
|
75
73
|
const text = truncateReplyText(dbRow.text || '');
|
|
76
|
-
const attachSummary = summarizeReplyAttachments(dbRow.attachments_json);
|
|
77
|
-
const body = [text, attachSummary].filter(Boolean).join('\n');
|
|
78
74
|
const editedAttr = dbRow.edited_ts
|
|
79
75
|
? ` edited_ts="${new Date(dbRow.edited_ts).toISOString()}"`
|
|
80
76
|
: '';
|
|
81
77
|
return `<reply_to msg_id="${dbRow.msg_id}" user="${xmlEscape(dbRow.user || 'Unknown')}" ts="${ts}"${editedAttr} source="bridge-db">
|
|
82
|
-
${xmlEscape(
|
|
78
|
+
${xmlEscape(text)}
|
|
83
79
|
</reply_to>`;
|
|
84
80
|
}
|
|
85
81
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.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
|
@@ -487,7 +487,15 @@ async function downloadAttachments(bot, token, chatId, msg, rows) {
|
|
|
487
487
|
// <attachment-failed reason="..." /> so claude tells the user
|
|
488
488
|
// "I couldn't see your <kind>" instead of pretending it received
|
|
489
489
|
// text only.
|
|
490
|
-
|
|
490
|
+
//
|
|
491
|
+
// Token redaction: the fetch URL embeds bot${TOKEN} (Telegram CDN
|
|
492
|
+
// requirement) and some undici/network error variants stringify
|
|
493
|
+
// the request including the URL into err.message. Persisting that
|
|
494
|
+
// raw to attachments.download_error or stderr would leak the bot
|
|
495
|
+
// token to anyone with DB or log access. Strip any `bot<token>`
|
|
496
|
+
// pattern from the reason before storing/logging.
|
|
497
|
+
const raw = (err.message || 'unknown').slice(0, 200);
|
|
498
|
+
const reason = raw.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot<redacted>');
|
|
491
499
|
console.error(`[attach] download failed for ${att.name}: ${reason}`);
|
|
492
500
|
results.push({ ...att, path: null, error: reason });
|
|
493
501
|
dbWrite(() => db.markAttachmentFailed(att.id, reason),
|
|
@@ -756,12 +764,6 @@ function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
|
|
|
756
764
|
});
|
|
757
765
|
}
|
|
758
766
|
|
|
759
|
-
// drainQueuesForChat is retained as a no-op for backwards compat with
|
|
760
|
-
// call sites in /model, /effort, chat-migration, and abort handlers.
|
|
761
|
-
// Returns 0 always; a drain isn't meaningful in the concurrent model —
|
|
762
|
-
// callers that want to abort should rely on pm.killChat.
|
|
763
|
-
const drainQueuesForChat = (_chatId) => 0;
|
|
764
|
-
|
|
765
767
|
// Per-session lock ordering stdin writes. Module is I/O-pure.
|
|
766
768
|
const stdinLock = createAsyncLock();
|
|
767
769
|
|
|
@@ -1161,17 +1163,17 @@ async function handleConfigCallback(ctx) {
|
|
|
1161
1163
|
user: cmdUser, user_id: cmdUserId, source: 'inline-button',
|
|
1162
1164
|
}), `log ${setting} change`);
|
|
1163
1165
|
|
|
1164
|
-
// Graceful respawn
|
|
1165
|
-
//
|
|
1166
|
+
// Graceful respawn of the topic's session that the card is in. With
|
|
1167
|
+
// isolateTopics=false sessionKey is the chat (one shared session). With
|
|
1168
|
+
// isolateTopics=true sessionKey carries the topic, so other topics'
|
|
1169
|
+
// in-flight turns are not disturbed and the card update + button toast
|
|
1170
|
+
// only affect the user's own context. Mirrors the text-command flow in
|
|
1171
|
+
// handleMessage's requestRespawnForSession.
|
|
1172
|
+
const callbackThreadId = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
|
|
1173
|
+
const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
|
|
1166
1174
|
const reason = setting === 'model' ? 'model-change' : 'effort-change';
|
|
1167
|
-
const
|
|
1168
|
-
|
|
1169
|
-
for (const key of pm.keys()) {
|
|
1170
|
-
if (key === prefix || key.startsWith(prefix + ':')) {
|
|
1171
|
-
const res = pm.requestRespawn(key, reason);
|
|
1172
|
-
if (!res.killed) anyActive = true;
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
+
const respawn = pm.requestRespawn(callbackSessionKey, reason);
|
|
1176
|
+
const anyActive = !respawn.killed;
|
|
1175
1177
|
|
|
1176
1178
|
// Re-render the card with updated ✓ + the same help text shown initially.
|
|
1177
1179
|
// Detect original card type (model-only / effort-only / both) by counting
|
|
@@ -1307,23 +1309,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1307
1309
|
await sendReply(info, { params: { reply_markup } });
|
|
1308
1310
|
return;
|
|
1309
1311
|
}
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
const res = pm.requestRespawn(key, reason);
|
|
1322
|
-
totalQueued += res.queued;
|
|
1323
|
-
if (!res.killed) anyActive = true;
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
return { queued: totalQueued, anyActive };
|
|
1312
|
+
// Graceful respawn of the user's CURRENT session only. With
|
|
1313
|
+
// isolateTopics=false the sessionKey is just the chat (one shared
|
|
1314
|
+
// session for the whole chat — every topic respawns implicitly).
|
|
1315
|
+
// With isolateTopics=true each topic is a separate session, and a
|
|
1316
|
+
// /model in topic A should NOT disturb topic B's in-flight turn or
|
|
1317
|
+
// post a phantom "✓ Using sonnet now" in a topic that didn't ask.
|
|
1318
|
+
// Pre-0.6.5 this iterated pm.keys() by chat prefix and incorrectly
|
|
1319
|
+
// fanned out across all topics under isolateTopics=true.
|
|
1320
|
+
const requestRespawnForSession = (reason) => {
|
|
1321
|
+
const res = pm.requestRespawn(sessionKey, reason);
|
|
1322
|
+
return { queued: res.queued, anyActive: !res.killed };
|
|
1327
1323
|
};
|
|
1328
1324
|
|
|
1329
1325
|
if (botAllowsCommands && text.startsWith('/model ')) {
|
|
@@ -1337,7 +1333,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1337
1333
|
old_value: oldModel, new_value: newModel,
|
|
1338
1334
|
user: cmdUser, user_id: cmdUserId, source: 'command',
|
|
1339
1335
|
}), 'log model change');
|
|
1340
|
-
const { anyActive } =
|
|
1336
|
+
const { anyActive } = requestRespawnForSession('model-change');
|
|
1341
1337
|
const ver = MODEL_VERSIONS[newModel] || newModel;
|
|
1342
1338
|
const suffix = anyActive ? ` — I'll switch when I finish` : '';
|
|
1343
1339
|
await sendReply(`Model → ${newModel} (${ver})${suffix}`);
|
|
@@ -1357,7 +1353,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1357
1353
|
old_value: oldEffort, new_value: newEffort,
|
|
1358
1354
|
user: cmdUser, user_id: cmdUserId, source: 'command',
|
|
1359
1355
|
}), 'log effort change');
|
|
1360
|
-
const { anyActive } =
|
|
1356
|
+
const { anyActive } = requestRespawnForSession('effort-change');
|
|
1361
1357
|
const suffix = anyActive ? ` — I'll switch when I finish` : '';
|
|
1362
1358
|
await sendReply(`Effort → ${newEffort}${suffix}`);
|
|
1363
1359
|
} else {
|
|
@@ -1507,7 +1503,19 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1507
1503
|
chat_id: chatId, text: `Attachment(s) skipped: ${summary.slice(0, 300)}`,
|
|
1508
1504
|
...replyOpts(threadId),
|
|
1509
1505
|
}, { source: 'attachment-skipped', botName: BOT_NAME });
|
|
1510
|
-
} catch {
|
|
1506
|
+
} catch (err) {
|
|
1507
|
+
// Surface the failure: claude is about to reply as if the photo
|
|
1508
|
+
// was processed (because filterAttachments dropped it before
|
|
1509
|
+
// download), and the user would otherwise have no signal that
|
|
1510
|
+
// their attachment was rejected. They'd assume claude saw it
|
|
1511
|
+
// and is just answering oddly.
|
|
1512
|
+
console.error(`[${label}] failed to notify user of skipped attachments: ${err.message}`);
|
|
1513
|
+
dbWrite(() => db.logEvent('attachment-skip-notice-failed', {
|
|
1514
|
+
chat_id: chatId, msg_id: msg.message_id,
|
|
1515
|
+
error: err.message?.slice(0, 200),
|
|
1516
|
+
rejected_count: rejected.length,
|
|
1517
|
+
}), 'log attachment-skip-notice-failed');
|
|
1518
|
+
}
|
|
1511
1519
|
}
|
|
1512
1520
|
|
|
1513
1521
|
await transcribeVoiceAttachments(downloaded, {
|
|
@@ -1870,16 +1878,24 @@ function createBot(token) {
|
|
|
1870
1878
|
const threadId = msg.message_thread_id?.toString();
|
|
1871
1879
|
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
1872
1880
|
const hadActive = pm.has(sessionKey) && !!pm.get(sessionKey)?.inFlight;
|
|
1873
|
-
const dropped = drainQueuesForChat(chatId);
|
|
1874
1881
|
// Mark BEFORE killing: the 'close' event fires almost immediately
|
|
1875
1882
|
// after SIGTERM, and processQueue's catch needs to see the flag to
|
|
1876
1883
|
// skip the generic error-reply. If we marked after, there'd be a
|
|
1877
1884
|
// race where the error-reply slips through.
|
|
1878
1885
|
if (hadActive) markSessionAborted(sessionKey);
|
|
1879
|
-
|
|
1886
|
+
// Kill ONLY the user's own session, not every topic in the chat.
|
|
1887
|
+
// Pre-0.6.5 this was pm.killChat(chatId) which fanned out across
|
|
1888
|
+
// all topics under isolateTopics=true: the user typed "stop" in
|
|
1889
|
+
// topic A and the bot tore down topic B's in-flight turn, surfacing
|
|
1890
|
+
// a 💥 reply to topic B's user (whose key was never marked aborted,
|
|
1891
|
+
// so the abort grace window didn't apply). With isolateTopics=false
|
|
1892
|
+
// the sessionKey is the chat itself, so killing one session is the
|
|
1893
|
+
// same as killing the chat — behavior unchanged for the common case.
|
|
1894
|
+
await pm.kill(sessionKey).catch((err) =>
|
|
1895
|
+
console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
|
|
1880
1896
|
dbWrite(() => db.logEvent('abort-requested', {
|
|
1881
1897
|
chat_id: chatId, user_id: msg.from?.id || null,
|
|
1882
|
-
had_active: hadActive,
|
|
1898
|
+
had_active: hadActive,
|
|
1883
1899
|
trigger: cleanText.slice(0, 40),
|
|
1884
1900
|
}), 'log abort-requested');
|
|
1885
1901
|
// Reply in the same language the user aborted in. Cyrillic-detection
|
|
@@ -2088,8 +2104,10 @@ function createBot(token) {
|
|
|
2088
2104
|
config.chats[newChatId] = { ...config.chats[oldChatId] };
|
|
2089
2105
|
delete config.chats[oldChatId];
|
|
2090
2106
|
saveConfig();
|
|
2091
|
-
|
|
2092
|
-
|
|
2107
|
+
// Chat migration is the one legit chat-wide kill: every session
|
|
2108
|
+
// (every topic) under the old chat_id is stale and must restart
|
|
2109
|
+
// under the new chat_id. Other respawn/abort paths target a
|
|
2110
|
+
// single sessionKey, but here ALL sessions are invalid.
|
|
2093
2111
|
await pm.killChat(oldChatId);
|
|
2094
2112
|
}
|
|
2095
2113
|
});
|