@qwen-code/channel-feishu 0.21.7 → 0.21.8

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.
@@ -4,11 +4,18 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
4
4
  import { basename, join } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
6
  import * as lark from '@larksuiteoapi/node-sdk';
7
- import { ChannelBase, ChannelProactiveDeliveryError, isChannelProactiveDeliveryError, isTerminalTaskLifecycleType, } from '@qwen-code/channel-base';
7
+ import { ChannelBase, ChannelProactiveDeliveryError, isChannelProactiveDeliveryError, isTerminalTaskLifecycleType, sanitizeSenderName, } from '@qwen-code/channel-base';
8
8
  import { buildCardContent, extractTitle, splitChunks } from './markdown.js';
9
9
  import { downloadMedia } from './media.js';
10
+ import { FeishuQuestionCardController } from './question-card-controller.js';
10
11
  /** Track seen message IDs to deduplicate retried events. */
11
12
  const DEDUP_TTL_MS = 5 * 60 * 1000;
13
+ /**
14
+ * Runtime label/lookup caches are bounded like the persisted observed-contact
15
+ * registry (500 observations) so a long-running daemon does not retain every
16
+ * user/chat/thread ID it ever sees.
17
+ */
18
+ const OBSERVED_LABEL_CACHE_LIMIT = 500;
12
19
  /** Minimum interval between card updates (ms) to avoid API rate limiting. */
13
20
  const CARD_UPDATE_INTERVAL_MS = 1500;
14
21
  const FEISHU_RUNNING_STATUS_LABEL = '运行中...';
