@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
|
@@ -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?.(
|
|
@@ -14,20 +14,43 @@ const FEISHU_CARD_UNAVAILABLE_TEXTS = new Set([
|
|
|
14
14
|
const FEISHU_IMAGE_PERMISSION_MESSAGE =
|
|
15
15
|
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Managed-topic key: one per Feishu topic the bot auto-rooted at a message.
|
|
19
|
+
* `scope` is the conversation the topic belongs to (`group:<chat_id>` for a
|
|
20
|
+
* group, `p2p:<open_id>` for a direct chat).
|
|
21
|
+
*/
|
|
22
|
+
export function managedTopicKey(scope, rootMessageId) {
|
|
23
|
+
if (!scope) throw new Error('Feishu managed-topic key needs a conversation scope');
|
|
20
24
|
if (!rootMessageId) throw new Error('Feishu managed-topic key needs a root message id');
|
|
21
|
-
return
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
export function
|
|
26
|
-
|
|
25
|
+
return `${scope}:managed:${rootMessageId}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Root message id encoded in a managed-topic key, or null for any other key. */
|
|
29
|
+
export function managedTopicRoot(key) {
|
|
30
|
+
if (typeof key !== 'string') return null;
|
|
31
|
+
const marker = ':managed:';
|
|
32
|
+
const at = key.lastIndexOf(marker);
|
|
33
|
+
if (at <= 0) return null;
|
|
34
|
+
return key.slice(at + marker.length) || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether a key denotes a topic-isolated conversation: either a thread the
|
|
39
|
+
* platform created (a topic group, a topic the reader opened, or the topic the
|
|
40
|
+
* bot's own reply opened) or a managed topic the bot rooted at one message.
|
|
41
|
+
*/
|
|
42
|
+
export function isTopicKey(key) {
|
|
43
|
+
return typeof key === 'string'
|
|
44
|
+
&& (key.startsWith('group:') || key.startsWith('p2p:'))
|
|
27
45
|
&& (key.includes(':thread:') || key.includes(':managed:'));
|
|
28
46
|
}
|
|
29
47
|
|
|
30
|
-
|
|
48
|
+
/**
|
|
49
|
+
* The conversation a message belongs to, without the topic dimension: the
|
|
50
|
+
* stable identity a managed-topic key is built on. Group events are identified
|
|
51
|
+
* by chat id; a direct chat is identified by its peer.
|
|
52
|
+
*/
|
|
53
|
+
export function conversationScope(event) {
|
|
31
54
|
const chatType = event?.message?.chat_type;
|
|
32
55
|
if (chatType === 'p2p') {
|
|
33
56
|
const senderId = event?.sender?.sender_id?.open_id || event?.sender?.sender_id?.user_id;
|
|
@@ -36,12 +59,25 @@ export function conversationKey(event) {
|
|
|
36
59
|
}
|
|
37
60
|
const chatId = event?.message?.chat_id;
|
|
38
61
|
if (!chatId) throw new Error('Feishu group event has no chat id');
|
|
62
|
+
return `group:${chatId}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function conversationKey(event) {
|
|
66
|
+
const scope = conversationScope(event);
|
|
39
67
|
// Topic groups: every message belongs to a thread, so key the session per
|
|
40
68
|
// thread to keep each topic's Harness conversation isolated. Regular group
|
|
41
69
|
// chats carry no thread_id and keep the single shared `group:<chat_id>` key.
|
|
70
|
+
//
|
|
71
|
+
// Direct chats deliberately keep one key per peer: their thread_id must not
|
|
72
|
+
// split the session, or an answer typed inside a thread would no longer reach
|
|
73
|
+
// the question the bot asked in the main window. A direct chat only leaves
|
|
74
|
+
// its main session for a topic the bot itself opened (a managed topic key).
|
|
42
75
|
const threadId = event?.message?.thread_id;
|
|
43
|
-
if (
|
|
44
|
-
|
|
76
|
+
if (event?.message?.chat_type !== 'p2p'
|
|
77
|
+
&& typeof threadId === 'string' && threadId.trim()) {
|
|
78
|
+
return `${scope}:thread:${threadId}`;
|
|
79
|
+
}
|
|
80
|
+
return scope;
|
|
45
81
|
}
|
|
46
82
|
|
|
47
83
|
function parsedMessageContent(event) {
|
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
normalizeFeishuStepPushMode,
|
|
23
23
|
} from './step-push-mode.mjs';
|
|
24
24
|
import { normalizeFeishuVoiceConfig } from './voice-config.mjs';
|
|
25
|
+
import {
|
|
26
|
+
isSlashPanelConfig,
|
|
27
|
+
normalizeSlashPanelConfig,
|
|
28
|
+
} from './slash-command-panel.mjs';
|
|
25
29
|
|
|
26
30
|
const ACTIVE_REGISTRATION_STATES = new Set([
|
|
27
31
|
'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched',
|
|
@@ -97,10 +101,11 @@ function configuredBotFingerprint(config) {
|
|
|
97
101
|
botOpenId: config.botOpenId,
|
|
98
102
|
activated: config.activated,
|
|
99
103
|
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
|
100
|
-
|
|
104
|
+
mentionTopicReply: config.mentionTopicReply !== false,
|
|
101
105
|
stepPush: config.stepPush === true,
|
|
102
106
|
stepPushMode: normalizeFeishuStepPushMode(config.stepPushMode),
|
|
103
107
|
voice: config.voice,
|
|
108
|
+
slashPanel: normalizeSlashPanelConfig(config.slashPanel),
|
|
104
109
|
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
|
105
110
|
deletionPending: config.deletionPending === true,
|
|
106
111
|
connectedAt: config.connectedAt ?? null,
|
|
@@ -605,15 +610,31 @@ export class MultiBotDshFeishuController {
|
|
|
605
610
|
}));
|
|
606
611
|
}
|
|
607
612
|
|
|
608
|
-
async
|
|
613
|
+
async updateMentionTopicReply(botId, mentionTopicReply) {
|
|
614
|
+
this.#assertOpen();
|
|
615
|
+
if (typeof mentionTopicReply !== 'boolean') {
|
|
616
|
+
throw new TypeError('Invalid Feishu mention topic reply flag');
|
|
617
|
+
}
|
|
618
|
+
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
|
619
|
+
const config = this.#requireBot(botId);
|
|
620
|
+
const saved = await this.#configStore.saveBot({ ...config, mentionTopicReply });
|
|
621
|
+
this.#runtimes.get(botId)?.setMentionTopicReply?.(saved.mentionTopicReply);
|
|
622
|
+
this.#touch();
|
|
623
|
+
return this.status(botId);
|
|
624
|
+
}));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async updateSlashPanel(botId, slashPanel) {
|
|
609
628
|
this.#assertOpen();
|
|
610
|
-
if (
|
|
611
|
-
throw new TypeError('Invalid Feishu
|
|
629
|
+
if (!isSlashPanelConfig(slashPanel)) {
|
|
630
|
+
throw new TypeError('Invalid Feishu slash panel config');
|
|
612
631
|
}
|
|
613
632
|
return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
|
|
614
633
|
const config = this.#requireBot(botId);
|
|
615
|
-
const saved = await this.#configStore.saveBot({ ...config,
|
|
616
|
-
|
|
634
|
+
const saved = await this.#configStore.saveBot({ ...config, slashPanel });
|
|
635
|
+
// The panel lives on Feishu's side, so the runtime re-syncs it in the
|
|
636
|
+
// background; this call only records what the panel should be.
|
|
637
|
+
this.#runtimes.get(botId)?.setSlashPanel?.(saved.slashPanel);
|
|
617
638
|
this.#touch();
|
|
618
639
|
return this.status(botId);
|
|
619
640
|
}));
|
|
@@ -728,10 +749,11 @@ export class MultiBotDshFeishuController {
|
|
|
728
749
|
connected,
|
|
729
750
|
configured: true,
|
|
730
751
|
groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
|
|
731
|
-
|
|
752
|
+
mentionTopicReply: config.mentionTopicReply !== false,
|
|
732
753
|
stepPush: config.stepPush === true,
|
|
733
754
|
stepPushMode: normalizeFeishuStepPushMode(config.stepPushMode),
|
|
734
755
|
voice: normalizeFeishuVoiceConfig(config.voice),
|
|
756
|
+
slashPanel: normalizeSlashPanelConfig(config.slashPanel),
|
|
735
757
|
groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
|
|
736
758
|
bot: publicBot(config),
|
|
737
759
|
connection,
|
|
@@ -4,6 +4,7 @@ import { dirname } from 'node:path';
|
|
|
4
4
|
import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
|
|
5
5
|
import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
|
|
6
6
|
import { normalizeFeishuVoiceConfig } from './voice-config.mjs';
|
|
7
|
+
import { normalizeSlashPanelConfig } from './slash-command-panel.mjs';
|
|
7
8
|
|
|
8
9
|
export const LEGACY_FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
|
|
9
10
|
|
|
@@ -47,10 +48,11 @@ function normalizeBot(value, { legacy = false } = {}) {
|
|
|
47
48
|
botOpenId: cleanString(value.botOpenId),
|
|
48
49
|
activated: value.activated ?? null,
|
|
49
50
|
groupResponseMode: normalizeFeishuGroupResponseMode(value.groupResponseMode),
|
|
50
|
-
|
|
51
|
+
mentionTopicReply: value.mentionTopicReply !== false,
|
|
51
52
|
stepPush: value.stepPush === true,
|
|
52
53
|
stepPushMode: normalizeFeishuStepPushMode(value.stepPushMode),
|
|
53
54
|
voice: normalizeFeishuVoiceConfig(value.voice),
|
|
55
|
+
slashPanel: normalizeSlashPanelConfig(value.slashPanel),
|
|
54
56
|
groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
|
|
55
57
|
deletionPending: value.deletionPending === true,
|
|
56
58
|
connectedAt: cleanString(value.connectedAt),
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Feishu "/" command panel of one bot: which commands it offers and in
|
|
3
|
+
* which order.
|
|
4
|
+
*
|
|
5
|
+
* The panel is stored server-side per Feishu app and is NOT pushed by
|
|
6
|
+
* dsh/Harness, so dsh-im holds a manifest and registers it through
|
|
7
|
+
* `slash-command-registry.mjs`. This module owns that manifest plus the per-bot
|
|
8
|
+
* configuration that narrows and orders it.
|
|
9
|
+
*
|
|
10
|
+
* Reference (official):
|
|
11
|
+
* https://open.feishu.cn/document/mcp_open_tools/agent-best-practices/agent-supports-slash-commands
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Icon keys are the documented values in the Feishu Slash Command doc.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_SLASH_COMMAND_ICON = 'ai-agent_outlined';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The dsh-im Feishu command manifest. Every entry's `command` is registered
|
|
21
|
+
* WITHOUT the leading slash; Feishu displays it as "/<command>" in the panel
|
|
22
|
+
* and sends "/<command>" back as text, which matches the bridge's regexes.
|
|
23
|
+
*
|
|
24
|
+
* Descriptions should stay short and match what the command actually does in
|
|
25
|
+
* bridge.mjs / the shared command modules.
|
|
26
|
+
*/
|
|
27
|
+
export const SLASH_COMMAND_MANIFEST = Object.freeze([
|
|
28
|
+
{ command: 'menu', icon: 'skill_outlined', default: '打开功能菜单', en_us: 'Open the feature menu' },
|
|
29
|
+
{ command: 'new', icon: 'ai-deepthink_outlined', default: '开启全新会话', en_us: 'Start a fresh session' },
|
|
30
|
+
{ command: 'help', icon: 'promptword_outlined', default: '查看帮助', en_us: 'Show help' },
|
|
31
|
+
{ command: 'status', icon: 'ai-functions_outlined', default: '查看机器人状态', en_us: 'Show bot status' },
|
|
32
|
+
{ command: 'compact', icon: 'ai-block_outlined', default: '压缩当前会话上下文', en_us: 'Compact the current session' },
|
|
33
|
+
{ command: 'sessionlist', icon: 'chat-ai_outlined', default: '列出会话', en_us: 'List sessions' },
|
|
34
|
+
{ command: 'workspacelist', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
|
|
35
|
+
{ command: 'workspaces', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
|
|
36
|
+
{ command: 'wsl', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
|
|
37
|
+
{ command: 'ws', icon: 'folder_outlined', default: '切换工作区', en_us: 'Switch workspace' },
|
|
38
|
+
{ command: 'watch', icon: 'flag_outlined', default: '关注一个会话', en_us: 'Watch a session' },
|
|
39
|
+
{ command: 'unwatch', icon: 'clear_outlined', default: '取消关注会话', en_us: 'Unwatch a session' },
|
|
40
|
+
{ command: 'watchlist', icon: 'flag_outlined', default: '查看关注列表', en_us: 'List watched sessions' },
|
|
41
|
+
{ command: 'archived', icon: 'folder_outlined', default: '设置归档会话显隐(on/off)', en_us: 'Show or hide archived sessions (on/off)' },
|
|
42
|
+
{ command: 'history', icon: 'chat-ai_outlined', default: '查看最近历史消息(仅私聊)', en_us: 'Show recent history (private chats only)' },
|
|
43
|
+
{ command: 'workspace', icon: 'folder_outlined', default: '切换工作区', en_us: 'Switch workspace' },
|
|
44
|
+
{ command: 'conv', icon: 'folder_outlined', default: '设置当前对话专属工作区', en_us: 'Set the workspace for this conversation' },
|
|
45
|
+
{ command: 'session', icon: 'chat-ai_outlined', default: '绑定已有会话', en_us: 'Bind an existing session' },
|
|
46
|
+
{ command: 'models', icon: 'ai-functions_outlined', default: '列出可用模型', en_us: 'List available models' },
|
|
47
|
+
{ command: 'model', icon: 'ai-agent_outlined', default: '查看或切换当前模型', en_us: 'Show or switch the current model' },
|
|
48
|
+
{ command: 'reasoninglist', icon: 'ai-deepthink_outlined', default: '列出可用推理等级', en_us: 'List available reasoning efforts' },
|
|
49
|
+
{ command: 'reasoning', icon: 'ai-deepthink_outlined', default: '查看或切换推理等级', en_us: 'Show or switch the reasoning effort' },
|
|
50
|
+
{ command: 'presetlist', icon: 'skill_outlined', default: '列出可用 Agent 预设', en_us: 'List available Agent Presets' },
|
|
51
|
+
{ command: 'preset', icon: 'skill_outlined', default: '查看或切换 Agent 预设', en_us: 'Show or switch the Agent Preset' },
|
|
52
|
+
{ command: 'stop', icon: 'clear_outlined', default: '停止当前任务', en_us: 'Stop the current task' },
|
|
53
|
+
{ command: 'steer', icon: 'promptword_outlined', default: '给当前任务补充指令', en_us: 'Send additional instructions to the current task' },
|
|
54
|
+
{ command: 'batch', icon: 'chat-ai_outlined', default: '开始批量输入(仅私聊)', en_us: 'Start batch input (private chats only)' },
|
|
55
|
+
{ command: 'send', icon: 'chat-ai_outlined', default: '提交当前批次(仅私聊)', en_us: 'Submit the current batch (private chats only)' },
|
|
56
|
+
{ command: 'cancel', icon: 'clear_outlined', default: '取消当前批次(仅私聊)', en_us: 'Cancel the current batch (private chats only)' },
|
|
57
|
+
{ command: 'version', icon: 'ai-functions_outlined', default: '查看插件版本', en_us: 'Show the plugin version' },
|
|
58
|
+
{ command: 'repair', icon: 'ai-functions_outlined', default: '补全飞书权限与卡片回调(仅私聊)', en_us: 'Complete Feishu permissions and card callbacks (private chats only)' },
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How the "/" panel of one bot is built.
|
|
63
|
+
*
|
|
64
|
+
* - `default` follows the shipped manifest: every command is registered, in the
|
|
65
|
+
* manifest's own order, and nothing already registered is removed. This is
|
|
66
|
+
* what every bot did before the panel became configurable.
|
|
67
|
+
* - `custom` follows `order`: exactly those commands, in that order. Commands
|
|
68
|
+
* from the manifest that are not listed are removed from the app, and the
|
|
69
|
+
* panel is rebuilt when its order no longer matches — Feishu returns the
|
|
70
|
+
* panel in creation order, so holding a chosen order means recreating the
|
|
71
|
+
* commands.
|
|
72
|
+
*
|
|
73
|
+
* Hidden is not the same as disabled: the panel only decides what the input box
|
|
74
|
+
* offers, and a command removed from it still works when typed.
|
|
75
|
+
*/
|
|
76
|
+
export const SLASH_PANEL_MODES = Object.freeze({
|
|
77
|
+
DEFAULT: 'default',
|
|
78
|
+
CUSTOM: 'custom',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const DEFAULT_SLASH_PANEL_CONFIG = Object.freeze({
|
|
82
|
+
mode: SLASH_PANEL_MODES.DEFAULT,
|
|
83
|
+
order: Object.freeze([]),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** A bot may pin at most this many commands; the shipped manifest is smaller. */
|
|
87
|
+
export const MAX_SLASH_PANEL_COMMANDS = 100;
|
|
88
|
+
|
|
89
|
+
/** Command names as the API and the stored config spell them (no leading "/"). */
|
|
90
|
+
function normalizeCommandName(value) {
|
|
91
|
+
if (typeof value !== 'string') return null;
|
|
92
|
+
const name = value.trim().replace(/^\//u, '').toLowerCase();
|
|
93
|
+
return name || null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function manifestHas(name) {
|
|
97
|
+
return SLASH_COMMAND_MANIFEST.some((entry) => entry.command === name);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Normalize a stored panel config, dropping anything the code cannot serve: an
|
|
102
|
+
* unknown name would put a dead command in the panel, and a duplicate would
|
|
103
|
+
* register twice. Never throws — a damaged value falls back to the default.
|
|
104
|
+
*/
|
|
105
|
+
export function normalizeSlashPanelConfig(value) {
|
|
106
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
107
|
+
return DEFAULT_SLASH_PANEL_CONFIG;
|
|
108
|
+
}
|
|
109
|
+
if (value.mode !== SLASH_PANEL_MODES.CUSTOM) return DEFAULT_SLASH_PANEL_CONFIG;
|
|
110
|
+
const order = [];
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
for (const candidate of Array.isArray(value.order) ? value.order : []) {
|
|
113
|
+
const name = normalizeCommandName(candidate);
|
|
114
|
+
if (!name || seen.has(name) || !manifestHas(name)) continue;
|
|
115
|
+
seen.add(name);
|
|
116
|
+
order.push(name);
|
|
117
|
+
if (order.length >= MAX_SLASH_PANEL_COMMANDS) break;
|
|
118
|
+
}
|
|
119
|
+
return Object.freeze({ mode: SLASH_PANEL_MODES.CUSTOM, order: Object.freeze(order) });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Strict counterpart of `normalizeSlashPanelConfig` for RPC payloads: a caller
|
|
124
|
+
* has to send exactly what it means, and damaged input is reported instead of
|
|
125
|
+
* being silently rewritten into a different panel.
|
|
126
|
+
*/
|
|
127
|
+
export function isSlashPanelConfig(value) {
|
|
128
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
129
|
+
const keys = Reflect.ownKeys(value);
|
|
130
|
+
if (keys.length !== 2 || !keys.includes('mode') || !keys.includes('order')) return false;
|
|
131
|
+
if (!Array.isArray(value.order) || value.order.length > MAX_SLASH_PANEL_COMMANDS) return false;
|
|
132
|
+
// Following the shipped manifest carries no list of its own: the manifest is
|
|
133
|
+
// the list, and it moves with the plugin version.
|
|
134
|
+
if (value.mode === SLASH_PANEL_MODES.DEFAULT) return value.order.length === 0;
|
|
135
|
+
if (value.mode !== SLASH_PANEL_MODES.CUSTOM) return false;
|
|
136
|
+
if (new Set(value.order).size !== value.order.length) return false;
|
|
137
|
+
return value.order.every((entry) => normalizeCommandName(entry) === entry
|
|
138
|
+
&& manifestHas(entry));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Whether the config asks the panel to be exactly its `order`. */
|
|
142
|
+
export function isCustomSlashPanel(config) {
|
|
143
|
+
return normalizeSlashPanelConfig(config).mode === SLASH_PANEL_MODES.CUSTOM;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The manifest entries a bot should offer, in panel order. `default` returns the
|
|
148
|
+
* shipped manifest untouched, so an upgrade never reshapes an existing bot.
|
|
149
|
+
*/
|
|
150
|
+
export function resolveSlashPanelManifest(config, manifest = SLASH_COMMAND_MANIFEST) {
|
|
151
|
+
const normalized = normalizeSlashPanelConfig(config);
|
|
152
|
+
if (normalized.mode !== SLASH_PANEL_MODES.CUSTOM) return manifest;
|
|
153
|
+
const byName = new Map(manifest.map((entry) => [entry.command, entry]));
|
|
154
|
+
return normalized.order.map((name) => byName.get(name)).filter(Boolean);
|
|
155
|
+
}
|