@nanhara/hara 0.122.2 → 0.122.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/CHANGELOG.md +108 -0
- package/README.md +15 -2
- package/dist/agent/limits.js +51 -0
- package/dist/agent/loop.js +367 -112
- package/dist/agent/repeat-guard.js +49 -12
- package/dist/checkpoints.js +9 -0
- package/dist/cli.js +2 -1
- package/dist/config.js +10 -2
- package/dist/context/agents-md.js +21 -2
- package/dist/context/mentions.js +84 -1
- package/dist/context/workspace-scope.js +49 -0
- package/dist/cron/deliver.js +61 -38
- package/dist/cron/runner.js +423 -65
- package/dist/cron/schedule.js +23 -7
- package/dist/cron/store.js +709 -43
- package/dist/fs-walk.js +279 -7
- package/dist/fs-write.js +9 -0
- package/dist/gateway/feishu.js +351 -64
- package/dist/gateway/flows-pending.js +31 -17
- package/dist/gateway/flows.js +306 -31
- package/dist/gateway/outbound-files.js +20 -6
- package/dist/gateway/runtime-state.js +1485 -0
- package/dist/gateway/serve.js +550 -242
- package/dist/gateway/sessions.js +3 -3
- package/dist/gateway/telegram.js +279 -29
- package/dist/gateway/tts.js +182 -38
- package/dist/hooks.js +22 -22
- package/dist/index.js +466 -158
- package/dist/memory/store.js +8 -5
- package/dist/org/planner.js +11 -6
- package/dist/org/projects.js +3 -3
- package/dist/process-identity.js +52 -0
- package/dist/providers/bounded-turn.js +42 -0
- package/dist/recall.js +69 -1
- package/dist/runtime.js +24 -0
- package/dist/sandbox.js +54 -4
- package/dist/search/embed.js +13 -2
- package/dist/search/hybrid.js +6 -3
- package/dist/search/semindex.js +87 -5
- package/dist/security/guardian.js +3 -15
- package/dist/security/permissions.js +11 -0
- package/dist/serve/server.js +70 -42
- package/dist/serve/sessions.js +4 -3
- package/dist/sync-sleep.js +46 -0
- package/dist/tools/agent.js +1 -1
- package/dist/tools/ask_user.js +5 -1
- package/dist/tools/builtin.js +5 -2
- package/dist/tools/codebase.js +67 -18
- package/dist/tools/computer.js +149 -68
- package/dist/tools/cron.js +72 -18
- package/dist/tools/edit.js +3 -2
- package/dist/tools/external_agent.js +66 -12
- package/dist/tools/memory.js +25 -7
- package/dist/tools/patch.js +11 -2
- package/dist/tools/registry.js +16 -1
- package/dist/tools/search.js +68 -9
- package/dist/tools/send.js +1 -1
- package/dist/tools/skill.js +1 -1
- package/dist/tools/task.js +3 -3
- package/dist/tools/web.js +43 -13
- package/dist/tui/App.js +93 -25
- package/dist/vision.js +5 -6
- package/package.json +2 -2
- package/runtime-bootstrap.cjs +22 -0
package/dist/gateway/sessions.js
CHANGED
|
@@ -8,6 +8,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
8
8
|
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
+
import { sleepSync } from "../sync-sleep.js";
|
|
11
12
|
/** Idle window before a chat auto-rotates to a FRESH session (hours). A WeChat/Feishu chat is one
|
|
12
13
|
* endless surface — without this, days-old context piles onto every new ask and the agent answers
|
|
13
14
|
* from stale state (the "gateway answers with old context" report). Default 8h: overnight gaps start
|
|
@@ -23,7 +24,6 @@ const dir = () => join(haraDir(), "gateway");
|
|
|
23
24
|
const file = () => join(dir(), "chats.json");
|
|
24
25
|
const lockFile = () => `${file()}.lock`;
|
|
25
26
|
const reclaimFile = () => `${lockFile()}.reclaim`;
|
|
26
|
-
const waitCell = new Int32Array(new SharedArrayBuffer(4));
|
|
27
27
|
const LOCK_WAIT_MS = 5_000;
|
|
28
28
|
function errorCode(error) {
|
|
29
29
|
return typeof error === "object" && error !== null && "code" in error
|
|
@@ -114,7 +114,7 @@ function acquireLock() {
|
|
|
114
114
|
}
|
|
115
115
|
if (Date.now() >= deadline)
|
|
116
116
|
throw new Error(`Timed out waiting for gateway session store lock: ${lockFile()}`);
|
|
117
|
-
|
|
117
|
+
sleepSync(10);
|
|
118
118
|
continue;
|
|
119
119
|
}
|
|
120
120
|
try {
|
|
@@ -157,7 +157,7 @@ function acquireLock() {
|
|
|
157
157
|
if (Date.now() >= deadline) {
|
|
158
158
|
throw new Error(`Timed out waiting for gateway session store lock: ${lockFile()}`);
|
|
159
159
|
}
|
|
160
|
-
|
|
160
|
+
sleepSync(10);
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
}
|
package/dist/gateway/telegram.js
CHANGED
|
@@ -2,8 +2,78 @@
|
|
|
2
2
|
// fetch, zero new dep). Token from HARA_TELEGRAM_TOKEN. The generic `ChatAdapter` shape is what WeChat(iLink)
|
|
3
3
|
// / Feishu plug into next.
|
|
4
4
|
import { InboundMediaBudget, savePrivateResponse } from "./media.js";
|
|
5
|
+
import { gatewayRuntimeScope } from "./runtime-state.js";
|
|
5
6
|
const API = "https://api.telegram.org";
|
|
6
7
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
8
|
+
const processOutboundTails = new Map();
|
|
9
|
+
/** Keeps each logical outbound message/file atomic within one credential-scoped chat while unrelated chats
|
|
10
|
+
* stay concurrent. The map is process-global rather than adapter-local because cron/flow delivery constructs
|
|
11
|
+
* short-lived adapters; those instances must not interleave chunks with the long-lived gateway adapter. */
|
|
12
|
+
export class PerChatOutboundLane {
|
|
13
|
+
scope;
|
|
14
|
+
constructor(platform = "gateway", connectionIdentity = "default") {
|
|
15
|
+
// `gatewayRuntimeScope` hashes the credential identity. Raw tokens/app ids never enter map keys, logs, or
|
|
16
|
+
// persisted state, while two adapters for the same bot still share one lane.
|
|
17
|
+
this.scope = gatewayRuntimeScope(platform, connectionIdentity);
|
|
18
|
+
}
|
|
19
|
+
run(chatId, task) {
|
|
20
|
+
const key = `${this.scope}\0${String(chatId)}`;
|
|
21
|
+
const previous = processOutboundTails.get(key) ?? Promise.resolve();
|
|
22
|
+
const operation = previous.then(task, task);
|
|
23
|
+
// A failure is returned to its caller but cannot poison later sends in the same chat.
|
|
24
|
+
const tail = operation.then(() => undefined, () => undefined);
|
|
25
|
+
processOutboundTails.set(key, tail);
|
|
26
|
+
void tail.then(() => {
|
|
27
|
+
if (processOutboundTails.get(key) === tail)
|
|
28
|
+
processOutboundTails.delete(key);
|
|
29
|
+
});
|
|
30
|
+
return operation;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const DEFAULT_TEXT_TRANSFER_TIMEOUT_MS = 30_000;
|
|
34
|
+
const DEFAULT_FILE_TRANSFER_TIMEOUT_MS = 120_000;
|
|
35
|
+
const MAX_OUTBOUND_TRANSFER_TIMEOUT_MS = 120_000;
|
|
36
|
+
const MIN_OUTBOUND_TRANSFER_TIMEOUT_MS = 50;
|
|
37
|
+
/** Operator/test override remains bounded: outbound transport can never disable or exceed the hard ceiling. */
|
|
38
|
+
export function outboundTransferTimeoutMs(kind, value = process.env.HARA_GATEWAY_OUTBOUND_TIMEOUT_MS) {
|
|
39
|
+
const fallback = kind === "file" ? DEFAULT_FILE_TRANSFER_TIMEOUT_MS : DEFAULT_TEXT_TRANSFER_TIMEOUT_MS;
|
|
40
|
+
if (value === undefined || value === "")
|
|
41
|
+
return fallback;
|
|
42
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
43
|
+
return Number.isFinite(parsed)
|
|
44
|
+
? Math.max(MIN_OUTBOUND_TRANSFER_TIMEOUT_MS, Math.min(MAX_OUTBOUND_TRANSFER_TIMEOUT_MS, Math.trunc(parsed)))
|
|
45
|
+
: fallback;
|
|
46
|
+
}
|
|
47
|
+
/** Cooperative cancellation plus a hard caller deadline. The hard race is intentional: some transports
|
|
48
|
+
* ignore AbortSignal, but shutdown must still settle promptly. Adapters place this deadline outside their
|
|
49
|
+
* process-global lane so an ambiguous late transfer remains quarantined until it really settles. */
|
|
50
|
+
export async function withOutboundDeadline(label, parentSignal, timeoutMs, transfer) {
|
|
51
|
+
if (parentSignal?.aborted)
|
|
52
|
+
throw new Error(`${label} cancelled`);
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
let rejectStopped;
|
|
55
|
+
const stopped = new Promise((_resolve, reject) => { rejectStopped = reject; });
|
|
56
|
+
let stoppedOnce = false;
|
|
57
|
+
const stop = (reason) => {
|
|
58
|
+
if (stoppedOnce)
|
|
59
|
+
return;
|
|
60
|
+
stoppedOnce = true;
|
|
61
|
+
controller.abort(reason);
|
|
62
|
+
rejectStopped(reason);
|
|
63
|
+
};
|
|
64
|
+
const onParentAbort = () => stop(new Error(`${label} cancelled`));
|
|
65
|
+
parentSignal?.addEventListener("abort", onParentAbort, { once: true });
|
|
66
|
+
const timer = setTimeout(() => stop(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
67
|
+
try {
|
|
68
|
+
// Starting through a microtask turns synchronous SDK throws into the same bounded promise path.
|
|
69
|
+
const running = Promise.resolve().then(() => transfer(controller.signal));
|
|
70
|
+
return await Promise.race([running, stopped]);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
7
77
|
/** Extract an InboundMsg from a Telegram getUpdates result item (pure). Accepts text and photo messages
|
|
8
78
|
* (photo → caption or a "[图片]" marker; the image itself is downloaded in start()). null otherwise. */
|
|
9
79
|
export function parseTelegramUpdate(u) {
|
|
@@ -19,6 +89,8 @@ export function parseTelegramUpdate(u) {
|
|
|
19
89
|
userId: m.from?.id ?? 0,
|
|
20
90
|
userName: m.from?.username || m.from?.first_name || String(m.from?.id ?? ""),
|
|
21
91
|
text: text || "[图片]",
|
|
92
|
+
...(Number.isSafeInteger(u?.update_id) ? { messageId: String(u.update_id) } : {}),
|
|
93
|
+
...(Number.isFinite(Number(m.date)) && Number(m.date) > 0 ? { createdAtMs: Math.trunc(Number(m.date) * 1_000) } : {}),
|
|
22
94
|
...(m.chat?.type === "private" ? { chatType: "p2p" } : ["group", "supergroup"].includes(m.chat?.type) ? { chatType: "group" } : {}),
|
|
23
95
|
};
|
|
24
96
|
}
|
|
@@ -44,19 +116,114 @@ async function downloadTelegramFile(base, token, fileId, options) {
|
|
|
44
116
|
return null;
|
|
45
117
|
}
|
|
46
118
|
}
|
|
47
|
-
|
|
119
|
+
let messageSegmenter;
|
|
120
|
+
function messageGraphemes(text) {
|
|
121
|
+
// Keep user-perceived characters (ZWJ emoji, flags, combining marks, CRLF) together. The fallback still
|
|
122
|
+
// protects surrogate pairs on runtimes without full ICU, although supported Hara runtimes provide it.
|
|
123
|
+
if (messageSegmenter === undefined) {
|
|
124
|
+
messageSegmenter = typeof Intl.Segmenter === "function"
|
|
125
|
+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
126
|
+
: null;
|
|
127
|
+
}
|
|
128
|
+
return messageSegmenter
|
|
129
|
+
? Array.from(messageSegmenter.segment(text), (entry) => entry.segment)
|
|
130
|
+
: Array.from(text);
|
|
131
|
+
}
|
|
132
|
+
function splitOversizedGrapheme(grapheme, max) {
|
|
133
|
+
const pieces = [];
|
|
134
|
+
let piece = "";
|
|
135
|
+
for (const point of grapheme) {
|
|
136
|
+
if (point.length > max) {
|
|
137
|
+
if (piece) {
|
|
138
|
+
pieces.push(piece);
|
|
139
|
+
piece = "";
|
|
140
|
+
}
|
|
141
|
+
// Only possible when max=1 and the code point is an astral character. The platform's UTF-16 bound is
|
|
142
|
+
// non-negotiable, so this is the sole case where even a code point must be split into code units.
|
|
143
|
+
for (let at = 0; at < point.length; at += max)
|
|
144
|
+
pieces.push(point.slice(at, at + max));
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (piece.length + point.length > max) {
|
|
148
|
+
pieces.push(piece);
|
|
149
|
+
piece = point;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
piece += point;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (piece)
|
|
156
|
+
pieces.push(piece);
|
|
157
|
+
return pieces;
|
|
158
|
+
}
|
|
159
|
+
/** Split text into chunks whose JavaScript UTF-16 length is ≤ max (pure). */
|
|
48
160
|
export function chunkText(text, max = 4000) {
|
|
161
|
+
if (!Number.isSafeInteger(max) || max < 1)
|
|
162
|
+
throw new RangeError("chunk size must be a positive integer");
|
|
49
163
|
if (text.length <= max)
|
|
50
164
|
return [text];
|
|
165
|
+
const units = messageGraphemes(text).flatMap((grapheme) => {
|
|
166
|
+
// A maliciously huge combining sequence cannot bypass the service limit. Splitting such a single cluster
|
|
167
|
+
// is the only case where both the hard platform bound and grapheme integrity cannot be satisfied.
|
|
168
|
+
return grapheme.length <= max ? [grapheme] : splitOversizedGrapheme(grapheme, max);
|
|
169
|
+
});
|
|
170
|
+
const widths = units.map((unit) => unit.length);
|
|
51
171
|
const out = [];
|
|
52
|
-
|
|
53
|
-
|
|
172
|
+
let start = 0;
|
|
173
|
+
while (start < units.length) {
|
|
174
|
+
let end = start;
|
|
175
|
+
let width = 0;
|
|
176
|
+
while (end < units.length && width + widths[end] <= max)
|
|
177
|
+
width += widths[end++];
|
|
178
|
+
if (end < units.length) {
|
|
179
|
+
const softFloor = Math.max(1, Math.floor(max / 2));
|
|
180
|
+
let breakAt = -1;
|
|
181
|
+
// Paragraph/line boundaries preserve the shape of long agent replies. Whitespace is the fallback;
|
|
182
|
+
// CJK prose without either still gets a safe hard grapheme boundary.
|
|
183
|
+
let candidateWidth = width;
|
|
184
|
+
for (let i = end - 1; i >= start; i--) {
|
|
185
|
+
if (candidateWidth < softFloor)
|
|
186
|
+
break;
|
|
187
|
+
if (units[i].includes("\n")) {
|
|
188
|
+
breakAt = i + 1;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
candidateWidth -= widths[i];
|
|
192
|
+
}
|
|
193
|
+
if (breakAt < 0) {
|
|
194
|
+
candidateWidth = width;
|
|
195
|
+
for (let i = end - 1; i >= start; i--) {
|
|
196
|
+
if (candidateWidth < softFloor)
|
|
197
|
+
break;
|
|
198
|
+
if (/^\s+$/u.test(units[i])) {
|
|
199
|
+
breakAt = i + 1;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
candidateWidth -= widths[i];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (breakAt > start)
|
|
206
|
+
end = breakAt;
|
|
207
|
+
}
|
|
208
|
+
out.push(units.slice(start, end).join(""));
|
|
209
|
+
start = end;
|
|
210
|
+
}
|
|
54
211
|
return out;
|
|
55
212
|
}
|
|
56
213
|
export function telegramAdapter(token) {
|
|
57
214
|
const base = `${API}/bot${token}`;
|
|
58
|
-
const
|
|
59
|
-
|
|
215
|
+
const outbound = new PerChatOutboundLane("telegram", token);
|
|
216
|
+
const request = async (method, init, signal) => {
|
|
217
|
+
let response;
|
|
218
|
+
try {
|
|
219
|
+
response = await fetch(`${base}/${method}`, { ...init, signal });
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
if (signal.aborted && signal.reason instanceof Error)
|
|
223
|
+
throw signal.reason;
|
|
224
|
+
// Never let a fetch implementation echo the bot-token URL/body through gateway logs or owner replies.
|
|
225
|
+
throw new Error(`Telegram ${method} transport failed`);
|
|
226
|
+
}
|
|
60
227
|
let body;
|
|
61
228
|
try {
|
|
62
229
|
body = await response.json();
|
|
@@ -71,39 +238,103 @@ export function telegramAdapter(token) {
|
|
|
71
238
|
};
|
|
72
239
|
return {
|
|
73
240
|
name: "telegram",
|
|
74
|
-
async send(chatId, text) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
241
|
+
async send(chatId, text, signal) {
|
|
242
|
+
await withOutboundDeadline("Telegram send", signal, outboundTransferTimeoutMs("text"), async (transferSignal) => {
|
|
243
|
+
return outbound.run(chatId, async () => {
|
|
244
|
+
if (transferSignal.aborted) {
|
|
245
|
+
throw transferSignal.reason instanceof Error ? transferSignal.reason : new Error("Telegram send cancelled");
|
|
246
|
+
}
|
|
247
|
+
for (const part of chunkText(text || "(empty)")) {
|
|
248
|
+
await request("sendMessage", {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: { "content-type": "application/json" },
|
|
251
|
+
body: JSON.stringify({ chat_id: chatId, text: part }),
|
|
252
|
+
}, transferSignal);
|
|
253
|
+
}
|
|
80
254
|
});
|
|
81
|
-
}
|
|
255
|
+
});
|
|
82
256
|
},
|
|
83
|
-
async sendFile(chatId, file) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
257
|
+
async sendFile(chatId, file, signal) {
|
|
258
|
+
await withOutboundDeadline("Telegram upload", signal, outboundTransferTimeoutMs("file"), async (transferSignal) => {
|
|
259
|
+
return outbound.run(chatId, async () => {
|
|
260
|
+
if (transferSignal.aborted) {
|
|
261
|
+
throw transferSignal.reason instanceof Error ? transferSignal.reason : new Error("Telegram upload cancelled");
|
|
262
|
+
}
|
|
263
|
+
// images → sendPhoto (inline preview); everything else → sendDocument (keeps the filename)
|
|
264
|
+
const name = file.safeName;
|
|
265
|
+
const isImg = /\.(png|jpe?g|gif|webp)$/i.test(name);
|
|
266
|
+
const form = new FormData();
|
|
267
|
+
form.append("chat_id", String(chatId));
|
|
268
|
+
form.append(isImg ? "photo" : "document", new Blob([new Uint8Array(file.bytes)]), name);
|
|
269
|
+
await request(isImg ? "sendPhoto" : "sendDocument", { method: "POST", body: form }, transferSignal);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
91
272
|
},
|
|
92
273
|
async start(onMessage, signal, shouldDownload) {
|
|
93
274
|
let offset = 0;
|
|
275
|
+
let consecutiveFailures = 0;
|
|
276
|
+
let outageAlerted = false;
|
|
277
|
+
const postAck = new Array();
|
|
94
278
|
while (!signal.aborted) {
|
|
95
279
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
280
|
+
// Telegram's `timeout=30` is only a server-side long-poll hint. A half-open proxy/socket can ignore
|
|
281
|
+
// it indefinitely, so the client adds a hard margin and aborts the actual fetch after 40 seconds.
|
|
282
|
+
const acknowledgedCount = postAck.length;
|
|
283
|
+
const j = await withOutboundDeadline("Telegram poll", signal, 40_000, async (pollSignal) => {
|
|
284
|
+
let response;
|
|
285
|
+
try {
|
|
286
|
+
response = await fetch(`${base}/getUpdates?timeout=30&offset=${offset}`, { signal: pollSignal });
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
if (pollSignal.aborted && pollSignal.reason instanceof Error)
|
|
290
|
+
throw pollSignal.reason;
|
|
291
|
+
// Fetch/proxy errors can include the complete request URL; never let the bot token reach logs.
|
|
292
|
+
throw new Error("Telegram getUpdates transport failed");
|
|
293
|
+
}
|
|
294
|
+
if (!response.ok)
|
|
295
|
+
throw new Error(`Telegram getUpdates failed: HTTP ${response.status}`);
|
|
296
|
+
try {
|
|
297
|
+
// Keep body consumption inside the hard deadline too. Receiving headers is not completion: a
|
|
298
|
+
// half-open peer can otherwise leave response.json() pending forever after the timer is cleared.
|
|
299
|
+
return await response.json();
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
if (pollSignal.aborted && pollSignal.reason instanceof Error)
|
|
303
|
+
throw pollSignal.reason;
|
|
304
|
+
throw new Error("Telegram getUpdates returned an invalid response");
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
// This successful request carried the offset advanced by the previous iteration, so Telegram has
|
|
308
|
+
// now durably forgotten those updates. Only then may their private execution markers be removed.
|
|
309
|
+
const acknowledged = postAck.splice(0, acknowledgedCount);
|
|
310
|
+
for (const entry of acknowledged) {
|
|
311
|
+
try {
|
|
312
|
+
await entry.cleanup();
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
entry.failures++;
|
|
316
|
+
if (entry.failures === 1) {
|
|
317
|
+
console.error("hara telegram: acknowledged-event cleanup failed; private marker will retry");
|
|
318
|
+
}
|
|
319
|
+
if (entry.failures >= 5) {
|
|
320
|
+
console.error("hara telegram: ALERT acknowledged-event cleanup suspended after 5 failures; private marker retained for manual recovery");
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
postAck.push(entry);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
100
326
|
}
|
|
101
|
-
const j = (await res.json());
|
|
102
327
|
for (const u of j.result ?? []) {
|
|
103
|
-
|
|
328
|
+
const updateId = Number(u?.update_id);
|
|
329
|
+
if (!Number.isSafeInteger(updateId) || updateId < 0 || !Number.isSafeInteger(updateId + 1)) {
|
|
330
|
+
throw new Error("Telegram getUpdates returned an invalid update_id");
|
|
331
|
+
}
|
|
332
|
+
const nextOffset = updateId + 1;
|
|
104
333
|
const msg = parseTelegramUpdate(u);
|
|
105
|
-
if (!msg)
|
|
334
|
+
if (!msg) {
|
|
335
|
+
offset = Math.max(offset, nextOffset);
|
|
106
336
|
continue;
|
|
337
|
+
}
|
|
107
338
|
const fid = photoFileId(u); // a photo message → download it so the agent can SEE it
|
|
108
339
|
if (fid && shouldDownload?.(msg) === true) {
|
|
109
340
|
const budget = new InboundMediaBudget("telegram", signal);
|
|
@@ -113,12 +344,31 @@ export function telegramAdapter(token) {
|
|
|
113
344
|
msg.transientFiles = [path];
|
|
114
345
|
}
|
|
115
346
|
}
|
|
116
|
-
|
|
347
|
+
// Acknowledge only after the whole gateway callback succeeds. On failure the offset stays put,
|
|
348
|
+
// so Telegram redelivers this update and later batch entries cannot overtake it.
|
|
349
|
+
const cleanup = await onMessage(msg);
|
|
350
|
+
offset = Math.max(offset, nextOffset);
|
|
351
|
+
if (cleanup)
|
|
352
|
+
postAck.push({ cleanup, failures: 0 });
|
|
353
|
+
if (signal.aborted)
|
|
354
|
+
break;
|
|
117
355
|
}
|
|
356
|
+
if (outageAlerted)
|
|
357
|
+
console.error("hara telegram: ✓ polling recovered");
|
|
358
|
+
consecutiveFailures = 0;
|
|
359
|
+
outageAlerted = false;
|
|
118
360
|
}
|
|
119
|
-
catch {
|
|
361
|
+
catch (error) {
|
|
120
362
|
if (signal.aborted)
|
|
121
363
|
break;
|
|
364
|
+
consecutiveFailures++;
|
|
365
|
+
if (consecutiveFailures === 1) {
|
|
366
|
+
console.error(`hara telegram: update handling failed — ${error instanceof Error ? error.message : String(error)}`);
|
|
367
|
+
}
|
|
368
|
+
else if (consecutiveFailures === 3 || consecutiveFailures % 10 === 0) {
|
|
369
|
+
outageAlerted = true;
|
|
370
|
+
console.error(`hara telegram: ALERT polling/handling has failed ${consecutiveFailures} consecutive times`);
|
|
371
|
+
}
|
|
122
372
|
await sleep(2000); // network blip → back off + retry
|
|
123
373
|
}
|
|
124
374
|
}
|