@@ -38,6 +45,19 @@ const FEISHU_STATUS_BLOCK_RE = new RegExp(`(?:^|\\n)---\\n\\*${FEISHU_STATUS_LAB
38
45
  const BASE_URL = 'https://open.feishu.cn/open-apis';
39
46
  /** Validate Feishu ID format to prevent SSRF path traversal in URL interpolation. */
40
47
  const FEISHU_ID_RE = /^[a-zA-Z0-9_.:-]+$/;
48
+ /**
49
+ * Typed failure for interactive-card delivery. `detail` is set for HTTP
50
+ * failures so callers (createStreamingCard) can report by field instead of
51
+ * string-matching message literals that could drift under rewording.
52
+ */
53
+ class FeishuCardDeliveryError extends Error {
54
+ detail;
55
+ constructor(message, detail) {
56
+ super(message);
57
+ this.detail = detail;
58
+ this.name = 'FeishuCardDeliveryError';
59
+ }
60
+ }
41
61
  export class FeishuChannel extends ChannelBase {
42
62
  eventDispatcher;
43
63
  wsClient;
@@ -59,6 +79,16 @@ export class FeishuChannel extends ChannelBase {
59
79
  botOpenId;
60
80
  tokenCache;
61
81
  tokenRefreshPromise;
82
+ questionCardController;
83
+ // Core (non-silent) callers waiting on the shared token refresh, so a
84
+ // silent-initiated refresh still logs token errors for them.
85
+ tokenRefreshHasCoreWaiters = false;
86
+ observedUserNames = new Map();
87
+ observedChatNames = new Map();
88
+ observedUserLookups = new Map();
89
+ observedChatLookups = new Map();
90
+ observedContactWrites = new Map();
91
+ hydratedObservedNames = false;
62
92
  collapsible;
63
93
  collapsibleThreshold;
64
94
  constructor(name, config, bridge, options) {
@@ -70,6 +100,15 @@ export class FeishuChannel extends ChannelBase {
70
100
  this.collapsible = feishuCfg['collapsible'] || false;
71
101
  this.collapsibleThreshold =
72
102
  feishuCfg['collapsibleThreshold'] || 500;
103
+ this.questionCardController = new FeishuQuestionCardController({
104
+ timeoutMs: 270_000,
105
+ sendCard: (chatId, card) => this.sendInteractiveCard(chatId, card),
106
+ patchCard: (messageId, card) => this.patchInteractiveCard(messageId, card),
107
+ sendFallback: (chatId, text) => this.sendMessageInternal(chatId, text, true),
108
+ onError: (operation, error) => {
109
+ process.stderr.write(`[Feishu:${this.name}] ${operation} error: ${error instanceof Error ? error.message : error}\n`);
110
+ },
111
+ });
73
112
  }
74
113
  supportsProactiveSend() {
75
114
  return true;
@@ -85,6 +124,18 @@ export class FeishuChannel extends ChannelBase {
85
124
  'card.action.trigger': (data) => {
86
125
  const payload = data;
87
126
  this.logDebugPayload('Feishu', payload);
127
+ const question = this.questionCardController.claim(payload);
128
+ if (question.kind === 'handled') {
129
+ const execute = question.execute;
130
+ if (execute) {
131
+ setImmediate(() => {
132
+ execute().catch((error) => {
133
+ process.stderr.write(`[Feishu:${this.name}] question action execution error: ${error instanceof Error ? error.message : error}\n`);
134
+ });
135
+ });
136
+ }
137
+ return question.response;
138
+ }
88
139
  const stopped = this.onCardAction(payload);
89
140
  if (stopped) {
90
141
  return { toast: { type: 'info', content: '已停止' } };
@@ -443,10 +494,12 @@ export class FeishuChannel extends ChannelBase {
443
494
  text = text.replace(/^好的,<at[^>]*><\/at>\s*\n*/, '');
444
495
  return text.trim() || undefined;
445
496
  }
446
- async getTenantAccessToken() {
497
+ async getTenantAccessToken(options) {
447
498
  if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) {
448
499
  return this.tokenCache.token;
449
500
  }
501
+ if (!options?.silent)
502
+ this.tokenRefreshHasCoreWaiters = true;
450
503
  if (this.tokenRefreshPromise)
451
504
  return this.tokenRefreshPromise;
452
505
  this.tokenRefreshPromise = this.refreshToken();
@@ -455,9 +508,12 @@ export class FeishuChannel extends ChannelBase {
455
508
  }
456
509
  finally {
457
510
  this.tokenRefreshPromise = undefined;
511
+ this.tokenRefreshHasCoreWaiters = false;
458
512
  }
459
513
  }
460
514
  async refreshToken() {
515
+ // Best-effort label enrichment initiates silent refreshes; failures must
516
+ // still surface when a core delivery caller initiated or joined it.
461
517
  try {
462
518
  const resp = await fetch(`${BASE_URL}/auth/v3/tenant_access_token/internal`, {
463
519
  method: 'POST',
@@ -469,7 +525,9 @@ export class FeishuChannel extends ChannelBase {
469
525
  signal: AbortSignal.timeout(15_000),
470
526
  });
471
527
  if (!resp.ok) {
472
- process.stderr.write(`[Feishu:${this.name}] getTenantAccessToken failed: HTTP ${resp.status}\n`);
528
+ if (this.tokenRefreshHasCoreWaiters) {
529
+ process.stderr.write(`[Feishu:${this.name}] getTenantAccessToken failed: HTTP ${resp.status}\n`);
530
+ }
473
531
  if (resp.status === 401)
474
532
  this.tokenCache = undefined;
475
533
  return undefined;
@@ -483,10 +541,187 @@ export class FeishuChannel extends ChannelBase {
483
541
  return this.tokenCache.token;
484
542
  }
485
543
  catch (err) {
486
- process.stderr.write(`[Feishu:${this.name}] getTenantAccessToken error: ${err}\n`);
544
+ if (this.tokenRefreshHasCoreWaiters) {
545
+ process.stderr.write(`[Feishu:${this.name}] getTenantAccessToken error: ${err}\n`);
546
+ }
487
547
  return undefined;
488
548
  }
489
549
  }
550
+ hydrateObservedNames() {
551
+ if (this.hydratedObservedNames)
552
+ return;
553
+ this.hydratedObservedNames = true;
554
+ const graph = this.persistedObservedContacts();
555
+ if (!graph)
556
+ return;
557
+ // Select the newest non-ID label per contact so an older observation
558
+ // (for example a stale group membership) cannot overwrite a more recent
559
+ // one during the traversal.
560
+ const newestUser = new Map();
561
+ const newestChat = new Map();
562
+ const consider = (best, id, label, at) => {
563
+ if (label === id)
564
+ return;
565
+ const current = best.get(id);
566
+ if (!current || at >= current.at)
567
+ best.set(id, { label, at });
568
+ };
569
+ for (const user of graph.users) {
570
+ consider(newestUser, user.id, user.label, user.lastObservedAt);
571
+ }
572
+ for (const group of graph.groups) {
573
+ consider(newestChat, group.id, group.label, group.lastObservedAt);
574
+ for (const member of group.users) {
575
+ consider(newestUser, member.id, member.label, member.lastObservedAt);
576
+ }
577
+ }
578
+ for (const [id, entry] of newestUser) {
579
+ this.observedUserNames.set(id, entry.label);
580
+ }
581
+ for (const [id, entry] of newestChat) {
582
+ this.observedChatNames.set(id, entry.label);
583
+ }
584
+ this.capObservedCache(this.observedUserNames);
585
+ this.capObservedCache(this.observedChatNames);
586
+ }
587
+ /** Evicts the oldest-inserted entries once a runtime cache exceeds the cap. */
588
+ capObservedCache(cache) {
589
+ let evicted = false;
590
+ while (cache.size > OBSERVED_LABEL_CACHE_LIMIT) {
591
+ const oldest = cache.keys().next();
592
+ if (oldest.done)
593
+ break;
594
+ cache.delete(oldest.value);
595
+ evicted = true;
596
+ }
597
+ return evicted;
598
+ }
599
+ observedUserName(userId) {
600
+ const userIdType = userId.startsWith('ou_')
601
+ ? 'open_id'
602
+ : userId.startsWith('on_')
603
+ ? 'union_id'
604
+ : 'user_id';
605
+ return this.observedNameLookup({
606
+ lookups: this.observedUserLookups,
607
+ names: this.observedUserNames,
608
+ id: userId,
609
+ request: (token) => fetch(`${BASE_URL}/contact/v3/users/basic_batch?user_id_type=${userIdType}`, {
610
+ method: 'POST',
611
+ headers: {
612
+ Authorization: `Bearer ${token}`,
613
+ 'Content-Type': 'application/json',
614
+ },
615
+ body: JSON.stringify({ user_ids: [userId] }),
616
+ signal: AbortSignal.timeout(15_000),
617
+ }),
618
+ extractName: (body) => {
619
+ const data = body;
620
+ return data.code === 0 ? data.data?.users?.[0]?.name : undefined;
621
+ },
622
+ });
623
+ }
624
+ observedChatName(chatId) {
625
+ return this.observedNameLookup({
626
+ lookups: this.observedChatLookups,
627
+ names: this.observedChatNames,
628
+ id: chatId,
629
+ request: (token) => fetch(`${BASE_URL}/im/v1/chats/${encodeURIComponent(chatId)}`, {
630
+ headers: { Authorization: `Bearer ${token}` },
631
+ signal: AbortSignal.timeout(15_000),
632
+ }),
633
+ extractName: (body) => {
634
+ const data = body;
635
+ return data.code === 0 ? data.data?.name : undefined;
636
+ },
637
+ });
638
+ }
639
+ observedNameLookup(options) {
640
+ const cached = options.names.get(options.id);
641
+ if (cached)
642
+ return Promise.resolve(cached);
643
+ const existing = options.lookups.get(options.id);
644
+ if (existing)
645
+ return existing;
646
+ const lookup = (async () => {
647
+ try {
648
+ const token = await this.getTenantAccessToken({ silent: true });
649
+ if (!token) {
650
+ options.lookups.delete(options.id);
651
+ return undefined;
652
+ }
653
+ const response = await options.request(token);
654
+ if (!response.ok) {
655
+ if (response.status === 401) {
656
+ this.tokenCache = undefined;
657
+ options.lookups.delete(options.id);
658
+ }
659
+ return undefined;
660
+ }
661
+ const name = options.extractName(await response.json())?.trim();
662
+ if (!name)
663
+ return undefined;
664
+ const label = sanitizeSenderName(name);
665
+ if (label === 'unknown')
666
+ return undefined;
667
+ options.names.set(options.id, label);
668
+ // Evicting a resolved label drops the next envelope back to the raw
669
+ // ID, and the initial persistence write would clobber the persisted
670
+ // label, so re-hydrate from the registry on the next message.
671
+ if (this.capObservedCache(options.names)) {
672
+ this.hydratedObservedNames = false;
673
+ }
674
+ return label;
675
+ }
676
+ catch {
677
+ return undefined;
678
+ }
679
+ })();
680
+ options.lookups.set(options.id, lookup);
681
+ this.capObservedCache(options.lookups);
682
+ return lookup;
683
+ }
684
+ onObservedContact(envelope) {
685
+ this.observedContactWrites.set(this.observedContactKey(envelope), {
686
+ senderName: envelope.senderName,
687
+ chatName: envelope.chatName,
688
+ });
689
+ this.capObservedCache(this.observedContactWrites);
690
+ void this.enrichObservedContact(envelope).catch(() => { });
691
+ }
692
+ observedContactKey(envelope) {
693
+ return envelope.isGroup
694
+ ? `${envelope.senderId}\u0000${envelope.chatId}\u0000${envelope.threadId ?? ''}`
695
+ : envelope.senderId;
696
+ }
697
+ async enrichObservedContact(envelope) {
698
+ const [senderName, chatName] = await Promise.all([
699
+ this.observedUserName(envelope.senderId),
700
+ envelope.isGroup
701
+ ? this.observedChatName(envelope.chatId)
702
+ : Promise.resolve(undefined),
703
+ ]);
704
+ if (!senderName && !chatName)
705
+ return;
706
+ const key = this.observedContactKey(envelope);
707
+ const nextLabels = {
708
+ senderName: senderName ?? envelope.senderName,
709
+ chatName: chatName ?? envelope.chatName,
710
+ };
711
+ const persistedLabels = this.observedContactWrites.get(key);
712
+ if (persistedLabels &&
713
+ persistedLabels.senderName === nextLabels.senderName &&
714
+ persistedLabels.chatName === nextLabels.chatName) {
715
+ return;
716
+ }
717
+ this.observedContactWrites.set(key, nextLabels);
718
+ this.capObservedCache(this.observedContactWrites);
719
+ await this.recordObservedContact({
720
+ ...envelope,
721
+ ...(senderName ? { senderName } : {}),
722
+ ...(chatName ? { chatName } : {}),
723
+ });
724
+ }
490
725
  async sendMessage(chatId, text) {
491
726
  await this.sendMessageInternal(chatId, text, false);
492
727
  }
@@ -561,54 +796,92 @@ export class FeishuChannel extends ChannelBase {
561
796
  }
562
797
  }
563
798
  // ----- Interactive Card Streaming -----
564
- async createStreamingCard(chatId, text, title, inboundMsgId) {
799
+ async sendInteractiveCard(chatId, card) {
565
800
  const token = await this.getTenantAccessToken();
566
801
  if (!token)
567
- return { messageId: '', success: false };
568
- const cardTitle = title || (inboundMsgId && this.msgToQuestion.get(inboundMsgId)) || 'Qwen';
569
- const card = buildCardContent(text, {
570
- title: cardTitle,
571
- showStopButton: true,
572
- isStreaming: true,
573
- statusLabel: this.statusLabelFor(),
574
- collapsible: this.collapsible,
575
- collapsibleThreshold: this.collapsibleThreshold,
802
+ throw new FeishuCardDeliveryError('Feishu card delivery failed: no access token');
803
+ const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
804
+ method: 'POST',
805
+ headers: {
806
+ Authorization: `Bearer ${token}`,
807
+ 'Content-Type': 'application/json',
808
+ },
809
+ body: JSON.stringify({
810
+ receive_id: chatId,
811
+ msg_type: 'interactive',
812
+ content: JSON.stringify(card),
813
+ }),
814
+ signal: AbortSignal.timeout(15_000),
576
815
  });
577
- const body = {
578
- receive_id: chatId,
579
- msg_type: 'interactive',
580
- content: JSON.stringify(card),
581
- };
816
+ if (!resp.ok) {
817
+ if (resp.status === 401)
818
+ this.tokenCache = undefined;
819
+ const detail = await resp.text().catch(() => '');
820
+ const errorDetail = `HTTP ${resp.status} ${detail}`;
821
+ throw new FeishuCardDeliveryError(`Feishu card delivery failed: ${errorDetail}`, errorDetail);
822
+ }
823
+ const data = (await resp.json());
824
+ const messageId = data.data?.message_id;
825
+ if (!messageId) {
826
+ throw new FeishuCardDeliveryError('Feishu card delivery returned no message id');
827
+ }
828
+ return messageId;
829
+ }
830
+ async patchInteractiveCard(messageId, card) {
831
+ const token = await this.getTenantAccessToken();
832
+ if (!token || !FEISHU_ID_RE.test(messageId))
833
+ return false;
582
834
  try {
583
- const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
584
- method: 'POST',
835
+ const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}`, {
836
+ method: 'PATCH',
585
837
  headers: {
586
838
  Authorization: `Bearer ${token}`,
587
839
  'Content-Type': 'application/json',
588
840
  },
589
- body: JSON.stringify(body),
841
+ body: JSON.stringify({
842
+ msg_type: 'interactive',
843
+ content: JSON.stringify(card),
844
+ }),
590
845
  signal: AbortSignal.timeout(15_000),
591
846
  });
