@xmanrui/dsh-im 4.29.1 → 4.31.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 +7 -4
- package/README.md +7 -4
- package/lib/client.js +578 -141
- package/lib/index.js +298 -298
- package/package.json +6 -2
- 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/context-enhancement.js +7 -0
- package/plugin-src/client/delivery-settings.js +16 -0
- package/plugin-src/client/i18n.js +39 -6
- 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/scripts/verify-package.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +183 -44
- package/src/channels/feishu/feishu-cards.mjs +7 -1
- 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
- package/src/channels/shared/context-enhancement.mjs +148 -7
- package/src/channels/shared/i18n-en/feishu.mjs +5 -0
- package/src/channels/weixin/weixin-bridge.mjs +28 -10
|
@@ -253,7 +253,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
253
253
|
domain: botConfig.domain,
|
|
254
254
|
botOpenId: botConfig.botOpenId,
|
|
255
255
|
groupResponseMode: botConfig.groupResponseMode,
|
|
256
|
-
|
|
256
|
+
mentionTopicReply: botConfig.mentionTopicReply,
|
|
257
257
|
stepPush: botConfig.stepPush,
|
|
258
258
|
stepPushMode: botConfig.stepPushMode,
|
|
259
259
|
voice: voiceSource,
|
|
@@ -267,6 +267,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
267
267
|
}),
|
|
268
268
|
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
|
269
269
|
slashCommands: config.slashCommands !== false,
|
|
270
|
+
slashPanel: botConfig.slashPanel,
|
|
270
271
|
...(wsAgent ? { wsAgent } : {}),
|
|
271
272
|
logger: {
|
|
272
273
|
error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args),
|
|
@@ -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 });
|
|
@@ -219,7 +219,7 @@ const directDependencies = {
|
|
|
219
219
|
'@tencent-connect/qqbot-nodejs': '1.0.4',
|
|
220
220
|
'@wecom/aibot-node-sdk': '1.0.7',
|
|
221
221
|
qrcode: '1.5.4',
|
|
222
|
-
undici: '7.29.
|
|
222
|
+
undici: '7.29.1',
|
|
223
223
|
};
|
|
224
224
|
for (const [name, version] of Object.entries(directDependencies)) {
|
|
225
225
|
if (manifest.dependencies?.[name] !== version) {
|
|
@@ -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
|
}
|
|
@@ -1175,7 +1264,7 @@ export class FeishuHarnessBridge {
|
|
|
1175
1264
|
this.#commandTasks.add(current);
|
|
1176
1265
|
return current;
|
|
1177
1266
|
}
|
|
1178
|
-
if (this.#isResolvedQuestionReply(event, key)) {
|
|
1267
|
+
if (!hasImages && this.#isResolvedQuestionReply(event, key)) {
|
|
1179
1268
|
const current = Promise.resolve()
|
|
1180
1269
|
.then(() => this.#discardResolvedInteractionReply(event, messageId))
|
|
1181
1270
|
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
|
|
@@ -1228,6 +1317,19 @@ export class FeishuHarnessBridge {
|
|
|
1228
1317
|
if (pending && senderOpenId(event) !== pending.actor) {
|
|
1229
1318
|
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
|
1230
1319
|
}
|
|
1320
|
+
if (pending && hasImages) {
|
|
1321
|
+
const previous = pending.queue ?? Promise.resolve();
|
|
1322
|
+
const interruption = previous.catch(() => undefined)
|
|
1323
|
+
.then(() => this.#interruptQuestionForImage(event, key, pending))
|
|
1324
|
+
.finally(() => {
|
|
1325
|
+
if (pending.queue === interruption) pending.queue = null;
|
|
1326
|
+
this.#interactionTasks.delete(interruption);
|
|
1327
|
+
});
|
|
1328
|
+
pending.queue = interruption;
|
|
1329
|
+
this.#interactionTasks.add(interruption);
|
|
1330
|
+
// Reserve the image's FIFO position before stopping releases the original turn.
|
|
1331
|
+
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
|
1332
|
+
}
|
|
1231
1333
|
if (pending?.submitting || pending?.claimedReplyMessageId) {
|
|
1232
1334
|
return this.#enqueueMessage(event, messageId, key, processingReaction);
|
|
1233
1335
|
}
|
|
@@ -3448,7 +3550,9 @@ export class FeishuHarnessBridge {
|
|
|
3448
3550
|
|
|
3449
3551
|
async #deliverDeferredOutcome(entry, outcome) {
|
|
3450
3552
|
this.#rememberTopicReply(entry.replyToMessageId, entry.key);
|
|
3451
|
-
|
|
3553
|
+
// A late answer still has to open the topic it was rooted at, so re-arm the
|
|
3554
|
+
// root candidate: the key alone says whether this route was a managed topic.
|
|
3555
|
+
if (managedTopicRoot(entry.key) === nonEmptyString(entry.replyToMessageId)) {
|
|
3452
3556
|
this.#rememberTopicRoot(entry.replyToMessageId);
|
|
3453
3557
|
}
|
|
3454
3558
|
const text = deferredOutcomeText(outcome);
|
|
@@ -3470,10 +3574,10 @@ export class FeishuHarnessBridge {
|
|
|
3470
3574
|
...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
|
|
3471
3575
|
...(this.#replyInThreadFor(replyToMessageId) ? {
|
|
3472
3576
|
replyInThread: true,
|
|
3473
|
-
onReplyThreadId: async (threadId) => this.#
|
|
3474
|
-
threadId,
|
|
3577
|
+
onReplyThreadId: async (threadId) => this.#registerTopicReply(
|
|
3475
3578
|
replyToMessageId,
|
|
3476
3579
|
chatId,
|
|
3580
|
+
threadId,
|
|
3477
3581
|
),
|
|
3478
3582
|
} : {}),
|
|
3479
3583
|
});
|
|
@@ -4280,7 +4384,7 @@ export class FeishuHarnessBridge {
|
|
|
4280
4384
|
replyTo,
|
|
4281
4385
|
signal: this.#signal,
|
|
4282
4386
|
...(this.#replyInThreadFor(replyTo)
|
|
4283
|
-
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#
|
|
4387
|
+
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicReply(replyTo, chatId, threadId) }
|
|
4284
4388
|
: {}),
|
|
4285
4389
|
})
|
|
4286
4390
|
: undefined,
|
|
@@ -4289,7 +4393,7 @@ export class FeishuHarnessBridge {
|
|
|
4289
4393
|
replyTo,
|
|
4290
4394
|
signal: this.#signal,
|
|
4291
4395
|
...(this.#replyInThreadFor(replyTo)
|
|
4292
|
-
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#
|
|
4396
|
+
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicReply(replyTo, chatId, threadId) }
|
|
4293
4397
|
: {}),
|
|
4294
4398
|
})
|
|
4295
4399
|
: undefined,
|
|
@@ -4832,10 +4936,11 @@ export class FeishuHarnessBridge {
|
|
|
4832
4936
|
error.feishuCode = response.code;
|
|
4833
4937
|
throw error;
|
|
4834
4938
|
}
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4939
|
+
// 与其他回复路径同一条登记逻辑:响应通常带 thread_id,没带就回读被回复的消息
|
|
4940
|
+
// (#registerTopicReply → #ensureTopicRegistered)。逐步消息以前只认响应,
|
|
4941
|
+
// 于是话题开出来了却没登记,话题内的追问会从托管键掉回聊天自己的会话。
|
|
4942
|
+
// 两个分支都以「该消息仍是待开话题的候选根」为前提,不是候选就是空操作。
|
|
4943
|
+
await this.#registerTopicReply(messageId, chatId, response?.data?.thread_id);
|
|
4839
4944
|
return response;
|
|
4840
4945
|
}
|
|
4841
4946
|
|
|
@@ -5539,7 +5644,7 @@ export class FeishuHarnessBridge {
|
|
|
5539
5644
|
}, {
|
|
5540
5645
|
replyTo: messageId,
|
|
5541
5646
|
...(this.#replyInThreadFor(messageId)
|
|
5542
|
-
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#
|
|
5647
|
+
? { replyInThread: true, onReplyThreadId: async (threadId) => this.#registerTopicReply(messageId, chatId, threadId) }
|
|
5543
5648
|
: {}),
|
|
5544
5649
|
});
|
|
5545
5650
|
} catch (error) {
|
|
@@ -5717,6 +5822,40 @@ export class FeishuHarnessBridge {
|
|
|
5717
5822
|
});
|
|
5718
5823
|
}
|
|
5719
5824
|
|
|
5825
|
+
async #interruptQuestionForImage(event, key, pending) {
|
|
5826
|
+
if (this.#signal?.aborted || this.#pendingInteractions.get(key) !== pending) return;
|
|
5827
|
+
const question = pending.questions[pending.index];
|
|
5828
|
+
const questionMessageId = pending.questionCardMessageId;
|
|
5829
|
+
const index = pending.index;
|
|
5830
|
+
const wasSubmitting = pending.submitting;
|
|
5831
|
+
pending.submitting = true;
|
|
5832
|
+
try {
|
|
5833
|
+
await runControlCommand('/stop', this.#harness, this.#state, key, {
|
|
5834
|
+
signal: this.#signal,
|
|
5835
|
+
control: { owner: this, key },
|
|
5836
|
+
});
|
|
5837
|
+
} catch (error) {
|
|
5838
|
+
if (this.#pendingInteractions.get(key) === pending) pending.submitting = wasSubmitting;
|
|
5839
|
+
this.#logger.warn?.('[dsh-feishu] could not stop the question turn for an image:', error.message);
|
|
5840
|
+
await this.#send(event.message.chat_id,
|
|
5841
|
+
t('图片已排队,但暂时无法结束当前提问。请先回答问题或发送 /stop,结束后会继续处理图片。'),
|
|
5842
|
+
{ replyTo: event.message.message_id }).catch(() => undefined);
|
|
5843
|
+
return;
|
|
5844
|
+
}
|
|
5845
|
+
await this.#cancelPendingInteraction(key, pending.interactionId);
|
|
5846
|
+
const resolvedText = t('已结束本轮提问,将在当前会话中继续处理图片。');
|
|
5847
|
+
if (questionMessageId) {
|
|
5848
|
+
await this.#patchCardMessage(event.message.chat_id, questionMessageId, questionCard({
|
|
5849
|
+
...question,
|
|
5850
|
+
interactionId: pending.interactionId,
|
|
5851
|
+
index,
|
|
5852
|
+
total: pending.questions.length,
|
|
5853
|
+
resolvedText,
|
|
5854
|
+
}));
|
|
5855
|
+
}
|
|
5856
|
+
await this.#send(event.message.chat_id, resolvedText, { replyTo: event.message.message_id }).catch(() => undefined);
|
|
5857
|
+
}
|
|
5858
|
+
|
|
5720
5859
|
async #submitQuestionAnswer(pending, answerText, { chatId, messageId, questionMessageId } = {}) {
|
|
5721
5860
|
const question = pending.questions[pending.index];
|
|
5722
5861
|
if (!question) return;
|
|
@@ -6054,8 +6193,8 @@ export class FeishuHarnessBridge {
|
|
|
6054
6193
|
return this.#takePendingInteraction(key, interactionId) !== null;
|
|
6055
6194
|
}
|
|
6056
6195
|
|
|
6057
|
-
async #cancelPendingInteraction(key) {
|
|
6058
|
-
const pending = this.#takePendingInteraction(key);
|
|
6196
|
+
async #cancelPendingInteraction(key, interactionId) {
|
|
6197
|
+
const pending = this.#takePendingInteraction(key, interactionId);
|
|
6059
6198
|
if (!pending || pending.kind !== 'question') return;
|
|
6060
6199
|
this.#rememberResolvedInteraction(key, pending);
|
|
6061
6200
|
try {
|
|
@@ -1211,9 +1211,10 @@ export function splitStepStreamCardBlocks(
|
|
|
1211
1211
|
* rendered as its own button; the selected option label is submitted via a
|
|
1212
1212
|
* card callback. Multi-select questions fall back to the plain-text flow (the
|
|
1213
1213
|
* caller decides), because a multi-select needs a confirm step.
|
|
1214
|
+
* A resolvedText replaces the buttons when an image ends the question flow.
|
|
1214
1215
|
* Action: answer:<interactionId>:<optionLabel>
|
|
1215
1216
|
*/
|
|
1216
|
-
export function questionCard({ interactionId, header, question, detail, options, index, total }) {
|
|
1217
|
+
export function questionCard({ interactionId, header, question, detail, options, index, total, resolvedText }) {
|
|
1217
1218
|
const elements = [];
|
|
1218
1219
|
const progress = total > 1 ? `(${index + 1}/${total})` : '';
|
|
1219
1220
|
if (header) elements.push({ tag: 'div', text: markdown(String(header)) });
|
|
@@ -1221,6 +1222,11 @@ export function questionCard({ interactionId, header, question, detail, options,
|
|
|
1221
1222
|
elements.push({ tag: 'div', text: markdown(String(qText)) });
|
|
1222
1223
|
if (detail) elements.push({ tag: 'div', text: markdown(String(detail)) });
|
|
1223
1224
|
|
|
1225
|
+
if (resolvedText) {
|
|
1226
|
+
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(resolvedText) });
|
|
1227
|
+
return cardWith(t('⏹ 提问已结束{progress}', { progress }), elements);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1224
1230
|
if (Array.isArray(options) && options.length > 0) {
|
|
1225
1231
|
elements.push({ tag: 'hr' });
|
|
1226
1232
|
for (const option of options) {
|
|
@@ -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
|
}
|