Merge pull request #94 from C3H3-AI/feat/feishu-slash-commands

feat(feishu): 启动时注册飞书原生 Slash Command 命令面板
This commit is contained in:
xiemanR 2026-08-31 01:22:08 +08:00 • committed by GitHub
commit 15c6bd2a45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 799 additions and 225 deletions

View file

@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
## [Unreleased]
### Added / 新增
- 飞书机器人启动时调用 `app_slash_commands` OpenAPI,把常用命令注册为原生 Slash Command,使飞书单聊输入框输入 `/` 弹出命令面板;命令清单由 dsh-im 持有并推送注册,不依赖 dsh/Harness 后端。扫码新建的应用默认申请所需权限,已有应用可通过“补全权限”或 `/repair` 增量补全;注册失败不影响消息收发。
On startup the Feishu bot registers its common commands as native Slash Commands via the `app_slash_commands` OpenAPI, so the `/` panel appears in Feishu direct-message input. The command list is owned and pushed by dsh-im and does not depend on the dsh/Harness backend. New QR-provisioned apps request the required scopes by default, while existing apps can add them through Complete permissions or `/repair`; registration failure does not affect messaging.
## [4.1.1] - 2026-08-31
### Fixed / 修复

View file

@ -60,7 +60,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
Other IM platforms can be added through the same channel-adapter structure.
All nine built-in channels can send JPEG, PNG, and WebP images, plus GIFs sent as image files, with optional captions to Harness. Each image is limited to 5 MB, and images in one message are limited to 20 MB in total. Downloading images or files from Feishu user messages requires the `im:message:readonly` tenant scope, shown on the confirmation page as **Read direct and group messages**; Feishu currently offers no narrower image-only scope for that download endpoint. Apps created through the built-in QR flow request it by default; for existing or manually connected apps, click **Complete permissions** on the IM Bot settings page and scan the QR code to incrementally add that scope, `im:resource` for uploading bot-sent images or files, and the card callback.
All nine built-in channels can send JPEG, PNG, and WebP images, plus GIFs sent as image files, with optional captions to Harness. Each image is limited to 5 MB, and images in one message are limited to 20 MB in total. Downloading images or files from Feishu user messages requires the `im:message:readonly` tenant scope, shown on the confirmation page as **Read direct and group messages**; Feishu currently offers no narrower image-only scope for that download endpoint. Apps created through the built-in QR flow request it by default; for existing or manually connected apps, click **Complete permissions** on the IM Bot settings page and scan the QR code to incrementally add that scope, `im:resource` for uploading bot-sent images or files, `application:app_slash_command:read` / `write` for the native command panel, and the card callback.
### Result-file and image delivery
@ -198,7 +198,7 @@ Do not modify the same profile through a terminal or plugin market during instal
| `/batch` | Start batch input in a direct chat and collect up to 10 text messages. |
| `/send` | Submit the collected messages, in order, as one input. |
| `/cancel` | Cancel batch input and discard its collected messages. |
| `/repair` | In a Feishu direct chat, incrementally repair the card callback and permissions required to read and upload message images or files. |
| `/repair` | In a Feishu direct chat, incrementally repair the card callback and permissions required for media and the native Slash Command panel. |
| `/compact` | Immediately compact older context in the Session bound to the current chat. |
| `/workspace <absolute workspace path>` | Switch the current bot's Harness workspace. |
| `/workspacelist` | List workspace absolute paths that still exist on the current Harness Host. |
@ -212,6 +212,8 @@ Example: send `/models`, then `/model 2` to switch to the second model in the li
If the Slack desktop app has no native Slash Command registered with the same name, it intercepts messages that begin directly with `/`. Send the command with one leading space instead, for example ` /presetlist`, ` /preset 2`, ` /history`, or ` /history 10`; the plugin command layer trims surrounding whitespace, so it executes exactly like the unspaced form.
**Feishu `/` command panel**: On startup the Feishu bot registers its common commands (`menu`, `new`, `help`, `status`, `compact`, `sessionlist`, `workspacelist`, `watch`, `unwatch`, `watchlist`, `archived`) as native Slash Commands through the `app_slash_commands` OpenAPI, so typing `/` in a Feishu direct-message input box pops the command panel and tapping a command triggers it. The command list is owned and pushed by dsh-im; it does not depend on the dsh/Harness backend. Apps created through the built-in QR flow request `application:app_slash_command:read` and `application:app_slash_command:write` by default; existing apps can add them incrementally through **Complete permissions** or `/repair`, followed by any publishing steps Feishu requires. The Feishu client also caches the list for a few minutes. This is best-effort and never blocks message delivery.
### Command details
- `/help` takes no arguments and never creates a Session. It returns the complete command list supported by the current bot.
@ -229,7 +231,7 @@ If the Slack desktop app has no native Slash Command registered with the same na
- `/stop` and `/steer` control only a running task started by this chat. Even when multiple chats bind the same Session, they do not intentionally control another chat's task. `/stop` does not delete the Session or its history, preserves queued work that has not started, and is safe to repeat.
- `/steer` accepts text only, including multiple lines. It neither creates another Session nor starts a second task. Send an ordinary message when no task is running; while an approval or question is pending, answer it first or use `/stop`.
- `/batch`, `/send`, and `/cancel` are available only in a direct chat with the bot. After `/batch`, subsequent text-only messages are held temporarily, up to 10 messages. The tenth is collected and prompts you to submit; later messages are rejected and the batch is never submitted automatically. `/send` processes the collected messages in their original order as one input, while `/cancel` discards them. Images, files, and other commands are not collected. An unsubmitted batch is lost if the bot restarts. Ordinary chat behavior is unchanged when batch input is not active.
- Feishu `/repair` is available only in a direct chat and follows the current bot's channel access policy just like every other command; the plugin defines no separate administrator role. It incrementally adds up to `card.action.trigger`, `im:message:readonly`, and `im:resource`, while the confirmation page shows only items the app is currently missing. The authorization page must be opened by an account that can access the target app in Feishu Open Platform. Bare `/repair` starts repair; if an older attempt is still awaiting authorization, it invalidates that one-time link before generating a new one. Use `/repair qr` for the current link's QR code, `/repair status` to inspect the attempt, `/repair verify` to refresh verification, and `/repair cancel` to cancel it; none of these four supplemental commands starts another authorization. Once Feishu has accepted the update and the bot is waiting for the test-button callback, a second repair is not started concurrently.
- Feishu `/repair` is available only in a direct chat and follows the current bot's channel access policy just like every other command; the plugin defines no separate administrator role. It incrementally adds the currently missing `card.action.trigger`, `im:message:readonly`, `im:resource`, `application:app_slash_command:read`, and `application:app_slash_command:write`, while the confirmation page shows only items the app is currently missing. The authorization page must be opened by an account that can access the target app in Feishu Open Platform. Bare `/repair` starts repair; if an older attempt is still awaiting authorization, it invalidates that one-time link before generating a new one. Use `/repair qr` for the current link's QR code, `/repair status` to inspect the attempt, `/repair verify` to refresh verification, and `/repair cancel` to cancel it; none of these four supplemental commands starts another authorization. Once Feishu has accepted the update and the bot is waiting for the test-button callback, a second repair is not started concurrently.
- `/compact` acts only on the Harness Session already bound to the current chat and is never sent to the model. The bot reports the applicable status when the chat has no Session yet, the Session is generating a reply, or there is no compactable history.
- The path must be an existing absolute directory. The bot returns an actionable error and the correct usage when validation fails.
- `/workspacelist` takes no arguments. It combines the Harness global registry with the current bot's path. When that current path still exists and is safe to display, it appears first and is marked as current. Any listed path can be copied directly into `/workspace`.

View file