592
847
  if (!resp.ok) {
593
848
  if (resp.status === 401)
594
849
  this.tokenCache = undefined;
595
850
  const detail = await resp.text().catch(() => '');
596
- process.stderr.write(`[Feishu:${this.name}] createStreamingCard failed: HTTP ${resp.status} ${detail}\n`);
597
- return { messageId: '', success: false };
851
+ process.stderr.write(`[Feishu:${this.name}] updateCard failed: HTTP ${resp.status} ${detail}\n`);
852
+ return false;
598
853
  }
599
- const data = (await resp.json());
600
- const messageId = data.data?.message_id || '';
601
- return { messageId, success: !!messageId };
854
+ return true;
602
855
  }
603
856
  catch (err) {
857
+ process.stderr.write(`[Feishu:${this.name}] updateCard error: ${err}\n`);
858
+ return false;
859
+ }
860
+ }
861
+ async createStreamingCard(chatId, text, title, inboundMsgId) {
862
+ const cardTitle = title || (inboundMsgId && this.msgToQuestion.get(inboundMsgId)) || 'Qwen';
863
+ const card = buildCardContent(text, {
864
+ title: cardTitle,
865
+ showStopButton: true,
866
+ isStreaming: true,
867
+ statusLabel: this.statusLabelFor(),
868
+ collapsible: this.collapsible,
869
+ collapsibleThreshold: this.collapsibleThreshold,
870
+ });
871
+ try {
872
+ const messageId = await this.sendInteractiveCard(chatId, card);
873
+ return { messageId, success: true };
874
+ }
875
+ catch (err) {
876
+ if (err instanceof FeishuCardDeliveryError) {
877
+ process.stderr.write(`[Feishu:${this.name}] createStreamingCard failed: ${err.detail ?? err.message}\n`);
878
+ return { messageId: '', success: false };
879
+ }
604
880
  process.stderr.write(`[Feishu:${this.name}] createStreamingCard error: ${err}\n`);
605
881
  return { messageId: '', success: false };
606
882
  }
607
883
  }
