Merge pull request #20 from mrpompy/local/telegram-private-allowlist

feat: connect AI Office jobs to local Harness
This commit is contained in:
xiemanR 2026-08-21 01:12:18 +08:00 • committed by GitHub
commit 7f114e1c36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2639 additions and 314 deletions

View file

@ -35,7 +35,7 @@
## Introduction ## Introduction
Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest, or entering existing bot credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp. **Every channel supports multiple bots**, each with independent connection state, workspace, and session bindings. It also supports switching workspaces and rebinding sessions. Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest, or entering existing bot credentials, and let the local Harness connect outward to a public AI Office. One plugin and one settings entry manage nine multi-bot IM channels and the AI Office Connector.
## Interface ## Interface
@ -51,7 +51,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
| 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; native typing and streaming replies in private chats, replies when mentioned in groups |
| 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; direct private replies, mention-or-reply group handling, and streaming through message edits | | Telegram | Enter a Bot Token generated by @BotFather | Bot API long polling; all groups ignored, private chats restricted to numeric IDs in `telegram.allowedUsers`, and streaming through message edits |
| 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, mention-only server replies, and streaming 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; read receipt and typing indicator followed by the final answer |
@ -59,6 +59,16 @@ 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.
## 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 current protocol is `office-harness.v1`. The connector authenticates and advertises capabilities with `POST /api/harness/connector/heartbeat`, then opens the downstream event plane with `GET /api/harness/connector/stream` over SSE. The settings page derives every fixed hook from the Office Base URL and reconnects with backoff after a disconnect.
A `job.available` event makes the local connector fetch the payload, validate Workspace/Preset aliases, claim a 90-second lease, and renew it every 30 seconds. It creates an isolated Harness Session, reports safe status/tool/text progress, and writes a terminal result exactly once. Tool approvals and follow-up questions surface in Office; approve, reject, and text answers return over SSE to the original Session. Heartbeats and leases recover from dropped connections.
A successful heartbeat response must be JSON: `{"ok":true,"protocolVersion":"office-harness.v1"}`. This makes a successful connection test proof of a compatible Office Connector instead of any URL that happens to return 200.
## Installation ## Installation
Install the published stable release from npm (recommended): Install the published stable release from npm (recommended):
@ -84,6 +94,18 @@ After installation, follow the built-in instructions on each channel page to sca
| Bot workspace | Each bot stores its workspace independently. New bots start with the Host's current working directory, which can later be changed from the bot card. | | Bot workspace | Each bot stores its workspace independently. New bots start with the Host's current working directory, which can later be changed from the bot card. |
| Agent Preset | New Sessions inherit Harness's `agent-presets.default` unless the channel explicitly overrides it. Later changes do not affect existing Sessions. | | Agent Preset | New Sessions inherit Harness's `agent-presets.default` unless the channel explicitly overrides it. Later changes do not affect existing Sessions. |
Telegram denies all inbound messages by default. Configure the numeric User IDs allowed to message the bot privately in the Web profile's `cordis.patch.yml`; group messages are ignored even when they mention the bot:
```yaml
- id: xmanrui-dsh-im
config:
telegram:
allowedUsers:
- 123456789
```
`allowedUsers` accepts numbers or decimal strings, removes duplicates, and rejects invalid values when the plugin starts. An empty allowlist remains deny-all.
## Bot commands ## Bot commands
| Command | Description | | Command | Description |
@ -124,7 +146,7 @@ Example: send `/models`, then `/model 2` to switch to the second model in the li
- `/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 who is already within the platform bot's visibility scope and can normally message it can run these commands; there is no additional administrator/ordinary-user distinction. - Except on Telegram, any user who is already within the platform bot's visibility scope and can normally message it can run these commands; Telegram admits only private users in `telegram.allowedUsers` and always ignores group commands.
- 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.
- Any user who can run `/session` can continue the selected session and use later messages to write to it or invoke its available tools. Expose the bot and session list only to trusted users. - Any user who can run `/session` can continue the selected session and use later messages to write to it or invoke its available tools. Expose the bot and session list only to trusted users.
@ -142,12 +164,12 @@ Example: send `/models`, then `/model 2` to switch to the second model in the li
## Design ## Design
- Registers a single **IM Bot** settings page in Harness. - Registers one **IM Bot** settings page containing nine IM channels and one AI Office Connector.
- Maintains all nine channel Host, client, and runtime sources in this repository without external standalone channel 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.
- Uses channel logos for WeChat, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp 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 channel. - 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, and redacted status data to the browser. 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, or raw user identifiers. - Returns only QR codes, the public Slack Manifest, and redacted status data to the browser. 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 raw user identifiers.
## Local development ## Local development

View file

@ -36,9 +36,9 @@
## 简介 ## 简介
通过扫码、App Manifest 或已有机器人凭据把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord 和 WhatsApp。**每个渠道都支持接入多个机器人**,各机器人的连接状态、工作区和会话绑定彼此独立。支持切换工作区和重新绑定会话。 通过扫码、App Manifest 或已有机器人凭据把 IM 机器人接入 DeepSeek Harness,并让本机 Harness 主动连接公网 AI Office。一个插件、一个设置入口,统一管理九种 IM 渠道和 AI Office Connector。**每个 IM 渠道都支持接入多个机器人**,各机器人的连接状态、工作区和会话绑定彼此独立。
Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest, or entering existing bot credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp. **Every channel supports multiple bots**, each with independent connection state, workspace, and session bindings. It also supports switching workspaces and rebinding sessions. Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest, or entering existing bot credentials, and let the local Harness connect outward to a public AI Office. One plugin and one settings entry manage nine multi-bot IM channels and the AI Office Connector.
## 界面 ## 界面
@ -54,7 +54,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
| 企业微信 | 使用企业微信 App 扫码创建智能机器人,或使用 Bot ID + Secret 手动绑定 | 官方 WebSocket 长连接;原生显示“正在思考中”、工具执行进度和流式回答 | | 企业微信 | 使用企业微信 App 扫码创建智能机器人,或使用 Bot ID + Secret 手动绑定 | 官方 WebSocket 长连接;原生显示“正在思考中”、工具执行进度和流式回答 |
| QQ | 使用手机 QQ 扫码创建机器人,或使用 AppID + AppSecret 手动绑定 | WebSocket 长连接;私聊显示“正在输入”和流式回答,群聊被 @ 后回复 | | QQ | 使用手机 QQ 扫码创建机器人,或使用 AppID + AppSecret 手动绑定 | WebSocket 长连接;私聊显示“正在输入”和流式回答,群聊被 @ 后回复 |
| 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 长轮询;群聊全部忽略,私聊仅接受 `telegram.allowedUsers` 中的数字 User ID,通过编辑消息流式显示回答 |
| Discord | 使用 Developer Portal 生成的 Bot Token | Gateway v10 长连接;私信直接回复,服务器频道被提及时响应,通过编辑消息流式显示回答 | | Discord | 使用 Developer Portal 生成的 Bot Token | Gateway v10 长连接;私信直接回复,服务器频道被提及时响应,通过编辑消息流式显示回答 |
| WhatsApp | 使用手机 WhatsApp 扫码关联设备 | WhatsApp Web 长连接;显示已读和“正在输入”,再发送最终回答 | | WhatsApp | 使用手机 WhatsApp 扫码关联设备 | WhatsApp Web 长连接;显示已读和“正在输入”,再发送最终回答 |
@ -62,6 +62,16 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
九个内置渠道均支持把 JPEG、PNG、WebP 图片,以及以图片文件方式发送的 GIF,连同可选文字说明发送给 Harness;单张图片上限为 5 MB,单条消息中的图片总大小上限为 20 MB。 九个内置渠道均支持把 JPEG、PNG、WebP 图片,以及以图片文件方式发送的 GIF,连同可选文字说明发送给 Harness;单张图片上限为 5 MB,单条消息中的图片总大小上限为 20 MB。
## AI Office Connector
「AI Office」页让本机 Harness 主动连接公网 Office,本机无需公网 IP、端口转发或 WebSocket 服务。Device Token 只写入 Harness 凭据存储;普通配置文件仅保存设备 ID、Office Origin、工作区 alias 和 Instruction Preset alias。Office 只能选择 alias,不会收到本机绝对路径。
当前协议版本为 `office-harness.v1`。连接器使用 `POST /api/harness/connector/heartbeat` 完成鉴权和能力握手,再以 `GET /api/harness/connector/stream` 建立 SSE 下行;设置页会从 Office Base URL 自动展示全部固定 Hook,并在断线后按退避策略自动重连。
Office 的 `job.available` 会触发本机拉取任务、校验 Workspace/Preset alias、领取 90 秒租约并每 30 秒续租。连接器创建独立 Harness Session,把状态、工具名和增量文字安全回传 Office,终态只允许写入一次。Harness 发起的工具审批或补充问题会进入 Office 人工面板;批准、拒绝或文字答案再经 SSE 回到原 Session,断线时由租约与 Heartbeat 恢复。
Heartbeat 成功响应必须是 JSON:`{"ok":true,"protocolVersion":"office-harness.v1"}`。这使「连接测试通过」代表命中了兼容的 Office Connector,而不只是某个碰巧返回 200 的网址。
## 安装 ## 安装
推荐从 npm 安装已发布的稳定版本: 推荐从 npm 安装已发布的稳定版本:
@ -87,6 +97,18 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
| 机器人工作区 | 每个机器人独立保存工作区。新机器人默认使用 Host 当时的工作目录;之后可在机器人卡片中修改。 | | 机器人工作区 | 每个机器人独立保存工作区。新机器人默认使用 Host 当时的工作目录;之后可在机器人卡片中修改。 |
| Agent Preset | 新会话默认继承 Harness 的 `agent-presets.default`;渠道显式配置优先,已有会话不受后续修改影响。 | | Agent Preset | 新会话默认继承 Harness 的 `agent-presets.default`;渠道显式配置优先,已有会话不受后续修改影响。 |
Telegram 默认拒绝所有入站消息。请在 Web profile 的 `cordis.patch.yml` 中配置允许私聊机器人的数字 User ID;群聊无论是否提及机器人都会被忽略:
```yaml
- id: xmanrui-dsh-im
config:
telegram:
allowedUsers:
- 123456789
```
`allowedUsers` 接受数字或十进制字符串、会去重,并在插件启动时拒绝无效值。空白名单保持全部拒绝。
## 机器人命令 ## 机器人命令
| 命令 | 作用 | | 命令 | 作用 |
@ -127,7 +149,7 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
- `/session` 只接受一个由 `/sessionlist` 获得的 Session ID。它不会新建会话或立即向模型发送消息;绑定成功后,当前聊天的后续消息会继续该会话。普通归档会话可以绑定但不会自动取消归档,子代理会话不能绑定。 - `/session` 只接受一个由 `/sessionlist` 获得的 Session ID。它不会新建会话或立即向模型发送消息;绑定成功后,当前聊天的后续消息会继续该会话。普通归档会话可以绑定但不会自动取消归档,子代理会话不能绑定。
- `/session` 会自动定位会话唯一所属的工作区。同工作区绑定只替换当前聊天的映射;跨工作区绑定会切换该机器人的工作区、清除该机器人所有聊天的旧会话映射,再绑定当前聊天,因此会影响该机器人的其他聊天。已经开始生成的回复仍可完成。 - `/session` 会自动定位会话唯一所属的工作区。同工作区绑定只替换当前聊天的映射;跨工作区绑定会切换该机器人的工作区、清除该机器人所有聊天的旧会话映射,再绑定当前聊天,因此会影响该机器人的其他聊天。已经开始生成的回复仍可完成。
- 工作区切换和会话绑定只会清除或替换 dsh-im 的聊天映射,不会删除、清空或归档任何旧 Session 内容;旧 Session 仍可再次列出和绑定。 - 工作区切换和会话绑定只会清除或替换 dsh-im 的聊天映射,不会删除、清空或归档任何旧 Session 内容;旧 Session 仍可再次列出和绑定。
- 任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行这些命令,不区分管理员和普通用户。 - 除 Telegram 外,任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行这些命令,不区分管理员和普通用户;Telegram 仅允许 `telegram.allowedUsers` 中的私聊用户执行,群聊命令始终忽略。
- 工作区列表来自 Harness Host 的全局登记信息,可能包含其他机器人、其他渠道或非 IM 项目的本机绝对路径。请将机器人可见范围限制给可信用户。 - 工作区列表来自 Harness Host 的全局登记信息,可能包含其他机器人、其他渠道或非 IM 项目的本机绝对路径。请将机器人可见范围限制给可信用户。
- 会话列表同样来自该全局 Harness Host;会话 ID 和标题可能属于其他机器人、其他渠道或非 IM 项目,并可能包含敏感元数据。开放命令前请确保所有可见用户都可信。 - 会话列表同样来自该全局 Harness Host;会话 ID 和标题可能属于其他机器人、其他渠道或非 IM 项目,并可能包含敏感元数据。开放命令前请确保所有可见用户都可信。
- 任何能执行 `/session` 的用户都能接续所选会话,并通过后续消息写入会话或触发其可用工具。请只向可信用户开放机器人及其会话列表。 - 任何能执行 `/session` 的用户都能接续所选会话,并通过后续消息写入会话或触发其可用工具。请只向可信用户开放机器人及其会话列表。
@ -145,12 +167,12 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
## 设计 ## 设计
- Harness 中只注册一个「IM机器人」设置页; - Harness 中只注册一个「IM机器人」设置页,其中包含九个 IM 渠道和一个 AI Office Connector;
- 九个渠道的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件; - 九个渠道及 Office Connector 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立插件;
- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换; - 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;
- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord 和 WhatsApp,不使用启用/停用开关; - 左侧使用 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 和 AI Office,不使用启用/停用开关;
- 九个渠道保持独立的 RPC、凭据、连接监督和会话映射; - 九个 IM 渠道保持独立的 RPC、凭据、连接监督和会话映射;Office Connector 另行维护设备凭据、Job 租约、审批等待与并发上限;
- 浏览器只获得二维码、Manifest 和脱敏状态;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥或原始用户标识。 - 浏览器只获得二维码、Manifest 和脱敏状态;手动输入的 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 或原始用户标识。
## 本地开发 ## 本地开发

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

36
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "@xmanrui/dsh-im", "name": "@xmanrui/dsh-im",
"version": "0.13.0", "version": "0.14.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@xmanrui/dsh-im", "name": "@xmanrui/dsh-im",
"version": "0.13.0", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tencent-connect/qqbot-connector": "1.2.0", "@tencent-connect/qqbot-connector": "1.2.0",
@ -87,6 +87,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@ -556,6 +557,7 @@
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -573,6 +575,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
@ -596,6 +599,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
@ -616,6 +620,7 @@
"os": [ "os": [
"freebsd" "freebsd"
], ],
"peer": true,
"dependencies": { "dependencies": {
"@img/sharp-wasm32": "0.35.3" "@img/sharp-wasm32": "0.35.3"
}, },
@ -639,6 +644,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@ -656,6 +662,7 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@ -673,6 +680,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@ -690,6 +698,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@ -707,6 +716,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@ -724,6 +734,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@ -741,6 +752,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@ -758,6 +770,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@ -775,6 +788,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@ -792,6 +806,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
@ -809,6 +824,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
@ -832,6 +848,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
@ -855,6 +872,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
@ -878,6 +896,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
@ -901,6 +920,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
@ -924,6 +944,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
@ -947,6 +968,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
@ -970,6 +992,7 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
@ -987,6 +1010,7 @@
"dev": true, "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"@emnapi/runtime": "^1.11.1" "@emnapi/runtime": "^1.11.1"
}, },
@ -1007,6 +1031,7 @@
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"@img/sharp-wasm32": "0.35.3" "@img/sharp-wasm32": "0.35.3"
}, },
@ -1030,6 +1055,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
@ -1050,6 +1076,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": "^20.9.0" "node": "^20.9.0"
}, },
@ -1070,6 +1097,7 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
@ -1543,6 +1571,7 @@
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -1927,7 +1956,6 @@
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@keyv/serialize": "^1.1.1" "@keyv/serialize": "^1.1.1"
} }
@ -2370,7 +2398,6 @@
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@ -2479,6 +2506,7 @@
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },

View file

@ -1,7 +1,7 @@
{ {
"name": "@xmanrui/dsh-im", "name": "@xmanrui/dsh-im",
"version": "0.13.0", "version": "0.14.0",
"description": "通过扫码或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord和WhatsApp)。 Connect IM bots to DeepSeek Harness via QR code or credentials (9 channels).", "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",
"dsh", "dsh",
@ -19,7 +19,8 @@
"slack", "slack",
"telegram", "telegram",
"discord", "discord",
"whatsapp" "whatsapp",
"ai-office"
], ],
"author": { "author": {
"name": "xmanrui", "name": "xmanrui",

View file

@ -131,3 +131,15 @@ export function WhatsappLogoGlyph({ size } = {}) {
d: 'M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.521.149-.173.198-.297.298-.495.099-.198.05-.372-.025-.521-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51a12.8 12.8 0 0 0-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479s1.065 2.875 1.213 3.074c.149.198 2.095 3.2 5.077 4.487.709.306 1.262.489 1.694.626.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.29.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.981.999-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.83 9.83 0 0 1 2.893 6.991c-.003 5.45-4.437 9.884-9.886 9.884m8.413-18.297A11.8 11.8 0 0 0 12.055 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.9 11.9 0 0 0 5.688 1.448h.005c6.557 0 11.892-5.335 11.895-11.893a11.82 11.82 0 0 0-3.486-8.413', d: 'M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.521.149-.173.198-.297.298-.495.099-.198.05-.372-.025-.521-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51a12.8 12.8 0 0 0-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479s1.065 2.875 1.213 3.074c.149.198 2.095 3.2 5.077 4.487.709.306 1.262.489 1.694.626.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.29.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.981.999-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.83 9.83 0 0 1 2.893 6.991c-.003 5.45-4.437 9.884-9.886 9.884m8.413-18.297A11.8 11.8 0 0 0 12.055 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.9 11.9 0 0 0 5.688 1.448h.005c6.557 0 11.892-5.335 11.895-11.893a11.82 11.82 0 0 0-3.486-8.413',
})); }));
} }
export function OfficeLogoGlyph({ size } = {}) {
return h('svg', {
...dimensions(size),
viewBox: '0 0 24 24',
focusable: 'false',
'aria-hidden': 'true',
'data-im-channel-logo': 'office',
},
h('path', { fill: 'currentColor', d: 'M4 3.5h10.5a2 2 0 0 1 2 2v13H4v-15Zm2.2 3v1.8h2V6.5h-2Zm4.1 0v1.8h2V6.5h-2Zm-4.1 4v1.8h2v-1.8h-2Zm4.1 0v1.8h2v-1.8h-2ZM8.4 15v3.5h3V15h-3Z' }),
h('path', { fill: 'currentColor', d: 'M18.3 8.2h1.5v3h3v1.5h-3v3h-1.5v-3h-3v-1.5h3v-3Z' }));
}

View file

@ -0,0 +1,44 @@
import {
OFFICE_PROTOCOL_VERSION,
OFFICE_RPC_CHANNEL,
OFFICE_RPC_ENDPOINTS,
officeHookUrls,
} from '../../../../src/channels/office/protocol.mjs';
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
export function unwrapOfficeRpc(result) {
if (!record(result) || typeof result.ok !== 'boolean') throw new Error('AI Office 服务返回了无法识别的响应');
if (!result.ok) {
const error = new Error(typeof result.error?.message === 'string' ? result.error.message : 'AI Office 操作失败');
error.code = typeof result.error?.code === 'string' ? result.error.code : 'office-rpc-error';
throw error;
}
return result.value;
}
export function normalizeOfficeStatus(value) {
if (!record(value) || value.configured !== true) {
return { configured: false, connected: false, state: 'unconfigured', config: null, health: null };
}
const config = record(value.config) ? value.config : {};
return {
configured: true,
connected: value.connected === true,
state: typeof value.state === 'string' ? value.state : 'idle',
tokenConfigured: value.tokenConfigured === true,
config: {
protocolVersion: config.protocolVersion ?? OFFICE_PROTOCOL_VERSION,
baseUrl: typeof config.baseUrl === 'string' ? config.baseUrl : '',
deviceId: typeof config.deviceId === 'string' ? config.deviceId : '',
maxConcurrency: Number(config.maxConcurrency ?? 1),
heartbeatSeconds: Number(config.heartbeatSeconds ?? 30),
workspaces: record(config.workspaces) ? config.workspaces : {},
instructionPresets: record(config.instructionPresets) ? config.instructionPresets : {},
hooks: record(config.hooks) ? config.hooks : {},
},
health: record(value.health) ? value.health : null,
};
}
export { OFFICE_PROTOCOL_VERSION, OFFICE_RPC_CHANNEL, OFFICE_RPC_ENDPOINTS, officeHookUrls };

View file

@ -0,0 +1,152 @@
import * as React from 'react';
import { h } from '../../i18n.js';
import {
OFFICE_PROTOCOL_VERSION,
OFFICE_RPC_ENDPOINTS,
normalizeOfficeStatus,
officeHookUrls,
unwrapOfficeRpc,
} from './api.js';
function Button({ children, kind = 'secondary', ...props }) {
return h('button', { ...props, type: 'button', className: 'ddt-button', 'data-kind': kind }, children);
}
function mapText(value) {
return Object.entries(value ?? {}).map(([key, item]) => `${key}=${item}`).join('\n');
}
function parseMap(value, label) {
const output = {};
for (const raw of value.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
const index = line.indexOf('=');
if (index < 1 || !line.slice(index + 1).trim()) {
throw new Error(label === 'Workspace 映射'
? 'Workspace 映射每行必须使用 alias=value'
: 'Instruction Preset 映射每行必须使用 alias=value');
}
output[line.slice(0, index).trim()] = line.slice(index + 1).trim();
}
return output;
}
function stateLabel(model) {
if (model.connected) return '已连接 Office';
if (!model.configured) return '尚未配置';
if (model.state === 'connecting') return '正在连接';
if (model.state === 'reconnecting') return '等待重连';
if (model.state === 'missing-token') return '凭据缺失';
return '已配置';
}
export function OfficeSettingsTab({ rpcCall, initialStatus }) {
const [model, setModel] = React.useState(normalizeOfficeStatus(initialStatus));
const [phase, setPhase] = React.useState(initialStatus === undefined ? 'loading' : 'ready');
const [busy, setBusy] = React.useState('');
const [error, setError] = React.useState('');
const [notice, setNotice] = React.useState('');
const [form, setForm] = React.useState({
baseUrl: 'https://fission.gridmind.ai', deviceId: 'local-harness', deviceToken: '',
maxConcurrency: '1', heartbeatSeconds: '30', workspaces: '', instructionPresets: '',
});
const invoke = React.useCallback(async (endpoint, payload = {}) => {
if (typeof rpcCall !== 'function') throw new Error('AI Office 设置页缺少 RPC 连接');
return unwrapOfficeRpc(await rpcCall(endpoint, payload));
}, [rpcCall]);
const adopt = React.useCallback((value) => {
const next = normalizeOfficeStatus(value?.snapshot ?? value);
setModel(next);
if (next.config) setForm((current) => ({
...current,
baseUrl: next.config.baseUrl,
deviceId: next.config.deviceId,
maxConcurrency: String(next.config.maxConcurrency),
heartbeatSeconds: String(next.config.heartbeatSeconds),
workspaces: mapText(next.config.workspaces),
instructionPresets: mapText(next.config.instructionPresets),
deviceToken: '',
}));
return next;
}, []);
const load = React.useCallback(async () => {
try { adopt(await invoke(OFFICE_RPC_ENDPOINTS.status)); setPhase('ready'); setError(''); }
catch (caught) { setPhase('error'); setError(caught.message); }
}, [adopt, invoke]);
React.useEffect(() => { void load(); }, [load]);
const run = async (name, operation) => {
setBusy(name); setError(''); setNotice('');
try { const value = await operation(); adopt(value); setNotice(name === 'test' ? '连接测试通过。' : '配置已保存。'); }
catch (caught) { setError(caught.message); }
finally { setBusy(''); }
};
const hooks = React.useMemo(() => {
try { return officeHookUrls(form.baseUrl); } catch { return {}; }
}, [form.baseUrl]);
const health = model.health ?? {};
if (phase === 'loading') return h('div', { className: 'ddt-card ddt-loading', 'aria-busy': 'true' }, '正在读取 AI Office Connector…');
return h('section', { className: 'dof-page', 'aria-label': 'AI Office 设置' },
h('div', { className: 'dof-hero' },
h('div', { className: 'dof-heroCopy' },
h('h3', null, 'AI Office Connector'),
h('p', null, '本机主动连接公网 Office;Harness 不开放端口。协议 Hook 固定为 ', OFFICE_PROTOCOL_VERSION, '。')),
h('span', { className: 'dof-status', 'data-connected': String(model.connected) },
h('span', { className: 'dof-dot' }), stateLabel(model))),
model.configured ? h('div', { className: 'dof-metrics' },
h('div', { className: 'dof-metric' }, h('span', null, '最近心跳'), h('strong', null, health.lastHeartbeatAt ?? '尚无')),
h('div', { className: 'dof-metric' }, h('span', null, '最近事件'), h('strong', null, health.lastEventType ?? '尚无')),
h('div', { className: 'dof-metric' }, h('span', null, '重连次数'), h('strong', null, String(health.reconnects ?? 0))),
h('div', { className: 'dof-metric' }, h('span', null, 'Job Offer'), h('strong', null, String(health.jobsOffered ?? 0))),
h('div', { className: 'dof-metric' }, h('span', null, '运行 Job'), h('strong', null, String(health.jobs?.running ?? 0))),
h('div', { className: 'dof-metric' }, h('span', null, '完成 Job'), h('strong', null, String(health.jobs?.completed ?? 0)))) : null,
h('div', { className: 'dof-card' },
h('div', { className: 'dof-cardTitle' }, h('h4', null, '设备连接'), h('span', null, 'Token 只写入本机凭据存储')),
h('div', { className: 'dof-grid' },
h('label', { className: 'dof-field', 'data-wide': 'true' }, 'Office Base URL',
h('input', { value: form.baseUrl, placeholder: 'https://fission.gridmind.ai', onChange: (event) => setForm({ ...form, baseUrl: event.target.value }) })),
h('label', { className: 'dof-field' }, 'Device ID',
h('input', { value: form.deviceId, placeholder: 'local-harness', onChange: (event) => setForm({ ...form, deviceId: event.target.value }) })),
h('label', { className: 'dof-field' }, 'Device Token',
h('input', { type: 'password', value: form.deviceToken, placeholder: model.tokenConfigured ? '已安全保存;留空保持不变' : '粘贴 Office 一次性凭据', autoComplete: 'new-password', onChange: (event) => setForm({ ...form, deviceToken: event.target.value }) })),
h('label', { className: 'dof-field' }, '最大并发',
h('input', { type: 'number', min: 1, max: 4, value: form.maxConcurrency, onChange: (event) => setForm({ ...form, maxConcurrency: event.target.value }) })),
h('label', { className: 'dof-field' }, 'Heartbeat 秒数',
h('input', { type: 'number', min: 10, max: 300, value: form.heartbeatSeconds, onChange: (event) => setForm({ ...form, heartbeatSeconds: event.target.value }) })),
h('label', { className: 'dof-field', 'data-wide': 'true' }, 'Workspace 映射',
h('textarea', { value: form.workspaces, placeholder: 'office-project=/Users/you/projects/ai-office', onChange: (event) => setForm({ ...form, workspaces: event.target.value }) }),
h('small', null, '每行 alias=/本机/绝对路径;Office 只能看到 alias。')),
h('label', { className: 'dof-field', 'data-wide': 'true' }, 'Instruction Preset 映射',
h('textarea', { value: form.instructionPresets, placeholder: 'action-items=转换为负责人、截止和验收明确的工单', onChange: (event) => setForm({ ...form, instructionPresets: event.target.value }) }),
h('small', null, '每行 alias=指令;新增 preset 不需要改 Office 代码。'))),
error ? h('p', { className: 'dof-error', role: 'alert' }, error) : null,
notice ? h('p', { className: 'dof-notice', role: 'status' }, notice) : null,
health.error?.message ? h('p', { className: 'dof-error' }, health.error.message) : null,
h('div', { className: 'dof-actions' },
h(Button, { kind: 'primary', disabled: Boolean(busy), onClick: () => void run('save', () => invoke(OFFICE_RPC_ENDPOINTS.configure, {
baseUrl: form.baseUrl,
deviceId: form.deviceId,
...(form.deviceToken ? { deviceToken: form.deviceToken } : {}),
maxConcurrency: Number(form.maxConcurrency),
heartbeatSeconds: Number(form.heartbeatSeconds),
workspaces: parseMap(form.workspaces, 'Workspace 映射'),
instructionPresets: parseMap(form.instructionPresets, 'Instruction Preset 映射'),
})) }, busy === 'save' ? '保存中…' : '保存并连接'),
h(Button, { disabled: !model.configured || Boolean(busy), onClick: () => void run('test', () => invoke(OFFICE_RPC_ENDPOINTS.test)) }, busy === 'test' ? '测试中…' : '测试连接'),
h(Button, { disabled: !model.configured || Boolean(busy), onClick: () => void run('reconnect', () => invoke(OFFICE_RPC_ENDPOINTS.reconnect)) }, '重新连接'),
h(Button, { kind: 'danger', disabled: !model.configured || Boolean(busy), onClick: () => void run('remove', () => invoke(OFFICE_RPC_ENDPOINTS.remove, { confirm: true })) }, '移除连接'))),
h('div', { className: 'dof-card' },
h('div', { className: 'dof-cardTitle' }, h('h4', null, '协议 Hook 预览'), h('span', null, '由 Base URL 自动派生,不单独填写')),
h('div', { className: 'dof-hooks' },
[['SSE', hooks.stream], ['Heartbeat', hooks.heartbeat], ['Job', hooks.job], ['Result', hooks.result]].map(([label, url]) => h('div', { className: 'dof-hook', key: label }, h('strong', null, label), h('code', null, url ?? 'Base URL 无效'))))),
h('p', { className: 'dof-notice' }, 'Office Hook 尚未部署时,配置会安全保存并自动重试;出现 HTTP 404 代表协议端点待上线,不代表 Harness 故障。'));
}

View file

@ -0,0 +1,50 @@
export const OFFICE_STYLE_ID = 'xmanrui-dsh-im-office-settings';
const CSS = `
.dof-page { --dof-accent: var(--dsw-alias-brand-primary, #3964fe); }
.dof-hero { position: relative; overflow: hidden; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 16px; align-items: center; margin-bottom: 12px; padding: 18px; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 16px; background: linear-gradient(135deg, color-mix(in srgb, var(--dof-accent) 9%, var(--dsw-alias-bg-layer-1, #fff)), var(--dsw-alias-bg-layer-1, #fff) 62%); }
.dof-hero::after { content: ""; position: absolute; width: 150px; height: 150px; right: -75px; top: -90px; border: 24px solid color-mix(in srgb, var(--dof-accent) 12%, transparent); border-radius: 50%; pointer-events: none; }
.dof-heroCopy { min-width: 0; }
.dof-heroCopy h3 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: 1.35; }
.dof-heroCopy p { margin: 6px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.6; }
.dof-status { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 7px; padding: 7px 10px; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 999px; background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; white-space: nowrap; }
.dof-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dsw-alias-state-warn-primary, #d97706); }
.dof-status[data-connected="true"] .dof-dot { 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); }
.dof-card { margin-top: 10px; padding: 16px; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 14px; background: var(--dsw-alias-bg-layer-1, #fff); }
.dof-cardTitle { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; margin-bottom: 12px; }
.dof-cardTitle h4 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; }
.dof-cardTitle span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; }
.dof-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.dof-field { min-width: 0; display: flex; flex-direction: column; gap: 6px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; }
.dof-field[data-wide="true"] { grid-column: 1 / -1; }
.dof-field input, .dof-field textarea { box-sizing: border-box; width: 100%; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 9px; background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #1f2329); font: inherit; font-size: 13px; line-height: 1.5; outline: none; }
.dof-field input { height: 38px; padding: 0 11px; }
.dof-field textarea { min-height: 86px; resize: vertical; padding: 9px 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dof-field input:focus, .dof-field textarea:focus { border-color: var(--dof-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dof-accent) 12%, transparent); }
.dof-field small { color: var(--dsw-alias-label-tertiary, #8f959e); line-height: 1.45; }
.dof-hooks { display: grid; gap: 7px; }
.dof-hook { min-width: 0; display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 10px; align-items: center; padding: 8px 10px; border-radius: 9px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
.dof-hook strong { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; }
.dof-hook code { overflow: hidden; color: var(--dsw-alias-label-primary, #1f2329); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.dof-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.dof-actions .ddt-button[data-kind="primary"] { color: #fff; border-color: var(--dof-accent); background: var(--dof-accent); }
.dof-error, .dof-notice { margin: 10px 0 0; padding: 9px 11px; border-radius: 9px; font-size: 12px; line-height: 1.5; }
.dof-error { color: var(--dsw-alias-state-error-primary, #d54941); background: var(--dsw-alias-state-error-secondary, #fff0ef); }
.dof-notice { color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
.dof-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
.dof-metric { min-width: 0; padding: 9px; border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
.dof-metric span { display: block; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; }
.dof-metric strong { display: block; overflow: hidden; margin-top: 4px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
@container (max-width: 680px) { .dof-grid { grid-template-columns: minmax(0, 1fr); } .dof-field[data-wide="true"] { grid-column: auto; } .dof-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (prefers-reduced-motion: reduce) { .dof-page * { transition: none !important; } }
`;
export function installOfficeStyles() {
if (typeof document === 'undefined') return () => {};
if (document.querySelector(`style[data-plugin-css="${OFFICE_STYLE_ID}"]`)) return () => {};
const style = document.createElement('style');
style.dataset.pluginCss = OFFICE_STYLE_ID;
style.textContent = CSS;
document.head.append(style);
return () => style.remove();
}

View file

@ -8,6 +8,51 @@ const EN = Object.freeze({
'IM机器人设置': 'IM bot settings', 'IM机器人设置': 'IM bot settings',
'IM 渠道': 'IM channels', 'IM 渠道': 'IM channels',
'让 DeepSeek Harness 触手可及': 'DeepSeek Harness, always within reach', '让 DeepSeek Harness 触手可及': 'DeepSeek Harness, always within reach',
'AI Office': 'AI Office',
'AI Office 设置': 'AI Office settings',
'AI Office 设置页缺少 RPC 连接': 'AI Office settings are missing an RPC connection',
'正在读取 AI Office Connector…': 'Loading AI Office Connector…',
'本机主动连接公网 Office;Harness 不开放端口。协议 Hook 固定为 ': 'This machine connects outward to the public Office; Harness exposes no port. Protocol hooks: ',
'尚未配置': 'Not configured',
'已连接 Office': 'Connected to Office',
'已配置': 'Configured',
'等待重连': 'Waiting to reconnect',
'凭据缺失': 'Credential missing',
'最近心跳': 'Last heartbeat',
'最近事件': 'Last event',
'重连次数': 'Reconnects',
'运行 Job': 'Running Jobs',
'完成 Job': 'Completed Jobs',
'尚无': 'None yet',
'设备连接': 'Device connection',
'Token 只写入本机凭据存储': 'Token is written only to the local credential store',
'粘贴 Office 一次性凭据': 'Paste the one-time Office credential',
'已安全保存;留空保持不变': 'Stored securely; leave blank to keep it',
'最大并发': 'Max concurrency',
'Heartbeat 秒数': 'Heartbeat seconds',
'Workspace 映射': 'Workspace mappings',
'每行 alias=/本机/绝对路径;Office 只能看到 alias。': 'One alias=/local/absolute/path per line; Office sees only aliases.',
'Instruction Preset 映射': 'Instruction preset mappings',
'每行 alias=指令;新增 preset 不需要改 Office 代码。': 'One alias=instruction per line; new presets require no Office code change.',
'保存并连接': 'Save and connect',
'测试连接': 'Test connection',
'测试中…': 'Testing…',
'重新连接': 'Reconnect',
'移除连接': 'Remove connection',
'连接测试通过。': 'Connection test passed.',
'配置已保存。': 'Configuration saved.',
'协议 Hook 预览': 'Protocol hook preview',
'由 Base URL 自动派生,不单独填写': 'Derived from Base URL; no separate input',
'Base URL 无效': 'Invalid Base URL',
'Office Hook 尚未部署时,配置会安全保存并自动重试;出现 HTTP 404 代表协议端点待上线,不代表 Harness 故障。': 'Configuration is saved and retried while Office hooks are unavailable; HTTP 404 means the protocol endpoint is pending, not a Harness failure.',
'Workspace 映射每行必须使用 alias=value': 'Each workspace mapping must use alias=value',
'Instruction Preset 映射每行必须使用 alias=value': 'Each instruction preset mapping must use alias=value',
'action-items=转换为负责人、截止和验收明确的工单': 'action-items=Turn this into accountable tasks with deadlines and acceptance criteria',
'AI Office 拒绝了 Device Token。': 'AI Office rejected the Device Token.',
'AI Office Connector Hook 尚未就绪。': 'AI Office Connector hooks are not available yet.',
'AI Office Connector 协议版本不兼容。': 'The AI Office Connector protocol is incompatible.',
'本机暂时无法访问 AI Office。': 'AI Office cannot currently be reached from this machine.',
'AI Office 连接已中断。': 'The AI Office connection was interrupted.',
'帮助与反馈 · 前往 GitHub': 'Help & feedback · Open GitHub', '帮助与反馈 · 前往 GitHub': 'Help & feedback · Open GitHub',
'微信': 'WeChat', '微信': 'WeChat',
'飞书': 'Feishu', '飞书': 'Feishu',

View file

@ -4,6 +4,7 @@ import {
DingtalkLogoGlyph, DingtalkLogoGlyph,
DiscordLogoGlyph, DiscordLogoGlyph,
FeishuLogoGlyph, FeishuLogoGlyph,
OfficeLogoGlyph,
QqLogoGlyph, QqLogoGlyph,
SlackLogoGlyph, SlackLogoGlyph,
TelegramLogoGlyph, TelegramLogoGlyph,
@ -22,6 +23,9 @@ import { installFeishuStyles } from './channels/feishu/styles.js';
import { QQ_RPC_CHANNEL } from './channels/qq/api.js'; import { QQ_RPC_CHANNEL } from './channels/qq/api.js';
import { QqSettingsTab } from './channels/qq/index.js'; import { QqSettingsTab } from './channels/qq/index.js';
import { installQqStyles } from './channels/qq/styles.js'; import { installQqStyles } from './channels/qq/styles.js';
import { OFFICE_RPC_CHANNEL } from './channels/office/api.js';
import { OfficeSettingsTab } from './channels/office/index.js';
import { installOfficeStyles } from './channels/office/styles.js';
import { SLACK_RPC_CHANNEL } from './channels/slack/api.js'; import { SLACK_RPC_CHANNEL } from './channels/slack/api.js';
import { SlackSettingsTab } from './channels/slack/index.js'; import { SlackSettingsTab } from './channels/slack/index.js';
import { installSlackStyles } from './channels/slack/styles.js'; import { installSlackStyles } from './channels/slack/styles.js';
@ -54,6 +58,7 @@ const CHANNELS = Object.freeze([
{ id: 'telegram', label: 'Telegram' }, { id: 'telegram', label: 'Telegram' },
{ id: 'discord', label: 'Discord' }, { id: 'discord', label: 'Discord' },
{ id: 'whatsapp', label: 'WhatsApp' }, { id: 'whatsapp', label: 'WhatsApp' },
{ id: 'office', label: 'AI Office' },
]); ]);
function WeixinLogo() { function WeixinLogo() {
@ -99,6 +104,11 @@ function WhatsappLogo() {
h(WhatsappLogoGlyph)); h(WhatsappLogoGlyph));
} }
function OfficeLogo() {
return h('span', { className: 'dim-logo dim-logoOffice', 'aria-hidden': 'true' },
h(OfficeLogoGlyph));
}
function ChannelLogo({ channel }) { function ChannelLogo({ channel }) {
if (channel === 'weixin') return h(WeixinLogo); if (channel === 'weixin') return h(WeixinLogo);
if (channel === 'feishu') return h(FeishuLogo); if (channel === 'feishu') return h(FeishuLogo);
@ -108,7 +118,8 @@ function ChannelLogo({ channel }) {
if (channel === 'slack') return h(SlackLogo); if (channel === 'slack') return h(SlackLogo);
if (channel === 'telegram') return h(TelegramLogo); if (channel === 'telegram') return h(TelegramLogo);
if (channel === 'discord') return h(DiscordLogo); if (channel === 'discord') return h(DiscordLogo);
return h(WhatsappLogo); if (channel === 'whatsapp') return h(WhatsappLogo);
return h(OfficeLogo);
} }
export function IMSettingsTab({ export function IMSettingsTab({
@ -121,6 +132,7 @@ export function IMSettingsTab({
wecomRpcCall, wecomRpcCall,
weixinRpcCall, weixinRpcCall,
whatsappRpcCall, whatsappRpcCall,
officeRpcCall,
workspaceDirectoryPicker, workspaceDirectoryPicker,
}) { }) {
const [selected, setSelected] = React.useState('weixin'); const [selected, setSelected] = React.useState('weixin');
@ -187,7 +199,9 @@ export function IMSettingsTab({
? h(TelegramSettingsTab, { rpcCall: telegramRpcCall }) ? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
: active.id === 'discord' : active.id === 'discord'
? h(DiscordSettingsTab, { rpcCall: discordRpcCall }) ? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
: h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })), : active.id === 'whatsapp'
? h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })
: h(OfficeSettingsTab, { rpcCall: officeRpcCall })),
), ),
)); ));
} }
@ -210,6 +224,7 @@ export function apply(ctx) {
installTelegramStyles(), installTelegramStyles(),
installDiscordStyles(), installDiscordStyles(),
installWhatsappStyles(), installWhatsappStyles(),
installOfficeStyles(),
installImStyles(), installImStyles(),
]; ];
return () => { return () => {
@ -235,6 +250,8 @@ export function apply(ctx) {
ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal); ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal);
const slackRpcCall = (endpoint, payload, signal) => const slackRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal); ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal);
const officeRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(OFFICE_RPC_CHANNEL, endpoint, payload, signal);
const workspaceDirectoryPicker = Object.freeze({ const workspaceDirectoryPicker = Object.freeze({
listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal), listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
pickDirectory: () => ctx.workspaces.pickDirectory(), pickDirectory: () => ctx.workspaces.pickDirectory(),
@ -256,6 +273,7 @@ export function apply(ctx) {
wecomRpcCall, wecomRpcCall,
weixinRpcCall, weixinRpcCall,
whatsappRpcCall, whatsappRpcCall,
officeRpcCall,
workspaceDirectoryPicker, workspaceDirectoryPicker,
}), }),
}, IMSettingsTab)); }, IMSettingsTab));

View file

@ -44,6 +44,8 @@ const CSS = String.raw`
.dim-logoWecom svg { width: 22px; height: 22px; } .dim-logoWecom svg { width: 22px; height: 22px; }
.dim-logoTelegram { color: white; background: #229ed9; } .dim-logoTelegram { color: white; background: #229ed9; }
.dim-logoTelegram svg { width: 21px; height: 21px; } .dim-logoTelegram svg { width: 21px; height: 21px; }
.dim-logoOffice { color: white; background: linear-gradient(145deg, #12213f, #3964fe); }
.dim-logoOffice svg { width: 23px; height: 23px; }
.dim-logoDiscord { color: white; background: #5865f2; } .dim-logoDiscord { color: white; background: #5865f2; }
.dim-logoDiscord svg { width: 21px; height: 21px; } .dim-logoDiscord svg { width: 21px; height: 21px; }
.dim-logoSlack { color: white; background: #4a154b; } .dim-logoSlack { color: white; background: #4a154b; }

View file

@ -0,0 +1,10 @@
import { createProductionController } from './production.mjs';
import { installOfficeRpc } from './rpc.mjs';
export async function apply(ctx, config = {}) {
if (config.controller) return installOfficeRpc(ctx, config.controller, config.rpcAuthority);
const production = await createProductionController(ctx, config, config.internals ?? {});
const dispose = installOfficeRpc(ctx, production.controller, config.rpcAuthority);
ctx.effect(() => async () => production.close(), 'dsh-im: close AI Office connector');
return dispose;
}

View file

@ -0,0 +1,45 @@
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { OfficeConfigStore } from '../../../../src/channels/office/config-store.mjs';
import { OfficeController } from '../../../../src/channels/office/office-controller.mjs';
import { OfficeRuntime } from '../../../../src/channels/office/office-runtime.mjs';
import { HarnessClient } from '../../../../src/channels/shared/harness-client.mjs';
import { harnessOrigin } from '../shared/production.mjs';
export function officePaths(config = {}) {
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-office'));
return { root, config: resolve(config.configPath ?? join(root, 'config.json')) };
}
export async function createProductionController(ctx, config = {}, internals = {}) {
const Store = internals.ConfigStore ?? OfficeConfigStore;
const Controller = internals.Controller ?? OfficeController;
const Runtime = internals.Runtime ?? OfficeRuntime;
const ResolvedHarness = internals.HarnessClient ?? HarnessClient;
const paths = officePaths(config);
const configStore = await new Store(paths.config).load();
const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:office') : (ctx.logger ?? console);
const harnessBaseUrl = harnessOrigin(ctx.webServer, config.harnessBaseUrl);
const createHarness = internals.createHarness ?? (({ workspace }) => new ResolvedHarness({
baseUrl: harnessBaseUrl,
workspace,
autostart: false,
dshBin: config.dshBin ?? 'dsh',
rpcIdPrefix: 'office',
logPrefix: 'dsh-im:office',
}));
const controller = new Controller({
credentials: ctx.credentials,
configStore,
logger,
createRuntime: (options) => new Runtime({
...options,
createHarness,
...(internals.transport ? { transport: internals.transport } : {}),
}),
});
await controller.initialize();
return { controller, close: () => controller.close(), paths };
}

View file

@ -0,0 +1,48 @@
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { OFFICE_RPC_CHANNEL, OFFICE_RPC_ENDPOINTS } from '../../../../src/channels/office/protocol.mjs';
function record(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
function exact(value, keys) { return record(value) && Object.keys(value).every((key) => keys.includes(key)); }
function validConfigure(payload) {
return exact(payload, [
'baseUrl', 'deviceId', 'deviceToken', 'maxConcurrency', 'heartbeatSeconds',
'workspaces', 'instructionPresets',
]) && typeof payload.baseUrl === 'string' && typeof payload.deviceId === 'string'
&& (payload.deviceToken === undefined || typeof payload.deviceToken === 'string')
&& record(payload.workspaces) && record(payload.instructionPresets);
}
export function createOfficeRpcHandler(controller) {
for (const method of ['status', 'configure', 'reconnect', 'test', 'remove']) {
if (typeof controller?.[method] !== 'function') throw new TypeError(`AI Office controller requires ${method}()`);
}
return async (endpoint, payload, signal) => {
if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
try {
let value;
if (endpoint === OFFICE_RPC_ENDPOINTS.status && exact(payload, [])) value = await controller.status();
else if (endpoint === OFFICE_RPC_ENDPOINTS.configure && validConfigure(payload)) value = await controller.configure(payload);
else if (endpoint === OFFICE_RPC_ENDPOINTS.reconnect && exact(payload, [])) value = await controller.reconnect();
else if (endpoint === OFFICE_RPC_ENDPOINTS.test && exact(payload, [])) value = await controller.test();
else if (endpoint === OFFICE_RPC_ENDPOINTS.remove && exact(payload, ['confirm']) && payload.confirm === true) value = await controller.remove();
else return { ok: false, error: { code: 'bad-request', message: 'Invalid AI Office connector request.' } };
return { ok: true, value };
} catch (error) {
const code = error?.code === 'invalid-device-token' ? 'invalid-device-token'
: error?.code === 'office-hook-unavailable' ? 'office-hook-unavailable' : 'office-operation-failed';
const message = code === 'invalid-device-token' ? 'AI Office Device Token 无效。'
: code === 'office-hook-unavailable' ? 'AI Office Hook 尚未上线或地址不正确。'
: error instanceof TypeError ? error.message : 'AI Office 连接操作失败,请稍后重试。';
return { ok: false, error: { code, message } };
}
};
}
export function installOfficeRpc(ctx, controller, authority) {
return ctx.connection.rpc.handle(
OFFICE_RPC_CHANNEL,
createOfficeRpcHandler(controller),
{ authority: resolveRpcAuthority(authority) },
);
}

View file

@ -32,7 +32,9 @@ export function pluginPaths(config, channel) {
} }
export async function createTokenProductionController(ctx, config, internals, definitions) { export async function createTokenProductionController(ctx, config, internals, definitions) {
const { channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime } = definitions; const {
channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime, runtimeOptions,
} = definitions;
if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`); if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`);
if (!ctx?.webServer) throw new TypeError(`dsh-im ${channel} requires ctx.webServer`); if (!ctx?.webServer) throw new TypeError(`dsh-im ${channel} requires ctx.webServer`);
@ -41,6 +43,11 @@ export async function createTokenProductionController(ctx, config, internals, de
const ResolvedHarness = internals.HarnessClient ?? HarnessClient; const ResolvedHarness = internals.HarnessClient ?? HarnessClient;
const ResolvedController = internals.Controller ?? Controller; const ResolvedController = internals.Controller ?? Controller;
const ResolvedRuntime = internals.Runtime ?? Runtime; const ResolvedRuntime = internals.Runtime ?? Runtime;
const channelRuntimeOptions = typeof runtimeOptions === 'function' ? runtimeOptions(config) : {};
if (!channelRuntimeOptions || typeof channelRuntimeOptions !== 'object'
|| Array.isArray(channelRuntimeOptions)) {
throw new TypeError(`dsh-im ${channel} runtimeOptions must return an object`);
}
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor; const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
const logger = typeof ctx.logger === 'function' const logger = typeof ctx.logger === 'function'
? ctx.logger(`dsh-im:${channel}`) : (ctx.logger ?? console); ? ctx.logger(`dsh-im:${channel}`) : (ctx.logger ?? console);
@ -91,6 +98,7 @@ export async function createTokenProductionController(ctx, config, internals, de
await workspaces.ensure(botId); await workspaces.ensure(botId);
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state }); const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new ResolvedRuntime({ return new ResolvedRuntime({
...channelRuntimeOptions,
config: botConfig, config: botConfig,
token, token,
harness: workspaceScope.harness, harness: workspaceScope.harness,

View file

@ -5,6 +5,24 @@ import { TelegramRuntime } from '../../../../src/channels/telegram/telegram-runt
import { TelegramStateStore } from '../../../../src/channels/telegram/state-store.mjs'; import { TelegramStateStore } from '../../../../src/channels/telegram/state-store.mjs';
import { createTokenProductionController } from '../shared/production.mjs'; import { createTokenProductionController } from '../shared/production.mjs';
const TELEGRAM_USER_ID = /^[1-9]\d{0,15}$/;
export function normalizeTelegramAllowedUsers(value) {
if (value === undefined) return Object.freeze([]);
if (!Array.isArray(value)) {
throw new TypeError('telegram.allowedUsers must be an array of numeric Telegram User IDs');
}
const normalized = value.map((entry) => {
const userId = typeof entry === 'number' && Number.isSafeInteger(entry)
? String(entry) : typeof entry === 'string' ? entry.trim() : '';
if (!TELEGRAM_USER_ID.test(userId)) {
throw new TypeError('telegram.allowedUsers contains an invalid Telegram User ID');
}
return userId;
});
return Object.freeze([...new Set(normalized)]);
}
export function createProductionController(ctx, config = {}, internals = {}) { export function createProductionController(ctx, config = {}, internals = {}) {
return createTokenProductionController(ctx, config, internals, { return createTokenProductionController(ctx, config, internals, {
channel: 'telegram', channel: 'telegram',
@ -13,5 +31,8 @@ export function createProductionController(ctx, config = {}, internals = {}) {
HarnessClient: TelegramHarnessClient, HarnessClient: TelegramHarnessClient,
Controller: TelegramController, Controller: TelegramController,
Runtime: TelegramRuntime, Runtime: TelegramRuntime,
runtimeOptions: (runtimeConfig) => ({
allowedPrivateUserIds: normalizeTelegramAllowedUsers(runtimeConfig.allowedUsers),
}),
}); });
} }

View file

@ -1,5 +1,6 @@
import { apply as applyDingtalk } from './channels/dingtalk/index.mjs'; import { apply as applyDingtalk } from './channels/dingtalk/index.mjs';
import { apply as applyDiscord } from './channels/discord/index.mjs'; import { apply as applyDiscord } from './channels/discord/index.mjs';
import { apply as applyOffice } from './channels/office/index.mjs';
import { apply as applyFeishu } from './channels/feishu/index.mjs'; import { apply as applyFeishu } from './channels/feishu/index.mjs';
import { apply as applyQq } from './channels/qq/index.mjs'; import { apply as applyQq } from './channels/qq/index.mjs';
import { apply as applySlack } from './channels/slack/index.mjs'; import { apply as applySlack } from './channels/slack/index.mjs';
@ -27,6 +28,7 @@ export function createImHostPlugin(internals = {}) {
const startSlack = internals.applySlack ?? applySlack; const startSlack = internals.applySlack ?? applySlack;
const startTelegram = internals.applyTelegram ?? applyTelegram; const startTelegram = internals.applyTelegram ?? applyTelegram;
const startDiscord = internals.applyDiscord ?? applyDiscord; const startDiscord = internals.applyDiscord ?? applyDiscord;
const startOffice = internals.applyOffice ?? applyOffice;
const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp; const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp;
return Object.freeze({ return Object.freeze({
name, name,
@ -41,6 +43,7 @@ export function createImHostPlugin(internals = {}) {
await startTelegram(ctx, channelConfig(config, 'telegram')); await startTelegram(ctx, channelConfig(config, 'telegram'));
await startDiscord(ctx, channelConfig(config, 'discord')); await startDiscord(ctx, channelConfig(config, 'discord'));
await startWhatsapp(ctx, channelConfig(config, 'whatsapp')); await startWhatsapp(ctx, channelConfig(config, 'whatsapp'));
await startOffice(ctx, channelConfig(config, 'office'));
}, },
}); });
} }

View file

@ -0,0 +1,107 @@
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute } from 'node:path';
import { normalizeOfficeBaseUrl, OFFICE_PROTOCOL_VERSION } from './protocol.mjs';
const ALIAS = /^[a-z][a-z0-9-]{1,63}$/;
const DEVICE_ID = /^[a-z0-9][a-z0-9_-]{2,63}$/i;
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function normalizeMap(value, { kind }) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const output = {};
for (const [rawId, rawValue] of Object.entries(value)) {
const id = cleanString(rawId);
const item = cleanString(rawValue);
if (!id || !ALIAS.test(id) || !item) return null;
if (kind === 'workspace' && !isAbsolute(item)) return null;
if (kind === 'preset' && item.length > 8_000) return null;
output[id] = item;
}
return Object.freeze(output);
}
export function normalizeOfficeConfig(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
let baseUrl;
try {
baseUrl = normalizeOfficeBaseUrl(value.baseUrl).origin;
} catch {
return null;
}
const deviceId = cleanString(value.deviceId);
const deviceTokenRef = cleanString(value.deviceTokenRef);
const workspaces = normalizeMap(value.workspaces ?? {}, { kind: 'workspace' });
const instructionPresets = normalizeMap(value.instructionPresets ?? {}, { kind: 'preset' });
const maxConcurrency = Number(value.maxConcurrency ?? 1);
const heartbeatSeconds = Number(value.heartbeatSeconds ?? 30);
if (!deviceId || !DEVICE_ID.test(deviceId) || !deviceTokenRef
|| !/^DSH_OFFICE_DEVICE_TOKEN_[A-F0-9]{24}$/.test(deviceTokenRef)
|| !workspaces || !instructionPresets
|| !Number.isInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4
|| !Number.isInteger(heartbeatSeconds) || heartbeatSeconds < 10 || heartbeatSeconds > 300) {
return null;
}
return Object.freeze({
version: 1,
protocolVersion: OFFICE_PROTOCOL_VERSION,
baseUrl,
deviceId,
deviceTokenRef,
maxConcurrency,
heartbeatSeconds,
workspaces,
instructionPresets,
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
updatedAt: cleanString(value.updatedAt) ?? new Date().toISOString(),
});
}
export class OfficeConfigStore {
#path;
#value = null;
#queue = Promise.resolve();
constructor(path) { this.#path = path; }
async load() {
try {
const normalized = normalizeOfficeConfig(JSON.parse(await readFile(this.#path, 'utf8')));
if (!normalized) throw new Error('dsh-im AI Office config is invalid');
this.#value = normalized;
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
this.#value = null;
}
return this;
}
get() { return this.#value ? structuredClone(this.#value) : null; }
async save(value) {
const normalized = normalizeOfficeConfig(value);
if (!normalized) throw new Error('Refusing to persist invalid AI Office configuration');
const operation = this.#queue.then(async () => {
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
const temporary = `${this.#path}.tmp`;
await writeFile(temporary, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
await rename(temporary, this.#path);
this.#value = normalized;
});
this.#queue = operation.then(() => undefined, () => undefined);
await operation;
return this.get();
}
async clear() {
const operation = this.#queue.then(async () => {
try { await unlink(this.#path); } catch (error) { if (error?.code !== 'ENOENT') throw error; }
this.#value = null;
});
this.#queue = operation.then(() => undefined, () => undefined);
await operation;
}
}

View file

@ -0,0 +1,167 @@
import { createHash } from 'node:crypto';
import { normalizeOfficeBaseUrl, officeHookUrls } from './protocol.mjs';
import { normalizeOfficeConfig } from './config-store.mjs';
import { OfficeRuntime } from './office-runtime.mjs';
function clean(value) { return typeof value === 'string' && value.trim() ? value.trim() : null; }
export function officeTokenRef(baseUrl, deviceId) {
const digest = createHash('sha256').update(`${baseUrl}\n${deviceId}`).digest('hex').slice(0, 24).toUpperCase();
return `DSH_OFFICE_DEVICE_TOKEN_${digest}`;
}
export class OfficeController {
#credentials;
#store;
#logger;
#createRuntime;
#runtime = null;
#transition = Promise.resolve();
constructor({ credentials, configStore, logger = console, createRuntime }) {
if (!credentials?.resolve || !credentials?.set || !credentials?.unset) {
throw new TypeError('AI Office requires the Harness credential provider');
}
if (!configStore?.get || !configStore?.save || !configStore?.clear) {
throw new TypeError('AI Office requires a config store');
}
this.#credentials = credentials;
this.#store = configStore;
this.#logger = logger;
this.#createRuntime = createRuntime ?? ((options) => new OfficeRuntime(options));
}
async initialize() {
const config = this.#store.get();
if (config) {
const credential = await this.#credentials.resolve(config.deviceTokenRef).catch(() => undefined);
if (credential?.value) await this.#start(config, credential.value);
}
return this.status();
}
async configure(input = {}) {
return this.#serial(async () => {
const previous = this.#store.get();
const requestedBaseUrl = clean(input.baseUrl);
const deviceId = clean(input.deviceId);
if (!requestedBaseUrl || !deviceId) throw new TypeError('Office URL and Device ID are required');
const baseUrl = normalizeOfficeBaseUrl(requestedBaseUrl).origin;
const tokenRef = officeTokenRef(baseUrl, deviceId);
const suppliedToken = clean(input.deviceToken);
const priorCredential = await this.#credentials.resolve(tokenRef).catch(() => undefined);
const token = suppliedToken ?? priorCredential?.value;
if (!token || token.length < 32) throw new TypeError('Device Token must contain at least 32 characters');
const now = new Date().toISOString();
const config = normalizeOfficeConfig({
version: 1,
baseUrl,
deviceId,
deviceTokenRef: tokenRef,
maxConcurrency: input.maxConcurrency,
heartbeatSeconds: input.heartbeatSeconds,
workspaces: input.workspaces,
instructionPresets: input.instructionPresets,
createdAt: previous?.createdAt ?? now,
updatedAt: now,
});
if (!config) throw new TypeError('AI Office connector configuration is invalid');
await this.#credentials.set(tokenRef, token);
try {
await this.#store.save(config);
} catch (error) {
if (priorCredential?.value) await this.#credentials.set(tokenRef, priorCredential.value).catch(() => undefined);
else await this.#credentials.unset(tokenRef).catch(() => undefined);
throw error;
}
if (previous?.deviceTokenRef && previous.deviceTokenRef !== tokenRef) {
await this.#credentials.unset(previous.deviceTokenRef).catch(() => undefined);
}
await this.#start(config, token);
return this.status();
});
}
async reconnect() {
return this.#serial(async () => {
const config = this.#store.get();
if (!config) throw new Error('AI Office connector is not configured');
await this.#start(config);
return this.status();
});
}
async test() {
const config = this.#store.get();
if (!config) throw new Error('AI Office connector is not configured');
const token = await this.#resolveToken(config);
const runtime = this.#createRuntime({ config, token, logger: this.#logger });
try { await runtime.testConnection(AbortSignal.timeout(10_000)); } finally { await runtime.stop().catch(() => undefined); }
return { tested: true, snapshot: await this.status() };
}
async remove() {
return this.#serial(async () => {
const config = this.#store.get();
await this.#stop();
if (config?.deviceTokenRef) await this.#credentials.unset(config.deviceTokenRef).catch(() => undefined);
await this.#store.clear();
return this.status();
});
}
async status() {
const config = this.#store.get();
if (!config) return { schemaVersion: 1, configured: false, connected: false, state: 'unconfigured' };
const credential = await this.#credentials.resolve(config.deviceTokenRef).catch(() => undefined);
const runtime = this.#runtime?.status ?? null;
return {
schemaVersion: 1,
configured: true,
connected: runtime?.connected === true,
state: runtime?.state ?? (credential?.value ? 'idle' : 'missing-token'),
config: {
protocolVersion: config.protocolVersion,
baseUrl: config.baseUrl,
deviceId: config.deviceId,
maxConcurrency: config.maxConcurrency,
heartbeatSeconds: config.heartbeatSeconds,
workspaces: config.workspaces,
instructionPresets: config.instructionPresets,
hooks: officeHookUrls(config.baseUrl),
},
health: runtime,
tokenConfigured: Boolean(credential?.value),
};
}
async close() { await this.#transition.catch(() => undefined); await this.#stop(); }
async #resolveToken(config) {
const credential = await this.#credentials.resolve(config.deviceTokenRef).catch(() => undefined);
if (!credential?.value) throw new Error('AI Office Device Token is missing');
return credential.value;
}
async #start(config, knownToken) {
await this.#stop();
const token = knownToken ?? await this.#resolveToken(config);
const runtime = this.#createRuntime({ config, token, logger: this.#logger });
this.#runtime = runtime;
runtime.start();
}
async #stop() {
const runtime = this.#runtime;
this.#runtime = null;
if (runtime) await runtime.stop();
}
#serial(operation) {
const run = this.#transition.then(operation, operation);
this.#transition = run.then(() => undefined, () => undefined);
return run;
}
}

View file

@ -0,0 +1,374 @@
import { setTimeout as sleep } from 'node:timers/promises';
import { harnessApprovalText } from '../shared/harness-approval.mjs';
import {
harnessAnswerForQuestion,
harnessQuestionText,
validHarnessQuestion,
} from '../shared/harness-question.mjs';
const JOB_ID = /^job-[a-f0-9]{32}$/;
const RENEW_MS = 30_000;
const COMPLETED_LIMIT = 2_048;
function abortError() {
return new DOMException('Office Job was cancelled', 'AbortError');
}
function clean(value, max = 8_000) {
return typeof value === 'string' ? value.trim().slice(0, max) : '';
}
function safeFailure(error) {
if (error?.code === 'office-job-alias-invalid') return error.message;
if (error?.code === 'turn-stopped') return '本机 Harness 已停止这次执行。';
if (error?.code === 'office-job-conflict') return 'Office Job 已被领取、取消或结束。';
return '本机 Harness 未能完成任务;请检查 Harness 会话后重试。';
}
function renderPrompt(job, preset) {
return [
'# AI Office Handoff',
'',
'你正在本机 DeepSeek Harness 中继续一个来自 AI Office 的任务。',
'只在当前 Workspace 内行动。完成后必须返回:结果摘要、改动文件、验证证据、未解决风险。',
'',
'## 本机 Instruction Preset',
preset,
'',
...(clean(job.instruction) ? ['## 本轮补充指令', clean(job.instruction), ''] : []),
'## Office 时间线',
clean(job.markdown, 200_000),
].join('\n');
}
function approvalRequest(interaction) {
const id = clean(interaction?.interactionId || interaction?.rpcId, 180);
if (!id || typeof interaction?.respond !== 'function') return null;
if (interaction.kind === 'approval') {
const prompt = harnessApprovalText(interaction.payload, {
toolCall: interaction.toolCall,
maxArgumentsLength: 10_000,
});
if (!prompt) return null;
return {
id,
kind: 'approval',
title: `批准 ${clean(interaction.payload?.toolName, 120) || 'Harness 工具'}`,
prompt,
toolName: clean(interaction.payload?.toolName, 120),
};
}
if (interaction.kind !== 'question') return null;
const questions = interaction.payload?.questions;
if (!Array.isArray(questions) || questions.length === 0
|| questions.some((question) => !validHarnessQuestion(question))) return null;
return {
id,
kind: 'question',
title: questions.length > 1 ? `Harness 需要 ${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'),
questions,
};
}
function waitForReply(entry, approvalId) {
return new Promise((resolve, reject) => {
if (entry.controller.signal.aborted) return reject(entry.controller.signal.reason ?? abortError());
const onAbort = () => {
entry.approvals.delete(approvalId);
reject(entry.controller.signal.reason ?? abortError());
};
entry.controller.signal.addEventListener('abort', onAbort, { once: true });
entry.approvals.set(approvalId, {
resolve(value) {
entry.controller.signal.removeEventListener('abort', onAbort);
entry.approvals.delete(approvalId);
resolve(value);
},
});
});
}
export class OfficeJobExecutor {
#config;
#transport;
#createHarness;
#logger;
#active = new Map();
#queued = new Set();
#completed = new Set();
#closed = false;
#status = {
running: 0,
completed: 0,
failed: 0,
lastJobId: null,
lastJobAt: null,
};
constructor({ config, transport, createHarness, logger = console }) {
if (!config || !transport || typeof createHarness !== 'function') {
throw new TypeError('OfficeJobExecutor requires config, transport, and createHarness');
}
this.#config = config;
this.#transport = transport;
this.#createHarness = createHarness;
this.#logger = logger;
}
get status() { return structuredClone(this.#status); }
offer(jobId) {
if (this.#closed || !JOB_ID.test(jobId) || this.#active.has(jobId)
|| this.#queued.has(jobId) || this.#completed.has(jobId)) return false;
this.#queued.add(jobId);
this.#drain();
return true;
}
handleEvent(event) {
const jobId = clean(event?.data?.jobId, 80);
if (!JOB_ID.test(jobId)) return false;
if (event.type === 'job.available') return this.offer(jobId);
if (event.type === 'job.cancel') return this.cancel(jobId);
if (event.type === 'approval.reply') {
const entry = this.#active.get(jobId);
const approvalId = clean(event.data?.approvalId, 180);
const pending = entry?.approvals.get(approvalId);
if (!pending) return false;
pending.resolve({
decision: event.data?.decision === 'approved' ? 'approved' : 'rejected',
answer: clean(event.data?.answer),
});
return true;
}
return false;
}
cancel(jobId) {
this.#queued.delete(jobId);
const entry = this.#active.get(jobId);
if (!entry) return false;
entry.cancelled = true;
entry.controller.abort(abortError());
if (entry.sessionId && entry.harness) {
void entry.harness.rpc('session.cancel', {
sessionId: entry.sessionId,
keepInbox: true,
}, 10_000).catch(() => undefined);
}
return true;
}
async close() {
this.#closed = true;
this.#queued.clear();
for (const entry of this.#active.values()) entry.controller.abort(abortError());
await Promise.allSettled([...this.#active.values()].map((entry) => entry.task));
}
#drain() {
while (!this.#closed && this.#active.size < this.#config.maxConcurrency && this.#queued.size > 0) {
const jobId = this.#queued.values().next().value;
this.#queued.delete(jobId);
const entry = {
controller: new AbortController(),
approvals: new Map(),
harness: null,
sessionId: null,
leaseToken: null,
cancelled: false,
lastProgressAt: 0,
lastProgress: '',
task: null,
};
entry.task = this.#run(jobId, entry).finally(() => {
this.#active.delete(jobId);
this.#status.running = this.#active.size;
this.#drain();
});
this.#active.set(jobId, entry);
this.#status.running = this.#active.size;
void entry.task.catch((error) => this.#logger.warn?.('[dsh-im:office] Job task ended:', error.message));
}
}
async #run(jobId, entry) {
const signal = entry.controller.signal;
let renewTask = null;
try {
const fetched = await this.#transport.getJob(jobId, { signal });
const job = fetched?.job;
if (!job || job.id !== jobId) throw new Error('Office returned an invalid Job payload');
const accepted = await this.#transport.acceptJob(jobId, { signal });
if (!clean(accepted?.leaseToken, 200)) throw new Error('Office returned an invalid Job lease');
entry.leaseToken = accepted.leaseToken;
renewTask = this.#renew(jobId, entry);
const workspace = this.#config.workspaces[job.workspaceAlias];
const preset = this.#config.instructionPresets[job.instructionPreset];
if (!workspace || !preset) {
const error = new Error('Office Job 引用了本机未配置的 Workspace/Preset alias。');
error.code = 'office-job-alias-invalid';
throw error;
}
entry.harness = this.#createHarness({ workspace });
await this.#progress(jobId, entry, { kind: 'status', message: `已领取 Job,准备 Workspace alias:${job.workspaceAlias}` }, true);
entry.sessionId = await entry.harness.createSession({ signal, workspace });
await this.#progress(jobId, entry, { kind: 'status', message: 'Harness Session 已创建。', sessionId: entry.sessionId }, true);
const answer = await entry.harness.ask(entry.sessionId, renderPrompt(job, preset), {
timeoutMs: 30 * 60_000,
signal,
onUpdate: (update) => this.#handleUpdate(jobId, entry, update),
onInteraction: (interaction) => this.#handleInteraction(jobId, entry, interaction),
onInteractionResolved: (resolution) => {
const id = clean(resolution?.interactionId, 180);
entry.approvals.get(id)?.resolve({ decision: 'rejected', answer: '' });
},
});
if (signal.aborted) throw signal.reason ?? abortError();
await this.#transport.completeJob(jobId, entry.leaseToken, {
resultMarkdown: answer,
sessionId: entry.sessionId,
}, { signal });
this.#status.completed += 1;
this.#rememberCompleted(jobId);
} catch (error) {
if (!entry.cancelled && entry.leaseToken) {
await this.#transport.failJob(jobId, entry.leaseToken, {
error: safeFailure(error),
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
}).catch(() => undefined);
this.#status.failed += 1;
}
if (error?.code === 'office-job-conflict' || error?.code === 'office-hook-unavailable') {
this.#rememberCompleted(jobId);
return;
}
if (!entry.cancelled && !signal.aborted) this.#logger.warn?.(`[dsh-im:office] Job ${jobId} failed:`, error.message);
} finally {
entry.controller.abort(abortError());
await renewTask?.catch(() => undefined);
this.#status.lastJobId = jobId;
this.#status.lastJobAt = new Date().toISOString();
}
}
async #renew(jobId, entry) {
while (!entry.controller.signal.aborted) {
try { await sleep(RENEW_MS, undefined, { signal: entry.controller.signal }); }
catch { return; }
try {
await this.#transport.renewJob(jobId, entry.leaseToken, { signal: entry.controller.signal });
const snapshot = await this.#transport.getJob(jobId, { signal: entry.controller.signal });
const approval = snapshot?.job?.approval;
if (approval && (approval.status === 'approved' || approval.status === 'rejected')) {
entry.approvals.get(approval.id)?.resolve({
decision: approval.status,
answer: clean(approval.answer),
});
}
} catch (error) {
if (entry.controller.signal.aborted) return;
entry.cancelled = true;
entry.controller.abort(error);
if (entry.sessionId && entry.harness) {
void entry.harness.rpc('session.cancel', {
sessionId: entry.sessionId,
keepInbox: true,
}, 10_000).catch(() => undefined);
}
return;
}
}
}
async #handleUpdate(jobId, entry, update) {
const message = update?.type === 'tool' ? `正在使用 ${clean(update.name, 160) || 'Harness 工具'}…`
: clean(update?.text, 4_000);
if (!message) return;
const key = `${update.type}:${message}`;
if (key === entry.lastProgress) return;
const now = Date.now();
if (update.type === 'text' && now - entry.lastProgressAt < 1_000) return;
entry.lastProgress = key;
entry.lastProgressAt = now;
await this.#progress(jobId, entry, {
kind: update.type === 'tool' ? 'tool' : update.type === 'text' ? 'text' : 'status',
message,
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
});
}
#progress(jobId, entry, value, required = false) {
const request = this.#transport.progressJob(jobId, entry.leaseToken, value, {
signal: entry.controller.signal,
});
return required ? request : request.catch((error) => {
this.#logger.debug?.('[dsh-im:office] ignored a progress delivery failure:', error.message);
});
}
async #handleInteraction(jobId, entry, interaction) {
const request = approvalRequest(interaction);
if (!request) {
if (interaction?.kind === 'approval' && typeof interaction.respond === 'function') {
await interaction.respond({
ok: true,
value: {
sessionId: interaction.sessionId,
approvalId: interaction.payload?.approvalId,
outcome: 'rejected',
},
});
}
return;
}
const reply = waitForReply(entry, request.id);
try {
await this.#transport.requestApproval(jobId, entry.leaseToken, request, {
signal: entry.controller.signal,
});
const decision = await reply;
if (request.kind === 'approval') {
await interaction.respond({
ok: true,
value: {
sessionId: interaction.sessionId,
approvalId: interaction.payload.approvalId,
outcome: decision.decision === 'approved' ? 'allowed-once' : 'rejected',
},
});
return;
}
if (decision.decision !== 'approved') {
await interaction.respond({
ok: false,
error: { code: 'cancelled', message: 'The AI Office user rejected this question.', details: {} },
});
return;
}
const answers = clean(decision.answer).split(/\r?\n/);
await interaction.respond({
ok: true,
value: {
sessionId: interaction.sessionId,
answer: {
answers: request.questions.map((question, index) => (
harnessAnswerForQuestion(question, answers[index] || answers[0] || '')
)),
},
},
});
} catch (error) {
entry.approvals.delete(request.id);
throw error;
}
}
#rememberCompleted(jobId) {
this.#completed.add(jobId);
if (this.#completed.size <= COMPLETED_LIMIT) return;
this.#completed.delete(this.#completed.values().next().value);
}
}

View file

@ -0,0 +1,156 @@
import { setTimeout as sleep } from 'node:timers/promises';
import { OfficeTransport } from './office-transport.mjs';
import { OFFICE_PROTOCOL_VERSION } from './protocol.mjs';
import { OfficeJobExecutor } from './office-job-executor.mjs';
const RETRY_DELAYS = Object.freeze([1_000, 3_000, 10_000, 30_000]);
function safeConnectionError(error) {
const code = typeof error?.code === 'string' ? error.code : 'office-connection-failed';
const messages = {
'invalid-device-token': 'AI Office 拒绝了 Device Token。',
'office-hook-unavailable': 'AI Office Connector Hook 尚未就绪。',
'office-protocol-mismatch': 'AI Office Connector 协议版本不兼容。',
'office-transport-failed': '本机暂时无法访问 AI Office。',
};
return { code, message: messages[code] ?? 'AI Office 连接已中断。' };
}
export class OfficeRuntime {
#config;
#token;
#logger;
#transport;
#controller = null;
#task = null;
#status;
#jobs;
constructor({ config, token, logger = console, transport, createHarness, jobExecutor }) {
this.#config = config;
this.#token = token;
this.#logger = logger;
this.#transport = transport ?? new OfficeTransport({
baseUrl: config.baseUrl, deviceId: config.deviceId, token,
});
this.#status = {
state: 'idle', connected: false, startedAt: null, lastHeartbeatAt: null,
lastEventAt: null, lastEventId: null, lastEventType: null, reconnects: 0,
jobsOffered: 0, error: null,
};
this.#jobs = jobExecutor ?? (createHarness ? new OfficeJobExecutor({
config,
transport: this.#transport,
createHarness,
logger,
}) : null);
}
get status() {
return structuredClone({
...this.#status,
...(this.#jobs ? { jobs: this.#jobs.status } : {}),
});
}
capabilities() {
return {
protocolVersion: OFFICE_PROTOCOL_VERSION,
deviceId: this.#config.deviceId,
workspaces: Object.keys(this.#config.workspaces),
instructionPresets: Object.keys(this.#config.instructionPresets),
maxConcurrency: this.#config.maxConcurrency,
};
}
async testConnection(signal) {
await this.#transport.heartbeat({ ...this.capabilities(), probe: true }, { signal });
return { ok: true };
}
start() {
if (this.#task) return this.status;
this.#controller = new AbortController();
this.#status.startedAt = new Date().toISOString();
this.#status.state = 'connecting';
this.#task = this.#run(this.#controller.signal).finally(() => { this.#task = null; });
this.#task.catch((error) => {
if (this.#controller?.signal.aborted) return;
this.#logger.error?.('[dsh-im:office] connector stopped:', error);
});
return this.status;
}
async #run(signal) {
let attempt = 0;
while (!signal.aborted) {
const attemptController = new AbortController();
const attemptSignal = AbortSignal.any([signal, attemptController.signal]);
try {
const heartbeat = await this.#transport.heartbeat(this.capabilities(), { signal: attemptSignal });
this.#offerJobs(heartbeat?.jobs);
this.#status.lastHeartbeatAt = new Date().toISOString();
const heartbeatTask = this.#heartbeatLoop(attemptSignal);
const stream = this.#transport.stream({
signal: attemptSignal,
lastEventId: this.#status.lastEventId,
onOpen: () => {
this.#status.connected = true;
this.#status.state = 'connected';
this.#status.error = null;
attempt = 0;
},
onEvent: async (event) => {
this.#status.lastEventAt = new Date().toISOString();
this.#status.lastEventId = event.id ?? this.#status.lastEventId;
this.#status.lastEventType = event.type;
if (event.type === 'job.available') this.#status.jobsOffered += 1;
this.#jobs?.handleEvent(event);
},
});
await Promise.race([stream, heartbeatTask]);
} catch (error) {
if (signal.aborted) break;
this.#status.connected = false;
this.#status.state = 'reconnecting';
this.#status.error = safeConnectionError(error);
this.#status.reconnects += 1;
const delay = RETRY_DELAYS[Math.min(attempt, RETRY_DELAYS.length - 1)];
attempt += 1;
try { await sleep(delay, undefined, { signal }); } catch { break; }
} finally {
attemptController.abort();
}
}
this.#status.connected = false;
this.#status.state = 'idle';
}
async #heartbeatLoop(signal) {
while (!signal.aborted) {
await sleep(this.#config.heartbeatSeconds * 1_000, undefined, { signal });
const heartbeat = await this.#transport.heartbeat(this.capabilities(), { signal });
this.#offerJobs(heartbeat?.jobs);
this.#status.lastHeartbeatAt = new Date().toISOString();
}
}
#offerJobs(jobs) {
if (!Array.isArray(jobs)) return;
for (const job of jobs) {
if (typeof job?.id === 'string' && this.#jobs?.offer(job.id)) this.#status.jobsOffered += 1;
}
}
async stop() {
const task = this.#task;
this.#controller?.abort();
this.#controller = null;
if (task) await task.catch(() => undefined);
await this.#jobs?.close();
this.#status.connected = false;
this.#status.state = 'idle';
return this.status;
}
}

View file

@ -0,0 +1,170 @@
import { OFFICE_HOOK_PATHS, OFFICE_PROTOCOL_VERSION, officeHookUrls } from './protocol.mjs';
function safeTransportError(operation, response) {
const error = new Error(`AI Office ${operation} failed: HTTP ${response.status}`);
error.code = response.status === 401 ? 'invalid-device-token'
: response.status === 404 ? 'office-hook-unavailable'
: response.status === 409 ? 'office-job-conflict' : 'office-transport-failed';
return error;
}
function transportFailure(message, code = 'office-transport-failed', cause) {
const error = new Error(message, cause === undefined ? undefined : { cause });
error.code = code;
return error;
}
function isAbort(error, signal) {
return signal?.aborted || error?.name === 'AbortError';
}
function parseFrame(frame) {
let type = 'message';
let id;
const data = [];
for (const line of frame.split(/\r?\n/)) {
if (line.startsWith('event:')) type = line.slice(6).trim() || 'message';
else if (line.startsWith('id:')) id = line.slice(3).trim() || undefined;
else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
}
if (data.length === 0) return null;
let value;
try { value = JSON.parse(data.join('\n')); } catch { throw new Error('AI Office SSE returned invalid JSON'); }
return { id, type: typeof value?.type === 'string' ? value.type : type, data: value };
}
export class OfficeTransport {
#baseUrl;
#deviceId;
#token;
#fetch;
constructor({ baseUrl, deviceId, token, fetchImpl = fetch }) {
this.#baseUrl = baseUrl;
this.#deviceId = deviceId;
this.#token = token;
this.#fetch = fetchImpl;
}
hooks() { return officeHookUrls(this.#baseUrl); }
#headers(extra = {}) {
return {
authorization: `Bearer ${this.#token}`,
'x-harness-device-id': this.#deviceId,
...extra,
};
}
async heartbeat(payload, { signal } = {}) {
let response;
try {
response = await this.#fetch(new URL(OFFICE_HOOK_PATHS.heartbeat, this.#baseUrl), {
method: 'POST',
headers: this.#headers({ accept: 'application/json', 'content-type': 'application/json' }),
body: JSON.stringify(payload),
signal,
redirect: 'error',
});
} catch (error) {
if (isAbort(error, signal)) throw error;
throw transportFailure('AI Office heartbeat request could not be completed', undefined, error);
}
if (!response.ok) throw safeTransportError('heartbeat', response);
let value;
try { value = await response.json(); }
catch (error) { throw transportFailure('AI Office heartbeat returned invalid JSON', 'office-protocol-mismatch', error); }
if (!value || typeof value !== 'object' || value.ok !== true
|| value.protocolVersion !== OFFICE_PROTOCOL_VERSION) {
throw transportFailure('AI Office heartbeat protocol does not match', 'office-protocol-mismatch');
}
return value;
}
async #jobRequest(path, { method = 'POST', body, leaseToken, signal } = {}) {
const headers = this.#headers({ accept: 'application/json' });
if (body !== undefined) headers['content-type'] = 'application/json';
if (leaseToken) headers['x-harness-lease-token'] = leaseToken;
let response;
try {
response = await this.#fetch(new URL(path, this.#baseUrl), {
method,
headers,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal,
redirect: 'error',
cache: 'no-store',
});
} catch (error) {
if (isAbort(error, signal)) throw error;
throw transportFailure('AI Office Job request could not be completed', undefined, error);
}
if (!response.ok) throw safeTransportError('Job request', response);
try { return await response.json(); }
catch (error) { throw transportFailure('AI Office Job returned invalid JSON', 'office-protocol-mismatch', error); }
}
async getJob(jobId, options = {}) {
return this.#jobRequest(OFFICE_HOOK_PATHS.job.replace(':id', jobId), { ...options, method: 'GET' });
}
async acceptJob(jobId, options = {}) {
return this.#jobRequest(OFFICE_HOOK_PATHS.accept.replace(':id', jobId), options);
}
async renewJob(jobId, leaseToken, options = {}) {
return this.#jobRequest(OFFICE_HOOK_PATHS.renew.replace(':id', jobId), { ...options, leaseToken });
}
async progressJob(jobId, leaseToken, body, options = {}) {
return this.#jobRequest(OFFICE_HOOK_PATHS.progress.replace(':id', jobId), { ...options, leaseToken, body });
}
async requestApproval(jobId, leaseToken, body, options = {}) {
return this.#jobRequest(OFFICE_HOOK_PATHS.approval.replace(':id', jobId), { ...options, leaseToken, body });
}
async completeJob(jobId, leaseToken, body, options = {}) {
return this.#jobRequest(OFFICE_HOOK_PATHS.result.replace(':id', jobId), { ...options, leaseToken, body });
}
async failJob(jobId, leaseToken, body, options = {}) {
return this.#jobRequest(OFFICE_HOOK_PATHS.fail.replace(':id', jobId), { ...options, leaseToken, body });
}
async stream({ signal, lastEventId, onOpen, onEvent }) {
const headers = this.#headers({ accept: 'text/event-stream' });
if (lastEventId) headers['last-event-id'] = lastEventId;
let response;
try {
response = await this.#fetch(new URL(OFFICE_HOOK_PATHS.stream, this.#baseUrl), {
method: 'GET', headers, signal, redirect: 'error', cache: 'no-store',
});
} catch (error) {
if (isAbort(error, signal)) throw error;
throw transportFailure('AI Office SSE request could not be completed', undefined, error);
}
if (!response.ok) throw safeTransportError('stream', response);
if (!response.headers.get('content-type')?.toLowerCase().includes('text/event-stream')) {
throw new Error('AI Office stream did not return text/event-stream');
}
if (!response.body) throw new Error('AI Office stream returned no body');
onOpen?.();
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer = `${buffer}${decoder.decode(value, { stream: true })}`.replaceAll('\r\n', '\n');
let boundary;
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
const raw = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const event = parseFrame(raw);
if (event) await onEvent?.(event);
}
}
throw new Error('AI Office SSE stream ended');
}
}

View file

@ -0,0 +1,43 @@
export const OFFICE_PROTOCOL_VERSION = 'office-harness.v1';
export const OFFICE_RPC_CHANNEL = '/office';
export const OFFICE_RPC_ENDPOINTS = Object.freeze({
status: 'connection.status',
configure: 'connector.configure',
reconnect: 'connector.reconnect',
test: 'connector.test',
remove: 'connector.remove',
});
export const OFFICE_HOOK_PATHS = Object.freeze({
stream: '/api/harness/connector/stream',
heartbeat: '/api/harness/connector/heartbeat',
job: '/api/harness/connector/jobs/:id',
accept: '/api/harness/connector/jobs/:id/accept',
renew: '/api/harness/connector/jobs/:id/renew',
progress: '/api/harness/connector/jobs/:id/progress',
approval: '/api/harness/connector/jobs/:id/approval',
result: '/api/harness/connector/jobs/:id/result',
fail: '/api/harness/connector/jobs/:id/fail',
});
export function normalizeOfficeBaseUrl(value) {
const url = new URL(typeof value === 'string' ? value.trim() : '');
const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
if (url.protocol !== 'https:' && !localHttp) {
throw new TypeError('AI Office URL must use HTTPS (HTTP is allowed only for loopback testing)');
}
if (url.username || url.password || url.search || url.hash) {
throw new TypeError('AI Office URL must be a bare origin');
}
url.pathname = '/';
return url;
}
export function officeHookUrls(baseUrl) {
const origin = normalizeOfficeBaseUrl(baseUrl);
return Object.fromEntries(Object.entries(OFFICE_HOOK_PATHS).map(([name, path]) => [
name,
new URL(path, origin).toString(),
]));
}

View file

@ -115,6 +115,12 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
}; };
} }
export function telegramInboundAllowed(message, allowedPrivateUserIds) {
return message?.kind === 'direct'
&& allowedPrivateUserIds instanceof Set
&& allowedPrivateUserIds.has(String(message.senderId));
}
class TelegramBotClient { class TelegramBotClient {
#api; #api;
#signal; #signal;
@ -198,6 +204,7 @@ export class TelegramRuntime {
#logger; #logger;
#replyTimeoutMs; #replyTimeoutMs;
#createApi; #createApi;
#allowedPrivateUserIds;
#status = createTelegramRuntimeStatus(); #status = createTelegramRuntimeStatus();
#api = null; #api = null;
#bridge = null; #bridge = null;
@ -213,6 +220,7 @@ export class TelegramRuntime {
logger = console, logger = console,
replyTimeoutMs = 600_000, replyTimeoutMs = 600_000,
createApi = (options) => new TelegramApi(options), createApi = (options) => new TelegramApi(options),
allowedPrivateUserIds = [],
}) { }) {
if (!config || !token || !harness || !state) { if (!config || !token || !harness || !state) {
throw new TypeError('TelegramRuntime requires config, token, Harness, and state'); throw new TypeError('TelegramRuntime requires config, token, Harness, and state');
@ -224,6 +232,9 @@ export class TelegramRuntime {
this.#logger = logger; this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs; this.#replyTimeoutMs = replyTimeoutMs;
this.#createApi = createApi; this.#createApi = createApi;
this.#allowedPrivateUserIds = new Set(
Array.isArray(allowedPrivateUserIds) ? allowedPrivateUserIds.map(String) : [],
);
} }
get status() { get status() {
@ -323,7 +334,7 @@ export class TelegramRuntime {
username: this.#config.username, username: this.#config.username,
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }), loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
}); });
if (message) { if (message && telegramInboundAllowed(message, this.#allowedPrivateUserIds)) {
void this.#bridge.accept(message).catch((error) => { void this.#bridge.accept(message).catch((error) => {
if (signal.aborted) return; if (signal.aborted) return;
this.#logger.error?.( this.#logger.error?.(
@ -331,6 +342,9 @@ export class TelegramRuntime {
error, error,
); );
}); });
} else if (message) {
this.#status.messagesRejected += 1;
this.#status.lastRejectedAt = new Date().toISOString();
} }
cursor = update.update_id + 1; cursor = update.update_id + 1;
await this.#state.setCursor(cursor); await this.#state.setCursor(cursor);

View file

@ -0,0 +1,297 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { OfficeConfigStore } from '../../../src/channels/office/config-store.mjs';
import { OfficeController } from '../../../src/channels/office/office-controller.mjs';
import { OfficeTransport } from '../../../src/channels/office/office-transport.mjs';
import { OfficeJobExecutor } from '../../../src/channels/office/office-job-executor.mjs';
import {
OFFICE_PROTOCOL_VERSION,
OFFICE_RPC_ENDPOINTS,
officeHookUrls,
} from '../../../src/channels/office/protocol.mjs';
import { createOfficeRpcHandler } from '../../../plugin-src/host/channels/office/rpc.mjs';
import { OfficeSettingsTab } from '../../../plugin-src/client/channels/office/index.js';
const TOKEN = 'office-device-token-ABCDEFGHIJKLMNOPQRSTUVWXYZ-123456';
async function eventually(predicate, timeoutMs = 2_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.fail('condition did not become true');
}
function config(overrides = {}) {
return {
version: 1,
baseUrl: 'https://fission.gridmind.ai',
deviceId: 'mac-a004',
deviceTokenRef: 'DSH_OFFICE_DEVICE_TOKEN_1234567890ABCDEF12345678',
maxConcurrency: 1,
heartbeatSeconds: 30,
workspaces: { 'office-project': '/Users/a004/glassespaw-ai-office-web' },
instructionPresets: { 'action-items': 'Convert into executable action items.' },
...overrides,
};
}
function credentials() {
const values = new Map();
return {
values,
resolve: async (ref) => values.has(ref) ? { value: values.get(ref), source: 'test' } : undefined,
set: async (ref, value) => values.set(ref, value),
unset: async (ref) => values.delete(ref),
};
}
test('AI Office protocol derives every fixed hook from one HTTPS origin', () => {
const hooks = officeHookUrls('https://fission.gridmind.ai');
assert.equal(OFFICE_PROTOCOL_VERSION, 'office-harness.v1');
assert.equal(hooks.stream, 'https://fission.gridmind.ai/api/harness/connector/stream');
assert.equal(hooks.result, 'https://fission.gridmind.ai/api/harness/connector/jobs/:id/result');
assert.throws(() => officeHookUrls('http://public.example'), /must use HTTPS/);
assert.equal(officeHookUrls('http://127.0.0.1:4300').heartbeat, 'http://127.0.0.1:4300/api/harness/connector/heartbeat');
});
test('AI Office config persists safe aliases without a Device Token', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-office-config-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const path = join(directory, 'config.json');
const store = await new OfficeConfigStore(path).load();
await store.save(config());
const raw = await readFile(path, 'utf8');
assert.doesNotMatch(raw, /office-device-token/);
assert.match(raw, /office-project/);
assert.equal((await stat(path)).mode & 0o777, 0o600);
assert.deepEqual(store.get().workspaces, { 'office-project': '/Users/a004/glassespaw-ai-office-web' });
await assert.rejects(() => store.save(config({ workspaces: { unsafe: 'relative/path' } })), /invalid/);
});
test('AI Office transport authenticates heartbeat and parses SSE frames', async () => {
const calls = [];
const encoder = new TextEncoder();
const fetchImpl = async (url, options) => {
calls.push({ url: String(url), options });
if (String(url).endsWith('/heartbeat')) {
return Response.json({ ok: true, protocolVersion: OFFICE_PROTOCOL_VERSION });
}
return new Response(new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('id: evt-1\nevent: job.available\ndata: {"type":"job.available","jobId":"job-1"}\n\n'));
controller.close();
},
}), { headers: { 'content-type': 'text/event-stream' } });
};
const transport = new OfficeTransport({
baseUrl: 'https://fission.gridmind.ai', deviceId: 'mac-a004', token: TOKEN, fetchImpl,
});
await transport.heartbeat({ protocolVersion: OFFICE_PROTOCOL_VERSION });
const events = [];
await assert.rejects(() => transport.stream({ onEvent: (event) => events.push(event) }), /stream ended/);
assert.equal(calls[0].options.headers.authorization, `Bearer ${TOKEN}`);
assert.equal(calls[0].options.headers['x-harness-device-id'], 'mac-a004');
assert.deepEqual(events, [{
id: 'evt-1', type: 'job.available', data: { type: 'job.available', jobId: 'job-1' },
}]);
});
test('AI Office transport uses fixed Job hooks and keeps the lease outside JSON bodies', async () => {
const calls = [];
const transport = new OfficeTransport({
baseUrl: 'https://fission.gridmind.ai',
deviceId: 'mac-a004',
token: TOKEN,
fetchImpl: async (url, options) => {
calls.push({ url: String(url), options });
return Response.json({ ok: true });
},
});
const jobId = 'job-1234567890abcdef1234567890abcdef';
await transport.getJob(jobId);
await transport.acceptJob(jobId);
await transport.progressJob(jobId, 'lease-secret', { kind: 'status', message: 'running' });
assert.equal(calls[0].url, `https://fission.gridmind.ai/api/harness/connector/jobs/${jobId}`);
assert.equal(calls[0].options.method, 'GET');
assert.equal(calls[1].url.endsWith(`/${jobId}/accept`), true);
assert.equal(calls[2].options.headers['x-harness-lease-token'], 'lease-secret');
assert.equal(calls[2].options.body.includes('lease-secret'), false);
});
test('AI Office controller stores the token in credentials and returns only safe status', async () => {
let stored = null;
const credentialStore = credentials();
const runtimes = [];
const controller = new OfficeController({
credentials: credentialStore,
configStore: {
get: () => stored,
save: async (value) => { stored = structuredClone(value); return stored; },
clear: async () => { stored = null; },
},
createRuntime: (options) => {
const runtime = {
options,
status: { state: 'connected', connected: true, reconnects: 0 },
start() {},
stop: async () => {},
testConnection: async () => ({ ok: true }),
};
runtimes.push(runtime);
return runtime;
},
});
const status = await controller.configure({
baseUrl: 'https://fission.gridmind.ai', deviceId: 'mac-a004', deviceToken: TOKEN,
maxConcurrency: 1, heartbeatSeconds: 30,
workspaces: { 'office-project': '/Users/a004/project' },
instructionPresets: { 'action-items': 'Make tasks.' },
});
assert.equal(status.connected, true);
assert.equal(status.tokenConfigured, true);
assert.equal(JSON.stringify(status).includes(TOKEN), false);
assert.equal(credentialStore.values.size, 1);
assert.equal(runtimes[0].options.token, TOKEN);
await controller.remove();
assert.equal(credentialStore.values.size, 0);
});
test('AI Office controller normalizes the origin and tolerates a missing local credential on startup', async () => {
const credentialStore = credentials();
let stored = config();
const configStore = {
get: () => stored,
save: async (value) => { stored = structuredClone(value); return stored; },
clear: async () => { stored = null; },
};
const controller = new OfficeController({
credentials: credentialStore,
configStore,
createRuntime: () => ({
status: { state: 'connected', connected: true },
start() {},
stop: async () => {},
}),
});
const initial = await controller.initialize();
assert.equal(initial.configured, true);
assert.equal(initial.state, 'missing-token');
await controller.configure({
baseUrl: 'https://fission.gridmind.ai/path/', deviceId: 'mac-a004', deviceToken: TOKEN,
maxConcurrency: 1, heartbeatSeconds: 30, workspaces: {}, instructionPresets: {},
});
assert.equal(stored.baseUrl, 'https://fission.gridmind.ai');
assert.equal(credentialStore.values.size, 1);
await controller.close();
});
test('AI Office RPC validates configuration and keeps transport failures safe', async () => {
const calls = [];
const handler = createOfficeRpcHandler({
status: async () => ({ configured: false }),
configure: async (payload) => { calls.push(payload); return { configured: true }; },
reconnect: async () => ({ configured: true }),
test: async () => { const error = new Error('HTTP 404 internal URL'); error.code = 'office-hook-unavailable'; throw error; },
remove: async () => ({ configured: false }),
});
assert.deepEqual(await handler(OFFICE_RPC_ENDPOINTS.configure, { baseUrl: 'x' }), {
ok: false, error: { code: 'bad-request', message: 'Invalid AI Office connector request.' },
});
assert.equal((await handler(OFFICE_RPC_ENDPOINTS.configure, {
baseUrl: 'https://fission.gridmind.ai', deviceId: 'mac-a004', deviceToken: TOKEN,
workspaces: {}, instructionPresets: {},
})).ok, true);
assert.equal(calls.length, 1);
assert.deepEqual(await handler(OFFICE_RPC_ENDPOINTS.test, {}), {
ok: false, error: { code: 'office-hook-unavailable', message: 'AI Office Hook 尚未上线或地址不正确。' },
});
});
test('AI Office settings renders connection fields and fixed hook preview', () => {
const markup = renderToStaticMarkup(React.createElement(OfficeSettingsTab, {
rpcCall: async () => ({ ok: true, value: { configured: false } }),
initialStatus: { configured: false },
}));
assert.match(markup, /AI Office Connector/);
assert.match(markup, /Office Base URL/);
assert.match(markup, /Device Token/);
assert.match(markup, /Workspace 映射/);
assert.match(markup, /api\/harness\/connector\/stream/);
});
test('AI Office Job executor claims, reports, approves, and returns one Harness result', async () => {
const jobId = 'job-1234567890abcdef1234567890abcdef';
const progress = [];
const approvals = [];
const results = [];
const responses = [];
let executor;
const transport = {
getJob: async () => ({ job: {
id: jobId,
workspaceAlias: 'office-project',
instructionPreset: 'execute',
instruction: 'Return evidence.',
markdown: '# Office timeline',
} }),
acceptJob: async () => ({ leaseToken: 'lease-token-1234567890' }),
renewJob: async () => ({ leaseExpiresAt: new Date(Date.now() + 90_000).toISOString() }),
progressJob: async (_id, _lease, value) => { progress.push(value); return { ok: true }; },
requestApproval: async (_id, _lease, value) => {
approvals.push(value);
queueMicrotask(() => executor.handleEvent({
type: 'approval.reply',
data: { jobId, approvalId: value.id, decision: 'approved' },
}));
return { ok: true };
},
completeJob: async (_id, _lease, value) => { results.push(value); return { ok: true }; },
failJob: async () => { throw new Error('must not fail'); },
};
const harness = {
createSession: async () => 'session-office-one',
ask: async (_sessionId, prompt, options) => {
assert.match(prompt, /Return evidence/);
await options.onUpdate({ type: 'tool', name: 'apply_patch' });
await options.onInteraction({
kind: 'approval',
interactionId: 'approval-one',
sessionId: 'session-office-one',
payload: {
type: 'approval/requested', sessionId: 'session-office-one',
approvalId: 'approval-one', toolName: 'apply_patch', callId: 'call-one',
},
toolCall: { callId: 'call-one', name: 'apply_patch', arguments: '{"patch":"safe"}' },
respond: async (value) => { responses.push(value); return { accepted: true }; },
});
return '# Completed\n\nVerified.';
},
rpc: async () => ({ ok: true }),
};
executor = new OfficeJobExecutor({
config: {
maxConcurrency: 1,
workspaces: { 'office-project': '/Users/a004/project' },
instructionPresets: { execute: 'Execute carefully.' },
},
transport,
createHarness: () => harness,
});
assert.equal(executor.offer(jobId), true);
await eventually(() => executor.status.completed === 1);
assert.equal(approvals[0].kind, 'approval');
assert.equal(responses[0].value.outcome, 'allowed-once');
assert.ok(progress.some((item) => item.kind === 'tool'));
assert.deepEqual(results, [{ resultMarkdown: '# Completed\n\nVerified.', sessionId: 'session-office-one' }]);
await executor.close();
});

