@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.
@@ -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
- registerSlashCommands,
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
- #groupTopicReply;
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
- groupTopicReply = false,
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.#groupTopicReply = groupTopicReply === true;
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
- setGroupTopicReply(value) {
227
- this.#groupTopicReply = value === true;
228
- this.#bridge?.setGroupTopicReply(this.#groupTopicReply);
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
- groupTopicReply: this.#groupTopicReply,
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
- void this.#registerSlashCommands(httpInstance, isCurrentStart, signal);
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
- async #registerSlashCommands(httpInstance, isCurrentStart, signal) {
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 registerSlashCommands({
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
- if (!isCurrentStart()) return;
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?.(`[dsh-feishu] registered ${result.created.length} slash command(s)`);
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
- /** Managed-topic group key: one per Feishu topic auto-rooted at a message. */
18
- export function managedGroupKey(chatId, rootMessageId) {
19
- if (!chatId) throw new Error('Feishu managed-topic key needs a chat_id');
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 `group:${chatId}:managed:${rootMessageId}`;
22
- }
23
-
24
- /** Whether a key denotes a topic-isolated group conversation (thread or managed). */
25
- export function isTopicGroupKey(key) {
26
- return typeof key === 'string' && key.startsWith('group:')
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
- export function conversationKey(event) {
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 (typeof threadId === 'string' && threadId.trim()) return `group:${chatId}:thread:${threadId}`;
44
- return `group:${chatId}`;
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
- groupTopicReply: config.groupTopicReply === true,
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 updateGroupTopicReply(botId, groupTopicReply) {
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 (typeof groupTopicReply !== 'boolean') {
611
- throw new TypeError('Invalid Feishu group topic reply flag');
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, groupTopicReply });
616
- this.#runtimes.get(botId)?.setGroupTopicReply?.(saved.groupTopicReply);
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
- groupTopicReply: config.groupTopicReply === true,
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
- groupTopicReply: value.groupTopicReply === true,
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
+ }