608
884
  async updateCard(messageId, text, finished = false, inboundMsgId, statusLabel) {
609
- const token = await this.getTenantAccessToken();
610
- if (!token)
611
- return false;
612
885
  const cardTitle = inboundMsgId
613
886
  ? this.msgToQuestion.get(inboundMsgId) || 'Qwen'
614
887
  : 'Qwen';
@@ -620,34 +893,22 @@ export class FeishuChannel extends ChannelBase {
620
893
  collapsible: this.collapsible,
621
894
  collapsibleThreshold: this.collapsibleThreshold,
622
895
  });
623
- if (!FEISHU_ID_RE.test(messageId))
624
- return false;
625
- try {
626
- const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}`, {
627
- method: 'PATCH',
628
- headers: {
629
- Authorization: `Bearer ${token}`,
630
- 'Content-Type': 'application/json',
631
- },
632
- body: JSON.stringify({
633
- msg_type: 'interactive',
634
- content: JSON.stringify(card),
635
- }),
636
- signal: AbortSignal.timeout(15_000),
637
- });
638
- if (!resp.ok) {
639
- if (resp.status === 401)
640
- this.tokenCache = undefined;
641
- const detail = await resp.text().catch(() => '');
642
- process.stderr.write(`[Feishu:${this.name}] updateCard failed: HTTP ${resp.status} ${detail}\n`);
643
- return false;
896
+ return this.patchInteractiveCard(messageId, card);
897
+ }
898
+ async presentUserInputRequest(context) {
899
+ if (!context.precedingSegmentId) {
900
+ const inboundMsgId = this.sessionToInboundMsg.get(context.sessionId);
901
+ const cardState = inboundMsgId
902
+ ? this.cardSessions.get(inboundMsgId)
903
+ : undefined;
904
+ if (inboundMsgId && cardState) {
905
+ // Production bridges emit response_boundary synchronously before the
906
+ // permission request, so the segment is already closed and no
907
+ // input_requested segment end runs — end the output presentation here.
908
+ await this.endOutputCardBeforeInputRequest(context.target.chatId, inboundMsgId, cardState);
644
909
  }
645
- return true;
646
- }
647
- catch (err) {
648
- process.stderr.write(`[Feishu:${this.name}] updateCard error: ${err}\n`);
649
- return false;
650
910
  }
911
+ return this.questionCardController.present(context);
651
912
  }
652
913
  /** Delete a card message from Feishu to prevent orphaned "思考中..." cards. */
653
914
  async deleteCard(messageId) {
@@ -702,6 +963,7 @@ export class FeishuChannel extends ChannelBase {
702
963
  }
703
964
  if (cardState.stopped)
704
965
  return;
966
+ cardState.boundaryText = undefined;
705
967
  const MAX_ACCUMULATE = 25_000;
706
968
  cardState.accumulatedText += chunk;
707
969
  if (cardState.accumulatedText.length > MAX_ACCUMULATE) {
@@ -714,12 +976,19 @@ export class FeishuChannel extends ChannelBase {
714
976
  // If card not yet created (fallback path), create now
715
977
  if (!cardState.created && !cardState.cardCreationFailed) {
716
978
  cardState.creating = true;
979
+ // The orphan sweep times out creations by lastUpdateAt; anchor it at
980
+ // creation start so a stale released pre-question entry cannot trip
981
+ // the 60s bound mid-creation.
982
+ cardState.lastUpdateAt = Date.now();
717
983
  const cs = cardState;
718
984
  cardState.creationTimer = setTimeout(async () => {
719
985
  try {
720
986
  if (cs.stopped || this.stoppedMessages.has(inboundMsgId)) {
721
987
  cs.creating = false;
722
- this.cleanupCard(inboundMsgId);
988
+ // An abandoned stop belongs to a pending-question release that
989
+ // intentionally kept the auxiliary maps — cleanup would wipe them.
990
+ if (!cs.abandoned)
991
+ this.cleanupCard(inboundMsgId);
723
992
  return;
724
993
  }
725
994
  // Note: don't check cancelling here — let the card creation proceed.
@@ -772,39 +1041,53 @@ export class FeishuChannel extends ChannelBase {
772
1041
  const cs = cardState;
773
1042
  const elapsed = Date.now() - cardState.lastUpdateAt;
774
1043
  const delay = Math.max(0, CARD_UPDATE_INTERVAL_MS - elapsed);
775
- cardState.pendingUpdateTimer = setTimeout(async () => {
1044
+ cardState.pendingUpdateTimer = setTimeout(() => {
776
1045
  cs.pendingUpdateTimer = undefined;
777
1046
  if (cs.stopped || cs.finalizing)
778
1047
  return;
779
1048
  cs.lastUpdateAt = Date.now();
780
- try {
781
- const MAX_CARD_CHARS = 20_000;
782
- const atPrefix = this.msgToSenderName.get(inboundMsgId);
783
- let displayContent = atPrefix
784
- ? `${atPrefix}\n\n${cs.accumulatedText}`
785
- : cs.accumulatedText;
786
- if (displayContent.length > MAX_CARD_CHARS) {
787
- const marker = '\n\n_(内容过长,已截断早期内容)_';
788
- displayContent =
789
- displayContent.slice(-(MAX_CARD_CHARS - marker.length)) + marker;
790
- // Re-balance code fences after truncation
791
- if (this.countFences(displayContent) % 2 === 1) {
792
- displayContent = '```\n' + displayContent;
793
- }
794
- }
795
- const ok = await this.updateCard(cs.messageId, displayContent, false, inboundMsgId);
796
- if (!ok) {
797
- // Fallback: strip tables to avoid card table limit (code-fence aware)
798
- const stripped = this.stripTables(displayContent, '(表格)');
799
- await this.updateCard(cs.messageId, stripped, false, inboundMsgId);
800
- }
1049
+ if (cs.pendingUpdatePromise) {
1050
+ // A run is already in flight or queued — coalesce instead of
1051
+ // stacking a burst of PATCHes; the trailing run picks up the
1052
+ // latest accumulated buffer.
1053
+ cs.updateQueued = true;
1054
+ return;
801
1055
  }
