@xmanrui/dsh-im 4.29.1 → 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.
@@ -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
+ }
@@ -16,6 +16,17 @@
16
16
  * existing "/xxx" text commands.
17
17
  */
18
18
 
19
+ import {
20
+ DEFAULT_SLASH_COMMAND_ICON,
21
+ SLASH_COMMAND_MANIFEST,
22
+ isCustomSlashPanel,
23
+ resolveSlashPanelManifest,
24
+ } from './slash-command-panel.mjs';
25
+
26
+ // The manifest describes the panel, so it lives with the panel's configuration;
27
+ // re-exported here because this is where callers have always imported it from.
28
+ export { SLASH_COMMAND_MANIFEST };
29
+
19
30
  const SLASH_ENDPOINT = '/open-apis/application/v7/app_slash_commands';
20
31
  const MISSING_PERMISSION_CODES = new Set(['99991640', '99991672']);
21
32
 
@@ -24,50 +35,15 @@ export const SLASH_COMMAND_TENANT_SCOPES = Object.freeze([
24
35
  'application:app_slash_command:write',
25
36
  ]);
26
37
 
27
- // Icon keys are the documented values in the Feishu Slash Command doc.
28
- const DEFAULT_ICON = 'ai-agent_outlined';
38
+ const DEFAULT_ICON = DEFAULT_SLASH_COMMAND_ICON;
29
39
 
30
40
  /**
31
- * The dsh-im Feishu command manifest. Every entry's `command` is registered
32
- * WITHOUT the leading slash; Feishu displays it as "/<command>" in the panel
33
- * and sends "/<command>" back as text, which matches the bridge's regexes.
34
- *
35
- * Descriptions should stay short and match what the command actually does in
36
- * bridge.mjs / the shared command modules.
41
+ * Feishu returns the panel in creation order (newest first, time to the
42
+ * second), so a chosen order is only reachable by creating the commands in
43
+ * reverse, one per second. Anything faster lands in the same second and the
44
+ * order degrades to the platform's tie-break.
37
45
  */
