mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
Merge pull request #20 from mrpompy/local/telegram-private-allowlist
feat: connect AI Office jobs to local Harness
This commit is contained in:
commit
7f114e1c36
31 changed files with 2639 additions and 314 deletions
38
README.en.md
38
README.en.md
|
|
@ -35,7 +35,7 @@
|
||||||
|
|
||||||
## Introduction
|
## Introduction
|
||||||
|
|
||||||
Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest, or entering existing bot credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp. **Every channel supports multiple bots**, each with independent connection state, workspace, and session bindings. It also supports switching workspaces and rebinding sessions.
|
Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest, or entering existing bot credentials, and let the local Harness connect outward to a public AI Office. One plugin and one settings entry manage nine multi-bot IM channels and the AI Office Connector.
|
||||||
|
|
||||||
## Interface
|
## Interface
|
||||||
|
|
||||||
|
|
@ -51,7 +51,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
|
||||||
| WeCom | Create an intelligent bot by QR code, or bind one with Bot ID + Secret | Official WebSocket connection; native thinking state, tool progress, and streaming replies |
|
| WeCom | Create an intelligent bot by QR code, or bind one with Bot ID + Secret | Official WebSocket connection; native thinking state, tool progress, and streaming replies |
|
||||||
| QQ | Create a bot with mobile QQ QR scanning, or bind one with AppID + AppSecret | WebSocket connection; native typing and streaming replies in private chats, replies when mentioned in groups |
|
| QQ | Create a bot with mobile QQ QR scanning, or bind one with AppID + AppSecret | WebSocket connection; native typing and streaming replies in private chats, replies when mentioned in groups |
|
||||||
| Slack | Create an app from the bundled App Manifest, then enter a Bot Token (`xoxb-`) and App Token (`xapp-`) | Socket Mode connection; direct DM replies, mention-only channel replies, and preferred native streaming API |
|
| Slack | Create an app from the bundled App Manifest, then enter a Bot Token (`xoxb-`) and App Token (`xapp-`) | Socket Mode connection; direct DM replies, mention-only channel replies, and preferred native streaming API |
|
||||||
| Telegram | Enter a Bot Token generated by @BotFather | Bot API long polling; direct private replies, mention-or-reply group handling, and streaming through message edits |
|
| Telegram | Enter a Bot Token generated by @BotFather | Bot API long polling; all groups ignored, private chats restricted to numeric IDs in `telegram.allowedUsers`, and streaming through message edits |
|
||||||
| Discord | Enter a Bot Token generated in the Developer Portal | Gateway v10 connection; direct DM replies, mention-only server replies, and streaming through message edits |
|
| Discord | Enter a Bot Token generated in the Developer Portal | Gateway v10 connection; direct DM replies, mention-only server replies, and streaming through message edits |
|
||||||
| WhatsApp | Scan a QR code with mobile WhatsApp to link a device | WhatsApp Web connection; read receipt and typing indicator followed by the final answer |
|
| WhatsApp | Scan a QR code with mobile WhatsApp to link a device | WhatsApp Web connection; read receipt and typing indicator followed by the final answer |
|
||||||
|
|
||||||
|
|
@ -59,6 +59,16 @@ Other IM platforms can be added through the same channel-adapter structure.
|
||||||
|
|
||||||
All nine built-in channels can send JPEG, PNG, and WebP images, plus GIFs sent as image files, with optional captions to Harness. Each image is limited to 5 MB, and images in one message are limited to 20 MB in total.
|
All nine built-in channels can send JPEG, PNG, and WebP images, plus GIFs sent as image files, with optional captions to Harness. Each image is limited to 5 MB, and images in one message are limited to 20 MB in total.
|
||||||
|
|
||||||
|
## AI Office Connector
|
||||||
|
|
||||||
|
The **AI Office** page lets the local Harness connect outward to a public Office. The machine needs no public IP, forwarded port, or WebSocket server. The Device Token is written only to the Harness credential provider; the ordinary config file contains only the device ID, Office origin, workspace aliases, and instruction-preset aliases. Office selects aliases and never receives local absolute paths.
|
||||||
|
|
||||||
|
The current protocol is `office-harness.v1`. The connector authenticates and advertises capabilities with `POST /api/harness/connector/heartbeat`, then opens the downstream event plane with `GET /api/harness/connector/stream` over SSE. The settings page derives every fixed hook from the Office Base URL and reconnects with backoff after a disconnect.
|
||||||
|
|
||||||
|
A `job.available` event makes the local connector fetch the payload, validate Workspace/Preset aliases, claim a 90-second lease, and renew it every 30 seconds. It creates an isolated Harness Session, reports safe status/tool/text progress, and writes a terminal result exactly once. Tool approvals and follow-up questions surface in Office; approve, reject, and text answers return over SSE to the original Session. Heartbeats and leases recover from dropped connections.
|
||||||
|
|
||||||
|
A successful heartbeat response must be JSON: `{"ok":true,"protocolVersion":"office-harness.v1"}`. This makes a successful connection test proof of a compatible Office Connector instead of any URL that happens to return 200.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Install the published stable release from npm (recommended):
|
Install the published stable release from npm (recommended):
|
||||||
|
|
@ -84,6 +94,18 @@ After installation, follow the built-in instructions on each channel page to sca
|
||||||
| Bot workspace | Each bot stores its workspace independently. New bots start with the Host's current working directory, which can later be changed from the bot card. |
|
| Bot workspace | Each bot stores its workspace independently. New bots start with the Host's current working directory, which can later be changed from the bot card. |
|
||||||
| Agent Preset | New Sessions inherit Harness's `agent-presets.default` unless the channel explicitly overrides it. Later changes do not affect existing Sessions. |
|
| Agent Preset | New Sessions inherit Harness's `agent-presets.default` unless the channel explicitly overrides it. Later changes do not affect existing Sessions. |
|
||||||
|
|
||||||
|
Telegram denies all inbound messages by default. Configure the numeric User IDs allowed to message the bot privately in the Web profile's `cordis.patch.yml`; group messages are ignored even when they mention the bot:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: xmanrui-dsh-im
|
||||||
|
config:
|
||||||
|
telegram:
|
||||||
|
allowedUsers:
|
||||||
|
- 123456789
|
||||||
|
```
|
||||||
|
|
||||||
|
`allowedUsers` accepts numbers or decimal strings, removes duplicates, and rejects invalid values when the plugin starts. An empty allowlist remains deny-all.
|
||||||
|
|
||||||
## Bot commands
|
## Bot commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|
@ -124,7 +146,7 @@ Example: send `/models`, then `/model 2` to switch to the second model in the li
|
||||||
- `/session` accepts exactly one Session ID obtained from `/sessionlist`. It neither creates a session nor immediately prompts the model; later messages in the current chat continue the bound session. Regular archived sessions can be bound without being unarchived, while subagent sessions cannot be bound.
|
- `/session` accepts exactly one Session ID obtained from `/sessionlist`. It neither creates a session nor immediately prompts the model; later messages in the current chat continue the bound session. Regular archived sessions can be bound without being unarchived, while subagent sessions cannot be bound.
|
||||||
- `/session` locates the session's unique workspace automatically. Binding inside the current workspace replaces only this chat's mapping. A cross-workspace binding switches the bot workspace, clears the old session mappings for all of that bot's chats, and then binds this chat, so it affects the bot's other chats. A reply already being generated may still finish.
|
- `/session` locates the session's unique workspace automatically. Binding inside the current workspace replaces only this chat's mapping. A cross-workspace binding switches the bot workspace, clears the old session mappings for all of that bot's chats, and then binds this chat, so it affects the bot's other chats. A reply already being generated may still finish.
|
||||||
- Workspace switches and session bindings only clear or replace dsh-im chat mappings. They never delete, empty, or archive old Session contents; an old Session can still be listed and bound again.
|
- Workspace switches and session bindings only clear or replace dsh-im chat mappings. They never delete, empty, or archive old Session contents; an old Session can still be listed and bound again.
|
||||||
- Any user who is already within the platform bot's visibility scope and can normally message it can run these commands; there is no additional administrator/ordinary-user distinction.
|
- Except on Telegram, any user who is already within the platform bot's visibility scope and can normally message it can run these commands; Telegram admits only private users in `telegram.allowedUsers` and always ignores group commands.
|
||||||
- The list comes from the Harness Host's global registry and can include local absolute paths for other bots, other channels, or non-IM projects. Restrict the bot's visibility to trusted users.
|
- The list comes from the Harness Host's global registry and can include local absolute paths for other bots, other channels, or non-IM projects. Restrict the bot's visibility to trusted users.
|
||||||
- Session results also come from the global Harness Host. Session IDs and titles can belong to other bots, other channels, or non-IM projects, and may contain sensitive metadata. Enable these commands only when every user in the bot's visibility scope is trusted.
|
- Session results also come from the global Harness Host. Session IDs and titles can belong to other bots, other channels, or non-IM projects, and may contain sensitive metadata. Enable these commands only when every user in the bot's visibility scope is trusted.
|
||||||
- Any user who can run `/session` can continue the selected session and use later messages to write to it or invoke its available tools. Expose the bot and session list only to trusted users.
|
- Any user who can run `/session` can continue the selected session and use later messages to write to it or invoke its available tools. Expose the bot and session list only to trusted users.
|
||||||
|
|
@ -142,12 +164,12 @@ Example: send `/models`, then `/model 2` to switch to the second model in the li
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
- Registers a single **IM Bot** settings page in Harness.
|
- Registers one **IM Bot** settings page containing nine IM channels and one AI Office Connector.
|
||||||
- Maintains all nine channel Host, client, and runtime sources in this repository without external standalone channel plugins.
|
- Maintains the Host, client, and runtime sources for all nine channels and the Office Connector in this repository without external standalone plugins.
|
||||||
- Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English.
|
- Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English.
|
||||||
- Uses channel logos for WeChat, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp navigation without enable/disable switches.
|
- Uses logos for WeChat, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, WhatsApp, and AI Office navigation without enable/disable switches.
|
||||||
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by channel.
|
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by IM channel; the Office Connector separately owns Device credentials, Job leases, approval waits, and concurrency limits.
|
||||||
- Returns only QR codes, the public Slack Manifest, and redacted status data to the browser. Manually entered secrets and Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Slack Bot/App Tokens, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, or raw user identifiers.
|
- Returns only QR codes, the public Slack Manifest, and redacted status data to the browser. Manually entered secrets and Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Slack Bot/App Tokens, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, AI Office Device Tokens, or raw user identifiers.
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
|
|
|
||||||
40
README.md
40
README.md
|
|
@ -36,9 +36,9 @@
|
||||||
|
|
||||||
## 简介
|
## 简介
|
||||||
|
|
||||||
通过扫码、App Manifest 或已有机器人凭据把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord 和 WhatsApp。**每个渠道都支持接入多个机器人**,各机器人的连接状态、工作区和会话绑定彼此独立。支持切换工作区和重新绑定会话。
|
通过扫码、App Manifest 或已有机器人凭据把 IM 机器人接入 DeepSeek Harness,并让本机 Harness 主动连接公网 AI Office。一个插件、一个设置入口,统一管理九种 IM 渠道和 AI Office Connector。**每个 IM 渠道都支持接入多个机器人**,各机器人的连接状态、工作区和会话绑定彼此独立。
|
||||||
|
|
||||||
Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest, or entering existing bot credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, QQ, Slack, Telegram, Discord, and WhatsApp. **Every channel supports multiple bots**, each with independent connection state, workspace, and session bindings. It also supports switching workspaces and rebinding sessions.
|
Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest, or entering existing bot credentials, and let the local Harness connect outward to a public AI Office. One plugin and one settings entry manage nine multi-bot IM channels and the AI Office Connector.
|
||||||
|
|
||||||
## 界面
|
## 界面
|
||||||
|
|
||||||
|
|
@ -54,7 +54,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
|
||||||
| 企业微信 | 使用企业微信 App 扫码创建智能机器人,或使用 Bot ID + Secret 手动绑定 | 官方 WebSocket 长连接;原生显示“正在思考中”、工具执行进度和流式回答 |
|
| 企业微信 | 使用企业微信 App 扫码创建智能机器人,或使用 Bot ID + Secret 手动绑定 | 官方 WebSocket 长连接;原生显示“正在思考中”、工具执行进度和流式回答 |
|
||||||
| QQ | 使用手机 QQ 扫码创建机器人,或使用 AppID + AppSecret 手动绑定 | WebSocket 长连接;私聊显示“正在输入”和流式回答,群聊被 @ 后回复 |
|
| QQ | 使用手机 QQ 扫码创建机器人,或使用 AppID + AppSecret 手动绑定 | WebSocket 长连接;私聊显示“正在输入”和流式回答,群聊被 @ 后回复 |
|
||||||
| Slack | 使用预置 App Manifest 创建应用,再填写 Bot Token(`xoxb-`)和 App Token(`xapp-`) | Socket Mode 长连接;私聊直接回复,频道被 @ 后响应,优先使用官方流式消息 API |
|
| Slack | 使用预置 App Manifest 创建应用,再填写 Bot Token(`xoxb-`)和 App Token(`xapp-`) | Socket Mode 长连接;私聊直接回复,频道被 @ 后响应,优先使用官方流式消息 API |
|
||||||
| Telegram | 使用 @BotFather 生成的 Bot Token | Bot API 长轮询;私聊直接回复,群聊被提及或收到对机器人消息的回复时响应,通过编辑消息流式显示回答 |
|
| Telegram | 使用 @BotFather 生成的 Bot Token | Bot API 长轮询;群聊全部忽略,私聊仅接受 `telegram.allowedUsers` 中的数字 User ID,通过编辑消息流式显示回答 |
|
||||||
| Discord | 使用 Developer Portal 生成的 Bot Token | Gateway v10 长连接;私信直接回复,服务器频道被提及时响应,通过编辑消息流式显示回答 |
|
| Discord | 使用 Developer Portal 生成的 Bot Token | Gateway v10 长连接;私信直接回复,服务器频道被提及时响应,通过编辑消息流式显示回答 |
|
||||||
| WhatsApp | 使用手机 WhatsApp 扫码关联设备 | WhatsApp Web 长连接;显示已读和“正在输入”,再发送最终回答 |
|
| WhatsApp | 使用手机 WhatsApp 扫码关联设备 | WhatsApp Web 长连接;显示已读和“正在输入”,再发送最终回答 |
|
||||||
|
|
||||||
|
|
@ -62,6 +62,16 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
|
||||||
|
|
||||||
九个内置渠道均支持把 JPEG、PNG、WebP 图片,以及以图片文件方式发送的 GIF,连同可选文字说明发送给 Harness;单张图片上限为 5 MB,单条消息中的图片总大小上限为 20 MB。
|
九个内置渠道均支持把 JPEG、PNG、WebP 图片,以及以图片文件方式发送的 GIF,连同可选文字说明发送给 Harness;单张图片上限为 5 MB,单条消息中的图片总大小上限为 20 MB。
|
||||||
|
|
||||||
|
## AI Office Connector
|
||||||
|
|
||||||
|
「AI Office」页让本机 Harness 主动连接公网 Office,本机无需公网 IP、端口转发或 WebSocket 服务。Device Token 只写入 Harness 凭据存储;普通配置文件仅保存设备 ID、Office Origin、工作区 alias 和 Instruction Preset alias。Office 只能选择 alias,不会收到本机绝对路径。
|
||||||
|
|
||||||
|
当前协议版本为 `office-harness.v1`。连接器使用 `POST /api/harness/connector/heartbeat` 完成鉴权和能力握手,再以 `GET /api/harness/connector/stream` 建立 SSE 下行;设置页会从 Office Base URL 自动展示全部固定 Hook,并在断线后按退避策略自动重连。
|
||||||
|
|
||||||
|
Office 的 `job.available` 会触发本机拉取任务、校验 Workspace/Preset alias、领取 90 秒租约并每 30 秒续租。连接器创建独立 Harness Session,把状态、工具名和增量文字安全回传 Office,终态只允许写入一次。Harness 发起的工具审批或补充问题会进入 Office 人工面板;批准、拒绝或文字答案再经 SSE 回到原 Session,断线时由租约与 Heartbeat 恢复。
|
||||||
|
|
||||||
|
Heartbeat 成功响应必须是 JSON:`{"ok":true,"protocolVersion":"office-harness.v1"}`。这使「连接测试通过」代表命中了兼容的 Office Connector,而不只是某个碰巧返回 200 的网址。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
推荐从 npm 安装已发布的稳定版本:
|
推荐从 npm 安装已发布的稳定版本:
|
||||||
|
|
@ -87,6 +97,18 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
|
||||||
| 机器人工作区 | 每个机器人独立保存工作区。新机器人默认使用 Host 当时的工作目录;之后可在机器人卡片中修改。 |
|
| 机器人工作区 | 每个机器人独立保存工作区。新机器人默认使用 Host 当时的工作目录;之后可在机器人卡片中修改。 |
|
||||||
| Agent Preset | 新会话默认继承 Harness 的 `agent-presets.default`;渠道显式配置优先,已有会话不受后续修改影响。 |
|
| Agent Preset | 新会话默认继承 Harness 的 `agent-presets.default`;渠道显式配置优先,已有会话不受后续修改影响。 |
|
||||||
|
|
||||||
|
Telegram 默认拒绝所有入站消息。请在 Web profile 的 `cordis.patch.yml` 中配置允许私聊机器人的数字 User ID;群聊无论是否提及机器人都会被忽略:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- id: xmanrui-dsh-im
|
||||||
|
config:
|
||||||
|
telegram:
|
||||||
|
allowedUsers:
|
||||||
|
- 123456789
|
||||||
|
```
|
||||||
|
|
||||||
|
`allowedUsers` 接受数字或十进制字符串、会去重,并在插件启动时拒绝无效值。空白名单保持全部拒绝。
|
||||||
|
|
||||||
## 机器人命令
|
## 机器人命令
|
||||||
|
|
||||||
| 命令 | 作用 |
|
| 命令 | 作用 |
|
||||||
|
|
@ -127,7 +149,7 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
|
||||||
- `/session` 只接受一个由 `/sessionlist` 获得的 Session ID。它不会新建会话或立即向模型发送消息;绑定成功后,当前聊天的后续消息会继续该会话。普通归档会话可以绑定但不会自动取消归档,子代理会话不能绑定。
|
- `/session` 只接受一个由 `/sessionlist` 获得的 Session ID。它不会新建会话或立即向模型发送消息;绑定成功后,当前聊天的后续消息会继续该会话。普通归档会话可以绑定但不会自动取消归档,子代理会话不能绑定。
|
||||||
- `/session` 会自动定位会话唯一所属的工作区。同工作区绑定只替换当前聊天的映射;跨工作区绑定会切换该机器人的工作区、清除该机器人所有聊天的旧会话映射,再绑定当前聊天,因此会影响该机器人的其他聊天。已经开始生成的回复仍可完成。
|
- `/session` 会自动定位会话唯一所属的工作区。同工作区绑定只替换当前聊天的映射;跨工作区绑定会切换该机器人的工作区、清除该机器人所有聊天的旧会话映射,再绑定当前聊天,因此会影响该机器人的其他聊天。已经开始生成的回复仍可完成。
|
||||||
- 工作区切换和会话绑定只会清除或替换 dsh-im 的聊天映射,不会删除、清空或归档任何旧 Session 内容;旧 Session 仍可再次列出和绑定。
|
- 工作区切换和会话绑定只会清除或替换 dsh-im 的聊天映射,不会删除、清空或归档任何旧 Session 内容;旧 Session 仍可再次列出和绑定。
|
||||||
- 任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行这些命令,不区分管理员和普通用户。
|
- 除 Telegram 外,任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行这些命令,不区分管理员和普通用户;Telegram 仅允许 `telegram.allowedUsers` 中的私聊用户执行,群聊命令始终忽略。
|
||||||
- 工作区列表来自 Harness Host 的全局登记信息,可能包含其他机器人、其他渠道或非 IM 项目的本机绝对路径。请将机器人可见范围限制给可信用户。
|
- 工作区列表来自 Harness Host 的全局登记信息,可能包含其他机器人、其他渠道或非 IM 项目的本机绝对路径。请将机器人可见范围限制给可信用户。
|
||||||
- 会话列表同样来自该全局 Harness Host;会话 ID 和标题可能属于其他机器人、其他渠道或非 IM 项目,并可能包含敏感元数据。开放命令前请确保所有可见用户都可信。
|
- 会话列表同样来自该全局 Harness Host;会话 ID 和标题可能属于其他机器人、其他渠道或非 IM 项目,并可能包含敏感元数据。开放命令前请确保所有可见用户都可信。
|
||||||
- 任何能执行 `/session` 的用户都能接续所选会话,并通过后续消息写入会话或触发其可用工具。请只向可信用户开放机器人及其会话列表。
|
- 任何能执行 `/session` 的用户都能接续所选会话,并通过后续消息写入会话或触发其可用工具。请只向可信用户开放机器人及其会话列表。
|
||||||
|
|
@ -145,12 +167,12 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
|
||||||
|
|
||||||
## 设计
|
## 设计
|
||||||
|
|
||||||
- Harness 中只注册一个「IM机器人」设置页;
|
- Harness 中只注册一个「IM机器人」设置页,其中包含九个 IM 渠道和一个 AI Office Connector;
|
||||||
- 九个渠道的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件;
|
- 九个渠道及 Office Connector 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立插件;
|
||||||
- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;
|
- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;
|
||||||
- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord 和 WhatsApp,不使用启用/停用开关;
|
- 左侧使用 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 和 AI Office,不使用启用/停用开关;
|
||||||
- 九个渠道保持独立的 RPC、凭据、连接监督和会话映射;
|
- 九个 IM 渠道保持独立的 RPC、凭据、连接监督和会话映射;Office Connector 另行维护设备凭据、Job 租约、审批等待与并发上限;
|
||||||
- 浏览器只获得二维码、Manifest 和脱敏状态;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥或原始用户标识。
|
- 浏览器只获得二维码、Manifest 和脱敏状态;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥、AI Office Device Token 或原始用户标识。
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
|
|
|
||||||
724
lib/client.js
724
lib/client.js
File diff suppressed because it is too large
Load diff
272
lib/index.js
272
lib/index.js
File diff suppressed because one or more lines are too long
36
package-lock.json
generated
36
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "@xmanrui/dsh-im",
|
"name": "@xmanrui/dsh-im",
|
||||||
"version": "0.13.0",
|
"version": "0.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@xmanrui/dsh-im",
|
"name": "@xmanrui/dsh-im",
|
||||||
"version": "0.13.0",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tencent-connect/qqbot-connector": "1.2.0",
|
"@tencent-connect/qqbot-connector": "1.2.0",
|
||||||
|
|
@ -87,6 +87,7 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
|
|
@ -556,6 +557,7 @@
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -573,6 +575,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
|
|
@ -596,6 +599,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
|
|
@ -616,6 +620,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/sharp-wasm32": "0.35.3"
|
"@img/sharp-wasm32": "0.35.3"
|
||||||
},
|
},
|
||||||
|
|
@ -639,6 +644,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
|
|
@ -656,6 +662,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
|
|
@ -673,6 +680,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
|
|
@ -690,6 +698,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
|
|
@ -707,6 +716,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
|
|
@ -724,6 +734,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
|
|
@ -741,6 +752,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
|
|
@ -758,6 +770,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
|
|
@ -775,6 +788,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
|
|
@ -792,6 +806,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
|
|
@ -809,6 +824,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
|
|
@ -832,6 +848,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
|
|
@ -855,6 +872,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
|
|
@ -878,6 +896,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
|
|
@ -901,6 +920,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
|
|
@ -924,6 +944,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
|
|
@ -947,6 +968,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
|
|
@ -970,6 +992,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
|
|
@ -987,6 +1010,7 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/runtime": "^1.11.1"
|
"@emnapi/runtime": "^1.11.1"
|
||||||
},
|
},
|
||||||
|
|
@ -1007,6 +1031,7 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/sharp-wasm32": "0.35.3"
|
"@img/sharp-wasm32": "0.35.3"
|
||||||
},
|
},
|
||||||
|
|
@ -1030,6 +1055,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
|
|
@ -1050,6 +1076,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.9.0"
|
"node": "^20.9.0"
|
||||||
},
|
},
|
||||||
|
|
@ -1070,6 +1097,7 @@
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
|
|
@ -1543,6 +1571,7 @@
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
|
|
@ -1927,7 +1956,6 @@
|
||||||
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@keyv/serialize": "^1.1.1"
|
"@keyv/serialize": "^1.1.1"
|
||||||
}
|
}
|
||||||
|
|
@ -2370,7 +2398,6 @@
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -2479,6 +2506,7 @@
|
||||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@xmanrui/dsh-im",
|
"name": "@xmanrui/dsh-im",
|
||||||
"version": "0.13.0",
|
"version": "0.14.0",
|
||||||
"description": "通过扫码或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord和WhatsApp)。 Connect IM bots to DeepSeek Harness via QR code or credentials (9 channels).",
|
"description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"deepseek-harness",
|
"deepseek-harness",
|
||||||
"dsh",
|
"dsh",
|
||||||
|
|
@ -19,7 +19,8 @@
|
||||||
"slack",
|
"slack",
|
||||||
"telegram",
|
"telegram",
|
||||||
"discord",
|
"discord",
|
||||||
"whatsapp"
|
"whatsapp",
|
||||||
|
"ai-office"
|
||||||
],
|
],
|
||||||
"author": {
|
"author": {
|
||||||
"name": "xmanrui",
|
"name": "xmanrui",
|
||||||
|
|
|
||||||
|
|
@ -131,3 +131,15 @@ export function WhatsappLogoGlyph({ size } = {}) {
|
||||||
d: 'M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.521.149-.173.198-.297.298-.495.099-.198.05-.372-.025-.521-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51a12.8 12.8 0 0 0-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479s1.065 2.875 1.213 3.074c.149.198 2.095 3.2 5.077 4.487.709.306 1.262.489 1.694.626.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.29.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.981.999-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.83 9.83 0 0 1 2.893 6.991c-.003 5.45-4.437 9.884-9.886 9.884m8.413-18.297A11.8 11.8 0 0 0 12.055 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.9 11.9 0 0 0 5.688 1.448h.005c6.557 0 11.892-5.335 11.895-11.893a11.82 11.82 0 0 0-3.486-8.413',
|
d: 'M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.521.149-.173.198-.297.298-.495.099-.198.05-.372-.025-.521-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51a12.8 12.8 0 0 0-.57-.01c-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479s1.065 2.875 1.213 3.074c.149.198 2.095 3.2 5.077 4.487.709.306 1.262.489 1.694.626.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.29.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.981.999-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.83 9.83 0 0 1 2.893 6.991c-.003 5.45-4.437 9.884-9.886 9.884m8.413-18.297A11.8 11.8 0 0 0 12.055 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.9 11.9 0 0 0 5.688 1.448h.005c6.557 0 11.892-5.335 11.895-11.893a11.82 11.82 0 0 0-3.486-8.413',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OfficeLogoGlyph({ size } = {}) {
|
||||||
|
return h('svg', {
|
||||||
|
...dimensions(size),
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
focusable: 'false',
|
||||||
|
'aria-hidden': 'true',
|
||||||
|
'data-im-channel-logo': 'office',
|
||||||
|
},
|
||||||
|
h('path', { fill: 'currentColor', d: 'M4 3.5h10.5a2 2 0 0 1 2 2v13H4v-15Zm2.2 3v1.8h2V6.5h-2Zm4.1 0v1.8h2V6.5h-2Zm-4.1 4v1.8h2v-1.8h-2Zm4.1 0v1.8h2v-1.8h-2ZM8.4 15v3.5h3V15h-3Z' }),
|
||||||
|
h('path', { fill: 'currentColor', d: 'M18.3 8.2h1.5v3h3v1.5h-3v3h-1.5v-3h-3v-1.5h3v-3Z' }));
|
||||||
|
}
|
||||||
|
|
|
||||||
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 };
|
||||||
152
plugin-src/client/channels/office/index.js
Normal file
152
plugin-src/client/channels/office/index.js
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { h } from '../../i18n.js';
|
||||||
|
import {
|
||||||
|
OFFICE_PROTOCOL_VERSION,
|
||||||
|
OFFICE_RPC_ENDPOINTS,
|
||||||
|
normalizeOfficeStatus,
|
||||||
|
officeHookUrls,
|
||||||
|
unwrapOfficeRpc,
|
||||||
|
} from './api.js';
|
||||||
|
|
||||||
|
function Button({ children, kind = 'secondary', ...props }) {
|
||||||
|
return h('button', { ...props, type: 'button', className: 'ddt-button', 'data-kind': kind }, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapText(value) {
|
||||||
|
return Object.entries(value ?? {}).map(([key, item]) => `${key}=${item}`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMap(value, label) {
|
||||||
|
const output = {};
|
||||||
|
for (const raw of value.split(/\r?\n/)) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
const index = line.indexOf('=');
|
||||||
|
if (index < 1 || !line.slice(index + 1).trim()) {
|
||||||
|
throw new Error(label === 'Workspace 映射'
|
||||||
|
? 'Workspace 映射每行必须使用 alias=value'
|
||||||
|
: 'Instruction Preset 映射每行必须使用 alias=value');
|
||||||
|
}
|
||||||
|
output[line.slice(0, index).trim()] = line.slice(index + 1).trim();
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateLabel(model) {
|
||||||
|
if (model.connected) return '已连接 Office';
|
||||||
|
if (!model.configured) return '尚未配置';
|
||||||
|
if (model.state === 'connecting') return '正在连接';
|
||||||
|
if (model.state === 'reconnecting') return '等待重连';
|
||||||
|
if (model.state === 'missing-token') return '凭据缺失';
|
||||||
|
return '已配置';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OfficeSettingsTab({ rpcCall, initialStatus }) {
|
||||||
|
const [model, setModel] = React.useState(normalizeOfficeStatus(initialStatus));
|
||||||
|
const [phase, setPhase] = React.useState(initialStatus === undefined ? 'loading' : 'ready');
|
||||||
|
const [busy, setBusy] = React.useState('');
|
||||||
|
const [error, setError] = React.useState('');
|
||||||
|
const [notice, setNotice] = React.useState('');
|
||||||
|
const [form, setForm] = React.useState({
|
||||||
|
baseUrl: 'https://fission.gridmind.ai', deviceId: 'local-harness', deviceToken: '',
|
||||||
|
maxConcurrency: '1', heartbeatSeconds: '30', workspaces: '', instructionPresets: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoke = React.useCallback(async (endpoint, payload = {}) => {
|
||||||
|
if (typeof rpcCall !== 'function') throw new Error('AI Office 设置页缺少 RPC 连接');
|
||||||
|
return unwrapOfficeRpc(await rpcCall(endpoint, payload));
|
||||||
|
}, [rpcCall]);
|
||||||
|
|
||||||
|
const adopt = React.useCallback((value) => {
|
||||||
|
const next = normalizeOfficeStatus(value?.snapshot ?? value);
|
||||||
|
setModel(next);
|
||||||
|
if (next.config) setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
baseUrl: next.config.baseUrl,
|
||||||
|
deviceId: next.config.deviceId,
|
||||||
|
maxConcurrency: String(next.config.maxConcurrency),
|
||||||
|
heartbeatSeconds: String(next.config.heartbeatSeconds),
|
||||||
|
workspaces: mapText(next.config.workspaces),
|
||||||
|
instructionPresets: mapText(next.config.instructionPresets),
|
||||||
|
deviceToken: '',
|
||||||
|
}));
|
||||||
|
return next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = React.useCallback(async () => {
|
||||||
|
try { adopt(await invoke(OFFICE_RPC_ENDPOINTS.status)); setPhase('ready'); setError(''); }
|
||||||
|
catch (caught) { setPhase('error'); setError(caught.message); }
|
||||||
|
}, [adopt, invoke]);
|
||||||
|
|
||||||
|
React.useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
const run = async (name, operation) => {
|
||||||
|
setBusy(name); setError(''); setNotice('');
|
||||||
|
try { const value = await operation(); adopt(value); setNotice(name === 'test' ? '连接测试通过。' : '配置已保存。'); }
|
||||||
|
catch (caught) { setError(caught.message); }
|
||||||
|
finally { setBusy(''); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const hooks = React.useMemo(() => {
|
||||||
|
try { return officeHookUrls(form.baseUrl); } catch { return {}; }
|
||||||
|
}, [form.baseUrl]);
|
||||||
|
const health = model.health ?? {};
|
||||||
|
|
||||||
|
if (phase === 'loading') return h('div', { className: 'ddt-card ddt-loading', 'aria-busy': 'true' }, '正在读取 AI Office Connector…');
|
||||||
|
|
||||||
|
return h('section', { className: 'dof-page', 'aria-label': 'AI Office 设置' },
|
||||||
|
h('div', { className: 'dof-hero' },
|
||||||
|
h('div', { className: 'dof-heroCopy' },
|
||||||
|
h('h3', null, 'AI Office Connector'),
|
||||||
|
h('p', null, '本机主动连接公网 Office;Harness 不开放端口。协议 Hook 固定为 ', OFFICE_PROTOCOL_VERSION, '。')),
|
||||||
|
h('span', { className: 'dof-status', 'data-connected': String(model.connected) },
|
||||||
|
h('span', { className: 'dof-dot' }), stateLabel(model))),
|
||||||
|
model.configured ? h('div', { className: 'dof-metrics' },
|
||||||
|
h('div', { className: 'dof-metric' }, h('span', null, '最近心跳'), h('strong', null, health.lastHeartbeatAt ?? '尚无')),
|
||||||
|
h('div', { className: 'dof-metric' }, h('span', null, '最近事件'), h('strong', null, health.lastEventType ?? '尚无')),
|
||||||
|
h('div', { className: 'dof-metric' }, h('span', null, '重连次数'), h('strong', null, String(health.reconnects ?? 0))),
|
||||||
|
h('div', { className: 'dof-metric' }, h('span', null, 'Job Offer'), h('strong', null, String(health.jobsOffered ?? 0))),
|
||||||
|
h('div', { className: 'dof-metric' }, h('span', null, '运行 Job'), h('strong', null, String(health.jobs?.running ?? 0))),
|
||||||
|
h('div', { className: 'dof-metric' }, h('span', null, '完成 Job'), h('strong', null, String(health.jobs?.completed ?? 0)))) : null,
|
||||||
|
h('div', { className: 'dof-card' },
|
||||||
|
h('div', { className: 'dof-cardTitle' }, h('h4', null, '设备连接'), h('span', null, 'Token 只写入本机凭据存储')),
|
||||||
|
h('div', { className: 'dof-grid' },
|
||||||
|
h('label', { className: 'dof-field', 'data-wide': 'true' }, 'Office Base URL',
|
||||||
|
h('input', { value: form.baseUrl, placeholder: 'https://fission.gridmind.ai', onChange: (event) => setForm({ ...form, baseUrl: event.target.value }) })),
|
||||||
|
h('label', { className: 'dof-field' }, 'Device ID',
|
||||||
|
h('input', { value: form.deviceId, placeholder: 'local-harness', onChange: (event) => setForm({ ...form, deviceId: event.target.value }) })),
|
||||||
|
h('label', { className: 'dof-field' }, 'Device Token',
|
||||||
|
h('input', { type: 'password', value: form.deviceToken, placeholder: model.tokenConfigured ? '已安全保存;留空保持不变' : '粘贴 Office 一次性凭据', autoComplete: 'new-password', onChange: (event) => setForm({ ...form, deviceToken: event.target.value }) })),
|
||||||
|
h('label', { className: 'dof-field' }, '最大并发',
|
||||||
|
h('input', { type: 'number', min: 1, max: 4, value: form.maxConcurrency, onChange: (event) => setForm({ ...form, maxConcurrency: event.target.value }) })),
|
||||||
|
h('label', { className: 'dof-field' }, 'Heartbeat 秒数',
|
||||||
|
h('input', { type: 'number', min: 10, max: 300, value: form.heartbeatSeconds, onChange: (event) => setForm({ ...form, heartbeatSeconds: event.target.value }) })),
|
||||||
|
h('label', { className: 'dof-field', 'data-wide': 'true' }, 'Workspace 映射',
|
||||||
|
h('textarea', { value: form.workspaces, placeholder: 'office-project=/Users/you/projects/ai-office', onChange: (event) => setForm({ ...form, workspaces: event.target.value }) }),
|
||||||
|
h('small', null, '每行 alias=/本机/绝对路径;Office 只能看到 alias。')),
|
||||||
|
h('label', { className: 'dof-field', 'data-wide': 'true' }, 'Instruction Preset 映射',
|
||||||
|
h('textarea', { value: form.instructionPresets, placeholder: 'action-items=转换为负责人、截止和验收明确的工单', onChange: (event) => setForm({ ...form, instructionPresets: event.target.value }) }),
|
||||||
|
h('small', null, '每行 alias=指令;新增 preset 不需要改 Office 代码。'))),
|
||||||
|
error ? h('p', { className: 'dof-error', role: 'alert' }, error) : null,
|
||||||
|
notice ? h('p', { className: 'dof-notice', role: 'status' }, notice) : null,
|
||||||
|
health.error?.message ? h('p', { className: 'dof-error' }, health.error.message) : null,
|
||||||
|
h('div', { className: 'dof-actions' },
|
||||||
|
h(Button, { kind: 'primary', disabled: Boolean(busy), onClick: () => void run('save', () => invoke(OFFICE_RPC_ENDPOINTS.configure, {
|
||||||
|
baseUrl: form.baseUrl,
|
||||||
|
deviceId: form.deviceId,
|
||||||
|
...(form.deviceToken ? { deviceToken: form.deviceToken } : {}),
|
||||||
|
maxConcurrency: Number(form.maxConcurrency),
|
||||||
|
heartbeatSeconds: Number(form.heartbeatSeconds),
|
||||||
|
workspaces: parseMap(form.workspaces, 'Workspace 映射'),
|
||||||
|
instructionPresets: parseMap(form.instructionPresets, 'Instruction Preset 映射'),
|
||||||
|
})) }, busy === 'save' ? '保存中…' : '保存并连接'),
|
||||||
|
h(Button, { disabled: !model.configured || Boolean(busy), onClick: () => void run('test', () => invoke(OFFICE_RPC_ENDPOINTS.test)) }, busy === 'test' ? '测试中…' : '测试连接'),
|
||||||
|
h(Button, { disabled: !model.configured || Boolean(busy), onClick: () => void run('reconnect', () => invoke(OFFICE_RPC_ENDPOINTS.reconnect)) }, '重新连接'),
|
||||||
|
h(Button, { kind: 'danger', disabled: !model.configured || Boolean(busy), onClick: () => void run('remove', () => invoke(OFFICE_RPC_ENDPOINTS.remove, { confirm: true })) }, '移除连接'))),
|
||||||
|
h('div', { className: 'dof-card' },
|
||||||
|
h('div', { className: 'dof-cardTitle' }, h('h4', null, '协议 Hook 预览'), h('span', null, '由 Base URL 自动派生,不单独填写')),
|
||||||
|
h('div', { className: 'dof-hooks' },
|
||||||
|
[['SSE', hooks.stream], ['Heartbeat', hooks.heartbeat], ['Job', hooks.job], ['Result', hooks.result]].map(([label, url]) => h('div', { className: 'dof-hook', key: label }, h('strong', null, label), h('code', null, url ?? 'Base URL 无效'))))),
|
||||||
|
h('p', { className: 'dof-notice' }, 'Office Hook 尚未部署时,配置会安全保存并自动重试;出现 HTTP 404 代表协议端点待上线,不代表 Harness 故障。'));
|
||||||
|
}
|
||||||
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(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
|
||||||
|
.dof-metric { min-width: 0; padding: 9px; border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
||||||
|
.dof-metric span { display: block; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; }
|
||||||
|
.dof-metric strong { display: block; overflow: hidden; margin-top: 4px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
@container (max-width: 680px) { .dof-grid { grid-template-columns: minmax(0, 1fr); } .dof-field[data-wide="true"] { grid-column: auto; } .dof-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .dof-page * { transition: none !important; } }
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function installOfficeStyles() {
|
||||||
|
if (typeof document === 'undefined') return () => {};
|
||||||
|
if (document.querySelector(`style[data-plugin-css="${OFFICE_STYLE_ID}"]`)) return () => {};
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.dataset.pluginCss = OFFICE_STYLE_ID;
|
||||||
|
style.textContent = CSS;
|
||||||
|
document.head.append(style);
|
||||||
|
return () => style.remove();
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,51 @@ const EN = Object.freeze({
|
||||||
'IM机器人设置': 'IM bot settings',
|
'IM机器人设置': 'IM bot settings',
|
||||||
'IM 渠道': 'IM channels',
|
'IM 渠道': 'IM channels',
|
||||||
'让 DeepSeek Harness 触手可及': 'DeepSeek Harness, always within reach',
|
'让 DeepSeek Harness 触手可及': 'DeepSeek Harness, always within reach',
|
||||||
|
'AI Office': 'AI Office',
|
||||||
|
'AI Office 设置': 'AI Office settings',
|
||||||
|
'AI Office 设置页缺少 RPC 连接': 'AI Office settings are missing an RPC connection',
|
||||||
|
'正在读取 AI Office Connector…': 'Loading AI Office Connector…',
|
||||||
|
'本机主动连接公网 Office;Harness 不开放端口。协议 Hook 固定为 ': 'This machine connects outward to the public Office; Harness exposes no port. Protocol hooks: ',
|
||||||
|
'尚未配置': 'Not configured',
|
||||||
|
'已连接 Office': 'Connected to Office',
|
||||||
|
'已配置': 'Configured',
|
||||||
|
'等待重连': 'Waiting to reconnect',
|
||||||
|
'凭据缺失': 'Credential missing',
|
||||||
|
'最近心跳': 'Last heartbeat',
|
||||||
|
'最近事件': 'Last event',
|
||||||
|
'重连次数': 'Reconnects',
|
||||||
|
'运行 Job': 'Running Jobs',
|
||||||
|
'完成 Job': 'Completed Jobs',
|
||||||
|
'尚无': 'None yet',
|
||||||
|
'设备连接': 'Device connection',
|
||||||
|
'Token 只写入本机凭据存储': 'Token is written only to the local credential store',
|
||||||
|
'粘贴 Office 一次性凭据': 'Paste the one-time Office credential',
|
||||||
|
'已安全保存;留空保持不变': 'Stored securely; leave blank to keep it',
|
||||||
|
'最大并发': 'Max concurrency',
|
||||||
|
'Heartbeat 秒数': 'Heartbeat seconds',
|
||||||
|
'Workspace 映射': 'Workspace mappings',
|
||||||
|
'每行 alias=/本机/绝对路径;Office 只能看到 alias。': 'One alias=/local/absolute/path per line; Office sees only aliases.',
|
||||||
|
'Instruction Preset 映射': 'Instruction preset mappings',
|
||||||
|
'每行 alias=指令;新增 preset 不需要改 Office 代码。': 'One alias=instruction per line; new presets require no Office code change.',
|
||||||
|
'保存并连接': 'Save and connect',
|
||||||
|
'测试连接': 'Test connection',
|
||||||
|
'测试中…': 'Testing…',
|
||||||
|
'重新连接': 'Reconnect',
|
||||||
|
'移除连接': 'Remove connection',
|
||||||
|
'连接测试通过。': 'Connection test passed.',
|
||||||
|
'配置已保存。': 'Configuration saved.',
|
||||||
|
'协议 Hook 预览': 'Protocol hook preview',
|
||||||
|
'由 Base URL 自动派生,不单独填写': 'Derived from Base URL; no separate input',
|
||||||
|
'Base URL 无效': 'Invalid Base URL',
|
||||||
|
'Office Hook 尚未部署时,配置会安全保存并自动重试;出现 HTTP 404 代表协议端点待上线,不代表 Harness 故障。': 'Configuration is saved and retried while Office hooks are unavailable; HTTP 404 means the protocol endpoint is pending, not a Harness failure.',
|
||||||
|
'Workspace 映射每行必须使用 alias=value': 'Each workspace mapping must use alias=value',
|
||||||
|
'Instruction Preset 映射每行必须使用 alias=value': 'Each instruction preset mapping must use alias=value',
|
||||||
|
'action-items=转换为负责人、截止和验收明确的工单': 'action-items=Turn this into accountable tasks with deadlines and acceptance criteria',
|
||||||
|
'AI Office 拒绝了 Device Token。': 'AI Office rejected the Device Token.',
|
||||||
|
'AI Office Connector Hook 尚未就绪。': 'AI Office Connector hooks are not available yet.',
|
||||||
|
'AI Office Connector 协议版本不兼容。': 'The AI Office Connector protocol is incompatible.',
|
||||||
|
'本机暂时无法访问 AI Office。': 'AI Office cannot currently be reached from this machine.',
|
||||||
|
'AI Office 连接已中断。': 'The AI Office connection was interrupted.',
|
||||||
'帮助与反馈 · 前往 GitHub': 'Help & feedback · Open GitHub',
|
'帮助与反馈 · 前往 GitHub': 'Help & feedback · Open GitHub',
|
||||||
'微信': 'WeChat',
|
'微信': 'WeChat',
|
||||||
'飞书': 'Feishu',
|
'飞书': 'Feishu',
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
DingtalkLogoGlyph,
|
DingtalkLogoGlyph,
|
||||||
DiscordLogoGlyph,
|
DiscordLogoGlyph,
|
||||||
FeishuLogoGlyph,
|
FeishuLogoGlyph,
|
||||||
|
OfficeLogoGlyph,
|
||||||
QqLogoGlyph,
|
QqLogoGlyph,
|
||||||
SlackLogoGlyph,
|
SlackLogoGlyph,
|
||||||
TelegramLogoGlyph,
|
TelegramLogoGlyph,
|
||||||
|
|
@ -22,6 +23,9 @@ import { installFeishuStyles } from './channels/feishu/styles.js';
|
||||||
import { QQ_RPC_CHANNEL } from './channels/qq/api.js';
|
import { QQ_RPC_CHANNEL } from './channels/qq/api.js';
|
||||||
import { QqSettingsTab } from './channels/qq/index.js';
|
import { QqSettingsTab } from './channels/qq/index.js';
|
||||||
import { installQqStyles } from './channels/qq/styles.js';
|
import { installQqStyles } from './channels/qq/styles.js';
|
||||||
|
import { OFFICE_RPC_CHANNEL } from './channels/office/api.js';
|
||||||
|
import { OfficeSettingsTab } from './channels/office/index.js';
|
||||||
|
import { installOfficeStyles } from './channels/office/styles.js';
|
||||||
import { SLACK_RPC_CHANNEL } from './channels/slack/api.js';
|
import { SLACK_RPC_CHANNEL } from './channels/slack/api.js';
|
||||||
import { SlackSettingsTab } from './channels/slack/index.js';
|
import { SlackSettingsTab } from './channels/slack/index.js';
|
||||||
import { installSlackStyles } from './channels/slack/styles.js';
|
import { installSlackStyles } from './channels/slack/styles.js';
|
||||||
|
|
@ -54,6 +58,7 @@ const CHANNELS = Object.freeze([
|
||||||
{ id: 'telegram', label: 'Telegram' },
|
{ id: 'telegram', label: 'Telegram' },
|
||||||
{ id: 'discord', label: 'Discord' },
|
{ id: 'discord', label: 'Discord' },
|
||||||
{ id: 'whatsapp', label: 'WhatsApp' },
|
{ id: 'whatsapp', label: 'WhatsApp' },
|
||||||
|
{ id: 'office', label: 'AI Office' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function WeixinLogo() {
|
function WeixinLogo() {
|
||||||
|
|
@ -99,6 +104,11 @@ function WhatsappLogo() {
|
||||||
h(WhatsappLogoGlyph));
|
h(WhatsappLogoGlyph));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OfficeLogo() {
|
||||||
|
return h('span', { className: 'dim-logo dim-logoOffice', 'aria-hidden': 'true' },
|
||||||
|
h(OfficeLogoGlyph));
|
||||||
|
}
|
||||||
|
|
||||||
function ChannelLogo({ channel }) {
|
function ChannelLogo({ channel }) {
|
||||||
if (channel === 'weixin') return h(WeixinLogo);
|
if (channel === 'weixin') return h(WeixinLogo);
|
||||||
if (channel === 'feishu') return h(FeishuLogo);
|
if (channel === 'feishu') return h(FeishuLogo);
|
||||||
|
|
@ -108,7 +118,8 @@ function ChannelLogo({ channel }) {
|
||||||
if (channel === 'slack') return h(SlackLogo);
|
if (channel === 'slack') return h(SlackLogo);
|
||||||
if (channel === 'telegram') return h(TelegramLogo);
|
if (channel === 'telegram') return h(TelegramLogo);
|
||||||
if (channel === 'discord') return h(DiscordLogo);
|
if (channel === 'discord') return h(DiscordLogo);
|
||||||
return h(WhatsappLogo);
|
if (channel === 'whatsapp') return h(WhatsappLogo);
|
||||||
|
return h(OfficeLogo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IMSettingsTab({
|
export function IMSettingsTab({
|
||||||
|
|
@ -121,6 +132,7 @@ export function IMSettingsTab({
|
||||||
wecomRpcCall,
|
wecomRpcCall,
|
||||||
weixinRpcCall,
|
weixinRpcCall,
|
||||||
whatsappRpcCall,
|
whatsappRpcCall,
|
||||||
|
officeRpcCall,
|
||||||
workspaceDirectoryPicker,
|
workspaceDirectoryPicker,
|
||||||
}) {
|
}) {
|
||||||
const [selected, setSelected] = React.useState('weixin');
|
const [selected, setSelected] = React.useState('weixin');
|
||||||
|
|
@ -187,7 +199,9 @@ export function IMSettingsTab({
|
||||||
? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
|
? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
|
||||||
: active.id === 'discord'
|
: active.id === 'discord'
|
||||||
? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
|
? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
|
||||||
: h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })),
|
: active.id === 'whatsapp'
|
||||||
|
? h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })
|
||||||
|
: h(OfficeSettingsTab, { rpcCall: officeRpcCall })),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -210,6 +224,7 @@ export function apply(ctx) {
|
||||||
installTelegramStyles(),
|
installTelegramStyles(),
|
||||||
installDiscordStyles(),
|
installDiscordStyles(),
|
||||||
installWhatsappStyles(),
|
installWhatsappStyles(),
|
||||||
|
installOfficeStyles(),
|
||||||
installImStyles(),
|
installImStyles(),
|
||||||
];
|
];
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -235,6 +250,8 @@ export function apply(ctx) {
|
||||||
ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal);
|
ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal);
|
||||||
const slackRpcCall = (endpoint, payload, signal) =>
|
const slackRpcCall = (endpoint, payload, signal) =>
|
||||||
ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal);
|
ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal);
|
||||||
|
const officeRpcCall = (endpoint, payload, signal) =>
|
||||||
|
ctx.connection.rpc.call(OFFICE_RPC_CHANNEL, endpoint, payload, signal);
|
||||||
const workspaceDirectoryPicker = Object.freeze({
|
const workspaceDirectoryPicker = Object.freeze({
|
||||||
listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
|
listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
|
||||||
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
||||||
|
|
@ -256,6 +273,7 @@ export function apply(ctx) {
|
||||||
wecomRpcCall,
|
wecomRpcCall,
|
||||||
weixinRpcCall,
|
weixinRpcCall,
|
||||||
whatsappRpcCall,
|
whatsappRpcCall,
|
||||||
|
officeRpcCall,
|
||||||
workspaceDirectoryPicker,
|
workspaceDirectoryPicker,
|
||||||
}),
|
}),
|
||||||
}, IMSettingsTab));
|
}, IMSettingsTab));
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ const CSS = String.raw`
|
||||||
.dim-logoWecom svg { width: 22px; height: 22px; }
|
.dim-logoWecom svg { width: 22px; height: 22px; }
|
||||||
.dim-logoTelegram { color: white; background: #229ed9; }
|
.dim-logoTelegram { color: white; background: #229ed9; }
|
||||||
.dim-logoTelegram svg { width: 21px; height: 21px; }
|
.dim-logoTelegram svg { width: 21px; height: 21px; }
|
||||||
|
.dim-logoOffice { color: white; background: linear-gradient(145deg, #12213f, #3964fe); }
|
||||||
|
.dim-logoOffice svg { width: 23px; height: 23px; }
|
||||||
.dim-logoDiscord { color: white; background: #5865f2; }
|
.dim-logoDiscord { color: white; background: #5865f2; }
|
||||||
.dim-logoDiscord svg { width: 21px; height: 21px; }
|
.dim-logoDiscord svg { width: 21px; height: 21px; }
|
||||||
.dim-logoSlack { color: white; background: #4a154b; }
|
.dim-logoSlack { color: white; background: #4a154b; }
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
45
plugin-src/host/channels/office/production.mjs
Normal file
45
plugin-src/host/channels/office/production.mjs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { OfficeConfigStore } from '../../../../src/channels/office/config-store.mjs';
|
||||||
|
import { OfficeController } from '../../../../src/channels/office/office-controller.mjs';
|
||||||
|
import { OfficeRuntime } from '../../../../src/channels/office/office-runtime.mjs';
|
||||||
|
import { HarnessClient } from '../../../../src/channels/shared/harness-client.mjs';
|
||||||
|
import { harnessOrigin } from '../shared/production.mjs';
|
||||||
|
|
||||||
|
export function officePaths(config = {}) {
|
||||||
|
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||||
|
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-office'));
|
||||||
|
return { root, config: resolve(config.configPath ?? join(root, 'config.json')) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProductionController(ctx, config = {}, internals = {}) {
|
||||||
|
const Store = internals.ConfigStore ?? OfficeConfigStore;
|
||||||
|
const Controller = internals.Controller ?? OfficeController;
|
||||||
|
const Runtime = internals.Runtime ?? OfficeRuntime;
|
||||||
|
const ResolvedHarness = internals.HarnessClient ?? HarnessClient;
|
||||||
|
const paths = officePaths(config);
|
||||||
|
const configStore = await new Store(paths.config).load();
|
||||||
|
const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:office') : (ctx.logger ?? console);
|
||||||
|
const harnessBaseUrl = harnessOrigin(ctx.webServer, config.harnessBaseUrl);
|
||||||
|
const createHarness = internals.createHarness ?? (({ workspace }) => new ResolvedHarness({
|
||||||
|
baseUrl: harnessBaseUrl,
|
||||||
|
workspace,
|
||||||
|
autostart: false,
|
||||||
|
dshBin: config.dshBin ?? 'dsh',
|
||||||
|
rpcIdPrefix: 'office',
|
||||||
|
logPrefix: 'dsh-im:office',
|
||||||
|
}));
|
||||||
|
const controller = new Controller({
|
||||||
|
credentials: ctx.credentials,
|
||||||
|
configStore,
|
||||||
|
logger,
|
||||||
|
createRuntime: (options) => new Runtime({
|
||||||
|
...options,
|
||||||
|
createHarness,
|
||||||
|
...(internals.transport ? { transport: internals.transport } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await controller.initialize();
|
||||||
|
return { controller, close: () => controller.close(), paths };
|
||||||
|
}
|
||||||
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) {
|
export async function createTokenProductionController(ctx, config, internals, definitions) {
|
||||||
const { channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime } = definitions;
|
const {
|
||||||
|
channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime, runtimeOptions,
|
||||||
|
} = definitions;
|
||||||
if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`);
|
if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`);
|
||||||
if (!ctx?.webServer) throw new TypeError(`dsh-im ${channel} requires ctx.webServer`);
|
if (!ctx?.webServer) throw new TypeError(`dsh-im ${channel} requires ctx.webServer`);
|
||||||
|
|
||||||
|
|
@ -41,6 +43,11 @@ export async function createTokenProductionController(ctx, config, internals, de
|
||||||
const ResolvedHarness = internals.HarnessClient ?? HarnessClient;
|
const ResolvedHarness = internals.HarnessClient ?? HarnessClient;
|
||||||
const ResolvedController = internals.Controller ?? Controller;
|
const ResolvedController = internals.Controller ?? Controller;
|
||||||
const ResolvedRuntime = internals.Runtime ?? Runtime;
|
const ResolvedRuntime = internals.Runtime ?? Runtime;
|
||||||
|
const channelRuntimeOptions = typeof runtimeOptions === 'function' ? runtimeOptions(config) : {};
|
||||||
|
if (!channelRuntimeOptions || typeof channelRuntimeOptions !== 'object'
|
||||||
|
|| Array.isArray(channelRuntimeOptions)) {
|
||||||
|
throw new TypeError(`dsh-im ${channel} runtimeOptions must return an object`);
|
||||||
|
}
|
||||||
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
|
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
|
||||||
const logger = typeof ctx.logger === 'function'
|
const logger = typeof ctx.logger === 'function'
|
||||||
? ctx.logger(`dsh-im:${channel}`) : (ctx.logger ?? console);
|
? ctx.logger(`dsh-im:${channel}`) : (ctx.logger ?? console);
|
||||||
|
|
@ -91,6 +98,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
||||||
await workspaces.ensure(botId);
|
await workspaces.ensure(botId);
|
||||||
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
|
const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
|
||||||
return new ResolvedRuntime({
|
return new ResolvedRuntime({
|
||||||
|
...channelRuntimeOptions,
|
||||||
config: botConfig,
|
config: botConfig,
|
||||||
token,
|
token,
|
||||||
harness: workspaceScope.harness,
|
harness: workspaceScope.harness,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,24 @@ import { TelegramRuntime } from '../../../../src/channels/telegram/telegram-runt
|
||||||
import { TelegramStateStore } from '../../../../src/channels/telegram/state-store.mjs';
|
import { TelegramStateStore } from '../../../../src/channels/telegram/state-store.mjs';
|
||||||
import { createTokenProductionController } from '../shared/production.mjs';
|
import { createTokenProductionController } from '../shared/production.mjs';
|
||||||
|
|
||||||
|
const TELEGRAM_USER_ID = /^[1-9]\d{0,15}$/;
|
||||||
|
|
||||||
|
export function normalizeTelegramAllowedUsers(value) {
|
||||||
|
if (value === undefined) return Object.freeze([]);
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new TypeError('telegram.allowedUsers must be an array of numeric Telegram User IDs');
|
||||||
|
}
|
||||||
|
const normalized = value.map((entry) => {
|
||||||
|
const userId = typeof entry === 'number' && Number.isSafeInteger(entry)
|
||||||
|
? String(entry) : typeof entry === 'string' ? entry.trim() : '';
|
||||||
|
if (!TELEGRAM_USER_ID.test(userId)) {
|
||||||
|
throw new TypeError('telegram.allowedUsers contains an invalid Telegram User ID');
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
});
|
||||||
|
return Object.freeze([...new Set(normalized)]);
|
||||||
|
}
|
||||||
|
|
||||||
export function createProductionController(ctx, config = {}, internals = {}) {
|
export function createProductionController(ctx, config = {}, internals = {}) {
|
||||||
return createTokenProductionController(ctx, config, internals, {
|
return createTokenProductionController(ctx, config, internals, {
|
||||||
channel: 'telegram',
|
channel: 'telegram',
|
||||||
|
|
@ -13,5 +31,8 @@ export function createProductionController(ctx, config = {}, internals = {}) {
|
||||||
HarnessClient: TelegramHarnessClient,
|
HarnessClient: TelegramHarnessClient,
|
||||||
Controller: TelegramController,
|
Controller: TelegramController,
|
||||||
Runtime: TelegramRuntime,
|
Runtime: TelegramRuntime,
|
||||||
|
runtimeOptions: (runtimeConfig) => ({
|
||||||
|
allowedPrivateUserIds: normalizeTelegramAllowedUsers(runtimeConfig.allowedUsers),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { apply as applyDingtalk } from './channels/dingtalk/index.mjs';
|
import { apply as applyDingtalk } from './channels/dingtalk/index.mjs';
|
||||||
import { apply as applyDiscord } from './channels/discord/index.mjs';
|
import { apply as applyDiscord } from './channels/discord/index.mjs';
|
||||||
|
import { apply as applyOffice } from './channels/office/index.mjs';
|
||||||
import { apply as applyFeishu } from './channels/feishu/index.mjs';
|
import { apply as applyFeishu } from './channels/feishu/index.mjs';
|
||||||
import { apply as applyQq } from './channels/qq/index.mjs';
|
import { apply as applyQq } from './channels/qq/index.mjs';
|
||||||
import { apply as applySlack } from './channels/slack/index.mjs';
|
import { apply as applySlack } from './channels/slack/index.mjs';
|
||||||
|
|
@ -27,6 +28,7 @@ export function createImHostPlugin(internals = {}) {
|
||||||
const startSlack = internals.applySlack ?? applySlack;
|
const startSlack = internals.applySlack ?? applySlack;
|
||||||
const startTelegram = internals.applyTelegram ?? applyTelegram;
|
const startTelegram = internals.applyTelegram ?? applyTelegram;
|
||||||
const startDiscord = internals.applyDiscord ?? applyDiscord;
|
const startDiscord = internals.applyDiscord ?? applyDiscord;
|
||||||
|
const startOffice = internals.applyOffice ?? applyOffice;
|
||||||
const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp;
|
const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp;
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
name,
|
name,
|
||||||
|
|
@ -41,6 +43,7 @@ export function createImHostPlugin(internals = {}) {
|
||||||
await startTelegram(ctx, channelConfig(config, 'telegram'));
|
await startTelegram(ctx, channelConfig(config, 'telegram'));
|
||||||
await startDiscord(ctx, channelConfig(config, 'discord'));
|
await startDiscord(ctx, channelConfig(config, 'discord'));
|
||||||
await startWhatsapp(ctx, channelConfig(config, 'whatsapp'));
|
await startWhatsapp(ctx, channelConfig(config, 'whatsapp'));
|
||||||
|
await startOffice(ctx, channelConfig(config, 'office'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
374
src/channels/office/office-job-executor.mjs
Normal file
374
src/channels/office/office-job-executor.mjs
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
|
|
||||||
|
import { harnessApprovalText } from '../shared/harness-approval.mjs';
|
||||||
|
import {
|
||||||
|
harnessAnswerForQuestion,
|
||||||
|
harnessQuestionText,
|
||||||
|
validHarnessQuestion,
|
||||||
|
} from '../shared/harness-question.mjs';
|
||||||
|
|
||||||
|
const JOB_ID = /^job-[a-f0-9]{32}$/;
|
||||||
|
const RENEW_MS = 30_000;
|
||||||
|
const COMPLETED_LIMIT = 2_048;
|
||||||
|
|
||||||
|
function abortError() {
|
||||||
|
return new DOMException('Office Job was cancelled', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(value, max = 8_000) {
|
||||||
|
return typeof value === 'string' ? value.trim().slice(0, max) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFailure(error) {
|
||||||
|
if (error?.code === 'office-job-alias-invalid') return error.message;
|
||||||
|
if (error?.code === 'turn-stopped') return '本机 Harness 已停止这次执行。';
|
||||||
|
if (error?.code === 'office-job-conflict') return 'Office Job 已被领取、取消或结束。';
|
||||||
|
return '本机 Harness 未能完成任务;请检查 Harness 会话后重试。';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPrompt(job, preset) {
|
||||||
|
return [
|
||||||
|
'# AI Office Handoff',
|
||||||
|
'',
|
||||||
|
'你正在本机 DeepSeek Harness 中继续一个来自 AI Office 的任务。',
|
||||||
|
'只在当前 Workspace 内行动。完成后必须返回:结果摘要、改动文件、验证证据、未解决风险。',
|
||||||
|
'',
|
||||||
|
'## 本机 Instruction Preset',
|
||||||
|
preset,
|
||||||
|
'',
|
||||||
|
...(clean(job.instruction) ? ['## 本轮补充指令', clean(job.instruction), ''] : []),
|
||||||
|
'## Office 时间线',
|
||||||
|
clean(job.markdown, 200_000),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function approvalRequest(interaction) {
|
||||||
|
const id = clean(interaction?.interactionId || interaction?.rpcId, 180);
|
||||||
|
if (!id || typeof interaction?.respond !== 'function') return null;
|
||||||
|
if (interaction.kind === 'approval') {
|
||||||
|
const prompt = harnessApprovalText(interaction.payload, {
|
||||||
|
toolCall: interaction.toolCall,
|
||||||
|
maxArgumentsLength: 10_000,
|
||||||
|
});
|
||||||
|
if (!prompt) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: 'approval',
|
||||||
|
title: `批准 ${clean(interaction.payload?.toolName, 120) || 'Harness 工具'}`,
|
||||||
|
prompt,
|
||||||
|
toolName: clean(interaction.payload?.toolName, 120),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (interaction.kind !== 'question') return null;
|
||||||
|
const questions = interaction.payload?.questions;
|
||||||
|
if (!Array.isArray(questions) || questions.length === 0
|
||||||
|
|| questions.some((question) => !validHarnessQuestion(question))) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: 'question',
|
||||||
|
title: questions.length > 1 ? `Harness 需要 ${questions.length} 项补充信息` : clean(questions[0].header || questions[0].question, 160),
|
||||||
|
prompt: questions.map((question, index) => harnessQuestionText(question, index, questions.length)).join('\n\n---\n\n'),
|
||||||
|
questions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForReply(entry, approvalId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (entry.controller.signal.aborted) return reject(entry.controller.signal.reason ?? abortError());
|
||||||
|
const onAbort = () => {
|
||||||
|
entry.approvals.delete(approvalId);
|
||||||
|
reject(entry.controller.signal.reason ?? abortError());
|
||||||
|
};
|
||||||
|
entry.controller.signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
entry.approvals.set(approvalId, {
|
||||||
|
resolve(value) {
|
||||||
|
entry.controller.signal.removeEventListener('abort', onAbort);
|
||||||
|
entry.approvals.delete(approvalId);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OfficeJobExecutor {
|
||||||
|
#config;
|
||||||
|
#transport;
|
||||||
|
#createHarness;
|
||||||
|
#logger;
|
||||||
|
#active = new Map();
|
||||||
|
#queued = new Set();
|
||||||
|
#completed = new Set();
|
||||||
|
#closed = false;
|
||||||
|
#status = {
|
||||||
|
running: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
lastJobId: null,
|
||||||
|
lastJobAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor({ config, transport, createHarness, logger = console }) {
|
||||||
|
if (!config || !transport || typeof createHarness !== 'function') {
|
||||||
|
throw new TypeError('OfficeJobExecutor requires config, transport, and createHarness');
|
||||||
|
}
|
||||||
|
this.#config = config;
|
||||||
|
this.#transport = transport;
|
||||||
|
this.#createHarness = createHarness;
|
||||||
|
this.#logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
get status() { return structuredClone(this.#status); }
|
||||||
|
|
||||||
|
offer(jobId) {
|
||||||
|
if (this.#closed || !JOB_ID.test(jobId) || this.#active.has(jobId)
|
||||||
|
|| this.#queued.has(jobId) || this.#completed.has(jobId)) return false;
|
||||||
|
this.#queued.add(jobId);
|
||||||
|
this.#drain();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEvent(event) {
|
||||||
|
const jobId = clean(event?.data?.jobId, 80);
|
||||||
|
if (!JOB_ID.test(jobId)) return false;
|
||||||
|
if (event.type === 'job.available') return this.offer(jobId);
|
||||||
|
if (event.type === 'job.cancel') return this.cancel(jobId);
|
||||||
|
if (event.type === 'approval.reply') {
|
||||||
|
const entry = this.#active.get(jobId);
|
||||||
|
const approvalId = clean(event.data?.approvalId, 180);
|
||||||
|
const pending = entry?.approvals.get(approvalId);
|
||||||
|
if (!pending) return false;
|
||||||
|
pending.resolve({
|
||||||
|
decision: event.data?.decision === 'approved' ? 'approved' : 'rejected',
|
||||||
|
answer: clean(event.data?.answer),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(jobId) {
|
||||||
|
this.#queued.delete(jobId);
|
||||||
|
const entry = this.#active.get(jobId);
|
||||||
|
if (!entry) return false;
|
||||||
|
entry.cancelled = true;
|
||||||
|
entry.controller.abort(abortError());
|
||||||
|
if (entry.sessionId && entry.harness) {
|
||||||
|
void entry.harness.rpc('session.cancel', {
|
||||||
|
sessionId: entry.sessionId,
|
||||||
|
keepInbox: true,
|
||||||
|
}, 10_000).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
this.#closed = true;
|
||||||
|
this.#queued.clear();
|
||||||
|
for (const entry of this.#active.values()) entry.controller.abort(abortError());
|
||||||
|
await Promise.allSettled([...this.#active.values()].map((entry) => entry.task));
|
||||||
|
}
|
||||||
|
|
||||||
|
#drain() {
|
||||||
|
while (!this.#closed && this.#active.size < this.#config.maxConcurrency && this.#queued.size > 0) {
|
||||||
|
const jobId = this.#queued.values().next().value;
|
||||||
|
this.#queued.delete(jobId);
|
||||||
|
const entry = {
|
||||||
|
controller: new AbortController(),
|
||||||
|
approvals: new Map(),
|
||||||
|
harness: null,
|
||||||
|
sessionId: null,
|
||||||
|
leaseToken: null,
|
||||||
|
cancelled: false,
|
||||||
|
lastProgressAt: 0,
|
||||||
|
lastProgress: '',
|
||||||
|
task: null,
|
||||||
|
};
|
||||||
|
entry.task = this.#run(jobId, entry).finally(() => {
|
||||||
|
this.#active.delete(jobId);
|
||||||
|
this.#status.running = this.#active.size;
|
||||||
|
this.#drain();
|
||||||
|
});
|
||||||
|
this.#active.set(jobId, entry);
|
||||||
|
this.#status.running = this.#active.size;
|
||||||
|
void entry.task.catch((error) => this.#logger.warn?.('[dsh-im:office] Job task ended:', error.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #run(jobId, entry) {
|
||||||
|
const signal = entry.controller.signal;
|
||||||
|
let renewTask = null;
|
||||||
|
try {
|
||||||
|
const fetched = await this.#transport.getJob(jobId, { signal });
|
||||||
|
const job = fetched?.job;
|
||||||
|
if (!job || job.id !== jobId) throw new Error('Office returned an invalid Job payload');
|
||||||
|
const accepted = await this.#transport.acceptJob(jobId, { signal });
|
||||||
|
if (!clean(accepted?.leaseToken, 200)) throw new Error('Office returned an invalid Job lease');
|
||||||
|
entry.leaseToken = accepted.leaseToken;
|
||||||
|
renewTask = this.#renew(jobId, entry);
|
||||||
|
const workspace = this.#config.workspaces[job.workspaceAlias];
|
||||||
|
const preset = this.#config.instructionPresets[job.instructionPreset];
|
||||||
|
if (!workspace || !preset) {
|
||||||
|
const error = new Error('Office Job 引用了本机未配置的 Workspace/Preset alias。');
|
||||||
|
error.code = 'office-job-alias-invalid';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
entry.harness = this.#createHarness({ workspace });
|
||||||
|
await this.#progress(jobId, entry, { kind: 'status', message: `已领取 Job,准备 Workspace alias:${job.workspaceAlias}` }, true);
|
||||||
|
entry.sessionId = await entry.harness.createSession({ signal, workspace });
|
||||||
|
await this.#progress(jobId, entry, { kind: 'status', message: 'Harness Session 已创建。', sessionId: entry.sessionId }, true);
|
||||||
|
const answer = await entry.harness.ask(entry.sessionId, renderPrompt(job, preset), {
|
||||||
|
timeoutMs: 30 * 60_000,
|
||||||
|
signal,
|
||||||
|
onUpdate: (update) => this.#handleUpdate(jobId, entry, update),
|
||||||
|
onInteraction: (interaction) => this.#handleInteraction(jobId, entry, interaction),
|
||||||
|
onInteractionResolved: (resolution) => {
|
||||||
|
const id = clean(resolution?.interactionId, 180);
|
||||||
|
entry.approvals.get(id)?.resolve({ decision: 'rejected', answer: '' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (signal.aborted) throw signal.reason ?? abortError();
|
||||||
|
await this.#transport.completeJob(jobId, entry.leaseToken, {
|
||||||
|
resultMarkdown: answer,
|
||||||
|
sessionId: entry.sessionId,
|
||||||
|
}, { signal });
|
||||||
|
this.#status.completed += 1;
|
||||||
|
this.#rememberCompleted(jobId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!entry.cancelled && entry.leaseToken) {
|
||||||
|
await this.#transport.failJob(jobId, entry.leaseToken, {
|
||||||
|
error: safeFailure(error),
|
||||||
|
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
|
||||||
|
}).catch(() => undefined);
|
||||||
|
this.#status.failed += 1;
|
||||||
|
}
|
||||||
|
if (error?.code === 'office-job-conflict' || error?.code === 'office-hook-unavailable') {
|
||||||
|
this.#rememberCompleted(jobId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!entry.cancelled && !signal.aborted) this.#logger.warn?.(`[dsh-im:office] Job ${jobId} failed:`, error.message);
|
||||||
|
} finally {
|
||||||
|
entry.controller.abort(abortError());
|
||||||
|
await renewTask?.catch(() => undefined);
|
||||||
|
this.#status.lastJobId = jobId;
|
||||||
|
this.#status.lastJobAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #renew(jobId, entry) {
|
||||||
|
while (!entry.controller.signal.aborted) {
|
||||||
|
try { await sleep(RENEW_MS, undefined, { signal: entry.controller.signal }); }
|
||||||
|
catch { return; }
|
||||||
|
try {
|
||||||
|
await this.#transport.renewJob(jobId, entry.leaseToken, { signal: entry.controller.signal });
|
||||||
|
const snapshot = await this.#transport.getJob(jobId, { signal: entry.controller.signal });
|
||||||
|
const approval = snapshot?.job?.approval;
|
||||||
|
if (approval && (approval.status === 'approved' || approval.status === 'rejected')) {
|
||||||
|
entry.approvals.get(approval.id)?.resolve({
|
||||||
|
decision: approval.status,
|
||||||
|
answer: clean(approval.answer),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (entry.controller.signal.aborted) return;
|
||||||
|
entry.cancelled = true;
|
||||||
|
entry.controller.abort(error);
|
||||||
|
if (entry.sessionId && entry.harness) {
|
||||||
|
void entry.harness.rpc('session.cancel', {
|
||||||
|
sessionId: entry.sessionId,
|
||||||
|
keepInbox: true,
|
||||||
|
}, 10_000).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #handleUpdate(jobId, entry, update) {
|
||||||
|
const message = update?.type === 'tool' ? `正在使用 ${clean(update.name, 160) || 'Harness 工具'}…`
|
||||||
|
: clean(update?.text, 4_000);
|
||||||
|
if (!message) return;
|
||||||
|
const key = `${update.type}:${message}`;
|
||||||
|
if (key === entry.lastProgress) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (update.type === 'text' && now - entry.lastProgressAt < 1_000) return;
|
||||||
|
entry.lastProgress = key;
|
||||||
|
entry.lastProgressAt = now;
|
||||||
|
await this.#progress(jobId, entry, {
|
||||||
|
kind: update.type === 'tool' ? 'tool' : update.type === 'text' ? 'text' : 'status',
|
||||||
|
message,
|
||||||
|
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress(jobId, entry, value, required = false) {
|
||||||
|
const request = this.#transport.progressJob(jobId, entry.leaseToken, value, {
|
||||||
|
signal: entry.controller.signal,
|
||||||
|
});
|
||||||
|
return required ? request : request.catch((error) => {
|
||||||
|
this.#logger.debug?.('[dsh-im:office] ignored a progress delivery failure:', error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async #handleInteraction(jobId, entry, interaction) {
|
||||||
|
const request = approvalRequest(interaction);
|
||||||
|
if (!request) {
|
||||||
|
if (interaction?.kind === 'approval' && typeof interaction.respond === 'function') {
|
||||||
|
await interaction.respond({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
sessionId: interaction.sessionId,
|
||||||
|
approvalId: interaction.payload?.approvalId,
|
||||||
|
outcome: 'rejected',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reply = waitForReply(entry, request.id);
|
||||||
|
try {
|
||||||
|
await this.#transport.requestApproval(jobId, entry.leaseToken, request, {
|
||||||
|
signal: entry.controller.signal,
|
||||||
|
});
|
||||||
|
const decision = await reply;
|
||||||
|
if (request.kind === 'approval') {
|
||||||
|
await interaction.respond({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
sessionId: interaction.sessionId,
|
||||||
|
approvalId: interaction.payload.approvalId,
|
||||||
|
outcome: decision.decision === 'approved' ? 'allowed-once' : 'rejected',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (decision.decision !== 'approved') {
|
||||||
|
await interaction.respond({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'cancelled', message: 'The AI Office user rejected this question.', details: {} },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const answers = clean(decision.answer).split(/\r?\n/);
|
||||||
|
await interaction.respond({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
sessionId: interaction.sessionId,
|
||||||
|
answer: {
|
||||||
|
answers: request.questions.map((question, index) => (
|
||||||
|
harnessAnswerForQuestion(question, answers[index] || answers[0] || '')
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
entry.approvals.delete(request.id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#rememberCompleted(jobId) {
|
||||||
|
this.#completed.add(jobId);
|
||||||
|
if (this.#completed.size <= COMPLETED_LIMIT) return;
|
||||||
|
this.#completed.delete(this.#completed.values().next().value);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/channels/office/office-runtime.mjs
Normal file
156
src/channels/office/office-runtime.mjs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
|
|
||||||
|
import { OfficeTransport } from './office-transport.mjs';
|
||||||
|
import { OFFICE_PROTOCOL_VERSION } from './protocol.mjs';
|
||||||
|
import { OfficeJobExecutor } from './office-job-executor.mjs';
|
||||||
|
|
||||||
|
const RETRY_DELAYS = Object.freeze([1_000, 3_000, 10_000, 30_000]);
|
||||||
|
|
||||||
|
function safeConnectionError(error) {
|
||||||
|
const code = typeof error?.code === 'string' ? error.code : 'office-connection-failed';
|
||||||
|
const messages = {
|
||||||
|
'invalid-device-token': 'AI Office 拒绝了 Device Token。',
|
||||||
|
'office-hook-unavailable': 'AI Office Connector Hook 尚未就绪。',
|
||||||
|
'office-protocol-mismatch': 'AI Office Connector 协议版本不兼容。',
|
||||||
|
'office-transport-failed': '本机暂时无法访问 AI Office。',
|
||||||
|
};
|
||||||
|
return { code, message: messages[code] ?? 'AI Office 连接已中断。' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OfficeRuntime {
|
||||||
|
#config;
|
||||||
|
#token;
|
||||||
|
#logger;
|
||||||
|
#transport;
|
||||||
|
#controller = null;
|
||||||
|
#task = null;
|
||||||
|
#status;
|
||||||
|
#jobs;
|
||||||
|
|
||||||
|
constructor({ config, token, logger = console, transport, createHarness, jobExecutor }) {
|
||||||
|
this.#config = config;
|
||||||
|
this.#token = token;
|
||||||
|
this.#logger = logger;
|
||||||
|
this.#transport = transport ?? new OfficeTransport({
|
||||||
|
baseUrl: config.baseUrl, deviceId: config.deviceId, token,
|
||||||
|
});
|
||||||
|
this.#status = {
|
||||||
|
state: 'idle', connected: false, startedAt: null, lastHeartbeatAt: null,
|
||||||
|
lastEventAt: null, lastEventId: null, lastEventType: null, reconnects: 0,
|
||||||
|
jobsOffered: 0, error: null,
|
||||||
|
};
|
||||||
|
this.#jobs = jobExecutor ?? (createHarness ? new OfficeJobExecutor({
|
||||||
|
config,
|
||||||
|
transport: this.#transport,
|
||||||
|
createHarness,
|
||||||
|
logger,
|
||||||
|
}) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
get status() {
|
||||||
|
return structuredClone({
|
||||||
|
...this.#status,
|
||||||
|
...(this.#jobs ? { jobs: this.#jobs.status } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
capabilities() {
|
||||||
|
return {
|
||||||
|
protocolVersion: OFFICE_PROTOCOL_VERSION,
|
||||||
|
deviceId: this.#config.deviceId,
|
||||||
|
workspaces: Object.keys(this.#config.workspaces),
|
||||||
|
instructionPresets: Object.keys(this.#config.instructionPresets),
|
||||||
|
maxConcurrency: this.#config.maxConcurrency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection(signal) {
|
||||||
|
await this.#transport.heartbeat({ ...this.capabilities(), probe: true }, { signal });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.#task) return this.status;
|
||||||
|
this.#controller = new AbortController();
|
||||||
|
this.#status.startedAt = new Date().toISOString();
|
||||||
|
this.#status.state = 'connecting';
|
||||||
|
this.#task = this.#run(this.#controller.signal).finally(() => { this.#task = null; });
|
||||||
|
this.#task.catch((error) => {
|
||||||
|
if (this.#controller?.signal.aborted) return;
|
||||||
|
this.#logger.error?.('[dsh-im:office] connector stopped:', error);
|
||||||
|
});
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #run(signal) {
|
||||||
|
let attempt = 0;
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const attemptController = new AbortController();
|
||||||
|
const attemptSignal = AbortSignal.any([signal, attemptController.signal]);
|
||||||
|
try {
|
||||||
|
const heartbeat = await this.#transport.heartbeat(this.capabilities(), { signal: attemptSignal });
|
||||||
|
this.#offerJobs(heartbeat?.jobs);
|
||||||
|
this.#status.lastHeartbeatAt = new Date().toISOString();
|
||||||
|
const heartbeatTask = this.#heartbeatLoop(attemptSignal);
|
||||||
|
const stream = this.#transport.stream({
|
||||||
|
signal: attemptSignal,
|
||||||
|
lastEventId: this.#status.lastEventId,
|
||||||
|
onOpen: () => {
|
||||||
|
this.#status.connected = true;
|
||||||
|
this.#status.state = 'connected';
|
||||||
|
this.#status.error = null;
|
||||||
|
attempt = 0;
|
||||||
|
},
|
||||||
|
onEvent: async (event) => {
|
||||||
|
this.#status.lastEventAt = new Date().toISOString();
|
||||||
|
this.#status.lastEventId = event.id ?? this.#status.lastEventId;
|
||||||
|
this.#status.lastEventType = event.type;
|
||||||
|
if (event.type === 'job.available') this.#status.jobsOffered += 1;
|
||||||
|
this.#jobs?.handleEvent(event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await Promise.race([stream, heartbeatTask]);
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) break;
|
||||||
|
this.#status.connected = false;
|
||||||
|
this.#status.state = 'reconnecting';
|
||||||
|
this.#status.error = safeConnectionError(error);
|
||||||
|
this.#status.reconnects += 1;
|
||||||
|
const delay = RETRY_DELAYS[Math.min(attempt, RETRY_DELAYS.length - 1)];
|
||||||
|
attempt += 1;
|
||||||
|
try { await sleep(delay, undefined, { signal }); } catch { break; }
|
||||||
|
} finally {
|
||||||
|
attemptController.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#status.connected = false;
|
||||||
|
this.#status.state = 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
async #heartbeatLoop(signal) {
|
||||||
|
while (!signal.aborted) {
|
||||||
|
await sleep(this.#config.heartbeatSeconds * 1_000, undefined, { signal });
|
||||||
|
const heartbeat = await this.#transport.heartbeat(this.capabilities(), { signal });
|
||||||
|
this.#offerJobs(heartbeat?.jobs);
|
||||||
|
this.#status.lastHeartbeatAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#offerJobs(jobs) {
|
||||||
|
if (!Array.isArray(jobs)) return;
|
||||||
|
for (const job of jobs) {
|
||||||
|
if (typeof job?.id === 'string' && this.#jobs?.offer(job.id)) this.#status.jobsOffered += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
const task = this.#task;
|
||||||
|
this.#controller?.abort();
|
||||||
|
this.#controller = null;
|
||||||
|
if (task) await task.catch(() => undefined);
|
||||||
|
await this.#jobs?.close();
|
||||||
|
this.#status.connected = false;
|
||||||
|
this.#status.state = 'idle';
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/channels/office/office-transport.mjs
Normal file
170
src/channels/office/office-transport.mjs
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { OFFICE_HOOK_PATHS, OFFICE_PROTOCOL_VERSION, officeHookUrls } from './protocol.mjs';
|
||||||
|
|
||||||
|
function safeTransportError(operation, response) {
|
||||||
|
const error = new Error(`AI Office ${operation} failed: HTTP ${response.status}`);
|
||||||
|
error.code = response.status === 401 ? 'invalid-device-token'
|
||||||
|
: response.status === 404 ? 'office-hook-unavailable'
|
||||||
|
: response.status === 409 ? 'office-job-conflict' : 'office-transport-failed';
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transportFailure(message, code = 'office-transport-failed', cause) {
|
||||||
|
const error = new Error(message, cause === undefined ? undefined : { cause });
|
||||||
|
error.code = code;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAbort(error, signal) {
|
||||||
|
return signal?.aborted || error?.name === 'AbortError';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrame(frame) {
|
||||||
|
let type = 'message';
|
||||||
|
let id;
|
||||||
|
const data = [];
|
||||||
|
for (const line of frame.split(/\r?\n/)) {
|
||||||
|
if (line.startsWith('event:')) type = line.slice(6).trim() || 'message';
|
||||||
|
else if (line.startsWith('id:')) id = line.slice(3).trim() || undefined;
|
||||||
|
else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
|
||||||
|
}
|
||||||
|
if (data.length === 0) return null;
|
||||||
|
let value;
|
||||||
|
try { value = JSON.parse(data.join('\n')); } catch { throw new Error('AI Office SSE returned invalid JSON'); }
|
||||||
|
return { id, type: typeof value?.type === 'string' ? value.type : type, data: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OfficeTransport {
|
||||||
|
#baseUrl;
|
||||||
|
#deviceId;
|
||||||
|
#token;
|
||||||
|
#fetch;
|
||||||
|
|
||||||
|
constructor({ baseUrl, deviceId, token, fetchImpl = fetch }) {
|
||||||
|
this.#baseUrl = baseUrl;
|
||||||
|
this.#deviceId = deviceId;
|
||||||
|
this.#token = token;
|
||||||
|
this.#fetch = fetchImpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks() { return officeHookUrls(this.#baseUrl); }
|
||||||
|
|
||||||
|
#headers(extra = {}) {
|
||||||
|
return {
|
||||||
|
authorization: `Bearer ${this.#token}`,
|
||||||
|
'x-harness-device-id': this.#deviceId,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async heartbeat(payload, { signal } = {}) {
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await this.#fetch(new URL(OFFICE_HOOK_PATHS.heartbeat, this.#baseUrl), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.#headers({ accept: 'application/json', 'content-type': 'application/json' }),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal,
|
||||||
|
redirect: 'error',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbort(error, signal)) throw error;
|
||||||
|
throw transportFailure('AI Office heartbeat request could not be completed', undefined, error);
|
||||||
|
}
|
||||||
|
if (!response.ok) throw safeTransportError('heartbeat', response);
|
||||||
|
let value;
|
||||||
|
try { value = await response.json(); }
|
||||||
|
catch (error) { throw transportFailure('AI Office heartbeat returned invalid JSON', 'office-protocol-mismatch', error); }
|
||||||
|
if (!value || typeof value !== 'object' || value.ok !== true
|
||||||
|
|| value.protocolVersion !== OFFICE_PROTOCOL_VERSION) {
|
||||||
|
throw transportFailure('AI Office heartbeat protocol does not match', 'office-protocol-mismatch');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #jobRequest(path, { method = 'POST', body, leaseToken, signal } = {}) {
|
||||||
|
const headers = this.#headers({ accept: 'application/json' });
|
||||||
|
if (body !== undefined) headers['content-type'] = 'application/json';
|
||||||
|
if (leaseToken) headers['x-harness-lease-token'] = leaseToken;
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await this.#fetch(new URL(path, this.#baseUrl), {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||||
|
signal,
|
||||||
|
redirect: 'error',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbort(error, signal)) throw error;
|
||||||
|
throw transportFailure('AI Office Job request could not be completed', undefined, error);
|
||||||
|
}
|
||||||
|
if (!response.ok) throw safeTransportError('Job request', response);
|
||||||
|
try { return await response.json(); }
|
||||||
|
catch (error) { throw transportFailure('AI Office Job returned invalid JSON', 'office-protocol-mismatch', error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJob(jobId, options = {}) {
|
||||||
|
return this.#jobRequest(OFFICE_HOOK_PATHS.job.replace(':id', jobId), { ...options, method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptJob(jobId, options = {}) {
|
||||||
|
return this.#jobRequest(OFFICE_HOOK_PATHS.accept.replace(':id', jobId), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async renewJob(jobId, leaseToken, options = {}) {
|
||||||
|
return this.#jobRequest(OFFICE_HOOK_PATHS.renew.replace(':id', jobId), { ...options, leaseToken });
|
||||||
|
}
|
||||||
|
|
||||||
|
async progressJob(jobId, leaseToken, body, options = {}) {
|
||||||
|
return this.#jobRequest(OFFICE_HOOK_PATHS.progress.replace(':id', jobId), { ...options, leaseToken, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestApproval(jobId, leaseToken, body, options = {}) {
|
||||||
|
return this.#jobRequest(OFFICE_HOOK_PATHS.approval.replace(':id', jobId), { ...options, leaseToken, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeJob(jobId, leaseToken, body, options = {}) {
|
||||||
|
return this.#jobRequest(OFFICE_HOOK_PATHS.result.replace(':id', jobId), { ...options, leaseToken, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
async failJob(jobId, leaseToken, body, options = {}) {
|
||||||
|
return this.#jobRequest(OFFICE_HOOK_PATHS.fail.replace(':id', jobId), { ...options, leaseToken, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
async stream({ signal, lastEventId, onOpen, onEvent }) {
|
||||||
|
const headers = this.#headers({ accept: 'text/event-stream' });
|
||||||
|
if (lastEventId) headers['last-event-id'] = lastEventId;
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await this.#fetch(new URL(OFFICE_HOOK_PATHS.stream, this.#baseUrl), {
|
||||||
|
method: 'GET', headers, signal, redirect: 'error', cache: 'no-store',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbort(error, signal)) throw error;
|
||||||
|
throw transportFailure('AI Office SSE request could not be completed', undefined, error);
|
||||||
|
}
|
||||||
|
if (!response.ok) throw safeTransportError('stream', response);
|
||||||
|
if (!response.headers.get('content-type')?.toLowerCase().includes('text/event-stream')) {
|
||||||
|
throw new Error('AI Office stream did not return text/event-stream');
|
||||||
|
}
|
||||||
|
if (!response.body) throw new Error('AI Office stream returned no body');
|
||||||
|
onOpen?.();
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer = `${buffer}${decoder.decode(value, { stream: true })}`.replaceAll('\r\n', '\n');
|
||||||
|
let boundary;
|
||||||
|
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
|
||||||
|
const raw = buffer.slice(0, boundary);
|
||||||
|
buffer = buffer.slice(boundary + 2);
|
||||||
|
const event = parseFrame(raw);
|
||||||
|
if (event) await onEvent?.(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('AI Office SSE stream ended');
|
||||||
|
}
|
||||||
|
}
|
||||||
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 {
|
class TelegramBotClient {
|
||||||
#api;
|
#api;
|
||||||
#signal;
|
#signal;
|
||||||
|
|
@ -198,6 +204,7 @@ export class TelegramRuntime {
|
||||||
#logger;
|
#logger;
|
||||||
#replyTimeoutMs;
|
#replyTimeoutMs;
|
||||||
#createApi;
|
#createApi;
|
||||||
|
#allowedPrivateUserIds;
|
||||||
#status = createTelegramRuntimeStatus();
|
#status = createTelegramRuntimeStatus();
|
||||||
#api = null;
|
#api = null;
|
||||||
#bridge = null;
|
#bridge = null;
|
||||||
|
|
@ -213,6 +220,7 @@ export class TelegramRuntime {
|
||||||
logger = console,
|
logger = console,
|
||||||
replyTimeoutMs = 600_000,
|
replyTimeoutMs = 600_000,
|
||||||
createApi = (options) => new TelegramApi(options),
|
createApi = (options) => new TelegramApi(options),
|
||||||
|
allowedPrivateUserIds = [],
|
||||||
}) {
|
}) {
|
||||||
if (!config || !token || !harness || !state) {
|
if (!config || !token || !harness || !state) {
|
||||||
throw new TypeError('TelegramRuntime requires config, token, Harness, and state');
|
throw new TypeError('TelegramRuntime requires config, token, Harness, and state');
|
||||||
|
|
@ -224,6 +232,9 @@ export class TelegramRuntime {
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
this.#createApi = createApi;
|
this.#createApi = createApi;
|
||||||
|
this.#allowedPrivateUserIds = new Set(
|
||||||
|
Array.isArray(allowedPrivateUserIds) ? allowedPrivateUserIds.map(String) : [],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get status() {
|
get status() {
|
||||||
|
|
@ -323,7 +334,7 @@ export class TelegramRuntime {
|
||||||
username: this.#config.username,
|
username: this.#config.username,
|
||||||
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
|
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
|
||||||
});
|
});
|
||||||
if (message) {
|
if (message && telegramInboundAllowed(message, this.#allowedPrivateUserIds)) {
|
||||||
void this.#bridge.accept(message).catch((error) => {
|
void this.#bridge.accept(message).catch((error) => {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
this.#logger.error?.(
|
this.#logger.error?.(
|
||||||
|
|
@ -331,6 +342,9 @@ export class TelegramRuntime {
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
} else if (message) {
|
||||||
|
this.#status.messagesRejected += 1;
|
||||||
|
this.#status.lastRejectedAt = new Date().toISOString();
|
||||||
}
|
}
|
||||||
cursor = update.update_id + 1;
|
cursor = update.update_id + 1;
|
||||||
await this.#state.setCursor(cursor);
|
await this.#state.setCursor(cursor);
|
||||||
|
|
|
||||||
297
test/channels/office/office.test.mjs
Normal file
297
test/channels/office/office.test.mjs
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
|
||||||
|
import { OfficeConfigStore } from '../../../src/channels/office/config-store.mjs';
|
||||||
|
import { OfficeController } from '../../../src/channels/office/office-controller.mjs';
|
||||||
|
import { OfficeTransport } from '../../../src/channels/office/office-transport.mjs';
|
||||||
|
import { OfficeJobExecutor } from '../../../src/channels/office/office-job-executor.mjs';
|
||||||
|
import {
|
||||||
|
OFFICE_PROTOCOL_VERSION,
|
||||||
|
OFFICE_RPC_ENDPOINTS,
|
||||||
|
officeHookUrls,
|
||||||
|
} from '../../../src/channels/office/protocol.mjs';
|
||||||
|
import { createOfficeRpcHandler } from '../../../plugin-src/host/channels/office/rpc.mjs';
|
||||||
|
import { OfficeSettingsTab } from '../../../plugin-src/client/channels/office/index.js';
|
||||||
|
|
||||||
|
const TOKEN = 'office-device-token-ABCDEFGHIJKLMNOPQRSTUVWXYZ-123456';
|
||||||
|
|
||||||
|
async function eventually(predicate, timeoutMs = 2_000) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await predicate()) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
assert.fail('condition did not become true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function config(overrides = {}) {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
baseUrl: 'https://fission.gridmind.ai',
|
||||||
|
deviceId: 'mac-a004',
|
||||||
|
deviceTokenRef: 'DSH_OFFICE_DEVICE_TOKEN_1234567890ABCDEF12345678',
|
||||||
|
maxConcurrency: 1,
|
||||||
|
heartbeatSeconds: 30,
|
||||||
|
workspaces: { 'office-project': '/Users/a004/glassespaw-ai-office-web' },
|
||||||
|
instructionPresets: { 'action-items': 'Convert into executable action items.' },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentials() {
|
||||||
|
const values = new Map();
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
resolve: async (ref) => values.has(ref) ? { value: values.get(ref), source: 'test' } : undefined,
|
||||||
|
set: async (ref, value) => values.set(ref, value),
|
||||||
|
unset: async (ref) => values.delete(ref),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('AI Office protocol derives every fixed hook from one HTTPS origin', () => {
|
||||||
|
const hooks = officeHookUrls('https://fission.gridmind.ai');
|
||||||
|
assert.equal(OFFICE_PROTOCOL_VERSION, 'office-harness.v1');
|
||||||
|
assert.equal(hooks.stream, 'https://fission.gridmind.ai/api/harness/connector/stream');
|
||||||
|
assert.equal(hooks.result, 'https://fission.gridmind.ai/api/harness/connector/jobs/:id/result');
|
||||||
|
assert.throws(() => officeHookUrls('http://public.example'), /must use HTTPS/);
|
||||||
|
assert.equal(officeHookUrls('http://127.0.0.1:4300').heartbeat, 'http://127.0.0.1:4300/api/harness/connector/heartbeat');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Office config persists safe aliases without a Device Token', async (t) => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'dsh-office-config-'));
|
||||||
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||||
|
const path = join(directory, 'config.json');
|
||||||
|
const store = await new OfficeConfigStore(path).load();
|
||||||
|
await store.save(config());
|
||||||
|
const raw = await readFile(path, 'utf8');
|
||||||
|
assert.doesNotMatch(raw, /office-device-token/);
|
||||||
|
assert.match(raw, /office-project/);
|
||||||
|
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||||
|
assert.deepEqual(store.get().workspaces, { 'office-project': '/Users/a004/glassespaw-ai-office-web' });
|
||||||
|
await assert.rejects(() => store.save(config({ workspaces: { unsafe: 'relative/path' } })), /invalid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Office transport authenticates heartbeat and parses SSE frames', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const fetchImpl = async (url, options) => {
|
||||||
|
calls.push({ url: String(url), options });
|
||||||
|
if (String(url).endsWith('/heartbeat')) {
|
||||||
|
return Response.json({ ok: true, protocolVersion: OFFICE_PROTOCOL_VERSION });
|
||||||
|
}
|
||||||
|
return new Response(new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode('id: evt-1\nevent: job.available\ndata: {"type":"job.available","jobId":"job-1"}\n\n'));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
}), { headers: { 'content-type': 'text/event-stream' } });
|
||||||
|
};
|
||||||
|
const transport = new OfficeTransport({
|
||||||
|
baseUrl: 'https://fission.gridmind.ai', deviceId: 'mac-a004', token: TOKEN, fetchImpl,
|
||||||
|
});
|
||||||
|
await transport.heartbeat({ protocolVersion: OFFICE_PROTOCOL_VERSION });
|
||||||
|
const events = [];
|
||||||
|
await assert.rejects(() => transport.stream({ onEvent: (event) => events.push(event) }), /stream ended/);
|
||||||
|
assert.equal(calls[0].options.headers.authorization, `Bearer ${TOKEN}`);
|
||||||
|
assert.equal(calls[0].options.headers['x-harness-device-id'], 'mac-a004');
|
||||||
|
assert.deepEqual(events, [{
|
||||||
|
id: 'evt-1', type: 'job.available', data: { type: 'job.available', jobId: 'job-1' },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Office transport uses fixed Job hooks and keeps the lease outside JSON bodies', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const transport = new OfficeTransport({
|
||||||
|
baseUrl: 'https://fission.gridmind.ai',
|
||||||
|
deviceId: 'mac-a004',
|
||||||
|
token: TOKEN,
|
||||||
|
fetchImpl: async (url, options) => {
|
||||||
|
calls.push({ url: String(url), options });
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const jobId = 'job-1234567890abcdef1234567890abcdef';
|
||||||
|
await transport.getJob(jobId);
|
||||||
|
await transport.acceptJob(jobId);
|
||||||
|
await transport.progressJob(jobId, 'lease-secret', { kind: 'status', message: 'running' });
|
||||||
|
assert.equal(calls[0].url, `https://fission.gridmind.ai/api/harness/connector/jobs/${jobId}`);
|
||||||
|
assert.equal(calls[0].options.method, 'GET');
|
||||||
|
assert.equal(calls[1].url.endsWith(`/${jobId}/accept`), true);
|
||||||
|
assert.equal(calls[2].options.headers['x-harness-lease-token'], 'lease-secret');
|
||||||
|
assert.equal(calls[2].options.body.includes('lease-secret'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Office controller stores the token in credentials and returns only safe status', async () => {
|
||||||
|
let stored = null;
|
||||||
|
const credentialStore = credentials();
|
||||||
|
const runtimes = [];
|
||||||
|
const controller = new OfficeController({
|
||||||
|
credentials: credentialStore,
|
||||||
|
configStore: {
|
||||||
|
get: () => stored,
|
||||||
|
save: async (value) => { stored = structuredClone(value); return stored; },
|
||||||
|
clear: async () => { stored = null; },
|
||||||
|
},
|
||||||
|
createRuntime: (options) => {
|
||||||
|
const runtime = {
|
||||||
|
options,
|
||||||
|
status: { state: 'connected', connected: true, reconnects: 0 },
|
||||||
|
start() {},
|
||||||
|
stop: async () => {},
|
||||||
|
testConnection: async () => ({ ok: true }),
|
||||||
|
};
|
||||||
|
runtimes.push(runtime);
|
||||||
|
return runtime;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const status = await controller.configure({
|
||||||
|
baseUrl: 'https://fission.gridmind.ai', deviceId: 'mac-a004', deviceToken: TOKEN,
|
||||||
|
maxConcurrency: 1, heartbeatSeconds: 30,
|
||||||
|
workspaces: { 'office-project': '/Users/a004/project' },
|
||||||
|
instructionPresets: { 'action-items': 'Make tasks.' },
|
||||||
|
});
|
||||||
|
assert.equal(status.connected, true);
|
||||||
|
assert.equal(status.tokenConfigured, true);
|
||||||
|
assert.equal(JSON.stringify(status).includes(TOKEN), false);
|
||||||
|
assert.equal(credentialStore.values.size, 1);
|
||||||
|
assert.equal(runtimes[0].options.token, TOKEN);
|
||||||
|
await controller.remove();
|
||||||
|
assert.equal(credentialStore.values.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Office controller normalizes the origin and tolerates a missing local credential on startup', async () => {
|
||||||
|
const credentialStore = credentials();
|
||||||
|
let stored = config();
|
||||||
|
const configStore = {
|
||||||
|
get: () => stored,
|
||||||
|
save: async (value) => { stored = structuredClone(value); return stored; },
|
||||||
|
clear: async () => { stored = null; },
|
||||||
|
};
|
||||||
|
const controller = new OfficeController({
|
||||||
|
credentials: credentialStore,
|
||||||
|
configStore,
|
||||||
|
createRuntime: () => ({
|
||||||
|
status: { state: 'connected', connected: true },
|
||||||
|
start() {},
|
||||||
|
stop: async () => {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const initial = await controller.initialize();
|
||||||
|
assert.equal(initial.configured, true);
|
||||||
|
assert.equal(initial.state, 'missing-token');
|
||||||
|
|
||||||
|
await controller.configure({
|
||||||
|
baseUrl: 'https://fission.gridmind.ai/path/', deviceId: 'mac-a004', deviceToken: TOKEN,
|
||||||
|
maxConcurrency: 1, heartbeatSeconds: 30, workspaces: {}, instructionPresets: {},
|
||||||
|
});
|
||||||
|
assert.equal(stored.baseUrl, 'https://fission.gridmind.ai');
|
||||||
|
assert.equal(credentialStore.values.size, 1);
|
||||||
|
await controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Office RPC validates configuration and keeps transport failures safe', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const handler = createOfficeRpcHandler({
|
||||||
|
status: async () => ({ configured: false }),
|
||||||
|
configure: async (payload) => { calls.push(payload); return { configured: true }; },
|
||||||
|
reconnect: async () => ({ configured: true }),
|
||||||
|
test: async () => { const error = new Error('HTTP 404 internal URL'); error.code = 'office-hook-unavailable'; throw error; },
|
||||||
|
remove: async () => ({ configured: false }),
|
||||||
|
});
|
||||||
|
assert.deepEqual(await handler(OFFICE_RPC_ENDPOINTS.configure, { baseUrl: 'x' }), {
|
||||||
|
ok: false, error: { code: 'bad-request', message: 'Invalid AI Office connector request.' },
|
||||||
|
});
|
||||||
|
assert.equal((await handler(OFFICE_RPC_ENDPOINTS.configure, {
|
||||||
|
baseUrl: 'https://fission.gridmind.ai', deviceId: 'mac-a004', deviceToken: TOKEN,
|
||||||
|
workspaces: {}, instructionPresets: {},
|
||||||
|
})).ok, true);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.deepEqual(await handler(OFFICE_RPC_ENDPOINTS.test, {}), {
|
||||||
|
ok: false, error: { code: 'office-hook-unavailable', message: 'AI Office Hook 尚未上线或地址不正确。' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Office settings renders connection fields and fixed hook preview', () => {
|
||||||
|
const markup = renderToStaticMarkup(React.createElement(OfficeSettingsTab, {
|
||||||
|
rpcCall: async () => ({ ok: true, value: { configured: false } }),
|
||||||
|
initialStatus: { configured: false },
|
||||||
|
}));
|
||||||
|
assert.match(markup, /AI Office Connector/);
|
||||||
|
assert.match(markup, /Office Base URL/);
|
||||||
|
assert.match(markup, /Device Token/);
|
||||||
|
assert.match(markup, /Workspace 映射/);
|
||||||
|
assert.match(markup, /api\/harness\/connector\/stream/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI Office Job executor claims, reports, approves, and returns one Harness result', async () => {
|
||||||
|
const jobId = 'job-1234567890abcdef1234567890abcdef';
|
||||||
|
const progress = [];
|
||||||
|
const approvals = [];
|
||||||
|
const results = [];
|
||||||
|
const responses = [];
|
||||||
|
let executor;
|
||||||
|
const transport = {
|
||||||
|
getJob: async () => ({ job: {
|
||||||
|
id: jobId,
|
||||||
|
workspaceAlias: 'office-project',
|
||||||
|
instructionPreset: 'execute',
|
||||||
|
instruction: 'Return evidence.',
|
||||||
|
markdown: '# Office timeline',
|
||||||
|
} }),
|
||||||
|
acceptJob: async () => ({ leaseToken: 'lease-token-1234567890' }),
|
||||||
|
renewJob: async () => ({ leaseExpiresAt: new Date(Date.now() + 90_000).toISOString() }),
|
||||||
|
progressJob: async (_id, _lease, value) => { progress.push(value); return { ok: true }; },
|
||||||
|
requestApproval: async (_id, _lease, value) => {
|
||||||
|
approvals.push(value);
|
||||||
|
queueMicrotask(() => executor.handleEvent({
|
||||||
|
type: 'approval.reply',
|
||||||
|
data: { jobId, approvalId: value.id, decision: 'approved' },
|
||||||
|
}));
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
completeJob: async (_id, _lease, value) => { results.push(value); return { ok: true }; },
|
||||||
|
failJob: async () => { throw new Error('must not fail'); },
|
||||||
|
};
|
||||||
|
const harness = {
|
||||||
|
createSession: async () => 'session-office-one',
|
||||||
|
ask: async (_sessionId, prompt, options) => {
|
||||||
|
assert.match(prompt, /Return evidence/);
|
||||||
|
await options.onUpdate({ type: 'tool', name: 'apply_patch' });
|
||||||
|
await options.onInteraction({
|
||||||
|
kind: 'approval',
|
||||||
|
interactionId: 'approval-one',
|
||||||
|
sessionId: 'session-office-one',
|
||||||
|
payload: {
|
||||||
|
type: 'approval/requested', sessionId: 'session-office-one',
|
||||||
|
approvalId: 'approval-one', toolName: 'apply_patch', callId: 'call-one',
|
||||||
|
},
|
||||||
|
toolCall: { callId: 'call-one', name: 'apply_patch', arguments: '{"patch":"safe"}' },
|
||||||
|
respond: async (value) => { responses.push(value); return { accepted: true }; },
|
||||||
|
});
|
||||||
|
return '# Completed\n\nVerified.';
|
||||||
|
},
|
||||||
|
rpc: async () => ({ ok: true }),
|
||||||
|
};
|
||||||
|
executor = new OfficeJobExecutor({
|
||||||
|
config: {
|
||||||
|
maxConcurrency: 1,
|
||||||
|
workspaces: { 'office-project': '/Users/a004/project' },
|
||||||
|
instructionPresets: { execute: 'Execute carefully.' },
|
||||||
|
},
|
||||||
|
transport,
|
||||||
|
createHarness: () => harness,
|
||||||
|
});
|
||||||
|
assert.equal(executor.offer(jobId), true);
|
||||||
|
await eventually(() => executor.status.completed === 1);
|
||||||
|
assert.equal(approvals[0].kind, 'approval');
|
||||||
|
assert.equal(responses[0].value.outcome, 'allowed-once');
|
||||||
|
assert.ok(progress.some((item) => item.kind === 'tool'));
|
||||||
|
assert.deepEqual(results, [{ resultMarkdown: '# Completed\n\nVerified.', sessionId: 'session-office-one' }]);
|
||||||
|
await executor.close();
|
||||||
|
});
|
||||||
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 {
|
import {
|
||||||
TelegramRuntime,
|
TelegramRuntime,
|
||||||
normalizeTelegramUpdate,
|
normalizeTelegramUpdate,
|
||||||
|
telegramInboundAllowed,
|
||||||
} from '../../../src/channels/telegram/telegram-runtime.mjs';
|
} from '../../../src/channels/telegram/telegram-runtime.mjs';
|
||||||
import { TelegramStateStore } from '../../../src/channels/telegram/state-store.mjs';
|
import { TelegramStateStore } from '../../../src/channels/telegram/state-store.mjs';
|
||||||
import {
|
import {
|
||||||
|
|
@ -351,6 +352,14 @@ test('Telegram normalizes private messages and requires an explicit group addres
|
||||||
assert.equal(topicTwo.replyTarget.messageThreadId, 200);
|
assert.equal(topicTwo.replyTarget.messageThreadId, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Telegram blocks every group and admits only allowlisted private senders', () => {
|
||||||
|
const allowed = new Set(['6087707998', '1202499116']);
|
||||||
|
assert.equal(telegramInboundAllowed({ kind: 'group', senderId: '6087707998' }, allowed), false);
|
||||||
|
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, allowed), true);
|
||||||
|
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '999999999' }, allowed), false);
|
||||||
|
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, new Set()), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('Telegram normalizes photo captions and image documents into one downloadable image', async () => {
|
test('Telegram normalizes photo captions and image documents into one downloadable image', async () => {
|
||||||
const loads = [];
|
const loads = [];
|
||||||
const groupPhoto = normalizeTelegramUpdate({
|
const groupPhoto = normalizeTelegramUpdate({
|
||||||
|
|
@ -622,6 +631,7 @@ test('Telegram runtime keeps polling while a Harness question waits for its answ
|
||||||
state,
|
state,
|
||||||
createApi: () => fakeApi,
|
createApi: () => fakeApi,
|
||||||
logger: { error() {}, warn() {} },
|
logger: { error() {}, warn() {} },
|
||||||
|
allowedPrivateUserIds: ['7'],
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ const QQ_SOURCE_URL = new URL(
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
);
|
);
|
||||||
|
|
||||||
test('IM settings renders nine compact logo channel tabs without enable switches', async () => {
|
test('IM settings renders nine IM channels plus the AI Office connector', async () => {
|
||||||
const styles = await readFile(STYLES_URL, 'utf8');
|
const styles = await readFile(STYLES_URL, 'utf8');
|
||||||
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
||||||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||||
|
|
@ -108,6 +108,7 @@ test('IM settings renders nine compact logo channel tabs without enable switches
|
||||||
telegramRpcCall: async () => ({ ok: true, value: {} }),
|
telegramRpcCall: async () => ({ ok: true, value: {} }),
|
||||||
discordRpcCall: async () => ({ ok: true, value: {} }),
|
discordRpcCall: async () => ({ ok: true, value: {} }),
|
||||||
whatsappRpcCall: async () => ({ ok: true, value: {} }),
|
whatsappRpcCall: async () => ({ ok: true, value: {} }),
|
||||||
|
officeRpcCall: async () => ({ ok: true, value: {} }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
assert.match(markup, /IM机器人/);
|
assert.match(markup, /IM机器人/);
|
||||||
|
|
@ -139,6 +140,7 @@ test('IM settings renders nine compact logo channel tabs without enable switches
|
||||||
assert.match(markup, />Telegram</);
|
assert.match(markup, />Telegram</);
|
||||||
assert.match(markup, />Discord</);
|
assert.match(markup, />Discord</);
|
||||||
assert.match(markup, />WhatsApp</);
|
assert.match(markup, />WhatsApp</);
|
||||||
|
assert.match(markup, />AI Office</);
|
||||||
assert.match(markup, /dim-logoWeixin/);
|
assert.match(markup, /dim-logoWeixin/);
|
||||||
assert.match(markup, /dim-logoFeishu/);
|
assert.match(markup, /dim-logoFeishu/);
|
||||||
assert.match(markup, /dim-logoDingtalk/);
|
assert.match(markup, /dim-logoDingtalk/);
|
||||||
|
|
@ -148,8 +150,9 @@ test('IM settings renders nine compact logo channel tabs without enable switches
|
||||||
assert.match(markup, /dim-logoTelegram/);
|
assert.match(markup, /dim-logoTelegram/);
|
||||||
assert.match(markup, /dim-logoDiscord/);
|
assert.match(markup, /dim-logoDiscord/);
|
||||||
assert.match(markup, /dim-logoWhatsapp/);
|
assert.match(markup, /dim-logoWhatsapp/);
|
||||||
|
assert.match(markup, /dim-logoOffice/);
|
||||||
assert.match(styles, /\.dim-logoFeishu svg \{ width: 28px; height: 28px; \}/);
|
assert.match(styles, /\.dim-logoFeishu svg \{ width: 28px; height: 28px; \}/);
|
||||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 9);
|
assert.equal((markup.match(/role="tab"/g) ?? []).length, 10);
|
||||||
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
||||||
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
||||||
assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/);
|
assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import test from 'node:test';
|
||||||
|
|
||||||
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
|
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
|
||||||
|
|
||||||
test('Host composes all nine IM channels inside one plugin context', async () => {
|
test('Host composes nine IM channels and the AI Office connector inside one plugin context', async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const plugin = createImHostPlugin({
|
const plugin = createImHostPlugin({
|
||||||
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
|
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
|
||||||
|
|
@ -15,6 +15,7 @@ test('Host composes all nine IM channels inside one plugin context', async () =>
|
||||||
applyTelegram: async (ctx, config) => calls.push(['telegram', ctx, config]),
|
applyTelegram: async (ctx, config) => calls.push(['telegram', ctx, config]),
|
||||||
applyDiscord: async (ctx, config) => calls.push(['discord', ctx, config]),
|
applyDiscord: async (ctx, config) => calls.push(['discord', ctx, config]),
|
||||||
applyWhatsapp: async (ctx, config) => calls.push(['whatsapp', ctx, config]),
|
applyWhatsapp: async (ctx, config) => calls.push(['whatsapp', ctx, config]),
|
||||||
|
applyOffice: async (ctx, config) => calls.push(['office', ctx, config]),
|
||||||
});
|
});
|
||||||
const ctx = { marker: 'shared-context' };
|
const ctx = { marker: 'shared-context' };
|
||||||
const config = {
|
const config = {
|
||||||
|
|
@ -28,6 +29,7 @@ test('Host composes all nine IM channels inside one plugin context', async () =>
|
||||||
telegram: { replyTimeoutMs: 60_000 },
|
telegram: { replyTimeoutMs: 60_000 },
|
||||||
discord: { replyTimeoutMs: 60_000 },
|
discord: { replyTimeoutMs: 60_000 },
|
||||||
whatsapp: { replyTimeoutMs: 60_000 },
|
whatsapp: { replyTimeoutMs: 60_000 },
|
||||||
|
office: { heartbeatSeconds: 30 },
|
||||||
};
|
};
|
||||||
|
|
||||||
await plugin.apply(ctx, config);
|
await plugin.apply(ctx, config);
|
||||||
|
|
@ -44,6 +46,7 @@ test('Host composes all nine IM channels inside one plugin context', async () =>
|
||||||
['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host' }],
|
['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host' }],
|
||||||
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }],
|
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }],
|
||||||
['whatsapp', ctx, { ...config.whatsapp, rpcAuthority: 'trusted-host' }],
|
['whatsapp', ctx, { ...config.whatsapp, rpcAuthority: 'trusted-host' }],
|
||||||
|
['office', ctx, { ...config.office, rpcAuthority: 'trusted-host' }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue