merge: integrate upstream v2.0.0 (files, images, i18n) into feishu-card-system

- Resolve conflicts in feishu-cards.mjs / bridge.mjs / control-command.mjs / lib
- Keep card-system enhancements: menuCard/settingsCard/presetCard/modelCard, /m newCard, card form submit, model select
- Adopt upstream i18n t() wrapping + file/image delivery + inbound files
- Add missing English dictionary entries for new feishu card strings
- Rebuild lib (package now 2.0.0)
This commit is contained in:
C3H3-AI 2026-08-24 07:22:50 +08:00
commit 044ab74c28
167 changed files with 22497 additions and 1793 deletions

261
CHANGELOG.md Normal file
View file

@ -0,0 +1,261 @@
# Changelog / 更新日志
本文件记录 dsh-im 各正式版本的重要变化。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
This file records the notable changes in each dsh-im release. Its format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and its versions follow [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [2.0.0] - 2026-08-24
### Added / 新增
- Discord 服务器文字和公告频道首次 @ 机器人后会创建原生 Thread;后续消息、流式回答和结果文件都留在该 Thread,并在重启、事件重放和并发创建时保持同一会话。
The first bot mention in a Discord server text or announcement channel now creates a native Thread; follow-up messages, streamed replies, and result files stay in that Thread, with stable routing across restarts, event replays, and concurrent creation attempts.
- Telegram 新增原生 Rich Message:私聊使用可更新 Draft 并持久化唯一最终消息,群聊和 Topic 原位完成占位消息,同时保留 Markdown 结构、长内容拆分和确定性纯文本降级。
Added native Telegram Rich Messages: private chats update a Draft and persist one final message, while groups and Topics finalize their placeholder in place, preserving Markdown structure, long-content splitting, and deterministic plain-text fallback.
- 新增机器人聊天消息的英文支持:Host 配置 `language: en`(或环境变量 `DSH_IM_LANGUAGE=en`)后,各渠道发送给用户的提示、命令帮助和交互消息会切换为英文;未设置或未收录的文案仍以中文原样输出,不影响现有中文用户。
Added English support for bot chat messages: with `language: en` in the Host config (or the `DSH_IM_LANGUAGE=en` environment variable), prompts, command help, and interaction messages sent by every channel switch to English; unset or untranslated text is still sent verbatim in Chinese, so existing Chinese users are unaffected.
### Changed / 变更
- **重大变更:** Discord 服务器父频道中的任务现在默认迁移到机器人创建的 Public Thread。部署方需要启用 **Message Content Intent**,并授予 **Create Public Threads**、**Send Messages in Threads**、**Send Messages** 和 **Read Message History**;文件交付还需要 **Attach Files**。
**Breaking:** Discord tasks started in server parent channels now move into bot-created Public Threads by default. Deployments must enable **Message Content Intent** and grant **Create Public Threads**, **Send Messages in Threads**, **Send Messages**, and **Read Message History**; file delivery also requires **Attach Files**.
- Harness 助手增量和最终回答现在保留 Markdown 呈现意图,Telegram 交付回执会记录 Rich、纯文本降级、失败或不确定终态,并避免重复最终消息。
Harness assistant updates and final replies now preserve Markdown presentation intent; Telegram delivery receipts record Rich delivery, plain-text fallback, failure, or uncertain terminal outcomes without duplicating final messages.
- 统一各渠道共用的英文文案到共享词典,消除同名键在不同渠道词典中的重复定义。
Consolidated English copy shared across channels into the shared dictionaries, removing duplicate keys that were defined in multiple channel dictionaries.
### Fixed / 修复
- 最终文本交付明确失败时会向渠道上层报告安全错误,同时仍然完成已登记结果文件的交付,不会重复运行 Prompt。
Definite final-text delivery failures now surface a safe channel-level error while registered result files still settle, without rerunning the Prompt.
- 收紧 Host 语言值识别并修复 Discord、Slack、Telegram 等渠道的英文消息边界,未知语言继续可靠回退为中文。
Hardened Host language-value recognition and English-message handling in Discord, Slack, Telegram, and other channels, while unknown languages continue to fall back reliably to Chinese.
## [1.5.0] - 2026-08-24
### Added / 新增
- Harness 返回的图片现在会在九个内置 IM 渠道中优先使用原生图片消息呈现;渠道不支持或明确拒绝图片发送时自动回退为文件附件。
Images returned by Harness now prefer native image messages across all nine built-in IM channels, with automatic file-attachment fallback when a channel does not support or definitively rejects image delivery.
### Changed / 变更
- 统一结果文件与图片的发送、交付回执、失败提示和资源释放,并在发送结果不确定时避免补发文件造成重复消息。
Unified result-file and image sending, delivery receipts, failure notices, and resource cleanup, while avoiding duplicate file fallback when an image delivery result is uncertain.
## [1.4.0] - 2026-08-24
### Added / 新增
- QQ 最终回答现在支持 Markdown;长回答会尽量按代码块和 GFM 表格边界切分,平台拒绝 Markdown 时自动回退为纯文本。
QQ final answers now support Markdown; long answers are split around code blocks and GFM tables where possible, with automatic plain-text fallback when QQ rejects Markdown.
### Changed / 变更
- QQ 私聊把进度和最终答案收束在一个回复气泡中,群聊只发送最终答案;工具失败会附在最终回答中,避免成功工具和状态消息刷屏。
QQ private chats keep progress and the final answer in one reply bubble, while group chats send only the final answer; tool failures are appended to the final response without spamming successful tool or status messages.
- 长 QQ 回答使用唯一消息序号并避开被动回复额度,降低重复去重和超额发送失败。
Long QQ answers use unique message sequence numbers and avoid passive-reply quotas, reducing duplicate suppression and over-quota delivery failures.
## [1.3.0] - 2026-08-23
### Added / 新增
- 九个内置 IM 渠道现在都能接收普通文件,并把文件随同用户消息安全地交给当前 Harness Session。
All nine built-in IM channels can now receive ordinary files and safely pass them with the user message to the active Harness Session.
### Changed / 变更
- 统一入站文件的提前下载、工作区暂存、路径保护、失败提示和 Turn 结束清理,并继续由各消息平台决定文件类型、数量和大小限制。
Unified inbound-file prefetching, workspace staging, path protection, failure messages, and end-of-Turn cleanup, while leaving file type, count, and size limits to each messaging platform.
## [1.2.0] - 2026-08-23
### Added / 新增
- WhatsApp 新增仅自己、指定联系人和开放响应三种访问模式;旧机器人和新接入机器人均默认仅响应账号自聊。
Added Only me, Selected contacts, and Open responses access modes for WhatsApp; existing and newly linked bots now default to self-chat only.
## [1.1.0] - 2026-08-23
### Added / 新增
- 新增 Harness 结果文件的原生交付能力,支持通过钉钉、Discord、飞书、QQ、Slack、Telegram、企业微信、微信和 WhatsApp 返回文件。
Added native delivery of Harness result files through DingTalk, Discord, Feishu, QQ, Slack, Telegram, WeCom, Weixin, and WhatsApp.
- 新增统一的结果文件快照、交付回执、失败分类与消息 ID 追踪。
Added unified result-file snapshots, delivery receipts, failure classifications, and message ID tracking.
### Changed / 变更
- 压缩机器人连接元数据布局,并统一各频道卡片的反馈样式。
Compacted bot connection metadata and unified feedback styling across channel cards.
- 将 Agent Preset 使用说明移入可访问的帮助提示。
Moved Agent Preset guidance into an accessible help tooltip.
### Fixed / 修复
- 改进微信对 Harness 访问失败、回环地址拒绝和健康检查错误的提示。
Improved Weixin messages for Harness access failures, loopback denials, and health-check errors.
## [1.0.2] - 2026-08-22
### Fixed / 修复
- 飞书 REST 请求和 WebSocket 连接现在都会遵循系统代理设置。
Feishu REST requests and WebSocket connections now honor system proxy settings.
## [1.0.1] - 2026-08-22
### Fixed / 修复
- 改进微信对 Harness 健康检查失败的分类和安全提示。
Improved the classification and safe messaging of Harness health-check failures in Weixin.
## [1.0.0] - 2026-08-22
### Added / 新增
- 新增 `/presetlist` 与 `/preset` 聊天命令,可查看和切换 Agent Preset,并支持恢复跟随 Host 默认值。
Added `/presetlist` and `/preset` chat commands for viewing and switching Agent Presets, including returning to the Host default.
## [0.19.0] - 2026-08-22
### Added / 新增
- Telegram 启动时注册命令菜单和菜单按钮。
Telegram now registers its command menu and menu button when the bot starts.
- 飞书新增群聊响应模式配置和群消息权限授权流程。
Added configurable group response modes and group-message permission authorization for Feishu.
## [0.18.0] - 2026-08-22
### Added / 新增
- 支持为每个机器人独立选择 Agent Preset,并完成创建、持久化和会话启动生命周期。
Added per-bot Agent Preset selection across bot creation, persistence, and session startup.
## [0.17.1] - 2026-08-21
### Fixed / 修复
- 当模型不支持图片输入时,各频道会返回更明确的提示。
Channels now provide clearer feedback when a model does not support image input.
## [0.17.0] - 2026-08-21
### Added / 新增
- 飞书新增持久化 Session 关注、完成推送和菜单入口。
Added persistent Session watches, completion notifications, and a watch menu entry for Feishu.
- 飞书 Session 列表支持关注按钮、归档标记和 `/archived on|off` 切换。
Feishu Session lists now include watch buttons, archived badges, and the `/archived on|off` toggle.
### Fixed / 修复
- 改进飞书关注完成消息的可靠交付和重连补偿。
Improved reliable delivery and reconnect compensation for Feishu watch completions.
## [0.16.0] - 2026-08-21
### Added / 新增
- 飞书新增交互式菜单卡片、Session/工作区列表卡片和一键回调修复。
Added interactive menu cards, Session and workspace list cards, and one-click callback repair for Feishu.
### Fixed / 修复
- 改进飞书交互卡片回调的可靠性。
Improved the reliability of Feishu interactive-card callbacks.
## [0.15.0] - 2026-08-21
### Changed / 变更
- 将 AI Office 连接器标记为实验性功能。
Marked the AI Office connector as experimental.
### Fixed / 修复
- 改进微信机器人激活失败的分类和提示。
Improved classification and messaging for Weixin bot activation failures.
## [0.14.0] - 2026-08-21
### Added / 新增
- 新增 AI Office 连接器,并支持在 Harness 中执行 Office 任务。
Added the AI Office connector and support for executing Office jobs in Harness.
- 加强 Telegram 私聊访问控制。
Strengthened access control for Telegram private chats.
### Changed / 变更
- 更新插件品牌、README 视觉和 AI Office 配置示例。
Updated plugin branding, README visuals, and the AI Office configuration example.
## [0.13.0] - 2026-08-20
### Added / 新增
- 支持通过编号选择模型。
Added model selection by list number.
### Changed / 变更
- 完善机器人交互、多机器人能力和频道配置文档。
Expanded documentation for bot interactions, multi-bot support, and channel setup.
- 加强发布包检查,避免捆绑 DSH 运行时包。
Strengthened package verification to prevent bundling DSH runtime packages.
## [0.12.0] - 2026-08-20
### Added / 新增
- 新增模型查看、模型切换、停止和引导当前 Turn 的聊天命令。
Added chat commands for listing and switching models, stopping work, and steering the active Turn.
### Changed / 变更
- 改进 npm 安装文档、包元数据和项目徽章。
Improved npm installation documentation, package metadata, and project badges.
## [0.11.0] - 2026-08-19
### Added / 新增
- 建立统一 IM 插件的首个保留标签版本,集中管理飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord 和 WhatsApp。
Established the first retained tag of the unified IM plugin, covering Feishu, Weixin, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp.
- 支持多机器人、工作区切换、Session 列表与绑定、Harness 交互和审批、图片输入及连接测试。
Added multi-bot support, workspace switching, Session listing and binding, Harness interactions and approvals, image input, and connection tests.
- 支持按 Harness 默认 Agent Preset 创建 IM Session。
Added IM Session creation using the Harness default Agent Preset.
### Changed / 变更
- 完成双语 README、插件品牌、深色主题和紧凑机器人卡片。
Added a bilingual README, plugin branding, dark-theme support, and compact bot cards.
- 改进 npm 发布包结构,保留 CLI 入口并避免安装脚本拦截。
Improved npm package contents to preserve the CLI entry point and avoid install-script blocking.
[Unreleased]: https://github.com/xmanrui/dsh-im/compare/v2.0.0...HEAD
[2.0.0]: https://github.com/xmanrui/dsh-im/compare/v1.5.0...v2.0.0
[1.5.0]: https://github.com/xmanrui/dsh-im/compare/v1.4.0...v1.5.0
[1.4.0]: https://github.com/xmanrui/dsh-im/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/xmanrui/dsh-im/compare/v1.2.0...v1.3.0
[1.2.0]: https://github.com/xmanrui/dsh-im/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/xmanrui/dsh-im/compare/v1.0.2...v1.1.0
[1.0.2]: https://github.com/xmanrui/dsh-im/compare/v1.0.1...v1.0.2
[1.0.1]: https://github.com/xmanrui/dsh-im/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/xmanrui/dsh-im/compare/v0.19.0...v1.0.0
[0.19.0]: https://github.com/xmanrui/dsh-im/compare/v0.18.0...v0.19.0
[0.18.0]: https://github.com/xmanrui/dsh-im/compare/v0.17.1...v0.18.0
[0.17.1]: https://github.com/xmanrui/dsh-im/compare/v0.17.0...v0.17.1
[0.17.0]: https://github.com/xmanrui/dsh-im/compare/v0.16.0...v0.17.0
[0.16.0]: https://github.com/xmanrui/dsh-im/compare/v0.15.0...v0.16.0
[0.15.0]: https://github.com/xmanrui/dsh-im/compare/v0.14.0...v0.15.0
[0.14.0]: https://github.com/xmanrui/dsh-im/compare/v0.13.0...v0.14.0
[0.13.0]: https://github.com/xmanrui/dsh-im/compare/v0.12.0...v0.13.0
[0.12.0]: https://github.com/xmanrui/dsh-im/compare/v0.11.0...v0.12.0
[0.11.0]: https://github.com/xmanrui/dsh-im/releases/tag/v0.11.0

73
CONTEXT.md Normal file
View file

@ -0,0 +1,73 @@
# DeepSeek Harness IM
本上下文描述 dsh-im 如何把不同即时通信平台中的用户意图和 DeepSeek Harness 的工作过程连接起来。项目的核心价值不是渠道数量,而是让用户在所在渠道中自然、可靠地完成 Harness 任务。
## Language
**语义消息(Semantic Message)**:
不依赖具体 IM 平台、能够完整表达用户输入及其上下文的一条消息,包括内容、引用关系和会话位置。
_Avoid_: 统一文本、通用消息体
**语义交互(Semantic Interaction)**:
Harness 等待用户完成的一次有状态操作,例如审批、单选、多选或补充输入。
_Avoid_: 按钮事件、命令回复
**产物(Artifact)**:
Harness 任务读取或产生、需要在 Harness 与用户所在渠道之间安全传递的文件或媒体。
_Avoid_: 附件路径、下载地址
**产物来源(Artifact Provenance)**:
证明一个现有或新建的出站产物由当前 Harness Session/Turn 通过受信工具结果显式登记的可验证记录;它证明交付意图和路由,不要求文件必须由当前 Turn 创建。
_Avoid_: 回答里的路径、最新文件
**会话路由(Conversation Route)**:
唯一确定消息所属机器人、聊天及话题或线程的会话位置。
_Avoid_: Chat ID、会话 Key
**渠道能力(Channel Capability)**:
某个机器人实例在当前权限和运行条件下可以可靠提供的原生输入、交互或呈现能力。
_Avoid_: 平台支持、SDK 功能
**原生呈现(Native Presentation)**:
渠道依据自身交互习惯呈现同一语义,例如卡片、按钮、消息编辑、流式回复或输入状态。
_Avoid_: 特殊适配、渠道特例
**呈现意图(Presentation Intent)**:
Harness 输出在进入渠道前必须保留的内容结构和格式含义,例如纯文本、Markdown、进度、交互或产物;它不指定某个平台的控件或语法。
_Avoid_: 回答字符串、Telegram Rich Message、统一富文本
**渠道原生动作(Native Channel Action)**:
渠道为承载一次语义任务而执行的平台专属操作,例如创建 Discord Thread、更新飞书卡片或上传附件;它属于渠道边界,不要求其他渠道提供同名操作。
_Avoid_: 公共命令、通用渠道方法
**选择呈现(Selection Presentation)**:
单选或多选在渠道中的实际实现形态,只能表述为原生控件、组合交互或文字降级;判断能力时必须同时说明呈现形式和已经验证的范围。
_Avoid_: 支持多选、能力允许时、统一选择器
**明确降级(Explicit Fallback)**:
渠道缺少所需能力时,保持业务语义并向用户说明限制的替代体验。
_Avoid_: 兼容模式、静默忽略
**能力切片(Capability Slice)**:
围绕一个完整用户价值,从统一语义、安全策略到各渠道原生呈现和验收的端到端建设单元。
_Avoid_: 渠道任务、接口改造
**标杆渠道(Reference Channel)**:
一项能力切片中最先完成真实客户端闭环、其原生机制最适合验证该语义和体验的渠道。标杆渠道按能力选择,不是永久主渠道。
_Avoid_: 主渠道、默认渠道
**用户价值优先级(User Value Priority)**:
依据核心任务完成度、语义准确性、移动端操作成本以及安全与可靠性,决定能力切片的建设先后顺序。
_Avoid_: 功能数量排序、渠道用户量排序
**渠道适配性(Channel Fit)**:
某项语义能力与渠道原生机制、权限覆盖、接口稳定性和可测试性之间的匹配程度,用于选择标杆渠道并决定原生实现或明确降级。
_Avoid_: 渠道排名、平台先进程度
**渠道行为基线(Channel Behavior Baseline)**:
语义迁移开始前,某个渠道已经向用户提供的任务流程、控制能力、状态语义和原生体验的可验证集合;后续实现可以改善它,但不能使其中任何能力消失或退化。
_Avoid_: 当前代码、旧实现、测试现状
**等价接管(Parity Cutover)**:
新语义路径在覆盖渠道行为基线、通过回归并具备回滚能力后,才取得某项消息或能力的唯一处理权。
_Avoid_: 直接替换、重写完成

View file

@ -49,16 +49,34 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
| WeChat | Scan a QR code to bind a WeChat bot | Tencent iLink long polling for sending and receiving messages | | WeChat | Scan a QR code to bind a WeChat bot | Tencent iLink long polling for sending and receiving messages |
| DingTalk | Create a bot by QR code, or bind one with Client ID + Client Secret | DingTalk Stream connection; streaming replies through AI Cards | | DingTalk | Create a bot by QR code, or bind one with Client ID + Client Secret | DingTalk Stream connection; streaming replies through AI Cards |
| WeCom | Create an intelligent bot by QR code, or bind one with Bot ID + Secret | Official WebSocket connection; native thinking state, tool progress, and streaming replies | | WeCom | Create an intelligent bot by QR code, or bind one with Bot ID + Secret | Official WebSocket connection; native thinking state, tool progress, and streaming replies |
| QQ | Create a bot with mobile QQ QR scanning, or bind one with AppID + AppSecret | WebSocket connection; native typing and streaming replies in private chats, replies when mentioned in groups | | QQ | Create a bot with mobile QQ QR scanning, or bind one with AppID + AppSecret | WebSocket connection; private chats show typing and receive one Markdown reply, while mentioned group chats receive only the final answer |
| Slack | Create an app from the bundled App Manifest, then enter a Bot Token (`xoxb-`) and App Token (`xapp-`) | Socket Mode connection; direct DM replies, mention-only channel replies, and preferred native streaming API | | Slack | Create an app from the bundled App Manifest, then enter a Bot Token (`xoxb-`) and App Token (`xapp-`) | Socket Mode connection; direct DM replies, mention-only channel replies, and preferred native streaming API |
| Telegram | Enter a Bot Token generated by @BotFather | Bot API long polling; DMs work by default and groups respond to mentions or replies, while each bot can optionally enable a private-DM allowlist; streaming uses message edits | | Telegram | Enter a Bot Token generated by @BotFather | Bot API long polling; DMs work by default and groups respond to mentions or replies, while each bot can optionally enable a private-DM allowlist; private chats stream through a Rich Message Draft and persist one rich final, groups and Topics finalize their placeholder in place, and unsupported Rich delivery falls back to ordinary text |
| Discord | Enter a Bot Token generated in the Developer Portal | Gateway v10 connection; direct DM replies, mention-only server replies, and streaming through message edits | | Discord | Enter a Bot Token generated in the Developer Portal | Gateway v10 connection; direct DM replies; the first mention in a server text or announcement channel creates a native Thread, where follow-up messages no longer need to mention the bot; replies stream through message edits |
| WhatsApp | Scan a QR code with mobile WhatsApp to link a device | WhatsApp Web connection; read receipt and typing indicator followed by the final answer | | WhatsApp | Scan a QR code with mobile WhatsApp to link a device | WhatsApp Web connection; self-chat only by default, with optional selected-contact and open-response modes; read receipt and typing indicator followed by the final answer |
Other IM platforms can be added through the same channel-adapter structure. 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. 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.
### Result-file and image delivery
All nine built-in channels can return any file readable by Harness as a native channel attachment. Existing files and files created by the current task can both be sent directly. The capability is available to every connected bot by default, with no switch or per-bot allowlist, while existing text, image, streaming, command, and Session behavior remains unchanged.
After the model calls the file-return tool, the plugin hands the specified file to the channel's native API. Images prefer native image messages; if a channel does not support or definitively rejects image delivery, the plugin falls back to a file attachment, while an uncertain result never triggers a duplicate fallback. The plugin adds no rules for file origin, creation time, workspace boundary, extension, content, count, size, or lifetime; the file only needs to exist and be readable. A channel may still reject delivery according to its own permissions, quota, file capability, or account tier, and the plugin reports that provider result.
| Channel | Platform requirements |
| --- | --- |
| WeChat | The current binding protocol and conversation must support native file messages; the WeChat API response determines the actual range. |
| Feishu | Feishu's file-upload API requires a non-empty file no larger than the platform's 30 MB limit. The app needs the `im:resource` tenant scope (**Read and upload images or other files**). Apps created through the built-in QR flow request it by default; existing or manually connected apps still need it added and approved. The Feishu developer console currently has no separate `im:resource:upload` scope. |
| DingTalk | The app needs `qyapi_base`, and the bot must support file messages. The current OAPI and bot capability determine the accepted formats and sizes. |
| WeCom | The app needs media-upload and file-message capability; the WeCom API response determines the actual range. |
| QQ | The bot needs file-message capability and remains subject to QQ's daily upload quota; the bot reports when the quota is exhausted. |
| Slack | The Bot Token needs `files:write`; the Workspace's current policy determines the actual size limit. After changing scopes, re-authorize/reinstall the App and reconnect the bot. |
| Telegram | The bot must be allowed to send documents in the current chat; the Bot API response determines the actual range. |
| Discord | Enable **Message Content Intent** in the Developer Portal. The bot needs **Send Messages**, **Create Public Threads**, **Send Messages in Threads**, and **Read Message History**; result-file delivery also requires **Attach Files**. The current account and server capability determine the actual attachment allowance. |
| WhatsApp | The linked session must support Document Messages; the WhatsApp/Baileys response determines the actual range. |
## AI Office Connector ## AI Office Connector
The **AI Office** page lets the local Harness connect outward to a public Office. The machine needs no public IP, forwarded port, or WebSocket server. The Device Token is written only to the Harness credential provider; the ordinary config file contains only the device ID, Office origin, workspace aliases, and instruction-preset aliases. Office selects aliases and never receives local absolute paths. The **AI Office** page lets the local Harness connect outward to a public Office. The machine needs no public IP, forwarded port, or WebSocket server. The Device Token is written only to the Harness credential provider; the ordinary config file contains only the device ID, Office origin, workspace aliases, and instruction-preset aliases. Office selects aliases and never receives local absolute paths.
@ -98,6 +116,8 @@ If this machine must use a forward proxy to reach Feishu, set `HTTPS_PROXY` to a
Each Telegram bot has its own access-mode control on its bot card. Existing and newly connected bots both default to **Compatible mode**: DMs receive replies, while group messages require a mention of or reply to the bot. Restrictions apply only after explicitly switching that bot to **Safe mode (private-chat allowlist)**. Safe mode ignores every group message and admits only numeric User IDs in that bot's allowlist. Enter one ID per line. Switching back to Compatible mode retains the allowlist without enforcing it, so it is available when Safe mode is enabled again. An empty allowlist in Safe mode rejects all inbound messages for that bot. Each Telegram bot has its own access-mode control on its bot card. Existing and newly connected bots both default to **Compatible mode**: DMs receive replies, while group messages require a mention of or reply to the bot. Restrictions apply only after explicitly switching that bot to **Safe mode (private-chat allowlist)**. Safe mode ignores every group message and admits only numeric User IDs in that bot's allowlist. Enter one ID per line. Switching back to Compatible mode retains the allowlist without enforcing it, so it is available when Safe mode is enabled again. An empty allowlist in Safe mode rejects all inbound messages for that bot.
Each WhatsApp bot also has its own access mode. Existing bots migrate to **Only me**, which is also the default for newly linked bots and accepts only self-chat messages from the linked account. **Selected contacts** additionally accepts direct messages from allowlisted phone numbers and ignores groups. Enter one number with its country or region code per line; a leading `+` is optional. **Open responses** preserves the previous behavior: all direct messages are accepted, together with group mentions or replies. Switching modes retains the allowlist. An empty Selected contacts allowlist behaves like Only me, and rejected messages are ignored silently.
## Bot commands ## Bot commands
| Command | Description | | Command | Description |
@ -148,7 +168,7 @@ If the Slack desktop app has no native Slash Command registered with the same na
- `/session` accepts exactly one Session ID obtained from `/sessionlist`. It neither creates a session nor immediately prompts the model; later messages in the current chat continue the bound session. Regular archived sessions can be bound without being unarchived, while subagent sessions cannot be bound. - `/session` accepts exactly one Session ID obtained from `/sessionlist`. It neither creates a session nor immediately prompts the model; later messages in the current chat continue the bound session. Regular archived sessions can be bound without being unarchived, while subagent sessions cannot be bound.
- `/session` locates the session's unique workspace automatically. Binding inside the current workspace replaces only this chat's mapping. A cross-workspace binding switches the bot workspace, clears the old session mappings for all of that bot's chats, and then binds this chat, so it affects the bot's other chats. A reply already being generated may still finish. - `/session` locates the session's unique workspace automatically. Binding inside the current workspace replaces only this chat's mapping. A cross-workspace binding switches the bot workspace, clears the old session mappings for all of that bot's chats, and then binds this chat, so it affects the bot's other chats. A reply already being generated may still finish.
- Workspace switches and session bindings only clear or replace dsh-im chat mappings. They never delete, empty, or archive old Session contents; an old Session can still be listed and bound again. - Workspace switches and session bindings only clear or replace dsh-im chat mappings. They never delete, empty, or archive old Session contents; an old Session can still be listed and bound again.
- Any user within the platform bot's visibility scope who can normally message it can run these commands; there is no separate administrator role. Telegram Compatible mode follows the original DM and group mention/reply rules. Safe mode admits only private users in that bot's allowlist and always ignores group commands. - Any user admitted by the current channel access policy can run these commands; there is no separate administrator role. Telegram Compatible mode follows the original DM and group mention/reply rules, while Safe mode admits only allowlisted private users. WhatsApp Only me accepts self-chat only, Selected contacts accepts self-chat plus allowlisted direct messages, and Open responses accepts every direct message plus group mentions or replies.
- Agent Preset names and IDs come from the same Harness Host, and any command-authorized user can change the Preset used by all future new Sessions across this bot's chats. Expose `/presetlist` and `/preset` only to trusted users. - Agent Preset names and IDs come from the same Harness Host, and any command-authorized user can change the Preset used by all future new Sessions across this bot's chats. Expose `/presetlist` and `/preset` only to trusted users.
- The list comes from the Harness Host's global registry and can include local absolute paths for other bots, other channels, or non-IM projects. Restrict the bot's visibility to trusted users. - The list comes from the Harness Host's global registry and can include local absolute paths for other bots, other channels, or non-IM projects. Restrict the bot's visibility to trusted users.
- Session results also come from the global Harness Host. Session IDs and titles can belong to other bots, other channels, or non-IM projects, and may contain sensitive metadata. Enable these commands only when every user in the bot's visibility scope is trusted. - Session results also come from the global Harness Host. Session IDs and titles can belong to other bots, other channels, or non-IM projects, and may contain sensitive metadata. Enable these commands only when every user in the bot's visibility scope is trusted.
@ -170,10 +190,10 @@ If the Slack desktop app has no native Slash Command registered with the same na
- Registers one **IM Bot** settings page containing nine IM channels and one AI Office Connector. - Registers one **IM Bot** settings page containing nine IM channels and one AI Office Connector.
- Maintains the Host, client, and runtime sources for all nine channels and the Office Connector in this repository without external standalone plugins. - Maintains the Host, client, and runtime sources for all nine channels and the Office Connector in this repository without external standalone plugins.
- Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English. - Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English. Bot chat messages follow the Host's `language` config (Chinese by default; `en` switches them to English), with Chinese always as the fallback so untranslated text is sent verbatim.
- Uses logos for WeChat, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, WhatsApp, and AI Office navigation without enable/disable switches. - Uses logos for WeChat, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, WhatsApp, and AI Office navigation without enable/disable switches.
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by IM channel; the Office Connector separately owns Device credentials, Job leases, approval waits, and concurrency limits. - Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by IM channel; the Office Connector separately owns Device credentials, Job leases, approval waits, and concurrency limits.
- Returns only QR codes, the public Slack Manifest, redacted status data, and the access mode and allowlist User IDs that the user explicitly saved for the current Telegram bot. Manually entered secrets and Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Slack Bot/App Tokens, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, AI Office Device Tokens, or other raw user identifiers observed from platform messages. - Returns only QR codes, the public Slack Manifest, redacted status data, and access modes or allowlist identifiers explicitly saved for the current Telegram or WhatsApp bot. Manually entered secrets and Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Slack Bot/App Tokens, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, AI Office Device Tokens, or other raw user identifiers observed from platform messages.
## Local development ## Local development
@ -195,6 +215,18 @@ IM management RPCs accept loopback browsers by default. When a Web profile is de
`trusted-host` reuses Harness's Host/Origin fence; it is not user authentication. Anyone who can reach that LAN authority can inspect bot status, scan or submit application credentials, reconnect bots, and remove bots. Enable it only on a trusted network. `trusted-host` reuses Harness's Host/Origin fence; it is not user authentication. Anyone who can reach that LAN authority can inspect bot status, scan or submit application credentials, reconnect bots, and remove bots. Enable it only on a trusted network.
### Bot chat message language
Bot chat messages are in Chinese by default. To switch them to English, set `language: en` in the plugin config (also accepts `en-US` or `english`), or set the `DSH_IM_LANGUAGE=en` environment variable:
```yaml
- id: xmanrui-dsh-im
config:
language: en
```
Without a setting, Chinese is used. Chinese is always the fallback language — any text missing from the English dictionary is sent verbatim in Chinese, so this feature never changes the behavior of existing Chinese users.
--- ---
## Contact ## Contact

View file

@ -52,16 +52,34 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
| 微信 | 使用微信扫码绑定机器人 | 腾讯 iLink 长轮询收发消息 | | 微信 | 使用微信扫码绑定机器人 | 腾讯 iLink 长轮询收发消息 |
| 钉钉 | 扫码创建机器人,或使用 Client ID + Client Secret 手动绑定 | 钉钉 Stream 长连接;通过 AI Card 流式显示回答 | | 钉钉 | 扫码创建机器人,或使用 Client ID + Client Secret 手动绑定 | 钉钉 Stream 长连接;通过 AI Card 流式显示回答 |
| 企业微信 | 使用企业微信 App 扫码创建智能机器人,或使用 Bot ID + Secret 手动绑定 | 官方 WebSocket 长连接;原生显示“正在思考中”、工具执行进度和流式回答 | | 企业微信 | 使用企业微信 App 扫码创建智能机器人,或使用 Bot ID + Secret 手动绑定 | 官方 WebSocket 长连接;原生显示“正在思考中”、工具执行进度和流式回答 |
| QQ | 使用手机 QQ 扫码创建机器人,或使用 AppID + AppSecret 手动绑定 | WebSocket 长连接;私聊显示“正在输入”和流式回答,群聊被 @ 后回复 | | QQ | 使用手机 QQ 扫码创建机器人,或使用 AppID + AppSecret 手动绑定 | WebSocket 长连接;私聊显示“正在输入”并以单条 Markdown 回复,群聊被 @ 后只发送最终答案 |
| Slack | 使用预置 App Manifest 创建应用,再填写 Bot Token(`xoxb-`)和 App Token(`xapp-`) | Socket Mode 长连接;私聊直接回复,频道被 @ 后响应,优先使用官方流式消息 API | | Slack | 使用预置 App Manifest 创建应用,再填写 Bot Token(`xoxb-`)和 App Token(`xapp-`) | Socket Mode 长连接;私聊直接回复,频道被 @ 后响应,优先使用官方流式消息 API |
| Telegram | 使用 @BotFather 生成的 Bot Token | Bot API 长轮询;默认私聊直接响应、群聊被提及或回复时响应,也可为每个机器人独立启用私聊白名单安全模式;通过编辑消息流式显示回答 | | Telegram | 使用 @BotFather 生成的 Bot Token | Bot API 长轮询;默认私聊直接响应、群聊被提及或回复时响应,也可为每个机器人独立启用私聊白名单安全模式;私聊通过 Rich Message Draft 流式预览并持久化最终富消息,群聊和 Topic 原位完成占位消息,平台不支持时回退为普通文字 |
| Discord | 使用 Developer Portal 生成的 Bot Token | Gateway v10 长连接;私信直接回复,服务器频道被提及时响应,通过编辑消息流式显示回答 | | Discord | 使用 Developer Portal 生成的 Bot Token | Gateway v10 长连接;私信直接回复;服务器文字/公告频道首次 @ 后创建原生 Thread,后续在线程中无需重复 @,并通过编辑消息流式显示回答 |
| WhatsApp | 使用手机 WhatsApp 扫码关联设备 | WhatsApp Web 长连接;显示已读和“正在输入”,再发送最终回答 | | WhatsApp | 使用手机 WhatsApp 扫码关联设备 | WhatsApp Web 长连接;默认仅响应账号自聊,也可切换到指定联系人或开放响应模式;显示已读和“正在输入”,再发送最终回答 |
其他 IM 平台可继续按同一渠道适配器结构接入。 其他 IM 平台可继续按同一渠道适配器结构接入。
九个内置渠道均支持把 JPEG、PNG、WebP 图片,以及以图片文件方式发送的 GIF,连同可选文字说明发送给 Harness;单张图片上限为 5 MB,单条消息中的图片总大小上限为 20 MB。 九个内置渠道均支持把 JPEG、PNG、WebP 图片,以及以图片文件方式发送的 GIF,连同可选文字说明发送给 Harness;单张图片上限为 5 MB,单条消息中的图片总大小上限为 20 MB。
### 结果文件与图片回传
九个内置渠道均已实现把 Harness 可读取的文件作为渠道原生附件回传。已有文件和当前任务新生成的文件都可以直接发送;该能力对所有已连接机器人默认可用,无需开关或机器人白名单,原有文字、图片、流式回复、命令和会话行为保持不变。
模型调用文件回传工具后,插件把指定文件交给当前渠道的原生接口。图片会优先以原生图片消息呈现;渠道不支持或明确拒绝图片发送时自动回退为文件附件,发送结果不确定时不会补发文件造成重复消息。插件不额外设置文件来源、创建时间、工作区边界、扩展名、内容、数量、大小或有效期规则;文件只需真实存在且可读取。渠道平台仍可能依据自身权限、配额、文件能力或账号等级拒绝发送,插件会按平台返回结果提示。
| 渠道 | 平台要求 |
| --- | --- |
| 微信 | 当前绑定协议和会话需支持原生文件消息,实际可发送范围以微信接口返回为准。 |
| 飞书 | 飞书文件上传接口要求文件非空且不超过平台 30 MB;应用需有租户权限 `im:resource`(“读取与上传图片或文件资源”)。内置扫码流程新建应用时默认申请该权限;已有或手动绑定的应用仍需在开发者后台添加并完成必要审批。飞书开发者后台当前没有单独的 `im:resource:upload` 权限。 |
| 钉钉 | 应用需开通 `qyapi_base`,机器人需具备文件消息能力;实际格式和大小以当前 OAPI 与机器人能力返回为准。 |
| 企业微信 | 应用需具备素材上传和文件消息能力,实际可发送范围以企业微信接口返回为准。 |
| QQ | 机器人需具备文件消息能力,并受 QQ 当日文件上传配额约束;额度耗尽时会明确提示稍后重试。 |
| Slack | Bot Token 需有 `files:write`;实际大小上限由 Workspace 当前策略决定。已有 App 新增或变更 Scope 后,必须重新授权/安装 App 并重新连接机器人。 |
| Telegram | 机器人必须能在当前聊天发送文档,实际可发送范围以 Bot API 返回为准。 |
| Discord | Developer Portal 的 Bot 设置中需启用 **Message Content Intent**;机器人需有 **Send Messages**、**Create Public Threads**、**Send Messages in Threads** 和 **Read Message History** 权限;发送结果文件还需 **Attach Files**。实际附件额度由当前账号与服务器能力决定。 |
| WhatsApp | 当前绑定会话需支持 Document Message,实际可发送范围以 WhatsApp/Baileys 返回为准。 |
## AI Office Connector ## AI Office Connector
「AI Office」页让本机 Harness 主动连接公网 Office,本机无需公网 IP、端口转发或 WebSocket 服务。Device Token 只写入 Harness 凭据存储;普通配置文件仅保存设备 ID、Office Origin、工作区 alias 和 Instruction Preset alias。Office 只能选择 alias,不会收到本机绝对路径。 「AI Office」页让本机 Harness 主动连接公网 Office,本机无需公网 IP、端口转发或 WebSocket 服务。Device Token 只写入 Harness 凭据存储;普通配置文件仅保存设备 ID、Office Origin、工作区 alias 和 Instruction Preset alias。Office 只能选择 alias,不会收到本机绝对路径。
@ -101,6 +119,8 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
每个 Telegram 机器人都可以在自己的卡片中切换访问模式。旧机器人和新接入机器人均默认使用**兼容模式**:私聊直接响应,群聊仅在提及机器人或回复机器人消息时响应。只有主动切换到**安全模式(私聊白名单)**后,机器人才会忽略全部群聊,并只接受该机器人白名单中的数字 User ID。白名单每行一个 ID、按机器人独立保存;切回兼容模式时会保留但不使用,再切回安全模式即可继续使用。安全模式的空白名单会拒绝该机器人的所有入站消息。 每个 Telegram 机器人都可以在自己的卡片中切换访问模式。旧机器人和新接入机器人均默认使用**兼容模式**:私聊直接响应,群聊仅在提及机器人或回复机器人消息时响应。只有主动切换到**安全模式(私聊白名单)**后,机器人才会忽略全部群聊,并只接受该机器人白名单中的数字 User ID。白名单每行一个 ID、按机器人独立保存;切回兼容模式时会保留但不使用,再切回安全模式即可继续使用。安全模式的空白名单会拒绝该机器人的所有入站消息。
每个 WhatsApp 机器人也有独立的访问模式。旧机器人升级后和新接入机器人都默认使用**仅自己模式**,只响应已绑定账号的自聊消息。**指定联系人模式**额外接受白名单电话号码的私聊并忽略群聊;号码需包含国家或地区代码,每行一个,可带开头的 `+`。**开放响应模式**保留原有行为:响应所有私聊,以及群聊中的提及或回复。切换模式会保留白名单;指定联系人模式的空白名单等同于仅自己模式。未授权消息会被静默忽略。
## 机器人命令 ## 机器人命令
| 命令 | 作用 | | 命令 | 作用 |
@ -151,7 +171,7 @@ Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/
- `/session` 只接受一个由 `/sessionlist` 获得的 Session ID。它不会新建会话或立即向模型发送消息;绑定成功后,当前聊天的后续消息会继续该会话。普通归档会话可以绑定但不会自动取消归档,子代理会话不能绑定。 - `/session` 只接受一个由 `/sessionlist` 获得的 Session ID。它不会新建会话或立即向模型发送消息;绑定成功后,当前聊天的后续消息会继续该会话。普通归档会话可以绑定但不会自动取消归档,子代理会话不能绑定。
- `/session` 会自动定位会话唯一所属的工作区。同工作区绑定只替换当前聊天的映射;跨工作区绑定会切换该机器人的工作区、清除该机器人所有聊天的旧会话映射,再绑定当前聊天,因此会影响该机器人的其他聊天。已经开始生成的回复仍可完成。 - `/session` 会自动定位会话唯一所属的工作区。同工作区绑定只替换当前聊天的映射;跨工作区绑定会切换该机器人的工作区、清除该机器人所有聊天的旧会话映射,再绑定当前聊天,因此会影响该机器人的其他聊天。已经开始生成的回复仍可完成。
- 工作区切换和会话绑定只会清除或替换 dsh-im 的聊天映射,不会删除、清空或归档任何旧 Session 内容;旧 Session 仍可再次列出和绑定。 - 工作区切换和会话绑定只会清除或替换 dsh-im 的聊天映射,不会删除、清空或归档任何旧 Session 内容;旧 Session 仍可再次列出和绑定。
- 任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行这些命令,不区分管理员和普通用户。Telegram 兼容模式遵循原有私聊及群聊提及/回复规则;安全模式只允许当前机器人白名单中的私聊用户执行,群聊命令始终忽略。 - 任何通过当前渠道访问策略的用户都可以执行这些命令,不另行区分管理员和普通用户。Telegram 兼容模式遵循原有私聊及群聊提及/回复规则;安全模式只允许当前机器人白名单中的私聊用户执行。WhatsApp 仅自己模式只接受自聊,指定联系人模式接受自聊和白名单私聊,开放响应模式接受所有私聊及群聊中的提及或回复。
- Agent Preset 名称和 ID 来自同一个 Harness Host,且任何有命令权限的用户都能修改该机器人所有聊天未来新 Session 的 Preset;请只向可信用户开放 `/presetlist` 和 `/preset`。 - Agent Preset 名称和 ID 来自同一个 Harness Host,且任何有命令权限的用户都能修改该机器人所有聊天未来新 Session 的 Preset;请只向可信用户开放 `/presetlist` 和 `/preset`。
- 工作区列表来自 Harness Host 的全局登记信息,可能包含其他机器人、其他渠道或非 IM 项目的本机绝对路径。请将机器人可见范围限制给可信用户。 - 工作区列表来自 Harness Host 的全局登记信息,可能包含其他机器人、其他渠道或非 IM 项目的本机绝对路径。请将机器人可见范围限制给可信用户。
- 会话列表同样来自该全局 Harness Host;会话 ID 和标题可能属于其他机器人、其他渠道或非 IM 项目,并可能包含敏感元数据。开放命令前请确保所有可见用户都可信。 - 会话列表同样来自该全局 Harness Host;会话 ID 和标题可能属于其他机器人、其他渠道或非 IM 项目,并可能包含敏感元数据。开放命令前请确保所有可见用户都可信。
@ -173,10 +193,10 @@ Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/
- Harness 中只注册一个「IM机器人」设置页,其中包含九个 IM 渠道和一个 AI Office Connector; - Harness 中只注册一个「IM机器人」设置页,其中包含九个 IM 渠道和一个 AI Office Connector;
- 九个渠道及 Office Connector 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立插件; - 九个渠道及 Office Connector 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立插件;
- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换; - 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;机器人发出的聊天消息跟随 Host 的 `language` 配置(默认中文;设为 `en` 即为英文),中文始终为兜底,未收录的文案原样输出;
- 左侧使用 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 和 AI Office,不使用启用/停用开关; - 左侧使用 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 和 AI Office,不使用启用/停用开关;
- 九个 IM 渠道保持独立的 RPC、凭据、连接监督和会话映射;Office Connector 另行维护设备凭据、Job 租约、审批等待与并发上限; - 九个 IM 渠道保持独立的 RPC、凭据、连接监督和会话映射;Office Connector 另行维护设备凭据、Job 租约、审批等待与并发上限;
- 浏览器只获得二维码、Manifest、脱敏状态,以及用户为当前 Telegram 机器人主动保存的访问模式和白名单 User ID;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥、AI Office Device Token,或从平台消息中观察到的其他原始用户标识。 - 浏览器只获得二维码、Manifest、脱敏状态,以及用户为当前 Telegram 或 WhatsApp 机器人主动保存的访问模式和白名单标识;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥、AI Office Device Token,或从平台消息中观察到的其他原始用户标识。
## 本地开发 ## 本地开发
@ -198,6 +218,18 @@ IM 管理 RPC 默认仅接受回环浏览器。如果 Web profile 在受信任
`trusted-host` 只复用 Harness 的 Host/Origin 防护,不是用户认证。启用后,能访问该局域网地址的人也能查看机器人状态、扫码或提交应用凭据、重连和删除机器人;只应在可信网络中使用。 `trusted-host` 只复用 Harness 的 Host/Origin 防护,不是用户认证。启用后,能访问该局域网地址的人也能查看机器人状态、扫码或提交应用凭据、重连和删除机器人;只应在可信网络中使用。
### 聊天消息语言
机器人发出的聊天消息默认使用中文。要切换为英文,在插件配置中设置 `language: en`(也接受 `en-US`、`english`),或设置环境变量 `DSH_IM_LANGUAGE=en`:
```yaml
- id: xmanrui-dsh-im
config:
language: en
```
未设置时保持中文;中文始终是兜底语言,任何未收录到英文词典的文案都会原样以中文输出,因此该功能不会改变现有中文用户的行为。
--- ---
## 联系方式 ## 联系方式

View file

@ -0,0 +1,31 @@
---
status: accepted
date: 2026-08-21
updated: 2026-08-23
---
# 统一语义核心与渠道原生适配
dsh-im 采用“横向建设统一语义,纵向逐个渠道打磨;按用户价值推进,按渠道特性落地”的架构与产品策略。核心层表达消息、引用、会话、交互、产物和进度等业务语义;渠道适配器依据机器人实例的实际能力进行原生呈现,不支持时执行明确降级。迁移采用增量包裹和等价接管:现有渠道行为构成最低基线,新路径可以改善体验,但不得通过删除、降级或转移既有能力来换取新能力。项目不再以 `content + images + sendText` 作为长期公共边界,也不为每个渠道复制 Harness 业务流程。
## Considered Options
- **最低公分母模型**:扩展快,但持续丢失按钮、引用、文件、语音、线程和富呈现等高价值能力。
- **每个渠道独立实现**:单点体验可控,但会复制会话、审批、安全和错误处理逻辑,长期无法保持一致性。
- **按渠道用户量排序**:看似容易生成开发顺序,但本地开源项目缺少可靠且必要的跨用户统计;现有使用量也无法代表尚未被满足的用户价值,容易让长尾渠道和高价值能力长期得不到验证。
- **直接替换旧桥接层**:代码收敛更快,但容易同时改变命令、会话、权限、队列、流式和异常处理等已经可用的行为,且发生回归时难以定位和回滚。
- **统一语义 + 原生适配**:公共业务规则只实现一次,渠道差异保留在边界内;需要维护能力矩阵和适配器契约,但最符合产品差异化目标。
## Consequences
- 新能力必须先定义用户语义、降级规则和验收标准,再进入渠道 SDK 实现。
- 真实 Issue 作为能力切片的需求入口;每个 Issue 只落地其闭环所需的最小公共语义,同时完成对应渠道的原生实现、降级和验收,不先建设整套大一统框架,也不留下脱离公共语义的临时补丁。
- 能力切片依据用户核心任务价值排序,不依据渠道用户量排序。
- 每项能力选择渠道适配性最高的平台作为标杆渠道,用于验证统一语义和状态机;其他渠道再依据自身原生机制、权限和稳定性决定原生实现或明确降级。
- 渠道能力按机器人实例和权限动态判断,不能仅凭平台名称宣称支持。
- 出站产物必须由受信工具显式登记并绑定 Session/Turn;现有文件和新建文件语义相同,不要求当前 Turn 创建。助手回答中的路径、Markdown 本地链接和工作区扫描结果不能自动触发文件发送。
- 每个渠道必须先建立可追溯的行为基线;新路径达到语义等价或更优、全量回归通过且可以回滚后,才能接管生产消息。
- 明确降级只适用于原本不具备的新能力或运行时临时故障,不能作为迁移时撤掉既有原生能力的理由。
- 可以在等价接管稳定后删除重复实现,但不得删除用户可观察功能、控制命令、状态语义或安全边界。
- 在高优先级能力完成前,不以继续增加渠道数量作为主要目标。
- 当前共享桥接器、九渠道命令与会话能力、飞书专属卡片能力以及 AI Office Connector 都是迁移资产;新语义路径只能增量包裹并等价接管,不能把成熟实现当作可直接替换的早期原型。

View file

@ -0,0 +1,390 @@
# 出站图片原生呈现落地方案
> 状态:已实施;九渠道真机验收通过
>
> 日期:2026-08-24
>
> 关联:[Issue #36](https://github.com/xmanrui/dsh-im/issues/36)、[ADR-0001:Semantic Core with Native Channel Adapters](../adr/0001-semantic-core-native-channel-adapters.md)、[渠道原生能力建设方案](./渠道原生能力建设方案.md)
## 1. 结论
本方案不新建一套“媒体消息框架”,而是在现有出站产物链路上增加一种呈现选择:
1. Agent 仍然只通过 `dsh_im_return_file` 返回产物,图片也是 `OutboundArtifact`,不增加 `return_image` 工具。
2. 语义核心继续负责产物绑定、物化、校验、释放、回执和失败语义。
3. 共享投递逻辑根据已物化产物的 `mediaType` 识别 `image/*`。
4. 渠道已有 `sendImage` 时调用渠道原生图片 API;没有独立图片 API、但现有附件本身就能原生预览的渠道继续调用 `sendFile`。
5. 原生图片发送被渠道明确拒绝时,自动降级为现有 `sendFile`;发送结果不确定时禁止再次发送,避免重复消息。
最小的新增抽象只有两项:
- 一个共享的产物投递函数,用来替代各 Bridge 中重复的 `#deliverArtifacts` 循环;
- 各渠道现有 API/Runtime 对象上的可选 `sendImage` 方法。
不引入新的产物类型、基类、注册表、配置开关、回执版本或本地图片格式白名单。
### 1.1 实施结果(2026-08-24)
本方案已按上述最小边界落地:
- 新增 `src/channels/shared/semantic/artifact-delivery.mjs`,统一负责 `image/*` 选择、确定性失败降级、`artifact-delivery-uncertain`/Abort 防重复、回执合并和 Artifact 释放。
- 共享 `TextHarnessBridge` 及飞书、微信、钉钉、企业微信、QQ Bridge 均改用该函数,不再各自维护图片识别与降级循环。
- 飞书、微信、钉钉、企业微信、QQ、Telegram、WhatsApp 接入各自原生图片发送;Slack、Discord 继续复用一次原生附件上传并显示图片预览。
- `dsh_im_return_file`、`OutboundArtifact`、`DeliveryReceipt` v1 和现有 Bot 配置结构均保持不变。
- `npm run check` 通过:1089/1089 测试通过,构建和发布包校验通过;独立代码复审未发现剩余 P0–P2 问题。
## 2. “语义核心 + 渠道原生适配”在本方案中的含义
| 层次 | 现有代码 | 本方案中的职责 |
| --- | --- | --- |
| 语义入口 | `src/channels/shared/semantic/artifact.mjs` 中的 `dsh_im_return_file` | 表达“把这个已生成产物交付给用户”,不表达飞书 `image_key`、Telegram `photo` 等渠道协议 |
| 语义核心 | `OutboundArtifact`、`materializeOutboundArtifact`、`releaseOutboundArtifact` | 校验 Session/Turn 归属,生成不可变字节快照,提供文件名、MIME、交付键并管理生命周期 |
| 共享交付语义 | `src/channels/shared/semantic/delivery.mjs` | 统一成功、拒绝、失败、结果不确定等回执语义 |
| 渠道原生适配 | 各渠道 `sendFile`,以及本方案新增的可选 `sendImage` | 把同一个图片产物翻译成渠道的原生图片上传与消息请求 |
| Bridge | 各渠道 Bridge、`text-harness-bridge.mjs` | 只注入目标、回复上下文和发送函数,不再各自维护图片识别与降级规则 |
也就是说,核心只知道“这是一个 `image/*` 产物,优先以图片呈现”;渠道适配器才知道它应当变成飞书的 `image_key`、Telegram 的 `sendPhoto`,还是 Discord 的图片附件。
这符合 ADR-0001 中的两个边界:公共语义只实现一次;供应商协议留在渠道边界。
## 3. 现状与缺口
### 3.1 已经具备、应直接复用的能力
- `dsh_im_return_file` 已经是受控的出站产物入口。
- `OutboundArtifact` 已携带 `artifactId`、`deliveryKey`、`fileName`、`mediaType`、`size` 和字节快照。
- MIME 推断已经覆盖 PNG、JPEG、GIF、WebP 等常见图片。
- `materializeOutboundArtifact`、`releaseOutboundArtifact` 已处理安全读取和生命周期。
- `DeliveryReceipt` v1 已能表示 `sent`、`rejected`、`failed`、`unknown`。
- `artifact-delivery-uncertain` 已表示“请求可能已经到达平台,但无法确认结果”。
- 九个渠道都已有可工作的 `sendFile` 路径,可作为统一降级路径。
### 3.2 当前问题
- 图片和普通文件最终都进入 `sendFile`,没有共享的“图片优先”选择规则。
- `#deliverArtifacts` 在共享 Bridge 及飞书、微信、钉钉、企业微信、QQ 等 Bridge 中重复,后续策略容易分叉。
- 飞书已经有一段用于修复二维码的原生图片上传代码,但尚未成为通用产物发送能力。
- 其他渠道有原生图片 API 或图片消息类型,尚未接入当前产物链路。
## 4. 设计原则
### 4.1 必须保持的约束
- 图片仍然是 Artifact,不创建第二条“图片产物”生命周期。
- 不解析模型文本中的 Markdown 图片、URL 或本地路径来触发发送。
- `mediaType` 只作为呈现提示,不作为核心层的准入判断。
- 图片格式、大小、权限等限制以渠道 API 的实际响应为准。
- 非图片产物的现有行为不得改变。
- 所有发送都继续服从 Abort、超时、幂等键和 `artifact-delivery-uncertain` 语义。
- 原生呈现失败时必须提供明确的文件降级或失败通知,不能静默丢失。
### 4.2 本期不做
- 不实现完整的 `SemanticDelivery`、`ChannelCapabilitySnapshot` 或 `ChannelAdapter` 类体系。
- 不新增 `OutboundMedia`、`NativeMediaDriver`、插件注册表或渠道配置开关。
- 不升级 `DeliveryReceipt` schema。
- 不做图片转码、压缩、缩略图、图集、富媒体排版或 caption 合并。
- 不要求各渠道支持完全相同的 GIF/WebP 动画效果;平台明确不支持时降级为文件。
- 不为 Slack、Discord 强行增加一个与现有附件上传完全相同的 `sendImage` 别名。
## 5. 最小架构
```text
Agent
│ dsh_im_return_file(path)
▼
OutboundArtifact 现有语义核心
│ materialize + mediaType
▼
deliverOutboundArtifacts() 新增的一处共享投递逻辑
│
├─ 非 image/* ───────────────────────────────► sendFile
│
└─ image/*
├─ 渠道提供 sendImage ─► sendImage
│ ├─ 成功 ───────► image receipt
│ ├─ 明确失败 ───► sendFile
│ └─ 结果不确定 ─► unknown receipt,不重发
│
└─ 渠道未提供 sendImage ────────────────► sendFile
(平台附件原生预览)
```
### 5.1 共享投递函数
新增:
```text
src/channels/shared/semantic/artifact-delivery.mjs
```
建议导出一个函数,不定义基类:
```js
deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId,
channelKey,
signal,
sendFile, // 必填:(file) => provider result
sendImage, // 可选:(file) => provider result
sendFailureNotice, // 必填:(artifact, error) => provider result
logger,
})
```
返回值保持贴近各 Bridge 现在需要的数据:
```js
{
receipt,
userVisible,
artifactsSent,
artifactSendErrors,
}
```
该函数只集中现有重复逻辑:
1. 逐个检查 Abort。
2. 物化 Artifact。
3. 选择 `sendImage` 或 `sendFile`。
4. 生成、合并现有 `DeliveryReceipt` v1。
5. 失败时调用渠道注入的安全通知函数。
6. 在 `finally` 中释放 Artifact。
Bridge 继续拥有会话目标、回复消息 ID、线程/话题、context token 等渠道上下文,通过闭包传入即可。例如:
```js
return deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: replyTo,
channelKey: 'telegram',
signal: this.#signal,
sendImage: typeof this.#bot.sendImage === 'function'
? (file) => this.#bot.sendImage(target, file)
: undefined,
sendFile: (file) => this.#bot.sendFile(target, file),
sendFailureNotice: (artifact, error) => this.#bot.sendText(
target,
artifactFailureText(artifact.fileName, error, this.#descriptor),
),
logger: this.#logger,
});
```
`typeof sendImage === 'function'` 就是本期所需的最小运行时能力判断,不需要先建设一套能力注册系统。
### 5.2 呈现选择规则
```js
const imagePreferred = file.mediaType?.startsWith('image/');
if (!imagePreferred || typeof sendImage !== 'function') {
return sendFile(file);
}
try {
return await sendImage(file);
} catch (error) {
if (signal?.aborted) throw error;
if (error?.code === 'artifact-delivery-uncertain') throw error;
return sendFile(file);
}
```
实际实现应记录最终使用的 presentation:
- 原生图片成功:`${channelKey}-image`
- 文件或附件成功:`${channelKey}-file`
- 失败:继续使用现有 `createArtifactFailureReceipt`
- 多项合并:第一阶段保留已有 aggregate presentation 值,避免不必要的兼容变更
这里不设置 `png/jpeg/...` 白名单。比如某平台不接受 WebP,`sendImage` 应把平台的明确拒绝映射为确定性错误,共享层随后调用 `sendFile`;核心层无需追踪每个平台不断变化的格式矩阵。
### 5.3 降级与防重复规则
| 原生图片结果 | 是否调用 `sendFile` | 最终语义 | 原因 |
| --- | --- | --- | --- |
| 成功 | 否 | `sent` | 已原生展示 |
| 上传前或平台明确拒绝 | 是 | 以文件发送结果为准 | 已知没有产生图片消息,可以安全降级 |
| `artifact-delivery-uncertain` | 否 | `unknown` | 图片消息可能已送达,再发文件会造成重复 |
| Abort | 否 | 向上抛出 | 尊重任务取消,不产生额外副作用 |
| 文件降级也失败 | 否 | 现有失败回执与安全通知 | 不再增加第三条路径 |
渠道适配器必须正确划分错误阶段:
- 图片字节校验、上传 URL 获取、明确的上传拒绝等,可映射为确定性失败。
- 最终消息请求发出后发生超时、连接中断或无法判断响应时,必须映射为 `artifact-delivery-uncertain`。
## 6. 各渠道落地方式
| 渠道 | 原生图片实现 | 复用现有代码 | 本期改动 |
| --- | --- | --- | --- |
| 飞书 | 上传图片取得 `image_key`,发送 `msg_type: image` | `bridge.mjs` 中 `#sendRepairQr` 已有同类代码;`FeishuChannel.sendFile` 已有超时、跟踪与错误映射 | 在 `FeishuChannel` 增加 `sendImage`,抽取/复用图片上传发送步骤;普通产物由共享投递函数选择 |
| 微信 | 加密上传到 CDN,发送 `image_item` | 现有 `weixin-api.mjs#sendFile` 的鉴权、加密上传、context token、run id 和错误映射;腾讯上游已有图片发送实现 | 抽取上传公共部分,新增 `sendImage`,图片类型使用微信图片消息协议 |
| 钉钉 | 上传图片媒体,发送 `sampleImageMsg` | `dingtalk-api.mjs#sendFile` 的 access token、媒体上传、目标选择和错误映射 | 增加 `sendImage`,只替换媒体类型和最终消息类型 |
| 企业微信 | `uploadMedia(type: image)` 后发送 image media message | 当前 Bridge 已用 SDK `uploadMedia` 和 `sendMediaMessage` 发送 file | 增加图片闭包或 Runtime 方法,分别传 `image`;不复制上传循环 |
| QQ | 调用 SDK `sendImage` | 已安装 QQ SDK 的图片方法;当前 Bridge 的超时、provider promise 跟踪和错误包装 | 在现有 Runtime/API 边界暴露 `sendImage`,保持和 `sendFile` 相同的消息上下文与错误语义 |
| Telegram | Bot API `sendPhoto` | `telegram-api.mjs` 的请求、超时、reply/thread 参数和错误映射;Runtime 已有 `sendFile` | 增加 `sendPhoto`/`sendImage` 薄封装 |
| WhatsApp | Baileys image message `{ image, mimetype }` | `whatsapp-runtime.mjs#sendFile` 的稳定消息 ID、echo 抑制、超时和不确定错误处理 | 增加 `sendImage`,只改变消息 content 类型 |
| Slack | 继续现有文件上传;图片 MIME 会显示附件图片预览 | `files.getUploadURLExternal`、`files.completeUploadExternal` 现有链路 | 不增加 `sendImage`;补契约测试和真机验证即可 |
| Discord | 继续现有 multipart attachment;图片附件会内联显示 | `discord-api.mjs` 现有附件上传和 Content-Type | 不增加 `sendImage`;补契约测试和真机验证,不引入 embed |
说明:这里的“各渠道原生显示”指用户在原生客户端中看到图片,而不是要求所有平台都必须有一个名字叫 `sendImage` 的独立接口。Slack 和 Discord 的原生图片呈现方式就是图片附件预览,复用现有 `sendFile` 更简单,也避免对同一个 API 进行无意义的失败重试。
## 7. 代码改动清单
### 7.1 共享层
- 新增 `src/channels/shared/semantic/artifact-delivery.mjs`。
- 将 `text-harness-bridge.mjs` 中现有 `#deliverArtifacts` 行为原样抽取到共享函数。
- 让飞书、微信、钉钉、企业微信、QQ 的重复循环逐步改为调用共享函数。
- Slack、Telegram、Discord、WhatsApp 已经走 `text-harness-bridge.mjs` 的部分直接获得新策略,无需各自增加路由判断。
- 在 `artifact.mjs` 中把工具说明和系统提示由“返回文件”扩展为“返回文件或生成的图片”,工具名和输入结构不变。
### 7.2 渠道层
- 只在确实有独立图片消息协议的渠道对象上增加 `sendImage`。
- `sendImage` 接受与现有 `sendFile` 相同的物化文件对象,并尽量保持相同的目标、reply、thread、signal 和超时参数形式。
- 共享层不引用任何平台 SDK;渠道层不重新实现 Artifact 物化、释放、回执合并或降级策略。
### 7.3 不改动的数据契约
- `OutboundArtifact` 字段不变。
- `dsh_im_return_file` 名称和参数不变。
- `DeliveryReceipt` 保持 schema version 1。
- 现有错误码继续使用;只有渠道缺少精确映射时,补齐到已有 `artifact-*` 错误语义。
- Bot 配置文件不增加 `nativeImage` 或格式列表。
## 8. 实施顺序
### 阶段 1:纯抽取,共享现有文件投递
1. 从 `text-harness-bridge.mjs` 抽取 `deliverOutboundArtifacts`。
2. 首先只调用 `sendFile`,确保行为、回执、失败通知和状态计数不变。
3. 迁移重复的 Bridge 循环,每迁移一个渠道就运行其现有测试。
这一阶段不改变用户可见行为,便于把“代码抽取问题”和“原生图片协议问题”分开排查。
### 阶段 2:加入共享图片选择策略
1. 增加 `image/*` 判断和可选 `sendImage`。
2. 落实“明确失败可降级、结果不确定不重发”的规则。
3. 更新工具说明和系统提示,使模型知道生成图片也通过现有 Artifact 工具交付。
### 阶段 3:以飞书作为参考渠道
1. 将现有修复二维码的图片上传/发送能力整理为 `FeishuChannel.sendImage`。
2. 接入普通 Artifact,并验证 PNG、JPEG、非图片文件和失败降级。
3. 若复用修复二维码路径会扩大改动面,可先保留其调用点,仅复用底层私有函数;不强求一次消除所有重复。
### 阶段 4:完成 Issue #36 与其余渠道
1. 微信:参考腾讯上游已工作的图片上传/消息结构,优先完成 Issue #36。
2. 企业微信、QQ、Telegram、WhatsApp、钉钉:各自增加薄 `sendImage` 适配。
3. Slack、Discord:不写新发送路径,只验证当前附件在官方客户端中确实内联显示。
### 阶段 5:清理重复实现
- 在所有渠道契约测试通过后,删除已被共享函数取代的 `#deliverArtifacts` 重复代码。
- 不顺便重构文本、卡片、语音等其他投递路径。
## 9. 测试方案
### 9.1 共享单元测试
至少覆盖以下决策表:
| 场景 | 预期 |
| --- | --- |
| 非图片产物 | 只调用一次 `sendFile` |
| 图片且存在 `sendImage` | 只调用一次 `sendImage`,presentation 为 image |
| 图片但不存在 `sendImage` | 调用一次 `sendFile` |
| `sendImage` 明确拒绝 | 随后调用一次 `sendFile` |
| `sendImage` 返回不确定错误 | 不调用 `sendFile`,Artifact outcome 为 `unknown` |
| 投递期间 Abort | 不降级、不发送失败通知,继续向上抛出 |
| 图片和文件混合多产物 | 保持原有顺序,分别生成并合并回执 |
| 部分成功、部分失败 | 成功项不重发,失败项有正确回执和安全通知 |
| 任意结果 | 每个 Artifact 最终只释放一次 |
### 9.2 渠道契约测试
对飞书、微信、钉钉、企业微信、QQ、Telegram、WhatsApp 分别验证:
- 图片上传请求的媒体类型正确。
- 最终消息请求使用图片类型而非文件类型。
- reply/thread/context token 等原有上下文没有丢失。
- 平台明确拒绝与结果不确定被映射到不同错误语义。
- 相同 `deliveryKey` 继续参与现有幂等或稳定消息 ID 机制。
对 Slack、Discord 验证:
- `image/png` 和 `image/jpeg` 继续走一次附件上传。
- 请求保留正确 MIME/Content-Type 和文件名。
- 不会因为不存在 `sendImage` 而额外尝试第二次上传。
### 9.3 真机验收
每个渠道至少保留以下证据:
1. PNG/JPEG 在官方客户端中的消息截图或录屏。
2. 非图片文件仍按原行为发送。
3. 一个平台不支持的图片格式或模拟明确拒绝能降级为文件。
4. 模拟最终发送超时不会紧接着出现重复文件。
自动化测试能验证协议形状,不能完全代替客户端是否“原生显示”的验收。
### 9.4 九渠道真机验收记录(2026-08-24)
统一使用工作区图片 `assets/logo-plugin-phone.png`(PNG,85,920 字节),由用户在每个渠道的真实会话中要求 Agent 调用 `dsh_im_return_file`。以下时间均为 Asia/Taipei:
| 渠道 | 实际发送路径 | 官方客户端可见结果 | 结果 |
| --- | --- | --- | --- |
| Telegram | `sendPhoto` | 03:07 出现一张原生图片消息 | 通过 |
| Slack | 单次 external file upload | 03:08 在消息列中显示一张内联图片预览 | 通过 |
| Discord | 单次 multipart attachment | 03:10 在 Bot 私信中显示一张内联图片附件 | 通过 |
| 飞书 | `image.create` + `msg_type: image` | 03:11 在“梁子文件传送”会话中显示一张原生图片 | 通过 |
| 微信 | CDN 图片上传 + `image_item` | 03:12 在 DeepSeek Harness 会话中显示一张图片气泡 | 通过 |
| 钉钉 | image media + `sampleImageMsg` | 03:14 在“梁子来了”会话中显示一张原生图片,侧栏标记 `[Image]` | 通过 |
| 企业微信 | `uploadMedia(image)` + image media message | 03:15 在“今天是梁子”会话中显示一张原生 `[Photo]` | 通过 |
| QQ | SDK `sendImage` | 03:15 在 `winBot` 私聊中显示一张原生图片,侧栏标记 `[图片]` | 通过 |
| WhatsApp | Baileys `{ image, mimetype }` | 03:16 在账号自聊中显示一张原生图片并保留引用上下文 | 通过 |
九个客户端均只出现一份图片产物,没有额外文件 fallback、重复图片或失败提示。事后状态检查中九个目标 Bot 均为 `connected` / `healthy`;除飞书状态投影不含本轮计数外,其余八个渠道均记录 1 条接收、1 条回复,Host 日志未出现 Artifact 投递失败、结果不确定或失败通知。
## 10. 验收标准
- 九个渠道中的 PNG、JPEG 都能在原生客户端直接看到图片或图片附件预览。
- 飞书、微信、钉钉、企业微信、QQ、Telegram、WhatsApp 使用各自的图片消息能力。
- Slack、Discord 使用其原生图片附件预览,且只上传一次。
- 非 `image/*` 产物仍只走现有 `sendFile`。
- 原生图片被明确拒绝时自动降级为文件。
- 结果不确定或 Abort 时不会二次发送。
- Session/Turn 绑定、不可变快照、摘要校验、释放生命周期均保持不变。
- 回执仍为 `DeliveryReceipt` v1,成功、失败、拒绝、不确定语义可区分。
- 不新增工具、Bot 配置项、本地格式白名单或完整渠道能力框架。
- 相关单元测试、渠道测试、`npm test` 和 `npm run build` 通过,并完成九渠道真机验收。
## 11. 风险与控制
| 风险 | 控制方式 |
| --- | --- |
| 原生发送超时后降级导致重复图片 | 复用并严格执行 `artifact-delivery-uncertain`;不确定时禁止降级 |
| 各平台图片格式限制不同 | 不在核心写死矩阵;平台明确拒绝后走文件 |
| 抽取共享函数造成既有文件回归 | 先做“纯抽取”提交,行为无变化,再加入图片策略 |
| 渠道 Bridge 上下文差异过大 | 通过闭包注入目标和上下文,不设计统一的大参数对象 |
| 飞书二维码重构影响修复流程 | 优先复用底层私有函数;是否迁移旧调用点由测试结果决定 |
| Slack/Discord 被误判为未实现 | 以官方客户端中的原生附件预览作为验收标准,并保留真机证据 |
## 12. 参考资料
- [Tencent openclaw-weixin:send-media.ts](https://github.com/Tencent/openclaw-weixin/blob/main/src/messaging/send-media.ts)
- [Tencent openclaw-weixin:CDN upload.ts](https://github.com/Tencent/openclaw-weixin/blob/main/src/cdn/upload.ts)
- [Telegram Bot API:sendPhoto](https://core.telegram.org/bots/api/#sendphoto)
- [钉钉机器人消息类型](https://open.dingtalk.com/document/development/robot-message-type)
- [Slack:Working with files](https://docs.slack.dev/messaging/working-with-files/)
- [Discord:Uploading Files](https://docs.discord.com/developers/reference#uploading-files)
- [Baileys](https://github.com/WhiskeySockets/Baileys)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "@xmanrui/dsh-im", "name": "@xmanrui/dsh-im",
"version": "1.0.2", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@xmanrui/dsh-im", "name": "@xmanrui/dsh-im",
"version": "1.0.2", "version": "2.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tencent-connect/qqbot-connector": "1.2.0", "@tencent-connect/qqbot-connector": "1.2.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "@xmanrui/dsh-im", "name": "@xmanrui/dsh-im",
"version": "1.0.2", "version": "2.0.0",
"description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.", "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
"keywords": [ "keywords": [
"deepseek-harness", "deepseek-harness",

View file

@ -0,0 +1,48 @@
import * as React from 'react';
import { h } from './i18n.js';
export function ChannelListHeading({ className = '', id, title, connectionLabel }) {
const helpId = React.useId();
return h('div', { className: `${className} dim-listHeading`.trim() },
h('div', { className: 'dim-listTitle' },
h('h3', id ? { id } : null, title),
h('span', { className: 'dim-channelHelp' },
h('button', {
type: 'button',
className: 'dim-channelHelpButton',
'aria-label': '查看消息通道说明',
'aria-describedby': helpId,
}, h('span', { 'aria-hidden': 'true' }, '?')),
h('span', {
id: helpId,
className: 'dim-channelTooltip',
role: 'tooltip',
},
h('span', null, '消息通道'),
h('strong', null, connectionLabel)))));
}
export function BotStatusMeta({
className = '',
dotClassName = '',
tone,
stateLabel,
lastCheckedAt,
formatCheckedTime,
healthState,
}) {
return h('div', { className: 'dim-botHealthGroup' },
h('div', {
className: `${className} dim-botHealth`.trim(),
...(healthState ? { 'data-health': healthState } : {}),
},
h('span', {
className: `${dotClassName} dim-healthDot`.trim(),
'data-tone': tone,
}),
h('span', null, stateLabel)),
h('div', { className: 'dim-lastChecked' },
h('span', null, '最近检查'),
h('span', null, formatCheckedTime(lastCheckedAt))));
}

View file

@ -9,6 +9,7 @@ import {
EMPTY_AGENT_PRESET_CATALOG, EMPTY_AGENT_PRESET_CATALOG,
} from '../../agent-preset.js'; } from '../../agent-preset.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js'; import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { BotStatusMeta, ChannelListHeading } from '../../channel-card-meta.js';
import { import {
DINGTALK_ENDPOINTS, DINGTALK_ENDPOINTS,
DINGTALK_RPC_CHANNEL, DINGTALK_RPC_CHANNEL,
@ -235,13 +236,14 @@ export function AccountCard({
h('div', { className: 'dim-botName' }, h('div', { className: 'dim-botName' },
h('h3', { title: account.bot.name }, account.bot.name), h('h3', { title: account.bot.name }, account.bot.name),
h('p', { title: account.bot.clientIdMasked }, account.bot.clientIdMasked))), h('p', { title: account.bot.clientIdMasked }, account.bot.clientIdMasked))),
h('div', { className: 'ddt-health dim-botHealth' }, h(BotStatusMeta, {
h('span', { className: 'ddt-dot dim-healthDot', 'data-tone': tone }), h('span', null, stateLabel))), className: 'ddt-health',
h('dl', { className: 'ddt-metrics dim-botMetrics' }, dotClassName: 'ddt-dot',
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), tone,
h('dd', null, account.connected ? 'Stream 长连接' : '离线')), stateLabel,
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), lastCheckedAt: account.health.lastCheckedAt,
h('dd', null, checkedTime(account.health.lastCheckedAt)))), formatCheckedTime: checkedTime,
})),
h(WorkspaceEditor, { h(WorkspaceEditor, {
workspace: account.workspace, workspace: account.workspace,
disabled: Boolean(busy), disabled: Boolean(busy),
@ -274,8 +276,11 @@ export function AccountCard({
function AccountList(props) { function AccountList(props) {
return h('section', { className: 'dim-listSection' }, return h('section', { className: 'dim-listSection' },
h('div', { className: 'ddt-listHeading dim-listHeading' }, h(ChannelListHeading, {
h('h3', null, '已接入的钉钉机器人')), className: 'ddt-listHeading',
title: '已接入的钉钉机器人',
connectionLabel: 'Stream 长连接',
}),
h('ul', { className: 'ddt-list dim-botList' }, props.bots.map((account) => h('li', { key: account.botId }, h('ul', { className: 'ddt-list dim-botList' }, props.bots.map((account) => h('li', { key: account.botId },
h(AccountCard, { h(AccountCard, {
account, account,

View file

@ -86,10 +86,6 @@ const CSS = String.raw`
.ddt-accountIdentity h3 { overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } .ddt-accountIdentity h3 { overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
.ddt-accountIdentity p { margin-top: 4px; color: var(--dsw-alias-label-secondary, #646a73); font: 12px ui-monospace, SFMono-Regular, monospace; } .ddt-accountIdentity p { margin-top: 4px; color: var(--dsw-alias-label-secondary, #646a73); font: 12px ui-monospace, SFMono-Regular, monospace; }
.ddt-health { display: inline-flex; align-items: center; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; white-space: nowrap; } .ddt-health { display: inline-flex; align-items: center; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; white-space: nowrap; }
.ddt-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 20px 0; }
.ddt-metric { min-width: 0; padding: 12px; border-radius: 9px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
.ddt-metric dt { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; }
.ddt-metric dd { overflow: hidden; margin: 5px 0 0; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.ddt-accountFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); } .ddt-accountFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
.ddt-accountFooter .ddt-actions { flex: none; flex-wrap: nowrap; gap: 8px; margin-top: 0; } .ddt-accountFooter .ddt-actions { flex: none; flex-wrap: nowrap; gap: 8px; margin-top: 0; }
.ddt-accountFooter .ddt-button { flex: none; white-space: nowrap; } .ddt-accountFooter .ddt-button { flex: none; white-space: nowrap; }
@ -110,8 +106,6 @@ const CSS = String.raw`
.ddt-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; gap: 24px; } .ddt-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; gap: 24px; }
.ddt-qrColumn { width: 100%; min-width: 0; } .ddt-qrColumn { width: 100%; min-width: 0; }
.ddt-qrCopy { width: 100%; } .ddt-qrCopy { width: 100%; }
.ddt-metrics { gap: 8px; }
.ddt-metric { padding: 10px; }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.ddt-heading, .ddt-accountTop { flex-direction: column; align-items: stretch; } .ddt-heading, .ddt-accountTop { flex-direction: column; align-items: stretch; }

View file

@ -23,6 +23,7 @@ import {
EMPTY_AGENT_PRESET_CATALOG, EMPTY_AGENT_PRESET_CATALOG,
} from "../../agent-preset.js"; } from "../../agent-preset.js";
import { useWorkspaceSnapshotFence } from "../../workspace-snapshot-fence.js"; import { useWorkspaceSnapshotFence } from "../../workspace-snapshot-fence.js";
import { BotStatusMeta, ChannelListHeading } from "../../channel-card-meta.js";
import { installFeishuStyles } from "./styles.js"; import { installFeishuStyles } from "./styles.js";
export const name = "feishu-settings"; export const name = "feishu-settings";
@ -591,15 +592,15 @@ export function BotCard({
h("h3", { id: titleId, title: bot.name }, bot.name), h("h3", { id: titleId, title: bot.name }, bot.name),
h("p", { title: bot.appIdMasked }, bot.appIdMasked ?? "应用标识已安全保存")), h("p", { title: bot.appIdMasked }, bot.appIdMasked ?? "应用标识已安全保存")),
), ),
h("div", { className: "bxf-healthPill dim-botHealth", "data-health": stateForDisplay }, h(BotStatusMeta, {
h("span", { className: "bxf-dot dim-healthDot", "data-tone": tone }), className: "bxf-healthPill",
h("span", null, HEALTH_LABELS[stateForDisplay] ?? "状态未知")), dotClassName: "bxf-dot",
), tone,
h("dl", { className: "bxf-statusGrid dim-botMetrics" }, stateLabel: HEALTH_LABELS[stateForDisplay] ?? "状态未知",
h("div", { className: "bxf-metric dim-botMetric" }, h("dt", null, "消息通道"), lastCheckedAt: health.lastCheckedAt,
h("dd", null, connected ? "长连接" : stateForDisplay === "connecting" ? "连接中" : "已断开")), formatCheckedTime,
h("div", { className: "bxf-metric dim-botMetric" }, h("dt", null, "最近检查"), healthState: stateForDisplay,
h("dd", null, formatCheckedTime(health.lastCheckedAt))), }),
), ),
h(WorkspaceEditor, { h(WorkspaceEditor, {
workspace: connection.workspace, workspace: connection.workspace,
@ -669,8 +670,12 @@ export function BotCard({
function BotList(props) { function BotList(props) {
return h("section", { className: "bxf-listSection dim-listSection", "aria-labelledby": "bxf-bot-list-title" }, return h("section", { className: "bxf-listSection dim-listSection", "aria-labelledby": "bxf-bot-list-title" },
h("div", { className: "bxf-listHeading dim-listHeading" }, h(ChannelListHeading, {
h("h3", { id: "bxf-bot-list-title" }, "已接入的机器人")), className: "bxf-listHeading",
id: "bxf-bot-list-title",
title: "已接入的机器人",
connectionLabel: "长连接",
}),
h("ul", { className: "bxf-botList dim-botList", role: "list" }, h("ul", { className: "bxf-botList dim-botList", role: "list" },
props.bots.map((bot) => h("li", { key: bot.botId }, props.bots.map((bot) => h("li", { key: bot.botId },
h(BotCard, { h(BotCard, {

View file

@ -402,10 +402,6 @@ const CSS = String.raw`
.bxf-healthPill[data-health="degraded"], .bxf-healthPill[data-health="checking"], .bxf-healthPill[data-health="connecting"] { color: var(--bxf-warning); background: color-mix(in srgb, var(--bxf-warning) 10%, transparent); } .bxf-healthPill[data-health="degraded"], .bxf-healthPill[data-health="checking"], .bxf-healthPill[data-health="connecting"] { color: var(--bxf-warning); background: color-mix(in srgb, var(--bxf-warning) 10%, transparent); }
.bxf-healthPill[data-health="offline"], .bxf-healthPill[data-health="error"] { color: var(--bxf-error); background: color-mix(in srgb, var(--bxf-error) 10%, transparent); } .bxf-healthPill[data-health="offline"], .bxf-healthPill[data-health="error"] { color: var(--bxf-error); background: color-mix(in srgb, var(--bxf-error) 10%, transparent); }
.bxf-statusGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 22px; }
.bxf-metric { min-width: 0; border: 1px solid var(--dsw-alias-border-l2, #dee0e3); border-radius: 9px; padding: 12px 13px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
.bxf-metric dt { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 17px; }
.bxf-metric dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; line-height: 18px; font-weight: 550; margin: 3px 0 0; }
.bxf-responseMode { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; column-gap: 10px; row-gap: 5px; margin-top: 6px; padding: 8px 10px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 9px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); } .bxf-responseMode { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; column-gap: 10px; row-gap: 5px; margin-top: 6px; padding: 8px 10px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 9px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
.bxf-responseModeHeader { display: contents; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: normal; } .bxf-responseModeHeader { display: contents; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: normal; }
@ -517,7 +513,6 @@ const CSS = String.raw`
.bxf-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; } .bxf-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; }
.bxf-botProvision .bxf-qrLayout { grid-template-columns: minmax(0, 1fr); } .bxf-botProvision .bxf-qrLayout { grid-template-columns: minmax(0, 1fr); }
.bxf-qrCopy { width: 100%; } .bxf-qrCopy { width: 100%; }
.bxf-statusGrid { grid-template-columns: minmax(0, 1fr); }
.bxf-connectedTop { align-items: flex-start; flex-direction: column; } .bxf-connectedTop { align-items: flex-start; flex-direction: column; }
.bxf-inlineError { grid-template-columns: minmax(0, 1fr); padding: 20px; } .bxf-inlineError { grid-template-columns: minmax(0, 1fr); padding: 20px; }
.bxf-statusNotice { align-items: flex-start; flex-wrap: wrap; } .bxf-statusNotice { align-items: flex-start; flex-wrap: wrap; }

View file

@ -10,6 +10,7 @@ import {
EMPTY_AGENT_PRESET_CATALOG, EMPTY_AGENT_PRESET_CATALOG,
} from '../../agent-preset.js'; } from '../../agent-preset.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js'; import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { BotStatusMeta, ChannelListHeading } from '../../channel-card-meta.js';
import { installDingtalkStyles } from '../dingtalk/styles.js'; import { installDingtalkStyles } from '../dingtalk/styles.js';
import { import {
QQ_ENDPOINTS, QQ_ENDPOINTS,
@ -176,11 +177,14 @@ export function AccountCard({
h('div', { className: 'ddt-avatar dim-botAvatar dqq-avatar', 'aria-hidden': 'true' }, h(QqLogoGlyph, { size: 29 })), h('div', { className: 'ddt-avatar dim-botAvatar dqq-avatar', 'aria-hidden': 'true' }, h(QqLogoGlyph, { size: 29 })),
h('div', { className: 'dim-botName' }, h('div', { className: 'dim-botName' },
h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))), h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))),
h('div', { className: 'ddt-health dim-botHealth' }, h(BotStatusMeta, {
h('span', { className: 'ddt-dot dim-healthDot', 'data-tone': tone }), h('span', null, stateLabel))), className: 'ddt-health',
h('dl', { className: 'ddt-metrics dim-botMetrics' }, dotClassName: 'ddt-dot',
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')), tone,
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))), stateLabel,
lastCheckedAt: account.health.lastCheckedAt,
formatCheckedTime: checkedTime,
})),
h(WorkspaceEditor, { h(WorkspaceEditor, {
workspace: account.workspace, workspace: account.workspace,
disabled: Boolean(busy), disabled: Boolean(busy),
@ -433,8 +437,11 @@ export function QqSettingsTab({ rpcCall }) {
const botList = model.bots.length > 0 const botList = model.bots.length > 0
? h('section', { className: 'dim-listSection' }, ? h('section', { className: 'dim-listSection' },
h('div', { className: 'ddt-listHeading dim-listHeading' }, h(ChannelListHeading, {
h('h3', null, '已绑定的 QQ 机器人')), className: 'ddt-listHeading',
title: '已绑定的 QQ 机器人',
connectionLabel: 'WebSocket 长连接',
}),
h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) => h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) =>
h('li', { key: account.botId }, h(AccountCard, { h('li', { key: account.botId }, h(AccountCard, {
account, account,

View file

@ -10,6 +10,7 @@ import {
EMPTY_AGENT_PRESET_CATALOG, EMPTY_AGENT_PRESET_CATALOG,
} from '../../agent-preset.js'; } from '../../agent-preset.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js'; import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { BotStatusMeta, ChannelListHeading } from '../../channel-card-meta.js';
const Button = React.forwardRef(function Button( const Button = React.forwardRef(function Button(
{ children, kind = 'secondary', className = '', ...props }, { children, kind = 'secondary', className = '', ...props },
@ -82,14 +83,14 @@ export function createTokenChannelSettings(definition) {
h(LogoGlyph, { size: 29 })), h(LogoGlyph, { size: 29 })),
h('div', { className: 'dim-botName' }, h('div', { className: 'dim-botName' },
h('h3', null, account.bot.name), h('p', null, identity))), h('h3', null, account.bot.name), h('p', null, identity))),
h('div', { className: 'ddt-health dim-botHealth' }, h(BotStatusMeta, {
h('span', { className: 'ddt-dot dim-healthDot', 'data-tone': tone }), className: 'ddt-health',
h('span', null, stateLabel))), dotClassName: 'ddt-dot',
h('dl', { className: 'ddt-metrics dim-botMetrics' }, tone,
h('div', { className: 'ddt-metric dim-botMetric' }, stateLabel,
h('dt', null, '消息通道'), h('dd', null, account.connected ? connectionLabel : '离线')), lastCheckedAt: account.health.lastCheckedAt,
h('div', { className: 'ddt-metric dim-botMetric' }, formatCheckedTime: checkedTime,
h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))), })),
h(WorkspaceEditor, { h(WorkspaceEditor, {
workspace: account.workspace, workspace: account.workspace,
disabled: Boolean(busy), disabled: Boolean(busy),
@ -271,8 +272,11 @@ export function createTokenChannelSettings(definition) {
const botList = model.bots.length > 0 const botList = model.bots.length > 0
? h('section', { className: 'dim-listSection' }, ? h('section', { className: 'dim-listSection' },
h('div', { className: 'ddt-listHeading dim-listHeading' }, h(ChannelListHeading, {
h('h3', null, `已接入的 ${channel} 机器人`)), className: 'ddt-listHeading',
title: `已接入的 ${channel} 机器人`,
connectionLabel,
}),
h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) => h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) =>
h('li', { key: account.botId }, h(AccountCard, { h('li', { key: account.botId }, h(AccountCard, {
account, account,

View file

@ -10,6 +10,7 @@ import {
EMPTY_AGENT_PRESET_CATALOG, EMPTY_AGENT_PRESET_CATALOG,
} from '../../agent-preset.js'; } from '../../agent-preset.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js'; import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { BotStatusMeta, ChannelListHeading } from '../../channel-card-meta.js';
import { installDingtalkStyles } from '../dingtalk/styles.js'; import { installDingtalkStyles } from '../dingtalk/styles.js';
import { import {
WECOM_ENDPOINTS, WECOM_ENDPOINTS,
@ -175,11 +176,14 @@ export function AccountCard({
h('div', { className: 'ddt-avatar dim-botAvatar dwecom-avatar', 'aria-hidden': 'true' }, h(WecomLogoGlyph, { size: 29 })), h('div', { className: 'ddt-avatar dim-botAvatar dwecom-avatar', 'aria-hidden': 'true' }, h(WecomLogoGlyph, { size: 29 })),
h('div', { className: 'dim-botName' }, h('div', { className: 'dim-botName' },
h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))), h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))),
h('div', { className: 'ddt-health dim-botHealth' }, h(BotStatusMeta, {
h('span', { className: 'ddt-dot dim-healthDot', 'data-tone': tone }), h('span', null, stateLabel))), className: 'ddt-health',
h('dl', { className: 'ddt-metrics dim-botMetrics' }, dotClassName: 'ddt-dot',
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')), tone,
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))), stateLabel,
lastCheckedAt: account.health.lastCheckedAt,
formatCheckedTime: checkedTime,
})),
h(WorkspaceEditor, { h(WorkspaceEditor, {
workspace: account.workspace, workspace: account.workspace,
disabled: Boolean(busy), disabled: Boolean(busy),
@ -464,8 +468,11 @@ export function WecomSettingsTab({ rpcCall }) {
const botList = model.bots.length > 0 const botList = model.bots.length > 0
? h('section', { className: 'dim-listSection' }, ? h('section', { className: 'dim-listSection' },
h('div', { className: 'ddt-listHeading dim-listHeading' }, h(ChannelListHeading, {
h('h3', null, '已绑定的企业微信机器人')), className: 'ddt-listHeading',
title: '已绑定的企业微信机器人',
connectionLabel: 'WebSocket 长连接',
}),
h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) => h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) =>
h('li', { key: account.botId }, h(AccountCard, { h('li', { key: account.botId }, h(AccountCard, {
account, account,

View file

@ -22,6 +22,7 @@ import {
EMPTY_AGENT_PRESET_CATALOG, EMPTY_AGENT_PRESET_CATALOG,
} from '../../agent-preset.js'; } from '../../agent-preset.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js'; import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { BotStatusMeta, ChannelListHeading } from '../../channel-card-meta.js';
import { installWeixinStyles } from './styles.js'; import { installWeixinStyles } from './styles.js';
export const name = 'weixin-settings'; export const name = 'weixin-settings';
@ -221,14 +222,14 @@ export function AccountCard({
h('div', { className: 'dxw-accountIdentity dim-botIdentity' }, h('div', { className: 'dxw-accountIdentity dim-botIdentity' },
h('div', { className: 'dxw-avatar dim-botAvatar', 'aria-hidden': 'true' }, h(WeixinLogoGlyph, { size: 27 })), h('div', { className: 'dxw-avatar dim-botAvatar', 'aria-hidden': 'true' }, h(WeixinLogoGlyph, { size: 27 })),
h('div', { className: 'dim-botName' }, h('h3', null, account.bot.name), h('p', null, account.bot.accountIdMasked))), h('div', { className: 'dim-botName' }, h('h3', null, account.bot.name), h('p', null, account.bot.accountIdMasked))),
h('div', { className: 'dxw-health dim-botHealth' }, h(BotStatusMeta, {
h('span', { className: 'dxw-dot dim-healthDot', 'data-tone': tone }), className: 'dxw-health',
h('span', null, account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪'))), dotClassName: 'dxw-dot',
h('dl', { className: 'dxw-metrics dim-botMetrics' }, tone,
h('div', { className: 'dxw-metric dim-botMetric' }, h('dt', null, '消息通道'), stateLabel: account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪',
h('dd', null, account.connected ? 'iLink 长轮询' : '离线')), lastCheckedAt: account.health.lastCheckedAt,
h('div', { className: 'dxw-metric dim-botMetric' }, h('dt', null, '最近检查'), formatCheckedTime: checkedTime,
h('dd', null, checkedTime(account.health.lastCheckedAt)))), })),
h(WorkspaceEditor, { h(WorkspaceEditor, {
workspace: account.workspace, workspace: account.workspace,
disabled: Boolean(busy), disabled: Boolean(busy),
@ -267,7 +268,11 @@ export function AccountCard({
function AccountList(props) { function AccountList(props) {
return h('section', { className: 'dim-listSection' }, return h('section', { className: 'dim-listSection' },
h('div', { className: 'dxw-listHeading dim-listHeading' }, h('h3', null, '已接入的微信账号')), h(ChannelListHeading, {
className: 'dxw-listHeading',
title: '已接入的微信账号',
connectionLabel: 'iLink 长轮询',
}),
h('ul', { className: 'dxw-list dim-botList' }, props.bots.map((account) => h('li', { key: account.botId }, h('ul', { className: 'dxw-list dim-botList' }, props.bots.map((account) => h('li', { key: account.botId },
h(AccountCard, { h(AccountCard, {
account, account,

View file

@ -77,10 +77,6 @@ const CSS = String.raw`
.dxw-accountIdentity h3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; } .dxw-accountIdentity h3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
.dxw-accountIdentity p { color: var(--dsw-alias-label-secondary, #646a73); font: 12px ui-monospace, SFMono-Regular, monospace; margin-top: 4px; } .dxw-accountIdentity p { color: var(--dsw-alias-label-secondary, #646a73); font: 12px ui-monospace, SFMono-Regular, monospace; margin-top: 4px; }
.dxw-health { display: inline-flex; align-items: center; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; white-space: nowrap; } .dxw-health { display: inline-flex; align-items: center; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; white-space: nowrap; }
.dxw-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 20px 0; }
.dxw-metric { padding: 12px 14px; border-radius: 9px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
.dxw-metric dt { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; }
.dxw-metric dd { overflow: hidden; margin: 5px 0 0; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.dxw-accountFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); } .dxw-accountFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
.dxw-accountFooter .dxw-actions { flex: none; flex-wrap: nowrap; gap: 8px; margin-top: 0; } .dxw-accountFooter .dxw-actions { flex: none; flex-wrap: nowrap; gap: 8px; margin-top: 0; }
.dxw-accountFooter .dxw-button { flex: none; white-space: nowrap; } .dxw-accountFooter .dxw-button { flex: none; white-space: nowrap; }
@ -99,7 +95,6 @@ const CSS = String.raw`
.dxw-logo { display: none; } .dxw-logo { display: none; }
.dxw-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; } .dxw-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; }
.dxw-qrCopy { width: 100%; } .dxw-qrCopy { width: 100%; }
.dxw-metrics { grid-template-columns: minmax(0, 1fr); }
.dxw-cardBody { padding: 20px; } .dxw-cardBody { padding: 20px; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View file

@ -9,6 +9,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
cancelProvisioning: 'provision.cancel', cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect', reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete', deleteBot: 'bot.delete',
setAccessPolicy: 'bot.access-policy.set',
setWorkspace: 'bot.workspace.set', setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT, setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
}); });
@ -86,6 +87,16 @@ function normalizeBot(value) {
state: connected ? 'connected' : state, state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096), workspace: text(value.workspace, '', 4_096),
agentPreset: normalizeAgentPresetId(value.agentPreset), agentPreset: normalizeAgentPresetId(value.agentPreset),
accessPolicy: {
accessMode: ['self-only', 'private-allowlist', 'open'].includes(
value.accessPolicy?.accessMode,
) ? value.accessPolicy.accessMode : 'self-only',
allowedNumbers: Array.isArray(value.accessPolicy?.allowedNumbers)
? [...new Set(value.accessPolicy.allowedNumbers.filter((entry) => (
typeof entry === 'string' && /^[1-9]\d{4,14}$/.test(entry)
)))]
: [],
},
bot: { bot: {
name: text(value.bot?.name, 'WhatsApp机器人', 100), name: text(value.bot?.name, 'WhatsApp机器人', 100),
idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140), idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140),

View file

@ -10,6 +10,7 @@ import {
EMPTY_AGENT_PRESET_CATALOG, EMPTY_AGENT_PRESET_CATALOG,
} from '../../agent-preset.js'; } from '../../agent-preset.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js'; import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { BotStatusMeta, ChannelListHeading } from '../../channel-card-meta.js';
import { installDingtalkStyles } from '../dingtalk/styles.js'; import { installDingtalkStyles } from '../dingtalk/styles.js';
import { import {
WHATSAPP_ENDPOINTS, WHATSAPP_ENDPOINTS,
@ -24,6 +25,119 @@ import { installWhatsappStyles } from './styles.js';
const ACTIVE_STATES = new Set(['pending', 'connecting']); const ACTIVE_STATES = new Set(['pending', 'connecting']);
function accessPolicyFor(account) {
const accessMode = ['self-only', 'private-allowlist', 'open'].includes(
account?.accessPolicy?.accessMode,
) ? account.accessPolicy.accessMode : 'self-only';
return {
accessMode,
allowedNumbers: Array.isArray(account?.accessPolicy?.allowedNumbers)
? account.accessPolicy.allowedNumbers : [],
};
}
function allowedNumbersFromText(value) {
const entries = value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
const normalized = entries.map((entry) => entry.replace(/^\+/, ''));
if (normalized.some((entry) => !/^[1-9]\d{4,14}$/.test(entry))) {
throw new TypeError('电话号码必须包含国家或地区代码,每行一个。');
}
return [...new Set(normalized)];
}
export function WhatsappAccessSettings({ account, busy = false, onSave }) {
const policy = accessPolicyFor(account);
const sourceNumbers = policy.allowedNumbers.join('\n');
const helpId = React.useId();
const [accessMode, setAccessMode] = React.useState(policy.accessMode);
const [allowedNumbers, setAllowedNumbers] = React.useState(sourceNumbers);
const [error, setError] = React.useState(null);
React.useEffect(() => {
setAccessMode(policy.accessMode);
setAllowedNumbers(sourceNumbers);
setError(null);
}, [policy.accessMode, sourceNumbers]);
const save = async (event) => {
event.preventDefault();
setError(null);
try {
const normalized = allowedNumbersFromText(allowedNumbers);
if (typeof onSave !== 'function') throw new Error('WhatsApp 访问设置暂不可用。');
await onSave({ accessMode, allowedNumbers: normalized });
} catch (caught) {
setError(caught?.message ?? 'WhatsApp 访问设置保存失败。');
}
};
const allowlistEnabled = accessMode === 'private-allowlist';
const labels = {
'self-only': '仅自己模式',
'private-allowlist': '指定联系人模式',
open: '开放响应模式',
};
return h('form', { className: 'dwa-access', onSubmit: save },
h('div', { className: 'dwa-accessHeading' },
h('strong', null, '访问设置'),
h('span', { className: 'dwa-accessStatus' },
h('span', { className: 'dwa-accessBadge', 'data-mode': policy.accessMode },
['已生效:', labels[policy.accessMode]]),
h('span', { className: 'dwa-accessHelp' },
h('button', {
type: 'button',
className: 'dwa-accessHelpButton',
'aria-label': '查看 WhatsApp 访问模式说明',
'aria-describedby': helpId,
}, h('span', { 'aria-hidden': 'true' }, '?')),
h('span', { id: helpId, className: 'dwa-accessTooltip', role: 'tooltip' },
h('span', { className: 'dwa-accessTooltipItem' },
h('strong', null, '仅自己模式'),
h('span', null, '只响应已绑定 WhatsApp 账号的自聊消息。')),
h('span', { className: 'dwa-accessTooltipItem' },
h('strong', null, '指定联系人模式'),
h('span', null, '响应自聊和白名单联系人的私聊,忽略群聊。')),
h('span', { className: 'dwa-accessTooltipItem' },
h('strong', null, '开放响应模式'),
h('span', null, '响应所有私聊,以及群聊中的提及或回复。')))))),
h('label', { className: 'dwa-accessField' },
h('span', null, '模式'),
h('select', {
value: accessMode,
disabled: busy,
'aria-label': 'WhatsApp 访问模式',
onChange: (event) => { setAccessMode(event.target.value); setError(null); },
},
h('option', { value: 'self-only' }, '仅自己模式(默认)'),
h('option', { value: 'private-allowlist' }, '指定联系人模式'),
h('option', { value: 'open' }, '开放响应模式'))),
allowlistEnabled
? h('label', { className: 'dwa-accessField' },
h('span', null, '允许私聊的 WhatsApp 电话号码'),
h('textarea', {
value: allowedNumbers,
disabled: busy,
rows: 3,
placeholder: '每行一个含国家或地区代码的号码',
'aria-label': '允许私聊的 WhatsApp 电话号码',
onChange: (event) => { setAllowedNumbers(event.target.value); setError(null); },
}),
h('small', null, '可以包含开头的 +,保存时会自动移除。'))
: null,
allowlistEnabled && allowedNumbers.trim() === ''
? h('p', { className: 'dwa-accessWarning', role: 'status' },
'白名单为空;保存后将只接受自聊消息。')
: null,
error ? h('p', { className: 'dwa-accessError', role: 'alert' }, error) : null,
h('div', { className: 'dwa-accessActions' },
h('button', {
type: 'submit',
className: 'ddt-button',
'data-kind': 'secondary',
disabled: busy,
}, busy ? '正在保存…' : '保存访问设置')));
}
const Button = React.forwardRef(function Button( const Button = React.forwardRef(function Button(
{ children, kind = 'secondary', className = '', ...props }, { children, kind = 'secondary', className = '', ...props },
ref, ref,
@ -179,6 +293,7 @@ export function WhatsappAccountCard({
onReconnect, onReconnect,
onWorkspaceSave, onWorkspaceSave,
onAgentPresetSave, onAgentPresetSave,
onAccessPolicySave,
onRequestRemove, onRequestRemove,
onConfirmRemove, onConfirmRemove,
onCancelRemove, onCancelRemove,
@ -197,15 +312,14 @@ export function WhatsappAccountCard({
}, h(WhatsappLogoGlyph, { size: 29 })), }, h(WhatsappLogoGlyph, { size: 29 })),
h('div', { className: 'dim-botName' }, h('div', { className: 'dim-botName' },
h('h3', null, account.bot.name), h('p', null, account.bot.idMasked))), h('h3', null, account.bot.name), h('p', null, account.bot.idMasked))),
h('div', { className: 'ddt-health dim-botHealth' }, h(BotStatusMeta, {
h('span', { className: 'ddt-dot dim-healthDot', 'data-tone': tone }), className: 'ddt-health',
h('span', null, stateLabel))), dotClassName: 'ddt-dot',
h('dl', { className: 'ddt-metrics dim-botMetrics' }, tone,
h('div', { className: 'ddt-metric dim-botMetric' }, stateLabel,
h('dt', null, '消息通道'), lastCheckedAt: account.health.lastCheckedAt,
h('dd', null, account.connected ? 'WhatsApp Web' : '离线')), formatCheckedTime: checkedTime,
h('div', { className: 'ddt-metric dim-botMetric' }, })),
h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h(WorkspaceEditor, { h(WorkspaceEditor, {
workspace: account.workspace, workspace: account.workspace,
disabled: Boolean(busy), disabled: Boolean(busy),
@ -216,6 +330,11 @@ export function WhatsappAccountCard({
disabled: Boolean(busy), disabled: Boolean(busy),
onSave: onAgentPresetSave, onSave: onAgentPresetSave,
}), }),
h(WhatsappAccessSettings, {
account,
busy: Boolean(busy),
onSave: onAccessPolicySave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' }, h('div', { className: 'ddt-accountFooter dim-cardFooter' },
h('div', { className: 'dim-cardFooterLayout' }, h('div', { className: 'dim-cardFooterLayout' },
h('div', { className: 'ddt-actions dim-cardActions' }, h('div', { className: 'ddt-actions dim-cardActions' },
@ -440,8 +559,11 @@ export function WhatsappSettingsTab({ rpcCall }) {
const botList = model.bots.length > 0 const botList = model.bots.length > 0
? h('section', { className: 'dim-listSection' }, ? h('section', { className: 'dim-listSection' },
h('div', { className: 'ddt-listHeading dim-listHeading' }, h(ChannelListHeading, {
h('h3', null, '已接入的 WhatsApp 机器人')), className: 'ddt-listHeading',
title: '已接入的 WhatsApp 机器人',
connectionLabel: 'WhatsApp Web',
}),
h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) => h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) =>
h('li', { key: account.botId }, h(WhatsappAccountCard, { h('li', { key: account.botId }, h(WhatsappAccountCard, {
account, account,
@ -466,6 +588,12 @@ export function WhatsappSettingsTab({ rpcCall }) {
WHATSAPP_ENDPOINTS.setAgentPreset, WHATSAPP_ENDPOINTS.setAgentPreset,
{ botId: account.botId, agentPreset }, { botId: account.botId, agentPreset },
), ),
onAccessPolicySave: (accessPolicy) => botAction(
account,
'access',
WHATSAPP_ENDPOINTS.setAccessPolicy,
{ botId: account.botId, ...accessPolicy },
),
onRequestRemove: () => setRemoveTarget(account.botId), onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null), onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => { onConfirmRemove: async () => {

View file

@ -4,6 +4,31 @@ const CSS = String.raw`
.dwa-page { --ddt-accent: #25d366; --ddt-accent-deep: #128c7e; --ddt-accent-wash: #eafbf0; } .dwa-page { --ddt-accent: #25d366; --ddt-accent-deep: #128c7e; --ddt-accent-wash: #eafbf0; }
.dwa-avatar { color: #fff; background: #25d366; } .dwa-avatar { color: #fff; background: #25d366; }
.dwa-avatar svg { display: block; } .dwa-avatar svg { display: block; }
.dwa-access { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-layer-2, #f7f8fa); }
.dwa-accessHeading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.dwa-accessHeading > strong { font-size: 13px; }
.dwa-accessStatus { min-width: 0; display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; }
.dwa-accessBadge { flex: none; padding: 3px 8px; border-radius: 999px; color: #08785f; background: #eafbf0; font-size: 11px; font-weight: 700; }
.dwa-accessBadge[data-mode="private-allowlist"] { color: #0f6f8f; background: #eaf7fd; }
.dwa-accessBadge[data-mode="open"] { color: #a15c00; background: #fff3d6; }
.dwa-accessHelp { position: relative; display: inline-flex; flex: none; }
.dwa-accessHelpButton { width: 20px; height: 20px; display: grid; place-items: center; padding: 0; border: 1px solid color-mix(in srgb, #25d366 34%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 50%; color: #128c7e; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; line-height: 1; font-weight: 750; cursor: help; }
.dwa-accessTooltip { position: absolute; top: calc(100% + 8px); right: 0; z-index: 30; width: 270px; max-width: min(290px, calc(100vw - 48px)); display: grid; gap: 8px; padding: 10px 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 10px 28px rgb(31 35 41 / 16%); opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; }
.dwa-accessTooltipItem { display: grid; gap: 2px; }
.dwa-accessTooltipItem + .dwa-accessTooltipItem { padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l2, #eef0f3); }
.dwa-accessTooltipItem strong { font-size: 12px; line-height: 17px; }
.dwa-accessTooltipItem > span { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 16px; }
.dwa-accessHelp:hover .dwa-accessTooltip, .dwa-accessHelp:focus-within .dwa-accessTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
.dwa-accessField { display: grid; gap: 5px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; font-weight: 600; }
.dwa-accessField select, .dwa-accessField textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1, #c9cdd4); border-radius: 7px; color: inherit; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-weight: 400; }
.dwa-accessField select { height: 34px; padding: 0 9px; }
.dwa-accessField textarea { min-height: 68px; padding: 8px 9px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dwa-accessField textarea:disabled { color: var(--dsw-alias-label-tertiary, #8f959e); background: var(--dsw-alias-bg-module-platform, #f2f3f5); cursor: not-allowed; resize: none; opacity: 1; }
.dwa-accessField small { color: var(--dsw-alias-label-secondary, #646a73); font-weight: 400; }
.dwa-accessWarning, .dwa-accessError { margin: 0; font-size: 12px; line-height: 1.5; }
.dwa-accessWarning { color: #a15c00; }
.dwa-accessError { color: var(--dsw-alias-state-error-primary, #d83931); }
.dwa-accessActions { display: flex; justify-content: flex-end; }
`; `;
export function installWhatsappStyles() { export function installWhatsappStyles() {

View file

@ -107,6 +107,7 @@ const EN = Object.freeze({
'离线': 'Offline', '离线': 'Offline',
'已断开': 'Disconnected', '已断开': 'Disconnected',
'消息通道': 'Message channel', '消息通道': 'Message channel',
'查看消息通道说明': 'View message channel details',
'最近检查': 'Last checked', '最近检查': 'Last checked',
'当前工作区': 'Current workspace', '当前工作区': 'Current workspace',
'选择目录': 'Choose folder', '选择目录': 'Choose folder',
@ -359,6 +360,24 @@ const EN = Object.freeze({
'正在建立安全的关联设备会话。': 'Creating a secure linked-device session.', '正在建立安全的关联设备会话。': 'Creating a secure linked-device session.',
'关联设备正在接入 DeepSeek Harness。': 'Linking the device to DeepSeek Harness.', '关联设备正在接入 DeepSeek Harness。': 'Linking the device to DeepSeek Harness.',
'WhatsApp Web 关联设备运行正常': 'WhatsApp linked device is healthy', 'WhatsApp Web 关联设备运行正常': 'WhatsApp linked device is healthy',
'查看 WhatsApp 访问模式说明': 'View WhatsApp access mode details',
'WhatsApp 访问模式': 'WhatsApp access mode',
'仅自己模式': 'Only me',
'指定联系人模式': 'Selected contacts',
'开放响应模式': 'Open responses',
'仅自己模式(默认)': 'Only me (default)',
'已生效:': 'Active: ',
'只响应已绑定 WhatsApp 账号的自聊消息。': 'Only respond to self-chat messages from the linked WhatsApp account.',
'响应自聊和白名单联系人的私聊,忽略群聊。': 'Respond to self-chat and allowlisted direct messages; ignore group messages.',
'响应所有私聊,以及群聊中的提及或回复。': 'Respond to all direct messages and to group mentions or replies.',
'允许私聊的 WhatsApp 电话号码': 'WhatsApp phone numbers allowed to send direct messages',
'每行一个含国家或地区代码的号码': 'One number with country or region code per line',
'可以包含开头的 +,保存时会自动移除。': 'A leading + is allowed and removed when saved.',
'仅指定联系人模式使用白名单,切换模式时会保留。': 'Only Selected contacts uses the allowlist; it is retained when modes change.',
'白名单为空;保存后将只接受自聊消息。': 'The allowlist is empty; only self-chat messages will be accepted after saving.',
'电话号码必须包含国家或地区代码,每行一个。': 'Each phone number must include a country or region code on its own line.',
'WhatsApp 访问设置暂不可用。': 'WhatsApp access settings are currently unavailable.',
'WhatsApp 访问设置保存失败。': 'Could not save WhatsApp access settings.',
'Bot API 长轮询': 'Bot API long polling', 'Bot API 长轮询': 'Bot API long polling',
' Gateway 长连接': ' Gateway persistent connection', ' Gateway 长连接': ' Gateway persistent connection',
'Gateway 长连接': 'Gateway persistent connection', 'Gateway 长连接': 'Gateway persistent connection',

View file

@ -87,6 +87,14 @@ const CSS = String.raw`
.dim-panel .dim-listSection { display: flex; flex-direction: column; gap: 0; } .dim-panel .dim-listSection { display: flex; flex-direction: column; gap: 0; }
.dim-panel .dim-listHeading { min-height: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 6px; padding: 0; } .dim-panel .dim-listHeading { min-height: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 6px; padding: 0; }
.dim-panel .dim-listHeading h3 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; line-height: normal; font-weight: 650; } .dim-panel .dim-listHeading h3 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; line-height: normal; font-weight: 650; }
.dim-panel .dim-listTitle { min-width: 0; display: inline-flex; align-items: center; gap: 6px; }
.dim-panel .dim-channelHelp { position: relative; display: inline-flex; flex: none; }
.dim-panel .dim-channelHelpButton { width: 17px; height: 17px; display: grid; place-items: center; padding: 0; border: 1px solid color-mix(in srgb, #1677ff 28%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 50%; color: #1677ff; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 11px; line-height: 1; font-weight: 700; cursor: help; transition: border-color .15s ease, color .15s ease, background .15s ease, box-shadow .15s ease; }
.dim-panel .dim-channelHelpButton:hover { border-color: #1677ff; color: #0f5fce; background: color-mix(in srgb, #1677ff 8%, var(--dsw-alias-bg-layer-1, #fff)); }
.dim-panel .dim-channelHelpButton:focus-visible { outline: none; border-color: #1677ff; box-shadow: 0 0 0 3px color-mix(in srgb, #1677ff 16%, transparent); }
.dim-panel .dim-channelTooltip { position: absolute; top: calc(100% + 7px); left: 0; z-index: 30; width: max-content; max-width: min(280px, calc(100vw - 48px)); display: flex; align-items: baseline; gap: 5px; padding: 8px 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 10px 28px rgb(31 35 41 / 16%); font-size: 11px; line-height: 16px; font-weight: 400; white-space: normal; opacity: 0; visibility: hidden; transform: translateY(-3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; }
.dim-panel .dim-channelTooltip strong { color: var(--dsw-alias-label-primary, #1f2329); font-weight: 600; white-space: nowrap; }
.dim-panel .dim-channelHelp:hover .dim-channelTooltip, .dim-panel .dim-channelHelp:focus-within .dim-channelTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
.dim-panel .dim-botList { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; } .dim-panel .dim-botList { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.dim-panel .dim-loadingView { padding: 38px; color: var(--dsw-alias-label-secondary, #646a73); text-align: center; } .dim-panel .dim-loadingView { padding: 38px; color: var(--dsw-alias-label-secondary, #646a73); text-align: center; }
.dim-panel .dim-loadingView h3 { margin: 0 0 7px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: normal; font-weight: 650; } .dim-panel .dim-loadingView h3 { margin: 0 0 7px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: normal; font-weight: 650; }
@ -214,15 +222,13 @@ const CSS = String.raw`
.dim-panel .dim-botName { min-width: 0; } .dim-panel .dim-botName { min-width: 0; }
.dim-panel .dim-botName h3 { overflow: hidden; margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 15px; font-weight: 650; line-height: normal; text-overflow: ellipsis; white-space: nowrap; } .dim-panel .dim-botName h3 { overflow: hidden; margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 15px; font-weight: 650; line-height: normal; text-overflow: ellipsis; white-space: nowrap; }
.dim-panel .dim-botName p { overflow: hidden; margin: 4px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font: 12px ui-monospace, SFMono-Regular, monospace; line-height: normal; text-overflow: ellipsis; white-space: nowrap; } .dim-panel .dim-botName p { overflow: hidden; margin: 4px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font: 12px ui-monospace, SFMono-Regular, monospace; line-height: normal; text-overflow: ellipsis; white-space: nowrap; }
.dim-panel .dim-botHealthGroup { flex: none; display: grid; justify-items: end; gap: 5px; }
.dim-panel .dim-botCard .dim-botHealth { flex: none; min-height: 0; display: inline-flex; align-items: center; gap: 7px; padding: 0; border: 0; border-radius: 0; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 12px; font-weight: 400; line-height: normal; white-space: nowrap; } .dim-panel .dim-botCard .dim-botHealth { flex: none; min-height: 0; display: inline-flex; align-items: center; gap: 7px; padding: 0; border: 0; border-radius: 0; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 12px; font-weight: 400; line-height: normal; white-space: nowrap; }
.dim-panel .dim-lastChecked { display: inline-flex; align-items: baseline; gap: 4px; color: var(--dsw-alias-label-tertiary, #8f959e); font: inherit; font-size: 11px; font-weight: 400; line-height: normal; white-space: nowrap; }
.dim-panel .dim-botCard .dim-healthDot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: #aeb3bb; box-shadow: none; } .dim-panel .dim-botCard .dim-healthDot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: #aeb3bb; box-shadow: none; }
.dim-panel .dim-botCard .dim-healthDot[data-tone="success"] { background: var(--dsw-alias-state-success-primary, #20a162); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary, #20a162) 14%, transparent); } .dim-panel .dim-botCard .dim-healthDot[data-tone="success"] { background: var(--dsw-alias-state-success-primary, #20a162); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary, #20a162) 14%, transparent); }
.dim-panel .dim-botCard .dim-healthDot[data-tone="warning"] { background: var(--dsw-alias-state-warn-primary, #d97706); } .dim-panel .dim-botCard .dim-healthDot[data-tone="warning"] { background: var(--dsw-alias-state-warn-primary, #d97706); }
.dim-panel .dim-botCard .dim-healthDot[data-tone="error"] { background: var(--dsw-alias-state-error-primary, #d54941); } .dim-panel .dim-botCard .dim-healthDot[data-tone="error"] { background: var(--dsw-alias-state-error-primary, #d54941); }
.dim-panel .dim-botMetrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 6px 0; }
.dim-panel .dim-botMetric { min-width: 0; padding: 6px 8px; border: 0; border-radius: 9px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
.dim-panel .dim-botMetric dt { margin: 0; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; font-weight: 400; line-height: normal; }
.dim-panel .dim-botMetric dd { overflow: hidden; margin: 3px 0 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; font-weight: 400; line-height: normal; text-overflow: ellipsis; white-space: nowrap; }
.dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; } .dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; }
.dim-panel .ddt-headingCopy { display: none; } .dim-panel .ddt-headingCopy { display: none; }
.dim-panel .ddt-qrFrame, .dim-panel .ddt-countdown { width: min(270px, 100%); } .dim-panel .ddt-qrFrame, .dim-panel .ddt-countdown { width: min(270px, 100%); }
@ -253,6 +259,7 @@ const CSS = String.raw`
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.dim-panel .dim-botCardTop { flex-direction: column; align-items: stretch; } .dim-panel .dim-botCardTop { flex-direction: column; align-items: stretch; }
.dim-panel .dim-botHealthGroup { justify-items: start; }
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.dim-title { flex-direction: column; gap: 10px; } .dim-title { flex-direction: column; gap: 10px; }

View file

@ -86,9 +86,10 @@ export async function createProductionController(ctx, config = {}, internals = {
return state; return state;
}; };
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor); const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, { const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
controlExecutor: internals.controlExecutor, controlExecutor: internals.controlExecutor,
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor, sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
fileIngressExecutor: internals.fileIngressExecutor,
}); });
const harness = new Harness({ const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@ -98,6 +99,7 @@ export async function createProductionController(ctx, config = {}, internals = {
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),
...(controlExecutor ? { controlExecutor } : {}), ...(controlExecutor ? { controlExecutor } : {}),
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}), ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
}); });
const coreController = new Controller({ const coreController = new Controller({
deviceAuth, deviceAuth,

View file

@ -133,9 +133,10 @@ export async function createProductionController(ctx, config = {}, internals = {
return stateFor(botConfig); return stateFor(botConfig);
}; };
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor); const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, { const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
controlExecutor: internals.controlExecutor, controlExecutor: internals.controlExecutor,
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor, sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
fileIngressExecutor: internals.fileIngressExecutor,
}); });
const harness = new Harness({ const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@ -147,6 +148,7 @@ export async function createProductionController(ctx, config = {}, internals = {
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),
...(controlExecutor ? { controlExecutor } : {}), ...(controlExecutor ? { controlExecutor } : {}),
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}), ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
}); });
const proxyEnv = internals.proxyEnv ?? process.env; const proxyEnv = internals.proxyEnv ?? process.env;
const wsAgent = createFeishuWebSocketAgent(proxyEnv, internals.createProxyAgent); const wsAgent = createFeishuWebSocketAgent(proxyEnv, internals.createProxyAgent);

View file

@ -77,9 +77,10 @@ export async function createProductionController(ctx, config = {}, internals = {
return state; return state;
}; };
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor); const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, { const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
controlExecutor: internals.controlExecutor, controlExecutor: internals.controlExecutor,
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor, sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
fileIngressExecutor: internals.fileIngressExecutor,
}); });
const harness = new Harness({ const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@ -89,6 +90,7 @@ export async function createProductionController(ctx, config = {}, internals = {
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),
...(controlExecutor ? { controlExecutor } : {}), ...(controlExecutor ? { controlExecutor } : {}),
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}), ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
}); });
const coreController = new Controller({ const coreController = new Controller({
qrAuth, qrAuth,

View file

@ -78,9 +78,10 @@ export async function createTokenProductionController(ctx, config, internals, de
return state; return state;
}; };
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor); const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, { const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
controlExecutor: internals.controlExecutor, controlExecutor: internals.controlExecutor,
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor, sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
fileIngressExecutor: internals.fileIngressExecutor,
}); });
const harness = new ResolvedHarness({ const harness = new ResolvedHarness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@ -90,6 +91,7 @@ export async function createTokenProductionController(ctx, config, internals, de
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),
...(controlExecutor ? { controlExecutor } : {}), ...(controlExecutor ? { controlExecutor } : {}),
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}), ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
}); });
const coreController = new ResolvedController({ const coreController = new ResolvedController({
credentials: ctx.credentials, credentials: ctx.credentials,

View file

@ -56,9 +56,10 @@ export async function createProductionController(ctx, config = {}, internals = {
return state; return state;
}; };
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor); const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, { const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
controlExecutor: internals.controlExecutor, controlExecutor: internals.controlExecutor,
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor, sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
fileIngressExecutor: internals.fileIngressExecutor,
}); });
const harness = new ResolvedHarness({ const harness = new ResolvedHarness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@ -68,6 +69,7 @@ export async function createProductionController(ctx, config = {}, internals = {
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),
...(controlExecutor ? { controlExecutor } : {}), ...(controlExecutor ? { controlExecutor } : {}),
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}), ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
}); });
const coreController = new ResolvedController({ const coreController = new ResolvedController({
credentials: ctx.credentials, credentials: ctx.credentials,

View file

@ -80,9 +80,10 @@ export async function createProductionController(ctx, config = {}, internals = {
return state; return state;
}; };
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor); const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, { const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
controlExecutor: internals.controlExecutor, controlExecutor: internals.controlExecutor,
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor, sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
fileIngressExecutor: internals.fileIngressExecutor,
}); });
const harness = new Harness({ const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@ -92,6 +93,7 @@ export async function createProductionController(ctx, config = {}, internals = {
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),
...(controlExecutor ? { controlExecutor } : {}), ...(controlExecutor ? { controlExecutor } : {}),
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}), ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
}); });
const coreController = new Controller({ const coreController = new Controller({
qrAuth, qrAuth,

View file

@ -80,9 +80,10 @@ export async function createProductionController(ctx, config = {}, internals = {
return state; return state;
}; };
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor); const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, { const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
controlExecutor: internals.controlExecutor, controlExecutor: internals.controlExecutor,
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor, sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
fileIngressExecutor: internals.fileIngressExecutor,
}); });
const harness = new Harness({ const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@ -92,6 +93,7 @@ export async function createProductionController(ctx, config = {}, internals = {
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),
...(controlExecutor ? { controlExecutor } : {}), ...(controlExecutor ? { controlExecutor } : {}),
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}), ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
}); });
const coreController = new Controller({ const coreController = new Controller({
api, api,

View file

@ -83,9 +83,10 @@ export async function createProductionController(ctx, config = {}, internals = {
return state; return state;
}; };
const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor); const commandExecutor = createHarnessCommandExecutor(ctx, internals.commandExecutor);
const { controlExecutor, sessionMaintenanceExecutor } = createHarnessSessionExecutors(ctx, { const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = createHarnessSessionExecutors(ctx, {
controlExecutor: internals.controlExecutor, controlExecutor: internals.controlExecutor,
sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor, sessionMaintenanceExecutor: internals.sessionMaintenanceExecutor,
fileIngressExecutor: internals.fileIngressExecutor,
}); });
const harness = new Harness({ const harness = new Harness({
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
@ -95,6 +96,7 @@ export async function createProductionController(ctx, config = {}, internals = {
...(commandExecutor ? { commandExecutor } : {}), ...(commandExecutor ? { commandExecutor } : {}),
...(controlExecutor ? { controlExecutor } : {}), ...(controlExecutor ? { controlExecutor } : {}),
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}), ...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
}); });
const coreController = new Controller({ const coreController = new Controller({
configStore: observedConfigStore, configStore: observedConfigStore,

View file

@ -1,6 +1,7 @@
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs'; import { publicConnectionTestResult } from '../../../../src/channels/shared/connection-test.mjs';
import { normalizeWhatsappAccessPolicy } from '../../../../src/channels/whatsapp/config-store.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs'; import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs'; import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs'; import { SET_AGENT_PRESET_ENDPOINT, validAgentPresetPayload } from '../shared/agent-preset-rpc.mjs';
@ -13,6 +14,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
cancelProvisioning: 'provision.cancel', cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect', reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete', deleteBot: 'bot.delete',
setAccessPolicy: 'bot.access-policy.set',
setWorkspace: SET_WORKSPACE_ENDPOINT, setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT, setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
}); });
@ -50,6 +52,17 @@ function payloadFailure(endpoint, payload) {
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId)
&& payload.confirm === true ? null : 'bot.delete requires a botId and confirm=true.'; && payload.confirm === true ? null : 'bot.delete requires a botId and confirm=true.';
} }
if (endpoint === WHATSAPP_ENDPOINTS.setAccessPolicy) {
if (!exactKeys(payload, ['botId', 'accessMode', 'allowedNumbers'])
|| Object.keys(payload).length !== 3
|| !validId(payload.botId)) return '请输入有效的 WhatsApp 访问模式和电话号码。';
try {
normalizeWhatsappAccessPolicy(payload);
return null;
} catch {
return '请输入有效的 WhatsApp 访问模式和电话号码。';
}
}
if (endpoint === WHATSAPP_ENDPOINTS.setWorkspace) { if (endpoint === WHATSAPP_ENDPOINTS.setWorkspace) {
return validWorkspacePayload(payload) return validWorkspacePayload(payload)
? null : '请输入工作区绝对路径。'; ? null : '请输入工作区绝对路径。';
@ -92,7 +105,7 @@ async function publicStatus(value, encodeQr) {
} }
export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } = {}) { export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) { for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot', 'setAccessPolicy']) {
if (typeof controller?.[method] !== 'function') { if (typeof controller?.[method] !== 'function') {
throw new TypeError(`A complete WhatsApp controller is required (${method})`); throw new TypeError(`A complete WhatsApp controller is required (${method})`);
} }
@ -166,6 +179,11 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
await controller.updateAgentPreset(payload.botId, payload.agentPreset), await controller.updateAgentPreset(payload.botId, payload.agentPreset),
cachedEncode, cachedEncode,
); );
} else if (endpoint === WHATSAPP_ENDPOINTS.setAccessPolicy) {
value = await publicStatus(
await controller.setAccessPolicy(payload.botId, normalizeWhatsappAccessPolicy(payload)),
cachedEncode,
);
} else { } else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode); value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
} }

View file

@ -1,5 +1,10 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import {
InboundFileError,
stageInboundFiles,
} from '../../src/channels/shared/inbound-file.mjs';
function agentsFromContext(ctx) { function agentsFromContext(ctx) {
if (typeof ctx?.get !== 'function') return undefined; if (typeof ctx?.get !== 'function') return undefined;
let agents; let agents;
@ -108,13 +113,30 @@ function createSessionMaintenanceExecutor(agents) {
}; };
} }
function createFileIngressExecutor(agents) {
return ({ sessionId, workspace, files, signal }) => {
const agent = agents.get(sessionId);
const attachedWorkspace = agent?.session?.header?.cwd;
const exactWorkspace = typeof attachedWorkspace === 'string' && attachedWorkspace
? attachedWorkspace
: workspace;
if (typeof exactWorkspace !== 'string' || !exactWorkspace) {
throw new InboundFileError(
'inbound-file-workspace-unavailable',
'The Harness Session workspace is unavailable for inbound files.',
);
}
return stageInboundFiles({ files }, { workspace: exactWorkspace, signal });
};
}
/** /**
* Build optional same-process Session executors without adding a hard Cordis * Build optional same-process Session executors. File ingress also works for
* service injection. Fixtures and deployments without AgentRegistry preserve * cold Sessions by using the authoritative cwd returned by session.list;
* the existing HTTP behavior. * when an Agent is attached, its live Session header remains authoritative.
*/ */
export function createHarnessSessionExecutors(ctx, provided = {}) { export function createHarnessSessionExecutors(ctx, provided = {}) {
const { controlExecutor, sessionMaintenanceExecutor } = provided; const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = provided;
if (controlExecutor !== undefined && typeof controlExecutor !== 'function') { if (controlExecutor !== undefined && typeof controlExecutor !== 'function') {
throw new TypeError('controlExecutor must be a function'); throw new TypeError('controlExecutor must be a function');
} }
@ -122,13 +144,18 @@ export function createHarnessSessionExecutors(ctx, provided = {}) {
&& typeof sessionMaintenanceExecutor !== 'function') { && typeof sessionMaintenanceExecutor !== 'function') {
throw new TypeError('sessionMaintenanceExecutor must be a function'); throw new TypeError('sessionMaintenanceExecutor must be a function');
} }
if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
throw new TypeError('fileIngressExecutor must be a function');
}
const agents = controlExecutor && sessionMaintenanceExecutor const agents = controlExecutor && sessionMaintenanceExecutor && fileIngressExecutor
? undefined ? undefined
: agentsFromContext(ctx); : agentsFromContext(ctx);
return { return {
controlExecutor: controlExecutor ?? (agents ? createControlExecutor(agents) : undefined), controlExecutor: controlExecutor ?? (agents ? createControlExecutor(agents) : undefined),
sessionMaintenanceExecutor: sessionMaintenanceExecutor sessionMaintenanceExecutor: sessionMaintenanceExecutor
?? (agents ? createSessionMaintenanceExecutor(agents) : undefined), ?? (agents ? createSessionMaintenanceExecutor(agents) : undefined),
fileIngressExecutor: fileIngressExecutor
?? createFileIngressExecutor(agents ?? { get: () => undefined }),
}; };
} }

View file

@ -8,9 +8,16 @@ import { apply as applyTelegram } from './channels/telegram/index.mjs';
import { apply as applyWecom } from './channels/wecom/index.mjs'; import { apply as applyWecom } from './channels/wecom/index.mjs';
import { apply as applyWeixin } from './channels/weixin/index.mjs'; import { apply as applyWeixin } from './channels/weixin/index.mjs';
import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs'; import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
export const name = 'dsh-im-host'; export const name = 'dsh-im-host';
export const inject = ['connection', 'credentials', 'webServer', 'typertGateway']; export const inject = [
'connection',
'credentials',
'webServer',
'typertGateway',
];
function channelConfig(config, name) { function channelConfig(config, name) {
const channel = config[name] ?? {}; const channel = config[name] ?? {};
@ -34,6 +41,14 @@ export function createImHostPlugin(internals = {}) {
name, name,
inject, inject,
async apply(ctx, config = {}) { async apply(ctx, config = {}) {
setImHostLanguage(config.language ?? process.env.DSH_IM_LANGUAGE);
if (typeof ctx?.inject === 'function') {
ctx.inject(['tools', 'systemPrompt'], (artifactCtx) => {
installOutboundArtifactTool(artifactCtx);
});
} else {
installOutboundArtifactTool(ctx);
}
await startFeishu(ctx, channelConfig(config, 'feishu')); await startFeishu(ctx, channelConfig(config, 'feishu'));
await startWeixin(ctx, channelConfig(config, 'weixin')); await startWeixin(ctx, channelConfig(config, 'weixin'));
await startDingtalk(ctx, channelConfig(config, 'dingtalk')); await startDingtalk(ctx, channelConfig(config, 'dingtalk'));

View file

@ -2,6 +2,8 @@ import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { t } from '../shared/i18n.mjs';
const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) }); const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
const STORED_BOT_KEYS = new Set(['clientId', 'secretRef', 'approvedSenders']); const STORED_BOT_KEYS = new Set(['clientId', 'secretRef', 'approvedSenders']);
@ -86,8 +88,8 @@ export function deriveDingtalkSenderKey() {
*/ */
export function maskDingtalkSenderId(staffId) { export function maskDingtalkSenderId(staffId) {
const value = cleanString(staffId); const value = cleanString(staffId);
if (!value) return '钉钉用户'; if (!value) return t('钉钉用户');
return '身份已隐藏'; return t('身份已隐藏');
} }
/** /**
@ -97,7 +99,7 @@ export function maskDingtalkSenderId(staffId) {
*/ */
export function maskDingtalkClientId(clientId) { export function maskDingtalkClientId(clientId) {
const value = cleanString(clientId); const value = cleanString(clientId);
if (!value) return '钉钉机器人'; if (!value) return t('钉钉机器人');
if (value.length <= 8) return `${value.slice(0, 2)}••••`; if (value.length <= 8) return `${value.slice(0, 2)}••••`;
return `${value.slice(0, 4)}••••${value.slice(-4)}`; return `${value.slice(0, 4)}••••${value.slice(-4)}`;
} }

View file

@ -1,6 +1,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { extname } from 'node:path';
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs'; import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
import { t } from '../shared/i18n.mjs';
export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/'; export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/'; export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/';
@ -25,8 +27,67 @@ function nonEmptyString(value) {
} }
function safeProviderCode(value) { function safeProviderCode(value) {
const code = nonEmptyString(value); const code = value === undefined || value === null ? null : String(value).trim();
return code && /^[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined; return code && /^-?[A-Za-z0-9_.:-]{1,160}$/.test(code) ? code : undefined;
}
function preserveArtifactMetadata(target, source) {
if (Number.isInteger(source?.status)) target.status = source.status;
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
return target;
}
function dingtalkArtifactError(cause, { fallback = 'artifact-provider-rejected' } = {}) {
if (cause?.code?.startsWith?.('artifact-')) return cause;
const status = Number(cause?.status);
const providerCode = safeProviderCode(cause?.providerCode);
const providerText = providerCode ?? '';
let code = fallback;
let message = 'DingTalk could not prepare the file for delivery.';
if (status === 401 || status === 403 || providerCode === '401' || providerCode === '403'
|| /(?:permission|forbidden|unauthor|access.?denied|\.auth(?:\.|$))/i.test(providerText)) {
code = 'artifact-permission-required';
message = 'DingTalk denied permission to send the file.';
} else if (status === 413 || providerCode === '413'
|| /(?:too.?large|size.?limit)/i.test(providerText)) {
code = 'artifact-too-large';
message = 'The file exceeds DingTalk\'s size limit.';
} else if (status === 429 || providerCode === '429'
|| /(?:rate.?limit|too.?many|throttl)/i.test(providerText)) {
code = 'artifact-rate-limited';
message = 'DingTalk rate-limited file delivery.';
} else if (fallback === 'artifact-provider-rejected') {
message = 'DingTalk rejected the file message.';
}
const error = new Error(message, { cause });
error.code = code;
return preserveArtifactMetadata(error, cause);
}
function uncertainDingtalkDelivery(cause) {
const error = new Error('DingTalk file delivery result is uncertain', { cause });
error.code = 'artifact-delivery-uncertain';
return preserveArtifactMetadata(error, cause);
}
function rejectedProviderResponse(value) {
if (!value || typeof value !== 'object') return null;
for (const field of ['errcode', 'code']) {
if (value[field] !== undefined && value[field] !== 0 && value[field] !== '0') {
return safeProviderCode(value[field]) ?? 'rejected';
}
}
return null;
}
function classifyDingtalkFinalDeliveryError(error, signal) {
if (signal?.aborted) throw abortError(signal);
const status = Number(error?.status);
if (error?.code === 'network-error' || error?.code === 'timeout'
|| error?.code === 'invalid-response' || (status >= 500 && status < 600)) {
return uncertainDingtalkDelivery(error);
}
return dingtalkArtifactError(error);
} }
function secureDingtalkDownloadUrl(value) { function secureDingtalkDownloadUrl(value) {
@ -174,6 +235,74 @@ async function requestJson(fetchImpl, url, {
} }
} }
async function requestMultipart(fetchImpl, url, { body, signal, timeoutMs = 60_000 } = {}) {
const controller = new AbortController();
let timedOut = false;
const onAbort = () => controller.abort(signal?.reason);
if (signal?.aborted) throw abortError(signal);
signal?.addEventListener('abort', onAbort, { once: true });
const timer = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
try {
const response = await fetchImpl(url, {
method: 'POST',
body,
signal: controller.signal,
redirect: 'error',
});
let value;
let parseError;
try {
value = await response.json();
} catch (error) {
parseError = error;
}
if (!response.ok) {
throw new DingtalkApiError(
'http-error',
`钉钉服务请求失败(HTTP ${response.status})。`,
{ status: response.status, providerCode: safeProviderCode(value?.code ?? value?.errcode) },
);
}
if (parseError) {
throw new DingtalkApiError(
'invalid-response',
'钉钉服务返回了无法解析的响应。',
{ cause: parseError },
);
}
return value;
} catch (error) {
if (signal?.aborted) throw abortError(signal);
if (timedOut) throw new DingtalkApiError('timeout', '钉钉服务请求超时。', { cause: error });
if (error instanceof DingtalkApiError) throw error;
throw new DingtalkApiError('network-error', '暂时无法完成钉钉文件上传请求。', { cause: error });
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
}
function normalizeFileTarget(target) {
const robotCode = nonEmptyString(target?.robotCode);
if (!robotCode) throw new TypeError('DingTalk robotCode is required');
if (target?.type === 'group') {
const openConversationId = nonEmptyString(target.openConversationId);
if (openConversationId) return { type: 'group', robotCode, openConversationId };
}
if (target?.type === 'user') {
const userId = nonEmptyString(target.userId);
if (userId) return { type: 'user', robotCode, userId };
}
throw new TypeError('DingTalk file target is invalid');
}
function dingtalkFileType(fileName) {
return extname(fileName).slice(1).toLowerCase();
}
function normalizeCardTarget(target) { function normalizeCardTarget(target) {
if (target?.type === 'user') { if (target?.type === 'user') {
const userId = nonEmptyString(target.userId); const userId = nonEmptyString(target.userId);
@ -307,6 +436,48 @@ export function createDingtalkApi({
return request; return request;
} }
async function messageFileDownloadUrl({
clientId,
clientSecret,
robotCode,
downloadCode,
signal,
kind,
}) {
const botCode = nonEmptyString(robotCode);
const fileCode = nonEmptyString(downloadCode);
if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required');
const token = await accessToken({ clientId, clientSecret, signal });
let response;
try {
response = await requestJson(
fetchImpl,
endpoint(apiBase, 'v1.0/robot/messageFiles/download'),
{
body: { downloadCode: fileCode, robotCode: botCode },
headers: { 'x-acs-dingtalk-access-token': token },
signal,
action: `${kind === 'image' ? '图片' : '文件'}下载地址`,
},
);
} catch (error) {
if (signal?.aborted) throw error;
throw new DingtalkApiError(
`${kind}-download-address-failed`,
`钉钉${kind === 'image' ? '图片' : '文件'}下载地址获取失败。`,
{ cause: error, status: error?.status, providerCode: error?.providerCode },
);
}
const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
if (!downloadUrl) {
throw new DingtalkApiError(
`invalid-${kind}-download`,
`钉钉服务没有返回${kind === 'image' ? '图片' : '文件'}下载地址。`,
);
}
return secureDingtalkDownloadUrl(downloadUrl);
}
function acquireCardRequestSlot(signal) { function acquireCardRequestSlot(signal) {
const acquire = async () => { const acquire = async () => {
const waitMs = Math.max(0, nextCardRequestAt - now()); const waitMs = Math.max(0, nextCardRequestAt - now());
@ -370,6 +541,93 @@ export function createDingtalkApi({
return true; return true;
} }
async function sendArtifact({
clientId,
clientSecret,
target,
file,
signal,
}, { uploadType, messageKey, createMessageParams }) {
if (!file || typeof file !== 'object'
|| typeof file.fileName !== 'string' || !file.fileName
|| !Buffer.isBuffer(file.bytes)) {
throw new TypeError('A DingTalk file is required');
}
const normalizedTarget = normalizeFileTarget(target);
let token;
try {
token = await accessToken({ clientId, clientSecret, signal });
} catch (error) {
if (signal?.aborted) throw abortError(signal);
const status = Number(error?.status);
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
? 'artifact-provider-rejected'
: 'artifact-provider-failed';
throw dingtalkArtifactError(error, { fallback });
}
const uploadUrl = new URL('media/upload', DINGTALK_REGISTRATION_BASE_URL);
uploadUrl.searchParams.set('access_token', token);
uploadUrl.searchParams.set('type', uploadType);
const form = new FormData();
form.append(
'media',
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
file.fileName,
);
let uploaded;
try {
uploaded = await requestMultipart(fetchImpl, uploadUrl, { body: form, signal });
} catch (error) {
if (signal?.aborted) throw abortError(signal);
const status = Number(error?.status);
const fallback = error?.code === 'http-error' && status >= 400 && status < 500
? 'artifact-provider-rejected'
: 'artifact-provider-failed';
throw dingtalkArtifactError(error, { fallback });
}
const uploadRejection = rejectedProviderResponse(uploaded);
const mediaId = nonEmptyString(uploaded?.media_id);
if (uploadRejection || !mediaId) {
throw dingtalkArtifactError(new DingtalkApiError(
'upload-rejected',
'钉钉服务拒绝了文件上传。',
{ providerCode: uploadRejection ?? 'missing-media-id' },
));
}
signal?.throwIfAborted();
const messageBody = {
robotCode: normalizedTarget.robotCode,
msgKey: messageKey,
msgParam: JSON.stringify(createMessageParams(mediaId, file)),
...(normalizedTarget.type === 'group'
? { openConversationId: normalizedTarget.openConversationId }
: { userIds: [normalizedTarget.userId] }),
};
const pathname = normalizedTarget.type === 'group'
? 'v1.0/robot/groupMessages/send'
: 'v1.0/robot/oToMessages/batchSend';
let response;
try {
response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
body: messageBody,
headers: { 'x-acs-dingtalk-access-token': token },
signal,
action: '文件消息发送',
});
} catch (error) {
throw classifyDingtalkFinalDeliveryError(error, signal);
}
const sendRejection = rejectedProviderResponse(response);
if (sendRejection) {
throw dingtalkArtifactError(new DingtalkApiError(
'send-rejected',
'钉钉服务拒绝了文件消息。',
{ providerCode: sendRejection },
));
}
return response;
}
return Object.freeze({ return Object.freeze({
async beginRegistration({ signal } = {}) { async beginRegistration({ signal } = {}) {
const initialized = assertRegistrationOk(await requestJson( const initialized = assertRegistrationOk(await requestJson(
@ -440,36 +698,16 @@ export function createDingtalkApi({
signal, signal,
maxBytes, maxBytes,
}) { }) {
const botCode = nonEmptyString(robotCode); const downloadUrl = await messageFileDownloadUrl({
const fileCode = nonEmptyString(downloadCode); clientId,
if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required'); clientSecret,
const token = await accessToken({ clientId, clientSecret, signal }); robotCode,
let response; downloadCode,
signal,
kind: 'image',
});
try { try {
response = await requestJson( return await fetchImageBuffer(downloadUrl, {
fetchImpl,
endpoint(apiBase, 'v1.0/robot/messageFiles/download'),
{
body: { downloadCode: fileCode, robotCode: botCode },
headers: { 'x-acs-dingtalk-access-token': token },
signal,
action: '图片下载地址',
},
);
} catch (error) {
if (signal?.aborted) throw error;
throw new DingtalkApiError(
'image-download-address-failed',
'钉钉图片下载地址获取失败。',
{ cause: error, status: error?.status, providerCode: error?.providerCode },
);
}
const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
if (!downloadUrl) {
throw new DingtalkApiError('invalid-image-download', '钉钉服务没有返回图片下载地址。');
}
try {
return await fetchImageBuffer(secureDingtalkDownloadUrl(downloadUrl), {
fetchImpl, fetchImpl,
signal, signal,
maxBytes, maxBytes,
@ -484,6 +722,50 @@ export function createDingtalkApi({
} }
}, },
async downloadFile({
clientId,
clientSecret,
robotCode,
downloadCode,
signal,
}) {
const downloadUrl = await messageFileDownloadUrl({
clientId,
clientSecret,
robotCode,
downloadCode,
signal,
kind: 'file',
});
if (downloadUrl.protocol !== 'https:') {
throw new DingtalkApiError('invalid-file-download', '钉钉服务返回了无效的文件下载地址。');
}
try {
const response = await fetchImpl(downloadUrl, {
method: 'GET',
redirect: 'follow',
signal,
});
if (!response?.ok) {
await response?.body?.cancel?.().catch?.(() => undefined);
throw new DingtalkApiError(
'file-content-download-failed',
`钉钉文件内容下载失败(HTTP ${response?.status ?? 'unknown'})。`,
{ status: response?.status },
);
}
return Buffer.from(await response.arrayBuffer());
} catch (error) {
if (signal?.aborted) throw abortError(signal);
if (error instanceof DingtalkApiError) throw error;
throw new DingtalkApiError(
'file-content-download-failed',
'钉钉文件内容下载失败。',
{ cause: error },
);
}
},
async createAiCard({ clientId, clientSecret, target, initialText, signal }) { async createAiCard({ clientId, clientSecret, target, initialText, signal }) {
const appKey = nonEmptyString(clientId); const appKey = nonEmptyString(clientId);
const appSecret = nonEmptyString(clientSecret); const appSecret = nonEmptyString(clientSecret);
@ -548,7 +830,7 @@ export function createDingtalkApi({
clientId: appKey, clientId: appKey,
clientSecret: appSecret, clientSecret: appSecret,
cardInstanceId, cardInstanceId,
text: '消息处理失败,请稍后重试。', text: t('消息处理失败,请稍后重试。'),
signal: cleanupSignal, signal: cleanupSignal,
}).catch(() => undefined); }).catch(() => undefined);
} }
@ -648,6 +930,26 @@ export function createDingtalkApi({
return true; return true;
}, },
async sendFile(request) {
return sendArtifact(request, {
uploadType: 'file',
messageKey: 'sampleFile',
createMessageParams: (mediaId, file) => ({
mediaId,
fileName: file.fileName,
fileType: dingtalkFileType(file.fileName),
}),
});
},
async sendImage(request) {
return sendArtifact(request, {
uploadType: 'image',
messageKey: 'sampleImageMsg',
createMessageParams: (mediaId) => ({ photoURL: mediaId }),
});
},
clearAccessToken(clientId) { clearAccessToken(clientId) {
const appKey = nonEmptyString(clientId); const appKey = nonEmptyString(clientId);
if (appKey) tokenCache.delete(appKey); if (appKey) tokenCache.delete(appKey);

View file

@ -29,16 +29,27 @@ import {
imagePromptUserMessage, imagePromptUserMessage,
promptContentForMessage, promptContentForMessage,
} from '../shared/image-prompt.mjs'; } from '../shared/image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
prefetchInboundFiles,
} from '../shared/inbound-file.mjs';
import { rememberConnectionTestTarget } from '../shared/connection-test.mjs'; import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
import {
createDeliveryReceipt,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
import { t } from '../shared/i18n.mjs';
const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…'; const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。'; const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。'; const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
const HELP_TEXT = [ const HELP_TEXT_LINES = [
'钉钉机器人已连接 DeepSeek Harness。', '钉钉机器人已连接 DeepSeek Harness。',
'', '',
'直接发送文字或图片即可继续当前会话。', '直接发送文字、图片或文件即可继续当前会话。',
'/new 开启一个全新会话', '/new 开启一个全新会话',
'/compact 压缩当前会话的较早上下文', '/compact 压缩当前会话的较早上下文',
'/workspace 工作区绝对路径 切换工作区', '/workspace 工作区绝对路径 切换工作区',
@ -56,12 +67,23 @@ const HELP_TEXT = [
'/steer 补充指令 纠偏当前任务', '/steer 补充指令 纠偏当前任务',
'/status 检查连接状态', '/status 检查连接状态',
'/help 显示本帮助', '/help 显示本帮助',
].join('\n'); ];
function helpText() {
return HELP_TEXT_LINES.map((line) => t(line)).join('\n');
}
function nonEmptyString(value) { function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
} }
function dingtalkFileProviderIds(result) {
const ids = providerMessageIdsFor(result);
const processQueryKey = nonEmptyString(result?.processQueryKey);
if (processQueryKey && !ids.includes(processQueryKey)) ids.push(processQueryKey);
return ids;
}
function safeErrorDiagnostic(error) { function safeErrorDiagnostic(error) {
const chain = []; const chain = [];
const seen = new Set(); const seen = new Set();
@ -89,13 +111,13 @@ function dingtalkImageErrorUserMessage(error) {
while (current && typeof current === 'object' && !seen.has(current)) { while (current && typeof current === 'object' && !seen.has(current)) {
seen.add(current); seen.add(current);
if (current.code === 'image-download-address-failed') { if (current.code === 'image-download-address-failed') {
return '钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。'; return t('钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。');
} }
if (current.code === 'invalid-image-download') { if (current.code === 'invalid-image-download') {
return '钉钉没有返回图片下载地址,请重新发送。'; return t('钉钉没有返回图片下载地址,请重新发送。');
} }
if (current.code === 'image-content-download-failed') { if (current.code === 'image-content-download-failed') {
return '钉钉返回的图片临时地址无法读取,请重新发送。'; return t('钉钉返回的图片临时地址无法读取,请重新发送。');
} }
current = current.cause; current = current.cause;
} }
@ -158,6 +180,7 @@ export function dingtalkInboundMessage(message, {
if (code) imageCodes.push(code); if (code) imageCodes.push(code);
} }
} }
const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
return { return {
content: text, content: text,
images: imageCodes.map((downloadCode, index) => ({ images: imageCodes.map((downloadCode, index) => ({
@ -176,6 +199,21 @@ export function dingtalkInboundMessage(message, {
}); });
}, },
})), })),
files: fileCode ? [{
name: nonEmptyString(content?.fileName ?? content?.file_name) ?? 'file',
load: ({ signal } = {}) => {
if (typeof api?.downloadFile !== 'function') {
throw new Error('DingTalk API does not support file downloads');
}
return api.downloadFile({
clientId,
clientSecret,
robotCode: message?.robotCode,
downloadCode: fileCode,
signal,
});
},
}] : [],
}; };
} }
@ -195,13 +233,47 @@ function cardTarget(message, sender) {
return { type: 'user', userId: sender }; return { type: 'user', userId: sender };
} }
function fileTarget(message, sender, clientId) {
const robotCode = nonEmptyString(message?.robotCode) ?? clientId;
if (String(message?.conversationType) === '2') {
return {
type: 'group',
openConversationId: nonEmptyString(message?.conversationId),
robotCode,
};
}
return { type: 'user', userId: sender, robotCode };
}
function artifactFailureText(fileName, error) {
const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
switch (error?.code) {
case 'artifact-delivery-uncertain':
return t('结果文件「{name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。', { name });
case 'artifact-permission-required':
return t('结果文件「{name}」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。', { name });
case 'artifact-too-large':
return t('结果文件「{name}」超过当前钉钉机器人可发送的文件大小,未发送。', { name });
case 'artifact-rate-limited':
return t('结果文件「{name}」暂时被钉钉限流,未能发送,请稍后重试。', { name });
case 'artifact-provider-rejected':
return t('结果文件「{name}」已生成,但钉钉拒绝了该文件消息,请检查文件类型和机器人文件消息配置。', { name });
case 'artifact-invalid':
case 'artifact-changed':
case 'artifact-unavailable':
return t('结果文件「{name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。', { name });
default:
return t('结果文件「{name}」已生成,但暂时未能通过钉钉发送,请稍后重试。', { name });
}
}
function progressText(update) { function progressText(update) {
if (update?.type === 'text' && nonEmptyString(update.text)) return update.text; if (update?.type === 'text' && nonEmptyString(update.text)) return update.text;
if (update?.type === 'tool') { if (update?.type === 'tool') {
if (update.name === 'web_search') return '_正在搜索网络并整理信息…_'; if (update.name === 'web_search') return t('_正在搜索网络并整理信息…_');
return `_正在使用 ${nonEmptyString(update.name) ?? '工具'}…_`; return t('_正在使用 {name}…_', { name: nonEmptyString(update.name) ?? t('工具') });
} }
return `_${nonEmptyString(update?.text) ?? '正在处理…'}_`; return t('_{text}_', { text: nonEmptyString(update?.text) ?? t('正在处理…') });
} }
function canClaimInteractionReply(message, pending, sender) { function canClaimInteractionReply(message, pending, sender) {
@ -342,7 +414,7 @@ export class DingtalkHarnessBridge {
clientSecret: this.#clientSecret, clientSecret: this.#clientSecret,
}); });
const commandText = nonEmptyString(promptMessage.content) ?? ''; const commandText = nonEmptyString(promptMessage.content) ?? '';
const commandRunner = isControlCommand(commandText) const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
? runControlCommand ? runControlCommand
: (isModelCommand(commandText) : (isModelCommand(commandText)
? runModelCommand ? runModelCommand
@ -359,9 +431,9 @@ export class DingtalkHarnessBridge {
commandRunner, commandRunner,
).catch((error) => { ).catch((error) => {
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return; if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
this.#status.lastError = '钉钉命令处理失败。'; this.#status.lastError = t('钉钉命令处理失败。');
this.#logger.error?.('[dsh-dingtalk] failed to process a command', safeErrorDiagnostic(error)); this.#logger.error?.('[dsh-dingtalk] failed to process a command', safeErrorDiagnostic(error));
return this.#send(sessionWebhook, CARD_ERROR_TEXT).catch(() => undefined); return this.#send(sessionWebhook, t(CARD_ERROR_TEXT)).catch(() => undefined);
}).finally(() => { }).finally(() => {
this.#acceptedMessageIds.delete(messageId); this.#acceptedMessageIds.delete(messageId);
this.#commandTasks.delete(task); this.#commandTasks.delete(task);
@ -396,13 +468,13 @@ export class DingtalkHarnessBridge {
if (!sessionWebhook) { if (!sessionWebhook) {
increment(this.#status, 'messagesRejected'); increment(this.#status, 'messagesRejected');
this.#status.lastRejectedAt = new Date().toISOString(); this.#status.lastRejectedAt = new Date().toISOString();
this.#status.lastError = '钉钉消息没有安全的回复地址。'; this.#status.lastError = t('钉钉消息没有安全的回复地址。');
} }
return true; return true;
}) })
.catch((error) => { .catch((error) => {
if (this.#signal?.aborted) return; if (this.#signal?.aborted) return;
this.#status.lastError = '钉钉审批处理失败。'; this.#status.lastError = t('钉钉审批处理失败。');
this.#logger.error?.('[dsh-dingtalk] failed to process an approval reply', error); this.#logger.error?.('[dsh-dingtalk] failed to process an approval reply', error);
}) })
.finally(() => { .finally(() => {
@ -448,10 +520,28 @@ export class DingtalkHarnessBridge {
releaseMessageId = true, releaseMessageId = true,
alreadyRecorded = false, alreadyRecorded = false,
} = {}) { } = {}) {
let hasSafeReplyRoute = false;
try {
normalizeDingtalkSessionWebhook(message.sessionWebhook);
hasSafeReplyRoute = true;
} catch {
// Keep the existing rejection path without downloading an unusable file.
}
const addressed = String(message.conversationType) !== '2' || message.isInAtList === true;
const preparedMessage = hasSafeReplyRoute && addressed
? prefetchInboundFiles(dingtalkInboundMessage(message, {
api: this.#api,
clientId: this.#clientId,
clientSecret: this.#clientSecret,
}), { signal: this.#signal })
: undefined;
const previous = this.#queues.get(key) ?? Promise.resolve(); const previous = this.#queues.get(key) ?? Promise.resolve();
const current = previous const current = previous
.catch(() => undefined) .catch(() => undefined)
.then(() => this.#process(message, messageId, sender, key, { alreadyRecorded })) .then(() => this.#process(message, messageId, sender, key, {
alreadyRecorded,
preparedMessage,
}))
.finally(() => { .finally(() => {
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId); if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
if (this.#queues.get(key) === current) this.#queues.delete(key); if (this.#queues.get(key) === current) this.#queues.delete(key);
@ -485,6 +575,7 @@ export class DingtalkHarnessBridge {
{ {
signal: this.#signal, signal: this.#signal,
hasImages: hasInboundImages(prompt), hasImages: hasInboundImages(prompt),
hasFiles: hasInboundFiles(prompt),
pendingInteraction: this.#pendingInteractions.has(key) pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key), || this.#approvals.hasPending(key),
control: { owner: this, key }, control: { owner: this, key },
@ -502,7 +593,10 @@ export class DingtalkHarnessBridge {
this.#status.lastError = null; this.#status.lastError = null;
} }
async #process(message, messageId, sender, key, { alreadyRecorded = false } = {}) { async #process(message, messageId, sender, key, {
alreadyRecorded = false,
preparedMessage,
} = {}) {
this.#signal?.throwIfAborted(); this.#signal?.throwIfAborted();
if (!alreadyRecorded) { if (!alreadyRecorded) {
if (this.#state.hasSeen(messageId)) return; if (this.#state.hasSeen(messageId)) return;
@ -522,42 +616,43 @@ export class DingtalkHarnessBridge {
} catch { } catch {
increment(this.#status, 'messagesRejected'); increment(this.#status, 'messagesRejected');
this.#status.lastRejectedAt = new Date().toISOString(); this.#status.lastRejectedAt = new Date().toISOString();
this.#status.lastError = '钉钉消息没有安全的回复地址。'; this.#status.lastError = t('钉钉消息没有安全的回复地址。');
return; return;
} }
const promptMessage = dingtalkInboundMessage(message, { const promptMessage = preparedMessage ?? dingtalkInboundMessage(message, {
api: this.#api, api: this.#api,
clientId: this.#clientId, clientId: this.#clientId,
clientSecret: this.#clientSecret, clientSecret: this.#clientSecret,
}); });
const text = promptMessage.content; const text = promptMessage.content;
const hasImages = hasInboundImages(promptMessage); const hasImages = hasInboundImages(promptMessage);
const hasFiles = hasInboundFiles(promptMessage);
const isPlainText = String(message?.msgtype).toLowerCase() === 'text'; const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
let cardStream = null; let cardStream = null;
let cardStarted = false; let cardStarted = false;
try { try {
if (!text && !hasImages) { if (!text && !hasImages && !hasFiles) {
await this.#send(sessionWebhook, '目前支持文字和图片消息。'); await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'));
return; return;
} }
const command = text.toLowerCase(); const command = text.toLowerCase();
if (isPlainText && !hasImages && command === '/help') { if (isPlainText && !hasImages && !hasFiles && command === '/help') {
await this.#send(sessionWebhook, HELP_TEXT); await this.#send(sessionWebhook, helpText());
return; return;
} }
if (isPlainText && !hasImages && command === '/status') { if (isPlainText && !hasImages && !hasFiles && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal }); await this.#harness.ensureRunning({ signal: this.#signal });
await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。'); await this.#send(sessionWebhook, t('钉钉机器人与 DeepSeek Harness 连接正常。'));
return; return;
} }
if (isPlainText && !hasImages && command === '/new') { if (isPlainText && !hasImages && !hasFiles && command === '/new') {
await this.#state.clearSession(key); await this.#state.clearSession(key);
await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。'); await this.#send(sessionWebhook, t('已开启新会话。请发送你的问题。'));
return; return;
} }
const workspaceCommand = isPlainText && !hasImages const workspaceCommand = isPlainText && !hasImages && !hasFiles
? await runWorkspaceCommand(text, this.#harness, key) ? await runWorkspaceCommand(text, this.#harness, key)
: null; : null;
if (workspaceCommand) { if (workspaceCommand) {
@ -566,7 +661,7 @@ export class DingtalkHarnessBridge {
} }
return; return;
} }
const compactCommand = isPlainText && !hasImages const compactCommand = isPlainText && !hasImages && !hasFiles
? await runCompactCommand( ? await runCompactCommand(
text, text,
this.#harness, this.#harness,
@ -594,9 +689,9 @@ export class DingtalkHarnessBridge {
signal: this.#signal, signal: this.#signal,
logger: this.#logger, logger: this.#logger,
}); });
cardStarted = await cardStream.start(CARD_INITIAL_TEXT); cardStarted = await cardStream.start(t(CARD_INITIAL_TEXT));
} }
const { answer } = await askInWorkspaceSession({ const { answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness, harness: this.#harness,
state: this.#state, state: this.#state,
key, key,
@ -617,26 +712,59 @@ export class DingtalkHarnessBridge {
requiresMention: String(message.conversationType) === '2', requiresMention: String(message.conversationType) === '2',
}), }),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution), onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files: promptMessage.files,
}, },
}); });
const streamed = cardStarted && await cardStream.finish(answer); const answerText = typeof answer === 'string' && answer.trim()
if (!streamed) await this.#send(sessionWebhook, answer); ? answer
: artifacts.length > 0 ? t('结果文件已生成。') : answer;
let textDeliveryError = null;
let textReceipt = null;
let streamed = false;
try {
streamed = cardStarted && await cardStream.finish(answerText);
if (streamed) {
textReceipt = createDeliveryReceipt({
deliveryId: messageId,
presentation: 'dingtalk-card',
});
} else {
textReceipt = createDeliveryReceipt({
deliveryId: messageId,
presentation: 'dingtalk-text',
providerMessageIds: await this.#send(sessionWebhook, answerText),
});
}
} catch (error) {
textDeliveryError = error;
}
const delivery = await this.#deliverArtifacts(
fileTarget(message, sender, this.#clientId),
sessionWebhook,
messageId,
artifacts,
textReceipt,
);
if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
increment(this.#status, 'messagesReplied'); increment(this.#status, 'messagesReplied');
this.#status.lastReplyAt = new Date().toISOString(); this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null; this.#status.lastError = null;
return delivery.receipt;
} catch (error) { } catch (error) {
if (error?.code === 'turn-stopped') { if (error?.code === 'turn-stopped') {
if (cardStarted) await cardStream.finish('已停止。').catch(() => undefined); if (cardStarted) await cardStream.finish(t('已停止。')).catch(() => undefined);
return; return;
} }
if (this.#signal?.aborted) return; if (this.#signal?.aborted) return;
this.#status.lastError = '钉钉消息处理失败。'; this.#status.lastError = t('钉钉消息处理失败。');
this.#logger.error?.( this.#logger.error?.(
'[dsh-dingtalk] failed to process an inbound message', '[dsh-dingtalk] failed to process an inbound message',
safeErrorDiagnostic(error), safeErrorDiagnostic(error),
); );
try { try {
const errorText = dingtalkImageErrorUserMessage(error) ?? CARD_ERROR_TEXT; const errorText = inboundFileUserMessage(error)
?? dingtalkImageErrorUserMessage(error)
?? t(CARD_ERROR_TEXT);
const streamed = cardStarted && await cardStream.finish(errorText); const streamed = cardStarted && await cardStream.finish(errorText);
if (!streamed) await this.#send(sessionWebhook, errorText); if (!streamed) await this.#send(sessionWebhook, errorText);
} catch { } catch {
@ -674,14 +802,14 @@ export class DingtalkHarnessBridge {
} catch { } catch {
increment(this.#status, 'messagesRejected'); increment(this.#status, 'messagesRejected');
this.#status.lastRejectedAt = new Date().toISOString(); this.#status.lastRejectedAt = new Date().toISOString();
this.#status.lastError = '钉钉消息没有安全的回复地址。'; this.#status.lastError = t('钉钉消息没有安全的回复地址。');
return; return;
} }
const text = message?.msgtype === 'text' ? nonEmptyString(message?.text?.content) : null; const text = message?.msgtype === 'text' ? nonEmptyString(message?.text?.content) : null;
if (!text) { if (!text) {
try { try {
await this.#send(sessionWebhook, '请用文字回答当前问题。'); await this.#send(sessionWebhook, t('请用文字回答当前问题。'));
} catch { } catch {
this.#logger.error?.('[dsh-dingtalk] failed to reject a non-text interaction reply'); this.#logger.error?.('[dsh-dingtalk] failed to reject a non-text interaction reply');
} }
@ -692,7 +820,7 @@ export class DingtalkHarnessBridge {
if (!pending || pending !== expected || pending.submitting) { if (!pending || pending !== expected || pending.submitting) {
if (claimed && (!pending || pending !== expected)) { if (claimed && (!pending || pending !== expected)) {
try { try {
await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT); await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
} catch { } catch {
this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice'); this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
} }
@ -708,7 +836,7 @@ export class DingtalkHarnessBridge {
try { try {
await this.#presentInteraction(pending); await this.#presentInteraction(pending);
} catch { } catch {
this.#status.lastError = '钉钉交互问题发送失败。'; this.#status.lastError = t('钉钉交互问题发送失败。');
this.#logger.error?.('[dsh-dingtalk] failed to retry an interaction question'); this.#logger.error?.('[dsh-dingtalk] failed to retry an interaction question');
pending.interaction.reconnect?.(); pending.interaction.reconnect?.();
} }
@ -727,7 +855,7 @@ export class DingtalkHarnessBridge {
try { try {
await this.#presentInteraction(pending); await this.#presentInteraction(pending);
} catch { } catch {
this.#status.lastError = '钉钉交互问题发送失败。'; this.#status.lastError = t('钉钉交互问题发送失败。');
this.#logger.error?.('[dsh-dingtalk] failed to send the next interaction question'); this.#logger.error?.('[dsh-dingtalk] failed to send the next interaction question');
pending.interaction.reconnect?.(); pending.interaction.reconnect?.();
} }
@ -751,7 +879,7 @@ export class DingtalkHarnessBridge {
if (error?.code === 'interaction-not-pending') { if (error?.code === 'interaction-not-pending') {
this.#clearPendingInteraction(key, pending.interactionId); this.#clearPendingInteraction(key, pending.interactionId);
try { try {
await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT); await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
} catch { } catch {
this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice'); this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
} }
@ -760,10 +888,10 @@ export class DingtalkHarnessBridge {
pending.submitting = false; pending.submitting = false;
pending.answers.pop(); pending.answers.pop();
pending.index -= 1; pending.index -= 1;
this.#status.lastError = '回答提交失败。'; this.#status.lastError = t('回答提交失败。');
this.#logger.error?.('[dsh-dingtalk] failed to answer a Harness interaction'); this.#logger.error?.('[dsh-dingtalk] failed to answer a Harness interaction');
try { try {
await this.#send(sessionWebhook, '回答提交失败,请重新发送当前问题的答案。'); await this.#send(sessionWebhook, t('回答提交失败,请重新发送当前问题的答案。'));
} catch { } catch {
this.#logger.error?.('[dsh-dingtalk] failed to send an interaction retry notice'); this.#logger.error?.('[dsh-dingtalk] failed to send an interaction retry notice');
} }
@ -809,7 +937,7 @@ export class DingtalkHarnessBridge {
}, },
}); });
try { try {
await this.#send(sessionWebhook, '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'); await this.#send(sessionWebhook, t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'));
} catch { } catch {
this.#logger.error?.('[dsh-dingtalk] failed to send an interaction recovery notice'); this.#logger.error?.('[dsh-dingtalk] failed to send an interaction recovery notice');
} }
@ -895,7 +1023,7 @@ export class DingtalkHarnessBridge {
return; return;
} }
try { try {
await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT); await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
} catch { } catch {
this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice'); this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
} }
@ -940,16 +1068,55 @@ export class DingtalkHarnessBridge {
} }
async #send(sessionWebhook, text) { async #send(sessionWebhook, text) {
const providerMessageIds = [];
for (const chunk of splitDingtalkText(text, this.#maxMessageChars)) { for (const chunk of splitDingtalkText(text, this.#maxMessageChars)) {
this.#signal?.throwIfAborted(); this.#signal?.throwIfAborted();
await this.#api.sendText({ const result = await this.#api.sendText({
clientId: this.#clientId, clientId: this.#clientId,
clientSecret: this.#clientSecret, clientSecret: this.#clientSecret,
sessionWebhook, sessionWebhook,
text: chunk, text: chunk,
signal: this.#signal, signal: this.#signal,
}); });
providerMessageIds.push(...providerMessageIdsFor(result));
} }
return providerMessageIds;
}
async #deliverArtifacts(target, sessionWebhook, replyTo, artifacts, baseReceipt) {
const sendArtifact = async (method, file) => dingtalkFileProviderIds(
await this.#api[method]({
clientId: this.#clientId,
clientSecret: this.#clientSecret,
target,
file,
signal: this.#signal,
}),
);
const delivery = await deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: replyTo,
aggregatePresentation: baseReceipt ? 'dingtalk-text-and-files' : 'dingtalk-files',
channelKey: 'dingtalk',
signal: this.#signal,
sendImage: typeof this.#api.sendImage === 'function'
? (file) => sendArtifact('sendImage', file)
: undefined,
sendFile: typeof this.#api.sendFile === 'function'
? (file) => sendArtifact('sendFile', file)
: undefined,
sendFailureNotice: (artifact, error) => this.#send(
sessionWebhook,
artifactFailureText(artifact?.fileName, error),
),
logger: this.#logger,
});
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
+ delivery.artifactsSent;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
+ delivery.artifactSendErrors;
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
} }
} }

View file

@ -1,3 +1,5 @@
import { t } from '../shared/i18n.mjs';
const DEFAULT_UPDATE_INTERVAL_MS = 500; const DEFAULT_UPDATE_INTERVAL_MS = 500;
const FAILURE_TEXT = '消息处理失败,请稍后重试。'; const FAILURE_TEXT = '消息处理失败,请稍后重试。';
@ -100,7 +102,7 @@ export function createDingTalkCardStream({
if (!cleanupPromise) { if (!cleanupPromise) {
cleanupPromise = api.failAiCard({ cleanupPromise = api.failAiCard({
...cardRequest, ...cardRequest,
text: FAILURE_TEXT, text: t(FAILURE_TEXT),
signal: AbortSignal.timeout(5_000), signal: AbortSignal.timeout(5_000),
}).then( }).then(
() => true, () => true,

View file

@ -10,6 +10,7 @@ import {
connectionTestMessage, connectionTestMessage,
connectionTestTargetUnavailable, connectionTestTargetUnavailable,
} from '../shared/connection-test.mjs'; } from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']); const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']); const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
@ -84,7 +85,7 @@ function normalizePendingSender(value) {
return { return {
requestId: opaqueRequestId, requestId: opaqueRequestId,
staffId, staffId,
displayName: cleanString(value.displayName ?? value.senderName ?? value.senderNick) ?? '钉钉用户', displayName: cleanString(value.displayName ?? value.senderName ?? value.senderNick) ?? t('钉钉用户'),
requestedAt: cleanString(value.requestedAt), requestedAt: cleanString(value.requestedAt),
}; };
} }
@ -114,7 +115,7 @@ function publicPendingSender(sender) {
function publicApprovedSender(sender) { function publicApprovedSender(sender) {
return { return {
senderKey: sender.senderKey, senderKey: sender.senderKey,
displayName: cleanString(sender.displayName) ?? '钉钉用户', displayName: cleanString(sender.displayName) ?? t('钉钉用户'),
senderIdMasked: maskDingtalkSenderId(sender.staffId), senderIdMasked: maskDingtalkSenderId(sender.staffId),
approvedAt: cleanString(sender.approvedAt), approvedAt: cleanString(sender.approvedAt),
}; };
@ -206,7 +207,7 @@ export class DingtalkController {
if (!clientSecret) { if (!clientSecret) {
this.#errors.set( this.#errors.set(
latest.botId, latest.botId,
safeError('missing-secret', '钉钉机器人凭据缺失,请移除后重新扫码。'), safeError('missing-secret', t('钉钉机器人凭据缺失,请移除后重新扫码。')),
); );
this.#touch(); this.#touch();
return; return;
@ -217,7 +218,7 @@ export class DingtalkController {
} catch { } catch {
this.#errors.set( this.#errors.set(
latest.botId, latest.botId,
safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'), safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
); );
this.#logger.warn?.(`[dsh-dingtalk] bot ${latest.botId} failed to initialize`); this.#logger.warn?.(`[dsh-dingtalk] bot ${latest.botId} failed to initialize`);
} }
@ -270,10 +271,10 @@ export class DingtalkController {
} catch (error) { } catch (error) {
if (record.controller.signal.aborted || error?.name === 'AbortError') { if (record.controller.signal.aborted || error?.name === 'AbortError') {
record.state = 'cancelled'; record.state = 'cancelled';
record.error = safeError('cancelled', '扫码接入已取消。'); record.error = safeError('cancelled', t('扫码接入已取消。'));
} else { } else {
record.state = 'failed'; record.state = 'failed';
record.error = safeError('qr-start-failed', '无法生成钉钉二维码,请稍后重试。'); record.error = safeError('qr-start-failed', t('无法生成钉钉二维码,请稍后重试。'));
} }
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#touch(); this.#touch();
@ -319,7 +320,7 @@ export class DingtalkController {
} catch { } catch {
this.#errors.set( this.#errors.set(
identity.botId, identity.botId,
safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'), safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
); );
this.#logger.warn?.('[dsh-dingtalk] credential-bound bot saved but its connection is not ready'); this.#logger.warn?.('[dsh-dingtalk] credential-bound bot saved but its connection is not ready');
} }
@ -337,7 +338,7 @@ export class DingtalkController {
} }
if (nowFrom(this.#clock) >= record.expiresAt) { if (nowFrom(this.#clock) >= record.expiresAt) {
record.state = 'expired'; record.state = 'expired';
record.error = safeError('expired', '二维码已过期,请重新生成。'); record.error = safeError('expired', t('二维码已过期,请重新生成。'));
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#touch(); this.#touch();
return publicAttempt(record); return publicAttempt(record);
@ -360,7 +361,7 @@ export class DingtalkController {
record.controller.abort(); record.controller.abort();
await record.pollTask?.catch(() => undefined); await record.pollTask?.catch(() => undefined);
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled'; if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
record.error ??= safeError('cancelled', '扫码接入已取消。'); record.error ??= safeError('cancelled', t('扫码接入已取消。'));
} }
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#touch(); this.#touch();
@ -380,7 +381,7 @@ export class DingtalkController {
} catch (error) { } catch (error) {
this.#errors.set( this.#errors.set(
botId, botId,
safeError('connection-failed', '钉钉连接仍未就绪,请稍后重试。'), safeError('connection-failed', t('钉钉连接仍未就绪,请稍后重试。')),
); );
throw error; throw error;
} finally { } finally {
@ -396,10 +397,10 @@ export class DingtalkController {
return this.#withBotTransition(botId, async () => { return this.#withBotTransition(botId, async () => {
const runtime = this.#runtimes.get(botId); const runtime = this.#runtimes.get(botId);
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') { if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
throw connectionTestTargetUnavailable('钉钉机器人'); throw connectionTestTargetUnavailable(t('钉钉机器人'));
} }
return runtime.sendConnectionTest(connectionTestMessage( return runtime.sendConnectionTest(connectionTestMessage(
`钉钉机器人(${maskDingtalkClientId(config.clientId)})`, t('钉钉机器人({clientId})', { clientId: maskDingtalkClientId(config.clientId) }),
)); ));
}); });
} }
@ -501,14 +502,14 @@ export class DingtalkController {
connected, connected,
configured: true, configured: true,
bot: { bot: {
name: '钉钉机器人', name: t('钉钉机器人'),
clientIdMasked: maskDingtalkClientId(config.clientId), clientIdMasked: maskDingtalkClientId(config.clientId),
}, },
health: { health: {
status: connected ? 'healthy' : accountError ? 'error' : 'offline', status: connected ? 'healthy' : accountError ? 'error' : 'offline',
summary: connected summary: connected
? '钉钉 Stream 消息连接运行正常' ? t('钉钉 Stream 消息连接运行正常')
: accountError?.message ?? '钉钉消息连接当前离线', : accountError?.message ?? t('钉钉消息连接当前离线'),
lastCheckedAt: currentStatus.lastCheckedAt ?? null, lastCheckedAt: currentStatus.lastCheckedAt ?? null,
}, },
stats: { stats: {
@ -580,32 +581,32 @@ export class DingtalkController {
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
} else if (state === 'EXPIRED') { } else if (state === 'EXPIRED') {
record.state = 'expired'; record.state = 'expired';
record.error = safeError('expired', '二维码已过期,请重新生成。'); record.error = safeError('expired', t('二维码已过期,请重新生成。'));
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
} else if (state === 'FAIL') { } else if (state === 'FAIL') {
record.state = 'failed'; record.state = 'failed';
record.error = safeError('authorization-failed', '钉钉未完成机器人授权,请重新扫码。'); record.error = safeError('authorization-failed', t('钉钉未完成机器人授权,请重新扫码。'));
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
} else { } else {
record.state = 'pending'; record.state = 'pending';
record.error = safeError('poll-pending', '钉钉授权状态暂时不可用,正在重试。'); record.error = safeError('poll-pending', t('钉钉授权状态暂时不可用,正在重试。'));
} }
} catch (error) { } catch (error) {
if (record.controller.signal.aborted || error?.name === 'AbortError') { if (record.controller.signal.aborted || error?.name === 'AbortError') {
record.state = 'cancelled'; record.state = 'cancelled';
record.error = safeError('cancelled', '扫码接入已取消。'); record.error = safeError('cancelled', t('扫码接入已取消。'));
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
} else if (record.state === 'connecting') { } else if (record.state === 'connecting') {
record.state = 'failed'; record.state = 'failed';
record.error = safeError( record.error = safeError(
'activation-failed', 'activation-failed',
'钉钉已授权,但无法安全保存接入配置。', t('钉钉已授权,但无法安全保存接入配置。'),
); );
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#logger.error?.('[dsh-dingtalk] bot activation failed'); this.#logger.error?.('[dsh-dingtalk] bot activation failed');
} else { } else {
record.state = 'pending'; record.state = 'pending';
record.error = safeError('poll-failed', '钉钉授权查询暂时失败,正在重试。'); record.error = safeError('poll-failed', t('钉钉授权查询暂时失败,正在重试。'));
} }
} finally { } finally {
this.#touch(); this.#touch();
@ -660,7 +661,7 @@ export class DingtalkController {
} }
this.#errors.set( this.#errors.set(
identity.botId, identity.botId,
safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'), safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
); );
this.#logger.warn?.('[dsh-dingtalk] authorized bot saved but its connection is not ready'); this.#logger.warn?.('[dsh-dingtalk] authorized bot saved but its connection is not ready');
} }
@ -681,7 +682,7 @@ export class DingtalkController {
await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined); await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined);
this.#errors.set( this.#errors.set(
previousConfig.botId, previousConfig.botId,
safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'), safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
); );
throw error; throw error;
} finally { } finally {

View file

@ -4,6 +4,7 @@ import {
DingtalkHarnessBridge, DingtalkHarnessBridge,
} from './dingtalk-bridge.mjs'; } from './dingtalk-bridge.mjs';
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs'; import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
function nonEmptyString(value) { function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
@ -275,7 +276,7 @@ export class DingtalkRuntime {
? JSON.parse(response.data) ? JSON.parse(response.data)
: response?.data; : response?.data;
} catch { } catch {
this.#status.lastError = '钉钉消息格式无效。'; this.#status.lastError = t('钉钉消息格式无效。');
this.#logger.warn?.('[dsh-dingtalk] ignored an invalid callback payload'); this.#logger.warn?.('[dsh-dingtalk] ignored an invalid callback payload');
return; return;
} }
@ -284,7 +285,7 @@ export class DingtalkRuntime {
await bridge.accept(message); await bridge.accept(message);
}).catch(() => { }).catch(() => {
if (signal.aborted || this.#bridge !== bridge) return; if (signal.aborted || this.#bridge !== bridge) return;
this.#status.lastError = '钉钉消息处理失败。'; this.#status.lastError = t('钉钉消息处理失败。');
this.#logger.error?.('[dsh-dingtalk] callback processing failed'); this.#logger.error?.('[dsh-dingtalk] callback processing failed');
}).finally(() => this.#callbackTasks.delete(task)); }).finally(() => this.#callbackTasks.delete(task));
this.#callbackTasks.add(task); this.#callbackTasks.add(task);
@ -352,7 +353,7 @@ export class DingtalkRuntime {
return sendRememberedConnectionTest({ return sendRememberedConnectionTest({
state: this.#state, state: this.#state,
text, text,
channelLabel: '钉钉机器人', channelLabel: t('钉钉机器人'),
send: async ({ sessionWebhook }, content) => { send: async ({ sessionWebhook }, content) => {
if (!this.#status.ready || !this.#abortController) { if (!this.#status.ready || !this.#abortController) {
throw new Error('DingTalk runtime is not connected'); throw new Error('DingTalk runtime is not connected');

View file

@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { t } from '../shared/i18n.mjs';
const EMPTY_STATE = Object.freeze({ const EMPTY_STATE = Object.freeze({
version: 1, version: 1,
sessions: {}, sessions: {},
@ -14,7 +16,7 @@ function nonEmptyString(value) {
} }
function displayName(value) { function displayName(value) {
return (nonEmptyString(value) ?? '钉钉用户').slice(0, 100); return (nonEmptyString(value) ?? t('钉钉用户')).slice(0, 100);
} }
function normalizePendingSender(value, fallbackRequestId) { function normalizePendingSender(value, fallbackRequestId) {

View file

@ -3,6 +3,7 @@ import {
maskPlatformId, maskPlatformId,
TokenBotConfigStore, TokenBotConfigStore,
} from '../shared/token-config-store.mjs'; } from '../shared/token-config-store.mjs';
import { t } from '../shared/i18n.mjs';
const IDENTITY_OPTIONS = Object.freeze({ const IDENTITY_OPTIONS = Object.freeze({
botPrefix: 'discord', botPrefix: 'discord',
@ -14,7 +15,7 @@ export function deriveDiscordBotIdentity(platformId) {
} }
export function maskDiscordBotId(platformId) { export function maskDiscordBotId(platformId) {
return maskPlatformId(platformId, 'Discord机器人'); return maskPlatformId(platformId, t('Discord机器人'));
} }
export class DiscordConfigStore extends TokenBotConfigStore { export class DiscordConfigStore extends TokenBotConfigStore {

View file

@ -1,4 +1,10 @@
import { createHash } from 'node:crypto';
import { t } from '../shared/i18n.mjs';
const DEFAULT_BASE_URL = 'https://discord.com/api/v10/'; const DEFAULT_BASE_URL = 'https://discord.com/api/v10/';
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
const DISCORD_PERMISSION_ERRORS = new Set([50001, 50013]);
const DISCORD_TOO_LARGE_ERRORS = new Set([40005]);
function cleanString(value) { function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
@ -9,6 +15,58 @@ function requestSignal(signal, timeoutMs) {
return signal ? AbortSignal.any([signal, timeout]) : timeout; return signal ? AbortSignal.any([signal, timeout]) : timeout;
} }
function abortReason(signal) {
return signal?.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted', 'AbortError');
}
function positiveTimeout(value, name) {
if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
return value;
}
function preserveProviderMetadata(target, source) {
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
if (source?.retry_after !== undefined) {
target.retry_after = source.retry_after;
target.retryAfter = source.retry_after;
}
if (Number.isInteger(source?.status)) target.status = source.status;
return target;
}
function discordArtifactProviderError(cause) {
const providerCode = Number(cause?.providerCode);
const status = Number(cause?.status);
const message = cleanString(cause?.message) ?? '';
let code = 'artifact-provider-rejected';
let summary = 'Discord rejected the attachment.';
if (status === 401 || status === 403 || DISCORD_PERMISSION_ERRORS.has(providerCode)) {
code = 'artifact-permission-required';
summary = 'Discord denied permission to send the attachment.';
} else if (status === 413 || DISCORD_TOO_LARGE_ERRORS.has(providerCode)
|| /(?:request|attachment|file).{0,24}too large/i.test(message)) {
code = 'artifact-too-large';
summary = 'The attachment exceeds Discord\'s size limit.';
} else if (status === 429) {
code = 'artifact-rate-limited';
summary = 'Discord rate-limited attachment delivery.';
} else if (status >= 500) {
code = 'artifact-delivery-uncertain';
summary = 'Discord attachment delivery result is uncertain.';
}
const error = new Error(summary, { cause });
error.code = code;
return preserveProviderMetadata(error, cause);
}
function uncertainDiscordDelivery(cause) {
const error = new Error('Discord attachment delivery result is uncertain', { cause });
error.code = 'artifact-delivery-uncertain';
return preserveProviderMetadata(error, cause);
}
function delay(ms, signal) { function delay(ms, signal) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (signal?.aborted) { if (signal?.aborted) {
@ -39,13 +97,20 @@ export class DiscordApi {
#token; #token;
#fetch; #fetch;
#baseUrl; #baseUrl;
#fileUploadTimeoutMs;
constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) { constructor({
token,
fetchImpl = fetch,
baseUrl = DEFAULT_BASE_URL,
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
}) {
if (!validDiscordToken(token)) throw new TypeError('Discord Bot Token is invalid'); if (!validDiscordToken(token)) throw new TypeError('Discord Bot Token is invalid');
if (typeof fetchImpl !== 'function') throw new TypeError('DiscordApi requires fetch'); if (typeof fetchImpl !== 'function') throw new TypeError('DiscordApi requires fetch');
this.#token = token.trim(); this.#token = token.trim();
this.#fetch = fetchImpl; this.#fetch = fetchImpl;
this.#baseUrl = new URL(baseUrl); this.#baseUrl = new URL(baseUrl);
this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
} }
getCurrentUser(options = {}) { getCurrentUser(options = {}) {
@ -56,6 +121,29 @@ export class DiscordApi {
return this.#request('gateway/bot', { ...options, method: 'GET' }); return this.#request('gateway/bot', { ...options, method: 'GET' });
} }
getChannel({ channelId, signal } = {}) {
return this.#request(`channels/${snowflake(channelId, 'channel id')}`, {
method: 'GET',
signal,
});
}
startThreadFromMessage({ channelId, messageId, name, signal } = {}) {
const threadName = cleanString(name);
if (!threadName || [...threadName].length > 100) {
throw new TypeError('Discord thread name must contain 1-100 characters');
}
if (signal?.aborted) throw abortReason(signal);
return this.#request(
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}/threads`,
{
method: 'POST',
signal,
body: { name: threadName },
},
);
}
createMessage({ channelId, content, replyToMessageId, signal }) { createMessage({ channelId, content, replyToMessageId, signal }) {
return this.#request(`channels/${snowflake(channelId, 'channel id')}/messages`, { return this.#request(`channels/${snowflake(channelId, 'channel id')}/messages`, {
method: 'POST', method: 'POST',
@ -74,6 +162,54 @@ export class DiscordApi {
}); });
} }
async createFileMessage({ channelId, file, replyToMessageId, signal }) {
if (!file || typeof file !== 'object'
|| typeof file.fileName !== 'string' || !file.fileName
|| !Buffer.isBuffer(file.bytes)) {
throw new TypeError('A Discord attachment is required');
}
const deliverySeed = cleanString(file.deliveryKey) ?? cleanString(file.artifactId);
const nonce = deliverySeed
? createHash('sha256').update(deliverySeed).digest('hex').slice(0, 25)
: undefined;
const payload = new FormData();
payload.append('payload_json', JSON.stringify({
allowed_mentions: { parse: [], replied_user: false },
attachments: [{ id: 0, filename: file.fileName }],
...(nonce ? { nonce, enforce_nonce: true } : {}),
...(replyToMessageId ? {
message_reference: {
message_id: snowflake(replyToMessageId, 'message id'),
channel_id: snowflake(channelId, 'channel id'),
fail_if_not_exists: false,
},
} : {}),
}));
payload.append(
'files[0]',
new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
file.fileName,
);
const targetChannelId = snowflake(channelId, 'channel id');
if (signal?.aborted) throw abortReason(signal);
const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
try {
return await this.#request(`channels/${targetChannelId}/messages`, {
method: 'POST',
signal: uploadSignal,
timeoutMs: this.#fileUploadTimeoutMs,
body: payload,
multipart: true,
});
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
if (error?.code?.startsWith?.('discord-')) {
throw discordArtifactProviderError(error);
}
throw uncertainDiscordDelivery(error);
}
}
editMessage({ channelId, messageId, content, signal }) { editMessage({ channelId, messageId, content, signal }) {
return this.#request( return this.#request(
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`, `channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`,
@ -100,6 +236,7 @@ export class DiscordApi {
timeoutMs = 15_000, timeoutMs = 15_000,
expectBody = true, expectBody = true,
retry = true, retry = true,
multipart = false,
}) { }) {
let response; let response;
try { try {
@ -107,10 +244,10 @@ export class DiscordApi {
method, method,
headers: { headers: {
authorization: `Bot ${this.#token}`, authorization: `Bot ${this.#token}`,
'content-type': 'application/json', ...(multipart ? {} : { 'content-type': 'application/json' }),
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 1.0.2)', 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 1.1.0)',
}, },
...(body === undefined ? {} : { body: JSON.stringify(body) }), ...(body === undefined ? {} : { body: multipart ? body : JSON.stringify(body) }),
signal: requestSignal(signal, timeoutMs), signal: requestSignal(signal, timeoutMs),
redirect: 'error', redirect: 'error',
}); });
@ -124,17 +261,32 @@ export class DiscordApi {
try { try {
parsed = await response.json(); parsed = await response.json();
} catch { } catch {
if (expectBody) throw new Error(`Discord ${method} returned invalid JSON`); if (expectBody) {
const error = new Error(`Discord ${method} returned invalid JSON`);
error.status = response?.status;
throw error;
}
} }
} }
if (response.status === 429 && retry) { if (response.status === 429 && retry) {
const retryAfterMs = Math.min(10_000, Math.max(50, Number(parsed?.retry_after) * 1_000 || 1_000)); const retryAfterMs = Math.min(10_000, Math.max(50, Number(parsed?.retry_after) * 1_000 || 1_000));
await delay(retryAfterMs, signal); await delay(retryAfterMs, signal);
return this.#request(path, { method, body, signal, timeoutMs, expectBody, retry: false }); return this.#request(path, {
method, body, signal, timeoutMs, expectBody, retry: false, multipart,
});
} }
if (!response.ok) { if (!response.ok) {
const error = new Error(cleanString(parsed?.message) ?? `Discord API failed with HTTP ${response.status}`); const error = new Error(cleanString(parsed?.message) ?? `Discord API failed with HTTP ${response.status}`);
error.code = `discord-${response.status}`; error.code = `discord-${response.status}`;
error.status = response.status;
if (Number.isInteger(parsed?.code) || typeof parsed?.code === 'string') {
error.providerCode = parsed.code;
}
const retryAfter = Number(parsed?.retry_after);
if (Number.isFinite(retryAfter) && retryAfter >= 0) {
error.retry_after = retryAfter;
error.retryAfter = retryAfter;
}
throw error; throw error;
} }
return expectBody ? parsed : null; return expectBody ? parsed : null;
@ -147,7 +299,7 @@ export async function inspectDiscordToken(token, options = {}) {
if (!bot?.id || bot?.bot !== true) throw new Error('Discord token does not belong to a bot'); if (!bot?.id || bot?.bot !== true) throw new Error('Discord token does not belong to a bot');
return { return {
platformId: String(bot.id), platformId: String(bot.id),
name: cleanString(bot.global_name) ?? cleanString(bot.username) ?? 'Discord机器人', name: cleanString(bot.global_name) ?? cleanString(bot.username) ?? t('Discord机器人'),
username: cleanString(bot.username), username: cleanString(bot.username),
}; };
} }

View file

@ -1,10 +1,19 @@
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs'; import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
import { fetchFileStream } from '../shared/file-download.mjs';
import { fetchImageBuffer } from '../shared/image-prompt.mjs'; import { fetchImageBuffer } from '../shared/image-prompt.mjs';
import { t } from '../shared/i18n.mjs';
import { DiscordApi } from './discord-api.mjs'; import { DiscordApi } from './discord-api.mjs';
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs'; import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12); const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12) | (1 << 15);
const THREAD_RECOVERY_TIMEOUT_MS = 5_000;
const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]); const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
const GUILD_TEXT = 0;
const GUILD_ANNOUNCEMENT = 5;
const ANNOUNCEMENT_THREAD = 10;
const PUBLIC_THREAD = 11;
const PRIVATE_THREAD = 12;
const THREAD_TYPES = new Set([ANNOUNCEMENT_THREAD, PUBLIC_THREAD, PRIVATE_THREAD]);
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']); const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
const IMAGE_FILE_TYPES = new Map([ const IMAGE_FILE_TYPES = new Map([
['.jpg', 'image/jpeg'], ['.jpg', 'image/jpeg'],
@ -39,12 +48,12 @@ function eventData(event) {
function gatewayCloseError(code) { function gatewayCloseError(code) {
if (code === 4004) { if (code === 4004) {
const error = new Error('Discord Bot Token 无效,请重新填写。'); const error = new Error(t('Discord Bot Token 无效,请重新填写。'));
error.code = 'discord-401'; error.code = 'discord-401';
return error; return error;
} }
if (code === 4013 || code === 4014) { if (code === 4013 || code === 4014) {
const error = new Error('Discord Gateway Intents 配置不正确,请检查 Developer Portal 的 Bot 设置。'); const error = new Error(t('Discord Gateway Intents 配置不正确,请检查 Developer Portal 的 Bot 设置。'));
error.code = 'discord-intents'; error.code = 'discord-intents';
return error; return error;
} }
@ -58,6 +67,94 @@ function stripBotMention(text, botId) {
return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim(); return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
} }
function cleanThreadName(message, botId) {
const name = stripBotMention(message?.content ?? '', botId).replace(/\s+/g, ' ').trim()
|| 'DeepSeek Harness';
return [...name].slice(0, 100).join('');
}
function isThreadChannel(channel) {
return THREAD_TYPES.has(Number(channel?.type));
}
function isThreadFromMessage(channel, message) {
return isThreadChannel(channel)
&& String(channel?.id ?? '') === String(message?.id ?? '')
&& String(channel?.parent_id ?? '') === String(message?.channel_id ?? '');
}
function rememberChannel(channel, callback) {
if (channel?.id) callback?.(channel);
return channel;
}
function withConversationRoute(normalized, channel, botId, {
fromSourceMessage = false,
fallback = null,
notice = null,
} = {}) {
const channelId = String(channel?.id ?? normalized.conversationId);
const thread = isThreadChannel(channel);
const managed = thread && String(channel?.owner_id ?? '') === String(botId);
const parentId = thread && channel?.parent_id ? String(channel.parent_id) : channelId;
return {
...normalized,
conversationId: channelId,
addressed: normalized.addressed || managed,
requiresMention: thread ? !managed : normalized.kind === 'group',
replyTarget: {
channelId,
...(!fromSourceMessage && normalized.replyTarget?.replyToMessageId
? { replyToMessageId: normalized.replyTarget.replyToMessageId }
: {}),
...(notice ? { notice } : {}),
},
connectionTestTarget: { channelId },
conversationRoute: {
peerId: parentId,
...(thread ? { threadId: channelId, managed } : {}),
...(fallback ? { fallback } : {}),
},
};
}
function uncertainThreadCreate(error) {
const status = Number(error?.status);
return !Number.isInteger(status) || status >= 500;
}
function threadCreateUncertain(cause) {
const error = new Error('Discord Thread creation result is uncertain', { cause });
error.code = 'discord-thread-create-uncertain';
return error;
}
async function findCreatedThread(api, message, { signal, onChannel } = {}) {
try {
const channel = rememberChannel(await api.getChannel({
channelId: String(message.id),
signal,
}), onChannel);
return isThreadFromMessage(channel, message) ? channel : null;
} catch (error) {
if (error?.status === 404) return null;
throw error;
}
}
async function sendThreadUncertainNotice(api, normalized, signal) {
try {
await api.createMessage({
channelId: normalized.replyTarget.channelId,
replyToMessageId: normalized.replyTarget.replyToMessageId,
content: 'Thread 创建结果暂时无法确认。若已创建,请在对应 Thread 中重试;若未创建,请稍后重新 @机器人。',
signal,
});
} catch (error) {
if (signal?.aborted) throw signal.reason ?? error;
}
}
function attachmentMediaType(attachment) { function attachmentMediaType(attachment) {
const value = typeof attachment?.content_type === 'string' const value = typeof attachment?.content_type === 'string'
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : ''; ? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
@ -88,8 +185,28 @@ function discordImageSource(attachment, fetchImpl) {
}; };
} }
function discordFileSource(attachment, fetchImpl) {
if (attachmentMediaType(attachment) || typeof attachment?.url !== 'string' || !attachment.url) {
return null;
}
const mediaType = typeof attachment.content_type === 'string' && attachment.content_type
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : undefined;
return {
name: typeof attachment.filename === 'string' && attachment.filename
? attachment.filename : String(attachment.id ?? 'discord-file'),
...(mediaType ? { mediaType } : {}),
size: attachmentSize(attachment.size),
load: ({ signal } = {}) => fetchFileStream(attachment.url, {
fetchImpl,
signal,
allowedHosts: DISCORD_IMAGE_HOSTS,
}),
};
}
export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) { export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
if (!message?.id || !message?.channel_id || !message?.author?.id) return null; if (!message?.id || !message?.channel_id || !message?.author?.id
|| Number(message.type) === 21) return null;
const direct = !message.guild_id; const direct = !message.guild_id;
const addressed = direct const addressed = direct
|| message.mentions?.some((mention) => String(mention?.id) === String(botId)); || message.mentions?.some((mention) => String(mention?.id) === String(botId));
@ -103,6 +220,9 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
images: Array.isArray(message.attachments) images: Array.isArray(message.attachments)
? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean) ? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
: [], : [],
files: Array.isArray(message.attachments)
? message.attachments.map((attachment) => discordFileSource(attachment, fetchImpl)).filter(Boolean)
: [],
addressed, addressed,
replyTarget: { replyTarget: {
channelId: String(message.channel_id), channelId: String(message.channel_id),
@ -112,9 +232,84 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
}; };
} }
class DiscordBotClient { export async function resolveDiscordMessageRoute(message, botId, {
api,
channel,
fetchImpl = fetch,
signal,
onChannel,
} = {}) {
const normalized = normalizeDiscordMessage(message, botId, { fetchImpl });
if (!normalized || normalized.senderIsBot) return normalized;
signal?.throwIfAborted();
if (normalized.kind === 'direct') {
return withConversationRoute(normalized, { id: normalized.conversationId, type: 1 }, botId);
}
if (!api || typeof api.getChannel !== 'function') {
throw new TypeError('Discord route resolution requires the Discord API');
}
const sourceChannel = rememberChannel(channel ?? await api.getChannel({
channelId: normalized.conversationId,
signal,
}), onChannel);
if (isThreadChannel(sourceChannel)) {
return withConversationRoute(normalized, sourceChannel, botId);
}
if (!normalized.addressed) return withConversationRoute(normalized, sourceChannel, botId);
const sourceType = Number(sourceChannel?.type);
if (sourceType !== GUILD_TEXT && sourceType !== GUILD_ANNOUNCEMENT) {
return withConversationRoute(normalized, sourceChannel, botId, {
fallback: 'unsupported-channel',
notice: '当前频道不支持自动创建 Thread,已直接在当前频道回复。',
});
}
signal?.throwIfAborted();
let created;
try {
created = rememberChannel(await api.startThreadFromMessage({
channelId: normalized.conversationId,
messageId: normalized.messageId,
name: cleanThreadName(message, botId),
signal,
}), onChannel);
if (!isThreadFromMessage(created, message)) {
throw new Error('Discord returned an invalid thread for the source message');
}
} catch (error) {
let recovered;
try {
recovered = await findCreatedThread(api, message, {
signal: AbortSignal.timeout(THREAD_RECOVERY_TIMEOUT_MS),
onChannel,
});
} catch (recoveryError) {
if (signal?.aborted) throw signal.reason ?? error;
await sendThreadUncertainNotice(api, normalized, signal);
throw threadCreateUncertain(recoveryError);
}
if (recovered) {
return withConversationRoute(normalized, recovered, botId, { fromSourceMessage: true });
}
if (signal?.aborted) throw signal.reason ?? error;
if (uncertainThreadCreate(error)) {
await sendThreadUncertainNotice(api, normalized, signal);
throw threadCreateUncertain(error);
}
return withConversationRoute(normalized, sourceChannel, botId, {
fallback: 'thread-create-failed',
notice: '无法创建 Thread,已直接在当前频道回复。',
});
}
return withConversationRoute(normalized, created, botId, { fromSourceMessage: true });
}
export class DiscordBotClient {
#api; #api;
#signal; #signal;
#deliveredNotices = new WeakSet();
constructor({ api, signal }) { constructor({ api, signal }) {
this.#api = api; this.#api = api;
@ -122,39 +317,56 @@ class DiscordBotClient {
} }
async sendText(target, text) { async sendText(target, text) {
const chunks = splitMessageText(text, 1_900); const notice = !this.#deliveredNotices.has(target) && target?.notice
let result = null; ? String(target.notice) : null;
const chunks = splitMessageText(notice ? `${notice}\n\n${text}` : text, 1_900);
const providerMessageIds = [];
for (const [index, chunk] of chunks.entries()) { for (const [index, chunk] of chunks.entries()) {
result = await this.#api.createMessage({ const result = await this.#api.createMessage({
channelId: target.channelId, channelId: target.channelId,
content: chunk, content: chunk,
replyToMessageId: index === 0 ? target.replyToMessageId : undefined, replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
signal: this.#signal, signal: this.#signal,
}); });
if (notice && index === 0) this.#deliveredNotices.add(target);
if (typeof result?.id === 'string' && result.id) providerMessageIds.push(result.id);
} }
return result; return { providerMessageIds };
} }
sendTyping(target) { sendTyping(target) {
return this.#api.sendTyping({ channelId: target.channelId, signal: this.#signal }); return this.#api.sendTyping({ channelId: target.channelId, signal: this.#signal });
} }
sendFile(target, file) {
return this.#api.createFileMessage({
channelId: target.channelId,
file,
replyToMessageId: target.replyToMessageId,
signal: this.#signal,
});
}
async openStream(target) { async openStream(target) {
const notice = !this.#deliveredNotices.has(target) && target?.notice
? String(target.notice) : null;
const decorate = (content) => notice ? `${notice}\n\n${content}` : content;
const stream = createEditableMessageStream({ const stream = createEditableMessageStream({
limit: 1_900, limit: notice ? 1_800 : 1_900,
create: async (content) => { create: async (content) => {
const message = await this.#api.createMessage({ const message = await this.#api.createMessage({
channelId: target.channelId, channelId: target.channelId,
content, content: decorate(content),
replyToMessageId: target.replyToMessageId, replyToMessageId: target.replyToMessageId,
signal: this.#signal, signal: this.#signal,
}); });
if (notice) this.#deliveredNotices.add(target);
return message.id; return message.id;
}, },
edit: (messageId, content) => this.#api.editMessage({ edit: (messageId, content) => this.#api.editMessage({
channelId: target.channelId, channelId: target.channelId,
messageId, messageId,
content, content: decorate(content),
signal: this.#signal, signal: this.#signal,
}), }),
sendRemainder: (content) => this.#api.createMessage({ sendRemainder: (content) => this.#api.createMessage({
@ -162,6 +374,7 @@ class DiscordBotClient {
content, content,
signal: this.#signal, signal: this.#signal,
}), }),
messageIdForResult: (message) => message?.id,
}); });
return stream.start(); return stream.start();
} }
@ -207,6 +420,8 @@ export class DiscordRuntime {
#generation = 0; #generation = 0;
#stopped = true; #stopped = true;
#starting = null; #starting = null;
#channels = new Map();
#routing = new Map();
constructor({ constructor({
config, config,
@ -265,6 +480,8 @@ export class DiscordRuntime {
this.#resumeUrl = null; this.#resumeUrl = null;
this.#sequence = null; this.#sequence = null;
this.#reconnectAttempt = 0; this.#reconnectAttempt = 0;
this.#channels.clear();
this.#routing.clear();
this.#status.startedAt = new Date().toISOString(); this.#status.startedAt = new Date().toISOString();
this.#status.connectionState = 'connecting'; this.#status.connectionState = 'connecting';
this.#status.lastError = null; this.#status.lastError = null;
@ -398,11 +615,21 @@ export class DiscordRuntime {
markReady(); markReady();
} else if (packet.t === 'RESUMED') { } else if (packet.t === 'RESUMED') {
markReady(); markReady();
} else if (packet.t === 'GUILD_CREATE') {
for (const channel of [...(packet.d?.channels ?? []), ...(packet.d?.threads ?? [])]) {
this.#rememberChannel(channel);
}
} else if (packet.t === 'CHANNEL_CREATE' || packet.t === 'CHANNEL_UPDATE'
|| packet.t === 'THREAD_CREATE' || packet.t === 'THREAD_UPDATE') {
this.#rememberChannel(packet.d);
} else if (packet.t === 'CHANNEL_DELETE' || packet.t === 'THREAD_DELETE') {
if (packet.d?.id) this.#channels.delete(String(packet.d.id));
} else if (packet.t === 'THREAD_LIST_SYNC') {
for (const channel of packet.d?.threads ?? []) this.#rememberChannel(channel);
} else if (packet.t === 'MESSAGE_CREATE') { } else if (packet.t === 'MESSAGE_CREATE') {
const message = normalizeDiscordMessage(packet.d, this.#config.platformId);
const bridge = this.#bridge; const bridge = this.#bridge;
if (message && bridge) { if (packet.d && bridge) {
void bridge.accept(message).catch((error) => { void this.#acceptMessage(packet.d, bridge).catch((error) => {
if (generation !== this.#generation || this.#stopped) return; if (generation !== this.#generation || this.#stopped) return;
this.#logger.error?.( this.#logger.error?.(
`[dsh-im:discord] bot ${this.#config.botId} message handling failed:`, `[dsh-im:discord] bot ${this.#config.botId} message handling failed:`,
@ -441,6 +668,38 @@ export class DiscordRuntime {
}); });
} }
#rememberChannel(channel) {
if (!channel?.id) return;
this.#channels.set(String(channel.id), channel);
}
async #acceptMessage(message, bridge) {
const messageId = String(message?.id ?? '');
if (!messageId || this.#state.hasSeen(messageId)) return;
let route = this.#routing.get(messageId);
if (!route) {
route = resolveDiscordMessageRoute(message, this.#config.platformId, {
api: this.#api,
channel: this.#channels.get(String(message.channel_id)),
signal: this.#abortController?.signal,
onChannel: (resolved) => this.#rememberChannel(resolved),
});
this.#routing.set(messageId, route);
void route.finally(() => {
if (this.#routing.get(messageId) === route) this.#routing.delete(messageId);
}).catch(() => undefined);
}
try {
const normalized = await route;
if (normalized) await bridge.accept(normalized);
} catch (error) {
if (error?.code === 'discord-thread-create-uncertain') {
await this.#state.markSeen(messageId);
}
throw error;
}
}
#sendGateway(socket, payload) { #sendGateway(socket, payload) {
if (socket.readyState !== 1) return; if (socket.readyState !== 1) return;
socket.send(JSON.stringify(payload)); socket.send(JSON.stringify(payload));
@ -507,6 +766,7 @@ export class DiscordRuntime {
this.#socket = null; this.#socket = null;
this.#bridge = null; this.#bridge = null;
this.#api = null; this.#api = null;
this.#routing.clear();
try { try {
if (socket && socket.readyState < 2) socket.close(1000, 'Plugin stopped'); if (socket && socket.readyState < 2) socket.close(1000, 'Plugin stopped');
} catch (error) { } catch (error) {

View file

@ -12,6 +12,10 @@ import {
imagePromptUserMessage, imagePromptUserMessage,
promptContentForMessage, promptContentForMessage,
} from '../shared/image-prompt.mjs'; } from '../shared/image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
} from '../shared/inbound-file.mjs';
import { import {
harnessAnswerForQuestion, harnessAnswerForQuestion,
harnessQuestionText, harnessQuestionText,
@ -34,6 +38,10 @@ import {
} from '../shared/preset-command.mjs'; } from '../shared/preset-command.mjs';
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs'; import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
import { askInWorkspaceSession } from '../shared/workspace-session.mjs'; import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
import {
createDeliveryReceipt,
} from '../shared/semantic/delivery.mjs';
import { import {
MENU_PAGE_SIZE, MENU_PAGE_SIZE,
PRESET_FOLLOW_DEFAULT_SENTINEL, PRESET_FOLLOW_DEFAULT_SENTINEL,
@ -52,13 +60,15 @@ import {
watchListCard, watchListCard,
workspaceListCard, workspaceListCard,
} from './feishu-cards.mjs'; } from './feishu-cards.mjs';
import { t } from '../shared/i18n.mjs';
import { MAX_WATCHES_PER_KEY } from './state-store.mjs'; import { MAX_WATCHES_PER_KEY } from './state-store.mjs';
import { import {
FEISHU_GROUP_RESPONSE_MODES, FEISHU_GROUP_RESPONSE_MODES,
normalizeFeishuGroupResponseMode, normalizeFeishuGroupResponseMode,
} from './group-response-mode.mjs'; } from './group-response-mode.mjs';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。'; // Lazily evaluated: t() must run after setImHostLanguage, not at import time.
const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
const RESOLVED_REPLY_TTL_MS = 30 * 60_000; const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
const MENU_COMMAND = /^\/m(?:enu)?$/i; const MENU_COMMAND = /^\/m(?:enu)?$/i;
@ -103,18 +113,45 @@ const WORKSPACE_HELP_LINES = [
function safeErrorText(error) { function safeErrorText(error) {
switch (error?.code) { switch (error?.code) {
case 'workspace-not-absolute': case 'workspace-not-absolute':
return '工作区必须是绝对路径。'; return t('工作区必须是绝对路径。');
case 'workspace-not-found': case 'workspace-not-found':
return '工作区路径不存在。'; return t('工作区路径不存在。');
case 'workspace-not-directory': case 'workspace-not-directory':
return '工作区路径必须指向一个目录。'; return t('工作区路径必须指向一个目录。');
case 'workspace-bot-not-found': case 'workspace-bot-not-found':
return '机器人正在移除或已重新接入,无法操作原会话的工作区。'; return t('机器人正在移除或已重新接入,无法操作原会话的工作区。');
default: default:
return '操作失败,请稍后重试。'; return t('操作失败,请稍后重试。');
} }
} }
function artifactFailureText(fileName, error) {
const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
switch (error?.code) {
case 'artifact-permission-required':
return t('结果文件「{name}」已生成,但机器人缺少飞书文件上传权限。请为应用添加 im:resource 并完成必要审批后重试。', { name });
case 'artifact-too-large':
return t('结果文件「{name}」超过飞书 30 MB 上限,未发送。', { name });
case 'artifact-empty':
return t('结果文件「{name}」为空,飞书不允许发送空文件。', { name });
case 'artifact-changed':
case 'artifact-invalid':
case 'artifact-unavailable':
return t('结果文件「{name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。', { name });
case 'artifact-rate-limited':
return t('结果文件「{name}」暂时被飞书限流,未能发送,请稍后重试。', { name });
case 'artifact-delivery-uncertain':
return t('结果文件「{name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。', { name });
default:
return t('结果文件「{name}」已生成,但暂时未能发送,请稍后重试。', { name });
}
}
function answerTextForDelivery(answer, artifacts) {
if (typeof answer === 'string' && answer.trim()) return answer;
return artifacts.length > 0 ? t('结果文件已生成。') : answer;
}
function nonEmptyString(value) { function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
} }
@ -403,7 +440,7 @@ export class FeishuHarnessBridge {
.finally(() => this.#acceptedMessageIds.delete(messageId)); .finally(() => this.#acceptedMessageIds.delete(messageId));
return processing; return processing;
} }
const commandRunner = isControlCommand(commandText) const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
? runControlCommand ? runControlCommand
: (isModelCommand(commandText) : (isModelCommand(commandText)
? runModelCommand ? runModelCommand
@ -548,7 +585,10 @@ export class FeishuHarnessBridge {
.then(() => this.#handle(event, key, { alreadyRecorded })); .then(() => this.#handle(event, key, { alreadyRecorded }));
const settled = finalize const settled = finalize
? work ? work
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE')) .then(async (receipt) => {
await this.#finishReaction(messageId, processingReaction, 'DONE');
return receipt;
})
.catch((error) => this.#handleMessageFailure( .catch((error) => this.#handleMessageFailure(
event, event,
messageId, messageId,
@ -579,8 +619,9 @@ export class FeishuHarnessBridge {
await this.#finishReaction(messageId, processingReaction, 'ERROR'); await this.#finishReaction(messageId, processingReaction, 'ERROR');
await this.#send( await this.#send(
event.message.chat_id, event.message.chat_id,
imagePromptUserMessage(error) inboundFileUserMessage(error)
?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。', ?? imagePromptUserMessage(error)
?? t('处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。'),
).catch(() => undefined); ).catch(() => undefined);
} }
@ -610,6 +651,7 @@ export class FeishuHarnessBridge {
{ {
signal: this.#signal, signal: this.#signal,
hasImages: hasInboundImages(message), hasImages: hasInboundImages(message),
hasFiles: hasInboundFiles(message),
pendingInteraction: this.#pendingInteractions.has(key) pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key), || this.#approvals.hasPending(key),
control: { owner: this, key }, control: { owner: this, key },
@ -640,9 +682,10 @@ export class FeishuHarnessBridge {
const message = extractInboundMessage(event, this.#client); const message = extractInboundMessage(event, this.#client);
const text = message.content; const text = message.content;
const hasImages = hasInboundImages(message); const hasImages = hasInboundImages(message);
const commandText = event.message.message_type === 'text' && !hasImages ? text : null; const hasFiles = hasInboundFiles(message);
if (!text && !hasImages) { const commandText = event.message.message_type === 'text' && !hasImages && !hasFiles ? text : null;
await this.#send(event.message.chat_id, '目前支持文字和图片消息。'); if (!text && !hasImages && !hasFiles) {
await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'));
return; return;
} }
@ -660,7 +703,7 @@ export class FeishuHarnessBridge {
} }
if (commandText === '/new') { if (commandText === '/new') {
await this.#state.clearSession(key); await this.#state.clearSession(key);
await this.#send(event.message.chat_id, '已开启全新 Harness 会话。'); await this.#send(event.message.chat_id, t('已开启全新 Harness 会话。'));
await this.#sendMenuCard(key, event.message.chat_id); await this.#sendMenuCard(key, event.message.chat_id);
return; return;
} }
@ -702,7 +745,7 @@ export class FeishuHarnessBridge {
const match = ARCHIVED_COMMAND.exec(commandText); const match = ARCHIVED_COMMAND.exec(commandText);
const value = match[1]?.toLowerCase(); const value = match[1]?.toLowerCase();
if (value !== 'on' && value !== 'off') { if (value !== 'on' && value !== 'off') {
await this.#send(event.message.chat_id, '用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)'); await this.#send(event.message.chat_id, t('用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)'));
return; return;
} }
if (typeof this.#state?.setIncludeArchivedSessions === 'function') { if (typeof this.#state?.setIncludeArchivedSessions === 'function') {
@ -710,7 +753,7 @@ export class FeishuHarnessBridge {
} }
await this.#send( await this.#send(
event.message.chat_id, event.message.chat_id,
value === 'on' ? '已开启:会话列表包含归档会话。' : '已关闭:会话列表隐藏归档会话。', value === 'on' ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
); );
return; return;
} }
@ -750,10 +793,11 @@ export class FeishuHarnessBridge {
this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`); this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
try { try {
await this.#answerWithStream(event, key, message); const receipt = await this.#answerWithStream(event, key, message);
this.#status.messagesReplied += 1; this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString(); this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null; this.#status.lastError = null;
return receipt;
} finally { } finally {
await this.#cancelPendingInteraction(key); await this.#cancelPendingInteraction(key);
await this.#approvals.closeRoute(key); await this.#approvals.closeRoute(key);
@ -766,7 +810,7 @@ export class FeishuHarnessBridge {
// and number replies because callback buttons are the capability being fixed. // and number replies because callback buttons are the capability being fixed.
async #handleRepairCommand(event, commandText) { async #handleRepairCommand(event, commandText) {
if (event?.message?.chat_type !== 'p2p') { if (event?.message?.chat_type !== 'p2p') {
await this.#send(event.message.chat_id, '为避免授权链接暴露,请私聊机器人发送 /repair。'); await this.#send(event.message.chat_id, t('为避免授权链接暴露,请私聊机器人发送 /repair。'));
return; return;
} }
const actorOpenId = strictSenderOpenId(event); const actorOpenId = strictSenderOpenId(event);
@ -774,19 +818,19 @@ export class FeishuHarnessBridge {
await this.#send( await this.#send(
event.message.chat_id, event.message.chat_id,
this.#repairOwnerOpenIds.size === 0 this.#repairOwnerOpenIds.size === 0
? '当前机器人没有可验证的接入者身份,不能从聊天发起修复;请先在插件页设置管理员。' ? t('当前机器人没有可验证的接入者身份,不能从聊天发起修复;请先在插件页设置管理员。')
: '此操作只能由机器人接入者在私聊中发起,未进行任何修改。', : t('此操作只能由机器人接入者在私聊中发起,未进行任何修改。'),
); );
return; return;
} }
if (!this.#repair) { if (!this.#repair) {
await this.#send(event.message.chat_id, '当前 Host 版本暂不支持聊天内修复,请先更新插件。'); await this.#send(event.message.chat_id, t('当前 Host 版本暂不支持聊天内修复,请先更新插件。'));
return; return;
} }
const parsed = REPAIR_COMMAND.exec(commandText); const parsed = REPAIR_COMMAND.exec(commandText);
if (!parsed) { if (!parsed) {
await this.#send(event.message.chat_id, '用法:/repair、/repair qr、/repair status、/repair cancel 或 /repair verify'); await this.#send(event.message.chat_id, t('用法:/repair、/repair qr、/repair status、/repair cancel 或 /repair verify'));
return; return;
} }
const operation = parsed[1]?.toLowerCase() ?? 'start'; const operation = parsed[1]?.toLowerCase() ?? 'start';
@ -800,12 +844,12 @@ export class FeishuHarnessBridge {
if (!attempt) { if (!attempt) {
await this.#send( await this.#send(
chatId, chatId,
'当前 Runtime 没有可恢复的修复任务记录(机器人可能刚完成密钥更新并重启)。本命令不会启动新的授权;请查看机器人发送的验证结果,确认上一次任务已结束后再发送 /repair。', t('当前 Runtime 没有可恢复的修复任务记录(机器人可能刚完成密钥更新并重启)。本命令不会启动新的授权;请查看机器人发送的验证结果,确认上一次任务已结束后再发送 /repair。'),
); );
return; return;
} }
if (attempt.actorOpenId !== actorOpenId) { if (attempt.actorOpenId !== actorOpenId) {
await this.#send(chatId, '另一位管理员正在修复该机器人,本次不会显示其授权信息。'); await this.#send(chatId, t('另一位管理员正在修复该机器人,本次不会显示其授权信息。'));
return; return;
} }
if (operation === 'cancel') { if (operation === 'cancel') {
@ -815,7 +859,7 @@ export class FeishuHarnessBridge {
snapshot = repairSnapshot(result, { botId: this.#botId }); snapshot = repairSnapshot(result, { botId: this.#botId });
attempt.snapshot = snapshot; attempt.snapshot = snapshot;
} catch { } catch {
await this.#send(chatId, '暂时无法取消修复任务,请稍后重试。'); await this.#send(chatId, t('暂时无法取消修复任务,请稍后重试。'));
return; return;
} }
if (snapshot.state === 'cancelled') { if (snapshot.state === 'cancelled') {
@ -830,7 +874,7 @@ export class FeishuHarnessBridge {
try { try {
snapshot = await this.#refreshRepairAttempt(attempt); snapshot = await this.#refreshRepairAttempt(attempt);
} catch { } catch {
await this.#send(chatId, '暂时无法查询修复状态,请稍后重试。'); await this.#send(chatId, t('暂时无法查询修复状态,请稍后重试。'));
return; return;
} }
if (operation === 'qr') { if (operation === 'qr') {
@ -859,7 +903,7 @@ export class FeishuHarnessBridge {
const previous = this.#repairAttempt; const previous = this.#repairAttempt;
if (previous && REPAIR_ACTIVE_STATES.has(previous.snapshot.state)) { if (previous && REPAIR_ACTIVE_STATES.has(previous.snapshot.state)) {
if (previous.actorOpenId !== actorOpenId) { if (previous.actorOpenId !== actorOpenId) {
await this.#send(chatId, '另一位管理员正在修复该机器人,本次不会显示其授权信息。'); await this.#send(chatId, t('另一位管理员正在修复该机器人,本次不会显示其授权信息。'));
return; return;
} }
try { try {
@ -869,7 +913,7 @@ export class FeishuHarnessBridge {
return; return;
} }
} catch { } catch {
await this.#send(chatId, '暂时无法查询修复状态,请稍后重试。'); await this.#send(chatId, t('暂时无法查询修复状态,请稍后重试。'));
return; return;
} }
} }
@ -883,7 +927,7 @@ export class FeishuHarnessBridge {
}), { botId: this.#botId }); }), { botId: this.#botId });
snapshot = await this.#waitForRepairLink(snapshot, { actorOpenId, chatId }); snapshot = await this.#waitForRepairLink(snapshot, { actorOpenId, chatId });
} catch { } catch {
await this.#send(chatId, '修复流程暂时失败,现有机器人连接不受影响;请稍后发送 /repair 重试。'); await this.#send(chatId, t('修复流程暂时失败,现有机器人连接不受影响;请稍后发送 /repair 重试。'));
return; return;
} }
const attempt = { const attempt = {
@ -904,7 +948,7 @@ export class FeishuHarnessBridge {
} catch { } catch {
attempt.stopped = true; attempt.stopped = true;
await this.#repair.cancel(this.#repairArgs(attempt)).catch(() => undefined); await this.#repair.cancel(this.#repairArgs(attempt)).catch(() => undefined);
await this.#send(chatId, '飞书返回了无法安全验证的授权链接,已中止本次修复。'); await this.#send(chatId, t('飞书返回了无法安全验证的授权链接,已中止本次修复。'));
return; return;
} }
} }
@ -917,7 +961,7 @@ export class FeishuHarnessBridge {
} }
if (!attempt.verificationUrl) { if (!attempt.verificationUrl) {
attempt.stopped = true; attempt.stopped = true;
await this.#send(chatId, '飞书未返回授权链接,已中止本次修复。'); await this.#send(chatId, t('飞书未返回授权链接,已中止本次修复。'));
return; return;
} }
await this.#sendRepairLink(chatId, attempt.verificationUrl, snapshot); await this.#sendRepairLink(chatId, attempt.verificationUrl, snapshot);
@ -972,7 +1016,7 @@ export class FeishuHarnessBridge {
attempt.announcedSaving = true; attempt.announcedSaving = true;
await this.#send( await this.#send(
attempt.chatId, attempt.chatId,
'授权已确认,正在发送并等待测试按钮回调;收到真实回调后才会完成。', t('授权已确认,正在发送并等待测试按钮回调;收到真实回调后才会完成。'),
); );
} }
if (REPAIR_TERMINAL_STATES.has(snapshot.state)) { if (REPAIR_TERMINAL_STATES.has(snapshot.state)) {
@ -993,7 +1037,7 @@ export class FeishuHarnessBridge {
this.#logger.warn?.('[dsh-feishu] callback repair status monitoring failed'); this.#logger.warn?.('[dsh-feishu] callback repair status monitoring failed');
await this.#send( await this.#send(
attempt.chatId, attempt.chatId,
'修复状态查询中断,现有机器人连接不受影响;发送 /repair status 重试查询。', t('修复状态查询中断,现有机器人连接不受影响;发送 /repair status 重试查询。'),
).catch(() => undefined); ).catch(() => undefined);
}); });
} }
@ -1002,16 +1046,16 @@ export class FeishuHarnessBridge {
const remaining = snapshot.remainingSeconds const remaining = snapshot.remainingSeconds
?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null); ?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null);
const expiry = remaining === null const expiry = remaining === null
? '链接为短期有效' ? t('链接为短期有效')
: `链接约 ${Math.max(1, Math.ceil(remaining / 60))} 分钟后过期`; : t('链接约 {minutes} 分钟后过期', { minutes: Math.max(1, Math.ceil(remaining / 60)) });
await this.#send(chatId, [ await this.#send(chatId, [
existing ? '已有一个修复任务在等待授权。' : '🔧 准备修复卡片按钮。', existing ? t('已有一个修复任务在等待授权。') : t('🔧 准备修复卡片按钮。'),
'本次只会增量添加 card.action.trigger。请核对确认页只显示这一项;若出现其他权限或事件,请取消。', t('本次只会增量添加 card.action.trigger。请核对确认页只显示这一项;若出现其他权限或事件,请取消。'),
'', '',
'当前设备直接打开:', t('当前设备直接打开:'),
url, url,
'', '',
`若要用另一台设备扫码,发送 /repair qr。${expiry}。`, t('若要用另一台设备扫码,发送 /repair qr。{expiry}。', { expiry }),
].join('\n')); ].join('\n'));
} }
@ -1027,9 +1071,12 @@ export class FeishuHarnessBridge {
if (!imageKey) throw new Error('Feishu QR upload returned no image key'); if (!imageKey) throw new Error('Feishu QR upload returned no image key');
const remaining = snapshot.remainingSeconds const remaining = snapshot.remainingSeconds
?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null); ?? (snapshot.expiresAt ? Math.max(0, Math.ceil((snapshot.expiresAt - Date.now()) / 1000)) : null);
const remainingText = remaining === null
? ''
: t('(剩余约 {minutes} 分钟)', { minutes: Math.max(1, Math.ceil(remaining / 60)) });
await this.#send( await this.#send(
chatId, chatId,
`请用另一台设备扫码完成授权${remaining === null ? '' : `(剩余约 ${Math.max(1, Math.ceil(remaining / 60))} 分钟)`}。`, t('请用另一台设备扫码完成授权{remaining}。', { remaining: remainingText }),
); );
const response = await this.#client.im.v1.message.create({ const response = await this.#client.im.v1.message.create({
params: { receive_id_type: 'chat_id' }, params: { receive_id_type: 'chat_id' },
@ -1041,40 +1088,40 @@ export class FeishuHarnessBridge {
}); });
if (response?.code && response.code !== 0) throw new Error('Feishu QR message send failed'); if (response?.code && response.code !== 0) throw new Error('Feishu QR message send failed');
} catch { } catch {
await this.#send(chatId, `二维码暂时无法发送,请直接打开授权链接:\n${url}`); await this.#send(chatId, t('二维码暂时无法发送,请直接打开授权链接:\n{url}', { url }));
} }
} }
#repairStatusText(snapshot, { verificationFocused = false } = {}) { #repairStatusText(snapshot, { verificationFocused = false } = {}) {
if (snapshot.state === 'succeeded') { if (snapshot.state === 'succeeded') {
return '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。'; return t('✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。');
} }
if (snapshot.state === 'expired' || snapshot.error?.code === 'expired_token') { if (snapshot.state === 'expired' || snapshot.error?.code === 'expired_token') {
return '授权链接已过期;平台未返回成功结果,无法确认已修复。发送 /repair 生成新链接。'; return t('授权链接已过期;平台未返回成功结果,无法确认已修复。发送 /repair 生成新链接。');
} }
if (snapshot.state === 'cancelled' || snapshot.error?.code === 'abort') { if (snapshot.state === 'cancelled' || snapshot.error?.code === 'abort') {
return '已取消本次修复授权,未确认完成修复。'; return t('已取消本次修复授权,未确认完成修复。');
} }
if (snapshot.error?.code === 'access_denied') { if (snapshot.error?.code === 'access_denied') {
return '你已取消或拒绝授权,没有确认修复;发送 /repair 可重试。'; return t('你已取消或拒绝授权,没有确认修复;发送 /repair 可重试。');
} }
if (snapshot.error?.code === 'card_action_probe_timeout' if (snapshot.error?.code === 'card_action_probe_timeout'
|| snapshot.error?.code === 'card-action-probe-timeout') { || snapshot.error?.code === 'card-action-probe-timeout') {
return '授权已提交,但未收到测试按钮回调。可能尚未点击或配置仍在传播;稍后发送 /repair verify 查询,不要盲目重复授权。'; return t('授权已提交,但未收到测试按钮回调。可能尚未点击或配置仍在传播;稍后发送 /repair verify 查询,不要盲目重复授权。');
} }
if (snapshot.state === 'error') { if (snapshot.state === 'error') {
return '修复流程暂时失败,现有机器人连接不受影响;发送 /repair 可重试。'; return t('修复流程暂时失败,现有机器人连接不受影响;发送 /repair 可重试。');
} }
if (snapshot.state === 'saving') { if (snapshot.state === 'saving') {
return '授权已确认,正在等待专用测试按钮的真实回调;回调到达前不会宣告成功。'; return t('授权已确认,正在等待专用测试按钮的真实回调;回调到达前不会宣告成功。');
} }
if (verificationFocused) { if (verificationFocused) {
return '授权尚未完成,暂时不能验证卡片按钮。请先打开授权链接并确认。'; return t('授权尚未完成,暂时不能验证卡片按钮。请先打开授权链接并确认。');
} }
const remaining = snapshot.remainingSeconds === null const remaining = snapshot.remainingSeconds === null
? '' ? ''
: `,剩余约 ${Math.max(1, Math.ceil(snapshot.remainingSeconds / 60))} 分钟`; : t(',剩余约 {minutes} 分钟', { minutes: Math.max(1, Math.ceil(snapshot.remainingSeconds / 60)) });
return `修复任务正在等待授权${remaining}。发送 /repair qr 可获取二维码,/repair cancel 可取消。`; return t('修复任务正在等待授权{remaining}。发送 /repair qr 可获取二维码,/repair cancel 可取消。', { remaining });
} }
/** /**
@ -1131,7 +1178,7 @@ export class FeishuHarnessBridge {
// restart) or never came from us: nudge instead of staying silent. // restart) or never came from us: nudge instead of staying silent.
const chatId = nonEmptyString(event?.context?.open_chat_id); const chatId = nonEmptyString(event?.context?.open_chat_id);
if (chatId) { if (chatId) {
this.#send(chatId, '这个菜单已过期,请回复 /m 重新打开。').catch(() => undefined); this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。')).catch(() => undefined);
} }
return Promise.resolve(); return Promise.resolve();
} }
@ -1211,7 +1258,7 @@ export class FeishuHarnessBridge {
} }
if (action === 'new') { if (action === 'new') {
await this.#state.clearSession(key); await this.#state.clearSession(key);
await this.#send(chatId, '已开启全新 Harness 会话。'); await this.#send(chatId, t('已开启全新 Harness 会话。'));
return; return;
} }
if (action === 'use:current') { if (action === 'use:current') {
@ -1342,7 +1389,7 @@ export class FeishuHarnessBridge {
const actions = ['use:current', 'new', 'sessions', 'status', 'repair', 'settings', 'help']; const actions = ['use:current', 'new', 'sessions', 'status', 'repair', 'settings', 'help'];
const action = actions[number - 1]; const action = actions[number - 1];
if (!action) { if (!action) {
await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。'); await this.#send(chatId, t('菜单没有这个编号,回复 /m 重新打开。'));
return; return;
} }
if (action === 'repair') { if (action === 'repair') {
@ -1355,7 +1402,7 @@ export class FeishuHarnessBridge {
if (menu.kind === 'sessions') { if (menu.kind === 'sessions') {
const session = menu.sessions[number - 1]; const session = menu.sessions[number - 1];
if (!session?.sessionId) { if (!session?.sessionId) {
await this.#send(chatId, `本页只有 ${menu.sessions.length} 个会话,回复 /sessionlist 重新查看。`); await this.#send(chatId, t('本页只有 {count} 个会话,回复 /sessionlist 重新查看。', { count: menu.sessions.length }));
return; return;
} }
// The number label sits on the session (bind) button of the row. // The number label sits on the session (bind) button of the row.
@ -1365,7 +1412,7 @@ export class FeishuHarnessBridge {
if (menu.kind === 'workspaces') { if (menu.kind === 'workspaces') {
const workspace = menu.paths[number - 1]; const workspace = menu.paths[number - 1];
if (!workspace) { if (!workspace) {
await this.#send(chatId, `只有 ${menu.paths.length} 个工作区,回复 /workspacelist 重新查看。`); await this.#send(chatId, t('只有 {count} 个工作区,回复 /workspacelist 重新查看。', { count: menu.paths.length }));
return; return;
} }
await this.#handleCardAction(`workspace:${workspace}`, { chatId, key }); await this.#handleCardAction(`workspace:${workspace}`, { chatId, key });
@ -1374,7 +1421,7 @@ export class FeishuHarnessBridge {
if (menu.kind === 'watches') { if (menu.kind === 'watches') {
const entry = menu.entries[number - 1]; const entry = menu.entries[number - 1];
if (!entry?.sessionId) { if (!entry?.sessionId) {
await this.#send(chatId, `关注列表只有 ${menu.entries.length} 个会话。`); await this.#send(chatId, t('关注列表只有 {count} 个会话。', { count: menu.entries.length }));
return; return;
} }
await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key }); await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key });
@ -1400,7 +1447,7 @@ export class FeishuHarnessBridge {
const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []); const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
const workspace = listed?.workspace ?? resolved.workspace; const workspace = listed?.workspace ?? resolved.workspace;
if (sessions.length === 0) { if (sessions.length === 0) {
await this.#send(chatId, `工作区:${workspace}\n该工作区暂无会话。`); await this.#send(chatId, t('工作区:{workspace}\n该工作区暂无会话。', { workspace }));
return; return;
} }
const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE); const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
@ -1425,7 +1472,7 @@ export class FeishuHarnessBridge {
); );
} catch (error) { } catch (error) {
this.#logger.warn?.('[dsh-feishu] session list failed:', error.message); this.#logger.warn?.('[dsh-feishu] session list failed:', error.message);
await this.#send(chatId, '暂时无法获取会话列表,请稍后重试。'); await this.#send(chatId, t('暂时无法获取会话列表,请稍后重试。'));
} }
} }
@ -1436,28 +1483,28 @@ export class FeishuHarnessBridge {
await this.#sendCard(chatId, workspaceListCard(paths, current), { key }); await this.#sendCard(chatId, workspaceListCard(paths, current), { key });
} catch (error) { } catch (error) {
this.#logger.warn?.('[dsh-feishu] workspace list failed:', error.message); this.#logger.warn?.('[dsh-feishu] workspace list failed:', error.message);
await this.#send(chatId, '暂时无法获取工作区列表,请稍后重试。'); await this.#send(chatId, t('暂时无法获取工作区列表,请稍后重试。'));
} }
} }
async #bindSession(key, chatId, sessionId) { async #bindSession(key, chatId, sessionId) {
try { try {
const bound = await this.#harness.bindWorkspaceSession(key, sessionId); const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || '暂无标题'; const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
await this.#send(chatId, `已绑定会话「${title}」\nID:${bound?.sessionId ?? sessionId}`); await this.#send(chatId, t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }));
await this.#sendMenuCard(key, chatId); await this.#sendMenuCard(key, chatId);
} catch (error) { } catch (error) {
await this.#send(chatId, `绑定失败:${safeErrorText(error)}`); await this.#send(chatId, t('绑定失败:{message}', { message: safeErrorText(error) }));
} }
} }
async #switchWorkspace(key, chatId, workspace) { async #switchWorkspace(key, chatId, workspace) {
try { try {
const current = await this.#harness.switchWorkspace(workspace); const current = await this.#harness.switchWorkspace(workspace);
await this.#send(chatId, `工作区已切换为:${current}`); await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }));
await this.#sendMenuCard(key, chatId); await this.#sendMenuCard(key, chatId);
} catch (error) { } catch (error) {
await this.#send(chatId, `切换失败:${safeErrorText(error)}`); await this.#send(chatId, t('切换失败:{message}', { message: safeErrorText(error) }));
} }
} }
@ -1930,7 +1977,7 @@ export class FeishuHarnessBridge {
*/ */
async #resolveWatchTarget(target) { async #resolveWatchTarget(target) {
if (typeof target !== 'string' || target === '') { if (typeof target !== 'string' || target === '') {
return { error: '用法:/watch <Session ID 或当前工作区序号>' }; return { error: t('用法:/watch <Session ID 或当前工作区序号>') };
} }
const numeric = /^\d{1,4}$/.test(target) ? Number(target) : null; const numeric = /^\d{1,4}$/.test(target) ? Number(target) : null;
const currentPath = typeof this.#harness?.currentWorkspace === 'function' const currentPath = typeof this.#harness?.currentWorkspace === 'function'
@ -1941,13 +1988,13 @@ export class FeishuHarnessBridge {
return Array.isArray(listed?.sessions) ? listed.sessions : []; return Array.isArray(listed?.sessions) ? listed.sessions : [];
}; };
if (numeric !== null) { if (numeric !== null) {
if (!currentPath) return { error: '当前机器人没有可用的工作区,无法按序号解析会话。' }; if (!currentPath) return { error: t('当前机器人没有可用的工作区,无法按序号解析会话。') };
const sessions = this.#visibleSessions(await listSessions(currentPath)); const sessions = this.#visibleSessions(await listSessions(currentPath));
const session = sessions[numeric - 1]; const session = sessions[numeric - 1];
if (!session?.sessionId) { if (!session?.sessionId) {
return { error: `当前工作区只有 ${sessions.length} 个会话。` }; return { error: t('当前工作区只有 {count} 个会话。', { count: sessions.length }) };
} }
return { sessionId: session.sessionId, title: session.title ?? '暂无标题' }; return { sessionId: session.sessionId, title: session.title ?? t('暂无标题') };
} }
const extraPaths = typeof this.#harness?.listWorkspaces === 'function' const extraPaths = typeof this.#harness?.listWorkspaces === 'function'
? (await this.#harness.listWorkspaces()).filter((path) => path !== currentPath) ? (await this.#harness.listWorkspaces()).filter((path) => path !== currentPath)
@ -1956,9 +2003,9 @@ export class FeishuHarnessBridge {
for (const workspace of paths) { for (const workspace of paths) {
const sessions = await listSessions(workspace); const sessions = await listSessions(workspace);
const session = sessions.find((candidate) => candidate.sessionId === target); const session = sessions.find((candidate) => candidate.sessionId === target);
if (session) return { sessionId: target, title: session.title ?? '暂无标题' }; if (session) return { sessionId: target, title: session.title ?? t('暂无标题') };
} }
return { error: '没有找到这个会话,请用 /sessionlist 查看可用会话。' }; return { error: t('没有找到这个会话,请用 /sessionlist 查看可用会话。') };
} }
async #latestSessionSeq(sessionId) { async #latestSessionSeq(sessionId) {
@ -1975,14 +2022,14 @@ export class FeishuHarnessBridge {
async #runWatch(key, chatId, target) { async #runWatch(key, chatId, target) {
this.#ensureEventWatcher(); this.#ensureEventWatcher();
if (typeof this.#state?.setWatch !== 'function') { if (typeof this.#state?.setWatch !== 'function') {
await this.#send(chatId, '当前状态存储不支持关注。'); await this.#send(chatId, t('当前状态存储不支持关注。'));
return; return;
} }
let resolved; let resolved;
try { try {
resolved = await this.#resolveWatchTarget(target); resolved = await this.#resolveWatchTarget(target);
} catch (error) { } catch (error) {
await this.#send(chatId, `无法解析会话:${safeErrorText(error)}`); await this.#send(chatId, t('无法解析会话:{message}', { message: safeErrorText(error) }));
return; return;
} }
if (resolved.error) { if (resolved.error) {
@ -1992,7 +2039,7 @@ export class FeishuHarnessBridge {
const existing = this.#state.watchEntries?.(key) ?? []; const existing = this.#state.watchEntries?.(key) ?? [];
const existingEntry = existing.find((entry) => entry.sessionId === resolved.sessionId); const existingEntry = existing.find((entry) => entry.sessionId === resolved.sessionId);
if (!existingEntry && existing.length >= MAX_WATCHES_PER_KEY) { if (!existingEntry && existing.length >= MAX_WATCHES_PER_KEY) {
await this.#send(chatId, `每个聊天最多关注 ${MAX_WATCHES_PER_KEY} 个会话。`); await this.#send(chatId, t('每个聊天最多关注 {count} 个会话。', { count: MAX_WATCHES_PER_KEY }));
return; return;
} }
try { try {
@ -2005,10 +2052,10 @@ export class FeishuHarnessBridge {
chatId, chatId,
lastSeq, lastSeq,
}); });
await this.#send(chatId, `已关注会话「${String(resolved.title).replace(/\s+/gu, ' ')}」,任务完成会推送结果。`); await this.#send(chatId, t('已关注会话「{title}」,任务完成会推送结果。', { title: String(resolved.title).replace(/\s+/gu, ' ') }));
await this.#queueEventTask(() => this.#compensateSession(resolved.sessionId)); await this.#queueEventTask(() => this.#compensateSession(resolved.sessionId));
} catch (error) { } catch (error) {
await this.#send(chatId, `关注失败:${safeErrorText(error)}`); await this.#send(chatId, t('关注失败:{message}', { message: safeErrorText(error) }));
} }
} }
@ -2019,15 +2066,15 @@ export class FeishuHarnessBridge {
? entries[Number(target) - 1] ? entries[Number(target) - 1]
: entries.find((candidate) => candidate.sessionId === target); : entries.find((candidate) => candidate.sessionId === target);
if (!entry) { if (!entry) {
await this.#send(chatId, '关注列表里没有这个会话,回复 /watchlist 查看。'); await this.#send(chatId, t('关注列表里没有这个会话,回复 /watchlist 查看。'));
return; return;
} }
try { try {
await this.#state.removeWatch(key, entry.sessionId); await this.#state.removeWatch(key, entry.sessionId);
this.#failedWatchSeqs.delete(`${key}\0${entry.sessionId}`); this.#failedWatchSeqs.delete(`${key}\0${entry.sessionId}`);
await this.#send(chatId, `已取消关注「${String(entry.title ?? '').replace(/\s+/gu, ' ')}」。`); await this.#send(chatId, t('已取消关注「{title}」。', { title: String(entry.title ?? '').replace(/\s+/gu, ' ') }));
} catch (error) { } catch (error) {
await this.#send(chatId, `取消失败:${safeErrorText(error)}`); await this.#send(chatId, t('取消失败:{message}', { message: safeErrorText(error) }));
} }
} }
@ -2149,7 +2196,7 @@ export class FeishuHarnessBridge {
} }
} }
#interactionAskOptions(event, key) { #interactionAskOptions(event, key, files) {
return { return {
timeoutMs: this.#replyTimeoutMs, timeoutMs: this.#replyTimeoutMs,
signal: this.#signal, signal: this.#signal,
@ -2161,6 +2208,68 @@ export class FeishuHarnessBridge {
requiresMention: event.message.chat_type !== 'p2p', requiresMention: event.message.chat_type !== 'p2p',
}), }),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution), onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files,
};
}
async #sendAnswerText(chatId, answer, { deliveryId, presentation }) {
const providerMessageIds = [];
for (const chunk of splitText(answer)) {
this.#signal?.throwIfAborted();
const messageId = await this.#send(chatId, chunk);
if (messageId) providerMessageIds.push(messageId);
}
return createDeliveryReceipt({
deliveryId,
presentation,
providerMessageIds,
});
}
async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt) {
const delivery = await deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: baseReceipt?.deliveryId ?? artifacts[0]?.deliveryKey ?? replyTo,
aggregatePresentation: baseReceipt ? 'feishu-text-and-files' : 'feishu-files',
channelKey: 'feishu',
signal: this.#signal,
sendImage: typeof this.#channel?.sendImage === 'function'
? (file) => this.#channel.sendImage(chatId, file, {
replyTo,
signal: this.#signal,
})
: undefined,
sendFile: typeof this.#channel?.sendFile === 'function'
? (file) => this.#channel.sendFile(chatId, file, {
replyTo,
signal: this.#signal,
})
: undefined,
sendFailureNotice: async (artifact, error) => ({
messageId: await this.#send(
chatId,
artifactFailureText(artifact?.fileName, error),
),
}),
logger: this.#logger,
});
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
+ delivery.artifactsSent;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
+ delivery.artifactSendErrors;
if (!delivery.receipt) {
return {
receipt: createDeliveryReceipt({
deliveryId: replyTo,
presentation: 'feishu-files',
}),
failureNoticeVisible: delivery.failureNoticeVisible,
};
}
return {
receipt: delivery.receipt,
failureNoticeVisible: delivery.failureNoticeVisible,
}; };
} }
@ -2172,7 +2281,7 @@ export class FeishuHarnessBridge {
? await promptContentForMessage(message, { signal: this.#signal }) ? await promptContentForMessage(message, { signal: this.#signal })
: undefined; : undefined;
if (!this.#channel?.stream) { if (!this.#channel?.stream) {
const { answer } = await askInWorkspaceSession({ const { answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness, harness: this.#harness,
state: this.#state, state: this.#state,
key, key,
@ -2180,27 +2289,53 @@ export class FeishuHarnessBridge {
content, content,
createOptions: { signal: this.#signal }, createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal }, existsOptions: { signal: this.#signal },
askOptions: this.#interactionAskOptions(event, key), askOptions: this.#interactionAskOptions(event, key, message.files),
}); });
for (const chunk of splitText(answer)) await this.#send(chatId, chunk); let textReceipt;
let textSendError = null;
try {
textReceipt = await this.#sendAnswerText(
chatId,
answerTextForDelivery(answer, artifacts),
{
deliveryId: messageId,
presentation: 'feishu-text',
},
);
} catch (error) {
textSendError = error;
this.#logger.warn?.(
'[dsh-feishu] final text delivery failed; continuing with result files:',
error,
);
}
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
const artifactDispatched = delivery.receipt.artifacts.some(
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
);
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
}
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1; this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
return; return delivery.receipt;
} }
let promptStarted = false; let promptStarted = false;
let completedAnswer = ''; let completedAnswer = '';
let completedArtifacts = [];
let stream;
try { try {
await this.#channel.stream(chatId, { stream = await this.#channel.stream(chatId, {
markdown: async (controller) => { markdown: async (controller) => {
promptStarted = true; promptStarted = true;
const askOptions = { const askOptions = {
...this.#interactionAskOptions(event, key), ...this.#interactionAskOptions(event, key, message.files),
onUpdate: async (update) => { onUpdate: async (update) => {
await controller.setContent(this.#progressText(update)); await controller.setContent(this.#progressText(update));
this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1; this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
}, },
}; };
({ answer: completedAnswer } = await askInWorkspaceSession({ const completed = await askInWorkspaceSession({
harness: this.#harness, harness: this.#harness,
state: this.#state, state: this.#state,
key, key,
@ -2209,26 +2344,56 @@ export class FeishuHarnessBridge {
createOptions: { signal: this.#signal }, createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal }, existsOptions: { signal: this.#signal },
askOptions, askOptions,
})); });
await controller.setContent(completedAnswer); completedAnswer = completed.answer;
completedArtifacts = completed.artifacts ?? [];
await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
}, },
}, { replyTo: messageId }); }, { replyTo: messageId });
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
} catch (error) { } catch (error) {
this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1; this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
if (completedAnswer) { if (completedAnswer || completedArtifacts.length > 0) {
this.#logger.warn?.( this.#logger.warn?.(
'[dsh-feishu] native stream failed after generation; sending final text:', '[dsh-feishu] native stream failed after generation; sending final text:',
error.message, error.message,
); );
for (const chunk of splitText(completedAnswer)) await this.#send(chatId, chunk); let textReceipt;
let textSendError = null;
try {
textReceipt = await this.#sendAnswerText(
chatId,
answerTextForDelivery(completedAnswer, completedArtifacts),
{
deliveryId: messageId,
presentation: 'feishu-text-fallback',
},
);
} catch (fallbackError) {
textSendError = fallbackError;
this.#logger.warn?.(
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
fallbackError,
);
}
const delivery = await this.#deliverArtifacts(
chatId,
messageId,
completedArtifacts,
textReceipt,
);
const artifactDispatched = delivery.receipt.artifacts.some(
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
);
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
}
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1; this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
return; return delivery.receipt;
} }
if (promptStarted) throw error; if (promptStarted) throw error;
this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message); this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
const { answer } = await askInWorkspaceSession({ const { answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness, harness: this.#harness,
state: this.#state, state: this.#state,
key, key,
@ -2236,11 +2401,48 @@ export class FeishuHarnessBridge {
content, content,
createOptions: { signal: this.#signal }, createOptions: { signal: this.#signal },
existsOptions: { signal: this.#signal }, existsOptions: { signal: this.#signal },
askOptions: this.#interactionAskOptions(event, key), askOptions: this.#interactionAskOptions(event, key, message.files),
}); });
for (const chunk of splitText(answer)) await this.#send(chatId, chunk); let textReceipt;
let textSendError = null;
try {
textReceipt = await this.#sendAnswerText(
chatId,
answerTextForDelivery(answer, artifacts),
{
deliveryId: messageId,
presentation: 'feishu-text-fallback',
},
);
} catch (fallbackError) {
textSendError = fallbackError;
this.#logger.warn?.(
'[dsh-feishu] fallback text delivery failed; continuing with result files:',
fallbackError,
);
}
const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
const artifactDispatched = delivery.receipt.artifacts.some(
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
);
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
}
this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1; this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
return delivery.receipt;
} }
const delivery = await this.#deliverArtifacts(
chatId,
messageId,
completedArtifacts,
createDeliveryReceipt({
deliveryId: messageId,
presentation: 'feishu-cardkit',
providerMessageIds: stream?.messageId ? [stream.messageId] : [],
}),
);
this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
return delivery.receipt;
} }
async #processInteractionReply(event, messageId, key, expected, processingReaction) { async #processInteractionReply(event, messageId, key, expected, processingReaction) {
@ -2266,18 +2468,18 @@ export class FeishuHarnessBridge {
const text = extractText(event); const text = extractText(event);
if (!text) { if (!text) {
await this.#send(event.message.chat_id, '请用文字回答当前问题。'); await this.#send(event.message.chat_id, t('请用文字回答当前问题。'));
return; return;
} }
const pending = this.#pendingInteractions.get(key); const pending = this.#pendingInteractions.get(key);
if (!pending || pending !== expected || pending.submitting) { if (!pending || pending !== expected || pending.submitting) {
if (this.#isResolvedQuestionReply(event, key)) { if (this.#isResolvedQuestionReply(event, key)) {
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT).catch(() => undefined); await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
return; return;
} }
if (claimed && (!pending || pending !== expected)) { if (claimed && (!pending || pending !== expected)) {
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT); await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT());
return; return;
} }
return this.#enqueueMessage(event, messageId, key, processingReaction, { return this.#enqueueMessage(event, messageId, key, processingReaction, {
@ -2335,7 +2537,7 @@ export class FeishuHarnessBridge {
if (error?.code === 'interaction-not-pending') { if (error?.code === 'interaction-not-pending') {
this.#rememberResolvedInteraction(key, pending); this.#rememberResolvedInteraction(key, pending);
this.#clearPendingInteraction(key, pending.interactionId); this.#clearPendingInteraction(key, pending.interactionId);
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT).catch(() => undefined); await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
return; return;
} }
pending.submitting = false; pending.submitting = false;
@ -2343,7 +2545,7 @@ export class FeishuHarnessBridge {
pending.index -= 1; pending.index -= 1;
this.#status.lastError = '回答提交失败。'; this.#status.lastError = '回答提交失败。';
this.#logger.error?.('[dsh-feishu] failed to answer a Harness interaction'); this.#logger.error?.('[dsh-feishu] failed to answer a Harness interaction');
await this.#send(event.message.chat_id, '回答提交失败,请重新发送当前问题的答案。') await this.#send(event.message.chat_id, t('回答提交失败,请重新发送当前问题的答案。'))
.catch(() => undefined); .catch(() => undefined);
} }
} }
@ -2388,7 +2590,7 @@ export class FeishuHarnessBridge {
}); });
await this.#send( await this.#send(
chatId, chatId,
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。', t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'),
).catch(() => undefined); ).catch(() => undefined);
return; return;
} }
@ -2490,7 +2692,7 @@ export class FeishuHarnessBridge {
await this.#state.markSeen(messageId); await this.#state.markSeen(messageId);
this.#status.lastMessageAt = new Date().toISOString(); this.#status.lastMessageAt = new Date().toISOString();
this.#status.messagesReceived += 1; this.#status.messagesReceived += 1;
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT).catch(() => undefined); await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
} }
#takePendingInteraction(key, interactionId) { #takePendingInteraction(key, interactionId) {
@ -2530,10 +2732,10 @@ export class FeishuHarnessBridge {
#progressText(update) { #progressText(update) {
if (update.type === 'text' && update.text) return update.text; if (update.type === 'text' && update.text) return update.text;
if (update.type === 'tool') { if (update.type === 'tool') {
if (update.name === 'web_search') return '_正在搜索网络并整理信息…_'; if (update.name === 'web_search') return t('_正在搜索网络并整理信息…_');
return `_正在使用 ${update.name || '工具'}…_`; return t('_正在使用 {name}…_', { name: update.name || t('工具') });
} }
return `_${update.text || '正在处理…'}_`; return t('_{text}_', { text: update.text || t('正在处理…') });
} }
async #addReaction(messageId, emojiType) { async #addReaction(messageId, emojiType) {

View file

@ -14,6 +14,8 @@
* without button callbacks. * without button callbacks.
*/ */
import { t } from '../shared/i18n.mjs';
export const MENU_PAGE_SIZE = 10; export const MENU_PAGE_SIZE = 10;
/** 预设下拉中「跟随默认」(null) 的哨兵值,供 initial_index 命中与回调识别。 */ /** 预设下拉中「跟随默认」(null) 的哨兵值,供 initial_index 命中与回调识别。 */
@ -87,7 +89,7 @@ function initialIndex(options, currentValue) {
function safeTitle(value) { function safeTitle(value) {
const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim(); const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
return title || '暂无标题'; return title || t('暂无标题');
} }
function cardWith(headerText, elements) { function cardWith(headerText, elements) {
@ -595,10 +597,10 @@ export function cardActionProbeCard(nonce) {
if (typeof nonce !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) { if (typeof nonce !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) {
throw new TypeError('A safe card-action probe nonce is required'); throw new TypeError('A safe card-action probe nonce is required');
} }
return cardWith('🧪 验证卡片按钮', [ return cardWith(t('🧪 验证卡片按钮'), [
{ {
tag: 'div', tag: 'div',
text: markdown('授权已提交。请点击下方按钮;机器人真实收到回调后才会判定修复成功。'), text: markdown(t('授权已提交。请点击下方按钮;机器人真实收到回调后才会判定修复成功。')),
}, },
{ {
tag: 'column_set', tag: 'column_set',
@ -609,7 +611,7 @@ export function cardActionProbeCard(nonce) {
weight: 1, weight: 1,
elements: [{ elements: [{
tag: 'button', tag: 'button',
text: plainText('完成验证'), text: plainText(t('完成验证')),
type: 'primary', type: 'primary',
width: 'fill', width: 'fill',
behaviors: [{ behaviors: [{
@ -648,23 +650,29 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
], ],
}); });
const elements = [ const elements = [
{ tag: 'div', text: markdown(`**工作区**:\`${workspace}\`\n共 **${total}** 个会话${total > MENU_PAGE_SIZE ? `(第 ${page + 1}/${pageCount} 页)` : ''}`) }, { tag: 'div', text: markdown(t('**工作区**:{workspace}\n共 **{total}** 个会话{paging}', {
workspace: `\`${workspace}\``,
total,
paging: total > MENU_PAGE_SIZE
? t('(第 {page}/{pageCount} 页)', { page: page + 1, pageCount })
: '',
})) },
...slice.map((session, offset) => { ...slice.map((session, offset) => {
// Page-local numbering: number replies resolve against this page. // Page-local numbering: number replies resolve against this page.
const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? '(已归档)' : ''}`; const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? t('(已归档)') : ''}`;
const watching = watched(session.sessionId); const watching = watched(session.sessionId);
return row( return row(
buttonElement(watching ? '⭐ 取消关注' : '☆ 关注', watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`), buttonElement(watching ? t('⭐ 取消关注') : t('☆ 关注'), watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
buttonElement(label, `use:${session.sessionId}`), buttonElement(label, `use:${session.sessionId}`),
); );
}), }),
]; ];
if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`)); if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
if (page + 1 < pageCount) elements.push(button('下一页 ▶', `sessions:${page + 1}`)); if (page + 1 < pageCount) elements.push(button(t('下一页 ▶'), `sessions:${page + 1}`));
elements.push( elements.push(
{ tag: 'hr' }, { tag: 'hr' },
buttonPair('🔙 返回菜单', 'back_to_menu', '🔍 关注列表', 'watchlist'), buttonPair('🔙 返回菜单', 'back_to_menu', t('🔍 关注列表'), 'watchlist'),
{ tag: 'div', text: markdown('回复数字(1~N)绑定本页会话。') }, { tag: 'div', text: markdown(t('回复数字(1~N)绑定本页会话。')) },
); );
return cardWith('📂 会话列表', elements); return cardWith('📂 会话列表', elements);
} }
@ -675,19 +683,19 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
export function workspaceListCard(paths, current) { export function workspaceListCard(paths, current) {
const elements = paths.length === 0 const elements = paths.length === 0
? [ ? [
{ tag: 'div', text: markdown('当前 Host 上没有已登记的工作区。') }, { tag: 'div', text: markdown(t('当前 Host 上没有已登记的工作区。')) },
backButton(), backButton(),
] ]
: [ : [
{ tag: 'div', text: markdown('回复数字切换工作区,或点击按钮:') }, { tag: 'div', text: markdown(t('回复数字切换工作区,或点击按钮:')) },
...paths.map((path, index) => button( ...paths.map((path, index) => button(
`${index + 1}. ${path}${path === current ? '(当前)' : ''}`, `${index + 1}. ${path}${path === current ? t('(当前)') : ''}`,
`workspace:${path}`, `workspace:${path}`,
)), )),
{ tag: 'hr' }, { tag: 'hr' },
backButton(), backButton(),
]; ];
return cardWith('🗂 工作区', elements); return cardWith(t('🗂 工作区'), elements);
} }
// ── Watch list card (multi-select add/remove + back button) ────────────── // ── Watch list card (multi-select add/remove + back button) ──────────────
@ -706,7 +714,7 @@ function watchAddSelect(entries, availableSessions) {
return { return {
tag: 'multi_select_static', tag: 'multi_select_static',
name: 'watch_add', name: 'watch_add',
placeholder: { tag: 'plain_text', content: '勾选要关注的会话' }, placeholder: { tag: 'plain_text', content: t('勾选要关注的会话') },
options, options,
behaviors: [{ type: 'callback', value: { action: 'watch_add', kind: 'multi' } }], behaviors: [{ type: 'callback', value: { action: 'watch_add', kind: 'multi' } }],
}; };
@ -722,7 +730,7 @@ function watchRemoveSelect(entries) {
return { return {
tag: 'multi_select_static', tag: 'multi_select_static',
name: 'watch_remove', name: 'watch_remove',
placeholder: { tag: 'plain_text', content: '勾选要取消关注的会话' }, placeholder: { tag: 'plain_text', content: t('勾选要取消关注的会话') },
options, options,
behaviors: [{ type: 'callback', value: { action: 'watch_remove', kind: 'multi' } }], behaviors: [{ type: 'callback', value: { action: 'watch_remove', kind: 'multi' } }],
}; };
@ -736,19 +744,19 @@ export function watchListCard(entries, availableSessions) {
const elements = []; const elements = [];
if (watching.length === 0) { if (watching.length === 0) {
elements.push({ tag: 'div', text: markdown('当前没有关注的会话。任务完成会自动推送结果。') }); elements.push({ tag: 'div', text: markdown(t('当前没有关注的会话。任务完成会自动推送结果。')) });
} else { } else {
elements.push({ tag: 'div', text: markdown(`当前关注 **${watching.length}** 个会话:`) }); elements.push({ tag: 'div', text: markdown(t('当前关注 **{count}** 个会话:', { count: watching.length })) });
} }
if (addSelect) { if (addSelect) {
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown('**➕ 添加关注**(多选下拉勾选)') }, addSelect); elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(t('**➕ 添加关注**(多选下拉勾选)')) }, addSelect);
} }
if (removeSelect) { if (removeSelect) {
elements.push({ tag: 'hr' }, { tag: 'div', text: markdown('**➖ 取消关注**(多选下拉勾选)') }, removeSelect); elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(t('**➖ 取消关注**(多选下拉勾选)')) }, removeSelect);
} }
elements.push({ tag: 'hr' }, buttonPair('📋 会话列表', 'sessions', '🔙 返回菜单', 'back_to_menu')); elements.push({ tag: 'hr' }, buttonPair(t('📋 会话列表'), 'sessions', t('🔙 返回菜单'), 'back_to_menu'));
return cardWith('👁 关注列表', elements); return cardWith(t('👁 关注列表'), elements);
} }
// ── Completion push card ────────────────────────────────────────────────── // ── Completion push card ──────────────────────────────────────────────────
@ -759,20 +767,20 @@ export function watchListCard(entries, availableSessions) {
*/ */
export function completionCard(sessionId, title, reason) { export function completionCard(sessionId, title, reason) {
const reasonText = reason === 'completed' const reasonText = reason === 'completed'
? '已完成' ? t('已完成')
: reason === 'stopped' : reason === 'stopped'
? '已停止' ? t('已停止')
: reason === 'aborted' : reason === 'aborted'
? '已中止' ? t('已中止')
: reason === 'cancelled' : reason === 'cancelled'
? '已取消' ? t('已取消')
: '已结束'; : t('已结束');
return cardWith('✅ 任务完成', [ return cardWith(t('✅ 任务完成'), [
{ tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) }, { tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
{ tag: 'div', text: markdown(`**状态**:${reasonText}`) }, { tag: 'div', text: markdown(t('**状态**:{reason}', { reason: reasonText })) },
{ tag: 'hr' }, { tag: 'hr' },
buttonPair('📋 会话列表', 'sessions', '🔙 返回菜单', 'back_to_menu'), buttonPair(t('📋 会话列表'), 'sessions', t('🔙 返回菜单'), 'back_to_menu'),
{ tag: 'div', text: markdown('绑定该会话后可继续追问,输入文字即可。') }, { tag: 'div', text: markdown(t('绑定该会话后可继续追问,输入文字即可。')) },
]); ]);
} }

View file

@ -1,6 +1,23 @@
import { createHash } from 'node:crypto';
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
import { createDeliveryReceipt } from '../shared/semantic/delivery.mjs';
import { t } from '../shared/i18n.mjs';
const STREAM_ELEMENT_ID = 'stream_md'; const STREAM_ELEMENT_ID = 'stream_md';
const DEFAULT_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…'; const DEFAULT_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
const MAX_STREAM_CHARS = 28000; const MAX_STREAM_CHARS = 28000;
const MAX_FILE_OPERATION_TIMEOUT_MS = 120_000;
const FILE_DELIVERY_ERRORS = new Map([
[99991672, ['artifact-permission-required', 'Feishu file delivery requires the im:resource permission.']],
[234006, ['artifact-too-large', 'The result file exceeds Feishu\'s size limit.']],
[234010, ['artifact-empty', 'Feishu does not accept empty files.']],
[230017, ['artifact-provider-rejected', 'Feishu rejected the uploaded file ownership.']],
[230020, ['artifact-rate-limited', 'Feishu temporarily rate-limited file delivery.']],
[230049, ['artifact-delivery-uncertain', 'Feishu could not confirm the file message result.']],
[230055, ['artifact-provider-rejected', 'Feishu rejected the file message type.']],
]);
function assertApiSuccess(operation, response) { function assertApiSuccess(operation, response) {
if (response?.code && response.code !== 0) { if (response?.code && response.code !== 0) {
@ -9,6 +26,104 @@ function assertApiSuccess(operation, response) {
return response; return response;
} }
function providerErrorCode(cause) {
const pending = [cause];
const seen = new Set();
let fallback;
while (pending.length > 0) {
const value = pending.shift();
if (!value || seen.has(value)) continue;
if (typeof value === 'object') seen.add(value);
if (Array.isArray(value)) {
pending.push(...value);
continue;
}
const code = Number(value?.code);
if (Number.isFinite(code) && code !== 0) {
if (FILE_DELIVERY_ERRORS.has(code)) return code;
fallback ??= code;
}
pending.push(value?.response?.data, value?.data, value?.error, value?.cause);
}
return fallback;
}
function fileDeliveryError(stage, cause, providerCode, { uncertain = false } = {}) {
const explicitCode = providerCode === undefined || providerCode === null
? undefined
: Number(providerCode);
const code = Number.isFinite(explicitCode) && explicitCode !== 0
? explicitCode
: providerErrorCode(cause);
const fallback = Number.isFinite(code)
? ['artifact-provider-rejected', `Feishu rejected file ${stage}.`]
: uncertain
? ['artifact-delivery-uncertain', 'Feishu could not confirm the file message result.']
: ['artifact-provider-failed', `Feishu file ${stage} failed.`];
const [errorCode, message] = FILE_DELIVERY_ERRORS.get(code) ?? fallback;
const error = new Error(message, { cause });
error.code = errorCode;
if (Number.isFinite(code)) error.providerCode = code;
return error;
}
function boundedFileTimeout(value, name) {
if (!Number.isInteger(value) || value < 1 || value > MAX_FILE_OPERATION_TIMEOUT_MS) {
throw new TypeError(`${name} must be an integer between 1 and ${MAX_FILE_OPERATION_TIMEOUT_MS}`);
}
return value;
}
function abortReason(signal) {
return signal?.reason ?? new DOMException('The operation was aborted', 'AbortError');
}
function operationTimeout(stage) {
const error = new Error(`Feishu file ${stage} timed out.`);
error.code = 'provider-timeout';
return error;
}
function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
signal?.throwIfAborted();
const deadline = new AbortController();
const operationSignal = signal
? AbortSignal.any([signal, deadline.signal])
: deadline.signal;
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
operationSignal.removeEventListener('abort', onAbort);
callback(value);
};
const onAbort = () => finish(
reject,
signal?.aborted ? abortReason(signal) : operationTimeout(stage),
);
const timer = setTimeout(() => deadline.abort(), timeoutMs);
operationSignal.addEventListener('abort', onAbort, { once: true });
Promise.resolve().then(() => operation(operationSignal)).then(
(value) => finish(resolve, value),
(error) => finish(reject, error),
);
if (operationSignal.aborted) onAbort();
});
}
function deliveryUuid(file, chatId, messageType) {
const seed = `${file.deliveryKey}\u0000${chatId}`;
const digest = createHash('sha256')
.update(messageType === 'file' ? seed : `${seed}\u0000${messageType}`)
.digest('hex')
.slice(0, 40);
return `dshim_${digest}`;
}
function summaryOf(text) { function summaryOf(text) {
const summary = String(text ?? '').replace(/\s+/g, ' ').trim(); const summary = String(text ?? '').replace(/\s+/g, ' ').trim();
return summary.length <= 50 ? summary : `${summary.slice(0, 49)}…`; return summary.length <= 50 ? summary : `${summary.slice(0, 49)}…`;
@ -19,7 +134,7 @@ function streamingCard(initialText) {
schema: '2.0', schema: '2.0',
config: { config: {
streaming_mode: true, streaming_mode: true,
summary: { content: '正在生成…' }, summary: { content: t('正在生成…') },
streaming_config: { streaming_config: {
print_frequency_ms: { default: 70 }, print_frequency_ms: { default: 70 },
print_step: { default: 1 }, print_step: { default: 1 },
@ -39,10 +154,19 @@ function streamingCard(initialText) {
export class VerifiedFeishuChannel { export class VerifiedFeishuChannel {
#client; #client;
#initialText; #initialText;
#fileUploadTimeoutMs;
#fileMessageTimeoutMs;
constructor({ client, initialText = DEFAULT_INITIAL_TEXT }) { constructor({
client,
initialText,
fileUploadTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
fileMessageTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
}) {
this.#client = client; this.#client = client;
this.#initialText = initialText; this.#initialText = initialText ?? t(DEFAULT_INITIAL_TEXT);
this.#fileUploadTimeoutMs = boundedFileTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
this.#fileMessageTimeoutMs = boundedFileTimeout(fileMessageTimeoutMs, 'fileMessageTimeoutMs');
} }
async stream(chatId, input, options = {}) { async stream(chatId, input, options = {}) {
@ -92,7 +216,7 @@ export class VerifiedFeishuChannel {
settings: JSON.stringify({ settings: JSON.stringify({
config: { config: {
streaming_mode: false, streaming_mode: false,
summary: { content: summaryOf(lastContent) || '回答完成' }, summary: { content: summaryOf(lastContent) || t('回答完成') },
}, },
}), }),
sequence: ++sequence, sequence: ++sequence,
@ -107,6 +231,144 @@ export class VerifiedFeishuChannel {
} }
} }
async sendFile(chatId, file, { replyTo, signal } = {}) {
return this.#sendArtifact(chatId, file, {
replyTo,
signal,
messageType: 'file',
presentation: 'feishu-file',
});
}
async sendImage(chatId, file, { replyTo, signal } = {}) {
return this.#sendArtifact(chatId, file, {
replyTo,
signal,
messageType: 'image',
presentation: 'feishu-image',
});
}
async #sendArtifact(chatId, file, {
replyTo,
signal,
messageType,
presentation,
}) {
signal?.throwIfAborted();
if (typeof chatId !== 'string' || !chatId) throw new TypeError('chatId is required');
if (!file || typeof file !== 'object'
|| typeof file.artifactId !== 'string' || !file.artifactId
|| typeof file.deliveryKey !== 'string' || !file.deliveryKey
|| typeof file.fileName !== 'string' || !file.fileName
|| !Buffer.isBuffer(file.bytes)) {
throw new TypeError('A materialized result file is required');
}
let uploaded;
try {
uploaded = await waitForFileOperation((operationSignal) => {
operationSignal.throwIfAborted();
const pending = messageType === 'image'
? this.#client.im.v1.image.create({
data: {
image_type: 'message',
image: file.bytes,
},
})
: this.#client.im.v1.file.create({
data: {
file_type: 'stream',
file_name: file.fileName,
file: file.bytes,
},
});
trackOutboundArtifactProviderPromise(file, pending);
return pending;
}, {
signal,
timeoutMs: this.#fileUploadTimeoutMs,
stage: 'upload',
});
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
throw fileDeliveryError('upload', error);
}
signal?.throwIfAborted();
const resourceKey = messageType === 'image'
? uploaded?.image_key ?? uploaded?.data?.image_key
: uploaded?.file_key ?? uploaded?.data?.file_key;
if (typeof resourceKey !== 'string' || !resourceKey) {
throw fileDeliveryError('upload', undefined, uploaded?.code);
}
const uuid = deliveryUuid(file, chatId, messageType);
const content = JSON.stringify(messageType === 'image'
? { image_key: resourceKey }
: { file_key: resourceKey });
const request = replyTo
? {
path: { message_id: replyTo },
data: { msg_type: messageType, content, uuid },
}
: {
params: { receive_id_type: 'chat_id' },
data: { receive_id: chatId, msg_type: messageType, content, uuid },
};
const send = () => {
const pending = replyTo
? this.#client.im.v1.message.reply(request)
: this.#client.im.v1.message.create(request);
trackOutboundArtifactProviderPromise(file, pending);
return pending;
};
let response;
try {
response = await waitForFileOperation(async (operationSignal) => {
operationSignal.throwIfAborted();
let result;
try {
result = await send();
} catch (error) {
if (providerErrorCode(error) !== 230049) throw error;
result = { code: 230049 };
}
operationSignal.throwIfAborted();
// Feishu documents 230049 as an uncertain asynchronous send result.
// Reuse the same resource key and UUID once so the provider can deduplicate.
if (Number(result?.code) === 230049) {
result = await send();
operationSignal.throwIfAborted();
}
return result;
}, {
signal,
timeoutMs: this.#fileMessageTimeoutMs,
stage: 'message send',
});
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
throw fileDeliveryError('message send', error, undefined, { uncertain: true });
}
if (Number.isFinite(Number(response?.code)) && Number(response.code) !== 0) {
throw fileDeliveryError('message send', undefined, response.code, { uncertain: true });
}
const messageId = response?.data?.message_id;
if (typeof messageId !== 'string' || !messageId) {
throw fileDeliveryError('message send', undefined, undefined, { uncertain: true });
}
return createDeliveryReceipt({
deliveryId: file.deliveryKey,
presentation,
providerMessageIds: [messageId],
artifacts: [{
artifactId: file.artifactId,
outcome: 'sent',
}],
});
}
async #sendCard(chatId, cardId, replyTo) { async #sendCard(chatId, cardId, replyTo) {
const content = JSON.stringify({ type: 'card', data: { card_id: cardId } }); const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
const response = replyTo const response = replyTo

View file

@ -7,6 +7,7 @@ import {
connectionTestTargetUnavailable, connectionTestTargetUnavailable,
sendRememberedConnectionTest, sendRememberedConnectionTest,
} from '../shared/connection-test.mjs'; } from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
const CALLBACK_PROBE_SUCCESS_NOTICE = '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。'; const CALLBACK_PROBE_SUCCESS_NOTICE = '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。';
@ -206,7 +207,7 @@ export class FeishuRuntime {
this.#client = new this.#lark.Client(larkConfig); this.#client = new this.#lark.Client(larkConfig);
const channel = new VerifiedFeishuChannel({ const channel = new VerifiedFeishuChannel({
client: this.#client, client: this.#client,
initialText: '已连接 DeepSeek Harness,正在思考…', initialText: t('已连接 DeepSeek Harness,正在思考…'),
}); });
this.#bridge = new FeishuHarnessBridge({ this.#bridge = new FeishuHarnessBridge({
client: this.#client, client: this.#client,
@ -340,7 +341,7 @@ export class FeishuRuntime {
} catch { } catch {
void this.#sendCardActionProbeNotice( void this.#sendCardActionProbeNotice(
operatorOpenId, operatorOpenId,
CALLBACK_PROBE_SEND_FAILURE_NOTICE, t(CALLBACK_PROBE_SEND_FAILURE_NOTICE),
'failure', 'failure',
); );
throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试'); throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试');
@ -348,7 +349,7 @@ export class FeishuRuntime {
if (response?.code && response.code !== 0) { if (response?.code && response.code !== 0) {
void this.#sendCardActionProbeNotice( void this.#sendCardActionProbeNotice(
operatorOpenId, operatorOpenId,
CALLBACK_PROBE_SEND_FAILURE_NOTICE, t(CALLBACK_PROBE_SEND_FAILURE_NOTICE),
'failure', 'failure',
); );
throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试'); throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试');
@ -358,7 +359,7 @@ export class FeishuRuntime {
if (!messageId) { if (!messageId) {
void this.#sendCardActionProbeNotice( void this.#sendCardActionProbeNotice(
operatorOpenId, operatorOpenId,
CALLBACK_PROBE_SEND_FAILURE_NOTICE, t(CALLBACK_PROBE_SEND_FAILURE_NOTICE),
'failure', 'failure',
); );
throw probeError('card_action_probe_send_failed', '飞书未返回测试卡片的消息 ID'); throw probeError('card_action_probe_send_failed', '飞书未返回测试卡片的消息 ID');
@ -371,7 +372,7 @@ export class FeishuRuntime {
this.#pendingCardActionProbes.delete(messageId); this.#pendingCardActionProbes.delete(messageId);
void this.#sendCardActionProbeNotice( void this.#sendCardActionProbeNotice(
operatorOpenId, operatorOpenId,
CALLBACK_PROBE_TIMEOUT_NOTICE, t(CALLBACK_PROBE_TIMEOUT_NOTICE),
'timeout', 'timeout',
); );
reject(probeError( reject(probeError(
@ -413,7 +414,7 @@ export class FeishuRuntime {
// the callback proof itself. // the callback proof itself.
void this.#sendCardActionProbeNotice( void this.#sendCardActionProbeNotice(
operatorOpenId, operatorOpenId,
CALLBACK_PROBE_SUCCESS_NOTICE, t(CALLBACK_PROBE_SUCCESS_NOTICE),
'success', 'success',
).finally(() => { ).finally(() => {
probe.resolve({ probe.resolve({
@ -482,10 +483,10 @@ export class FeishuRuntime {
return sendRememberedConnectionTest({ return sendRememberedConnectionTest({
state: this.#state, state: this.#state,
text, text,
channelLabel: '飞书机器人', channelLabel: t('飞书机器人'),
send: async (target, content) => { send: async (target, content) => {
const chatId = typeof target?.chatId === 'string' ? target.chatId.trim() : ''; const chatId = typeof target?.chatId === 'string' ? target.chatId.trim() : '';
if (!chatId) throw connectionTestTargetUnavailable('飞书机器人'); if (!chatId) throw connectionTestTargetUnavailable(t('飞书机器人'));
await send('chat_id', chatId, content); await send('chat_id', chatId, content);
}, },
}); });
@ -500,7 +501,7 @@ export class FeishuRuntime {
clearTimeout(probe.timeout); clearTimeout(probe.timeout);
void this.#sendCardActionProbeNotice( void this.#sendCardActionProbeNotice(
probe.expectedOperatorOpenId, probe.expectedOperatorOpenId,
CALLBACK_PROBE_ABORT_NOTICE, t(CALLBACK_PROBE_ABORT_NOTICE),
'abort', 'abort',
); );
probe.reject(probeError('abort', '飞书运行时已停止')); probe.reject(probeError('abort', '飞书运行时已停止'));

View file

@ -1,4 +1,5 @@
import { ImagePromptError } from '../shared/image-prompt.mjs'; import { ImagePromptError } from '../shared/image-prompt.mjs';
import { t } from '../shared/i18n.mjs';
const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672; const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
const FEISHU_ERROR_BODY_LIMIT = 64 * 1024; const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
@ -113,7 +114,7 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
throw new ImagePromptError( throw new ImagePromptError(
'image-too-large', 'image-too-large',
`Feishu image exceeds ${maxBytes} bytes`, `Feishu image exceeds ${maxBytes} bytes`,
'图片超过 5 MB,请压缩后重试。', t('图片超过 5 MB,请压缩后重试。'),
); );
} }
chunks.push(data); chunks.push(data);
@ -125,6 +126,31 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
} }
} }
async function readStream(stream, { signal }) {
if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
throw new Error('Feishu file download returned no readable stream');
}
signal?.throwIfAborted();
const abort = () => stream.destroy?.(
signal.reason ?? new DOMException('Feishu file download aborted', 'AbortError'),
);
signal?.addEventListener('abort', abort, { once: true });
const chunks = [];
let size = 0;
try {
for await (const chunk of stream) {
signal?.throwIfAborted();
const data = Buffer.from(chunk);
size += data.length;
chunks.push(data);
}
signal?.throwIfAborted();
return Buffer.concat(chunks, size);
} finally {
signal?.removeEventListener('abort', abort);
}
}
function providerCode(value) { function providerCode(value) {
if (!value || typeof value !== 'object') return null; if (!value || typeof value !== 'object') return null;
const code = value.code ?? value.error?.code; const code = value.code ?? value.error?.code;
@ -196,7 +222,7 @@ async function feishuImageDownloadError(error, signal) {
return new ImagePromptError( return new ImagePromptError(
'feishu-image-permission-required', 'feishu-image-permission-required',
'Feishu image download requires the im:message:readonly tenant scope', 'Feishu image download requires the im:message:readonly tenant scope',
FEISHU_IMAGE_PERMISSION_MESSAGE, t(FEISHU_IMAGE_PERMISSION_MESSAGE),
{ cause: error }, { cause: error },
); );
} }
@ -224,7 +250,7 @@ function feishuImageSource(event, client, key) {
throw new ImagePromptError( throw new ImagePromptError(
'image-too-large', 'image-too-large',
`Feishu image declares ${size} bytes; the limit is ${maxBytes}`, `Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
'图片超过 5 MB,请压缩后重试。', t('图片超过 5 MB,请压缩后重试。'),
); );
} }
return readBoundedStream(resource?.getReadableStream?.(), { signal, maxBytes }); return readBoundedStream(resource?.getReadableStream?.(), { signal, maxBytes });
@ -232,6 +258,26 @@ function feishuImageSource(event, client, key) {
}; };
} }
function feishuFileSource(event, client, file) {
const key = nonEmptyString(file?.file_key);
if (!key) return null;
return {
name: nonEmptyString(file?.file_name) ?? 'file',
async load({ signal } = {}) {
signal?.throwIfAborted();
const resource = await client?.im?.v1?.messageResource?.get?.({
path: {
message_id: event.message.message_id,
file_key: key,
},
params: { type: 'file' },
});
signal?.throwIfAborted();
return readStream(resource?.getReadableStream?.(), { signal });
},
};
}
export function extractInboundMessage(event, client) { export function extractInboundMessage(event, client) {
const messageType = event?.message?.message_type; const messageType = event?.message?.message_type;
const parsed = parsedMessageContent(event); const parsed = parsedMessageContent(event);
@ -240,9 +286,11 @@ export function extractInboundMessage(event, client) {
? nonEmptyString(parsed?.image_key) ? nonEmptyString(parsed?.image_key)
: null; : null;
const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? []; const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
return { return {
content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '', content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
images: imageKeys.map((key) => feishuImageSource(event, client, key)), images: imageKeys.map((key) => feishuImageSource(event, client, key)),
files: file ? [file] : [],
}; };
} }

View file

@ -9,6 +9,7 @@ export const REQUIRED_TENANT_SCOPES = Object.freeze([
'im:message:send_as_bot', 'im:message:send_as_bot',
'im:message.reactions:write_only', 'im:message.reactions:write_only',
'im:message:recall', 'im:message:recall',
'im:resource',
'cardkit:card:write', 'cardkit:card:write',
]); ]);

View file

@ -1,5 +1,6 @@
import { setTimeout as sleep } from 'node:timers/promises'; import { setTimeout as sleep } from 'node:timers/promises';
import { t } from '../shared/i18n.mjs';
import { harnessApprovalText } from '../shared/harness-approval.mjs'; import { harnessApprovalText } from '../shared/harness-approval.mjs';
import { import {
harnessAnswerForQuestion, harnessAnswerForQuestion,
@ -21,23 +22,23 @@ function clean(value, max = 8_000) {
function safeFailure(error) { function safeFailure(error) {
if (error?.code === 'office-job-alias-invalid') return error.message; if (error?.code === 'office-job-alias-invalid') return error.message;
if (error?.code === 'turn-stopped') return '本机 Harness 已停止这次执行。'; if (error?.code === 'turn-stopped') return t('本机 Harness 已停止这次执行。');
if (error?.code === 'office-job-conflict') return 'Office Job 已被领取、取消或结束。'; if (error?.code === 'office-job-conflict') return t('Office Job 已被领取、取消或结束。');
return '本机 Harness 未能完成任务;请检查 Harness 会话后重试。'; return t('本机 Harness 未能完成任务;请检查 Harness 会话后重试。');
} }
function renderPrompt(job, preset) { function renderPrompt(job, preset) {
return [ return [
'# AI Office Handoff', '# AI Office Handoff',
'', '',
'你正在本机 DeepSeek Harness 中继续一个来自 AI Office 的任务。', t('你正在本机 DeepSeek Harness 中继续一个来自 AI Office 的任务。'),
'只在当前 Workspace 内行动。完成后必须返回:结果摘要、改动文件、验证证据、未解决风险。', t('只在当前 Workspace 内行动。完成后必须返回:结果摘要、改动文件、验证证据、未解决风险。'),
'', '',
'## 本机 Instruction Preset', t('## 本机 Instruction Preset'),
preset, preset,
'', '',
...(clean(job.instruction) ? ['## 本轮补充指令', clean(job.instruction), ''] : []), ...(clean(job.instruction) ? [t('## 本轮补充指令'), clean(job.instruction), ''] : []),
'## Office 时间线', t('## Office 时间线'),
clean(job.markdown, 200_000), clean(job.markdown, 200_000),
].join('\n'); ].join('\n');
} }
@ -54,7 +55,7 @@ function approvalRequest(interaction) {
return { return {
id, id,
kind: 'approval', kind: 'approval',
title: `批准 ${clean(interaction.payload?.toolName, 120) || 'Harness 工具'}`, title: t('批准 {tool}', { tool: clean(interaction.payload?.toolName, 120) || t('Harness 工具') }),
prompt, prompt,
toolName: clean(interaction.payload?.toolName, 120), toolName: clean(interaction.payload?.toolName, 120),
}; };
@ -66,7 +67,7 @@ function approvalRequest(interaction) {
return { return {
id, id,
kind: 'question', kind: 'question',
title: questions.length > 1 ? `Harness 需要 ${questions.length} 项补充信息` : clean(questions[0].header || questions[0].question, 160), title: questions.length > 1 ? t('Harness 需要 {count} 项补充信息', { count: questions.length }) : clean(questions[0].header || questions[0].question, 160),
prompt: questions.map((question, index) => harnessQuestionText(question, index, questions.length)).join('\n\n---\n\n'), prompt: questions.map((question, index) => harnessQuestionText(question, index, questions.length)).join('\n\n---\n\n'),
questions, questions,
}; };
@ -216,14 +217,14 @@ export class OfficeJobExecutor {
const workspace = this.#config.workspaces[job.workspaceAlias]; const workspace = this.#config.workspaces[job.workspaceAlias];
const preset = this.#config.instructionPresets[job.instructionPreset]; const preset = this.#config.instructionPresets[job.instructionPreset];
if (!workspace || !preset) { if (!workspace || !preset) {
const error = new Error('Office Job 引用了本机未配置的 Workspace/Preset alias。'); const error = new Error(t('Office Job 引用了本机未配置的 Workspace/Preset alias。'));
error.code = 'office-job-alias-invalid'; error.code = 'office-job-alias-invalid';
throw error; throw error;
} }
entry.harness = this.#createHarness({ workspace }); entry.harness = this.#createHarness({ workspace });
await this.#progress(jobId, entry, { kind: 'status', message: `已领取 Job,准备 Workspace alias:${job.workspaceAlias}` }, true); await this.#progress(jobId, entry, { kind: 'status', message: t('已领取 Job,准备 Workspace alias:{alias}', { alias: job.workspaceAlias }) }, true);
entry.sessionId = await entry.harness.createSession({ signal, workspace }); entry.sessionId = await entry.harness.createSession({ signal, workspace });
await this.#progress(jobId, entry, { kind: 'status', message: 'Harness Session 已创建。', sessionId: entry.sessionId }, true); await this.#progress(jobId, entry, { kind: 'status', message: t('Harness Session 已创建。'), sessionId: entry.sessionId }, true);
const answer = await entry.harness.ask(entry.sessionId, renderPrompt(job, preset), { const answer = await entry.harness.ask(entry.sessionId, renderPrompt(job, preset), {
timeoutMs: 30 * 60_000, timeoutMs: 30 * 60_000,
signal, signal,
@ -300,7 +301,7 @@ export class OfficeJobExecutor {
} }
async #handleUpdate(jobId, entry, update) { async #handleUpdate(jobId, entry, update) {
const message = update?.type === 'tool' ? `正在使用 ${clean(update.name, 160) || 'Harness 工具'}…` const message = update?.type === 'tool' ? t('正在使用 {tool}…', { tool: clean(update.name, 160) || t('Harness 工具') })
: clean(update?.text, 4_000); : clean(update?.text, 4_000);
if (!message) return; if (!message) return;
const key = `${update.type}:${message}`; const key = `${update.type}:${message}`;

View file

@ -1,5 +1,6 @@
import { setTimeout as sleep } from 'node:timers/promises'; import { setTimeout as sleep } from 'node:timers/promises';
import { t } from '../shared/i18n.mjs';
import { OfficeTransport } from './office-transport.mjs'; import { OfficeTransport } from './office-transport.mjs';
import { OFFICE_PROTOCOL_VERSION } from './protocol.mjs'; import { OFFICE_PROTOCOL_VERSION } from './protocol.mjs';
import { OfficeJobExecutor } from './office-job-executor.mjs'; import { OfficeJobExecutor } from './office-job-executor.mjs';
@ -9,12 +10,12 @@ const RETRY_DELAYS = Object.freeze([1_000, 3_000, 10_000, 30_000]);
function safeConnectionError(error) { function safeConnectionError(error) {
const code = typeof error?.code === 'string' ? error.code : 'office-connection-failed'; const code = typeof error?.code === 'string' ? error.code : 'office-connection-failed';
const messages = { const messages = {
'invalid-device-token': 'AI Office 拒绝了 Device Token。', 'invalid-device-token': t('AI Office 拒绝了 Device Token。'),
'office-hook-unavailable': 'AI Office Connector Hook 尚未就绪。', 'office-hook-unavailable': t('AI Office Connector Hook 尚未就绪。'),
'office-protocol-mismatch': 'AI Office Connector 协议版本不兼容。', 'office-protocol-mismatch': t('AI Office Connector 协议版本不兼容。'),
'office-transport-failed': '本机暂时无法访问 AI Office。', 'office-transport-failed': t('本机暂时无法访问 AI Office。'),
}; };
return { code, message: messages[code] ?? 'AI Office 连接已中断。' }; return { code, message: messages[code] ?? t('AI Office 连接已中断。') };
} }
export class OfficeRuntime { export class OfficeRuntime {

View file

@ -2,6 +2,8 @@ import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { t } from '../shared/i18n.mjs';
const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) }); const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
function cleanString(value) { function cleanString(value) {
@ -30,7 +32,7 @@ export function deriveQqBotIdentity(appId) {
export function maskQqAppId(appId) { export function maskQqAppId(appId) {
const value = cleanString(appId) ?? ''; const value = cleanString(appId) ?? '';
if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : 'QQ机器人'; if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : t('QQ机器人');
return `${value.slice(0, 6)}••••${value.slice(-4)}`; return `${value.slice(0, 6)}••••${value.slice(-4)}`;
} }

View file

@ -0,0 +1,178 @@
// QQ markdown 回复投递:长文尽量按结构边界切分,以 msg_type=2 发送,
// 平台拒绝 markdown 时逐条回退纯文本。
import { t } from '../shared/i18n.mjs';
const DEFAULT_CHUNK_LIMIT = 4_500;
const CODE_FENCE_OPEN = /^```/;
const GFM_TABLE_LINE = /^\|.+\|$/;
const PASSIVE_REPLY_LIMIT = Object.freeze({ c2c: 4, group: 5 });
const PARTIAL_REPLY_NOTICE = () => t('回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。');
function safeSliceIndex(value, limit) {
let index = Math.min(limit, value.length);
const before = value.charCodeAt(index - 1);
const after = value.charCodeAt(index);
if (before >= 0xD800 && before <= 0xDBFF && after >= 0xDC00 && after <= 0xDFFF) {
index -= 1;
}
return Math.max(1, index);
}
/**
* 按换行边界切分 Markdown 文本:
* - 不在代码块中间断开;
* - 不在 GFM 表格中间断开;
* - 超长行在 limit 处硬切,避免单行超限无法投递。
*/
export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
const value = typeof text === 'string' ? text : '';
const bound = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_CHUNK_LIMIT;
if (value.length <= bound) return value ? [value] : [];
const lines = value.split('\n');
const chunks = [];
let current = '';
let inCodeBlock = false;
let tableBuffer = [];
const appendBlock = (block) => {
if (block.length <= bound) {
if (!current) {
current = block;
return;
}
const candidate = `${current}\n${block}`;
if (candidate.length > bound) {
chunks.push(current);
current = block;
} else {
current = candidate;
}
return;
}
// 超大块:收束当前块后按 bound 硬切,保证每块可投递。
if (current) {
chunks.push(current);
current = '';
}
let remaining = block;
while (remaining.length > bound) {
const index = safeSliceIndex(remaining, bound);
chunks.push(remaining.slice(0, index));
remaining = remaining.slice(index);
}
current = remaining;
};
const flushTable = () => {
if (tableBuffer.length === 0) return;
const block = tableBuffer.join('\n');
tableBuffer = [];
appendBlock(block);
};
const appendLine = (line) => {
let remaining = line;
// 超长行先硬切,保证每块不超过 bound。
while (remaining.length > bound) {
if (current) {
chunks.push(current);
current = '';
}
const index = safeSliceIndex(remaining, bound);
chunks.push(remaining.slice(0, index));
remaining = remaining.slice(index);
}
appendBlock(remaining);
};
for (const line of lines) {
if (CODE_FENCE_OPEN.test(line)) {
flushTable();
if (!inCodeBlock && current) {
// 代码块开启:先收束当前块,让整个代码块从新块开始。
chunks.push(current);
current = '';
}
inCodeBlock = !inCodeBlock;
appendLine(line);
continue;
}
if (inCodeBlock) {
appendLine(line);
continue;
}
if (GFM_TABLE_LINE.test(line)) {
tableBuffer.push(line);
continue;
}
flushTable();
appendLine(line);
}
flushTable();
if (current) chunks.push(current);
return chunks;
}
function nextMsgSeq() {
// 与 SDK getNextMsgSeq 相同的随机策略:被动回复同 msg_id 的多条消息
// 各自带不同 msg_seq,避免平台去重(错误码 40054005)。
const timePart = Date.now() % 100_000_000;
const random = Math.floor(Math.random() * 65_536);
return (timePart ^ random) % 65_536;
}
/**
* 以 markdown(msg_type=2)发送回复;单条被平台拒绝时回退纯文本(msg_type=0)。
* 返回每条消息的平台响应,供调用方提取 provider message ids。
*/
export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
const chunks = chunkMarkdownText(text);
const results = [];
const passiveLimit = target?.msgId ? PASSIVE_REPLY_LIMIT[target.scope] : null;
const overflow = passiveLimit !== null && chunks.length > passiveLimit;
const passiveContentCount = overflow ? passiveLimit - 1 : chunks.length;
const proactiveTarget = target?.msgId
? { scope: target.scope, targetId: target.targetId }
: target;
let partialNoticeSent = false;
const sendPartialNotice = async () => {
if (partialNoticeSent || !target?.msgId) return;
partialNoticeSent = true;
try {
results.push(await bot.sendText(target, PARTIAL_REPLY_NOTICE()));
} catch (error) {
logger?.warn?.('[dsh-im:qq] unable to send partial reply notice:', error);
}
};
for (const [index, chunk] of chunks.entries()) {
const deliveryTarget = overflow && index >= passiveContentCount
? proactiveTarget
: target;
if (typeof bot?.send === 'function') {
try {
results.push(await bot.send({
target: deliveryTarget,
msgType: 2,
markdown: { content: chunk },
extra: { msg_seq: nextMsgSeq() },
}));
continue;
} catch (error) {
logger?.warn?.('[dsh-im:qq] markdown delivery failed; retrying as plain text:', error);
}
}
try {
results.push(await bot.sendText(deliveryTarget, chunk));
} catch (error) {
if (results.length === 0) throw error;
await sendPartialNotice();
break;
}
}
return results;
}

View file

@ -26,8 +26,26 @@ import {
imagePromptUserMessage, imagePromptUserMessage,
promptContentForMessage, promptContentForMessage,
} from '../shared/image-prompt.mjs'; } from '../shared/image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
prefetchInboundFiles,
} from '../shared/inbound-file.mjs';
import {
trackOutboundArtifactProviderPromise,
} from '../shared/semantic/artifact.mjs';
import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
import {
createDeliveryReceipt,
providerMessageIdsFor,
} from '../shared/semantic/delivery.mjs';
import { sendMarkdownReply } from './markdown-reply.mjs';
import { t } from '../shared/i18n.mjs';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。'; function interactionResolvedText() {
return t('这个问题已在其他客户端处理,无需再次回答。');
}
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
export const QQ_IMAGE_HOSTS = Object.freeze([ export const QQ_IMAGE_HOSTS = Object.freeze([
'.myqcloud.com', '.myqcloud.com',
@ -40,28 +58,30 @@ export const QQ_IMAGE_HOSTS = Object.freeze([
const QQ_IMAGE_FILENAME = /\.(?:gif|jpe?g|png|webp)$/i; const QQ_IMAGE_FILENAME = /\.(?:gif|jpe?g|png|webp)$/i;
const HELP_TEXT = [ function helpText() {
'QQ 机器人已连接 DeepSeek Harness。', return [
'', t('QQ 机器人已连接 DeepSeek Harness。'),
'直接发送文字或图片即可继续当前会话。', '',
'/new 开启一个全新会话', t('直接发送文字、图片或文件即可继续当前会话。'),
'/compact 压缩当前会话的较早上下文', t('/new 开启一个全新会话'),
'/workspace 工作区绝对路径 切换工作区', t('/compact 压缩当前会话的较早上下文'),
'/workspacelist 列出工作区绝对路径', t('/workspace 工作区绝对路径 切换工作区'),
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题', t('/workspacelist 列出工作区绝对路径'),
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话', t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
'/models 按序号列出所有可用模型', t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
'/model [序号或完整模型ID] 查看或切换当前会话模型', t('/models 按序号列出所有可用模型'),
'示例:先发 /models,再发 /model 2', t('/model [序号或完整模型ID] 查看或切换当前会话模型'),
'/presetlist 按序号列出可用 Agent Preset', t('示例:先发 /models,再发 /model 2'),
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset', t('/presetlist 按序号列出可用 Agent Preset'),
'纯数字 ID:/preset id:<ID>', t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
'/preset --default 跟随 Host 默认', t('纯数字 ID:/preset id:<ID>'),
'/stop 停止当前任务', t('/preset --default 跟随 Host 默认'),
'/steer 补充指令 纠偏当前任务', t('/stop 停止当前任务'),
'/status 检查连接状态', t('/steer 补充指令 纠偏当前任务'),
'/help 显示本帮助', t('/status 检查连接状态'),
].join('\n'); t('/help 显示本帮助'),
].join('\n');
}
function conversationKey(message) { function conversationKey(message) {
return `${message.kind}:${message.kind === 'group' ? message.groupOpenid : message.senderId}`; return `${message.kind}:${message.kind === 'group' ? message.groupOpenid : message.senderId}`;
@ -88,43 +108,213 @@ function hasQqImageAttachments(message) {
&& message.attachments.some(isQqImageAttachment); && message.attachments.some(isQqImageAttachment);
} }
function hasQqFileAttachments(message) {
return Array.isArray(message?.attachments)
&& message.attachments.some((attachment) => !isQqImageAttachment(attachment));
}
async function fetchQqFileBuffer(url, { fetchImpl, signal }) {
const normalizedUrl = url.startsWith('//') ? `https:${url}` : url;
const response = await fetchImpl(new URL(normalizedUrl), {
method: 'GET',
signal,
redirect: 'follow',
});
if (!response?.ok) {
await response?.body?.cancel?.().catch?.(() => undefined);
throw new Error(`QQ file download failed with HTTP ${response?.status ?? 'unknown'}`);
}
return Buffer.from(await response.arrayBuffer());
}
/** Convert QQ's attachment metadata into lazily downloaded image references. */ /** Convert QQ's attachment metadata into lazily downloaded image references. */
export function qqInboundMessage(message, { fetchImpl = fetch } = {}) { export function qqInboundMessage(message, { fetchImpl = fetch } = {}) {
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function'); if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
const images = []; const images = [];
const files = [];
for (const attachment of message?.attachments ?? []) { for (const attachment of message?.attachments ?? []) {
if (!isQqImageAttachment(attachment)) continue;
const url = nonEmptyString(attachment?.url); const url = nonEmptyString(attachment?.url);
const name = nonEmptyString(attachment?.filename) ?? undefined; const name = nonEmptyString(attachment?.filename) ?? undefined;
const mediaType = attachmentMediaType(attachment); const mediaType = attachmentMediaType(attachment);
const declaredSize = Number(attachment?.size); const declaredSize = Number(attachment?.size);
images.push({ if (isQqImageAttachment(attachment)) {
...(name ? { name } : {}), images.push({
...(mediaType?.startsWith('image/') ? { mediaType } : {}), ...(name ? { name } : {}),
...(mediaType?.startsWith('image/') ? { mediaType } : {}),
...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
load: ({ signal, maxBytes }) => {
if (!url) throw new Error('QQ image attachment has no download URL');
return fetchImageBuffer(url, {
fetchImpl,
signal,
maxBytes,
allowedHosts: QQ_IMAGE_HOSTS,
});
},
});
continue;
}
files.push({
name: name ?? (files.length === 0 ? 'file' : `file-${files.length + 1}`),
...(mediaType?.includes('/') ? { mediaType } : {}),
...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}), ...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
load: ({ signal, maxBytes }) => { load: ({ signal } = {}) => {
if (!url) throw new Error('QQ image attachment has no download URL'); if (!url) throw new Error('QQ file attachment has no download URL');
return fetchImageBuffer(url, { return fetchQqFileBuffer(url, { fetchImpl, signal });
fetchImpl,
signal,
maxBytes,
allowedHosts: QQ_IMAGE_HOSTS,
});
}, },
}); });
} }
return { content: safeText(message), images }; return { content: safeText(message), images, files };
} }
function nonEmptyString(value) { function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
} }
function artifactFailureText(fileName, error) {
const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
if (error?.name === 'UploadDailyLimitExceededError') {
return t('结果文件「{name}」已生成,但 QQ 今日文件上传额度已用完,请稍后重试。', { name });
}
switch (error?.code) {
case 'artifact-delivery-uncertain':
return t('结果文件「{name}」的发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。', { name });
case 'artifact-permission-required':
return t('结果文件「{name}」已生成,但当前 QQ 机器人没有文件消息权限。', { name });
case 'artifact-too-large':
return t('结果文件「{name}」超过当前 QQ 机器人可发送的文件大小,未发送。', { name });
case 'artifact-empty':
return t('结果文件「{name}」为空,QQ 不允许发送空文件。', { name });
case 'artifact-changed':
case 'artifact-invalid':
case 'artifact-unavailable':
return t('结果文件「{name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。', { name });
case 'artifact-rate-limited':
return t('结果文件「{name}」暂时被 QQ 限流,未能发送,请稍后重试。', { name });
case 'artifact-provider-rejected':
return t('结果文件「{name}」已生成,但 QQ 拒绝了该文件或文件消息。', { name });
default:
return t('结果文件「{name}」已生成,但暂时未能通过 QQ 发送,请稍后重试。', { name });
}
}
function answerTextForDelivery(answer, artifacts) {
if (typeof answer === 'string' && answer.trim()) return answer;
return artifacts.length > 0 ? t('结果文件已生成。') : answer;
}
function qqArtifactError(error, { dispatched = false } = {}) {
if (error?.code?.startsWith?.('artifact-') || error?.name === 'UploadDailyLimitExceededError') {
return error;
}
const status = Number(error?.httpStatus);
const wrapped = new Error('QQ file delivery failed', { cause: error });
if (status === 401 || status === 403) wrapped.code = 'artifact-permission-required';
else if (status === 413) wrapped.code = 'artifact-too-large';
else if (status === 429) wrapped.code = 'artifact-rate-limited';
else if ([400, 404, 405, 406, 410, 415, 422].includes(status)) {
wrapped.code = 'artifact-provider-rejected';
} else {
wrapped.code = dispatched ? 'artifact-delivery-uncertain' : 'artifact-provider-failed';
}
return wrapped;
}
function abortReason(signal) {
return signal?.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted', 'AbortError');
}
function waitWithSignal(promise, signal) {
if (!signal) return promise;
signal.throwIfAborted();
return new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
signal.removeEventListener('abort', onAbort);
callback(value);
};
const onAbort = () => finish(reject, abortReason(signal));
signal.addEventListener('abort', onAbort, { once: true });
Promise.resolve(promise).then(
(value) => finish(resolve, value),
(error) => finish(reject, error),
);
if (signal.aborted) onAbort();
});
}
/** Send one materialized artifact through QQ's native image message. */
export async function sendQqImage(
bot,
target,
file,
{ signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
) {
signal?.throwIfAborted();
if (typeof bot?.sendImage !== 'function') {
const unavailable = new Error('QQ image delivery is unavailable');
unavailable.code = 'artifact-provider-unavailable';
throw unavailable;
}
try {
const timeout = AbortSignal.timeout(timeoutMs);
const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
const pending = bot.sendImage(
target,
{ buffer: file.bytes },
{ onProgress: () => signal?.throwIfAborted() },
);
trackOutboundArtifactProviderPromise(file, pending);
return await waitWithSignal(pending, waitSignal);
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
throw qqArtifactError(error, { dispatched: true });
}
}
async function sendQqFile(
bot,
target,
file,
{ signal, timeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS } = {},
) {
signal?.throwIfAborted();
if (typeof bot?.sendFile !== 'function') {
const unavailable = new Error('QQ file delivery is unavailable');
unavailable.code = 'artifact-provider-unavailable';
throw unavailable;
}
try {
const timeout = AbortSignal.timeout(timeoutMs);
const waitSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
const pending = bot.sendFile(
target,
{ buffer: file.bytes },
{
fileName: file.fileName,
onProgress: () => signal?.throwIfAborted(),
},
);
trackOutboundArtifactProviderPromise(file, pending);
return await waitWithSignal(pending, waitSignal);
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
throw qqArtifactError(error, { dispatched: true });
}
}
function canClaimInteractionReply(message, pending) { function canClaimInteractionReply(message, pending) {
return pending.questions[pending.index] return pending.questions[pending.index]
&& nonEmptyString(message?.senderId) === pending.actor && nonEmptyString(message?.senderId) === pending.actor
&& (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE') && (message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE')
&& !hasQqImageAttachments(message) && !hasQqImageAttachments(message)
&& !hasQqFileAttachments(message)
&& nonEmptyString(safeText(message)); && nonEmptyString(safeText(message));
} }
@ -133,6 +323,8 @@ export function createQqBridgeStatus() {
messagesReceived: 0, messagesReceived: 0,
messagesReplied: 0, messagesReplied: 0,
messagesRejected: 0, messagesRejected: 0,
artifactsSent: 0,
artifactSendErrors: 0,
lastMessageAt: null, lastMessageAt: null,
lastReplyAt: null, lastReplyAt: null,
lastRejectedAt: null, lastRejectedAt: null,
@ -150,6 +342,7 @@ export class QqHarnessBridge {
#replyTimeoutMs; #replyTimeoutMs;
#signal; #signal;
#fetchImpl; #fetchImpl;
#fileUploadTimeoutMs;
#queues = new Map(); #queues = new Map();
#pendingInteractions = new Map(); #pendingInteractions = new Map();
#interactionKeys = new Map(); #interactionKeys = new Map();
@ -168,11 +361,15 @@ export class QqHarnessBridge {
replyTimeoutMs = 600_000, replyTimeoutMs = 600_000,
signal, signal,
fetchImpl = fetch, fetchImpl = fetch,
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
}) { }) {
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required'); if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required');
if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required'); if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required');
if (!harness || !state) throw new TypeError('Harness client and state store are required'); if (!harness || !state) throw new TypeError('Harness client and state store are required');
if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function'); if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
if (!Number.isInteger(fileUploadTimeoutMs) || fileUploadTimeoutMs < 1) {
throw new TypeError('fileUploadTimeoutMs must be a positive integer');
}
this.#bot = bot; this.#bot = bot;
this.#ownerUserOpenid = ownerUserOpenid; this.#ownerUserOpenid = ownerUserOpenid;
this.#harness = harness; this.#harness = harness;
@ -182,6 +379,7 @@ export class QqHarnessBridge {
this.#replyTimeoutMs = replyTimeoutMs; this.#replyTimeoutMs = replyTimeoutMs;
this.#signal = signal; this.#signal = signal;
this.#fetchImpl = fetchImpl; this.#fetchImpl = fetchImpl;
this.#fileUploadTimeoutMs = Math.min(fileUploadTimeoutMs, DEFAULT_FILE_UPLOAD_TIMEOUT_MS);
this.#approvals = new HarnessApprovalQueue({ label: 'qq', logger }); this.#approvals = new HarnessApprovalQueue({ label: 'qq', logger });
} }
@ -207,7 +405,7 @@ export class QqHarnessBridge {
} }
const pending = this.#pendingInteractions.get(key); const pending = this.#pendingInteractions.get(key);
const commandText = safeText(message); const commandText = safeText(message);
const commandRunner = isControlCommand(commandText) const commandRunner = hasQqFileAttachments(message) ? null : isControlCommand(commandText)
? runControlCommand ? runControlCommand
: (isModelCommand(commandText) : (isModelCommand(commandText)
? runModelCommand ? runModelCommand
@ -227,7 +425,7 @@ export class QqHarnessBridge {
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return; if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error); this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:qq] failed to process a command:', error); this.#logger.error?.('[dsh-im:qq] failed to process a command:', error);
return this.#bot.sendText(message.replyTarget, '消息处理失败,请稍后重试。') return this.#bot.sendText(message.replyTarget, t('消息处理失败,请稍后重试。'))
.catch(() => undefined); .catch(() => undefined);
}).finally(() => { }).finally(() => {
this.#acceptedMessageIds.delete(messageId); this.#acceptedMessageIds.delete(messageId);
@ -240,7 +438,7 @@ export class QqHarnessBridge {
key, key,
actor: sender, actor: sender,
messageId, messageId,
text: hasQqImageAttachments(message) ? '' : safeText(message), text: hasQqImageAttachments(message) || hasQqFileAttachments(message) ? '' : safeText(message),
addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE', addressed: message.kind !== 'group' || message.rawEventType === 'GROUP_AT_MESSAGE_CREATE',
hasPendingQuestion: Boolean(pending), hasPendingQuestion: Boolean(pending),
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@ -295,10 +493,19 @@ export class QqHarnessBridge {
releaseMessageId = true, releaseMessageId = true,
alreadyRecorded = false, alreadyRecorded = false,
} = {}) { } = {}) {
const allowed = this.#ownerUserOpenid === '*' || message.senderId === this.#ownerUserOpenid;
const addressed = message.kind !== 'group'
|| message.rawEventType === 'GROUP_AT_MESSAGE_CREATE';
const preparedMessage = allowed && addressed
? prefetchInboundFiles(
qqInboundMessage(message, { fetchImpl: this.#fetchImpl }),
{ signal: this.#signal },
)
: undefined;
const previous = this.#queues.get(key) ?? Promise.resolve(); const previous = this.#queues.get(key) ?? Promise.resolve();
const current = previous const current = previous
.catch(() => undefined) .catch(() => undefined)
.then(() => this.#process(message, key, { alreadyRecorded })) .then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
.finally(() => { .finally(() => {
if (releaseMessageId) this.#acceptedMessageIds.delete(messageId); if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
if (this.#queues.get(key) === current) this.#queues.delete(key); if (this.#queues.get(key) === current) this.#queues.delete(key);
@ -327,6 +534,7 @@ export class QqHarnessBridge {
const result = await runner(text, this.#harness, this.#state, key, { const result = await runner(text, this.#harness, this.#state, key, {
signal: this.#signal, signal: this.#signal,
hasImages: hasQqImageAttachments(message), hasImages: hasQqImageAttachments(message),
hasFiles: hasQqFileAttachments(message),
pendingInteraction: this.#pendingInteractions.has(key) pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key), || this.#approvals.hasPending(key),
control: { owner: this, key }, control: { owner: this, key },
@ -343,7 +551,43 @@ export class QqHarnessBridge {
this.#status.lastError = null; this.#status.lastError = null;
} }
async #process(message, key, { alreadyRecorded = false } = {}) { async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt = null) {
if (artifacts.length === 0) {
return { receipt: baseReceipt, failureNoticeVisible: false };
}
const delivery = await deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: replyTo,
aggregatePresentation: baseReceipt ? 'qq-text-and-files' : 'qq-files',
alwaysMerge: true,
channelKey: 'qq',
signal: this.#signal,
sendImage: (file) => sendQqImage(this.#bot, target, file, {
signal: this.#signal,
timeoutMs: this.#fileUploadTimeoutMs,
}),
sendFile: (file) => sendQqFile(this.#bot, target, file, {
signal: this.#signal,
timeoutMs: this.#fileUploadTimeoutMs,
}),
sendFailureNotice: (artifact, error) => this.#bot.sendText(
target,
artifactFailureText(artifact?.fileName, error),
),
logger: this.#logger,
});
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
+ delivery.artifactsSent;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
+ delivery.artifactSendErrors;
return {
receipt: delivery.receipt,
failureNoticeVisible: delivery.failureNoticeVisible,
};
}
async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
if (this.#signal?.aborted) return; if (this.#signal?.aborted) return;
const messageId = nonEmptyString(message?.messageId); const messageId = nonEmptyString(message?.messageId);
const sender = nonEmptyString(message?.senderId); const sender = nonEmptyString(message?.senderId);
@ -362,35 +606,37 @@ export class QqHarnessBridge {
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return; if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
const target = message.replyTarget; const target = message.replyTarget;
const promptMessage = qqInboundMessage(message, { fetchImpl: this.#fetchImpl }); const promptMessage = preparedMessage
?? qqInboundMessage(message, { fetchImpl: this.#fetchImpl });
const text = promptMessage.content; const text = promptMessage.content;
const hasImages = hasInboundImages(promptMessage); const hasImages = hasInboundImages(promptMessage);
const hasFiles = hasInboundFiles(promptMessage);
let stream = null; let stream = null;
try { try {
if (!text && !hasImages) { if (!text && !hasImages && !hasFiles) {
await this.#bot.sendText(target, '目前支持文字和图片消息。'); await this.#bot.sendText(target, t('目前支持文字、图片和文件消息。'));
await this.#state.markSeen(messageId); await this.#state.markSeen(messageId);
return; return;
} }
const command = text.toLowerCase(); const command = text.toLowerCase();
if (!hasImages && command === '/help') { if (!hasImages && !hasFiles && command === '/help') {
await this.#bot.sendText(target, HELP_TEXT); await this.#bot.sendText(target, helpText());
await this.#state.markSeen(messageId); await this.#state.markSeen(messageId);
return; return;
} }
if (!hasImages && command === '/status') { if (!hasImages && !hasFiles && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal }); await this.#harness.ensureRunning({ signal: this.#signal });
await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。'); await this.#bot.sendText(target, t('QQ 机器人与 DeepSeek Harness 连接正常。'));
await this.#state.markSeen(messageId); await this.#state.markSeen(messageId);
return; return;
} }
if (!hasImages && command === '/new') { if (!hasImages && !hasFiles && command === '/new') {
await this.#state.clearSession(key); await this.#state.clearSession(key);
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。'); await this.#bot.sendText(target, t('已开启新会话。请发送你的问题。'));
await this.#state.markSeen(messageId); await this.#state.markSeen(messageId);
return; return;
} }
const workspaceCommand = hasImages const workspaceCommand = hasImages || hasFiles
? null ? null
: await runWorkspaceCommand(text, this.#harness, key); : await runWorkspaceCommand(text, this.#harness, key);
if (workspaceCommand) { if (workspaceCommand) {
@ -400,7 +646,7 @@ export class QqHarnessBridge {
await this.#state.markSeen(messageId); await this.#state.markSeen(messageId);
return; return;
} }
const compactCommand = hasImages const compactCommand = hasImages || hasFiles
? null ? null
: await runCompactCommand( : await runCompactCommand(
text, text,
@ -418,17 +664,21 @@ export class QqHarnessBridge {
const content = hasImages const content = hasImages
? await promptContentForMessage(promptMessage, { signal: this.#signal }) ? await promptContentForMessage(promptMessage, { signal: this.#signal })
: undefined; : undefined;
let streamFinished = false; // QQ C2C keeps one stream bubble. Progress is collected but never submitted:
// some clients reject replacing an already visible stream frame, which would
// otherwise leave a stale progress bubble plus a separate fallback answer.
if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') { if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
try { try {
stream = this.#bot.openStream({ target }); stream = this.#bot.openStream({ target });
} catch (error) { } catch (error) {
this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using a text reply:', error); this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using markdown fallback:', error);
} }
} }
const toolErrors = [];
let answer; let answer;
let artifacts = [];
try { try {
({ answer } = await askInWorkspaceSession({ ({ answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness, harness: this.#harness,
state: this.#state, state: this.#state,
key, key,
@ -439,14 +689,15 @@ export class QqHarnessBridge {
timeoutMs: this.#replyTimeoutMs, timeoutMs: this.#replyTimeoutMs,
signal: this.#signal, signal: this.#signal,
control: { owner: this, key }, control: { owner: this, key },
onUpdate: stream ? async (update) => { progressMode: 'all',
const progress = update.type === 'text' onUpdate: (update) => {
? update.text if (update.error) {
: update.type === 'tool' const label = nonEmptyString(update.toolName)
? `正在使用${update.name}…` ? `Tool call ${update.toolName}` : 'Tool call';
: update.text; const text = `${label}\nError: ${update.error}`;
if (progress) await stream.update(progress); toolErrors.push(text);
} : undefined, }
},
onInteraction: (interaction) => this.#handleInteraction(interaction, { onInteraction: (interaction) => this.#handleInteraction(interaction, {
key, key,
actor: sender, actor: sender,
@ -454,6 +705,7 @@ export class QqHarnessBridge {
requiresMention: message.kind === 'group', requiresMention: message.kind === 'group',
}), }),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution), onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files: promptMessage.files,
}, },
})); }));
} finally { } finally {
@ -462,46 +714,89 @@ export class QqHarnessBridge {
this.#approvals.closeRoute(key), this.#approvals.closeRoute(key),
]); ]);
} }
if (stream) { this.#signal?.throwIfAborted();
try { const answerText = answerTextForDelivery(answer, artifacts);
await stream.update(answer); const displayAnswer = toolErrors.length > 0
await stream.complete(); ? `${answerText}\n\n---\n\n${toolErrors.join('\n\n')}`
streamFinished = true; : answerText;
} catch (error) { let textReceipt = null;
stream.cancel?.(); let textSendError = null;
this.#logger.warn?.('[dsh-im:qq] QQ stream finalization failed; using a text reply:', error); try {
let streamFinished = false;
if (stream) {
try {
await stream.update(displayAnswer);
streamFinished = true;
textReceipt = createDeliveryReceipt({
deliveryId: messageId,
presentation: 'qq-text',
providerMessageIds: providerMessageIdsFor(stream),
});
try {
await stream.complete();
} catch (error) {
this.#logger.warn?.('[dsh-im:qq] QQ stream completion failed after visible final content:', error);
}
} catch (error) {
stream.cancel?.();
this.#logger.warn?.('[dsh-im:qq] QQ stream update failed; using markdown fallback:', error);
}
} }
if (!streamFinished) {
const deliveries = await sendMarkdownReply(this.#bot, target, displayAnswer, {
logger: this.#logger,
});
textReceipt = createDeliveryReceipt({
deliveryId: messageId,
presentation: 'qq-text',
providerMessageIds: deliveries.flatMap((delivery) => providerMessageIdsFor(delivery)),
});
}
} catch (error) {
textSendError = error;
this.#logger.warn?.('[dsh-im:qq] final text delivery failed; continuing with result files:', error);
}
const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
const artifactDispatched = delivery.receipt?.artifacts?.some(
({ outcome }) => outcome === 'sent' || outcome === 'unknown',
);
if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
throw textSendError;
} }
if (!streamFinished) await this.#bot.sendText(target, answer);
await this.#state.markSeen(messageId); await this.#state.markSeen(messageId);
this.#status.messagesReplied += 1; this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString(); this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null; this.#status.lastError = null;
return delivery.receipt;
} catch (error) { } catch (error) {
if (error?.code === 'turn-stopped') { if (error?.code === 'turn-stopped') {
if (stream) { try {
try { stream?.cancel?.();
await stream.cancel?.(); } catch (streamError) {
} catch (streamError) { this.#logger.warn?.('[dsh-im:qq] unable to cancel a stopped QQ stream:', streamError);
this.#logger.warn?.('[dsh-im:qq] unable to cancel a stopped QQ stream:', streamError); }
} try {
try { await this.#bot.sendText(target, t('已停止。'));
await this.#bot.sendText(target, '已停止。'); } catch (sendError) {
} catch (sendError) { this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
this.#logger.warn?.('[dsh-im:qq] unable to announce a stopped QQ turn:', sendError);
}
} }
await this.#state.markSeen(messageId); await this.#state.markSeen(messageId);
return; return;
} }
stream?.cancel?.(); try {
stream?.cancel?.();
} catch (streamError) {
this.#logger.warn?.('[dsh-im:qq] unable to cancel a failed QQ stream:', streamError);
}
if (this.#signal?.aborted) return; if (this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error); this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error); this.#logger.error?.('[dsh-im:qq] failed to process an inbound message:', error);
try { try {
await this.#bot.sendText( await this.#bot.sendText(
target, target,
imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。', inboundFileUserMessage(error)
?? imagePromptUserMessage(error)
?? t('消息处理失败,请稍后重试。'),
); );
await this.#state.markSeen(messageId); await this.#state.markSeen(messageId);
} catch (sendError) { } catch (sendError) {
@ -527,15 +822,15 @@ export class QqHarnessBridge {
if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return; if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return;
const text = nonEmptyString(safeText(message)); const text = nonEmptyString(safeText(message));
if (!text || hasQqImageAttachments(message)) { if (!text || hasQqImageAttachments(message) || hasQqFileAttachments(message)) {
await this.#bot.sendText(message.replyTarget, '请用文字回答当前问题。'); await this.#bot.sendText(message.replyTarget, t('请用文字回答当前问题。'));
return; return;
} }
const pending = this.#pendingInteractions.get(key); const pending = this.#pendingInteractions.get(key);
if (!pending || pending !== expected || pending.submitting) { if (!pending || pending !== expected || pending.submitting) {
if (claimed && (!pending || pending !== expected)) { if (claimed && (!pending || pending !== expected)) {
await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT); await this.#bot.sendText(message.replyTarget, interactionResolvedText());
return; return;
} }
return this.#enqueueMessage(message, messageId, key, { return this.#enqueueMessage(message, messageId, key, {
@ -548,7 +843,7 @@ export class QqHarnessBridge {
try { try {
await this.#presentInteraction(pending); await this.#presentInteraction(pending);
} catch { } catch {
this.#status.lastError = 'QQ 交互问题发送失败。'; this.#status.lastError = t('QQ 交互问题发送失败。');
this.#logger.error?.('[dsh-im:qq] failed to retry an interaction question'); this.#logger.error?.('[dsh-im:qq] failed to retry an interaction question');
pending.interaction.reconnect?.(); pending.interaction.reconnect?.();
return; return;
@ -556,7 +851,7 @@ export class QqHarnessBridge {
const presentedPending = this.#pendingInteractions.get(key); const presentedPending = this.#pendingInteractions.get(key);
if (!presentedPending || presentedPending !== expected || presentedPending.submitting) { if (!presentedPending || presentedPending !== expected || presentedPending.submitting) {
if (claimed && (!presentedPending || presentedPending !== expected)) { if (claimed && (!presentedPending || presentedPending !== expected)) {
await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT) await this.#bot.sendText(message.replyTarget, interactionResolvedText())
.catch(() => undefined); .catch(() => undefined);
return; return;
} }
@ -579,7 +874,7 @@ export class QqHarnessBridge {
try { try {
await this.#presentInteraction(pending); await this.#presentInteraction(pending);
} catch { } catch {
this.#status.lastError = 'QQ 交互问题发送失败。'; this.#status.lastError = t('QQ 交互问题发送失败。');
this.#logger.error?.('[dsh-im:qq] failed to send the next interaction question'); this.#logger.error?.('[dsh-im:qq] failed to send the next interaction question');
pending.interaction.reconnect?.(); pending.interaction.reconnect?.();
} }
@ -601,16 +896,16 @@ export class QqHarnessBridge {
if (this.#signal?.aborted) return; if (this.#signal?.aborted) return;
if (error?.code === 'interaction-not-pending') { if (error?.code === 'interaction-not-pending') {
this.#clearPendingInteraction(key, pending.interactionId); this.#clearPendingInteraction(key, pending.interactionId);
await this.#bot.sendText(pending.target, INTERACTION_RESOLVED_TEXT).catch(() => undefined); await this.#bot.sendText(pending.target, interactionResolvedText()).catch(() => undefined);
return; return;
} }
if (this.#pendingInteractions.get(key) !== pending) return; if (this.#pendingInteractions.get(key) !== pending) return;
pending.submitting = false; pending.submitting = false;
pending.answers.pop(); pending.answers.pop();
pending.index -= 1; pending.index -= 1;
this.#status.lastError = '回答提交失败。'; this.#status.lastError = t('回答提交失败。');
this.#logger.error?.('[dsh-im:qq] failed to answer a Harness interaction'); this.#logger.error?.('[dsh-im:qq] failed to answer a Harness interaction');
await this.#bot.sendText(pending.target, '回答提交失败,请重新发送当前问题的答案。') await this.#bot.sendText(pending.target, t('回答提交失败,请重新发送当前问题的答案。'))
.catch(() => undefined); .catch(() => undefined);
} }
} }
@ -655,7 +950,7 @@ export class QqHarnessBridge {
}); });
await this.#bot.sendText( await this.#bot.sendText(
target, target,
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。', t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'),
).catch(() => undefined); ).catch(() => undefined);
return; return;
} }
@ -740,7 +1035,7 @@ export class QqHarnessBridge {
await this.#state.markSeen(messageId); await this.#state.markSeen(messageId);
this.#status.messagesReceived += 1; this.#status.messagesReceived += 1;
this.#status.lastMessageAt = new Date().toISOString(); this.#status.lastMessageAt = new Date().toISOString();
await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT).catch(() => undefined); await this.#bot.sendText(message.replyTarget, interactionResolvedText()).catch(() => undefined);
} }
#takePendingInteraction(key, interactionId) { #takePendingInteraction(key, interactionId) {
@ -782,7 +1077,7 @@ export class QqHarnessBridge {
if (!this.#state.hasSeen(messageId)) { if (!this.#state.hasSeen(messageId)) {
await this.#state.markSeen(messageId).catch(() => undefined); await this.#state.markSeen(messageId).catch(() => undefined);
} }
await this.#bot.sendText(message.replyTarget, '消息处理失败,请稍后重试。') await this.#bot.sendText(message.replyTarget, t('消息处理失败,请稍后重试。'))
.catch(() => undefined); .catch(() => undefined);
} }
} }

View file

@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { connectionTestMessage } from '../shared/connection-test.mjs'; import { connectionTestMessage } from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs'; import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs';
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']); const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']);
@ -77,14 +78,14 @@ export class QqController {
if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return; if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
const appSecret = await this.#resolveSecret(config.secretRef); const appSecret = await this.#resolveSecret(config.secretRef);
if (!appSecret) { if (!appSecret) {
this.#errors.set(config.botId, safeError('missing-secret', 'QQ 机器人凭据缺失,请移除后重新扫码。')); this.#errors.set(config.botId, safeError('missing-secret', t('QQ 机器人凭据缺失,请移除后重新扫码。')));
return; return;
} }
try { try {
await this.#startRuntime(config, appSecret); await this.#startRuntime(config, appSecret);
this.#errors.delete(config.botId); this.#errors.delete(config.botId);
} catch (error) { } catch (error) {
this.#errors.set(config.botId, safeError('connection-failed', 'QQ 连接未就绪,插件会自动重试。')); this.#errors.set(config.botId, safeError('connection-failed', t('QQ 连接未就绪,插件会自动重试。')));
this.#logger.warn?.(`[dsh-im:qq] bot ${config.botId} failed to initialize:`, error); this.#logger.warn?.(`[dsh-im:qq] bot ${config.botId} failed to initialize:`, error);
} finally { } finally {
this.#touch(); this.#touch();
@ -147,7 +148,7 @@ export class QqController {
onFailure: (error) => { onFailure: (error) => {
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return; if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
record.state = 'failed'; record.state = 'failed';
record.error = safeError('qr-connect-failed', 'QQ 扫码服务暂时不可用,请重新生成二维码。'); record.error = safeError('qr-connect-failed', t('QQ 扫码服务暂时不可用,请重新生成二维码。'));
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#touch(); this.#touch();
firstQrReject(error); firstQrReject(error);
@ -158,10 +159,10 @@ export class QqController {
} catch (error) { } catch (error) {
if (record.controller.signal.aborted) { if (record.controller.signal.aborted) {
record.state = 'cancelled'; record.state = 'cancelled';
record.error = safeError('cancelled', '扫码绑定已取消。'); record.error = safeError('cancelled', t('扫码绑定已取消。'));
} else if (!TERMINAL_ATTEMPT_STATES.has(record.state)) { } else if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
record.state = 'failed'; record.state = 'failed';
record.error = safeError('qr-start-failed', '无法生成 QQ 二维码,请稍后重试。'); record.error = safeError('qr-start-failed', t('无法生成 QQ 二维码,请稍后重试。'));
} }
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#touch(); this.#touch();
@ -207,7 +208,7 @@ export class QqController {
await this.#startRuntime(config, normalizedSecret); await this.#startRuntime(config, normalizedSecret);
this.#errors.delete(identity.botId); this.#errors.delete(identity.botId);
} catch (error) { } catch (error) {
this.#errors.set(identity.botId, safeError('connection-failed', 'QQ 机器人已绑定,消息连接暂未就绪。')); this.#errors.set(identity.botId, safeError('connection-failed', t('QQ 机器人已绑定,消息连接暂未就绪。')));
this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} credential connection failed:`, error); this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} credential connection failed:`, error);
} }
this.#touch(); this.#touch();
@ -223,7 +224,7 @@ export class QqController {
record.dispose?.(); record.dispose?.();
await record.task?.catch(() => undefined); await record.task?.catch(() => undefined);
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled'; if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
record.error ??= safeError('cancelled', '扫码绑定已取消。'); record.error ??= safeError('cancelled', t('扫码绑定已取消。'));
} }
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
this.#touch(); this.#touch();
@ -240,7 +241,7 @@ export class QqController {
await this.#startRuntime(config, secret); await this.#startRuntime(config, secret);
this.#errors.delete(botId); this.#errors.delete(botId);
} catch (error) { } catch (error) {
this.#errors.set(botId, safeError('connection-failed', 'QQ 连接仍未就绪,请稍后重试。')); this.#errors.set(botId, safeError('connection-failed', t('QQ 连接仍未就绪,请稍后重试。')));
throw error; throw error;
} finally { } finally {
this.#touch(); this.#touch();
@ -255,12 +256,12 @@ export class QqController {
return this.#withBotTransition(botId, async () => { return this.#withBotTransition(botId, async () => {
const runtime = this.#runtimes.get(botId); const runtime = this.#runtimes.get(botId);
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') { if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
const error = new Error('QQ机器人尚未连接'); const error = new Error(t('QQ机器人尚未连接'));
error.code = 'test-target-unavailable'; error.code = 'test-target-unavailable';
throw error; throw error;
} }
await runtime.sendConnectionTest(connectionTestMessage( await runtime.sendConnectionTest(connectionTestMessage(
`QQ 机器人(${maskQqAppId(config.appId)})`, t('QQ 机器人({appId})', { appId: maskQqAppId(config.appId) }),
)); ));
return { sent: true }; return { sent: true };
}); });
@ -306,11 +307,11 @@ export class QqController {
state, state,
connected, connected,
configured: true, configured: true,
bot: { name: 'QQ机器人', appIdMasked: maskQqAppId(config.appId) }, bot: { name: t('QQ机器人'), appIdMasked: maskQqAppId(config.appId) },
health: { health: {
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline', status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
summary: connected ? 'QQ WebSocket 长连接运行正常' summary: connected ? t('QQ WebSocket 长连接运行正常')
: state === 'error' ? 'QQ 连接未就绪,插件会自动重试' : 'QQ 连接当前离线', : state === 'error' ? t('QQ 连接未就绪,插件会自动重试') : t('QQ 连接当前离线'),
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null, lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null, lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
}, },
@ -364,10 +365,10 @@ export class QqController {
} catch (error) { } catch (error) {
if (record.controller.signal.aborted) { if (record.controller.signal.aborted) {
record.state = 'cancelled'; record.state = 'cancelled';
record.error = safeError('cancelled', '扫码绑定已取消。'); record.error = safeError('cancelled', t('扫码绑定已取消。'));
} else { } else {
record.state = 'failed'; record.state = 'failed';
record.error = safeError('activation-failed', 'QQ 已授权,但无法安全保存接入配置。'); record.error = safeError('activation-failed', t('QQ 已授权,但无法安全保存接入配置。'));
this.#logger.error?.('[dsh-im:qq] provisioning failed:', error); this.#logger.error?.('[dsh-im:qq] provisioning failed:', error);
} }
} finally { } finally {
@ -417,7 +418,7 @@ export class QqController {
await this.#restoreCredential(identity.secretRef, previousSecret); await this.#restoreCredential(identity.secretRef, previousSecret);
throw error; throw error;
} }
this.#errors.set(identity.botId, safeError('connection-failed', 'QQ 机器人已绑定,消息连接暂未就绪。')); this.#errors.set(identity.botId, safeError('connection-failed', t('QQ 机器人已绑定,消息连接暂未就绪。')));
this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} activation connection failed:`, error); this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} activation connection failed:`, error);
} }
this.#touch(); this.#touch();

View file

@ -4,6 +4,7 @@ import {
connectionTestTarget, connectionTestTarget,
connectionTestTargetUnavailable, connectionTestTargetUnavailable,
} from '../shared/connection-test.mjs'; } from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs'; import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
function timeoutError() { function timeoutError() {
@ -73,7 +74,7 @@ export class QqRuntime {
async sendConnectionTest(text) { async sendConnectionTest(text) {
if (!this.#status.ready || !this.#bot) { if (!this.#status.ready || !this.#bot) {
throw connectionTestTargetUnavailable('QQ机器人'); throw connectionTestTargetUnavailable(t('QQ机器人'));
} }
const ownerUserOpenid = typeof this.#config.ownerUserOpenid === 'string' const ownerUserOpenid = typeof this.#config.ownerUserOpenid === 'string'
? this.#config.ownerUserOpenid.trim() ? this.#config.ownerUserOpenid.trim()
@ -88,7 +89,7 @@ export class QqRuntime {
: (ownerUserOpenid && ownerUserOpenid !== '*' : (ownerUserOpenid && ownerUserOpenid !== '*'
? { scope: 'c2c', targetId: ownerUserOpenid } ? { scope: 'c2c', targetId: ownerUserOpenid }
: null); : null);
if (!target) throw connectionTestTargetUnavailable('QQ机器人'); if (!target) throw connectionTestTargetUnavailable(t('QQ机器人'));
await this.#bot.sendText(target, text); await this.#bot.sendText(target, text);
return { sent: true }; return { sent: true };
} }

View file

@ -1,3 +1,5 @@
import { t } from './i18n.mjs';
/** Matches DeepSeek Harness agent-preset directory ids. */ /** Matches DeepSeek Harness agent-preset directory ids. */
export const AGENT_PRESET_ID = /^[a-z0-9][a-z0-9-]*$/; export const AGENT_PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
@ -17,7 +19,7 @@ export function validateAgentPresetId(value) {
if (value == null || value === '') return null; if (value == null || value === '') return null;
const id = normalizeAgentPresetId(value); const id = normalizeAgentPresetId(value);
if (!id) { if (!id) {
const error = new Error('Agent Preset 无效。'); const error = new Error(t('Agent Preset 无效。'));
error.code = 'agent-preset-invalid'; error.code = 'agent-preset-invalid';
throw error; throw error;
} }

View file

@ -1,3 +1,4 @@
import { t } from './i18n.mjs';
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs'; import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
const COMPACT_COMMAND = /^\/compact(?=$|\s)([\s\S]*)$/i; const COMPACT_COMMAND = /^\/compact(?=$|\s)([\s\S]*)$/i;
@ -41,27 +42,27 @@ function compactResultText(result) {
const text = result.text?.trim() ?? ''; const text = result.text?.trim() ?? '';
const compacted = /^Compacted (\d+) history items \(~(\d+) tokens\)\.$/u.exec(text); const compacted = /^Compacted (\d+) history items \(~(\d+) tokens\)\.$/u.exec(text);
if (compacted) { if (compacted) {
return `已压缩 ${compacted[1]} 条历史记录(约 ${compacted[2]} 个 token)。`; return t('已压缩 {count} 条历史记录(约 {tokens} 个 token)。', { count: compacted[1], tokens: compacted[2] });
} }
if (COMPACT_RESULT_TEXT.has(text)) return COMPACT_RESULT_TEXT.get(text); if (COMPACT_RESULT_TEXT.has(text)) return t(COMPACT_RESULT_TEXT.get(text));
if (text) return text; if (text) return text;
return result.kind === 'success' ? '上下文压缩完成。' : '上下文压缩失败。'; return result.kind === 'success' ? t('上下文压缩完成。') : t('上下文压缩失败。');
} }
function compactErrorMessage(error) { function compactErrorMessage(error) {
const code = error?.code ?? error?.failure?.code; const code = error?.code ?? error?.failure?.code;
if (code === 'session-not-found') { if (code === 'session-not-found') {
return '当前聊天绑定的会话已不存在,请发送新消息开启会话。'; return t('当前聊天绑定的会话已不存在,请发送新消息开启会话。');
} }
if (code === 'agent-busy') return '当前会话正在生成回复,请稍后重试。'; if (code === 'agent-busy') return t('当前会话正在生成回复,请稍后重试。');
if (code === 'cancelled' || error?.name === 'AbortError') return '上下文压缩已取消。'; if (code === 'cancelled' || error?.name === 'AbortError') return t('上下文压缩已取消。');
if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') { if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
return '工作区或机器人状态已发生变化,请重试。'; return t('工作区或机器人状态已发生变化,请重试。');
} }
if (code === 'commands-unavailable') { if (code === 'commands-unavailable') {
return '当前 Harness 暂不支持从机器人执行上下文压缩。'; return t('当前 Harness 暂不支持从机器人执行上下文压缩。');
} }
return '上下文压缩失败,请稍后重试。'; return t('上下文压缩失败,请稍后重试。');
} }
/** /**
@ -72,21 +73,21 @@ export async function runCompactCommand(text, harness, state, conversationKey, o
if (typeof text !== 'string') return null; if (typeof text !== 'string') return null;
const match = COMPACT_COMMAND.exec(text.trim()); const match = COMPACT_COMMAND.exec(text.trim());
if (!match) return null; if (!match) return null;
if (match[1].trim()) return commandResult(COMPACT_USAGE); if (match[1].trim()) return commandResult(t(COMPACT_USAGE));
if (typeof state?.sessionFor !== 'function') { if (typeof state?.sessionFor !== 'function') {
return commandResult('当前机器人没有可用的会话状态。'); return commandResult(t('当前机器人没有可用的会话状态。'));
} }
const sessionId = state.sessionFor(conversationKey); const sessionId = state.sessionFor(conversationKey);
if (typeof sessionId !== 'string' || !sessionId) { if (typeof sessionId !== 'string' || !sessionId) {
return commandResult('当前聊天还没有可压缩的会话,请先发送一条消息。'); return commandResult(t('当前聊天还没有可压缩的会话,请先发送一条消息。'));
} }
if (typeof harness?.executeCommand !== 'function') { if (typeof harness?.executeCommand !== 'function') {
return commandResult('当前机器人暂不支持上下文压缩。'); return commandResult(t('当前机器人暂不支持上下文压缩。'));
} }
try { try {
const execution = await harness.executeCommand(sessionId, '/compact', options); const execution = await harness.executeCommand(sessionId, '/compact', options);
if (execution === undefined) { if (execution === undefined) {
return commandResult('当前 Harness 未注册 /compact 命令,请确认上下文压缩组件已启用。'); return commandResult(t('当前 Harness 未注册 /compact 命令,请确认上下文压缩组件已启用。'));
} }
return commandResult(compactResultText(execution?.result)); return commandResult(compactResultText(execution?.result));
} catch (error) { } catch (error) {

View file

@ -1,3 +1,5 @@
import { t } from './i18n.mjs';
const targets = new WeakMap(); const targets = new WeakMap();
export const CONNECTION_TEST_STATE_IDENTITY = Symbol('dsh-im.connection-test-state-identity'); export const CONNECTION_TEST_STATE_IDENTITY = Symbol('dsh-im.connection-test-state-identity');
@ -25,13 +27,13 @@ export function connectionTestTarget(state) {
return target ? structuredClone(target) : null; return target ? structuredClone(target) : null;
} }
export function connectionTestMessage(botName, channelLabel = '机器人') { export function connectionTestMessage(botName, channelLabel = t('机器人')) {
const name = cleanText(botName) ?? channelLabel; const name = cleanText(botName) ?? channelLabel;
return `✅ DeepSeek Harness 连接测试成功\n这条消息由插件页面中的“${name}”机器人卡片发出。`; return `${t('✅ DeepSeek Harness 连接测试成功')}\n${t('这条消息由插件页面中的“{name}”机器人卡片发出。', { name })}`;
} }
export function connectionTestTargetUnavailable(channelLabel = '机器人') { export function connectionTestTargetUnavailable(channelLabel = t('机器人')) {
const error = new Error(`${channelLabel}尚未收到可用于测试的私聊消息。`); const error = new Error(t('{channelLabel}尚未收到可用于测试的私聊消息。', { channelLabel }));
error.code = 'test-target-unavailable'; error.code = 'test-target-unavailable';
return error; return error;
} }

View file

@ -1,3 +1,5 @@
import { t } from './i18n.mjs';
const CONTROL_COMMAND = /^\/(?:stop|steer)(?=$|\s)/iu; const CONTROL_COMMAND = /^\/(?:stop|steer)(?=$|\s)/iu;
const STOP_COMMAND = /^\/stop(?=$|\s)/iu; const STOP_COMMAND = /^\/stop(?=$|\s)/iu;
const STOP_USAGE = '用法:/stop(不带参数)'; const STOP_USAGE = '用法:/stop(不带参数)';
@ -40,35 +42,35 @@ export async function runControlCommand(text, harness, state, key, {
const command = text.trim(); const command = text.trim();
const stop = STOP_COMMAND.test(command); const stop = STOP_COMMAND.test(command);
if (hasImages) return commandResult(TEXT_ONLY); if (hasImages) return commandResult(t(TEXT_ONLY));
if (stop) { if (stop) {
if (!/^\/stop$/iu.test(command)) return commandResult(STOP_USAGE); if (!/^\/stop$/iu.test(command)) return commandResult(t(STOP_USAGE));
const session = boundSession(harness, state, key); const session = boundSession(harness, state, key);
if (!session) return commandResult('当前聊天没有正在运行的任务。'); if (!session) return commandResult(t('当前聊天没有正在运行的任务。'));
if (typeof session.stopActiveTurn !== 'function') { if (typeof session.stopActiveTurn !== 'function') {
throw new TypeError('Harness session does not support stopping active turns'); throw new TypeError('Harness session does not support stopping active turns');
} }
const stopped = await session.stopActiveTurn(control, requestOptions(signal)); const stopped = await session.stopActiveTurn(control, requestOptions(signal));
return stopped return stopped
? commandResult('已请求停止当前任务。', { stopped: true }) ? commandResult(t('已请求停止当前任务。'), { stopped: true })
: commandResult('当前聊天没有正在运行的任务。'); : commandResult(t('当前聊天没有正在运行的任务。'));
} }
const match = /^\/steer(?:\s+([\s\S]*))?$/iu.exec(command); const match = /^\/steer(?:\s+([\s\S]*))?$/iu.exec(command);
const instruction = match?.[1]?.trim() ?? ''; const instruction = match?.[1]?.trim() ?? '';
if (!instruction) return commandResult(STEER_USAGE); if (!instruction) return commandResult(t(STEER_USAGE));
if (pendingInteraction) { if (pendingInteraction) {
return commandResult([ return commandResult([
'当前任务正在等待你的回答或审批。', t('当前任务正在等待你的回答或审批。'),
'', '',
'请先处理当前请求,或者发送 /stop 停止任务。', t('请先处理当前请求,或者发送 /stop 停止任务。'),
].join('\n')); ].join('\n'));
} }
const session = boundSession(harness, state, key); const session = boundSession(harness, state, key);
if (!session) { if (!session) {
return commandResult('当前聊天没有绑定会话,无法补充指令。请先绑定会话。'); return commandResult(t('当前聊天没有绑定会话,无法补充指令。请先绑定会话。'));
} }
if (typeof session.steerActiveTurn !== 'function') { if (typeof session.steerActiveTurn !== 'function') {
throw new TypeError('Harness session does not support steering active turns'); throw new TypeError('Harness session does not support steering active turns');
@ -79,6 +81,6 @@ export async function runControlCommand(text, harness, state, key, {
requestOptions(signal), requestOptions(signal),
); );
return steered return steered
? commandResult('已提交补充指令,Agent 会在下一步读取。') ? commandResult(t('已提交补充指令,Agent 会在下一步读取。'))
: commandResult('任务已结束,没有正在运行的任务,无法补充指令。请直接发送消息开始新任务。'); : commandResult(t('任务已结束,没有正在运行的任务,无法补充指令。请直接发送消息开始新任务。'));
} }

View file

@ -1,3 +1,5 @@
import { t } from './i18n.mjs';
export function splitMessageText(value, limit) { export function splitMessageText(value, limit) {
const text = typeof value === 'string' ? value.trim() : ''; const text = typeof value === 'string' ? value.trim() : '';
if (!text) return []; if (!text) return [];
@ -15,15 +17,17 @@ export function splitMessageText(value, limit) {
} }
export function createEditableMessageStream({ export function createEditableMessageStream({
initialText = '正在处理…', initialText = t('正在处理…'),
limit, limit,
updateIntervalMs = 800, updateIntervalMs = 800,
create, create,
edit, edit,
sendRemainder, sendRemainder,
messageIdForResult = () => null,
logger = console, logger = console,
}) { }) {
let messageId; let messageId;
const providerMessageIds = [];
let pending = null; let pending = null;
let timer = null; let timer = null;
let inFlight = null; let inFlight = null;
@ -49,8 +53,17 @@ export function createEditableMessageStream({
}; };
return { return {
get messageId() {
return messageId;
},
get providerMessageIds() {
return [...providerMessageIds];
},
async start() { async start() {
messageId = await create(initialText); messageId = await create(initialText);
if ((typeof messageId === 'string' && messageId.trim()) || Number.isSafeInteger(messageId)) {
providerMessageIds.push(String(messageId));
}
return this; return this;
}, },
update(text) { update(text) {
@ -66,10 +79,16 @@ export function createEditableMessageStream({
pending = null; pending = null;
await inFlight?.catch(() => undefined); await inFlight?.catch(() => undefined);
const chunks = splitMessageText(text, limit); const chunks = splitMessageText(text, limit);
const first = chunks[0] ?? '处理完成。'; const first = chunks[0] ?? t('处理完成。');
if (first !== lastSent) await edit(messageId, first); if (first !== lastSent) await edit(messageId, first);
lastSent = first; lastSent = first;
for (const chunk of chunks.slice(1)) await sendRemainder(chunk); for (const chunk of chunks.slice(1)) {
const result = await sendRemainder(chunk);
const id = messageIdForResult(result);
if ((typeof id === 'string' && id.trim()) || Number.isSafeInteger(id)) {
providerMessageIds.push(String(id));
}
}
}, },
cancel() { cancel() {
closed = true; closed = true;

View file

@ -0,0 +1,64 @@
async function cancelResponseBody(response) {
try {
await response?.body?.cancel?.();
} catch {
// Preserve the original download failure.
}
}
function hostedByMessagingPlatform(target, allowedHosts) {
return !Array.isArray(allowedHosts) || allowedHosts.some((rule) => (
typeof rule === 'string'
&& (target.hostname === rule
|| (rule.startsWith('.')
&& (target.hostname === rule.slice(1) || target.hostname.endsWith(rule))))
));
}
/**
* Open a channel-hosted ordinary file as a stream.
*
* This deliberately has no plugin-defined size, type, count, or download-time
* limit. The caller owns cancellation through its AbortSignal and the channel
* remains the authority for its own file limits.
*/
export async function fetchFileStream(url, {
fetchImpl = fetch,
headers,
signal,
allowedHosts,
} = {}) {
const target = new URL(url);
if (target.protocol !== 'https:') throw new Error('File download URL must use HTTPS');
if (!hostedByMessagingPlatform(target, allowedHosts)) {
throw new Error('File download URL is not hosted by the messaging platform');
}
const response = await fetchImpl(target, {
method: 'GET',
headers,
signal,
redirect: 'manual',
});
if (Number.isInteger(response?.status) && response.status >= 300 && response.status < 400) {
await cancelResponseBody(response);
const error = new Error(`File download redirect was blocked (HTTP ${response.status})`);
error.code = 'file-redirect-blocked';
throw error;
}
if (!response?.ok) {
await cancelResponseBody(response);
const error = new Error(`File download failed with HTTP ${response?.status ?? 'unknown'}`);
error.code = 'file-http-error';
error.status = response?.status;
throw error;
}
if (response.body?.[Symbol.asyncIterator]) return { stream: response.body };
if (typeof response.arrayBuffer === 'function') {
const data = Buffer.from(await response.arrayBuffer());
return {
stream: (async function* fileBody() { yield data; }()),
};
}
throw new Error('File download returned no readable body');
}

View file

@ -1,3 +1,5 @@
import { t } from './i18n.mjs';
const APPROVAL_REPLIES = new Map([ const APPROVAL_REPLIES = new Map([
['批准', 'allowed-once'], ['批准', 'allowed-once'],
['同意', 'allowed-once'], ['同意', 'allowed-once'],
@ -68,16 +70,16 @@ export function harnessApprovalText(payload, {
if (!operation || operation.length > maxArgumentsLength) return null; if (!operation || operation.length > maxArgumentsLength) return null;
const lines = [ const lines = [
'DeepSeek Harness 需要你的审批:', t('DeepSeek Harness 需要你的审批:'),
'', '',
`工具:${printableText(payload.toolName)}`, t('工具:{tool}', { tool: printableText(payload.toolName) }),
'操作参数:', t('操作参数:'),
operation, operation,
]; ];
const reason = printableText(payload.reason); const reason = printableText(payload.reason);
if (reason) lines.push(`原因:${reason}`); if (reason) lines.push(t('原因:{reason}', { reason }));
lines.push('', APPROVAL_PROMPT); lines.push('', t(APPROVAL_PROMPT));
if (requiresMention) lines.push('', '群聊中请 @机器人 后发送审批决定。'); if (requiresMention) lines.push('', t('群聊中请 @机器人 后发送审批决定。'));
return lines.join('\n'); return lines.join('\n');
} }
@ -93,9 +95,9 @@ function approvalResult(pending, outcome) {
} }
function approvalOutcomeText(outcome) { function approvalOutcomeText(outcome) {
if (outcome === 'allowed-once') return '已批准,仅对本次操作有效。'; if (outcome === 'allowed-once') return t('已批准,仅对本次操作有效。');
if (outcome === 'rejected') return '已拒绝此次操作。'; if (outcome === 'rejected') return t('已拒绝此次操作。');
return APPROVAL_RESOLVED_TEXT; return t(APPROVAL_RESOLVED_TEXT);
} }
export class HarnessApprovalQueue { export class HarnessApprovalQueue {
@ -149,11 +151,11 @@ export class HarnessApprovalQueue {
return null; return null;
} }
if (!decision) return null; if (!decision) return null;
return notice(APPROVAL_RESOLVED_TEXT, true); return notice(t(APPROVAL_RESOLVED_TEXT), true);
} }
if (pending.actor !== actor || (pending.requiresMention && addressed !== true)) { if (pending.actor !== actor || (pending.requiresMention && addressed !== true)) {
if (!decision) return null; if (!decision) return null;
return notice('只有发起当前任务的用户可以处理这条审批。'); return notice(t('只有发起当前任务的用户可以处理这条审批。'));
} }
return { return {
@ -168,14 +170,14 @@ export class HarnessApprovalQueue {
await questionCompletion.catch(() => undefined); await questionCompletion.catch(() => undefined);
if (pending.inactive || pending.resolving) return; if (pending.inactive || pending.resolving) return;
if (typeof isQuestionPending === 'function' && isQuestionPending()) { if (typeof isQuestionPending === 'function' && isQuestionPending()) {
await send(APPROVAL_AFTER_QUESTION_PROMPT); await send(t(APPROVAL_AFTER_QUESTION_PROMPT));
return; return;
} }
} }
await pending.activationTask?.catch(() => undefined); await pending.activationTask?.catch(() => undefined);
await pending.presentationTask?.catch(() => undefined); await pending.presentationTask?.catch(() => undefined);
if (pending.inactive || pending.resolving) { if (pending.inactive || pending.resolving) {
await send(APPROVAL_RESOLVED_TEXT); await send(t(APPROVAL_RESOLVED_TEXT));
return; return;
} }
pending.send = send; pending.send = send;
@ -185,15 +187,15 @@ export class HarnessApprovalQueue {
if (!presentedWhenClaimed || !pending.presented) { if (!presentedWhenClaimed || !pending.presented) {
if (!pending.presented) await this.#present(pending); if (!pending.presented) await this.#present(pending);
if (pending.inactive || pending.resolving) return; if (pending.inactive || pending.resolving) return;
await send(APPROVAL_PROMPT); await send(t(APPROVAL_PROMPT));
return; return;
} }
if (pending.submitting) { if (pending.submitting) {
await send('审批决定正在提交,请稍候。'); await send(t('审批决定正在提交,请稍候。'));
return; return;
} }
if (!decision) { if (!decision) {
await send(APPROVAL_PROMPT); await send(t(APPROVAL_PROMPT));
return; return;
} }
await this.#submit(pending, decision); await this.#submit(pending, decision);
@ -251,8 +253,8 @@ export class HarnessApprovalQueue {
if (!text) { if (!text) {
const rejected = await this.#rejectInteraction(interaction, payload); const rejected = await this.#rejectInteraction(interaction, payload);
await send(rejected await send(rejected
? '无法完整展示这次操作,已安全拒绝此次审批。' ? t('无法完整展示这次操作,已安全拒绝此次审批。')
: APPROVAL_RESOLVED_TEXT); : t(APPROVAL_RESOLVED_TEXT));
return true; return true;
} }
@ -335,7 +337,7 @@ export class HarnessApprovalQueue {
pending.closedOutcome = 'resolved'; pending.closedOutcome = 'resolved';
if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) { if ((pending.presented || pending.deliveryCompleted) && !pending.resolutionNotified) {
pending.resolutionNotified = true; pending.resolutionNotified = true;
await pending.send(APPROVAL_RESOLVED_TEXT).catch(() => undefined); await pending.send(t(APPROVAL_RESOLVED_TEXT)).catch(() => undefined);
} }
} else { } else {
this.#logger.warn?.(`[dsh-im:${this.#label}] failed to reject a closing approval:`, error); this.#logger.warn?.(`[dsh-im:${this.#label}] failed to reject a closing approval:`, error);
@ -377,7 +379,7 @@ export class HarnessApprovalQueue {
const next = this.#remove(pending); const next = this.#remove(pending);
await this.#transition(next, async () => { await this.#transition(next, async () => {
if (!pending.resolutionNotified) { if (!pending.resolutionNotified) {
await send(APPROVAL_RESOLVED_TEXT).catch(() => undefined); await send(t(APPROVAL_RESOLVED_TEXT)).catch(() => undefined);
} }
}); });
return; return;
@ -385,7 +387,7 @@ export class HarnessApprovalQueue {
if (pending.inactive) return; if (pending.inactive) return;
pending.submitting = false; pending.submitting = false;
this.#logger.error?.(`[dsh-im:${this.#label}] failed to submit an approval:`, error); this.#logger.error?.(`[dsh-im:${this.#label}] failed to submit an approval:`, error);
await pending.send('审批提交失败,请重新回复「批准」或「拒绝」。').catch(() => undefined); await pending.send(t('审批提交失败,请重新回复「批准」或「拒绝」。')).catch(() => undefined);
return; return;
} }

View file

@ -3,6 +3,12 @@ import { randomUUID } from 'node:crypto';
import { isAbsolute } from 'node:path'; import { isAbsolute } from 'node:path';
import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs'; import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs';
import {
appendInboundFilesToPrompt,
InboundFileError,
} from './inbound-file.mjs';
import { outboundArtifactRegistry } from './semantic/artifact.mjs';
import { t } from './i18n.mjs';
// Every channel plugin runs in the same Host process. Sharing ownership by // Every channel plugin runs in the same Host process. Sharing ownership by
// Harness origin prevents two channel-specific clients bound to one Session // Harness origin prevents two channel-specific clients bound to one Session
@ -250,6 +256,21 @@ function assistantMessageText(event) {
.trim(); .trim();
} }
function nonEmptyText(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
/** Flatten a tool/result error payload into a displayable one-line reason. */
function toolResultErrorText(error) {
if (!error || typeof error !== 'object') return null;
const message = nonEmptyText(error.message);
if (message) return message;
const name = nonEmptyText(error.name);
const code = nonEmptyText(error.code);
if (name || code) return [name ?? 'Error', code].filter(Boolean).join(': ');
return null;
}
function consumeInteractionOwnership(ownership, entries) { function consumeInteractionOwnership(ownership, entries) {
const ordered = [...entries] const ordered = [...entries]
.map((entry) => entry?.event ?? entry) .map((entry) => entry?.event ?? entry)
@ -320,6 +341,8 @@ export class HarnessReplyTracker {
#latestText = ''; #latestText = '';
#finished = false; #finished = false;
#reason = null; #reason = null;
#toolNames = new Map();
#lastToolName = null;
constructor({ promptRpcId, afterSeq = -1 }) { constructor({ promptRpcId, afterSeq = -1 }) {
this.#promptRpcId = promptRpcId; this.#promptRpcId = promptRpcId;
@ -346,8 +369,20 @@ export class HarnessReplyTracker {
return this.#targetTurn; return this.#targetTurn;
} }
consume(entries) { consumeAll(entries) {
let update = null; const updates = [];
// 同一批轮询内的 text 帧只保留最新累积,其余事件逐帧透出,
// 让消费方能按顺序看到每个工具调用与结果。
const pushUpdate = (update) => {
if (update.type === 'text' && updates.length > 0) {
const last = updates[updates.length - 1];
if (last.type === 'text') {
updates[updates.length - 1] = update;
return;
}
}
updates.push(update);
};
const ordered = [...entries] const ordered = [...entries]
.map((entry) => entry?.event ?? entry) .map((entry) => entry?.event ?? entry)
.filter(Boolean) .filter(Boolean)
@ -389,7 +424,7 @@ export class HarnessReplyTracker {
.trim(); .trim();
if (text && text !== this.#latestText) { if (text && text !== this.#latestText) {
this.#latestText = text; this.#latestText = text;
update = { type: 'text', text }; pushUpdate({ type: 'text', text });
} }
continue; continue;
} }
@ -398,18 +433,38 @@ export class HarnessReplyTracker {
const text = assistantMessageText(event); const text = assistantMessageText(event);
if (text && text !== this.#latestText) { if (text && text !== this.#latestText) {
this.#latestText = text; this.#latestText = text;
update = { type: 'text', text }; pushUpdate({ type: 'text', text });
} }
continue; continue;
} }
if (event.type === 'tool/call') { if (event.type === 'tool/call') {
update = { type: 'tool', name: event.data?.name ?? '工具' }; const name = nonEmptyText(event.data?.name) ?? t('工具');
const callId = nonEmptyText(event.data?.callId)
?? nonEmptyText(event.data?.subCallId);
if (callId) this.#toolNames.set(callId, name);
this.#lastToolName = name;
pushUpdate({ type: 'tool', name, ...(callId ? { callId } : {}) });
} else if (event.type === 'tool/result') { } else if (event.type === 'tool/result') {
update = { type: 'status', text: '正在整理结果…' }; const callId = nonEmptyText(event.data?.message?.source?.callId)
?? nonEmptyText(event.data?.callId)
?? nonEmptyText(event.data?.subCallId);
const toolName = (callId ? this.#toolNames.get(callId) : null)
?? this.#lastToolName;
const error = toolResultErrorText(event.data?.error);
pushUpdate({
type: 'status',
text: t('正在整理结果…'),
...(toolName ? { toolName } : {}),
...(error ? { error } : {}),
});
} }
} }
return update; return updates;
}
consume(entries) {
return this.consumeAll(entries).at(-1) ?? null;
} }
} }
@ -465,6 +520,7 @@ export class HarnessClient {
#commandExecutor; #commandExecutor;
#controlExecutor; #controlExecutor;
#sessionMaintenanceExecutor; #sessionMaintenanceExecutor;
#fileIngressExecutor;
#managedProcess = null; #managedProcess = null;
#interactionRegistry; #interactionRegistry;
#interactionOwnerships; #interactionOwnerships;
@ -485,6 +541,7 @@ export class HarnessClient {
commandExecutor, commandExecutor,
controlExecutor, controlExecutor,
sessionMaintenanceExecutor, sessionMaintenanceExecutor,
fileIngressExecutor,
}) { }) {
if (typeof createWebSocket !== 'function') { if (typeof createWebSocket !== 'function') {
throw new TypeError('createWebSocket must be a function'); throw new TypeError('createWebSocket must be a function');
@ -508,6 +565,9 @@ export class HarnessClient {
&& typeof sessionMaintenanceExecutor !== 'function') { && typeof sessionMaintenanceExecutor !== 'function') {
throw new TypeError('sessionMaintenanceExecutor must be a function'); throw new TypeError('sessionMaintenanceExecutor must be a function');
} }
if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
throw new TypeError('fileIngressExecutor must be a function');
}
this.#baseUrl = new URL(baseUrl); this.#baseUrl = new URL(baseUrl);
this.#workspace = workspace; this.#workspace = workspace;
// Keep an omitted preset absent so session.create resolves the Host's current default. // Keep an omitted preset absent so session.create resolves the Host's current default.
@ -522,6 +582,7 @@ export class HarnessClient {
this.#commandExecutor = commandExecutor; this.#commandExecutor = commandExecutor;
this.#controlExecutor = controlExecutor; this.#controlExecutor = controlExecutor;
this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor; this.#sessionMaintenanceExecutor = sessionMaintenanceExecutor;
this.#fileIngressExecutor = fileIngressExecutor;
this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin); this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin);
this.#interactionOwnerships = this.#interactionRegistry.ownerships; this.#interactionOwnerships = this.#interactionRegistry.ownerships;
this.#interactionClaims = this.#interactionRegistry.claims; this.#interactionClaims = this.#interactionRegistry.claims;
@ -1025,6 +1086,8 @@ export class HarnessClient {
const timeoutMs = options.timeoutMs ?? 600_000; const timeoutMs = options.timeoutMs ?? 600_000;
const signal = options.signal; const signal = options.signal;
const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null; const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null;
const progressMode = options.progressMode === 'all' ? 'all' : 'latest';
const onArtifact = typeof options.onArtifact === 'function' ? options.onArtifact : null;
const onInteraction = typeof options.onInteraction === 'function' const onInteraction = typeof options.onInteraction === 'function'
? options.onInteraction ? options.onInteraction
: undefined; : undefined;
@ -1032,6 +1095,7 @@ export class HarnessClient {
? options.onInteractionResolved ? options.onInteractionResolved
: undefined; : undefined;
const control = normalizeControl(options.control); const control = normalizeControl(options.control);
const inboundFiles = Array.isArray(options.files) ? options.files.filter(Boolean) : [];
await this.ensureRunning({ signal }); await this.ensureRunning({ signal });
const before = await this.rpc( const before = await this.rpc(
'session.history', 'session.history',
@ -1071,13 +1135,63 @@ export class HarnessClient {
} }
: null; : null;
let interactionTask = null; let interactionTask = null;
let artifactsDelivered = false;
let deliveredArtifactCount = 0;
let stagedInboundFiles = null;
let promptAccepted = false;
let turnFinished = false;
const deliverArtifacts = async () => {
if (!onArtifact || artifactsDelivered || tracker.turn === null) {
return deliveredArtifactCount;
}
artifactsDelivered = true;
const artifacts = outboundArtifactRegistry.take(sessionId, tracker.turn, { signal });
for (const artifact of artifacts) {
try {
await onArtifact(artifact);
deliveredArtifactCount += 1;
} catch (error) {
outboundArtifactRegistry.release(artifact);
console.warn(`[${this.#logPrefix}] ignored an artifact handoff failure:`, error.message);
}
}
return deliveredArtifactCount;
};
if (ownership) { if (ownership) {
this.#registerInteractionOwnership(sessionId, ownership); this.#registerInteractionOwnership(sessionId, ownership);
this.#registerControlOwnership(ownership); this.#registerControlOwnership(ownership);
} }
// This is resource ownership, not a feature Gate: it lets the Host retain
// this Turn's snapshots until the channel has polled and claimed them.
const closeArtifactConsumer = outboundArtifactRegistry.openConsumer(sessionId, promptRpcId);
try { try {
if (inboundFiles.length > 0) {
if (!this.#fileIngressExecutor) {
throw new InboundFileError(
'inbound-file-ingress-unavailable',
'Harness file ingress is unavailable in this Host process.',
);
}
const sessionList = await this.rpc(
'session.list',
{},
30_000,
{ signal },
);
const sessionWorkspace = sessionList?.items?.find(
(item) => item?.sessionId === sessionId,
)?.cwd;
stagedInboundFiles = await this.#fileIngressExecutor({
sessionId,
workspace: sessionWorkspace,
files: inboundFiles,
signal,
});
prompt = appendInboundFilesToPrompt(prompt, stagedInboundFiles);
}
if (interactionSignal) { if (interactionSignal) {
let markOpen; let markOpen;
const opened = new Promise((resolve) => { markOpen = resolve; }); const opened = new Promise((resolve) => { markOpen = resolve; });
@ -1109,6 +1223,7 @@ export class HarnessClient {
content, content,
clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}, 30_000, { rpcId: promptRpcId, signal }); }, 30_000, { rpcId: promptRpcId, signal });
promptAccepted = true;
try { try {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
@ -1125,16 +1240,28 @@ export class HarnessClient {
this.#consumeInteractionOwnerships(sessionId, history.events ?? []); this.#consumeInteractionOwnerships(sessionId, history.events ?? []);
if (!wasActive && ownership.active) ownership.reconnect?.(); if (!wasActive && ownership.active) ownership.reconnect?.();
} }
const update = tracker.consume(history.events ?? []); const updates = tracker.consumeAll(history.events ?? []);
if (update && onUpdate) { if (onUpdate) {
try { const visibleUpdates = progressMode === 'all' ? updates : updates.slice(-1);
await onUpdate(update); for (const update of visibleUpdates) {
} catch (error) { try {
console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message); await onUpdate(update);
} catch (error) {
console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message);
}
} }
} }
if (!tracker.finished) continue; if (!tracker.finished) continue;
if (tracker.answer) return tracker.answer; turnFinished = true;
// An accepted /stop revokes attachment delivery even when Harness
// preserved a useful partial text answer for the existing UX.
const artifactCount = ownership?.stopRequested
? 0
: await deliverArtifacts();
if (tracker.answer) {
return tracker.answer;
}
if (artifactCount > 0) return '';
if (ownership?.stopRequested) throw turnStoppedError(); if (ownership?.stopRequested) throw turnStoppedError();
throw new Error( throw new Error(
`Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`, `Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
@ -1145,15 +1272,24 @@ export class HarnessClient {
// Once cancellation was accepted, transport/poll failures and timeouts // Once cancellation was accepted, transport/poll failures and timeouts
// describe the convergence of that stop, not an unrelated ask failure. // describe the convergence of that stop, not an unrelated ask failure.
if (!ownership?.stopRequested) throw error; if (!ownership?.stopRequested) throw error;
if (tracker.answer) return tracker.answer; if (tracker.answer) {
return tracker.answer;
}
if (error?.code === 'turn-stopped') throw error; if (error?.code === 'turn-stopped') throw error;
throw turnStoppedError(); throw turnStoppedError();
} }
} finally { } finally {
if (stagedInboundFiles && (!promptAccepted || turnFinished)) {
await stagedInboundFiles.cleanup().catch((error) => {
console.warn(`[${this.#logPrefix}] unable to clean inbound files:`, error.message);
});
}
closeArtifactConsumer();
if (ownership) { if (ownership) {
this.#unregisterControlOwnership(ownership); this.#unregisterControlOwnership(ownership);
this.#unregisterInteractionOwnership(sessionId, ownership); this.#unregisterInteractionOwnership(sessionId, ownership);
} }
if (tracker.turn !== null) outboundArtifactRegistry.discard(sessionId, tracker.turn);
interactionController?.abort(new DOMException('Harness turn finished', 'AbortError')); interactionController?.abort(new DOMException('Harness turn finished', 'AbortError'));
if (interactionTask) await interactionTask.catch(() => undefined); if (interactionTask) await interactionTask.catch(() => undefined);
} }

View file

@ -1,3 +1,5 @@
import { t } from './i18n.mjs';
function nonEmptyString(value) { function nonEmptyString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
} }
@ -17,9 +19,9 @@ export function validHarnessQuestion(question) {
export function harnessQuestionText(question, index, total, { requiresMention = false } = {}) { export function harnessQuestionText(question, index, total, { requiresMention = false } = {}) {
const lines = []; const lines = [];
const progress = total > 1 ? `(${index + 1}/${total})` : ''; const progress = total > 1 ? `(${index + 1}/${total})` : '';
lines.push(`DeepSeek Harness 需要你补充信息${progress}:`); lines.push(t('DeepSeek Harness 需要你补充信息{progress}:', { progress }));
if (nonEmptyString(question.header)) lines.push('', question.header.trim()); if (nonEmptyString(question.header)) lines.push('', question.header.trim());
lines.push('', nonEmptyString(question.question) ?? '请输入你的回答。'); lines.push('', nonEmptyString(question.question) ?? t('请输入你的回答。'));
if (nonEmptyString(question.detail)) lines.push('', question.detail.trim()); if (nonEmptyString(question.detail)) lines.push('', question.detail.trim());
const options = Array.isArray(question.options) ? question.options : []; const options = Array.isArray(question.options) ? question.options : [];
@ -31,12 +33,12 @@ export function harnessQuestionText(question, index, total, { requiresMention =
lines.push(`${optionIndex + 1}. ${label}${description ? ` — ${description}` : ''}`); lines.push(`${optionIndex + 1}. ${label}${description ? ` — ${description}` : ''}`);
}); });
lines.push('', question.multiSelect === true lines.push('', question.multiSelect === true
? '请回复选项序号或文字;多选用逗号分隔,也可补充其他内容。' ? t('请回复选项序号或文字;多选用逗号分隔,也可补充其他内容。')
: '请回复一个选项序号或文字,也可直接输入其他答案。'); : t('请回复一个选项序号或文字,也可直接输入其他答案。'));
} else { } else {
lines.push('', '请直接回复你的答案。'); lines.push('', t('请直接回复你的答案。'));
} }
if (requiresMention) lines.push('', '群聊中请 @机器人 后发送答案。'); if (requiresMention) lines.push('', t('群聊中请 @机器人 后发送答案。'));
return lines.join('\n'); return lines.join('\n');
} }

View file

@ -0,0 +1,35 @@
// English translations for host-side user-facing text.
// Keys are the exact Chinese literals passed to t() in src/channels/**.
// Chinese output is the identity default and needs no entries here.
// Entries are maintained per area in ./i18n-en/*.mjs and merged here.
import sharedA from './i18n-en/shared-a.mjs';
import sharedB from './i18n-en/shared-b.mjs';
import sharedC from './i18n-en/shared-c.mjs';
import feishu from './i18n-en/feishu.mjs';
import dingtalk from './i18n-en/dingtalk.mjs';
import wecom from './i18n-en/wecom.mjs';
import qq from './i18n-en/qq.mjs';
import weixin from './i18n-en/weixin.mjs';
import slack from './i18n-en/slack.mjs';
import telegram from './i18n-en/telegram.mjs';
import discord from './i18n-en/discord.mjs';
import whatsapp from './i18n-en/whatsapp.mjs';
import office from './i18n-en/office.mjs';
export const EN = Object.freeze(Object.assign(
{},
sharedA,
sharedB,
sharedC,
feishu,
dingtalk,
wecom,
qq,
weixin,
slack,
telegram,
discord,
whatsapp,
office,
));

View file

@ -0,0 +1,52 @@
// English translations (dingtalk area). Keys are exact Chinese literals passed to t().
export default {
// Bot chat replies and card text (dingtalk-bridge.mjs, dingtalk-card-stream.mjs,
// dingtalk-api.mjs)
'已连接 DeepSeek Harness,正在思考…': 'Connected to DeepSeek Harness, thinking…',
'钉钉机器人已连接 DeepSeek Harness。': 'The DingTalk bot is connected to DeepSeek Harness.',
'钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。': 'DingTalk could not provide the image download address. Please resend; if it keeps failing, check the bot\'s "Send messages as an internal robot" permission.',
'钉钉没有返回图片下载地址,请重新发送。': 'DingTalk did not return an image download address. Please resend.',
'钉钉返回的图片临时地址无法读取,请重新发送。': 'The temporary image address returned by DingTalk could not be read. Please resend.',
'结果文件「{name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。': 'Delivery of the result file "{name}" could not be confirmed. Please check whether it already arrived in the chat before retrying.',
'结果文件「{name}」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。': 'Result file "{name}" was generated, but the DingTalk app or bot lacks file message permission. Enable the app\'s qyapi_base permission and make sure the bot can send file messages.',
'结果文件「{name}」超过当前钉钉机器人可发送的文件大小,未发送。': 'Result file "{name}" exceeds the file size limit of the current DingTalk bot and was not sent.',
'结果文件「{name}」暂时被钉钉限流,未能发送,请稍后重试。': 'Result file "{name}" was rate-limited by DingTalk and could not be sent. Please try again later.',
'结果文件「{name}」已生成,但钉钉拒绝了该文件消息,请检查文件类型和机器人文件消息配置。': 'Result file "{name}" was generated, but DingTalk rejected the file message. Check the file type and the bot\'s file message configuration.',
'结果文件「{name}」已生成,但暂时未能通过钉钉发送,请稍后重试。': 'Result file "{name}" was generated but could not be sent via DingTalk right now. Please try again later.',
'结果文件已生成。': 'The result file has been generated.',
'钉钉机器人与 DeepSeek Harness 连接正常。': 'The DingTalk bot is connected to DeepSeek Harness normally.',
// Host-side status and error text (dingtalk-bridge.mjs, dingtalk-runtime.mjs,
// dingtalk-controller.mjs, config-store.mjs, state-store.mjs)
'钉钉命令处理失败。': 'Failed to process the DingTalk command.',
'钉钉消息没有安全的回复地址。': 'The DingTalk message has no safe reply address.',
'钉钉审批处理失败。': 'Failed to process the DingTalk approval.',
'钉钉消息处理失败。': 'Failed to process the DingTalk message.',
'钉钉消息格式无效。': 'Invalid DingTalk message format.',
'钉钉交互问题发送失败。': 'Failed to send the DingTalk interaction question.',
'钉钉用户': 'DingTalk user',
'钉钉机器人': 'DingTalk bot',
'身份已隐藏': 'Identity hidden',
'钉钉机器人凭据缺失,请移除后重新扫码。': 'The DingTalk bot credentials are missing. Remove the bot and scan the QR code again.',
'钉钉连接未就绪,请稍后重试。': 'The DingTalk connection is not ready. Please try again later.',
'钉钉连接仍未就绪,请稍后重试。': 'The DingTalk connection is still not ready. Please try again later.',
'扫码接入已取消。': 'QR code setup has been cancelled.',
'无法生成钉钉二维码,请稍后重试。': 'Could not generate the DingTalk QR code. Please try again later.',
'二维码已过期,请重新生成。': 'The QR code has expired. Generate a new one.',
'钉钉已接入,但消息连接暂未就绪,请稍后重试。': 'DingTalk is connected, but the message connection is not ready yet. Please try again later.',
'钉钉未完成机器人授权,请重新扫码。': 'DingTalk did not complete the bot authorization. Please scan the QR code again.',
'钉钉授权状态暂时不可用,正在重试。': 'The DingTalk authorization status is temporarily unavailable. Retrying.',
'钉钉已授权,但无法安全保存接入配置。': 'DingTalk authorization succeeded, but the configuration could not be saved safely.',
'钉钉授权查询暂时失败,正在重试。': 'The DingTalk authorization query failed temporarily. Retrying.',
'钉钉机器人({clientId})': 'DingTalk bot ({clientId})',
'钉钉 Stream 消息连接运行正常': 'The DingTalk Stream message connection is running normally',
'钉钉消息连接当前离线': 'The DingTalk message connection is currently offline',
};

View file

@ -0,0 +1,8 @@
// English translations (discord area). Keys are exact Chinese literals passed to t().
export default {
'Discord Bot Token 无效,请重新填写。': 'The Discord Bot Token is invalid. Please enter it again.',
'Discord Gateway Intents 配置不正确,请检查 Developer Portal 的 Bot 设置。':
'The Discord Gateway Intents are misconfigured. Please check the Bot settings in the Developer Portal.',
'Discord机器人': 'Discord Bot',
' Gateway 长连接': ' Gateway long-lived connection',
};

View file

@ -0,0 +1,229 @@
// English translations (feishu area). Keys are exact Chinese literals passed to t().
export default {
// feishu/bridge.mjs — welcome / help
'北汇星河 AIOS 已连接 DeepSeek Harness。':
'BeiHui XingHe AIOS is connected to DeepSeek Harness.',
'/repair 修复卡片按钮回调': '/repair Repair the card-button callback',
'/m(或 /menu) 打开交互卡片菜单': '/m (or /menu) Open the interactive card menu',
'/watch [Session ID 或序号] 关注会话,任务完成自动推送':
'/watch [Session ID or index] Watch a session; completion is pushed automatically',
'/unwatch [Session ID 或序号] 取消关注':
'/unwatch [Session ID or index] Stop watching',
'/watchlist 查看关注列表': '/watchlist View the watch list',
'/archived on|off 会话列表是否包含归档会话':
'/archived on|off Whether the session list includes archived sessions',
'工作区必须是绝对路径。': 'The workspace must be an absolute path.',
'工作区路径不存在。': 'The workspace path does not exist.',
'工作区路径必须指向一个目录。': 'The workspace path must point to a directory.',
'机器人正在移除或已重新接入,无法操作原会话的工作区。':
'The bot is being removed or has been reconnected; the original session’s workspace cannot be changed.',
'操作失败,请稍后重试。': 'The operation failed. Please try again later.',
'结果文件「{name}」已生成,但机器人缺少飞书文件上传权限。请为应用添加 im:resource 并完成必要审批后重试。':
'The result file "{name}" was generated, but the bot lacks Feishu file-upload permission. Add the im:resource scope to the app, complete the required approval, and try again.',
'结果文件「{name}」超过飞书 30 MB 上限,未发送。':
'The result file "{name}" exceeds the Feishu 30 MB limit and was not sent.',
'结果文件「{name}」为空,飞书不允许发送空文件。':
'The result file "{name}" is empty; Feishu does not allow sending empty files.',
'结果文件「{name}」暂时被飞书限流,未能发送,请稍后重试。':
'The result file "{name}" was temporarily rate-limited by Feishu and could not be sent. Please try again later.',
'结果文件「{name}」已生成,但暂时未能发送,请稍后重试。':
'The result file "{name}" was generated but could not be sent right now. Please try again later.',
'处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。':
'Message processing failed. Please try again later. If the problem persists, check the connection status on the Feishu plugin page in DeepSeek Harness.',
'已开启全新 Harness 会话。': 'A brand-new Harness session has started.',
'飞书机器人与 DeepSeek Harness 连接正常。':
'The Feishu bot is connected to DeepSeek Harness and working normally.',
'用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)':
'Usage: /archived on (include archived sessions) or /archived off (hide archived sessions)',
'已开启:会话列表包含归档会话。': 'On: the session list includes archived sessions.',
'已关闭:会话列表隐藏归档会话。': 'Off: the session list hides archived sessions.',
// feishu/bridge.mjs — repair flow
'为避免授权链接暴露,请私聊机器人发送 /repair。':
'To keep the authorization link private, send /repair to the bot in a direct message.',
'当前机器人没有可验证的接入者身份,不能从聊天发起修复;请先在插件页设置管理员。':
'This bot has no verifiable owner identity, so repair cannot be started from chat. Set an administrator on the plugin page first.',
'此操作只能由机器人接入者在私聊中发起,未进行任何修改。':
'This action can only be started by the bot owner in a direct message. Nothing was changed.',
'当前 Host 版本暂不支持聊天内修复,请先更新插件。':
'The current Host version does not support in-chat repair. Update the plugin first.',
'用法:/repair、/repair qr、/repair status、/repair cancel 或 /repair verify':
'Usage: /repair, /repair qr, /repair status, /repair cancel, or /repair verify',
'当前 Runtime 没有可恢复的修复任务记录(机器人可能刚完成密钥更新并重启)。本命令不会启动新的授权;请查看机器人发送的验证结果,确认上一次任务已结束后再发送 /repair。':
'The current Runtime has no recoverable repair attempt (the bot may have just rotated its secret and restarted). This command starts no new authorization. Check the verification result the bot sent, then send /repair after the previous attempt has finished.',
'另一位管理员正在修复该机器人,本次不会显示其授权信息。':
'Another administrator is repairing this bot; its authorization info will not be shown this time.',
'暂时无法取消修复任务,请稍后重试。':
'Could not cancel the repair task right now. Please try again later.',
'暂时无法查询修复状态,请稍后重试。':
'Could not check the repair status right now. Please try again later.',
'修复流程暂时失败,现有机器人连接不受影响;请稍后发送 /repair 重试。':
'The repair flow failed for now; the existing bot connection is unaffected. Send /repair again later.',
'飞书返回了无法安全验证的授权链接,已中止本次修复。':
'Feishu returned an authorization link that cannot be verified safely. This repair was aborted.',
'飞书未返回授权链接,已中止本次修复。':
'Feishu did not return an authorization link. This repair was aborted.',
'授权已确认,正在发送并等待测试按钮回调;收到真实回调后才会完成。':
'Authorization confirmed. Sending a test button and waiting for its callback; this completes only after a real callback arrives.',
'修复状态查询中断,现有机器人连接不受影响;发送 /repair status 重试查询。':
'The repair status query was interrupted; the existing bot connection is unaffected. Send /repair status to retry.',
'链接为短期有效': 'The link is valid for a short time.',
'链接约 {minutes} 分钟后过期': 'The link expires in about {minutes} minutes',
'已有一个修复任务在等待授权。': 'A repair task is already waiting for authorization.',
'🔧 准备修复卡片按钮。': '🔧 Preparing to repair the card buttons.',
'本次只会增量添加 card.action.trigger。请核对确认页只显示这一项;若出现其他权限或事件,请取消。':
'Only card.action.trigger will be added. Check that the confirmation page shows only this item; cancel if any other permission or event appears.',
'当前设备直接打开:': 'Open directly on this device:',
'若要用另一台设备扫码,发送 /repair qr。{expiry}。':
'To scan with another device, send /repair qr. {expiry}.',
'请用另一台设备扫码完成授权{remaining}。':
'Complete authorization by scanning with another device{remaining}.',
'(剩余约 {minutes} 分钟)': ' (about {minutes} minutes left)',
'二维码暂时无法发送,请直接打开授权链接:\n{url}':
'The QR code could not be sent. Open the authorization link directly:\n{url}',
'授权链接已过期;平台未返回成功结果,无法确认已修复。发送 /repair 生成新链接。':
'The authorization link expired; the platform returned no success, so the repair cannot be confirmed. Send /repair for a new link.',
'已取消本次修复授权,未确认完成修复。':
'Repair authorization was cancelled; the repair was not confirmed.',
'你已取消或拒绝授权,没有确认修复;发送 /repair 可重试。':
'You cancelled or declined authorization, so the repair was not confirmed. Send /repair to retry.',
'授权已提交,但未收到测试按钮回调。可能尚未点击或配置仍在传播;稍后发送 /repair verify 查询,不要盲目重复授权。':
'Authorization was submitted, but no test-button callback arrived. It may not have been tapped yet, or the config is still propagating. Send /repair verify later to check; do not authorize blindly again.',
'修复流程暂时失败,现有机器人连接不受影响;发送 /repair 可重试。':
'The repair flow failed for now; the existing bot connection is unaffected. Send /repair to retry.',
'授权已确认,正在等待专用测试按钮的真实回调;回调到达前不会宣告成功。':
'Authorization confirmed. Waiting for the real callback from the dedicated test button; success is not declared until it arrives.',
'授权尚未完成,暂时不能验证卡片按钮。请先打开授权链接并确认。':
'Authorization is not complete, so the card buttons cannot be verified yet. Open the authorization link and confirm first.',
'修复任务正在等待授权{remaining}。发送 /repair qr 可获取二维码,/repair cancel 可取消。':
'The repair task is waiting for authorization{remaining}. Send /repair qr for the QR code, or /repair cancel to cancel.',
',剩余约 {minutes} 分钟': ', about {minutes} minutes left',
'这个菜单已过期,请回复 /m 重新打开。': 'This menu has expired. Send /m to reopen it.',
'菜单没有这个编号,回复 /m 重新打开。': 'This menu has no such number. Send /m to reopen it.',
'本页只有 {count} 个会话,回复 /sessionlist 重新查看。':
'This page has only {count} sessions. Send /sessionlist to view them again.',
'只有 {count} 个工作区,回复 /workspacelist 重新查看。':
'There are only {count} workspaces. Send /workspacelist to view them again.',
'关注列表只有 {count} 个会话。': 'The watch list has only {count} sessions.',
'已绑定会话「{title}」\nID:{id}': 'Session bound: "{title}"\nID: {id}',
'绑定失败:{message}': 'Binding failed: {message}',
'切换失败:{message}': 'Switch failed: {message}',
'用法:/watch <Session ID 或当前工作区序号>':
'Usage: /watch <Session ID or the current workspace index>',
'当前机器人没有可用的工作区,无法按序号解析会话。':
'This bot has no available workspace, so sessions cannot be resolved by index.',
'当前工作区只有 {count} 个会话。': 'The current workspace has only {count} sessions.',
'没有找到这个会话,请用 /sessionlist 查看可用会话。':
'Session not found. Use /sessionlist to see the available sessions.',
'当前状态存储不支持关注。': 'The current state store does not support watching.',
'无法解析会话:{message}': 'Could not resolve the session: {message}',
'每个聊天最多关注 {count} 个会话。': 'Each chat can watch at most {count} sessions.',
'关注列表里没有这个会话,回复 /watchlist 查看。':
'This session is not in the watch list. Send /watchlist to view it.',
'已关注会话「{title}」,任务完成会推送结果。':
'Watching session "{title}"; results are pushed when the task completes.',
'关注失败:{message}': 'Could not watch: {message}',
'已取消关注「{title}」。': 'Unwatched "{title}".',
'取消失败:{message}': 'Could not unwatch: {message}',
'飞书交互问题发送失败。': 'Failed to send the Feishu interaction question.',
// feishu/feishu-cards.mjs — interactive cards
'🤖 助手菜单': '🤖 Assistant menu',
'**点击按钮或直接回复数字**': '**Tap a button or reply with a number**',
'1 · 会话列表': '1 · Sessions',
'2 · 工作区': '2 · Workspace',
'3 · 新会话': '3 · New session',
'4 · 状态': '4 · Status',
'5 · 帮助': '5 · Help',
'**6 · 修复卡片按钮**(请直接回复数字 **6**)':
'**6 · Repair card buttons** (reply with the number **6**)',
'7 · 关注列表': '7 · Watch list',
'🧪 验证卡片按钮': '🧪 Verify card buttons',
'授权已提交。请点击下方按钮;机器人真实收到回调后才会判定修复成功。':
'Authorization submitted. Tap the button below; the repair is confirmed only after the bot receives the real callback.',
'完成验证': 'Finish verification',
'**工作区**:{workspace}\n共 **{total}** 个会话{paging}':
'**Workspace**: {workspace}\n**{total}** sessions in total{paging}',
'(第 {page}/{pageCount} 页)': ' (page {page}/{pageCount})',
'⭐取关': '⭐ Unwatch',
'⭐关注': '⭐ Watch',
'◀ 上一页': '◀ Previous',
'下一页 ▶': 'Next ▶',
'回复数字(1~N)绑定本页会话。': 'Reply with a number (1–N) to bind a session on this page.',
'📂 会话列表': '📂 Session list',
'当前 Host 上没有已登记的工作区。': 'No workspaces are registered on the current Host.',
'回复数字切换工作区,或点击按钮:': 'Reply with a number to switch workspaces, or tap a button:',
'🗂 工作区': '🗂 Workspace',
'🤖 助手菜单(回复数字即可,无需记命令)':
'🤖 Assistant menu (reply with a number — no need to memorize commands)',
'1 · /sessionlist 列出会话(回复数字绑定)':
'1 · /sessionlist List sessions (reply with a number to bind)',
'2 · /workspacelist 列出工作区(回复数字切换)':
'2 · /workspacelist List workspaces (reply with a number to switch)',
'3 · /new 开启新会话': '3 · /new Start a new session',
'4 · /status 连接状态': '4 · /status Connection status',
'5 · /help 本帮助': '5 · /help This help',
'6 · /repair 修复卡片按钮(请回复数字 6)':
'6 · /repair Repair card buttons (reply with the number 6)',
'7 · /watchlist 关注列表': '7 · /watchlist Watch list',
'直接发送文字/图片即继续当前会话。':
'Send text or an image directly to continue the current session.',
'/session ID 或序号 绑定已有会话': '/session ID or index Bind an existing session',
'/watch ID 或序号 关注会话(完成后推送)':
'/watch ID or index Watch a session (push when done)',
'/compact 压缩上下文': '/compact Compact the context',
'/workspace 绝对路径 切换工作区': '/workspace <absolute path> Switch workspace',
'/presetlist 列出可用 Agent Preset': '/presetlist List available Agent Presets',
'任务完成会自动推送,回复数字或点按钮取消关注:':
'Completion is pushed automatically. Reply with a number or tap a button to unwatch:',
'👁 关注列表': '👁 Watch list',
'⭐ 取消关注': '⭐ Unwatch',
'☆ 关注': '☆ Watch',
'🔍 关注列表': '🔍 Watch list',
'勾选要关注的会话': 'Select sessions to watch',
'勾选要取消关注的会话': 'Select sessions to unwatch',
'当前没有关注的会话。任务完成会自动推送结果。':
'No sessions are being watched. Results are pushed automatically when tasks complete.',
'当前关注 **{count}** 个会话:': 'Currently watching **{count}** sessions:',
'**➕ 添加关注**(多选下拉勾选)': '**➕ Add watch** (select from the multi-select dropdown)',
'**➖ 取消关注**(多选下拉勾选)': '**➖ Remove watch** (select from the multi-select dropdown)',
'📋 会话列表': '📋 Session list',
'🔙 返回菜单': '🔙 Back to menu',
'已完成': 'Completed',
'已停止': 'Stopped',
'已中止': 'Aborted',
'已取消': 'Cancelled',
'已结束': 'Ended',
'✅ 任务完成': '✅ Task complete',
'**状态**:{reason}': '**Status**: {reason}',
'打开会话列表': 'Open session list',
'工作区': 'Workspace',
'绑定该会话后可继续追问,输入文字即可。':
'After binding this session you can keep asking; just type a message.',
'当前没有关注的会话。\n`/watch <ID|序号>` 关注后,任务完成会自动推送。':
'No sessions are being watched.\nWatch one with `/watch <ID|index>` and completion is pushed automatically.',
// feishu/feishu-channel.mjs
'正在生成…': 'Generating…',
'回答完成': 'Answer complete',
'飞书机器人': 'Feishu bot',
// feishu/message-utils.mjs
'飞书机器人缺少图片读取权限。请在飞书开放平台为该应用添加 im:message:readonly,发布新版本并完成必要的管理员审批后,再重新发送图片。':
'The Feishu bot is missing image-read permission. Add im:message:readonly to the app in Feishu Open Platform, publish a new version, complete the required admin approval, then resend the image.',
// feishu/feishu-runtime.mjs — callback probe notices
'✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。':
'✅ Repair complete: card.action.trigger was received in a real test; the menu buttons now work.',
'⚠️ 修复验证超时:未收到测试卡按钮的 card.action.trigger,不能确认按钮已修复。请不要重复授权;先检查飞书开放平台的卡片回调配置,确认后再发送 /repair。':
'⚠️ Repair verification timed out: no card.action.trigger from the test card button was received, so the buttons cannot be confirmed repaired. Do not authorize again; check the card-callback configuration in Feishu Open Platform first, then send /repair.',
'⚠️ 修复验证失败:无法发送专用测试卡,不能确认 card.action.trigger 已恢复。请不要重复授权;先检查机器人消息权限和连接状态。':
'⚠️ Repair verification failed: the dedicated test card could not be sent, so card.action.trigger cannot be confirmed restored. Do not authorize again; check the bot message permission and connection status first.',
'⚠️ 修复验证中断:Runtime 已停止,未完成 card.action.trigger 实测,不能确认修复成功。请不要重复授权;先等待机器人恢复连接。':
'⚠️ Repair verification interrupted: the Runtime stopped before the card.action.trigger test completed, so the repair cannot be confirmed. Do not authorize again; wait for the bot to reconnect.',
};

View file

@ -0,0 +1,23 @@
// English translations (office area). Keys are exact Chinese literals passed to t().
export default {
'AI Office 拒绝了 Device Token。': 'AI Office rejected the Device Token.',
'AI Office Connector Hook 尚未就绪。': 'The AI Office Connector Hook is not ready yet.',
'AI Office Connector 协议版本不兼容。': 'The AI Office Connector protocol version is incompatible.',
'本机暂时无法访问 AI Office。': 'This machine cannot reach AI Office right now.',
'AI Office 连接已中断。': 'The AI Office connection was interrupted.',
'本机 Harness 已停止这次执行。': 'The local Harness stopped this run.',
'Office Job 已被领取、取消或结束。': 'The Office Job has already been claimed, cancelled, or finished.',
'本机 Harness 未能完成任务;请检查 Harness 会话后重试。': 'The local Harness could not complete the task; check the Harness Session and try again.',
'Office Job 引用了本机未配置的 Workspace/Preset alias。': 'The Office Job references a Workspace/Preset alias that is not configured locally.',
'你正在本机 DeepSeek Harness 中继续一个来自 AI Office 的任务。': 'You are continuing an AI Office task inside the local DeepSeek Harness.',
'只在当前 Workspace 内行动。完成后必须返回:结果摘要、改动文件、验证证据、未解决风险。': 'Act only within the current Workspace. When finished you must return: a result summary, changed files, verification evidence, and unresolved risks.',
'## 本机 Instruction Preset': '## Local Instruction Preset',
'## 本轮补充指令': '## Additional Instructions for This Round',
'## Office 时间线': '## Office Timeline',
'批准 {tool}': 'Approve {tool}',
'Harness 工具': 'Harness tool',
'Harness 需要 {count} 项补充信息': 'Harness needs {count} pieces of additional information',
'已领取 Job,准备 Workspace alias:{alias}': 'Job claimed; preparing Workspace alias: {alias}',
'Harness Session 已创建。': 'Harness Session created.',
'正在使用 {tool}…': 'Using {tool}…',
};

View file

@ -0,0 +1,54 @@
// English translations (qq area). Keys are exact Chinese literals passed to t().
export default {
'QQ 机器人已连接 DeepSeek Harness。':
'The QQ bot is connected to DeepSeek Harness.',
'回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。':
'The answer is long and the rest could not be sent fully through QQ. Reply “继续” (continue) to receive it.',
'结果文件「{name}」已生成,但 QQ 今日文件上传额度已用完,请稍后重试。':
'The result file "{name}" was generated, but QQ\'s daily file upload quota is used up. Please try again later.',
'结果文件「{name}」已生成,但当前 QQ 机器人没有文件消息权限。':
'The result file "{name}" was generated, but the current QQ bot does not have file message permission.',
'结果文件「{name}」超过当前 QQ 机器人可发送的文件大小,未发送。':
'The result file "{name}" exceeds the file size the current QQ bot can send and was not sent.',
'结果文件「{name}」为空,QQ 不允许发送空文件。':
'The result file "{name}" is empty, and QQ does not allow sending empty files.',
'结果文件「{name}」暂时被 QQ 限流,未能发送,请稍后重试。':
'The result file "{name}" was temporarily rate-limited by QQ and was not sent. Please try again later.',
'结果文件「{name}」已生成,但 QQ 拒绝了该文件或文件消息。':
'The result file "{name}" was generated, but QQ rejected the file or file message.',
'结果文件「{name}」已生成,但暂时未能通过 QQ 发送,请稍后重试。':
'The result file "{name}" was generated but could not be sent via QQ right now. Please try again later.',
'QQ 机器人与 DeepSeek Harness 连接正常。':
'The QQ bot is connected to DeepSeek Harness normally.',
'QQ 交互问题发送失败。': 'Failed to send the QQ interaction question.',
'QQ 机器人凭据缺失,请移除后重新扫码。':
'The QQ bot credentials are missing. Remove the bot and scan the QR code again.',
'QQ 连接未就绪,插件会自动重试。':
'The QQ connection is not ready; the plugin will retry automatically.',
'QQ 扫码服务暂时不可用,请重新生成二维码。':
'The QQ QR service is temporarily unavailable. Please generate a new QR code.',
'无法生成 QQ 二维码,请稍后重试。':
'Unable to generate the QQ QR code. Please try again later.',
'QQ 机器人已绑定,消息连接暂未就绪。':
'The QQ bot is bound, but the message connection is not ready yet.',
'QQ 连接仍未就绪,请稍后重试。':
'The QQ connection is still not ready. Please try again later.',
QQ机器人尚未连接: 'The QQ bot is not connected yet',
'QQ 机器人({appId})': 'QQ bot ({appId})',
QQ机器人: 'QQ bot',
'QQ WebSocket 长连接运行正常': 'The QQ WebSocket long connection is running normally',
'QQ 连接未就绪,插件会自动重试':
'The QQ connection is not ready; the plugin will retry automatically',
'QQ 连接当前离线': 'The QQ connection is currently offline',
'QQ 已授权,但无法安全保存接入配置。':
'QQ authorization succeeded, but the connection configuration could not be saved safely.',
};

View file

@ -0,0 +1,91 @@
// English translations (shared-a area). Keys are exact Chinese literals passed to t().
export default {
'这个问题已在其他客户端处理,无需再次回答。':
'This question has already been answered from another client; no further reply is needed.',
'任务已完成。': 'Task completed.',
'结果文件': 'result file',
'结果文件「{name}」的发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。':
'The delivery result of the result file "{name}" could not be confirmed. Please check whether it already arrived in the chat before retrying.',
'结果文件「{name}」已生成,但 Slack 应用缺少 files:write 权限。请更新 Manifest、重新安装应用并重新连接机器人后重试。':
'The result file "{name}" was generated, but the Slack app is missing the files:write permission. Please update the Manifest, reinstall the app, reconnect the bot, and try again.',
'结果文件「{name}」已生成,但机器人缺少 Discord 的 Send Messages、Attach Files 或 Read Message History 权限。':
'The result file "{name}" was generated, but the bot is missing the Discord Send Messages, Attach Files, or Read Message History permission.',
'结果文件「{name}」已生成,但 Telegram 不允许机器人在当前聊天发送文档,请检查聊天权限。':
'The result file "{name}" was generated, but Telegram does not allow the bot to send documents in this chat. Please check the chat permissions.',
'结果文件「{name}」已生成,但当前机器人没有文件发送权限,请检查渠道权限。':
'The result file "{name}" was generated, but the bot does not have permission to send files. Please check the channel permissions.',
'结果文件「{name}」超过当前渠道大小上限,未发送。':
'The result file "{name}" exceeds the size limit of this channel and was not sent.',
'结果文件「{name}」为空,未发送。':
'The result file "{name}" is empty and was not sent.',
'结果文件「{name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。':
'The result file "{name}" cannot be read or prepared for sending right now. Please make sure the file is still accessible and try again.',
'结果文件「{name}」暂时被当前渠道限流,未能发送,请稍后重试。':
'The result file "{name}" was temporarily rate-limited by this channel and could not be sent. Please try again later.',
'结果文件「{name}」已生成,但当前渠道拒绝了该文件或文件消息。':
'The result file "{name}" was generated, but this channel rejected the file or file message.',
'结果文件「{name}」已生成,但当前渠道暂时未能发送,请稍后重试。':
'The result file "{name}" was generated, but this channel could not send it right now. Please try again later.',
'消息处理失败,请稍后重试。': 'Failed to process the message. Please try again later.',
'{label}机器人': '{label} bot',
'目前支持文字和图片消息。': 'Only text and image messages are supported at the moment.',
'目前支持文字、图片和文件消息。':
'Only text, image, and file messages are supported at the moment.',
'目前支持文字、图片、文件和语音转写消息。':
'Only text, image, file, and voice-transcription messages are supported at the moment.',
'目前支持文字、图片、文件,以及微信已转成文字的语音消息。':
'Only text, image, file, and voice messages already transcribed to text by WeChat are supported at the moment.',
'直接发送文字或图片即可继续当前会话。': 'Send text or an image directly to continue the current session.',
'直接发送文字、图片或文件即可继续当前会话。':
'Send text, an image, or a file directly to continue the current session.',
'直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。':
'Send text, an image, a file, or a voice message already transcribed to text to continue the current session.',
'{label}机器人已连接 DeepSeek Harness。': 'The {label} bot is connected to DeepSeek Harness.',
'/new 开启一个全新会话': '/new Start a brand-new session',
'/compact 压缩当前会话的较早上下文': '/compact Compact the earlier context of the current session',
'/workspace 工作区绝对路径 切换工作区': '/workspace <absolute workspace path> Switch workspace',
'/workspacelist 列出工作区绝对路径': '/workspacelist List absolute workspace paths',
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题':
'/sessionlist [workspace index or absolute path] List session IDs and titles',
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话':
'/session <Session ID or workspace index> Bind this chat to the specified session',
'/models 按序号列出所有可用模型': '/models List all available models by index',
'/model [序号或完整模型ID] 查看或切换当前会话模型':
'/model [index or full model ID] Show or switch the model of the current session',
'示例:先发 /models,再发 /model 2': 'Example: send /models first, then /model 2',
'/presetlist 按序号列出可用 Agent Preset': '/presetlist List available Agent Presets by index',
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset':
'/preset [index or full ID] Show or set the Agent Preset of this bot',
'纯数字 ID:/preset id:<ID>': 'Numeric-only ID: /preset id:<ID>',
'/preset --default 跟随 Host 默认': '/preset --default Follow the Host default',
'/stop 停止当前任务': '/stop Stop the current task',
'/steer 补充指令 纠偏当前任务': '/steer <additional instruction> Steer the current task',
'/status 检查连接状态': '/status Check the connection status',
'/help 显示本帮助': '/help Show this help',
'{label}机器人与 DeepSeek Harness 连接正常。':
'The {label} bot is connected to DeepSeek Harness and working normally.',
'{label}机器人凭据缺失,请移除后重新接入。':
'{label} bot credentials are missing. Remove the bot and connect it again.',
'{label}连接未就绪,插件会自动重试。':
'The {label} connection is not ready yet. The plugin will retry automatically.',
'{label}机器人已接入,消息连接暂未就绪。':
'The {label} bot is connected, but its message connection is not ready yet.',
'{label}连接仍未就绪,请稍后重试。':
'The {label} connection is still not ready. Please try again later.',
'{label}机器人尚未连接': 'The {label} bot is not connected yet',
'{name}({id})': '{name} ({id})',
'{label}{connectionLabel}运行正常': 'The {label}{connectionLabel} is running normally',
'{label}连接未就绪,插件会自动重试':
'The {label} connection is not ready. The plugin will retry automatically',
'{label}连接当前离线': 'The {label} connection is currently offline',
'已开启新会话。请发送你的问题。': 'A new session has started. Please send your question.',
'正在使用{name}…': 'Using {name}…',
'已停止。': 'Stopped.',
'请用文字回答当前问题。': 'Please answer the current question with text.',
'{label}交互问题发送失败。': 'Failed to send the {label} interaction question.',
'回答提交失败。': 'Failed to submit the answer.',
'回答提交失败,请重新发送当前问题的答案。':
'Failed to submit the answer. Please resend your answer to the current question.',
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。':
'A pending question left over in this Session was detected. It has been safely cancelled, and your latest message is being processed.',
};

View file

@ -0,0 +1,219 @@
// English translations (shared-b area). Keys are exact Chinese literals passed to t().
export default {
// workspace-command.mjs
'用法:/session Session ID 或当前工作区序号(/session N)':
'Usage: /session Session ID or the session index in the current Workspace (/session N)',
'用法:\n/sessionlist 列出当前工作区会话\n/sessionlist 工作区序号 按 /workspacelist 序号列出会话\n/sessionlist 工作区绝对路径 列出指定工作区会话':
'Usage:\n/sessionlist List Sessions in the current Workspace\n/sessionlist Workspace index List Sessions by /workspacelist index\n/sessionlist Workspace absolute path List Sessions in the given Workspace',
'工作区必须是绝对路径。\n{usage}': 'The Workspace must be an absolute path.\n{usage}',
'工作区路径包含不支持的字符或长度超过限制。\n{usage}':
'The Workspace path contains unsupported characters or exceeds the length limit.\n{usage}',
'工作区路径不存在。\n{usage}': 'The Workspace path does not exist.\n{usage}',
'工作区路径必须指向一个目录。\n{usage}': 'The Workspace path must point to a directory.\n{usage}',
'用法:/workspacelist': 'Usage: /workspacelist',
'当前机器人暂不支持列出工作区。': 'This bot does not support listing Workspaces yet.',
'当前 Harness Host 上没有仍然存在的已登记工作区。':
'No registered Workspaces still exist on the current Harness Host.',
'当前 Harness Host 上存在的工作区({count}):':
'Workspaces on the current Harness Host ({count}):',
'(当前)': '(current)',
'切换用法:/workspace 工作区绝对路径': 'To switch: /workspace Workspace absolute path',
'查看会话:/sessionlist 工作区序号或绝对路径':
'To view Sessions: /sessionlist Workspace index or absolute path',
'机器人正在移除或已重新接入,无法列出原会话的工作区。':
'The bot is being removed or has been reconnected; cannot list Workspaces of the original Session.',
'暂时无法获取工作区列表,请稍后重试。':
'Unable to get the Workspace list right now; please try again later.',
'当前机器人没有可用的工作区。': 'This bot has no available Workspace.',
'当前机器人暂不支持按序号选择工作区。':
'This bot does not support selecting a Workspace by index yet.',
'工作区序号不存在,请先执行 /workspacelist。':
'The Workspace index does not exist; please run /workspacelist first.',
'今天 {time}': 'Today {time}',
'昨天 {time}': 'Yesterday {time}',
'前天 {time}': 'The day before yesterday {time}',
'{month}月{day}日 {time}': '{month}/{day} {time}',
'{year}年{month}月{day}日': '{year}-{month}-{day}',
'标题暂不可用': 'Title unavailable',
'暂无标题': 'No title',
'(已归档)': '(archived)',
'工作区:{workspace}\n该工作区暂无会话。': 'Workspace: {workspace}\nThis Workspace has no Sessions yet.',
'工作区:{workspace}': 'Workspace: {workspace}',
'会话({count}):': 'Sessions ({count}):',
'绑定用法:/session Session ID 或当前工作区序号(/session N)':
'To bind: /session Session ID or the Session index in the current Workspace (/session N)',
'绑定用法:/session Session ID\n提示:/session N 只按机器人当前工作区的序号绑定。':
"To bind: /session Session ID\nNote: /session N binds by index within the bot's current Workspace only.",
'当前机器人暂不支持列出工作区会话。':
'This bot does not support listing Workspace Sessions yet.',
'机器人正在移除或已重新接入,无法列出原会话的工作区会话。':
'The bot is being removed or has been reconnected; cannot list Workspace Sessions of the original Session.',
'暂时无法获取工作区会话列表,请稍后重试。':
'Unable to get the Workspace Session list right now; please try again later.',
'Session ID 格式无效。\n{usage}': 'Invalid Session ID format.\n{usage}',
'未找到该会话,请先执行 /sessionlist 确认 Session ID。':
'Session not found; please run /sessionlist to confirm the Session ID.',
'子代理会话不能绑定到机器人对话,请选择普通会话。':
'Sub-agent Sessions cannot be bound to a bot conversation; please choose a regular Session.',
'该会话的工作区归属不明确,暂时无法绑定。':
"The Session's Workspace ownership is ambiguous; cannot bind it right now.",
'暂时无法读取该会话的信息,请稍后重试。':
'Unable to read the Session info right now; please try again later.',
'机器人正在移除或已重新接入,无法绑定原对话的会话。':
'The bot is being removed or has been reconnected; cannot bind the Session of the original conversation.',
'工作区或会话状态已发生变化,请重试。':
'The Workspace or Session state has changed; please try again.',
'暂时无法绑定会话,请稍后重试。': 'Unable to bind the Session right now; please try again later.',
'当前机器人暂不支持按序号绑定,请使用 /session Session ID。':
'This bot does not support binding by index yet; please use /session Session ID.',
'会话序号不存在,请先执行 /sessionlist 查看序号。':
'The Session index does not exist; please run /sessionlist to see the indexes.',
'暂时无法获取会话列表,请稍后重试。':
'Unable to get the Session list right now; please try again later.',
'当前机器人暂不支持绑定已有会话。':
'This bot does not support binding existing Sessions yet.',
'当前消息缺少可绑定的会话上下文。':
'The current message lacks a conversation context to bind to.',
'当前聊天已绑定会话:': 'This chat is now bound to the Session:',
'标题:{title}': 'Title: {title}',
'归档:{archived}': 'Archived: {archived}',
'是': 'Yes',
'否': 'No',
'用法:/workspace 工作区绝对路径': 'Usage: /workspace Workspace absolute path',
'当前机器人暂不支持切换工作区。': 'This bot does not support switching Workspaces yet.',
'工作区已切换为:{workspace}': 'Workspace switched to: {workspace}',
'{message}\n用法:/workspace 工作区绝对路径': '{message}\nUsage: /workspace Workspace absolute path',
'机器人正在移除或已重新接入,无法切换原会话的工作区。':
'The bot is being removed or has been reconnected; cannot switch the Workspace of the original Session.',
// preset-command.mjs
'用法:/presetlist(不带参数)': 'Usage: /presetlist (no arguments)',
'用法:\n/preset 查看当前设置\n/preset <序号> 按最近一次 /presetlist 的序号选择\n/preset <ID> 按 Agent Preset ID 选择\n/preset id:<纯数字 ID> 选择纯数字 ID\n/preset --default 跟随 Host 默认':
'Usage:\n/preset Show current settings\n/preset <index> Select by index from the latest /presetlist\n/preset <ID> Select by Agent Preset ID\n/preset id:<numeric ID> Select a purely numeric ID\n/preset --default Follow the Host default',
'未设置或当前不可用': 'Not set or currently unavailable',
'{id}(当前不可用)': '{id} (currently unavailable)',
'跟随 Host 默认:{preset}': 'Following Host default: {preset}',
'跟随 Host 默认(Host 默认当前不可用)': 'Following Host default (Host default currently unavailable)',
'{id}(已不可用)': '{id} (no longer available)',
'当前机器人用于新会话的 Agent Preset:': 'Agent Preset this bot uses for new Sessions:',
'已有会话不会受此设置影响。': 'Existing Sessions are not affected by this setting.',
'查看可用项:/presetlist': 'See available options: /presetlist',
'恢复跟随 Host 默认:/preset --default': 'Restore following Host default: /preset --default',
'Host 默认:{preset}': 'Host default: {preset}',
'可用 Agent Preset({count}):': 'Available Agent Presets ({count}):',
'当前没有可用 Agent Preset。': 'No Agent Presets are currently available.',
'Host 默认': 'Host default',
'当前选择': 'current selection',
'当前生效': 'currently in effect',
'选择:/preset <序号或 ID>': 'To select: /preset <index or ID>',
'当前机器人用于新会话的 Agent Preset 已设置为:':
'The Agent Preset this bot uses for new Sessions is now set to:',
'已有会话不变。若当前聊天已有会话,请先发送 /new,再发送普通消息,才会使用新设置创建会话。':
'Existing Sessions are unchanged. If this chat already has a Session, send /new first, then a regular message, to create a Session with the new setting.',
'Agent Preset 序号无效,请先执行 /presetlist。':
'Invalid Agent Preset index; please run /presetlist first.',
'请先执行 /presetlist,再按列表序号选择 Agent Preset。':
'Please run /presetlist first, then select an Agent Preset by list index.',
'Agent Preset 序号不存在,请重新执行 /presetlist。':
'The Agent Preset index does not exist; please run /presetlist again.',
'Agent Preset ID 格式无效。\n{usage}': 'Invalid Agent Preset ID format.\n{usage}',
'Agent Preset 不存在或当前不可用,请重新执行 /presetlist。':
'The Agent Preset does not exist or is currently unavailable; please run /presetlist again.',
'工作区或机器人状态已发生变化,请重试。':
'The Workspace or bot state has changed; please try again.',
'获取 Agent Preset 列表已取消。': 'Fetching the Agent Preset list was cancelled.',
'获取 Agent Preset 设置已取消。': 'Fetching the Agent Preset settings was cancelled.',
'Agent Preset 修改已取消。': 'The Agent Preset change was cancelled.',
'暂时无法获取 Agent Preset 列表,请稍后重试。':
'Unable to get the Agent Preset list right now; please try again later.',
'暂时无法获取 Agent Preset 设置,请稍后重试。':
'Unable to get the Agent Preset settings right now; please try again later.',
'Agent Preset 修改失败,请稍后重试。': 'Failed to change the Agent Preset; please try again later.',
'Agent Preset 命令仅支持纯文字,请移除图片后重试。':
'Agent Preset commands support text only; please remove images and try again.',
// model-command.mjs
'用法:/model <序号> 或 /model <provider>/<model>': 'Usage: /model <index> or /model <provider>/<model>',
'用法:/models(不带参数)': 'Usage: /models (no arguments)',
'模型序号无效:{input}': 'Invalid model index: {input}',
'请发送 /models 查看并输入有效的正整数序号。':
'Please send /models to view the list and enter a valid positive integer index.',
'可用模型:': 'Available models:',
'当前没有可用模型。': 'No models are currently available.',
'以下模型提供方暂时不可用:': 'The following model providers are temporarily unavailable:',
'切换模型:/model <序号>': 'To switch models: /model <index>',
'当前模型:': 'Current model:',
'查看全部模型:/models': 'See all models: /models',
'当前聊天还没有会话。': 'This chat has no Session yet.',
'查看模型:/models': 'View models: /models',
'选择模型:/model <序号>': 'Select a model: /model <index>',
'当前任务正在运行,请等待完成或先发送 /stop。':
'A task is currently running; please wait for it to finish or send /stop first.',
'当前聊天绑定的会话已不存在,请重试。':
'The Session bound to this chat no longer exists; please try again.',
'无法切换到该模型。模型当前不可用,或不支持当前会话中的图片。':
'Cannot switch to that model. It is currently unavailable, or does not support the images in this Session.',
'当前聊天绑定的会话已发生变化,请重试。':
'The Session bound to this chat has changed; please try again.',
'获取模型列表已取消。': 'Fetching the model list was cancelled.',
'模型切换已取消。': 'The model switch was cancelled.',
'暂时无法获取模型列表,请稍后重试。':
'Unable to get the model list right now; please try again later.',
'模型切换失败,请稍后重试。': 'Failed to switch the model; please try again later.',
'模型命令仅支持纯文字,请移除图片后重试。':
'Model commands support text only; please remove images and try again.',
'当前任务正在等待你的回答或审批。': 'The current task is waiting for your answer or approval.',
'请先处理当前请求,或者发送 /stop 停止任务。':
'Please handle the current request first, or send /stop to stop the task.',
'没有找到模型:{model}': 'Model not found: {model}',
'请发送 /models 查看可用模型。': 'Please send /models to view available models.',
'模型已切换为:\n{model}\n\n后续消息将使用该模型。':
'Model switched to:\n{model}\n\nSubsequent messages will use this model.',
// compact-command.mjs
'用法:/compact(不带参数)': 'Usage: /compact (no arguments)',
'暂无可压缩的历史记录。': 'No compactable history yet.',
'当前会话正在生成回复或执行压缩,请稍后重试。':
'The current Session is generating a reply or compacting; please try again later.',
'上下文压缩已取消。': 'Context compaction was cancelled.',
'压缩期间会话历史发生变化,本次未修改会话,请重试。':
'The Session history changed during compaction; the Session was not modified this time, please try again.',
'未能生成有效的压缩摘要,本次未修改会话。':
'Could not produce a useful compaction summary; the Session was not modified this time.',
'上下文压缩未正常完成,部分会话历史可能已变化,请检查会话后再重试。':
'Context compaction did not finish cleanly; some Session history may have changed. Please inspect the Session before retrying.',
'上下文已压缩,但会话保存失败。': 'Context was compacted, but saving the Session failed.',
'已压缩 {count} 条历史记录(约 {tokens} 个 token)。':
'Compacted {count} history items (~{tokens} tokens).',
'上下文压缩完成。': 'Context compaction completed.',
'上下文压缩失败。': 'Context compaction failed.',
'当前聊天绑定的会话已不存在,请发送新消息开启会话。':
'The Session bound to this chat no longer exists; please send a new message to start a Session.',
'当前会话正在生成回复,请稍后重试。':
'The current Session is generating a reply; please try again later.',
'当前 Harness 暂不支持从机器人执行上下文压缩。':
'The current Harness does not support running context compaction from a bot yet.',
'上下文压缩失败,请稍后重试。': 'Context compaction failed; please try again later.',
'当前机器人没有可用的会话状态。': 'This bot has no available Session state.',
'当前聊天还没有可压缩的会话,请先发送一条消息。':
'This chat has no Session to compact yet; please send a message first.',
'当前机器人暂不支持上下文压缩。': 'This bot does not support context compaction yet.',
'当前 Harness 未注册 /compact 命令,请确认上下文压缩组件已启用。':
'The current Harness has not registered the /compact command; please make sure the context compaction component is enabled.',
// control-command.mjs
'用法:/stop(不带参数)': 'Usage: /stop (no arguments)',
'用法:/steer <补充指令>': 'Usage: /steer <additional instruction>',
'控制命令仅支持纯文字,请移除图片后重试。':
'Control commands support text only; please remove images and try again.',
'当前聊天没有正在运行的任务。': 'This chat has no running task.',
'已请求停止当前任务。': 'Stop requested for the current task.',
'当前聊天没有正在运行的任务,请直接发送普通消息。':
'This chat has no running task; just send a regular message.',
'当前聊天没有绑定会话,无法补充指令。请先绑定会话。':
'No session is bound to this chat, so an additional instruction cannot be given. Bind a session first.',
'任务已结束,没有正在运行的任务,无法补充指令。请直接发送消息开始新任务。':
'The task has ended and there is no running task to steer. Send a message to start a new task.',
'已提交补充指令,Agent 会在下一步读取。':
'Additional instruction submitted; the Agent will read it at the next step.',
};

View file

@ -0,0 +1,99 @@
// English translations (shared-c area). Keys are exact Chinese literals passed to t().
export default {
// harness-approval.mjs
'请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。':
'Please reply exactly with 「批准」 (approve) or 「拒绝」 (reject). Also accepted: 同意 / 不同意 / yes / no.',
'请先完成当前问题,再精准回复「批准」或「拒绝」。':
'Please finish the current question first, then reply exactly with 「批准」 (approve) or 「拒绝」 (reject).',
'该审批已处理,无需再次回复。':
'This approval has already been handled; no need to reply again.',
'DeepSeek Harness 需要你的审批:':
'DeepSeek Harness needs your approval:',
'工具:{tool}': 'Tool: {tool}',
'操作参数:': 'Operation parameters:',
'原因:{reason}': 'Reason: {reason}',
'群聊中请 @机器人 后发送审批决定。':
'In group chats, please @ the bot before sending your approval decision.',
'只有发起当前任务的用户可以处理这条审批。':
'Only the user who started the current task can handle this approval.',
'审批决定正在提交,请稍候。':
'Your approval decision is being submitted; please wait.',
'已批准,仅对本次操作有效。':
'Approved — valid for this operation only.',
'已拒绝此次操作。': 'This operation was rejected.',
'无法完整展示这次操作,已安全拒绝此次审批。':
'The operation could not be displayed in full, so this approval was safely rejected.',
'审批提交失败,请重新回复「批准」或「拒绝」。':
'Failed to submit the approval; please reply with 「批准」 (approve) or 「拒绝」 (reject) again.',
// harness-question.mjs
'DeepSeek Harness 需要你补充信息{progress}:':
'DeepSeek Harness needs more information{progress}:',
'请输入你的回答。': 'Please enter your answer.',
'请回复选项序号或文字;多选用逗号分隔,也可补充其他内容。':
'Reply with option numbers or text; separate multiple choices with commas, or add anything else.',
'请回复一个选项序号或文字,也可直接输入其他答案。':
'Reply with an option number or its text, or type your own answer directly.',
'请直接回复你的答案。': 'Please reply with your answer directly.',
'群聊中请 @机器人 后发送答案。':
'In group chats, please @ the bot before sending your answer.',
// image-prompt.mjs
'当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。':
'The current model does not support images. Use /models to list available models, switch with /model <number>, then resend.',
'图片超过宿主允许的大小,请压缩后重试。':
'The image exceeds the size allowed by the host; compress it and try again.',
'图片分辨率过高,请压缩后重试。':
'The image resolution is too high; compress it and try again.',
'图片内容无效或格式不受支持,请重新发送。':
'The image content is invalid or its format is unsupported; please resend it.',
'未能读取图片内容,请重新发送。':
'Could not read the image content; please resend it.',
'图片格式与实际内容不一致,请重新发送。':
'The image format does not match its actual content; please resend it.',
'一次发送的图片数量超过宿主限制,请减少后重试。':
'The number of images sent at once exceeds the host limit; send fewer and try again.',
'图片总大小超过宿主限制,请减少图片或压缩后重试。':
'The total image size exceeds the host limit; send fewer images or compress them and try again.',
'图片下载地址发生了重定向,暂时无法读取。':
'The image download URL redirected and cannot be read right now.',
'图片下载失败(HTTP {status}),请重新发送后再试。':
'Image download failed (HTTP {status}); please resend it and try again.',
'图片超过 5 MB,请压缩后重试。':
'The image is larger than 5 MB; compress it and try again.',
'一次最多只能处理 {maxImages} 张图片。':
'At most {maxImages} images can be processed at once.',
'一次发送的图片总大小过大,请减少图片数量或压缩后重试。':
'The total size of the images sent is too large; send fewer images or compress them and try again.',
'图片下载失败,请重新发送后再试。':
'Image download failed; please resend it and try again.',
'暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。':
'This image format is not supported yet; please send a JPEG, PNG, WebP, or GIF image.',
// connection-test.mjs
'✅ DeepSeek Harness 连接测试成功':
'✅ DeepSeek Harness connection test succeeded',
'这条消息由插件页面中的“{name}”机器人卡片发出。':
'This message was sent from the "{name}" bot card on the plugin page.',
'{channelLabel}尚未收到可用于测试的私聊消息。':
'The {channelLabel} has not received a direct message that can be used for testing yet.',
'机器人': 'bot',
// editable-message-stream.mjs / harness-client.mjs / progress text
'正在处理…': 'Processing…',
'处理完成。': 'Processing complete.',
'工具': 'tool',
'正在整理结果…': 'Gathering results…',
'_正在搜索网络并整理信息…_': '_Searching the web and gathering information…_',
'_正在使用 {name}…_': '_Using {name}…_',
// image-prompt.mjs
'请分析这张图片。': 'Analyze this image.',
// inbound-file.mjs
'文件接收失败,请重新发送后再试。': 'File reception failed. Please resend it.',
'文件下载失败,请重新发送后再试。': 'File download failed. Please resend it.',
// agent-preset.mjs
'Agent Preset 无效。': 'Invalid Agent Preset.',
};

View file

@ -0,0 +1,27 @@
// English translations (slack area). Keys are exact Chinese literals passed to t().
export default {
'Slack机器人': 'Slack bot',
'Slack机器人凭据缺失,请移除后重新接入。':
'Slack bot credentials are missing. Remove the bot and connect it again.',
'Slack Socket Mode 连接未就绪,插件会自动重试。':
'The Slack Socket Mode connection is not ready yet. The plugin will retry automatically.',
'Slack机器人已接入,Socket Mode 连接暂未就绪。':
'The Slack bot is connected, but the Socket Mode connection is not ready yet.',
'Slack Socket Mode 连接仍未就绪,请检查两个 Token。':
'The Slack Socket Mode connection is still not ready. Check both tokens.',
'Slack机器人尚未连接': 'The Slack bot is not connected yet',
'Slack Socket Mode 长连接运行正常':
'The Slack Socket Mode long-lived connection is running normally',
'Slack连接未就绪,插件会自动重试':
'The Slack connection is not ready. The plugin will retry automatically',
'Slack连接当前离线': 'The Slack connection is currently offline',
'Slack 未授权机器人读取该文件。请为应用添加 files:read 后重新安装,再重新发送图片。':
'Slack has not granted the bot access to this file. Add the files:read scope to the app, reinstall it, then send the image again.',
'Slack Bot Token 必须以 xoxb- 开头。': 'The Slack Bot Token must start with xoxb-.',
'Slack App Token 必须以 xapp- 开头。': 'The Slack App Token must start with xapp-.',
'Slack Bot Token 没有返回完整的机器人身份。':
'The Slack Bot Token did not return a complete bot identity.',
'Slack App Token 无法创建 Socket Mode 连接,请确认已启用 Socket Mode 和 connections:write。':
'The Slack App Token could not create a Socket Mode connection. Make sure Socket Mode and connections:write are enabled.',
};

View file

@ -0,0 +1,21 @@
// English translations (telegram area). Keys are exact Chinese literals passed to t().
export default {
'开启一个全新会话': 'Start a brand-new Session',
'压缩当前会话的较早上下文': 'Compact the earlier context of the current Session',
'切换工作区': 'Switch Workspace',
'列出工作区绝对路径': 'List Workspace absolute paths',
'列出会话 ID 和标题': 'List Session IDs and titles',
'将当前聊天绑定到指定会话': 'Bind the current chat to a specific Session',
'按序号列出所有可用模型': 'List all available models by number',
'查看或切换当前会话模型': 'View or switch the model of the current Session',
'列出可用 Agent Preset': 'List available Agent Presets',
'查看或设置新会话 Agent Preset': 'View or set the Agent Preset for new Sessions',
'停止当前任务': 'Stop the current task',
'纠偏当前任务': 'Steer the current task',
'检查连接状态': 'Check connection status',
'显示帮助': 'Show help',
'该 Telegram 机器人已配置 Webhook,请先在原服务中移除 Webhook 后重试。':
'This Telegram bot already has a Webhook configured. Remove the Webhook from the original service and try again.',
'Telegram机器人': 'Telegram Bot',
' Bot API 长轮询': ' Bot API long polling',
};

View file

@ -0,0 +1,38 @@
// English translations (wecom area). Keys are exact Chinese literals passed to t().
export default {
// Help text (wecom-bridge.mjs)
'企业微信机器人已连接 DeepSeek Harness。': 'The Enterprise WeChat bot is connected to DeepSeek Harness.',
// Bot replies and status text (wecom-bridge.mjs)
'当前待处理图片较多,请稍后重新发送。': 'Too many images are pending. Please send them again later.',
'结果文件「{name}」已生成,但企业微信智能机器人缺少素材上传或文件消息能力,请检查机器人权限。': 'The result file "{name}" was generated, but the Enterprise WeChat bot lacks media upload or file message capability. Please check the bot permissions.',
'结果文件「{name}」超过当前企业微信机器人可发送的文件大小,未发送。': 'The result file "{name}" exceeds the file size this Enterprise WeChat bot can send and was not sent.',
'结果文件「{name}」为空,企业微信不允许发送空文件。': 'The result file "{name}" is empty. Enterprise WeChat does not allow sending empty files.',
'结果文件「{name}」暂时被企业微信限流,未能发送,请稍后重试。': 'The result file "{name}" was rate-limited by Enterprise WeChat and could not be sent. Please try again later.',
'结果文件「{name}」已生成,但企业微信拒绝了该文件或文件消息。': 'The result file "{name}" was generated, but Enterprise WeChat rejected the file or file message.',
'结果文件「{name}」已生成,但暂时未能通过企业微信发送,请稍后重试。': 'The result file "{name}" was generated but could not be sent via Enterprise WeChat right now. Please try again later.',
'任务已完成,但没有生成可显示的文本。': 'The task completed, but no displayable text was generated.',
'目前支持文字、图片和语音转写消息。': 'Currently only text, images, and voice transcription messages are supported.',
'企业微信机器人与 DeepSeek Harness 连接正常。': 'The connection between the Enterprise WeChat bot and DeepSeek Harness is working.',
'正在思考中…': 'Thinking…',
'企业微信交互问题发送失败。': 'Failed to send the Enterprise WeChat interaction question.',
// Provisioning, status, and errors (wecom-controller.mjs)
'企业微信机器人凭据缺失,请移除后重新扫码。': 'The Enterprise WeChat bot credentials are missing. Please remove the bot and scan the QR code again.',
'企业微信连接未就绪,插件会自动重试。': 'The Enterprise WeChat connection is not ready. The plugin will retry automatically.',
'扫码绑定已取消。': 'QR code binding has been cancelled.',
'无法生成企业微信二维码,请稍后重试。': 'Unable to generate the Enterprise WeChat QR code. Please try again later.',
'企业微信二维码已过期,请重新生成。': 'The Enterprise WeChat QR code has expired. Please generate a new one.',
'企业微信机器人已绑定,消息连接暂未就绪。': 'The Enterprise WeChat bot is bound, but the message connection is not ready yet.',
'企业微信连接仍未就绪,请稍后重试。': 'The Enterprise WeChat connection is still not ready. Please try again later.',
'企业微信扫码没有完成,请重新生成二维码。': 'The Enterprise WeChat QR scan was not completed. Please generate a new QR code.',
'企业微信扫码服务暂时不可用,请重新生成二维码。': 'The Enterprise WeChat QR service is temporarily unavailable. Please generate a new QR code.',
'企业微信已授权,但无法安全保存接入配置。': 'Enterprise WeChat authorization succeeded, but the connection configuration could not be saved safely.',
'企业微信机器人': 'Enterprise WeChat bot',
'企业微信机器人({botId})': 'Enterprise WeChat bot ({botId})',
'企业微信 WebSocket 长连接运行正常': 'The Enterprise WeChat WebSocket long connection is running normally',
'企业微信连接未就绪,插件会自动重试': 'The Enterprise WeChat connection is not ready. The plugin will retry automatically',
'企业微信连接当前离线': 'The Enterprise WeChat connection is currently offline',
};

View file

@ -0,0 +1,80 @@
// English translations (weixin area). Keys are exact Chinese literals passed to t().
export default {
// weixin-bridge.mjs
'微信已连接 DeepSeek Harness。': 'WeChat is connected to DeepSeek Harness.',
'结果文件「{name}」已生成,但微信机器人当前没有文件消息发送权限,请检查机器人文件消息能力。': 'The result file "{name}" was generated, but the WeChat bot currently has no permission to send file messages. Please check the bot\'s file messaging capability.',
'结果文件「{name}」超过当前微信会话可发送的文件大小,未发送。': 'The result file "{name}" exceeds the file size limit of the current WeChat conversation and was not sent.',
'结果文件「{name}」暂时被微信限流,未能发送,请稍后重试。': 'The result file "{name}" was temporarily rate-limited by WeChat and was not sent. Please try again later.',
'结果文件「{name}」已生成,但微信拒绝了该文件消息。': 'The result file "{name}" was generated, but WeChat rejected the file message.',
'结果文件「{name}」已生成,但暂时未能通过微信发送,请稍后重试。': 'The result file "{name}" was generated but could not be sent via WeChat right now. Please try again later.',
'目前支持文字、图片,以及微信已转成文字的语音消息。': 'Currently supports text, images, and voice messages that WeChat has converted to text.',
'微信与 DeepSeek Harness 连接正常。': 'WeChat is connected to DeepSeek Harness normally.',
'微信交互问题发送失败。': 'Failed to send the WeChat interaction question.',
// weixin-controller.mjs
'微信已授权,但无法读取现有登录凭据。请检查 DSH 凭据存储。': 'WeChat was authorized, but the existing login credential could not be read. Please check the DSH credential store.',
'微信已授权,但登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。': 'WeChat was authorized, but the login credential could not be written to the DSH credential store. Please check that the credential store is writable.',
'微信已授权,但账号配置无法写入本机。请检查 DSH_HOME 目录权限。': 'WeChat was authorized, but the account configuration could not be written locally. Please check the DSH_HOME directory permissions.',
'微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。': 'WeChat was authorized, but the account state or workspace could not be initialized. Please check DSH_HOME and the workspace directories.',
'微信已授权,但插件无法连接本机 Harness。请检查 dsh web 地址和端口。': 'WeChat was authorized, but the plugin could not connect to the local Harness. Please check the dsh web address and port.',
'微信已授权,但 Harness 健康检查超时。请确认 dsh web 未阻塞。': 'WeChat was authorized, but the Harness health check timed out. Please confirm dsh web is not blocked.',
'微信已授权,但 Harness 健康检查需要身份认证。请检查代理、网关或自定义鉴权配置。': 'WeChat was authorized, but the Harness health check requires authentication. Please check proxy, gateway, or custom auth configuration.',
'微信已授权,但本机 Harness 请求被代理要求认证。请让回环地址绕过代理,并检查 NO_PROXY 配置。': 'WeChat was authorized, but the local Harness request was challenged for proxy authentication. Please bypass the proxy for loopback addresses and check the NO_PROXY configuration.',
'微信已授权,但 Harness 异常拒绝了回环地址的健康检查。请检查 HTTP 代理、Harness 源码版本和构建产物。': 'WeChat was authorized, but Harness unexpectedly rejected the health check from a loopback address. Please check the HTTP proxy, Harness source version, and build artifacts.',
'微信已授权,但 Harness 的 Host 信任检查拒绝了非回环地址请求。请检查 harnessBaseUrl 与 trustedHosts 配置。': 'WeChat was authorized, but the Harness Host trust check rejected a non-loopback request. Please check the harnessBaseUrl and trustedHosts configuration.',
'微信已授权,但健康检查收到了非 Harness 标准的 403 拒绝响应。请检查代理或网关配置。': 'WeChat was authorized, but the health check received a non-standard Harness 403 rejection. Please check the proxy or gateway configuration.',
'微信已授权,但找不到 Harness 健康检查接口。请确认 Harness 与插件版本兼容。': 'WeChat was authorized, but the Harness health check endpoint was not found. Please confirm the Harness and plugin versions are compatible.',
'微信已授权,但 Harness 健康检查返回服务错误。请查看 dsh web 日志。': 'WeChat was authorized, but the Harness health check returned a server error. Please check the dsh web logs.',
'微信已授权,但 Harness 返回了无法识别的响应。请确认 Harness 与插件版本兼容。': 'WeChat was authorized, but Harness returned an unrecognized response. Please confirm the Harness and plugin versions are compatible.',
'微信已授权,但 Harness 拒绝了健康检查请求。请查看 dsh web 日志。': 'WeChat was authorized, but Harness rejected the health check request. Please check the dsh web logs.',
'微信已授权,但 Harness 健康检查发生未知错误。请查看 dsh web 日志。': 'WeChat was authorized, but the Harness health check failed with an unknown error. Please check the dsh web logs.',
'微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。': 'WeChat was authorized, but the message connection failed to initialize. Please check the dsh web logs and try again.',
'微信已授权,但激活过程中发生未知错误。请查看 dsh web 日志。': 'WeChat was authorized, but an unknown error occurred during activation. Please check the dsh web logs.',
'登录凭据缺失,请移除账号后重新扫码。': 'The login credential is missing. Please remove the account and scan the QR code again.',
'微信连接未就绪,插件会自动重试。': 'The WeChat connection is not ready; the plugin will retry automatically.',
'微信连接未就绪,插件会自动重试': 'The WeChat connection is not ready; the plugin will retry automatically',
'无法生成微信二维码,请稍后重试。': 'Unable to generate the WeChat QR code. Please try again later.',
'微信连接仍未就绪,请稍后重试。': 'The WeChat connection is still not ready. Please try again later.',
'微信机器人': 'WeChat Bot',
'微信机器人({name})': 'WeChat Bot ({name})',
'微信消息长轮询运行正常': 'WeChat message long-polling is running normally',
'微信连接当前离线': 'The WeChat connection is currently offline',
'配对码多次错误,请重新生成二维码。': 'The pairing code was entered incorrectly too many times. Please generate a new QR code.',
'该微信账号已绑定,但本机没有可恢复的凭据。': 'This WeChat account is already bound, but no recoverable credential exists on this machine.',
'微信授权成功,但返回的账号凭据不完整。': 'WeChat authorization succeeded, but the returned account credential is incomplete.',
// weixin-runtime.mjs
'微信登录凭据已失效,请移除账号后重新扫码。': 'The WeChat login credential has expired. Please remove the account and scan the QR code again.',
'微信消息同步请求被拒绝。': 'The WeChat message sync request was rejected.',
// weixin-api.mjs
'微信图片的加密密钥无效。': 'The encryption key of the WeChat image is invalid.',
'微信图片的加密数据无效。': 'The encrypted data of the WeChat image is invalid.',
'微信图片解密失败。': 'Failed to decrypt the WeChat image.',
'微信图片没有可用的下载地址。': 'The WeChat image has no usable download URL.',
'微信图片的下载地址无效。': 'The download URL of the WeChat image is invalid.',
'微信图片的下载地址不受信任。': 'The download URL of the WeChat image is not trusted.',
'微信服务返回了无效的连接地址。': 'The WeChat service returned an invalid connection URL.',
'微信服务返回了不受信任的连接地址。': 'The WeChat service returned an untrusted connection URL.',
'微信服务没有返回扫码地址。': 'The WeChat service did not return a QR-code URL.',
'微信服务返回了无效的扫码地址。': 'The WeChat service returned an invalid QR-code URL.',
'微信服务返回了不受信任的扫码地址。': 'The WeChat service returned an untrusted QR-code URL.',
'微信服务返回了无效的文件上传地址。': 'The WeChat service returned an invalid file upload URL.',
'微信服务返回了不受信任的文件上传地址。': 'The WeChat service returned an untrusted file upload URL.',
'微信服务没有返回文件上传地址。': 'The WeChat service did not return a file upload URL.',
'微信文件上传被拒绝(HTTP {status})。': 'The WeChat file upload was rejected (HTTP {status}).',
'微信文件上传失败(HTTP {status})。': 'The WeChat file upload failed (HTTP {status}).',
'微信文件上传响应缺少下载参数。': 'The WeChat file upload response is missing the download parameter.',
'微信文件上传失败。': 'The WeChat file upload failed.',
'拒绝访问不受信任的微信服务地址。': 'Refusing to access an untrusted WeChat service URL.',
'微信服务请求失败(HTTP {status})。': 'The WeChat service request failed (HTTP {status}).',
'微信服务返回了无法解析的响应。': 'The WeChat service returned an unparseable response.',
'微信服务请求超时。': 'The WeChat service request timed out.',
'暂时无法访问微信服务。': 'The WeChat service is temporarily unreachable.',
'微信服务返回了无法识别的扫码状态。': 'The WeChat service returned an unrecognized QR-code status.',
'微信服务没有返回二维码令牌。': 'The WeChat service did not return a QR-code token.',
'微信服务拒绝了回复消息。': 'The WeChat service rejected the reply message.',
'微信服务拒绝了文件上传请求。': 'The WeChat service rejected the file upload request.',
'微信服务拒绝了文件消息。': 'The WeChat service rejected the file message.',
'微信账号连接启动失败。': 'Failed to start the WeChat account connection.',
};

View file

@ -0,0 +1,17 @@
// English translations (whatsapp area). Keys are exact Chinese literals passed to t().
export default {
'WhatsApp 关联设备已失效,请移除后重新扫码。': 'The WhatsApp linked device is no longer valid. Remove it and scan the QR code again.',
'WhatsApp 连接未就绪,插件会自动重试。': 'The WhatsApp connection is not ready yet; the plugin will retry automatically.',
'WhatsApp 连接仍未就绪,请稍后重试。': 'The WhatsApp connection is still not ready. Please try again later.',
'WhatsApp机器人尚未连接': 'The WhatsApp bot is not connected yet',
'WhatsApp机器人': 'WhatsApp bot',
'WhatsApp Web 关联设备运行正常': 'The WhatsApp Web linked device is working normally',
'WhatsApp 连接未就绪': 'The WhatsApp connection is not ready',
'WhatsApp 连接当前离线': 'The WhatsApp connection is currently offline',
'WhatsApp 已绑定,消息连接暂未就绪。': 'WhatsApp is linked, but the messaging connection is not ready yet.',
'WhatsApp 已扫码,但无法保存关联设备。': 'The QR code was scanned, but the linked device could not be saved.',
'无法连接 WhatsApp,请重新生成二维码。': 'Unable to connect to WhatsApp. Please generate a new QR code.',
'WhatsApp账号': 'WhatsApp account',
' Web 关联设备': ' Web linked device',
};

View file

@ -0,0 +1,35 @@
// Host-side i18n for dsh-im. Mirrors the conventions of the settings-UI
// translator in plugin-src/client/i18n.js: dictionary keys are the exact
// Chinese source literals, and Chinese (zh) is the identity default, so
// untranslated or unrecognized text always falls back to the original
// Chinese output unchanged.
//
// The English dictionary lives in ./i18n-en.mjs to keep this module small.
import { EN } from './i18n-en.mjs';
let language = 'zh';
// Accepts 'en', 'en-US', 'english' (any case) as English; anything else
// (including undefined and unrecognized values) selects Chinese.
export function setImHostLanguage(lang) {
const normalized = typeof lang === 'string' ? lang.trim().toLowerCase() : '';
language = normalized === 'english' || /^en(?:[-_].*)?$/u.test(normalized) ? 'en' : 'zh';
}
export function getImHostLanguage() {
return language;
}
// Translate a user-facing Chinese literal. In zh mode (the default) this is
// the identity function. Optional `params` fills `{name}` placeholders in
// both the Chinese key and its translation, e.g.
// t('共 {count} 个机器人', { count: 3 })
export function t(text, params) {
if (typeof text !== 'string') return text;
const translated = language === 'en' ? EN[text] ?? text : text;
if (params == null) return translated;
return translated.replace(/\{(\w+)\}/g, (match, name) =>
Object.hasOwn(params, name) ? String(params[name]) : match,
);
}

View file

@ -1,3 +1,5 @@
import { t } from './i18n.mjs';
const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024; const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const DEFAULT_MAX_IMAGES = 20; const DEFAULT_MAX_IMAGES = 20;
const DEFAULT_MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024; const DEFAULT_MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024;
@ -67,7 +69,7 @@ export async function fetchImageBuffer(url, {
throw new ImagePromptError( throw new ImagePromptError(
'image-redirect-blocked', 'image-redirect-blocked',
`Image download redirect was blocked (HTTP ${response.status})`, `Image download redirect was blocked (HTTP ${response.status})`,
'图片下载地址发生了重定向,暂时无法读取。', t('图片下载地址发生了重定向,暂时无法读取。'),
); );
} }
if (!response?.ok) { if (!response?.ok) {
@ -75,7 +77,7 @@ export async function fetchImageBuffer(url, {
throw new ImagePromptError( throw new ImagePromptError(
'image-http-error', 'image-http-error',
`Image download failed with HTTP ${response?.status ?? 'unknown'}`, `Image download failed with HTTP ${response?.status ?? 'unknown'}`,
`图片下载失败(HTTP ${response?.status ?? 'unknown'}),请重新发送后再试。`, t('图片下载失败(HTTP {status}),请重新发送后再试。', { status: response?.status ?? 'unknown' }),
); );
} }
const declaredLength = Number(response.headers?.get?.('content-length')); const declaredLength = Number(response.headers?.get?.('content-length'));
@ -84,7 +86,7 @@ export async function fetchImageBuffer(url, {
throw new ImagePromptError( throw new ImagePromptError(
'image-too-large', 'image-too-large',
`Image response declares ${declaredLength} bytes; the limit is ${maxBytes}`, `Image response declares ${declaredLength} bytes; the limit is ${maxBytes}`,
'图片超过 5 MB,请压缩后重试。', t('图片超过 5 MB,请压缩后重试。'),
); );
} }
@ -99,7 +101,7 @@ export async function fetchImageBuffer(url, {
throw new ImagePromptError( throw new ImagePromptError(
'image-too-large', 'image-too-large',
`Image response exceeded ${maxBytes} bytes`, `Image response exceeded ${maxBytes} bytes`,
'图片超过 5 MB,请压缩后重试。', t('图片超过 5 MB,请压缩后重试。'),
); );
} }
chunks.push(data); chunks.push(data);
@ -112,7 +114,7 @@ export async function fetchImageBuffer(url, {
throw new ImagePromptError( throw new ImagePromptError(
'image-too-large', 'image-too-large',
`Image response contains ${data.length} bytes; the limit is ${maxBytes}`, `Image response contains ${data.length} bytes; the limit is ${maxBytes}`,
'图片超过 5 MB,请压缩后重试。', t('图片超过 5 MB,请压缩后重试。'),
); );
} }
return data; return data;
@ -192,7 +194,7 @@ export async function promptContentForMessage(message, {
throw new ImagePromptError( throw new ImagePromptError(
'too-many-images', 'too-many-images',
`Image message contains ${sources.length} images; the limit is ${maxImages}`, `Image message contains ${sources.length} images; the limit is ${maxImages}`,
`一次最多只能处理 ${maxImages} 张图片。`, t('一次最多只能处理 {maxImages} 张图片。', { maxImages }),
); );
} }
@ -200,7 +202,7 @@ export async function promptContentForMessage(message, {
const content = []; const content = [];
let totalImageBytes = 0; let totalImageBytes = 0;
if (text) content.push({ type: 'text', text }); if (text) content.push({ type: 'text', text });
else if (sources.length > 0) content.push({ type: 'text', text: DEFAULT_IMAGE_PROMPT }); else if (sources.length > 0) content.push({ type: 'text', text: t(DEFAULT_IMAGE_PROMPT) });
for (const [index, source] of sources.entries()) { for (const [index, source] of sources.entries()) {
signal?.throwIfAborted(); signal?.throwIfAborted();
@ -208,14 +210,14 @@ export async function promptContentForMessage(message, {
throw new ImagePromptError( throw new ImagePromptError(
'image-too-large', 'image-too-large',
`Image ${index + 1} declares ${source.size} bytes; the limit is ${maxImageBytes}`, `Image ${index + 1} declares ${source.size} bytes; the limit is ${maxImageBytes}`,
'图片超过 5 MB,请压缩后重试。', t('图片超过 5 MB,请压缩后重试。'),
); );
} }
if (Number.isFinite(source?.size) && totalImageBytes + source.size > maxTotalImageBytes) { if (Number.isFinite(source?.size) && totalImageBytes + source.size > maxTotalImageBytes) {
throw new ImagePromptError( throw new ImagePromptError(
'images-too-large', 'images-too-large',
`Images declare more than ${maxTotalImageBytes} bytes in total`, `Images declare more than ${maxTotalImageBytes} bytes in total`,
'一次发送的图片总大小过大,请减少图片数量或压缩后重试。', t('一次发送的图片总大小过大,请减少图片数量或压缩后重试。'),
); );
} }
@ -230,7 +232,7 @@ export async function promptContentForMessage(message, {
throw new ImagePromptError( throw new ImagePromptError(
'image-download-failed', 'image-download-failed',
`Unable to download image ${index + 1}: ${error?.message ?? String(error)}`, `Unable to download image ${index + 1}: ${error?.message ?? String(error)}`,
'图片下载失败,请重新发送后再试。', t('图片下载失败,请重新发送后再试。'),
{ cause: error }, { cause: error },
); );
} }
@ -239,21 +241,21 @@ export async function promptContentForMessage(message, {
throw new ImagePromptError( throw new ImagePromptError(
'invalid-image-data', 'invalid-image-data',
`Image ${index + 1} returned no data`, `Image ${index + 1} returned no data`,
'未能读取图片内容,请重新发送。', t('未能读取图片内容,请重新发送。'),
); );
} }
if (loaded.data.length > maxImageBytes) { if (loaded.data.length > maxImageBytes) {
throw new ImagePromptError( throw new ImagePromptError(
'image-too-large', 'image-too-large',
`Image ${index + 1} contains ${loaded.data.length} bytes; the limit is ${maxImageBytes}`, `Image ${index + 1} contains ${loaded.data.length} bytes; the limit is ${maxImageBytes}`,
'图片超过 5 MB,请压缩后重试。', t('图片超过 5 MB,请压缩后重试。'),
); );
} }
if (totalImageBytes + loaded.data.length > maxTotalImageBytes) { if (totalImageBytes + loaded.data.length > maxTotalImageBytes) {
throw new ImagePromptError( throw new ImagePromptError(
'images-too-large', 'images-too-large',
`Images contain more than ${maxTotalImageBytes} bytes in total`, `Images contain more than ${maxTotalImageBytes} bytes in total`,
'一次发送的图片总大小过大,请减少图片数量或压缩后重试。', t('一次发送的图片总大小过大,请减少图片数量或压缩后重试。'),
); );
} }
totalImageBytes += loaded.data.length; totalImageBytes += loaded.data.length;
@ -262,7 +264,7 @@ export async function promptContentForMessage(message, {
throw new ImagePromptError( throw new ImagePromptError(
'unsupported-image-type', 'unsupported-image-type',
`Image ${index + 1} is not JPEG, PNG, GIF, or WebP`, `Image ${index + 1} is not JPEG, PNG, GIF, or WebP`,
'暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。', t('暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。'),
); );
} }
content.push({ content.push({
@ -289,7 +291,7 @@ export function imagePromptDiagnostic(error) {
} }
const reason = error.details.reason; const reason = error.details.reason;
const userMessage = Object.hasOwn(HOST_ATTACHMENT_USER_MESSAGES, reason) const userMessage = Object.hasOwn(HOST_ATTACHMENT_USER_MESSAGES, reason)
? HOST_ATTACHMENT_USER_MESSAGES[reason] ? t(HOST_ATTACHMENT_USER_MESSAGES[reason])
: null; : null;
return userMessage ? { code: 'attachment-error', reason, userMessage } : null; return userMessage ? { code: 'attachment-error', reason, userMessage } : null;
} }

View file

@ -0,0 +1,208 @@
import { createWriteStream } from 'node:fs';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { isAbsolute, join, relative, resolve } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { t } from './i18n.mjs';
const FILES_DIRECTORY = join('.dsh-im', 'inbound');
export class InboundFileError extends Error {
constructor(code, message, userMessage = t('文件接收失败,请重新发送后再试。'), options = {}) {
super(message, options);
this.name = 'InboundFileError';
this.code = code;
this.userMessage = userMessage;
}
}
function fileSources(message) {
return Array.isArray(message?.files) ? message.files.filter(Boolean) : [];
}
function displayName(value, fallback) {
if (typeof value !== 'string') return fallback;
const cleaned = value
.replaceAll('\\', '/')
.split('/')
.at(-1)
?.replace(/[\u0000-\u001f\u007f]/g, '')
.trim();
return cleaned || fallback;
}
function storageName(value, index) {
const cleaned = displayName(value, 'file')
.replace(/[^\p{L}\p{N}._ -]/gu, '_')
.replace(/^\.+/, '')
.slice(0, 160) || 'file';
return `${String(index + 1).padStart(2, '0')}-${cleaned}`;
}
function loadedFile(value) {
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
return { data: Buffer.from(value) };
}
const raw = value?.data ?? value?.buffer;
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
return {
data: Buffer.from(raw),
name: value?.name ?? value?.filename,
mediaType: value?.mediaType ?? value?.mimetype,
};
}
const stream = value?.stream ?? value;
if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
return {
stream,
name: value?.name ?? value?.filename,
mediaType: value?.mediaType ?? value?.mimetype,
};
}
return null;
}
export function hasInboundFiles(message) {
return fileSources(message).length > 0;
}
/** Start provider downloads immediately while preserving the lazy file-source contract. */
export function prefetchInboundFiles(message, { signal } = {}) {
const sources = fileSources(message);
if (sources.length === 0) return message;
return {
...message,
files: sources.map((source) => {
if (source?.data !== undefined || typeof source?.load !== 'function') return source;
let download;
try {
download = Promise.resolve(source.load({ signal }));
} catch (error) {
download = Promise.reject(error);
}
download.catch(() => undefined);
return {
...source,
async load({ signal: loadSignal } = {}) {
loadSignal?.throwIfAborted();
const result = await download;
loadSignal?.throwIfAborted();
return result;
},
};
}),
};
}
export async function stageInboundFiles(message, {
workspace,
signal,
} = {}) {
const sources = fileSources(message);
if (sources.length === 0) return null;
if (typeof workspace !== 'string' || !isAbsolute(workspace)) {
throw new InboundFileError(
'inbound-file-workspace-unavailable',
'The Harness Session workspace is unavailable for inbound files.',
);
}
signal?.throwIfAborted();
const root = resolve(workspace, FILES_DIRECTORY);
await mkdir(root, { recursive: true, mode: 0o700 });
const directory = await mkdtemp(join(root, 'turn-'));
const files = [];
try {
for (const [index, source] of sources.entries()) {
signal?.throwIfAborted();
let value;
try {
value = source?.data === undefined
? await source?.load?.({ signal })
: source.data;
} catch (error) {
if (signal?.aborted) throw error;
throw new InboundFileError(
'inbound-file-download-failed',
`Unable to download inbound file ${index + 1}: ${error?.message ?? String(error)}`,
t('文件下载失败,请重新发送后再试。'),
{ cause: error },
);
}
const loaded = loadedFile(value);
if (!loaded) {
throw new InboundFileError(
'inbound-file-data-invalid',
`Inbound file ${index + 1} returned no readable data.`,
);
}
const name = displayName(loaded.name ?? source?.name, `file-${index + 1}`);
const path = join(directory, storageName(name, index));
if (loaded.data) {
await writeFile(path, loaded.data, { mode: 0o600, signal });
} else {
try {
await pipeline(
loaded.stream,
createWriteStream(path, { flags: 'wx', mode: 0o600 }),
{ signal },
);
} catch (error) {
if (signal?.aborted) throw error;
throw new InboundFileError(
'inbound-file-download-failed',
`Unable to stream inbound file ${index + 1}: ${error?.message ?? String(error)}`,
t('文件下载失败,请重新发送后再试。'),
{ cause: error },
);
}
}
const relativePath = relative(resolve(workspace), path);
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) {
throw new InboundFileError(
'inbound-file-path-invalid',
'The staged inbound file escaped the Harness Session workspace.',
);
}
files.push(Object.freeze({
name,
path: relativePath,
...(typeof (loaded.mediaType ?? source?.mediaType) === 'string'
&& (loaded.mediaType ?? source.mediaType).trim()
? { mediaType: (loaded.mediaType ?? source.mediaType).trim() }
: {}),
}));
}
return Object.freeze({
files: Object.freeze(files),
async cleanup() {
await rm(directory, { recursive: true, force: true });
},
});
} catch (error) {
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
}
export function appendInboundFilesToPrompt(prompt, staged) {
if (!staged?.files?.length) return prompt;
const manifest = [
'<dsh_im_files>',
JSON.stringify({
description: 'Files uploaded with this user message. Paths are relative to the current Harness workspace.',
files: staged.files,
}),
'</dsh_im_files>',
].join('\n');
if (Array.isArray(prompt)) return [...prompt, { type: 'text', text: manifest }];
const text = typeof prompt === 'string' ? prompt.trim() : '';
return text ? `${text}\n\n${manifest}` : manifest;
}
export function inboundFileUserMessage(error) {
return error instanceof InboundFileError ? error.userMessage : null;
}

View file

@ -1,4 +1,5 @@
import { splitWorkspaceCommandMessage } from './workspace-command.mjs'; import { splitWorkspaceCommandMessage } from './workspace-command.mjs';
import { t } from './i18n.mjs';
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs'; import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
import { withSessionBindingLock } from './session-binding-lock.mjs'; import { withSessionBindingLock } from './session-binding-lock.mjs';
@ -109,9 +110,9 @@ function modelNumberRequest(requested) {
function invalidModelNumberMessage(requested) { function invalidModelNumberMessage(requested) {
return [ return [
`模型序号无效:${safeDisplayText(requested)}`, t('模型序号无效:{input}', { input: safeDisplayText(requested) }),
'', '',
'请发送 /models 查看并输入有效的正整数序号。', t('请发送 /models 查看并输入有效的正整数序号。'),
].join('\n'); ].join('\n');
} }
@ -119,43 +120,43 @@ function formatCatalog(catalog) {
const currentId = catalog.current const currentId = catalog.current
? modelId(catalog.current.provider, catalog.current.model) ? modelId(catalog.current.provider, catalog.current.model)
: null; : null;
const lines = ['可用模型:']; const lines = [t('可用模型:')];
let index = 0; let index = 0;
if (catalog.groups.length === 0) lines.push('', '当前没有可用模型。'); if (catalog.groups.length === 0) lines.push('', t('当前没有可用模型。'));
for (const group of catalog.groups) { for (const group of catalog.groups) {
lines.push('', safeDisplayText(group.name) || safeDisplayText(group.id)); lines.push('', safeDisplayText(group.name) || safeDisplayText(group.id));
for (const model of group.models) { for (const model of group.models) {
index += 1; index += 1;
const id = modelId(group.id, model.id); const id = modelId(group.id, model.id);
lines.push(`${index}. ${safeDisplayText(id)}${id === currentId ? '(当前)' : ''}`); lines.push(`${index}. ${safeDisplayText(id)}${id === currentId ? t('(当前)') : ''}`);
} }
} }
if (catalog.failures.length > 0) { if (catalog.failures.length > 0) {
lines.push('', '以下模型提供方暂时不可用:'); lines.push('', t('以下模型提供方暂时不可用:'));
for (const failure of catalog.failures) { for (const failure of catalog.failures) {
lines.push(`- ${safeDisplayText(failure.name) || safeDisplayText(failure.id)}`); lines.push(`- ${safeDisplayText(failure.name) || safeDisplayText(failure.id)}`);
} }
} }
if (index > 0) lines.push('', '切换模型:/model <序号>'); if (index > 0) lines.push('', t('切换模型:/model <序号>'));
return lines.join('\n'); return lines.join('\n');
} }
function currentModelMessage(current) { function currentModelMessage(current) {
return [ return [
'当前模型:', t('当前模型:'),
modelId(current.provider, current.model), modelId(current.provider, current.model),
'', '',
'查看全部模型:/models', t('查看全部模型:/models'),
'切换模型:/model <序号>', t('切换模型:/model <序号>'),
].join('\n'); ].join('\n');
} }
function noSessionMessage() { function noSessionMessage() {
return [ return [
'当前聊天还没有会话。', t('当前聊天还没有会话。'),
'', '',
'查看模型:/models', t('查看模型:/models'),
'选择模型:/model <序号>', t('选择模型:/model <序号>'),
].join('\n'); ].join('\n');
} }
@ -166,26 +167,26 @@ function errorCode(error) {
function modelErrorMessage(error, action) { function modelErrorMessage(error, action) {
const code = errorCode(error); const code = errorCode(error);
if (code === 'agent-busy') { if (code === 'agent-busy') {
return '当前任务正在运行,请等待完成或先发送 /stop。'; return t('当前任务正在运行,请等待完成或先发送 /stop。');
} }
if (code === 'session-not-found') { if (code === 'session-not-found') {
return '当前聊天绑定的会话已不存在,请重试。'; return t('当前聊天绑定的会话已不存在,请重试。');
} }
if (code === 'model-unavailable') { if (code === 'model-unavailable') {
return '无法切换到该模型。模型当前不可用,或不支持当前会话中的图片。'; return t('无法切换到该模型。模型当前不可用,或不支持当前会话中的图片。');
} }
if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') { if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
return '工作区或机器人状态已发生变化,请重试。'; return t('工作区或机器人状态已发生变化,请重试。');
} }
if (code === SESSION_BINDING_CHANGED) { if (code === SESSION_BINDING_CHANGED) {
return '当前聊天绑定的会话已发生变化,请重试。'; return t('当前聊天绑定的会话已发生变化,请重试。');
} }
if (code === 'cancelled' || error?.name === 'AbortError') { if (code === 'cancelled' || error?.name === 'AbortError') {
return action === 'list' ? '获取模型列表已取消。' : '模型切换已取消。'; return action === 'list' ? t('获取模型列表已取消。') : t('模型切换已取消。');
} }
return action === 'list' return action === 'list'
? '暂时无法获取模型列表,请稍后重试。' ? t('暂时无法获取模型列表,请稍后重试。')
: '模型切换失败,请稍后重试。'; : t('模型切换失败,请稍后重试。');
} }
async function boundSession(harness, state, key, options) { async function boundSession(harness, state, key, options) {
@ -243,12 +244,12 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
if (!isModelCommand(text)) return null; if (!isModelCommand(text)) return null;
const command = text.trim(); const command = text.trim();
if (options.hasImages) { if (options.hasImages) {
return commandResult('模型命令仅支持纯文字,请移除图片后重试。'); return commandResult(t('模型命令仅支持纯文字,请移除图片后重试。'));
} }
const requestOptions = rpcOptions(options.signal); const requestOptions = rpcOptions(options.signal);
if (isModelsCommand(command)) { if (isModelsCommand(command)) {
if (!/^\/models[ \t]*$/iu.test(command)) return commandResult(MODELS_USAGE); if (!/^\/models[ \t]*$/iu.test(command)) return commandResult(t(MODELS_USAGE));
try { try {
const bound = await boundSession(harness, state, key, requestOptions); const bound = await boundSession(harness, state, key, requestOptions);
const catalog = bound const catalog = bound
@ -261,7 +262,7 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
} }
const match = /^\/model(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(command); const match = /^\/model(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(command);
if (!match) return commandResult(MODEL_USAGE); if (!match) return commandResult(t(MODEL_USAGE));
const requested = match[1]; const requested = match[1];
if (!requested) { if (!requested) {
try { try {
@ -279,13 +280,13 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
} }
if (!numberRequest if (!numberRequest
&& (!requested.includes('/') || requested.startsWith('/') || requested.endsWith('/'))) { && (!requested.includes('/') || requested.startsWith('/') || requested.endsWith('/'))) {
return commandResult(MODEL_USAGE); return commandResult(t(MODEL_USAGE));
} }
if (options.pendingInteraction) { if (options.pendingInteraction) {
return commandResult([ return commandResult([
'当前任务正在等待你的回答或审批。', t('当前任务正在等待你的回答或审批。'),
'', '',
'请先处理当前请求,或者发送 /stop 停止任务。', t('请先处理当前请求,或者发送 /stop 停止任务。'),
].join('\n')); ].join('\n'));
} }
@ -293,7 +294,7 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
return await withSessionBindingLock(state, key, async () => { return await withSessionBindingLock(state, key, async () => {
const bound = await boundSession(harness, state, key, requestOptions); const bound = await boundSession(harness, state, key, requestOptions);
if (bound && await sessionIsBusy(bound.session, options.control, requestOptions)) { if (bound && await sessionIsBusy(bound.session, options.control, requestOptions)) {
return commandResult('当前任务正在运行,请等待完成或先发送 /stop。'); return commandResult(t('当前任务正在运行,请等待完成或先发送 /stop。'));
} }
const catalog = bound const catalog = bound
@ -305,9 +306,9 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
if (!selection) { if (!selection) {
if (numberRequest) return commandResult(invalidModelNumberMessage(requested)); if (numberRequest) return commandResult(invalidModelNumberMessage(requested));
return commandResult([ return commandResult([
`没有找到模型:${safeDisplayText(requested)}`, t('没有找到模型:{model}', { model: safeDisplayText(requested) }),
'', '',
'请发送 /models 查看可用模型。', t('请发送 /models 查看可用模型。'),
].join('\n')); ].join('\n'));
} }
@ -344,7 +345,10 @@ export async function runModelCommand(text, harness, state, key, options = {}) {
throw stale; throw stale;
} }
} }
return commandResult(`模型已切换为:\n${modelId(selection.provider, selection.model)}\n\n后续消息将使用该模型。`); return commandResult(t(`模型已切换为:
{model}
后续消息将使用该模型。`, { model: modelId(selection.provider, selection.model) }));
}); });
} catch (error) { } catch (error) {
return commandResult(modelErrorMessage(error, 'select')); return commandResult(modelErrorMessage(error, 'select'));

View file

@ -2,6 +2,7 @@ import {
normalizeAgentPresetCatalog, normalizeAgentPresetCatalog,
normalizeAgentPresetId, normalizeAgentPresetId,
} from './agent-preset.mjs'; } from './agent-preset.mjs';
import { t } from './i18n.mjs';
import { withSessionBindingLock } from './session-binding-lock.mjs'; import { withSessionBindingLock } from './session-binding-lock.mjs';
import { splitWorkspaceCommandMessage } from './workspace-command.mjs'; import { splitWorkspaceCommandMessage } from './workspace-command.mjs';
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs'; import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
@ -59,7 +60,7 @@ function normalizeSettings(value) {
function presetItemText(item) { function presetItemText(item) {
const label = safeDisplayText(item.label) || item.id; const label = safeDisplayText(item.label) || item.id;
return `${label}(${item.id})`; return t('{label}({id})', { label, id: item.id });
} }
function itemFor(catalog, id) { function itemFor(catalog, id) {
@ -67,11 +68,11 @@ function itemFor(catalog, id) {
} }
function defaultDescription(catalog) { function defaultDescription(catalog) {
if (!catalog.defaultId) return '未设置或当前不可用'; if (!catalog.defaultId) return t('未设置或当前不可用');
const item = itemFor(catalog, catalog.defaultId); const item = itemFor(catalog, catalog.defaultId);
return item return item
? presetItemText(item) ? presetItemText(item)
: `${catalog.defaultId}(当前不可用)`; : t('{id}(当前不可用)', { id: catalog.defaultId });
} }
function currentDescription(settings) { function currentDescription(settings) {
@ -79,63 +80,63 @@ function currentDescription(settings) {
if (agentPreset === null) { if (agentPreset === null) {
const item = itemFor(catalog, catalog.defaultId); const item = itemFor(catalog, catalog.defaultId);
return item return item
? `跟随 Host 默认:${presetItemText(item)}` ? t('跟随 Host 默认:{preset}', { preset: presetItemText(item) })
: '跟随 Host 默认(Host 默认当前不可用)'; : t('跟随 Host 默认(Host 默认当前不可用)');
} }
const item = itemFor(catalog, agentPreset); const item = itemFor(catalog, agentPreset);
return item return item
? presetItemText(item) ? presetItemText(item)
: `${agentPreset}(已不可用)`; : t('{id}(已不可用)', { id: agentPreset });
} }
function formatCurrent(settings) { function formatCurrent(settings) {
return [ return [
'当前机器人用于新会话的 Agent Preset:', t('当前机器人用于新会话的 Agent Preset:'),
currentDescription(settings), currentDescription(settings),
'', '',
'已有会话不会受此设置影响。', t('已有会话不会受此设置影响。'),
'查看可用项:/presetlist', t('查看可用项:/presetlist'),
'恢复跟随 Host 默认:/preset --default', t('恢复跟随 Host 默认:/preset --default'),
].join('\n'); ].join('\n');
} }
function formatList(settings) { function formatList(settings) {
const { agentPreset, agentPresetCatalog: catalog } = settings; const { agentPreset, agentPresetCatalog: catalog } = settings;
const lines = [ const lines = [
'当前机器人用于新会话的 Agent Preset:', t('当前机器人用于新会话的 Agent Preset:'),
currentDescription(settings), currentDescription(settings),
'', '',
`Host 默认:${defaultDescription(catalog)}`, t('Host 默认:{preset}', { preset: defaultDescription(catalog) }),
'', '',
`可用 Agent Preset(${catalog.items.length}):`, t('可用 Agent Preset({count}):', { count: catalog.items.length }),
]; ];
if (catalog.items.length === 0) { if (catalog.items.length === 0) {
lines.push('当前没有可用 Agent Preset。'); lines.push(t('当前没有可用 Agent Preset。'));
} else { } else {
catalog.items.forEach((item, index) => { catalog.items.forEach((item, index) => {
const markers = []; const markers = [];
if (item.id === catalog.defaultId) markers.push('Host 默认'); if (item.id === catalog.defaultId) markers.push(t('Host 默认'));
if (item.id === agentPreset) markers.push('当前选择'); if (item.id === agentPreset) markers.push(t('当前选择'));
if (agentPreset === null && item.id === catalog.defaultId) markers.push('当前生效'); if (agentPreset === null && item.id === catalog.defaultId) markers.push(t('当前生效'));
const annotation = markers.length > 0 ? `(${markers.join(',')})` : ''; const annotation = markers.length > 0 ? `(${markers.join(',')})` : '';
lines.push(`${index + 1}. ${presetItemText(item)}${annotation}`); lines.push(`${index + 1}. ${presetItemText(item)}${annotation}`);
}); });
} }
lines.push( lines.push(
'', '',
'选择:/preset <序号或 ID>', t('选择:/preset <序号或 ID>'),
'纯数字 ID:/preset id:<ID>', t('纯数字 ID:/preset id:<ID>'),
'恢复跟随 Host 默认:/preset --default', t('恢复跟随 Host 默认:/preset --default'),
); );
return lines.join('\n'); return lines.join('\n');
} }
function formatUpdated(settings) { function formatUpdated(settings) {
return [ return [
'当前机器人用于新会话的 Agent Preset 已设置为:', t('当前机器人用于新会话的 Agent Preset 已设置为:'),
currentDescription(settings), currentDescription(settings),
'', '',
'已有会话不变。若当前聊天已有会话,请先发送 /new,再发送普通消息,才会使用新设置创建会话。', t('已有会话不变。若当前聊天已有会话,请先发送 /new,再发送普通消息,才会使用新设置创建会话。'),
].join('\n'); ].join('\n');
} }
@ -189,16 +190,16 @@ function presetFromSnapshot(state, key, requested) {
if (!/^\d+$/u.test(requested)) return { numeric: false, id: null }; if (!/^\d+$/u.test(requested)) return { numeric: false, id: null };
const index = Number(requested); const index = Number(requested);
if (!Number.isSafeInteger(index) || index < 1) { if (!Number.isSafeInteger(index) || index < 1) {
return { numeric: true, error: 'Agent Preset 序号无效,请先执行 /presetlist。' }; return { numeric: true, error: t('Agent Preset 序号无效,请先执行 /presetlist。') };
} }
const snapshot = loadSnapshot(state, key); const snapshot = loadSnapshot(state, key);
if (!snapshot) { if (!snapshot) {
return { numeric: true, error: '请先执行 /presetlist,再按列表序号选择 Agent Preset。' }; return { numeric: true, error: t('请先执行 /presetlist,再按列表序号选择 Agent Preset。') };
} }
const id = snapshot[index - 1]; const id = snapshot[index - 1];
return id return id
? { numeric: true, id } ? { numeric: true, id }
: { numeric: true, error: 'Agent Preset 序号不存在,请重新执行 /presetlist。' }; : { numeric: true, error: t('Agent Preset 序号不存在,请重新执行 /presetlist。') };
} }
function errorCode(error) { function errorCode(error) {
@ -208,22 +209,23 @@ function errorCode(error) {
function presetErrorMessage(error, action) { function presetErrorMessage(error, action) {
const code = errorCode(error); const code = errorCode(error);
if (code === 'agent-preset-invalid') { if (code === 'agent-preset-invalid') {
return `Agent Preset ID 格式无效。\n${PRESET_USAGE}`; return t(`Agent Preset ID 格式无效。
{usage}`, { usage: t(PRESET_USAGE) });
} }
if (code === 'agent-preset-unavailable') { if (code === 'agent-preset-unavailable') {
return 'Agent Preset 不存在或当前不可用,请重新执行 /presetlist。'; return t('Agent Preset 不存在或当前不可用,请重新执行 /presetlist。');
} }
if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') { if (code === WORKSPACE_SESSION_STALE || code === 'workspace-bot-not-found') {
return '工作区或机器人状态已发生变化,请重试。'; return t('工作区或机器人状态已发生变化,请重试。');
} }
if (code === 'cancelled' || error?.name === 'AbortError') { if (code === 'cancelled' || error?.name === 'AbortError') {
if (action === 'list') return '获取 Agent Preset 列表已取消。'; if (action === 'list') return t('获取 Agent Preset 列表已取消。');
if (action === 'current') return '获取 Agent Preset 设置已取消。'; if (action === 'current') return t('获取 Agent Preset 设置已取消。');
return 'Agent Preset 修改已取消。'; return t('Agent Preset 修改已取消。');
} }
if (action === 'list') return '暂时无法获取 Agent Preset 列表,请稍后重试。'; if (action === 'list') return t('暂时无法获取 Agent Preset 列表,请稍后重试。');
if (action === 'current') return '暂时无法获取 Agent Preset 设置,请稍后重试。'; if (action === 'current') return t('暂时无法获取 Agent Preset 设置,请稍后重试。');
return 'Agent Preset 修改失败,请稍后重试。'; return t('Agent Preset 修改失败,请稍后重试。');
} }
async function settings(harness, options) { async function settings(harness, options) {
@ -250,12 +252,12 @@ export async function runPresetCommand(text, harness, state, key, options = {})
if (!isPresetCommand(text)) return null; if (!isPresetCommand(text)) return null;
const command = text.trim(); const command = text.trim();
if (options.hasImages) { if (options.hasImages) {
return commandResult('Agent Preset 命令仅支持纯文字,请移除图片后重试。'); return commandResult(t('Agent Preset 命令仅支持纯文字,请移除图片后重试。'));
} }
const requestOptions = rpcOptions(options.signal); const requestOptions = rpcOptions(options.signal);
if (PRESET_LIST_COMMAND.test(command)) { if (PRESET_LIST_COMMAND.test(command)) {
if (!/^\/presetlist[ \t]*$/iu.test(command)) return commandResult(PRESET_LIST_USAGE); if (!/^\/presetlist[ \t]*$/iu.test(command)) return commandResult(t(PRESET_LIST_USAGE));
try { try {
const current = await settings(harness, requestOptions); const current = await settings(harness, requestOptions);
saveSnapshot(state, key, current.agentPresetCatalog.items); saveSnapshot(state, key, current.agentPresetCatalog.items);
@ -266,7 +268,7 @@ export async function runPresetCommand(text, harness, state, key, options = {})
} }
const match = /^\/preset(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(command); const match = /^\/preset(?:[ \t]+([^\s]+))?[ \t]*$/iu.exec(command);
if (!match) return commandResult(PRESET_USAGE); if (!match) return commandResult(t(PRESET_USAGE));
const requested = match[1]; const requested = match[1];
if (!requested) { if (!requested) {
try { try {
@ -290,7 +292,8 @@ export async function runPresetCommand(text, harness, state, key, options = {})
selected = fromSnapshot.id; selected = fromSnapshot.id;
} else { } else {
selected = normalizeAgentPresetId(requested); selected = normalizeAgentPresetId(requested);
if (!selected) return commandResult(`Agent Preset ID 格式无效。\n${PRESET_USAGE}`); if (!selected) return commandResult(t(`Agent Preset ID 格式无效。
{usage}`, { usage: t(PRESET_USAGE) }));
} }
} }
} }

View file

@ -0,0 +1,170 @@
import {
materializeOutboundArtifact,
releaseOutboundArtifact,
} from './artifact.mjs';
import {
createArtifactFailureReceipt,
createDeliveryReceipt,
mergeDeliveryReceipts,
providerMessageIdsFor,
} from './delivery.mjs';
function unavailableError() {
const error = new Error('Native file delivery is unavailable');
error.code = 'artifact-provider-unavailable';
return error;
}
function isAbort(error, signal) {
return signal?.aborted
|| error?.name === 'AbortError'
|| error?.code === 'ABORT_ERR';
}
function providerIds(value) {
if (Array.isArray(value)) {
return [...new Set(value
.filter((candidate) => (
(typeof candidate === 'string' && candidate.trim())
|| Number.isSafeInteger(candidate)
))
.map(String))];
}
return providerMessageIdsFor(value);
}
async function sendMaterializedArtifact(file, {
sendFile,
sendImage,
signal,
}) {
if (file.mediaType?.startsWith('image/') && typeof sendImage === 'function') {
try {
return {
presentation: 'image',
result: await sendImage(file),
};
} catch (error) {
if (isAbort(error, signal) || error?.code === 'artifact-delivery-uncertain') {
throw error;
}
}
}
signal?.throwIfAborted();
if (typeof sendFile !== 'function') throw unavailableError();
return {
presentation: 'file',
result: await sendFile(file),
};
}
/**
* Deliver registered artifacts with one shared image-first policy while keeping
* provider protocol details inside the channel-supplied send closures.
*/
export async function deliverOutboundArtifacts({
artifacts = [],
baseReceipt = null,
deliveryId,
aggregatePresentation,
alwaysMerge = false,
channelKey,
signal,
sendFile,
sendImage,
sendFailureNotice,
logger,
}) {
const receipts = baseReceipt ? [baseReceipt] : [];
let userVisible = Boolean(baseReceipt) && baseReceipt.deliveryOutcome !== 'failed';
let failureNoticeVisible = false;
let artifactsSent = 0;
let artifactSendErrors = 0;
let artifactIndex = 0;
try {
while (artifactIndex < artifacts.length) {
const artifact = artifacts[artifactIndex];
artifactIndex += 1;
try {
signal?.throwIfAborted();
const file = await materializeOutboundArtifact(artifact, { signal });
signal?.throwIfAborted();
const sent = await sendMaterializedArtifact(file, {
sendFile,
sendImage,
signal,
});
signal?.throwIfAborted();
receipts.push(createDeliveryReceipt({
deliveryId: file.deliveryKey,
presentation: `${channelKey}-${sent.presentation}`,
providerMessageIds: providerIds(sent.result),
artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
}));
artifactsSent += 1;
userVisible = true;
} catch (error) {
if (isAbort(error, signal)) throw error;
artifactSendErrors += 1;
logger?.warn?.(
`[dsh-im:${channelKey}] result artifact delivery failed (${error?.code ?? 'unknown'})`,
);
let messageIds = [];
if (typeof sendFailureNotice === 'function') {
try {
signal?.throwIfAborted();
const notice = await sendFailureNotice(artifact, error);
signal?.throwIfAborted();
messageIds = providerIds(notice);
failureNoticeVisible = true;
} catch (noticeError) {
if (isAbort(noticeError, signal)) throw noticeError;
logger?.warn?.(
`[dsh-im:${channelKey}] unable to send the safe artifact failure notice`,
);
}
}
const failureReceipt = createArtifactFailureReceipt({
artifactId: artifact?.artifactId ?? 'unknown',
deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
error,
providerMessageIds: messageIds,
});
receipts.push(failureReceipt);
if (failureNoticeVisible || failureReceipt.artifacts[0]?.outcome === 'unknown') {
userVisible = true;
}
} finally {
releaseOutboundArtifact(artifact);
}
}
} finally {
while (artifactIndex < artifacts.length) {
releaseOutboundArtifact(artifacts[artifactIndex]);
artifactIndex += 1;
}
}
let receipt = null;
if (receipts.length === 1 && !alwaysMerge) {
[receipt] = receipts;
} else if (receipts.length > 0) {
receipt = mergeDeliveryReceipts({
deliveryId: deliveryId
?? baseReceipt?.deliveryId
?? artifacts[0]?.deliveryKey,
presentation: aggregatePresentation
?? `${channelKey}-${baseReceipt ? 'text-and-files' : 'files'}`,
receipts,
});
}
return {
receipt,
userVisible,
failureNoticeVisible,
artifactsSent,
artifactSendErrors,
};
}

View file

@ -0,0 +1,748 @@
import { createHash, randomUUID } from 'node:crypto';
import { constants as fsConstants } from 'node:fs';
import { copyFile, lstat, mkdtemp, open, realpath, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, extname, isAbsolute, join, resolve } from 'node:path';
export const OUTBOUND_ARTIFACT_TOOL = 'dsh_im_return_file';
const ARTIFACT_KIND = 'dsh-im-outbound-artifact';
const ARTIFACT_READ_CHUNK_BYTES = 64 * 1024;
const MIME_BY_EXTENSION = new Map([
['.csv', 'text/csv'],
['.doc', 'application/msword'],
['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
['.gif', 'image/gif'],
['.html', 'text/html'],
['.jpeg', 'image/jpeg'],
['.jpg', 'image/jpeg'],
['.json', 'application/json'],
['.md', 'text/markdown'],
['.pdf', 'application/pdf'],
['.png', 'image/png'],
['.rar', 'application/vnd.rar'],
['.txt', 'text/plain'],
['.webp', 'image/webp'],
['.xls', 'application/vnd.ms-excel'],
['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
['.xml', 'application/xml'],
['.zip', 'application/zip'],
]);
const artifactStorage = new WeakMap();
const materializedArtifactSources = new WeakMap();
const artifactProviderSettlements = new WeakMap();
let managedSnapshotDirectoryPromise;
function managedSnapshotDirectory() {
managedSnapshotDirectoryPromise ??= mkdtemp(join(tmpdir(), 'dsh-im-outbound-'));
return managedSnapshotDirectoryPromise;
}
function artifactError(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
function currentTurn(agent) {
const events = agent?.session?.events;
if (!Array.isArray(events)) return null;
let turn = null;
for (const event of events) {
if (event?.type === 'turn/start') {
turn = Number.isInteger(event.data?.turn) ? event.data.turn : null;
} else if (event?.type === 'turn/end' && event.data?.turn === turn) {
turn = null;
}
}
return Number.isInteger(turn) && turn >= 0 ? turn : null;
}
function sessionIdOf(session) {
const sessionId = session?.id ?? session?.header?.id;
return typeof sessionId === 'string' && sessionId ? sessionId : null;
}
function turnKey(sessionId, turn) {
return `${sessionId}\u0000${turn}`;
}
function promptKey(sessionId, promptRpcId) {
return `${sessionId}\u0000${promptRpcId}`;
}
function safeFileName(value) {
const cleaned = String(value ?? '')
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\p{Cf}/gu, '')
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) return 'result.bin';
return [...cleaned].slice(0, 255).join('');
}
function mediaTypeFor(name) {
return MIME_BY_EXTENSION.get(extname(name).toLowerCase()) ?? 'application/octet-stream';
}
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}
function sameIdentity(left, right) {
return typeof left?.dev === 'bigint'
&& typeof left?.ino === 'bigint'
&& typeof left?.size === 'bigint'
&& typeof left?.mtimeNs === 'bigint'
&& typeof left?.ctimeNs === 'bigint'
&& left.dev === right?.dev
&& left.ino === right?.ino
&& left.size === right?.size
&& left.mtimeNs === right?.mtimeNs
&& left.ctimeNs === right?.ctimeNs;
}
async function hashFile(path, signal) {
let handle;
try {
handle = await open(path, fsConstants.O_RDONLY);
const hash = createHash('sha256');
const stream = handle.createReadStream({
autoClose: false,
highWaterMark: ARTIFACT_READ_CHUNK_BYTES,
...(signal ? { signal } : {}),
});
for await (const chunk of stream) {
signal?.throwIfAborted();
hash.update(chunk);
}
return hash.digest('hex');
} finally {
await handle?.close().catch(() => undefined);
}
}
/** Read exactly the observed file size and prove EOF. No project-level size or time limit applies. */
export async function readExactArtifactFile(handle, expectedSize, {
signal,
errorCode = 'artifact-changed',
errorMessage = 'The result file changed while it was being read.',
} = {}) {
const changed = () => artifactError(errorCode, errorMessage);
if (!Number.isSafeInteger(expectedSize) || expectedSize < 0) throw changed();
signal?.throwIfAborted();
let bytes;
try {
bytes = Buffer.allocUnsafe(expectedSize);
} catch {
throw changed();
}
if (typeof handle?.createReadStream === 'function') {
const stream = handle.createReadStream({
autoClose: false,
start: 0,
// Node's end offset is inclusive, so this reads one byte beyond the
// observed size when the file grows and catches the change.
end: expectedSize,
highWaterMark: ARTIFACT_READ_CHUNK_BYTES,
...(signal ? { signal } : {}),
});
let offset = 0;
for await (const chunk of stream) {
signal?.throwIfAborted();
const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (offset + part.length > expectedSize) throw changed();
part.copy(bytes, offset);
offset += part.length;
}
if (offset !== expectedSize) throw changed();
return bytes;
}
let offset = 0;
while (offset < expectedSize) {
signal?.throwIfAborted();
const length = Math.min(ARTIFACT_READ_CHUNK_BYTES, expectedSize - offset);
const result = await handle.read(bytes, offset, length, offset);
const bytesRead = Number(result?.bytesRead ?? 0);
if (!Number.isInteger(bytesRead) || bytesRead <= 0 || bytesRead > length) throw changed();
offset += bytesRead;
}
signal?.throwIfAborted();
const probe = Buffer.allocUnsafe(1);
if ((await handle.read(probe, 0, 1, expectedSize))?.bytesRead !== 0) throw changed();
return bytes;
}
async function snapshotFile(workspace, requestedPath, signal) {
signal?.throwIfAborted();
let storagePath;
try {
const candidate = isAbsolute(requestedPath)
? requestedPath
: resolve(workspace, requestedPath);
const canonicalPath = await realpath(candidate);
const source = await lstat(canonicalPath, { bigint: true });
if (!source.isFile()) {
throw artifactError('artifact-not-file', 'The requested path is not a file.');
}
const directory = await managedSnapshotDirectory();
storagePath = join(directory, `${randomUUID()}.artifact`);
await copyFile(canonicalPath, storagePath, fsConstants.COPYFILE_EXCL);
signal?.throwIfAborted();
const snapshot = await lstat(storagePath, { bigint: true });
const size = Number(snapshot.size);
if (!snapshot.isFile() || !Number.isSafeInteger(size) || size < 0) {
throw artifactError('artifact-unavailable', 'The file could not be prepared for delivery.');
}
const fileName = safeFileName(basename(candidate));
return Object.freeze({
fileName,
mediaType: mediaTypeFor(fileName),
size,
digest: await hashFile(storagePath, signal),
storagePath,
});
} catch (error) {
if (storagePath) await unlink(storagePath).catch(() => undefined);
if (error?.code?.startsWith?.('artifact-')) throw error;
if (signal?.aborted) throw signal.reason ?? error;
throw artifactError('artifact-unavailable', 'The requested file is unavailable.');
}
}
function publicArtifact(artifact) {
return Object.freeze({
artifactId: artifact.artifactId,
fileName: artifact.fileName,
size: artifact.size,
});
}
function artifactKey(artifact) {
return artifact.artifactId;
}
function cleanupArtifactStorage(artifact) {
const storage = artifactStorage.get(artifact);
if (!storage) return;
storage.releaseRequested = true;
if (storage.materializing > 0) {
storage.cleanupRequested = true;
return;
}
const pending = [...artifactProviderSettlements.get(artifact) ?? []];
if (pending.length > 0) {
if (!storage.cleanupDeferred) {
storage.cleanupDeferred = true;
void Promise.allSettled(pending).finally(() => {
storage.cleanupDeferred = false;
cleanupArtifactStorage(artifact);
});
}
return;
}
artifactStorage.delete(artifact);
artifactProviderSettlements.delete(artifact);
storage.onCleanup?.();
void unlink(storage.path).catch(() => undefined);
}
/**
* Holds successful tool results until the channel that owns the Session Turn
* collects them. It does not decide which files a user may send.
*/
export class OutboundArtifactRegistry {
#turns = new Map();
#stagedTurns = new Map();
#claimedTurns = new Map();
#claimSignals = new Map();
#signalClaims = new Map();
#consumersByPrompt = new Map();
#consumersByTurn = new Map();
#openTurns = new Map();
#uuid;
constructor({ uuid = randomUUID } = {}) {
if (typeof uuid !== 'function') throw new TypeError('uuid must be a function');
this.#uuid = uuid;
}
/**
* Bind one channel request to the Turn it starts. This owns cleanup only:
* it never changes tool visibility or decides whether a file may be sent.
*/
openConsumer(sessionId, promptRpcId) {
if (typeof sessionId !== 'string' || !sessionId
|| typeof promptRpcId !== 'string' || !promptRpcId) {
throw new TypeError('sessionId and promptRpcId are required');
}
const key = promptKey(sessionId, promptRpcId);
const consumer = {
sessionId,
promptRpcId,
turn: null,
released: false,
};
this.#consumersByPrompt.set(key, consumer);
return () => {
if (consumer.released) return;
consumer.released = true;
if (this.#consumersByPrompt.get(key) === consumer) {
this.#consumersByPrompt.delete(key);
}
if (consumer.turn !== null) {
const keyForTurn = turnKey(sessionId, consumer.turn);
if (this.#consumersByTurn.get(keyForTurn) === consumer) {
this.#consumersByTurn.delete(keyForTurn);
}
// Claimed artifacts have already crossed into the provider pipeline and
// are released there. discard() only removes unclaimed work.
this.discard(sessionId, consumer.turn);
}
};
}
/** Observe durable Session events solely to terminate unclaimed snapshots. */
observeSessionEvent(session, event) {
const sessionId = sessionIdOf(session);
if (!sessionId || !event || typeof event !== 'object') return;
if (event.type === 'turn/start') {
const turn = event.data?.turn;
if (Number.isInteger(turn) && turn >= 0) this.#openTurns.set(sessionId, turn);
return;
}
if (event.type === 'user/message') {
const rpcId = event.data?.source?.rpcId;
const turn = this.#openTurns.get(sessionId) ?? currentTurn({ session });
if (typeof rpcId !== 'string' || !rpcId || !Number.isInteger(turn)) return;
this.#openTurns.set(sessionId, turn);
const consumer = this.#consumersByPrompt.get(promptKey(sessionId, rpcId));
if (!consumer || consumer.released) return;
consumer.turn = turn;
this.#consumersByTurn.set(turnKey(sessionId, turn), consumer);
return;
}
if (event.type !== 'turn/end') return;
const turn = event.data?.turn;
if (!Number.isInteger(turn) || turn < 0) return;
if (this.#openTurns.get(sessionId) === turn) this.#openTurns.delete(sessionId);
const consumer = this.#consumersByTurn.get(turnKey(sessionId, turn));
if (!consumer || consumer.released) this.discard(sessionId, turn);
}
/** A disposed Session cannot have another channel consumer claim its files. */
disposeSession(session) {
const sessionId = sessionIdOf(session);
if (!sessionId) return;
const prefix = `${sessionId}\u0000`;
const artifacts = new Set();
for (const entries of [this.#turns, this.#stagedTurns]) {
for (const [key, turnArtifacts] of entries) {
if (!key.startsWith(prefix)) continue;
for (const artifact of turnArtifacts.values()) artifacts.add(artifact);
entries.delete(key);
}
}
for (const [key, consumer] of this.#consumersByPrompt) {
if (!key.startsWith(prefix)) continue;
consumer.released = true;
this.#consumersByPrompt.delete(key);
}
for (const key of this.#consumersByTurn.keys()) {
if (key.startsWith(prefix)) this.#consumersByTurn.delete(key);
}
this.#openTurns.delete(sessionId);
for (const artifact of artifacts) cleanupArtifactStorage(artifact);
}
async stage(args, exec) {
const requestedPath = args?.path;
if (typeof requestedPath !== 'string' || !requestedPath.trim()) {
throw new TypeError('A file path is required.');
}
const agent = exec?.agent;
const sessionId = agent?.session?.header?.id;
const workspace = agent?.session?.header?.cwd;
const turn = currentTurn(agent);
if (typeof sessionId !== 'string' || !sessionId
|| typeof workspace !== 'string' || !workspace || turn === null) {
throw artifactError(
'artifact-context-required',
'A live Harness Session is required to return a file.',
);
}
const snapshot = await snapshotFile(workspace, requestedPath, exec?.signal);
const { storagePath, ...snapshotMetadata } = snapshot;
const artifact = Object.freeze({
kind: ARTIFACT_KIND,
schemaVersion: 1,
artifactId: this.#uuid(),
deliveryKey: this.#uuid(),
...snapshotMetadata,
source: 'managed-temp',
registeredBy: Object.freeze({
kind: 'tool-result',
eventId: typeof exec?.callId === 'string' ? exec.callId : 'unknown',
toolName: OUTBOUND_ARTIFACT_TOOL,
}),
origin: Object.freeze({
sessionId,
turn,
callId: typeof exec?.callId === 'string' ? exec.callId : null,
}),
createdAt: Date.now(),
});
artifactStorage.set(artifact, {
path: storagePath,
materializing: 0,
materialized: false,
releaseRequested: false,
cleanupRequested: false,
cleanupDeferred: false,
onCleanup: () => this.#forgetArtifact(artifact),
});
const keyForTurn = turnKey(sessionId, turn);
let staged = this.#stagedTurns.get(keyForTurn);
if (!staged) {
staged = new Map();
this.#stagedTurns.set(keyForTurn, staged);
}
staged.set(artifactKey(artifact), artifact);
return artifact;
}
commit(artifact) {
if (artifact?.kind !== ARTIFACT_KIND || !artifactStorage.has(artifact)) return null;
const keyForTurn = turnKey(artifact.origin.sessionId, artifact.origin.turn);
const key = artifactKey(artifact);
const staged = this.#stagedTurns.get(keyForTurn);
if (staged?.get(key) !== artifact) return null;
staged.delete(key);
if (staged.size === 0) this.#stagedTurns.delete(keyForTurn);
let committed = this.#turns.get(keyForTurn);
if (!committed) {
committed = new Map();
this.#turns.set(keyForTurn, committed);
}
committed.set(key, artifact);
return publicArtifact(artifact);
}
release(artifact) {
if (artifact?.kind !== ARTIFACT_KIND) return;
const keyForTurn = turnKey(artifact.origin.sessionId, artifact.origin.turn);
const key = artifactKey(artifact);
const staged = this.#stagedTurns.get(keyForTurn);
if (staged?.get(key) === artifact) staged.delete(key);
if (staged?.size === 0) this.#stagedTurns.delete(keyForTurn);
cleanupArtifactStorage(artifact);
}
take(sessionId, turn, { signal } = {}) {
const keyForTurn = turnKey(sessionId, turn);
const committed = this.#turns.get(keyForTurn);
this.#turns.delete(keyForTurn);
if (!committed) return [];
if (signal?.aborted) {
for (const artifact of committed.values()) cleanupArtifactStorage(artifact);
return [];
}
const claimed = this.#claimedTurns.get(keyForTurn) ?? new Map();
this.#claimedTurns.set(keyForTurn, claimed);
const artifacts = [];
for (const [key, artifact] of committed) {
if (!artifactStorage.has(artifact)) continue;
claimed.set(key, artifact);
artifacts.push(artifact);
}
if (claimed.size === 0) this.#claimedTurns.delete(keyForTurn);
else if (!this.#bindClaimSignal(keyForTurn, signal)) return [];
return artifacts;
}
discard(sessionId, turn) {
if (typeof sessionId !== 'string' || !Number.isInteger(turn)) return;
const keyForTurn = turnKey(sessionId, turn);
const artifacts = new Set([
...this.#turns.get(keyForTurn)?.values() ?? [],
...this.#stagedTurns.get(keyForTurn)?.values() ?? [],
]);
this.#turns.delete(keyForTurn);
this.#stagedTurns.delete(keyForTurn);
for (const artifact of artifacts) cleanupArtifactStorage(artifact);
}
clear() {
const artifacts = new Set();
for (const entries of this.#turns.values()) {
for (const artifact of entries.values()) artifacts.add(artifact);
}
for (const entries of this.#stagedTurns.values()) {
for (const artifact of entries.values()) artifacts.add(artifact);
}
for (const entries of this.#claimedTurns.values()) {
for (const artifact of entries.values()) artifacts.add(artifact);
}
for (const artifact of artifacts) cleanupArtifactStorage(artifact);
this.#turns.clear();
this.#stagedTurns.clear();
this.#claimedTurns.clear();
for (const turnKey of this.#claimSignals.keys()) this.#releaseClaimSignal(turnKey);
for (const consumer of this.#consumersByPrompt.values()) consumer.released = true;
this.#consumersByPrompt.clear();
this.#consumersByTurn.clear();
this.#openTurns.clear();
}
#bindClaimSignal(turnKey, signal) {
if (!signal) return true;
this.#releaseClaimSignal(turnKey);
let claim = this.#signalClaims.get(signal);
if (!claim) {
claim = { turnKeys: new Set(), onAbort: null };
claim.onAbort = () => {
for (const claimedTurnKey of [...claim.turnKeys]) {
for (const artifact of this.#claimedTurns.get(claimedTurnKey)?.values() ?? []) {
cleanupArtifactStorage(artifact);
}
}
};
this.#signalClaims.set(signal, claim);
signal.addEventListener('abort', claim.onAbort, { once: true });
}
claim.turnKeys.add(turnKey);
this.#claimSignals.set(turnKey, signal);
if (signal.aborted) {
claim.onAbort();
return false;
}
return true;
}
#releaseClaimSignal(turnKey) {
const signal = this.#claimSignals.get(turnKey);
if (!signal) return;
this.#claimSignals.delete(turnKey);
const claim = this.#signalClaims.get(signal);
claim?.turnKeys.delete(turnKey);
if (claim?.turnKeys.size === 0) {
signal.removeEventListener('abort', claim.onAbort);
this.#signalClaims.delete(signal);
}
}
#forgetArtifact(artifact) {
const keyForTurn = turnKey(artifact.origin.sessionId, artifact.origin.turn);
const key = artifactKey(artifact);
const committed = this.#turns.get(keyForTurn);
if (committed?.get(key) === artifact) committed.delete(key);
if (committed?.size === 0) this.#turns.delete(keyForTurn);
const staged = this.#stagedTurns.get(keyForTurn);
if (staged?.get(key) === artifact) staged.delete(key);
if (staged?.size === 0) this.#stagedTurns.delete(keyForTurn);
const claimed = this.#claimedTurns.get(keyForTurn);
if (claimed?.get(key) === artifact) claimed.delete(key);
if (claimed?.size === 0) {
this.#claimedTurns.delete(keyForTurn);
this.#releaseClaimSignal(keyForTurn);
}
}
}
export const outboundArtifactRegistry = new OutboundArtifactRegistry();
/**
* Build a two-phase tool: execute stages a file; the authoritative tools/result
* observer commits only a successful native call or successful Code Mode parent.
*/
export function createOutboundArtifactTool({ registry = outboundArtifactRegistry } = {}) {
const staged = new WeakMap();
const pendingByParent = new Map();
const appendPending = (parent, artifact) => {
let pending = pendingByParent.get(parent);
if (!pending) {
pending = { artifacts: [] };
pendingByParent.set(parent, pending);
}
pending.artifacts.push(artifact);
};
const definition = Object.freeze({
name: OUTBOUND_ARTIFACT_TOOL,
description: 'Send a readable file or generated image to the user through the current conversation. Existing and newly created files are both valid.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
path: {
type: 'string',
description: 'Absolute path, or a path relative to the current workspace.',
},
},
required: ['path'],
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
artifactId: { type: 'string' },
fileName: { type: 'string' },
size: { type: 'number' },
},
required: ['artifactId', 'fileName', 'size'],
},
render: (_args, value) => [{
type: 'text',
text: `Registered ${value.fileName} (${value.size} bytes) for IM delivery.`,
}],
},
async execute(args, exec) {
const artifact = await registry.stage(args, exec);
staged.set(exec, artifact);
return publicArtifact(artifact);
},
});
const onResult = (exec, result) => {
if (exec?.name === OUTBOUND_ARTIFACT_TOOL) {
const artifact = staged.get(exec);
if (!artifact) return;
staged.delete(exec);
if (result?.isError) {
registry.release(artifact);
return;
}
if (exec.parent === undefined) registry.commit(artifact);
else appendPending(exec.parent, artifact);
return;
}
const pending = pendingByParent.get(exec?.token);
if (!pending) return;
pendingByParent.delete(exec.token);
if (result?.isError) {
for (const artifact of pending.artifacts) registry.release(artifact);
return;
}
if (exec.parent !== undefined) {
for (const artifact of pending.artifacts) appendPending(exec.parent, artifact);
return;
}
for (const artifact of pending.artifacts) registry.commit(artifact);
};
return Object.freeze({ definition, onResult });
}
/** Register the file-return tool without a per-request Gate. */
export function installOutboundArtifactTool(ctx, { registry = outboundArtifactRegistry } = {}) {
if (typeof ctx?.tools?.register !== 'function'
|| typeof ctx?.systemPrompt?.section !== 'function'
|| typeof ctx?.on !== 'function') return false;
const tool = createOutboundArtifactTool({ registry });
ctx.tools.register(tool.definition);
ctx.on('tools/result', tool.onResult);
ctx.on('session/event', (session, event) => registry.observeSessionEvent(session, event));
ctx.on('session/disposed', (session) => registry.disposeSession(session));
ctx.systemPrompt.section({
name: 'dsh-im:return-file',
order: 115,
text: `When the user asks to receive a file or generated image, call ${OUTBOUND_ARTIFACT_TOOL} with its path. Existing files can be sent directly; do not recreate or rename a file solely for delivery.`,
});
return true;
}
/** Materialize the registered snapshot for the channel provider. */
export async function materializeOutboundArtifact(artifact, {
signal,
} = {}) {
if (signal?.aborted) {
cleanupArtifactStorage(artifact);
signal.throwIfAborted();
}
if (artifact?.kind !== ARTIFACT_KIND || artifact.schemaVersion !== 1
|| !artifactStorage.has(artifact)
|| typeof artifact.digest !== 'string'
|| !Number.isSafeInteger(artifact.size) || artifact.size < 0) {
throw artifactError('artifact-invalid', 'The file registration is invalid.');
}
const storage = artifactStorage.get(artifact);
storage.materializing += 1;
let handle;
let materialized = false;
try {
const noFollow = Number.isInteger(fsConstants.O_NOFOLLOW) ? fsConstants.O_NOFOLLOW : 0;
handle = await open(storage.path, fsConstants.O_RDONLY | noFollow);
const before = await handle.stat({ bigint: true });
if (!before.isFile() || before.size !== BigInt(artifact.size)) {
throw artifactError('artifact-invalid', 'The file registration is invalid.');
}
const bytes = await readExactArtifactFile(handle, artifact.size, {
signal,
errorCode: 'artifact-invalid',
errorMessage: 'The file registration is invalid.',
});
const after = await handle.stat({ bigint: true });
if (!sameIdentity(before, after)
|| bytes.byteLength !== artifact.size
|| sha256(bytes) !== artifact.digest) {
throw artifactError('artifact-invalid', 'The file registration is invalid.');
}
signal?.throwIfAborted();
materialized = true;
const file = Object.freeze({
artifactId: artifact.artifactId,
deliveryKey: artifact.deliveryKey,
fileName: artifact.fileName,
mediaType: artifact.mediaType,
size: artifact.size,
bytes,
});
materializedArtifactSources.set(file, artifact);
storage.materialized = true;
return file;
} catch (error) {
if (error?.code?.startsWith?.('artifact-')) throw error;
if (signal?.aborted) throw signal.reason ?? error;
throw artifactError('artifact-invalid', 'The file registration is invalid.');
} finally {
await handle?.close().catch(() => undefined);
storage.materializing -= 1;
if (!materialized || storage.cleanupRequested) {
cleanupArtifactStorage(artifact);
}
}
}
/** Keep a materialized snapshot alive until an unabortable provider call settles. */
export function trackOutboundArtifactProviderPromise(file, promise) {
const artifact = materializedArtifactSources.get(file);
if (!artifact || !artifactStorage.has(artifact)
|| !promise || typeof promise.then !== 'function') return promise;
const settlements = artifactProviderSettlements.get(artifact) ?? new Set();
const settlement = Promise.resolve(promise).then(
() => undefined,
() => undefined,
);
settlements.add(settlement);
artifactProviderSettlements.set(artifact, settlements);
void settlement.finally(() => {
settlements.delete(settlement);
if (settlements.size === 0) artifactProviderSettlements.delete(artifact);
});
return promise;
}
/** Release a claimed snapshot after its provider send reaches a terminal result. */
export function releaseOutboundArtifact(artifact) {
const storage = artifactStorage.get(artifact);
if (storage) storage.releaseRequested = true;
cleanupArtifactStorage(artifact);
}

View file

@ -0,0 +1,193 @@
export const DELIVERY_RECEIPT_SCHEMA_VERSION = 1;
const ARTIFACT_OUTCOMES = new Set(['sent', 'rejected', 'failed', 'unknown']);
const DELIVERY_OUTCOMES = new Set(['sent', 'failed', 'unknown']);
const TEXT_FORMATS = new Set(['plain', 'markdown']);
const REJECTED_ARTIFACT_ERRORS = new Set([
'artifact-changed',
'artifact-context-required',
'artifact-empty',
'artifact-invalid',
'artifact-not-file',
'artifact-permission-required',
'artifact-provider-rejected',
'artifact-too-large',
'artifact-unavailable',
]);
export function providerMessageIdsFor(value) {
if (!value || typeof value !== 'object') return [];
const ids = Array.isArray(value.providerMessageIds)
? value.providerMessageIds
.filter((candidate) => (
(typeof candidate === 'string' && candidate.trim())
|| Number.isSafeInteger(candidate)
))
.map(String)
: [];
const candidates = [
value.message_id,
value.messageId,
value.id,
value.ts,
value.message?.message_id,
value.message?.messageId,
value.message?.id,
value.message?.ts,
value.key?.id,
value.data?.message_id,
value.body?.msgid,
value.body?.message_id,
];
const id = candidates.find((candidate) => (
(typeof candidate === 'string' && candidate.trim())
|| Number.isSafeInteger(candidate)
));
if (id !== undefined) ids.push(String(id));
return [...new Set(ids)];
}
function requiredString(value, name) {
if (typeof value !== 'string' || !value.trim()) {
throw new TypeError(`${name} must be a non-empty string`);
}
return value;
}
export function createTextDeliveryBlock(value, format = 'plain') {
const block = typeof value === 'string'
? { kind: 'text', text: value, format }
: value;
if (!block || typeof block !== 'object' || Array.isArray(block)
|| block.kind !== 'text' || typeof block.text !== 'string' || !block.text.trim()) {
throw new TypeError('text delivery block must contain non-empty text');
}
if (!TEXT_FORMATS.has(block.format)) {
throw new TypeError('text delivery format must be plain or markdown');
}
return Object.freeze({
kind: 'text',
text: block.text,
format: block.format,
});
}
function providerIds(values) {
if (!Array.isArray(values)) throw new TypeError('providerMessageIds must be an array');
const ids = [];
const seen = new Set();
for (const value of values) {
const id = requiredString(value, 'providerMessageId');
if (seen.has(id)) continue;
seen.add(id);
ids.push(id);
}
return Object.freeze(ids);
}
function artifactResults(values) {
if (!Array.isArray(values)) throw new TypeError('artifacts must be an array');
return Object.freeze(values.map((value) => {
if (!value || typeof value !== 'object') {
throw new TypeError('artifact result must be an object');
}
const artifactId = requiredString(value.artifactId, 'artifactId');
if (!ARTIFACT_OUTCOMES.has(value.outcome)) {
throw new TypeError('artifact outcome must be sent, rejected, failed, or unknown');
}
const reason = value.reason === undefined
? undefined
: requiredString(value.reason, 'artifact reason');
return Object.freeze({
artifactId,
outcome: value.outcome,
...(reason === undefined ? {} : { reason }),
});
}));
}
export function artifactOutcomeForError(error) {
const code = typeof error === 'string' ? error : error?.code;
if (code === 'artifact-delivery-uncertain') return 'unknown';
if (REJECTED_ARTIFACT_ERRORS.has(code)) return 'rejected';
return 'failed';
}
export function createDeliveryReceipt({
deliveryId,
presentation,
providerMessageIds = [],
artifacts = [],
deliveryOutcome,
reason,
}) {
if (deliveryOutcome !== undefined && !DELIVERY_OUTCOMES.has(deliveryOutcome)) {
throw new TypeError('deliveryOutcome must be sent, failed, or unknown');
}
const normalizedReason = reason === undefined
? undefined
: requiredString(reason, 'delivery reason');
return Object.freeze({
schemaVersion: DELIVERY_RECEIPT_SCHEMA_VERSION,
deliveryId: requiredString(deliveryId, 'deliveryId'),
presentation: requiredString(presentation, 'presentation'),
providerMessageIds: providerIds(providerMessageIds),
...(deliveryOutcome === undefined ? {} : { deliveryOutcome }),
...(normalizedReason === undefined ? {} : { reason: normalizedReason }),
artifacts: artifactResults(artifacts),
});
}
export function createArtifactFailureReceipt({
artifactId,
deliveryId,
error,
presentation = 'text-fallback',
providerMessageIds = [],
}) {
const code = typeof error === 'string' ? error : error?.code;
const reason = typeof code === 'string' && code
? code
: 'artifact-provider-failed';
return createDeliveryReceipt({
deliveryId,
presentation,
providerMessageIds,
artifacts: [{
artifactId,
outcome: artifactOutcomeForError(error),
reason,
}],
});
}
export function mergeDeliveryReceipts({ deliveryId, presentation, receipts }) {
if (!Array.isArray(receipts) || receipts.length === 0) {
throw new TypeError('receipts must contain at least one delivery receipt');
}
const messageIds = [];
const artifacts = new Map();
let deliveryOutcome;
let reason;
for (const receipt of receipts) {
if (!receipt || receipt.schemaVersion !== DELIVERY_RECEIPT_SCHEMA_VERSION) {
throw new TypeError('receipt must use DeliveryReceipt schema version 1');
}
messageIds.push(...(receipt.providerMessageIds ?? []));
if (deliveryOutcome === undefined && receipt.deliveryOutcome !== undefined) {
deliveryOutcome = receipt.deliveryOutcome;
reason = receipt.reason;
}
for (const artifact of receipt.artifacts ?? []) {
artifacts.set(artifact.artifactId, artifact);
}
}
return createDeliveryReceipt({
deliveryId,
presentation,
providerMessageIds: messageIds,
deliveryOutcome,
reason,
artifacts: [...artifacts.values()],
});
}

View file

@ -1,3 +1,4 @@
import { t } from './i18n.mjs';
import { runWorkspaceCommand } from './workspace-command.mjs'; import { runWorkspaceCommand } from './workspace-command.mjs';
import { runCompactCommand } from './compact-command.mjs'; import { runCompactCommand } from './compact-command.mjs';
import { import {
@ -23,13 +24,24 @@ import {
imagePromptUserMessage, imagePromptUserMessage,
promptContentForMessage, promptContentForMessage,
} from './image-prompt.mjs'; } from './image-prompt.mjs';
import {
hasInboundFiles,
inboundFileUserMessage,
} from './inbound-file.mjs';
import { import {
harnessAnswerForQuestion, harnessAnswerForQuestion,
harnessQuestionText, harnessQuestionText,
validHarnessQuestion, validHarnessQuestion,
} from './harness-question.mjs'; } from './harness-question.mjs';
import { deliverOutboundArtifacts } from './semantic/artifact-delivery.mjs';
import {
createDeliveryReceipt,
createTextDeliveryBlock,
providerMessageIdsFor,
} from './semantic/delivery.mjs';
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。'; const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
function cleanText(value) { function cleanText(value) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
@ -39,9 +51,46 @@ function canClaimInteractionReply(message, pending, senderId) {
return pending.actor === senderId return pending.actor === senderId
&& (message.kind !== 'group' || message.addressed === true) && (message.kind !== 'group' || message.addressed === true)
&& !hasInboundImages(message) && !hasInboundImages(message)
&& !hasInboundFiles(message)
&& Boolean(cleanText(message.content)); && Boolean(cleanText(message.content));
} }
function artifactFailureText(fileName, error, descriptor) {
const name = String(fileName ?? t('结果文件'))
.replace(/[\r\n]+/g, ' ')
.trim()
.slice(0, 255) || t('结果文件');
switch (error?.code) {
case 'artifact-delivery-uncertain':
return t('结果文件「{name}」的发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。', { name });
case 'artifact-permission-required':
if (descriptor?.key === 'slack') {
return t('结果文件「{name}」已生成,但 Slack 应用缺少 files:write 权限。请更新 Manifest、重新安装应用并重新连接机器人后重试。', { name });
}
if (descriptor?.key === 'discord') {
return t('结果文件「{name}」已生成,但机器人缺少 Discord 的 Send Messages、Attach Files 或 Read Message History 权限。', { name });
}
if (descriptor?.key === 'telegram') {
return t('结果文件「{name}」已生成,但 Telegram 不允许机器人在当前聊天发送文档,请检查聊天权限。', { name });
}
return t('结果文件「{name}」已生成,但当前机器人没有文件发送权限,请检查渠道权限。', { name });
case 'artifact-too-large':
return t('结果文件「{name}」超过当前渠道大小上限,未发送。', { name });
case 'artifact-empty':
return t('结果文件「{name}」为空,未发送。', { name });
case 'artifact-invalid':
case 'artifact-changed':
case 'artifact-unavailable':
return t('结果文件「{name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。', { name });
case 'artifact-rate-limited':
return t('结果文件「{name}」暂时被当前渠道限流,未能发送,请稍后重试。', { name });
case 'artifact-provider-rejected':
return t('结果文件「{name}」已生成,但当前渠道拒绝了该文件或文件消息。', { name });
default:
return t('结果文件「{name}」已生成,但当前渠道暂时未能发送,请稍后重试。', { name });
}
}
export function createTextBridgeStatus() { export function createTextBridgeStatus() {
return { return {
messagesReceived: 0, messagesReceived: 0,
@ -124,7 +173,7 @@ export class TextHarnessBridge {
const key = `${kind}:${conversationId}`; const key = `${kind}:${conversationId}`;
const pending = this.#pendingInteractions.get(key); const pending = this.#pendingInteractions.get(key);
const text = cleanText(normalized.content); const text = cleanText(normalized.content);
const commandRunner = isControlCommand(text) const commandRunner = hasInboundFiles(normalized) ? null : isControlCommand(text)
? runControlCommand ? runControlCommand
: (isModelCommand(text) : (isModelCommand(text)
? runModelCommand ? runModelCommand
@ -147,7 +196,7 @@ export class TextHarnessBridge {
key, key,
actor: senderId, actor: senderId,
messageId, messageId,
text: hasInboundImages(normalized) ? '' : normalized.content, text: hasInboundImages(normalized) || hasInboundFiles(normalized) ? '' : normalized.content,
addressed: normalized.kind !== 'group' || normalized.addressed === true, addressed: normalized.kind !== 'group' || normalized.addressed === true,
hasPendingQuestion: Boolean(pending), hasPendingQuestion: Boolean(pending),
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@ -253,6 +302,7 @@ export class TextHarnessBridge {
{ {
signal: this.#signal, signal: this.#signal,
hasImages: hasInboundImages(message), hasImages: hasInboundImages(message),
hasFiles: hasInboundFiles(message),
pendingInteraction: this.#pendingInteractions.has(key) pendingInteraction: this.#pendingInteractions.has(key)
|| this.#approvals.hasPending(key), || this.#approvals.hasPending(key),
control: { owner: this, key }, control: { owner: this, key },
@ -272,7 +322,7 @@ export class TextHarnessBridge {
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return; if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
this.#status.lastError = error?.message ?? String(error); this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a command:`, error); this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a command:`, error);
await this.#bot.sendText(target, '消息处理失败,请稍后重试。').catch(() => undefined); await this.#bot.sendText(target, t('消息处理失败,请稍后重试。')).catch(() => undefined);
} }
} }
@ -280,11 +330,37 @@ export class TextHarnessBridge {
return sendRememberedConnectionTest({ return sendRememberedConnectionTest({
state: this.#state, state: this.#state,
text, text,
channelLabel: `${this.#descriptor.label}机器人`, channelLabel: t('{label}机器人', { label: this.#descriptor.label }),
send: (target, message) => this.#bot.sendText(target, message), send: (target, message) => this.#bot.sendText(target, message),
}); });
} }
async #deliverArtifacts(target, replyTo, artifacts = [], baseReceipt) {
const delivery = await deliverOutboundArtifacts({
artifacts,
baseReceipt,
deliveryId: replyTo,
channelKey: this.#descriptor.key,
signal: this.#signal,
sendImage: typeof this.#bot.sendImage === 'function'
? (file) => this.#bot.sendImage(target, file)
: undefined,
sendFile: typeof this.#bot.sendFile === 'function'
? (file) => this.#bot.sendFile(target, file)
: undefined,
sendFailureNotice: (artifact, error) => this.#bot.sendText(
target,
artifactFailureText(artifact?.fileName, error, this.#descriptor),
),
logger: this.#logger,
});
this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
+ delivery.artifactsSent;
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
+ delivery.artifactSendErrors;
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
}
async #process(message, messageId, senderId, conversationKey, { async #process(message, messageId, senderId, conversationKey, {
alreadyRecorded = false, alreadyRecorded = false,
} = {}) { } = {}) {
@ -298,6 +374,7 @@ export class TextHarnessBridge {
const target = message.replyTarget; const target = message.replyTarget;
const text = cleanText(message.content); const text = cleanText(message.content);
let stream = null; let stream = null;
let semanticStream = false;
try { try {
this.#signal?.throwIfAborted(); this.#signal?.throwIfAborted();
if (message.kind === 'group' && message.addressed !== true) { if (message.kind === 'group' && message.addressed !== true) {
@ -306,42 +383,43 @@ export class TextHarnessBridge {
return; return;
} }
const hasImages = hasInboundImages(message); const hasImages = hasInboundImages(message);
if (!text && !hasImages) { const hasFiles = hasInboundFiles(message);
await this.#bot.sendText(target, '目前支持文字和图片消息。'); if (!text && !hasImages && !hasFiles) {
await this.#bot.sendText(target, t('目前支持文字、图片和文件消息。'));
return; return;
} }
const command = text.toLowerCase(); const command = text.toLowerCase();
if (!hasImages && command === '/help') { if (!hasImages && !hasFiles && command === '/help') {
await this.#bot.sendText(target, [ await this.#bot.sendText(target, [
`${this.#descriptor.label}机器人已连接 DeepSeek Harness。`, t('{label}机器人已连接 DeepSeek Harness。', { label: this.#descriptor.label }),
'', '',
'直接发送文字或图片即可继续当前会话。', t('直接发送文字、图片或文件即可继续当前会话。'),
'/new 开启一个全新会话', t('/new 开启一个全新会话'),
'/compact 压缩当前会话的较早上下文', t('/compact 压缩当前会话的较早上下文'),
'/workspace 工作区绝对路径 切换工作区', t('/workspace 工作区绝对路径 切换工作区'),
'/workspacelist 列出工作区绝对路径', t('/workspacelist 列出工作区绝对路径'),
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题', t('/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题'),
'/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话', t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
'/models 按序号列出所有可用模型', t('/models 按序号列出所有可用模型'),
'/model [序号或完整模型ID] 查看或切换当前会话模型', t('/model [序号或完整模型ID] 查看或切换当前会话模型'),
'示例:先发 /models,再发 /model 2', t('示例:先发 /models,再发 /model 2'),
'/presetlist 按序号列出可用 Agent Preset', t('/presetlist 按序号列出可用 Agent Preset'),
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset', t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
'纯数字 ID:/preset id:<ID>', t('纯数字 ID:/preset id:<ID>'),
'/preset --default 跟随 Host 默认', t('/preset --default 跟随 Host 默认'),
'/stop 停止当前任务', t('/stop 停止当前任务'),
'/steer 补充指令 纠偏当前任务', t('/steer 补充指令 纠偏当前任务'),
'/status 检查连接状态', t('/status 检查连接状态'),
'/help 显示本帮助', t('/help 显示本帮助'),
].join('\n')); ].join('\n'));
return; return;
} }
if (!hasImages && command === '/status') { if (!hasImages && !hasFiles && command === '/status') {
await this.#harness.ensureRunning({ signal: this.#signal }); await this.#harness.ensureRunning({ signal: this.#signal });
await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`); await this.#bot.sendText(target, t('{label}机器人与 DeepSeek Harness 连接正常。', { label: this.#descriptor.label }));
return; return;
} }
const workspaceCommand = !hasImages const workspaceCommand = !hasImages && !hasFiles
? await runWorkspaceCommand(text, this.#harness, conversationKey) ? await runWorkspaceCommand(text, this.#harness, conversationKey)
: null; : null;
if (workspaceCommand) { if (workspaceCommand) {
@ -350,12 +428,12 @@ export class TextHarnessBridge {
} }
return; return;
} }
if (!hasImages && command === '/new') { if (!hasImages && !hasFiles && command === '/new') {
await this.#state.clearSession(conversationKey); await this.#state.clearSession(conversationKey);
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。'); await this.#bot.sendText(target, t('已开启新会话。请发送你的问题。'));
return; return;
} }
const compactCommand = !hasImages const compactCommand = !hasImages && !hasFiles
? await runCompactCommand( ? await runCompactCommand(
text, text,
this.#harness, this.#harness,
@ -373,7 +451,17 @@ export class TextHarnessBridge {
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error); this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
}); });
let streamFinished = false; let streamFinished = false;
if (typeof this.#bot.openStream === 'function') { if (typeof this.#bot.openDeliveryStream === 'function') {
try {
stream = await this.#bot.openDeliveryStream(target);
semanticStream = true;
} catch (error) {
this.#logger.warn?.(
`[dsh-im:${this.#descriptor.key}] unable to start a semantic reply stream; using final delivery:`,
error,
);
}
} else if (typeof this.#bot.openStream === 'function') {
try { try {
stream = await this.#bot.openStream(target); stream = await this.#bot.openStream(target);
} catch (error) { } catch (error) {
@ -386,7 +474,7 @@ export class TextHarnessBridge {
const content = hasImages const content = hasImages
? await promptContentForMessage(message, { signal: this.#signal }) ? await promptContentForMessage(message, { signal: this.#signal })
: undefined; : undefined;
const { answer } = await askInWorkspaceSession({ const { answer, artifacts = [] } = await askInWorkspaceSession({
harness: this.#harness, harness: this.#harness,
state: this.#state, state: this.#state,
key: conversationKey, key: conversationKey,
@ -400,22 +488,49 @@ export class TextHarnessBridge {
control: { owner: this, key: conversationKey }, control: { owner: this, key: conversationKey },
onUpdate: stream ? async (update) => { onUpdate: stream ? async (update) => {
const progress = update.type === 'text' ? update.text const progress = update.type === 'text' ? update.text
: update.type === 'tool' ? `正在使用${update.name}…` : update.text; : update.type === 'tool' ? t('正在使用{name}…', { name: update.name }) : update.text;
if (progress) await stream.update(progress); if (progress) {
const format = update.type === 'text' ? 'markdown' : 'plain';
await stream.update(semanticStream
? createTextDeliveryBlock(progress, format)
: progress);
}
} : undefined, } : undefined,
onInteraction: (interaction) => this.#handleInteraction(interaction, { onInteraction: (interaction) => this.#handleInteraction(interaction, {
key: conversationKey, key: conversationKey,
actor: senderId, actor: senderId,
target, target,
requiresMention: message.kind === 'group', requiresMention: message.kind === 'group' && message.requiresMention !== false,
}), }),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution), onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files: message.files,
}, },
}); });
const fileOnlyCompletion = !cleanText(answer) && artifacts.length > 0;
const visibleAnswer = fileOnlyCompletion
? t(FILE_ONLY_COMPLETION_TEXT)
: answer;
const answerFormat = fileOnlyCompletion ? 'plain' : 'markdown';
let textDeliveryError = null;
let textReceipt = null;
if (stream) { if (stream) {
try { try {
await stream.finish(answer); const result = await stream.finish(semanticStream
? createTextDeliveryBlock(visibleAnswer, answerFormat)
: visibleAnswer);
streamFinished = true; streamFinished = true;
textReceipt = createDeliveryReceipt({
deliveryId: messageId,
presentation: result?.presentation
?? stream.presentation
?? `${this.#descriptor.key}-stream`,
providerMessageIds: [
...providerMessageIdsFor(stream),
...providerMessageIdsFor(result),
],
deliveryOutcome: result?.deliveryOutcome,
reason: result?.reason,
});
} catch (error) { } catch (error) {
stream.cancel?.(); stream.cancel?.();
this.#logger.warn?.( this.#logger.warn?.(
@ -424,26 +539,76 @@ export class TextHarnessBridge {
); );
} }
} }
if (!streamFinished) await this.#bot.sendText(target, answer); if (!streamFinished) {
this.#status.messagesReplied += 1; try {
this.#status.lastReplyAt = new Date().toISOString(); const result = typeof this.#bot.sendDelivery === 'function'
this.#status.lastError = null; ? await this.#bot.sendDelivery(
target,
createTextDeliveryBlock(visibleAnswer, answerFormat),
)
: await this.#bot.sendText(target, visibleAnswer);
textReceipt = createDeliveryReceipt({
deliveryId: messageId,
presentation: result?.presentation ?? `${this.#descriptor.key}-text`,
providerMessageIds: providerMessageIdsFor(result),
deliveryOutcome: result?.deliveryOutcome,
reason: result?.reason,
});
} catch (error) {
textDeliveryError = error;
}
}
if (textReceipt?.deliveryOutcome === 'failed') {
const reason = textReceipt.reason ?? 'text-delivery-failed';
textDeliveryError = new Error(`Final text delivery failed (${reason})`);
textDeliveryError.code = reason;
}
// A failed final text must not discard an already registered result file.
// Settle the independent attachment path before surfacing the text error.
const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
if (textDeliveryError && !delivery.userVisible) {
textDeliveryError.deliveryReceipt = delivery.receipt;
throw textDeliveryError;
}
if (delivery.userVisible) {
this.#status.messagesReplied += 1;
this.#status.lastReplyAt = new Date().toISOString();
this.#status.lastError = null;
}
return delivery.receipt;
} catch (error) { } catch (error) {
if (error?.code === 'turn-stopped') { if (error?.code === 'turn-stopped') {
if (stream) { if (stream) {
try { try {
await stream.finish('已停止。'); await stream.finish(t('已停止。'));
} catch { } catch {
stream.cancel?.(); stream.cancel?.();
} }
} }
return; return;
} }
stream?.cancel?.(); if (this.#signal?.aborted) {
if (this.#signal?.aborted) return; stream?.cancel?.();
return;
}
this.#status.lastError = error?.message ?? String(error); this.#status.lastError = error?.message ?? String(error);
const presentStreamFailure = async (text) => {
if (typeof stream?.fail !== 'function') return false;
try {
const result = await stream.fail(text);
return Boolean(result) && result.deliveryOutcome !== 'failed';
} catch (streamError) {
this.#logger.warn?.(
`[dsh-im:${this.#descriptor.key}] unable to finalize the failed stream:`,
streamError,
);
return false;
}
};
const imageErrorMessage = imagePromptUserMessage(error); const imageErrorMessage = imagePromptUserMessage(error);
if (imageErrorMessage) { if (imageErrorMessage) {
if (await presentStreamFailure(imageErrorMessage)) return;
stream?.cancel?.();
try { try {
await this.#bot.sendText(target, imageErrorMessage); await this.#bot.sendText(target, imageErrorMessage);
} catch (sendError) { } catch (sendError) {
@ -454,15 +619,34 @@ export class TextHarnessBridge {
} }
return; return;
} }
const fileErrorMessage = inboundFileUserMessage(error);
if (fileErrorMessage) {
if (await presentStreamFailure(fileErrorMessage)) return;
stream?.cancel?.();
try {
await this.#bot.sendText(target, fileErrorMessage);
} catch (sendError) {
this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send the file error reply:`,
sendError,
);
}
return;
}
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error); this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
if (await presentStreamFailure('消息处理失败,请稍后重试。')) {
return error.deliveryReceipt;
}
stream?.cancel?.();
try { try {
await this.#bot.sendText(target, '消息处理失败,请稍后重试。'); await this.#bot.sendText(target, t('消息处理失败,请稍后重试。'));
} catch (sendError) { } catch (sendError) {
this.#logger.error?.( this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`, `[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`,
sendError, sendError,
); );
} }
return error.deliveryReceipt;
} finally { } finally {
await Promise.allSettled([ await Promise.allSettled([
this.#cancelPendingInteraction(conversationKey), this.#cancelPendingInteraction(conversationKey),
@ -496,9 +680,9 @@ export class TextHarnessBridge {
const target = message.replyTarget; const target = message.replyTarget;
const text = cleanText(message.content); const text = cleanText(message.content);
if (!text || hasInboundImages(message)) { if (!text || hasInboundImages(message) || hasInboundFiles(message)) {
try { try {
await this.#bot.sendText(target, '请用文字回答当前问题。'); await this.#bot.sendText(target, t('请用文字回答当前问题。'));
} catch (error) { } catch (error) {
this.#logger.error?.( this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to reject a non-text interaction reply:`, `[dsh-im:${this.#descriptor.key}] failed to reject a non-text interaction reply:`,
@ -526,7 +710,7 @@ export class TextHarnessBridge {
try { try {
await this.#presentInteraction(pending); await this.#presentInteraction(pending);
} catch (error) { } catch (error) {
this.#status.lastError = `${this.#descriptor.label}交互问题发送失败。`; this.#status.lastError = t('{label}交互问题发送失败。', { label: this.#descriptor.label });
this.#logger.error?.( this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to retry an interaction question:`, `[dsh-im:${this.#descriptor.key}] failed to retry an interaction question:`,
error, error,
@ -564,7 +748,7 @@ export class TextHarnessBridge {
try { try {
await this.#presentInteraction(pending); await this.#presentInteraction(pending);
} catch (error) { } catch (error) {
this.#status.lastError = `${this.#descriptor.label}交互问题发送失败。`; this.#status.lastError = t('{label}交互问题发送失败。', { label: this.#descriptor.label });
this.#logger.error?.( this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send the next interaction question:`, `[dsh-im:${this.#descriptor.key}] failed to send the next interaction question:`,
error, error,
@ -590,7 +774,7 @@ export class TextHarnessBridge {
this.#clearPendingInteraction(key, pending.interactionId); this.#clearPendingInteraction(key, pending.interactionId);
if (this.#signal?.aborted) return; if (this.#signal?.aborted) return;
try { try {
await this.#bot.sendText(target, INTERACTION_RESOLVED_TEXT); await this.#bot.sendText(target, t(INTERACTION_RESOLVED_TEXT));
} catch (sendError) { } catch (sendError) {
this.#logger.error?.( this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send an expired interaction notice:`, `[dsh-im:${this.#descriptor.key}] failed to send an expired interaction notice:`,
@ -603,13 +787,13 @@ export class TextHarnessBridge {
pending.submitting = false; pending.submitting = false;
pending.answers.pop(); pending.answers.pop();
pending.index -= 1; pending.index -= 1;
this.#status.lastError = '回答提交失败。'; this.#status.lastError = t('回答提交失败。');
this.#logger.error?.( this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to answer a Harness interaction:`, `[dsh-im:${this.#descriptor.key}] failed to answer a Harness interaction:`,
error, error,
); );
try { try {
await this.#bot.sendText(target, '回答提交失败,请重新发送当前问题的答案。'); await this.#bot.sendText(target, t('回答提交失败,请重新发送当前问题的答案。'));
} catch (sendError) { } catch (sendError) {
this.#logger.error?.( this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send an interaction retry notice:`, `[dsh-im:${this.#descriptor.key}] failed to send an interaction retry notice:`,
@ -656,7 +840,7 @@ export class TextHarnessBridge {
try { try {
await this.#bot.sendText( await this.#bot.sendText(
target, target,
'检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。', t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'),
); );
} catch (error) { } catch (error) {
this.#logger.error?.( this.#logger.error?.(
@ -757,7 +941,7 @@ export class TextHarnessBridge {
this.#status.lastMessageAt = new Date().toISOString(); this.#status.lastMessageAt = new Date().toISOString();
} }
try { try {
await this.#bot.sendText(message.replyTarget, INTERACTION_RESOLVED_TEXT); await this.#bot.sendText(message.replyTarget, t(INTERACTION_RESOLVED_TEXT));
} catch (error) { } catch (error) {
this.#logger.error?.( this.#logger.error?.(
`[dsh-im:${this.#descriptor.key}] failed to send an expired interaction notice:`, `[dsh-im:${this.#descriptor.key}] failed to send an expired interaction notice:`,

View file

@ -1,4 +1,5 @@
import { connectionTestMessage } from './connection-test.mjs'; import { connectionTestMessage } from './connection-test.mjs';
import { t } from './i18n.mjs';
function cleanString(value) { function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
@ -70,7 +71,9 @@ export class TokenBotController {
if (!token) { if (!token) {
this.#errors.set(config.botId, safeError( this.#errors.set(config.botId, safeError(
'missing-token', 'missing-token',
`${this.#descriptor.label}机器人凭据缺失,请移除后重新接入。`, t('{label}机器人凭据缺失,请移除后重新接入。', {
label: this.#descriptor.label,
}),
)); ));
return; return;
} }
@ -80,7 +83,9 @@ export class TokenBotController {
} catch (error) { } catch (error) {
this.#errors.set(config.botId, safeError( this.#errors.set(config.botId, safeError(
'connection-failed', 'connection-failed',
`${this.#descriptor.label}连接未就绪,插件会自动重试。`, t('{label}连接未就绪,插件会自动重试。', {
label: this.#descriptor.label,
}),
)); ));
this.#logger.warn?.( this.#logger.warn?.(
`[dsh-im:${this.#descriptor.key}] bot ${config.botId} failed to initialize:`, `[dsh-im:${this.#descriptor.key}] bot ${config.botId} failed to initialize:`,
@ -129,7 +134,9 @@ export class TokenBotController {
} catch (error) { } catch (error) {
this.#errors.set(identity.botId, safeError( this.#errors.set(identity.botId, safeError(
'connection-failed', 'connection-failed',
`${this.#descriptor.label}机器人已接入,消息连接暂未就绪。`, t('{label}机器人已接入,消息连接暂未就绪。', {
label: this.#descriptor.label,
}),
)); ));
this.#logger.warn?.( this.#logger.warn?.(
`[dsh-im:${this.#descriptor.key}] bot ${identity.botId} credential connection failed:`, `[dsh-im:${this.#descriptor.key}] bot ${identity.botId} credential connection failed:`,
@ -153,7 +160,9 @@ export class TokenBotController {
} catch (error) { } catch (error) {
this.#errors.set(botId, safeError( this.#errors.set(botId, safeError(
'connection-failed', 'connection-failed',
`${this.#descriptor.label}连接仍未就绪,请稍后重试。`, t('{label}连接仍未就绪,请稍后重试。', {
label: this.#descriptor.label,
}),
)); ));
throw error; throw error;
} finally { } finally {
@ -181,7 +190,9 @@ export class TokenBotController {
} catch (error) { } catch (error) {
this.#errors.set(botId, safeError( this.#errors.set(botId, safeError(
'connection-failed', 'connection-failed',
`${this.#descriptor.label}连接仍未就绪,请稍后重试。`, t('{label}连接仍未就绪,请稍后重试。', {
label: this.#descriptor.label,
}),
)); ));
throw error; throw error;
} finally { } finally {
@ -197,14 +208,19 @@ export class TokenBotController {
return this.#withBotTransition(botId, async () => { return this.#withBotTransition(botId, async () => {
const runtime = this.#runtimes.get(botId); const runtime = this.#runtimes.get(botId);
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') { if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
const error = new Error(`${this.#descriptor.label}机器人尚未连接`); const error = new Error(t('{label}机器人尚未连接', {
label: this.#descriptor.label,
}));
error.code = 'test-target-unavailable'; error.code = 'test-target-unavailable';
throw error; throw error;
} }
const cardLabel = `${config.name}(${this.#maskPlatformId(config.platformId)})`; const cardLabel = t('{name}({id})', {
name: config.name,
id: this.#maskPlatformId(config.platformId),
});
await runtime.sendConnectionTest(connectionTestMessage( await runtime.sendConnectionTest(connectionTestMessage(
cardLabel, cardLabel,
`${this.#descriptor.label}机器人`, t('{label}机器人', { label: this.#descriptor.label }),
)); ));
return { sent: true }; return { sent: true };
}); });
@ -260,9 +276,14 @@ export class TokenBotController {
}, },
health: { health: {
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline', status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
summary: connected ? `${this.#descriptor.label}${this.#descriptor.connectionLabel}运行正常` summary: connected ? t('{label}{connectionLabel}运行正常', {
: state === 'error' ? `${this.#descriptor.label}连接未就绪,插件会自动重试` label: this.#descriptor.label,
: `${this.#descriptor.label}连接当前离线`, connectionLabel: t(this.#descriptor.connectionLabel),
})
: state === 'error' ? t('{label}连接未就绪,插件会自动重试', {
label: this.#descriptor.label,
})
: t('{label}连接当前离线', { label: this.#descriptor.label }),
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null, lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null, lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
}, },

View file

@ -1,6 +1,7 @@
import { realpath, stat } from 'node:fs/promises'; import { realpath, stat } from 'node:fs/promises';
import { isAbsolute, resolve } from 'node:path'; import { isAbsolute, resolve } from 'node:path';
import { t } from './i18n.mjs';
import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs'; import { WORKSPACE_SESSION_STALE } from './workspace-session.mjs';
const WORKSPACE_COMMAND = /^\/workspace(?:\s+([\s\S]+))?$/i; const WORKSPACE_COMMAND = /^\/workspace(?:\s+([\s\S]+))?$/i;
@ -60,28 +61,34 @@ async function existingWorkspacePaths(values) {
async function selectedWorkspacePath(value) { async function selectedWorkspacePath(value) {
if (typeof value !== 'string' || !isAbsolute(value.trim())) { if (typeof value !== 'string' || !isAbsolute(value.trim())) {
return { error: `工作区必须是绝对路径。\n${SESSION_LIST_USAGE}` }; return { error: t(`工作区必须是绝对路径。
{usage}`, { usage: t(SESSION_LIST_USAGE) }) };
} }
const workspace = normalizedWorkspacePath(value.trim()); const workspace = normalizedWorkspacePath(value.trim());
if (!workspace) { if (!workspace) {
return { error: `工作区路径包含不支持的字符或长度超过限制。\n${SESSION_LIST_USAGE}` }; return { error: t(`工作区路径包含不支持的字符或长度超过限制。
{usage}`, { usage: t(SESSION_LIST_USAGE) }) };
} }
let info; let info;
try { try {
info = await stat(workspace); info = await stat(workspace);
} catch { } catch {
return { error: `工作区路径不存在。\n${SESSION_LIST_USAGE}` }; return { error: t(`工作区路径不存在。
{usage}`, { usage: t(SESSION_LIST_USAGE) }) };
} }
if (!info.isDirectory()) { if (!info.isDirectory()) {
return { error: `工作区路径必须指向一个目录。\n${SESSION_LIST_USAGE}` }; return { error: t(`工作区路径必须指向一个目录。
{usage}`, { usage: t(SESSION_LIST_USAGE) }) };
} }
try { try {
const canonical = normalizedWorkspacePath(await realpath(workspace)); const canonical = normalizedWorkspacePath(await realpath(workspace));
return canonical return canonical
? { workspace: canonical } ? { workspace: canonical }
: { error: `工作区路径包含不支持的字符或长度超过限制。\n${SESSION_LIST_USAGE}` }; : { error: t(`工作区路径包含不支持的字符或长度超过限制。
{usage}`, { usage: t(SESSION_LIST_USAGE) }) };
} catch { } catch {
return { error: `工作区路径不存在。\n${SESSION_LIST_USAGE}` }; return { error: t(`工作区路径不存在。
{usage}`, { usage: t(SESSION_LIST_USAGE) }) };
} }
} }
@ -120,38 +127,38 @@ export function splitWorkspaceCommandMessage(message) {
} }
async function runWorkspaceListCommand(match, harness) { async function runWorkspaceListCommand(match, harness) {
if (match[1]?.trim()) return commandResult('用法:/workspacelist'); if (match[1]?.trim()) return commandResult(t('用法:/workspacelist'));
if (typeof harness?.listWorkspaces !== 'function') { if (typeof harness?.listWorkspaces !== 'function') {
return commandResult('当前机器人暂不支持列出工作区。'); return commandResult(t('当前机器人暂不支持列出工作区。'));
} }
try { try {
const { current, paths } = await workspacePathSnapshot(harness); const { current, paths } = await workspacePathSnapshot(harness);
if (paths.length === 0) { if (paths.length === 0) {
return commandResult('当前 Harness Host 上没有仍然存在的已登记工作区。'); return commandResult(t('当前 Harness Host 上没有仍然存在的已登记工作区。'));
} }
const lines = [ const lines = [
`当前 Harness Host 上存在的工作区(${paths.length}):`, t('当前 Harness Host 上存在的工作区({count}):', { count: paths.length }),
...paths.map((workspace, index) => ( ...paths.map((workspace, index) => (
`${index + 1}. ${workspace}${workspace === current ? '(当前)' : ''}` `${index + 1}. ${workspace}${workspace === current ? t('(当前)') : ''}`
)), )),
'', '',
'切换用法:/workspace 工作区绝对路径', t('切换用法:/workspace 工作区绝对路径'),
'查看会话:/sessionlist 工作区序号或绝对路径', t('查看会话:/sessionlist 工作区序号或绝对路径'),
]; ];
const message = lines.join('\n'); const message = lines.join('\n');
return commandResult(message, splitWorkspaceCommandMessage(message)); return commandResult(message, splitWorkspaceCommandMessage(message));
} catch (error) { } catch (error) {
if (error?.code === 'workspace-bot-not-found') { if (error?.code === 'workspace-bot-not-found') {
return commandResult('机器人正在移除或已重新接入,无法列出原会话的工作区。'); return commandResult(t('机器人正在移除或已重新接入,无法列出原会话的工作区。'));
} }
return commandResult('暂时无法获取工作区列表,请稍后重试。'); return commandResult(t('暂时无法获取工作区列表,请稍后重试。'));
} }
} }
export async function resolveSessionListWorkspace(selector, harness) { export async function resolveSessionListWorkspace(selector, harness) {
if (!selector) { if (!selector) {
if (typeof harness?.currentWorkspace !== 'function') { if (typeof harness?.currentWorkspace !== 'function') {
return { error: '当前机器人没有可用的工作区。' }; return { error: t('当前机器人没有可用的工作区。') };
} }
const selected = await selectedWorkspacePath(harness.currentWorkspace()); const selected = await selectedWorkspacePath(harness.currentWorkspace());
harness.assertWorkspaceScope?.(); harness.assertWorkspaceScope?.();
@ -160,12 +167,12 @@ export async function resolveSessionListWorkspace(selector, harness) {
if (/^\d+$/u.test(selector)) { if (/^\d+$/u.test(selector)) {
if (typeof harness?.listWorkspaces !== 'function') { if (typeof harness?.listWorkspaces !== 'function') {
return { error: '当前机器人暂不支持按序号选择工作区。' }; return { error: t('当前机器人暂不支持按序号选择工作区。') };
} }
const { paths } = await workspacePathSnapshot(harness); const { paths } = await workspacePathSnapshot(harness);
const position = Number(selector); const position = Number(selector);
if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) { if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
return { error: '工作区序号不存在,请先执行 /workspacelist。' }; return { error: t('工作区序号不存在,请先执行 /workspacelist。') };
} }
return { workspace: paths[position - 1] }; return { workspace: paths[position - 1] };
} }
@ -185,13 +192,13 @@ function formatSessionRelativeTime(value) {
const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const now = new Date(); const now = new Date();
const dayDiff = Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000); const dayDiff = Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000);
if (dayDiff === 0) return `今天 ${hm}`; if (dayDiff === 0) return t('今天 {time}', { time: hm });
if (dayDiff === 1) return `昨天 ${hm}`; if (dayDiff === 1) return t('昨天 {time}', { time: hm });
if (dayDiff === 2) return `前天 ${hm}`; if (dayDiff === 2) return t('前天 {time}', { time: hm });
if (date.getFullYear() === now.getFullYear()) { if (date.getFullYear() === now.getFullYear()) {
return `${date.getMonth() + 1}月${date.getDate()}日 ${hm}`; return t('{month}月{day}日 {time}', { month: date.getMonth() + 1, day: date.getDate(), time: hm });
} }
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`; return t('{year}年{month}月{day}日', { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() });
} }
function sessionListMessage(workspace, sessions, { currentWorkspace = false } = {}) { function sessionListMessage(workspace, sessions, { currentWorkspace = false } = {}) {
@ -199,22 +206,24 @@ function sessionListMessage(workspace, sessions, { currentWorkspace = false } =
const sessionId = safeDisplayText(session?.sessionId); const sessionId = safeDisplayText(session?.sessionId);
if (!sessionId) throw new TypeError('Harness returned an invalid session id'); if (!sessionId) throw new TypeError('Harness returned an invalid session id');
const title = session?.summaryAvailable === false const title = session?.summaryAvailable === false
? '标题暂不可用' ? t('标题暂不可用')
: safeDisplayText(session?.title) || '暂无标题'; : safeDisplayText(session?.title) || t('暂无标题');
const timeText = formatSessionRelativeTime(session?.time); const timeText = formatSessionRelativeTime(session?.time);
const annotation = `${timeText ? ` · ${timeText}` : ''}${session?.archived === true ? '(已归档)' : ''}`; const annotation = `${timeText ? ` · ${timeText}` : ''}${session?.archived === true ? t('(已归档)') : ''}`;
return `${title}${annotation}\n ID: ${sessionId}`; return `${title}${annotation}\n ID: ${sessionId}`;
}); });
if (rows.length === 0) return `工作区:${workspace}\n该工作区暂无会话。`; if (rows.length === 0) return t(`工作区:{workspace}
该工作区暂无会话。`, { workspace });
return [ return [
`工作区:${workspace}`, t('工作区:{workspace}', { workspace }),
`会话(${rows.length}):`, t('会话({count}):', { count: rows.length }),
'', '',
...rows.map((row, index) => `${index + 1}. ${row}`), ...rows.map((row, index) => `${index + 1}. ${row}`),
'', '',
currentWorkspace currentWorkspace
? '绑定用法:/session Session ID 或当前工作区序号(/session N)' ? t('绑定用法:/session Session ID 或当前工作区序号(/session N)')
: '绑定用法:/session Session ID\n提示:/session N 只按机器人当前工作区的序号绑定。', : t(`绑定用法:/session Session ID
提示:/session N 只按机器人当前工作区的序号绑定。`),
].join('\n'); ].join('\n');
} }
@ -227,7 +236,7 @@ async function currentSessionListWorkspace(harness) {
async function runSessionListCommand(match, harness) { async function runSessionListCommand(match, harness) {
if (typeof harness?.listWorkspaceSessions !== 'function') { if (typeof harness?.listWorkspaceSessions !== 'function') {
return commandResult('当前机器人暂不支持列出工作区会话。'); return commandResult(t('当前机器人暂不支持列出工作区会话。'));
} }
const selector = match[1]?.trim() ?? ''; const selector = match[1]?.trim() ?? '';
try { try {
@ -246,36 +255,37 @@ async function runSessionListCommand(match, harness) {
return commandResult(message, splitWorkspaceCommandMessage(message)); return commandResult(message, splitWorkspaceCommandMessage(message));
} catch (error) { } catch (error) {
if (error?.code === 'workspace-bot-not-found') { if (error?.code === 'workspace-bot-not-found') {
return commandResult('机器人正在移除或已重新接入,无法列出原会话的工作区会话。'); return commandResult(t('机器人正在移除或已重新接入,无法列出原会话的工作区会话。'));
} }
return commandResult('暂时无法获取工作区会话列表,请稍后重试。'); return commandResult(t('暂时无法获取工作区会话列表,请稍后重试。'));
} }
} }
function sessionBindErrorMessage(error) { function sessionBindErrorMessage(error) {
if (error?.code === 'session-id-invalid') { if (error?.code === 'session-id-invalid') {
return `Session ID 格式无效。\n${SESSION_BIND_USAGE}`; return t(`Session ID 格式无效。
{usage}`, { usage: t(SESSION_BIND_USAGE) });
} }
if (['session-not-registered', 'session-not-found'].includes(error?.code)) { if (['session-not-registered', 'session-not-found'].includes(error?.code)) {
return '未找到该会话,请先执行 /sessionlist 确认 Session ID。'; return t('未找到该会话,请先执行 /sessionlist 确认 Session ID。');
} }
if (error?.code === 'session-subagent-unsupported') { if (error?.code === 'session-subagent-unsupported') {
return '子代理会话不能绑定到机器人对话,请选择普通会话。'; return t('子代理会话不能绑定到机器人对话,请选择普通会话。');
} }
if (error?.code === 'session-workspace-ambiguous') { if (error?.code === 'session-workspace-ambiguous') {
return '该会话的工作区归属不明确,暂时无法绑定。'; return t('该会话的工作区归属不明确,暂时无法绑定。');
} }
if (error?.code === 'session-summary-unavailable') { if (error?.code === 'session-summary-unavailable') {
return '暂时无法读取该会话的信息,请稍后重试。'; return t('暂时无法读取该会话的信息,请稍后重试。');
} }
if (error?.code === 'workspace-bot-not-found') { if (error?.code === 'workspace-bot-not-found') {
return '机器人正在移除或已重新接入,无法绑定原对话的会话。'; return t('机器人正在移除或已重新接入,无法绑定原对话的会话。');
} }
if ([WORKSPACE_SESSION_STALE, 'agent-busy', 'session-conflict', 'workspace-conflict'] if ([WORKSPACE_SESSION_STALE, 'agent-busy', 'session-conflict', 'workspace-conflict']
.includes(error?.code)) { .includes(error?.code)) {
return '工作区或会话状态已发生变化,请重试。'; return t('工作区或会话状态已发生变化,请重试。');
} }
return '暂时无法绑定会话,请稍后重试。'; return t('暂时无法绑定会话,请稍后重试。');
} }
async function runSessionBindCommand(command, harness, conversationKey) { async function runSessionBindCommand(command, harness, conversationKey) {
@ -285,7 +295,7 @@ async function runSessionBindCommand(command, harness, conversationKey) {
// 序号模式:把 /session N 解析成当前工作区会话列表中的第 N 个会话 // 序号模式:把 /session N 解析成当前工作区会话列表中的第 N 个会话
if (typeof harness?.listWorkspaceSessions !== 'function' if (typeof harness?.listWorkspaceSessions !== 'function'
|| typeof harness?.currentWorkspace !== 'function') { || typeof harness?.currentWorkspace !== 'function') {
return commandResult('当前机器人暂不支持按序号绑定,请使用 /session Session ID。'); return commandResult(t('当前机器人暂不支持按序号绑定,请使用 /session Session ID。'));
} }
try { try {
const selected = await selectedWorkspacePath(harness.currentWorkspace()); const selected = await selectedWorkspacePath(harness.currentWorkspace());
@ -298,7 +308,7 @@ async function runSessionBindCommand(command, harness, conversationKey) {
const position = Number(sessionId); const position = Number(sessionId);
if (!Number.isSafeInteger(position) || position < 1 if (!Number.isSafeInteger(position) || position < 1
|| position > listed.sessions.length) { || position > listed.sessions.length) {
return commandResult('会话序号不存在,请先执行 /sessionlist 查看序号。'); return commandResult(t('会话序号不存在,请先执行 /sessionlist 查看序号。'));
} }
const selectedSessionId = listed.sessions[position - 1]?.sessionId; const selectedSessionId = listed.sessions[position - 1]?.sessionId;
if (!validSessionId(selectedSessionId)) { if (!validSessionId(selectedSessionId)) {
@ -309,15 +319,15 @@ async function runSessionBindCommand(command, harness, conversationKey) {
if (error?.code === 'workspace-bot-not-found') { if (error?.code === 'workspace-bot-not-found') {
return commandResult(sessionBindErrorMessage(error)); return commandResult(sessionBindErrorMessage(error));
} }
return commandResult('暂时无法获取会话列表,请稍后重试。'); return commandResult(t('暂时无法获取会话列表,请稍后重试。'));
} }
} }
if (!validSessionId(sessionId)) return commandResult(SESSION_BIND_USAGE); if (!validSessionId(sessionId)) return commandResult(t(SESSION_BIND_USAGE));
if (typeof harness?.bindWorkspaceSession !== 'function') { if (typeof harness?.bindWorkspaceSession !== 'function') {
return commandResult('当前机器人暂不支持绑定已有会话。'); return commandResult(t('当前机器人暂不支持绑定已有会话。'));
} }
if (typeof conversationKey !== 'string' || !conversationKey) { if (typeof conversationKey !== 'string' || !conversationKey) {
return commandResult('当前消息缺少可绑定的会话上下文。'); return commandResult(t('当前消息缺少可绑定的会话上下文。'));
} }
try { try {
const bound = await harness.bindWorkspaceSession(conversationKey, sessionId); const bound = await harness.bindWorkspaceSession(conversationKey, sessionId);
@ -327,13 +337,13 @@ async function runSessionBindCommand(command, harness, conversationKey) {
if (!workspace || !boundSessionId) { if (!workspace || !boundSessionId) {
throw new TypeError('Harness returned an invalid bound session'); throw new TypeError('Harness returned an invalid bound session');
} }
const title = safeDisplayText(bound?.title) || '暂无标题'; const title = safeDisplayText(bound?.title) || t('暂无标题');
const message = [ const message = [
'当前聊天已绑定会话:', t('当前聊天已绑定会话:'),
`工作区:${workspace}`, t('工作区:{workspace}', { workspace }),
`标题:${title}`, t('标题:{title}', { title }),
`ID:${boundSessionId}`, `ID:${boundSessionId}`,
`归档:${bound?.archived === true ? '是' : '否'}`, t('归档:{archived}', { archived: bound?.archived === true ? t('是') : t('否') }),
].join('\n'); ].join('\n');
return commandResult(message, splitWorkspaceCommandMessage(message)); return commandResult(message, splitWorkspaceCommandMessage(message));
} catch (error) { } catch (error) {
@ -355,20 +365,21 @@ export async function runWorkspaceCommand(text, harness, conversationKey) {
if (!match) return null; if (!match) return null;
const workspace = match[1]?.trim(); const workspace = match[1]?.trim();
if (!workspace) { if (!workspace) {
return commandResult('用法:/workspace 工作区绝对路径'); return commandResult(t('用法:/workspace 工作区绝对路径'));
} }
if (typeof harness?.switchWorkspace !== 'function') { if (typeof harness?.switchWorkspace !== 'function') {
return commandResult('当前机器人暂不支持切换工作区。'); return commandResult(t('当前机器人暂不支持切换工作区。'));
} }
try { try {
const current = await harness.switchWorkspace(workspace); const current = await harness.switchWorkspace(workspace);
return commandResult(`工作区已切换为:${current}`); return commandResult(t('工作区已切换为:{workspace}', { workspace: current }));
} catch (error) { } catch (error) {
if (['workspace-not-absolute', 'workspace-not-found', 'workspace-not-directory'].includes(error?.code)) { if (['workspace-not-absolute', 'workspace-not-found', 'workspace-not-directory'].includes(error?.code)) {
return commandResult(`${error.message}\n用法:/workspace 工作区绝对路径`); return commandResult(t(`{message}
用法:/workspace 工作区绝对路径`, { message: error.message }));
} }
if (error?.code === 'workspace-bot-not-found') { if (error?.code === 'workspace-bot-not-found') {
return commandResult('机器人正在移除或已重新接入,无法切换原会话的工作区。'); return commandResult(t('机器人正在移除或已重新接入,无法切换原会话的工作区。'));
} }
throw error; throw error;
} }

View file

@ -59,9 +59,23 @@ export async function askInWorkspaceSession({
return { sessionId, session }; return { sessionId, session };
}); });
if (!binding) continue; if (!binding) continue;
const artifacts = [];
const originalOnArtifact = typeof askOptions === 'object'
&& typeof askOptions?.onArtifact === 'function'
? askOptions.onArtifact
: null;
const artifactOptions = typeof askOptions === 'number'
? { timeoutMs: askOptions }
: { ...askOptions };
artifactOptions.onArtifact = async (artifact) => {
artifacts.push(artifact);
await originalOnArtifact?.(artifact);
};
const answer = await binding.session.ask(content ?? text, artifactOptions);
return { return {
sessionId: binding.sessionId, sessionId: binding.sessionId,
answer: await binding.session.ask(content ?? text, askOptions), answer,
...(artifacts.length > 0 ? { artifacts } : {}),
}; };
} catch (error) { } catch (error) {
if (error?.code !== WORKSPACE_SESSION_STALE) throw error; if (error?.code !== WORKSPACE_SESSION_STALE) throw error;

View file

@ -2,6 +2,8 @@ import { createHash } from 'node:crypto';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { t } from '../shared/i18n.mjs';
const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) }); const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
const BOT_ID_PATTERN = /^slack_[a-f0-9]{24}$/; const BOT_ID_PATTERN = /^slack_[a-f0-9]{24}$/;
const BOT_TOKEN_REF_PATTERN = /^DSH_SLACK_BOT_TOKEN_[A-F0-9]{24}$/; const BOT_TOKEN_REF_PATTERN = /^DSH_SLACK_BOT_TOKEN_[A-F0-9]{24}$/;
@ -27,7 +29,7 @@ export function maskSlackBotId(platformId) {
const value = cleanString(platformId) ?? ''; const value = cleanString(platformId) ?? '';
const [teamId, userId] = value.split(':'); const [teamId, userId] = value.split(':');
if (teamId && userId) return `${teamId.slice(0, 5)}••• · ${userId.slice(0, 5)}•••`; if (teamId && userId) return `${teamId.slice(0, 5)}••• · ${userId.slice(0, 5)}•••`;
return value ? `${value.slice(0, 6)}••••` : 'Slack机器人'; return value ? `${value.slice(0, 6)}••••` : t('Slack机器人');
} }
export class SlackConfigStore { export class SlackConfigStore {

View file

@ -18,6 +18,7 @@ oauth_config:
- app_mentions:read - app_mentions:read
- chat:write - chat:write
- files:read - files:read
- files:write
- im:history - im:history
settings: settings:
event_subscriptions: event_subscriptions:

View file

@ -1,10 +1,14 @@
import { fetchFileStream } from '../shared/file-download.mjs';
import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs'; import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
import { t } from '../shared/i18n.mjs';
const DEFAULT_BASE_URL = 'https://slack.com/api/'; const DEFAULT_BASE_URL = 'https://slack.com/api/';
const SLACK_FILE_HOST = 'files.slack.com'; const SLACK_FILE_HOST = 'files.slack.com';
const LEGACY_SLACK_FILE_HOST = 'slack.com'; const LEGACY_SLACK_FILE_HOST = 'slack.com';
const SLACK_FILE_PATH_PREFIX = '/files-pri/'; const SLACK_FILE_PATH_PREFIX = '/files-pri/';
const SLACK_FILE_HOSTS = Object.freeze([SLACK_FILE_HOST]); const SLACK_FILE_HOSTS = Object.freeze([SLACK_FILE_HOST]);
const SLACK_UPLOAD_PATH_PREFIX = '/upload/';
const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
function cleanString(value) { function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
@ -15,6 +19,47 @@ function requestSignal(signal, timeoutMs) {
return signal ? AbortSignal.any([signal, timeout]) : timeout; return signal ? AbortSignal.any([signal, timeout]) : timeout;
} }
function abortReason(signal) {
return signal?.reason instanceof Error
? signal.reason
: new DOMException('The operation was aborted', 'AbortError');
}
function positiveTimeout(value, name) {
if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
return value;
}
function preserveProviderMetadata(target, source) {
if (source?.providerCode !== undefined) target.providerCode = source.providerCode;
if (Number.isInteger(source?.status)) target.status = source.status;
return target;
}
function slackArtifactPreparationError(cause) {
let code = 'artifact-provider-failed';
let message = 'Slack file upload preparation failed';
if (cause?.code === 'slack-file_upload_size_restricted') {
code = 'artifact-too-large';
message = 'Slack rejected the result file because it is too large';
} else if (cause?.code === 'slack-missing-scope') {
code = 'artifact-permission-required';
message = 'Slack file delivery requires the files:write scope';
} else if (cause?.code?.startsWith?.('slack-')) {
code = 'artifact-provider-rejected';
message = 'Slack rejected file upload preparation';
}
const error = new Error(message, { cause });
error.code = code;
return preserveProviderMetadata(error, cause);
}
function uncertainSlackDelivery(cause) {
const error = new Error('Slack file completion result is uncertain', { cause });
error.code = 'artifact-delivery-uncertain';
return preserveProviderMetadata(error, cause);
}
function isRedirectStatus(status) { function isRedirectStatus(status) {
return Number.isInteger(status) && status >= 300 && status < 400; return Number.isInteger(status) && status >= 300 && status < 400;
} }
@ -43,6 +88,17 @@ function secureSlackFileUrl(value) {
return url; return url;
} }
function secureSlackUploadUrl(value) {
const url = new URL(value);
if (url.protocol !== 'https:' || url.username || url.password
|| (url.port && url.port !== '443') || url.hostname !== SLACK_FILE_HOST
|| !url.pathname.startsWith(SLACK_UPLOAD_PATH_PREFIX)) {
throw new Error('Slack returned an unsafe file upload URL');
}
url.hash = '';
return url;
}
function redirectUrl(response, source) { function redirectUrl(response, source) {
const location = response?.headers?.get?.('location'); const location = response?.headers?.get?.('location');
if (!location) return null; if (!location) return null;
@ -111,6 +167,7 @@ function safeOutgoingText(value, { trim = true } = {}) {
function apiFailure(method, payload, tokenKind) { function apiFailure(method, payload, tokenKind) {
const reason = cleanString(payload?.error) ?? 'unknown_error'; const reason = cleanString(payload?.error) ?? 'unknown_error';
const error = new Error(`Slack ${method} failed: ${reason.replaceAll('_', ' ')}`); const error = new Error(`Slack ${method} failed: ${reason.replaceAll('_', ' ')}`);
error.providerCode = reason;
if (['invalid_auth', 'not_authed', 'token_revoked', 'account_inactive'].includes(reason)) { if (['invalid_auth', 'not_authed', 'token_revoked', 'account_inactive'].includes(reason)) {
error.code = tokenKind === 'app' ? 'slack-invalid-app-token' : 'slack-invalid-bot-token'; error.code = tokenKind === 'app' ? 'slack-invalid-app-token' : 'slack-invalid-bot-token';
} else if (reason === 'missing_scope') { } else if (reason === 'missing_scope') {
@ -141,8 +198,15 @@ export class SlackApi {
#fetch; #fetch;
#baseUrl; #baseUrl;
#botScopes = null; #botScopes = null;
#fileUploadTimeoutMs;
constructor({ botToken, appToken, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) { constructor({
botToken,
appToken,
fetchImpl = fetch,
baseUrl = DEFAULT_BASE_URL,
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
}) {
if (botToken !== undefined && !validSlackBotToken(botToken)) { if (botToken !== undefined && !validSlackBotToken(botToken)) {
throw new TypeError('Slack Bot Token is invalid'); throw new TypeError('Slack Bot Token is invalid');
} }
@ -155,6 +219,7 @@ export class SlackApi {
this.#appToken = appToken?.trim(); this.#appToken = appToken?.trim();
this.#fetch = fetchImpl; this.#fetch = fetchImpl;
this.#baseUrl = new URL(baseUrl); this.#baseUrl = new URL(baseUrl);
this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
} }
authTest(options = {}) { authTest(options = {}) {
@ -169,6 +234,18 @@ export class SlackApi {
}); });
} }
async fileInfo({ fileId, signal } = {}) {
const value = await this.#request('files.info', {
tokenKind: 'bot',
signal,
body: { file: slackId(fileId, 'file id') },
});
if (!value?.file || typeof value.file !== 'object' || Array.isArray(value.file)) {
throw new Error('Slack files.info returned no file object');
}
return value.file;
}
postMessage({ channelId, text, threadTs, signal }) { postMessage({ channelId, text, threadTs, signal }) {
return this.#request('chat.postMessage', { return this.#request('chat.postMessage', {
tokenKind: 'bot', tokenKind: 'bot',
@ -239,7 +316,108 @@ export class SlackApi {
}); });
} }
async uploadFile({ channelId, threadTs, file, signal }) {
if (!file || typeof file !== 'object'
|| typeof file.fileName !== 'string' || !file.fileName
|| !Buffer.isBuffer(file.bytes)) {
throw new TypeError('A Slack file is required');
}
if (signal?.aborted) throw abortReason(signal);
const uploadSignal = requestSignal(signal, this.#fileUploadTimeoutMs);
let ticket;
try {
ticket = await this.#request('files.getUploadURLExternal', {
tokenKind: 'bot',
signal: uploadSignal,
timeoutMs: this.#fileUploadTimeoutMs,
body: { filename: file.fileName, length: file.bytes.byteLength },
});
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
throw slackArtifactPreparationError(error);
}
let fileId;
let uploadUrl;
try {
fileId = requiredString(ticket?.file_id, 'file id');
uploadUrl = secureSlackUploadUrl(ticket?.upload_url);
} catch (error) {
throw slackArtifactPreparationError(error);
}
let uploaded;
try {
uploaded = await this.#fetch(uploadUrl, {
method: 'POST',
headers: { 'content-type': file.mediaType ?? 'application/octet-stream' },
body: file.bytes,
signal: uploadSignal,
redirect: 'error',
});
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
throw slackArtifactPreparationError(error);
}
if (!Number.isInteger(uploaded?.status)) {
throw slackArtifactPreparationError(new Error('Slack file upload returned an invalid response'));
}
if (uploaded.status !== 200) {
await cancelResponseBody(uploaded);
const error = new Error(`Slack file upload failed with HTTP ${uploaded.status}`);
error.status = uploaded.status;
if (uploaded.status === 413) {
error.code = 'artifact-too-large';
} else if (uploaded.status === 429) {
error.code = 'artifact-rate-limited';
} else {
error.code = 'artifact-provider-rejected';
}
throw error;
}
await cancelResponseBody(uploaded);
let completionBody;
try {
completionBody = {
files: [{ id: fileId, title: file.fileName }],
channel_id: slackId(channelId, 'channel id'),
...(threadTs ? { thread_ts: requiredString(threadTs, 'thread timestamp') } : {}),
};
} catch (error) {
throw slackArtifactPreparationError(error);
}
if (uploadSignal.aborted) {
if (signal?.aborted) throw abortReason(signal);
throw slackArtifactPreparationError(uploadSignal.reason);
}
try {
const completed = await this.#request('files.completeUploadExternal', {
tokenKind: 'bot',
signal: uploadSignal,
timeoutMs: this.#fileUploadTimeoutMs,
retry: false,
body: completionBody,
});
if (!Array.isArray(completed?.files)
|| !completed.files.some((entry) => cleanString(entry?.id) === fileId)) {
throw new Error('Slack file completion did not confirm the uploaded file');
}
return completed;
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
if (error?.code === 'slack-missing-scope') throw slackArtifactPreparationError(error);
throw uncertainSlackDelivery(error);
}
}
async downloadFile({ url, signal, maxBytes }) { async downloadFile({ url, signal, maxBytes }) {
return this.#downloadFile({ url, signal, maxBytes, stream: false });
}
async downloadFileStream({ url, signal }) {
return this.#downloadFile({ url, signal, stream: true });
}
async #downloadFile({ url, signal, maxBytes, stream }) {
if (!this.#botToken) throw new TypeError('Slack bot token is required for file download'); if (!this.#botToken) throw new TypeError('Slack bot token is required for file download');
const target = secureSlackFileUrl(url); const target = secureSlackFileUrl(url);
const fetchSlackFile = async (requestUrl, options) => { const fetchSlackFile = async (requestUrl, options) => {
@ -253,18 +431,20 @@ export class SlackApi {
throw new ImagePromptError( throw new ImagePromptError(
'slack-file-access-required', 'slack-file-access-required',
'Slack redirected a private file request to the workspace because file access was not granted', 'Slack redirected a private file request to the workspace because file access was not granted',
'Slack 未授权机器人读取该文件。请为应用添加 files:read 后重新安装,再重新发送图片。', t('Slack 未授权机器人读取该文件。请为应用添加 files:read 后重新安装,再重新发送图片。'),
); );
} }
return response; return response;
}; };
return fetchImageBuffer(target, { const options = {
fetchImpl: fetchSlackFile, fetchImpl: fetchSlackFile,
headers: { authorization: `Bearer ${this.#botToken}` }, headers: { authorization: `Bearer ${this.#botToken}` },
signal, signal,
maxBytes,
allowedHosts: SLACK_FILE_HOSTS, allowedHosts: SLACK_FILE_HOSTS,
}); };
return stream
? fetchFileStream(target, options)
: fetchImageBuffer(target, { ...options, maxBytes });
} }
async #request(method, { async #request(method, {
@ -276,18 +456,21 @@ export class SlackApi {
}) { }) {
const token = tokenKind === 'app' ? this.#appToken : this.#botToken; const token = tokenKind === 'app' ? this.#appToken : this.#botToken;
if (!token) throw new TypeError(`Slack ${tokenKind} token is required for ${method}`); if (!token) throw new TypeError(`Slack ${tokenKind} token is required for ${method}`);
const formEncoded = method === 'files.getUploadURLExternal' || method === 'files.info';
let response; let response;
try { try {
response = await this.#fetch(new URL(method, this.#baseUrl), { response = await this.#fetch(new URL(method, this.#baseUrl), {
method: 'POST', method: 'POST',
headers: { headers: {
authorization: `Bearer ${token}`, authorization: `Bearer ${token}`,
'content-type': body === undefined 'content-type': body === undefined || formEncoded
? 'application/x-www-form-urlencoded;charset=utf-8' ? 'application/x-www-form-urlencoded;charset=utf-8'
: 'application/json;charset=utf-8', : 'application/json;charset=utf-8',
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.2.2)', 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.2.2)',
}, },
...(body === undefined ? {} : { body: JSON.stringify(body) }), ...(body === undefined ? {} : {
body: formEncoded ? new URLSearchParams(body).toString() : JSON.stringify(body),
}),
signal: requestSignal(signal, timeoutMs), signal: requestSignal(signal, timeoutMs),
redirect: 'error', redirect: 'error',
}); });
@ -312,26 +495,30 @@ export class SlackApi {
await delay(Math.min(10_000, Math.max(100, seconds * 1_000)), signal); await delay(Math.min(10_000, Math.max(100, seconds * 1_000)), signal);
return this.#request(method, { tokenKind, body, signal, timeoutMs, retry: false }); return this.#request(method, { tokenKind, body, signal, timeoutMs, retry: false });
} }
if (!response.ok || payload?.ok !== true) throw apiFailure(method, payload, tokenKind); if (!response.ok || payload?.ok !== true) {
const error = apiFailure(method, payload, tokenKind);
error.status = response.status;
throw error;
}
return payload; return payload;
} }
} }
export async function inspectSlackCredentials({ botToken, appToken }, options = {}) { export async function inspectSlackCredentials({ botToken, appToken }, options = {}) {
if (!validSlackBotToken(botToken)) { if (!validSlackBotToken(botToken)) {
const error = new TypeError('Slack Bot Token 必须以 xoxb- 开头。'); const error = new TypeError(t('Slack Bot Token 必须以 xoxb- 开头。'));
error.code = 'slack-invalid-bot-token'; error.code = 'slack-invalid-bot-token';
throw error; throw error;
} }
if (!validSlackAppToken(appToken)) { if (!validSlackAppToken(appToken)) {
const error = new TypeError('Slack App Token 必须以 xapp- 开头。'); const error = new TypeError(t('Slack App Token 必须以 xapp- 开头。'));
error.code = 'slack-invalid-app-token'; error.code = 'slack-invalid-app-token';
throw error; throw error;
} }
const api = new SlackApi({ botToken, appToken, ...options }); const api = new SlackApi({ botToken, appToken, ...options });
const [identity, connection] = await Promise.all([api.authTest(), api.openConnection()]); const [identity, connection] = await Promise.all([api.authTest(), api.openConnection()]);
if (!identity?.team_id || !identity?.user_id || !identity?.bot_id) { if (!identity?.team_id || !identity?.user_id || !identity?.bot_id) {
throw new Error('Slack Bot Token 没有返回完整的机器人身份。'); throw new Error(t('Slack Bot Token 没有返回完整的机器人身份。'));
} }
let socketUrl; let socketUrl;
try { try {
@ -340,7 +527,7 @@ export async function inspectSlackCredentials({ botToken, appToken }, options =
socketUrl = null; socketUrl = null;
} }
if (!socketUrl || socketUrl.protocol !== 'wss:') { if (!socketUrl || socketUrl.protocol !== 'wss:') {
const error = new Error('Slack App Token 无法创建 Socket Mode 连接,请确认已启用 Socket Mode 和 connections:write。'); const error = new Error(t('Slack App Token 无法创建 Socket Mode 连接,请确认已启用 Socket Mode 和 connections:write。'));
error.code = 'slack-socket-mode'; error.code = 'slack-socket-mode';
throw error; throw error;
} }

View file

@ -1,4 +1,5 @@
import { connectionTestMessage } from '../shared/connection-test.mjs'; import { connectionTestMessage } from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
import { deriveSlackBotIdentity, maskSlackBotId } from './config-store.mjs'; import { deriveSlackBotIdentity, maskSlackBotId } from './config-store.mjs';
import { inspectSlackCredentials } from './slack-api.mjs'; import { inspectSlackCredentials } from './slack-api.mjs';
import { SLACK_DESCRIPTOR } from './slack-bridge.mjs'; import { SLACK_DESCRIPTOR } from './slack-bridge.mjs';
@ -60,7 +61,7 @@ export class SlackController {
if (!resolved) { if (!resolved) {
this.#errors.set(config.botId, safeError( this.#errors.set(config.botId, safeError(
'missing-token', 'missing-token',
'Slack机器人凭据缺失,请移除后重新接入。', t('Slack机器人凭据缺失,请移除后重新接入。'),
)); ));
return; return;
} }
@ -70,7 +71,7 @@ export class SlackController {
} catch (error) { } catch (error) {
this.#errors.set(config.botId, safeError( this.#errors.set(config.botId, safeError(
'connection-failed', 'connection-failed',
'Slack Socket Mode 连接未就绪,插件会自动重试。', t('Slack Socket Mode 连接未就绪,插件会自动重试。'),
)); ));
this.#logger.warn?.( this.#logger.warn?.(
`[dsh-im:slack] bot ${config.botId} failed to initialize:`, `[dsh-im:slack] bot ${config.botId} failed to initialize:`,
@ -135,7 +136,7 @@ export class SlackController {
} catch (error) { } catch (error) {
this.#errors.set(identity.botId, safeError( this.#errors.set(identity.botId, safeError(
'connection-failed', 'connection-failed',
'Slack机器人已接入,Socket Mode 连接暂未就绪。', t('Slack机器人已接入,Socket Mode 连接暂未就绪。'),
)); ));
this.#logger.warn?.( this.#logger.warn?.(
`[dsh-im:slack] bot ${identity.botId} credential connection failed:`, `[dsh-im:slack] bot ${identity.botId} credential connection failed:`,
@ -159,7 +160,7 @@ export class SlackController {
} catch (error) { } catch (error) {
this.#errors.set(botId, safeError( this.#errors.set(botId, safeError(
'connection-failed', 'connection-failed',
'Slack Socket Mode 连接仍未就绪,请检查两个 Token。', t('Slack Socket Mode 连接仍未就绪,请检查两个 Token。'),
)); ));
throw error; throw error;
} finally { } finally {
@ -175,13 +176,13 @@ export class SlackController {
return this.#withBotTransition(botId, async () => { return this.#withBotTransition(botId, async () => {
const runtime = this.#runtimes.get(botId); const runtime = this.#runtimes.get(botId);
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') { if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
const error = new Error('Slack机器人尚未连接'); const error = new Error(t('Slack机器人尚未连接'));
error.code = 'test-target-unavailable'; error.code = 'test-target-unavailable';
throw error; throw error;
} }
await runtime.sendConnectionTest(connectionTestMessage( await runtime.sendConnectionTest(connectionTestMessage(
`${config.name}(${maskSlackBotId(config.platformId)})`, `${config.name}(${maskSlackBotId(config.platformId)})`,
'Slack机器人', t('Slack机器人'),
)); ));
return { sent: true }; return { sent: true };
}); });
@ -243,9 +244,9 @@ export class SlackController {
}, },
health: { health: {
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline', status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
summary: connected ? `Slack${SLACK_DESCRIPTOR.connectionLabel}运行正常` summary: connected ? t(`Slack${SLACK_DESCRIPTOR.connectionLabel}运行正常`)
: state === 'error' ? 'Slack连接未就绪,插件会自动重试' : state === 'error' ? t('Slack连接未就绪,插件会自动重试')
: 'Slack连接当前离线', : t('Slack连接当前离线'),
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null, lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null, lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
}, },

View file

@ -1,4 +1,5 @@
import { splitMessageText } from '../shared/editable-message-stream.mjs'; import { splitMessageText } from '../shared/editable-message-stream.mjs';
import { t } from '../shared/i18n.mjs';
import { SlackApi } from './slack-api.mjs'; import { SlackApi } from './slack-api.mjs';
import { createSlackBridgeStatus, SlackHarnessBridge } from './slack-bridge.mjs'; import { createSlackBridgeStatus, SlackHarnessBridge } from './slack-bridge.mjs';
@ -43,10 +44,14 @@ function stripBotMention(value, botUserId) {
.trim(); .trim();
} }
function slackFileUrl(file) {
return typeof file?.url_private_download === 'string' && file.url_private_download
? file.url_private_download : file?.url_private;
}
function slackImageSource(file, loadFile) { function slackImageSource(file, loadFile) {
const mediaType = typeof file?.mimetype === 'string' ? file.mimetype.toLowerCase() : ''; const mediaType = typeof file?.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
const url = typeof file?.url_private_download === 'string' && file.url_private_download const url = slackFileUrl(file);
? file.url_private_download : file?.url_private;
if (!IMAGE_MEDIA_TYPES.has(mediaType) || typeof url !== 'string' || !url) return null; if (!IMAGE_MEDIA_TYPES.has(mediaType) || typeof url !== 'string' || !url) return null;
return { return {
name: typeof file.name === 'string' ? file.name : undefined, name: typeof file.name === 'string' ? file.name : undefined,
@ -56,9 +61,50 @@ function slackImageSource(file, loadFile) {
}; };
} }
export function normalizeSlackEvent(payload, botUserId, { loadFile = async () => { function slackFileSource(file, loadFile, loadFileInfo) {
throw new Error('Slack file downloader is unavailable'); const mediaType = typeof file?.mimetype === 'string' && file.mimetype
} } = {}) { ? file.mimetype.toLowerCase() : undefined;
const url = slackFileUrl(file);
if (IMAGE_MEDIA_TYPES.has(mediaType)) return null;
const requiresInfo = file?.file_access === 'check_file_info'
&& typeof file?.id === 'string' && file.id;
if ((typeof url !== 'string' || !url) && !requiresInfo) return null;
return {
name: typeof file?.name === 'string' && file.name
? file.name : typeof file?.title === 'string' && file.title
? file.title : requiresInfo ? file.id : 'slack-file',
...(mediaType ? { mediaType } : {}),
size: Number.isSafeInteger(file?.size) && file.size >= 0 ? file.size : undefined,
load: async ({ signal } = {}) => {
if (!requiresInfo) return loadFile(url, { signal });
const resolved = await loadFileInfo(file.id, { signal });
const resolvedUrl = slackFileUrl(resolved);
if (typeof resolvedUrl !== 'string' || !resolvedUrl) {
throw new Error('Slack files.info returned no downloadable URL');
}
const loaded = await loadFile(resolvedUrl, { signal });
const name = typeof resolved?.name === 'string' && resolved.name
? resolved.name : typeof resolved?.title === 'string' && resolved.title
? resolved.title : file.id;
const resolvedMediaType = typeof resolved?.mimetype === 'string' && resolved.mimetype
? resolved.mimetype.toLowerCase() : undefined;
if (Buffer.isBuffer(loaded) || loaded instanceof Uint8Array) {
return { data: loaded, name, ...(resolvedMediaType ? { mediaType: resolvedMediaType } : {}) };
}
return {
...loaded,
name,
...(resolvedMediaType ? { mediaType: resolvedMediaType } : {}),
};
},
};
}
export function normalizeSlackEvent(payload, botUserId, {
loadFile = async () => { throw new Error('Slack file downloader is unavailable'); },
loadFileStream = loadFile,
loadFileInfo = async () => { throw new Error('Slack file metadata loader is unavailable'); },
} = {}) {
const event = payload?.event; const event = payload?.event;
if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null; if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
const direct = event.type === 'message' && event.channel_type === 'im'; const direct = event.type === 'message' && event.channel_type === 'im';
@ -76,6 +122,9 @@ export function normalizeSlackEvent(payload, botUserId, { loadFile = async () =>
images: Array.isArray(event.files) images: Array.isArray(event.files)
? event.files.map((file) => slackImageSource(file, loadFile)).filter(Boolean) ? event.files.map((file) => slackImageSource(file, loadFile)).filter(Boolean)
: [], : [],
files: Array.isArray(event.files)
? event.files.map((file) => slackFileSource(file, loadFileStream, loadFileInfo)).filter(Boolean)
: [],
addressed: direct || mentioned, addressed: direct || mentioned,
replyTarget: { replyTarget: {
channelId: String(event.channel), channelId: String(event.channel),
@ -87,8 +136,18 @@ export function normalizeSlackEvent(payload, botUserId, { loadFile = async () =>
}; };
} }
function isToolProgress(text) { export function isSlackToolProgress(text) {
return typeof text === 'string' && /^正在使用.+…$/.test(text.trim()); if (typeof text !== 'string') return false;
const marker = '__DSH_IM_TOOL_NAME__';
const template = t('正在使用{name}…', { name: marker });
const markerIndex = template.indexOf(marker);
if (markerIndex < 0) return false;
const prefix = template.slice(0, markerIndex);
const suffix = template.slice(markerIndex + marker.length);
const source = text.trim();
return source.startsWith(prefix)
&& source.endsWith(suffix)
&& source.length > prefix.length + suffix.length;
} }
async function appendInChunks(api, target, ts, text, signal) { async function appendInChunks(api, target, ts, text, signal) {
@ -120,6 +179,7 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
let inFlight = null; let inFlight = null;
let broken = false; let broken = false;
let closed = false; let closed = false;
const providerMessageIds = [ts];
const appendLatest = async (text) => { const appendLatest = async (text) => {
const next = splitMessageText(text, SLACK_MESSAGE_LIMIT)[0] ?? ''; const next = splitMessageText(text, SLACK_MESSAGE_LIMIT)[0] ?? '';
@ -150,8 +210,13 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
}; };
return { return {
messageId: ts,
get providerMessageIds() {
return [...providerMessageIds];
},
update(text) { update(text) {
if (closed || broken || typeof text !== 'string' || !text.trim() || isToolProgress(text)) return; if (closed || broken || typeof text !== 'string' || !text.trim()
|| isSlackToolProgress(text)) return;
pending = text; pending = text;
schedule(); schedule();
}, },
@ -164,7 +229,7 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
await inFlight?.catch(() => undefined); await inFlight?.catch(() => undefined);
const chunks = splitMessageText(text, SLACK_MESSAGE_LIMIT); const chunks = splitMessageText(text, SLACK_MESSAGE_LIMIT);
const first = chunks[0] ?? '处理完成。'; const first = chunks[0] ?? t('处理完成。');
if (!broken && first.startsWith(appended)) { if (!broken && first.startsWith(appended)) {
await appendInChunks(api, target, ts, first.slice(appended.length), signal); await appendInChunks(api, target, ts, first.slice(appended.length), signal);
await api.stopStream({ channelId: target.channelId, ts, signal }); await api.stopStream({ channelId: target.channelId, ts, signal });
@ -173,12 +238,13 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
await api.updateMessage({ channelId: target.channelId, ts, text: first, signal }); await api.updateMessage({ channelId: target.channelId, ts, text: first, signal });
} }
for (const chunk of chunks.slice(1)) { for (const chunk of chunks.slice(1)) {
await api.postMessage({ const result = await api.postMessage({
channelId: target.channelId, channelId: target.channelId,
threadTs: target.threadTs, threadTs: target.threadTs,
text: chunk, text: chunk,
signal, signal,
}); });
if (typeof result?.ts === 'string' && result.ts) providerMessageIds.push(result.ts);
} }
}, },
cancel() { cancel() {
@ -191,7 +257,7 @@ async function createSlackMessageStream({ api, target, signal, logger }) {
}; };
} }
class SlackBotClient { export class SlackBotClient {
#api; #api;
#signal; #signal;
#logger; #logger;
@ -204,16 +270,17 @@ class SlackBotClient {
async sendText(target, text) { async sendText(target, text) {
const chunks = splitMessageText(text, SLACK_MESSAGE_LIMIT); const chunks = splitMessageText(text, SLACK_MESSAGE_LIMIT);
let result = null; const providerMessageIds = [];
for (const chunk of chunks) { for (const chunk of chunks) {
result = await this.#api.postMessage({ const result = await this.#api.postMessage({
channelId: target.channelId, channelId: target.channelId,
threadTs: target.threadTs, threadTs: target.threadTs,
text: chunk, text: chunk,
signal: this.#signal, signal: this.#signal,
}); });
if (typeof result?.ts === 'string' && result.ts) providerMessageIds.push(result.ts);
} }
return result; return { providerMessageIds };
} }
openStream(target) { openStream(target) {
@ -224,6 +291,15 @@ class SlackBotClient {
logger: this.#logger, logger: this.#logger,
}); });
} }
sendFile(target, file) {
return this.#api.uploadFile({
channelId: target.channelId,
threadTs: target.threadTs,
file,
signal: this.#signal,
});
}
} }
export function createSlackRuntimeStatus() { export function createSlackRuntimeStatus() {
@ -420,6 +496,8 @@ export class SlackRuntime {
&& packet.payload.api_app_id !== this.#appId) return; && packet.payload.api_app_id !== this.#appId) return;
const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1], { const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1], {
loadFile: (url, options) => this.#api.downloadFile({ url, ...options }), loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
loadFileStream: (url, options) => this.#api.downloadFileStream({ url, ...options }),
loadFileInfo: (fileId, options) => this.#api.fileInfo({ fileId, ...options }),
}); });
const bridge = this.#bridge; const bridge = this.#bridge;
if (message && bridge) { if (message && bridge) {

Some files were not shown because too many files have changed in this diff Show more