802
- catch (err) {
1056
+ cs.pendingUpdatePromise = this.runThrottledCardUpdate(inboundMsgId, cs)
1057
+ .catch((err) => {
803
1058
  process.stderr.write(`[Feishu:${this.name}] card update error: ${err}\n`);
804
- }
1059
+ })
1060
+ .then(() => {
1061
+ cs.pendingUpdatePromise = undefined;
1062
+ });
805
1063
  }, delay);
806
1064
  }
807
1065
  }
1066
+ /** Runs one throttled streaming PATCH, then re-runs once when timer fires
1067
+ * coalesced behind it (`updateQueued`) so the latest buffer still goes out.
1068
+ * `pendingUpdatePromise` covers the whole chain so finalization can drain
1069
+ * every in-flight update before sending the final patch. */
1070
+ async runThrottledCardUpdate(inboundMsgId, cs) {
1071
+ if (cs.stopped || cs.finalizing)
1072
+ return;
1073
+ try {
1074
+ const atPrefix = this.msgToSenderName.get(inboundMsgId);
1075
+ const displayContent = this.truncateCardText(atPrefix ? `${atPrefix}\n\n${cs.accumulatedText}` : cs.accumulatedText);
1076
+ const ok = await this.updateCard(cs.messageId, displayContent, false, inboundMsgId);
1077
+ if (!ok && !cs.stopped && !cs.finalizing) {
1078
+ // Fallback: strip tables to avoid card table limit (code-fence aware)
1079
+ const stripped = this.stripTables(displayContent, '(表格)');
1080
+ await this.updateCard(cs.messageId, stripped, false, inboundMsgId);
1081
+ }
1082
+ }
1083
+ catch (err) {
1084
+ process.stderr.write(`[Feishu:${this.name}] card update error: ${err}\n`);
1085
+ }
1086
+ if (cs.updateQueued) {
1087
+ cs.updateQueued = false;
1088
+ await this.runThrottledCardUpdate(inboundMsgId, cs);
1089
+ }
1090
+ }
808
1091
  onResponseBoundary(_chatId, sessionId) {
809
1092
  if (this.config.blockStreaming === 'on')
810
1093
  return;
@@ -818,8 +1101,112 @@ export class FeishuChannel extends ChannelBase {
818
1101
  clearTimeout(cardState.pendingUpdateTimer);
819
1102
  cardState.pendingUpdateTimer = undefined;
820
1103
  }
1104
+ // The boundary empties the buffer, so a coalesced trailing run would have
1105
+ // nothing legitimate to send; drop the flag or it PATCHes the card empty.
1106
+ cardState.updateQueued = false;
1107
+ if (cardState.accumulatedText) {
1108
+ cardState.boundaryText = cardState.accumulatedText;
1109
+ }
821
1110
  cardState.accumulatedText = '';
822
1111
  }
