feat: secure Telegram access and connect AI Office

This commit is contained in:
pompy 2026-08-20 11:56:53 +08:00
parent e3ae772106
commit b0606159e0
30 changed files with 2045 additions and 302 deletions

View file

@ -32,7 +32,7 @@
## 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
@ -48,7 +48,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 |
| 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 |
| 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 |
| 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 |
@ -56,6 +56,14 @@ 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.
## 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. Job state and results use outbound HTTPS POSTs from Harness. The settings page derives every fixed hook from the Office Base URL and reconnects with backoff after a disconnect. Until the Office hooks are deployed, an HTTP 404 is explicitly reported as a pending hook.
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
Install the published stable release from npm (recommended):
@ -81,6 +89,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. |
| 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
| Command | Description |
@ -121,7 +141,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` 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.
- 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.
- 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.
@ -139,12 +159,12 @@ Example: send `/models`, then `/model 2` to switch to the second model in the li
## Design
- Registers a single **IM Bot** settings page in Harness.
- Maintains all nine channel Host, client, and runtime sources in this repository without external standalone channel plugins.
- Registers one **IM Bot** settings page containing nine IM channels and one AI Office Connector.
- Maintains the Host, client, and runtime sources for all nine channels and the Office Connector in this repository without external standalone plugins.
- 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.
- 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

View file

@ -33,9 +33,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.
## 界面
@ -51,7 +51,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
| 企业微信 | 使用企业微信 App 扫码创建智能机器人,或使用 Bot ID + Secret 手动绑定 | 官方 WebSocket 长连接;原生显示“正在思考中”、工具执行进度和流式回答 |
| QQ | 使用手机 QQ 扫码创建机器人,或使用 AppID + AppSecret 手动绑定 | WebSocket 长连接;私聊显示“正在输入”和流式回答,群聊被 @ 后回复 |
| 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 长连接;私信直接回复,服务器频道被提及时响应,通过编辑消息流式显示回答 |
| WhatsApp | 使用手机 WhatsApp 扫码关联设备 | WhatsApp Web 长连接;显示已读和“正在输入”,再发送最终回答 |
@ -59,6 +59,14 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
九个内置渠道均支持把 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 下行;任务状态与结果使用 Harness 主动发起的 HTTPS POST。设置页会从 Office Base URL 自动展示全部固定 Hook,并在断线后按退避策略自动重连。Office 端尚未部署时,HTTP 404 会明确显示为 Hook 未就绪。
Heartbeat 成功响应必须是 JSON:`{"ok":true,"protocolVersion":"office-harness.v1"}`。这使「连接测试通过」代表命中了兼容的 Office Connector,而不只是某个碰巧返回 200 的网址。
## 安装
推荐从 npm 安装已发布的稳定版本:
@ -84,6 +92,18 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
| 机器人工作区 | 每个机器人独立保存工作区。新机器人默认使用 Host 当时的工作目录;之后可在机器人卡片中修改。 |
| Agent Preset | 新会话默认继承 Harness 的 `agent-presets.default`;渠道显式配置优先,已有会话不受后续修改影响。 |
Telegram 默认拒绝所有入站消息。请在 Web profile 的 `cordis.patch.yml` 中配置允许私聊机器人的数字 User ID;群聊无论是否提及机器人都会被忽略:
```yaml
- id: xmanrui-dsh-im
config:
telegram:
allowedUsers:
- 123456789
```
`allowedUsers` 接受数字或十进制字符串、会去重,并在插件启动时拒绝无效值。空白名单保持全部拒绝。
## 机器人命令
| 命令 | 作用 |
@ -124,7 +144,7 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
- `/session` 只接受一个由 `/sessionlist` 获得的 Session ID。它不会新建会话或立即向模型发送消息;绑定成功后,当前聊天的后续消息会继续该会话。普通归档会话可以绑定但不会自动取消归档,子代理会话不能绑定。
- `/session` 会自动定位会话唯一所属的工作区。同工作区绑定只替换当前聊天的映射;跨工作区绑定会切换该机器人的工作区、清除该机器人所有聊天的旧会话映射,再绑定当前聊天,因此会影响该机器人的其他聊天。已经开始生成的回复仍可完成。
- 工作区切换和会话绑定只会清除或替换 dsh-im 的聊天映射,不会删除、清空或归档任何旧 Session 内容;旧 Session 仍可再次列出和绑定。
- 任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行这些命令,不区分管理员和普通用户。
- 除 Telegram 外,任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行这些命令,不区分管理员和普通用户;Telegram 仅允许 `telegram.allowedUsers` 中的私聊用户执行,群聊命令始终忽略。
- 工作区列表来自 Harness Host 的全局登记信息,可能包含其他机器人、其他渠道或非 IM 项目的本机绝对路径。请将机器人可见范围限制给可信用户。
- 会话列表同样来自该全局 Harness Host;会话 ID 和标题可能属于其他机器人、其他渠道或非 IM 项目,并可能包含敏感元数据。开放命令前请确保所有可见用户都可信。
- 任何能执行 `/session` 的用户都能接续所选会话,并通过后续消息写入会话或触发其可用工具。请只向可信用户开放机器人及其会话列表。
@ -142,12 +162,12 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
## 设计
- Harness 中只注册一个「IM机器人」设置页;
- 九个渠道的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件;
- Harness 中只注册一个「IM机器人」设置页,其中包含九个 IM 渠道和一个 AI Office Connector;
- 九个渠道及 Office Connector 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立插件;
- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;
- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord 和 WhatsApp,不使用启用/停用开关;
- 左侧使用 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 和 AI Office,不使用启用/停用开关;
- 九个渠道保持独立的 RPC、凭据、连接监督和会话映射;
- 浏览器只获得二维码、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

32
package-lock.json generated
View file

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

View file

@ -1,7 +1,7 @@
{
"name": "@xmanrui/dsh-im",
"version": "0.13.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": [
"deepseek-harness",
"dsh",
@ -19,7 +19,8 @@
"slack",
"telegram",
"discord",
"whatsapp"
"whatsapp",
"ai-office"
],
"author": {
"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',
}));
}
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,150 @@
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)))) : 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(4, 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,49 @@ const EN = Object.freeze({
'IM机器人设置': 'IM bot settings',
'IM 渠道': 'IM channels',
'让 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',
'尚无': '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',
'微信': 'WeChat',
'飞书': 'Feishu',

View file

@ -4,6 +4,7 @@ import {
DingtalkLogoGlyph,
DiscordLogoGlyph,
FeishuLogoGlyph,
OfficeLogoGlyph,
QqLogoGlyph,
SlackLogoGlyph,
TelegramLogoGlyph,
@ -22,6 +23,9 @@ import { installFeishuStyles } from './channels/feishu/styles.js';
import { QQ_RPC_CHANNEL } from './channels/qq/api.js';
import { QqSettingsTab } from './channels/qq/index.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 { SlackSettingsTab } from './channels/slack/index.js';
import { installSlackStyles } from './channels/slack/styles.js';
@ -56,6 +60,7 @@ const CHANNELS = Object.freeze([
{ id: 'telegram', label: 'Telegram' },
{ id: 'discord', label: 'Discord' },
{ id: 'whatsapp', label: 'WhatsApp' },
{ id: 'office', label: 'AI Office' },
]);
function WeixinLogo() {
@ -101,6 +106,11 @@ function WhatsappLogo() {
h(WhatsappLogoGlyph));
}
function OfficeLogo() {
return h('span', { className: 'dim-logo dim-logoOffice', 'aria-hidden': 'true' },
h(OfficeLogoGlyph));
}
function ChannelLogo({ channel }) {
if (channel === 'weixin') return h(WeixinLogo);
if (channel === 'feishu') return h(FeishuLogo);
@ -110,7 +120,8 @@ function ChannelLogo({ channel }) {
if (channel === 'slack') return h(SlackLogo);
if (channel === 'telegram') return h(TelegramLogo);
if (channel === 'discord') return h(DiscordLogo);
return h(WhatsappLogo);
if (channel === 'whatsapp') return h(WhatsappLogo);
return h(OfficeLogo);
}
export function IMSettingsTab({
@ -123,6 +134,7 @@ export function IMSettingsTab({
wecomRpcCall,
weixinRpcCall,
whatsappRpcCall,
officeRpcCall,
workspaceDirectoryPicker,
}) {
const [selected, setSelected] = React.useState('weixin');
@ -195,7 +207,9 @@ export function IMSettingsTab({
? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
: active.id === 'discord'
? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
: h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })),
: active.id === 'whatsapp'
? h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })
: h(OfficeSettingsTab, { rpcCall: officeRpcCall })),
),
));
}
@ -218,6 +232,7 @@ export function apply(ctx) {
installTelegramStyles(),
installDiscordStyles(),
installWhatsappStyles(),
installOfficeStyles(),
installImStyles(),
];
return () => {
@ -243,6 +258,8 @@ export function apply(ctx) {
ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal);
const slackRpcCall = (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({
listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
pickDirectory: () => ctx.workspaces.pickDirectory(),
@ -264,6 +281,7 @@ export function apply(ctx) {
wecomRpcCall,
weixinRpcCall,
whatsappRpcCall,
officeRpcCall,
workspaceDirectoryPicker,
}),
}, IMSettingsTab));

View file

@ -44,6 +44,8 @@ const CSS = String.raw`
.dim-logoWecom svg { width: 22px; height: 22px; }
.dim-logoTelegram { color: white; background: #229ed9; }
.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 svg { width: 21px; height: 21px; }
.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,29 @@
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';
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 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 controller = new Controller({
credentials: ctx.credentials,
configStore,
logger,
createRuntime: (options) => new Runtime({ ...options, ...(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) {
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?.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 ResolvedController = internals.Controller ?? Controller;
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 logger = typeof ctx.logger === 'function'
? ctx.logger(`dsh-im:${channel}`) : (ctx.logger ?? console);
@ -91,6 +98,7 @@ export async function createTokenProductionController(ctx, config, internals, de
await workspaces.ensure(botId);
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
return new ResolvedRuntime({
...channelRuntimeOptions,
config: botConfig,
token,
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 { 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 = {}) {
return createTokenProductionController(ctx, config, internals, {
channel: 'telegram',
@ -13,5 +31,8 @@ export function createProductionController(ctx, config = {}, internals = {}) {
HarnessClient: TelegramHarnessClient,
Controller: TelegramController,
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 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 applyQq } from './channels/qq/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 startTelegram = internals.applyTelegram ?? applyTelegram;
const startDiscord = internals.applyDiscord ?? applyDiscord;
const startOffice = internals.applyOffice ?? applyOffice;
const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp;
return Object.freeze({
name,
@ -41,6 +43,7 @@ export function createImHostPlugin(internals = {}) {
await startTelegram(ctx, channelConfig(config, 'telegram'));
await startDiscord(ctx, channelConfig(config, 'discord'));
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,132 @@
import { setTimeout as sleep } from 'node:timers/promises';
import { OfficeTransport } from './office-transport.mjs';
import { OFFICE_PROTOCOL_VERSION } from './protocol.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;
constructor({ config, token, logger = console, transport }) {
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,
};
}
get status() { return structuredClone(this.#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 {
await this.#transport.heartbeat(this.capabilities(), { signal: attemptSignal });
this.#status.lastHeartbeatAt = new Date().toISOString();
const heartbeat = 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;
},
});
await Promise.race([stream, heartbeat]);
} 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 });
await this.#transport.heartbeat(this.capabilities(), { signal });
this.#status.lastHeartbeatAt = new Date().toISOString();
}
}
async stop() {
const task = this.#task;
this.#controller?.abort();
this.#controller = null;
if (task) await task.catch(() => undefined);
this.#status.connected = false;
this.#status.state = 'idle';
return this.status;
}
}

View file

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

View file

@ -0,0 +1,198 @@
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 {
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';
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 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/);
});

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 {
TelegramRuntime,
normalizeTelegramUpdate,
telegramInboundAllowed,
} from '../../../src/channels/telegram/telegram-runtime.mjs';
import { TelegramStateStore } from '../../../src/channels/telegram/state-store.mjs';
import {
@ -351,6 +352,14 @@ test('Telegram normalizes private messages and requires an explicit group addres
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 () => {
const loads = [];
const groupPhoto = normalizeTelegramUpdate({
@ -622,6 +631,7 @@ test('Telegram runtime keeps polling while a Harness question waits for its answ
state,
createApi: () => fakeApi,
logger: { error() {}, warn() {} },
allowedPrivateUserIds: ['7'],
});
try {

View file

@ -96,7 +96,7 @@ const QQ_SOURCE_URL = new 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 markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
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: {} }),
discordRpcCall: async () => ({ ok: true, value: {} }),
whatsappRpcCall: async () => ({ ok: true, value: {} }),
officeRpcCall: async () => ({ ok: true, value: {} }),
}));
assert.match(markup, /IM机器人/);
@ -137,6 +138,7 @@ test('IM settings renders nine compact logo channel tabs without enable switches
assert.match(markup, />Telegram</);
assert.match(markup, />Discord</);
assert.match(markup, />WhatsApp</);
assert.match(markup, />AI Office</);
assert.match(markup, /dim-logoWeixin/);
assert.match(markup, /dim-logoFeishu/);
assert.match(markup, /dim-logoDingtalk/);
@ -146,8 +148,9 @@ test('IM settings renders nine compact logo channel tabs without enable switches
assert.match(markup, /dim-logoTelegram/);
assert.match(markup, /dim-logoDiscord/);
assert.match(markup, /dim-logoWhatsapp/);
assert.match(markup, /dim-logoOffice/);
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.doesNotMatch(markup, /role="switch"|type="checkbox"/);
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';
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 plugin = createImHostPlugin({
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]),
applyDiscord: async (ctx, config) => calls.push(['discord', 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 config = {
@ -28,6 +29,7 @@ test('Host composes all nine IM channels inside one plugin context', async () =>
telegram: { replyTimeoutMs: 60_000 },
discord: { replyTimeoutMs: 60_000 },
whatsapp: { replyTimeoutMs: 60_000 },
office: { heartbeatSeconds: 30 },
};
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' }],
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }],
['whatsapp', ctx, { ...config.whatsapp, rpcAuthority: 'trusted-host' }],
['office', ctx, { ...config.office, rpcAuthority: 'trusted-host' }],
]);
});