38
- export const SLASH_COMMAND_MANIFEST = Object.freeze([
39
- { command: 'menu', icon: 'skill_outlined', default: '打开功能菜单', en_us: 'Open the feature menu' },
40
- { command: 'new', icon: 'ai-deepthink_outlined', default: '开启全新会话', en_us: 'Start a fresh session' },
41
- { command: 'help', icon: 'promptword_outlined', default: '查看帮助', en_us: 'Show help' },
42
- { command: 'status', icon: 'ai-functions_outlined', default: '查看机器人状态', en_us: 'Show bot status' },
43
- { command: 'compact', icon: 'ai-block_outlined', default: '压缩当前会话上下文', en_us: 'Compact the current session' },
44
- { command: 'sessionlist', icon: 'chat-ai_outlined', default: '列出会话', en_us: 'List sessions' },
45
- { command: 'workspacelist', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
46
- { command: 'workspaces', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
47
- { command: 'wsl', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
48
- { command: 'ws', icon: 'folder_outlined', default: '切换工作区', en_us: 'Switch workspace' },
49
- { command: 'watch', icon: 'flag_outlined', default: '关注一个会话', en_us: 'Watch a session' },
50
- { command: 'unwatch', icon: 'clear_outlined', default: '取消关注会话', en_us: 'Unwatch a session' },
51
- { command: 'watchlist', icon: 'flag_outlined', default: '查看关注列表', en_us: 'List watched sessions' },
52
- { command: 'archived', icon: 'folder_outlined', default: '设置归档会话显隐(on/off)', en_us: 'Show or hide archived sessions (on/off)' },
53
- { command: 'history', icon: 'chat-ai_outlined', default: '查看最近历史消息(仅私聊)', en_us: 'Show recent history (private chats only)' },
54
- { command: 'workspace', icon: 'folder_outlined', default: '切换工作区', en_us: 'Switch workspace' },
55
- { command: 'conv', icon: 'folder_outlined', default: '设置当前对话专属工作区', en_us: 'Set the workspace for this conversation' },
56
- { command: 'session', icon: 'chat-ai_outlined', default: '绑定已有会话', en_us: 'Bind an existing session' },
57
- { command: 'models', icon: 'ai-functions_outlined', default: '列出可用模型', en_us: 'List available models' },
58
- { command: 'model', icon: 'ai-agent_outlined', default: '查看或切换当前模型', en_us: 'Show or switch the current model' },
59
- { command: 'reasoninglist', icon: 'ai-deepthink_outlined', default: '列出可用推理等级', en_us: 'List available reasoning efforts' },
60
- { command: 'reasoning', icon: 'ai-deepthink_outlined', default: '查看或切换推理等级', en_us: 'Show or switch the reasoning effort' },
61
- { command: 'presetlist', icon: 'skill_outlined', default: '列出可用 Agent 预设', en_us: 'List available Agent Presets' },
62
- { command: 'preset', icon: 'skill_outlined', default: '查看或切换 Agent 预设', en_us: 'Show or switch the Agent Preset' },
63
- { command: 'stop', icon: 'clear_outlined', default: '停止当前任务', en_us: 'Stop the current task' },
64
- { command: 'steer', icon: 'promptword_outlined', default: '给当前任务补充指令', en_us: 'Send additional instructions to the current task' },
65
- { command: 'batch', icon: 'chat-ai_outlined', default: '开始批量输入(仅私聊)', en_us: 'Start batch input (private chats only)' },
66
- { command: 'send', icon: 'chat-ai_outlined', default: '提交当前批次(仅私聊)', en_us: 'Submit the current batch (private chats only)' },
67
- { command: 'cancel', icon: 'clear_outlined', default: '取消当前批次(仅私聊)', en_us: 'Cancel the current batch (private chats only)' },
68
- { command: 'version', icon: 'ai-functions_outlined', default: '查看插件版本', en_us: 'Show the plugin version' },
69
- { command: 'repair', icon: 'ai-functions_outlined', default: '补全飞书权限与卡片回调(仅私聊)', en_us: 'Complete Feishu permissions and card callbacks (private chats only)' },
70
- ]);
46
+ export const SLASH_PANEL_CREATE_INTERVAL_MS = 1_100;
71
47
 
72
48
  // Commands that require a parameter are registered too, so the user can type
73
49
  // "/watch <session ID>" from the panel. A leading placeholder hint is not part of
@@ -276,4 +252,244 @@ export async function registerSlashCommands({
276
252
  };
277
253
  }
278
254
 
255
+ /** Delete one registered command with an already-fetched tenant token. */
256
+ async function deleteSlashCommandWithToken({
257
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal, commandId,
258
+ }) {
259
+ await requestJson(httpInstance, {
260
+ method: 'DELETE',
261
+ url: endpointFor(domain, `${SLASH_ENDPOINT}/${commandId}`).href,
262
+ headers: { authorization: `Bearer ${tenantAccessToken}` },
263
+ signal: requestSignal(signal, timeoutMs),
264
+ timeout: timeoutMs,
265
+ }, 'Feishu slash command delete');
266
+ }
267
+
268
+ /** Plain timer used between creates; tests inject their own. */
269
+ function defaultWait(ms, signal) {
270
+ return new Promise((resolve) => {
271
+ const timer = setTimeout(resolve, ms);
272
+ signal?.addEventListener('abort', () => {
273
+ clearTimeout(timer);
274
+ resolve();
275
+ }, { once: true });
276
+ });
277
+ }
278
+
279
+ function commandNameOf(item) {
280
+ return typeof item?.command === 'string' && item.command ? item.command : null;
281
+ }
282
+
283
+ /**
284
+ * The commands of ours in panel order. Feishu returns the list newest-first
285
+ * with second precision, so `create_time` is the panel order and `command_id`
286
+ * only breaks ties inside the same second.
287
+ */
288
+ function orderedOwnedNames(items, owned) {
289
+ return items
290
+ .map((item, index) => ({ item, index }))
291
+ .filter(({ item }) => owned.has(commandNameOf(item)))
292
+ .sort((a, b) => {
293
+ const aTime = Number(a.item.create_time);
294
+ const bTime = Number(b.item.create_time);
295
+ if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return bTime - aTime;
296
+ const aId = Number(a.item.command_id);
297
+ const bId = Number(b.item.command_id);
298
+ if (Number.isFinite(aId) && Number.isFinite(bId) && aId !== bId) return bId - aId;
299
+ return a.index - b.index;
300
+ })
301
+ .map(({ item }) => commandNameOf(item))
302
+ .filter(Boolean);
303
+ }
304
+
305
+ function slashPanelDescription(entry, command) {
306
+ return {
307
+ default_value: entry.default ?? entry.en_us ?? command,
308
+ i18n: {
309
+ zh_cn: entry.default ?? command,
310
+ en_us: entry.en_us ?? entry.default ?? command,
311
+ },
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Sync one bot's "/" panel with its configured manifest.
317
+ *
318
+ * `default` mode is the pre-configurable behaviour: create what is missing and
319
+ * leave everything else alone. `custom` mode converges the app to the
320
+ * configured commands *and order*:
321
+ *
322
+ * 1. read the registered commands (with command_id and create_time);
323
+ * 2. stop when the panel already holds exactly that order — Feishu reports the
324
+ * panel in creation order, so a matching order needs no calls at all;
325
+ * 3. otherwise delete our commands and recreate the configured ones
326
+ * newest-first, one per `intervalMs`, because the order *is* the creation
327
+ * order.
328
+ *
329
+ * Commands registered outside the manifest are never deleted: dsh-im does not
330
+ * own them, and their position is the platform's business.
331
+ *
332
+ * @returns {{ created: Array<{command,command_id}>, deleted: string[],
333
+ * existing: string[], external: string[], failed: Array<{command,error}>,
334
+ * changed: boolean, superseded: boolean }} `superseded` marks a run that
335
+ * stopped early because a newer panel config took over; it reports what it
336
+ * managed to do, and the newer run converges the panel from there.
337
+ */
338
+ export async function syncSlashCommands({
339
+ appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000,
340
+ signal, manifest = SLASH_COMMAND_MANIFEST, config = null,
341
+ intervalMs = SLASH_PANEL_CREATE_INTERVAL_MS, wait = defaultWait,
342
+ }) {
343
+ // A superseded run — the runtime aborts this signal when a newer panel config
344
+ // arrives — must not send further create/delete calls: the newer run re-reads
345
+ // the panel and converges it, and letting both write would leave the older
346
+ // plan's commands behind. Requests already in flight are left to finish.
347
+ const stopped = () => signal?.aborted === true;
348
+ const tenantAccessToken = await fetchTenantAccessToken({
349
+ appId, appSecret, domain, httpInstance, timeoutMs, signal,
350
+ });
351
+ const items = await listSlashCommandsWithToken({
352
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal,
353
+ });
354
+ const owned = new Set(manifest.map((entry) => commandNameOf(entry)).filter(Boolean));
355
+ const external = items
356
+ .map((item) => commandNameOf(item))
357
+ .filter((name) => name && !owned.has(name));
358
+ const registered = new Set(
359
+ items.map((item) => commandNameOf(item)).filter(Boolean),
360
+ );
361
+
362
+ if (!isCustomSlashPanel(config)) {
363
+ const created = [];
364
+ const failed = [];
365
+ for (const entry of resolveSlashPanelManifest(config, manifest)) {
366
+ if (stopped()) break;
367
+ const command = commandNameOf(entry);
368
+ if (!command || registered.has(command)) continue;
369
+ try {
370
+ const commandId = await createSlashCommandWithToken({
371
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal,
372
+ command,
373
+ description: slashPanelDescription(entry, command),
374
+ icon: entry.icon ?? DEFAULT_ICON,
375
+ });
376
+ created.push({ command, command_id: commandId });
377
+ registered.add(command);
378
+ } catch (error) {
379
+ if (stopped()) break;
380
+ if (error?.code === '40000000' && /already exists/i.test(error?.msg ?? '')) {
381
+ registered.add(command);
382
+ continue;
383
+ }
384
+ failed.push({ command, error: error?.message ?? String(error) });
385
+ if (MISSING_PERMISSION_CODES.has(error?.code)
386
+ || /(?:lacks permission|access denied)/i.test(error?.msg ?? '')) {
387
+ break;
388
+ }
389
+ }
390
+ }
391
+ return {
392
+ created,
393
+ deleted: [],
394
+ existing: [...registered].filter(Boolean),
395
+ external,
396
+ failed,
397
+ changed: created.length > 0,
398
+ superseded: stopped(),
399
+ };
400
+ }
401
+
402
+ const current = orderedOwnedNames(items, owned);
403
+ const plannedEntries = resolveSlashPanelManifest(config, manifest)
404
+ .filter((entry) => commandNameOf(entry));
405
+ const plannedNames = plannedEntries.map((entry) => commandNameOf(entry));
406
+ if (current.length === plannedNames.length
407
+ && plannedNames.every((name, index) => name === current[index])) {
408
+ return {
409
+ created: [], deleted: [], existing: current, external, failed: [], changed: false,
410
+ };
411
+ }
412
+
413
+ // A newer config took over while we were reading the panel: leave the rebuild
414
+ // to its run rather than deleting commands for a plan nobody wants anymore.
415
+ if (stopped()) {
416
+ return {
417
+ created: [], deleted: [], existing: current, external, failed: [], changed: false, superseded: true,
418
+ };
419
+ }
420
+
421
+ const byName = new Map(
422
+ items.map((item) => [commandNameOf(item), item]).filter(([name]) => name && owned.has(name)),
423
+ );
424
+ const deleted = [];
425
+ const failed = [];
426
+ for (const name of current) {
427
+ if (stopped()) break;
428
+ const item = byName.get(name);
429
+ if (!item?.command_id) {
430
+ failed.push({ command: name, error: 'the registered command reports no command_id' });
431
+ continue;
432
+ }
433
+ try {
434
+ await deleteSlashCommandWithToken({
435
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal, commandId: item.command_id,
436
+ });
437
+ deleted.push(name);
438
+ } catch (error) {
439
+ if (stopped()) break;
440
+ failed.push({ command: name, error: error?.message ?? String(error) });
441
+ }
442
+ }
443
+ // A superseded run stops before rebuilding: the newer run owns the panel now.
444
+ if (stopped()) {
445
+ return {
446
+ created: [], deleted, existing: current, external, failed, changed: deleted.length > 0, superseded: true,
447
+ };
448
+ }
449
+ // Deleting only part of the panel would leave it half-converged, and
450
+ // recreating a name that still exists is rejected as a duplicate. Stop here;
451
+ // the next sync (config change, reconnect, or a later start) converges.
452
+ if (failed.length > 0) {
453
+ return { created: [], deleted, existing: current, external, failed, changed: deleted.length > 0 };
454
+ }
455
+
456
+ const created = [];
457
+ for (const [index, entry] of [...plannedEntries].reverse().entries()) {
458
+ if (stopped()) break;
459
+ const command = commandNameOf(entry);
460
+ try {
461
+ if (index > 0) {
462
+ await wait(intervalMs, signal);
463
+ // The wait wakes on abort as well, so re-check instead of recording a
464
+ // superseded run as a failed create.
465
+ if (stopped()) break;
466
+ }
467
+ const commandId = await createSlashCommandWithToken({
468
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal,
469
+ command,
470
+ description: slashPanelDescription(entry, command),
471
+ icon: entry.icon ?? DEFAULT_ICON,
472
+ });
473
+ created.push({ command, command_id: commandId });
474
+ } catch (error) {
475
+ if (stopped()) break;
476
+ failed.push({ command, error: error?.message ?? String(error) });
477
+ if (MISSING_PERMISSION_CODES.has(error?.code)
478
+ || /(?:lacks permission|access denied)/i.test(error?.msg ?? '')) {
479
+ break;
480
+ }
481
+ }
482
+ }
483
+
484
+ return {
485
+ created,
486
+ deleted,
487
+ existing: [],
488
+ external,
489
+ failed,
490
+ changed: created.length > 0 || deleted.length > 0,
491
+ superseded: stopped(),
492
+ };
493
+ }
494
+
279
495
  export default registerSlashCommands;