@botcord/daemon 0.2.70 → 0.2.71

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.
@@ -8,6 +8,8 @@ import { splitText } from "./text-split.js";
8
8
  const FEISHU_PROVIDER = "feishu";
9
9
  const DEFAULT_SPLIT_AT = 4000;
10
10
  const MAX_SEEN_MESSAGES = 2048;
11
+ const TYPING_EMOJI = "Typing";
12
+ const TYPING_REACTION_TTL_MS = 20_000;
11
13
  function sdkDomain(domain) {
12
14
  const sdk = Lark;
13
15
  return domain === "lark" ? sdk.Domain?.Lark : sdk.Domain?.Feishu;
@@ -63,6 +65,7 @@ export function createFeishuChannel(opts) {
63
65
  let botOpenId;
64
66
  let botName;
65
67
  let liveSetStatus = null;
68
+ const activeTypingReactions = new Map();
66
69
  let statusSnapshot = {
67
70
  channel: opts.id,
68
71
  accountId: opts.accountId,
@@ -292,6 +295,45 @@ export function createFeishuChannel(opts) {
292
295
  return ((typeof res.data?.message_id === "string" ? res.data.message_id : undefined) ??
293
296
  (typeof res.message_id === "string" ? res.message_id : undefined));
294
297
  }
298
+ function resultReactionId(res) {
299
+ return ((typeof res.data?.reaction_id === "string" ? res.data.reaction_id : undefined) ??
300
+ (typeof res.reaction_id === "string" ? res.reaction_id : undefined) ??
301
+ null);
302
+ }
303
+ function messageIdFromTrace(traceId) {
304
+ if (!traceId.startsWith("feishu:"))
305
+ return null;
306
+ const messageId = traceId.slice("feishu:".length).trim();
307
+ return messageId.length > 0 ? messageId : null;
308
+ }
309
+ async function removeTypingReaction(messageId) {
310
+ const state = activeTypingReactions.get(messageId);
311
+ if (!state)
312
+ return;
313
+ activeTypingReactions.delete(messageId);
314
+ if (state.timer)
315
+ clearTimeout(state.timer);
316
+ if (!state.reactionId)
317
+ return;
318
+ try {
319
+ await callFeishu({
320
+ method: "DELETE",
321
+ url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(state.reactionId)}`,
322
+ });
323
+ }
324
+ catch (err) {
325
+ statusSnapshot.lastError = err instanceof Error ? err.message : String(err);
326
+ }
327
+ }
328
+ function scheduleTypingCleanup(messageId, state) {
329
+ if (state.timer)
330
+ clearTimeout(state.timer);
331
+ state.timer = setTimeout(() => {
332
+ void removeTypingReaction(messageId);
333
+ }, TYPING_REACTION_TTL_MS);
334
+ if (typeof state.timer.unref === "function")
335
+ state.timer.unref();
336
+ }
295
337
  function resultResourceKey(res, key) {
296
338
  const direct = res[key];
297
339
  if (typeof direct === "string")
@@ -422,14 +464,61 @@ export function createFeishuChannel(opts) {
422
464
  replyInThread: Boolean(ctx.message.threadId),
423
465
  }) ?? providerMessageId;
424
466
  }
467
+ if (ctx.message.replyTo) {
468
+ void removeTypingReaction(ctx.message.replyTo);
469
+ }
470
+ if (ctx.message.threadId && ctx.message.threadId !== ctx.message.replyTo) {
471
+ void removeTypingReaction(ctx.message.threadId);
472
+ }
425
473
  markStatus({ lastSendAt: Date.now() });
426
474
  return { providerMessageId };
427
475
  }
428
476
  async function typing(ctx) {
429
- ctx.log.debug("feishu typing ignored: no native bot typing API", {
430
- channel: opts.id,
431
- conversationId: ctx.conversationId,
432
- });
477
+ const messageId = messageIdFromTrace(ctx.traceId);
478
+ if (!messageId) {
479
+ ctx.log.debug("feishu typing skipped: trace id has no message id", {
480
+ channel: opts.id,
481
+ conversationId: ctx.conversationId,
482
+ traceId: ctx.traceId,
483
+ });
484
+ return;
485
+ }
486
+ const existing = activeTypingReactions.get(messageId);
487
+ if (existing) {
488
+ scheduleTypingCleanup(messageId, existing);
489
+ return;
490
+ }
491
+ const state = { reactionId: null, timer: null };
492
+ activeTypingReactions.set(messageId, state);
493
+ scheduleTypingCleanup(messageId, state);
494
+ try {
495
+ const res = await callFeishu({
496
+ method: "POST",
497
+ url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions`,
498
+ data: { reaction_type: { emoji_type: TYPING_EMOJI } },
499
+ });
500
+ const reactionId = resultReactionId(res);
501
+ if (activeTypingReactions.get(messageId) !== state) {
502
+ if (reactionId) {
503
+ await callFeishu({
504
+ method: "DELETE",
505
+ url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(reactionId)}`,
506
+ });
507
+ }
508
+ return;
509
+ }
510
+ state.reactionId = reactionId;
511
+ }
512
+ catch (err) {
513
+ activeTypingReactions.delete(messageId);
514
+ if (state.timer)
515
+ clearTimeout(state.timer);
516
+ ctx.log.warn("feishu typing reaction failed", {
517
+ channel: opts.id,
518
+ conversationId: ctx.conversationId,
519
+ err: err instanceof Error ? err.message : String(err),
520
+ });
521
+ }
433
522
  }
434
523
  async function stop(_ctx) {
435
524
  try {
@@ -439,6 +528,7 @@ export function createFeishuChannel(opts) {
439
528
  // best effort
440
529
  }
441
530
  wsClient = null;
531
+ await Promise.allSettled(Array.from(activeTypingReactions.keys()).map(removeTypingReaction));
442
532
  try {
443
533
  stateStore?.close();
444
534
  }
@@ -149,6 +149,7 @@ export declare class Dispatcher {
149
149
  private recomposeUserTurn;
150
150
  private runTurn;
151
151
  private sendReply;
152
+ private providerReplyTo;
152
153
  private emitInbound;
153
154
  private emitOutbound;
154
155
  }
@@ -1098,7 +1098,7 @@ export class Dispatcher {
1098
1098
  threadId: msg.conversation.threadId ?? null,
1099
1099
  type: "error",
1100
1100
  text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
1101
- replyTo: msg.id,
1101
+ replyTo: this.providerReplyTo(msg),
1102
1102
  traceId: msg.trace?.id ?? null,
1103
1103
  }, turnId);
1104
1104
  }
@@ -1144,7 +1144,7 @@ export class Dispatcher {
1144
1144
  threadId: msg.conversation.threadId ?? null,
1145
1145
  type: "error",
1146
1146
  text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
1147
- replyTo: msg.id,
1147
+ replyTo: this.providerReplyTo(msg),
1148
1148
  traceId: msg.trace?.id ?? null,
1149
1149
  }, turnId);
1150
1150
  }
@@ -1252,7 +1252,7 @@ export class Dispatcher {
1252
1252
  threadId: msg.conversation.threadId ?? null,
1253
1253
  type: "error",
1254
1254
  text: `⚠️ Runtime error: ${truncate(result.error, 500)}`,
1255
- replyTo: msg.id,
1255
+ replyTo: this.providerReplyTo(msg),
1256
1256
  traceId: msg.trace?.id ?? null,
1257
1257
  }, turnId);
1258
1258
  this.emitOutbound({
@@ -1323,7 +1323,7 @@ export class Dispatcher {
1323
1323
  conversationId: msg.conversation.id,
1324
1324
  threadId: msg.conversation.threadId ?? null,
1325
1325
  text: replyText,
1326
- replyTo: msg.id,
1326
+ replyTo: this.providerReplyTo(msg),
1327
1327
  traceId: msg.trace?.id ?? null,
1328
1328
  }, turnId);
1329
1329
  this.emitOutbound({
@@ -1388,6 +1388,9 @@ export class Dispatcher {
1388
1388
  }
1389
1389
  return { ok: true };
1390
1390
  }
1391
+ providerReplyTo(msg) {
1392
+ return msg.replyTo ?? msg.id;
1393
+ }
1391
1394
  emitInbound(turnId, msg) {
1392
1395
  if (!this.transcript.enabled)
1393
1396
  return;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.70",
3
+ "version": "0.2.71",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
7
- "botcord-daemon": "./dist/index.js"
7
+ "botcord-daemon": "dist/index.js"
8
8
  },
9
9
  "main": "./dist/index.js",
10
10
  "types": "./dist/index.d.ts",
@@ -293,6 +293,30 @@ describe("Dispatcher", () => {
293
293
  expect(store.all()[0].threadId).toBe("t_1");
294
294
  });
295
295
 
296
+ it("sends replies to the provider reply id when it differs from the internal message id", async () => {
297
+ const runtime = new FakeRuntime({ reply: "ok" });
298
+ const feishuChannel = new FakeChannel({ id: "gw_feishu_1", type: "feishu" });
299
+ const { dispatcher, channel } = await scaffold({
300
+ runtimeFactory: () => runtime,
301
+ channel: feishuChannel,
302
+ config: baseConfig({
303
+ channels: [{ id: "gw_feishu_1", type: "feishu", accountId: "ag_me" }],
304
+ }),
305
+ });
306
+
307
+ await dispatcher.handle(
308
+ makeEnvelope({
309
+ id: "feishu:om_internal_wrapped",
310
+ replyTo: "om_provider_raw",
311
+ channel: "gw_feishu_1",
312
+ conversation: { id: "feishu:user:oc_chat", kind: "direct" },
313
+ }),
314
+ );
315
+
316
+ expect(channel.sends.length).toBe(1);
317
+ expect(channel.sends[0].message.replyTo).toBe("om_provider_raw");
318
+ });
319
+
296
320
  it("reuses session id on second message with same queue key", async () => {
297
321
  const seen: Array<string | null> = [];
298
322
  const runtime = new FakeRuntime({
@@ -2009,7 +2033,8 @@ describe("Dispatcher", () => {
2009
2033
  });
2010
2034
  await dispatcher.handle(
2011
2035
  makeEnvelope({
2012
- id: "m_err",
2036
+ id: "feishu:om_internal_err",
2037
+ replyTo: "om_provider_err",
2013
2038
  conversation: { id: "rm_g_other", kind: "group" },
2014
2039
  }),
2015
2040
  );
@@ -2017,7 +2042,7 @@ describe("Dispatcher", () => {
2017
2042
  expect(channel.sends[0].message.type).toBe("error");
2018
2043
  expect(channel.sends[0].message.text).toContain("Runtime error: boom");
2019
2044
  expect(channel.sends[0].message.conversationId).toBe("rm_g_other");
2020
- expect(channel.sends[0].message.replyTo).toBe("m_err");
2045
+ expect(channel.sends[0].message.replyTo).toBe("om_provider_err");
2021
2046
  });
2022
2047
 
2023
2048
  // ─────────────────────────────────────────────────────────────────────
@@ -255,27 +255,80 @@ describe("createFeishuChannel", () => {
255
255
  expect(JSON.parse(send.data.content as string)).toEqual({ file_key: "file_v2_uploaded" });
256
256
  });
257
257
 
258
- it("exposes typing as a safe no-op because Feishu has no bot typing API", async () => {
259
- const debug = vi.fn();
258
+ it("adds a Typing reaction for typing and removes it when replying", async () => {
260
259
  const adapter = createFeishuChannel({
261
260
  id: "gw_fs",
262
261
  accountId: "ag_self",
263
262
  appId: "cli_a",
264
263
  appSecret: "sec",
265
264
  });
265
+ larkMock.responses.push(
266
+ { code: 0, data: { reaction_id: "react_typing_1" } },
267
+ { code: 0, data: { message_id: "om_reply" } },
268
+ { code: 0, data: {} },
269
+ );
266
270
 
267
271
  await adapter.typing?.({
268
272
  traceId: "feishu:om_1",
269
273
  accountId: "ag_self",
270
274
  conversationId: "feishu:chat:oc_chat",
271
- log: { ...SILENT_LOG, debug },
275
+ log: SILENT_LOG,
272
276
  });
273
277
 
274
- expect(larkMock.requests).toHaveLength(0);
275
- expect(debug).toHaveBeenCalledWith(
276
- "feishu typing ignored: no native bot typing API",
277
- expect.objectContaining({ channel: "gw_fs" }),
278
- );
278
+ expect(larkMock.requests).toHaveLength(1);
279
+ expect(larkMock.requests[0]).toEqual({
280
+ method: "POST",
281
+ url: "/open-apis/im/v1/messages/om_1/reactions",
282
+ data: { reaction_type: { emoji_type: "Typing" } },
283
+ });
284
+
285
+ await adapter.send({
286
+ message: {
287
+ channel: "gw_fs",
288
+ accountId: "ag_self",
289
+ conversationId: "feishu:chat:oc_chat",
290
+ text: "reply",
291
+ replyTo: "om_1",
292
+ },
293
+ log: SILENT_LOG,
294
+ });
295
+ await new Promise((resolve) => setTimeout(resolve, 0));
296
+
297
+ expect(larkMock.requests).toHaveLength(3);
298
+ expect(larkMock.requests[2]).toEqual({
299
+ method: "DELETE",
300
+ url: "/open-apis/im/v1/messages/om_1/reactions/react_typing_1",
301
+ });
302
+ });
303
+
304
+ it("refreshes an existing Feishu typing reaction without creating duplicates", async () => {
305
+ const adapter = createFeishuChannel({
306
+ id: "gw_fs",
307
+ accountId: "ag_self",
308
+ appId: "cli_a",
309
+ appSecret: "sec",
310
+ });
311
+ larkMock.responses.push({ code: 0, data: { reaction_id: "react_typing_1" } });
312
+
313
+ await adapter.typing?.({
314
+ traceId: "feishu:om_1",
315
+ accountId: "ag_self",
316
+ conversationId: "feishu:chat:oc_chat",
317
+ log: SILENT_LOG,
318
+ });
319
+ await adapter.typing?.({
320
+ traceId: "feishu:om_1",
321
+ accountId: "ag_self",
322
+ conversationId: "feishu:chat:oc_chat",
323
+ log: SILENT_LOG,
324
+ });
325
+
326
+ expect(larkMock.requests).toHaveLength(1);
327
+ expect(larkMock.requests[0]).toEqual({
328
+ method: "POST",
329
+ url: "/open-apis/im/v1/messages/om_1/reactions",
330
+ data: { reaction_type: { emoji_type: "Typing" } },
331
+ });
279
332
  });
280
333
 
281
334
  it("surfaces websocket start failures in channel status", async () => {
@@ -21,6 +21,8 @@ import type { FeishuDomain } from "./feishu-registration.js";
21
21
  const FEISHU_PROVIDER = "feishu" as const;
22
22
  const DEFAULT_SPLIT_AT = 4000;
23
23
  const MAX_SEEN_MESSAGES = 2048;
24
+ const TYPING_EMOJI = "Typing";
25
+ const TYPING_REACTION_TTL_MS = 20_000;
24
26
 
25
27
  export interface FeishuChannelOptions {
26
28
  id: string;
@@ -80,6 +82,10 @@ interface FeishuApiResponse {
80
82
  }
81
83
 
82
84
  type FeishuClient = { request(args: unknown): Promise<unknown> };
85
+ type TypingReactionState = {
86
+ reactionId: string | null;
87
+ timer: ReturnType<typeof setTimeout> | null;
88
+ };
83
89
 
84
90
  function sdkDomain(domain: FeishuDomain | undefined): unknown {
85
91
  const sdk = Lark as unknown as {
@@ -137,6 +143,7 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
137
143
  let botOpenId: string | undefined;
138
144
  let botName: string | undefined;
139
145
  let liveSetStatus: ((patch: Partial<ChannelStatusSnapshot>) => void) | null = null;
146
+ const activeTypingReactions = new Map<string, TypingReactionState>();
140
147
 
141
148
  let statusSnapshot: ChannelStatusSnapshot = {
142
149
  channel: opts.id,
@@ -387,6 +394,44 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
387
394
  );
388
395
  }
389
396
 
397
+ function resultReactionId(res: FeishuApiResponse): string | null {
398
+ return (
399
+ (typeof res.data?.reaction_id === "string" ? res.data.reaction_id : undefined) ??
400
+ (typeof res.reaction_id === "string" ? res.reaction_id : undefined) ??
401
+ null
402
+ );
403
+ }
404
+
405
+ function messageIdFromTrace(traceId: string): string | null {
406
+ if (!traceId.startsWith("feishu:")) return null;
407
+ const messageId = traceId.slice("feishu:".length).trim();
408
+ return messageId.length > 0 ? messageId : null;
409
+ }
410
+
411
+ async function removeTypingReaction(messageId: string): Promise<void> {
412
+ const state = activeTypingReactions.get(messageId);
413
+ if (!state) return;
414
+ activeTypingReactions.delete(messageId);
415
+ if (state.timer) clearTimeout(state.timer);
416
+ if (!state.reactionId) return;
417
+ try {
418
+ await callFeishu({
419
+ method: "DELETE",
420
+ url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(state.reactionId)}`,
421
+ });
422
+ } catch (err) {
423
+ statusSnapshot.lastError = err instanceof Error ? err.message : String(err);
424
+ }
425
+ }
426
+
427
+ function scheduleTypingCleanup(messageId: string, state: TypingReactionState): void {
428
+ if (state.timer) clearTimeout(state.timer);
429
+ state.timer = setTimeout(() => {
430
+ void removeTypingReaction(messageId);
431
+ }, TYPING_REACTION_TTL_MS);
432
+ if (typeof state.timer.unref === "function") state.timer.unref();
433
+ }
434
+
390
435
  function resultResourceKey(res: FeishuApiResponse, key: "image_key" | "file_key"): string {
391
436
  const direct = res[key];
392
437
  if (typeof direct === "string") return direct;
@@ -516,15 +561,61 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
516
561
  replyInThread: Boolean(ctx.message.threadId),
517
562
  }) ?? providerMessageId;
518
563
  }
564
+ if (ctx.message.replyTo) {
565
+ void removeTypingReaction(ctx.message.replyTo);
566
+ }
567
+ if (ctx.message.threadId && ctx.message.threadId !== ctx.message.replyTo) {
568
+ void removeTypingReaction(ctx.message.threadId);
569
+ }
519
570
  markStatus({ lastSendAt: Date.now() });
520
571
  return { providerMessageId };
521
572
  }
