polygram 0.6.3 → 0.6.5
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/migrations/007-attachments-table.sql +25 -7
- package/package.json +1 -1
- package/polygram.js +55 -42
|
@@ -64,6 +64,19 @@ CREATE INDEX IF NOT EXISTS idx_attachments_unique_id
|
|
|
64
64
|
-- mime_type, size, file_id, file_unique_id, and optionally transcription.
|
|
65
65
|
-- Pre-0.5.x rows may not have file_unique_id. We pull what's there and
|
|
66
66
|
-- leave the rest NULL.
|
|
67
|
+
--
|
|
68
|
+
-- Robustness: json_each() raises on malformed JSON or on a non-array
|
|
69
|
+
-- root, which would roll back the entire migration transaction (one bad
|
|
70
|
+
-- row blocks the upgrade). The `json_valid(...) AND json_type(...) =
|
|
71
|
+
-- 'array'` guards skip those rows so the rest still backfill. Rows
|
|
72
|
+
-- without a `file_id` are also skipped — the schema declares file_id
|
|
73
|
+
-- NOT NULL and we'd rather drop a corrupt entry than materialise a
|
|
74
|
+
-- permanently un-redownloadable row with file_id=''.
|
|
75
|
+
-- Pre-filter messages in a subquery so json_each() only runs on valid
|
|
76
|
+
-- JSON arrays. SQLite's json_each raises on malformed JSON or on a
|
|
77
|
+
-- non-array root, and that error rolls back the whole migration. The
|
|
78
|
+
-- subquery materialises only rows that pass json_valid + json_type
|
|
79
|
+
-- before the join expands them.
|
|
67
80
|
INSERT INTO attachments (
|
|
68
81
|
message_id, chat_id, msg_id, thread_id, bot_name,
|
|
69
82
|
file_id, file_unique_id, kind, name, mime_type, size_bytes,
|
|
@@ -71,7 +84,7 @@ INSERT INTO attachments (
|
|
|
71
84
|
)
|
|
72
85
|
SELECT
|
|
73
86
|
m.id, m.chat_id, m.msg_id, m.thread_id, m.bot_name,
|
|
74
|
-
|
|
87
|
+
json_extract(att.value, '$.file_id'),
|
|
75
88
|
json_extract(att.value, '$.file_unique_id'),
|
|
76
89
|
COALESCE(json_extract(att.value, '$.kind'), 'document'),
|
|
77
90
|
json_extract(att.value, '$.name'),
|
|
@@ -81,9 +94,14 @@ SELECT
|
|
|
81
94
|
'downloaded',
|
|
82
95
|
json_extract(att.value, '$.transcription.text'),
|
|
83
96
|
m.ts
|
|
84
|
-
FROM
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
FROM (
|
|
98
|
+
SELECT id, chat_id, msg_id, thread_id, bot_name, attachments_json, ts
|
|
99
|
+
FROM messages
|
|
100
|
+
WHERE direction = 'in'
|
|
101
|
+
AND attachments_json IS NOT NULL
|
|
102
|
+
AND json_valid(attachments_json) = 1
|
|
103
|
+
AND json_type(attachments_json) = 'array'
|
|
104
|
+
AND NOT EXISTS (SELECT 1 FROM attachments a WHERE a.message_id = id)
|
|
105
|
+
) m, json_each(m.attachments_json) att
|
|
106
|
+
WHERE json_extract(att.value, '$.file_id') IS NOT NULL
|
|
107
|
+
AND json_extract(att.value, '$.file_id') != '';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
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
|
@@ -208,7 +208,13 @@ function recordInbound(msg) {
|
|
|
208
208
|
const chatConfig = config.chats[chatId];
|
|
209
209
|
const ts = (msg.date || Math.floor(Date.now() / 1000)) * 1000;
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
// Atomic message + attachments write: all-or-nothing so a half-applied
|
|
212
|
+
// record can never leave a message row with zero (or partial) attachment
|
|
213
|
+
// rows that boot replay would silently treat as "no media." Wrapping in
|
|
214
|
+
// db.raw.transaction also collapses the message + N-attachment fsyncs
|
|
215
|
+
// into one commit (perf win for media groups: 7-attachment albums go
|
|
216
|
+
// from 8 sync writes to 1).
|
|
217
|
+
const writeInbound = db.raw.transaction(() => {
|
|
212
218
|
db.insertMessage({
|
|
213
219
|
chat_id: chatId,
|
|
214
220
|
thread_id: threadId,
|
|
@@ -231,6 +237,13 @@ function recordInbound(msg) {
|
|
|
231
237
|
// the upsert path; an explicit lookup is cheap and always correct.
|
|
232
238
|
const messageId = db.getInboundMessageId({ chat_id: chatId, msg_id: msg.message_id });
|
|
233
239
|
if (!messageId) return;
|
|
240
|
+
// Edit-safe insert: Telegram edited_message events re-fire
|
|
241
|
+
// recordInbound with the same (chat_id, msg_id). Telegram doesn't
|
|
242
|
+
// permit replacing media in an edit (only text/caption), so if rows
|
|
243
|
+
// already exist for this message_id they're correct as-is —
|
|
244
|
+
// re-inserting would (a) duplicate them, (b) reset download_status
|
|
245
|
+
// back to 'pending' and lose the local_path we already fetched.
|
|
246
|
+
if (db.getAttachmentsByMessage(messageId).length > 0) return;
|
|
234
247
|
for (const att of attachments) {
|
|
235
248
|
db.insertAttachment({
|
|
236
249
|
message_id: messageId,
|
|
@@ -247,7 +260,9 @@ function recordInbound(msg) {
|
|
|
247
260
|
ts,
|
|
248
261
|
});
|
|
249
262
|
}
|
|
250
|
-
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
dbWrite(() => writeInbound(), `insert inbound ${chatId}/${msg.message_id}`);
|
|
251
266
|
}
|
|
252
267
|
|
|
253
268
|
|
|
@@ -741,12 +756,6 @@ function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
|
|
|
741
756
|
});
|
|
742
757
|
}
|
|
743
758
|
|
|
744
|
-
// drainQueuesForChat is retained as a no-op for backwards compat with
|
|
745
|
-
// call sites in /model, /effort, chat-migration, and abort handlers.
|
|
746
|
-
// Returns 0 always; a drain isn't meaningful in the concurrent model —
|
|
747
|
-
// callers that want to abort should rely on pm.killChat.
|
|
748
|
-
const drainQueuesForChat = (_chatId) => 0;
|
|
749
|
-
|
|
750
759
|
// Per-session lock ordering stdin writes. Module is I/O-pure.
|
|
751
760
|
const stdinLock = createAsyncLock();
|
|
752
761
|
|
|
@@ -1146,17 +1155,17 @@ async function handleConfigCallback(ctx) {
|
|
|
1146
1155
|
user: cmdUser, user_id: cmdUserId, source: 'inline-button',
|
|
1147
1156
|
}), `log ${setting} change`);
|
|
1148
1157
|
|
|
1149
|
-
// Graceful respawn
|
|
1150
|
-
//
|
|
1158
|
+
// Graceful respawn of the topic's session that the card is in. With
|
|
1159
|
+
// isolateTopics=false sessionKey is the chat (one shared session). With
|
|
1160
|
+
// isolateTopics=true sessionKey carries the topic, so other topics'
|
|
1161
|
+
// in-flight turns are not disturbed and the card update + button toast
|
|
1162
|
+
// only affect the user's own context. Mirrors the text-command flow in
|
|
1163
|
+
// handleMessage's requestRespawnForSession.
|
|
1164
|
+
const callbackThreadId = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
|
|
1165
|
+
const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
|
|
1151
1166
|
const reason = setting === 'model' ? 'model-change' : 'effort-change';
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1154
|
-
for (const key of pm.keys()) {
|
|
1155
|
-
if (key === prefix || key.startsWith(prefix + ':')) {
|
|
1156
|
-
const res = pm.requestRespawn(key, reason);
|
|
1157
|
-
if (!res.killed) anyActive = true;
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1167
|
+
const respawn = pm.requestRespawn(callbackSessionKey, reason);
|
|
1168
|
+
const anyActive = !respawn.killed;
|
|
1160
1169
|
|
|
1161
1170
|
// Re-render the card with updated ✓ + the same help text shown initially.
|
|
1162
1171
|
// Detect original card type (model-only / effort-only / both) by counting
|
|
@@ -1292,23 +1301,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1292
1301
|
await sendReply(info, { params: { reply_markup } });
|
|
1293
1302
|
return;
|
|
1294
1303
|
}
|
|
1295
|
-
//
|
|
1296
|
-
//
|
|
1297
|
-
//
|
|
1298
|
-
//
|
|
1299
|
-
//
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
const res = pm.requestRespawn(key, reason);
|
|
1307
|
-
totalQueued += res.queued;
|
|
1308
|
-
if (!res.killed) anyActive = true;
|
|
1309
|
-
}
|
|
1310
|
-
}
|
|
1311
|
-
return { queued: totalQueued, anyActive };
|
|
1304
|
+
// Graceful respawn of the user's CURRENT session only. With
|
|
1305
|
+
// isolateTopics=false the sessionKey is just the chat (one shared
|
|
1306
|
+
// session for the whole chat — every topic respawns implicitly).
|
|
1307
|
+
// With isolateTopics=true each topic is a separate session, and a
|
|
1308
|
+
// /model in topic A should NOT disturb topic B's in-flight turn or
|
|
1309
|
+
// post a phantom "✓ Using sonnet now" in a topic that didn't ask.
|
|
1310
|
+
// Pre-0.6.5 this iterated pm.keys() by chat prefix and incorrectly
|
|
1311
|
+
// fanned out across all topics under isolateTopics=true.
|
|
1312
|
+
const requestRespawnForSession = (reason) => {
|
|
1313
|
+
const res = pm.requestRespawn(sessionKey, reason);
|
|
1314
|
+
return { queued: res.queued, anyActive: !res.killed };
|
|
1312
1315
|
};
|
|
1313
1316
|
|
|
1314
1317
|
if (botAllowsCommands && text.startsWith('/model ')) {
|
|
@@ -1322,7 +1325,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1322
1325
|
old_value: oldModel, new_value: newModel,
|
|
1323
1326
|
user: cmdUser, user_id: cmdUserId, source: 'command',
|
|
1324
1327
|
}), 'log model change');
|
|
1325
|
-
const { anyActive } =
|
|
1328
|
+
const { anyActive } = requestRespawnForSession('model-change');
|
|
1326
1329
|
const ver = MODEL_VERSIONS[newModel] || newModel;
|
|
1327
1330
|
const suffix = anyActive ? ` — I'll switch when I finish` : '';
|
|
1328
1331
|
await sendReply(`Model → ${newModel} (${ver})${suffix}`);
|
|
@@ -1342,7 +1345,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1342
1345
|
old_value: oldEffort, new_value: newEffort,
|
|
1343
1346
|
user: cmdUser, user_id: cmdUserId, source: 'command',
|
|
1344
1347
|
}), 'log effort change');
|
|
1345
|
-
const { anyActive } =
|
|
1348
|
+
const { anyActive } = requestRespawnForSession('effort-change');
|
|
1346
1349
|
const suffix = anyActive ? ` — I'll switch when I finish` : '';
|
|
1347
1350
|
await sendReply(`Effort → ${newEffort}${suffix}`);
|
|
1348
1351
|
} else {
|
|
@@ -1855,16 +1858,24 @@ function createBot(token) {
|
|
|
1855
1858
|
const threadId = msg.message_thread_id?.toString();
|
|
1856
1859
|
const sessionKey = getSessionKey(chatId, threadId, chatConfig);
|
|
1857
1860
|
const hadActive = pm.has(sessionKey) && !!pm.get(sessionKey)?.inFlight;
|
|
1858
|
-
const dropped = drainQueuesForChat(chatId);
|
|
1859
1861
|
// Mark BEFORE killing: the 'close' event fires almost immediately
|
|
1860
1862
|
// after SIGTERM, and processQueue's catch needs to see the flag to
|
|
1861
1863
|
// skip the generic error-reply. If we marked after, there'd be a
|
|
1862
1864
|
// race where the error-reply slips through.
|
|
1863
1865
|
if (hadActive) markSessionAborted(sessionKey);
|
|
1864
|
-
|
|
1866
|
+
// Kill ONLY the user's own session, not every topic in the chat.
|
|
1867
|
+
// Pre-0.6.5 this was pm.killChat(chatId) which fanned out across
|
|
1868
|
+
// all topics under isolateTopics=true: the user typed "stop" in
|
|
1869
|
+
// topic A and the bot tore down topic B's in-flight turn, surfacing
|
|
1870
|
+
// a 💥 reply to topic B's user (whose key was never marked aborted,
|
|
1871
|
+
// so the abort grace window didn't apply). With isolateTopics=false
|
|
1872
|
+
// the sessionKey is the chat itself, so killing one session is the
|
|
1873
|
+
// same as killing the chat — behavior unchanged for the common case.
|
|
1874
|
+
await pm.kill(sessionKey).catch((err) =>
|
|
1875
|
+
console.error(`[${BOT_NAME}] abort kill failed: ${err.message}`));
|
|
1865
1876
|
dbWrite(() => db.logEvent('abort-requested', {
|
|
1866
1877
|
chat_id: chatId, user_id: msg.from?.id || null,
|
|
1867
|
-
had_active: hadActive,
|
|
1878
|
+
had_active: hadActive,
|
|
1868
1879
|
trigger: cleanText.slice(0, 40),
|
|
1869
1880
|
}), 'log abort-requested');
|
|
1870
1881
|
// Reply in the same language the user aborted in. Cyrillic-detection
|
|
@@ -2073,8 +2084,10 @@ function createBot(token) {
|
|
|
2073
2084
|
config.chats[newChatId] = { ...config.chats[oldChatId] };
|
|
2074
2085
|
delete config.chats[oldChatId];
|
|
2075
2086
|
saveConfig();
|
|
2076
|
-
|
|
2077
|
-
|
|
2087
|
+
// Chat migration is the one legit chat-wide kill: every session
|
|
2088
|
+
// (every topic) under the old chat_id is stale and must restart
|
|
2089
|
+
// under the new chat_id. Other respawn/abort paths target a
|
|
2090
|
+
// single sessionKey, but here ALL sessions are invalid.
|
|
2078
2091
|
await pm.killChat(oldChatId);
|
|
2079
2092
|
}
|
|
2080
2093
|
});
|