@xmanrui/dsh-im 4.29.0 → 4.30.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/README.en.md +3 -1
- package/README.md +3 -1
- package/assets/logo-plugin-message-link-gradient.webp +0 -0
- package/lib/client.js +558 -136
- package/lib/index.js +291 -291
- package/package.json +2 -1
- package/plugin-src/client/channels/feishu/api.js +5 -2
- package/plugin-src/client/channels/feishu/group-settings.js +14 -14
- package/plugin-src/client/channels/feishu/index.js +2 -1
- package/plugin-src/client/channels/feishu/slash-panel-settings.js +322 -0
- package/plugin-src/client/delivery-settings.js +16 -0
- package/plugin-src/client/i18n.js +34 -5
- package/plugin-src/client/styles.js +9 -0
- package/plugin-src/host/channels/feishu/production.mjs +2 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +28 -8
- package/src/channels/feishu/bridge.mjs +133 -41
- package/src/channels/feishu/feishu-channel.mjs +9 -4
- package/src/channels/feishu/feishu-runtime.mjs +86 -15
- package/src/channels/feishu/message-utils.mjs +48 -12
- package/src/channels/feishu/multi-bot-controller.mjs +29 -7
- package/src/channels/feishu/plugin-config-store.mjs +3 -1
- package/src/channels/feishu/slash-command-panel.mjs +155 -0
- package/src/channels/feishu/slash-command-registry.mjs +257 -41
|
@@ -31,6 +31,10 @@ import {
|
|
|
31
31
|
normalizeFeishuStepPushMode,
|
|
32
32
|
} from '../../../../src/channels/feishu/step-push-mode.mjs';
|
|
33
33
|
import { normalizeFeishuVoiceConfig } from '../../../../src/channels/feishu/voice-config.mjs';
|
|
34
|
+
import {
|
|
35
|
+
isSlashPanelConfig,
|
|
36
|
+
normalizeSlashPanelConfig,
|
|
37
|
+
} from '../../../../src/channels/feishu/slash-command-panel.mjs';
|
|
34
38
|
import {
|
|
35
39
|
FEISHU_ENDPOINTS as FEISHU_CLIENT_ENDPOINTS,
|
|
36
40
|
FEISHU_RPC_CHANNEL,
|
|
@@ -305,10 +309,11 @@ function publicBotEntry(entry) {
|
|
|
305
309
|
contextEnhancement: normalizeContextEnhancementConfig(source.contextEnhancement),
|
|
306
310
|
accessPolicy: normalizeAccessPolicy(source.accessPolicy),
|
|
307
311
|
groupResponseMode: normalizeFeishuGroupResponseMode(source.groupResponseMode),
|
|
308
|
-
|
|
312
|
+
mentionTopicReply: source.mentionTopicReply !== false,
|
|
309
313
|
stepPush: source.stepPush === true,
|
|
310
314
|
stepPushMode: normalizeFeishuStepPushMode(source.stepPushMode),
|
|
311
315
|
voice: normalizeFeishuVoiceConfig(source.voice),
|
|
316
|
+
slashPanel: normalizeSlashPanelConfig(source.slashPanel),
|
|
312
317
|
groupMessagePermissionGranted: source.groupMessagePermissionGranted === true,
|
|
313
318
|
bot: publicBot(source.bot),
|
|
314
319
|
health: publicHealth(source, connected),
|
|
@@ -471,10 +476,10 @@ function validPayload(endpoint, payload) {
|
|
|
471
476
|
? null
|
|
472
477
|
: '请选择群聊响应方式。';
|
|
473
478
|
}
|
|
474
|
-
if (endpoint === FEISHU_ENDPOINTS.
|
|
475
|
-
return hasOnlyKeys(payload, new Set(['botId', '
|
|
479
|
+
if (endpoint === FEISHU_ENDPOINTS.setMentionTopicReply) {
|
|
480
|
+
return hasOnlyKeys(payload, new Set(['botId', 'mentionTopicReply']))
|
|
476
481
|
&& safeOpaqueId(payload.botId)
|
|
477
|
-
&& typeof payload.
|
|
482
|
+
&& typeof payload.mentionTopicReply === 'boolean'
|
|
478
483
|
? null
|
|
479
484
|
: '请选择是否以话题方式回复。';
|
|
480
485
|
}
|
|
@@ -503,6 +508,13 @@ function validPayload(endpoint, payload) {
|
|
|
503
508
|
? null
|
|
504
509
|
: '语音配置无效。';
|
|
505
510
|
}
|
|
511
|
+
if (endpoint === FEISHU_ENDPOINTS.setSlashPanel) {
|
|
512
|
+
return hasOnlyKeys(payload, new Set(['botId', 'slashPanel']))
|
|
513
|
+
&& safeOpaqueId(payload.botId)
|
|
514
|
+
&& isSlashPanelConfig(payload.slashPanel)
|
|
515
|
+
? null
|
|
516
|
+
: '请选择面板要显示的指令与顺序。';
|
|
517
|
+
}
|
|
506
518
|
return 'Unknown Feishu endpoint.';
|
|
507
519
|
}
|
|
508
520
|
|
|
@@ -786,12 +798,12 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
|
|
786
798
|
await controller.updateGroupResponseMode(payload.botId, payload.groupResponseMode),
|
|
787
799
|
{ encodeQr: cachedEncodeQr },
|
|
788
800
|
);
|
|
789
|
-
} else if (endpoint === FEISHU_ENDPOINTS.
|
|
790
|
-
if (typeof controller.
|
|
791
|
-
throw new Error('
|
|
801
|
+
} else if (endpoint === FEISHU_ENDPOINTS.setMentionTopicReply) {
|
|
802
|
+
if (typeof controller.updateMentionTopicReply !== 'function') {
|
|
803
|
+
throw new Error('Mention topic reply update is unavailable');
|
|
792
804
|
}
|
|
793
805
|
value = await toPublicFeishuStatus(
|
|
794
|
-
await controller.
|
|
806
|
+
await controller.updateMentionTopicReply(payload.botId, payload.mentionTopicReply),
|
|
795
807
|
{ encodeQr: cachedEncodeQr },
|
|
796
808
|
);
|
|
797
809
|
} else if (endpoint === FEISHU_ENDPOINTS.setStepPush) {
|
|
@@ -818,6 +830,14 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
|
|
818
830
|
await controller.updateVoice(payload.botId, payload.voice),
|
|
819
831
|
{ encodeQr: cachedEncodeQr },
|
|
820
832
|
);
|
|
833
|
+
} else if (endpoint === FEISHU_ENDPOINTS.setSlashPanel) {
|
|
834
|
+
if (typeof controller.updateSlashPanel !== 'function') {
|
|
835
|
+
throw new Error('Slash panel update is unavailable');
|
|
836
|
+
}
|
|
837
|
+
value = await toPublicFeishuStatus(
|
|
838
|
+
await controller.updateSlashPanel(payload.botId, payload.slashPanel),
|
|
839
|
+
{ encodeQr: cachedEncodeQr },
|
|
840
|
+
);
|
|
821
841
|
} else {
|
|
822
842
|
if (typeof controller.deleteBot !== 'function') throw new Error('Multi-bot delete is unavailable');
|
|
823
843
|
value = await toPublicFeishuStatus(await controller.deleteBot(payload.botId), { encodeQr: cachedEncodeQr });
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import QRCode from 'qrcode';
|
|
2
2
|
import {
|
|
3
3
|
conversationKey,
|
|
4
|
+
conversationScope,
|
|
4
5
|
extractInboundMessage,
|
|
5
6
|
extractText,
|
|
6
7
|
isAllowedSender,
|
|
7
8
|
isBotSender,
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
isTopicKey,
|
|
10
|
+
managedTopicKey,
|
|
11
|
+
managedTopicRoot,
|
|
10
12
|
splitText,
|
|
11
13
|
} from './message-utils.mjs';
|
|
12
14
|
import {
|
|
@@ -331,6 +333,26 @@ function isBareMentionMenuRequest(event, text, {
|
|
|
331
333
|
return !String(text ?? '').trim();
|
|
332
334
|
}
|
|
333
335
|
|
|
336
|
+
/**
|
|
337
|
+
* A message that is nothing but a mention of the bot, with no body of its own.
|
|
338
|
+
* There is no question to answer — a direct chat opens the menu card for it and
|
|
339
|
+
* a group gets the "text, image and file only" notice — so it must not open a
|
|
340
|
+
* topic either: an empty topic would leave an empty session behind, and every
|
|
341
|
+
* look at the menu would create another one.
|
|
342
|
+
*
|
|
343
|
+
* Mirrors the reply-reference test (`feishuReplyTargetId`): an explicit quote
|
|
344
|
+
* keeps the message a real question even when the mention is all the text it
|
|
345
|
+
* carries.
|
|
346
|
+
*/
|
|
347
|
+
function isBareMentionMessage(event) {
|
|
348
|
+
if (event?.message?.message_type !== 'text') return false;
|
|
349
|
+
const messageId = nonEmptyString(event?.message?.message_id);
|
|
350
|
+
const parentId = nonEmptyString(event?.message?.parent_id);
|
|
351
|
+
const rootId = nonEmptyString(event?.message?.root_id);
|
|
352
|
+
if (parentId || (rootId && rootId !== messageId)) return false;
|
|
353
|
+
return !String(extractText(event) ?? '').trim();
|
|
354
|
+
}
|
|
355
|
+
|
|
334
356
|
/** Canonical workspace/session help advertised by every bridge family. */
|
|
335
357
|
const WORKSPACE_HELP_LINES = [
|
|
336
358
|
'/workspace 工作区序号或绝对路径 切换工作区',
|
|
@@ -639,8 +661,13 @@ export class FeishuHarnessBridge {
|
|
|
639
661
|
#appId;
|
|
640
662
|
#botOpenId;
|
|
641
663
|
#groupResponseMode;
|
|
642
|
-
/**
|
|
643
|
-
|
|
664
|
+
/**
|
|
665
|
+
* When true (the default), a question addressed to the bot opens a Feishu
|
|
666
|
+
* topic of its own — in groups and in direct chats alike — and the answer
|
|
667
|
+
* stays inside it. Turning it off keeps every question in the chat's main
|
|
668
|
+
* session and answers it in the main feed.
|
|
669
|
+
*/
|
|
670
|
+
#mentionTopicReply = true;
|
|
644
671
|
/** When true, streaming turns push tool calls and interim notes as discrete messages. */
|
|
645
672
|
#stepPush = false;
|
|
646
673
|
/** Step push presentation: discrete posts, a CardKit card, or native live CoT. */
|
|
@@ -706,7 +733,7 @@ export class FeishuHarnessBridge {
|
|
|
706
733
|
appId,
|
|
707
734
|
botOpenId,
|
|
708
735
|
groupResponseMode = FEISHU_GROUP_RESPONSE_MODES.ALL,
|
|
709
|
-
|
|
736
|
+
mentionTopicReply = true,
|
|
710
737
|
stepPush = false,
|
|
711
738
|
stepPushMode = FEISHU_STEP_PUSH_MODES.POST,
|
|
712
739
|
stepPushClock = null,
|
|
@@ -764,7 +791,7 @@ export class FeishuHarnessBridge {
|
|
|
764
791
|
this.#appId = nonEmptyString(appId);
|
|
765
792
|
this.#botOpenId = nonEmptyString(botOpenId);
|
|
766
793
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
|
|
767
|
-
this.#
|
|
794
|
+
this.#mentionTopicReply = mentionTopicReply !== false;
|
|
768
795
|
this.#stepPush = stepPush === true;
|
|
769
796
|
this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
|
|
770
797
|
this.#stepPushClock = stepPushClock ?? DEFAULT_STEP_PUSH_CLOCK;
|
|
@@ -850,8 +877,8 @@ export class FeishuHarnessBridge {
|
|
|
850
877
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
|
|
851
878
|
}
|
|
852
879
|
|
|
853
|
-
|
|
854
|
-
this.#
|
|
880
|
+
setMentionTopicReply(value) {
|
|
881
|
+
this.#mentionTopicReply = value !== false;
|
|
855
882
|
}
|
|
856
883
|
|
|
857
884
|
setStepPush(value) {
|
|
@@ -872,6 +899,16 @@ export class FeishuHarnessBridge {
|
|
|
872
899
|
|
|
873
900
|
#isAddressed(event) {
|
|
874
901
|
if (event?.message?.chat_type === 'p2p') return true;
|
|
902
|
+
return this.#isMentioned(event);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Whether this message mentions the bot. Unlike #isAddressed — which answers
|
|
907
|
+
* "does this chat expect a reply at all" and is therefore always true in a
|
|
908
|
+
* direct chat — this asks the literal question, so a topic is only opened for
|
|
909
|
+
* a message the reader actually addressed to the bot.
|
|
910
|
+
*/
|
|
911
|
+
#isMentioned(event) {
|
|
875
912
|
const mentions = Array.isArray(event?.message?.mentions) ? event.message.mentions : [];
|
|
876
913
|
if (!this.#botOpenId) return mentions.length > 0;
|
|
877
914
|
return mentions.some((mention) => mention?.id?.open_id === this.#botOpenId
|
|
@@ -879,27 +916,31 @@ export class FeishuHarnessBridge {
|
|
|
879
916
|
}
|
|
880
917
|
|
|
881
918
|
/**
|
|
882
|
-
* Conversation key for one inbound event
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
919
|
+
* Conversation key for one inbound event, for groups and direct chats alike.
|
|
920
|
+
*
|
|
921
|
+
* - A message inside a topic resolves back to the managed key when the bot
|
|
922
|
+
* rooted that topic, so the whole topic keeps one session; every other
|
|
923
|
+
* thread keeps the key the channel would have used without managed topics.
|
|
924
|
+
* - A main-feed message that mentions the bot opens a fresh managed topic
|
|
925
|
+
* session instead of joining the chat's shared session. A bare "@bot" is
|
|
926
|
+
* excluded: it is a menu request, not a question.
|
|
927
|
+
* - Everything else stays in the chat's shared session.
|
|
886
928
|
*/
|
|
887
929
|
#resolveKey(event) {
|
|
888
|
-
const chatType = event?.message?.chat_type;
|
|
889
|
-
const chatId = nonEmptyString(event?.message?.chat_id);
|
|
890
|
-
if (chatType !== 'group') return conversationKey(event);
|
|
891
930
|
const messageId = nonEmptyString(event?.message?.message_id);
|
|
892
931
|
const threadId = nonEmptyString(event?.message?.thread_id);
|
|
893
932
|
if (threadId) {
|
|
894
933
|
const root = this.#state?.topicRootFor?.(threadId) ?? null;
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
: `group:${chatId}:thread:${threadId}`;
|
|
934
|
+
if (root) return managedTopicKey(conversationScope(event), root.rootMessageId);
|
|
935
|
+
return conversationKey(event);
|
|
898
936
|
}
|
|
899
|
-
if (this.#
|
|
900
|
-
|
|
937
|
+
if (this.#mentionTopicReply
|
|
938
|
+
&& messageId
|
|
939
|
+
&& this.#isMentioned(event)
|
|
940
|
+
&& !isBareMentionMessage(event)) {
|
|
941
|
+
return managedTopicKey(conversationScope(event), messageId);
|
|
901
942
|
}
|
|
902
|
-
return
|
|
943
|
+
return conversationKey(event);
|
|
903
944
|
}
|
|
904
945
|
|
|
905
946
|
/**
|
|
@@ -911,7 +952,7 @@ export class FeishuHarnessBridge {
|
|
|
911
952
|
*/
|
|
912
953
|
#rememberTopicReply(messageId, key) {
|
|
913
954
|
if (!nonEmptyString(messageId)) return;
|
|
914
|
-
this.#anchorTopicReply.set(messageId,
|
|
955
|
+
this.#anchorTopicReply.set(messageId, isTopicKey(key));
|
|
915
956
|
if (this.#anchorTopicReply.size > 2048) {
|
|
916
957
|
const oldest = this.#anchorTopicReply.keys().next().value;
|
|
917
958
|
if (oldest !== undefined) this.#anchorTopicReply.delete(oldest);
|
|
@@ -960,13 +1001,62 @@ export class FeishuHarnessBridge {
|
|
|
960
1001
|
|
|
961
1002
|
/** Persist thread_id once Feishu auto-opened a topic from a managed root. */
|
|
962
1003
|
async #registerTopicFromReply(replyTo, chatId, response) {
|
|
963
|
-
|
|
1004
|
+
await this.#registerTopicReply(replyTo, chatId, response?.data?.thread_id);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Record the topic for a root this bot asked Feishu to open. The reply
|
|
1009
|
+
* response carries the thread_id in most cases; when it does not — we have
|
|
1010
|
+
* seen a topic come back without one — read it back from the message we
|
|
1011
|
+
* replied to. Feishu opened the topic either way, and a topic left
|
|
1012
|
+
* unrecorded would answer its follow-ups from the chat's own session instead
|
|
1013
|
+
* of the topic's.
|
|
1014
|
+
*/
|
|
1015
|
+
async #registerTopicReply(rootMessageId, chatId, threadId) {
|
|
1016
|
+
const known = nonEmptyString(threadId);
|
|
1017
|
+
if (known) {
|
|
1018
|
+
await this.#registerTopicThreadId(known, rootMessageId, chatId);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
await this.#ensureTopicRegistered(rootMessageId, chatId);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Fallback for a threaded reply that reported no thread_id: ask Feishu which
|
|
1026
|
+
* topic the replied-to message now belongs to. Only runs while that message
|
|
1027
|
+
* is still a pending root candidate, so a topic already recorded — or a
|
|
1028
|
+
* conversation that has no topics at all — costs no extra API call.
|
|
1029
|
+
*/
|
|
1030
|
+
async #ensureTopicRegistered(rootMessageId, chatId) {
|
|
1031
|
+
this.#pruneRootCandidates();
|
|
1032
|
+
if (!nonEmptyString(rootMessageId) || !this.#rootCandidates.has(rootMessageId)) return;
|
|
1033
|
+
const threadId = await this.#readTopicThreadId(rootMessageId);
|
|
964
1034
|
if (threadId) {
|
|
965
|
-
await this.#registerTopicThreadId(threadId,
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1035
|
+
await this.#registerTopicThreadId(threadId, rootMessageId, chatId);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
// Still no topic: drop the candidate so a later addressed question opens a
|
|
1039
|
+
// fresh session rather than reusing a stale slot.
|
|
1040
|
+
this.#forgetTopicRoot(rootMessageId);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** The topic a message belongs to, or null when it is not in one. */
|
|
1044
|
+
async #readTopicThreadId(messageId) {
|
|
1045
|
+
const read = this.#client?.im?.v1?.message?.get;
|
|
1046
|
+
if (typeof read !== 'function') return null;
|
|
1047
|
+
try {
|
|
1048
|
+
const response = await read({ path: { message_id: messageId } });
|
|
1049
|
+
if (response?.code && response.code !== 0) return null;
|
|
1050
|
+
const items = Array.isArray(response?.data?.items) ? response.data.items : [];
|
|
1051
|
+
const item = items.find((candidate) => nonEmptyString(candidate?.message_id) === messageId)
|
|
1052
|
+
?? items[0];
|
|
1053
|
+
return nonEmptyString(item?.thread_id);
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
this.#logger.warn?.(
|
|
1056
|
+
'[dsh-feishu] could not read the topic of a replied-to message:',
|
|
1057
|
+
error?.message ?? String(error),
|
|
1058
|
+
);
|
|
1059
|
+
return null;
|
|
970
1060
|
}
|
|
971
1061
|
}
|
|
972
1062
|
|
|
@@ -1053,10 +1143,9 @@ export class FeishuHarnessBridge {
|
|
|
1053
1143
|
}
|
|
1054
1144
|
// Register a managed-topic root only after access is allowed, so a
|
|
1055
1145
|
// rejected or ignored question never reserves a candidate slot.
|
|
1056
|
-
if (this.#
|
|
1057
|
-
&& event?.message?.chat_type === 'group'
|
|
1146
|
+
if (this.#mentionTopicReply
|
|
1058
1147
|
&& !nonEmptyString(event?.message?.thread_id)
|
|
1059
|
-
&&
|
|
1148
|
+
&& isTopicKey(key)
|
|
1060
1149
|
&& key.includes(':managed:')) {
|
|
1061
1150
|
this.#rememberTopicRoot(messageId);
|
|
1062
1151
|
}
|
|
@@ -3448,7 +3537,9 @@ export class FeishuHarnessBridge {
|
|
|
3448
3537
|
|
|
3449
3538
|
async #deliverDeferredOutcome(entry, outcome) {
|
|
3450
3539
|
this.#rememberTopicReply(entry.replyToMessageId, entry.key);
|
|
3451
|
-
|
|
3540
|
+
// A late answer still has to open the topic it was rooted at, so re-arm the
|
|
3541
|
+
// root candidate: the key alone says whether this route was a managed topic.
|
|
3542
|
+
if (managedTopicRoot(entry.key) === nonEmptyString(entry.replyToMessageId)) {
|
|
3452
3543
|
this.#rememberTopicRoot(entry.replyToMessageId);
|
|
3453
3544
|
}
|
|
3454
3545
|
const text = deferredOutcomeText(outcome);
|
|
@@ -3470,10 +3561,10 @@ export class FeishuHarnessBridge {
|
|
|
3470
3561
|
...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
|
|
3471
3562
|
...(this.#replyInThreadFor(replyToMessageId) ? {
|
|
3472
3563
|
replyInThread: true,
|
|
3473
|
-
onReplyThreadId: async (threadId) => this.#
|
|
3474
|
-
threadId,
|
|
3564
|
+
onReplyThreadId: async (threadId) => this.#registerTopicReply(
|
|
3475
3565
|
replyToMessageId,
|
|
3476
3566
|
chatId,
|
|
3567
|
+
threadId,
|
|
3477
3568
|
),
|
|
3478
3569
|
} : {}),
|
|
3479
3570
|
});
|
|
@@ -4280,7 +4371,7 @@ export class FeishuHarnessBridge {
|
|
|
4280
4371
|
replyTo,
|
|
4281
4372
|
signal: this.#signal,
|
|
4282
4373
|
...(this.#replyInThreadFor(replyTo)
|
|
4283
|
-
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#
|
|
4374
|
+
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicReply(replyTo, chatId, threadId) }
|
|
4284
4375
|
: {}),
|
|
4285
4376
|
})
|
|
4286
4377
|
: undefined,
|
|
@@ -4289,7 +4380,7 @@ export class FeishuHarnessBridge {
|
|
|
4289
4380
|
replyTo,
|
|
4290
4381
|
signal: this.#signal,
|
|
4291
4382
|
...(this.#replyInThreadFor(replyTo)
|
|
4292
|
-
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#
|
|
4383
|
+
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicReply(replyTo, chatId, threadId) }
|
|
4293
4384
|
: {}),
|
|
4294
4385
|
})
|
|
4295
4386
|
: undefined,
|
|
@@ -4832,10 +4923,11 @@ export class FeishuHarnessBridge {
|
|
|
4832
4923
|
error.feishuCode = response.code;
|
|
4833
4924
|
throw error;
|
|
4834
4925
|
}
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4926
|
+
// 与其他回复路径同一条登记逻辑:响应通常带 thread_id,没带就回读被回复的消息
|
|
4927
|
+
// (#registerTopicReply → #ensureTopicRegistered)。逐步消息以前只认响应,
|
|
4928
|
+
// 于是话题开出来了却没登记,话题内的追问会从托管键掉回聊天自己的会话。
|
|
4929
|
+
// 两个分支都以「该消息仍是待开话题的候选根」为前提,不是候选就是空操作。
|
|
4930
|
+
await this.#registerTopicReply(messageId, chatId, response?.data?.thread_id);
|
|
4839
4931
|
return response;
|
|
4840
4932
|
}
|
|
4841
4933
|
|
|
@@ -5539,7 +5631,7 @@ export class FeishuHarnessBridge {
|
|
|
5539
5631
|
}, {
|
|
5540
5632
|
replyTo: messageId,
|
|
5541
5633
|
...(this.#replyInThreadFor(messageId)
|
|
5542
|
-
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#
|
|
5634
|
+
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicReply(messageId, chatId, threadId) }
|
|
5543
5635
|
: {}),
|
|
5544
5636
|
});
|
|
5545
5637
|
} catch (error) {
|
|
@@ -516,9 +516,12 @@ export class VerifiedFeishuChannel {
|
|
|
516
516
|
if (typeof messageId !== 'string' || !messageId) {
|
|
517
517
|
throw fileDeliveryError('message send', undefined, undefined, { uncertain: true });
|
|
518
518
|
}
|
|
519
|
-
if (replyTo && typeof onReplyThreadId === 'function') {
|
|
519
|
+
if (replyTo && replyInThread && typeof onReplyThreadId === 'function') {
|
|
520
|
+
// The reply itself opens the topic, and the response reports its
|
|
521
|
+
// thread_id only sometimes (a direct-chat topic comes back without one).
|
|
522
|
+
// Hand over what we have and let the bridge read it back when missing.
|
|
520
523
|
const threadId = response?.data?.thread_id;
|
|
521
|
-
|
|
524
|
+
await onReplyThreadId(typeof threadId === 'string' && threadId ? threadId : null);
|
|
522
525
|
}
|
|
523
526
|
return createDeliveryReceipt({
|
|
524
527
|
deliveryId: file.deliveryKey,
|
|
@@ -596,9 +599,11 @@ export class VerifiedFeishuChannel {
|
|
|
596
599
|
assertApiSuccess('Feishu message send', response);
|
|
597
600
|
const messageId = response?.data?.message_id;
|
|
598
601
|
if (!messageId) throw new Error('Feishu message send returned no message_id');
|
|
599
|
-
if (replyTo && typeof options.onReplyThreadId === 'function') {
|
|
602
|
+
if (replyTo && replyInThread && typeof options.onReplyThreadId === 'function') {
|
|
603
|
+
// See #sendArtifact: the topic is created by the reply, but the response
|
|
604
|
+
// may omit its thread_id, so pass it through as it came.
|
|
600
605
|
const threadId = response?.data?.thread_id;
|
|
601
|
-
|
|
606
|
+
await options.onReplyThreadId(typeof threadId === 'string' && threadId ? threadId : null);
|
|
602
607
|
}
|
|
603
608
|
return messageId;
|
|
604
609
|
}
|
|
@@ -7,9 +7,10 @@ import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
|
|
|
7
7
|
import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
|
|
8
8
|
import { createVoice } from './voice.mjs';
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
syncSlashCommands,
|
|
11
11
|
SLASH_COMMAND_MANIFEST,
|
|
12
12
|
} from './slash-command-registry.mjs';
|
|
13
|
+
import { normalizeSlashPanelConfig } from './slash-command-panel.mjs';
|
|
13
14
|
import {
|
|
14
15
|
connectionTestTargetUnavailable,
|
|
15
16
|
sendRememberedConnectionTest,
|
|
@@ -95,6 +96,7 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
|
|
|
95
96
|
slashCommandsRegistered: 0,
|
|
96
97
|
slashCommandsExisting: 0,
|
|
97
98
|
slashCommandsFailed: 0,
|
|
99
|
+
slashCommandsRemoved: 0,
|
|
98
100
|
slashCommandsError: null,
|
|
99
101
|
};
|
|
100
102
|
}
|
|
@@ -113,7 +115,7 @@ export class FeishuRuntime {
|
|
|
113
115
|
#domain;
|
|
114
116
|
#botOpenId;
|
|
115
117
|
#groupResponseMode;
|
|
116
|
-
#
|
|
118
|
+
#mentionTopicReply;
|
|
117
119
|
#stepPush;
|
|
118
120
|
#stepPushMode;
|
|
119
121
|
#voice = null;
|
|
@@ -139,6 +141,19 @@ export class FeishuRuntime {
|
|
|
139
141
|
#pendingCardActionProbes = new Map();
|
|
140
142
|
#status;
|
|
141
143
|
#slashCommands = true;
|
|
144
|
+
/** Which commands the "/" panel should offer, and in which order. */
|
|
145
|
+
#slashPanel = null;
|
|
146
|
+
/** HTTP instance kept for panel re-syncs after startup. */
|
|
147
|
+
#slashHttpInstance = null;
|
|
148
|
+
/**
|
|
149
|
+
* Bumped on every panel change: a sync started for an older config stops at
|
|
150
|
+
* its next checkpoint instead of racing the newer one (last writer wins).
|
|
151
|
+
*/
|
|
152
|
+
#slashSyncEpoch = 0;
|
|
153
|
+
/** Cancels the panel sync in flight; it stops before its next request. */
|
|
154
|
+
#slashSyncCancel = null;
|
|
155
|
+
/** Panel syncs run one after another, so two plans never interleave. */
|
|
156
|
+
#slashSyncQueue = Promise.resolve();
|
|
142
157
|
|
|
143
158
|
constructor({
|
|
144
159
|
lark,
|
|
@@ -148,7 +163,7 @@ export class FeishuRuntime {
|
|
|
148
163
|
domain = 'feishu',
|
|
149
164
|
botOpenId,
|
|
150
165
|
groupResponseMode,
|
|
151
|
-
|
|
166
|
+
mentionTopicReply = true,
|
|
152
167
|
stepPush = false,
|
|
153
168
|
stepPushMode = 'post',
|
|
154
169
|
voice = null,
|
|
@@ -164,6 +179,7 @@ export class FeishuRuntime {
|
|
|
164
179
|
connectTimeoutMs = 15000,
|
|
165
180
|
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
166
181
|
slashCommands = true,
|
|
182
|
+
slashPanel = null,
|
|
167
183
|
wsAgent,
|
|
168
184
|
logger = console,
|
|
169
185
|
}) {
|
|
@@ -188,7 +204,7 @@ export class FeishuRuntime {
|
|
|
188
204
|
this.#domain = domain;
|
|
189
205
|
this.#botOpenId = nonEmptyString(botOpenId);
|
|
190
206
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
|
|
191
|
-
this.#
|
|
207
|
+
this.#mentionTopicReply = mentionTopicReply !== false;
|
|
192
208
|
this.#stepPush = stepPush === true;
|
|
193
209
|
this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
|
|
194
210
|
// voice 为 { config, secret } 来源;凭据缺失时 createVoice 返回禁用对象,
|
|
@@ -209,6 +225,7 @@ export class FeishuRuntime {
|
|
|
209
225
|
this.#connectTimeoutMs = connectTimeoutMs;
|
|
210
226
|
this.#requestTimeoutMs = requestTimeoutMs;
|
|
211
227
|
this.#slashCommands = Boolean(slashCommands);
|
|
228
|
+
this.#slashPanel = normalizeSlashPanelConfig(slashPanel);
|
|
212
229
|
this.#wsAgent = wsAgent;
|
|
213
230
|
this.#logger = logger; this.#diagnostics = createConnectionDiagnostics({ channel: 'feishu', logger });
|
|
214
231
|
this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length });
|
|
@@ -223,9 +240,9 @@ export class FeishuRuntime {
|
|
|
223
240
|
this.#bridge?.setGroupResponseMode(this.#groupResponseMode);
|
|
224
241
|
}
|
|
225
242
|
|
|
226
|
-
|
|
227
|
-
this.#
|
|
228
|
-
this.#bridge?.
|
|
243
|
+
setMentionTopicReply(value) {
|
|
244
|
+
this.#mentionTopicReply = value !== false;
|
|
245
|
+
this.#bridge?.setMentionTopicReply(this.#mentionTopicReply);
|
|
229
246
|
}
|
|
230
247
|
|
|
231
248
|
setStepPush(value) {
|
|
@@ -245,6 +262,17 @@ export class FeishuRuntime {
|
|
|
245
262
|
this.#bridge?.setVoice(this.#voice);
|
|
246
263
|
}
|
|
247
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Record which commands the "/" panel should offer. The panel itself lives on
|
|
267
|
+
* Feishu's side, so a connected bot re-syncs it in the background; the newer
|
|
268
|
+
* config wins and any sync still running for the older one stops.
|
|
269
|
+
*/
|
|
270
|
+
setSlashPanel(value) {
|
|
271
|
+
this.#slashPanel = normalizeSlashPanelConfig(value);
|
|
272
|
+
this.#slashSyncEpoch += 1;
|
|
273
|
+
this.#scheduleSlashPanelSync();
|
|
274
|
+
}
|
|
275
|
+
|
|
248
276
|
async start() {
|
|
249
277
|
while (true) {
|
|
250
278
|
while (this.#stopping) await this.#stopping;
|
|
@@ -333,7 +361,7 @@ export class FeishuRuntime {
|
|
|
333
361
|
appId: this.#appId,
|
|
334
362
|
botOpenId: this.#botOpenId,
|
|
335
363
|
groupResponseMode: this.#groupResponseMode,
|
|
336
|
-
|
|
364
|
+
mentionTopicReply: this.#mentionTopicReply,
|
|
337
365
|
stepPush: this.#stepPush,
|
|
338
366
|
stepPushMode: this.#stepPushMode,
|
|
339
367
|
voice: this.#voice,
|
|
@@ -444,9 +472,11 @@ export class FeishuRuntime {
|
|
|
444
472
|
assertCurrentStart();
|
|
445
473
|
// Register the native Slash Command panel best-effort and asynchronously
|
|
446
474
|
// so it never blocks the long-connection startup. The panel is only a
|
|
447
|
-
// client-side convenience; failure here must not take the bot down.
|
|
475
|
+
// client-side convenience; failure here must not take the bot down. The
|
|
476
|
+
// HTTP instance is kept so a later panel change can re-sync while running.
|
|
448
477
|
if (this.#slashCommands && httpInstance) {
|
|
449
|
-
|
|
478
|
+
this.#slashHttpInstance = httpInstance;
|
|
479
|
+
this.#scheduleSlashPanelSync();
|
|
450
480
|
}
|
|
451
481
|
return this.status;
|
|
452
482
|
} catch (error) {
|
|
@@ -689,28 +719,69 @@ export class FeishuRuntime {
|
|
|
689
719
|
return { sent: true };
|
|
690
720
|
}
|
|
691
721
|
|
|
692
|
-
|
|
722
|
+
/**
|
|
723
|
+
* Queue one panel sync for the current config.
|
|
724
|
+
*
|
|
725
|
+
* Two saves in a row used to run two syncs concurrently, and the older one
|
|
726
|
+
* kept deleting and creating commands from a plan nobody wanted anymore. Here
|
|
727
|
+
* the run in flight is cancelled first — it stops at its next checkpoint while
|
|
728
|
+
* requests already sent are left to finish — and the new run starts only once
|
|
729
|
+
* it has settled, so the panel converges to the newest config.
|
|
730
|
+
*/
|
|
731
|
+
#scheduleSlashPanelSync() {
|
|
732
|
+
const httpInstance = this.#slashHttpInstance;
|
|
733
|
+
const connectionSignal = this.#abortController?.signal;
|
|
734
|
+
if (!this.#slashCommands || !httpInstance || !connectionSignal || connectionSignal.aborted) return;
|
|
735
|
+
const epoch = this.#slashSyncEpoch;
|
|
736
|
+
this.#slashSyncCancel?.();
|
|
737
|
+
const controller = new AbortController();
|
|
738
|
+
const cancel = () => controller.abort();
|
|
739
|
+
const forwardAbort = () => cancel();
|
|
740
|
+
this.#slashSyncCancel = cancel;
|
|
741
|
+
connectionSignal.addEventListener('abort', forwardAbort, { once: true });
|
|
742
|
+
if (connectionSignal.aborted) cancel();
|
|
743
|
+
const run = this.#slashSyncQueue.then(() => this.#registerSlashCommands(
|
|
744
|
+
httpInstance,
|
|
745
|
+
() => !connectionSignal.aborted && this.#abortController?.signal === connectionSignal,
|
|
746
|
+
controller.signal,
|
|
747
|
+
epoch,
|
|
748
|
+
));
|
|
749
|
+
const settled = run.then(() => {}, () => {});
|
|
750
|
+
this.#slashSyncQueue = settled;
|
|
751
|
+
void settled.then(() => {
|
|
752
|
+
connectionSignal.removeEventListener('abort', forwardAbort);
|
|
753
|
+
if (this.#slashSyncCancel === cancel) this.#slashSyncCancel = null;
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async #registerSlashCommands(httpInstance, isCurrentStart, signal, epoch = this.#slashSyncEpoch) {
|
|
693
758
|
this.#status.slashCommandRegistration = 'registering';
|
|
694
759
|
this.#status.slashCommandsError = null;
|
|
695
760
|
try {
|
|
696
|
-
const result = await
|
|
761
|
+
const result = await syncSlashCommands({
|
|
697
762
|
appId: this.#appId,
|
|
698
763
|
appSecret: this.#appSecret,
|
|
699
764
|
domain: this.#domain,
|
|
700
765
|
httpInstance,
|
|
701
766
|
signal,
|
|
702
767
|
manifest: SLASH_COMMAND_MANIFEST,
|
|
768
|
+
config: this.#slashPanel,
|
|
703
769
|
});
|
|
704
|
-
|
|
770
|
+
// A newer panel config took over while this sync was running: its own run
|
|
771
|
+
// reports the outcome, so this one only stops.
|
|
772
|
+
if (!isCurrentStart() || epoch !== this.#slashSyncEpoch || result.superseded === true) return;
|
|
705
773
|
this.#status.slashCommandRegistration = 'done';
|
|
706
774
|
this.#status.slashCommandsRegistered = result.created.length;
|
|
707
775
|
this.#status.slashCommandsExisting = result.existing.length;
|
|
708
776
|
this.#status.slashCommandsFailed = result.failed.length;
|
|
777
|
+
this.#status.slashCommandsRemoved = result.deleted.length;
|
|
709
778
|
this.#status.slashCommandsError = result.failed.length > 0
|
|
710
779
|
? result.failed.map((f) => `/${f.command}: ${f.error?.message ?? String(f.error)}`).join('; ')
|
|
711
780
|
: null;
|
|
712
|
-
if (result.created.length > 0) {
|
|
713
|
-
this.#logger.info?.(
|
|
781
|
+
if (result.created.length > 0 || result.deleted.length > 0) {
|
|
782
|
+
this.#logger.info?.(
|
|
783
|
+
`[dsh-feishu] slash panel synced: ${result.created.length} added, ${result.deleted.length} removed`,
|
|
784
|
+
);
|
|
714
785
|
}
|
|
715
786
|
if (result.failed.length > 0) {
|
|
716
787
|
this.#logger.warn?.(
|