@xmanrui/dsh-im 4.9.1 → 4.11.0
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/PROACTIVE_DELIVERY.en.md +23 -1
- package/PROACTIVE_DELIVERY.md +23 -1
- package/README.en.md +5 -1
- package/README.md +6 -2
- package/lib/client.js +2077 -673
- package/lib/index.js +277 -272
- package/package.json +13 -1
- package/plugin-src/client/channel-card-meta.js +9 -1
- package/plugin-src/client/channels/dingtalk/api.js +4 -0
- package/plugin-src/client/channels/dingtalk/index.js +25 -2
- package/plugin-src/client/channels/feishu/api.js +6 -0
- package/plugin-src/client/channels/feishu/group-settings.js +581 -0
- package/plugin-src/client/channels/feishu/index.js +26 -150
- package/plugin-src/client/channels/qq/api.js +4 -0
- package/plugin-src/client/channels/qq/index.js +25 -2
- package/plugin-src/client/channels/shared/token-api.js +4 -0
- package/plugin-src/client/channels/shared/token-channel.js +25 -3
- package/plugin-src/client/channels/wecom/api.js +4 -0
- package/plugin-src/client/channels/wecom/index.js +25 -2
- package/plugin-src/client/channels/weixin/api.js +4 -0
- package/plugin-src/client/channels/weixin/index.js +25 -2
- package/plugin-src/client/channels/whatsapp/api.js +4 -0
- package/plugin-src/client/channels/whatsapp/index.js +24 -2
- package/plugin-src/client/delivery-settings.js +73 -5
- package/plugin-src/client/global-settings.js +341 -0
- package/plugin-src/client/i18n.js +49 -0
- package/plugin-src/client/index.js +47 -6
- package/plugin-src/client/model-setting.js +104 -0
- package/plugin-src/client/styles.js +99 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +15 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
- package/plugin-src/host/channels/feishu/production.mjs +17 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +32 -0
- package/plugin-src/host/channels/qq/production.mjs +15 -0
- package/plugin-src/host/channels/qq/rpc.mjs +11 -0
- package/plugin-src/host/channels/shared/model-setting-rpc.mjs +19 -0
- package/plugin-src/host/channels/shared/production.mjs +15 -0
- package/plugin-src/host/channels/shared/rpc.mjs +9 -0
- package/plugin-src/host/channels/shared/workspace-rpc.mjs +2 -0
- package/plugin-src/host/channels/slack/production.mjs +15 -0
- package/plugin-src/host/channels/slack/rpc.mjs +9 -0
- package/plugin-src/host/channels/wecom/production.mjs +15 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
- package/plugin-src/host/channels/weixin/production.mjs +15 -0
- package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +15 -0
- package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
- package/plugin-src/host/delivery-adapter.mjs +142 -3
- package/plugin-src/host/delivery-rpc.mjs +12 -0
- package/plugin-src/host/delivery-service.mjs +125 -3
- package/plugin-src/host/delivery-suggestions.mjs +46 -0
- package/plugin-src/host/harness-session-coordinator.mjs +32 -10
- package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
- package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
- package/plugin-src/host/inbound-ttl-service.mjs +179 -0
- package/plugin-src/host/index.mjs +26 -1
- package/plugin-src/host/modern-harness-api.mjs +58 -2
- package/plugin-src/host/session-sync-coordinator.mjs +217 -0
- package/scripts/verify-package.mjs +12 -6
- package/src/channels/feishu/bridge.mjs +164 -4
- package/src/channels/feishu/feishu-channel.mjs +45 -10
- package/src/channels/feishu/feishu-runtime.mjs +9 -0
- package/src/channels/feishu/message-utils.mjs +13 -0
- package/src/channels/feishu/multi-bot-controller.mjs +16 -0
- package/src/channels/feishu/plugin-config-store.mjs +1 -0
- package/src/channels/feishu/state-store.mjs +25 -0
- package/src/channels/shared/bot-workspace-store.mjs +291 -38
- package/src/channels/shared/harness-client.mjs +124 -43
- package/src/channels/shared/inbound-file.mjs +117 -2
- package/src/channels/shared/inbound-ttl-store.mjs +110 -0
- package/src/channels/shared/inbound-ttl.mjs +22 -0
- package/src/channels/shared/model-setting.mjs +110 -0
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
extractText,
|
|
6
6
|
isAllowedSender,
|
|
7
7
|
isBotSender,
|
|
8
|
+
isTopicGroupKey,
|
|
9
|
+
managedGroupKey,
|
|
8
10
|
splitText,
|
|
9
11
|
} from './message-utils.mjs';
|
|
10
12
|
import {
|
|
@@ -101,6 +103,10 @@ import {
|
|
|
101
103
|
// Lazily evaluated: t() must run after setImHostLanguage, not at import time.
|
|
102
104
|
const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
|
|
103
105
|
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
|
106
|
+
/** Managed-topic root candidates expire without a returned thread_id. */
|
|
107
|
+
const ROOT_CANDIDATE_TTL_MS = 30 * 60_000;
|
|
108
|
+
/** Bound main-feed roots awaiting a topic so silent drops cannot grow them. */
|
|
109
|
+
const MAX_ROOT_CANDIDATES = 512;
|
|
104
110
|
|
|
105
111
|
const MENU_COMMAND = /^\/m(?:enu)?$/i;
|
|
106
112
|
const REPAIR_COMMAND_PREFIX = /^\/repair(?:\s|$)/i;
|
|
@@ -480,6 +486,14 @@ export class FeishuHarnessBridge {
|
|
|
480
486
|
#appId;
|
|
481
487
|
#botOpenId;
|
|
482
488
|
#groupResponseMode;
|
|
489
|
+
/** When true, group replies that belong to a Feishu topic ask reply_in_thread. */
|
|
490
|
+
#groupTopicReply = false;
|
|
491
|
+
/** Group chats this bot has seen; only these may use topic replies. */
|
|
492
|
+
#groupChatIds = new Set();
|
|
493
|
+
/** Anchor message id → whether its replies should stay in a Feishu topic. */
|
|
494
|
+
#anchorTopicReply = new Map();
|
|
495
|
+
/** Main-feed roots this bot intends to auto-open as topics, awaiting thread_id. */
|
|
496
|
+
#rootCandidates = new Map();
|
|
483
497
|
#repair;
|
|
484
498
|
#repairAttempt = null;
|
|
485
499
|
#repairMonitorVersion = 0;
|
|
@@ -516,6 +530,7 @@ export class FeishuHarnessBridge {
|
|
|
516
530
|
appId,
|
|
517
531
|
botOpenId,
|
|
518
532
|
groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
|
|
533
|
+
groupTopicReply = false,
|
|
519
534
|
repair,
|
|
520
535
|
repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
|
|
521
536
|
repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
|
|
@@ -555,6 +570,7 @@ export class FeishuHarnessBridge {
|
|
|
555
570
|
this.#appId = nonEmptyString(appId);
|
|
556
571
|
this.#botOpenId = nonEmptyString(botOpenId);
|
|
557
572
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
|
|
573
|
+
this.#groupTopicReply = groupTopicReply === true;
|
|
558
574
|
this.#repair = repair ?? null;
|
|
559
575
|
this.#repairPollIntervalMs = repairPollIntervalMs;
|
|
560
576
|
this.#repairLinkWaitMs = repairLinkWaitMs;
|
|
@@ -576,6 +592,10 @@ export class FeishuHarnessBridge {
|
|
|
576
592
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
|
|
577
593
|
}
|
|
578
594
|
|
|
595
|
+
setGroupTopicReply(value) {
|
|
596
|
+
this.#groupTopicReply = value === true;
|
|
597
|
+
}
|
|
598
|
+
|
|
579
599
|
#isAddressed(event) {
|
|
580
600
|
if (event?.message?.chat_type === 'p2p') return true;
|
|
581
601
|
const mentions = Array.isArray(event?.message?.mentions) ? event.message.mentions : [];
|
|
@@ -584,6 +604,106 @@ export class FeishuHarnessBridge {
|
|
|
584
604
|
|| mention?.open_id === this.#botOpenId);
|
|
585
605
|
}
|
|
586
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Conversation key for one inbound event. When group-topic replies are on,
|
|
609
|
+
* a main-feed question addressed to the bot opens a fresh managed topic
|
|
610
|
+
* session instead of joining the shared group session, and messages inside
|
|
611
|
+
* a topic we already opened resolve back to that same managed session.
|
|
612
|
+
*/
|
|
613
|
+
#resolveKey(event) {
|
|
614
|
+
const chatType = event?.message?.chat_type;
|
|
615
|
+
const chatId = nonEmptyString(event?.message?.chat_id);
|
|
616
|
+
if (chatType !== 'group') return conversationKey(event);
|
|
617
|
+
const messageId = nonEmptyString(event?.message?.message_id);
|
|
618
|
+
const threadId = nonEmptyString(event?.message?.thread_id);
|
|
619
|
+
if (threadId) {
|
|
620
|
+
const root = this.#state?.topicRootFor?.(threadId) ?? null;
|
|
621
|
+
return root && chatId
|
|
622
|
+
? managedGroupKey(chatId, root.rootMessageId)
|
|
623
|
+
: `group:${chatId}:thread:${threadId}`;
|
|
624
|
+
}
|
|
625
|
+
if (this.#groupTopicReply && chatId && messageId && this.#isAddressed(event)) {
|
|
626
|
+
return managedGroupKey(chatId, messageId);
|
|
627
|
+
}
|
|
628
|
+
return `group:${chatId}`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Record whether replies anchored on `messageId` should stay in a topic.
|
|
633
|
+
* `asRoot` marks a main-feed question this bot will auto-open as a topic.
|
|
634
|
+
*/
|
|
635
|
+
#rememberTopicReply(messageId, key) {
|
|
636
|
+
if (!nonEmptyString(messageId)) return;
|
|
637
|
+
this.#anchorTopicReply.set(messageId, this.#groupTopicReply && isTopicGroupKey(key));
|
|
638
|
+
if (this.#anchorTopicReply.size > 2048) {
|
|
639
|
+
const oldest = this.#anchorTopicReply.keys().next().value;
|
|
640
|
+
if (oldest !== undefined) this.#anchorTopicReply.delete(oldest);
|
|
641
|
+
}
|
|
642
|
+
if (typeof key === 'string' && key.startsWith('group:')) {
|
|
643
|
+
const chatId = key.split(':')[1];
|
|
644
|
+
if (chatId) this.#groupChatIds.add(chatId);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Whether a reply anchored on `replyTo` must ask Feishu to keep it in a topic. */
|
|
649
|
+
#replyInThreadFor(replyTo) {
|
|
650
|
+
return this.#groupTopicReply
|
|
651
|
+
&& nonEmptyString(replyTo)
|
|
652
|
+
&& this.#anchorTopicReply.get(replyTo) === true;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Drop expired root candidates so a silently dropped reply cannot leak memory. */
|
|
656
|
+
#pruneRootCandidates(now = Date.now()) {
|
|
657
|
+
for (const [messageId, expiresAt] of this.#rootCandidates) {
|
|
658
|
+
if (expiresAt <= now) this.#rootCandidates.delete(messageId);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Track a main-feed root this bot will auto-open as a topic, bounded + TTL'd. */
|
|
663
|
+
#rememberTopicRoot(messageId) {
|
|
664
|
+
if (!nonEmptyString(messageId)) return;
|
|
665
|
+
const now = Date.now();
|
|
666
|
+
this.#pruneRootCandidates(now);
|
|
667
|
+
this.#rootCandidates.delete(messageId);
|
|
668
|
+
this.#rootCandidates.set(messageId, now + ROOT_CANDIDATE_TTL_MS);
|
|
669
|
+
while (this.#rootCandidates.size > MAX_ROOT_CANDIDATES) {
|
|
670
|
+
const oldest = this.#rootCandidates.keys().next().value;
|
|
671
|
+
if (oldest === undefined) break;
|
|
672
|
+
this.#rootCandidates.delete(oldest);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Forget a root candidate once its topic opened or the reply degraded. */
|
|
677
|
+
#forgetTopicRoot(messageId) {
|
|
678
|
+
if (!nonEmptyString(messageId)) return;
|
|
679
|
+
this.#rootCandidates.delete(messageId);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Persist thread_id once Feishu auto-opened a topic from a managed root. */
|
|
683
|
+
async #registerTopicFromReply(replyTo, chatId, response) {
|
|
684
|
+
const threadId = nonEmptyString(response?.data?.thread_id);
|
|
685
|
+
if (threadId) {
|
|
686
|
+
await this.#registerTopicThreadId(threadId, replyTo, chatId);
|
|
687
|
+
} else {
|
|
688
|
+
// No thread_id means the topic did not open; drop the candidate so a
|
|
689
|
+
// later addressed question opens a fresh session, not a stale slot.
|
|
690
|
+
this.#forgetTopicRoot(replyTo);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Persist thread_id when the reply anchor is a root this bot auto-opened. */
|
|
695
|
+
async #registerTopicThreadId(threadId, rootMessageId, chatId) {
|
|
696
|
+
this.#pruneRootCandidates();
|
|
697
|
+
if (!threadId || !this.#rootCandidates.has(rootMessageId)) return;
|
|
698
|
+
this.#rootCandidates.delete(rootMessageId);
|
|
699
|
+
if (!chatId || typeof this.#state?.setTopic !== 'function') return;
|
|
700
|
+
try {
|
|
701
|
+
await this.#state.setTopic(threadId, { rootMessageId, chatId });
|
|
702
|
+
} catch (error) {
|
|
703
|
+
this.#logger.warn?.('[dsh-feishu] failed to record managed topic:', error?.message ?? String(error));
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
587
707
|
accept(event) {
|
|
588
708
|
if (this.#signal?.aborted) return Promise.resolve();
|
|
589
709
|
const messageId = nonEmptyString(event?.message?.message_id);
|
|
@@ -606,12 +726,13 @@ export class FeishuHarnessBridge {
|
|
|
606
726
|
|
|
607
727
|
let key;
|
|
608
728
|
try {
|
|
609
|
-
key =
|
|
729
|
+
key = this.#resolveKey(event);
|
|
610
730
|
} catch {
|
|
611
731
|
this.#status.messagesRejected += 1;
|
|
612
732
|
this.#status.lastRejectedAt = new Date().toISOString();
|
|
613
733
|
return Promise.resolve();
|
|
614
734
|
}
|
|
735
|
+
this.#rememberTopicReply(messageId, key);
|
|
615
736
|
|
|
616
737
|
const commandMessage = extractInboundMessage(event, this.#client);
|
|
617
738
|
const commandText = nonEmptyString(commandMessage.content) ?? '';
|
|
@@ -635,6 +756,15 @@ export class FeishuHarnessBridge {
|
|
|
635
756
|
this.#acceptedMessageIds.set(messageId, null);
|
|
636
757
|
return this.#finishAccessDecision(event, messageId, access);
|
|
637
758
|
}
|
|
759
|
+
// Register a managed-topic root only after access is allowed, so a
|
|
760
|
+
// rejected or ignored question never reserves a candidate slot.
|
|
761
|
+
if (this.#groupTopicReply
|
|
762
|
+
&& event?.message?.chat_type === 'group'
|
|
763
|
+
&& !nonEmptyString(event?.message?.thread_id)
|
|
764
|
+
&& isTopicGroupKey(key)
|
|
765
|
+
&& key.includes(':managed:')) {
|
|
766
|
+
this.#rememberTopicRoot(messageId);
|
|
767
|
+
}
|
|
638
768
|
if (event.message.chat_type === 'p2p') {
|
|
639
769
|
const chatId = nonEmptyString(event.message.chat_id);
|
|
640
770
|
if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
|
|
@@ -1910,6 +2040,7 @@ export class FeishuHarnessBridge {
|
|
|
1910
2040
|
}) {
|
|
1911
2041
|
// Confirmations triggered by a card interaction stay anchored to the
|
|
1912
2042
|
// card's message so they land inside the same Feishu topic.
|
|
2043
|
+
this.#rememberTopicReply(messageId, key);
|
|
1913
2044
|
const reply = (text) => this.#send(chatId, text, { replyTo: messageId });
|
|
1914
2045
|
// Approval card buttons: approve:<approvalId> / reject:<approvalId>
|
|
1915
2046
|
if (action.startsWith('approve:') || action.startsWith('reject:')) {
|
|
@@ -2383,18 +2514,25 @@ export class FeishuHarnessBridge {
|
|
|
2383
2514
|
try {
|
|
2384
2515
|
const response = await this.#client.im.v1.message.reply({
|
|
2385
2516
|
path: { message_id: replyTo },
|
|
2386
|
-
data: {
|
|
2517
|
+
data: {
|
|
2518
|
+
msg_type: 'interactive',
|
|
2519
|
+
content,
|
|
2520
|
+
...(this.#replyInThreadFor(replyTo) ? { reply_in_thread: true } : {}),
|
|
2521
|
+
},
|
|
2387
2522
|
});
|
|
2388
2523
|
if (response?.code && response.code !== 0) {
|
|
2389
2524
|
throw new Error(`Feishu card reply failed: ${response.msg || response.code}`);
|
|
2390
2525
|
}
|
|
2391
2526
|
const repliedMessageId = nonEmptyString(response?.data?.message_id);
|
|
2527
|
+
await this.#registerTopicFromReply(replyTo, chatId, response);
|
|
2392
2528
|
if (repliedMessageId) {
|
|
2393
2529
|
this.#rememberCardRoute(repliedMessageId, chatId, options);
|
|
2394
2530
|
return repliedMessageId;
|
|
2395
2531
|
}
|
|
2396
2532
|
} catch (error) {
|
|
2397
2533
|
this.#logger.warn?.('[dsh-feishu] threaded card reply failed; sending a plain card:', error?.message ?? String(error));
|
|
2534
|
+
// The topic never opened; a managed root candidate must not linger.
|
|
2535
|
+
this.#forgetTopicRoot(replyTo);
|
|
2398
2536
|
}
|
|
2399
2537
|
}
|
|
2400
2538
|
const response = await this.#client.im.v1.message.create({
|
|
@@ -3206,6 +3344,10 @@ export class FeishuHarnessBridge {
|
|
|
3206
3344
|
|| (validLastSeq(entry.lastSeq) && entry.lastSeq >= event.seq)
|
|
3207
3345
|
|| (validEventSeq(failedSeq) && event.seq > failedSeq)) continue;
|
|
3208
3346
|
try {
|
|
3347
|
+
// Re-derive topic intent at delivery time: the push may outlive the
|
|
3348
|
+
// bounded in-memory anchor cache, but the conversation key still tells
|
|
3349
|
+
// us whether the completion belongs to a Feishu topic.
|
|
3350
|
+
this.#rememberTopicReply(entry.replyToMessageId ?? null, key);
|
|
3209
3351
|
await this.#sendCard(
|
|
3210
3352
|
entry.chatId,
|
|
3211
3353
|
completionCard(sessionId, entry.title, reason),
|
|
@@ -3346,12 +3488,18 @@ export class FeishuHarnessBridge {
|
|
|
3346
3488
|
? (file) => this.#channel.sendImage(chatId, file, {
|
|
3347
3489
|
replyTo,
|
|
3348
3490
|
signal: this.#signal,
|
|
3491
|
+
...(this.#replyInThreadFor(replyTo)
|
|
3492
|
+
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, replyTo, chatId) }
|
|
3493
|
+
: {}),
|
|
3349
3494
|
})
|
|
3350
3495
|
: undefined,
|
|
3351
3496
|
sendFile: typeof this.#channel?.sendFile === 'function'
|
|
3352
3497
|
? (file) => this.#channel.sendFile(chatId, file, {
|
|
3353
3498
|
replyTo,
|
|
3354
3499
|
signal: this.#signal,
|
|
3500
|
+
...(this.#replyInThreadFor(replyTo)
|
|
3501
|
+
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, replyTo, chatId) }
|
|
3502
|
+
: {}),
|
|
3355
3503
|
})
|
|
3356
3504
|
: undefined,
|
|
3357
3505
|
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
|
|
@@ -3499,7 +3647,12 @@ export class FeishuHarnessBridge {
|
|
|
3499
3647
|
completedArtifacts = completed.artifacts ?? [];
|
|
3500
3648
|
await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
|
|
3501
3649
|
},
|
|
3502
|
-
}, {
|
|
3650
|
+
}, {
|
|
3651
|
+
replyTo: messageId,
|
|
3652
|
+
...(this.#replyInThreadFor(messageId)
|
|
3653
|
+
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicThreadId(threadId, messageId, chatId) }
|
|
3654
|
+
: {}),
|
|
3655
|
+
});
|
|
3503
3656
|
} catch (error) {
|
|
3504
3657
|
this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
|
|
3505
3658
|
if (completedAnswer || completedArtifacts.length > 0) {
|
|
@@ -4007,11 +4160,16 @@ export class FeishuHarnessBridge {
|
|
|
4007
4160
|
try {
|
|
4008
4161
|
const response = await this.#client.im.v1.message.reply({
|
|
4009
4162
|
path: { message_id: replyTo },
|
|
4010
|
-
data: {
|
|
4163
|
+
data: {
|
|
4164
|
+
msg_type: 'text',
|
|
4165
|
+
content,
|
|
4166
|
+
...(this.#replyInThreadFor(replyTo) ? { reply_in_thread: true } : {}),
|
|
4167
|
+
},
|
|
4011
4168
|
});
|
|
4012
4169
|
if (response?.code && response.code !== 0) {
|
|
4013
4170
|
throw new Error(`Feishu reply failed: ${response.msg || response.code}`);
|
|
4014
4171
|
}
|
|
4172
|
+
await this.#registerTopicFromReply(replyTo, chatId, response);
|
|
4015
4173
|
return nonEmptyString(response?.data?.message_id);
|
|
4016
4174
|
} catch (error) {
|
|
4017
4175
|
// The referenced message may be gone (recalled/deleted); keep the
|
|
@@ -4020,6 +4178,8 @@ export class FeishuHarnessBridge {
|
|
|
4020
4178
|
'[dsh-feishu] threaded reply failed; falling back to a plain message:',
|
|
4021
4179
|
error?.message ?? String(error),
|
|
4022
4180
|
);
|
|
4181
|
+
// The topic never opened; a managed root candidate must not linger.
|
|
4182
|
+
this.#forgetTopicRoot(replyTo);
|
|
4023
4183
|
}
|
|
4024
4184
|
}
|
|
4025
4185
|
const response = await this.#client.im.v1.message.create({
|
|
@@ -208,7 +208,7 @@ export class VerifiedFeishuChannel {
|
|
|
208
208
|
let activeCard = null;
|
|
209
209
|
let rotating = false;
|
|
210
210
|
try {
|
|
211
|
-
activeCard = await this.#createStreamCard(chatId, options
|
|
211
|
+
activeCard = await this.#createStreamCard(chatId, options);
|
|
212
212
|
cards.push(activeCard);
|
|
213
213
|
let lastContent = this.#initialText;
|
|
214
214
|
// issue #86:独立交互消息(提问/审批)落在占位卡下方后,最终答案不得
|
|
@@ -217,7 +217,7 @@ export class VerifiedFeishuChannel {
|
|
|
217
217
|
// 交互消息之后。旧卡纳入 cards,参与 recall 与 providerMessageIds。
|
|
218
218
|
const ensureActiveCard = async () => {
|
|
219
219
|
if (!rotating) return activeCard;
|
|
220
|
-
activeCard = await this.#createStreamCard(chatId, options
|
|
220
|
+
activeCard = await this.#createStreamCard(chatId, options);
|
|
221
221
|
cards.push(activeCard);
|
|
222
222
|
rotating = false;
|
|
223
223
|
return activeCard;
|
|
@@ -255,7 +255,7 @@ export class VerifiedFeishuChannel {
|
|
|
255
255
|
for (const [index, chunk] of chunks.entries()) {
|
|
256
256
|
const card = index === 0
|
|
257
257
|
? await ensureActiveCard()
|
|
258
|
-
: await this.#createStreamCard(chatId, options
|
|
258
|
+
: await this.#createStreamCard(chatId, options);
|
|
259
259
|
if (index > 0) cards.push(card);
|
|
260
260
|
await this.#updateStreamCard(card, chunk);
|
|
261
261
|
await this.#finishStreamCard(card);
|
|
@@ -272,19 +272,33 @@ export class VerifiedFeishuChannel {
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
async sendFile(chatId, file, {
|
|
275
|
+
async sendFile(chatId, file, {
|
|
276
|
+
replyTo,
|
|
277
|
+
signal,
|
|
278
|
+
replyInThread = false,
|
|
279
|
+
onReplyThreadId,
|
|
280
|
+
} = {}) {
|
|
276
281
|
return this.#sendArtifact(chatId, file, {
|
|
277
282
|
replyTo,
|
|
278
283
|
signal,
|
|
284
|
+
replyInThread,
|
|
285
|
+
onReplyThreadId,
|
|
279
286
|
messageType: 'file',
|
|
280
287
|
presentation: 'feishu-file',
|
|
281
288
|
});
|
|
282
289
|
}
|
|
283
290
|
|
|
284
|
-
async sendImage(chatId, file, {
|
|
291
|
+
async sendImage(chatId, file, {
|
|
292
|
+
replyTo,
|
|
293
|
+
signal,
|
|
294
|
+
replyInThread = false,
|
|
295
|
+
onReplyThreadId,
|
|
296
|
+
} = {}) {
|
|
285
297
|
return this.#sendArtifact(chatId, file, {
|
|
286
298
|
replyTo,
|
|
287
299
|
signal,
|
|
300
|
+
replyInThread,
|
|
301
|
+
onReplyThreadId,
|
|
288
302
|
messageType: 'image',
|
|
289
303
|
presentation: 'feishu-image',
|
|
290
304
|
});
|
|
@@ -293,6 +307,8 @@ export class VerifiedFeishuChannel {
|
|
|
293
307
|
async #sendArtifact(chatId, file, {
|
|
294
308
|
replyTo,
|
|
295
309
|
signal,
|
|
310
|
+
replyInThread = false,
|
|
311
|
+
onReplyThreadId,
|
|
296
312
|
messageType,
|
|
297
313
|
presentation,
|
|
298
314
|
}) {
|
|
@@ -349,7 +365,12 @@ export class VerifiedFeishuChannel {
|
|
|
349
365
|
const request = replyTo
|
|
350
366
|
? {
|
|
351
367
|
path: { message_id: replyTo },
|
|
352
|
-
data: {
|
|
368
|
+
data: {
|
|
369
|
+
msg_type: messageType,
|
|
370
|
+
content,
|
|
371
|
+
uuid,
|
|
372
|
+
...(replyInThread === true ? { reply_in_thread: true } : {}),
|
|
373
|
+
},
|
|
353
374
|
}
|
|
354
375
|
: {
|
|
355
376
|
params: { receive_id_type: 'chat_id' },
|
|
@@ -399,6 +420,10 @@ export class VerifiedFeishuChannel {
|
|
|
399
420
|
if (typeof messageId !== 'string' || !messageId) {
|
|
400
421
|
throw fileDeliveryError('message send', undefined, undefined, { uncertain: true });
|
|
401
422
|
}
|
|
423
|
+
if (replyTo && typeof onReplyThreadId === 'function') {
|
|
424
|
+
const threadId = response?.data?.thread_id;
|
|
425
|
+
if (typeof threadId === 'string' && threadId) await onReplyThreadId(threadId);
|
|
426
|
+
}
|
|
402
427
|
return createDeliveryReceipt({
|
|
403
428
|
deliveryId: file.deliveryKey,
|
|
404
429
|
presentation,
|
|
@@ -410,7 +435,7 @@ export class VerifiedFeishuChannel {
|
|
|
410
435
|
});
|
|
411
436
|
}
|
|
412
437
|
|
|
413
|
-
async #createStreamCard(chatId,
|
|
438
|
+
async #createStreamCard(chatId, options = {}) {
|
|
414
439
|
const content = streamPreview(this.#initialText);
|
|
415
440
|
const response = assertApiSuccess('Feishu card.create', await this.#client.cardkit.v1.card.create({
|
|
416
441
|
data: {
|
|
@@ -420,7 +445,7 @@ export class VerifiedFeishuChannel {
|
|
|
420
445
|
}));
|
|
421
446
|
const cardId = response?.data?.card_id;
|
|
422
447
|
if (!cardId) throw new Error('Feishu card.create returned no card_id');
|
|
423
|
-
const messageId = await this.#sendCard(chatId, cardId,
|
|
448
|
+
const messageId = await this.#sendCard(chatId, cardId, options);
|
|
424
449
|
return { cardId, messageId, content, sequence: 0 };
|
|
425
450
|
}
|
|
426
451
|
|
|
@@ -455,12 +480,18 @@ export class VerifiedFeishuChannel {
|
|
|
455
480
|
assertApiSuccess('Feishu card.settings', response);
|
|
456
481
|
}
|
|
457
482
|
|
|
458
|
-
async #sendCard(chatId, cardId,
|
|
483
|
+
async #sendCard(chatId, cardId, options = {}) {
|
|
484
|
+
const replyTo = options.replyTo;
|
|
485
|
+
const replyInThread = options.replyInThread === true;
|
|
459
486
|
const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
|
|
460
487
|
const response = replyTo
|
|
461
488
|
? await this.#client.im.v1.message.reply({
|
|
462
489
|
path: { message_id: replyTo },
|
|
463
|
-
data: {
|
|
490
|
+
data: {
|
|
491
|
+
msg_type: 'interactive',
|
|
492
|
+
content,
|
|
493
|
+
...(replyInThread ? { reply_in_thread: true } : {}),
|
|
494
|
+
},
|
|
464
495
|
})
|
|
465
496
|
: await this.#client.im.v1.message.create({
|
|
466
497
|
params: { receive_id_type: 'chat_id' },
|
|
@@ -469,6 +500,10 @@ export class VerifiedFeishuChannel {
|
|
|
469
500
|
assertApiSuccess('Feishu message send', response);
|
|
470
501
|
const messageId = response?.data?.message_id;
|
|
471
502
|
if (!messageId) throw new Error('Feishu message send returned no message_id');
|
|
503
|
+
if (replyTo && typeof options.onReplyThreadId === 'function') {
|
|
504
|
+
const threadId = response?.data?.thread_id;
|
|
505
|
+
if (typeof threadId === 'string' && threadId) await options.onReplyThreadId(threadId);
|
|
506
|
+
}
|
|
472
507
|
return messageId;
|
|
473
508
|
}
|
|
474
509
|
|
|
@@ -110,6 +110,7 @@ export class FeishuRuntime {
|
|
|
110
110
|
#domain;
|
|
111
111
|
#botOpenId;
|
|
112
112
|
#groupResponseMode;
|
|
113
|
+
#groupTopicReply;
|
|
113
114
|
#ownerOpenIds;
|
|
114
115
|
#harness;
|
|
115
116
|
#state;
|
|
@@ -139,6 +140,7 @@ export class FeishuRuntime {
|
|
|
139
140
|
domain = 'feishu',
|
|
140
141
|
botOpenId,
|
|
141
142
|
groupResponseMode,
|
|
143
|
+
groupTopicReply = false,
|
|
142
144
|
ownerOpenId,
|
|
143
145
|
ownerOpenIds,
|
|
144
146
|
harness,
|
|
@@ -174,6 +176,7 @@ export class FeishuRuntime {
|
|
|
174
176
|
this.#domain = domain;
|
|
175
177
|
this.#botOpenId = nonEmptyString(botOpenId);
|
|
176
178
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
|
|
179
|
+
this.#groupTopicReply = groupTopicReply === true;
|
|
177
180
|
this.#ownerOpenIds = normalizedOwners;
|
|
178
181
|
this.#harness = harness;
|
|
179
182
|
this.#state = state;
|
|
@@ -198,6 +201,11 @@ export class FeishuRuntime {
|
|
|
198
201
|
this.#bridge?.setGroupResponseMode(this.#groupResponseMode);
|
|
199
202
|
}
|
|
200
203
|
|
|
204
|
+
setGroupTopicReply(value) {
|
|
205
|
+
this.#groupTopicReply = value === true;
|
|
206
|
+
this.#bridge?.setGroupTopicReply(this.#groupTopicReply);
|
|
207
|
+
}
|
|
208
|
+
|
|
201
209
|
async start() {
|
|
202
210
|
while (true) {
|
|
203
211
|
while (this.#stopping) await this.#stopping;
|
|
@@ -286,6 +294,7 @@ export class FeishuRuntime {
|
|
|
286
294
|
appId: this.#appId,
|
|
287
295
|
botOpenId: this.#botOpenId,
|
|
288
296
|
groupResponseMode: this.#groupResponseMode,
|
|
297
|
+
groupTopicReply: this.#groupTopicReply,
|
|
289
298
|
repair: this.#repair,
|
|
290
299
|
replyTimeoutMs: this.#replyTimeoutMs,
|
|
291
300
|
// Interaction cards (approval/question buttons) are on by default.
|
|
@@ -13,6 +13,19 @@ const FEISHU_CARD_UNAVAILABLE_TEXTS = new Set([
|
|
|
13
13
|
const FEISHU_IMAGE_PERMISSION_MESSAGE =
|
|
14
14
|
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
|
|
15
15
|
|
|
16
|
+
/** Managed-topic group key: one per Feishu topic auto-rooted at a message. */
|
|
17
|
+
export function managedGroupKey(chatId, rootMessageId) {
|
|
18
|
+
if (!chatId) throw new Error('Feishu managed-topic key needs a chat_id');
|
|
19
|
+
if (!rootMessageId) throw new Error('Feishu managed-topic key needs a root message id');
|
|
20
|
+
return `group:${chatId}:managed:${rootMessageId}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether a key denotes a topic-isolated group conversation (thread or managed). */
|
|
24
|
+
export function isTopicGroupKey(key) {
|
|
25
|
+
return typeof key === 'string' && key.startsWith('group:')
|
|
26
|
+
&& (key.includes(':thread:') || key.includes(':managed:'));
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
export function conversationKey(event) {
|
|
17
30
|
const chatType = event?.message?.chat_type;
|
|
18
31
|
if (chatType === 'p2p') {
|
|
@@ -90,6 +90,7 @@ function configuredBotFingerprint(config) {
|
|
|
90
90
|
botOpenId: config.botOpenId,
|
|
91
91
|
activated: config.activated,
|
|
92
92
|
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
|
93
|
+
groupTopicReply: config.groupTopicReply === true,
|
|
93
94
|
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
|
94
95
|
deletionPending: config.deletionPending === true,
|
|
95
96
|
connectedAt: config.connectedAt ?? null,
|
|
@@ -584,6 +585,20 @@ export class MultiBotDshFeishuController {
|
|
|
584
585
|
}));
|
|
585
586
|
}
|
|
586
587
|
|
|
588
|
+
async updateGroupTopicReply(botId, groupTopicReply) {
|
|
589
|
+
this.#assertOpen();
|
|
590
|
+
if (typeof groupTopicReply !== 'boolean') {
|
|
591
|
+
throw new TypeError('Invalid Feishu group topic reply flag');
|
|
592
|
+
}
|
|
593
|
+
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
|
594
|
+
const config = this.#requireBot(botId);
|
|
595
|
+
const saved = await this.#configStore.saveBot({ ...config, groupTopicReply });
|
|
596
|
+
this.#runtimes.get(botId)?.setGroupTopicReply?.(saved.groupTopicReply);
|
|
597
|
+
this.#touch();
|
|
598
|
+
return this.status(botId);
|
|
599
|
+
}));
|
|
600
|
+
}
|
|
601
|
+
|
|
587
602
|
async deleteBot(botId) {
|
|
588
603
|
this.#assertOpen();
|
|
589
604
|
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
|
@@ -646,6 +661,7 @@ export class MultiBotDshFeishuController {
|
|
|
646
661
|
connected,
|
|
647
662
|
configured: true,
|
|
648
663
|
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
|
664
|
+
groupTopicReply: config.groupTopicReply === true,
|
|
649
665
|
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
|
650
666
|
bot: publicBot(config),
|
|
651
667
|
connection,
|
|
@@ -45,6 +45,7 @@ function normalizeBot(value, { legacy = false } = {}) {
|
|
|
45
45
|
botOpenId: cleanString(value.botOpenId),
|
|
46
46
|
activated: value.activated ?? null,
|
|
47
47
|
groupResponseMode: normalizeFeishuGroupResponseMode(value.groupResponseMode),
|
|
48
|
+
groupTopicReply: value.groupTopicReply === true,
|
|
48
49
|
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
|
|
49
50
|
deletionPending: value.deletionPending === true,
|
|
50
51
|
connectedAt: cleanString(value.connectedAt),
|
|
@@ -7,6 +7,7 @@ const EMPTY_STATE = Object.freeze({
|
|
|
7
7
|
seenMessageIds: [],
|
|
8
8
|
watches: {},
|
|
9
9
|
includeArchivedSessions: false,
|
|
10
|
+
topics: {},
|
|
10
11
|
});
|
|
11
12
|
|
|
12
13
|
/** One conversation key may watch at most this many sessions. */
|
|
@@ -40,6 +41,7 @@ export class StateStore {
|
|
|
40
41
|
includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean'
|
|
41
42
|
? parsed.includeArchivedSessions
|
|
42
43
|
: false,
|
|
44
|
+
topics: parsed.topics && typeof parsed.topics === 'object' ? parsed.topics : {},
|
|
43
45
|
};
|
|
44
46
|
} catch (error) {
|
|
45
47
|
if (error?.code !== 'ENOENT') throw error;
|
|
@@ -136,6 +138,29 @@ export class StateStore {
|
|
|
136
138
|
return [...ids];
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
// ── Managed Feishu topics (thread_id → root message, persisted) ────────
|
|
142
|
+
|
|
143
|
+
topicRootFor(threadId) {
|
|
144
|
+
const entry = this.#state.topics[threadId];
|
|
145
|
+
return entry && typeof entry === 'object'
|
|
146
|
+
&& typeof entry.rootMessageId === 'string' && entry.rootMessageId.length > 0
|
|
147
|
+
&& typeof entry.chatId === 'string' && entry.chatId.length > 0
|
|
148
|
+
? { rootMessageId: entry.rootMessageId, chatId: entry.chatId }
|
|
149
|
+
: null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async setTopic(threadId, root) {
|
|
153
|
+
const validRoot = root && typeof root === 'object'
|
|
154
|
+
&& typeof root.rootMessageId === 'string' && root.rootMessageId.length > 0
|
|
155
|
+
&& typeof root.chatId === 'string' && root.chatId.length > 0;
|
|
156
|
+
if (!validRoot) throw new TypeError('Invalid Feishu topic root');
|
|
157
|
+
this.#state.topics[threadId] = {
|
|
158
|
+
rootMessageId: root.rootMessageId,
|
|
159
|
+
chatId: root.chatId,
|
|
160
|
+
};
|
|
161
|
+
await this.#persist();
|
|
162
|
+
}
|
|
163
|
+
|
|
139
164
|
// ── Session-list archived policy (per bot) ───────────────────
|
|
140
165
|
|
|
141
166
|
includesArchivedSessions() {
|