522
573
 
523
574
  async function typing(ctx: ChannelTypingContext): Promise<void> {
524
- ctx.log.debug("feishu typing ignored: no native bot typing API", {
525
- channel: opts.id,
526
- conversationId: ctx.conversationId,
527
- });
575
+ const messageId = messageIdFromTrace(ctx.traceId);
576
+ if (!messageId) {
577
+ ctx.log.debug("feishu typing skipped: trace id has no message id", {
578
+ channel: opts.id,
579
+ conversationId: ctx.conversationId,
580
+ traceId: ctx.traceId,
581
+ });
582
+ return;
583
+ }
584
+ const existing = activeTypingReactions.get(messageId);
585
+ if (existing) {
586
+ scheduleTypingCleanup(messageId, existing);
587
+ return;
588
+ }
589
+
590
+ const state: TypingReactionState = { reactionId: null, timer: null };
591
+ activeTypingReactions.set(messageId, state);
592
+ scheduleTypingCleanup(messageId, state);
593
+ try {
594
+ const res = await callFeishu({
595
+ method: "POST",
596
+ url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions`,
597
+ data: { reaction_type: { emoji_type: TYPING_EMOJI } },
598
+ });
599
+ const reactionId = resultReactionId(res);
600
+ if (activeTypingReactions.get(messageId) !== state) {
601
+ if (reactionId) {
602
+ await callFeishu({
603
+ method: "DELETE",
604
+ url: `/open-apis/im/v1/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(reactionId)}`,
605
+ });
606
+ }
607
+ return;
608
+ }
609
+ state.reactionId = reactionId;
610
+ } catch (err) {
611
+ activeTypingReactions.delete(messageId);
612
+ if (state.timer) clearTimeout(state.timer);
613
+ ctx.log.warn("feishu typing reaction failed", {
614
+ channel: opts.id,
615
+ conversationId: ctx.conversationId,
616
+ err: err instanceof Error ? err.message : String(err),
617
+ });
618
+ }
528
619
  }
529
620
 
530
621
  async function stop(_ctx: ChannelStopContext): Promise<void> {
@@ -534,6 +625,7 @@ export function createFeishuChannel(opts: FeishuChannelOptions): ChannelAdapter
534
625
  // best effort
535
626
  }
536
627
  wsClient = null;
628
+ await Promise.allSettled(Array.from(activeTypingReactions.keys()).map(removeTypingReaction));
537
629
  try {
538
630
  stateStore?.close();
539
631
  } catch {
@@ -1343,7 +1343,7 @@ export class Dispatcher {
1343
1343
  threadId: msg.conversation.threadId ?? null,
1344
1344
  type: "error",
1345
1345
  text: `⚠️ Runtime timeout after ${Math.round(this.turnTimeoutMs / 60000)} minute(s); aborted`,
1346
- replyTo: msg.id,
1346
+ replyTo: this.providerReplyTo(msg),
1347
1347
  traceId: msg.trace?.id ?? null,
1348
1348
  }, turnId);
1349
1349
  } else {
@@ -1389,7 +1389,7 @@ export class Dispatcher {
1389
1389
  threadId: msg.conversation.threadId ?? null,
1390
1390
  type: "error",
1391
1391
  text: `⚠️ Runtime error: ${truncate(errMsg, 500)}`,
1392
- replyTo: msg.id,
1392
+ replyTo: this.providerReplyTo(msg),
1393
1393
  traceId: msg.trace?.id ?? null,
1394
1394
  }, turnId);
1395
1395
  } else {
@@ -1494,7 +1494,7 @@ export class Dispatcher {
1494
1494
  threadId: msg.conversation.threadId ?? null,
1495
1495
  type: "error",
1496
1496
  text: `⚠️ Runtime error: ${truncate(result.error, 500)}`,
1497
- replyTo: msg.id,
1497
+ replyTo: this.providerReplyTo(msg),
1498
1498
  traceId: msg.trace?.id ?? null,
1499
1499
  }, turnId);
1500
1500
  this.emitOutbound({
@@ -1571,7 +1571,7 @@ export class Dispatcher {
1571
1571
  conversationId: msg.conversation.id,
1572
1572
  threadId: msg.conversation.threadId ?? null,
1573
1573
  text: replyText,
1574
- replyTo: msg.id,
1574
+ replyTo: this.providerReplyTo(msg),
1575
1575
  traceId: msg.trace?.id ?? null,
1576
1576
  }, turnId);
1577
1577
  this.emitOutbound({
@@ -1638,6 +1638,10 @@ export class Dispatcher {
1638
1638
  return { ok: true };
1639
1639
  }
1640
1640
 
1641
+ private providerReplyTo(msg: GatewayInboundMessage): string {
1642
+ return msg.replyTo ?? msg.id;
1643
+ }
1644
+
1641
1645
  private emitInbound(turnId: string, msg: GatewayInboundEnvelope["message"]): void {
1642
1646
  if (!this.transcript.enabled) return;
1643
1647
  const rawText = typeof msg.text === "string" ? msg.text : "";