polygram 0.13.1 → 0.14.0
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/events-retention.js +4 -0
- package/lib/db.js +60 -1
- package/lib/handlers/download.js +12 -3
- package/lib/handlers/replay-disposition.js +120 -0
- package/migrations/013-clean-shutdown-marker.sql +10 -0
- package/package.json +1 -1
- package/polygram.js +99 -61
|
@@ -40,6 +40,10 @@ const DEFAULT_POLICY = {
|
|
|
40
40
|
keepForeverKinds: [
|
|
41
41
|
'polygram-start', 'polygram-stop', 'shutdown-drain',
|
|
42
42
|
'handler-error', 'auth-expired', 'resume-fail',
|
|
43
|
+
// 0.14 boot-replay forensics — was a real task skipped-and-announced, or
|
|
44
|
+
// silently lost? Kept so "why didn't my message get answered after the
|
|
45
|
+
// restart?" is answerable beyond the 90d default (still capped).
|
|
46
|
+
'replay-on-boot', 'replay-notice-sent', 'replay-notice-failed',
|
|
43
47
|
// the prune's own audit trail — kept so it survives a prune (still capped):
|
|
44
48
|
'events-pruned', 'events-prune-preview', 'events-prune-skipped',
|
|
45
49
|
],
|
package/lib/db.js
CHANGED
|
@@ -19,7 +19,11 @@ const Database = require('better-sqlite3');
|
|
|
19
19
|
// SCHEMA_VERSION; the early-return on line ~42 then skipped the
|
|
20
20
|
// migration loop on any DB already at user_version=8 → turn_metrics
|
|
21
21
|
// table never created → INSERT prepare at startup crashed polygram.
|
|
22
|
-
|
|
22
|
+
//
|
|
23
|
+
// 0.14: bumped from 12 → 13. Adds migration 013-clean-shutdown-marker.sql
|
|
24
|
+
// (polling_state.clean_shutdown_at). Same footgun as the 8→9 note: forgetting
|
|
25
|
+
// the bump skips the migration on any DB already at user_version=12.
|
|
26
|
+
const SCHEMA_VERSION = 13;
|
|
23
27
|
|
|
24
28
|
// Sentinel `error` value for outbound rows whose API call may or may not
|
|
25
29
|
// have reached Telegram. markStalePending writes it; hasOutboundReplyTo
|
|
@@ -637,6 +641,61 @@ function wrap(db) {
|
|
|
637
641
|
`).run(botName, cutoff);
|
|
638
642
|
},
|
|
639
643
|
|
|
644
|
+
// 0.14 boot-replay: record a DELIBERATE (clean) shutdown. Atomically, in ONE
|
|
645
|
+
// transaction: (a) mark still-in-flight inbound rows replay-pending (so a
|
|
646
|
+
// deliberate restart that interrupted a long turn still recovers it), and
|
|
647
|
+
// (b) stamp polling_state.clean_shutdown_at so boot can tell clean from
|
|
648
|
+
// crash. Written UNCONDITIONALLY on every clean shutdown — NOT gated on
|
|
649
|
+
// in-flight count — because a stale replay-pending row from a prior life
|
|
650
|
+
// must NOT be crash-recovered (re-answered) on a deliberate restart.
|
|
651
|
+
//
|
|
652
|
+
// The upsert satisfies polling_state's NOT NULL last_update_id/ts (migration
|
|
653
|
+
// 005) for a fresh/quiet bot that has no row yet (a row is otherwise created
|
|
654
|
+
// only on a non-empty getUpdates batch): COALESCE the existing values, else
|
|
655
|
+
// seed (0, now).
|
|
656
|
+
recordCleanShutdown({ botName, now = Date.now(), since } = {}) {
|
|
657
|
+
const cutoff = since ?? now - 30 * 60 * 1000;
|
|
658
|
+
const txn = db.transaction(() => {
|
|
659
|
+
const marked = db.prepare(`
|
|
660
|
+
UPDATE messages SET handler_status = 'replay-pending'
|
|
661
|
+
WHERE direction = 'in'
|
|
662
|
+
AND handler_status IN ('dispatched', 'processing')
|
|
663
|
+
AND bot_name = ?
|
|
664
|
+
AND ts > ?
|
|
665
|
+
`).run(botName, cutoff);
|
|
666
|
+
db.prepare(`
|
|
667
|
+
INSERT INTO polling_state (bot_name, last_update_id, ts, clean_shutdown_at)
|
|
668
|
+
VALUES (?,
|
|
669
|
+
COALESCE((SELECT last_update_id FROM polling_state WHERE bot_name = ?), 0),
|
|
670
|
+
COALESCE((SELECT ts FROM polling_state WHERE bot_name = ?), ?),
|
|
671
|
+
?)
|
|
672
|
+
ON CONFLICT(bot_name) DO UPDATE SET clean_shutdown_at = excluded.clean_shutdown_at
|
|
673
|
+
`).run(botName, botName, botName, now, now);
|
|
674
|
+
return marked.changes;
|
|
675
|
+
});
|
|
676
|
+
return { replayMarked: txn() };
|
|
677
|
+
},
|
|
678
|
+
|
|
679
|
+
// 0.14: read AND clear the clean-shutdown marker in one txn. "Clean" iff a
|
|
680
|
+
// marker is present, not future-dated (clock skew → crash), and within
|
|
681
|
+
// maxAgeMs (derived from the replay window). Clear-on-read so a marker from
|
|
682
|
+
// a prior boot can never be inherited as "clean" after a later crash. Any
|
|
683
|
+
// ambiguity ⇒ clean:false (the caller treats that as crash → recover).
|
|
684
|
+
consumeCleanShutdownMarker({ botName, now = Date.now(), maxAgeMs }) {
|
|
685
|
+
const txn = db.transaction(() => {
|
|
686
|
+
const row = db.prepare('SELECT clean_shutdown_at FROM polling_state WHERE bot_name = ?').get(botName);
|
|
687
|
+
const at = row ? row.clean_shutdown_at : null;
|
|
688
|
+
if (row && at != null) {
|
|
689
|
+
db.prepare('UPDATE polling_state SET clean_shutdown_at = NULL WHERE bot_name = ?').run(botName);
|
|
690
|
+
}
|
|
691
|
+
return at;
|
|
692
|
+
});
|
|
693
|
+
const at = txn();
|
|
694
|
+
const age = typeof at === 'number' ? now - at : null;
|
|
695
|
+
const clean = age != null && age >= 0 && (maxAgeMs == null || age <= maxAgeMs);
|
|
696
|
+
return { clean, markerAt: at };
|
|
697
|
+
},
|
|
698
|
+
|
|
640
699
|
// ─── Attachments (migration 007, polygram 0.6.0) ──────────────────
|
|
641
700
|
//
|
|
642
701
|
// Replaces the messages.attachments_json blob. Each attachment is its
|
package/lib/handlers/download.js
CHANGED
|
@@ -106,10 +106,19 @@ function createDownloadAttachments({
|
|
|
106
106
|
} catch (e) {
|
|
107
107
|
if (e.code === 'EEXIST') {
|
|
108
108
|
logger.log?.(`[attach] ${chatId} ← ${att.kind} ${safeName} (race: already on disk)`);
|
|
109
|
-
} else if (e.code === 'EXDEV') {
|
|
110
|
-
fs.copyFileSync(fileInfo.file_path, localPath); // cross-device fallback
|
|
111
109
|
} else {
|
|
112
|
-
|
|
110
|
+
// A hard link can fail even when a byte copy succeeds: EXDEV (the
|
|
111
|
+
// bot-api data dir is a different device/overlay than the inbox) or
|
|
112
|
+
// EPERM (the volume's filesystem refuses a cross-fs / cross-owner
|
|
113
|
+
// hard link — the local Bot API container writes files as a
|
|
114
|
+
// different user, e.g. `messagebus`, and polygram reads them via
|
|
115
|
+
// group membership). Copy works whenever we can READ the source
|
|
116
|
+
// (group perm) + WRITE the inbox; if the source read is genuinely
|
|
117
|
+
// denied, copyFileSync throws EACCES and the outer handler marks
|
|
118
|
+
// the attachment failed. Falling back on EXDEV only (the old
|
|
119
|
+
// behaviour) left EPERM uncaught → every inbound file failed once
|
|
120
|
+
// the local Bot API server was a Docker volume (2026-06-16).
|
|
121
|
+
fs.copyFileSync(fileInfo.file_path, localPath);
|
|
113
122
|
}
|
|
114
123
|
}
|
|
115
124
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Boot-replay disposition (0.14) — decide what to do with the replay candidate
|
|
5
|
+
* set, given whether the last shutdown was a DELIBERATE restart or a CRASH.
|
|
6
|
+
*
|
|
7
|
+
* Pure function (no I/O) so it's unit-testable without booting the daemon; the
|
|
8
|
+
* caller performs the actual dispatch / skip-marking / notice-sending from the
|
|
9
|
+
* returned plan (see executeReplayPlan).
|
|
10
|
+
*
|
|
11
|
+
* - CRASH (cleanShutdown=false): recover every still-unanswered candidate —
|
|
12
|
+
* the existing rc.57 behavior. An unexpected exit must never silently drop
|
|
13
|
+
* interrupted work.
|
|
14
|
+
* - CLEAN (cleanShutdown=true): a deliberate restart. Prod data (n=3430)
|
|
15
|
+
* showed skip-vs-recover CANNOT be auto-classified (53% of turns run >=30s
|
|
16
|
+
* so ordinary interactive turns are replay-pending just like a long task,
|
|
17
|
+
* and the rc.57 Xero case is itself a user message). So we decide by RESTART
|
|
18
|
+
* INTENT, not message state: skip ALL pending candidates (don't re-answer
|
|
19
|
+
* stale messages) and surface ONE visibility notice per chat/topic so a
|
|
20
|
+
* genuinely-needed reply can be re-sent. rc.57's harm was *silent* loss;
|
|
21
|
+
* this makes the skip *visible*.
|
|
22
|
+
*
|
|
23
|
+
* Dedup: a candidate already answered (hasCompletedTurn -> true) is dropped from
|
|
24
|
+
* the plan entirely — never recovered, never announced. The caller wires this to
|
|
25
|
+
* db.hasCompletedTurnFor (turn_metrics), NOT hasOutboundReplyTo (rc.51).
|
|
26
|
+
*
|
|
27
|
+
* @param {object} opts
|
|
28
|
+
* @param {Array<{chat_id, thread_id?, msg_id}>} opts.candidates
|
|
29
|
+
* @param {boolean} opts.cleanShutdown true iff the clean-shutdown marker was present+fresh
|
|
30
|
+
* @param {(candidate)=>boolean} [opts.hasCompletedTurn] already-answered predicate (default: none)
|
|
31
|
+
* @param {(candidate)=>boolean} [opts.announceable] notice-eligible predicate (default: all). Excludes
|
|
32
|
+
* admin/slash + abort-shaped rows so we don't announce "I didn't resume your /new".
|
|
33
|
+
* @returns {{recover: Array, skip: Array, notices: Array<{chat_id, thread_id, items: Array}>}}
|
|
34
|
+
*/
|
|
35
|
+
function classifyReplay({ candidates = [], cleanShutdown = false, hasCompletedTurn, announceable } = {}) {
|
|
36
|
+
const answered = typeof hasCompletedTurn === 'function' ? hasCompletedTurn : () => false;
|
|
37
|
+
const pending = (candidates || []).filter((c) => !answered(c));
|
|
38
|
+
|
|
39
|
+
if (!cleanShutdown) {
|
|
40
|
+
// Crash: recover everything unanswered (unchanged rc.57 behavior).
|
|
41
|
+
return { recover: pending, skip: [], notices: [] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Deliberate restart: skip ALL pending (none re-answered), and group the
|
|
45
|
+
// ANNOUNCEABLE ones per (chat, thread) for one visibility notice each
|
|
46
|
+
// (isolateTopics-safe). announceable excludes admin/slash + abort-shaped rows
|
|
47
|
+
// (H5) — those are skipped silently, mirroring the crash path's redelivery
|
|
48
|
+
// gate which never re-executes them. Default: everything announceable.
|
|
49
|
+
const isAnnounceable = typeof announceable === 'function' ? announceable : () => true;
|
|
50
|
+
const SEP = String.fromCharCode(0); // NUL separator (H8, collision-proof)
|
|
51
|
+
const groups = new Map();
|
|
52
|
+
for (const c of pending) {
|
|
53
|
+
if (!isAnnounceable(c)) continue;
|
|
54
|
+
const thread = c.thread_id == null ? null : c.thread_id;
|
|
55
|
+
const key = `${c.chat_id}${SEP}${thread == null ? '' : thread}`;
|
|
56
|
+
let g = groups.get(key);
|
|
57
|
+
if (!g) { g = { chat_id: c.chat_id, thread_id: thread, items: [] }; groups.set(key, g); }
|
|
58
|
+
g.items.push(c);
|
|
59
|
+
}
|
|
60
|
+
return { recover: [], skip: pending, notices: Array.from(groups.values()) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Execute a classified plan (0.14, H6). Pure-ish: all I/O via injected deps, so
|
|
65
|
+
* it's unit-testable and the crash-seam ordering can be asserted.
|
|
66
|
+
*
|
|
67
|
+
* Ordering (fail-toward-recovery): the caller has already read-and-cleared the
|
|
68
|
+
* marker BEFORE this runs, so a crash mid-execution leaves un-marked rows as
|
|
69
|
+
* candidates -> next boot has no marker -> CRASH branch recovers them (never a
|
|
70
|
+
* silent drop). Within the clean branch: send each group's notice FIRST; only on
|
|
71
|
+
* a CONFIRMED send mark that group's rows terminal ('replay-skipped'); on a send
|
|
72
|
+
* FAILURE leave the rows recoverable and emit replay-notice-failed. Gate-blocked
|
|
73
|
+
* skip items (not in any notice group) are marked terminal silently.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} opts
|
|
76
|
+
* @param {{recover:Array, skip:Array, notices:Array}} opts.plan
|
|
77
|
+
* @param {object} opts.deps
|
|
78
|
+
* @param {(candidate)=>Promise<{ok:boolean}>} opts.deps.recover crash-path re-dispatch
|
|
79
|
+
* @param {(group)=>Promise<{ok:boolean, messageId?:any, error?:string}>} opts.deps.sendNotice
|
|
80
|
+
* @param {(candidate)=>void} opts.deps.markSkipped mark row terminal 'replay-skipped'
|
|
81
|
+
* @param {(kind, detail)=>void} [opts.deps.logEvent]
|
|
82
|
+
* @returns {Promise<{recovered:number, skipped:number, noticed:number, noticeFailed:number}>}
|
|
83
|
+
*/
|
|
84
|
+
async function executeReplayPlan({ plan, deps }) {
|
|
85
|
+
const { recover = [], skip = [], notices = [] } = plan || {};
|
|
86
|
+
const log = (deps && deps.logEvent) || (() => {});
|
|
87
|
+
let recovered = 0; let skipped = 0; let noticed = 0; let noticeFailed = 0;
|
|
88
|
+
|
|
89
|
+
// CRASH branch — recover each (unchanged rc.57 behavior).
|
|
90
|
+
for (const c of recover) {
|
|
91
|
+
// eslint-disable-next-line no-await-in-loop
|
|
92
|
+
const r = await deps.recover(c);
|
|
93
|
+
if (r && r.ok) recovered += 1; else skipped += 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// CLEAN branch — notice-then-mark, per group.
|
|
97
|
+
const inNotices = new Set();
|
|
98
|
+
for (const g of notices) for (const c of g.items) inNotices.add(c);
|
|
99
|
+
for (const g of notices) {
|
|
100
|
+
let res;
|
|
101
|
+
// eslint-disable-next-line no-await-in-loop
|
|
102
|
+
try { res = await deps.sendNotice(g); } catch (e) { res = { ok: false, error: e && e.message }; }
|
|
103
|
+
if (res && res.ok) {
|
|
104
|
+
for (const c of g.items) { deps.markSkipped(c); skipped += 1; }
|
|
105
|
+
noticed += 1;
|
|
106
|
+
log('replay-notice-sent', { chat_id: g.chat_id, thread_id: g.thread_id, count: g.items.length, notice_msg_id: res.messageId });
|
|
107
|
+
} else {
|
|
108
|
+
noticeFailed += 1;
|
|
109
|
+
log('replay-notice-failed', { chat_id: g.chat_id, thread_id: g.thread_id, count: g.items.length, error: res && res.error });
|
|
110
|
+
// leave rows recoverable (no markSkipped) — next boot's crash branch recovers them
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Gate-blocked skip items (never in a notice group) -> terminal, silent.
|
|
114
|
+
for (const c of skip) {
|
|
115
|
+
if (!inNotices.has(c)) { deps.markSkipped(c); skipped += 1; }
|
|
116
|
+
}
|
|
117
|
+
return { recovered, skipped, noticed, noticeFailed };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { classifyReplay, executeReplayPlan };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- 0.14: clean-shutdown marker for boot-replay.
|
|
2
|
+
--
|
|
3
|
+
-- On a DELIBERATE restart polygram skips re-dispatching stale candidates (so it
|
|
4
|
+
-- doesn't re-answer messages the user already saw it working on) and posts one
|
|
5
|
+
-- visibility notice instead. On a CRASH it recovers everything (unchanged).
|
|
6
|
+
--
|
|
7
|
+
-- The marker is written in the shutdown handler (same txn as markReplayPending)
|
|
8
|
+
-- and read-and-cleared at boot. NULL = no clean shutdown recorded → treat as
|
|
9
|
+
-- crash (recover). One row per bot (shares polling_state's PK).
|
|
10
|
+
ALTER TABLE polling_state ADD COLUMN clean_shutdown_at INTEGER;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
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
|
@@ -67,8 +67,9 @@ const { createHandleAbort } = require('./lib/handlers/abort');
|
|
|
67
67
|
const { createAutosteerHandlers } = require('./lib/handlers/autosteer');
|
|
68
68
|
const { createEditCorrectionInjector } = require('./lib/handlers/edit-correction');
|
|
69
69
|
const { createEditRedelivery } = require('./lib/handlers/edit-redelivery');
|
|
70
|
-
const { createGateInbound } = require('./lib/handlers/gate-inbound');
|
|
70
|
+
const { createGateInbound, ADMIN_CMD_RE, PAIR_CLAIM_RE } = require('./lib/handlers/gate-inbound');
|
|
71
71
|
const { createRedeliver } = require('./lib/handlers/redeliver');
|
|
72
|
+
const { classifyReplay, executeReplayPlan } = require('./lib/handlers/replay-disposition');
|
|
72
73
|
const { createDropRedeliverer } = require('./lib/handlers/drop-redeliver');
|
|
73
74
|
const { createSessionFeedback } = require('./lib/feedback/session-feedback');
|
|
74
75
|
const { createSlashCommands } = require('./lib/handlers/slash-commands');
|
|
@@ -2650,28 +2651,29 @@ async function main() {
|
|
|
2650
2651
|
let remaining = 0;
|
|
2651
2652
|
for (const n of inFlightHandlers.values()) remaining += n;
|
|
2652
2653
|
|
|
2653
|
-
// 3.
|
|
2654
|
-
//
|
|
2655
|
-
|
|
2654
|
+
// 3. This handler only runs on a DELIBERATE shutdown (SIGINT/SIGTERM/SIGHUP);
|
|
2655
|
+
// a crash (SIGKILL/OOM/panic) never reaches here. Record a clean-shutdown
|
|
2656
|
+
// marker AND mark any still-in-flight rows replay-pending, atomically, on
|
|
2657
|
+
// EVERY clean shutdown (not just when in-flight>0) — boot uses the marker
|
|
2658
|
+
// to SKIP re-answering stale messages on a deliberate restart while still
|
|
2659
|
+
// recovering everything on a crash (0.14, §H1). markReplayPending alone
|
|
2660
|
+
// (the old behavior) couldn't distinguish the two, so every deploy
|
|
2661
|
+
// re-answered. A stale replay-pending row from a prior life would also be
|
|
2662
|
+
// crash-recovered on a deliberate restart without the unconditional marker.
|
|
2663
|
+
if (db) {
|
|
2656
2664
|
try {
|
|
2657
|
-
const res = db.
|
|
2665
|
+
const res = db.recordCleanShutdown({ botName: BOT_NAME });
|
|
2658
2666
|
logEvent('shutdown-drain', {
|
|
2659
2667
|
bot: BOT_NAME,
|
|
2660
2668
|
in_flight: remaining,
|
|
2661
|
-
replay_marked: res?.
|
|
2669
|
+
replay_marked: res?.replayMarked ?? 0,
|
|
2662
2670
|
elapsed_ms: drainElapsed,
|
|
2671
|
+
clean: true,
|
|
2663
2672
|
});
|
|
2664
|
-
console.log(`[shutdown] drained ${drainElapsed}ms, ${remaining}
|
|
2673
|
+
console.log(`[shutdown] clean shutdown recorded; drained ${drainElapsed}ms, ${remaining} in-flight, ${res?.replayMarked ?? 0} marked replay-pending`);
|
|
2665
2674
|
} catch (err) {
|
|
2666
|
-
console.error(`[shutdown]
|
|
2675
|
+
console.error(`[shutdown] recordCleanShutdown failed: ${err.message}`);
|
|
2667
2676
|
}
|
|
2668
|
-
} else if (db) {
|
|
2669
|
-
logEvent('shutdown-drain', {
|
|
2670
|
-
bot: BOT_NAME,
|
|
2671
|
-
in_flight: 0,
|
|
2672
|
-
elapsed_ms: drainElapsed,
|
|
2673
|
-
});
|
|
2674
|
-
console.log(`[shutdown] clean drain in ${drainElapsed}ms`);
|
|
2675
2677
|
}
|
|
2676
2678
|
|
|
2677
2679
|
// 4. Remaining shutdown: approvals sweeper, IPC, resolve hook waiters,
|
|
@@ -2752,34 +2754,52 @@ async function main() {
|
|
|
2752
2754
|
const chatIds = Object.keys(config.chats);
|
|
2753
2755
|
if (chatIds.length > 0) {
|
|
2754
2756
|
const replayWindowMs = resolveReplayWindowMs(config);
|
|
2757
|
+
|
|
2758
|
+
// 0.14: classify by RESTART INTENT. Read-and-clear the clean-shutdown
|
|
2759
|
+
// marker FIRST, in its own try/catch — ANY error => treat as crash
|
|
2760
|
+
// (recover), never skip-all (fail toward recovery). A deliberate restart
|
|
2761
|
+
// skips re-answering stale messages and posts one visibility notice; a
|
|
2762
|
+
// crash recovers everything (unchanged rc.57 behavior).
|
|
2763
|
+
let cleanShutdown = false;
|
|
2764
|
+
try {
|
|
2765
|
+
const maxAgeMs = 2 * (replayWindowMs || 3 * 60 * 1000);
|
|
2766
|
+
cleanShutdown = db.consumeCleanShutdownMarker({ botName: BOT_NAME, maxAgeMs }).clean;
|
|
2767
|
+
} catch (err) {
|
|
2768
|
+
console.error(`[replay] clean-shutdown marker read failed (-> crash recover): ${err.message}`);
|
|
2769
|
+
cleanShutdown = false;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2755
2772
|
const candidates = db.getReplayCandidates({ chatIds, ...(replayWindowMs && { olderThanMs: replayWindowMs }) });
|
|
2756
|
-
|
|
2757
|
-
|
|
2773
|
+
|
|
2774
|
+
// Cleanup pass: a candidate with a COMPLETED turn (turn_metrics, not just
|
|
2775
|
+
// an ack-bubble — rc.51, the rc.50 msg-12158 lesson) was already answered.
|
|
2776
|
+
// Mark it terminal 'replied' and exclude it from the plan (recovered nor
|
|
2777
|
+
// announced). Single pass → reuse the result as the dedup predicate.
|
|
2778
|
+
const completed = new Set();
|
|
2758
2779
|
for (const row of candidates) {
|
|
2759
|
-
// rc.51: dedupe on turn_metrics (definitive turn completion),
|
|
2760
|
-
// NOT just on hasOutboundReplyTo. The latter trips on
|
|
2761
|
-
// intermediate ack-bubbles (e.g. "Catching up on history…",
|
|
2762
|
-
// "I'll write a quick inline script…") and silently skips the
|
|
2763
|
-
// replay even when the actual answer never arrived. The rc.50
|
|
2764
|
-
// EIO-orphan incident lost Ivan DM msg 12158 this way: an ack
|
|
2765
|
-
// bubble was sent at 13:20:36, the turn was killed mid-flight,
|
|
2766
|
-
// boot-replay saw the ack and assumed "answered."
|
|
2767
|
-
//
|
|
2768
|
-
// turn_metrics is only inserted by the SDK pm's onResult
|
|
2769
|
-
// callback, which fires only when the turn definitively
|
|
2770
|
-
// completes. No row → no completion → re-dispatch.
|
|
2771
2780
|
if (db.hasCompletedTurnFor({ chat_id: row.chat_id, msg_id: row.msg_id })) {
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
});
|
|
2775
|
-
skipped += 1;
|
|
2776
|
-
continue;
|
|
2781
|
+
completed.add(`${row.chat_id}/${row.msg_id}`);
|
|
2782
|
+
db.setInboundHandlerStatus({ chat_id: row.chat_id, msg_id: row.msg_id, status: 'replied' });
|
|
2777
2783
|
}
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2784
|
+
}
|
|
2785
|
+
const hasCompletedTurn = (row) => completed.has(`${row.chat_id}/${row.msg_id}`);
|
|
2786
|
+
|
|
2787
|
+
// Notice eligibility (H5): never announce admin/slash or abort-shaped rows
|
|
2788
|
+
// (the crash path's redelivery gate never re-executes them either). An
|
|
2789
|
+
// attachment-only message (no text, e.g. a screenshot) IS announceable.
|
|
2790
|
+
const announceable = (row) => {
|
|
2791
|
+
const t = (row.text || '').trim();
|
|
2792
|
+
if (!t) return true;
|
|
2793
|
+
if (typeof isAbortRequest === 'function' && isAbortRequest(t)) return false;
|
|
2794
|
+
if (ADMIN_CMD_RE.test(t) || PAIR_CLAIM_RE.test(t)) return false;
|
|
2795
|
+
return true;
|
|
2796
|
+
};
|
|
2797
|
+
|
|
2798
|
+
// Reconstruct a minimal grammy-like Message for the crash-path
|
|
2799
|
+
// re-dispatch (the shape dispatchRegularMessage expects; attachments via
|
|
2800
|
+
// the media-group shortcut so the normal download path re-fetches).
|
|
2801
|
+
const reconstruct = (row) => {
|
|
2802
|
+
const msg = {
|
|
2783
2803
|
chat: { id: Number(row.chat_id), type: row.chat_id.startsWith('-') ? 'supergroup' : 'private' },
|
|
2784
2804
|
message_id: row.msg_id,
|
|
2785
2805
|
from: { id: row.user_id, first_name: row.user },
|
|
@@ -2788,36 +2808,54 @@ async function main() {
|
|
|
2788
2808
|
...(row.thread_id && { message_thread_id: Number(row.thread_id) }),
|
|
2789
2809
|
...(row.reply_to_id && { reply_to_message: { message_id: row.reply_to_id } }),
|
|
2790
2810
|
};
|
|
2791
|
-
// Attach already-recorded attachments via the media-group shortcut
|
|
2792
|
-
// field so extractAttachments picks them up without re-parsing
|
|
2793
|
-
// grammy fields that don't exist on this reconstructed object.
|
|
2794
2811
|
const attRows = db.getAttachmentsByMessage(row.id);
|
|
2795
2812
|
if (attRows.length) {
|
|
2796
|
-
|
|
2813
|
+
msg._mergedAttachments = attRows.map((a) => ({
|
|
2797
2814
|
kind: a.kind, name: a.name, mime_type: a.mime_type,
|
|
2798
2815
|
size: a.size_bytes, file_id: a.file_id, file_unique_id: a.file_unique_id,
|
|
2799
2816
|
}));
|
|
2800
2817
|
}
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2818
|
+
return msg;
|
|
2819
|
+
};
|
|
2820
|
+
|
|
2821
|
+
const plan = classifyReplay({ candidates, cleanShutdown, hasCompletedTurn, announceable });
|
|
2822
|
+
|
|
2823
|
+
const result = await executeReplayPlan({
|
|
2824
|
+
plan,
|
|
2825
|
+
deps: {
|
|
2826
|
+
// CRASH path — unchanged: through the unified redelivery tail (D5 gate
|
|
2827
|
+
// at tier 'redelivery', 'replay-attempted' one-shot pre-mark, ack).
|
|
2828
|
+
recover: async (row) => {
|
|
2829
|
+
const chatConfig = config.chats[row.chat_id];
|
|
2830
|
+
if (!chatConfig) return { ok: false };
|
|
2831
|
+
return redeliverAsFreshTurn({
|
|
2832
|
+
chatId: row.chat_id, msg: reconstruct(row),
|
|
2833
|
+
source: 'boot-replay', preMark: 'replay-attempted',
|
|
2834
|
+
});
|
|
2835
|
+
},
|
|
2836
|
+
// CLEAN path — one visibility notice per (chat, thread). plainText so
|
|
2837
|
+
// no markdown/HTML parse on a boot send.
|
|
2838
|
+
sendNotice: async (g) => {
|
|
2839
|
+
const n = g.items.length;
|
|
2840
|
+
const text = `↺ Restarted — I didn't auto-resume ${n} message${n > 1 ? 's' : ''} you sent just before. If any still need a reply, send it again.`;
|
|
2841
|
+
const res = await tg(bot, 'sendMessage', {
|
|
2842
|
+
chat_id: Number(g.chat_id), text,
|
|
2843
|
+
...(g.thread_id ? { message_thread_id: Number(g.thread_id) } : {}),
|
|
2844
|
+
}, { source: 'boot-replay-notice', plainText: true });
|
|
2845
|
+
return { ok: true, messageId: res?.message_id };
|
|
2846
|
+
},
|
|
2847
|
+
markSkipped: (row) => db.setInboundHandlerStatus({ chat_id: row.chat_id, msg_id: row.msg_id, status: 'replay-skipped' }),
|
|
2848
|
+
logEvent,
|
|
2849
|
+
},
|
|
2850
|
+
});
|
|
2851
|
+
|
|
2817
2852
|
if (candidates.length > 0) {
|
|
2818
|
-
console.log(`[replay] ${
|
|
2853
|
+
console.log(`[replay] ${cleanShutdown ? 'clean restart' : 'crash'} — recovered ${result.recovered}, skipped ${result.skipped}, noticed ${result.noticed}${result.noticeFailed ? `, notice-failed ${result.noticeFailed}` : ''}`);
|
|
2819
2854
|
logEvent('replay-on-boot', {
|
|
2820
|
-
bot: BOT_NAME,
|
|
2855
|
+
bot: BOT_NAME, clean: cleanShutdown,
|
|
2856
|
+
recovered: result.recovered, skipped: result.skipped,
|
|
2857
|
+
noticed: result.noticed, notice_failed: result.noticeFailed,
|
|
2858
|
+
total: candidates.length,
|
|
2821
2859
|
});
|
|
2822
2860
|
}
|
|
2823
2861
|
}
|