polygram 0.6.2 → 0.6.4

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 CHANGED
@@ -463,6 +463,24 @@ function wrap(db) {
463
463
  return db.prepare(sql).all(...args);
464
464
  },
465
465
 
466
+ // Re-FK every attachment whose (chat_id, msg_id) is in `msg_ids` over
467
+ // to a single primary message row. Used when the media-group buffer
468
+ // coalesces N Telegram messages (each carrying one photo of an album)
469
+ // into one synthetic turn — siblings were recorded under their own
470
+ // msg_ids by recordInbound, but Claude needs to see them all under
471
+ // the primary message so handleMessage's per-message attachment
472
+ // lookup returns the full album.
473
+ reassignAttachmentsToMessage({ chat_id, msg_ids, target_message_id }) {
474
+ if (!Array.isArray(msg_ids) || msg_ids.length === 0) return { changes: 0 };
475
+ const placeholders = msg_ids.map(() => '?').join(',');
476
+ return db.prepare(`
477
+ UPDATE attachments
478
+ SET message_id = ?, msg_id = (SELECT msg_id FROM messages WHERE id = ?)
479
+ WHERE chat_id = ? AND msg_id IN (${placeholders})
480
+ AND message_id != ?
481
+ `).run(target_message_id, target_message_id, String(chat_id), ...msg_ids, target_message_id);
482
+ },
483
+
466
484
  // Look up the messages.id auto-pk for an inbound message. Used by
467
485
  // recordInbound to FK attachments to the just-inserted message even
468
486
  // when an ON-CONFLICT update happened (lastInsertRowid is 0 in that
@@ -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.2",
3
+ "version": "0.6.4",
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
 
@@ -1930,6 +1945,29 @@ function createBot(token) {
1930
1945
  // all-media-no-text groups.
1931
1946
  const primary = messages.find((m) => m.text || m.caption) || messages[0];
1932
1947
  const merged = messages.flatMap((m) => extractAttachments(m));
1948
+
1949
+ // 0.6.0 attachment-table regression fix: recordInbound (called per
1950
+ // sibling on bot.on('message')) inserted each photo's row under its
1951
+ // OWN msg_id. handleMessage looks up attachments via
1952
+ // getAttachmentsByMessage(primary.message_id) — which only returns
1953
+ // the primary's row. Without re-FK'ing the siblings we'd silently
1954
+ // drop N-1 of N photos in any album, exactly the umi-assistant bug
1955
+ // the user hit (saw 1 of 2 photos sent in a Telegram album).
1956
+ const chatId = String(primary.chat.id);
1957
+ const primaryDbId = db.getInboundMessageId({
1958
+ chat_id: chatId, msg_id: primary.message_id,
1959
+ });
1960
+ const siblingMsgIds = messages
1961
+ .filter((m) => m.message_id !== primary.message_id)
1962
+ .map((m) => m.message_id);
1963
+ if (primaryDbId && siblingMsgIds.length) {
1964
+ dbWrite(() => db.reassignAttachmentsToMessage({
1965
+ chat_id: chatId,
1966
+ msg_ids: siblingMsgIds,
1967
+ target_message_id: primaryDbId,
1968
+ }), 'reassign media-group sibling attachments');
1969
+ }
1970
+
1933
1971
  const synthetic = { ...primary, _mergedAttachments: merged };
1934
1972
  // Carry the primary's text verbatim (dispatchRegularMessage re-cleans
1935
1973
  // the mention). Caption → text so downstream sees it uniformly.