@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.
@@ -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: AbortSignal.timeout(timeoutMs),
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: false },
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 (!canStream || typingFired || typeof channel.typing !== "function")
674
+ if (!canType || typingFired)
674
675
  return;
675
676
  typingFired = true;
676
677
  const key = `${msg.accountId}:${msg.conversation.id}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.40",
3
+ "version": "0.2.42",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 canStream", async () => {
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: AbortSignal.timeout(timeoutMs),
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: false },
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 (!canStream || typingFired || typeof channel.typing !== "function") return;
891
+ if (!canType || typingFired) return;
890
892
  typingFired = true;
891
893
  const key = `${msg.accountId}:${msg.conversation.id}`;
892
894
  const now = Date.now();