mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 01:53:21 +08:00
feat: secure Telegram access and connect AI Office
This commit is contained in:
parent
e3ae772106
commit
b0606159e0
30 changed files with 2045 additions and 302 deletions
34
README.en.md
34
README.en.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
36
README.md
36
README.md
|
|
@ -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 或原始用户标识。
|
||||
|
||||
## 本地开发
|
||||
|
||||
|
|
|
|||
720
lib/client.js
720
lib/client.js
File diff suppressed because it is too large
Load diff
253
lib/index.js
253
lib/index.js
File diff suppressed because one or more lines are too long
32
package-lock.json
generated
32
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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' }));
|
||||
}
|
||||
|
|
|
|||
44
plugin-src/client/channels/office/api.js
Normal file
44
plugin-src/client/channels/office/api.js
Normal 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 };
|
||||
150
plugin-src/client/channels/office/index.js
Normal file
150
plugin-src/client/channels/office/index.js
Normal 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 故障。'));
|
||||
}
|
||||
50
plugin-src/client/channels/office/styles.js
Normal file
50
plugin-src/client/channels/office/styles.js
Normal 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();
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
10
plugin-src/host/channels/office/index.mjs
Normal file
10
plugin-src/host/channels/office/index.mjs
Normal 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;
|
||||
}
|
||||
29
plugin-src/host/channels/office/production.mjs
Normal file
29
plugin-src/host/channels/office/production.mjs
Normal 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 };
|
||||
}
|
||||
48
plugin-src/host/channels/office/rpc.mjs
Normal file
48
plugin-src/host/channels/office/rpc.mjs
Normal 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) },
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
107
src/channels/office/config-store.mjs
Normal file
107
src/channels/office/config-store.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
167
src/channels/office/office-controller.mjs
Normal file
167
src/channels/office/office-controller.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
132
src/channels/office/office-runtime.mjs
Normal file
132
src/channels/office/office-runtime.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
118
src/channels/office/office-transport.mjs
Normal file
118
src/channels/office/office-transport.mjs
Normal 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');
|
||||
}
|
||||
}
|
||||
43
src/channels/office/protocol.mjs
Normal file
43
src/channels/office/protocol.mjs
Normal 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(),
|
||||
]));
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
198
test/channels/office/office.test.mjs
Normal file
198
test/channels/office/office.test.mjs
Normal 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/);
|
||||
});
|
||||
20
test/channels/telegram/production.test.mjs
Normal file
20
test/channels/telegram/production.test.mjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>/);
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue