@botcord/daemon 0.2.40 → 0.2.42
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/dist/gateway/channels/telegram.js +46 -7
- package/dist/gateway/dispatcher.js +2 -1
- package/package.json +1 -1
- package/src/gateway/__tests__/dispatcher.test.ts +16 -1
- package/src/gateway/__tests__/telegram-channel.test.ts +50 -0
- package/src/gateway/channels/telegram.ts +48 -5
- package/src/gateway/dispatcher.ts +3 -1
|
@@ -72,17 +72,18 @@ export function createTelegramChannel(opts) {
|
|
|
72
72
|
}
|
|
73
73
|
return botToken;
|
|
74
74
|
}
|
|
75
|
-
async function callApi(method, params, timeoutMs) {
|
|
75
|
+
async function callApi(method, params, timeoutMs, abortSignal) {
|
|
76
76
|
if (!botToken)
|
|
77
77
|
throw new Error("telegram bot token not loaded");
|
|
78
78
|
const url = `${baseUrl}/bot${botToken}/${method}`;
|
|
79
|
+
const signalLease = createTimeoutSignal(timeoutMs, abortSignal);
|
|
79
80
|
let resp;
|
|
80
81
|
try {
|
|
81
82
|
resp = await fetchImpl(url, {
|
|
82
83
|
method: "POST",
|
|
83
84
|
headers: { "Content-Type": "application/json" },
|
|
84
85
|
body: JSON.stringify(params),
|
|
85
|
-
signal:
|
|
86
|
+
signal: signalLease.signal,
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
89
|
catch (err) {
|
|
@@ -94,6 +95,9 @@ export function createTelegramChannel(opts) {
|
|
|
94
95
|
next.name = e.name ?? "Error";
|
|
95
96
|
throw next;
|
|
96
97
|
}
|
|
98
|
+
finally {
|
|
99
|
+
signalLease.cleanup();
|
|
100
|
+
}
|
|
97
101
|
const json = (await resp.json());
|
|
98
102
|
return json;
|
|
99
103
|
}
|
|
@@ -156,7 +160,7 @@ export function createTelegramChannel(opts) {
|
|
|
156
160
|
replyTo: null,
|
|
157
161
|
mentioned: false,
|
|
158
162
|
receivedAt: Date.now(),
|
|
159
|
-
trace: { id: messageId, streamable:
|
|
163
|
+
trace: { id: messageId, streamable: true },
|
|
160
164
|
};
|
|
161
165
|
}
|
|
162
166
|
async function pollLoop(ctx) {
|
|
@@ -194,20 +198,20 @@ export function createTelegramChannel(opts) {
|
|
|
194
198
|
});
|
|
195
199
|
log.info("telegram poll loop starting", { gatewayId: opts.id, offset });
|
|
196
200
|
let stopped = false;
|
|
201
|
+
const stopController = new AbortController();
|
|
197
202
|
const onAbort = () => {
|
|
198
203
|
stopped = true;
|
|
204
|
+
stopController.abort();
|
|
199
205
|
};
|
|
200
206
|
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
201
|
-
stopCallback =
|
|
202
|
-
stopped = true;
|
|
203
|
-
};
|
|
207
|
+
stopCallback = onAbort;
|
|
204
208
|
while (!stopped && !abortSignal.aborted) {
|
|
205
209
|
try {
|
|
206
210
|
const resp = await callApi("getUpdates", {
|
|
207
211
|
offset,
|
|
208
212
|
timeout: POLL_TIMEOUT_S,
|
|
209
213
|
allowed_updates: ["message"],
|
|
210
|
-
}, (POLL_TIMEOUT_S + 15) * 1000);
|
|
214
|
+
}, (POLL_TIMEOUT_S + 15) * 1000, stopController.signal);
|
|
211
215
|
markStatus({ lastPollAt: Date.now() });
|
|
212
216
|
if (!resp.ok) {
|
|
213
217
|
log.warn("telegram getUpdates non-ok", {
|
|
@@ -369,3 +373,38 @@ function sleep(ms, signal) {
|
|
|
369
373
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
370
374
|
});
|
|
371
375
|
}
|
|
376
|
+
function createTimeoutSignal(timeoutMs, parent) {
|
|
377
|
+
const controller = new AbortController();
|
|
378
|
+
let settled = false;
|
|
379
|
+
const abort = (reason) => {
|
|
380
|
+
if (settled)
|
|
381
|
+
return;
|
|
382
|
+
settled = true;
|
|
383
|
+
try {
|
|
384
|
+
controller.abort(reason);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
controller.abort();
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const timer = setTimeout(() => {
|
|
391
|
+
abort(new DOMException("Timeout", "TimeoutError"));
|
|
392
|
+
}, timeoutMs);
|
|
393
|
+
const onParentAbort = () => abort(parent?.reason);
|
|
394
|
+
if (parent) {
|
|
395
|
+
if (parent.aborted) {
|
|
396
|
+
onParentAbort();
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
parent.addEventListener("abort", onParentAbort, { once: true });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
signal: controller.signal,
|
|
404
|
+
cleanup: () => {
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
if (parent)
|
|
407
|
+
parent.removeEventListener("abort", onParentAbort);
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
@@ -591,6 +591,7 @@ export class Dispatcher {
|
|
|
591
591
|
const trustLevel = route.trustLevel ?? "trusted";
|
|
592
592
|
const streamable = msg.trace?.streamable === true;
|
|
593
593
|
const traceId = msg.trace?.id;
|
|
594
|
+
const canType = streamable && typeof traceId === "string" && typeof channel.typing === "function";
|
|
594
595
|
const canStream = streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
|
|
595
596
|
const recordBlock = (block) => {
|
|
596
597
|
const summary = { type: block.kind };
|
|
@@ -670,7 +671,7 @@ export class Dispatcher {
|
|
|
670
671
|
forwardBlockToChannel(synth);
|
|
671
672
|
};
|
|
672
673
|
const fireTypingIfNeeded = () => {
|
|
673
|
-
if (!
|
|
674
|
+
if (!canType || typingFired)
|
|
674
675
|
return;
|
|
675
676
|
typingFired = true;
|
|
676
677
|
const key = `${msg.accountId}:${msg.conversation.id}`;
|
package/package.json
CHANGED
|
@@ -619,7 +619,7 @@ describe("Dispatcher", () => {
|
|
|
619
619
|
// typing / thinking lifecycle (design: runtime-typing-thinking-status-design.md)
|
|
620
620
|
// ---------------------------------------------------------------------------
|
|
621
621
|
|
|
622
|
-
it("typing: fires channel.typing once before runtime.run when
|
|
622
|
+
it("typing: fires channel.typing once before runtime.run when trace is streamable", async () => {
|
|
623
623
|
const observed: Array<{ typings: number; calls: number }> = [];
|
|
624
624
|
const runtime = new FakeRuntime({
|
|
625
625
|
observeRun: () => {
|
|
@@ -644,6 +644,21 @@ describe("Dispatcher", () => {
|
|
|
644
644
|
expect(observed[0]?.typings).toBe(1);
|
|
645
645
|
});
|
|
646
646
|
|
|
647
|
+
it("typing: does not require channel streamBlock support", async () => {
|
|
648
|
+
const runtime = new FakeRuntime({ reply: "ok", newSessionId: "sid" });
|
|
649
|
+
const channel = new FakeChannel({ withStream: false });
|
|
650
|
+
const { dispatcher } = await scaffold({ channel, runtimeFactory: () => runtime });
|
|
651
|
+
|
|
652
|
+
await dispatcher.handle(
|
|
653
|
+
makeEnvelope({ trace: { id: "trace_t", streamable: true } }),
|
|
654
|
+
);
|
|
655
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
656
|
+
|
|
657
|
+
expect(channel.typings.length).toBe(1);
|
|
658
|
+
expect(channel.streams.length).toBe(0);
|
|
659
|
+
expect(channel.sends.length).toBe(1);
|
|
660
|
+
});
|
|
661
|
+
|
|
647
662
|
it("typing: not fired when streamable is false", async () => {
|
|
648
663
|
const channel = new FakeChannel();
|
|
649
664
|
const { dispatcher } = await scaffold({
|
|
@@ -144,6 +144,7 @@ describe("createTelegramChannel — start()", () => {
|
|
|
144
144
|
expect(msg.text).toBe("hello world");
|
|
145
145
|
expect(msg.accountId).toBe("ag_self");
|
|
146
146
|
expect(msg.channel).toBe("gw_tg_a");
|
|
147
|
+
expect(msg.trace).toEqual({ id: "telegram:42:7", streamable: true });
|
|
147
148
|
});
|
|
148
149
|
|
|
149
150
|
it("uses telegram:group:<id> for non-private chats", async () => {
|
|
@@ -319,6 +320,55 @@ describe("createTelegramChannel — start()", () => {
|
|
|
319
320
|
expect(calls).toHaveLength(1);
|
|
320
321
|
expect((calls[0]!.body as Record<string, unknown>).offset).toBe(999);
|
|
321
322
|
});
|
|
323
|
+
|
|
324
|
+
it("aborts an in-flight getUpdates request when stopped", async () => {
|
|
325
|
+
const calls: FetchCall[] = [];
|
|
326
|
+
let requestSignal: AbortSignal | undefined;
|
|
327
|
+
let fetchStarted!: () => void;
|
|
328
|
+
const fetchStartedPromise = new Promise<void>((resolve) => {
|
|
329
|
+
fetchStarted = resolve;
|
|
330
|
+
});
|
|
331
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
332
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
333
|
+
const body = init?.body ? JSON.parse(init.body as string) : undefined;
|
|
334
|
+
calls.push({ url, body });
|
|
335
|
+
requestSignal = init?.signal ?? undefined;
|
|
336
|
+
fetchStarted();
|
|
337
|
+
return await new Promise<Response>((_resolve, reject) => {
|
|
338
|
+
requestSignal?.addEventListener(
|
|
339
|
+
"abort",
|
|
340
|
+
() => {
|
|
341
|
+
const err = new Error("aborted");
|
|
342
|
+
err.name = "AbortError";
|
|
343
|
+
reject(err);
|
|
344
|
+
},
|
|
345
|
+
{ once: true },
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
}) as typeof fetch;
|
|
349
|
+
|
|
350
|
+
const channel = createTelegramChannel({
|
|
351
|
+
id: "gw_tg_stop",
|
|
352
|
+
accountId: "ag_self",
|
|
353
|
+
botToken: "tok",
|
|
354
|
+
allowedChatIds: ["42"],
|
|
355
|
+
allowedSenderIds: ["42"],
|
|
356
|
+
stateFile: path.join(tmp, "state.json"),
|
|
357
|
+
stateDebounceMs: 0,
|
|
358
|
+
fetchImpl,
|
|
359
|
+
});
|
|
360
|
+
const abort = new AbortController();
|
|
361
|
+
const { ctx } = makeStartCtx({ abort });
|
|
362
|
+
const startPromise = channel.start(ctx);
|
|
363
|
+
|
|
364
|
+
await fetchStartedPromise;
|
|
365
|
+
expect(requestSignal?.aborted).toBe(false);
|
|
366
|
+
await channel.stop!({ reason: "remove_gateway" });
|
|
367
|
+
await startPromise;
|
|
368
|
+
|
|
369
|
+
expect(requestSignal?.aborted).toBe(true);
|
|
370
|
+
expect(calls[0]!.url).toContain("/getUpdates");
|
|
371
|
+
});
|
|
322
372
|
});
|
|
323
373
|
|
|
324
374
|
describe("createTelegramChannel — send()", () => {
|
|
@@ -152,16 +152,18 @@ export function createTelegramChannel(opts: TelegramChannelOptions): ChannelAdap
|
|
|
152
152
|
method: string,
|
|
153
153
|
params: Record<string, unknown>,
|
|
154
154
|
timeoutMs: number,
|
|
155
|
+
abortSignal?: AbortSignal,
|
|
155
156
|
): Promise<TelegramApiResult<T>> {
|
|
156
157
|
if (!botToken) throw new Error("telegram bot token not loaded");
|
|
157
158
|
const url = `${baseUrl}/bot${botToken}/${method}`;
|
|
159
|
+
const signalLease = createTimeoutSignal(timeoutMs, abortSignal);
|
|
158
160
|
let resp: Response;
|
|
159
161
|
try {
|
|
160
162
|
resp = await fetchImpl(url, {
|
|
161
163
|
method: "POST",
|
|
162
164
|
headers: { "Content-Type": "application/json" },
|
|
163
165
|
body: JSON.stringify(params),
|
|
164
|
-
signal:
|
|
166
|
+
signal: signalLease.signal,
|
|
165
167
|
});
|
|
166
168
|
} catch (err) {
|
|
167
169
|
// C3: fetch errors often stringify the URL (which embeds the token).
|
|
@@ -171,6 +173,8 @@ export function createTelegramChannel(opts: TelegramChannelOptions): ChannelAdap
|
|
|
171
173
|
const next = new Error(redacted);
|
|
172
174
|
next.name = e.name ?? "Error";
|
|
173
175
|
throw next;
|
|
176
|
+
} finally {
|
|
177
|
+
signalLease.cleanup();
|
|
174
178
|
}
|
|
175
179
|
const json = (await resp.json()) as TelegramApiResult<T>;
|
|
176
180
|
return json;
|
|
@@ -237,7 +241,7 @@ export function createTelegramChannel(opts: TelegramChannelOptions): ChannelAdap
|
|
|
237
241
|
replyTo: null,
|
|
238
242
|
mentioned: false,
|
|
239
243
|
receivedAt: Date.now(),
|
|
240
|
-
trace: { id: messageId, streamable:
|
|
244
|
+
trace: { id: messageId, streamable: true },
|
|
241
245
|
};
|
|
242
246
|
}
|
|
243
247
|
|
|
@@ -280,13 +284,13 @@ export function createTelegramChannel(opts: TelegramChannelOptions): ChannelAdap
|
|
|
280
284
|
log.info("telegram poll loop starting", { gatewayId: opts.id, offset });
|
|
281
285
|
|
|
282
286
|
let stopped = false;
|
|
287
|
+
const stopController = new AbortController();
|
|
283
288
|
const onAbort = () => {
|
|
284
289
|
stopped = true;
|
|
290
|
+
stopController.abort();
|
|
285
291
|
};
|
|
286
292
|
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
287
|
-
stopCallback =
|
|
288
|
-
stopped = true;
|
|
289
|
-
};
|
|
293
|
+
stopCallback = onAbort;
|
|
290
294
|
|
|
291
295
|
while (!stopped && !abortSignal.aborted) {
|
|
292
296
|
try {
|
|
@@ -298,6 +302,7 @@ export function createTelegramChannel(opts: TelegramChannelOptions): ChannelAdap
|
|
|
298
302
|
allowed_updates: ["message"],
|
|
299
303
|
},
|
|
300
304
|
(POLL_TIMEOUT_S + 15) * 1000,
|
|
305
|
+
stopController.signal,
|
|
301
306
|
);
|
|
302
307
|
markStatus({ lastPollAt: Date.now() });
|
|
303
308
|
if (!resp.ok) {
|
|
@@ -467,3 +472,41 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
467
472
|
});
|
|
468
473
|
}
|
|
469
474
|
|
|
475
|
+
function createTimeoutSignal(
|
|
476
|
+
timeoutMs: number,
|
|
477
|
+
parent?: AbortSignal,
|
|
478
|
+
): { signal: AbortSignal; cleanup: () => void } {
|
|
479
|
+
const controller = new AbortController();
|
|
480
|
+
let settled = false;
|
|
481
|
+
|
|
482
|
+
const abort = (reason?: unknown) => {
|
|
483
|
+
if (settled) return;
|
|
484
|
+
settled = true;
|
|
485
|
+
try {
|
|
486
|
+
controller.abort(reason);
|
|
487
|
+
} catch {
|
|
488
|
+
controller.abort();
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const timer = setTimeout(() => {
|
|
493
|
+
abort(new DOMException("Timeout", "TimeoutError"));
|
|
494
|
+
}, timeoutMs);
|
|
495
|
+
|
|
496
|
+
const onParentAbort = () => abort(parent?.reason);
|
|
497
|
+
if (parent) {
|
|
498
|
+
if (parent.aborted) {
|
|
499
|
+
onParentAbort();
|
|
500
|
+
} else {
|
|
501
|
+
parent.addEventListener("abort", onParentAbort, { once: true });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
signal: controller.signal,
|
|
507
|
+
cleanup: () => {
|
|
508
|
+
clearTimeout(timer);
|
|
509
|
+
if (parent) parent.removeEventListener("abort", onParentAbort);
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
@@ -803,6 +803,8 @@ export class Dispatcher {
|
|
|
803
803
|
|
|
804
804
|
const streamable = msg.trace?.streamable === true;
|
|
805
805
|
const traceId = msg.trace?.id;
|
|
806
|
+
const canType =
|
|
807
|
+
streamable && typeof traceId === "string" && typeof channel.typing === "function";
|
|
806
808
|
const canStream =
|
|
807
809
|
streamable && typeof traceId === "string" && typeof channel.streamBlock === "function";
|
|
808
810
|
const recordBlock = (block: StreamBlock): void => {
|
|
@@ -886,7 +888,7 @@ export class Dispatcher {
|
|
|
886
888
|
};
|
|
887
889
|
|
|
888
890
|
const fireTypingIfNeeded = (): void => {
|
|
889
|
-
if (!
|
|
891
|
+
if (!canType || typingFired) return;
|
|
890
892
|
typingFired = true;
|
|
891
893
|
const key = `${msg.accountId}:${msg.conversation.id}`;
|
|
892
894
|
const now = Date.now();
|