@xmanrui/dsh-im 4.1.0 → 4.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -305,7 +305,7 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
305
305
  ? "使用飞书确认群消息权限"
306
306
  : "使用飞书扫码创建机器人"),
307
307
  h("p", null, repairing
308
- ? "扫码会更新现有飞书应用,最多增量补充卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”),以及上传机器人图片或文件所需的 im:resource;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。"
308
+ ? "扫码会更新现有飞书应用,增量补充当前缺少的卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource,以及原生命令面板所需的 application:app_slash_command:read / write;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。"
309
309
  : grantingGroupMessages
310
310
  ? "扫码会更新现有飞书应用,只增量开通“获取群组中所有消息”权限;不会创建新应用。确认后会自动启用“响应所有群消息”,其他机器人不受影响。"
311
311
  : "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
@@ -666,7 +666,7 @@ export function BotCard({
666
666
  role: "tooltip",
667
667
  },
668
668
  h("strong", null, "补全范围"),
669
- h("span", null, "最多增量添加卡片回调 card.action.trigger、读取消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”),以及上传机器人图片或文件所需的 im:resource;确认页只显示当前缺少项,不会创建新应用。"))),
669
+ h("span", null, "增量添加当前缺少的卡片回调 card.action.trigger、读取消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource,以及原生命令面板所需的 application:app_slash_command:read / write;确认页只显示当前缺少项,不会创建新应用。"))),
670
670
  h(Button, {
671
671
  className: "dim-cardAction", kind: "danger", onClick: onRequestRemove,
672
672
  disabled: Boolean(busy), ref: removeButtonRef,
@@ -357,7 +357,7 @@ const EN = Object.freeze({
357
357
  '权限配置已提交,正在启用全部消息模式并重连此机器人;此阶段无法取消,其他机器人不会中断。': 'The permission update was submitted. Enabling all-message mode and reconnecting this bot. This stage cannot be cancelled; other bots will not be interrupted.',
358
358
  '正在为现有飞书应用申请群消息权限二维码,请稍候。': 'Requesting a group-message permission QR code for the existing Feishu app…',
359
359
  '群消息权限没有开通完成': 'Group-message permission was not granted',
360
- '扫码会更新现有飞书应用,最多增量补充卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”),以及上传机器人图片或文件所需的 im:resource;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。': 'Scanning updates the existing Feishu app with up to three missing items: the card-button callback, im:message:readonly for reading images or files in user messages (shown by Feishu as “Read direct and group messages”), and im:resource for uploading images or files sent by the bot. It does not create a new app. The confirmation page shows only missing items; this bot reconnects briefly afterward, while other bots are unaffected.',
360
+ '扫码会更新现有飞书应用,增量补充当前缺少的卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource,以及原生命令面板所需的 application:app_slash_command:read / write;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。': 'Scanning updates the existing Feishu app with the missing card-button callback, im:message:readonly for reading images or files in user messages (shown by Feishu as “Read direct and group messages”), im:resource for uploading images or files sent by the bot, and application:app_slash_command:read / write for the native command panel. It does not create a new app. The confirmation page shows only missing items; this bot reconnects briefly afterward, while other bots are unaffected.',
361
361
  '核对现有应用名称,并确认只新增当前缺少的上述配置': 'Review the existing app name and confirm that only the missing items described above are added',
362
362
  '保持本页打开,等待权限与回调补全完成': 'Keep this page open until permissions and the callback are complete',
363
363
  '取消补全': 'Cancel setup',
@@ -368,7 +368,7 @@ const EN = Object.freeze({
368
368
  '权限与回调没有补全完成': 'Permissions and callback setup did not finish',
369
369
  '补全权限': 'Complete permissions',
370
370
  '补全范围': 'Completion scope',
371
- '最多增量添加卡片回调 card.action.trigger、读取消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”),以及上传机器人图片或文件所需的 im:resource;确认页只显示当前缺少项,不会创建新应用。': 'Adds up to the missing card callback card.action.trigger, im:message:readonly for reading images or files in messages (shown by Feishu as “Read direct and group messages”), and im:resource for uploading images or files sent by the bot. The confirmation page shows only missing items; no new app is created.',
371
+ '增量添加当前缺少的卡片回调 card.action.trigger、读取消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource,以及原生命令面板所需的 application:app_slash_command:read / write;确认页只显示当前缺少项,不会创建新应用。': 'Adds the missing card callback card.action.trigger, im:message:readonly for reading images or files in messages (shown by Feishu as “Read direct and group messages”), im:resource for uploading images or files sent by the bot, and application:app_slash_command:read / write for the native command panel. The confirmation page shows only missing items; no new app is created.',
372
372
  '等待扫码…': 'Waiting for scan…',
373
373
  '飞书服务返回了不匹配的权限补全二维码': 'Feishu returned a permission-completion QR code for a different bot',
374
374
  '飞书服务返回了不匹配的群消息权限二维码': 'Feishu returned a group-message permission QR code for a different bot',
@@ -172,6 +172,7 @@ export async function createProductionController(ctx, config = {}, internals = {
172
172
  state: workspaceScope.state,
173
173
  contextEnhancement: { botId: id, getSettings: () => workspaces.contextEnhancementFor(id) },
174
174
  replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
175
+ slashCommands: config.slashCommands !== false,
175
176
  ...(wsAgent ? { wsAgent } : {}),
176
177
  logger: {
177
178
  error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args),
@@ -1399,7 +1399,7 @@ export class FeishuHarnessBridge {
1399
1399
  : t('链接约 {minutes} 分钟后过期', { minutes: Math.max(1, Math.ceil(remaining / 60)) });
1400
1400
  await this.#send(chatId, [
1401
1401
  restarted ? t('旧授权链接已作废,已生成新的修复链接。') : t('🔧 准备补全权限与回调。'),
1402
- t('本次最多增量添加三项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);以及 im:resource(用于上传机器人发送的图片或文件)。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。'),
1402
+ t('本次会增量添加当前缺少项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);im:resource(用于上传机器人发送的图片或文件);以及原生命令面板所需的 application:app_slash_command:read / write。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。'),
1403
1403
  '',
1404
1404
  t('当前设备直接打开:'),
1405
1405
  url,
@@ -3,6 +3,10 @@ import { FeishuHarnessBridge } from './bridge.mjs';
3
3
  import { cardActionProbeCard } from './feishu-cards.mjs';
4
4
  import { VerifiedFeishuChannel } from './feishu-channel.mjs';
5
5
  import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
6
+ import {
7
+ registerSlashCommands,
8
+ SLASH_COMMAND_MANIFEST,
9
+ } from './slash-command-registry.mjs';
6
10
  import {
7
11
  connectionTestTargetUnavailable,
8
12
  sendRememberedConnectionTest,
@@ -84,6 +88,11 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
84
88
  agentPreset: 'standard',
85
89
  authorizationMode: 'sender-open-id-allowlist',
86
90
  allowedSenderCount,
91
+ slashCommandRegistration: 'idle',
92
+ slashCommandsRegistered: 0,
93
+ slashCommandsExisting: 0,
94
+ slashCommandsFailed: 0,
95
+ slashCommandsError: null,
87
96
  };
88
97
  }
89
98
 
@@ -119,6 +128,7 @@ export class FeishuRuntime {
119
128
  #abortController = null;
120
129
  #pendingCardActionProbes = new Map();
121
130
  #status;
131
+ #slashCommands = true;
122
132
 
123
133
  constructor({
124
134
  lark,
@@ -137,6 +147,7 @@ export class FeishuRuntime {
137
147
  replyTimeoutMs = 600000,
138
148
  connectTimeoutMs = 15000,
139
149
  requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
150
+ slashCommands = true,
140
151
  wsAgent,
141
152
  logger = console,
142
153
  }) {
@@ -169,6 +180,7 @@ export class FeishuRuntime {
169
180
  this.#replyTimeoutMs = replyTimeoutMs;
170
181
  this.#connectTimeoutMs = connectTimeoutMs;
171
182
  this.#requestTimeoutMs = requestTimeoutMs;
183
+ this.#slashCommands = Boolean(slashCommands);
172
184
  this.#wsAgent = wsAgent;
173
185
  this.#logger = logger;
174
186
  this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length });
@@ -368,6 +380,12 @@ export class FeishuRuntime {
368
380
  });
369
381
  await Promise.all([wsStarted, ready]);
370
382
  assertCurrentStart();
383
+ // Register the native Slash Command panel best-effort and asynchronously
384
+ // so it never blocks the long-connection startup. The panel is only a
385
+ // client-side convenience; failure here must not take the bot down.
386
+ if (this.#slashCommands && httpInstance) {
387
+ void this.#registerSlashCommands(httpInstance, isCurrentStart, signal);
388
+ }
371
389
  return this.status;
372
390
  } catch (error) {
373
391
  // stop() owns the terminal idle state for an explicitly aborted start.
@@ -598,6 +616,44 @@ export class FeishuRuntime {
598
616
  return { sent: true };
599
617
  }
600
618
 
619
+ async #registerSlashCommands(httpInstance, isCurrentStart, signal) {
620
+ this.#status.slashCommandRegistration = 'registering';
621
+ this.#status.slashCommandsError = null;
622
+ try {
623
+ const result = await registerSlashCommands({
624
+ appId: this.#appId,
625
+ appSecret: this.#appSecret,
626
+ domain: this.#domain,
627
+ httpInstance,
628
+ signal,
629
+ manifest: SLASH_COMMAND_MANIFEST,
630
+ });
631
+ if (!isCurrentStart()) return;
632
+ this.#status.slashCommandRegistration = 'done';
633
+ this.#status.slashCommandsRegistered = result.created.length;
634
+ this.#status.slashCommandsExisting = result.existing.length;
635
+ this.#status.slashCommandsFailed = result.failed.length;
636
+ this.#status.slashCommandsError = result.failed.length > 0
637
+ ? result.failed.map((f) => `/${f.command}: ${f.error?.message ?? String(f.error)}`).join('; ')
638
+ : null;
639
+ if (result.created.length > 0) {
640
+ this.#logger.info?.(`[dsh-feishu] registered ${result.created.length} slash command(s)`);
641
+ }
642
+ if (result.failed.length > 0) {
643
+ this.#logger.warn?.(
644
+ `[dsh-feishu] ${result.failed.length} slash command(s) failed to register: ${this.#status.slashCommandsError}`,
645
+ );
646
+ }
647
+ } catch (error) {
648
+ if (!isCurrentStart()) return;
649
+ this.#status.slashCommandRegistration = 'failed';
650
+ this.#status.slashCommandsError = error?.message ?? String(error);
651
+ this.#logger.warn?.(
652
+ `[dsh-feishu] slash command registration skipped: ${this.#status.slashCommandsError}`,
653
+ );
654
+ }
655
+ }
656
+
601
657
  stop(options = {}) {
602
658
  if (this.#stopping) return this.#stopping;
603
659
 
@@ -642,6 +698,7 @@ export class FeishuRuntime {
642
698
  if (bridge) await bridge.waitForIdle();
643
699
  this.#client = null;
644
700
  this.#status.feishuLongConnectionState = preserveError ? 'failed' : 'idle';
701
+ this.#status.slashCommandRegistration = 'idle';
645
702
  this.#status.lastError = error;
646
703
  return this.status;
647
704
  }
@@ -1,4 +1,5 @@
1
1
  import { RegistrationManager } from './registration-manager.mjs';
2
+ import { SLASH_COMMAND_TENANT_SCOPES } from './slash-command-registry.mjs';
2
3
 
3
4
  export const FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
4
5
 
@@ -11,6 +12,7 @@ export const REQUIRED_TENANT_SCOPES = Object.freeze([
11
12
  'im:message:recall',
12
13
  'im:resource',
13
14
  'cardkit:card:write',
15
+ ...SLASH_COMMAND_TENANT_SCOPES,
14
16
  ]);
15
17
 
16
18
  function safeConnectionStatus(runtime) {
@@ -1,4 +1,5 @@
1
1
  import { RegistrationManager } from './registration-manager.mjs';
2
+ import { SLASH_COMMAND_TENANT_SCOPES } from './slash-command-registry.mjs';
2
3
 
3
4
  export const CARD_ACTION_CALLBACK = 'card.action.trigger';
4
5
  export const FEISHU_MESSAGE_READ_SCOPE = 'im:message:readonly';
@@ -74,9 +75,10 @@ export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu')
74
75
  * One targeted update attempt for an existing Feishu app. It intentionally
75
76
  * shares RegistrationManager's polling/state implementation while fixing the
76
77
  * update manifest in one place so callers can add only the card callback, the
77
- * message-read scope needed to download user-sent media, and the resource
78
- * scope needed to upload bot-sent images/files, without adding unrelated
79
- * scopes, events, presets, or createOnly.
78
+ * message-read scope needed to download user-sent media, the resource scope
79
+ * needed to upload bot-sent images/files, and the Slash Command scopes needed
80
+ * for the native command panel, without adding unrelated scopes, events,
81
+ * presets, or createOnly.
80
82
  */
81
83
  export class CallbackRepairManager {
82
84
  #manager;
@@ -110,7 +112,13 @@ export class CallbackRepairManager {
110
112
  appId: this.#appId,
111
113
  addons: {
112
114
  preset: false,
113
- scopes: { tenant: [FEISHU_MESSAGE_READ_SCOPE, FEISHU_RESOURCE_SCOPE] },
115
+ scopes: {
116
+ tenant: [
117
+ FEISHU_MESSAGE_READ_SCOPE,
118
+ FEISHU_RESOURCE_SCOPE,
119
+ ...SLASH_COMMAND_TENANT_SCOPES,
120
+ ],
121
+ },
114
122
  callbacks: { items: [CARD_ACTION_CALLBACK] },
115
123
  },
116
124
  });
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Feishu native Slash Command registration for the dsh-im Feishu channel.
3
+ *
4
+ * The Feishu client shows a "/" command panel in the chat input box. The
5
+ * command list is stored server-side per bot application and is NOT pushed
6
+ * by dsh/Harness. dsh-im holds its own static command manifest and calls the
7
+ * Feishu OpenAPI to register it, so users can discover commands by typing "/".
8
+ *
9
+ * Reference (official):
10
+ * https://open.feishu.cn/document/mcp_open_tools/agent-best-practices/agent-supports-slash-commands
11
+ *
12
+ * The registered command panel is only a client-side convenience: when a user
13
+ * taps a command, Feishu sends it to the bot as an ordinary text message via
14
+ * im.message.receive_v1. The bridge's #handle() command matcher therefore
15
+ * needs no changes as long as every registered command name matches the
16
+ * existing "/xxx" text commands.
17
+ */
18
+
19
+ const SLASH_ENDPOINT = '/open-apis/application/v7/app_slash_commands';
20
+ const MISSING_PERMISSION_CODES = new Set(['99991640', '99991672']);
21
+
22
+ export const SLASH_COMMAND_TENANT_SCOPES = Object.freeze([
23
+ 'application:app_slash_command:read',
24
+ 'application:app_slash_command:write',
25
+ ]);
26
+
27
+ // Icon keys are the documented values in the Feishu Slash Command doc.
28
+ const DEFAULT_ICON = 'ai-agent_outlined';
29
+
30
+ /**
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.
37
+ */
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: 'watch', icon: 'flag_outlined', default: '关注一个会话', en_us: 'Watch a session' },
47
+ { command: 'unwatch', icon: 'clear_outlined', default: '取消关注会话', en_us: 'Unwatch a session' },
48
+ { command: 'watchlist', icon: 'flag_outlined', default: '查看关注列表', en_us: 'List watched sessions' },
49
+ { command: 'archived', icon: 'folder_outlined', default: '设置归档会话显隐(on/off)', en_us: 'Show or hide archived sessions (on/off)' },
50
+ ]);
51
+
52
+ // Commands that require a parameter are registered too, so the user can type
53
+ // "/watch <session ID>" from the panel. A leading placeholder hint is not part of
54
+ // the registered name; Feishu only allows a plain command token.
55
+
56
+ function endpointFor(domain, path) {
57
+ const origin = domain === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
58
+ return new URL(path, origin);
59
+ }
60
+
61
+ function jsonResponse(body, operation) {
62
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
63
+ throw new Error(`${operation} returned a non-JSON response`);
64
+ }
65
+ if (body.code !== 0) {
66
+ const error = new Error(`${operation} failed: ${body.msg || `code ${body.code}`}`);
67
+ error.code = String(body.code);
68
+ error.msg = body.msg;
69
+ throw error;
70
+ }
71
+ return body;
72
+ }
73
+
74
+ function requestSignal(signal, timeoutMs) {
75
+ const timeout = AbortSignal.timeout(timeoutMs);
76
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
77
+ }
78
+
79
+ async function requestJson(httpInstance, options, operation) {
80
+ try {
81
+ return jsonResponse(await httpInstance.request(options), operation);
82
+ } catch (error) {
83
+ const body = error?.response?.data;
84
+ if (body && typeof body === 'object' && !Array.isArray(body)
85
+ && Object.hasOwn(body, 'code')) {
86
+ return jsonResponse(body, operation);
87
+ }
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ /** Fetch a tenant_access_token for the app. */
93
+ async function fetchTenantAccessToken({
94
+ appId, appSecret, domain, httpInstance, timeoutMs, signal,
95
+ }) {
96
+ if (!appId || !appSecret) throw new Error('Feishu slash registration requires app credentials');
97
+ if (!httpInstance || typeof httpInstance.request !== 'function') {
98
+ throw new TypeError('Feishu slash registration requires an HTTP instance');
99
+ }
100
+ const body = await requestJson(httpInstance, {
101
+ method: 'POST',
102
+ url: endpointFor(domain, '/open-apis/auth/v3/tenant_access_token/internal').href,
103
+ headers: { 'content-type': 'application/json; charset=utf-8' },
104
+ data: { app_id: appId, app_secret: appSecret },
105
+ signal: requestSignal(signal, timeoutMs),
106
+ timeout: timeoutMs,
107
+ }, 'Feishu authentication');
108
+ if (!body.tenant_access_token) {
109
+ throw new Error('Feishu authentication returned no tenant access token');
110
+ }
111
+ return body.tenant_access_token;
112
+ }
113
+
114
+ async function listSlashCommandsWithToken({
115
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal,
116
+ }) {
117
+ const body = await requestJson(httpInstance, {
118
+ method: 'GET',
119
+ url: endpointFor(domain, SLASH_ENDPOINT).href,
120
+ headers: {
121
+ authorization: `Bearer ${tenantAccessToken}`,
122
+ 'content-type': 'application/json; charset=utf-8',
123
+ },
124
+ signal: requestSignal(signal, timeoutMs),
125
+ timeout: timeoutMs,
126
+ }, 'Feishu slash command list');
127
+ return Array.isArray(body.data?.items) ? body.data.items : [];
128
+ }
129
+
130
+ /** List every slash command currently registered for the app. */
131
+ export async function listSlashCommands({
132
+ appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000, signal,
133
+ }) {
134
+ const tenantAccessToken = await fetchTenantAccessToken({
135
+ appId, appSecret, domain, httpInstance, timeoutMs, signal,
136
+ });
137
+ return listSlashCommandsWithToken({
138
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal,
139
+ });
140
+ }
141
+
142
+ async function createSlashCommandWithToken({
143
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal,
144
+ command, description, icon = DEFAULT_ICON,
145
+ }) {
146
+ const data = { command };
147
+ if (description && (description.default_value || description.i18n)) {
148
+ data.description = description;
149
+ } else if (typeof description === 'string' && description.trim()) {
150
+ data.description = { default_value: description.trim() };
151
+ }
152
+ if (icon) data.description = { ...(data.description ?? {}), icon: { icon_key: icon } };
153
+ const body = await requestJson(httpInstance, {
154
+ method: 'POST',
155
+ url: endpointFor(domain, SLASH_ENDPOINT).href,
156
+ headers: {
157
+ authorization: `Bearer ${tenantAccessToken}`,
158
+ 'content-type': 'application/json; charset=utf-8',
159
+ },
160
+ data,
161
+ signal: requestSignal(signal, timeoutMs),
162
+ timeout: timeoutMs,
163
+ }, `Feishu slash command create (/${command})`);
164
+ return body.data?.command_id ?? null;
165
+ }
166
+
167
+ /** Register a single slash command. Returns the server-assigned command_id. */
168
+ export async function createSlashCommand({
169
+ appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000,
170
+ signal, command, description, icon = DEFAULT_ICON,
171
+ }) {
172
+ const tenantAccessToken = await fetchTenantAccessToken({
173
+ appId, appSecret, domain, httpInstance, timeoutMs, signal,
174
+ });
175
+ return createSlashCommandWithToken({
176
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal,
177
+ command, description, icon,
178
+ });
179
+ }
180
+
181
+ /** Delete a registered slash command by its server command_id. */
182
+ export async function deleteSlashCommand({
183
+ appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000, signal, commandId,
184
+ }) {
185
+ const tenantAccessToken = await fetchTenantAccessToken({
186
+ appId, appSecret, domain, httpInstance, timeoutMs, signal,
187
+ });
188
+ await requestJson(httpInstance, {
189
+ method: 'DELETE',
190
+ url: endpointFor(domain, `${SLASH_ENDPOINT}/${commandId}`).href,
191
+ headers: { authorization: `Bearer ${tenantAccessToken}` },
192
+ signal: requestSignal(signal, timeoutMs),
193
+ timeout: timeoutMs,
194
+ }, 'Feishu slash command delete');
195
+ }
196
+
197
+ /**
198
+ * Best-effort sync of the manifest into the app's registered slash commands.
199
+ * Creates any command in the manifest that is not yet registered and returns
200
+ * a structured report. This is idempotent (the API rejects duplicates with
201
+ * "command already exists", so we skip existing names).
202
+ *
203
+ * @returns {{ created: Array<{command,command_id}>, existing: string[], failed: Array<{command,error}> }}
204
+ */
205
+ export async function registerSlashCommands({
206
+ appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000,
207
+ signal, manifest = SLASH_COMMAND_MANIFEST,
208
+ }) {
209
+ const tenantAccessToken = await fetchTenantAccessToken({
210
+ appId, appSecret, domain, httpInstance, timeoutMs, signal,
211
+ });
212
+ const existing = new Set((await listSlashCommandsWithToken({
213
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal,
214
+ }))
215
+ .map((item) => item.command));
216
+
217
+ const created = [];
218
+ const failed = [];
219
+ for (const entry of manifest) {
220
+ const command = String(entry.command ?? '').replace(/^\//, '');
221
+ if (!command) continue;
222
+ if (existing.has(command)) continue;
223
+ try {
224
+ const description = {
225
+ default_value: entry.default ?? entry.en_us ?? command,
226
+ i18n: {
227
+ zh_cn: entry.default ?? command,
228
+ en_us: entry.en_us ?? entry.default ?? command,
229
+ },
230
+ };
231
+ const commandId = await createSlashCommandWithToken({
232
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal,
233
+ command, description, icon: entry.icon ?? DEFAULT_ICON,
234
+ });
235
+ created.push({ command, command_id: commandId });
236
+ } catch (error) {
237
+ // "command already exists" can race with concurrent runs; treat as existing.
238
+ if (error?.code === '40000000' && /already exists/i.test(error?.msg ?? '')) {
239
+ existing.add(command);
240
+ continue;
241
+ }
242
+ if (MISSING_PERMISSION_CODES.has(error?.code)
243
+ || /(?:lacks permission|access denied)/i.test(error?.msg ?? '')) {
244
+ // Missing app_slash_command:write permission; abort the batch.
245
+ failed.push({ command, error });
246
+ break;
247
+ }
248
+ failed.push({ command, error: error?.message ?? String(error) });
249
+ }
250
+ }
251
+
252
+ return {
253
+ created,
254
+ existing: [...existing].filter((c) => c !== null && c !== undefined),
255
+ failed,
256
+ };
257
+ }
258
+
259
+ export default registerSlashCommands;
@@ -109,6 +109,15 @@ function conversationKey(message) {
109
109
  return `${message.kind}:${message.kind === 'group' ? message.groupOpenid : message.senderId}`;
110
110
  }
111
111
 
112
+ function senderAllowed(message, ownerUserOpenid) {
113
+ // QR binding yields a C2C user_openid, while group events identify senders
114
+ // with a group-scoped member_openid. Treat group membership plus @mention as
115
+ // the access boundary, and keep the scanner restriction for private chats.
116
+ return message?.kind === 'group'
117
+ || ownerUserOpenid === '*'
118
+ || message?.senderId === ownerUserOpenid;
119
+ }
120
+
112
121
  function safeText(message) {
113
122
  return typeof message?.content === 'string' ? message.content.trim() : '';
114
123
  }
@@ -442,7 +451,7 @@ export class QqHarnessBridge {
442
451
  }
443
452
  const pending = this.#pendingInteractions.get(key);
444
453
  const commandText = safeText(message);
445
- const allowed = this.#ownerUserOpenid === '*' || sender === this.#ownerUserOpenid;
454
+ const allowed = senderAllowed(message, this.#ownerUserOpenid);
446
455
  const addressed = message.kind !== 'group'
447
456
  || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
448
457
  const batchCommand = isBatchInputCommand(commandText);
@@ -569,7 +578,7 @@ export class QqHarnessBridge {
569
578
  alreadyRecorded = false,
570
579
  batchSubmission = null,
571
580
  } = {}) {
572
- const allowed = this.#ownerUserOpenid === '*' || message.senderId === this.#ownerUserOpenid;
581
+ const allowed = senderAllowed(message, this.#ownerUserOpenid);
573
582
  const addressed = message.kind !== 'group'
574
583
  || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
575
584
  const preparedMessage = allowed && addressed
@@ -721,7 +730,7 @@ export class QqHarnessBridge {
721
730
  await this.#state.markSeen(messageId);
722
731
  messageRecorded = true;
723
732
  };
724
- if (this.#ownerUserOpenid !== '*' && sender !== this.#ownerUserOpenid) {
733
+ if (!senderAllowed(message, this.#ownerUserOpenid)) {
725
734
  this.#status.messagesRejected += 1;
726
735
  this.#status.lastRejectedAt = new Date().toISOString();
727
736
  return;
@@ -72,8 +72,8 @@ export default {
72
72
  '旧授权链接已作废,已生成新的修复链接。':
73
73
  'The previous authorization link was invalidated and a new repair link was generated.',
74
74
  '🔧 准备补全权限与回调。': '🔧 Preparing to complete permissions and the callback.',
75
- '本次最多增量添加三项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);以及 im:resource(用于上传机器人发送的图片或文件)。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。':
76
- 'This may incrementally add up to three items: the card callback card.action.trigger; the tenant scope im:message:readonly, shown by Feishu as “Read direct and group messages” and used to read images or files in user messages; and im:resource, used to upload images or files sent by the bot. The confirmation page shows only items the app is currently missing; cancel if anything outside this scope appears.',
75
+ '本次会增量添加当前缺少项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);im:resource(用于上传机器人发送的图片或文件);以及原生命令面板所需的 application:app_slash_command:read / write。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。':
76
+ 'This incrementally adds the currently missing items: the card callback card.action.trigger; the tenant scope im:message:readonly, shown by Feishu as “Read direct and group messages” and used to read images or files in user messages; im:resource, used to upload images or files sent by the bot; and application:app_slash_command:read / write for the native command panel. The confirmation page shows only items the app is currently missing; cancel if anything outside this scope appears.',
77
77
  '当前设备直接打开:': 'Open directly on this device:',
78
78
  '若要用另一台设备扫码,发送 /repair qr。{expiry}。':
79
79
  'To scan with another device, send /repair qr. {expiry}.',