talon-agent 3.24.0 → 3.24.1
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/package.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Bot } from "grammy";
|
|
10
10
|
import { respawnSelf } from "../../../util/respawn.js";
|
|
11
|
+
import { isStaleCommand } from "../stale-command.js";
|
|
11
12
|
import {
|
|
12
13
|
getRepoRoot,
|
|
13
14
|
runSelfUpdate,
|
|
@@ -154,6 +155,10 @@ export function registerAdminCommands(
|
|
|
154
155
|
await ctx.reply("Not authorized.");
|
|
155
156
|
return;
|
|
156
157
|
}
|
|
158
|
+
// A restart that predates this process is a redelivery (or an order
|
|
159
|
+
// aimed at a daemon that is already gone) — obeying it would restart
|
|
160
|
+
// us again, and again, on every boot.
|
|
161
|
+
if (isStaleCommand(ctx.message?.date, "/restart")) return;
|
|
157
162
|
await ctx.reply("♻️ Restarting...");
|
|
158
163
|
respawnSelf("telegram /restart");
|
|
159
164
|
});
|
|
@@ -169,6 +174,8 @@ export function registerAdminCommands(
|
|
|
169
174
|
await ctx.reply("Not authorized.");
|
|
170
175
|
return;
|
|
171
176
|
}
|
|
177
|
+
// Same redelivery hazard as /restart — it also ends the process.
|
|
178
|
+
if (isStaleCommand(ctx.message?.date, "/update")) return;
|
|
172
179
|
const remote = config.update?.remote ?? "origin";
|
|
173
180
|
const branch = config.update?.branch ?? "main";
|
|
174
181
|
const sent = await ctx.reply(
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "./commands/index.js";
|
|
24
24
|
import { setAccessControl } from "./handlers/index.js";
|
|
25
25
|
import { registerMiddleware } from "./middleware.js";
|
|
26
|
+
import { confirmUpdates } from "./update-offset.js";
|
|
26
27
|
import { registerCallbacks } from "./callbacks/index.js";
|
|
27
28
|
import { log, logError } from "../../util/log.js";
|
|
28
29
|
|
|
@@ -142,6 +143,10 @@ export function createTelegramFrontend(
|
|
|
142
143
|
async stop() {
|
|
143
144
|
try {
|
|
144
145
|
await bot.stop();
|
|
146
|
+
// grammY advances the update offset on its NEXT poll, which never
|
|
147
|
+
// comes once we are shutting down — so confirm it explicitly or
|
|
148
|
+
// Telegram redelivers the command that triggered this shutdown.
|
|
149
|
+
await confirmUpdates(bot);
|
|
145
150
|
log("shutdown", "Bot disconnected");
|
|
146
151
|
} catch (err) {
|
|
147
152
|
logError("shutdown", "Bot stop error", err);
|
|
@@ -10,6 +10,7 @@ import { allowChat, revokeChat } from "./userbot.js";
|
|
|
10
10
|
import { registerChat } from "../../core/background/pulse.js";
|
|
11
11
|
import { log } from "../../util/log.js";
|
|
12
12
|
import { getSenderName } from "./handlers/index.js";
|
|
13
|
+
import { noteUpdateId } from "./update-offset.js";
|
|
13
14
|
import { noteInboundThread } from "./topics.js";
|
|
14
15
|
import { recordJoinRequest } from "./join-requests.js";
|
|
15
16
|
import { newlyAddedEmojis, recordReactionToBot } from "../../core/soul/taps.js";
|
|
@@ -26,6 +27,15 @@ import {
|
|
|
26
27
|
} from "./handlers/index.js";
|
|
27
28
|
|
|
28
29
|
export function registerMiddleware(bot: Bot, config: TalonConfig): void {
|
|
30
|
+
// ── Update-offset tracking (every update, before anything else) ──────────
|
|
31
|
+
// Telegram redelivers any update whose id was never confirmed; the
|
|
32
|
+
// shutdown path confirms this one so a process-ending command can't be
|
|
33
|
+
// served twice. See update-offset.ts.
|
|
34
|
+
bot.use((ctx, next) => {
|
|
35
|
+
noteUpdateId(ctx.update.update_id);
|
|
36
|
+
return next();
|
|
37
|
+
});
|
|
38
|
+
|
|
29
39
|
// ── History capture (runs for ALL messages, before handlers) ─────────────
|
|
30
40
|
bot.on("message", (ctx, next) => {
|
|
31
41
|
const chatId = String(ctx.chat.id);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staleness guard for process-ending commands.
|
|
3
|
+
*
|
|
4
|
+
* Second layer under `update-offset.ts`. Confirming the offset stops the
|
|
5
|
+
* redelivery that caused the restart loop, but it can't be guaranteed —
|
|
6
|
+
* the confirmation is a network call on the way out, and a SIGKILL, a
|
|
7
|
+
* crash, or a 409 from two pollers skips it entirely. Any command that
|
|
8
|
+
* ends the process is therefore also checked for age: it must be newer
|
|
9
|
+
* than this process, because a `/restart` issued before we booted has
|
|
10
|
+
* either already been served (redelivery) or refers to a daemon that is
|
|
11
|
+
* no longer running.
|
|
12
|
+
*
|
|
13
|
+
* Only self-terminating commands are gated. Ordinary messages sent while
|
|
14
|
+
* the daemon was down are real work and must still be processed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { logWarn } from "../../util/log.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Grace window for clock skew between Telegram's timestamps and ours,
|
|
21
|
+
* and for a command issued in the seconds around a boot.
|
|
22
|
+
*/
|
|
23
|
+
const CLOCK_SKEW_GRACE_MS = 30_000;
|
|
24
|
+
|
|
25
|
+
/** When this process started — the cutoff a fresh command must beat. */
|
|
26
|
+
const PROCESS_START_MS = Date.now();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when a command predates this process and must not be re-executed.
|
|
30
|
+
* `messageDate` is Telegram's `message.date` (seconds since epoch).
|
|
31
|
+
*/
|
|
32
|
+
export function isStaleCommand(
|
|
33
|
+
messageDate: number | undefined,
|
|
34
|
+
command: string,
|
|
35
|
+
): boolean {
|
|
36
|
+
if (!messageDate) return false; // no timestamp — treat as live
|
|
37
|
+
const sentAtMs = messageDate * 1000;
|
|
38
|
+
if (sentAtMs >= PROCESS_START_MS - CLOCK_SKEW_GRACE_MS) return false;
|
|
39
|
+
const ageSec = Math.round((Date.now() - sentAtMs) / 1000);
|
|
40
|
+
logWarn(
|
|
41
|
+
"bot",
|
|
42
|
+
`Ignoring stale ${command} from ${ageSec}s ago — it predates this process ` +
|
|
43
|
+
`(a redelivered restart would loop the daemon).`,
|
|
44
|
+
);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update-offset confirmation — the reason a `/restart` used to run twice.
|
|
3
|
+
*
|
|
4
|
+
* Telegram's getUpdates is at-least-once: an update stays queued until a
|
|
5
|
+
* LATER call passes `offset = update_id + 1`. grammY advances that offset
|
|
6
|
+
* on its next poll, so a command that ends the process — `/restart`,
|
|
7
|
+
* `/update` — exits before the confirmation is ever sent. Telegram then
|
|
8
|
+
* redelivers it to the successor, which restarts, which never confirms
|
|
9
|
+
* either: a boot loop that survives every restart, observed live on
|
|
10
|
+
* 2026-08-22 taking the daemon down four times in a row.
|
|
11
|
+
*
|
|
12
|
+
* The fix is to confirm explicitly before exiting. This module tracks the
|
|
13
|
+
* highest update_id seen and `confirmUpdates` acknowledges it with a
|
|
14
|
+
* zero-timeout getUpdates, which is exactly what grammY's next poll would
|
|
15
|
+
* have done.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Bot } from "grammy";
|
|
19
|
+
import { log, logWarn } from "../../util/log.js";
|
|
20
|
+
|
|
21
|
+
let highestUpdateId = 0;
|
|
22
|
+
|
|
23
|
+
/** Record an update as seen. Called for every update grammY dispatches. */
|
|
24
|
+
export function noteUpdateId(updateId: number): void {
|
|
25
|
+
if (updateId > highestUpdateId) highestUpdateId = updateId;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Highest update id seen this process (0 when none). Test seam. */
|
|
29
|
+
export function lastUpdateId(): number {
|
|
30
|
+
return highestUpdateId;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Test seam: forget the tracked offset. */
|
|
34
|
+
export function resetUpdateOffset(): void {
|
|
35
|
+
highestUpdateId = 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Tell Telegram every update seen so far is handled, so none of them is
|
|
40
|
+
* redelivered to the next process. Best-effort: this runs on the
|
|
41
|
+
* shutdown path, where a failure must never block exit.
|
|
42
|
+
*/
|
|
43
|
+
export async function confirmUpdates(bot: Bot): Promise<void> {
|
|
44
|
+
if (highestUpdateId === 0) return;
|
|
45
|
+
try {
|
|
46
|
+
await bot.api.getUpdates({
|
|
47
|
+
offset: highestUpdateId + 1,
|
|
48
|
+
limit: 1,
|
|
49
|
+
timeout: 0,
|
|
50
|
+
});
|
|
51
|
+
log("shutdown", `Confirmed Telegram updates through ${highestUpdateId}`);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
logWarn(
|
|
54
|
+
"shutdown",
|
|
55
|
+
`Could not confirm Telegram update offset: ${err instanceof Error ? err.message : err}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|