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.
@@ -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
- COALESCE(json_extract(att.value, '$.file_id'), ''),
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 messages m, json_each(m.attachments_json) att
85
- WHERE m.attachments_json IS NOT NULL
86
- AND m.direction = 'in'
87
- AND NOT EXISTS (
88
- SELECT 1 FROM attachments a WHERE a.message_id = m.id
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",
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
- dbWrite(() => {
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
- }, `insert inbound ${chatId}/${msg.message_id}`);
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 across all sessionKeys for this chat (matches the
1150
- // text-command flow in handleMessage).
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 prefix = chatId;
1153
- let anyActive = false;
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
- // Helper: request respawn across ALL sessionKeys owned by this chat (one
1296
- // per topic if isolateTopics=true, otherwise just the single chat-level
1297
- // key). Graceful: in-flight turns drain on old settings, new turns use
1298
- // the new settings. Returns total pending turns across all keys so the
1299
- // reply can tell the user.
1300
- const requestRespawnForChat = (reason) => {
1301
- const prefix = String(chatId);
1302
- let totalQueued = 0;
1303
- let anyActive = false;
1304
- for (const key of pm.keys()) {
1305
- if (key === prefix || key.startsWith(prefix + ':')) {
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 } = requestRespawnForChat('model-change');
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 } = requestRespawnForChat('effort-change');
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
- await pm.killChat(chatId).catch(() => {});
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, queued_dropped: dropped,
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
- const droppedMigrate = drainQueuesForChat(oldChatId);
2077
- if (droppedMigrate) dbWrite(() => db.logEvent('queue-drained', { chat_id: oldChatId, reason: 'chat-migrated', dropped: droppedMigrate }), 'log queue-drained');
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
  });