@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.
- package/dist/FeishuAdapter.d.ts +34 -2
- package/dist/FeishuAdapter.d.ts.map +1 -1
- package/dist/FeishuAdapter.js +563 -161
- package/dist/FeishuAdapter.js.map +1 -1
- package/dist/question-card-controller.d.ts +37 -0
- package/dist/question-card-controller.d.ts.map +1 -0
- package/dist/question-card-controller.js +284 -0
- package/dist/question-card-controller.js.map +1 -0
- package/dist/question-card.d.ts +24 -0
- package/dist/question-card.d.ts.map +1 -0
- package/dist/question-card.js +182 -0
- package/dist/question-card.js.map +1 -0
- package/package.json +2 -2
package/dist/FeishuAdapter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
799
|
+
async sendInteractiveCard(chatId, card) {
|
|
565
800
|
const token = await this.getTenantAccessToken();
|
|
566
801
|
if (!token)
|
|
567
|
-
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
|
584
|
-
method: '
|
|
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(
|
|
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}]
|
|
597
|
-
return
|
|
851
|
+
process.stderr.write(`[Feishu:${this.name}] updateCard failed: HTTP ${resp.status} ${detail}\n`);
|
|
852
|
+
return false;
|
|
598
853
|
}
|
|
599
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
-
|
|
929
|
-
|
|
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(
|
|
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
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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:
|
|
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.
|
|
1125
|
-
//
|
|
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
|
|
1885
|
+
senderName,
|
|
1485
1886
|
chatId,
|
|
1887
|
+
...(chatName ? { chatName } : {}),
|
|
1486
1888
|
text: cleanText,
|
|
1487
1889
|
messageId: msgId,
|
|
1488
1890
|
threadId: msg.root_id || undefined,
|