@ -63,7 +63,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
其他 IM 平台可继续按同一渠道适配器结构接入。
九个内置渠道均支持把 JPEG、PNG、WebP 图片,以及以图片文件方式发送的 GIF,连同可选文字说明发送给 Harness;单张图片上限为 5 MB,单条消息中的图片总大小上限为 20 MB。飞书下载用户消息中的图片或文件需要租户权限 `im:message:readonly`,确认页将其显示为“获取单聊、群组消息”;飞书目前没有为该下载接口提供仅限图片的更窄权限。扫码新建的应用会默认申请;已有或手动绑定的应用可私聊机器人执行 `/repair`,或在「IM机器人」设置页点击“补全权限”,扫码增量补全该权限、上传机器人图片或文件所需的 `im:resource`,以及卡片回调。
九个内置渠道均支持把 JPEG、PNG、WebP 图片,以及以图片文件方式发送的 GIF,连同可选文字说明发送给 Harness;单张图片上限为 5 MB,单条消息中的图片总大小上限为 20 MB。飞书下载用户消息中的图片或文件需要租户权限 `im:message:readonly`,确认页将其显示为“获取单聊、群组消息”;飞书目前没有为该下载接口提供仅限图片的更窄权限。扫码新建的应用会默认申请;已有或手动绑定的应用可私聊机器人执行 `/repair`,或在「IM机器人」设置页点击“补全权限”,扫码增量补全该权限、上传机器人图片或文件所需的 `im:resource`、原生命令面板所需的 `application:app_slash_command:read` / `write`,以及卡片回调。
### 结果文件与图片回传
@ -201,7 +201,7 @@ dsh plugin --profile web add -w --save-exact @xmanrui/dsh-im@3.1.0 --registry=ht
| `/batch` | 在私聊中开启批量输入,最多收集 10 条纯文字消息。 |
| `/send` | 将已收集的消息按原顺序作为一次输入提交。 |
| `/cancel` | 取消批量输入并丢弃已收集的消息。 |
| `/repair` | 在飞书私聊中增量修复卡片回调,并补全读取及上传消息图片或文件所需的权限。 |
| `/repair` | 在飞书私聊中增量修复卡片回调,并补全媒体与原生 Slash Command 面板所需的权限。 |
| `/compact` | 立即压缩当前聊天绑定会话的较早上下文。 |
| `/workspace <工作区绝对路径>` | 切换当前机器人的 Harness 工作区。 |
| `/workspacelist` | 列出当前 Harness Host 上仍然存在的工作区绝对路径。 |
@ -215,6 +215,8 @@ dsh plugin --profile web add -w --save-exact @xmanrui/dsh-im@3.1.0 --registry=ht
Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/` 开头的消息。此时请加一个前导空格发送,例如 ` /presetlist`、` /preset 2`、` /history` 或 ` /history 10`;插件命令层会去除首尾空白,执行效果与无空格命令相同。
**飞书输入框的 `/` 命令面板**:机器人启动时,dsh-im 会调用飞书 `app_slash_commands` OpenAPI,把常用命令(`menu`、`new`、`help`、`status`、`compact`、`sessionlist`、`workspacelist`、`watch`、`unwatch`、`watchlist`、`archived`)注册成原生 Slash Command,这样在飞书单聊输入框输入 `/` 会弹出命令面板,点选即触发。命令列表由 dsh-im 自己持有并推送注册,不依赖 dsh/Harness 后端。扫码新建的应用会默认申请 `application:app_slash_command:read` 和 `application:app_slash_command:write`;已有应用可通过“补全权限”或私聊 `/repair` 增量补全并按飞书提示发布。注册后飞书客户端约有几分钟缓存延迟。该能力是尽力而为的,注册失败不会影响机器人消息收发。
### 命令说明
- `/help` 不需要参数,也不会创建会话;它会返回当前机器人支持的完整命令列表。
@ -232,7 +234,7 @@ Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/
- `/stop` 和 `/steer` 只控制当前聊天自己发起的运行任务,即使多个聊天绑定同一个 Session,也不会有意控制其他聊天的任务。`/stop` 不删除会话或历史,并保留尚未开始的排队消息;重复发送是安全的。
- `/steer` 只接受文字,可包含多行;它不会创建新会话或第二个任务。没有运行任务时请直接发送普通消息;等待审批或问题回答时请先处理交互,或使用 `/stop`。
- `/batch`、`/send` 和 `/cancel` 仅在与机器人的私聊中可用。发送 `/batch` 后,接下来的纯文字消息会暂存,最多 10 条;第 10 条仍会收录并提示提交,之后的消息不会收录,也不会自动提交。发送 `/send` 后,机器人会按原顺序将整批内容作为一次输入处理;发送 `/cancel` 会直接丢弃当前批次。图片、文件和其他命令不会被收录。机器人重启会丢失尚未提交的批次。未进入批量输入模式时,普通聊天流程不变。
- 飞书 `/repair` 仅在私聊中可用,并与其他命令一样只服从当前飞书机器人的渠道访问策略;插件不另行区分管理员和普通用户。它最多增量补全 `card.action.trigger`、`im:message:readonly` 和 `im:resource`,确认页只显示当前应用缺少的项。授权页必须由在飞书开放平台中有权访问目标应用的账号打开。普通 `/repair` 会启动修复;若旧任务仍在等待授权,会先作废旧的一次性链接再生成新链接。发送 `/repair qr` 获取当前链接的二维码,`/repair status` 查询当前任务,`/repair verify` 重新查询验证状态,`/repair cancel` 取消任务;这四个补充命令均不会另起授权。平台已接受更新、正在等待测试按钮回调时,不会并发启动第二次修复。
- 飞书 `/repair` 仅在私聊中可用,并与其他命令一样只服从当前飞书机器人的渠道访问策略;插件不另行区分管理员和普通用户。它增量补全当前缺少的 `card.action.trigger`、`im:message:readonly`、`im:resource`、`application:app_slash_command:read` 和 `application:app_slash_command:write`,确认页只显示当前应用缺少的项。授权页必须由在飞书开放平台中有权访问目标应用的账号打开。普通 `/repair` 会启动修复;若旧任务仍在等待授权,会先作废旧的一次性链接再生成新链接。发送 `/repair qr` 获取当前链接的二维码,`/repair status` 查询当前任务,`/repair verify` 重新查询验证状态,`/repair cancel` 取消任务;这四个补充命令均不会另起授权。平台已接受更新、正在等待测试按钮回调时,不会并发启动第二次修复。
- `/compact` 只作用于当前聊天已经绑定的 Harness 会话,不会把命令发送给模型。当前聊天尚未创建会话、会话正在生成回复或没有可压缩历史时,机器人会直接返回对应状态。
- 只接受已经存在的绝对目录;路径无效时机器人会返回具体提示和正确用法。
- `/workspacelist` 不需要参数。它合并 Harness 全局登记项与当前机器人的路径;当前路径仍存在且可安全显示时会排在首位并标记为“当前”。结果可直接复制到 `/workspace` 命令。

View file

@ -1190,7 +1190,7 @@ var EN = Object.freeze({
"\u6743\u9650\u914D\u7F6E\u5DF2\u63D0\u4EA4\uFF0C\u6B63\u5728\u542F\u7528\u5168\u90E8\u6D88\u606F\u6A21\u5F0F\u5E76\u91CD\u8FDE\u6B64\u673A\u5668\u4EBA\uFF1B\u6B64\u9636\u6BB5\u65E0\u6CD5\u53D6\u6D88\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u4F1A\u4E2D\u65AD\u3002": "The permission update was submitted. Enabling all-message mode and reconnecting this bot. This stage cannot be cancelled; other bots will not be interrupted.",
"\u6B63\u5728\u4E3A\u73B0\u6709\u98DE\u4E66\u5E94\u7528\u7533\u8BF7\u7FA4\u6D88\u606F\u6743\u9650\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u5019\u3002": "Requesting a group-message permission QR code for the existing Feishu app\u2026",
"\u7FA4\u6D88\u606F\u6743\u9650\u6CA1\u6709\u5F00\u901A\u5B8C\u6210": "Group-message permission was not granted",
"\u626B\u7801\u4F1A\u66F4\u65B0\u73B0\u6709\u98DE\u4E66\u5E94\u7528\uFF0C\u6700\u591A\u589E\u91CF\u8865\u5145\u5361\u7247\u6309\u94AE\u56DE\u8C03\u3001\u8BFB\u53D6\u7528\u6237\u6D88\u606F\u5185\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:message:readonly\uFF08\u98DE\u4E66\u663E\u793A\u4E3A\u201C\u83B7\u53D6\u5355\u804A\u3001\u7FA4\u7EC4\u6D88\u606F\u201D\uFF09\uFF0C\u4EE5\u53CA\u4E0A\u4F20\u673A\u5668\u4EBA\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:resource\uFF1B\u4E0D\u4F1A\u521B\u5EFA\u65B0\u5E94\u7528\u3002\u786E\u8BA4\u9875\u53EA\u663E\u793A\u5F53\u524D\u7F3A\u5C11\u9879\uFF0C\u5B8C\u6210\u540E\u6B64\u673A\u5668\u4EBA\u4F1A\u77ED\u6682\u91CD\u8FDE\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u53D7\u5F71\u54CD\u3002": "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 \u201CRead direct and group messages\u201D), 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.",
"\u626B\u7801\u4F1A\u66F4\u65B0\u73B0\u6709\u98DE\u4E66\u5E94\u7528\uFF0C\u589E\u91CF\u8865\u5145\u5F53\u524D\u7F3A\u5C11\u7684\u5361\u7247\u6309\u94AE\u56DE\u8C03\u3001\u8BFB\u53D6\u7528\u6237\u6D88\u606F\u5185\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:message:readonly\uFF08\u98DE\u4E66\u663E\u793A\u4E3A\u201C\u83B7\u53D6\u5355\u804A\u3001\u7FA4\u7EC4\u6D88\u606F\u201D\uFF09\u3001\u4E0A\u4F20\u673A\u5668\u4EBA\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:resource\uFF0C\u4EE5\u53CA\u539F\u751F\u547D\u4EE4\u9762\u677F\u6240\u9700\u7684 application:app_slash_command:read / write\uFF1B\u4E0D\u4F1A\u521B\u5EFA\u65B0\u5E94\u7528\u3002\u786E\u8BA4\u9875\u53EA\u663E\u793A\u5F53\u524D\u7F3A\u5C11\u9879\uFF0C\u5B8C\u6210\u540E\u6B64\u673A\u5668\u4EBA\u4F1A\u77ED\u6682\u91CD\u8FDE\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u53D7\u5F71\u54CD\u3002": "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 \u201CRead direct and group messages\u201D), 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.",
"\u6838\u5BF9\u73B0\u6709\u5E94\u7528\u540D\u79F0\uFF0C\u5E76\u786E\u8BA4\u53EA\u65B0\u589E\u5F53\u524D\u7F3A\u5C11\u7684\u4E0A\u8FF0\u914D\u7F6E": "Review the existing app name and confirm that only the missing items described above are added",
"\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u6743\u9650\u4E0E\u56DE\u8C03\u8865\u5168\u5B8C\u6210": "Keep this page open until permissions and the callback are complete",
"\u53D6\u6D88\u8865\u5168": "Cancel setup",
@ -1201,7 +1201,7 @@ var EN = Object.freeze({
"\u6743\u9650\u4E0E\u56DE\u8C03\u6CA1\u6709\u8865\u5168\u5B8C\u6210": "Permissions and callback setup did not finish",
"\u8865\u5168\u6743\u9650": "Complete permissions",
"\u8865\u5168\u8303\u56F4": "Completion scope",
"\u6700\u591A\u589E\u91CF\u6DFB\u52A0\u5361\u7247\u56DE\u8C03 card.action.trigger\u3001\u8BFB\u53D6\u6D88\u606F\u5185\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:message:readonly\uFF08\u98DE\u4E66\u663E\u793A\u4E3A\u201C\u83B7\u53D6\u5355\u804A\u3001\u7FA4\u7EC4\u6D88\u606F\u201D\uFF09\uFF0C\u4EE5\u53CA\u4E0A\u4F20\u673A\u5668\u4EBA\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:resource\uFF1B\u786E\u8BA4\u9875\u53EA\u663E\u793A\u5F53\u524D\u7F3A\u5C11\u9879\uFF0C\u4E0D\u4F1A\u521B\u5EFA\u65B0\u5E94\u7528\u3002": "Adds up to the missing card callback card.action.trigger, im:message:readonly for reading images or files in messages (shown by Feishu as \u201CRead direct and group messages\u201D), and im:resource for uploading images or files sent by the bot. The confirmation page shows only missing items; no new app is created.",
"\u589E\u91CF\u6DFB\u52A0\u5F53\u524D\u7F3A\u5C11\u7684\u5361\u7247\u56DE\u8C03 card.action.trigger\u3001\u8BFB\u53D6\u6D88\u606F\u5185\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:message:readonly\uFF08\u98DE\u4E66\u663E\u793A\u4E3A\u201C\u83B7\u53D6\u5355\u804A\u3001\u7FA4\u7EC4\u6D88\u606F\u201D\uFF09\u3001\u4E0A\u4F20\u673A\u5668\u4EBA\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:resource\uFF0C\u4EE5\u53CA\u539F\u751F\u547D\u4EE4\u9762\u677F\u6240\u9700\u7684 application:app_slash_command:read / write\uFF1B\u786E\u8BA4\u9875\u53EA\u663E\u793A\u5F53\u524D\u7F3A\u5C11\u9879\uFF0C\u4E0D\u4F1A\u521B\u5EFA\u65B0\u5E94\u7528\u3002": "Adds the missing card callback card.action.trigger, im:message:readonly for reading images or files in messages (shown by Feishu as \u201CRead direct and group messages\u201D), 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.",
"\u7B49\u5F85\u626B\u7801\u2026": "Waiting for scan\u2026",
"\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u4E0D\u5339\u914D\u7684\u6743\u9650\u8865\u5168\u4E8C\u7EF4\u7801": "Feishu returned a permission-completion QR code for a different bot",
"\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u4E0D\u5339\u914D\u7684\u7FA4\u6D88\u606F\u6743\u9650\u4E8C\u7EF4\u7801": "Feishu returned a group-message permission QR code for a different bot",
@ -5500,7 +5500,7 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
h2("span", null, repairing ? `\u6B63\u5728\u4E3A\u300C${botName}\u300D\u8865\u5168\u6743\u9650\u4E0E\u56DE\u8C03` : grantingGroupMessages ? `\u6B63\u5728\u4E3A\u300C${botName}\u300D\u5F00\u901A\u7FA4\u6D88\u606F\u6743\u9650` : "\u6B63\u5728\u6DFB\u52A0\u65B0\u673A\u5668\u4EBA")
),
h2("h3", null, expired ? "\u5237\u65B0\u4E8C\u7EF4\u7801\u540E\u7EE7\u7EED" : repairing ? "\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u8865\u5168\u6743\u9650" : grantingGroupMessages ? "\u4F7F\u7528\u98DE\u4E66\u786E\u8BA4\u7FA4\u6D88\u606F\u6743\u9650" : "\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u521B\u5EFA\u673A\u5668\u4EBA"),
h2("p", null, repairing ? "\u626B\u7801\u4F1A\u66F4\u65B0\u73B0\u6709\u98DE\u4E66\u5E94\u7528\uFF0C\u6700\u591A\u589E\u91CF\u8865\u5145\u5361\u7247\u6309\u94AE\u56DE\u8C03\u3001\u8BFB\u53D6\u7528\u6237\u6D88\u606F\u5185\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:message:readonly\uFF08\u98DE\u4E66\u663E\u793A\u4E3A\u201C\u83B7\u53D6\u5355\u804A\u3001\u7FA4\u7EC4\u6D88\u606F\u201D\uFF09\uFF0C\u4EE5\u53CA\u4E0A\u4F20\u673A\u5668\u4EBA\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:resource\uFF1B\u4E0D\u4F1A\u521B\u5EFA\u65B0\u5E94\u7528\u3002\u786E\u8BA4\u9875\u53EA\u663E\u793A\u5F53\u524D\u7F3A\u5C11\u9879\uFF0C\u5B8C\u6210\u540E\u6B64\u673A\u5668\u4EBA\u4F1A\u77ED\u6682\u91CD\u8FDE\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u53D7\u5F71\u54CD\u3002" : grantingGroupMessages ? "\u626B\u7801\u4F1A\u66F4\u65B0\u73B0\u6709\u98DE\u4E66\u5E94\u7528\uFF0C\u53EA\u589E\u91CF\u5F00\u901A\u201C\u83B7\u53D6\u7FA4\u7EC4\u4E2D\u6240\u6709\u6D88\u606F\u201D\u6743\u9650\uFF1B\u4E0D\u4F1A\u521B\u5EFA\u65B0\u5E94\u7528\u3002\u786E\u8BA4\u540E\u4F1A\u81EA\u52A8\u542F\u7528\u201C\u54CD\u5E94\u6240\u6709\u7FA4\u6D88\u606F\u201D\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u53D7\u5F71\u54CD\u3002" : "\u626B\u7801\u53EA\u4F1A\u65B0\u589E\u4E00\u4E2A\u673A\u5668\u4EBA\uFF0C\u5DF2\u63A5\u5165\u7684\u673A\u5668\u4EBA\u4F1A\u7EE7\u7EED\u6B63\u5E38\u6536\u53D1\u6D88\u606F\u3002"),
h2("p", null, repairing ? "\u626B\u7801\u4F1A\u66F4\u65B0\u73B0\u6709\u98DE\u4E66\u5E94\u7528\uFF0C\u589E\u91CF\u8865\u5145\u5F53\u524D\u7F3A\u5C11\u7684\u5361\u7247\u6309\u94AE\u56DE\u8C03\u3001\u8BFB\u53D6\u7528\u6237\u6D88\u606F\u5185\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:message:readonly\uFF08\u98DE\u4E66\u663E\u793A\u4E3A\u201C\u83B7\u53D6\u5355\u804A\u3001\u7FA4\u7EC4\u6D88\u606F\u201D\uFF09\u3001\u4E0A\u4F20\u673A\u5668\u4EBA\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:resource\uFF0C\u4EE5\u53CA\u539F\u751F\u547D\u4EE4\u9762\u677F\u6240\u9700\u7684 application:app_slash_command:read / write\uFF1B\u4E0D\u4F1A\u521B\u5EFA\u65B0\u5E94\u7528\u3002\u786E\u8BA4\u9875\u53EA\u663E\u793A\u5F53\u524D\u7F3A\u5C11\u9879\uFF0C\u5B8C\u6210\u540E\u6B64\u673A\u5668\u4EBA\u4F1A\u77ED\u6682\u91CD\u8FDE\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u53D7\u5F71\u54CD\u3002" : grantingGroupMessages ? "\u626B\u7801\u4F1A\u66F4\u65B0\u73B0\u6709\u98DE\u4E66\u5E94\u7528\uFF0C\u53EA\u589E\u91CF\u5F00\u901A\u201C\u83B7\u53D6\u7FA4\u7EC4\u4E2D\u6240\u6709\u6D88\u606F\u201D\u6743\u9650\uFF1B\u4E0D\u4F1A\u521B\u5EFA\u65B0\u5E94\u7528\u3002\u786E\u8BA4\u540E\u4F1A\u81EA\u52A8\u542F\u7528\u201C\u54CD\u5E94\u6240\u6709\u7FA4\u6D88\u606F\u201D\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u53D7\u5F71\u54CD\u3002" : "\u626B\u7801\u53EA\u4F1A\u65B0\u589E\u4E00\u4E2A\u673A\u5668\u4EBA\uFF0C\u5DF2\u63A5\u5165\u7684\u673A\u5668\u4EBA\u4F1A\u7EE7\u7EED\u6B63\u5E38\u6536\u53D1\u6D88\u606F\u3002"),
h2(
"ol",
{ className: "bxf-steps dim-steps" },
@ -5875,7 +5875,7 @@ function BotCard({
role: "tooltip"
},
h2("strong", null, "\u8865\u5168\u8303\u56F4"),
h2("span", null, "\u6700\u591A\u589E\u91CF\u6DFB\u52A0\u5361\u7247\u56DE\u8C03 card.action.trigger\u3001\u8BFB\u53D6\u6D88\u606F\u5185\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:message:readonly\uFF08\u98DE\u4E66\u663E\u793A\u4E3A\u201C\u83B7\u53D6\u5355\u804A\u3001\u7FA4\u7EC4\u6D88\u606F\u201D\uFF09\uFF0C\u4EE5\u53CA\u4E0A\u4F20\u673A\u5668\u4EBA\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:resource\uFF1B\u786E\u8BA4\u9875\u53EA\u663E\u793A\u5F53\u524D\u7F3A\u5C11\u9879\uFF0C\u4E0D\u4F1A\u521B\u5EFA\u65B0\u5E94\u7528\u3002")
h2("span", null, "\u589E\u91CF\u6DFB\u52A0\u5F53\u524D\u7F3A\u5C11\u7684\u5361\u7247\u56DE\u8C03 card.action.trigger\u3001\u8BFB\u53D6\u6D88\u606F\u5185\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:message:readonly\uFF08\u98DE\u4E66\u663E\u793A\u4E3A\u201C\u83B7\u53D6\u5355\u804A\u3001\u7FA4\u7EC4\u6D88\u606F\u201D\uFF09\u3001\u4E0A\u4F20\u673A\u5668\u4EBA\u56FE\u7247\u6216\u6587\u4EF6\u6240\u9700\u7684 im:resource\uFF0C\u4EE5\u53CA\u539F\u751F\u547D\u4EE4\u9762\u677F\u6240\u9700\u7684 application:app_slash_command:read / write\uFF1B\u786E\u8BA4\u9875\u53EA\u663E\u793A\u5F53\u524D\u7F3A\u5C11\u9879\uFF0C\u4E0D\u4F1A\u521B\u5EFA\u65B0\u5E94\u7528\u3002")
)
),
h2(Button5, {

File diff suppressed because one or more lines are too long

View file

@ -305,7 +305,7 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
? "使用飞书确认群消息权限"
: "使用飞书扫码创建机器人"),
h("p", null, repairing
? "扫码会更新现有飞书应用,最多增量补充卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”),以及上传机器人图片或文件所需的 im:resource;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。"
? "扫码会更新现有飞书应用,增量补充当前缺少的卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource,以及原生命令面板所需的 application:app_slash_command:read / write;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。"
: grantingGroupMessages
? "扫码会更新现有飞书应用,只增量开通“获取群组中所有消息”权限;不会创建新应用。确认后会自动启用“响应所有群消息”,其他机器人不受影响。"
: "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
@ -666,7 +666,7 @@ export function BotCard({
role: "tooltip",
},
h("strong", null, "补全范围"),
h("span", null, "最多增量添加卡片回调 card.action.trigger、读取消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”),以及上传机器人图片或文件所需的 im:resource;确认页只显示当前缺少项,不会创建新应用。"))),
h("span", null, "增量添加当前缺少的卡片回调 card.action.trigger、读取消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource,以及原生命令面板所需的 application:app_slash_command:read / write;确认页只显示当前缺少项,不会创建新应用。"))),
h(Button, {
className: "dim-cardAction", kind: "danger", onClick: onRequestRemove,
disabled: Boolean(busy), ref: removeButtonRef,

View file

@ -357,7 +357,7 @@ const EN = Object.freeze({
'权限配置已提交,正在启用全部消息模式并重连此机器人;此阶段无法取消,其他机器人不会中断。': 'The permission update was submitted. Enabling all-message mode and reconnecting this bot. This stage cannot be cancelled; other bots will not be interrupted.',
'正在为现有飞书应用申请群消息权限二维码,请稍候。': 'Requesting a group-message permission QR code for the existing Feishu app…',
'群消息权限没有开通完成': 'Group-message permission was not granted',
'扫码会更新现有飞书应用,最多增量补充卡片按钮回调、读取用户消息内图片或文件所需的 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.',
'扫码会更新现有飞书应用,增量补充当前缺少的卡片按钮回调、读取用户消息内图片或文件所需的 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.',
'核对现有应用名称,并确认只新增当前缺少的上述配置': 'Review the existing app name and confirm that only the missing items described above are added',
'保持本页打开,等待权限与回调补全完成': 'Keep this page open until permissions and the callback are complete',
'取消补全': 'Cancel setup',
@ -368,7 +368,7 @@ const EN = Object.freeze({
'权限与回调没有补全完成': 'Permissions and callback setup did not finish',
'补全权限': 'Complete permissions',
'补全范围': 'Completion scope',
'最多增量添加卡片回调 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.',
'增量添加当前缺少的卡片回调 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.',
'等待扫码…': 'Waiting for scan…',
'飞书服务返回了不匹配的权限补全二维码': 'Feishu returned a permission-completion QR code for a different bot',
'飞书服务返回了不匹配的群消息权限二维码': 'Feishu returned a group-message permission QR code for a different bot',

View file

@ -172,6 +172,7 @@ export async function createProductionController(ctx, config = {}, internals = {
state: workspaceScope.state,
contextEnhancement: { botId: id, getSettings: () => workspaces.contextEnhancementFor(id) },
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
slashCommands: config.slashCommands !== false,
...(wsAgent ? { wsAgent } : {}),
logger: {
error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args),

View file

@ -1399,7 +1399,7 @@ export class FeishuHarnessBridge {
: t('链接约 {minutes} 分钟后过期', { minutes: Math.max(1, Math.ceil(remaining / 60)) });
await this.#send(chatId, [
restarted ? t('旧授权链接已作废,已生成新的修复链接。') : t('🔧 准备补全权限与回调。'),
t('本次最多增量添加三项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);以及 im:resource(用于上传机器人发送的图片或文件)。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。'),
t('本次会增量添加当前缺少项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);im:resource(用于上传机器人发送的图片或文件);以及原生命令面板所需的 application:app_slash_command:read / write。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。'),
'',
t('当前设备直接打开:'),
url,

View file

@ -3,6 +3,10 @@ import { FeishuHarnessBridge } from './bridge.mjs';
import { cardActionProbeCard } from './feishu-cards.mjs';
import { VerifiedFeishuChannel } from './feishu-channel.mjs';
import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
import {
registerSlashCommands,
SLASH_COMMAND_MANIFEST,
} from './slash-command-registry.mjs';
import {
connectionTestTargetUnavailable,
sendRememberedConnectionTest,
@ -84,6 +88,11 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
agentPreset: 'standard',
authorizationMode: 'sender-open-id-allowlist',
allowedSenderCount,
slashCommandRegistration: 'idle',
slashCommandsRegistered: 0,
slashCommandsExisting: 0,
slashCommandsFailed: 0,
slashCommandsError: null,
};
}
@ -119,6 +128,7 @@ export class FeishuRuntime {
#abortController = null;
#pendingCardActionProbes = new Map();
#status;
#slashCommands = true;
constructor({
lark,
@ -137,6 +147,7 @@ export class FeishuRuntime {
replyTimeoutMs = 600000,
connectTimeoutMs = 15000,
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
slashCommands = true,
wsAgent,
logger = console,
}) {
@ -169,6 +180,7 @@ export class FeishuRuntime {
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
this.#requestTimeoutMs = requestTimeoutMs;
this.#slashCommands = Boolean(slashCommands);
this.#wsAgent = wsAgent;
this.#logger = logger;
this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length });
@ -368,6 +380,12 @@ export class FeishuRuntime {
});
await Promise.all([wsStarted, ready]);
assertCurrentStart();
// Register the native Slash Command panel best-effort and asynchronously
// so it never blocks the long-connection startup. The panel is only a
// client-side convenience; failure here must not take the bot down.
if (this.#slashCommands && httpInstance) {
void this.#registerSlashCommands(httpInstance, isCurrentStart, signal);
}
return this.status;
} catch (error) {
// stop() owns the terminal idle state for an explicitly aborted start.
@ -598,6 +616,44 @@ export class FeishuRuntime {
return { sent: true };
}
async #registerSlashCommands(httpInstance, isCurrentStart, signal) {
this.#status.slashCommandRegistration = 'registering';
this.#status.slashCommandsError = null;
try {
const result = await registerSlashCommands({
appId: this.#appId,
appSecret: this.#appSecret,
domain: this.#domain,
httpInstance,
signal,
manifest: SLASH_COMMAND_MANIFEST,
});
if (!isCurrentStart()) return;
this.#status.slashCommandRegistration = 'done';
this.#status.slashCommandsRegistered = result.created.length;
this.#status.slashCommandsExisting = result.existing.length;
this.#status.slashCommandsFailed = result.failed.length;
this.#status.slashCommandsError = result.failed.length > 0
? result.failed.map((f) => `/${f.command}: ${f.error?.message ?? String(f.error)}`).join('; ')
: null;
if (result.created.length > 0) {
this.#logger.info?.(`[dsh-feishu] registered ${result.created.length} slash command(s)`);
}
if (result.failed.length > 0) {
this.#logger.warn?.(
`[dsh-feishu] ${result.failed.length} slash command(s) failed to register: ${this.#status.slashCommandsError}`,
);
}
} catch (error) {
if (!isCurrentStart()) return;
this.#status.slashCommandRegistration = 'failed';
this.#status.slashCommandsError = error?.message ?? String(error);
this.#logger.warn?.(
`[dsh-feishu] slash command registration skipped: ${this.#status.slashCommandsError}`,
);
}
}
stop(options = {}) {
if (this.#stopping) return this.#stopping;
@ -642,6 +698,7 @@ export class FeishuRuntime {
if (bridge) await bridge.waitForIdle();
this.#client = null;
this.#status.feishuLongConnectionState = preserveError ? 'failed' : 'idle';
this.#status.slashCommandRegistration = 'idle';
this.#status.lastError = error;
return this.status;
}

View file

@ -1,4 +1,5 @@
import { RegistrationManager } from './registration-manager.mjs';
import { SLASH_COMMAND_TENANT_SCOPES } from './slash-command-registry.mjs';
export const FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
@ -11,6 +12,7 @@ export const REQUIRED_TENANT_SCOPES = Object.freeze([
'im:message:recall',
'im:resource',
'cardkit:card:write',
...SLASH_COMMAND_TENANT_SCOPES,
]);
function safeConnectionStatus(runtime) {

View file

@ -1,4 +1,5 @@
import { RegistrationManager } from './registration-manager.mjs';
import { SLASH_COMMAND_TENANT_SCOPES } from './slash-command-registry.mjs';
export const CARD_ACTION_CALLBACK = 'card.action.trigger';
export const FEISHU_MESSAGE_READ_SCOPE = 'im:message:readonly';
@ -74,9 +75,10 @@ export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu')
* One targeted update attempt for an existing Feishu app. It intentionally
* shares RegistrationManager's polling/state implementation while fixing the
* update manifest in one place so callers can add only the card callback, the
* message-read scope needed to download user-sent media, and the resource
* scope needed to upload bot-sent images/files, without adding unrelated
* scopes, events, presets, or createOnly.
* message-read scope needed to download user-sent media, the resource scope
* needed to upload bot-sent images/files, and the Slash Command scopes needed
* for the native command panel, without adding unrelated scopes, events,
* presets, or createOnly.
*/
export class CallbackRepairManager {
#manager;
@ -110,7 +112,13 @@ export class CallbackRepairManager {
appId: this.#appId,
addons: {
preset: false,
scopes: { tenant: [FEISHU_MESSAGE_READ_SCOPE, FEISHU_RESOURCE_SCOPE] },
scopes: {
tenant: [
FEISHU_MESSAGE_READ_SCOPE,
FEISHU_RESOURCE_SCOPE,
...SLASH_COMMAND_TENANT_SCOPES,
],
},
callbacks: { items: [CARD_ACTION_CALLBACK] },
},
});

View file

@ -0,0 +1,259 @@
/**
* Feishu native Slash Command registration for the dsh-im Feishu channel.
*
* The Feishu client shows a "/" command panel in the chat input box. The
* command list is stored server-side per bot application and is NOT pushed
* by dsh/Harness. dsh-im holds its own static command manifest and calls the
* Feishu OpenAPI to register it, so users can discover commands by typing "/".
*
* Reference (official):
* https://open.feishu.cn/document/mcp_open_tools/agent-best-practices/agent-supports-slash-commands
*
* The registered command panel is only a client-side convenience: when a user
* taps a command, Feishu sends it to the bot as an ordinary text message via
* im.message.receive_v1. The bridge's #handle() command matcher therefore
* needs no changes as long as every registered command name matches the
* existing "/xxx" text commands.
*/
const SLASH_ENDPOINT = '/open-apis/application/v7/app_slash_commands';
const MISSING_PERMISSION_CODES = new Set(['99991640', '99991672']);
export const SLASH_COMMAND_TENANT_SCOPES = Object.freeze([
'application:app_slash_command:read',
'application:app_slash_command:write',
]);
// Icon keys are the documented values in the Feishu Slash Command doc.
const DEFAULT_ICON = 'ai-agent_outlined';
/**
* The dsh-im Feishu command manifest. Every entry's `command` is registered
* WITHOUT the leading slash; Feishu displays it as "/<command>" in the panel
* and sends "/<command>" back as text, which matches the bridge's regexes.
*
* Descriptions should stay short and match what the command actually does in
* bridge.mjs / the shared command modules.
*/
export const SLASH_COMMAND_MANIFEST = Object.freeze([
{ command: 'menu', icon: 'skill_outlined', default: '打开功能菜单', en_us: 'Open the feature menu' },
{ command: 'new', icon: 'ai-deepthink_outlined', default: '开启全新会话', en_us: 'Start a fresh session' },
{ command: 'help', icon: 'promptword_outlined', default: '查看帮助', en_us: 'Show help' },
{ command: 'status', icon: 'ai-functions_outlined', default: '查看机器人状态', en_us: 'Show bot status' },
{ command: 'compact', icon: 'ai-block_outlined', default: '压缩当前会话上下文', en_us: 'Compact the current session' },
{ command: 'sessionlist', icon: 'chat-ai_outlined', default: '列出会话', en_us: 'List sessions' },
{ command: 'workspacelist', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
{ command: 'watch', icon: 'flag_outlined', default: '关注一个会话', en_us: 'Watch a session' },
{ command: 'unwatch', icon: 'clear_outlined', default: '取消关注会话', en_us: 'Unwatch a session' },
{ command: 'watchlist', icon: 'flag_outlined', default: '查看关注列表', en_us: 'List watched sessions' },
{ command: 'archived', icon: 'folder_outlined', default: '设置归档会话显隐(on/off)', en_us: 'Show or hide archived sessions (on/off)' },
]);
// Commands that require a parameter are registered too, so the user can type
// "/watch <session ID>" from the panel. A leading placeholder hint is not part of
// the registered name; Feishu only allows a plain command token.
function endpointFor(domain, path) {
const origin = domain === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
return new URL(path, origin);
}
function jsonResponse(body, operation) {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new Error(`${operation} returned a non-JSON response`);
}
if (body.code !== 0) {
const error = new Error(`${operation} failed: ${body.msg || `code ${body.code}`}`);
error.code = String(body.code);
error.msg = body.msg;
throw error;
}
return body;
}
function requestSignal(signal, timeoutMs) {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
async function requestJson(httpInstance, options, operation) {
try {
return jsonResponse(await httpInstance.request(options), operation);
} catch (error) {
const body = error?.response?.data;
if (body && typeof body === 'object' && !Array.isArray(body)
&& Object.hasOwn(body, 'code')) {
return jsonResponse(body, operation);
}
throw error;
}
}
/** Fetch a tenant_access_token for the app. */
async function fetchTenantAccessToken({
appId, appSecret, domain, httpInstance, timeoutMs, signal,
}) {
if (!appId || !appSecret) throw new Error('Feishu slash registration requires app credentials');
if (!httpInstance || typeof httpInstance.request !== 'function') {
throw new TypeError('Feishu slash registration requires an HTTP instance');
}
const body = await requestJson(httpInstance, {
method: 'POST',
url: endpointFor(domain, '/open-apis/auth/v3/tenant_access_token/internal').href,
headers: { 'content-type': 'application/json; charset=utf-8' },
data: { app_id: appId, app_secret: appSecret },
signal: requestSignal(signal, timeoutMs),
timeout: timeoutMs,
}, 'Feishu authentication');
if (!body.tenant_access_token) {
throw new Error('Feishu authentication returned no tenant access token');
}
return body.tenant_access_token;
}
async function listSlashCommandsWithToken({
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
}) {
const body = await requestJson(httpInstance, {
method: 'GET',
url: endpointFor(domain, SLASH_ENDPOINT).href,
headers: {
authorization: `Bearer ${tenantAccessToken}`,
'content-type': 'application/json; charset=utf-8',
},
signal: requestSignal(signal, timeoutMs),
timeout: timeoutMs,
}, 'Feishu slash command list');
return Array.isArray(body.data?.items) ? body.data.items : [];
}
/** List every slash command currently registered for the app. */
export async function listSlashCommands({
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000, signal,
}) {
const tenantAccessToken = await fetchTenantAccessToken({
appId, appSecret, domain, httpInstance, timeoutMs, signal,
});
return listSlashCommandsWithToken({
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
});
}
async function createSlashCommandWithToken({
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
command, description, icon = DEFAULT_ICON,
}) {
const data = { command };
if (description && (description.default_value || description.i18n)) {
data.description = description;
} else if (typeof description === 'string' && description.trim()) {
data.description = { default_value: description.trim() };
}
if (icon) data.description = { ...(data.description ?? {}), icon: { icon_key: icon } };
const body = await requestJson(httpInstance, {
method: 'POST',
url: endpointFor(domain, SLASH_ENDPOINT).href,
headers: {
authorization: `Bearer ${tenantAccessToken}`,
'content-type': 'application/json; charset=utf-8',
},
data,
signal: requestSignal(signal, timeoutMs),
timeout: timeoutMs,
}, `Feishu slash command create (/${command})`);
return body.data?.command_id ?? null;
}
/** Register a single slash command. Returns the server-assigned command_id. */
export async function createSlashCommand({
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000,
signal, command, description, icon = DEFAULT_ICON,
}) {
const tenantAccessToken = await fetchTenantAccessToken({
appId, appSecret, domain, httpInstance, timeoutMs, signal,
});
return createSlashCommandWithToken({
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
command, description, icon,
});
}
/** Delete a registered slash command by its server command_id. */
export async function deleteSlashCommand({
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000, signal, commandId,
}) {
const tenantAccessToken = await fetchTenantAccessToken({
appId, appSecret, domain, httpInstance, timeoutMs, signal,
});
await requestJson(httpInstance, {
method: 'DELETE',
url: endpointFor(domain, `${SLASH_ENDPOINT}/${commandId}`).href,
headers: { authorization: `Bearer ${tenantAccessToken}` },
signal: requestSignal(signal, timeoutMs),
timeout: timeoutMs,
}, 'Feishu slash command delete');
}
/**
* Best-effort sync of the manifest into the app's registered slash commands.
* Creates any command in the manifest that is not yet registered and returns
* a structured report. This is idempotent (the API rejects duplicates with
* "command already exists", so we skip existing names).
*
* @returns {{ created: Array<{command,command_id}>, existing: string[], failed: Array<{command,error}> }}
*/
export async function registerSlashCommands({
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000,
signal, manifest = SLASH_COMMAND_MANIFEST,
}) {
const tenantAccessToken = await fetchTenantAccessToken({
appId, appSecret, domain, httpInstance, timeoutMs, signal,
});
const existing = new Set((await listSlashCommandsWithToken({
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
}))
.map((item) => item.command));
const created = [];
const failed = [];
for (const entry of manifest) {
const command = String(entry.command ?? '').replace(/^\//, '');
if (!command) continue;
if (existing.has(command)) continue;
try {
const description = {
default_value: entry.default ?? entry.en_us ?? command,
i18n: {
zh_cn: entry.default ?? command,
en_us: entry.en_us ?? entry.default ?? command,
},
};
const commandId = await createSlashCommandWithToken({
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
command, description, icon: entry.icon ?? DEFAULT_ICON,
});
created.push({ command, command_id: commandId });
} catch (error) {
// "command already exists" can race with concurrent runs; treat as existing.
if (error?.code === '40000000' && /already exists/i.test(error?.msg ?? '')) {
existing.add(command);
continue;
}
if (MISSING_PERMISSION_CODES.has(error?.code)
|| /(?:lacks permission|access denied)/i.test(error?.msg ?? '')) {
// Missing app_slash_command:write permission; abort the batch.
failed.push({ command, error });
break;
}
failed.push({ command, error: error?.message ?? String(error) });
}
}
return {
created,
existing: [...existing].filter((c) => c !== null && c !== undefined),
failed,
};
}
export default registerSlashCommands;

View file

@ -72,8 +72,8 @@ export default {
'旧授权链接已作废,已生成新的修复链接。':
'The previous authorization link was invalidated and a new repair link was generated.',
'🔧 准备补全权限与回调。': '🔧 Preparing to complete permissions and the callback.',
'本次最多增量添加三项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);以及 im:resource(用于上传机器人发送的图片或文件)。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。':
'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.',
'本次会增量添加当前缺少项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);im:resource(用于上传机器人发送的图片或文件);以及原生命令面板所需的 application:app_slash_command:read / write。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。':
'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.',
'当前设备直接打开:': 'Open directly on this device:',
'若要用另一台设备扫码,发送 /repair qr。{expiry}。':
'To scan with another device, send /repair qr. {expiry}.',

View file

@ -110,6 +110,14 @@ function deferred() {
return { promise, resolve, reject };
}
async function waitFor(predicate, timeoutMs = 1_000) {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error('condition timed out');
await new Promise((resolve) => setImmediate(resolve));
}
}
test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connected', async () => {
let harnessChecks = 0;
let harnessSignal;
@ -206,6 +214,74 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
assert.deepEqual(runtime.status, stoppedStatus);
});
test('FeishuRuntime keeps Slash registration non-blocking and aborts it on stop', async () => {
const lark = fakeLark();
const requests = [];
let createSignal;
lark.defaultHttpInstance.request = async (options) => {
requests.push(options);
if (options.url.includes('/tenant_access_token/')) {
return { code: 0, tenant_access_token: 'tenant-token' };
}
if (options.method === 'GET') return { code: 0, data: { items: [] } };
createSignal = options.signal;
return new Promise((_resolve, reject) => {
const abort = () => reject(createSignal.reason);
createSignal.addEventListener('abort', abort, { once: true });
if (createSignal.aborted) abort();
});
};
const runtime = new FeishuRuntime({
lark,
appId: 'cli_slash',
appSecret: 'secret',
ownerOpenIds: ['ou_owner'],
harness: { async ensureRunning() {} },
state: { hasSeen: () => false },
logger: { info() {}, warn() {}, error() {} },
});
const starting = runtime.start();
await new Promise((resolve) => setImmediate(resolve));
FakeWSClient.instances[0].becomeReady();
const ready = await starting;
assert.equal(ready.ready, true);
await waitFor(() => createSignal !== undefined);
assert.equal(runtime.status.slashCommandRegistration, 'registering');
assert.equal(requests.filter((request) => request.url.includes('/tenant_access_token/')).length, 1);
await runtime.stop();
assert.equal(createSignal.aborted, true);
assert.equal(runtime.status.slashCommandRegistration, 'idle');
});
test('FeishuRuntime can disable Slash registration', async () => {
const lark = fakeLark();
let requests = 0;
lark.defaultHttpInstance.request = async () => {
requests += 1;
return { code: 0 };
};
const runtime = new FeishuRuntime({
lark,
appId: 'cli_no_slash',
appSecret: 'secret',
ownerOpenIds: ['ou_owner'],
harness: { async ensureRunning() {} },
state: { hasSeen: () => false },
slashCommands: false,
});
const starting = runtime.start();
await new Promise((resolve) => setImmediate(resolve));
FakeWSClient.instances[0].becomeReady();
await starting;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(requests, 0);
assert.equal(runtime.status.slashCommandRegistration, 'idle');
await runtime.stop();
});
test('FeishuRuntime uses a remembered private target for wildcard-only manual bots', async () => {
const state = { hasSeen: () => false };
const runtime = new FeishuRuntime({

View file

@ -187,6 +187,8 @@ test('QR registration separates events from card callbacks', async () => {
assert.deepEqual(run.options.addons.callbacks.items, ['card.action.trigger']);
assert.ok(run.options.addons.scopes.tenant.includes('im:resource'));
assert.equal(run.options.addons.scopes.tenant.includes('im:resource:upload'), false);
assert.ok(run.options.addons.scopes.tenant.includes('application:app_slash_command:read'));
assert.ok(run.options.addons.scopes.tenant.includes('application:app_slash_command:write'));
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/callbacks', expireIn: 60 });
run.resolve({
client_id: 'cli_callbacks', client_secret: 'callbacks-secret',
@ -346,7 +348,14 @@ test('callback repair is deduplicated per bot, updates only its secret, and prov
assert.equal(Object.hasOwn(run.options, 'appPreset'), false);
assert.deepEqual(run.options.addons, {
preset: false,
scopes: { tenant: ['im:message:readonly', 'im:resource'] },
scopes: {
tenant: [
'im:message:readonly',
'im:resource',
'application:app_slash_command:read',
'application:app_slash_command:write',
],
},
callbacks: { items: ['card.action.trigger'] },
});
run.options.onQRCodeReady({

View file

@ -86,6 +86,8 @@ test('QR success stores the secret off-config and becomes immediately chat-ready
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:send_as_bot'));
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:resource'));
assert.equal(fx.getSdkOptions().addons.scopes.tenant.includes('im:resource:upload'), false);
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('application:app_slash_command:read'));
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('application:app_slash_command:write'));
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('cardkit:card:write'));
fx.getSdkOptions().onQRCodeReady({ url: 'https://accounts.feishu.cn/qr', expireIn: 600 });
fixture.resolveRegistration({

View file

@ -1165,6 +1165,7 @@ test('production assembly uses ctx credentials and the active Host apiProxy with
}, {
dshHome: '/tmp/dsh-feishu-host-test',
workspace: '/tmp/dsh-feishu-workspace',
slashCommands: false,
}, {
lark: { registerApp: async () => ({}), defaultHttpInstance: httpInstance },
Controller: FakeController,
@ -1209,6 +1210,7 @@ test('production assembly uses ctx credentials and the active Host apiProxy with
assert.match(constructed.statePath, /integrations\/dsh-feishu\/state\.json$/);
assert.equal(constructed.runtime.appSecret, 'host-only');
assert.equal(constructed.runtime.wsAgent, wsAgent);
assert.equal(constructed.runtime.slashCommands, false);
const repair = { start() {}, status() {}, cancel() {} };
await constructed.controller.createRuntime({
botId: 'bot_alpha',

View file

@ -6,6 +6,7 @@ import {
FEISHU_RESOURCE_SCOPE,
assertCallbackRepairUrl,
} from '../../../src/channels/feishu/repair-manager.mjs';
import { SLASH_COMMAND_TENANT_SCOPES } from '../../../src/channels/feishu/slash-command-registry.mjs';
const flush = () => new Promise((resolve) => setImmediate(resolve));
@ -17,7 +18,7 @@ async function waitFor(predicate, timeoutMs = 1000) {
}
}
test('CallbackRepairManager targets one real app with only the callback and media scopes', async () => {
test('CallbackRepairManager targets one real app with only the callback and required scopes', async () => {
let observed;
let resolveRegistration;
const accepted = [];
@ -39,7 +40,13 @@ test('CallbackRepairManager targets one real app with only the callback and medi
assert.equal(Object.hasOwn(observed, 'appPreset'), false);
assert.deepEqual(observed.addons, {
preset: false,
scopes: { tenant: [FEISHU_MESSAGE_READ_SCOPE, FEISHU_RESOURCE_SCOPE] },
scopes: {
tenant: [
FEISHU_MESSAGE_READ_SCOPE,
FEISHU_RESOURCE_SCOPE,
...SLASH_COMMAND_TENANT_SCOPES,
],
},
callbacks: { items: ['card.action.trigger'] },
});
assert.equal(Object.hasOwn(observed.addons, 'events'), false);

View file

@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
listSlashCommands,
registerSlashCommands,
SLASH_COMMAND_TENANT_SCOPES,
SLASH_COMMAND_MANIFEST,
} from '../../../src/channels/feishu/slash-command-registry.mjs';
function fakeHttpInstance(handlers) {
const requests = [];
return {
requests,
http: {
async request(options) {
requests.push(options);
const { method, url } = options;
const key = method + ' ' + url.split('/open-apis/')[1];
const handler = handlers[key];
if (typeof handler !== 'function') {
throw new Error(`no fake handler for ${key}`);
}
return handler(options);
},
},
};
}
const AUTH_KEY = 'POST auth/v3/tenant_access_token/internal';
const LIST_KEY = 'GET application/v7/app_slash_commands';
test('SLASH_COMMAND_MANIFEST is non-empty and has no leading slash', () => {
assert.deepEqual(SLASH_COMMAND_TENANT_SCOPES, [
'application:app_slash_command:read',
'application:app_slash_command:write',
]);
assert.ok(Array.isArray(SLASH_COMMAND_MANIFEST));
assert.ok(SLASH_COMMAND_MANIFEST.length > 0);
for (const entry of SLASH_COMMAND_MANIFEST) {
assert.equal(typeof entry.command, 'string');
assert.ok(entry.command.length > 0);
assert.equal(entry.command.startsWith('/'), false, `command ${entry.command} must not start with /`);
assert.equal(typeof entry.default, 'string');
}
});
test('listSlashCommands returns the registered command items', async () => {
const { http, requests } = fakeHttpInstance({
[AUTH_KEY]: () => ({ code: 0, tenant_access_token: 'tenant-token' }),
[LIST_KEY]: () => ({ code: 0, data: { items: [{ command: 'menu', command_id: '1' }] } }),
});
const items = await listSlashCommands({ appId: 'a', appSecret: 's', httpInstance: http });
assert.deepEqual(items, [{ command: 'menu', command_id: '1' }]);
assert.equal(requests[1].headers.authorization, 'Bearer tenant-token');
});
test('registerSlashCommands creates missing and skips existing commands', async () => {
const createdBodies = [];
const { http, requests } = fakeHttpInstance({
[AUTH_KEY]: () => ({ code: 0, tenant_access_token: 'tenant-token' }),
[LIST_KEY]: () => ({ code: 0, data: { items: [{ command: 'menu' }, { command: 'help' }] } }),
'POST application/v7/app_slash_commands': (options) => {
createdBodies.push(options.data);
return { code: 0, data: { command_id: `id-${options.data.command}` } };
},
});
const manifest = [
{ command: 'menu', default: '打开菜单', en_us: 'Open menu' },
{ command: 'status', icon: 'ai-functions_outlined', default: '状态', en_us: 'Status' },
{ command: 'watch', icon: 'flag_outlined', default: '关注', en_us: 'Watch' },
];
const result = await registerSlashCommands({
appId: 'a', appSecret: 's', httpInstance: http, manifest,
});
// menu exists -> skipped; status & watch created
assert.equal(result.created.length, 2);
assert.ok(result.created.some((c) => c.command === 'status'));
assert.ok(result.created.some((c) => c.command === 'watch'));
assert.ok(result.existing.includes('menu'));
assert.ok(result.existing.includes('help'));
assert.equal(result.failed.length, 0);
assert.equal(createdBodies.length, 2);
assert.equal(requests.filter((request) => request.url.includes('/tenant_access_token/')).length, 1);
assert.deepEqual(createdBodies.map((body) => body.description.icon.icon_key), [
'ai-functions_outlined',
'flag_outlined',
]);
for (const body of createdBodies) {
assert.equal(body.command.startsWith('/'), false);
assert.ok(body.description.default_value);
assert.ok(body.description.i18n.zh_cn);
assert.ok(body.description.icon.icon_key);
}
});
test('registerSlashCommands aborts batch on missing permission', async () => {
const { http, requests } = fakeHttpInstance({
[AUTH_KEY]: () => ({ code: 0, tenant_access_token: 'tenant-token' }),
[LIST_KEY]: () => ({ code: 0, data: { items: [] } }),
'POST application/v7/app_slash_commands': () => {
const error = new Error('Request failed with status code 400');
error.response = {
data: {
code: 99991672,
msg: 'Access denied. One of the following scopes is required: [application:app_slash_command:write]',
},
};
throw error;
},
});
const manifest = [
{ command: 'menu', default: 'x', en_us: 'x' },
{ command: 'status', default: 'x', en_us: 'x' },
];
const result = await registerSlashCommands({
appId: 'a', appSecret: 's', httpInstance: http, manifest,
});
assert.equal(result.created.length, 0);
assert.equal(result.failed.length, 1);
assert.equal(result.failed[0].error.code, '99991672');
assert.equal(
requests.filter((request) => request.url.endsWith('/app_slash_commands')
&& request.method === 'POST').length,
1,
);
});
test('registerSlashCommands treats duplicate-create as already-existing', async () => {
const { http } = fakeHttpInstance({
[AUTH_KEY]: () => ({ code: 0, tenant_access_token: 'tenant-token' }),
[LIST_KEY]: () => ({ code: 0, data: { items: [] } }),
'POST application/v7/app_slash_commands': () => ({ code: 40000000, msg: 'command already exists' }),
});
const result = await registerSlashCommands({
appId: 'a', appSecret: 's', httpInstance: http,
manifest: [{ command: 'menu', default: 'x', en_us: 'x' }],
});
assert.equal(result.created.length, 0);
assert.equal(result.failed.length, 0);
});