@nanhara/hara 0.122.3 → 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 +98 -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 +28 -14
- 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/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/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/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/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/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
|
}
|
package/dist/gateway/tts.js
CHANGED
|
@@ -11,8 +11,18 @@
|
|
|
11
11
|
import { spawn } from "node:child_process";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
-
import { writeFileSync } from "node:fs";
|
|
14
|
+
import { chmodSync, lstatSync, rmSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { terminateSubprocessTree } from "../security/subprocess-env.js";
|
|
17
|
+
const DEFAULT_TTS_TIMEOUT_MS = 60_000;
|
|
18
|
+
const MAX_TTS_TIMEOUT_MS = 120_000;
|
|
19
|
+
/** TTS cannot opt out of a deadline. Small values remain available for deterministic health checks/tests. */
|
|
20
|
+
export function ttsTimeoutMs(value = process.env.HARA_TTS_TIMEOUT_MS) {
|
|
21
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
22
|
+
return Number.isFinite(parsed) && parsed > 0
|
|
23
|
+
? Math.max(50, Math.min(Math.floor(parsed), MAX_TTS_TIMEOUT_MS))
|
|
24
|
+
: DEFAULT_TTS_TIMEOUT_MS;
|
|
25
|
+
}
|
|
16
26
|
/** Read TTS settings from the environment (fully configurable — nothing hardcoded). */
|
|
17
27
|
export function ttsConfigFromEnv(env = process.env) {
|
|
18
28
|
return {
|
|
@@ -22,6 +32,8 @@ export function ttsConfigFromEnv(env = process.env) {
|
|
|
22
32
|
baseURL: (env.HARA_TTS_BASE_URL || "").trim(),
|
|
23
33
|
apiKey: (env.HARA_TTS_API_KEY || "").trim(),
|
|
24
34
|
cmd: (env.HARA_TTS_CMD || "").trim(),
|
|
35
|
+
// Passing an explicit fixture env must not accidentally fall back to the live process environment.
|
|
36
|
+
timeoutMs: ttsTimeoutMs(env.HARA_TTS_TIMEOUT_MS ?? Number.NaN),
|
|
25
37
|
};
|
|
26
38
|
}
|
|
27
39
|
/** Normalize a reply for speech: collapse whitespace, drop code fences, cap length (long audio is unwanted). */
|
|
@@ -32,38 +44,113 @@ export function ttsCleanText(text, max = 1200) {
|
|
|
32
44
|
.trim()
|
|
33
45
|
.slice(0, max);
|
|
34
46
|
}
|
|
35
|
-
function
|
|
36
|
-
return new
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
function abortReason(signal) {
|
|
48
|
+
return signal.reason instanceof Error ? signal.reason : new Error("TTS cancelled");
|
|
49
|
+
}
|
|
50
|
+
function throwIfAborted(signal) {
|
|
51
|
+
if (signal.aborted)
|
|
52
|
+
throw abortReason(signal);
|
|
53
|
+
}
|
|
54
|
+
/** Race even an API implementation that ignores AbortSignal, while still passing the signal to its fetch. */
|
|
55
|
+
function withAbort(task, signal) {
|
|
56
|
+
if (signal.aborted)
|
|
57
|
+
return Promise.reject(abortReason(signal));
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let settled = false;
|
|
60
|
+
const finish = (fn) => {
|
|
61
|
+
if (settled)
|
|
62
|
+
return;
|
|
63
|
+
settled = true;
|
|
64
|
+
signal.removeEventListener("abort", onAbort);
|
|
65
|
+
fn();
|
|
66
|
+
};
|
|
67
|
+
const onAbort = () => finish(() => reject(abortReason(signal)));
|
|
68
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
69
|
+
task.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Run local TTS in its own process group. Cancellation escalates TERM→KILL for the whole descendant tree. */
|
|
73
|
+
function run(cmd, args, signal, stdin) {
|
|
74
|
+
throwIfAborted(signal);
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const processGroup = process.platform !== "win32";
|
|
77
|
+
let child;
|
|
78
|
+
try {
|
|
79
|
+
child = spawn(cmd, args, {
|
|
80
|
+
stdio: [stdin != null ? "pipe" : "ignore", "ignore", "ignore"],
|
|
81
|
+
detached: processGroup,
|
|
82
|
+
windowsHide: true,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
resolve(false);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
let settled = false;
|
|
90
|
+
let stopping = false;
|
|
91
|
+
let cancelTermination;
|
|
92
|
+
const settle = (ok, error) => {
|
|
93
|
+
if (settled)
|
|
94
|
+
return;
|
|
95
|
+
settled = true;
|
|
96
|
+
signal.removeEventListener("abort", stop);
|
|
97
|
+
// Cancel only the API fallback. If TERM closed the direct child while a descendant survived, the helper's
|
|
98
|
+
// referenced force timer still kills the owned process group after the grace period.
|
|
99
|
+
cancelTermination?.();
|
|
100
|
+
child.stdin?.destroy();
|
|
101
|
+
if (error)
|
|
102
|
+
reject(error);
|
|
103
|
+
else
|
|
104
|
+
resolve(ok);
|
|
105
|
+
};
|
|
106
|
+
const stop = () => {
|
|
107
|
+
if (settled || stopping)
|
|
108
|
+
return;
|
|
109
|
+
stopping = true;
|
|
110
|
+
cancelTermination = terminateSubprocessTree(child, {
|
|
111
|
+
processGroup,
|
|
112
|
+
graceMs: 250,
|
|
113
|
+
fallbackMs: 250,
|
|
114
|
+
onFallback: () => settle(false, abortReason(signal)),
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
child.once("error", () => settle(false, stopping ? abortReason(signal) : undefined));
|
|
118
|
+
child.once("close", (code) => settle(code === 0, stopping ? abortReason(signal) : undefined));
|
|
119
|
+
child.stdin?.on("error", () => { });
|
|
120
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
121
|
+
if (signal.aborted)
|
|
122
|
+
stop();
|
|
123
|
+
if (stdin != null && !stopping)
|
|
124
|
+
child.stdin?.end(stdin);
|
|
42
125
|
});
|
|
43
126
|
}
|
|
44
127
|
// local: macOS `say` → m4a (AAC). Zero config; HARA_TTS_VOICE picks the voice (default Tingting / zh).
|
|
45
|
-
async function sayTts(text, out, cfg) {
|
|
46
|
-
return run("say", ["-v", cfg.voice || "Tingting", "-o", out, "--file-format", "m4af", "--data-format", "aac", text]);
|
|
128
|
+
async function sayTts(text, out, cfg, signal) {
|
|
129
|
+
return run("say", ["-v", cfg.voice || "Tingting", "-o", out, "--file-format", "m4af", "--data-format", "aac", text], signal);
|
|
47
130
|
}
|
|
48
131
|
// API: OpenAI-compatible /audio/speech. Works against OpenAI, Aliyun DashScope (compatible-mode), or a local
|
|
49
132
|
// server exposing the same shape — set HARA_TTS_BASE_URL + HARA_TTS_API_KEY + HARA_TTS_MODEL + HARA_TTS_VOICE.
|
|
50
|
-
async function openaiTts(text, out, cfg) {
|
|
133
|
+
async function openaiTts(text, out, cfg, signal) {
|
|
51
134
|
if (!cfg.apiKey && !cfg.baseURL)
|
|
52
135
|
return false; // not configured → unavailable
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
136
|
+
return withAbort((async () => {
|
|
137
|
+
const { default: OpenAI } = (await import("openai"));
|
|
138
|
+
throwIfAborted(signal);
|
|
139
|
+
const client = new OpenAI({ baseURL: cfg.baseURL || undefined, apiKey: cfg.apiKey || "x" });
|
|
140
|
+
const res = await client.audio.speech.create({ model: cfg.model || "tts-1", voice: cfg.voice || "alloy", input: text }, { signal });
|
|
141
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
142
|
+
throwIfAborted(signal);
|
|
143
|
+
if (!buf.length)
|
|
144
|
+
return false;
|
|
145
|
+
writeFileSync(out, buf, { flag: "wx", mode: 0o600 });
|
|
146
|
+
return true;
|
|
147
|
+
})(), signal);
|
|
61
148
|
}
|
|
62
149
|
// local: a configurable command (e.g. VoxCPM). `{out}` → output path; the text is piped on stdin.
|
|
63
|
-
async function cmdTts(text, out, cfg) {
|
|
150
|
+
async function cmdTts(text, out, cfg, signal) {
|
|
64
151
|
if (!cfg.cmd)
|
|
65
152
|
return false;
|
|
66
|
-
return run("sh", ["-c", cfg.cmd.replace(/\{out\}/g, out)], text);
|
|
153
|
+
return run("sh", ["-c", cfg.cmd.replace(/\{out\}/g, out)], signal, text);
|
|
67
154
|
}
|
|
68
155
|
const PROVIDERS = {
|
|
69
156
|
say: sayTts,
|
|
@@ -71,30 +158,87 @@ const PROVIDERS = {
|
|
|
71
158
|
cmd: cmdTts,
|
|
72
159
|
};
|
|
73
160
|
const EXT = { say: "m4a", openai: "mp3", cmd: "wav" };
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
161
|
+
function isAbortSignal(value) {
|
|
162
|
+
return Boolean(value && typeof value.addEventListener === "function" && typeof value.aborted === "boolean");
|
|
163
|
+
}
|
|
164
|
+
function safeProviderError(error, cfg) {
|
|
165
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
166
|
+
for (const [secret, replacement] of [[cfg.apiKey, "[redacted]"], [cfg.baseURL, "[redacted-url]"]]) {
|
|
167
|
+
if (secret)
|
|
168
|
+
message = message.split(secret).join(replacement);
|
|
169
|
+
}
|
|
170
|
+
return message;
|
|
171
|
+
}
|
|
172
|
+
function secureCompletedAudio(path) {
|
|
173
|
+
try {
|
|
174
|
+
const stat = lstatSync(path);
|
|
175
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size <= 0)
|
|
176
|
+
return false;
|
|
177
|
+
chmodSync(path, 0o600);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/** Synthesize `text` to a temp audio file; returns its path, or null on provider/deadline failure. The legacy
|
|
185
|
+
* `synthesize(text, config)` form remains valid; gateway callers use `synthesize(text, signal, config?)`. */
|
|
186
|
+
export async function synthesize(text, signalOrConfig, explicitConfig) {
|
|
187
|
+
const parentSignal = isAbortSignal(signalOrConfig) ? signalOrConfig : undefined;
|
|
188
|
+
if (parentSignal?.aborted)
|
|
189
|
+
throw abortReason(parentSignal);
|
|
77
190
|
const clean = ttsCleanText(text);
|
|
78
191
|
if (!clean)
|
|
79
192
|
return null;
|
|
193
|
+
const cfg = isAbortSignal(signalOrConfig)
|
|
194
|
+
? (explicitConfig ?? ttsConfigFromEnv())
|
|
195
|
+
: (explicitConfig ?? signalOrConfig ?? ttsConfigFromEnv());
|
|
196
|
+
const deadline = new AbortController();
|
|
197
|
+
const timeoutMs = ttsTimeoutMs(cfg.timeoutMs);
|
|
198
|
+
const timeoutError = new Error(`TTS timed out after ${timeoutMs}ms`);
|
|
199
|
+
const timeout = setTimeout(() => deadline.abort(timeoutError), timeoutMs);
|
|
200
|
+
const cancel = () => deadline.abort(parentSignal?.reason instanceof Error ? parentSignal.reason : new Error("TTS cancelled"));
|
|
201
|
+
parentSignal?.addEventListener("abort", cancel, { once: true });
|
|
80
202
|
const provider = PROVIDERS[cfg.provider] ? cfg.provider : "say";
|
|
81
|
-
const out = join(tmpdir(), `hara-tts-${randomUUID()
|
|
203
|
+
const out = join(tmpdir(), `hara-tts-${randomUUID()}.${EXT[provider] || "wav"}`);
|
|
82
204
|
try {
|
|
83
|
-
if (await PROVIDERS[provider](clean, out, cfg))
|
|
84
|
-
return out;
|
|
85
|
-
}
|
|
86
|
-
catch (e) {
|
|
87
|
-
console.error(`tts(${provider}): ${e?.message ?? e}`);
|
|
88
|
-
}
|
|
89
|
-
if (provider !== "say") {
|
|
90
205
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return o2; // graceful fallback to local
|
|
206
|
+
if (await PROVIDERS[provider](clean, out, cfg, deadline.signal) && secureCompletedAudio(out))
|
|
207
|
+
return out;
|
|
94
208
|
}
|
|
95
|
-
catch {
|
|
96
|
-
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (parentSignal?.aborted)
|
|
211
|
+
throw abortReason(parentSignal);
|
|
212
|
+
if (deadline.signal.aborted) {
|
|
213
|
+
console.error(`tts(${provider}): ${timeoutError.message}`);
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
console.error(`tts(${provider}): ${safeProviderError(error, cfg)}`);
|
|
97
217
|
}
|
|
218
|
+
rmSync(out, { force: true });
|
|
219
|
+
if (provider !== "say" && !deadline.signal.aborted) {
|
|
220
|
+
const fallback = join(tmpdir(), `hara-tts-${randomUUID()}.m4a`);
|
|
221
|
+
try {
|
|
222
|
+
if (await sayTts(clean, fallback, cfg, deadline.signal) && secureCompletedAudio(fallback))
|
|
223
|
+
return fallback; // graceful local fallback
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (parentSignal?.aborted)
|
|
227
|
+
throw abortReason(parentSignal);
|
|
228
|
+
if (deadline.signal.aborted)
|
|
229
|
+
console.error(`tts(say): ${timeoutError.message}`);
|
|
230
|
+
else
|
|
231
|
+
console.error(`tts(say): ${safeProviderError(error, cfg)}`);
|
|
232
|
+
}
|
|
233
|
+
rmSync(fallback, { force: true });
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
clearTimeout(timeout);
|
|
239
|
+
parentSignal?.removeEventListener("abort", cancel);
|
|
240
|
+
// Returned paths are owned by the caller. Every other partial/late provider output is removed here.
|
|
241
|
+
if (deadline.signal.aborted)
|
|
242
|
+
rmSync(out, { force: true });
|
|
98
243
|
}
|
|
99
|
-
return null;
|
|
100
244
|
}
|