1112
+ async onOutputSegmentEnd(chatId, sessionId, _segment, reason) {
1113
+ if (reason === 'response_boundary') {
1114
+ this.onResponseBoundary(chatId, sessionId);
1115
+ return;
1116
+ }
1117
+ if (reason !== 'input_requested' || this.config.blockStreaming === 'on') {
1118
+ return;
1119
+ }
1120
+ const inboundMsgId = this.sessionToInboundMsg.get(sessionId);
1121
+ if (!inboundMsgId)
1122
+ return;
1123
+ const cardState = this.cardSessions.get(inboundMsgId);
1124
+ if (!cardState)
1125
+ return;
1126
+ await this.endOutputCardBeforeInputRequest(chatId, inboundMsgId, cardState);
1127
+ }
1128
+ async endOutputCardBeforeInputRequest(chatId, inboundMsgId, cardState) {
1129
+ // Stop owns the card once the user clicks it: handleStop refuses to race
1130
+ // a finalizing card, so finalizing here would render 已完成 over a stopped
1131
+ // run and drop the stop label. Leave the card to the stop wind-down paths.
1132
+ if (cardState.cancelling ||
1133
+ cardState.stopped ||
1134
+ cardState.finalizing ||
1135
+ this.stoppedMessages.has(inboundMsgId)) {
1136
+ return;
1137
+ }
1138
+ const atPrefix = this.msgToSenderName.get(inboundMsgId);
1139
+ const text = cardState.accumulatedText || cardState.boundaryText || '';
1140
+ const displayText = atPrefix
1141
+ ? text
1142
+ ? `${atPrefix}\n\n${text}`
1143
+ : atPrefix
1144
+ : text;
1145
+ // Mirror onResponseComplete: reserve room for the greeting prefix and the
1146
+ // completed status block that buildCardContent renders alongside the text.
1147
+ const completedSuffix = `\n\n---\n*${this.statusLabelFor('completed')}*`;
1148
+ const prefixPart = atPrefix && text ? `${atPrefix}\n\n` : '';
1149
+ const finalText = text
1150
+ ? prefixPart +
1151
+ this.truncateCardText(text, prefixPart.length + completedSuffix.length)
1152
+ : displayText;
1153
+ try {
1154
+ if (cardState.created && cardState.messageId && !cardState.stopped) {
1155
+ // Mirror onResponseComplete: block the throttled update path before
1156
+ // the final patch. Clearing a timer that already fired is a no-op, so
1157
+ // an in-flight streaming PATCH is awaited as well — otherwise it can
1158
+ // land after the final patch and re-render the card as running.
1159
+ cardState.finalizing = true;
1160
+ if (cardState.pendingUpdateTimer) {
1161
+ clearTimeout(cardState.pendingUpdateTimer);
1162
+ cardState.pendingUpdateTimer = undefined;
1163
+ }
1164
+ if (cardState.pendingUpdatePromise) {
1165
+ await cardState.pendingUpdatePromise;
1166
+ }
1167
+ let updated = false;
1168
+ try {
1169
+ // Stop may settle during the drain above; mirror onResponseComplete
1170
+ // and hand the card to the stop path instead of labelling a stopped
1171
+ // run 已完成.
1172
+ if (await this.finalizeStoppedCardUpdate(inboundMsgId, cardState, chatId)) {
1173
+ return;
1174
+ }
1175
+ updated = await this.updateCard(cardState.messageId, finalText, true, inboundMsgId, this.statusLabelFor('completed'));
1176
+ if (!updated) {
1177
+ // Mirror onResponseComplete: retry without tables (Feishu card
1178
+ // table-count limit) before giving up on the card.
1179
+ const noTableText = this.stripTables(finalText, '(表格内容请查看原文)');
1180
+ updated = await this.updateCard(cardState.messageId, noTableText, true, inboundMsgId, this.statusLabelFor('completed'));
1181
+ }
1182
+ // Stop may also settle during the patch awaits above.
1183
+ if (await this.finalizeStoppedCardUpdate(inboundMsgId, cardState, chatId)) {
1184
+ return;
1185
+ }
1186
+ }
1187
+ catch (error) {
1188
+ process.stderr.write(`[Feishu:${this.name}] input-request card finalization error: ${error instanceof Error ? error.message : error}\n`);
1189
+ }
1190
+ if (!updated) {
1191
+ await this.deleteCard(cardState.messageId);
1192
+ if (displayText)
1193
+ await this.sendMessage(chatId, displayText);
1194
+ }
1195
+ }
1196
+ else {
1197
+ if (cardState.creating) {
1198
+ cardState.stopped = true;
1199
+ cardState.abandoned = true;
1200
+ }
1201
+ if (text) {
1202
+ await this.sendMessage(chatId, displayText);
1203
+ }
1204
+ }
1205
+ }
1206
+ finally {
1207
+ this.releaseOutputCard(inboundMsgId);
1208
+ }
1209
+ }
823
1210
  isKnownInboundMessageId(messageId) {
824
1211
  return (this.msgToQuestion.has(messageId) ||
825
1212
  this.msgToSenderName.has(messageId) ||
@@ -871,6 +1258,14 @@ export class FeishuChannel extends ChannelBase {
871
1258
  if (!isTerminalTaskLifecycleType(event.type)) {
872
1259
  return;
873
1260
  }
1261
+ if (event.runId) {
1262
+ // Mirror the DingTalk sibling: only a user-initiated cancel projects
1263
+ // 已取消; a completed or failed run leaves the question 已过期.
1264
+ this.questionCardController.cancelRun(event.runId, event.type === 'cancelled' &&
1265
+ (event.reason === 'cancel_command' || event.reason === 'clear')
1266
+ ? 'cancelled'
1267
+ : 'expired');
1268
+ }
874
1269
  const inboundMsgId = this.knownInboundMessageId(event.sessionId, event.messageId);
875
1270
  if (!inboundMsgId)
876
1271
  return;
@@ -899,34 +1294,23 @@ export class FeishuChannel extends ChannelBase {
899
1294
  }
900
1295
  // Prepend greeting with sender name
901
1296
  const atSender = this.msgToSenderName.get(inboundMsgId);
902
- let displayText = atSender ? `${atSender}\n\n${fullText}` : fullText;
903
1297
  const completedLabel = this.statusLabelFor('completed');
904
1298
  const completedSuffix = `\n\n---\n*${completedLabel}*`;
905
- // Enforce card size limit to avoid wasted API round-trips
906
- const MAX_FINAL_CARD_CHARS = 20_000;
907
- if (displayText.length + completedSuffix.length > MAX_FINAL_CARD_CHARS) {
908
- const prefix = atSender ? `${atSender}\n\n` : '';
909
- const suffix = '\n\n_(内容过长,已截断早期内容)_';
910
- const fenceReserve = 4; // potential '```\n' prepend for fence rebalancing
911
- const maxBody = MAX_FINAL_CARD_CHARS -
912
- prefix.length -
913
- suffix.length -
914
- completedSuffix.length -
915
- fenceReserve;
916
- displayText = prefix + fullText.slice(-maxBody) + suffix;
917
- // Re-balance code fences after truncation (line-by-line, handles indented fences)
918
- if (this.countFences(displayText) % 2 === 1) {
919
- displayText = '```\n' + displayText;
920
- }
921
- }
1299
+ const atPrefix = atSender ? `${atSender}\n\n` : '';
1300
+ // Enforce card size limit to avoid wasted API round-trips; reserve room
1301
+ // for the greeting prefix and the completed status block.
1302
+ const displayText = atPrefix +
1303
+ this.truncateCardText(fullText, atPrefix.length + completedSuffix.length);
922
1304
  // Mark as finalizing to prevent concurrent updates/create from timers
923
1305
  if (cardState)
924
1306
  cardState.finalizing = true;
925
1307
  if (cardState?.pendingUpdateTimer) {
926
1308
  clearTimeout(cardState.pendingUpdateTimer);
927
1309
  }
928
- if (cardState?.creationTimer) {
929
- clearTimeout(cardState.creationTimer);
1310
+ // Do not clear creationTimer: the pending creation callback is the only
1311
+ // path that clears `creating`, which the busy-wait below drains.
1312
+ if (cardState?.pendingUpdatePromise) {
1313
+ await cardState.pendingUpdatePromise;
930
1314
  }
931
1315
  // Wait for in-flight card creation (with 10s timeout)
932
1316
  if (cardState?.creating) {
@@ -1009,73 +1393,22 @@ export class FeishuChannel extends ChannelBase {
1009
1393
  this.cleanupCard(inboundMsgId);
1010
1394
  await this.sendMessage(chatId, atSender ? `${atSender}\n\n${fullText}` : fullText);
1011
1395
  }
1012
- onPromptStart(chatId, sessionId, messageId) {
1396
+ onPromptStart(_chatId, sessionId, messageId) {
1013
1397
  const inboundMsgId = messageId && this.isKnownInboundMessageId(messageId)
1014
1398
  ? messageId
1015
1399
  : undefined;
1016
1400
  if (inboundMsgId) {
1017
1401
  this.sessionToInboundMsg.set(sessionId, inboundMsgId);
1018
1402
  this.addReaction(inboundMsgId, 'OnIt').catch(() => { });
1019
- // In blockStreaming mode, skip card creation — BlockStreamer handles delivery
1020
- if (this.config.blockStreaming === 'on')
1021
- return;
1022
- // Create streaming card now that gating has passed
1023
- if (!this.cardSessions.has(inboundMsgId)) {
1024
- const atSender = this.msgToSenderName.get(inboundMsgId) || '';
1025
- const placeholderText = atSender
1026
- ? `${atSender},思考中...`
1027
- : '思考中...';
1028
- const cardState = {
1403
+ if (this.config.blockStreaming !== 'on' &&
1404
+ !this.cardSessions.has(inboundMsgId)) {
1405
+ this.cardSessions.set(inboundMsgId, {
1029
1406
  messageId: '',
1030
1407
  created: false,
1031
- creating: true,
1408
+ creating: false,
1032
1409
  stopped: false,
1033
1410
  accumulatedText: '',
1034
1411
  lastUpdateAt: Date.now(),
1035
- };
1036
- this.cardSessions.set(inboundMsgId, cardState);
1037
- this.createStreamingCard(chatId, placeholderText, undefined, inboundMsgId)
1038
- .then((result) => {
1039
- // Only check stopped (not cancelling) — cancelling is set before
1040
- // cancelSession resolves, and the card must still be created so
1041
- // handleStop can update it once cancelSession completes.
1042
- if (cardState.stopped || this.stoppedMessages.has(inboundMsgId)) {
1043
- // If abandoned by busy-wait timeout, delete the streaming card —
1044
- // the response was already delivered via sendMessage.
1045
- if (cardState.abandoned) {
1046
- if (result.success) {
1047
- this.deleteCard(result.messageId).catch((err) => {
1048
- process.stderr.write(`[Feishu:${this.name}] ORPHANED CARD: failed to delete abandoned card msg=${result.messageId}: ${err instanceof Error ? err.message : err}\n`);
1049
- });
1050
- }
1051
- cardState.creating = false;
1052
- return;
1053
- }
1054
- if (result.success) {
1055
- // Use cardState.atPrefix (captured by onCardAction before cleanupCard)
1056
- const prefix = cardState.atPrefix ||
1057
- this.msgToSenderName.get(inboundMsgId) ||
1058
- '';
1059
- this.updateCard(result.messageId, prefix, true, inboundMsgId, this.stopLabelFor(cardState.terminalStatus, cardState.userStopped ?? false)).catch(() => { });
1060
- }
1061
- cardState.creating = false;
1062
- this.cleanupCard(inboundMsgId);
1063
- return;
1064
- }
1065
- if (result.success) {
1066
- cardState.messageId = result.messageId;
1067
- cardState.created = true;
1068
- cardState.lastUpdateAt = Date.now();
1069
- }
1070
- else {
1071
- cardState.cardCreationFailed = true;
1072
- }
1073
- cardState.creating = false;
1074
- })
1075
- .catch((err) => {
1076
- process.stderr.write(`[Feishu:${this.name}] Processing card error: ${err}\n`);
1077
- cardState.creating = false;
1078
- this.cleanupCard(inboundMsgId);
1079
1412
  });
1080
1413
  }
1081
1414
  }
@@ -1096,6 +1429,16 @@ export class FeishuChannel extends ChannelBase {
1096
1429
  }
1097
1430
  else if (cs.created) {
1098
1431
  cs.stopped = true;
1432
+ // Mirror the other final-patch paths: drain the streaming chain so
1433
+ // the terminal patch is the last update Feishu applies.
1434
+ cs.finalizing = true;
1435
+ if (cs.pendingUpdateTimer) {
1436
+ clearTimeout(cs.pendingUpdateTimer);
1437
+ cs.pendingUpdateTimer = undefined;
1438
+ }
1439
+ if (cs.pendingUpdatePromise) {
1440
+ await cs.pendingUpdatePromise;
1441
+ }
1099
1442
  const atPrefix = this.msgToSenderName.get(inboundMsgId) || '';
1100
1443
  const terminalStatus = cs.terminalStatus ?? (cs.cancelling ? 'cancelled' : 'failed');
1101
1444
  // userStopped: a Stop click may lose the race against this wind-down
@@ -1120,9 +1463,10 @@ export class FeishuChannel extends ChannelBase {
1120
1463
  : cs.accumulatedText;
1121
1464
  this.sendMessage(_chatId, fallbackText).catch(() => { });
1122
1465
  }
1123
- else {
1124
- // No accumulated text (e.g. immediate LLM error before first chunk)
1125
- // — send a generic error so the user isn't left without feedback.
1466
+ else if (cs.terminalStatus !== 'completed') {
1467
+ // No accumulated text (e.g. a failure before the first chunk, or a
1468
+ // post-answer failure after the output card was released for a
1469
+ // question). A completed turn with no output ends silently.
1126
1470
  const atPrefix = this.msgToSenderName.get(inboundMsgId) || '';
1127
1471
  const fallbackLabel = cs.terminalStatus
1128
1472
  ? this.statusLabelFor(cs.terminalStatus)
@@ -1289,6 +1633,16 @@ export class FeishuChannel extends ChannelBase {
1289
1633
  // If onResponseComplete is already finalizing the card, don't race with it.
1290
1634
  if (cardState.finalizing)
1291
1635
  return;
1636
+ // Mirror the other final-patch paths: drain the streaming chain so a
1637
+ // slow or reordered streaming PATCH cannot land after the stop patch
1638
+ // and re-render the stopped card as running.
1639
+ if (cardState.pendingUpdateTimer) {
1640
+ clearTimeout(cardState.pendingUpdateTimer);
1641
+ cardState.pendingUpdateTimer = undefined;
1642
+ }
1643
+ if (cardState.pendingUpdatePromise) {
1644
+ await cardState.pendingUpdatePromise;
1645
+ }
1292
1646
  // Only update card if it was actually created (skip if still creating —
1293
1647
  // the createStreamingCard callback will finalize using cardState.atPrefix)
1294
1648
  if (cardState.created && cardState.messageId) {
@@ -1331,6 +1685,7 @@ export class FeishuChannel extends ChannelBase {
1331
1685
  }
1332
1686
  }
1333
1687
  disconnect() {
1688
+ this.questionCardController.dispose();
1334
1689
  if (this.dedupTimer) {
1335
1690
  clearInterval(this.dedupTimer);
1336
1691
  this.dedupTimer = undefined;
@@ -1406,6 +1761,22 @@ export class FeishuChannel extends ChannelBase {
1406
1761
  }
1407
1762
  return result.join('\n');
1408
1763
  }
1764
+ /** Truncate card content to the Feishu card size limit, keeping the tail.
1765
+ * `reservedChars` covers content rendered alongside the text (greeting
1766
+ * prefix, status block) that must fit the same limit. */
1767
+ truncateCardText(text, reservedChars = 0) {
1768
+ const MAX_CARD_CHARS = 20_000;
1769
+ if (text.length + reservedChars <= MAX_CARD_CHARS)
1770
+ return text;
1771
+ const marker = '\n\n_(内容过长,已截断早期内容)_';
1772
+ const fenceReserve = 4; // potential '```\n' prepend for fence rebalancing
1773
+ let truncated = text.slice(-(MAX_CARD_CHARS - marker.length - fenceReserve - reservedChars)) + marker;
1774
+ // Re-balance code fences after truncation
1775
+ if (this.countFences(truncated) % 2 === 1) {
1776
+ truncated = '```\n' + truncated;
1777
+ }
1778
+ return truncated;
1779
+ }
1409
1780
  cleanupCard(inboundMsgId) {
1410
1781
  const cardState = this.cardSessions.get(inboundMsgId);
1411
1782
  if (cardState?.pendingUpdateTimer) {
@@ -1427,6 +1798,33 @@ export class FeishuChannel extends ChannelBase {
1427
1798
  }
1428
1799
  }
1429
1800
  }
1801
+ releaseOutputCard(inboundMsgId) {
1802
+ const cardState = this.cardSessions.get(inboundMsgId);
1803
+ if (!cardState)
1804
+ return;
1805
+ if (cardState.pendingUpdateTimer) {
1806
+ clearTimeout(cardState.pendingUpdateTimer);
1807
+ }
1808
+ if (cardState.creationTimer) {
1809
+ clearTimeout(cardState.creationTimer);
1810
+ }
1811
+ // Keep an inert entry while the question is pending: the orphan sweep and
1812
+ // the terminal-feedback paths both key on card-session presence. Carry any
1813
+ // terminal status onTaskLifecycle wrote during the awaited finalization.
1814
+ this.cardSessions.set(inboundMsgId, {
1815
+ messageId: '',
1816
+ created: false,
1817
+ creating: false,
1818
+ // Carry a settled user stop (not the abandoned-creation marker) so a
1819
+ // late-settled stop cannot flip onPromptEnd into a contradictory
1820
+ // terminal message.
1821
+ stopped: cardState.stopped && !cardState.abandoned,
1822
+ accumulatedText: '',
1823
+ lastUpdateAt: Date.now(),
1824
+ userStopped: cardState.userStopped,
1825
+ terminalStatus: cardState.terminalStatus,
1826
+ });
1827
+ }
1430
1828
  // ----- Message handling -----
1431
1829
  onMessage(data) {
1432
1830
  try {
@@ -1446,6 +1844,9 @@ export class FeishuChannel extends ChannelBase {
1446
1844
  sender.sender_id?.user_id ||
1447
1845
  sender.sender_id?.union_id ||
1448
1846
  '';
1847
+ this.hydrateObservedNames();
1848
+ const senderName = this.observedUserNames.get(senderId) || senderId;
1849
+ const chatName = isGroup ? this.observedChatNames.get(chatId) : undefined;
1449
1850
  // Parse message content
1450
1851
  const content = this.extractContent(msg.message_type, msg.content);
1451
1852
  // Check @mention
@@ -1481,8 +1882,9 @@ export class FeishuChannel extends ChannelBase {
1481
1882
  const envelope = {
1482
1883
  channelName: this.name,
1483
1884
  senderId,
1484
- senderName: senderId,
1885
+ senderName,
1485
1886
  chatId,
1887
+ ...(chatName ? { chatName } : {}),
1486
1888
  text: cleanText,
1487
1889
  messageId: msgId,
1488
1890
  threadId: msg.root_id || undefined,