View file

@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeTelegramAllowedUsers } from '../../../plugin-src/host/channels/telegram/production.mjs';
test('Telegram production normalizes and validates private-message allowlists', () => {
assert.deepEqual(normalizeTelegramAllowedUsers(undefined), []);
assert.deepEqual(
normalizeTelegramAllowedUsers([6087707998, '1202499116', '6087707998']),
['6087707998', '1202499116'],
);
assert.throws(
() => normalizeTelegramAllowedUsers('6087707998'),
/must be an array/,
);
assert.throws(
() => normalizeTelegramAllowedUsers([0, '-100123', 'username']),
/invalid Telegram User ID/,
);
});

View file

@ -18,6 +18,7 @@ import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-b
import { import {
TelegramRuntime, TelegramRuntime,
normalizeTelegramUpdate, normalizeTelegramUpdate,
telegramInboundAllowed,
} from '../../../src/channels/telegram/telegram-runtime.mjs'; } from '../../../src/channels/telegram/telegram-runtime.mjs';
import { TelegramStateStore } from '../../../src/channels/telegram/state-store.mjs'; import { TelegramStateStore } from '../../../src/channels/telegram/state-store.mjs';
import { import {
@ -351,6 +352,14 @@ test('Telegram normalizes private messages and requires an explicit group addres
assert.equal(topicTwo.replyTarget.messageThreadId, 200); assert.equal(topicTwo.replyTarget.messageThreadId, 200);
}); });
test('Telegram blocks every group and admits only allowlisted private senders', () => {
const allowed = new Set(['6087707998', '1202499116']);
assert.equal(telegramInboundAllowed({ kind: 'group', senderId: '6087707998' }, allowed), false);
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, allowed), true);
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '999999999' }, allowed), false);
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, new Set()), false);
});
test('Telegram normalizes photo captions and image documents into one downloadable image', async () => { test('Telegram normalizes photo captions and image documents into one downloadable image', async () => {
const loads = []; const loads = [];
const groupPhoto = normalizeTelegramUpdate({ const groupPhoto = normalizeTelegramUpdate({
@ -622,6 +631,7 @@ test('Telegram runtime keeps polling while a Harness question waits for its answ
state, state,
createApi: () => fakeApi, createApi: () => fakeApi,
logger: { error() {}, warn() {} }, logger: { error() {}, warn() {} },
allowedPrivateUserIds: ['7'],
}); });
try { try {

View file

@ -96,7 +96,7 @@ const QQ_SOURCE_URL = new URL(
import.meta.url, import.meta.url,
); );
test('IM settings renders nine compact logo channel tabs without enable switches', async () => { test('IM settings renders nine IM channels plus the AI Office connector', async () => {
const styles = await readFile(STYLES_URL, 'utf8'); const styles = await readFile(STYLES_URL, 'utf8');
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, { const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
feishuRpcCall: async () => ({ ok: true, value: {} }), feishuRpcCall: async () => ({ ok: true, value: {} }),
@ -108,6 +108,7 @@ test('IM settings renders nine compact logo channel tabs without enable switches
telegramRpcCall: async () => ({ ok: true, value: {} }), telegramRpcCall: async () => ({ ok: true, value: {} }),
discordRpcCall: async () => ({ ok: true, value: {} }), discordRpcCall: async () => ({ ok: true, value: {} }),
whatsappRpcCall: async () => ({ ok: true, value: {} }), whatsappRpcCall: async () => ({ ok: true, value: {} }),
officeRpcCall: async () => ({ ok: true, value: {} }),
})); }));
assert.match(markup, /IM机器人/); assert.match(markup, /IM机器人/);
@ -139,6 +140,7 @@ test('IM settings renders nine compact logo channel tabs without enable switches
assert.match(markup, />Telegram</); assert.match(markup, />Telegram</);
assert.match(markup, />Discord</); assert.match(markup, />Discord</);
assert.match(markup, />WhatsApp</); assert.match(markup, />WhatsApp</);
assert.match(markup, />AI Office</);
assert.match(markup, /dim-logoWeixin/); assert.match(markup, /dim-logoWeixin/);
assert.match(markup, /dim-logoFeishu/); assert.match(markup, /dim-logoFeishu/);
assert.match(markup, /dim-logoDingtalk/); assert.match(markup, /dim-logoDingtalk/);
@ -148,8 +150,9 @@ test('IM settings renders nine compact logo channel tabs without enable switches
assert.match(markup, /dim-logoTelegram/); assert.match(markup, /dim-logoTelegram/);
assert.match(markup, /dim-logoDiscord/); assert.match(markup, /dim-logoDiscord/);
assert.match(markup, /dim-logoWhatsapp/); assert.match(markup, /dim-logoWhatsapp/);
assert.match(markup, /dim-logoOffice/);
assert.match(styles, /\.dim-logoFeishu svg \{ width: 28px; height: 28px; \}/); assert.match(styles, /\.dim-logoFeishu svg \{ width: 28px; height: 28px; \}/);
assert.equal((markup.match(/role="tab"/g) ?? []).length, 9); assert.equal((markup.match(/role="tab"/g) ?? []).length, 10);
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1); assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/); assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/); assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/);

View file

@ -3,7 +3,7 @@ import test from 'node:test';
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs'; import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
test('Host composes all nine IM channels inside one plugin context', async () => { test('Host composes nine IM channels and the AI Office connector inside one plugin context', async () => {
const calls = []; const calls = [];
const plugin = createImHostPlugin({ const plugin = createImHostPlugin({
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]), applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
@ -15,6 +15,7 @@ test('Host composes all nine IM channels inside one plugin context', async () =>
applyTelegram: async (ctx, config) => calls.push(['telegram', ctx, config]), applyTelegram: async (ctx, config) => calls.push(['telegram', ctx, config]),
applyDiscord: async (ctx, config) => calls.push(['discord', ctx, config]), applyDiscord: async (ctx, config) => calls.push(['discord', ctx, config]),
applyWhatsapp: async (ctx, config) => calls.push(['whatsapp', ctx, config]), applyWhatsapp: async (ctx, config) => calls.push(['whatsapp', ctx, config]),
applyOffice: async (ctx, config) => calls.push(['office', ctx, config]),
}); });
const ctx = { marker: 'shared-context' }; const ctx = { marker: 'shared-context' };
const config = { const config = {
@ -28,6 +29,7 @@ test('Host composes all nine IM channels inside one plugin context', async () =>
telegram: { replyTimeoutMs: 60_000 }, telegram: { replyTimeoutMs: 60_000 },
discord: { replyTimeoutMs: 60_000 }, discord: { replyTimeoutMs: 60_000 },
whatsapp: { replyTimeoutMs: 60_000 }, whatsapp: { replyTimeoutMs: 60_000 },
office: { heartbeatSeconds: 30 },
}; };
await plugin.apply(ctx, config); await plugin.apply(ctx, config);
@ -44,6 +46,7 @@ test('Host composes all nine IM channels inside one plugin context', async () =>
['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host' }], ['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host' }],
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }], ['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }],
['whatsapp', ctx, { ...config.whatsapp, rpcAuthority: 'trusted-host' }], ['whatsapp', ctx, { ...config.whatsapp, rpcAuthority: 'trusted-host' }],
['office', ctx, { ...config.office, rpcAuthority: 'trusted-host' }],
]); ]);
}); });