From cb74e1c4c79a4ecd3c66b9d398c4f67c563eec75 Mon Sep 17 00:00:00 2001 From: xmanrui <841206367@qq.com> Date: Sun, 16 Aug 2026 14:47:16 +0800 Subject: [PATCH] Add Telegram and Discord IM channels --- README.md | 50 +- THIRD_PARTY_NOTICES.md | 4 +- lib/client.js | 2941 +++++++------ lib/index.js | 3624 ++++++++++++++--- package.json | 2 +- plugin-src/client/channel-logos.js | 26 + plugin-src/client/channels/discord/api.js | 11 + plugin-src/client/channels/discord/index.js | 25 + plugin-src/client/channels/discord/styles.js | 19 + .../client/channels/shared/token-api.js | 88 + .../client/channels/shared/token-channel.js | 277 ++ plugin-src/client/channels/telegram/api.js | 11 + plugin-src/client/channels/telegram/index.js | 25 + plugin-src/client/channels/telegram/styles.js | 19 + plugin-src/client/credential-binding.js | 14 +- plugin-src/client/index.js | 58 +- plugin-src/client/styles.js | 5 + plugin-src/host/channels/dingtalk/index.mjs | 11 +- plugin-src/host/channels/dingtalk/rpc.mjs | 5 +- plugin-src/host/channels/discord/index.mjs | 30 + .../host/channels/discord/production.mjs | 17 + plugin-src/host/channels/discord/rpc.mjs | 21 + plugin-src/host/channels/feishu/index.mjs | 11 +- plugin-src/host/channels/feishu/rpc.mjs | 7 +- plugin-src/host/channels/qq/index.mjs | 11 +- plugin-src/host/channels/qq/rpc.mjs | 9 +- .../channels/shared/connection-supervisor.mjs | 133 + .../host/channels/shared/production.mjs | 104 + plugin-src/host/channels/shared/rpc.mjs | 119 + plugin-src/host/channels/telegram/index.mjs | 30 + .../host/channels/telegram/production.mjs | 17 + plugin-src/host/channels/telegram/rpc.mjs | 21 + plugin-src/host/channels/wecom/index.mjs | 11 +- plugin-src/host/channels/wecom/rpc.mjs | 5 +- plugin-src/host/channels/weixin/index.mjs | 11 +- plugin-src/host/channels/weixin/rpc.mjs | 5 +- plugin-src/host/index.mjs | 23 +- plugin-src/host/rpc-authority.mjs | 11 + scripts/verify-package.mjs | 6 +- src/channels/discord/config-store.mjs | 24 + src/channels/discord/discord-api.mjs | 153 + src/channels/discord/discord-bridge.mjs | 15 + src/channels/discord/discord-controller.mjs | 16 + src/channels/discord/discord-runtime.mjs | 460 +++ src/channels/discord/harness-client.mjs | 3 + src/channels/discord/state-store.mjs | 3 + .../shared/conversation-state-store.mjs | 107 + .../shared/editable-message-stream.mjs | 81 + src/channels/shared/text-harness-bridge.mjs | 180 + src/channels/shared/token-bot-controller.mjs | 298 ++ src/channels/shared/token-config-store.mjs | 180 + src/channels/telegram/config-store.mjs | 24 + src/channels/telegram/harness-client.mjs | 3 + src/channels/telegram/state-store.mjs | 3 + src/channels/telegram/telegram-api.mjs | 120 + src/channels/telegram/telegram-bridge.mjs | 15 + src/channels/telegram/telegram-controller.mjs | 16 + src/channels/telegram/telegram-runtime.mjs | 278 ++ test/channels/discord/client-ui.test.mjs | 42 + test/channels/discord/discord.test.mjs | 272 ++ test/channels/telegram/client-ui.test.mjs | 42 + test/channels/telegram/telegram.test.mjs | 274 ++ test/channels/weixin/plugin-host.test.mjs | 15 + test/client-ui.test.mjs | 12 +- test/host.test.mjs | 19 +- test/rpc-authority.test.mjs | 17 + 66 files changed, 8579 insertions(+), 1910 deletions(-) create mode 100644 plugin-src/client/channels/discord/api.js create mode 100644 plugin-src/client/channels/discord/index.js create mode 100644 plugin-src/client/channels/discord/styles.js create mode 100644 plugin-src/client/channels/shared/token-api.js create mode 100644 plugin-src/client/channels/shared/token-channel.js create mode 100644 plugin-src/client/channels/telegram/api.js create mode 100644 plugin-src/client/channels/telegram/index.js create mode 100644 plugin-src/client/channels/telegram/styles.js create mode 100644 plugin-src/host/channels/discord/index.mjs create mode 100644 plugin-src/host/channels/discord/production.mjs create mode 100644 plugin-src/host/channels/discord/rpc.mjs create mode 100644 plugin-src/host/channels/shared/connection-supervisor.mjs create mode 100644 plugin-src/host/channels/shared/production.mjs create mode 100644 plugin-src/host/channels/shared/rpc.mjs create mode 100644 plugin-src/host/channels/telegram/index.mjs create mode 100644 plugin-src/host/channels/telegram/production.mjs create mode 100644 plugin-src/host/channels/telegram/rpc.mjs create mode 100644 plugin-src/host/rpc-authority.mjs create mode 100644 src/channels/discord/config-store.mjs create mode 100644 src/channels/discord/discord-api.mjs create mode 100644 src/channels/discord/discord-bridge.mjs create mode 100644 src/channels/discord/discord-controller.mjs create mode 100644 src/channels/discord/discord-runtime.mjs create mode 100644 src/channels/discord/harness-client.mjs create mode 100644 src/channels/discord/state-store.mjs create mode 100644 src/channels/shared/conversation-state-store.mjs create mode 100644 src/channels/shared/editable-message-stream.mjs create mode 100644 src/channels/shared/text-harness-bridge.mjs create mode 100644 src/channels/shared/token-bot-controller.mjs create mode 100644 src/channels/shared/token-config-store.mjs create mode 100644 src/channels/telegram/config-store.mjs create mode 100644 src/channels/telegram/harness-client.mjs create mode 100644 src/channels/telegram/state-store.mjs create mode 100644 src/channels/telegram/telegram-api.mjs create mode 100644 src/channels/telegram/telegram-bridge.mjs create mode 100644 src/channels/telegram/telegram-controller.mjs create mode 100644 src/channels/telegram/telegram-runtime.mjs create mode 100644 test/channels/discord/client-ui.test.mjs create mode 100644 test/channels/discord/discord.test.mjs create mode 100644 test/channels/telegram/client-ui.test.mjs create mode 100644 test/channels/telegram/telegram.test.mjs create mode 100644 test/rpc-authority.test.mjs diff --git a/README.md b/README.md index f2aa5a3..0f18b82 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ ## 中文 -通过扫码或已有应用凭据把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信和 QQ 机器人。 +通过扫码或已有机器人凭据把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信、QQ、Telegram 和 Discord 机器人。 -> GitHub 简介:通过扫码或应用凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。 +> GitHub 简介:通过扫码或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Telegram和Discord)。 ## 界面 @@ -17,6 +17,8 @@ - 钉钉:扫码创建机器人,或使用已有 Client ID + Client Secret 绑定机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。 - 企业微信:使用企业微信 App 扫码创建智能机器人,或使用已有 Bot ID + Secret 绑定机器人,通过官方 WebSocket 长连接收消息,原生显示“正在思考中”、工具执行进度和流式回答。 - QQ:使用手机 QQ 扫码创建机器人,或使用已有 AppID + AppSecret 绑定机器人,通过 WebSocket 长连接收消息;私聊支持原生“正在输入”和流式回答,群聊在机器人被 @ 后回复。 +- Telegram:使用 @BotFather 生成的 Bot Token 接入机器人,通过官方 Bot API 长轮询收消息;私聊直接回复,群聊仅在机器人被提及或收到对机器人消息的回复时响应,并通过编辑消息流式显示 Harness 回答。 +- Discord:使用 Developer Portal 生成的 Bot Token 接入机器人,通过 Gateway v10 长连接收消息;私信直接回复,服务器频道仅在机器人被提及时响应,并通过编辑消息流式显示 Harness 回答。 其他 IM 平台可继续按同一渠道适配器结构接入。 @@ -30,6 +32,8 @@ npx -y github:xmanrui/dsh-im install 飞书、QQ、钉钉和企业微信页面都提供两种入口:带二维码图标的蓝色「扫码接入机器人」按钮走平台官方扫码流程,右侧带钥匙图标的白色描边「手动接入」按钮连接已经创建的机器人应用。飞书和 QQ 分别填写 App ID + App Secret、AppID + AppSecret;钉钉填写官方 Client ID + Client Secret;企业微信填写官方 Bot ID + Secret。Secret 只提交给本机 Harness Host,并写入受保护的凭据存储;状态接口和机器人列表不会回传 Secret。 +Telegram 和 Discord 没有官方扫码创建机器人流程,因此页面只显示带钥匙图标的「手动接入」入口,并只要求 Bot Token。Telegram Token 由 @BotFather 生成;若该机器人已经配置 Webhook,需要先由原服务移除 Webhook,Bot API 长轮询才能接管消息。Discord Token 来自 Developer Portal 的 Bot 页面;还需把机器人邀请到目标服务器,并授予查看频道、发送消息和读取历史消息权限。本插件只读取私信和明确提及机器人的服务器消息,因此不要求 Message Content 特权 Intent。 + 钉钉扫码接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。 企业微信扫码接入时,请使用已加入企业且具有机器人创建或管理权限的企业微信账号,并在手机端确认创建智能机器人。扫码创建的是企业微信智能机器人,不是让插件直接登录个人微信账号。无论扫码还是凭据绑定,企业微信中的机器人可见范围就是入站访问范围,请只开放给信任的企业成员和群聊。 @@ -41,10 +45,10 @@ QQ 扫码接入使用腾讯 QQBot v2 官方流程。默认腾讯授权页会把 ## 设计 - Harness 中只注册一个「IM机器人」设置页; -- 飞书、微信、钉钉、企业微信和 QQ 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件; -- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信和 QQ,不使用启用/停用开关; -- 五个渠道保持独立的 RPC、凭据、连接监督和会话映射; -- 浏览器只获得二维码和脱敏状态;手动输入的 Secret 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret` 或原始用户标识。 +- 七个渠道的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件; +- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信、QQ、Telegram 和 Discord,不使用启用/停用开关; +- 七个渠道保持独立的 RPC、凭据、连接监督和会话映射; +- 浏览器只获得二维码和脱敏状态;手动输入的 Secret 或 Bot Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Telegram/Discord Bot Token 或原始用户标识。 ## 本地开发 @@ -56,13 +60,23 @@ node bin/dsh-im.mjs install --source . `npm run check` 运行单元测试、构建 Host/Client 产物,并验证发布包不包含凭据或独立渠道设置页注册。 +IM 管理 RPC 默认仅接受回环浏览器。如果 Web profile 在受信任的局域网内对外提供服务,可在该 profile 的 `cordis.patch.yml` 中显式开放给 Connection 已信任的 Host authority: + +```yaml +- id: xmanrui-dsh-im + config: + rpcAuthority: trusted-host +``` + +`trusted-host` 只复用 Harness 的 Host/Origin 防护,不是用户认证。启用后,能访问该局域网地址的人也能查看机器人状态、扫码或提交应用凭据、重连和删除机器人;只应在可信网络中使用。 + --- ## English -Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing application credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, and QQ bots. +Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing bot credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, QQ, Telegram, and Discord bots. -> GitHub description: Connect IM bots to DeepSeek Harness by QR code or application credentials (supports Feishu, WeChat, DingTalk, WeCom, and QQ). +> GitHub description: Connect IM bots to DeepSeek Harness by QR code or bot credentials (supports Feishu, WeChat, DingTalk, WeCom, QQ, Telegram, and Discord). ## Interface @@ -75,6 +89,8 @@ Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing a - DingTalk: create a bot by QR code or bind an existing bot with Client ID + Client Secret, receive messages through DingTalk Stream, and stream Harness replies through AI Cards. - WeCom: create an intelligent bot by QR code or bind an existing bot with Bot ID + Secret, receive messages over the official WebSocket connection, and natively show a thinking state, tool progress, and streaming replies. - QQ: create a bot by QR code or bind an existing bot with AppID + AppSecret, receive messages over a WebSocket connection, stream private-chat replies with a native typing indicator, and reply in groups when mentioned. +- Telegram: bind a BotFather-created bot with its Bot Token, receive messages through Bot API long polling, reply directly in private chats, require a mention or reply in groups, and stream Harness output by editing the reply. +- Discord: bind a Developer Portal bot with its Bot Token, receive events through Gateway v10, reply directly in DMs, require a mention in server channels, and stream Harness output by editing the reply. Other IM platforms can be added through the same channel-adapter structure. @@ -88,6 +104,8 @@ Restart `dsh web`, then open **Settings → Plugins → IM Bot**. The installer Feishu, QQ, DingTalk, and WeCom each provide two entry points. The blue **QR access** action uses the platform QR flow; the key-marked, outlined **Manual access** action immediately to its right connects an existing bot application. Feishu and QQ use App ID + App Secret and AppID + AppSecret respectively, DingTalk uses Client ID + Client Secret, and WeCom uses Bot ID + Secret. Secrets are sent only to the local Harness Host and stored through its protected credential provider; status responses and bot lists never return them. +Telegram and Discord do not provide an official QR flow for creating bots, so their pages expose only the key-marked **Manual access** action and request a Bot Token. Generate the Telegram token with BotFather; an existing webhook must be removed by its current service before Bot API long polling can receive updates. Generate the Discord token on the Developer Portal's Bot page, invite the bot to the target server, and grant View Channel, Send Messages, and Read Message History. The plugin reads DMs and server messages that explicitly mention the bot, so it does not request the privileged Message Content intent. + For DingTalk QR binding, scan with an account that belongs to an enterprise or organization and can create bots, then choose **Create a new bot** on the authorization page. If DingTalk reports that the account has not joined an organization, create one or switch to an account that has, then scan again. There is no second local sender-approval flow: the bot's DingTalk visibility is its inbound access scope, so restrict it to trusted organizations, groups, or members. For WeCom QR binding, scan with an account that belongs to an enterprise and can create or manage bots, then confirm creation of the intelligent bot in the mobile app. This creates a WeCom intelligent bot; it does not sign the plugin into a personal WeChat account. For both QR and credential binding, restrict the bot's WeCom visibility to trusted enterprise members and group chats. @@ -99,10 +117,10 @@ Feishu QR binding records the scanner as an allowed user. Manual credentials can ## Design - Registers a single **IM Bot** settings page in Harness. -- Maintains the Feishu, WeChat, DingTalk, WeCom, and QQ Host, client, and runtime sources in this repository without external standalone channel plugins. -- Uses channel logos for WeChat, Feishu, DingTalk, WeCom, and QQ navigation without enable/disable switches. +- Maintains all seven channel Host, client, and runtime sources in this repository without external standalone channel plugins. +- Uses channel logos for WeChat, Feishu, DingTalk, WeCom, QQ, Telegram, and Discord navigation without enable/disable switches. - Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by channel. -- Returns only QR codes and redacted status data to the browser. Manually entered secrets travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, or raw user identifiers. +- Returns only QR codes and redacted status data to the browser. Manually entered secrets and Bot Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Telegram/Discord Bot Tokens, or raw user identifiers. ## Local development @@ -113,3 +131,13 @@ node bin/dsh-im.mjs install --source . ``` `npm run check` runs unit tests, builds the Host and Client artifacts, and verifies that the published package contains neither credentials nor standalone channel settings-page registrations. + +IM management RPCs accept loopback browsers by default. When a Web profile is deliberately served on a trusted LAN, opt the plugin into the Host authorities already trusted by Connection in that profile's `cordis.patch.yml`: + +```yaml +- id: xmanrui-dsh-im + config: + rpcAuthority: trusted-host +``` + +`trusted-host` reuses Harness's Host/Origin fence; it is not user authentication. Anyone who can reach that LAN authority can inspect bot status, scan or submit application credentials, reconnect bots, and remove bots. Enable it only on a trusted network. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 9ffd6ec..67cd6d2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -10,6 +10,6 @@ This package depends on [`@larksuiteoapi/node-sdk`](https://github.com/larksuite QQ QR binding uses Tencent Connect's official [`@tencent-connect/qqbot-connector`](https://www.npmjs.com/package/@tencent-connect/qqbot-connector) 1.2.0 package as an external runtime dependency. Its npm metadata declares `UNLICENSED`; no connector source is copied into this project. -This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, WeCom, Feishu, DingTalk, or QQ. +This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, WeCom, Feishu, DingTalk, QQ, Telegram, or Discord. -The WeChat and QQ marks use path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu, DingTalk, and WeCom marks are inline vectors used for channel identification. Product names and logos remain trademarks of their respective owners. +The WeChat, QQ, Telegram, and Discord marks use path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu, DingTalk, and WeCom marks are inline vectors used for channel identification. Product names and logos remain trademarks of their respective owners. diff --git a/lib/client.js b/lib/client.js index 66f1189..d10cd3d 100644 --- a/lib/client.js +++ b/lib/client.js @@ -40,7 +40,7 @@ __export(index_exports, { name: () => name }); module.exports = __toCommonJS(index_exports); -var React9 = __toESM(require("react"), 1); +var React10 = __toESM(require("react"), 1); // plugin-src/client/channel-logos.js var React = __toESM(require("react"), 1); @@ -123,6 +123,30 @@ function WecomLogoGlyph({ size } = {}) { h("path", { fill: "#3370FF", d: "M15.25 18.75a.92.92 0 1 1 1.3 1.3.92.92 0 0 1-1.3-1.3Z" }) ); } +function TelegramLogoGlyph({ size } = {}) { + return h("svg", { + ...dimensions(size), + viewBox: "0 0 24 24", + focusable: "false", + "aria-hidden": "true", + "data-im-channel-logo": "telegram" + }, h("path", { + fill: "currentColor", + d: "M23.95 4.57c-.36-1.45-1.43-1.76-2.82-1.24L1.5 10.9c-1.34.52-1.32 1.27-.24 1.6l5.03 1.57 11.66-7.36c.55-.34 1.05-.16.64.21l-9.44 8.52-.37 5.12c.54 0 .78-.24 1.08-.53l2.59-2.51 5.38 3.97c.99.55 1.7.27 1.95-.92L23.95 4.57Z" + })); +} +function DiscordLogoGlyph({ size } = {}) { + return h("svg", { + ...dimensions(size), + viewBox: "0 0 24 24", + focusable: "false", + "aria-hidden": "true", + "data-im-channel-logo": "discord" + }, h("path", { + fill: "currentColor", + d: "M20.32 4.37a19.8 19.8 0 0 0-4.89-1.51c-.21.38-.46.89-.63 1.29a18.4 18.4 0 0 0-5.59 0 13 13 0 0 0-.64-1.29c-1.71.29-3.36.8-4.89 1.52C.59 9.09-.25 13.68.17 18.2a19.9 19.9 0 0 0 6 3.04c.48-.66.91-1.36 1.28-2.1-.7-.26-1.37-.58-2-.96.17-.12.33-.25.49-.38 3.86 1.79 8.04 1.79 11.86 0 .16.13.32.26.49.38-.64.38-1.31.7-2.01.97.37.73.8 1.44 1.28 2.09a19.8 19.8 0 0 0 6-3.04c.49-5.24-.84-9.79-3.24-13.83ZM8.02 15.42c-1.16 0-2.11-1.07-2.11-2.38s.93-2.38 2.11-2.38c1.18 0 2.13 1.08 2.11 2.38 0 1.31-.93 2.38-2.11 2.38Zm7.95 0c-1.16 0-2.11-1.07-2.11-2.38s.93-2.38 2.11-2.38c1.18 0 2.13 1.08 2.11 2.38 0 1.31-.93 2.38-2.11 2.38Z" + })); +} // plugin-src/client/channels/dingtalk/api.js var DINGTALK_RPC_CHANNEL = "/dingtalk"; @@ -162,8 +186,8 @@ function optionalString(value, maxLength = 240) { return trimmed ? trimmed.slice(0, maxLength) : void 0; } function opaqueId(value) { - const id3 = optionalString(value, 128); - return id3 && /^[a-z\d_-]+$/i.test(id3) ? id3 : void 0; + const id4 = optionalString(value, 128); + return id4 && /^[a-z\d_-]+$/i.test(id4) ? id4 : void 0; } function timestamp(value) { if (typeof value === "number" && Number.isFinite(value)) return value; @@ -359,7 +383,7 @@ function CredentialActionIcon() { ); } function CredentialBindingPanel({ - channel, + channel: channel3, identityLabel, identityPlaceholder, secretLabel, @@ -372,11 +396,12 @@ function CredentialBindingPanel({ const [identity, setIdentity] = React2.useState(""); const [secret, setSecret] = React2.useState(""); const headingId = React2.useId(); + const hasIdentity = Boolean(identityLabel); const submit = (event) => { event.preventDefault(); const normalizedIdentity = identity.trim(); const normalizedSecret = secret.trim(); - if (!normalizedIdentity || !normalizedSecret || busy) return; + if (hasIdentity && !normalizedIdentity || !normalizedSecret || busy) return; void onSubmit?.({ identity: normalizedIdentity, secret: normalizedSecret }); }; return h2( @@ -385,11 +410,14 @@ function CredentialBindingPanel({ className: "ddt-card dim-surfaceCard dim-credentialPanel", "aria-labelledby": headingId }, - h2("h3", { id: headingId, className: "dim-credentialTitle" }, `\u624B\u52A8\u63A5\u5165${channel}\u673A\u5668\u4EBA`), + h2("h3", { id: headingId, className: "dim-credentialTitle" }, `\u624B\u52A8\u63A5\u5165${channel3}\u673A\u5668\u4EBA`), h2( "form", - { className: "dim-credentialForm", onSubmit: submit }, - h2( + { + className: `dim-credentialForm${hasIdentity ? "" : " dim-credentialFormSingle"}`, + onSubmit: submit + }, + hasIdentity ? h2( "label", { className: "dim-credentialField" }, h2("span", null, identityLabel), @@ -405,7 +433,7 @@ function CredentialBindingPanel({ disabled: busy, required: true }) - ), + ) : null, h2( "label", { className: "dim-credentialField" }, @@ -432,7 +460,7 @@ function CredentialBindingPanel({ type: "submit", className: "ddt-button", "data-kind": "primary", - disabled: busy || !identity.trim() || !secret.trim() + disabled: busy || hasIdentity && !identity.trim() || !secret.trim() }, busy ? "\u6B63\u5728\u7ED1\u5B9A\u2026" : "\u7ED1\u5B9A\u5E76\u8FDE\u63A5"), h2("button", { type: "button", @@ -1366,8 +1394,485 @@ function DingtalkSettingsTab({ rpcCall }) { ); } +// plugin-src/client/channels/shared/token-api.js +var ACCOUNT_STATES2 = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]); +function isRecord2(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function text(value, fallback, max = 240) { + return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : fallback; +} +function id(value) { + const result = text(value, "", 128); + return /^[a-z\d_-]+$/i.test(result) ? result : void 0; +} +function timestamp2(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN; + return Number.isNaN(parsed) ? void 0 : parsed; +} +var TOKEN_BOT_ENDPOINTS = Object.freeze({ + status: "connection.status", + bindCredentials: "bot.bind-credentials", + reconnectBot: "bot.reconnect", + deleteBot: "bot.delete" +}); +function createTokenChannelApi(channel3, connectionSummary) { + const unwrapRpcResult8 = (result) => { + if (!isRecord2(result) || typeof result.ok !== "boolean") { + throw new Error(`${channel3} \u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94`); + } + if (!result.ok) { + const error = new Error(text(result.error?.message, `${channel3} \u64CD\u4F5C\u5931\u8D25`)); + error.code = text(result.error?.code, `${channel3.toUpperCase()}_RPC_ERROR`, 80); + throw error; + } + return result.value; + }; + const normalizeBot6 = (value) => { + if (!isRecord2(value) || !id(value.botId)) return void 0; + const connected = value.connected === true; + const state = ACCOUNT_STATES2.has(value.state) ? value.state : "offline"; + return { + botId: id(value.botId), + connected, + state: connected ? "connected" : state, + bot: { + name: text(value.bot?.name, `${channel3}\u673A\u5668\u4EBA`, 100), + username: text(value.bot?.username, "", 100), + idMasked: text(value.bot?.idMasked, "\u673A\u5668\u4EBA\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58", 140) + }, + health: { + summary: text( + value.health?.summary, + connected ? `${channel3}${connectionSummary}\u8FD0\u884C\u6B63\u5E38` : `${channel3}\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA` + ), + lastCheckedAt: timestamp2(value.health?.lastCheckedAt) + }, + error: isRecord2(value.error) ? { + code: text(value.error.code, `${channel3.toUpperCase()}_ACCOUNT_ERROR`, 80), + message: text(value.error.message, `${channel3}\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA`) + } : null + }; + }; + const normalizeSnapshot7 = (value) => { + const source = isRecord2(value?.snapshot) ? value.snapshot : value; + if (!isRecord2(source) || !Array.isArray(source.bots)) { + throw new Error(`${channel3} \u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u673A\u5668\u4EBA\u5217\u8868`); + } + const bots = source.bots.map(normalizeBot6).filter(Boolean); + return { + revision: Number.isSafeInteger(source.revision) ? source.revision : 0, + bots, + totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length } + }; + }; + const presentError8 = (error) => ({ + code: text(error?.code, `${channel3.toUpperCase()}_ERROR`, 80), + message: text(error?.message, `${channel3}\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5`) + }); + return Object.freeze({ unwrapRpcResult: unwrapRpcResult8, normalizeSnapshot: normalizeSnapshot7, presentError: presentError8 }); +} + +// plugin-src/client/channels/discord/api.js +var DISCORD_RPC_CHANNEL = "/discord"; +var DISCORD_ENDPOINTS = TOKEN_BOT_ENDPOINTS; +var api = createTokenChannelApi("Discord", " Gateway \u957F\u8FDE\u63A5"); +var unwrapRpcResult2 = api.unwrapRpcResult; +var normalizeSnapshot2 = api.normalizeSnapshot; +var presentError2 = api.presentError; + +// plugin-src/client/channels/shared/token-channel.js +var React4 = __toESM(require("react"), 1); +var h4 = React4.createElement; +var Button3 = React4.forwardRef(function Button4({ children, kind = "secondary", className = "", ...props }, ref) { + return h4("button", { + ...props, + ref, + type: "button", + className: `ddt-button ${className}`.trim(), + "data-kind": kind + }, children); +}); +function checkedTime2(value) { + if (!value) return "\u5C1A\u672A\u68C0\u67E5"; + try { + return new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }).format(new Date(value)); + } catch { + return "\u521A\u521A"; + } +} +function createTokenChannelSettings(definition) { + const { + channel: channel3, + endpoints, + api: api3, + LogoGlyph, + installStyles, + pageClass, + avatarClass, + connectionLabel, + tokenPlaceholder, + emptyTitle, + emptyDescription, + platformLabel + } = definition; + function AccountCard5({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { + const state = busy === "reconnect" ? "connecting" : account.state; + const tone = account.connected ? "success" : state === "error" ? "error" : "warning"; + const stateLabel = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA"; + const summary = account.error?.message ?? (account.connected ? null : account.health.summary); + const identity = account.bot.username ? `@${account.bot.username}` : account.bot.idMasked; + return h4( + "article", + { className: "ddt-card dim-botCard", "data-bot-id": account.botId }, + h4( + "div", + { className: "ddt-cardBody dim-botCardBody" }, + h4( + "div", + { className: "ddt-accountTop dim-botCardTop" }, + h4( + "div", + { className: "ddt-accountIdentity dim-botIdentity" }, + h4( + "div", + { className: `ddt-avatar dim-botAvatar ${avatarClass}`, "aria-hidden": "true" }, + h4(LogoGlyph, { size: 29 }) + ), + h4( + "div", + { className: "dim-botName" }, + h4("h3", null, account.bot.name), + h4("p", null, identity) + ) + ), + h4( + "div", + { className: "ddt-health dim-botHealth" }, + h4("span", { className: "ddt-dot dim-healthDot", "data-tone": tone }), + h4("span", null, stateLabel) + ) + ), + h4( + "dl", + { className: "ddt-metrics dim-botMetrics" }, + h4( + "div", + { className: "ddt-metric dim-botMetric" }, + h4("dt", null, "\u6D88\u606F\u901A\u9053"), + h4("dd", null, account.connected ? connectionLabel : "\u79BB\u7EBF") + ), + h4( + "div", + { className: "ddt-metric dim-botMetric" }, + h4("dt", null, "\u6700\u8FD1\u68C0\u67E5"), + h4("dd", null, checkedTime2(account.health.lastCheckedAt)) + ) + ), + h4( + "div", + { className: "ddt-accountFooter dim-cardFooter" }, + summary ? h4("div", { className: "ddt-summary dim-cardSummary" }, summary) : null, + h4( + "div", + { className: "ddt-actions dim-cardActions" }, + h4(Button3, { + className: "dim-cardAction", + onClick: onReconnect, + disabled: Boolean(busy) + }, busy === "reconnect" ? "\u68C0\u67E5\u4E2D\u2026" : account.connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"), + h4(Button3, { + className: "dim-cardAction", + kind: "danger", + onClick: onRequestRemove, + disabled: Boolean(busy) + }, "\u79FB\u9664\u63A5\u5165") + ) + ) + ), + removing ? h4( + "div", + { className: "ddt-confirm dim-confirm", role: "alertdialog" }, + h4("strong", null, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${account.bot.name}\u201D\uFF1F`), + h4("p", null, `\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684 Bot Token\u3001\u673A\u5668\u4EBA\u914D\u7F6E\u53CA\u4F1A\u8BDD\u6620\u5C04\u3002${platformLabel}\u4E2D\u7684\u673A\u5668\u4EBA\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\u3002`), + h4( + "div", + { className: "ddt-actions dim-viewActions" }, + h4(Button3, { onClick: onCancelRemove, disabled: Boolean(busy) }, "\u4FDD\u7559\u673A\u5668\u4EBA"), + h4( + Button3, + { kind: "danger", onClick: onConfirmRemove, disabled: Boolean(busy) }, + busy === "delete" ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664\u63A5\u5165" + ) + ) + ) : null + ); + } + function SettingsTab({ rpcCall }) { + const [model, setModel] = React4.useState({ + phase: "loading", + bots: [], + totals: { configured: 0, connected: 0 }, + error: null + }); + const [credentialOpen, setCredentialOpen] = React4.useState(false); + const [credentialError, setCredentialError] = React4.useState(null); + const [busy, setBusy] = React4.useState(false); + const [busyByBot, setBusyByBot] = React4.useState({}); + const [removeTarget, setRemoveTarget] = React4.useState(null); + const mounted = React4.useRef(true); + React4.useEffect(() => { + const disposeDingtalk = installDingtalkStyles(); + const disposeChannel = installStyles(); + mounted.current = true; + return () => { + mounted.current = false; + disposeChannel(); + disposeDingtalk(); + }; + }, []); + const invoke = React4.useCallback(async (endpoint, payload = {}, signal) => { + if (typeof rpcCall !== "function") throw new TypeError(`${channel3} \u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5`); + return api3.unwrapRpcResult(await rpcCall(endpoint, payload, signal)); + }, [rpcCall]); + const loadStatus = React4.useCallback(async ({ signal, silent = false } = {}) => { + if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null })); + try { + const snapshot = api3.normalizeSnapshot(await invoke(endpoints.status, {}, signal)); + if (!mounted.current || signal?.aborted) return; + setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); + } catch (error) { + if (error?.name !== "AbortError" && mounted.current && !signal?.aborted) { + setModel((current) => ({ + ...current, + phase: silent ? current.phase : "error", + error: api3.presentError(error) + })); + } + } + }, [invoke]); + React4.useEffect(() => { + const controller = new AbortController(); + void loadStatus({ signal: controller.signal }); + return () => controller.abort(); + }, [loadStatus]); + React4.useEffect(() => { + if (model.phase !== "ready") return void 0; + const controller = new AbortController(); + const timer = window.setInterval( + () => void loadStatus({ signal: controller.signal, silent: true }), + 15e3 + ); + return () => { + controller.abort(); + window.clearInterval(timer); + }; + }, [loadStatus, model.phase]); + const bindCredentials = React4.useCallback(async ({ secret }) => { + setBusy(true); + setCredentialError(null); + try { + const snapshot = api3.normalizeSnapshot(await invoke(endpoints.bindCredentials, { token: secret })); + if (!mounted.current) return; + setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); + setCredentialOpen(false); + } catch (error) { + if (mounted.current) setCredentialError(api3.presentError(error)); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke]); + const botAction = React4.useCallback(async (account, operation, endpoint, payload) => { + setBusyByBot((current) => ({ ...current, [account.botId]: operation })); + try { + const snapshot = api3.normalizeSnapshot(await invoke(endpoint, payload)); + if (mounted.current) { + setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); + } + } finally { + if (mounted.current) setBusyByBot((current) => { + const next = { ...current }; + delete next[account.botId]; + return next; + }); + } + }, [invoke]); + const botList = model.bots.length > 0 ? h4( + "section", + { className: "dim-listSection" }, + h4( + "div", + { className: "ddt-listHeading dim-listHeading" }, + h4("h3", null, `\u5DF2\u63A5\u5165\u7684 ${channel3} \u673A\u5668\u4EBA`) + ), + h4("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h4("li", { key: account.botId }, h4(AccountCard5, { + account, + busy: busyByBot[account.botId], + removing: removeTarget === account.botId, + onReconnect: () => void botAction( + account, + "reconnect", + endpoints.reconnectBot, + { botId: account.botId } + ), + onRequestRemove: () => setRemoveTarget(account.botId), + onCancelRemove: () => setRemoveTarget(null), + onConfirmRemove: async () => { + await botAction(account, "delete", endpoints.deleteBot, { + botId: account.botId, + confirm: true + }); + if (mounted.current) setRemoveTarget(null); + } + })))) + ) : null; + return h4( + "section", + { + className: `ddt-page ${pageClass} dim-channelPage`, + "aria-label": `${channel3} \u8BBE\u7F6E` + }, + h4( + "div", + { className: "ddt-heading" }, + h4( + "div", + { className: "ddt-tools" }, + h4( + "div", + { className: "dim-bindActions" }, + h4(Button3, { + kind: "credential", + className: "dim-credentialButton", + onClick: () => { + setCredentialOpen((value) => !value); + setCredentialError(null); + }, + disabled: busy, + "aria-pressed": credentialOpen, + "aria-label": `\u4F7F\u7528 Bot Token \u63A5\u5165 ${channel3} \u673A\u5668\u4EBA` + }, h4(CredentialActionIcon), credentialOpen ? "\u6536\u8D77\u51ED\u636E" : "\u624B\u52A8\u63A5\u5165") + ), + model.totals.configured > 0 ? h4( + "div", + { className: "ddt-badge dim-onlineBadge" }, + h4("span", null, `${model.totals.connected} / ${model.totals.configured} \u5728\u7EBF`) + ) : null + ) + ), + model.phase === "loading" ? h4("div", { + className: "ddt-card ddt-loading dim-surfaceCard dim-loadingView", + "aria-busy": "true" + }, h4("div", { className: "ddt-spinner dim-spinner" }), `\u6B63\u5728\u8BFB\u53D6 ${channel3} \u673A\u5668\u4EBA\u72B6\u6001\u2026`) : model.phase === "error" ? h4( + "div", + { className: "ddt-card dim-surfaceCard" }, + h4( + "div", + { className: "ddt-inlineError dim-inlineError" }, + h4("h3", null, `\u65E0\u6CD5\u8BFB\u53D6 ${channel3} \u673A\u5668\u4EBA\u72B6\u6001`), + h4("p", null, model.error?.message), + h4(Button3, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") + ) + ) : h4( + React4.Fragment, + null, + credentialOpen ? h4(CredentialBindingPanel, { + channel: channel3, + secretLabel: "Bot Token", + secretPlaceholder: tokenPlaceholder, + busy, + error: credentialError, + onSubmit: bindCredentials, + onCancel: () => { + setCredentialOpen(false); + setCredentialError(null); + } + }) : null, + model.bots.length === 0 && !credentialOpen ? h4( + "div", + { className: "ddt-card dim-surfaceCard" }, + h4( + "div", + { className: "ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView" }, + h4( + "div", + { className: "dim-emptyCopy" }, + h4( + "div", + { className: "ddt-stateLabel dim-stateLabel" }, + h4("span", { className: "ddt-dot dim-stateDot" }), + h4("span", null, `\u5C1A\u672A\u63A5\u5165 ${channel3} \u673A\u5668\u4EBA`) + ), + h4("h3", null, emptyTitle), + h4("p", null, emptyDescription), + h4( + "div", + { className: "ddt-actions dim-viewActions" }, + h4(Button3, { + kind: "primary", + onClick: () => setCredentialOpen(true) + }, "\u586B\u5199 Bot Token") + ) + ), + h4("div", { + className: `ddt-brandMark dim-emptyBrand ${avatarClass}`, + "aria-hidden": "true" + }, h4(LogoGlyph, { size: 64 })) + ) + ) : null, + botList + ) + ); + } + return { SettingsTab, AccountCard: AccountCard5 }; +} + +// plugin-src/client/channels/discord/styles.js +var DISCORD_STYLE_ID = "xmanrui-dsh-im-discord-settings"; +var CSS2 = String.raw` +.ddc-page { --ddt-accent: #5865f2; --ddt-accent-deep: #4752c4; --ddt-accent-wash: #eef0ff; } +.ddc-avatar { color: #fff; background: #5865f2; } +.ddc-avatar svg { display: block; } +`; +function installDiscordStyles() { + if (typeof document === "undefined") return () => { + }; + const existing = document.querySelector(`style[data-plugin-css="${DISCORD_STYLE_ID}"]`); + if (existing) return () => { + }; + const style = document.createElement("style"); + style.dataset.plugin = "@xmanrui/dsh-im"; + style.dataset.pluginCss = DISCORD_STYLE_ID; + style.textContent = CSS2; + document.head.appendChild(style); + return () => style.remove(); +} + +// plugin-src/client/channels/discord/index.js +var channel = createTokenChannelSettings({ + channel: "Discord", + endpoints: DISCORD_ENDPOINTS, + api, + LogoGlyph: DiscordLogoGlyph, + installStyles: installDiscordStyles, + pageClass: "ddc-page", + avatarClass: "ddc-avatar", + connectionLabel: "Gateway \u957F\u8FDE\u63A5", + tokenPlaceholder: "\u586B\u5199 Discord Developer Portal \u7684 Bot Token", + emptyTitle: "\u63A5\u5165 Discord \u673A\u5668\u4EBA", + emptyDescription: "\u5148\u5728 Developer Portal \u521B\u5EFA Bot \u5E76\u9080\u8BF7\u5230\u670D\u52A1\u5668\uFF0C\u518D\u5728\u8FD9\u91CC\u5B8C\u6210\u63A5\u5165\u3002", + platformLabel: "Discord Developer Portal" +}); +var DiscordSettingsTab = channel.SettingsTab; +var DiscordAccountCard = channel.AccountCard; + // plugin-src/client/channels/feishu/index.js -var React5 = __toESM(require("react"), 1); +var React6 = __toESM(require("react"), 1); // plugin-src/client/channels/feishu/api.js var FEISHU_RPC_CHANNEL = "/feishu"; @@ -1401,7 +1906,7 @@ var POLL_STATES = /* @__PURE__ */ new Set([ "expired", "failed" ]); -function isRecord2(value) { +function isRecord3(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function optionalString2(value) { @@ -1418,8 +1923,8 @@ function optionalTimestamp(value) { function clamp2(value, min, max, fallback) { return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback; } -function unwrapRpcResult2(result) { - if (!isRecord2(result) || typeof result.ok !== "boolean") { +function unwrapRpcResult3(result) { + if (!isRecord3(result) || typeof result.ok !== "boolean") { throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94"); } if (!result.ok) { @@ -1431,8 +1936,8 @@ function unwrapRpcResult2(result) { return result.value; } function normalizeProvisioning2(value, now = Date.now()) { - const source = isRecord2(value?.provisioning) ? value.provisioning : value; - if (!isRecord2(source)) throw new Error("\u98DE\u4E66\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u4E8C\u7EF4\u7801\u4FE1\u606F"); + const source = isRecord3(value?.provisioning) ? value.provisioning : value; + if (!isRecord3(source)) throw new Error("\u98DE\u4E66\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u4E8C\u7EF4\u7801\u4FE1\u606F"); const attemptId = optionalString2(source.attemptId) ?? optionalString2(source.provisioningId); const verificationUrl = optionalString2(source.verificationUrl); const qrCodeDataUrl = optionalString2(source.qrCodeDataUrl); @@ -1450,7 +1955,7 @@ function normalizeProvisioning2(value, now = Date.now()) { }; } function normalizeBot2(value) { - const source = isRecord2(value) ? value : {}; + const source = isRecord3(value) ? value : {}; return { name: optionalString2(source.name) ?? "\u98DE\u4E66\u673A\u5668\u4EBA", avatarUrl: optionalString2(source.avatarUrl), @@ -1461,7 +1966,7 @@ function normalizeBot2(value) { }; } function normalizeHealth(value, connected = false) { - const source = isRecord2(value) ? value : {}; + const source = isRecord3(value) ? value : {}; const fallbackStatus = connected ? "healthy" : "offline"; const status = ["healthy", "degraded", "offline", "checking"].includes(source.status) ? source.status : fallbackStatus; return { @@ -1472,7 +1977,7 @@ function normalizeHealth(value, connected = false) { }; } function normalizeError2(value) { - if (!isRecord2(value)) return void 0; + if (!isRecord3(value)) return void 0; const message = optionalString2(value.message); if (!message) return void 0; return { message, code: optionalString2(value.code) }; @@ -1487,7 +1992,7 @@ function authoritativeState(value, connected) { return "offline"; } function normalizeBotConnection(value, fallbackBotId) { - if (!isRecord2(value)) throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6548\u7684\u673A\u5668\u4EBA\u72B6\u6001"); + if (!isRecord3(value)) throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6548\u7684\u673A\u5668\u4EBA\u72B6\u6001"); const botId = optionalString2(value.botId) ?? optionalString2(fallbackBotId); if (!botId) throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u7684\u673A\u5668\u4EBA\u7F3A\u5C11 botId"); const connected = value.connected === true; @@ -1502,7 +2007,7 @@ function normalizeBotConnection(value, fallbackBotId) { }; } function normalizeBotsSnapshot(value) { - if (!isRecord2(value)) throw new Error("\u98DE\u4E66\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u8FDE\u63A5\u72B6\u6001"); + if (!isRecord3(value)) throw new Error("\u98DE\u4E66\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u8FDE\u63A5\u72B6\u6001"); let sourceBots = Array.isArray(value.bots) ? value.bots : []; if (sourceBots.length === 0 && value.configured === true) { sourceBots = [{ @@ -1540,7 +2045,7 @@ function normalizeBotsSnapshot(value) { }; } function normalizeConnectionSnapshot(value) { - if (!isRecord2(value)) throw new Error("\u98DE\u4E66\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u8FDE\u63A5\u72B6\u6001"); + if (!isRecord3(value)) throw new Error("\u98DE\u4E66\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u8FDE\u63A5\u72B6\u6001"); const connected = value.connected === true; const reportedState = CONNECTION_STATES.has(value.state) ? value.state : "disconnected"; const state = connected ? "connected" : reportedState === "connected" ? "connecting" : reportedState; @@ -1556,7 +2061,7 @@ function normalizeConnectionSnapshot(value) { return snapshot; } function normalizePollResult(value) { - if (!isRecord2(value)) throw new Error("\u98DE\u4E66\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u521B\u5EFA\u8FDB\u5EA6"); + if (!isRecord3(value)) throw new Error("\u98DE\u4E66\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u521B\u5EFA\u8FDB\u5EA6"); const status = POLL_STATES.has(value.status) ? value.status : POLL_STATES.has(value.state) ? value.state : void 0; if (!status) throw new Error("\u98DE\u4E66\u670D\u52A1\u8FD4\u56DE\u4E86\u672A\u77E5\u7684\u521B\u5EFA\u72B6\u6001"); const normalized = { @@ -1567,12 +2072,12 @@ function normalizePollResult(value) { provisioning: void 0 }; if (value.provisioning) normalized.provisioning = normalizeProvisioning2(value.provisioning); - if (status === "connected" && isRecord2(value.connection)) { + if (status === "connected" && isRecord3(value.connection)) { normalized.connection = value.connection.botId ? normalizeBotConnection(value.connection) : normalizeConnectionSnapshot(value.connection); } return normalized; } -function presentError2(error) { +function presentError3(error) { const raw = optionalString2(error?.message) ?? "\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"; const message = raw.replace(/(client[_-]?secret|app[_-]?secret|secret|token)\s*[:=]\s*[^\s,;]+/gi, "$1=\u2022\u2022\u2022\u2022\u2022\u2022").slice(0, 240); return { message, code: optionalString2(error?.code) }; @@ -1585,7 +2090,7 @@ function formatRemaining2(milliseconds) { } // plugin-src/client/lifecycle.js -var React4 = __toESM(require("react"), 1); +var React5 = __toESM(require("react"), 1); function createPollScheduler({ setTimeoutFn, clearTimeoutFn }) { let disposed = false; let timer; @@ -1647,8 +2152,8 @@ function createAnimationFrameScheduler({ requestFrame, cancelFrame }) { }; } function useAnimationFrameScheduler() { - const schedulerRef = React4.useRef(null); - React4.useEffect(() => { + const schedulerRef = React5.useRef(null); + React5.useEffect(() => { const scheduler = createAnimationFrameScheduler({ requestFrame: (callback) => window.requestAnimationFrame(callback), cancelFrame: (frame) => window.cancelAnimationFrame(frame) @@ -1659,7 +2164,7 @@ function useAnimationFrameScheduler() { if (schedulerRef.current === scheduler) schedulerRef.current = null; }; }, []); - return React4.useCallback( + return React5.useCallback( (callback, key) => schedulerRef.current?.schedule(callback, key) ?? false, [] ); @@ -1667,7 +2172,7 @@ function useAnimationFrameScheduler() { // plugin-src/client/channels/feishu/styles.js var FEISHU_STYLE_ID = "beihuixinghe-dsh-feishu-settings"; -var CSS2 = String.raw` +var CSS3 = String.raw` .bxf-page { --bxf-accent: var(--dsw-alias-state-business-primary, #3370ff); --bxf-success: var(--dsw-alias-state-success-primary, #20a162); @@ -2162,7 +2667,7 @@ function installFeishuStyles() { const style = document.createElement("style"); style.dataset.plugin = "@xmanrui/dsh-feishu"; style.dataset.pluginCss = FEISHU_STYLE_ID; - style.textContent = CSS2; + style.textContent = CSS3; document.head.appendChild(style); return () => { style.remove(); @@ -2170,9 +2675,9 @@ function installFeishuStyles() { } // plugin-src/client/channels/feishu/index.js -var h4 = React5.createElement; +var h5 = React6.createElement; function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) { - return h4("svg", { + return h5("svg", { width: size, height: size, viewBox, @@ -2184,10 +2689,10 @@ function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) { }, children); } function RobotIcon({ size = 26 }) { - return h4( + return h5( SvgIcon, { size }, - h4("rect", { + h5("rect", { x: "5", y: "7.5", width: "14", @@ -2196,7 +2701,7 @@ function RobotIcon({ size = 26 }) { stroke: "currentColor", strokeWidth: "1.7" }), - h4("path", { + h5("path", { d: "M12 4.5v3M8.7 12h.01M15.3 12h.01M9.2 15.3c1.67 1.08 3.93 1.08 5.6 0M3.5 11.5v3M20.5 11.5v3", stroke: "currentColor", strokeWidth: "1.7", @@ -2205,16 +2710,16 @@ function RobotIcon({ size = 26 }) { ); } function AlertIcon({ size = 22 }) { - return h4( + return h5( SvgIcon, { size }, - h4("path", { + h5("path", { d: "M12 3.4 21 19H3L12 3.4Z", stroke: "currentColor", strokeWidth: "1.7", strokeLinejoin: "round" }), - h4("path", { + h5("path", { d: "M12 9v4.4M12 16.6v.01", stroke: "currentColor", strokeWidth: "1.9", @@ -2223,36 +2728,36 @@ function AlertIcon({ size = 22 }) { ); } function QrIcon({ size = 58 }) { - return h4(SvgIcon, { size }, h4("path", { + return h5(SvgIcon, { size }, h5("path", { d: "M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h2v2h-2v-2Zm4 0h2v4h-2v-4Zm-4 4h4v2h-4v-2Z", fill: "currentColor" })); } -var Button3 = React5.forwardRef(function Button4({ children, kind = "secondary", size, icon, className = "", ...props }, ref) { - return h4("button", { +var Button5 = React6.forwardRef(function Button6({ children, kind = "secondary", size, icon, className = "", ...props }, ref) { + return h5("button", { ...props, ref, type: "button", className: `bxf-button ${className}`.trim(), "data-kind": kind, "data-size": size - }, icon, h4("span", null, children)); + }, icon, h5("span", null, children)); }); function BrandMark() { - return h4("div", { className: "bxf-brandMark" }, h4(RobotIcon, { size: 34 })); + return h5("div", { className: "bxf-brandMark" }, h5(RobotIcon, { size: 34 })); } function Heading2({ totals, onAdd, onCredential, credentialOpen, adding, busy, addButtonRef }) { const hasBots = totals.configured > 0; - return h4( + return h5( "div", { className: "bxf-heading" }, - h4( + h5( "div", { className: "bxf-headingTools" }, - h4( + h5( "div", { className: "dim-bindActions" }, - h4(Button3, { + h5(Button5, { kind: "primary", size: "small", className: "bxf-bindButton dim-scanButton", @@ -2261,9 +2766,9 @@ function Heading2({ totals, onAdd, onCredential, credentialOpen, adding, busy, a ref: addButtonRef, "aria-busy": busy ? "true" : void 0, "aria-label": "\u626B\u7801\u63A5\u5165\u98DE\u4E66\u673A\u5668\u4EBA", - icon: h4(QrActionIcon) + icon: h5(QrActionIcon) }, adding ? "\u6B63\u5728\u63A5\u5165" : "\u626B\u7801\u63A5\u5165\u673A\u5668\u4EBA"), - h4(Button3, { + h5(Button5, { kind: "credential", size: "small", className: "dim-credentialButton", @@ -2271,50 +2776,50 @@ function Heading2({ totals, onAdd, onCredential, credentialOpen, adding, busy, a disabled: adding || busy, "aria-pressed": credentialOpen, "aria-label": "\u4F7F\u7528 App ID \u548C App Secret \u7ED1\u5B9A\u98DE\u4E66\u673A\u5668\u4EBA", - icon: h4(CredentialActionIcon) + icon: h5(CredentialActionIcon) }, credentialOpen ? "\u6536\u8D77\u51ED\u636E" : "\u624B\u52A8\u63A5\u5165") ), - hasBots ? h4("div", { + hasBots ? h5("div", { className: "bxf-totalBadge dim-onlineBadge", "aria-label": `\u5DF2\u63A5\u5165 ${totals.configured} \u4E2A\u673A\u5668\u4EBA\uFF0C\u5176\u4E2D ${totals.connected} \u4E2A\u5728\u7EBF` - }, h4("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`)) : null + }, h5("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`)) : null ) ); } function LoadingView2() { - return h4( + return h5( "div", { className: "bxf-card dim-surfaceCard dim-loadingView", "aria-busy": "true", "aria-label": "\u6B63\u5728\u8BFB\u53D6\u98DE\u4E66\u673A\u5668\u4EBA\u5217\u8868" }, - h4("div", { className: "dim-spinner", "aria-hidden": "true" }), - h4("span", null, "\u6B63\u5728\u8BFB\u53D6\u98DE\u4E66\u8FDE\u63A5\u72B6\u6001\u2026") + h5("div", { className: "dim-spinner", "aria-hidden": "true" }), + h5("span", null, "\u6B63\u5728\u8BFB\u53D6\u98DE\u4E66\u8FDE\u63A5\u72B6\u6001\u2026") ); } function EmptyView2({ onStart, busy }) { - return h4( + return h5( "div", { className: "bxf-card dim-surfaceCard" }, - h4( + h5( "div", { className: "bxf-cardBody bxf-intro dim-surfaceBody dim-emptyView" }, - h4( + h5( "div", { className: "bxf-introCopy dim-emptyCopy" }, - h4( + h5( "div", { className: "bxf-stateLabel dim-stateLabel" }, - h4("span", { className: "bxf-dot dim-stateDot" }), - h4("span", null, "\u5C1A\u672A\u63A5\u5165\u673A\u5668\u4EBA") + h5("span", { className: "bxf-dot dim-stateDot" }), + h5("span", null, "\u5C1A\u672A\u63A5\u5165\u673A\u5668\u4EBA") ), - h4("h3", null, "\u626B\u7801\uFF0C\u521B\u5EFA\u7B2C\u4E00\u4E2A\u98DE\u4E66\u5165\u53E3"), - h4("p", null, "\u65E0\u9700\u624B\u52A8\u586B\u5199 App ID\u3002\u4EE5\u540E\u8FD8\u53EF\u4EE5\u7EE7\u7EED\u6DFB\u52A0\u673A\u5668\u4EBA\uFF0C\u5206\u522B\u670D\u52A1\u4E0D\u540C\u56E2\u961F\u6216\u98DE\u4E66\u79DF\u6237\u3002"), - h4( + h5("h3", null, "\u626B\u7801\uFF0C\u521B\u5EFA\u7B2C\u4E00\u4E2A\u98DE\u4E66\u5165\u53E3"), + h5("p", null, "\u65E0\u9700\u624B\u52A8\u586B\u5199 App ID\u3002\u4EE5\u540E\u8FD8\u53EF\u4EE5\u7EE7\u7EED\u6DFB\u52A0\u673A\u5668\u4EBA\uFF0C\u5206\u522B\u670D\u52A1\u4E0D\u540C\u56E2\u961F\u6216\u98DE\u4E66\u79DF\u6237\u3002"), + h5( "div", { className: "bxf-actions dim-viewActions" }, - h4(Button3, { + h5(Button5, { kind: "primary", onClick: onStart, disabled: busy, @@ -2322,7 +2827,7 @@ function EmptyView2({ onStart, busy }) { }, busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210\u98DE\u4E66\u4E8C\u7EF4\u7801") ) ), - h4("div", { className: "bxf-markStage dim-emptyBrand", "aria-hidden": "true" }, h4(BrandMark)) + h5("div", { className: "bxf-markStage dim-emptyBrand", "aria-hidden": "true" }, h5(BrandMark)) ) ); } @@ -2340,93 +2845,93 @@ function safeQrSource2(value) { return /^data:image\/(?:png|webp|svg\+xml)(?:;charset=[^;,]+)?;base64,/i.test(value) ? value : void 0; } function QrPane({ provision, now, onRefresh, onCancel, busy }) { - const [imageFailed, setImageFailed] = React5.useState(false); + const [imageFailed, setImageFailed] = React6.useState(false); const qrSource = safeQrSource2(provision.qrCodeDataUrl); const href = safeVerificationHref(provision.verificationUrl); const remaining = Math.max(0, provision.expiresAt - now); const expired = provision.expired === true || remaining === 0; const progress = Math.min(1, remaining / Math.max(1, provision.durationMs ?? remaining)); - React5.useEffect(() => setImageFailed(false), [qrSource]); - return h4( + React6.useEffect(() => setImageFailed(false), [qrSource]); + return h5( "div", { className: "bxf-card bxf-provisionCard dim-surfaceCard" }, - h4( + h5( "div", { className: "bxf-cardBody bxf-qrLayout dim-surfaceBody dim-qrLayout" }, - h4( + h5( "div", { className: "bxf-qrColumn dim-qrColumn" }, - h4( + h5( "div", { className: "bxf-qrFrame dim-qrFrame" }, - qrSource && !imageFailed ? h4("img", { + qrSource && !imageFailed ? h5("img", { src: qrSource, alt: "\u7528\u4E8E\u65B0\u589E DeepSeek Harness \u98DE\u4E66\u673A\u5668\u4EBA\u7684\u4E00\u6B21\u6027\u6388\u6743\u4E8C\u7EF4\u7801", onError: () => setImageFailed(true) - }) : h4( + }) : h5( "div", { className: "bxf-qrFallback dim-qrFallback" }, - h4("div", null, h4(QrIcon), h4("span", null, "\u4E8C\u7EF4\u7801\u672A\u5C31\u7EEA\uFF0C\u8BF7\u6253\u5F00\u6388\u6743\u94FE\u63A5")) + h5("div", null, h5(QrIcon), h5("span", null, "\u4E8C\u7EF4\u7801\u672A\u5C31\u7EEA\uFF0C\u8BF7\u6253\u5F00\u6388\u6743\u94FE\u63A5")) ), - expired ? h4( + expired ? h5( "div", { className: "bxf-expiredOverlay dim-qrExpired", role: "status" }, - h4("div", null, "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548", h4("br"), "\u8BF7\u5237\u65B0\u540E\u91CD\u65B0\u626B\u7801") + h5("div", null, "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548", h5("br"), "\u8BF7\u5237\u65B0\u540E\u91CD\u65B0\u626B\u7801") ) : null ), - h4( + h5( "div", { className: "bxf-countdown dim-countdown", "aria-label": expired ? "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548" : `\u4E8C\u7EF4\u7801\u5269\u4F59 ${formatRemaining2(remaining)}` }, - h4( + h5( "div", { className: "bxf-countdownTop dim-countdownTop", "aria-hidden": "true" }, - h4("span", null, expired ? "\u7B49\u5F85\u5237\u65B0" : "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), - h4("strong", null, formatRemaining2(remaining)) + h5("span", null, expired ? "\u7B49\u5F85\u5237\u65B0" : "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), + h5("strong", null, formatRemaining2(remaining)) ), - h4( + h5( "div", { className: "bxf-progress dim-progress", "aria-hidden": "true" }, - h4("span", { style: { "--bxf-progress": `${Math.round(progress * 100)}%` } }) + h5("span", { style: { "--bxf-progress": `${Math.round(progress * 100)}%` } }) ) ) ), - h4( + h5( "div", { className: "bxf-qrCopy dim-qrCopy" }, - h4( + h5( "div", { className: "bxf-stateLabel dim-stateLabel" }, - h4("span", { className: "bxf-dot dim-stateDot", "data-tone": "warning" }), - h4("span", null, "\u6B63\u5728\u6DFB\u52A0\u65B0\u673A\u5668\u4EBA") + h5("span", { className: "bxf-dot dim-stateDot", "data-tone": "warning" }), + h5("span", null, "\u6B63\u5728\u6DFB\u52A0\u65B0\u673A\u5668\u4EBA") ), - h4("h3", null, expired ? "\u5237\u65B0\u4E8C\u7EF4\u7801\u540E\u7EE7\u7EED" : "\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u521B\u5EFA\u673A\u5668\u4EBA"), - h4("p", null, "\u626B\u7801\u53EA\u4F1A\u65B0\u589E\u4E00\u4E2A\u673A\u5668\u4EBA\uFF0C\u5DF2\u63A5\u5165\u7684\u673A\u5668\u4EBA\u4F1A\u7EE7\u7EED\u6B63\u5E38\u6536\u53D1\u6D88\u606F\u3002"), - h4( + h5("h3", null, expired ? "\u5237\u65B0\u4E8C\u7EF4\u7801\u540E\u7EE7\u7EED" : "\u4F7F\u7528\u98DE\u4E66\u626B\u7801\u521B\u5EFA\u673A\u5668\u4EBA"), + h5("p", null, "\u626B\u7801\u53EA\u4F1A\u65B0\u589E\u4E00\u4E2A\u673A\u5668\u4EBA\uFF0C\u5DF2\u63A5\u5165\u7684\u673A\u5668\u4EBA\u4F1A\u7EE7\u7EED\u6B63\u5E38\u6536\u53D1\u6D88\u606F\u3002"), + h5( "ol", { className: "bxf-steps dim-steps" }, - h4("li", null, "\u6253\u5F00\u98DE\u4E66\u79FB\u52A8\u7AEF\uFF0C\u4F7F\u7528\u626B\u4E00\u626B\u8BFB\u53D6\u4E8C\u7EF4\u7801"), - h4("li", null, "\u6838\u5BF9\u5E94\u7528\u540D\u79F0\u4E0E\u6743\u9650\u8303\u56F4\uFF0C\u5E76\u786E\u8BA4\u521B\u5EFA"), - h4("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u65B0\u673A\u5668\u4EBA\u7684\u957F\u8FDE\u63A5\u5C31\u7EEA") + h5("li", null, "\u6253\u5F00\u98DE\u4E66\u79FB\u52A8\u7AEF\uFF0C\u4F7F\u7528\u626B\u4E00\u626B\u8BFB\u53D6\u4E8C\u7EF4\u7801"), + h5("li", null, "\u6838\u5BF9\u5E94\u7528\u540D\u79F0\u4E0E\u6743\u9650\u8303\u56F4\uFF0C\u5E76\u786E\u8BA4\u521B\u5EFA"), + h5("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u65B0\u673A\u5668\u4EBA\u7684\u957F\u8FDE\u63A5\u5C31\u7EEA") ), - h4( + h5( "div", { className: "bxf-actions dim-viewActions" }, - expired ? h4(Button3, { + expired ? h5(Button5, { kind: "primary", onClick: onRefresh, disabled: busy - }, busy ? "\u5237\u65B0\u4E2D\u2026" : "\u5237\u65B0\u4E8C\u7EF4\u7801") : href ? h4("a", { + }, busy ? "\u5237\u65B0\u4E2D\u2026" : "\u5237\u65B0\u4E8C\u7EF4\u7801") : href ? h5("a", { className: "bxf-button bxf-link", "data-kind": "secondary", href, target: "_blank", rel: "noopener noreferrer" - }, h4("span", null, "\u5728\u98DE\u4E66\u4E2D\u6253\u5F00")) : null, - !expired ? h4(Button3, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, - h4(Button3, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u6DFB\u52A0") + }, h5("span", null, "\u5728\u98DE\u4E66\u4E2D\u6253\u5F00")) : null, + !expired ? h5(Button5, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, + h5(Button5, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u6DFB\u52A0") ) ) ) @@ -2434,44 +2939,44 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) { } function ProvisionProgress({ phase, onCancel, busy }) { const connecting = phase === "connecting"; - return h4( + return h5( "div", { className: "bxf-card bxf-provisionCard dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h4("div", { className: "dim-spinner", "aria-hidden": "true" }), - h4("h3", null, connecting ? "\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u8FDE\u63A5\u65B0\u673A\u5668\u4EBA" : "\u6B63\u5728\u51C6\u5907\u6388\u6743\u4E8C\u7EF4\u7801"), - h4("p", null, connecting ? "\u6B63\u5728\u5B89\u5168\u4FDD\u5B58\u51ED\u636E\u5E76\u68C0\u67E5\u65B0\u673A\u5668\u4EBA\u7684\u6D88\u606F\u901A\u9053\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u4F1A\u4E2D\u65AD\u3002" : "\u6B63\u5728\u5411\u98DE\u4E66\u7533\u8BF7\u4E00\u6B21\u6027\u6388\u6743\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u5019\u3002"), - connecting ? h4( + h5("div", { className: "dim-spinner", "aria-hidden": "true" }), + h5("h3", null, connecting ? "\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u8FDE\u63A5\u65B0\u673A\u5668\u4EBA" : "\u6B63\u5728\u51C6\u5907\u6388\u6743\u4E8C\u7EF4\u7801"), + h5("p", null, connecting ? "\u6B63\u5728\u5B89\u5168\u4FDD\u5B58\u51ED\u636E\u5E76\u68C0\u67E5\u65B0\u673A\u5668\u4EBA\u7684\u6D88\u606F\u901A\u9053\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E0D\u4F1A\u4E2D\u65AD\u3002" : "\u6B63\u5728\u5411\u98DE\u4E66\u7533\u8BF7\u4E00\u6B21\u6027\u6388\u6743\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u5019\u3002"), + connecting ? h5( "div", { className: "bxf-actions dim-viewActions", style: { justifyContent: "center" } }, - h4(Button3, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u6DFB\u52A0") + h5(Button5, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u6DFB\u52A0") ) : null ); } function ProvisionError2({ error, onRetry, onCancel, busy }) { - return h4( + return h5( "div", { className: "bxf-card bxf-provisionCard dim-surfaceCard" }, - h4( + h5( "div", { className: "bxf-inlineError dim-inlineError", role: "alert" }, - h4( + h5( "div", null, - h4("h3", null, "\u65B0\u673A\u5668\u4EBA\u6CA1\u6709\u6DFB\u52A0\u5B8C\u6210"), - h4("p", null, error.message), - error.code ? h4("span", { className: "bxf-errorCode" }, error.code) : null, - h4( + h5("h3", null, "\u65B0\u673A\u5668\u4EBA\u6CA1\u6709\u6DFB\u52A0\u5B8C\u6210"), + h5("p", null, error.message), + error.code ? h5("span", { className: "bxf-errorCode" }, error.code) : null, + h5( "div", { className: "bxf-actions dim-viewActions" }, - h4( - Button3, + h5( + Button5, { kind: "primary", onClick: onRetry, disabled: busy }, busy ? "\u91CD\u8BD5\u4E2D\u2026" : "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801" ), - h4(Button3, { onClick: onCancel, disabled: busy }, "\u5173\u95ED") + h5(Button5, { onClick: onCancel, disabled: busy }, "\u5173\u95ED") ) ) ) @@ -2483,25 +2988,25 @@ var HEALTH_LABELS = { offline: "\u8FDE\u63A5\u4E2D\u65AD", error: "\u9700\u8981\u5904\u7406" }; -function formatCheckedTime(timestamp5) { - if (!timestamp5) return "\u5C1A\u672A\u68C0\u67E5"; +function formatCheckedTime(timestamp6) { + if (!timestamp6) return "\u5C1A\u672A\u68C0\u67E5"; try { return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" - }).format(new Date(timestamp5)); + }).format(new Date(timestamp6)); } catch { return "\u521A\u521A"; } } function RemoveConfirmation2({ bot, busy, onConfirm, onCancel }) { - const cancelRef = React5.useRef(null); + const cancelRef = React6.useRef(null); const idPart = bot.botId.replace(/[^a-zA-Z0-9_-]/g, "-"); const titleId = `bxf-remove-title-${idPart}`; const descriptionId = `bxf-remove-description-${idPart}`; - React5.useEffect(() => cancelRef.current?.focus(), []); - return h4( + React6.useEffect(() => cancelRef.current?.focus(), []); + return h5( "div", { className: "bxf-confirm dim-confirm", @@ -2515,18 +3020,18 @@ function RemoveConfirmation2({ bot, busy, onConfirm, onCancel }) { } } }, - h4("h4", { id: titleId }, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${bot.bot.name}\u201D\uFF1F`), - h4( + h5("h4", { id: titleId }, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${bot.bot.name}\u201D\uFF1F`), + h5( "p", { id: descriptionId }, "\u6B64\u64CD\u4F5C\u4F1A\u505C\u6B62\u8FD9\u4E2A\u673A\u5668\u4EBA\u7684\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u4FDD\u5B58\u5728\u672C\u673A\u7684\u63A5\u5165\u914D\u7F6E\u548C\u51ED\u636E\u3002\u98DE\u4E66\u5F00\u653E\u5E73\u53F0\u4E2D\u7684\u5E94\u7528\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\uFF0C\u5176\u4ED6\u673A\u5668\u4EBA\u4E5F\u4E0D\u53D7\u5F71\u54CD\u3002" ), - h4( + h5( "div", { className: "bxf-actions dim-viewActions" }, - h4(Button3, { ref: cancelRef, onClick: onCancel, disabled: busy }, "\u4FDD\u7559\u673A\u5668\u4EBA"), - h4( - Button3, + h5(Button5, { ref: cancelRef, onClick: onCancel, disabled: busy }, "\u4FDD\u7559\u673A\u5668\u4EBA"), + h5( + Button5, { kind: "danger", onClick: onConfirm, disabled: busy }, busy ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664\u63A5\u5165" ) @@ -2550,7 +3055,7 @@ function BotCard({ const tone = stateForDisplay === "connected" ? "success" : stateForDisplay === "connecting" ? "warning" : "error"; const summary = actionError?.message ?? connection.error?.message ?? (connected ? null : health.summary); const titleId = `bxf-bot-${connection.botId.replace(/[^a-zA-Z0-9_-]/g, "-")}`; - return h4( + return h5( "article", { className: "bxf-card bxf-botCard dim-botCard", @@ -2559,69 +3064,69 @@ function BotCard({ tabIndex: -1, ref: cardRef }, - h4( + h5( "div", { className: "bxf-cardBody dim-botCardBody" }, - h4( + h5( "div", { className: "bxf-connectedTop dim-botCardTop" }, - h4( + h5( "div", { className: "bxf-botIdentity dim-botIdentity" }, - h4( + h5( "div", { className: "bxf-avatar dim-botAvatar", "aria-hidden": "true" }, - h4(FeishuLogoGlyph, { size: 34 }) + h5(FeishuLogoGlyph, { size: 34 }) ), - h4( + h5( "div", { className: "bxf-botName dim-botName" }, - h4("h3", { id: titleId, title: bot.name }, bot.name), - h4("p", { title: bot.appIdMasked }, bot.appIdMasked ?? "\u5E94\u7528\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58") + h5("h3", { id: titleId, title: bot.name }, bot.name), + h5("p", { title: bot.appIdMasked }, bot.appIdMasked ?? "\u5E94\u7528\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58") ) ), - h4( + h5( "div", { className: "bxf-healthPill dim-botHealth", "data-health": stateForDisplay }, - h4("span", { className: "bxf-dot dim-healthDot", "data-tone": tone }), - h4("span", null, HEALTH_LABELS[stateForDisplay] ?? "\u72B6\u6001\u672A\u77E5") + h5("span", { className: "bxf-dot dim-healthDot", "data-tone": tone }), + h5("span", null, HEALTH_LABELS[stateForDisplay] ?? "\u72B6\u6001\u672A\u77E5") ) ), - h4( + h5( "dl", { className: "bxf-statusGrid dim-botMetrics" }, - h4( + h5( "div", { className: "bxf-metric dim-botMetric" }, - h4("dt", null, "\u6D88\u606F\u901A\u9053"), - h4("dd", null, connected ? "\u957F\u8FDE\u63A5" : stateForDisplay === "connecting" ? "\u8FDE\u63A5\u4E2D" : "\u5DF2\u65AD\u5F00") + h5("dt", null, "\u6D88\u606F\u901A\u9053"), + h5("dd", null, connected ? "\u957F\u8FDE\u63A5" : stateForDisplay === "connecting" ? "\u8FDE\u63A5\u4E2D" : "\u5DF2\u65AD\u5F00") ), - h4( + h5( "div", { className: "bxf-metric dim-botMetric" }, - h4("dt", null, "\u6700\u8FD1\u68C0\u67E5"), - h4("dd", null, formatCheckedTime(health.lastCheckedAt)) + h5("dt", null, "\u6700\u8FD1\u68C0\u67E5"), + h5("dd", null, formatCheckedTime(health.lastCheckedAt)) ) ), - h4( + h5( "div", { className: "bxf-connectedFooter dim-cardFooter" }, - summary ? h4( + summary ? h5( "div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : void 0 }, summary ) : null, - h4( + h5( "div", { className: "bxf-actions bxf-botActions dim-cardActions" }, - h4(Button3, { + h5(Button5, { className: "dim-cardAction", onClick: onReconnect, disabled: Boolean(busy), "aria-busy": busy === "reconnect" ? "true" : void 0, "aria-label": `${connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"}${bot.name}` }, busy === "reconnect" ? connected ? "\u68C0\u67E5\u4E2D\u2026" : "\u6B63\u5728\u8FDE\u63A5\u2026" : connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"), - h4(Button3, { + h5(Button5, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, @@ -2632,7 +3137,7 @@ function BotCard({ ) ) ), - removing ? h4(RemoveConfirmation2, { + removing ? h5(RemoveConfirmation2, { bot: connection, busy: busy === "delete", onConfirm: onConfirmRemove, @@ -2641,21 +3146,21 @@ function BotCard({ ); } function BotList(props) { - return h4( + return h5( "section", { className: "bxf-listSection dim-listSection", "aria-labelledby": "bxf-bot-list-title" }, - h4( + h5( "div", { className: "bxf-listHeading dim-listHeading" }, - h4("h3", { id: "bxf-bot-list-title" }, "\u5DF2\u63A5\u5165\u7684\u673A\u5668\u4EBA") + h5("h3", { id: "bxf-bot-list-title" }, "\u5DF2\u63A5\u5165\u7684\u673A\u5668\u4EBA") ), - h4( + h5( "ul", { className: "bxf-botList dim-botList", role: "list" }, - props.bots.map((bot) => h4( + props.bots.map((bot) => h5( "li", { key: bot.botId }, - h4(BotCard, { + h5(BotCard, { connection: bot, busy: props.busyByBot[bot.botId], actionError: props.errorsByBot[bot.botId], @@ -2672,23 +3177,23 @@ function BotList(props) { ); } function PageError({ error, onRetry, busy }) { - return h4( + return h5( "div", { className: "bxf-card dim-surfaceCard" }, - h4( + h5( "div", { className: "bxf-error dim-inlineError", role: "alert" }, - h4( + h5( "div", null, - h4("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u98DE\u4E66\u673A\u5668\u4EBA"), - h4("p", null, error.message), - error.code ? h4("span", { className: "bxf-errorCode" }, error.code) : null, - h4( + h5("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u98DE\u4E66\u673A\u5668\u4EBA"), + h5("p", null, error.message), + error.code ? h5("span", { className: "bxf-errorCode" }, error.code) : null, + h5( "div", { className: "bxf-actions dim-viewActions" }, - h4( - Button3, + h5( + Button5, { kind: "primary", onClick: onRetry, disabled: busy }, busy ? "\u91CD\u8BD5\u4E2D\u2026" : "\u91CD\u65B0\u8BFB\u53D6" ) @@ -2721,7 +3226,7 @@ function mergeFeishuSnapshotState(current, snapshot, { restoreProvisioning = fal }; } function FeishuSettingsTab({ rpcCall }) { - const [model, setModel] = React5.useState({ + const [model, setModel] = React6.useState({ phase: "loading", revision: 0, bots: [], @@ -2730,31 +3235,31 @@ function FeishuSettingsTab({ rpcCall }) { pageError: null, statusError: null }); - const [pageBusy, setPageBusy] = React5.useState(false); - const [provisionBusy, setProvisionBusy] = React5.useState(false); - const [credentialOpen, setCredentialOpen] = React5.useState(false); - const [credentialBusy, setCredentialBusy] = React5.useState(false); - const [credentialError, setCredentialError] = React5.useState(null); - const [busyByBot, setBusyByBot] = React5.useState({}); - const [errorsByBot, setErrorsByBot] = React5.useState({}); - const [removeTargetId, setRemoveTargetId] = React5.useState(null); - const [announcement, setAnnouncement] = React5.useState(""); - const [now, setNow] = React5.useState(() => Date.now()); - const [focusBotId, setFocusBotId] = React5.useState(null); - const cardRefs = React5.useRef(/* @__PURE__ */ new Map()); - const removeButtonRefs = React5.useRef(/* @__PURE__ */ new Map()); - const addButtonRef = React5.useRef(null); + const [pageBusy, setPageBusy] = React6.useState(false); + const [provisionBusy, setProvisionBusy] = React6.useState(false); + const [credentialOpen, setCredentialOpen] = React6.useState(false); + const [credentialBusy, setCredentialBusy] = React6.useState(false); + const [credentialError, setCredentialError] = React6.useState(null); + const [busyByBot, setBusyByBot] = React6.useState({}); + const [errorsByBot, setErrorsByBot] = React6.useState({}); + const [removeTargetId, setRemoveTargetId] = React6.useState(null); + const [announcement, setAnnouncement] = React6.useState(""); + const [now, setNow] = React6.useState(() => Date.now()); + const [focusBotId, setFocusBotId] = React6.useState(null); + const cardRefs = React6.useRef(/* @__PURE__ */ new Map()); + const removeButtonRefs = React6.useRef(/* @__PURE__ */ new Map()); + const addButtonRef = React6.useRef(null); const scheduleAnimationFrame = useAnimationFrameScheduler(); - const announce = React5.useCallback((message) => { + const announce = React6.useCallback((message) => { setAnnouncement(""); scheduleAnimationFrame(() => { if (message) setAnnouncement(message); }, "announcement"); }, [scheduleAnimationFrame]); - const invoke = React5.useCallback(async (endpoint, payload = {}, signal) => { - return unwrapRpcResult2(await rpcCall(endpoint, payload, signal)); + const invoke = React6.useCallback(async (endpoint, payload = {}, signal) => { + return unwrapRpcResult3(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const mergeSnapshot = React5.useCallback((snapshot, { restoreProvisioning = false } = {}) => { + const mergeSnapshot = React6.useCallback((snapshot, { restoreProvisioning = false } = {}) => { const now2 = Date.now(); setModel((current) => mergeFeishuSnapshotState( current, @@ -2762,7 +3267,7 @@ function FeishuSettingsTab({ rpcCall }) { { restoreProvisioning, now: now2 } )); }, []); - const loadStatus = React5.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => { + const loadStatus = React6.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => { if (!silent) setPageBusy(true); try { const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal)); @@ -2771,19 +3276,19 @@ function FeishuSettingsTab({ rpcCall }) { return snapshot; } catch (error) { if (signal?.aborted || error?.name === "AbortError") return void 0; - const presented = presentError2(error); + const presented = presentError3(error); setModel((current) => current.phase === "loading" || !silent ? { ...current, phase: "error", pageError: presented } : { ...current, statusError: presented }); return void 0; } finally { if (!silent && !signal?.aborted) setPageBusy(false); } }, [invoke, mergeSnapshot]); - React5.useEffect(() => { + React6.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restoreProvisioning: true }); return () => controller.abort(); }, [loadStatus]); - React5.useEffect(() => { + React6.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); let inFlight = false; @@ -2802,7 +3307,7 @@ function FeishuSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React5.useEffect(() => { + React6.useEffect(() => { if (!focusBotId) return; const node = cardRefs.current.get(focusBotId); if (!node) return; @@ -2810,7 +3315,7 @@ function FeishuSettingsTab({ rpcCall }) { node.focus({ preventScroll: true }); setFocusBotId(null); }, [focusBotId, model.bots]); - const startProvisioning = React5.useCallback(async ({ replace = false } = {}) => { + const startProvisioning = React6.useCallback(async ({ replace = false } = {}) => { setCredentialOpen(false); setCredentialError(null); setProvisionBusy(true); @@ -2829,14 +3334,14 @@ function FeishuSettingsTab({ rpcCall }) { FEISHU_ENDPOINTS.beginProvisioning, { locale: "zh-CN" } )); - const timestamp5 = Date.now(); - setNow(timestamp5); + const timestamp6 = Date.now(); + setNow(timestamp6); setModel((current) => ({ ...current, provisioning: { phase: "qr", ...provision2, - durationMs: Math.max(1, provision2.expiresAt - timestamp5), + durationMs: Math.max(1, provision2.expiresAt - timestamp6), expired: false } })); @@ -2844,13 +3349,13 @@ function FeishuSettingsTab({ rpcCall }) { } catch (error) { setModel((current) => ({ ...current, - provisioning: { phase: "error", error: presentError2(error) } + provisioning: { phase: "error", error: presentError3(error) } })); } finally { setProvisionBusy(false); } }, [announce, invoke, model.provisioning?.attemptId]); - const bindCredentials = React5.useCallback(async ({ identity, secret }) => { + const bindCredentials = React6.useCallback(async ({ identity, secret }) => { setCredentialBusy(true); setCredentialError(null); try { @@ -2862,12 +3367,12 @@ function FeishuSettingsTab({ rpcCall }) { setCredentialOpen(false); announce("\u98DE\u4E66\u673A\u5668\u4EBA\u51ED\u636E\u5DF2\u7ED1\u5B9A\u3002"); } catch (error) { - setCredentialError(presentError2(error)); + setCredentialError(presentError3(error)); } finally { setCredentialBusy(false); } }, [announce, invoke, mergeSnapshot]); - const cancelProvisioning = React5.useCallback(async () => { + const cancelProvisioning = React6.useCallback(async () => { const attemptId = model.provisioning?.attemptId; setProvisionBusy(true); try { @@ -2879,7 +3384,7 @@ function FeishuSettingsTab({ rpcCall }) { } catch (error) { setModel((current) => ({ ...current, - provisioning: { phase: "error", attemptId, error: presentError2(error) } + provisioning: { phase: "error", attemptId, error: presentError3(error) } })); } finally { setProvisionBusy(false); @@ -2889,12 +3394,12 @@ function FeishuSettingsTab({ rpcCall }) { const countdownPhase = model.provisioning?.phase; const countdownExpiresAt = model.provisioning?.expiresAt; const countdownExpired = model.provisioning?.expired; - React5.useEffect(() => { + React6.useEffect(() => { if (!countdownAttemptId || countdownPhase !== "qr" || countdownExpired) return void 0; const tick = () => { - const timestamp5 = Date.now(); - setNow(timestamp5); - if (timestamp5 >= countdownExpiresAt) { + const timestamp6 = Date.now(); + setNow(timestamp6); + if (timestamp6 >= countdownExpiresAt) { setModel((current) => current.provisioning?.attemptId === countdownAttemptId ? { ...current, provisioning: { ...current.provisioning, expired: true } } : current); } }; @@ -2902,7 +3407,7 @@ function FeishuSettingsTab({ rpcCall }) { const timer = window.setInterval(tick, 1e3); return () => window.clearInterval(timer); }, [countdownAttemptId, countdownPhase, countdownExpiresAt, countdownExpired]); - React5.useEffect(() => { + React6.useEffect(() => { const provision2 = model.provisioning; if (!provision2 || !["qr", "connecting"].includes(provision2.phase) || !provision2.attemptId || provision2.expired) return void 0; const controller = new AbortController(); @@ -2956,7 +3461,7 @@ function FeishuSettingsTab({ rpcCall }) { provisioning: { phase: "error", attemptId: provision2.attemptId, - error: presentError2(error) + error: presentError3(error) } } : current); } @@ -2966,7 +3471,7 @@ function FeishuSettingsTab({ rpcCall }) { window.clearTimeout(timer); }; }, [announce, invoke, loadStatus, model.provisioning]); - const setBotBusy = React5.useCallback((botId, value) => { + const setBotBusy = React6.useCallback((botId, value) => { setBusyByBot((current) => { const next = { ...current }; if (value) next[botId] = value; @@ -2974,15 +3479,15 @@ function FeishuSettingsTab({ rpcCall }) { return next; }); }, []); - const setBotError = React5.useCallback((botId, error) => { + const setBotError = React6.useCallback((botId, error) => { setErrorsByBot((current) => { const next = { ...current }; - if (error) next[botId] = presentError2(error); + if (error) next[botId] = presentError3(error); else delete next[botId]; return next; }); }, []); - const reconnectOneBot = React5.useCallback(async (connection) => { + const reconnectOneBot = React6.useCallback(async (connection) => { const { botId, bot } = connection; setBotBusy(botId, "reconnect"); setBotError(botId, null); @@ -3005,15 +3510,15 @@ function FeishuSettingsTab({ rpcCall }) { setBotBusy(botId, null); } }, [announce, invoke, mergeSnapshot, setBotBusy, setBotError]); - const requestRemove = React5.useCallback((connection) => { + const requestRemove = React6.useCallback((connection) => { setRemoveTargetId(connection.botId); }, []); - const cancelRemove = React5.useCallback(() => { + const cancelRemove = React6.useCallback(() => { const botId = removeTargetId; setRemoveTargetId(null); scheduleAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus(), "focus"); }, [removeTargetId, scheduleAnimationFrame]); - const confirmRemove = React5.useCallback(async (connection) => { + const confirmRemove = React6.useCallback(async (connection) => { const { botId, bot } = connection; setBotBusy(botId, "delete"); setBotError(botId, null); @@ -3044,9 +3549,9 @@ function FeishuSettingsTab({ rpcCall }) { const provision = model.provisioning; let provisionContent = null; if (provision?.phase === "creating") { - provisionContent = h4(ProvisionProgress, { phase: "creating", busy: provisionBusy }); + provisionContent = h5(ProvisionProgress, { phase: "creating", busy: provisionBusy }); } else if (provision?.phase === "qr") { - provisionContent = h4(QrPane, { + provisionContent = h5(QrPane, { provision, now, onRefresh: () => void startProvisioning({ replace: true }), @@ -3054,20 +3559,20 @@ function FeishuSettingsTab({ rpcCall }) { busy: provisionBusy || model.phase !== "ready" }); } else if (provision?.phase === "connecting") { - provisionContent = h4(ProvisionProgress, { + provisionContent = h5(ProvisionProgress, { phase: "connecting", onCancel: () => void cancelProvisioning(), busy: provisionBusy }); } else if (provision?.phase === "error") { - provisionContent = h4(ProvisionError2, { + provisionContent = h5(ProvisionError2, { error: provision.error, onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }), onCancel: () => void cancelProvisioning(), busy: provisionBusy }); } - const credentialContent = credentialOpen ? h4(CredentialBindingPanel, { + const credentialContent = credentialOpen ? h5(CredentialBindingPanel, { channel: "\u98DE\u4E66", identityLabel: "App ID", identityPlaceholder: "\u586B\u5199\u98DE\u4E66\u5F00\u653E\u5E73\u53F0 App ID", @@ -3081,18 +3586,18 @@ function FeishuSettingsTab({ rpcCall }) { setCredentialError(null); } }) : null; - const setCardRef = React5.useCallback((botId, node) => { + const setCardRef = React6.useCallback((botId, node) => { if (node) cardRefs.current.set(botId, node); else cardRefs.current.delete(botId); }, []); - const setRemoveButtonRef = React5.useCallback((botId, node) => { + const setRemoveButtonRef = React6.useCallback((botId, node) => { if (node) removeButtonRefs.current.set(botId, node); else removeButtonRefs.current.delete(botId); }, []); - return h4( + return h5( "section", { className: "bxf-page dim-channelPage", "aria-label": "\u98DE\u4E66\u673A\u5668\u4EBA\u8BBE\u7F6E" }, - h4(Heading2, { + h5(Heading2, { totals: model.totals, onAdd: () => void startProvisioning(), onCredential: () => { @@ -3104,30 +3609,30 @@ function FeishuSettingsTab({ rpcCall }) { busy: provisionBusy || credentialBusy, addButtonRef }), - h4("div", { + h5("div", { className: "bxf-visuallyHidden", role: "status", "aria-live": "polite", "aria-atomic": "true" }, announcement), - model.statusError ? h4( + model.statusError ? h5( "div", { className: "bxf-statusNotice dim-statusNotice", role: "status" }, - h4(AlertIcon, { size: 16 }), - h4("span", null, `\u72B6\u6001\u81EA\u52A8\u5237\u65B0\u5931\u8D25\uFF1A${model.statusError.message}`), - h4(Button3, { size: "small", onClick: () => void loadStatus({ silent: true }), disabled: pageBusy }, "\u7ACB\u5373\u91CD\u8BD5") + h5(AlertIcon, { size: 16 }), + h5("span", null, `\u72B6\u6001\u81EA\u52A8\u5237\u65B0\u5931\u8D25\uFF1A${model.statusError.message}`), + h5(Button5, { size: "small", onClick: () => void loadStatus({ silent: true }), disabled: pageBusy }, "\u7ACB\u5373\u91CD\u8BD5") ) : null, - model.phase === "loading" ? h4(LoadingView2) : model.phase === "error" ? h4(PageError, { + model.phase === "loading" ? h5(LoadingView2) : model.phase === "error" ? h5(PageError, { error: model.pageError ?? { message: "\u65E0\u6CD5\u8BFB\u53D6\u8FDE\u63A5\u72B6\u6001" }, onRetry: () => void loadStatus(), busy: pageBusy - }) : h4( - React5.Fragment, + }) : h5( + React6.Fragment, null, credentialContent, provisionContent, - model.bots.length === 0 && !provision && !credentialOpen ? h4(EmptyView2, { onStart: () => void startProvisioning(), busy: provisionBusy }) : null, - model.bots.length > 0 ? h4(BotList, { + model.bots.length === 0 && !provision && !credentialOpen ? h5(EmptyView2, { onStart: () => void startProvisioning(), busy: provisionBusy }) : null, + model.bots.length > 0 ? h5(BotList, { bots: model.bots, busyByBot, errorsByBot, @@ -3155,634 +3660,8 @@ var QQ_ENDPOINTS = Object.freeze({ deleteBot: "bot.delete" }); var PROVISION_STATES2 = /* @__PURE__ */ new Set(["starting", "pending", "refreshing", "connecting", "connected", "failed", "cancelled"]); -var ACCOUNT_STATES2 = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]); -var QR_DATA_URL2 = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i; -function isRecord3(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} -function text(value, fallback, max = 240) { - return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : fallback; -} -function id(value) { - const result = text(value, "", 128); - return /^[a-z\d_-]+$/i.test(result) ? result : void 0; -} -function timestamp2(value) { - if (typeof value === "number" && Number.isFinite(value)) return value; - const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN; - return Number.isNaN(parsed) ? void 0 : parsed; -} -function unwrapRpcResult3(result) { - if (!isRecord3(result) || typeof result.ok !== "boolean") throw new Error("QQ \u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94"); - if (!result.ok) { - const error = new Error(text(result.error?.message, "QQ \u64CD\u4F5C\u5931\u8D25")); - error.code = text(result.error?.code, "QQ_RPC_ERROR", 80); - throw error; - } - return result.value; -} -function safeQrSource3(value) { - return typeof value === "string" && value.length <= 2 * 1024 * 1024 && QR_DATA_URL2.test(value) ? value : void 0; -} -function normalizeProvisioning3(value, now = Date.now()) { - const source = isRecord3(value?.provisioning) ? value.provisioning : value; - if (!isRecord3(source)) throw new Error("QQ \u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u626B\u7801\u7ED1\u5B9A\u8FDB\u5EA6"); - const attemptId = id(source.attemptId); - if (!attemptId) throw new Error("QQ \u626B\u7801\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u7ED1\u5B9A\u4EFB\u52A1"); - const reported = text(source.status, "failed", 32); - const result = { - attemptId, - status: PROVISION_STATES2.has(reported) ? reported : "failed", - expiresAt: timestamp2(source.expiresAt) ?? now + 5 * 6e4, - pollIntervalMs: Math.min(1e4, Math.max(500, Number(source.pollIntervalMs) || 1e3)), - qrRevision: Number.isSafeInteger(source.qrRevision) ? source.qrRevision : 0 - }; - const qrCodeDataUrl = safeQrSource3(source.qrCodeDataUrl); - if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl; - if (id(source.botId)) result.botId = id(source.botId); - if (isRecord3(source.error)) result.error = { - code: text(source.error.code, "QQ_PROVISION_FAILED", 80), - message: text(source.error.message, "QQ \u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210") - }; - return result; -} -function normalizeBot3(value) { - if (!isRecord3(value) || !id(value.botId)) return void 0; - const connected = value.connected === true; - const state = ACCOUNT_STATES2.has(value.state) ? value.state : "offline"; - return { - botId: id(value.botId), - connected, - state: connected ? "connected" : state, - bot: { - name: text(value.bot?.name, "QQ\u673A\u5668\u4EBA", 100), - appIdMasked: text(value.bot?.appIdMasked, "\u5E94\u7528\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58", 140) - }, - health: { - summary: text(value.health?.summary, connected ? "QQ WebSocket \u957F\u8FDE\u63A5\u8FD0\u884C\u6B63\u5E38" : "QQ \u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA"), - lastCheckedAt: timestamp2(value.health?.lastCheckedAt) - }, - error: isRecord3(value.error) ? { - code: text(value.error.code, "QQ_ACCOUNT_ERROR", 80), - message: text(value.error.message, "QQ \u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA") - } : null - }; -} -function normalizeSnapshot2(value) { - const source = isRecord3(value?.snapshot) ? value.snapshot : value; - if (!isRecord3(source) || !Array.isArray(source.bots)) throw new Error("QQ \u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u673A\u5668\u4EBA\u5217\u8868"); - const bots = source.bots.map(normalizeBot3).filter(Boolean); - return { - revision: Number.isSafeInteger(source.revision) ? source.revision : 0, - bots, - totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length }, - provisioning: source.provisioning ? normalizeProvisioning3(source.provisioning) : null - }; -} -function presentError3(error) { - return { - code: text(error?.code, "QQ_ERROR", 80), - message: text(error?.message, "QQ \u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5") - }; -} -function formatRemaining3(milliseconds) { - const seconds = Math.max(0, Math.ceil(Number(milliseconds) / 1e3) || 0); - return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; -} - -// plugin-src/client/channels/qq/index.js -var React6 = __toESM(require("react"), 1); - -// plugin-src/client/channels/qq/styles.js -var QQ_STYLE_ID = "xmanrui-dsh-im-qq-settings"; -var CSS3 = String.raw` -.dqq-page { --ddt-accent: #1677ff; --ddt-accent-deep: #0958d9; --ddt-accent-wash: #eaf3ff; } -.dqq-avatar, .dqq-brand { color: #fff; background: #1677ff; } -.dqq-avatar svg, .dqq-brand svg { display: block; } -`; -function installQqStyles() { - if (typeof document === "undefined") return () => { - }; - const existing = document.querySelector(`style[data-plugin-css="${QQ_STYLE_ID}"]`); - if (existing) return () => { - }; - const style = document.createElement("style"); - style.dataset.plugin = "@xmanrui/dsh-im"; - style.dataset.pluginCss = QQ_STYLE_ID; - style.textContent = CSS3; - document.head.appendChild(style); - return () => style.remove(); -} - -// plugin-src/client/channels/qq/index.js -var h5 = React6.createElement; -var ACTIVE_STATES = /* @__PURE__ */ new Set(["pending", "refreshing", "connecting"]); -var Button5 = React6.forwardRef(function Button6({ children, kind = "secondary", className = "", ...props }, ref) { - return h5("button", { - ...props, - ref, - type: "button", - className: `ddt-button ${className}`.trim(), - "data-kind": kind - }, children); -}); -function checkedTime2(value) { - if (!value) return "\u5C1A\u672A\u68C0\u67E5"; - try { - return new Intl.DateTimeFormat("zh-CN", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit" - }).format(new Date(value)); - } catch { - return "\u521A\u521A"; - } -} -function Heading3({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) { - return h5( - "div", - { className: "ddt-heading" }, - h5( - "div", - { className: "ddt-tools" }, - h5( - "div", - { className: "dim-bindActions" }, - h5(Button5, { - kind: "primary", - className: "dim-scanButton", - onClick: onAdd, - disabled: adding || busy, - ref: addButtonRef, - "aria-label": "\u626B\u7801\u63A5\u5165 QQ \u673A\u5668\u4EBA" - }, h5(QrActionIcon), adding ? "\u6B63\u5728\u63A5\u5165" : "\u626B\u7801\u63A5\u5165\u673A\u5668\u4EBA"), - h5(Button5, { - kind: "credential", - className: "dim-credentialButton", - onClick: onCredential, - disabled: adding || busy, - "aria-pressed": credentialOpen, - "aria-label": "\u4F7F\u7528 AppID \u548C AppSecret \u7ED1\u5B9A QQ \u673A\u5668\u4EBA" - }, h5(CredentialActionIcon), credentialOpen ? "\u6536\u8D77\u51ED\u636E" : "\u624B\u52A8\u63A5\u5165") - ), - totals.configured > 0 ? h5( - "div", - { className: "ddt-badge dim-onlineBadge" }, - h5("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) - ) : null - ) - ); -} -function LoadingView3() { - return h5( - "div", - { className: "ddt-card ddt-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h5("div", { className: "ddt-spinner dim-spinner" }), - h5("span", null, "\u6B63\u5728\u8BFB\u53D6 QQ \u673A\u5668\u4EBA\u72B6\u6001\u2026") - ); -} -function EmptyView3({ busy, onStart }) { - return h5( - "div", - { className: "ddt-card dim-surfaceCard" }, - h5( - "div", - { className: "ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView" }, - h5( - "div", - { className: "dim-emptyCopy" }, - h5( - "div", - { className: "ddt-stateLabel dim-stateLabel" }, - h5("span", { className: "ddt-dot dim-stateDot" }), - h5("span", null, "\u5C1A\u672A\u7ED1\u5B9A QQ \u673A\u5668\u4EBA") - ), - h5("h3", null, "\u4F7F\u7528\u624B\u673A QQ \u626B\u7801\u521B\u5EFA\u5E76\u7ED1\u5B9A\u673A\u5668\u4EBA"), - h5("p", null, "\u626B\u7801\u7531\u817E\u8BAF\u5B98\u65B9\u9875\u9762\u5B8C\u6210\uFF0C\u4E0D\u9700\u8981\u624B\u52A8\u586B\u5199 AppID \u6216 AppSecret\u3002\u626B\u7801\u6210\u529F\u540E\uFF0C\u673A\u5668\u4EBA\u4F1A\u81EA\u52A8\u8FDE\u63A5 DeepSeek Harness\u3002"), - h5( - "div", - { className: "ddt-actions dim-viewActions" }, - h5( - Button5, - { kind: "primary", onClick: onStart, disabled: busy }, - busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210 QQ \u4E8C\u7EF4\u7801" - ) - ) - ), - h5( - "div", - { className: "ddt-brandMark dim-emptyBrand dqq-brand", "aria-hidden": "true" }, - h5(QqLogoGlyph, { size: 64 }) - ) - ) - ); -} -function QrPanel2({ provision, now, busy, onRefresh, onCancel }) { - const source = safeQrSource3(provision.qrCodeDataUrl); - const remaining = Math.max(0, provision.expiresAt - now); - const duration = Math.max(1, provision.durationMs ?? 5 * 6e4); - const progress = Math.round(Math.min(1, remaining / duration) * 100); - const refreshing = provision.status === "refreshing"; - return h5( - "div", - { className: "ddt-card dim-surfaceCard" }, - h5( - "div", - { className: "ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout" }, - h5( - "div", - { className: "ddt-qrColumn dim-qrColumn" }, - h5( - "div", - { className: "ddt-qrFrame dim-qrFrame" }, - source ? h5("img", { src: source, alt: "\u7528\u4E8E\u7ED1\u5B9A QQ \u673A\u5668\u4EBA\u7684\u4E00\u6B21\u6027\u4E8C\u7EF4\u7801" }) : h5( - "div", - { className: "ddt-qrFallback dim-qrFallback" }, - refreshing ? "\u4E8C\u7EF4\u7801\u6B63\u5728\u81EA\u52A8\u5237\u65B0\u2026" : "\u4E8C\u7EF4\u7801\u56FE\u7247\u6B63\u5728\u751F\u6210\u2026" - ) - ), - h5( - "div", - { className: "ddt-countdown dim-countdown" }, - h5( - "div", - { className: "ddt-countdownTop dim-countdownTop" }, - h5("span", null, "\u5F53\u524D\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), - h5("strong", null, refreshing ? "--:--" : formatRemaining3(remaining)) - ), - h5("div", { className: "ddt-progress dim-progress", style: { "--ddt-progress": `${progress}%` } }, h5("span")) - ) - ), - h5( - "div", - { className: "ddt-qrCopy dim-qrCopy" }, - h5( - "div", - { className: "ddt-stateLabel dim-stateLabel" }, - h5("span", { className: "ddt-dot dim-stateDot", "data-tone": "warning" }), - h5("span", null, refreshing ? "\u6B63\u5728\u5237\u65B0\u4E8C\u7EF4\u7801" : "\u7B49\u5F85\u624B\u673A QQ \u626B\u7801") - ), - h5("h3", null, "\u4F7F\u7528\u624B\u673A QQ \u5B8C\u6210\u673A\u5668\u4EBA\u7ED1\u5B9A"), - h5("p", null, "\u817E\u8BAF\u9875\u9762\u4F1A\u521B\u5EFA\u6216\u7ED1\u5B9A\u4E00\u4E2A QQ \u673A\u5668\u4EBA\uFF0C\u5E76\u628A\u8FDE\u63A5\u51ED\u636E\u5B89\u5168\u4EA4\u7ED9\u672C\u673A Harness Host\u3002"), - h5( - "ol", - { className: "ddt-steps dim-steps" }, - h5("li", null, "\u6253\u5F00\u624B\u673A QQ\uFF0C\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), - h5("li", null, "\u5728\u817E\u8BAF\u6388\u6743\u9875\u9762\u786E\u8BA4\u521B\u5EFA\u6216\u7ED1\u5B9A\u673A\u5668\u4EBA"), - h5("li", null, "\u8FD4\u56DE\u8FD9\u91CC\u7B49\u5F85\u8FDE\u63A5\u5B8C\u6210") - ), - h5( - "div", - { className: "ddt-actions dim-viewActions" }, - h5(Button5, { onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), - h5(Button5, { kind: "quiet", onClick: onCancel, disabled: busy }, "\u53D6\u6D88") - ) - ) - ) - ); -} -function ProvisionView({ provision, busy, onRetry, onClose }) { - if (provision.status === "connecting") { - return h5( - "div", - { className: "ddt-card ddt-loading dim-surfaceCard dim-specialView", "aria-busy": "true" }, - h5("div", { className: "ddt-spinner dim-spinner" }), - h5("h3", null, "QQ \u5DF2\u6388\u6743\uFF0C\u6B63\u5728\u8FDE\u63A5\u673A\u5668\u4EBA"), - h5("p", null, "\u51ED\u636E\u6B63\u5728\u5199\u5165\u672C\u673A\uFF0C\u5E76\u542F\u52A8 QQ WebSocket \u6D88\u606F\u8FDE\u63A5\u3002") - ); - } - const error = provision.error ?? { code: "QQ_PROVISION_FAILED", message: "QQ \u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210" }; - return h5( - "div", - { className: "ddt-card dim-surfaceCard" }, - h5( - "div", - { className: "ddt-inlineError dim-inlineError", role: "alert" }, - h5("h3", null, "QQ \u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), - h5("p", null, error.message), - h5("span", { className: "ddt-errorCode" }, error.code), - h5( - "div", - { className: "ddt-actions dim-viewActions" }, - h5(Button5, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), - h5(Button5, { onClick: onClose, disabled: busy }, "\u5173\u95ED") - ) - ) - ); -} -function RemoveConfirmation3({ account, busy, onConfirm, onCancel }) { - return h5( - "div", - { className: "ddt-confirm dim-confirm", role: "alertdialog" }, - h5("strong", null, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${account.bot.name}\u201D\uFF1F`), - h5("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684\u5E94\u7528\u51ED\u636E\u3001\u673A\u5668\u4EBA\u914D\u7F6E\u53CA\u4F1A\u8BDD\u6620\u5C04\u3002\u817E\u8BAF\u5E73\u53F0\u4E2D\u7684\u673A\u5668\u4EBA\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\u3002"), - h5( - "div", - { className: "ddt-actions dim-viewActions" }, - h5(Button5, { onClick: onCancel, disabled: busy }, "\u4FDD\u7559\u673A\u5668\u4EBA"), - h5(Button5, { kind: "danger", onClick: onConfirm, disabled: busy }, busy ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664\u63A5\u5165") - ) - ); -} -function AccountCard2({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { - const tone = account.connected ? "success" : account.state === "error" ? "error" : "warning"; - const stateLabel = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : account.state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA"; - const summary = account.error?.message ?? (account.connected ? null : account.health.summary); - return h5( - "article", - { className: "ddt-card dim-botCard", "data-bot-id": account.botId }, - h5( - "div", - { className: "ddt-cardBody dim-botCardBody" }, - h5( - "div", - { className: "ddt-accountTop dim-botCardTop" }, - h5( - "div", - { className: "ddt-accountIdentity dim-botIdentity" }, - h5("div", { className: "ddt-avatar dim-botAvatar dqq-avatar", "aria-hidden": "true" }, h5(QqLogoGlyph, { size: 29 })), - h5( - "div", - { className: "dim-botName" }, - h5("h3", null, account.bot.name), - h5("p", null, account.bot.appIdMasked) - ) - ), - h5( - "div", - { className: "ddt-health dim-botHealth" }, - h5("span", { className: "ddt-dot dim-healthDot", "data-tone": tone }), - h5("span", null, stateLabel) - ) - ), - h5( - "dl", - { className: "ddt-metrics dim-botMetrics" }, - h5("div", { className: "ddt-metric dim-botMetric" }, h5("dt", null, "\u6D88\u606F\u901A\u9053"), h5("dd", null, account.connected ? "WebSocket \u957F\u8FDE\u63A5" : "\u79BB\u7EBF")), - h5("div", { className: "ddt-metric dim-botMetric" }, h5("dt", null, "\u6700\u8FD1\u68C0\u67E5"), h5("dd", null, checkedTime2(account.health.lastCheckedAt))) - ), - h5( - "div", - { className: "ddt-accountFooter dim-cardFooter" }, - summary ? h5("div", { className: "ddt-summary dim-cardSummary" }, summary) : null, - h5( - "div", - { className: "ddt-actions dim-cardActions" }, - h5(Button5, { className: "dim-cardAction", onClick: onReconnect, disabled: Boolean(busy) }, busy === "reconnect" ? "\u68C0\u67E5\u4E2D\u2026" : account.connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"), - h5(Button5, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") - ) - ) - ), - removing ? h5(RemoveConfirmation3, { - account, - busy: busy === "delete", - onConfirm: onConfirmRemove, - onCancel: onCancelRemove - }) : null - ); -} -function QqSettingsTab({ rpcCall }) { - const [model, setModel] = React6.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); - const [provision, setProvision] = React6.useState(null); - const [busy, setBusy] = React6.useState(false); - const [busyByBot, setBusyByBot] = React6.useState({}); - const [removeTarget, setRemoveTarget] = React6.useState(null); - const [credentialOpen, setCredentialOpen] = React6.useState(false); - const [credentialError, setCredentialError] = React6.useState(null); - const [now, setNow] = React6.useState(Date.now()); - const mounted = React6.useRef(true); - const addButtonRef = React6.useRef(null); - React6.useEffect(() => { - const disposeDingtalk = installDingtalkStyles(); - const disposeQq = installQqStyles(); - mounted.current = true; - return () => { - mounted.current = false; - disposeQq(); - disposeDingtalk(); - }; - }, []); - const invoke = React6.useCallback(async (endpoint, payload = {}, signal) => { - if (typeof rpcCall !== "function") throw new TypeError("QQ \u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5"); - return unwrapRpcResult3(await rpcCall(endpoint, payload, signal)); - }, [rpcCall]); - const loadStatus = React6.useCallback(async ({ signal, silent = false, restore = false } = {}) => { - if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null })); - try { - const snapshot = normalizeSnapshot2(await invoke(QQ_ENDPOINTS.status, {}, signal)); - if (!mounted.current || signal?.aborted) return void 0; - setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); - if (restore && snapshot.provisioning) setProvision({ - ...snapshot.provisioning, - durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()) - }); - return snapshot; - } catch (error) { - if (error?.name !== "AbortError" && mounted.current && !signal?.aborted) { - setModel((current) => ({ ...current, phase: silent ? current.phase : "error", error: presentError3(error) })); - } - return void 0; - } - }, [invoke]); - React6.useEffect(() => { - const controller = new AbortController(); - void loadStatus({ signal: controller.signal, restore: true }); - return () => controller.abort(); - }, [loadStatus]); - React6.useEffect(() => { - if (model.phase !== "ready") return void 0; - const controller = new AbortController(); - const timer = window.setInterval(() => void loadStatus({ signal: controller.signal, silent: true }), 15e3); - return () => { - controller.abort(); - window.clearInterval(timer); - }; - }, [loadStatus, model.phase]); - React6.useEffect(() => { - if (!provision || !ACTIVE_STATES.has(provision.status)) return void 0; - const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1e3); - return () => window.clearInterval(timer); - }, [provision?.attemptId, provision?.status]); - const startProvisioning = React6.useCallback(async (replace = false) => { - setCredentialOpen(false); - setCredentialError(null); - setBusy(true); - try { - if (replace && provision?.attemptId) await invoke(QQ_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); - if (!mounted.current) return; - setProvision({ status: "starting" }); - const started = normalizeProvisioning3(await invoke(QQ_ENDPOINTS.beginProvisioning, { locale: "zh-CN" })); - if (!mounted.current) return; - setNow(Date.now()); - setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) }); - } catch (error) { - if (mounted.current) setProvision({ status: "failed", error: presentError3(error) }); - } finally { - if (mounted.current) setBusy(false); - } - }, [invoke, provision?.attemptId]); - const bindCredentials = React6.useCallback(async ({ identity, secret }) => { - setBusy(true); - setCredentialError(null); - try { - const snapshot = normalizeSnapshot2(await invoke( - QQ_ENDPOINTS.bindCredentials, - { appId: identity, appSecret: secret } - )); - if (!mounted.current) return; - setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); - setCredentialOpen(false); - } catch (error) { - if (mounted.current) setCredentialError(presentError3(error)); - } finally { - if (mounted.current) setBusy(false); - } - }, [invoke]); - const closeProvision = React6.useCallback(async () => { - setBusy(true); - try { - if (provision?.attemptId && ACTIVE_STATES.has(provision.status)) { - await invoke(QQ_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); - } - if (mounted.current) setProvision(null); - } finally { - if (mounted.current) setBusy(false); - } - }, [invoke, provision?.attemptId, provision?.status]); - React6.useEffect(() => { - const attemptId = provision?.attemptId; - if (!attemptId || !ACTIVE_STATES.has(provision.status)) return void 0; - const controller = new AbortController(); - let disposed = false; - let timer; - const poll = async () => { - try { - const current = normalizeProvisioning3(await invoke(QQ_ENDPOINTS.pollProvisioning, { attemptId }, controller.signal)); - if (disposed || controller.signal.aborted || !mounted.current) return; - if (current.status === "connected") { - setProvision(null); - await loadStatus({ signal: controller.signal, silent: true }); - return; - } - setProvision((previous) => previous?.attemptId === attemptId ? { ...previous, ...current, durationMs: current.qrRevision !== previous.qrRevision ? Math.max(1, current.expiresAt - Date.now()) : previous.durationMs } : previous); - if (ACTIVE_STATES.has(current.status)) timer = window.setTimeout(poll, current.pollIntervalMs); - } catch (error) { - if (!disposed && !controller.signal.aborted && mounted.current) { - setProvision((current) => ({ ...current, status: "failed", error: presentError3(error) })); - } - } - }; - timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1e3); - return () => { - disposed = true; - controller.abort(); - window.clearTimeout(timer); - }; - }, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]); - const botAction = React6.useCallback(async (account, operation, endpoint, payload) => { - setBusyByBot((current) => ({ ...current, [account.botId]: operation })); - try { - const snapshot = normalizeSnapshot2(await invoke(endpoint, payload)); - if (mounted.current) setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); - return snapshot; - } finally { - if (mounted.current) setBusyByBot((current) => { - const next = { ...current }; - delete next[account.botId]; - return next; - }); - } - }, [invoke]); - let provisionView = null; - if (provision?.status === "starting") provisionView = h5("div", { className: "ddt-card ddt-loading dim-surfaceCard" }, h5("div", { className: "ddt-spinner" }), "\u6B63\u5728\u7533\u8BF7 QQ \u4E8C\u7EF4\u7801\u2026"); - else if (["pending", "refreshing"].includes(provision?.status)) provisionView = h5(QrPanel2, { - provision, - now, - busy, - onRefresh: () => void startProvisioning(true), - onCancel: () => void closeProvision() - }); - else if (provision) provisionView = h5(ProvisionView, { - provision, - busy, - onRetry: () => void startProvisioning(true), - onClose: () => void closeProvision() - }); - const botList = model.bots.length > 0 ? h5( - "section", - { className: "dim-listSection" }, - h5( - "div", - { className: "ddt-listHeading dim-listHeading" }, - h5("h3", null, "\u5DF2\u7ED1\u5B9A\u7684 QQ \u673A\u5668\u4EBA") - ), - h5("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h5("li", { key: account.botId }, h5(AccountCard2, { - account, - busy: busyByBot[account.botId], - removing: removeTarget === account.botId, - onReconnect: () => void botAction(account, "reconnect", QQ_ENDPOINTS.reconnectBot, { botId: account.botId }), - onRequestRemove: () => setRemoveTarget(account.botId), - onCancelRemove: () => setRemoveTarget(null), - onConfirmRemove: async () => { - await botAction(account, "delete", QQ_ENDPOINTS.deleteBot, { botId: account.botId, confirm: true }); - if (mounted.current) setRemoveTarget(null); - } - })))) - ) : null; - const credentialView = credentialOpen ? h5(CredentialBindingPanel, { - channel: "QQ", - identityLabel: "AppID", - identityPlaceholder: "\u586B\u5199 QQ \u5F00\u653E\u5E73\u53F0 AppID", - secretLabel: "AppSecret", - secretPlaceholder: "\u586B\u5199 QQ \u5F00\u653E\u5E73\u53F0 AppSecret", - busy, - error: credentialError, - onSubmit: bindCredentials, - onCancel: () => { - setCredentialOpen(false); - setCredentialError(null); - } - }) : null; - return h5( - "section", - { className: "ddt-page dqq-page dim-channelPage", "aria-label": "QQ \u8BBE\u7F6E" }, - h5(Heading3, { - totals: model.totals, - adding: Boolean(provision), - busy, - onAdd: () => void startProvisioning(), - onCredential: () => { - setCredentialOpen((value) => !value); - setCredentialError(null); - }, - credentialOpen, - addButtonRef - }), - model.phase === "loading" ? h5(LoadingView3) : model.phase === "error" ? h5("div", { className: "ddt-card dim-surfaceCard" }, h5("div", { className: "ddt-inlineError dim-inlineError" }, h5("h3", null, "\u65E0\u6CD5\u8BFB\u53D6 QQ \u673A\u5668\u4EBA\u72B6\u6001"), h5("p", null, model.error?.message), h5(Button5, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6"))) : h5( - React6.Fragment, - null, - credentialView, - provisionView, - model.bots.length === 0 && !provision && !credentialOpen ? h5(EmptyView3, { busy, onStart: () => void startProvisioning() }) : null, - botList - ) - ); -} - -// plugin-src/client/channels/wecom/api.js -var WECOM_RPC_CHANNEL = "/wecom"; -var WECOM_ENDPOINTS = Object.freeze({ - status: "connection.status", - beginProvisioning: "provision.begin", - pollProvisioning: "provision.poll", - cancelProvisioning: "provision.cancel", - bindCredentials: "bot.bind-credentials", - reconnectBot: "bot.reconnect", - deleteBot: "bot.delete" -}); -var PROVISION_STATES3 = /* @__PURE__ */ new Set(["starting", "pending", "refreshing", "connecting", "connected", "failed", "cancelled"]); var ACCOUNT_STATES3 = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]); -var QR_DATA_URL3 = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i; +var QR_DATA_URL2 = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i; function isRecord4(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -3799,40 +3678,40 @@ function timestamp3(value) { return Number.isNaN(parsed) ? void 0 : parsed; } function unwrapRpcResult4(result) { - if (!isRecord4(result) || typeof result.ok !== "boolean") throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94"); + if (!isRecord4(result) || typeof result.ok !== "boolean") throw new Error("QQ \u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94"); if (!result.ok) { - const error = new Error(text2(result.error?.message, "\u4F01\u4E1A\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25")); - error.code = text2(result.error?.code, "WECOM_RPC_ERROR", 80); + const error = new Error(text2(result.error?.message, "QQ \u64CD\u4F5C\u5931\u8D25")); + error.code = text2(result.error?.code, "QQ_RPC_ERROR", 80); throw error; } return result.value; } -function safeQrSource4(value) { - return typeof value === "string" && value.length <= 2 * 1024 * 1024 && QR_DATA_URL3.test(value) ? value : void 0; +function safeQrSource3(value) { + return typeof value === "string" && value.length <= 2 * 1024 * 1024 && QR_DATA_URL2.test(value) ? value : void 0; } -function normalizeProvisioning4(value, now = Date.now()) { +function normalizeProvisioning3(value, now = Date.now()) { const source = isRecord4(value?.provisioning) ? value.provisioning : value; - if (!isRecord4(source)) throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u626B\u7801\u7ED1\u5B9A\u8FDB\u5EA6"); + if (!isRecord4(source)) throw new Error("QQ \u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u626B\u7801\u7ED1\u5B9A\u8FDB\u5EA6"); const attemptId = id2(source.attemptId); - if (!attemptId) throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u626B\u7801\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u7ED1\u5B9A\u4EFB\u52A1"); + if (!attemptId) throw new Error("QQ \u626B\u7801\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u7ED1\u5B9A\u4EFB\u52A1"); const reported = text2(source.status, "failed", 32); const result = { attemptId, - status: PROVISION_STATES3.has(reported) ? reported : "failed", + status: PROVISION_STATES2.has(reported) ? reported : "failed", expiresAt: timestamp3(source.expiresAt) ?? now + 5 * 6e4, pollIntervalMs: Math.min(1e4, Math.max(500, Number(source.pollIntervalMs) || 1e3)), qrRevision: Number.isSafeInteger(source.qrRevision) ? source.qrRevision : 0 }; - const qrCodeDataUrl = safeQrSource4(source.qrCodeDataUrl); + const qrCodeDataUrl = safeQrSource3(source.qrCodeDataUrl); if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl; if (id2(source.botId)) result.botId = id2(source.botId); if (isRecord4(source.error)) result.error = { - code: text2(source.error.code, "WECOM_PROVISION_FAILED", 80), - message: text2(source.error.message, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210") + code: text2(source.error.code, "QQ_PROVISION_FAILED", 80), + message: text2(source.error.message, "QQ \u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210") }; return result; } -function normalizeBot4(value) { +function normalizeBot3(value) { if (!isRecord4(value) || !id2(value.botId)) return void 0; const connected = value.connected === true; const state = ACCOUNT_STATES3.has(value.state) ? value.state : "offline"; @@ -3841,68 +3720,68 @@ function normalizeBot4(value) { connected, state: connected ? "connected" : state, bot: { - name: text2(value.bot?.name, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA", 100), + name: text2(value.bot?.name, "QQ\u673A\u5668\u4EBA", 100), appIdMasked: text2(value.bot?.appIdMasked, "\u5E94\u7528\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58", 140) }, health: { - summary: text2(value.health?.summary, connected ? "\u4F01\u4E1A\u5FAE\u4FE1 WebSocket \u957F\u8FDE\u63A5\u8FD0\u884C\u6B63\u5E38" : "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA"), + summary: text2(value.health?.summary, connected ? "QQ WebSocket \u957F\u8FDE\u63A5\u8FD0\u884C\u6B63\u5E38" : "QQ \u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA"), lastCheckedAt: timestamp3(value.health?.lastCheckedAt) }, error: isRecord4(value.error) ? { - code: text2(value.error.code, "WECOM_ACCOUNT_ERROR", 80), - message: text2(value.error.message, "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA") + code: text2(value.error.code, "QQ_ACCOUNT_ERROR", 80), + message: text2(value.error.message, "QQ \u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA") } : null }; } function normalizeSnapshot3(value) { const source = isRecord4(value?.snapshot) ? value.snapshot : value; - if (!isRecord4(source) || !Array.isArray(source.bots)) throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u673A\u5668\u4EBA\u5217\u8868"); - const bots = source.bots.map(normalizeBot4).filter(Boolean); + if (!isRecord4(source) || !Array.isArray(source.bots)) throw new Error("QQ \u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u673A\u5668\u4EBA\u5217\u8868"); + const bots = source.bots.map(normalizeBot3).filter(Boolean); return { revision: Number.isSafeInteger(source.revision) ? source.revision : 0, bots, totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length }, - provisioning: source.provisioning ? normalizeProvisioning4(source.provisioning) : null + provisioning: source.provisioning ? normalizeProvisioning3(source.provisioning) : null }; } function presentError4(error) { return { - code: text2(error?.code, "WECOM_ERROR", 80), - message: text2(error?.message, "\u4F01\u4E1A\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5") + code: text2(error?.code, "QQ_ERROR", 80), + message: text2(error?.message, "QQ \u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5") }; } -function formatRemaining4(milliseconds) { +function formatRemaining3(milliseconds) { const seconds = Math.max(0, Math.ceil(Number(milliseconds) / 1e3) || 0); return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; } -// plugin-src/client/channels/wecom/index.js +// plugin-src/client/channels/qq/index.js var React7 = __toESM(require("react"), 1); -// plugin-src/client/channels/wecom/styles.js -var WECOM_STYLE_ID = "xmanrui-dsh-im-wecom-settings"; +// plugin-src/client/channels/qq/styles.js +var QQ_STYLE_ID = "xmanrui-dsh-im-qq-settings"; var CSS4 = String.raw` -.dwecom-page { --ddt-accent: #3370ff; --ddt-accent-deep: #245bdb; --ddt-accent-wash: #eef4ff; } -.dwecom-avatar, .dwecom-brand { color: #3370ff; background: #fff; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); } -.dwecom-avatar svg, .dwecom-brand svg { display: block; } +.dqq-page { --ddt-accent: #1677ff; --ddt-accent-deep: #0958d9; --ddt-accent-wash: #eaf3ff; } +.dqq-avatar, .dqq-brand { color: #fff; background: #1677ff; } +.dqq-avatar svg, .dqq-brand svg { display: block; } `; -function installWecomStyles() { +function installQqStyles() { if (typeof document === "undefined") return () => { }; - const existing = document.querySelector(`style[data-plugin-css="${WECOM_STYLE_ID}"]`); + const existing = document.querySelector(`style[data-plugin-css="${QQ_STYLE_ID}"]`); if (existing) return () => { }; const style = document.createElement("style"); style.dataset.plugin = "@xmanrui/dsh-im"; - style.dataset.pluginCss = WECOM_STYLE_ID; + style.dataset.pluginCss = QQ_STYLE_ID; style.textContent = CSS4; document.head.appendChild(style); return () => style.remove(); } -// plugin-src/client/channels/wecom/index.js +// plugin-src/client/channels/qq/index.js var h6 = React7.createElement; -var ACTIVE_STATES2 = /* @__PURE__ */ new Set(["pending", "refreshing", "connecting"]); +var ACTIVE_STATES = /* @__PURE__ */ new Set(["pending", "refreshing", "connecting"]); var Button7 = React7.forwardRef(function Button8({ children, kind = "secondary", className = "", ...props }, ref) { return h6("button", { ...props, @@ -3924,7 +3803,7 @@ function checkedTime3(value) { return "\u521A\u521A"; } } -function Heading4({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) { +function Heading3({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) { return h6( "div", { className: "ddt-heading" }, @@ -3940,7 +3819,7 @@ function Heading4({ totals, adding, busy, onAdd, onCredential, credentialOpen, a onClick: onAdd, disabled: adding || busy, ref: addButtonRef, - "aria-label": "\u626B\u7801\u63A5\u5165\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA" + "aria-label": "\u626B\u7801\u63A5\u5165 QQ \u673A\u5668\u4EBA" }, h6(QrActionIcon), adding ? "\u6B63\u5728\u63A5\u5165" : "\u626B\u7801\u63A5\u5165\u673A\u5668\u4EBA"), h6(Button7, { kind: "credential", @@ -3948,7 +3827,7 @@ function Heading4({ totals, adding, busy, onAdd, onCredential, credentialOpen, a onClick: onCredential, disabled: adding || busy, "aria-pressed": credentialOpen, - "aria-label": "\u4F7F\u7528 Bot ID \u548C Secret \u7ED1\u5B9A\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA" + "aria-label": "\u4F7F\u7528 AppID \u548C AppSecret \u7ED1\u5B9A QQ \u673A\u5668\u4EBA" }, h6(CredentialActionIcon), credentialOpen ? "\u6536\u8D77\u51ED\u636E" : "\u624B\u52A8\u63A5\u5165") ), totals.configured > 0 ? h6( @@ -3959,15 +3838,15 @@ function Heading4({ totals, adding, busy, onAdd, onCredential, credentialOpen, a ) ); } -function LoadingView4() { +function LoadingView3() { return h6( "div", { className: "ddt-card ddt-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, h6("div", { className: "ddt-spinner dim-spinner" }), - h6("span", null, "\u6B63\u5728\u8BFB\u53D6\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u72B6\u6001\u2026") + h6("span", null, "\u6B63\u5728\u8BFB\u53D6 QQ \u673A\u5668\u4EBA\u72B6\u6001\u2026") ); } -function EmptyView4({ busy, onStart }) { +function EmptyView3({ busy, onStart }) { return h6( "div", { className: "ddt-card dim-surfaceCard" }, @@ -3981,30 +3860,30 @@ function EmptyView4({ busy, onStart }) { "div", { className: "ddt-stateLabel dim-stateLabel" }, h6("span", { className: "ddt-dot dim-stateDot" }), - h6("span", null, "\u5C1A\u672A\u7ED1\u5B9A\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA") + h6("span", null, "\u5C1A\u672A\u7ED1\u5B9A QQ \u673A\u5668\u4EBA") ), - h6("h3", null, "\u4F7F\u7528\u4F01\u4E1A\u5FAE\u4FE1 App \u626B\u7801\u521B\u5EFA\u667A\u80FD\u673A\u5668\u4EBA"), - h6("p", null, "\u626B\u7801\u7531\u817E\u8BAF\u5B98\u65B9\u9875\u9762\u5B8C\u6210\uFF0C\u4E0D\u9700\u8981\u624B\u52A8\u586B\u5199 Bot ID \u6216 Secret\u3002\u521B\u5EFA\u6210\u529F\u540E\uFF0C\u673A\u5668\u4EBA\u4F1A\u81EA\u52A8\u8FDE\u63A5 DeepSeek Harness\u3002"), + h6("h3", null, "\u4F7F\u7528\u624B\u673A QQ \u626B\u7801\u521B\u5EFA\u5E76\u7ED1\u5B9A\u673A\u5668\u4EBA"), + h6("p", null, "\u626B\u7801\u7531\u817E\u8BAF\u5B98\u65B9\u9875\u9762\u5B8C\u6210\uFF0C\u4E0D\u9700\u8981\u624B\u52A8\u586B\u5199 AppID \u6216 AppSecret\u3002\u626B\u7801\u6210\u529F\u540E\uFF0C\u673A\u5668\u4EBA\u4F1A\u81EA\u52A8\u8FDE\u63A5 DeepSeek Harness\u3002"), h6( "div", { className: "ddt-actions dim-viewActions" }, h6( Button7, { kind: "primary", onClick: onStart, disabled: busy }, - busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801" + busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210 QQ \u4E8C\u7EF4\u7801" ) ) ), h6( "div", - { className: "ddt-brandMark dim-emptyBrand dwecom-brand", "aria-hidden": "true" }, - h6(WecomLogoGlyph, { size: 64 }) + { className: "ddt-brandMark dim-emptyBrand dqq-brand", "aria-hidden": "true" }, + h6(QqLogoGlyph, { size: 64 }) ) ) ); } -function QrPanel3({ provision, now, busy, onRefresh, onCancel }) { - const source = safeQrSource4(provision.qrCodeDataUrl); +function QrPanel2({ provision, now, busy, onRefresh, onCancel }) { + const source = safeQrSource3(provision.qrCodeDataUrl); const remaining = Math.max(0, provision.expiresAt - now); const duration = Math.max(1, provision.durationMs ?? 5 * 6e4); const progress = Math.round(Math.min(1, remaining / duration) * 100); @@ -4021,7 +3900,7 @@ function QrPanel3({ provision, now, busy, onRefresh, onCancel }) { h6( "div", { className: "ddt-qrFrame dim-qrFrame" }, - source ? h6("img", { src: source, alt: "\u7528\u4E8E\u7ED1\u5B9A\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u7684\u4E00\u6B21\u6027\u4E8C\u7EF4\u7801" }) : h6( + source ? h6("img", { src: source, alt: "\u7528\u4E8E\u7ED1\u5B9A QQ \u673A\u5668\u4EBA\u7684\u4E00\u6B21\u6027\u4E8C\u7EF4\u7801" }) : h6( "div", { className: "ddt-qrFallback dim-qrFallback" }, refreshing ? "\u4E8C\u7EF4\u7801\u6B63\u5728\u81EA\u52A8\u5237\u65B0\u2026" : "\u4E8C\u7EF4\u7801\u56FE\u7247\u6B63\u5728\u751F\u6210\u2026" @@ -4034,7 +3913,7 @@ function QrPanel3({ provision, now, busy, onRefresh, onCancel }) { "div", { className: "ddt-countdownTop dim-countdownTop" }, h6("span", null, "\u5F53\u524D\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), - h6("strong", null, refreshing ? "--:--" : formatRemaining4(remaining)) + h6("strong", null, refreshing ? "--:--" : formatRemaining3(remaining)) ), h6("div", { className: "ddt-progress dim-progress", style: { "--ddt-progress": `${progress}%` } }, h6("span")) ) @@ -4046,15 +3925,15 @@ function QrPanel3({ provision, now, busy, onRefresh, onCancel }) { "div", { className: "ddt-stateLabel dim-stateLabel" }, h6("span", { className: "ddt-dot dim-stateDot", "data-tone": "warning" }), - h6("span", null, refreshing ? "\u6B63\u5728\u5237\u65B0\u4E8C\u7EF4\u7801" : "\u7B49\u5F85\u4F01\u4E1A\u5FAE\u4FE1 App \u626B\u7801") + h6("span", null, refreshing ? "\u6B63\u5728\u5237\u65B0\u4E8C\u7EF4\u7801" : "\u7B49\u5F85\u624B\u673A QQ \u626B\u7801") ), - h6("h3", null, "\u4F7F\u7528\u4F01\u4E1A\u5FAE\u4FE1 App \u5B8C\u6210\u667A\u80FD\u673A\u5668\u4EBA\u6388\u6743"), - h6("p", null, "\u4F01\u4E1A\u5FAE\u4FE1\u5B98\u65B9\u9875\u9762\u4F1A\u521B\u5EFA\u4E00\u4E2A\u667A\u80FD\u673A\u5668\u4EBA\uFF0C\u5E76\u628A\u8FDE\u63A5\u51ED\u636E\u5B89\u5168\u4EA4\u7ED9\u672C\u673A Harness Host\u3002"), + h6("h3", null, "\u4F7F\u7528\u624B\u673A QQ \u5B8C\u6210\u673A\u5668\u4EBA\u7ED1\u5B9A"), + h6("p", null, "\u817E\u8BAF\u9875\u9762\u4F1A\u521B\u5EFA\u6216\u7ED1\u5B9A\u4E00\u4E2A QQ \u673A\u5668\u4EBA\uFF0C\u5E76\u628A\u8FDE\u63A5\u51ED\u636E\u5B89\u5168\u4EA4\u7ED9\u672C\u673A Harness Host\u3002"), h6( "ol", { className: "ddt-steps dim-steps" }, - h6("li", null, "\u6253\u5F00\u4F01\u4E1A\u5FAE\u4FE1 App\uFF0C\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), - h6("li", null, "\u5728\u817E\u8BAF\u6388\u6743\u9875\u9762\u786E\u8BA4\u521B\u5EFA\u667A\u80FD\u673A\u5668\u4EBA"), + h6("li", null, "\u6253\u5F00\u624B\u673A QQ\uFF0C\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), + h6("li", null, "\u5728\u817E\u8BAF\u6388\u6743\u9875\u9762\u786E\u8BA4\u521B\u5EFA\u6216\u7ED1\u5B9A\u673A\u5668\u4EBA"), h6("li", null, "\u8FD4\u56DE\u8FD9\u91CC\u7B49\u5F85\u8FDE\u63A5\u5B8C\u6210") ), h6( @@ -4067,24 +3946,24 @@ function QrPanel3({ provision, now, busy, onRefresh, onCancel }) { ) ); } -function ProvisionView2({ provision, busy, onRetry, onClose }) { +function ProvisionView({ provision, busy, onRetry, onClose }) { if (provision.status === "connecting") { return h6( "div", { className: "ddt-card ddt-loading dim-surfaceCard dim-specialView", "aria-busy": "true" }, h6("div", { className: "ddt-spinner dim-spinner" }), - h6("h3", null, "\u4F01\u4E1A\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u6B63\u5728\u8FDE\u63A5\u673A\u5668\u4EBA"), - h6("p", null, "\u51ED\u636E\u6B63\u5728\u5199\u5165\u672C\u673A\uFF0C\u5E76\u542F\u52A8\u4F01\u4E1A\u5FAE\u4FE1 WebSocket \u6D88\u606F\u8FDE\u63A5\u3002") + h6("h3", null, "QQ \u5DF2\u6388\u6743\uFF0C\u6B63\u5728\u8FDE\u63A5\u673A\u5668\u4EBA"), + h6("p", null, "\u51ED\u636E\u6B63\u5728\u5199\u5165\u672C\u673A\uFF0C\u5E76\u542F\u52A8 QQ WebSocket \u6D88\u606F\u8FDE\u63A5\u3002") ); } - const error = provision.error ?? { code: "WECOM_PROVISION_FAILED", message: "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210" }; + const error = provision.error ?? { code: "QQ_PROVISION_FAILED", message: "QQ \u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210" }; return h6( "div", { className: "ddt-card dim-surfaceCard" }, h6( "div", { className: "ddt-inlineError dim-inlineError", role: "alert" }, - h6("h3", null, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), + h6("h3", null, "QQ \u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), h6("p", null, error.message), h6("span", { className: "ddt-errorCode" }, error.code), h6( @@ -4096,12 +3975,12 @@ function ProvisionView2({ provision, busy, onRetry, onClose }) { ) ); } -function RemoveConfirmation4({ account, busy, onConfirm, onCancel }) { +function RemoveConfirmation3({ account, busy, onConfirm, onCancel }) { return h6( "div", { className: "ddt-confirm dim-confirm", role: "alertdialog" }, h6("strong", null, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${account.bot.name}\u201D\uFF1F`), - h6("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684\u5E94\u7528\u51ED\u636E\u3001\u673A\u5668\u4EBA\u914D\u7F6E\u53CA\u4F1A\u8BDD\u6620\u5C04\u3002\u4F01\u4E1A\u5FAE\u4FE1\u5E73\u53F0\u4E2D\u7684\u673A\u5668\u4EBA\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\u3002"), + h6("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684\u5E94\u7528\u51ED\u636E\u3001\u673A\u5668\u4EBA\u914D\u7F6E\u53CA\u4F1A\u8BDD\u6620\u5C04\u3002\u817E\u8BAF\u5E73\u53F0\u4E2D\u7684\u673A\u5668\u4EBA\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\u3002"), h6( "div", { className: "ddt-actions dim-viewActions" }, @@ -4110,7 +3989,7 @@ function RemoveConfirmation4({ account, busy, onConfirm, onCancel }) { ) ); } -function AccountCard3({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { +function AccountCard2({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { const tone = account.connected ? "success" : account.state === "error" ? "error" : "warning"; const stateLabel = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : account.state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA"; const summary = account.error?.message ?? (account.connected ? null : account.health.summary); @@ -4126,7 +4005,7 @@ function AccountCard3({ account, busy, removing, onReconnect, onRequestRemove, o h6( "div", { className: "ddt-accountIdentity dim-botIdentity" }, - h6("div", { className: "ddt-avatar dim-botAvatar dwecom-avatar", "aria-hidden": "true" }, h6(WecomLogoGlyph, { size: 29 })), + h6("div", { className: "ddt-avatar dim-botAvatar dqq-avatar", "aria-hidden": "true" }, h6(QqLogoGlyph, { size: 29 })), h6( "div", { className: "dim-botName" }, @@ -4159,7 +4038,7 @@ function AccountCard3({ account, busy, removing, onReconnect, onRequestRemove, o ) ) ), - removing ? h6(RemoveConfirmation4, { + removing ? h6(RemoveConfirmation3, { account, busy: busy === "delete", onConfirm: onConfirmRemove, @@ -4167,7 +4046,7 @@ function AccountCard3({ account, busy, removing, onReconnect, onRequestRemove, o }) : null ); } -function WecomSettingsTab({ rpcCall }) { +function QqSettingsTab({ rpcCall }) { const [model, setModel] = React7.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); const [provision, setProvision] = React7.useState(null); const [busy, setBusy] = React7.useState(false); @@ -4180,22 +4059,22 @@ function WecomSettingsTab({ rpcCall }) { const addButtonRef = React7.useRef(null); React7.useEffect(() => { const disposeDingtalk = installDingtalkStyles(); - const disposeWecom = installWecomStyles(); + const disposeQq = installQqStyles(); mounted.current = true; return () => { mounted.current = false; - disposeWecom(); + disposeQq(); disposeDingtalk(); }; }, []); const invoke = React7.useCallback(async (endpoint, payload = {}, signal) => { - if (typeof rpcCall !== "function") throw new TypeError("\u4F01\u4E1A\u5FAE\u4FE1\u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5"); + if (typeof rpcCall !== "function") throw new TypeError("QQ \u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5"); return unwrapRpcResult4(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); const loadStatus = React7.useCallback(async ({ signal, silent = false, restore = false } = {}) => { if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null })); try { - const snapshot = normalizeSnapshot3(await invoke(WECOM_ENDPOINTS.status, {}, signal)); + const snapshot = normalizeSnapshot3(await invoke(QQ_ENDPOINTS.status, {}, signal)); if (!mounted.current || signal?.aborted) return void 0; setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); if (restore && snapshot.provisioning) setProvision({ @@ -4225,7 +4104,7 @@ function WecomSettingsTab({ rpcCall }) { }; }, [loadStatus, model.phase]); React7.useEffect(() => { - if (!provision || !ACTIVE_STATES2.has(provision.status)) return void 0; + if (!provision || !ACTIVE_STATES.has(provision.status)) return void 0; const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1e3); return () => window.clearInterval(timer); }, [provision?.attemptId, provision?.status]); @@ -4234,10 +4113,10 @@ function WecomSettingsTab({ rpcCall }) { setCredentialError(null); setBusy(true); try { - if (replace && provision?.attemptId) await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); + if (replace && provision?.attemptId) await invoke(QQ_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); if (!mounted.current) return; setProvision({ status: "starting" }); - const started = normalizeProvisioning4(await invoke(WECOM_ENDPOINTS.beginProvisioning, { locale: "zh-CN" })); + const started = normalizeProvisioning3(await invoke(QQ_ENDPOINTS.beginProvisioning, { locale: "zh-CN" })); if (!mounted.current) return; setNow(Date.now()); setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) }); @@ -4252,8 +4131,8 @@ function WecomSettingsTab({ rpcCall }) { setCredentialError(null); try { const snapshot = normalizeSnapshot3(await invoke( - WECOM_ENDPOINTS.bindCredentials, - { botId: identity, secret } + QQ_ENDPOINTS.bindCredentials, + { appId: identity, appSecret: secret } )); if (!mounted.current) return; setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); @@ -4267,8 +4146,8 @@ function WecomSettingsTab({ rpcCall }) { const closeProvision = React7.useCallback(async () => { setBusy(true); try { - if (provision?.attemptId && ACTIVE_STATES2.has(provision.status)) { - await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); + if (provision?.attemptId && ACTIVE_STATES.has(provision.status)) { + await invoke(QQ_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); } if (mounted.current) setProvision(null); } finally { @@ -4277,13 +4156,13 @@ function WecomSettingsTab({ rpcCall }) { }, [invoke, provision?.attemptId, provision?.status]); React7.useEffect(() => { const attemptId = provision?.attemptId; - if (!attemptId || !ACTIVE_STATES2.has(provision.status)) return void 0; + if (!attemptId || !ACTIVE_STATES.has(provision.status)) return void 0; const controller = new AbortController(); let disposed = false; let timer; const poll = async () => { try { - const current = normalizeProvisioning4(await invoke(WECOM_ENDPOINTS.pollProvisioning, { attemptId }, controller.signal)); + const current = normalizeProvisioning3(await invoke(QQ_ENDPOINTS.pollProvisioning, { attemptId }, controller.signal)); if (disposed || controller.signal.aborted || !mounted.current) return; if (current.status === "connected") { setProvision(null); @@ -4291,7 +4170,7 @@ function WecomSettingsTab({ rpcCall }) { return; } setProvision((previous) => previous?.attemptId === attemptId ? { ...previous, ...current, durationMs: current.qrRevision !== previous.qrRevision ? Math.max(1, current.expiresAt - Date.now()) : previous.durationMs } : previous); - if (ACTIVE_STATES2.has(current.status)) timer = window.setTimeout(poll, current.pollIntervalMs); + if (ACTIVE_STATES.has(current.status)) timer = window.setTimeout(poll, current.pollIntervalMs); } catch (error) { if (!disposed && !controller.signal.aborted && mounted.current) { setProvision((current) => ({ ...current, status: "failed", error: presentError4(error) })); @@ -4320,15 +4199,15 @@ function WecomSettingsTab({ rpcCall }) { } }, [invoke]); let provisionView = null; - if (provision?.status === "starting") provisionView = h6("div", { className: "ddt-card ddt-loading dim-surfaceCard" }, h6("div", { className: "ddt-spinner" }), "\u6B63\u5728\u7533\u8BF7\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\u2026"); - else if (["pending", "refreshing"].includes(provision?.status)) provisionView = h6(QrPanel3, { + if (provision?.status === "starting") provisionView = h6("div", { className: "ddt-card ddt-loading dim-surfaceCard" }, h6("div", { className: "ddt-spinner" }), "\u6B63\u5728\u7533\u8BF7 QQ \u4E8C\u7EF4\u7801\u2026"); + else if (["pending", "refreshing"].includes(provision?.status)) provisionView = h6(QrPanel2, { provision, now, busy, onRefresh: () => void startProvisioning(true), onCancel: () => void closeProvision() }); - else if (provision) provisionView = h6(ProvisionView2, { + else if (provision) provisionView = h6(ProvisionView, { provision, busy, onRetry: () => void startProvisioning(true), @@ -4340,9 +4219,682 @@ function WecomSettingsTab({ rpcCall }) { h6( "div", { className: "ddt-listHeading dim-listHeading" }, - h6("h3", null, "\u5DF2\u7ED1\u5B9A\u7684\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA") + h6("h3", null, "\u5DF2\u7ED1\u5B9A\u7684 QQ \u673A\u5668\u4EBA") ), - h6("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h6("li", { key: account.botId }, h6(AccountCard3, { + h6("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h6("li", { key: account.botId }, h6(AccountCard2, { + account, + busy: busyByBot[account.botId], + removing: removeTarget === account.botId, + onReconnect: () => void botAction(account, "reconnect", QQ_ENDPOINTS.reconnectBot, { botId: account.botId }), + onRequestRemove: () => setRemoveTarget(account.botId), + onCancelRemove: () => setRemoveTarget(null), + onConfirmRemove: async () => { + await botAction(account, "delete", QQ_ENDPOINTS.deleteBot, { botId: account.botId, confirm: true }); + if (mounted.current) setRemoveTarget(null); + } + })))) + ) : null; + const credentialView = credentialOpen ? h6(CredentialBindingPanel, { + channel: "QQ", + identityLabel: "AppID", + identityPlaceholder: "\u586B\u5199 QQ \u5F00\u653E\u5E73\u53F0 AppID", + secretLabel: "AppSecret", + secretPlaceholder: "\u586B\u5199 QQ \u5F00\u653E\u5E73\u53F0 AppSecret", + busy, + error: credentialError, + onSubmit: bindCredentials, + onCancel: () => { + setCredentialOpen(false); + setCredentialError(null); + } + }) : null; + return h6( + "section", + { className: "ddt-page dqq-page dim-channelPage", "aria-label": "QQ \u8BBE\u7F6E" }, + h6(Heading3, { + totals: model.totals, + adding: Boolean(provision), + busy, + onAdd: () => void startProvisioning(), + onCredential: () => { + setCredentialOpen((value) => !value); + setCredentialError(null); + }, + credentialOpen, + addButtonRef + }), + model.phase === "loading" ? h6(LoadingView3) : model.phase === "error" ? h6("div", { className: "ddt-card dim-surfaceCard" }, h6("div", { className: "ddt-inlineError dim-inlineError" }, h6("h3", null, "\u65E0\u6CD5\u8BFB\u53D6 QQ \u673A\u5668\u4EBA\u72B6\u6001"), h6("p", null, model.error?.message), h6(Button7, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6"))) : h6( + React7.Fragment, + null, + credentialView, + provisionView, + model.bots.length === 0 && !provision && !credentialOpen ? h6(EmptyView3, { busy, onStart: () => void startProvisioning() }) : null, + botList + ) + ); +} + +// plugin-src/client/channels/telegram/api.js +var TELEGRAM_RPC_CHANNEL = "/telegram"; +var TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS; +var api2 = createTokenChannelApi("Telegram", " Bot API \u957F\u8F6E\u8BE2"); +var unwrapRpcResult5 = api2.unwrapRpcResult; +var normalizeSnapshot4 = api2.normalizeSnapshot; +var presentError5 = api2.presentError; + +// plugin-src/client/channels/telegram/styles.js +var TELEGRAM_STYLE_ID = "xmanrui-dsh-im-telegram-settings"; +var CSS5 = String.raw` +.dtg-page { --ddt-accent: #229ed9; --ddt-accent-deep: #1687bd; --ddt-accent-wash: #eaf7fd; } +.dtg-avatar { color: #fff; background: #229ed9; } +.dtg-avatar svg { display: block; } +`; +function installTelegramStyles() { + if (typeof document === "undefined") return () => { + }; + const existing = document.querySelector(`style[data-plugin-css="${TELEGRAM_STYLE_ID}"]`); + if (existing) return () => { + }; + const style = document.createElement("style"); + style.dataset.plugin = "@xmanrui/dsh-im"; + style.dataset.pluginCss = TELEGRAM_STYLE_ID; + style.textContent = CSS5; + document.head.appendChild(style); + return () => style.remove(); +} + +// plugin-src/client/channels/telegram/index.js +var channel2 = createTokenChannelSettings({ + channel: "Telegram", + endpoints: TELEGRAM_ENDPOINTS, + api: api2, + LogoGlyph: TelegramLogoGlyph, + installStyles: installTelegramStyles, + pageClass: "dtg-page", + avatarClass: "dtg-avatar", + connectionLabel: "Bot API \u957F\u8F6E\u8BE2", + tokenPlaceholder: "\u586B\u5199 @BotFather \u751F\u6210\u7684 Bot Token", + emptyTitle: "\u63A5\u5165 Telegram \u673A\u5668\u4EBA", + emptyDescription: "\u5148\u901A\u8FC7 @BotFather \u83B7\u53D6 Bot Token\uFF0C\u518D\u5728\u8FD9\u91CC\u5B8C\u6210\u63A5\u5165\u3002", + platformLabel: "Telegram" +}); +var TelegramSettingsTab = channel2.SettingsTab; +var TelegramAccountCard = channel2.AccountCard; + +// plugin-src/client/channels/wecom/api.js +var WECOM_RPC_CHANNEL = "/wecom"; +var WECOM_ENDPOINTS = Object.freeze({ + status: "connection.status", + beginProvisioning: "provision.begin", + pollProvisioning: "provision.poll", + cancelProvisioning: "provision.cancel", + bindCredentials: "bot.bind-credentials", + reconnectBot: "bot.reconnect", + deleteBot: "bot.delete" +}); +var PROVISION_STATES3 = /* @__PURE__ */ new Set(["starting", "pending", "refreshing", "connecting", "connected", "failed", "cancelled"]); +var ACCOUNT_STATES4 = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]); +var QR_DATA_URL3 = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i; +function isRecord5(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function text3(value, fallback, max = 240) { + return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : fallback; +} +function id3(value) { + const result = text3(value, "", 128); + return /^[a-z\d_-]+$/i.test(result) ? result : void 0; +} +function timestamp4(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN; + return Number.isNaN(parsed) ? void 0 : parsed; +} +function unwrapRpcResult6(result) { + if (!isRecord5(result) || typeof result.ok !== "boolean") throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94"); + if (!result.ok) { + const error = new Error(text3(result.error?.message, "\u4F01\u4E1A\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25")); + error.code = text3(result.error?.code, "WECOM_RPC_ERROR", 80); + throw error; + } + return result.value; +} +function safeQrSource4(value) { + return typeof value === "string" && value.length <= 2 * 1024 * 1024 && QR_DATA_URL3.test(value) ? value : void 0; +} +function normalizeProvisioning4(value, now = Date.now()) { + const source = isRecord5(value?.provisioning) ? value.provisioning : value; + if (!isRecord5(source)) throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u626B\u7801\u7ED1\u5B9A\u8FDB\u5EA6"); + const attemptId = id3(source.attemptId); + if (!attemptId) throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u626B\u7801\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u7ED1\u5B9A\u4EFB\u52A1"); + const reported = text3(source.status, "failed", 32); + const result = { + attemptId, + status: PROVISION_STATES3.has(reported) ? reported : "failed", + expiresAt: timestamp4(source.expiresAt) ?? now + 5 * 6e4, + pollIntervalMs: Math.min(1e4, Math.max(500, Number(source.pollIntervalMs) || 1e3)), + qrRevision: Number.isSafeInteger(source.qrRevision) ? source.qrRevision : 0 + }; + const qrCodeDataUrl = safeQrSource4(source.qrCodeDataUrl); + if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl; + if (id3(source.botId)) result.botId = id3(source.botId); + if (isRecord5(source.error)) result.error = { + code: text3(source.error.code, "WECOM_PROVISION_FAILED", 80), + message: text3(source.error.message, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u6CA1\u6709\u63A5\u5165\u5B8C\u6210") + }; + return result; +} +function normalizeBot4(value) { + if (!isRecord5(value) || !id3(value.botId)) return void 0; + const connected = value.connected === true; + const state = ACCOUNT_STATES4.has(value.state) ? value.state : "offline"; + return { + botId: id3(value.botId), + connected, + state: connected ? "connected" : state, + bot: { + name: text3(value.bot?.name, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA", 100), + appIdMasked: text3(value.bot?.appIdMasked, "\u5E94\u7528\u6807\u8BC6\u5DF2\u5B89\u5168\u4FDD\u5B58", 140) + }, + health: { + summary: text3(value.health?.summary, connected ? "\u4F01\u4E1A\u5FAE\u4FE1 WebSocket \u957F\u8FDE\u63A5\u8FD0\u884C\u6B63\u5E38" : "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA"), + lastCheckedAt: timestamp4(value.health?.lastCheckedAt) + }, + error: isRecord5(value.error) ? { + code: text3(value.error.code, "WECOM_ACCOUNT_ERROR", 80), + message: text3(value.error.message, "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA") + } : null + }; +} +function normalizeSnapshot5(value) { + const source = isRecord5(value?.snapshot) ? value.snapshot : value; + if (!isRecord5(source) || !Array.isArray(source.bots)) throw new Error("\u4F01\u4E1A\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u673A\u5668\u4EBA\u5217\u8868"); + const bots = source.bots.map(normalizeBot4).filter(Boolean); + return { + revision: Number.isSafeInteger(source.revision) ? source.revision : 0, + bots, + totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length }, + provisioning: source.provisioning ? normalizeProvisioning4(source.provisioning) : null + }; +} +function presentError6(error) { + return { + code: text3(error?.code, "WECOM_ERROR", 80), + message: text3(error?.message, "\u4F01\u4E1A\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5") + }; +} +function formatRemaining4(milliseconds) { + const seconds = Math.max(0, Math.ceil(Number(milliseconds) / 1e3) || 0); + return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; +} + +// plugin-src/client/channels/wecom/index.js +var React8 = __toESM(require("react"), 1); + +// plugin-src/client/channels/wecom/styles.js +var WECOM_STYLE_ID = "xmanrui-dsh-im-wecom-settings"; +var CSS6 = String.raw` +.dwecom-page { --ddt-accent: #3370ff; --ddt-accent-deep: #245bdb; --ddt-accent-wash: #eef4ff; } +.dwecom-avatar, .dwecom-brand { color: #3370ff; background: #fff; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); } +.dwecom-avatar svg, .dwecom-brand svg { display: block; } +`; +function installWecomStyles() { + if (typeof document === "undefined") return () => { + }; + const existing = document.querySelector(`style[data-plugin-css="${WECOM_STYLE_ID}"]`); + if (existing) return () => { + }; + const style = document.createElement("style"); + style.dataset.plugin = "@xmanrui/dsh-im"; + style.dataset.pluginCss = WECOM_STYLE_ID; + style.textContent = CSS6; + document.head.appendChild(style); + return () => style.remove(); +} + +// plugin-src/client/channels/wecom/index.js +var h7 = React8.createElement; +var ACTIVE_STATES2 = /* @__PURE__ */ new Set(["pending", "refreshing", "connecting"]); +var Button9 = React8.forwardRef(function Button10({ children, kind = "secondary", className = "", ...props }, ref) { + return h7("button", { + ...props, + ref, + type: "button", + className: `ddt-button ${className}`.trim(), + "data-kind": kind + }, children); +}); +function checkedTime4(value) { + if (!value) return "\u5C1A\u672A\u68C0\u67E5"; + try { + return new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }).format(new Date(value)); + } catch { + return "\u521A\u521A"; + } +} +function Heading4({ totals, adding, busy, onAdd, onCredential, credentialOpen, addButtonRef }) { + return h7( + "div", + { className: "ddt-heading" }, + h7( + "div", + { className: "ddt-tools" }, + h7( + "div", + { className: "dim-bindActions" }, + h7(Button9, { + kind: "primary", + className: "dim-scanButton", + onClick: onAdd, + disabled: adding || busy, + ref: addButtonRef, + "aria-label": "\u626B\u7801\u63A5\u5165\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA" + }, h7(QrActionIcon), adding ? "\u6B63\u5728\u63A5\u5165" : "\u626B\u7801\u63A5\u5165\u673A\u5668\u4EBA"), + h7(Button9, { + kind: "credential", + className: "dim-credentialButton", + onClick: onCredential, + disabled: adding || busy, + "aria-pressed": credentialOpen, + "aria-label": "\u4F7F\u7528 Bot ID \u548C Secret \u7ED1\u5B9A\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA" + }, h7(CredentialActionIcon), credentialOpen ? "\u6536\u8D77\u51ED\u636E" : "\u624B\u52A8\u63A5\u5165") + ), + totals.configured > 0 ? h7( + "div", + { className: "ddt-badge dim-onlineBadge" }, + h7("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) + ) : null + ) + ); +} +function LoadingView4() { + return h7( + "div", + { className: "ddt-card ddt-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, + h7("div", { className: "ddt-spinner dim-spinner" }), + h7("span", null, "\u6B63\u5728\u8BFB\u53D6\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u72B6\u6001\u2026") + ); +} +function EmptyView4({ busy, onStart }) { + return h7( + "div", + { className: "ddt-card dim-surfaceCard" }, + h7( + "div", + { className: "ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView" }, + h7( + "div", + { className: "dim-emptyCopy" }, + h7( + "div", + { className: "ddt-stateLabel dim-stateLabel" }, + h7("span", { className: "ddt-dot dim-stateDot" }), + h7("span", null, "\u5C1A\u672A\u7ED1\u5B9A\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA") + ), + h7("h3", null, "\u4F7F\u7528\u4F01\u4E1A\u5FAE\u4FE1 App \u626B\u7801\u521B\u5EFA\u667A\u80FD\u673A\u5668\u4EBA"), + h7("p", null, "\u626B\u7801\u7531\u817E\u8BAF\u5B98\u65B9\u9875\u9762\u5B8C\u6210\uFF0C\u4E0D\u9700\u8981\u624B\u52A8\u586B\u5199 Bot ID \u6216 Secret\u3002\u521B\u5EFA\u6210\u529F\u540E\uFF0C\u673A\u5668\u4EBA\u4F1A\u81EA\u52A8\u8FDE\u63A5 DeepSeek Harness\u3002"), + h7( + "div", + { className: "ddt-actions dim-viewActions" }, + h7( + Button9, + { kind: "primary", onClick: onStart, disabled: busy }, + busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801" + ) + ) + ), + h7( + "div", + { className: "ddt-brandMark dim-emptyBrand dwecom-brand", "aria-hidden": "true" }, + h7(WecomLogoGlyph, { size: 64 }) + ) + ) + ); +} +function QrPanel3({ provision, now, busy, onRefresh, onCancel }) { + const source = safeQrSource4(provision.qrCodeDataUrl); + const remaining = Math.max(0, provision.expiresAt - now); + const duration = Math.max(1, provision.durationMs ?? 5 * 6e4); + const progress = Math.round(Math.min(1, remaining / duration) * 100); + const refreshing = provision.status === "refreshing"; + return h7( + "div", + { className: "ddt-card dim-surfaceCard" }, + h7( + "div", + { className: "ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout" }, + h7( + "div", + { className: "ddt-qrColumn dim-qrColumn" }, + h7( + "div", + { className: "ddt-qrFrame dim-qrFrame" }, + source ? h7("img", { src: source, alt: "\u7528\u4E8E\u7ED1\u5B9A\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u7684\u4E00\u6B21\u6027\u4E8C\u7EF4\u7801" }) : h7( + "div", + { className: "ddt-qrFallback dim-qrFallback" }, + refreshing ? "\u4E8C\u7EF4\u7801\u6B63\u5728\u81EA\u52A8\u5237\u65B0\u2026" : "\u4E8C\u7EF4\u7801\u56FE\u7247\u6B63\u5728\u751F\u6210\u2026" + ) + ), + h7( + "div", + { className: "ddt-countdown dim-countdown" }, + h7( + "div", + { className: "ddt-countdownTop dim-countdownTop" }, + h7("span", null, "\u5F53\u524D\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), + h7("strong", null, refreshing ? "--:--" : formatRemaining4(remaining)) + ), + h7("div", { className: "ddt-progress dim-progress", style: { "--ddt-progress": `${progress}%` } }, h7("span")) + ) + ), + h7( + "div", + { className: "ddt-qrCopy dim-qrCopy" }, + h7( + "div", + { className: "ddt-stateLabel dim-stateLabel" }, + h7("span", { className: "ddt-dot dim-stateDot", "data-tone": "warning" }), + h7("span", null, refreshing ? "\u6B63\u5728\u5237\u65B0\u4E8C\u7EF4\u7801" : "\u7B49\u5F85\u4F01\u4E1A\u5FAE\u4FE1 App \u626B\u7801") + ), + h7("h3", null, "\u4F7F\u7528\u4F01\u4E1A\u5FAE\u4FE1 App \u5B8C\u6210\u667A\u80FD\u673A\u5668\u4EBA\u6388\u6743"), + h7("p", null, "\u4F01\u4E1A\u5FAE\u4FE1\u5B98\u65B9\u9875\u9762\u4F1A\u521B\u5EFA\u4E00\u4E2A\u667A\u80FD\u673A\u5668\u4EBA\uFF0C\u5E76\u628A\u8FDE\u63A5\u51ED\u636E\u5B89\u5168\u4EA4\u7ED9\u672C\u673A Harness Host\u3002"), + h7( + "ol", + { className: "ddt-steps dim-steps" }, + h7("li", null, "\u6253\u5F00\u4F01\u4E1A\u5FAE\u4FE1 App\uFF0C\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), + h7("li", null, "\u5728\u817E\u8BAF\u6388\u6743\u9875\u9762\u786E\u8BA4\u521B\u5EFA\u667A\u80FD\u673A\u5668\u4EBA"), + h7("li", null, "\u8FD4\u56DE\u8FD9\u91CC\u7B49\u5F85\u8FDE\u63A5\u5B8C\u6210") + ), + h7( + "div", + { className: "ddt-actions dim-viewActions" }, + h7(Button9, { onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), + h7(Button9, { kind: "quiet", onClick: onCancel, disabled: busy }, "\u53D6\u6D88") + ) + ) + ) + ); +} +function ProvisionView2({ provision, busy, onRetry, onClose }) { + if (provision.status === "connecting") { + return h7( + "div", + { className: "ddt-card ddt-loading dim-surfaceCard dim-specialView", "aria-busy": "true" }, + h7("div", { className: "ddt-spinner dim-spinner" }), + h7("h3", null, "\u4F01\u4E1A\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u6B63\u5728\u8FDE\u63A5\u673A\u5668\u4EBA"), + h7("p", null, "\u51ED\u636E\u6B63\u5728\u5199\u5165\u672C\u673A\uFF0C\u5E76\u542F\u52A8\u4F01\u4E1A\u5FAE\u4FE1 WebSocket \u6D88\u606F\u8FDE\u63A5\u3002") + ); + } + const error = provision.error ?? { code: "WECOM_PROVISION_FAILED", message: "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210" }; + return h7( + "div", + { className: "ddt-card dim-surfaceCard" }, + h7( + "div", + { className: "ddt-inlineError dim-inlineError", role: "alert" }, + h7("h3", null, "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), + h7("p", null, error.message), + h7("span", { className: "ddt-errorCode" }, error.code), + h7( + "div", + { className: "ddt-actions dim-viewActions" }, + h7(Button9, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), + h7(Button9, { onClick: onClose, disabled: busy }, "\u5173\u95ED") + ) + ) + ); +} +function RemoveConfirmation4({ account, busy, onConfirm, onCancel }) { + return h7( + "div", + { className: "ddt-confirm dim-confirm", role: "alertdialog" }, + h7("strong", null, `\u4ECE DeepSeek Harness \u79FB\u9664\u201C${account.bot.name}\u201D\uFF1F`), + h7("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684\u5E94\u7528\u51ED\u636E\u3001\u673A\u5668\u4EBA\u914D\u7F6E\u53CA\u4F1A\u8BDD\u6620\u5C04\u3002\u4F01\u4E1A\u5FAE\u4FE1\u5E73\u53F0\u4E2D\u7684\u673A\u5668\u4EBA\u4E0D\u4F1A\u88AB\u81EA\u52A8\u5220\u9664\u3002"), + h7( + "div", + { className: "ddt-actions dim-viewActions" }, + h7(Button9, { onClick: onCancel, disabled: busy }, "\u4FDD\u7559\u673A\u5668\u4EBA"), + h7(Button9, { kind: "danger", onClick: onConfirm, disabled: busy }, busy ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664\u63A5\u5165") + ) + ); +} +function AccountCard3({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { + const tone = account.connected ? "success" : account.state === "error" ? "error" : "warning"; + const stateLabel = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : account.state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA"; + const summary = account.error?.message ?? (account.connected ? null : account.health.summary); + return h7( + "article", + { className: "ddt-card dim-botCard", "data-bot-id": account.botId }, + h7( + "div", + { className: "ddt-cardBody dim-botCardBody" }, + h7( + "div", + { className: "ddt-accountTop dim-botCardTop" }, + h7( + "div", + { className: "ddt-accountIdentity dim-botIdentity" }, + h7("div", { className: "ddt-avatar dim-botAvatar dwecom-avatar", "aria-hidden": "true" }, h7(WecomLogoGlyph, { size: 29 })), + h7( + "div", + { className: "dim-botName" }, + h7("h3", null, account.bot.name), + h7("p", null, account.bot.appIdMasked) + ) + ), + h7( + "div", + { className: "ddt-health dim-botHealth" }, + h7("span", { className: "ddt-dot dim-healthDot", "data-tone": tone }), + h7("span", null, stateLabel) + ) + ), + h7( + "dl", + { className: "ddt-metrics dim-botMetrics" }, + h7("div", { className: "ddt-metric dim-botMetric" }, h7("dt", null, "\u6D88\u606F\u901A\u9053"), h7("dd", null, account.connected ? "WebSocket \u957F\u8FDE\u63A5" : "\u79BB\u7EBF")), + h7("div", { className: "ddt-metric dim-botMetric" }, h7("dt", null, "\u6700\u8FD1\u68C0\u67E5"), h7("dd", null, checkedTime4(account.health.lastCheckedAt))) + ), + h7( + "div", + { className: "ddt-accountFooter dim-cardFooter" }, + summary ? h7("div", { className: "ddt-summary dim-cardSummary" }, summary) : null, + h7( + "div", + { className: "ddt-actions dim-cardActions" }, + h7(Button9, { className: "dim-cardAction", onClick: onReconnect, disabled: Boolean(busy) }, busy === "reconnect" ? "\u68C0\u67E5\u4E2D\u2026" : account.connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5"), + h7(Button9, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") + ) + ) + ), + removing ? h7(RemoveConfirmation4, { + account, + busy: busy === "delete", + onConfirm: onConfirmRemove, + onCancel: onCancelRemove + }) : null + ); +} +function WecomSettingsTab({ rpcCall }) { + const [model, setModel] = React8.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); + const [provision, setProvision] = React8.useState(null); + const [busy, setBusy] = React8.useState(false); + const [busyByBot, setBusyByBot] = React8.useState({}); + const [removeTarget, setRemoveTarget] = React8.useState(null); + const [credentialOpen, setCredentialOpen] = React8.useState(false); + const [credentialError, setCredentialError] = React8.useState(null); + const [now, setNow] = React8.useState(Date.now()); + const mounted = React8.useRef(true); + const addButtonRef = React8.useRef(null); + React8.useEffect(() => { + const disposeDingtalk = installDingtalkStyles(); + const disposeWecom = installWecomStyles(); + mounted.current = true; + return () => { + mounted.current = false; + disposeWecom(); + disposeDingtalk(); + }; + }, []); + const invoke = React8.useCallback(async (endpoint, payload = {}, signal) => { + if (typeof rpcCall !== "function") throw new TypeError("\u4F01\u4E1A\u5FAE\u4FE1\u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5"); + return unwrapRpcResult6(await rpcCall(endpoint, payload, signal)); + }, [rpcCall]); + const loadStatus = React8.useCallback(async ({ signal, silent = false, restore = false } = {}) => { + if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null })); + try { + const snapshot = normalizeSnapshot5(await invoke(WECOM_ENDPOINTS.status, {}, signal)); + if (!mounted.current || signal?.aborted) return void 0; + setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); + if (restore && snapshot.provisioning) setProvision({ + ...snapshot.provisioning, + durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()) + }); + return snapshot; + } catch (error) { + if (error?.name !== "AbortError" && mounted.current && !signal?.aborted) { + setModel((current) => ({ ...current, phase: silent ? current.phase : "error", error: presentError6(error) })); + } + return void 0; + } + }, [invoke]); + React8.useEffect(() => { + const controller = new AbortController(); + void loadStatus({ signal: controller.signal, restore: true }); + return () => controller.abort(); + }, [loadStatus]); + React8.useEffect(() => { + if (model.phase !== "ready") return void 0; + const controller = new AbortController(); + const timer = window.setInterval(() => void loadStatus({ signal: controller.signal, silent: true }), 15e3); + return () => { + controller.abort(); + window.clearInterval(timer); + }; + }, [loadStatus, model.phase]); + React8.useEffect(() => { + if (!provision || !ACTIVE_STATES2.has(provision.status)) return void 0; + const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1e3); + return () => window.clearInterval(timer); + }, [provision?.attemptId, provision?.status]); + const startProvisioning = React8.useCallback(async (replace = false) => { + setCredentialOpen(false); + setCredentialError(null); + setBusy(true); + try { + if (replace && provision?.attemptId) await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); + if (!mounted.current) return; + setProvision({ status: "starting" }); + const started = normalizeProvisioning4(await invoke(WECOM_ENDPOINTS.beginProvisioning, { locale: "zh-CN" })); + if (!mounted.current) return; + setNow(Date.now()); + setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) }); + } catch (error) { + if (mounted.current) setProvision({ status: "failed", error: presentError6(error) }); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke, provision?.attemptId]); + const bindCredentials = React8.useCallback(async ({ identity, secret }) => { + setBusy(true); + setCredentialError(null); + try { + const snapshot = normalizeSnapshot5(await invoke( + WECOM_ENDPOINTS.bindCredentials, + { botId: identity, secret } + )); + if (!mounted.current) return; + setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); + setCredentialOpen(false); + } catch (error) { + if (mounted.current) setCredentialError(presentError6(error)); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke]); + const closeProvision = React8.useCallback(async () => { + setBusy(true); + try { + if (provision?.attemptId && ACTIVE_STATES2.has(provision.status)) { + await invoke(WECOM_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); + } + if (mounted.current) setProvision(null); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke, provision?.attemptId, provision?.status]); + React8.useEffect(() => { + const attemptId = provision?.attemptId; + if (!attemptId || !ACTIVE_STATES2.has(provision.status)) return void 0; + const controller = new AbortController(); + let disposed = false; + let timer; + const poll = async () => { + try { + const current = normalizeProvisioning4(await invoke(WECOM_ENDPOINTS.pollProvisioning, { attemptId }, controller.signal)); + if (disposed || controller.signal.aborted || !mounted.current) return; + if (current.status === "connected") { + setProvision(null); + await loadStatus({ signal: controller.signal, silent: true }); + return; + } + setProvision((previous) => previous?.attemptId === attemptId ? { ...previous, ...current, durationMs: current.qrRevision !== previous.qrRevision ? Math.max(1, current.expiresAt - Date.now()) : previous.durationMs } : previous); + if (ACTIVE_STATES2.has(current.status)) timer = window.setTimeout(poll, current.pollIntervalMs); + } catch (error) { + if (!disposed && !controller.signal.aborted && mounted.current) { + setProvision((current) => ({ ...current, status: "failed", error: presentError6(error) })); + } + } + }; + timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1e3); + return () => { + disposed = true; + controller.abort(); + window.clearTimeout(timer); + }; + }, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]); + const botAction = React8.useCallback(async (account, operation, endpoint, payload) => { + setBusyByBot((current) => ({ ...current, [account.botId]: operation })); + try { + const snapshot = normalizeSnapshot5(await invoke(endpoint, payload)); + if (mounted.current) setModel({ phase: "ready", bots: snapshot.bots, totals: snapshot.totals, error: null }); + return snapshot; + } finally { + if (mounted.current) setBusyByBot((current) => { + const next = { ...current }; + delete next[account.botId]; + return next; + }); + } + }, [invoke]); + let provisionView = null; + if (provision?.status === "starting") provisionView = h7("div", { className: "ddt-card ddt-loading dim-surfaceCard" }, h7("div", { className: "ddt-spinner" }), "\u6B63\u5728\u7533\u8BF7\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\u2026"); + else if (["pending", "refreshing"].includes(provision?.status)) provisionView = h7(QrPanel3, { + provision, + now, + busy, + onRefresh: () => void startProvisioning(true), + onCancel: () => void closeProvision() + }); + else if (provision) provisionView = h7(ProvisionView2, { + provision, + busy, + onRetry: () => void startProvisioning(true), + onClose: () => void closeProvision() + }); + const botList = model.bots.length > 0 ? h7( + "section", + { className: "dim-listSection" }, + h7( + "div", + { className: "ddt-listHeading dim-listHeading" }, + h7("h3", null, "\u5DF2\u7ED1\u5B9A\u7684\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA") + ), + h7("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h7("li", { key: account.botId }, h7(AccountCard3, { account, busy: busyByBot[account.botId], removing: removeTarget === account.botId, @@ -4355,7 +4907,7 @@ function WecomSettingsTab({ rpcCall }) { } })))) ) : null; - const credentialView = credentialOpen ? h6(CredentialBindingPanel, { + const credentialView = credentialOpen ? h7(CredentialBindingPanel, { channel: "\u4F01\u4E1A\u5FAE\u4FE1", identityLabel: "Bot ID", identityPlaceholder: "\u586B\u5199\u4F01\u4E1A\u5FAE\u4FE1\u667A\u80FD\u673A\u5668\u4EBA Bot ID", @@ -4369,10 +4921,10 @@ function WecomSettingsTab({ rpcCall }) { setCredentialError(null); } }) : null; - return h6( + return h7( "section", { className: "ddt-page dwecom-page dim-channelPage", "aria-label": "\u4F01\u4E1A\u5FAE\u4FE1\u8BBE\u7F6E" }, - h6(Heading4, { + h7(Heading4, { totals: model.totals, adding: Boolean(provision), busy, @@ -4384,19 +4936,19 @@ function WecomSettingsTab({ rpcCall }) { credentialOpen, addButtonRef }), - model.phase === "loading" ? h6(LoadingView4) : model.phase === "error" ? h6("div", { className: "ddt-card dim-surfaceCard" }, h6("div", { className: "ddt-inlineError dim-inlineError" }, h6("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u72B6\u6001"), h6("p", null, model.error?.message), h6(Button7, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6"))) : h6( - React7.Fragment, + model.phase === "loading" ? h7(LoadingView4) : model.phase === "error" ? h7("div", { className: "ddt-card dim-surfaceCard" }, h7("div", { className: "ddt-inlineError dim-inlineError" }, h7("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u72B6\u6001"), h7("p", null, model.error?.message), h7(Button9, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6"))) : h7( + React8.Fragment, null, credentialView, provisionView, - model.bots.length === 0 && !provision && !credentialOpen ? h6(EmptyView4, { busy, onStart: () => void startProvisioning() }) : null, + model.bots.length === 0 && !provision && !credentialOpen ? h7(EmptyView4, { busy, onStart: () => void startProvisioning() }) : null, botList ) ); } // plugin-src/client/channels/weixin/index.js -var React8 = __toESM(require("react"), 1); +var React9 = __toESM(require("react"), 1); // plugin-src/client/channels/weixin/api.js var WEIXIN_RPC_CHANNEL = "/weixin"; @@ -4409,7 +4961,7 @@ var WEIXIN_ENDPOINTS = Object.freeze({ reconnectBot: "bot.reconnect", deleteBot: "bot.delete" }); -var ACCOUNT_STATES4 = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]); +var ACCOUNT_STATES5 = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]); var PROVISION_STATES4 = /* @__PURE__ */ new Set([ "starting", "pending", @@ -4421,17 +4973,17 @@ var PROVISION_STATES4 = /* @__PURE__ */ new Set([ "failed", "cancelled" ]); -function isRecord5(value) { +function isRecord6(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function string(value, fallback = "") { return typeof value === "string" && value.trim() ? value.trim() : fallback; } -function timestamp4(value) { +function timestamp5(value) { return typeof value === "number" && Number.isFinite(value) ? value : null; } -function unwrapRpcResult5(result) { - if (!isRecord5(result) || typeof result.ok !== "boolean") { +function unwrapRpcResult7(result) { + if (!isRecord6(result) || typeof result.ok !== "boolean") { throw new Error("\u5FAE\u4FE1\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94"); } if (!result.ok) { @@ -4455,14 +5007,14 @@ function safeVerificationUrl(value) { } } function normalizeProvisioning5(value) { - if (!isRecord5(value) || !string(value.attemptId)) { + if (!isRecord6(value) || !string(value.attemptId)) { throw new Error("\u5FAE\u4FE1\u626B\u7801\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u7ED1\u5B9A\u4EFB\u52A1"); } const status = PROVISION_STATES4.has(value.status) ? value.status : "failed"; const result = { attemptId: string(value.attemptId), status, - expiresAt: timestamp4(value.expiresAt) ?? Date.now(), + expiresAt: timestamp5(value.expiresAt) ?? Date.now(), pollIntervalMs: Math.min(5e3, Math.max(500, Number(value.pollIntervalMs) || 1e3)), verificationRequired: value.verificationRequired === true || status === "needs_verification" }; @@ -4472,7 +5024,7 @@ function normalizeProvisioning5(value) { if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl; if (string(value.botId)) result.botId = string(value.botId); if (value.alreadyConnected === true) result.alreadyConnected = true; - if (isRecord5(value.error)) { + if (isRecord6(value.error)) { result.error = { code: string(value.error.code, "WEIXIN_PROVISION_FAILED"), message: string(value.error.message, "\u5FAE\u4FE1\u7ED1\u5B9A\u6CA1\u6709\u5B8C\u6210") @@ -4481,8 +5033,8 @@ function normalizeProvisioning5(value) { return result; } function normalizeBot5(value) { - if (!isRecord5(value) || !string(value.botId) || !isRecord5(value.bot)) return null; - const state = ACCOUNT_STATES4.has(value.state) ? value.state : "error"; + if (!isRecord6(value) || !string(value.botId) || !isRecord6(value.bot)) return null; + const state = ACCOUNT_STATES5.has(value.state) ? value.state : "error"; const connected = value.connected === true; return { botId: string(value.botId), @@ -4496,20 +5048,20 @@ function normalizeBot5(value) { health: { status: string(value.health?.status, connected ? "healthy" : "offline"), summary: string(value.health?.summary, connected ? "\u5FAE\u4FE1\u8FDE\u63A5\u6B63\u5E38" : "\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA"), - lastCheckedAt: timestamp4(value.health?.lastCheckedAt) + lastCheckedAt: timestamp5(value.health?.lastCheckedAt) }, stats: { messagesReceived: Math.max(0, Number(value.stats?.messagesReceived) || 0), messagesReplied: Math.max(0, Number(value.stats?.messagesReplied) || 0) }, - error: isRecord5(value.error) ? { + error: isRecord6(value.error) ? { code: string(value.error.code, "WEIXIN_ACCOUNT_ERROR"), message: string(value.error.message, "\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA") } : null }; } -function normalizeSnapshot4(value) { - if (!isRecord5(value) || !Array.isArray(value.bots)) { +function normalizeSnapshot6(value) { + if (!isRecord6(value) || !Array.isArray(value.bots)) { throw new Error("\u5FAE\u4FE1\u670D\u52A1\u6CA1\u6709\u8FD4\u56DE\u6709\u6548\u7684\u8D26\u53F7\u5217\u8868"); } const bots = value.bots.map(normalizeBot5).filter(Boolean); @@ -4525,7 +5077,7 @@ function normalizeSnapshot4(value) { provisioning: value.provisioning ? normalizeProvisioning5(value.provisioning) : null }; } -function presentError5(error) { +function presentError7(error) { return { code: string(error?.code, "WEIXIN_ERROR"), message: string(error?.message, "\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5") @@ -4538,7 +5090,7 @@ function formatRemaining5(milliseconds) { // plugin-src/client/channels/weixin/styles.js var WEIXIN_STYLE_ID = "xmanrui-dsh-weixin-settings"; -var CSS5 = String.raw` +var CSS7 = String.raw` .dxw-page { --dxw-accent: #07c160; --dxw-accent-dark: #05994c; @@ -4653,15 +5205,15 @@ function installWeixinStyles() { const style = document.createElement("style"); style.dataset.plugin = "@xmanrui/dsh-weixin"; style.dataset.pluginCss = WEIXIN_STYLE_ID; - style.textContent = CSS5; + style.textContent = CSS7; document.head.appendChild(style); return () => style.remove(); } // plugin-src/client/channels/weixin/index.js -var h7 = React8.createElement; -var Button9 = React8.forwardRef(function Button10({ children, kind = "secondary", className = "", ...props }, ref) { - return h7("button", { +var h8 = React9.createElement; +var Button11 = React9.forwardRef(function Button12({ children, kind = "secondary", className = "", ...props }, ref) { + return h8("button", { ...props, ref, type: "button", @@ -4670,150 +5222,150 @@ var Button9 = React8.forwardRef(function Button10({ children, kind = "secondary" }, children); }); function Heading5({ totals, adding, busy, onAdd, addButtonRef }) { - return h7( + return h8( "div", { className: "dxw-heading" }, - h7( + h8( "div", { className: "dxw-tools" }, - h7(Button9, { + h8(Button11, { kind: "primary", className: "dim-scanButton", onClick: onAdd, disabled: adding || busy, ref: addButtonRef, "aria-label": "\u626B\u7801\u63A5\u5165\u5FAE\u4FE1\u673A\u5668\u4EBA" - }, h7(QrActionIcon), adding ? "\u6B63\u5728\u63A5\u5165" : "\u626B\u7801\u63A5\u5165\u673A\u5668\u4EBA"), - totals.configured > 0 ? h7( + }, h8(QrActionIcon), adding ? "\u6B63\u5728\u63A5\u5165" : "\u626B\u7801\u63A5\u5165\u673A\u5668\u4EBA"), + totals.configured > 0 ? h8( "div", { className: "dxw-badge dim-onlineBadge" }, - h7("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) + h8("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) ) : null ) ); } function LoadingView5() { - return h7( + return h8( "div", { className: "dxw-card dxw-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h7("div", { className: "dxw-spinner dim-spinner" }), - h7("span", null, "\u6B63\u5728\u8BFB\u53D6\u5FAE\u4FE1\u8FDE\u63A5\u72B6\u6001\u2026") + h8("div", { className: "dxw-spinner dim-spinner" }), + h8("span", null, "\u6B63\u5728\u8BFB\u53D6\u5FAE\u4FE1\u8FDE\u63A5\u72B6\u6001\u2026") ); } function EmptyView5({ onStart, busy }) { - return h7( + return h8( "div", { className: "dxw-card dim-surfaceCard" }, - h7( + h8( "div", { className: "dxw-cardBody dxw-empty dim-surfaceBody dim-emptyView" }, - h7( + h8( "div", { className: "dim-emptyCopy" }, - h7( + h8( "div", { className: "dxw-stateLabel dim-stateLabel" }, - h7("span", { className: "dxw-dot dim-stateDot" }), - h7("span", null, "\u5C1A\u672A\u7ED1\u5B9A\u5FAE\u4FE1") + h8("span", { className: "dxw-dot dim-stateDot" }), + h8("span", null, "\u5C1A\u672A\u7ED1\u5B9A\u5FAE\u4FE1") ), - h7("h3", null, "\u626B\u4E00\u6B21\u7801\uFF0C\u5C31\u80FD\u5728\u5FAE\u4FE1\u91CC\u4F7F\u7528 Harness"), - h7("p", null, "\u4E8C\u7EF4\u7801\u7531\u817E\u8BAF\u5FAE\u4FE1 iLink \u670D\u52A1\u7B7E\u53D1\u3002\u7528\u624B\u673A\u5FAE\u4FE1\u626B\u63CF\u5E76\u786E\u8BA4\u540E\uFF0C\u8D26\u53F7\u51ED\u636E\u4F1A\u76F4\u63A5\u5199\u5165 Harness Host\uFF0C\u6D4F\u89C8\u5668\u4E0D\u4F1A\u6536\u5230 bot_token\u3002"), - h7( + h8("h3", null, "\u626B\u4E00\u6B21\u7801\uFF0C\u5C31\u80FD\u5728\u5FAE\u4FE1\u91CC\u4F7F\u7528 Harness"), + h8("p", null, "\u4E8C\u7EF4\u7801\u7531\u817E\u8BAF\u5FAE\u4FE1 iLink \u670D\u52A1\u7B7E\u53D1\u3002\u7528\u624B\u673A\u5FAE\u4FE1\u626B\u63CF\u5E76\u786E\u8BA4\u540E\uFF0C\u8D26\u53F7\u51ED\u636E\u4F1A\u76F4\u63A5\u5199\u5165 Harness Host\uFF0C\u6D4F\u89C8\u5668\u4E0D\u4F1A\u6536\u5230 bot_token\u3002"), + h8( "div", { className: "dxw-actions dim-viewActions" }, - h7( - Button9, + h8( + Button11, { kind: "primary", onClick: onStart, disabled: busy }, busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210\u5FAE\u4FE1\u4E8C\u7EF4\u7801" ) ) ), - h7("div", { className: "dxw-logo dim-emptyBrand", "aria-hidden": "true" }, h7(WeixinLogoGlyph, { size: 64 })) + h8("div", { className: "dxw-logo dim-emptyBrand", "aria-hidden": "true" }, h8(WeixinLogoGlyph, { size: 64 })) ) ); } function QrPanel4({ provision, now, busy, onRefresh, onCancel }) { - const [imageFailed, setImageFailed] = React8.useState(false); + const [imageFailed, setImageFailed] = React9.useState(false); const source = safeQrSource5(provision.qrCodeDataUrl); const href = safeVerificationUrl(provision.verificationUrl); const remaining = Math.max(0, provision.expiresAt - now); const expired = remaining === 0 || provision.status === "expired"; const duration = Math.max(1, provision.durationMs ?? 5 * 6e4); const progress = Math.round(Math.min(1, remaining / duration) * 100); - React8.useEffect(() => setImageFailed(false), [source]); - return h7( + React9.useEffect(() => setImageFailed(false), [source]); + return h8( "div", { className: "dxw-card dim-surfaceCard" }, - h7( + h8( "div", { className: "dxw-cardBody dxw-qrLayout dim-surfaceBody dim-qrLayout" }, - h7( + h8( "div", { className: "dxw-qrColumn dim-qrColumn" }, - h7( + h8( "div", { className: "dxw-qrFrame dim-qrFrame" }, - source && !imageFailed ? h7("img", { + source && !imageFailed ? h8("img", { src: source, alt: "\u7528\u4E8E\u628A\u5FAE\u4FE1\u673A\u5668\u4EBA\u7ED1\u5B9A\u5230 DeepSeek Harness \u7684\u4E00\u6B21\u6027\u4E8C\u7EF4\u7801", onError: () => setImageFailed(true) - }) : h7("div", { className: "dxw-qrFallback dim-qrFallback" }, "\u4E8C\u7EF4\u7801\u56FE\u7247\u672A\u5C31\u7EEA\uFF0C\u8BF7\u4F7F\u7528\u5907\u7528\u94FE\u63A5\u3002"), - expired ? h7("div", { className: "dxw-expired dim-qrExpired" }, "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\n\u8BF7\u91CD\u65B0\u751F\u6210") : null + }) : h8("div", { className: "dxw-qrFallback dim-qrFallback" }, "\u4E8C\u7EF4\u7801\u56FE\u7247\u672A\u5C31\u7EEA\uFF0C\u8BF7\u4F7F\u7528\u5907\u7528\u94FE\u63A5\u3002"), + expired ? h8("div", { className: "dxw-expired dim-qrExpired" }, "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\n\u8BF7\u91CD\u65B0\u751F\u6210") : null ), - h7( + h8( "div", { className: "dxw-countdown dim-countdown" }, - h7("div", { className: "dim-countdownTop" }, h7("span", null, "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), h7("strong", null, formatRemaining5(remaining))), - h7( + h8("div", { className: "dim-countdownTop" }, h8("span", null, "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), h8("strong", null, formatRemaining5(remaining))), + h8( "div", { className: "dxw-progress dim-progress", "aria-hidden": "true" }, - h7("span", { style: { "--dxw-progress": `${progress}%` } }) + h8("span", { style: { "--dxw-progress": `${progress}%` } }) ) ) ), - h7( + h8( "div", { className: "dxw-qrCopy dim-qrCopy" }, - h7( + h8( "div", { className: "dxw-stateLabel dim-stateLabel" }, - h7("span", { className: "dxw-dot dim-stateDot", "data-tone": provision.status === "scanned" ? "success" : "warning" }), - h7("span", null, provision.status === "scanned" ? "\u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u4E0A\u786E\u8BA4" : "\u7B49\u5F85\u5FAE\u4FE1\u626B\u7801") + h8("span", { className: "dxw-dot dim-stateDot", "data-tone": provision.status === "scanned" ? "success" : "warning" }), + h8("span", null, provision.status === "scanned" ? "\u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u4E0A\u786E\u8BA4" : "\u7B49\u5F85\u5FAE\u4FE1\u626B\u7801") ), - h7("h3", null, expired ? "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548" : "\u4F7F\u7528\u624B\u673A\u5FAE\u4FE1\u626B\u63CF\u4E8C\u7EF4\u7801"), - h7("p", null, "\u8BF7\u5728\u624B\u673A\u4E0A\u6838\u5BF9\u5E76\u786E\u8BA4\u6388\u6743\u3002\u90E8\u5206\u8D26\u53F7\u4F1A\u989D\u5916\u663E\u793A\u4E00\u4E2A\u914D\u5BF9\u6570\u5B57\uFF0C\u9875\u9762\u4F1A\u5728\u9700\u8981\u65F6\u63D0\u793A\u8F93\u5165\u3002"), - h7( + h8("h3", null, expired ? "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548" : "\u4F7F\u7528\u624B\u673A\u5FAE\u4FE1\u626B\u63CF\u4E8C\u7EF4\u7801"), + h8("p", null, "\u8BF7\u5728\u624B\u673A\u4E0A\u6838\u5BF9\u5E76\u786E\u8BA4\u6388\u6743\u3002\u90E8\u5206\u8D26\u53F7\u4F1A\u989D\u5916\u663E\u793A\u4E00\u4E2A\u914D\u5BF9\u6570\u5B57\uFF0C\u9875\u9762\u4F1A\u5728\u9700\u8981\u65F6\u63D0\u793A\u8F93\u5165\u3002"), + h8( "ol", { className: "dxw-steps dim-steps" }, - h7("li", null, "\u6253\u5F00\u624B\u673A\u5FAE\u4FE1\u5E76\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), - h7("li", null, "\u5728\u5FAE\u4FE1\u4E2D\u786E\u8BA4\u8FDE\u63A5\u8BE5\u673A\u5668\u4EBA"), - h7("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u6D88\u606F\u957F\u8F6E\u8BE2\u53D8\u4E3A\u5728\u7EBF") + h8("li", null, "\u6253\u5F00\u624B\u673A\u5FAE\u4FE1\u5E76\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), + h8("li", null, "\u5728\u5FAE\u4FE1\u4E2D\u786E\u8BA4\u8FDE\u63A5\u8BE5\u673A\u5668\u4EBA"), + h8("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u6D88\u606F\u957F\u8F6E\u8BE2\u53D8\u4E3A\u5728\u7EBF") ), - h7( + h8( "div", { className: "dxw-actions dim-viewActions" }, - expired ? h7(Button9, { kind: "primary", onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801") : null, - href ? h7("a", { + expired ? h8(Button11, { kind: "primary", onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801") : null, + href ? h8("a", { className: "dxw-button", href, target: "_blank", rel: "noopener noreferrer" }, "\u6253\u5F00\u5907\u7528\u94FE\u63A5") : null, - !expired ? h7(Button9, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, - h7(Button9, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") + !expired ? h8(Button11, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, + h8(Button11, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") ) ) ) ); } function VerificationPanel({ provision, busy, onSubmit, onCancel }) { - const [code, setCode] = React8.useState(""); + const [code, setCode] = React9.useState(""); const valid = /^\d{4,8}$/.test(code); - React8.useEffect(() => setCode(""), [provision.attemptId]); - return h7( + React9.useEffect(() => setCode(""), [provision.attemptId]); + return h8( "div", { className: "dxw-card dim-surfaceCard" }, - h7( + h8( "form", { className: "dxw-verify dim-specialView", @@ -4822,18 +5374,18 @@ function VerificationPanel({ provision, busy, onSubmit, onCancel }) { if (valid && !busy) onSubmit(code); } }, - h7( + h8( "div", { className: "dxw-stateLabel" }, - h7("span", { className: "dxw-dot", "data-tone": "warning" }), - h7("span", null, "\u9700\u8981\u914D\u5BF9\u7801") + h8("span", { className: "dxw-dot", "data-tone": "warning" }), + h8("span", null, "\u9700\u8981\u914D\u5BF9\u7801") ), - h7("h3", null, "\u8F93\u5165\u624B\u673A\u5FAE\u4FE1\u663E\u793A\u7684\u6570\u5B57"), - h7("p", null, "\u8FD9\u662F\u5FAE\u4FE1\u9644\u52A0\u7684\u5B89\u5168\u786E\u8BA4\u6B65\u9AA4\u3002\u914D\u5BF9\u7801\u53EA\u7528\u4E8E\u672C\u6B21\u626B\u7801\u8F6E\u8BE2\uFF0C\u4E0D\u4F1A\u5199\u5165\u914D\u7F6E\u6216\u65E5\u5FD7\u3002"), - h7( + h8("h3", null, "\u8F93\u5165\u624B\u673A\u5FAE\u4FE1\u663E\u793A\u7684\u6570\u5B57"), + h8("p", null, "\u8FD9\u662F\u5FAE\u4FE1\u9644\u52A0\u7684\u5B89\u5168\u786E\u8BA4\u6B65\u9AA4\u3002\u914D\u5BF9\u7801\u53EA\u7528\u4E8E\u672C\u6B21\u626B\u7801\u8F6E\u8BE2\uFF0C\u4E0D\u4F1A\u5199\u5165\u914D\u7F6E\u6216\u65E5\u5FD7\u3002"), + h8( "div", { className: "dxw-codeRow" }, - h7("input", { + h8("input", { className: "dxw-input", value: code, inputMode: "numeric", @@ -4843,59 +5395,59 @@ function VerificationPanel({ provision, busy, onSubmit, onCancel }) { onChange: (event) => setCode(event.target.value.replace(/\D/g, "").slice(0, 8)), autoFocus: true }), - h7("button", { + h8("button", { type: "submit", className: "dxw-button", "data-kind": "primary", disabled: !valid || busy }, busy ? "\u6B63\u5728\u9A8C\u8BC1\u2026" : "\u7EE7\u7EED\u8FDE\u63A5") ), - h7(Button9, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u7ED1\u5B9A") + h8(Button11, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u7ED1\u5B9A") ) ); } function ProgressPanel2({ scanned, onCancel, busy }) { - return h7( + return h8( "div", { className: "dxw-card dxw-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h7("div", { className: "dxw-spinner dim-spinner" }), - h7("h3", null, scanned ? "\u5FAE\u4FE1\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u542F\u52A8\u6D88\u606F\u8FDE\u63A5" : "\u6B63\u5728\u51C6\u5907\u5FAE\u4FE1\u4E8C\u7EF4\u7801"), - h7("p", null, scanned ? "\u6B63\u5728\u4FDD\u5B58\u51ED\u636E\u5E76\u9A8C\u8BC1 Harness \u4E0E\u5FAE\u4FE1\u957F\u8F6E\u8BE2\u3002" : "\u6B63\u5728\u8054\u7CFB\u817E\u8BAF\u5FAE\u4FE1 iLink \u670D\u52A1\u3002"), - onCancel ? h7( + h8("div", { className: "dxw-spinner dim-spinner" }), + h8("h3", null, scanned ? "\u5FAE\u4FE1\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u542F\u52A8\u6D88\u606F\u8FDE\u63A5" : "\u6B63\u5728\u51C6\u5907\u5FAE\u4FE1\u4E8C\u7EF4\u7801"), + h8("p", null, scanned ? "\u6B63\u5728\u4FDD\u5B58\u51ED\u636E\u5E76\u9A8C\u8BC1 Harness \u4E0E\u5FAE\u4FE1\u957F\u8F6E\u8BE2\u3002" : "\u6B63\u5728\u8054\u7CFB\u817E\u8BAF\u5FAE\u4FE1 iLink \u670D\u52A1\u3002"), + onCancel ? h8( "div", { className: "dxw-actions dim-viewActions", style: { justifyContent: "center", marginTop: 14 } }, - h7(Button9, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") + h8(Button11, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") ) : null ); } function ProvisionError3({ provision, busy, onRetry, onClose }) { const error = provision.error ?? { code: "WEIXIN_PROVISION_FAILED", message: "\u5FAE\u4FE1\u7ED1\u5B9A\u6CA1\u6709\u5B8C\u6210" }; - return h7( + return h8( "div", { className: "dxw-card dim-surfaceCard" }, - h7( + h8( "div", { className: "dxw-error dim-inlineError", role: "alert" }, - h7("h3", null, provision.status === "expired" ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F" : "\u5FAE\u4FE1\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), - h7("p", null, error.message), - h7("span", { className: "dxw-errorCode" }, error.code), - h7( + h8("h3", null, provision.status === "expired" ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F" : "\u5FAE\u4FE1\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), + h8("p", null, error.message), + h8("span", { className: "dxw-errorCode" }, error.code), + h8( "div", { className: "dxw-actions dim-viewActions" }, - h7(Button9, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), - h7(Button9, { onClick: onClose, disabled: busy }, "\u5173\u95ED") + h8(Button11, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), + h8(Button11, { onClick: onClose, disabled: busy }, "\u5173\u95ED") ) ) ); } -function checkedTime4(timestamp5) { - if (!timestamp5) return "\u5C1A\u672A\u68C0\u67E5"; +function checkedTime5(timestamp6) { + if (!timestamp6) return "\u5C1A\u672A\u68C0\u67E5"; try { return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" - }).format(new Date(timestamp5)); + }).format(new Date(timestamp6)); } catch { return "\u521A\u521A"; } @@ -4904,71 +5456,71 @@ function AccountCard4({ account, busy, removing, onReconnect, onRequestRemove, o const state = busy === "reconnect" ? "connecting" : account.state; const tone = account.connected ? "success" : state === "error" ? "error" : "warning"; const summary = account.error?.message ?? (account.connected ? null : account.health.summary); - return h7( + return h8( "article", { className: "dxw-card dim-botCard", tabIndex: -1, "data-bot-id": account.botId }, - h7( + h8( "div", { className: "dxw-cardBody dim-botCardBody" }, - h7( + h8( "div", { className: "dxw-accountTop dim-botCardTop" }, - h7( + h8( "div", { className: "dxw-accountIdentity dim-botIdentity" }, - h7("div", { className: "dxw-avatar dim-botAvatar", "aria-hidden": "true" }, h7(WeixinLogoGlyph, { size: 27 })), - h7("div", { className: "dim-botName" }, h7("h3", null, account.bot.name), h7("p", null, account.bot.accountIdMasked)) + h8("div", { className: "dxw-avatar dim-botAvatar", "aria-hidden": "true" }, h8(WeixinLogoGlyph, { size: 27 })), + h8("div", { className: "dim-botName" }, h8("h3", null, account.bot.name), h8("p", null, account.bot.accountIdMasked)) ), - h7( + h8( "div", { className: "dxw-health dim-botHealth" }, - h7("span", { className: "dxw-dot dim-healthDot", "data-tone": tone }), - h7("span", null, account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA") + h8("span", { className: "dxw-dot dim-healthDot", "data-tone": tone }), + h8("span", null, account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA") ) ), - h7( + h8( "dl", { className: "dxw-metrics dim-botMetrics" }, - h7( + h8( "div", { className: "dxw-metric dim-botMetric" }, - h7("dt", null, "\u6D88\u606F\u901A\u9053"), - h7("dd", null, account.connected ? "iLink \u957F\u8F6E\u8BE2" : "\u79BB\u7EBF") + h8("dt", null, "\u6D88\u606F\u901A\u9053"), + h8("dd", null, account.connected ? "iLink \u957F\u8F6E\u8BE2" : "\u79BB\u7EBF") ), - h7( + h8( "div", { className: "dxw-metric dim-botMetric" }, - h7("dt", null, "\u6700\u8FD1\u68C0\u67E5"), - h7("dd", null, checkedTime4(account.health.lastCheckedAt)) + h8("dt", null, "\u6700\u8FD1\u68C0\u67E5"), + h8("dd", null, checkedTime5(account.health.lastCheckedAt)) ) ), - h7( + h8( "div", { className: "dxw-accountFooter dim-cardFooter" }, - summary ? h7("div", { className: "dxw-summary dim-cardSummary" }, summary) : null, - h7( + summary ? h8("div", { className: "dxw-summary dim-cardSummary" }, summary) : null, + h8( "div", { className: "dxw-actions dim-cardActions" }, - h7( - Button9, + h8( + Button11, { className: "dim-cardAction", onClick: onReconnect, disabled: Boolean(busy) }, busy === "reconnect" ? "\u68C0\u67E5\u4E2D\u2026" : account.connected ? "\u68C0\u67E5\u8FDE\u63A5" : "\u91CD\u8BD5\u8FDE\u63A5" ), - h7(Button9, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") + h8(Button11, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") ) ) ), - removing ? h7( + removing ? h8( "div", { className: "dxw-confirm dim-confirm", role: "alertdialog" }, - h7("strong", null, "\u4ECE\u6B64 Harness \u79FB\u9664\u8FD9\u4E2A\u5FAE\u4FE1\u8D26\u53F7\uFF1F"), - h7("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684 bot_token\u3001\u8D26\u53F7\u914D\u7F6E\u548C\u4F1A\u8BDD\u6620\u5C04\u3002\u5176\u4ED6\u5FAE\u4FE1\u8D26\u53F7\u4E0D\u53D7\u5F71\u54CD\u3002"), - h7( + h8("strong", null, "\u4ECE\u6B64 Harness \u79FB\u9664\u8FD9\u4E2A\u5FAE\u4FE1\u8D26\u53F7\uFF1F"), + h8("p", null, "\u8FD9\u4F1A\u505C\u6B62\u6D88\u606F\u8FDE\u63A5\uFF0C\u5E76\u5220\u9664\u672C\u673A\u4FDD\u5B58\u7684 bot_token\u3001\u8D26\u53F7\u914D\u7F6E\u548C\u4F1A\u8BDD\u6620\u5C04\u3002\u5176\u4ED6\u5FAE\u4FE1\u8D26\u53F7\u4E0D\u53D7\u5F71\u54CD\u3002"), + h8( "div", { className: "dxw-actions dim-viewActions" }, - h7(Button9, { onClick: onCancelRemove, disabled: busy === "delete" }, "\u4FDD\u7559\u8D26\u53F7"), - h7( - Button9, + h8(Button11, { onClick: onCancelRemove, disabled: busy === "delete" }, "\u4FDD\u7559\u8D26\u53F7"), + h8( + Button11, { kind: "danger", onClick: onConfirmRemove, disabled: busy === "delete" }, busy === "delete" ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664" ) @@ -4977,14 +5529,14 @@ function AccountCard4({ account, busy, removing, onReconnect, onRequestRemove, o ); } function AccountList2(props) { - return h7( + return h8( "section", { className: "dim-listSection" }, - h7("div", { className: "dxw-listHeading dim-listHeading" }, h7("h3", null, "\u5DF2\u63A5\u5165\u7684\u5FAE\u4FE1\u8D26\u53F7")), - h7("ul", { className: "dxw-list dim-botList" }, props.bots.map((account) => h7( + h8("div", { className: "dxw-listHeading dim-listHeading" }, h8("h3", null, "\u5DF2\u63A5\u5165\u7684\u5FAE\u4FE1\u8D26\u53F7")), + h8("ul", { className: "dxw-list dim-botList" }, props.bots.map((account) => h8( "li", { key: account.botId }, - h7(AccountCard4, { + h8(AccountCard4, { account, busy: props.busyByBot[account.botId], removing: props.removeTarget === account.botId, @@ -5007,38 +5559,38 @@ function mergeWeixinProvisioningSnapshot(current, incoming, { restoreProvisionin }; } function WeixinSettingsTab({ rpcCall }) { - const [model, setModel] = React8.useState({ + const [model, setModel] = React9.useState({ phase: "loading", bots: [], totals: EMPTY_TOTALS3, revision: 0, error: null }); - const [provision, setProvision] = React8.useState(null); - const [busy, setBusy] = React8.useState(false); - const [busyByBot, setBusyByBot] = React8.useState({}); - const [removeTarget, setRemoveTarget] = React8.useState(null); - const [notice, setNotice] = React8.useState(""); - const [now, setNow] = React8.useState(() => Date.now()); - const addButtonRef = React8.useRef(null); + const [provision, setProvision] = React9.useState(null); + const [busy, setBusy] = React9.useState(false); + const [busyByBot, setBusyByBot] = React9.useState({}); + const [removeTarget, setRemoveTarget] = React9.useState(null); + const [notice, setNotice] = React9.useState(""); + const [now, setNow] = React9.useState(() => Date.now()); + const addButtonRef = React9.useRef(null); const scheduleAnimationFrame = useAnimationFrameScheduler(); - const announce = React8.useCallback((value) => { + const announce = React9.useCallback((value) => { setNotice(""); scheduleAnimationFrame(() => { if (value) setNotice(value); }, "announcement"); }, [scheduleAnimationFrame]); - const invoke = React8.useCallback(async (endpoint, payload = {}, signal) => { - return unwrapRpcResult5(await rpcCall(endpoint, payload, signal)); + const invoke = React9.useCallback(async (endpoint, payload = {}, signal) => { + return unwrapRpcResult7(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React8.useCallback(async ({ + const loadStatus = React9.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => { if (!silent) setModel((current) => ({ ...current, phase: "loading", error: null })); try { - const snapshot = normalizeSnapshot4(await invoke(WEIXIN_ENDPOINTS.status, {}, signal)); + const snapshot = normalizeSnapshot6(await invoke(WEIXIN_ENDPOINTS.status, {}, signal)); if (signal?.aborted) return void 0; setModel({ phase: "ready", @@ -5060,17 +5612,17 @@ function WeixinSettingsTab({ rpcCall }) { setModel((current) => ({ ...current, phase: silent && current.phase === "ready" ? "ready" : "error", - error: presentError5(error) + error: presentError7(error) })); return void 0; } }, [invoke]); - React8.useEffect(() => { + React9.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restoreProvisioning: true }); return () => controller.abort(); }, [loadStatus]); - React8.useEffect(() => { + React9.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); let running = false; @@ -5089,12 +5641,12 @@ function WeixinSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React8.useEffect(() => { + React9.useEffect(() => { if (!provision || !["pending", "scanned"].includes(provision.status)) return void 0; const timer = window.setInterval(() => setNow(Date.now()), 1e3); return () => window.clearInterval(timer); }, [provision?.attemptId, provision?.status]); - const startProvisioning = React8.useCallback(async ({ replace = false } = {}) => { + const startProvisioning = React9.useCallback(async ({ replace = false } = {}) => { setBusy(true); try { if (replace && provision?.attemptId) { @@ -5108,14 +5660,14 @@ function WeixinSettingsTab({ rpcCall }) { } catch (error) { setProvision({ status: "failed", - error: presentError5(error), + error: presentError7(error), ...provision?.attemptId ? { attemptId: provision.attemptId } : {} }); } finally { setBusy(false); } }, [announce, invoke, provision?.attemptId]); - const cancelProvisioning = React8.useCallback(async () => { + const cancelProvisioning = React9.useCallback(async () => { setBusy(true); try { if (provision?.attemptId && !["failed", "expired", "cancelled"].includes(provision.status)) { @@ -5125,12 +5677,12 @@ function WeixinSettingsTab({ rpcCall }) { announce("\u5DF2\u53D6\u6D88\u5FAE\u4FE1\u7ED1\u5B9A\u3002"); scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus"); } catch (error) { - setProvision((current) => ({ ...current, status: "failed", error: presentError5(error) })); + setProvision((current) => ({ ...current, status: "failed", error: presentError7(error) })); } finally { setBusy(false); } }, [announce, invoke, provision?.attemptId, provision?.status, scheduleAnimationFrame]); - const submitVerification = React8.useCallback(async (verifyCode) => { + const submitVerification = React9.useCallback(async (verifyCode) => { if (!provision?.attemptId) return; setBusy(true); try { @@ -5141,12 +5693,12 @@ function WeixinSettingsTab({ rpcCall }) { setProvision((current) => ({ ...current, ...next })); announce("\u914D\u5BF9\u7801\u5DF2\u63D0\u4EA4\uFF0C\u6B63\u5728\u7B49\u5F85\u5FAE\u4FE1\u786E\u8BA4\u3002"); } catch (error) { - setProvision((current) => ({ ...current, status: "failed", error: presentError5(error) })); + setProvision((current) => ({ ...current, status: "failed", error: presentError7(error) })); } finally { setBusy(false); } }, [announce, invoke, provision?.attemptId]); - React8.useEffect(() => { + React9.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !["pending", "scanned", "connecting"].includes(provision.status)) return void 0; const controller = new AbortController(); @@ -5185,7 +5737,7 @@ function WeixinSettingsTab({ rpcCall }) { } } catch (error) { if (scheduler.disposed || error?.name === "AbortError") return; - setProvision((current) => current?.attemptId === attemptId ? { ...current, status: "failed", error: presentError5(error) } : current); + setProvision((current) => current?.attemptId === attemptId ? { ...current, status: "failed", error: presentError7(error) } : current); } }; scheduler.schedule(poll, provision.pollIntervalMs ?? 1e3); @@ -5194,7 +5746,7 @@ function WeixinSettingsTab({ rpcCall }) { controller.abort(); }; }, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]); - const setBotBusy = React8.useCallback((botId, value) => { + const setBotBusy = React9.useCallback((botId, value) => { setBusyByBot((current) => { const next = { ...current }; if (value) next[botId] = value; @@ -5202,23 +5754,23 @@ function WeixinSettingsTab({ rpcCall }) { return next; }); }, []); - const reconnect = React8.useCallback(async (account) => { + const reconnect = React9.useCallback(async (account) => { setBotBusy(account.botId, "reconnect"); try { - const snapshot = normalizeSnapshot4(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId })); + const snapshot = normalizeSnapshot6(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId })); setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision })); const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId); announce(refreshed?.connected ? "\u5FAE\u4FE1\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002" : "\u5FAE\u4FE1\u4ECD\u672A\u8FDE\u63A5\uFF0C\u63D2\u4EF6\u4F1A\u7EE7\u7EED\u81EA\u52A8\u91CD\u8BD5\u3002"); } catch (error) { - announce(`\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF1A${presentError5(error).message}`); + announce(`\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF1A${presentError7(error).message}`); } finally { setBotBusy(account.botId, null); } }, [announce, invoke, setBotBusy]); - const remove = React8.useCallback(async (account) => { + const remove = React9.useCallback(async (account) => { setBotBusy(account.botId, "delete"); try { - const snapshot = normalizeSnapshot4(await invoke(WEIXIN_ENDPOINTS.deleteBot, { + const snapshot = normalizeSnapshot6(await invoke(WEIXIN_ENDPOINTS.deleteBot, { botId: account.botId, confirm: true })); @@ -5226,16 +5778,16 @@ function WeixinSettingsTab({ rpcCall }) { setRemoveTarget(null); announce("\u5FAE\u4FE1\u8D26\u53F7\u53CA\u672C\u673A\u51ED\u636E\u5DF2\u79FB\u9664\u3002"); } catch (error) { - announce(`\u79FB\u9664\u5931\u8D25\uFF1A${presentError5(error).message}`); + announce(`\u79FB\u9664\u5931\u8D25\uFF1A${presentError7(error).message}`); } finally { setBotBusy(account.botId, null); } }, [announce, invoke, setBotBusy]); let provisionView = null; if (provision?.status === "starting") { - provisionView = h7(ProgressPanel2, { busy }); + provisionView = h8(ProgressPanel2, { busy }); } else if (["pending", "scanned"].includes(provision?.status)) { - provisionView = h7(QrPanel4, { + provisionView = h8(QrPanel4, { provision, now, busy, @@ -5243,54 +5795,54 @@ function WeixinSettingsTab({ rpcCall }) { onCancel: () => void cancelProvisioning() }); } else if (provision?.status === "needs_verification") { - provisionView = h7(VerificationPanel, { + provisionView = h8(VerificationPanel, { provision, busy, onSubmit: (code) => void submitVerification(code), onCancel: () => void cancelProvisioning() }); } else if (provision?.status === "connecting") { - provisionView = h7(ProgressPanel2, { + provisionView = h8(ProgressPanel2, { scanned: true, busy, onCancel: () => void cancelProvisioning() }); } else if (provision && ["failed", "expired", "cancelled"].includes(provision.status)) { - provisionView = h7(ProvisionError3, { + provisionView = h8(ProvisionError3, { provision, busy, onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }), onClose: () => void cancelProvisioning() }); } - return h7( + return h8( "section", { className: "dxw-page dim-channelPage", "aria-label": "\u5FAE\u4FE1\u8BBE\u7F6E" }, - h7(Heading5, { + h8(Heading5, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }), - h7("div", { className: "dxw-visuallyHidden", role: "status", "aria-live": "polite" }, notice), - model.error && model.phase === "ready" ? h7("div", { className: "dxw-statusNotice dim-statusNotice" }, `\u72B6\u6001\u5237\u65B0\u5931\u8D25\uFF1A${model.error.message}`) : null, - model.phase === "loading" ? h7(LoadingView5) : model.phase === "error" ? h7( + h8("div", { className: "dxw-visuallyHidden", role: "status", "aria-live": "polite" }, notice), + model.error && model.phase === "ready" ? h8("div", { className: "dxw-statusNotice dim-statusNotice" }, `\u72B6\u6001\u5237\u65B0\u5931\u8D25\uFF1A${model.error.message}`) : null, + model.phase === "loading" ? h8(LoadingView5) : model.phase === "error" ? h8( "div", { className: "dxw-card dim-surfaceCard" }, - h7( + h8( "div", { className: "dxw-error dim-inlineError" }, - h7("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u5FAE\u4FE1\u72B6\u6001"), - h7("p", null, model.error?.message ?? "\u8BF7\u7A0D\u540E\u91CD\u8BD5"), - h7(Button9, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") + h8("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u5FAE\u4FE1\u72B6\u6001"), + h8("p", null, model.error?.message ?? "\u8BF7\u7A0D\u540E\u91CD\u8BD5"), + h8(Button11, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") ) - ) : h7( - React8.Fragment, + ) : h8( + React9.Fragment, null, provisionView, - model.bots.length === 0 && !provision ? h7(EmptyView5, { onStart: () => void startProvisioning(), busy }) : null, - model.bots.length > 0 ? h7(AccountList2, { + model.bots.length === 0 && !provision ? h8(EmptyView5, { onStart: () => void startProvisioning(), busy }) : null, + model.bots.length > 0 ? h8(AccountList2, { bots: model.bots, busyByBot, removeTarget, @@ -5305,7 +5857,7 @@ function WeixinSettingsTab({ rpcCall }) { // plugin-src/client/styles.js var IM_STYLE_ID = "xmanrui-dsh-im-settings"; -var CSS6 = String.raw` +var CSS8 = String.raw` .dim-page { --dim-blue: #3370ff; --dim-blue-soft: color-mix(in srgb, var(--dim-blue) 9%, transparent); @@ -5338,6 +5890,10 @@ var CSS6 = String.raw` .dim-logoQq svg { width: 21px; height: 21px; } .dim-logoWecom { background: white; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); } .dim-logoWecom svg { width: 22px; height: 22px; } +.dim-logoTelegram { color: white; background: #229ed9; } +.dim-logoTelegram svg { width: 21px; height: 21px; } +.dim-logoDiscord { color: white; background: #5865f2; } +.dim-logoDiscord svg { width: 21px; height: 21px; } .dim-channelCopy { min-width: 0; display: block; } .dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 14px; line-height: 20px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; } .dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-line-divider, #eef0f3); } @@ -5360,6 +5916,7 @@ var CSS6 = String.raw` .dim-panel .dim-credentialPanel { display: grid; gap: 18px; padding: 20px; } .dim-panel .dim-credentialTitle { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: 1.35; font-weight: 650; } .dim-panel .dim-credentialForm { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 12px; } +.dim-panel .dim-credentialFormSingle { grid-template-columns: minmax(0, 1fr); } .dim-panel .dim-credentialField { min-width: 0; display: grid; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: normal; font-weight: 560; } .dim-panel .dim-credentialField input { width: 100%; min-width: 0; height: 38px; padding: 0 11px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 8px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; transition: border-color .16s ease, box-shadow .16s ease; } .dim-panel .dim-credentialField input:focus { border-color: #4e5969; box-shadow: 0 0 0 3px rgb(78 89 105 / 10%); } @@ -5487,13 +6044,13 @@ function installImStyles() { const style = document.createElement("style"); style.dataset.plugin = "@xmanrui/dsh-im"; style.dataset.pluginCss = IM_STYLE_ID; - style.textContent = CSS6; + style.textContent = CSS8; document.head.appendChild(style); return () => style.remove(); } // plugin-src/client/index.js -var h8 = React9.createElement; +var h9 = React10.createElement; var name = "im-settings"; var inject = ["slots", "connection"]; var CHANNELS = Object.freeze([ @@ -5501,86 +6058,112 @@ var CHANNELS = Object.freeze([ { id: "feishu", label: "\u98DE\u4E66" }, { id: "dingtalk", label: "\u9489\u9489" }, { id: "wecom", label: "\u4F01\u4E1A\u5FAE\u4FE1" }, - { id: "qq", label: "QQ" } + { id: "qq", label: "QQ" }, + { id: "telegram", label: "Telegram" }, + { id: "discord", label: "Discord" } ]); function WeixinLogo() { - return h8( + return h9( "span", { className: "dim-logo dim-logoWeixin", "aria-hidden": "true" }, - h8(WeixinLogoGlyph) + h9(WeixinLogoGlyph) ); } function FeishuLogo() { - return h8( + return h9( "span", { className: "dim-logo dim-logoFeishu", "aria-hidden": "true" }, - h8(FeishuLogoGlyph) + h9(FeishuLogoGlyph) ); } function DingtalkLogo() { - return h8( + return h9( "span", { className: "dim-logo dim-logoDingtalk", "aria-hidden": "true" }, - h8(DingtalkLogoGlyph) + h9(DingtalkLogoGlyph) ); } function QqLogo() { - return h8("span", { className: "dim-logo dim-logoQq", "aria-hidden": "true" }, h8(QqLogoGlyph)); + return h9("span", { className: "dim-logo dim-logoQq", "aria-hidden": "true" }, h9(QqLogoGlyph)); } function WecomLogo() { - return h8("span", { className: "dim-logo dim-logoWecom", "aria-hidden": "true" }, h8(WecomLogoGlyph)); + return h9("span", { className: "dim-logo dim-logoWecom", "aria-hidden": "true" }, h9(WecomLogoGlyph)); } -function ChannelLogo({ channel }) { - if (channel === "weixin") return h8(WeixinLogo); - if (channel === "feishu") return h8(FeishuLogo); - if (channel === "dingtalk") return h8(DingtalkLogo); - if (channel === "wecom") return h8(WecomLogo); - return h8(QqLogo); +function TelegramLogo() { + return h9( + "span", + { className: "dim-logo dim-logoTelegram", "aria-hidden": "true" }, + h9(TelegramLogoGlyph) + ); } -function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall, weixinRpcCall }) { - const [selected, setSelected] = React9.useState("weixin"); - const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0]; - return h8( +function DiscordLogo() { + return h9( + "span", + { className: "dim-logo dim-logoDiscord", "aria-hidden": "true" }, + h9(DiscordLogoGlyph) + ); +} +function ChannelLogo({ channel: channel3 }) { + if (channel3 === "weixin") return h9(WeixinLogo); + if (channel3 === "feishu") return h9(FeishuLogo); + if (channel3 === "dingtalk") return h9(DingtalkLogo); + if (channel3 === "wecom") return h9(WecomLogo); + if (channel3 === "qq") return h9(QqLogo); + if (channel3 === "telegram") return h9(TelegramLogo); + return h9(DiscordLogo); +} +function IMSettingsTab({ + dingtalkRpcCall, + discordRpcCall, + feishuRpcCall, + qqRpcCall, + telegramRpcCall, + wecomRpcCall, + weixinRpcCall +}) { + const [selected, setSelected] = React10.useState("weixin"); + const active = CHANNELS.find((channel3) => channel3.id === selected) ?? CHANNELS[0]; + return h9( "section", { className: "dim-page", "aria-label": "IM\u673A\u5668\u4EBA\u8BBE\u7F6E" }, - h8( + h9( "header", { className: "dim-title" }, - h8("p", null, "\u901A\u8FC7\u626B\u7801\u628A\u673A\u5668\u4EBA\u63A5\u5165 DeepSeek Harness") + h9("p", null, "\u628A\u673A\u5668\u4EBA\u63A5\u5165 DeepSeek Harness") ), - h8( + h9( "div", { className: "dim-layout" }, - h8( + h9( "nav", { className: "dim-rail", role: "tablist", "aria-label": "IM \u6E20\u9053" }, - CHANNELS.map((channel) => h8( + CHANNELS.map((channel3) => h9( "button", { - key: channel.id, + key: channel3.id, type: "button", role: "tab", - id: `dim-tab-${channel.id}`, + id: `dim-tab-${channel3.id}`, className: "dim-channel", - "aria-selected": channel.id === active.id, - "aria-controls": `dim-panel-${channel.id}`, - onClick: () => setSelected(channel.id) + "aria-selected": channel3.id === active.id, + "aria-controls": `dim-panel-${channel3.id}`, + onClick: () => setSelected(channel3.id) }, - h8(ChannelLogo, { channel: channel.id }), - h8( + h9(ChannelLogo, { channel: channel3.id }), + h9( "span", { className: "dim-channelCopy" }, - h8("strong", null, channel.label) + h9("strong", null, channel3.label) ) )) ), - h8("div", { className: "dim-divider", "aria-hidden": "true" }), - h8("main", { + h9("div", { className: "dim-divider", "aria-hidden": "true" }), + h9("main", { className: "dim-panel", role: "tabpanel", id: `dim-panel-${active.id}`, "aria-labelledby": `dim-tab-${active.id}` - }, active.id === "weixin" ? h8(WeixinSettingsTab, { rpcCall: weixinRpcCall }) : active.id === "feishu" ? h8(FeishuSettingsTab, { rpcCall: feishuRpcCall }) : active.id === "dingtalk" ? h8(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall }) : active.id === "wecom" ? h8(WecomSettingsTab, { rpcCall: wecomRpcCall }) : h8(QqSettingsTab, { rpcCall: qqRpcCall })) + }, active.id === "weixin" ? h9(WeixinSettingsTab, { rpcCall: weixinRpcCall }) : active.id === "feishu" ? h9(FeishuSettingsTab, { rpcCall: feishuRpcCall }) : active.id === "dingtalk" ? h9(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall }) : active.id === "wecom" ? h9(WecomSettingsTab, { rpcCall: wecomRpcCall }) : active.id === "qq" ? h9(QqSettingsTab, { rpcCall: qqRpcCall }) : active.id === "telegram" ? h9(TelegramSettingsTab, { rpcCall: telegramRpcCall }) : h9(DiscordSettingsTab, { rpcCall: discordRpcCall })) ) ); } @@ -5591,6 +6174,8 @@ function apply(ctx) { installWeixinStyles(), installWecomStyles(), installQqStyles(), + installTelegramStyles(), + installDiscordStyles(), installImStyles() ]; return () => { @@ -5602,12 +6187,22 @@ function apply(ctx) { const dingtalkRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(DINGTALK_RPC_CHANNEL, endpoint, payload, signal); const qqRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(QQ_RPC_CHANNEL, endpoint, payload, signal); const wecomRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(WECOM_RPC_CHANNEL, endpoint, payload, signal); + const telegramRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(TELEGRAM_RPC_CHANNEL, endpoint, payload, signal); + const discordRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(DISCORD_RPC_CHANNEL, endpoint, payload, signal); ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({ name: "settings.plugins.tab", id: "im", order: 20, label: "IM\u673A\u5668\u4EBA", - inject: () => ({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall, weixinRpcCall }) + inject: () => ({ + dingtalkRpcCall, + discordRpcCall, + feishuRpcCall, + qqRpcCall, + telegramRpcCall, + wecomRpcCall, + weixinRpcCall + }) }, IMSettingsTab)); } diff --git a/lib/index.js b/lib/index.js index 7de68ea..c4c2abf 100644 --- a/lib/index.js +++ b/lib/index.js @@ -395,7 +395,7 @@ var DingtalkDeviceAuth = class { async #post(path, body, action, signal) { let response; const timeoutSignal = AbortSignal.timeout(this.#timeoutMs); - const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; + const requestSignal3 = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; try { response = await this.#fetch(`${this.#baseUrl}${path}`, { method: "POST", @@ -405,7 +405,7 @@ var DingtalkDeviceAuth = class { }, body: JSON.stringify(body), redirect: "error", - signal: requestSignal + signal: requestSignal3 }); } catch (error) { if (signal?.aborted) throw signal.reason ?? error; @@ -1179,14 +1179,14 @@ function abortError2(signal) { } function abortableDelay(ms, signal) { if (ms <= 0) return Promise.resolve(); - return new Promise((resolve6, reject) => { + return new Promise((resolve7, reject) => { if (signal?.aborted) { reject(abortError2(signal)); return; } const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); - resolve6(); + resolve7(); }, ms); const onAbort = () => { clearTimeout(timer); @@ -1319,7 +1319,7 @@ function createDingtalkApi({ now = () => Date.now(), cardMinIntervalMs = 50, cardBackoffMs = 1e3, - delay: delay2 = abortableDelay + delay: delay3 = abortableDelay } = {}) { if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a function"); if (typeof now !== "function") throw new TypeError("now must be a function"); @@ -1329,7 +1329,7 @@ function createDingtalkApi({ if (!Number.isFinite(cardBackoffMs) || cardBackoffMs < 0) { throw new TypeError("cardBackoffMs must be a non-negative number"); } - if (typeof delay2 !== "function") throw new TypeError("delay must be a function"); + if (typeof delay3 !== "function") throw new TypeError("delay must be a function"); const registrationBase = normalizeTrustedUrl(registrationBaseUrl, { label: "\u9489\u9489\u6CE8\u518C\u670D\u52A1", requireSubdomain: false @@ -1368,7 +1368,7 @@ function createDingtalkApi({ function acquireCardRequestSlot(signal) { const acquire = async () => { const waitMs = Math.max(0, nextCardRequestAt - now()); - if (waitMs > 0) await delay2(waitMs, signal); + if (waitMs > 0) await delay3(waitMs, signal); nextCardRequestAt = Math.max(nextCardRequestAt, now()) + cardMinIntervalMs; }; const slot = cardSlotTail.then(acquire, acquire); @@ -1381,7 +1381,7 @@ function createDingtalkApi({ return await requestJson(fetchImpl, endpoint(apiBase, pathname), options); } catch (error) { if (!(error instanceof DingtalkApiError) || error.status !== 403) throw error; - await delay2(cardBackoffMs, options.signal); + await delay3(cardBackoffMs, options.signal); await acquireCardRequestSlot(options.signal); return requestJson(fetchImpl, endpoint(apiBase, pathname), options); } @@ -1670,7 +1670,7 @@ function createDingTalkCardStream({ updateIntervalMs = DEFAULT_UPDATE_INTERVAL_MS, clock = () => Date.now(), timer = { - setTimeout: (callback, delay2) => globalThis.setTimeout(callback, delay2), + setTimeout: (callback, delay3) => globalThis.setTimeout(callback, delay3), clearTimeout: (handle) => globalThis.clearTimeout(handle) } } = {}) { @@ -1741,12 +1741,12 @@ function createDingTalkCardStream({ if (phase !== "aborted") signal?.addEventListener("abort", onAbort, { once: true }); const launchUpdate = () => { if (phase !== "active" || updateWorker || pendingText === null) return; - const delay2 = Math.max(0, lastUpdateAt + updateIntervalMs - readClock()); - if (delay2 > 0) { + const delay3 = Math.max(0, lastUpdateAt + updateIntervalMs - readClock()); + if (delay3 > 0) { scheduledUpdate = timer.setTimeout(() => { scheduledUpdate = null; launchUpdate(); - }, delay2); + }, delay3); return; } const text = pendingText; @@ -2098,7 +2098,7 @@ function streamIsOpen(client) { return client?.connected === true || client?.socket?.readyState === 1; } function abortable(promise, signal) { - return new Promise((resolve6, reject) => { + return new Promise((resolve7, reject) => { if (signal.aborted) { reject(signal.reason); return; @@ -2108,7 +2108,7 @@ function abortable(promise, signal) { Promise.resolve(promise).then( (value) => { signal.removeEventListener("abort", onAbort); - resolve6(value); + resolve7(value); }, (error) => { signal.removeEventListener("abort", onAbort); @@ -2121,10 +2121,10 @@ async function waitForStreamOpen(client, pollIntervalMs, signal) { while (true) { signal?.throwIfAborted(); if (streamIsOpen(client)) return; - await new Promise((resolve6, reject) => { + await new Promise((resolve7, reject) => { const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); - resolve6(); + resolve7(); }, pollIntervalMs); const onAbort = () => { clearTimeout(timer); @@ -2399,14 +2399,14 @@ var DingtalkRuntime = class { import { spawn } from "node:child_process"; import { randomUUID as randomUUID4 } from "node:crypto"; function sleep(ms, signal) { - return new Promise((resolve6, reject) => { + return new Promise((resolve7, reject) => { if (signal?.aborted) { reject(signal.reason ?? new DOMException("Aborted", "AbortError")); return; } const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); - resolve6(); + resolve7(); }, ms); const onAbort = () => { clearTimeout(timer); @@ -2837,7 +2837,7 @@ var DingtalkStateStore = class { var DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); function retryDelays(value) { if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS]; - const valid = value.filter((delay2) => Number.isFinite(delay2) && delay2 >= 0); + const valid = value.filter((delay3) => Number.isFinite(delay3) && delay3 >= 0); return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS]; } var ConnectionSupervisor = class { @@ -2877,8 +2877,8 @@ var ConnectionSupervisor = class { this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 ? healthyIntervalMs : 15e3; this.#setTimeout = setTimeoutImpl; this.#clearTimeout = clearTimeoutImpl; - this.#ready = new Promise((resolve6) => { - this.#resolveReady = resolve6; + this.#ready = new Promise((resolve7) => { + this.#resolveReady = resolve7; }); } get ready() { @@ -3061,6 +3061,16 @@ async function createProductionController(ctx, config = {}, internals = {}) { // plugin-src/host/channels/dingtalk/rpc.mjs import QRCode from "qrcode"; + +// plugin-src/host/rpc-authority.mjs +var RPC_AUTHORITIES = /* @__PURE__ */ new Set(["loopback", "trusted-host"]); +function resolveRpcAuthority(value) { + if (value === void 0) return "loopback"; + if (RPC_AUTHORITIES.has(value)) return value; + throw new TypeError('dsh-im rpcAuthority must be "loopback" or "trusted-host"'); +} + +// plugin-src/host/channels/dingtalk/rpc.mjs var DINGTALK_RPC_CHANNEL = "/dingtalk"; var DINGTALK_ENDPOINTS = Object.freeze({ status: "connection.status", @@ -3245,28 +3255,2024 @@ function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } = {}) { } }; } -function installDingtalkRpc(ctx, controller, options) { +function installDingtalkRpc(ctx, controller, options, authority) { if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== "function") { throw new TypeError("DSH Host Connection RPC is required"); } return ctx.connection.rpc.handle( DINGTALK_RPC_CHANNEL, createDingtalkRpcHandler(controller, options), - { authority: "loopback" } + { authority: resolveRpcAuthority(authority) } ); } // plugin-src/host/channels/dingtalk/index.mjs async function apply(ctx, config = {}) { - if (config?.controller) return installDingtalkRpc(ctx, config.controller, config.rpcOptions); + if (config?.controller) { + return installDingtalkRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority); + } const production = await createProductionController(ctx, config, config.internals); - const disposeRpc = installDingtalkRpc(ctx, production.controller, config.rpcOptions); + const disposeRpc = installDingtalkRpc( + ctx, + production.controller, + config.rpcOptions, + config.rpcAuthority + ); ctx.effect(() => async () => { await production.close(); }, "dsh-dingtalk: close bot connections"); return disposeRpc; } +// src/channels/shared/token-config-store.mjs +import { createHash as createHash2 } from "node:crypto"; +import { mkdir as mkdir3, readFile as readFile3, rename as rename3, unlink as unlink4, writeFile as writeFile3 } from "node:fs/promises"; +import { dirname as dirname3 } from "node:path"; +var EMPTY_DOCUMENT2 = Object.freeze({ version: 1, bots: Object.freeze([]) }); +function cleanString4(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function escapePattern(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +function deriveTokenBotIdentity(platformId, { botPrefix, tokenRefPrefix }) { + const raw = cleanString4(platformId); + if (!raw) throw new TypeError("platformId is required"); + const digest2 = createHash2("sha256").update(raw).digest("hex").slice(0, 24); + return { + botId: `${botPrefix}_${digest2}`, + tokenRef: `${tokenRefPrefix}_${digest2.toUpperCase()}` + }; +} +function maskPlatformId(platformId, fallback) { + const value = cleanString4(platformId) ?? ""; + if (value.length <= 10) return value ? `${value.slice(0, 3)}\u2022\u2022\u2022` : fallback; + return `${value.slice(0, 6)}\u2022\u2022\u2022\u2022${value.slice(-4)}`; +} +var TokenBotConfigStore = class { + #path; + #channel; + #botPrefix; + #tokenRefPrefix; + #botIdPattern; + #tokenRefPattern; + #value = EMPTY_DOCUMENT2; + #writeQueue = Promise.resolve(); + constructor(path, { channel, botPrefix, tokenRefPrefix }) { + this.#path = path; + this.#channel = channel; + this.#botPrefix = botPrefix; + this.#tokenRefPrefix = tokenRefPrefix; + this.#botIdPattern = new RegExp(`^${escapePattern(botPrefix)}_[a-f0-9]{24}$`); + this.#tokenRefPattern = new RegExp(`^${escapePattern(tokenRefPrefix)}_[A-F0-9]{24}$`); + } + async load() { + try { + const normalized = this.#normalizeDocument(JSON.parse(await readFile3(this.#path, "utf8"))); + if (!normalized) throw new Error(`dsh-im ${this.#channel} config contains invalid bot data`); + this.#value = normalized; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + this.#value = EMPTY_DOCUMENT2; + } + return this; + } + list() { + return structuredClone(this.#value.bots); + } + get(botId) { + const bot = this.#value.bots.find((candidate) => candidate.botId === botId); + return bot ? structuredClone(bot) : null; + } + getByPlatformId(platformId) { + const bot = this.#value.bots.find((candidate) => candidate.platformId === platformId); + return bot ? structuredClone(bot) : null; + } + async save(value) { + const normalized = this.#normalizeBot(value); + if (!normalized) throw new Error(`Refusing to persist incomplete ${this.#channel} bot data`); + return this.#mutate((bots) => { + const identityCollision = bots.find( + (bot) => bot.platformId === normalized.platformId && bot.botId !== normalized.botId + ); + const refCollision = bots.find( + (bot) => bot.tokenRef === normalized.tokenRef && bot.botId !== normalized.botId + ); + if (identityCollision || refCollision) throw new Error(`Duplicate ${this.#channel} bot identity`); + const index = bots.findIndex((bot) => bot.botId === normalized.botId); + if (index === -1) bots.push(normalized); + else bots[index] = normalized; + return structuredClone(normalized); + }); + } + async remove(botId) { + if (!this.#botIdPattern.test(botId)) throw new TypeError(`Invalid ${this.#channel} bot id`); + return this.#mutate((bots) => { + const index = bots.findIndex((bot) => bot.botId === botId); + if (index === -1) return null; + const [removed] = bots.splice(index, 1); + return structuredClone(removed); + }); + } + async clear() { + const operation = this.#writeQueue.then(async () => { + try { + await unlink4(this.#path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + this.#value = EMPTY_DOCUMENT2; + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + } + #normalizeBot(value) { + if (!value || typeof value !== "object") return null; + const platformId = cleanString4(value.platformId); + const botId = cleanString4(value.botId); + const tokenRef = cleanString4(value.tokenRef); + const name2 = cleanString4(value.name); + if (!platformId || !botId || !tokenRef || !name2 || !this.#botIdPattern.test(botId) || !this.#tokenRefPattern.test(tokenRef)) return null; + const derived = deriveTokenBotIdentity(platformId, { + botPrefix: this.#botPrefix, + tokenRefPrefix: this.#tokenRefPrefix + }); + if (derived.botId !== botId || derived.tokenRef !== tokenRef) return null; + return Object.freeze({ + botId, + platformId, + tokenRef, + name: name2, + username: cleanString4(value.username), + createdAt: cleanString4(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: cleanString4(value.connectedAt) + }); + } + #normalizeDocument(value) { + if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null; + const bots = value.bots.map((bot) => this.#normalizeBot(bot)); + if (bots.some((bot) => bot === null)) return null; + const ids = /* @__PURE__ */ new Set(); + const platformIds = /* @__PURE__ */ new Set(); + const refs = /* @__PURE__ */ new Set(); + for (const bot of bots) { + if (ids.has(bot.botId) || platformIds.has(bot.platformId) || refs.has(bot.tokenRef)) return null; + ids.add(bot.botId); + platformIds.add(bot.platformId); + refs.add(bot.tokenRef); + } + return Object.freeze({ version: 1, bots: Object.freeze(bots) }); + } + async #mutate(mutator) { + let result; + const operation = this.#writeQueue.then(async () => { + const bots = [...this.#value.bots]; + result = mutator(bots); + const document = Object.freeze({ version: 1, bots: Object.freeze(bots) }); + await this.#write(document); + this.#value = document; + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + return result; + } + async #write(document) { + await mkdir3(dirname3(this.#path), { recursive: true, mode: 448 }); + const temporary = `${this.#path}.tmp`; + await writeFile3(temporary, `${JSON.stringify(document, null, 2)} +`, { + encoding: "utf8", + mode: 384 + }); + await rename3(temporary, this.#path); + } +}; + +// src/channels/discord/config-store.mjs +var IDENTITY_OPTIONS = Object.freeze({ + botPrefix: "discord", + tokenRefPrefix: "DSH_DISCORD_BOT_TOKEN" +}); +function deriveDiscordBotIdentity(platformId) { + return deriveTokenBotIdentity(platformId, IDENTITY_OPTIONS); +} +function maskDiscordBotId(platformId) { + return maskPlatformId(platformId, "Discord\u673A\u5668\u4EBA"); +} +var DiscordConfigStore = class extends TokenBotConfigStore { + constructor(path) { + super(path, { channel: "Discord", ...IDENTITY_OPTIONS }); + } +}; + +// src/channels/shared/token-bot-controller.mjs +function cleanString5(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function safeError2(code, message) { + return Object.freeze({ code, message }); +} +var TokenBotController = class { + #descriptor; + #credentials; + #configStore; + #inspectToken; + #deriveIdentity; + #maskPlatformId; + #createRuntime; + #deleteState; + #logger; + #runtimes = /* @__PURE__ */ new Map(); + #errors = /* @__PURE__ */ new Map(); + #transitions = /* @__PURE__ */ new Map(); + #revision = 0; + #closed = false; + constructor({ + descriptor, + credentials, + configStore, + inspectToken, + deriveIdentity, + maskPlatformId: maskPlatformId2, + createRuntime, + deleteState = async () => { + }, + logger = console + }) { + if (!descriptor?.key || !descriptor?.label || !descriptor?.connectionLabel) { + throw new TypeError("TokenBotController requires a channel descriptor"); + } + if (!credentials || typeof credentials.resolve !== "function" || typeof credentials.set !== "function" || typeof credentials.unset !== "function") { + throw new TypeError(`${descriptor.label} requires the DSH credential provider`); + } + if (!configStore || typeof configStore.list !== "function" || typeof configStore.save !== "function" || typeof configStore.remove !== "function") { + throw new TypeError(`${descriptor.label} requires a config store`); + } + if (typeof inspectToken !== "function" || typeof deriveIdentity !== "function" || typeof maskPlatformId2 !== "function" || typeof createRuntime !== "function") { + throw new TypeError(`${descriptor.label} controller dependencies are incomplete`); + } + this.#descriptor = descriptor; + this.#credentials = credentials; + this.#configStore = configStore; + this.#inspectToken = inspectToken; + this.#deriveIdentity = deriveIdentity; + this.#maskPlatformId = maskPlatformId2; + this.#createRuntime = createRuntime; + this.#deleteState = deleteState; + this.#logger = logger; + } + async initialize() { + if (this.#closed) return this.status(); + for (const config of this.#configStore.list()) { + await this.#withBotTransition(config.botId, async () => { + if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return; + const token = await this.#resolveToken(config.tokenRef); + if (!token) { + this.#errors.set(config.botId, safeError2( + "missing-token", + `${this.#descriptor.label}\u673A\u5668\u4EBA\u51ED\u636E\u7F3A\u5931\uFF0C\u8BF7\u79FB\u9664\u540E\u91CD\u65B0\u63A5\u5165\u3002` + )); + return; + } + try { + await this.#startRuntime(config, token); + this.#errors.delete(config.botId); + } catch (error) { + this.#errors.set(config.botId, safeError2( + "connection-failed", + `${this.#descriptor.label}\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u91CD\u8BD5\u3002` + )); + this.#logger.warn?.( + `[dsh-im:${this.#descriptor.key}] bot ${config.botId} failed to initialize:`, + error + ); + } finally { + this.#touch(); + } + }); + } + return this.status(); + } + async bindCredentials({ token } = {}) { + if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`); + const normalizedToken = cleanString5(token); + if (!normalizedToken) throw new TypeError(`${this.#descriptor.label} Bot Token is required`); + const inspected = await this.#inspectToken(normalizedToken); + const platformId = cleanString5(inspected?.platformId); + const name2 = cleanString5(inspected?.name); + if (!platformId || !name2) throw new Error(`${this.#descriptor.label} returned an invalid bot identity`); + const identity = this.#deriveIdentity(platformId); + await this.#withBotTransition(identity.botId, async () => { + if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`); + const previousConfig = this.#configStore.getByPlatformId(platformId); + const previousToken = await this.#credentials.resolve(identity.tokenRef).catch(() => void 0); + const config = { + botId: identity.botId, + platformId, + tokenRef: identity.tokenRef, + name: name2, + username: cleanString5(inspected.username), + createdAt: previousConfig?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: (/* @__PURE__ */ new Date()).toISOString() + }; + await this.#credentials.set(identity.tokenRef, normalizedToken); + try { + await this.#configStore.save(config); + } catch (error) { + await this.#restoreCredential(identity.tokenRef, previousToken); + throw error; + } + try { + await this.#startRuntime(config, normalizedToken); + this.#errors.delete(identity.botId); + } catch (error) { + this.#errors.set(identity.botId, safeError2( + "connection-failed", + `${this.#descriptor.label}\u673A\u5668\u4EBA\u5DF2\u63A5\u5165\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002` + )); + this.#logger.warn?.( + `[dsh-im:${this.#descriptor.key}] bot ${identity.botId} credential connection failed:`, + error + ); + } + this.#touch(); + }); + return this.status(); + } + async reconnectBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`); + await this.#withBotTransition(botId, async () => { + const token = await this.#resolveToken(config.tokenRef); + if (!token) throw new Error(`${this.#descriptor.label} bot token is missing`); + try { + await this.#startRuntime(config, token); + this.#errors.delete(botId); + } catch (error) { + this.#errors.set(botId, safeError2( + "connection-failed", + `${this.#descriptor.label}\u8FDE\u63A5\u4ECD\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002` + )); + throw error; + } finally { + this.#touch(); + } + }); + return this.status(); + } + async deleteBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`); + await this.#withBotTransition(botId, async () => { + const previous = await this.#credentials.resolve(config.tokenRef).catch(() => void 0); + await this.#stopRuntime(botId); + try { + await this.#credentials.unset(config.tokenRef); + await this.#configStore.remove(botId); + } catch (error) { + if (previous?.value) { + await this.#credentials.set(config.tokenRef, previous.value).catch(() => void 0); + await this.#startRuntime(config, previous.value).catch(() => void 0); + } + throw new Error(`Unable to remove the ${this.#descriptor.label} bot safely.`, { cause: error }); + } + await this.#deleteState({ botId, config }).catch((error) => { + this.#logger.warn?.( + `[dsh-im:${this.#descriptor.key}] bot ${botId} state cleanup failed:`, + error + ); + }); + this.#errors.delete(botId); + this.#touch(); + }); + return this.status(); + } + status() { + const bots = this.#configStore.list().map((config) => { + const runtimeStatus2 = this.#runtimes.get(config.botId)?.status ?? null; + const connected = runtimeStatus2?.ready === true && runtimeStatus2.connectionState === "connected" && runtimeStatus2.harnessReachable === true; + const state = connected ? "connected" : runtimeStatus2?.connectionState === "connecting" ? "connecting" : this.#errors.has(config.botId) || runtimeStatus2?.connectionState === "failed" ? "error" : "offline"; + return { + botId: config.botId, + state, + connected, + configured: true, + bot: { + name: config.name, + username: config.username, + idMasked: this.#maskPlatformId(config.platformId) + }, + health: { + status: connected ? "healthy" : state === "error" ? "error" : "offline", + summary: connected ? `${this.#descriptor.label}${this.#descriptor.connectionLabel}\u8FD0\u884C\u6B63\u5E38` : state === "error" ? `${this.#descriptor.label}\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u91CD\u8BD5` : `${this.#descriptor.label}\u8FDE\u63A5\u5F53\u524D\u79BB\u7EBF`, + lastCheckedAt: runtimeStatus2?.lastCheckedAt ?? null, + lastConnectedAt: runtimeStatus2?.lastConnectedAt ?? null + }, + stats: { + messagesReceived: runtimeStatus2?.messagesReceived ?? 0, + messagesReplied: runtimeStatus2?.messagesReplied ?? 0 + }, + error: structuredClone(this.#errors.get(config.botId) ?? null) + }; + }); + const connectedCount = bots.filter((bot) => bot.connected).length; + return { + schemaVersion: 1, + revision: this.#revision, + state: bots.length === 0 ? "disconnected" : connectedCount === bots.length ? "connected" : connectedCount > 0 ? "degraded" : "offline", + bots, + totals: { configured: bots.length, connected: connectedCount } + }; + } + async close() { + if (this.#closed) return; + this.#closed = true; + await Promise.allSettled([...this.#transitions.values()]); + await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId))); + } + async #startRuntime(config, token) { + if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`); + await this.#stopRuntime(config.botId); + if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`); + const runtime = await this.#createRuntime({ botId: config.botId, config, token }); + if (!runtime || typeof runtime.start !== "function" || typeof runtime.stop !== "function") { + throw new TypeError(`createRuntime returned an invalid ${this.#descriptor.label} runtime`); + } + this.#runtimes.set(config.botId, runtime); + try { + await runtime.start(); + } catch (error) { + await runtime.stop().catch(() => void 0); + this.#runtimes.delete(config.botId); + throw error; + } + } + async #stopRuntime(botId) { + const runtime = this.#runtimes.get(botId); + this.#runtimes.delete(botId); + await runtime?.stop().catch((error) => { + this.#logger.warn?.( + `[dsh-im:${this.#descriptor.key}] bot ${botId} failed to stop cleanly:`, + error + ); + }); + } + async #resolveToken(ref) { + const result = await this.#credentials.resolve(ref).catch(() => void 0); + return cleanString5(result?.value); + } + async #restoreCredential(ref, previous) { + if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => void 0); + else await this.#credentials.unset(ref).catch(() => void 0); + } + #withBotTransition(botId, operation) { + const previous = this.#transitions.get(botId) ?? Promise.resolve(); + const current = previous.catch(() => void 0).then(operation); + const settled = current.finally(() => { + if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId); + }); + this.#transitions.set(botId, settled); + return settled; + } + #touch() { + this.#revision += 1; + } +}; + +// src/channels/discord/discord-api.mjs +var DEFAULT_BASE_URL = "https://discord.com/api/v10/"; +function cleanString6(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function requestSignal(signal, timeoutMs) { + const timeout = AbortSignal.timeout(timeoutMs); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} +function delay(ms, signal) { + return new Promise((resolve7, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new DOMException("Aborted", "AbortError")); + return; + } + const timer = setTimeout(resolve7, ms); + timer?.unref?.(); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + reject(signal.reason ?? new DOMException("Aborted", "AbortError")); + }, { once: true }); + }); +} +function snowflake(value, name2) { + const id = cleanString6(value); + if (!id || !/^\d{5,30}$/.test(id)) throw new TypeError(`Invalid Discord ${name2}`); + return id; +} +function validDiscordToken(value) { + return typeof value === "string" && /^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{20,}$/.test(value.trim()); +} +var DiscordApi = class { + #token; + #fetch; + #baseUrl; + constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) { + if (!validDiscordToken(token)) throw new TypeError("Discord Bot Token is invalid"); + if (typeof fetchImpl !== "function") throw new TypeError("DiscordApi requires fetch"); + this.#token = token.trim(); + this.#fetch = fetchImpl; + this.#baseUrl = new URL(baseUrl); + } + getCurrentUser(options = {}) { + return this.#request("users/@me", { ...options, method: "GET" }); + } + getGatewayBot(options = {}) { + return this.#request("gateway/bot", { ...options, method: "GET" }); + } + createMessage({ channelId, content, replyToMessageId, signal }) { + return this.#request(`channels/${snowflake(channelId, "channel id")}/messages`, { + method: "POST", + signal, + body: { + content, + allowed_mentions: { parse: [], replied_user: false }, + ...replyToMessageId ? { + message_reference: { + message_id: snowflake(replyToMessageId, "message id"), + channel_id: snowflake(channelId, "channel id"), + fail_if_not_exists: false + } + } : {} + } + }); + } + editMessage({ channelId, messageId, content, signal }) { + return this.#request( + `channels/${snowflake(channelId, "channel id")}/messages/${snowflake(messageId, "message id")}`, + { + method: "PATCH", + signal, + body: { content, allowed_mentions: { parse: [], replied_user: false } } + } + ); + } + sendTyping({ channelId, signal }) { + return this.#request(`channels/${snowflake(channelId, "channel id")}/typing`, { + method: "POST", + signal, + expectBody: false + }); + } + async #request(path, { + method, + body, + signal, + timeoutMs = 15e3, + expectBody = true, + retry = true + }) { + let response; + try { + response = await this.#fetch(new URL(path, this.#baseUrl), { + method, + headers: { + authorization: `Bot ${this.#token}`, + "content-type": "application/json", + "user-agent": "DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.1.0)" + }, + ...body === void 0 ? {} : { body: JSON.stringify(body) }, + signal: requestSignal(signal, timeoutMs), + redirect: "error" + }); + } catch (error) { + if (error?.name === "AbortError" || error?.name === "TimeoutError") throw error; + throw new Error(`Discord ${method} transport failed`); + } + let parsed = null; + if (expectBody || response.status === 429 || !response.ok) { + try { + parsed = await response.json(); + } catch { + if (expectBody) throw new Error(`Discord ${method} returned invalid JSON`); + } + } + if (response.status === 429 && retry) { + const retryAfterMs = Math.min(1e4, Math.max(50, Number(parsed?.retry_after) * 1e3 || 1e3)); + await delay(retryAfterMs, signal); + return this.#request(path, { method, body, signal, timeoutMs, expectBody, retry: false }); + } + if (!response.ok) { + const error = new Error(cleanString6(parsed?.message) ?? `Discord API failed with HTTP ${response.status}`); + error.code = `discord-${response.status}`; + throw error; + } + return expectBody ? parsed : null; + } +}; +async function inspectDiscordToken(token, options = {}) { + const api = new DiscordApi({ token, ...options }); + const bot = await api.getCurrentUser(); + if (!bot?.id || bot?.bot !== true) throw new Error("Discord token does not belong to a bot"); + return { + platformId: String(bot.id), + name: cleanString6(bot.global_name) ?? cleanString6(bot.username) ?? "Discord\u673A\u5668\u4EBA", + username: cleanString6(bot.username) + }; +} + +// src/channels/shared/text-harness-bridge.mjs +function cleanText(value) { + return typeof value === "string" ? value.trim() : ""; +} +function createTextBridgeStatus() { + return { + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + lastMessageAt: null, + lastReplyAt: null, + lastRejectedAt: null, + lastError: null + }; +} +var TextHarnessBridge = class { + #descriptor; + #bot; + #harness; + #state; + #status; + #logger; + #replyTimeoutMs; + #queues = /* @__PURE__ */ new Map(); + constructor({ + descriptor, + bot, + harness, + state, + status = createTextBridgeStatus(), + logger = console, + replyTimeoutMs = 6e5 + }) { + if (!descriptor?.key || !descriptor?.label) throw new TypeError("A channel descriptor is required"); + if (!bot || typeof bot.sendText !== "function") throw new TypeError("A bot client is required"); + if (!harness || !state) throw new TypeError("Harness client and state store are required"); + this.#descriptor = descriptor; + this.#bot = bot; + this.#harness = harness; + this.#state = state; + this.#status = status; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + } + get status() { + return structuredClone(this.#status); + } + accept(message) { + const conversationId = cleanText(message?.conversationId); + const kind = message?.kind === "group" ? "group" : "direct"; + const key = `${kind}:${conversationId}`; + const previous = this.#queues.get(key) ?? Promise.resolve(); + const current = previous.catch(() => void 0).then(() => this.#process({ ...message, kind, conversationId })).finally(() => { + if (this.#queues.get(key) === current) this.#queues.delete(key); + }); + this.#queues.set(key, current); + return current; + } + async waitForIdle() { + await Promise.allSettled([...this.#queues.values()]); + } + async #process(message) { + const messageId = cleanText(message.messageId); + const senderId = cleanText(message.senderId); + if (!messageId || !senderId || !message.conversationId || message.senderIsBot === true) return; + if (this.#state.hasSeen(messageId)) return; + this.#status.messagesReceived += 1; + this.#status.lastMessageAt = (/* @__PURE__ */ new Date()).toISOString(); + if (message.kind === "group" && message.addressed !== true) { + this.#status.messagesRejected += 1; + this.#status.lastRejectedAt = (/* @__PURE__ */ new Date()).toISOString(); + return; + } + const target = message.replyTarget; + const text = cleanText(message.content); + try { + if (!text) { + await this.#bot.sendText(target, "\u76EE\u524D\u4EC5\u652F\u6301\u6587\u5B57\u6D88\u606F\u3002"); + await this.#state.markSeen(messageId); + return; + } + const command = text.toLowerCase(); + if (command === "/help") { + await this.#bot.sendText(target, [ + `${this.#descriptor.label}\u673A\u5668\u4EBA\u5DF2\u8FDE\u63A5 DeepSeek Harness\u3002`, + "", + "\u76F4\u63A5\u53D1\u9001\u6587\u5B57\u5373\u53EF\u7EE7\u7EED\u5F53\u524D\u4F1A\u8BDD\u3002", + "/new \u5F00\u542F\u4E00\u4E2A\u5168\u65B0\u4F1A\u8BDD", + "/status \u68C0\u67E5\u8FDE\u63A5\u72B6\u6001", + "/help \u663E\u793A\u672C\u5E2E\u52A9" + ].join("\n")); + await this.#state.markSeen(messageId); + return; + } + if (command === "/status") { + await this.#harness.ensureRunning(); + await this.#bot.sendText(target, `${this.#descriptor.label}\u673A\u5668\u4EBA\u4E0E DeepSeek Harness \u8FDE\u63A5\u6B63\u5E38\u3002`); + await this.#state.markSeen(messageId); + return; + } + const conversationKey6 = `${message.kind}:${message.conversationId}`; + if (command === "/new") { + await this.#state.clearSession(conversationKey6); + await this.#bot.sendText(target, "\u5DF2\u5F00\u542F\u65B0\u4F1A\u8BDD\u3002\u8BF7\u53D1\u9001\u4F60\u7684\u95EE\u9898\u3002"); + await this.#state.markSeen(messageId); + return; + } + let sessionId = this.#state.sessionFor(conversationKey6); + if (!sessionId || !await this.#harness.sessionExists(sessionId)) { + sessionId = await this.#harness.createSession(); + await this.#state.setSession(conversationKey6, sessionId); + } + await this.#bot.sendTyping?.(target).catch((error) => { + this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error); + }); + let stream = null; + let streamFinished = false; + if (typeof this.#bot.openStream === "function") { + try { + stream = await this.#bot.openStream(target); + } catch (error) { + this.#logger.warn?.( + `[dsh-im:${this.#descriptor.key}] unable to start a streamed reply; using text:`, + error + ); + } + } + const answer = await this.#harness.ask(sessionId, text, { + timeoutMs: this.#replyTimeoutMs, + onUpdate: stream ? async (update) => { + const progress = update.type === "text" ? update.text : update.type === "tool" ? `\u6B63\u5728\u4F7F\u7528${update.name}\u2026` : update.text; + if (progress) await stream.update(progress); + } : void 0 + }); + if (stream) { + try { + await stream.finish(answer); + streamFinished = true; + } catch (error) { + stream.cancel?.(); + this.#logger.warn?.( + `[dsh-im:${this.#descriptor.key}] streamed reply finalization failed; using text:`, + error + ); + } + } + if (!streamFinished) await this.#bot.sendText(target, answer); + await this.#state.markSeen(messageId); + this.#status.messagesReplied += 1; + this.#status.lastReplyAt = (/* @__PURE__ */ new Date()).toISOString(); + this.#status.lastError = null; + } catch (error) { + this.#status.lastError = error?.message ?? String(error); + this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error); + try { + await this.#bot.sendText(target, "\u6D88\u606F\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"); + await this.#state.markSeen(messageId); + } catch (sendError) { + this.#logger.error?.( + `[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`, + sendError + ); + } + } + } +}; + +// src/channels/discord/discord-bridge.mjs +var DISCORD_DESCRIPTOR = Object.freeze({ + key: "discord", + label: "Discord", + connectionLabel: " Gateway \u957F\u8FDE\u63A5" +}); +var DiscordHarnessBridge = class extends TextHarnessBridge { + constructor(options) { + super({ descriptor: DISCORD_DESCRIPTOR, ...options }); + } +}; + +// src/channels/discord/discord-controller.mjs +var DiscordController = class extends TokenBotController { + constructor(options) { + super({ + ...options, + descriptor: DISCORD_DESCRIPTOR, + inspectToken: options.inspectToken ?? inspectDiscordToken, + deriveIdentity: deriveDiscordBotIdentity, + maskPlatformId: maskDiscordBotId + }); + } +}; + +// src/channels/weixin/harness-client.mjs +import { spawn as spawn2 } from "node:child_process"; +import { randomUUID as randomUUID6 } from "node:crypto"; +var sleep2 = (ms) => new Promise((resolve7) => setTimeout(resolve7, ms)); +function assistantMessageText2(event) { + return (event?.data?.message?.content ?? []).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim(); +} +var HarnessReplyTracker2 = class { + #promptRpcId; + #lastSeq; + #openTurn = null; + #targetTurn = null; + #stepText = /* @__PURE__ */ new Map(); + #latestText = ""; + #finished = false; + #reason = null; + constructor({ promptRpcId, afterSeq = -1 }) { + this.#promptRpcId = promptRpcId; + this.#lastSeq = afterSeq; + } + get finished() { + return this.#finished; + } + get answer() { + return this.#latestText.trim(); + } + get reason() { + return this.#reason; + } + consume(entries) { + let update = null; + const ordered = [...entries].map((entry) => entry?.event ?? entry).filter(Boolean).sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1)); + for (const event of ordered) { + const seq = event.seq ?? -1; + if (seq <= this.#lastSeq) continue; + this.#lastSeq = seq; + if (event.type === "turn/start") this.#openTurn = event.data?.turn ?? null; + if (event.type === "user/message" && event.data?.source?.rpcId === this.#promptRpcId) { + this.#targetTurn = this.#openTurn; + continue; + } + if (this.#targetTurn === null) continue; + if (event.type === "turn/end") { + if (event.data?.turn !== this.#targetTurn) continue; + this.#finished = true; + this.#reason = event.data?.reason ?? null; + this.#openTurn = null; + continue; + } + if (event.data?.turn !== this.#targetTurn) continue; + if (event.type === "assistant/chunk" && event.data?.chunk?.type === "text-delta") { + const step = event.data?.step ?? 0; + const index = event.data.chunk.index ?? 0; + const key = `${step}:${index}`; + this.#stepText.set(key, (this.#stepText.get(key) ?? "") + event.data.chunk.text); + const prefix = `${step}:`; + const text = [...this.#stepText.entries()].filter(([partKey]) => partKey.startsWith(prefix)).sort(([left], [right]) => Number(left.split(":")[1]) - Number(right.split(":")[1])).map(([, part]) => part).join("\n").trim(); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: "text", text }; + } + continue; + } + if (event.type === "assistant/message") { + const text = assistantMessageText2(event); + if (text && text !== this.#latestText) { + this.#latestText = text; + update = { type: "text", text }; + } + continue; + } + if (event.type === "tool/call") { + update = { type: "tool", name: event.data?.name ?? "\u5DE5\u5177" }; + } else if (event.type === "tool/result") { + update = { type: "status", text: "\u6B63\u5728\u6574\u7406\u7ED3\u679C\u2026" }; + } + } + return update; + } +}; +var HarnessRpcError2 = class extends Error { + constructor(method, error) { + super(`${method}: ${error?.message ?? "unknown Harness RPC error"}`); + this.name = "HarnessRpcError"; + this.method = method; + this.code = error?.code ?? "internal"; + this.details = error?.details ?? {}; + } +}; +var HarnessClient2 = class { + #baseUrl; + #workspace; + #agentPreset; + #autostart; + #dshBin; + #managedProcess = null; + constructor({ baseUrl, workspace, agentPreset = "standard", autostart = false, dshBin = "dsh" }) { + this.#baseUrl = new URL(baseUrl); + this.#workspace = workspace; + this.#agentPreset = agentPreset; + this.#autostart = autostart; + this.#dshBin = dshBin; + } + async rpc(method, payload = {}, timeoutMs = 3e4, options = {}) { + const rpcId = options.rpcId ?? `weixin-${randomUUID6()}`; + const response = await fetch(new URL(`/api/${method}`, this.#baseUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "client-request", rpcId, method, payload }), + signal: AbortSignal.timeout(timeoutMs) + }); + if (!response.ok) throw new Error(`Harness transport ${method} failed: HTTP ${response.status}`); + const body = await response.json(); + if (body?.type !== "server-response" || body?.rpcId !== rpcId) { + throw new Error(`Harness returned an invalid response for ${method}`); + } + if (!body.result?.ok) throw new HarnessRpcError2(method, body.result?.error); + return body.result.value; + } + async health() { + await this.rpc("host.describe", {}, 5e3); + return true; + } + async ensureRunning() { + try { + return await this.health(); + } catch (firstError) { + if (!this.#autostart) throw firstError; + } + if (!this.#managedProcess || this.#managedProcess.exitCode !== null) { + const port = this.#baseUrl.port || (this.#baseUrl.protocol === "https:" ? "443" : "80"); + this.#managedProcess = spawn2(this.#dshBin, [ + "web", + "--host", + this.#baseUrl.hostname, + "--port", + port + ], { + cwd: this.#workspace, + env: process.env, + stdio: ["ignore", "inherit", "inherit"] + }); + this.#managedProcess.on("error", (error) => { + console.error("[dsh-weixin] failed to start Harness:", error.message); + }); + } + const deadline = Date.now() + 6e4; + let lastError; + while (Date.now() < deadline) { + await sleep2(1e3); + try { + return await this.health(); + } catch (error) { + lastError = error; + } + } + throw new Error(`Harness did not become ready: ${lastError?.message ?? "timeout"}`); + } + async workspaceId() { + const { items } = await this.rpc("workspace.list", {}); + const existing = items.find((item) => item.path === this.#workspace); + if (existing) return existing.workspaceId; + const created = await this.rpc("workspace.create", { path: this.#workspace }); + return created.workspace.workspaceId; + } + async createSession() { + await this.ensureRunning(); + const workspaceId = await this.workspaceId(); + const created = await this.rpc("session.create", { + workspaceId, + agentPreset: this.#agentPreset + }); + return created.sessionId; + } + async sessionExists(sessionId) { + try { + await this.rpc("session.history", { sessionId, maxMessages: 1 }); + return true; + } catch (error) { + if (error instanceof HarnessRpcError2 && error.code === "session-not-found") return false; + throw error; + } + } + async ask(sessionId, text, options = {}) { + if (typeof options === "number") options = { timeoutMs: options }; + const timeoutMs = options.timeoutMs ?? 6e5; + const onUpdate = typeof options.onUpdate === "function" ? options.onUpdate : null; + await this.ensureRunning(); + const before = await this.rpc("session.history", { sessionId, maxMessages: 1 }); + const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1)); + const promptRpcId = `weixin-${randomUUID6()}`; + const tracker = new HarnessReplyTracker2({ promptRpcId, afterSeq: baselineSeq }); + await this.rpc("session.prompt", { + sessionId, + mode: "queue", + content: [{ type: "text", text }], + clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone + }, 3e4, { rpcId: promptRpcId }); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await sleep2(300); + const history = await this.rpc("session.history", { sessionId, maxMessages: 50 }); + const update = tracker.consume(history.events ?? []); + if (update && onUpdate) { + try { + await onUpdate(update); + } catch (error) { + console.warn("[dsh-weixin] ignored a progress update failure:", error.message); + } + } + if (!tracker.finished) continue; + if (tracker.answer) return tracker.answer; + throw new Error( + `Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ""}` + ); + } + throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1e3)} seconds`); + } + stopManagedProcess() { + if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill("SIGTERM"); + } +}; + +// src/channels/discord/harness-client.mjs +var DiscordHarnessClient = class extends HarnessClient2 { +}; + +// src/channels/shared/editable-message-stream.mjs +function splitMessageText(value, limit) { + const text = typeof value === "string" ? value.trim() : ""; + if (!text) return []; + const chunks = []; + let remaining = text; + while (remaining.length > limit) { + let cut = remaining.lastIndexOf("\n", limit); + if (cut < Math.floor(limit * 0.55)) cut = remaining.lastIndexOf(" ", limit); + if (cut < Math.floor(limit * 0.55)) cut = limit; + chunks.push(remaining.slice(0, cut).trim()); + remaining = remaining.slice(cut).trimStart(); + } + if (remaining) chunks.push(remaining); + return chunks; +} +function createEditableMessageStream({ + initialText = "\u6B63\u5728\u5904\u7406\u2026", + limit, + updateIntervalMs = 800, + create, + edit, + sendRemainder, + logger = console +}) { + let messageId; + let pending = null; + let timer = null; + let inFlight = null; + let closed = false; + let lastSent = initialText; + const schedule = () => { + if (closed || timer !== null || inFlight || !pending) return; + timer = setTimeout(() => { + timer = null; + const text = pending; + pending = null; + const next = splitMessageText(text, limit)[0] ?? initialText; + inFlight = Promise.resolve(next === lastSent ? void 0 : edit(messageId, next)).then(() => { + lastSent = next; + }).catch((error) => logger.warn?.("[dsh-im] streamed message update failed:", error)).finally(() => { + inFlight = null; + schedule(); + }); + }, updateIntervalMs); + timer?.unref?.(); + }; + return { + async start() { + messageId = await create(initialText); + return this; + }, + update(text) { + if (closed || typeof text !== "string" || !text.trim()) return; + pending = text; + schedule(); + }, + async finish(text) { + if (closed) throw new Error("Message stream is already closed"); + closed = true; + if (timer !== null) clearTimeout(timer); + timer = null; + pending = null; + await inFlight?.catch(() => void 0); + const chunks = splitMessageText(text, limit); + const first = chunks[0] ?? "\u5904\u7406\u5B8C\u6210\u3002"; + if (first !== lastSent) await edit(messageId, first); + lastSent = first; + for (const chunk of chunks.slice(1)) await sendRemainder(chunk); + }, + cancel() { + closed = true; + pending = null; + if (timer !== null) clearTimeout(timer); + timer = null; + } + }; +} + +// src/channels/discord/discord-runtime.mjs +var DISCORD_GATEWAY_INTENTS = 1 << 0 | 1 << 9 | 1 << 12; +var RECONNECT_DELAYS_MS = Object.freeze([1e3, 3e3, 5e3, 1e4, 3e4]); +function socketUrl(value) { + const url = new URL(value); + if (url.protocol !== "wss:") throw new Error("Discord returned an insecure Gateway URL"); + url.searchParams.set("v", "10"); + url.searchParams.set("encoding", "json"); + return url.href; +} +function addSocketListener(socket, event, listener) { + if (typeof socket.addEventListener === "function") socket.addEventListener(event, listener); + else if (typeof socket.on === "function") socket.on(event, listener); + else throw new TypeError("Discord WebSocket does not support events"); +} +function eventData(event) { + if (typeof event === "string") return event; + if (typeof event?.data === "string") return event.data; + if (Buffer.isBuffer(event)) return event.toString("utf8"); + if (Buffer.isBuffer(event?.data)) return event.data.toString("utf8"); + return null; +} +function gatewayCloseError(code) { + if (code === 4004) { + const error2 = new Error("Discord Bot Token \u65E0\u6548\uFF0C\u8BF7\u91CD\u65B0\u586B\u5199\u3002"); + error2.code = "discord-401"; + return error2; + } + if (code === 4013 || code === 4014) { + const error2 = new Error("Discord Gateway Intents \u914D\u7F6E\u4E0D\u6B63\u786E\uFF0C\u8BF7\u68C0\u67E5 Developer Portal \u7684 Bot \u8BBE\u7F6E\u3002"); + error2.code = "discord-intents"; + return error2; + } + const error = new Error(`Discord Gateway closed (${code || "unknown"})`); + error.code = "discord-gateway-closed"; + return error; +} +function stripBotMention(text, botId) { + if (typeof text !== "string") return ""; + return text.replace(new RegExp(`<@!?${botId}>`, "g"), "").trim(); +} +function normalizeDiscordMessage(message, botId) { + if (!message?.id || !message?.channel_id || !message?.author?.id) return null; + const direct = !message.guild_id; + const addressed = direct || message.mentions?.some((mention) => String(mention?.id) === String(botId)); + return { + messageId: String(message.id), + senderId: String(message.author.id), + senderIsBot: message.author.bot === true, + kind: direct ? "direct" : "group", + conversationId: String(message.channel_id), + content: stripBotMention(message.content ?? "", botId), + addressed, + replyTarget: { + channelId: String(message.channel_id), + replyToMessageId: String(message.id) + } + }; +} +var DiscordBotClient = class { + #api; + #signal; + constructor({ api, signal }) { + this.#api = api; + this.#signal = signal; + } + async sendText(target, text) { + const chunks = splitMessageText(text, 1900); + let result = null; + for (const [index, chunk] of chunks.entries()) { + result = await this.#api.createMessage({ + channelId: target.channelId, + content: chunk, + replyToMessageId: index === 0 ? target.replyToMessageId : void 0, + signal: this.#signal + }); + } + return result; + } + sendTyping(target) { + return this.#api.sendTyping({ channelId: target.channelId, signal: this.#signal }); + } + async openStream(target) { + const stream = createEditableMessageStream({ + limit: 1900, + create: async (content) => { + const message = await this.#api.createMessage({ + channelId: target.channelId, + content, + replyToMessageId: target.replyToMessageId, + signal: this.#signal + }); + return message.id; + }, + edit: (messageId, content) => this.#api.editMessage({ + channelId: target.channelId, + messageId, + content, + signal: this.#signal + }), + sendRemainder: (content) => this.#api.createMessage({ + channelId: target.channelId, + content, + signal: this.#signal + }) + }); + return stream.start(); + } +}; +function createDiscordRuntimeStatus() { + return { + startedAt: null, + ready: false, + connectionState: "idle", + harnessReachable: false, + lastCheckedAt: null, + lastConnectedAt: null, + lastError: null, + ...createTextBridgeStatus() + }; +} +var DiscordRuntime = class { + #config; + #token; + #harness; + #state; + #logger; + #replyTimeoutMs; + #connectTimeoutMs; + #createApi; + #createWebSocket; + #random; + #status = createDiscordRuntimeStatus(); + #api = null; + #bridge = null; + #abortController = null; + #socket = null; + #gatewayUrl = null; + #resumeUrl = null; + #sessionId = null; + #sequence = null; + #heartbeatTimer = null; + #heartbeatAcked = true; + #reconnectTimer = null; + #reconnectAttempt = 0; + #generation = 0; + #stopped = true; + #starting = null; + constructor({ + config, + token, + harness, + state, + logger = console, + replyTimeoutMs = 6e5, + connectTimeoutMs = 2e4, + createApi = (options) => new DiscordApi(options), + createWebSocket = (url) => new WebSocket(url), + random = Math.random + }) { + if (!config || !token || !harness || !state) { + throw new TypeError("DiscordRuntime requires config, token, Harness, and state"); + } + if (typeof createWebSocket !== "function") throw new TypeError("DiscordRuntime requires WebSocket"); + this.#config = config; + this.#token = token; + this.#harness = harness; + this.#state = state; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#connectTimeoutMs = connectTimeoutMs; + this.#createApi = createApi; + this.#createWebSocket = createWebSocket; + this.#random = random; + } + get status() { + return structuredClone(this.#status); + } + async start() { + if (this.#status.ready && this.#socket) return this.status; + if (this.#starting) return this.#starting; + this.#starting = this.#start().finally(() => { + this.#starting = null; + }); + return this.#starting; + } + async #start() { + await this.stop(); + this.#stopped = false; + this.#sessionId = null; + this.#resumeUrl = null; + this.#sequence = null; + this.#reconnectAttempt = 0; + this.#status.startedAt = (/* @__PURE__ */ new Date()).toISOString(); + this.#status.connectionState = "connecting"; + this.#status.lastError = null; + await this.#harness.ensureRunning(); + this.#status.harnessReachable = true; + const controller = new AbortController(); + this.#abortController = controller; + const api = this.#createApi({ token: this.#token }); + this.#api = api; + try { + const [bot, gateway] = await Promise.all([ + api.getCurrentUser({ signal: controller.signal }), + api.getGatewayBot({ signal: controller.signal }) + ]); + if (String(bot?.id ?? "") !== this.#config.platformId || bot?.bot !== true) { + throw new Error("Discord token identity does not match the saved bot"); + } + this.#gatewayUrl = gateway?.url; + const client = new DiscordBotClient({ api, signal: controller.signal }); + this.#bridge = new DiscordHarnessBridge({ + bot: client, + harness: this.#harness, + state: this.#state, + status: this.#status, + logger: this.#logger, + replyTimeoutMs: this.#replyTimeoutMs + }); + let timer; + try { + await Promise.race([ + this.#openSocket(false), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Discord Gateway did not become ready in time")), this.#connectTimeoutMs); + }) + ]); + } finally { + clearTimeout(timer); + } + return this.status; + } catch (error) { + this.#status.ready = false; + this.#status.connectionState = "failed"; + this.#status.lastError = error?.message ?? String(error); + await this.stop(); + throw error; + } + } + #openSocket(resume) { + if (this.#stopped) return Promise.reject(new Error("Discord runtime is stopped")); + const generation = ++this.#generation; + const url = socketUrl(resume && this.#resumeUrl ? this.#resumeUrl : this.#gatewayUrl); + const socket = this.#createWebSocket(url); + this.#socket = socket; + let settled = false; + return new Promise((resolve7, reject) => { + const markReady = () => { + if (settled || generation !== this.#generation) return; + settled = true; + this.#reconnectAttempt = 0; + const now = Date.now(); + this.#status.ready = true; + this.#status.connectionState = "connected"; + this.#status.lastCheckedAt = now; + this.#status.lastConnectedAt = now; + this.#status.lastError = null; + resolve7(); + }; + addSocketListener(socket, "message", (event) => { + if (generation !== this.#generation || this.#stopped) return; + const raw = eventData(event); + if (!raw) return; + let packet; + try { + packet = JSON.parse(raw); + } catch { + this.#logger.warn?.("[dsh-im:discord] ignored malformed Gateway JSON"); + return; + } + if (Number.isSafeInteger(packet.s)) this.#sequence = packet.s; + if (packet.op === 10) { + this.#startHeartbeat(packet.d?.heartbeat_interval, socket, generation); + if (resume && this.#sessionId) { + this.#sendGateway(socket, { + op: 6, + d: { token: this.#token, session_id: this.#sessionId, seq: this.#sequence } + }); + } else { + this.#sendGateway(socket, { + op: 2, + d: { + token: this.#token, + intents: DISCORD_GATEWAY_INTENTS, + properties: { + os: process.platform, + browser: "dsh-im", + device: "dsh-im" + } + } + }); + } + return; + } + if (packet.op === 11) { + this.#heartbeatAcked = true; + this.#status.lastCheckedAt = Date.now(); + return; + } + if (packet.op === 1) { + this.#heartbeat(socket); + return; + } + if (packet.op === 7) { + socket.close(4e3, "Reconnect requested"); + return; + } + if (packet.op === 9) { + if (packet.d !== true) { + this.#sessionId = null; + this.#resumeUrl = null; + this.#sequence = null; + } + socket.close(4e3, "Invalid session"); + return; + } + if (packet.op !== 0) return; + if (packet.t === "READY") { + this.#sessionId = packet.d?.session_id ?? null; + this.#resumeUrl = packet.d?.resume_gateway_url ?? null; + markReady(); + } else if (packet.t === "RESUMED") { + markReady(); + } else if (packet.t === "MESSAGE_CREATE") { + const message = normalizeDiscordMessage(packet.d, this.#config.platformId); + if (message) void this.#bridge?.accept(message); + } + }); + addSocketListener(socket, "close", (event = {}) => { + if (generation !== this.#generation) return; + this.#clearHeartbeat(); + if (this.#socket === socket) this.#socket = null; + if (this.#stopped) { + if (!settled) reject(new DOMException("Stopped", "AbortError")); + return; + } + const error = gatewayCloseError(Number(event.code) || 0); + this.#status.ready = false; + this.#status.connectionState = "connecting"; + this.#status.lastError = error.message; + if (!settled) { + settled = true; + reject(error); + } + if (error.code === "discord-401" || error.code === "discord-intents") { + this.#status.connectionState = "failed"; + return; + } + this.#scheduleReconnect(); + }); + addSocketListener(socket, "error", () => { + if (generation !== this.#generation || this.#stopped) return; + this.#status.lastError = "Discord Gateway WebSocket error"; + }); + }); + } + #sendGateway(socket, payload) { + if (socket.readyState !== 1) return; + socket.send(JSON.stringify(payload)); + } + #startHeartbeat(interval, socket, generation) { + this.#clearHeartbeat(); + if (!Number.isFinite(interval) || interval < 1e3) { + socket.close(4e3, "Invalid heartbeat interval"); + return; + } + this.#heartbeatAcked = true; + const schedule = (delay3) => { + this.#heartbeatTimer = setTimeout(() => { + if (this.#stopped || generation !== this.#generation || this.#socket !== socket) return; + if (!this.#heartbeatAcked) { + socket.close(4e3, "Heartbeat was not acknowledged"); + return; + } + this.#heartbeat(socket); + schedule(interval); + }, delay3); + this.#heartbeatTimer?.unref?.(); + }; + schedule(Math.floor(interval * this.#random())); + } + #heartbeat(socket) { + this.#heartbeatAcked = false; + this.#sendGateway(socket, { op: 1, d: this.#sequence }); + } + #clearHeartbeat() { + if (this.#heartbeatTimer !== null) clearTimeout(this.#heartbeatTimer); + this.#heartbeatTimer = null; + this.#heartbeatAcked = true; + } + #scheduleReconnect() { + if (this.#stopped || this.#reconnectTimer !== null) return; + const delay3 = RECONNECT_DELAYS_MS[Math.min(this.#reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)]; + this.#reconnectAttempt += 1; + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = null; + void this.#openSocket(Boolean(this.#sessionId)).catch((error) => { + if (this.#stopped) return; + this.#logger.warn?.("[dsh-im:discord] Gateway reconnect failed:", error); + this.#scheduleReconnect(); + }); + }, delay3); + this.#reconnectTimer?.unref?.(); + } + async stop() { + this.#stopped = true; + this.#generation += 1; + this.#abortController?.abort(); + this.#abortController = null; + if (this.#reconnectTimer !== null) clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + this.#clearHeartbeat(); + const socket = this.#socket; + const bridge = this.#bridge; + this.#socket = null; + this.#bridge = null; + this.#api = null; + try { + if (socket && socket.readyState < 2) socket.close(1e3, "Plugin stopped"); + } catch (error) { + this.#logger.warn?.(`[dsh-im:discord] bot ${this.#config.botId} failed to close Gateway:`, error); + } + await Promise.race([ + bridge?.waitForIdle() ?? Promise.resolve(), + new Promise((resolve7) => setTimeout(resolve7, 2e3)) + ]); + this.#status.ready = false; + this.#status.connectionState = "idle"; + return this.status; + } +}; + +// src/channels/shared/conversation-state-store.mjs +import { mkdir as mkdir4, readFile as readFile4, rename as rename4, unlink as unlink5, writeFile as writeFile4 } from "node:fs/promises"; +import { dirname as dirname4 } from "node:path"; +var EMPTY_STATE2 = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [], cursor: null }); +function normalizeState2(value) { + if (!value || typeof value !== "object") return structuredClone(EMPTY_STATE2); + const sessions = {}; + if (value.sessions && typeof value.sessions === "object" && !Array.isArray(value.sessions)) { + for (const [key, sessionId] of Object.entries(value.sessions)) { + if (typeof key === "string" && key && typeof sessionId === "string" && sessionId) { + sessions[key] = sessionId; + } + } + } + return { + version: 1, + sessions, + seenMessageIds: Array.isArray(value.seenMessageIds) ? value.seenMessageIds.filter((id) => typeof id === "string" && id).slice(-1e3) : [], + cursor: Number.isSafeInteger(value.cursor) && value.cursor >= 0 ? value.cursor : null + }; +} +var ConversationStateStore = class { + #path; + #state = structuredClone(EMPTY_STATE2); + #writeQueue = Promise.resolve(); + constructor(path) { + this.#path = path; + } + async load() { + try { + this.#state = normalizeState2(JSON.parse(await readFile4(this.#path, "utf8"))); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + this.#state = structuredClone(EMPTY_STATE2); + await this.#persist(); + } + return this; + } + sessionFor(key) { + return this.#state.sessions[key] ?? null; + } + async setSession(key, sessionId) { + this.#state.sessions[key] = sessionId; + await this.#persist(); + } + async clearSession(key) { + delete this.#state.sessions[key]; + await this.#persist(); + } + hasSeen(messageId) { + return this.#state.seenMessageIds.includes(messageId); + } + async markSeen(messageId) { + if (this.hasSeen(messageId)) return; + this.#state.seenMessageIds.push(messageId); + if (this.#state.seenMessageIds.length > 1e3) { + this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1e3); + } + await this.#persist(); + } + cursor() { + return this.#state.cursor; + } + async setCursor(cursor) { + if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TypeError("Invalid update cursor"); + this.#state.cursor = cursor; + await this.#persist(); + } + snapshot() { + return structuredClone(this.#state); + } + async remove() { + try { + await unlink5(this.#path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + this.#state = structuredClone(EMPTY_STATE2); + } + async #persist() { + const snapshot = `${JSON.stringify(this.#state, null, 2)} +`; + const operation = this.#writeQueue.then(async () => { + await mkdir4(dirname4(this.#path), { recursive: true, mode: 448 }); + const temporary = `${this.#path}.tmp`; + await writeFile4(temporary, snapshot, { encoding: "utf8", mode: 384 }); + await rename4(temporary, this.#path); + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + } +}; + +// src/channels/discord/state-store.mjs +var DiscordStateStore = class extends ConversationStateStore { +}; + +// plugin-src/host/channels/shared/production.mjs +import { unlink as unlink6 } from "node:fs/promises"; +import { homedir as homedir2 } from "node:os"; +import { join as join2, resolve as resolve2 } from "node:path"; + +// plugin-src/host/channels/shared/connection-supervisor.mjs +var DEFAULT_RETRY_DELAYS_MS2 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); +function retryDelays2(value) { + if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS2]; + const valid = value.filter((delay3) => Number.isFinite(delay3) && delay3 >= 0); + return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS2]; +} +var TokenConnectionSupervisor = class { + #channel; + #controller; + #harness; + #logger; + #retryDelays; + #healthyIntervalMs; + #setTimeout; + #clearTimeout; + #timer = null; + #running = null; + #retryIndex = 0; + #closed = false; + #started = false; + #ready; + #resolveReady; + constructor({ + channel, + controller, + harness, + logger = console, + retryDelaysMs, + healthyIntervalMs = 15e3, + setTimeoutImpl = setTimeout, + clearTimeoutImpl = clearTimeout + }) { + if (!channel) throw new TypeError("TokenConnectionSupervisor requires a channel"); + if (!controller || typeof controller.initialize !== "function" || typeof controller.status !== "function") { + throw new TypeError("TokenConnectionSupervisor requires a controller"); + } + if (!harness || typeof harness.ensureRunning !== "function") { + throw new TypeError("TokenConnectionSupervisor requires a Harness client"); + } + this.#channel = channel; + this.#controller = controller; + this.#harness = harness; + this.#logger = logger; + this.#retryDelays = retryDelays2(retryDelaysMs); + this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 ? healthyIntervalMs : 15e3; + this.#setTimeout = setTimeoutImpl; + this.#clearTimeout = clearTimeoutImpl; + this.#ready = new Promise((resolve7) => { + this.#resolveReady = resolve7; + }); + } + get ready() { + return this.#ready; + } + start() { + if (this.#started || this.#closed) return this; + this.#started = true; + this.#schedule(0); + return this; + } + async close() { + if (this.#closed) return; + this.#closed = true; + if (this.#timer !== null) this.#clearTimeout(this.#timer); + this.#timer = null; + await this.#running?.catch(() => void 0); + this.#resolveReady?.(null); + this.#resolveReady = null; + } + #schedule(delayMs) { + if (this.#closed) return; + this.#timer = this.#setTimeout(() => { + this.#timer = null; + void this.#run(); + }, delayMs); + this.#timer?.unref?.(); + } + async #run() { + if (this.#closed || this.#running) return; + const operation = this.#reconcile(); + this.#running = operation; + try { + await operation; + } finally { + if (this.#running === operation) this.#running = null; + } + } + async #reconcile() { + try { + await this.#harness.ensureRunning(); + if (this.#closed) return; + const status = await this.#controller.initialize(); + if (this.#closed) return; + this.#resolveReady?.(status); + this.#resolveReady = null; + const { configured, connected } = status.totals; + if (connected < configured) { + const delay3 = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-im:${this.#channel}] ${connected}/${configured} bots connected; retrying in ${delay3}ms` + ); + this.#schedule(delay3); + return; + } + this.#retryIndex = 0; + this.#schedule(this.#healthyIntervalMs); + } catch (error) { + if (this.#closed) return; + const delay3 = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-im:${this.#channel}] connection reconciliation failed; retrying in ${delay3}ms`, + error + ); + this.#schedule(delay3); + } + } +}; +function createTokenConnectionSupervisor(options) { + return new TokenConnectionSupervisor(options); +} + +// plugin-src/host/channels/shared/production.mjs +function harnessOrigin2(webServer, configured) { + if (configured !== void 0) return new URL(configured); + const port = webServer?.port; + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("dsh-im token channel requires an initialized DSH webServer port"); + } + return new URL(`http://127.0.0.1:${port}`); +} +function pluginPaths2(config, channel) { + const dshHome = resolve2(config.dshHome ?? process.env.DSH_HOME ?? join2(homedir2(), ".dsh")); + const root = resolve2(config.dataDir ?? join2(dshHome, "integrations", `dsh-${channel}`)); + return { + config: resolve2(config.configPath ?? join2(root, "config.json")), + bots: resolve2(config.botsDir ?? join2(root, "bots")) + }; +} +async function createTokenProductionController(ctx, config, internals, definitions) { + const { channel, ConfigStore, StateStore: StateStore2, HarnessClient: HarnessClient4, Controller, Runtime } = definitions; + if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`); + if (!ctx?.webServer) throw new TypeError(`dsh-im ${channel} requires ctx.webServer`); + const ResolvedConfigStore = internals.ConfigStore ?? ConfigStore; + const ResolvedStateStore = internals.StateStore ?? StateStore2; + const ResolvedHarness = internals.HarnessClient ?? HarnessClient4; + const ResolvedController = internals.Controller ?? Controller; + const ResolvedRuntime = internals.Runtime ?? Runtime; + const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor; + const logger = typeof ctx.logger === "function" ? ctx.logger(`dsh-im:${channel}`) : ctx.logger ?? console; + const paths = pluginPaths2(config, channel); + const configStore = await new ResolvedConfigStore(paths.config).load(); + const stateStores = /* @__PURE__ */ new Map(); + const statePath = (botId) => resolve2(paths.bots, botId, "state.json"); + const stateFor = async (botId) => { + let state = stateStores.get(botId); + if (!state) { + state = await new ResolvedStateStore(statePath(botId)).load(); + stateStores.set(botId, state); + } + return state; + }; + const harness = new ResolvedHarness({ + baseUrl: harnessOrigin2(ctx.webServer, config.harnessBaseUrl), + workspace: resolve2(config.workspace ?? process.cwd()), + agentPreset: config.agentPreset ?? "standard", + autostart: false, + dshBin: config.dshBin ?? "dsh" + }); + const controller = new ResolvedController({ + credentials: ctx.credentials, + configStore, + logger, + ...internals.inspectToken ? { inspectToken: internals.inspectToken } : {}, + createRuntime: async ({ botId, config: botConfig, token }) => new ResolvedRuntime({ + config: botConfig, + token, + harness, + state: await stateFor(botId), + replyTimeoutMs: config.replyTimeoutMs ?? 6e5, + connectTimeoutMs: config.connectTimeoutMs ?? 2e4, + logger: { + error: (...args) => logger.error?.(`[${botId}]`, ...args), + warn: (...args) => logger.warn?.(`[${botId}]`, ...args), + info: (...args) => logger.info?.(`[${botId}]`, ...args), + debug: (...args) => logger.debug?.(`[${botId}]`, ...args) + } + }), + deleteState: async ({ botId }) => { + const state = stateStores.get(botId); + stateStores.delete(botId); + if (state && typeof state.remove === "function") return state.remove(); + try { + await unlink6(statePath(botId)); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + }); + const supervisor = createSupervisor({ + channel, + controller, + harness, + logger, + retryDelaysMs: config.retryDelaysMs, + healthyIntervalMs: config.healthyIntervalMs + }).start(); + return { + controller, + ready: supervisor.ready, + async close() { + await supervisor.close(); + await controller.close(); + harness.stopManagedProcess(); + } + }; +} + +// plugin-src/host/channels/discord/production.mjs +function createProductionController2(ctx, config = {}, internals = {}) { + return createTokenProductionController(ctx, config, internals, { + channel: "discord", + ConfigStore: DiscordConfigStore, + StateStore: DiscordStateStore, + HarnessClient: DiscordHarnessClient, + Controller: DiscordController, + Runtime: DiscordRuntime + }); +} + +// plugin-src/host/channels/shared/rpc.mjs +var TOKEN_BOT_ENDPOINTS = Object.freeze({ + status: "connection.status", + bindCredentials: "bot.bind-credentials", + reconnectBot: "bot.reconnect", + deleteBot: "bot.delete" +}); +var ENDPOINTS = Object.freeze(Object.values(TOKEN_BOT_ENDPOINTS)); +var FORBIDDEN_PUBLIC_KEYS2 = /* @__PURE__ */ new Set([ + "token", + "botToken", + "tokenRef", + "platformId", + "secret", + "secretRef" +]); +function isRecord2(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function exactKeys2(value, allowed) { + return isRecord2(value) && Object.keys(value).every((key) => allowed.includes(key)); +} +function validId2(value) { + return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); +} +function validToken(value) { + return typeof value === "string" && value.trim().length >= 20 && value.length <= 4096; +} +function payloadFailure2(endpoint, payload) { + if (!isRecord2(payload)) return "Payload must be an object."; + if (endpoint === TOKEN_BOT_ENDPOINTS.status) { + return exactKeys2(payload, []) ? null : "connection.status does not accept fields."; + } + if (endpoint === TOKEN_BOT_ENDPOINTS.bindCredentials) { + return exactKeys2(payload, ["token"]) && validToken(payload.token) ? null : "bot.bind-credentials requires a Bot Token."; + } + if (endpoint === TOKEN_BOT_ENDPOINTS.reconnectBot) { + return exactKeys2(payload, ["botId"]) && validId2(payload.botId) ? null : "bot.reconnect requires a botId."; + } + if (endpoint === TOKEN_BOT_ENDPOINTS.deleteBot) { + return exactKeys2(payload, ["botId", "confirm"]) && validId2(payload.botId) && payload.confirm === true ? null : "bot.delete requires a botId and confirm=true."; + } + return "Unknown bot endpoint."; +} +function sanitizePublic2(value) { + if (Array.isArray(value)) return value.map(sanitizePublic2); + if (!isRecord2(value)) return value; + const safe = {}; + for (const [key, child] of Object.entries(value)) { + if (!FORBIDDEN_PUBLIC_KEYS2.has(key)) safe[key] = sanitizePublic2(child); + } + return safe; +} +function operationError(channel, error) { + if (error?.code === "webhook-configured") { + return { code: "webhook-configured", message: error.message }; + } + if (error?.code === "telegram-401" || error?.code === "discord-401") { + return { code: "invalid-token", message: `${channel} Bot Token \u65E0\u6548\uFF0C\u8BF7\u91CD\u65B0\u586B\u5199\u3002` }; + } + if (error?.code === "discord-intents") { + return { code: "discord-intents", message: error.message }; + } + return { code: `${channel.toLowerCase()}-operation-failed`, message: `${channel} \u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002` }; +} +function createTokenBotRpcHandler(controller, { channel }) { + for (const method of ["status", "bindCredentials", "reconnectBot", "deleteBot"]) { + if (typeof controller?.[method] !== "function") { + throw new TypeError(`A complete ${channel} controller is required (${method})`); + } + } + return async (endpoint, payload, signal) => { + if (signal?.aborted) { + return { ok: false, error: { code: "cancelled", message: "The request was cancelled." } }; + } + if (!ENDPOINTS.includes(endpoint)) { + return { ok: false, error: { code: "bad-request", message: `Unknown ${channel} endpoint.` } }; + } + const invalid = payloadFailure2(endpoint, payload); + if (invalid) return { ok: false, error: { code: "bad-request", message: invalid } }; + try { + let value; + if (endpoint === TOKEN_BOT_ENDPOINTS.status) value = await controller.status(); + else if (endpoint === TOKEN_BOT_ENDPOINTS.bindCredentials) { + value = await controller.bindCredentials(payload); + } else if (endpoint === TOKEN_BOT_ENDPOINTS.reconnectBot) { + value = await controller.reconnectBot(payload.botId); + } else { + value = await controller.deleteBot(payload.botId); + } + return signal?.aborted ? { ok: false, error: { code: "cancelled", message: "The request was cancelled." } } : { ok: true, value: sanitizePublic2(value) }; + } catch (error) { + return signal?.aborted ? { ok: false, error: { code: "cancelled", message: "The request was cancelled." } } : { ok: false, error: operationError(channel, error) }; + } + }; +} +function installTokenBotRpc(ctx, controller, { channel, rpcChannel, authority }) { + if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== "function") { + throw new TypeError("DSH Host Connection RPC is required"); + } + return ctx.connection.rpc.handle( + rpcChannel, + createTokenBotRpcHandler(controller, { channel }), + { authority: resolveRpcAuthority(authority) } + ); +} + +// plugin-src/host/channels/discord/rpc.mjs +var DISCORD_RPC_CHANNEL = "/discord"; +var DISCORD_ENDPOINTS = TOKEN_BOT_ENDPOINTS; +var DISCORD_RPC_ENDPOINTS = Object.freeze(Object.values(DISCORD_ENDPOINTS)); +function installDiscordRpc(ctx, controller, authority) { + return installTokenBotRpc(ctx, controller, { + channel: "Discord", + rpcChannel: DISCORD_RPC_CHANNEL, + authority + }); +} + +// plugin-src/host/channels/discord/index.mjs +async function apply2(ctx, config = {}) { + if (config?.controller) { + return installDiscordRpc(ctx, config.controller, config.rpcAuthority); + } + const production = await createProductionController2(ctx, config, config.internals ?? {}); + const disposeRpc = installDiscordRpc(ctx, production.controller, config.rpcAuthority); + ctx.effect(() => async () => production.close(), "dsh-im: close Discord bot connections"); + return disposeRpc; +} + // plugin-src/host/channels/feishu/controller.mjs var ACTIVE_REGISTRATION_STATES = /* @__PURE__ */ new Set([ "starting", @@ -3406,20 +5412,20 @@ function createProvisioningBackedController(options) { } // plugin-src/host/channels/feishu/production.mjs -import { homedir as homedir2 } from "node:os"; -import { join as join2, resolve as resolve2 } from "node:path"; -import { unlink as unlink5 } from "node:fs/promises"; +import { homedir as homedir3 } from "node:os"; +import { join as join3, resolve as resolve3 } from "node:path"; +import { unlink as unlink8 } from "node:fs/promises"; import * as Lark from "@larksuiteoapi/node-sdk"; // plugin-src/host/channels/feishu/connection-supervisor.mjs -var DEFAULT_RETRY_DELAYS_MS2 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); +var DEFAULT_RETRY_DELAYS_MS3 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); function safeDelay(value, fallback) { return Number.isFinite(value) && value >= 0 ? value : fallback; } function safeRetryDelays(value) { - if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS2]; - const delays = value.map((delay2) => safeDelay(delay2, -1)).filter((delay2) => delay2 >= 0); - return delays.length > 0 ? delays : [...DEFAULT_RETRY_DELAYS_MS2]; + if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS3]; + const delays = value.map((delay3) => safeDelay(delay3, -1)).filter((delay3) => delay3 >= 0); + return delays.length > 0 ? delays : [...DEFAULT_RETRY_DELAYS_MS3]; } function totals(status) { const configured = Number.isInteger(status?.totals?.configured) ? status.totals.configured : Array.isArray(status?.bots) ? status.bots.length : 0; @@ -3463,8 +5469,8 @@ var ConnectionSupervisor2 = class { this.#healthyIntervalMs = safeDelay(healthyIntervalMs, 15e3); this.#setTimeout = setTimeoutImpl; this.#clearTimeout = clearTimeoutImpl; - this.#ready = new Promise((resolve6) => { - this.#resolveReady = resolve6; + this.#ready = new Promise((resolve7) => { + this.#resolveReady = resolve7; }); } get ready() { @@ -3522,12 +5528,12 @@ var ConnectionSupervisor2 = class { this.#resolveReady = null; const current = totals(status); if (current.connected < current.configured) { - const delay2 = this.#retryDelaysMs[Math.min(this.#retryIndex, this.#retryDelaysMs.length - 1)]; + const delay3 = this.#retryDelaysMs[Math.min(this.#retryIndex, this.#retryDelaysMs.length - 1)]; this.#retryIndex += 1; this.#logger.warn?.( - `[dsh-feishu] ${current.connected}/${current.configured} bots connected; retrying automatically in ${delay2}ms` + `[dsh-feishu] ${current.connected}/${current.configured} bots connected; retrying automatically in ${delay3}ms` ); - this.#schedule(delay2); + this.#schedule(delay3); return; } this.#retryIndex = 0; @@ -3538,13 +5544,13 @@ var ConnectionSupervisor2 = class { } } #retry(message, error) { - const delay2 = this.#retryDelaysMs[Math.min(this.#retryIndex, this.#retryDelaysMs.length - 1)]; + const delay3 = this.#retryDelaysMs[Math.min(this.#retryIndex, this.#retryDelaysMs.length - 1)]; this.#retryIndex += 1; this.#logger.warn?.( - `[dsh-feishu] ${message}; retrying automatically in ${delay2}ms`, + `[dsh-feishu] ${message}; retrying automatically in ${delay3}ms`, error ); - this.#schedule(delay2); + this.#schedule(delay3); } }; function createConnectionSupervisor2(options) { @@ -4099,7 +6105,7 @@ var FeishuRuntime = class { }); let settleReady; let settleError; - const ready = new Promise((resolve6, reject) => { + const ready = new Promise((resolve7, reject) => { let settled = false; const timer = setTimeout(() => { if (settled) return; @@ -4110,7 +6116,7 @@ var FeishuRuntime = class { if (settled) return; settled = true; clearTimeout(timer); - resolve6(); + resolve7(); }; settleError = (error) => { if (settled) return; @@ -4178,13 +6184,13 @@ var FeishuRuntime = class { }; // src/channels/feishu/harness-client.mjs -import { spawn as spawn2 } from "node:child_process"; -import { randomUUID as randomUUID6 } from "node:crypto"; -var sleep2 = (ms) => new Promise((resolve6) => setTimeout(resolve6, ms)); +import { spawn as spawn3 } from "node:child_process"; +import { randomUUID as randomUUID7 } from "node:crypto"; +var sleep3 = (ms) => new Promise((resolve7) => setTimeout(resolve7, ms)); function messageText(event) { return (event?.data?.message?.content ?? []).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim(); } -var HarnessReplyTracker2 = class { +var HarnessReplyTracker3 = class { #promptRpcId; #lastSeq; #openTurn = null; @@ -4259,7 +6265,7 @@ var HarnessReplyTracker2 = class { return update; } }; -var HarnessRpcError2 = class extends Error { +var HarnessRpcError3 = class extends Error { constructor(method, error) { super(`${method}: ${error?.message ?? "unknown Harness RPC error"}`); this.name = "HarnessRpcError"; @@ -4268,7 +6274,7 @@ var HarnessRpcError2 = class extends Error { this.details = error?.details ?? {}; } }; -var HarnessClient2 = class { +var HarnessClient3 = class { #baseUrl; #workspace; #agentPreset; @@ -4283,7 +6289,7 @@ var HarnessClient2 = class { this.#dshBin = dshBin; } async rpc(method, payload = {}, timeoutMs = 3e4, options = {}) { - const rpcId = options.rpcId ?? `feishu-${randomUUID6()}`; + const rpcId = options.rpcId ?? `feishu-${randomUUID7()}`; const response = await fetch(new URL(`/api/${method}`, this.#baseUrl), { method: "POST", headers: { "content-type": "application/json" }, @@ -4295,7 +6301,7 @@ var HarnessClient2 = class { if (body?.type !== "server-response" || body?.rpcId !== rpcId) { throw new Error(`Harness returned an invalid response for ${method}`); } - if (!body.result?.ok) throw new HarnessRpcError2(method, body.result?.error); + if (!body.result?.ok) throw new HarnessRpcError3(method, body.result?.error); return body.result.value; } async health() { @@ -4310,7 +6316,7 @@ var HarnessClient2 = class { } if (!this.#managedProcess || this.#managedProcess.exitCode !== null) { const port = this.#baseUrl.port || (this.#baseUrl.protocol === "https:" ? "443" : "80"); - this.#managedProcess = spawn2(this.#dshBin, [ + this.#managedProcess = spawn3(this.#dshBin, [ "web", "--host", this.#baseUrl.hostname, @@ -4328,7 +6334,7 @@ var HarnessClient2 = class { const deadline = Date.now() + 6e4; let lastError; while (Date.now() < deadline) { - await sleep2(1e3); + await sleep3(1e3); try { return await this.health(); } catch (error) { @@ -4358,7 +6364,7 @@ var HarnessClient2 = class { await this.rpc("session.history", { sessionId, maxMessages: 1 }); return true; } catch (error) { - if (error instanceof HarnessRpcError2 && error.code === "session-not-found") return false; + if (error instanceof HarnessRpcError3 && error.code === "session-not-found") return false; throw error; } } @@ -4369,8 +6375,8 @@ var HarnessClient2 = class { await this.ensureRunning(); const before = await this.rpc("session.history", { sessionId, maxMessages: 1 }); const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1)); - const promptRpcId = `feishu-${randomUUID6()}`; - const tracker = new HarnessReplyTracker2({ promptRpcId, afterSeq: baselineSeq }); + const promptRpcId = `feishu-${randomUUID7()}`; + const tracker = new HarnessReplyTracker3({ promptRpcId, afterSeq: baselineSeq }); await this.rpc("session.prompt", { sessionId, mode: "queue", @@ -4379,7 +6385,7 @@ var HarnessClient2 = class { }, 3e4, { rpcId: promptRpcId }); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - await sleep2(300); + await sleep3(300); const history = await this.rpc("session.history", { sessionId, maxMessages: 50 }); const update = tracker.consume(history.events ?? []); if (update && onUpdate) { @@ -4401,31 +6407,31 @@ var HarnessClient2 = class { }; // src/channels/feishu/plugin-config-store.mjs -import { createHash as createHash2 } from "node:crypto"; -import { mkdir as mkdir3, readFile as readFile3, rename as rename3, unlink as unlink4, writeFile as writeFile3 } from "node:fs/promises"; -import { dirname as dirname3 } from "node:path"; +import { createHash as createHash3 } from "node:crypto"; +import { mkdir as mkdir5, readFile as readFile5, rename as rename5, unlink as unlink7, writeFile as writeFile5 } from "node:fs/promises"; +import { dirname as dirname5 } from "node:path"; var LEGACY_FEISHU_SECRET_REF = "DSH_FEISHU_APP_SECRET"; -function cleanString4(value) { +function cleanString7(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } function safeId(value) { - const id = cleanString4(value); + const id = cleanString7(value); return id && /^[A-Za-z0-9_-]{1,128}$/.test(id) ? id : null; } function legacyBotId(appId) { - return `bot_${createHash2("sha256").update(appId).digest("hex").slice(0, 24)}`; + return `bot_${createHash3("sha256").update(appId).digest("hex").slice(0, 24)}`; } function normalizeOwners(value) { const candidates = Array.isArray(value.ownerOpenIds) ? value.ownerOpenIds : [value.ownerOpenId]; - return [...new Set(candidates.map(cleanString4).filter(Boolean))]; + return [...new Set(candidates.map(cleanString7).filter(Boolean))]; } function normalizeBot2(value, { legacy = false } = {}) { if (!value || typeof value !== "object") return null; - const appId = cleanString4(value.appId); + const appId = cleanString7(value.appId); const ownerOpenIds = normalizeOwners(value); if (!appId || ownerOpenIds.length === 0) return null; const id = safeId(value.id) ?? (legacy ? legacyBotId(appId) : null); - const secretRef = cleanString4(value.secretRef) ?? (legacy ? LEGACY_FEISHU_SECRET_REF : null); + const secretRef = cleanString7(value.secretRef) ?? (legacy ? LEGACY_FEISHU_SECRET_REF : null); if (!id || !secretRef) return null; if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretRef)) return null; const domain = value.domain === "lark" ? "lark" : "feishu"; @@ -4435,12 +6441,12 @@ function normalizeBot2(value, { legacy = false } = {}) { secretRef, ownerOpenIds: Object.freeze(ownerOpenIds), domain, - botName: cleanString4(value.botName), - botOpenId: cleanString4(value.botOpenId), + botName: cleanString7(value.botName), + botOpenId: cleanString7(value.botOpenId), activated: value.activated ?? null, deletionPending: value.deletionPending === true, - connectedAt: cleanString4(value.connectedAt), - createdAt: cleanString4(value.createdAt) ?? cleanString4(value.connectedAt) + connectedAt: cleanString7(value.connectedAt), + createdAt: cleanString7(value.createdAt) ?? cleanString7(value.connectedAt) }); } function normalizeDocument2(value) { @@ -4479,7 +6485,7 @@ var PluginConfigStore = class { } async load() { try { - const parsed = JSON.parse(await readFile3(this.#path, "utf8")); + const parsed = JSON.parse(await readFile5(this.#path, "utf8")); const normalized = normalizeDocument2(parsed); if (!normalized) throw new Error("dsh-feishu config is incomplete or invalid"); this.#value = normalized.value; @@ -4508,7 +6514,7 @@ var PluginConfigStore = class { /** Backward-compatible save replaces the original single-bot view. */ async save(value) { const fallback = { ...value }; - if (!fallback.id) fallback.id = legacyBotId(cleanString4(fallback.appId) ?? "invalid"); + if (!fallback.id) fallback.id = legacyBotId(cleanString7(fallback.appId) ?? "invalid"); if (!fallback.secretRef) fallback.secretRef = LEGACY_FEISHU_SECRET_REF; const normalized = normalizeBot2(fallback); if (!normalized) throw new Error("Refusing to persist incomplete dsh-feishu configuration"); @@ -4541,7 +6547,7 @@ var PluginConfigStore = class { async clear() { const operation = this.#writeQueue.then(async () => { try { - await unlink4(this.#path); + await unlink7(this.#path); } catch (error) { if (error?.code !== "ENOENT") throw error; } @@ -4573,16 +6579,16 @@ var PluginConfigStore = class { return result; } async #writeDocument(document) { - await mkdir3(dirname3(this.#path), { recursive: true, mode: 448 }); + await mkdir5(dirname5(this.#path), { recursive: true, mode: 448 }); const temporary = `${this.#path}.tmp`; - await writeFile3(temporary, `${JSON.stringify(document, null, 2)} + await writeFile5(temporary, `${JSON.stringify(document, null, 2)} `, { encoding: "utf8", mode: 384 }); - await rename3(temporary, this.#path); + await rename5(temporary, this.#path); } }; // src/channels/feishu/multi-bot-controller.mjs -import { randomUUID as randomUUID7 } from "node:crypto"; +import { randomUUID as randomUUID8 } from "node:crypto"; // src/channels/feishu/registration-manager.mjs var ACTIVE_STATES = /* @__PURE__ */ new Set([ @@ -4923,10 +6929,10 @@ function botPhase({ connected, error, connection }) { return "disconnected"; } function makeBotId() { - return `bot_${randomUUID7().replaceAll("-", "")}`; + return `bot_${randomUUID8().replaceAll("-", "")}`; } function makeRegistrationId() { - return `reg_${randomUUID7().replaceAll("-", "")}`; + return `reg_${randomUUID8().replaceAll("-", "")}`; } function secretRefFor(botId) { return `DSH_FEISHU_APP_SECRET_${botId.slice(4).toUpperCase()}`; @@ -5513,19 +7519,19 @@ var MultiBotDshFeishuController = class { }; // src/channels/feishu/state-store.mjs -import { mkdir as mkdir4, readFile as readFile4, rename as rename4, writeFile as writeFile4 } from "node:fs/promises"; -import { dirname as dirname4 } from "node:path"; -var EMPTY_STATE2 = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] }); +import { mkdir as mkdir6, readFile as readFile6, rename as rename6, writeFile as writeFile6 } from "node:fs/promises"; +import { dirname as dirname6 } from "node:path"; +var EMPTY_STATE3 = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] }); var StateStore = class { #path; - #state = structuredClone(EMPTY_STATE2); + #state = structuredClone(EMPTY_STATE3); #writeQueue = Promise.resolve(); constructor(path) { this.#path = path; } async load() { try { - const parsed = JSON.parse(await readFile4(this.#path, "utf8")); + const parsed = JSON.parse(await readFile6(this.#path, "utf8")); this.#state = { version: 1, sessions: parsed.sessions && typeof parsed.sessions === "object" ? parsed.sessions : {}, @@ -5565,17 +7571,17 @@ var StateStore = class { async #persist() { const snapshot = JSON.stringify(this.#state, null, 2) + "\n"; this.#writeQueue = this.#writeQueue.then(async () => { - await mkdir4(dirname4(this.#path), { recursive: true, mode: 448 }); + await mkdir6(dirname6(this.#path), { recursive: true, mode: 448 }); const temporary = `${this.#path}.tmp`; - await writeFile4(temporary, snapshot, { encoding: "utf8", mode: 384 }); - await rename4(temporary, this.#path); + await writeFile6(temporary, snapshot, { encoding: "utf8", mode: 384 }); + await rename6(temporary, this.#path); }); await this.#writeQueue; } }; // plugin-src/host/channels/feishu/production.mjs -function harnessOrigin2(webServer, configured) { +function harnessOrigin3(webServer, configured) { if (configured !== void 0) return new URL(configured); const port = webServer?.port; if (!Number.isInteger(port) || port < 1 || port > 65535) { @@ -5583,32 +7589,32 @@ function harnessOrigin2(webServer, configured) { } return new URL(`http://127.0.0.1:${port}`); } -function pluginPaths2(config) { - const dshHome = resolve2(config.dshHome ?? process.env.DSH_HOME ?? join2(homedir2(), ".dsh")); - const root = resolve2(config.dataDir ?? join2(dshHome, "integrations", "dsh-feishu")); +function pluginPaths3(config) { + const dshHome = resolve3(config.dshHome ?? process.env.DSH_HOME ?? join3(homedir3(), ".dsh")); + const root = resolve3(config.dataDir ?? join3(dshHome, "integrations", "dsh-feishu")); return { root, - config: resolve2(config.configPath ?? join2(root, "config.json")), - legacyState: resolve2(config.statePath ?? join2(root, "state.json")), - bots: resolve2(config.botsDir ?? join2(root, "bots")) + config: resolve3(config.configPath ?? join3(root, "config.json")), + legacyState: resolve3(config.statePath ?? join3(root, "state.json")), + bots: resolve3(config.botsDir ?? join3(root, "bots")) }; } -async function createProductionController2(ctx, config = {}, internals = {}) { +async function createProductionController3(ctx, config = {}, internals = {}) { if (!ctx?.credentials) throw new TypeError("dsh-feishu requires ctx.credentials"); if (!ctx?.webServer) throw new TypeError("dsh-feishu requires ctx.webServer"); const lark = internals.lark ?? Lark; const Controller = internals.Controller ?? MultiBotDshFeishuController; const ConfigStore = internals.ConfigStore ?? PluginConfigStore; const SessionStateStore = internals.StateStore ?? StateStore; - const Harness = internals.HarnessClient ?? HarnessClient2; + const Harness = internals.HarnessClient ?? HarnessClient3; const Runtime = internals.FeishuRuntime ?? FeishuRuntime; const verifyApp = internals.verifyFeishuApp ?? verifyFeishuApp; const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor2; const logger = typeof ctx.logger === "function" ? ctx.logger("dsh-feishu") : ctx.logger ?? console; - const paths = pluginPaths2(config); + const paths = pluginPaths3(config); const configStore = await new ConfigStore(paths.config).load(); const stateStores = /* @__PURE__ */ new Map(); - const statePathFor = (botConfig) => !botConfig.id || !botConfig.secretRef || botConfig.secretRef === LEGACY_FEISHU_SECRET_REF ? paths.legacyState : resolve2(paths.bots, botConfig.id, "state.json"); + const statePathFor = (botConfig) => !botConfig.id || !botConfig.secretRef || botConfig.secretRef === LEGACY_FEISHU_SECRET_REF ? paths.legacyState : resolve3(paths.bots, botConfig.id, "state.json"); const stateFor = async (botConfig) => { const stateKey = botConfig.id ?? "__legacy__"; let state = stateStores.get(stateKey); @@ -5619,8 +7625,8 @@ async function createProductionController2(ctx, config = {}, internals = {}) { return state; }; const harness = new Harness({ - baseUrl: harnessOrigin2(ctx.webServer, config.harnessBaseUrl), - workspace: resolve2(config.workspace ?? process.cwd()), + baseUrl: harnessOrigin3(ctx.webServer, config.harnessBaseUrl), + workspace: resolve3(config.workspace ?? process.cwd()), agentPreset: config.agentPreset ?? "standard", // This plugin is already hosted by a running DSH process. Starting a // second DSH would create a competing server and lifecycle. @@ -5654,7 +7660,7 @@ async function createProductionController2(ctx, config = {}, internals = {}) { deleteState: async ({ botId, config: botConfig }) => { stateStores.delete(botId); try { - await unlink5(statePathFor(botConfig)); + await unlink8(statePathFor(botConfig)); } catch (error) { if (error?.code !== "ENOENT") throw error; } @@ -5934,7 +7940,7 @@ function validPayload(endpoint, payload) { return "Unknown Feishu endpoint."; } function abortableDelay2(milliseconds, signal) { - return new Promise((resolve6, reject) => { + return new Promise((resolve7, reject) => { if (signal?.aborted) { reject(signal.reason ?? new Error("aborted")); return; @@ -5943,7 +7949,7 @@ function abortableDelay2(milliseconds, signal) { timer.unref?.(); function done() { signal?.removeEventListener("abort", aborted); - resolve6(); + resolve7(); } function aborted() { clearTimeout(timer); @@ -6005,8 +8011,8 @@ function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl } = {}) { return async (endpoint, payload, signal) => { if (signal?.aborted) return cancelled2(); if (!FEISHU_RPC_ENDPOINTS.includes(endpoint)) return badRequest2("Unknown Feishu endpoint."); - const payloadFailure5 = validPayload(endpoint, payload); - if (payloadFailure5) return badRequest2(payloadFailure5); + const payloadFailure6 = validPayload(endpoint, payload); + if (payloadFailure6) return badRequest2(payloadFailure6); try { let value; if (endpoint === FEISHU_ENDPOINTS.status) { @@ -6085,14 +8091,14 @@ function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl } = {}) { } }; } -function installFeishuRpc(ctx, controller, options) { +function installFeishuRpc(ctx, controller, options, authority) { if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== "function") { throw new TypeError("DSH Host Connection RPC is required"); } return ctx.connection.rpc.handle( FEISHU_RPC_CHANNEL, createFeishuRpcHandler(controller, options), - { authority: "loopback" } + { authority: resolveRpcAuthority(authority) } ); } @@ -6105,11 +8111,18 @@ function controllerFrom(ctx, config) { } return void 0; } -async function apply2(ctx, config = {}) { +async function apply3(ctx, config = {}) { const controller = controllerFrom(ctx, config); - if (controller) return installFeishuRpc(ctx, controller); - const production = await createProductionController2(ctx, config); - const disposeRpc = installFeishuRpc(ctx, production.controller); + if (controller) { + return installFeishuRpc(ctx, controller, config.rpcOptions, config.rpcAuthority); + } + const production = await createProductionController3(ctx, config); + const disposeRpc = installFeishuRpc( + ctx, + production.controller, + config.rpcOptions, + config.rpcAuthority + ); ctx.effect(() => async () => { await production.close(); }, "dsh-feishu: close controller and live connection"); @@ -6117,44 +8130,44 @@ async function apply2(ctx, config = {}) { } // plugin-src/host/channels/qq/production.mjs -import { unlink as unlink8 } from "node:fs/promises"; -import { homedir as homedir3 } from "node:os"; -import { join as join3, resolve as resolve3 } from "node:path"; +import { unlink as unlink11 } from "node:fs/promises"; +import { homedir as homedir4 } from "node:os"; +import { join as join4, resolve as resolve4 } from "node:path"; // src/channels/qq/config-store.mjs -import { createHash as createHash3 } from "node:crypto"; -import { mkdir as mkdir5, readFile as readFile5, rename as rename5, unlink as unlink6, writeFile as writeFile5 } from "node:fs/promises"; -import { dirname as dirname5 } from "node:path"; -var EMPTY_DOCUMENT2 = Object.freeze({ version: 1, bots: Object.freeze([]) }); -function cleanString5(value) { +import { createHash as createHash4 } from "node:crypto"; +import { mkdir as mkdir7, readFile as readFile7, rename as rename7, unlink as unlink9, writeFile as writeFile7 } from "node:fs/promises"; +import { dirname as dirname7 } from "node:path"; +var EMPTY_DOCUMENT3 = Object.freeze({ version: 1, bots: Object.freeze([]) }); +function cleanString8(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } function safeBotId2(value) { - const id = cleanString5(value); + const id = cleanString8(value); return id && /^qq_[a-f0-9]{24}$/.test(id) ? id : null; } function safeSecretRef2(value) { - const ref = cleanString5(value); + const ref = cleanString8(value); return ref && /^DSH_QQBOT_APP_SECRET_[A-F0-9]{24}$/.test(ref) ? ref : null; } function deriveQqBotIdentity(appId) { - const raw = cleanString5(appId); + const raw = cleanString8(appId); if (!raw) throw new TypeError("appId is required"); - const digest2 = createHash3("sha256").update(raw).digest("hex").slice(0, 24); + const digest2 = createHash4("sha256").update(raw).digest("hex").slice(0, 24); return { botId: `qq_${digest2}`, secretRef: `DSH_QQBOT_APP_SECRET_${digest2.toUpperCase()}` }; } function maskQqAppId(appId) { - const value = cleanString5(appId) ?? ""; + const value = cleanString8(appId) ?? ""; if (value.length <= 10) return value ? `${value.slice(0, 3)}\u2022\u2022\u2022` : "QQ\u673A\u5668\u4EBA"; return `${value.slice(0, 6)}\u2022\u2022\u2022\u2022${value.slice(-4)}`; } function normalizeBot3(value) { if (!value || typeof value !== "object") return null; - const appId = cleanString5(value.appId); - const ownerUserOpenid = cleanString5(value.ownerUserOpenid); + const appId = cleanString8(value.appId); + const ownerUserOpenid = cleanString8(value.ownerUserOpenid); const botId = safeBotId2(value.botId); const secretRef = safeSecretRef2(value.secretRef); if (!appId || !ownerUserOpenid || !botId || !secretRef) return null; @@ -6165,8 +8178,8 @@ function normalizeBot3(value) { appId, secretRef, ownerUserOpenid, - createdAt: cleanString5(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), - connectedAt: cleanString5(value.connectedAt) + createdAt: cleanString8(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: cleanString8(value.connectedAt) }); } function normalizeDocument3(value) { @@ -6186,19 +8199,19 @@ function normalizeDocument3(value) { } var QqConfigStore = class { #path; - #value = EMPTY_DOCUMENT2; + #value = EMPTY_DOCUMENT3; #writeQueue = Promise.resolve(); constructor(path) { this.#path = path; } async load() { try { - const normalized = normalizeDocument3(JSON.parse(await readFile5(this.#path, "utf8"))); + const normalized = normalizeDocument3(JSON.parse(await readFile7(this.#path, "utf8"))); if (!normalized) throw new Error("dsh-im QQ config contains invalid bot data"); this.#value = normalized; } catch (error) { if (error?.code !== "ENOENT") throw error; - this.#value = EMPTY_DOCUMENT2; + this.#value = EMPTY_DOCUMENT3; } return this; } @@ -6242,11 +8255,11 @@ var QqConfigStore = class { async clear() { const operation = this.#writeQueue.then(async () => { try { - await unlink6(this.#path); + await unlink9(this.#path); } catch (error) { if (error?.code !== "ENOENT") throw error; } - this.#value = EMPTY_DOCUMENT2; + this.#value = EMPTY_DOCUMENT3; }); this.#writeQueue = operation.then(() => void 0, () => void 0); await operation; @@ -6265,242 +8278,19 @@ var QqConfigStore = class { return result; } async #write(document) { - await mkdir5(dirname5(this.#path), { recursive: true, mode: 448 }); + await mkdir7(dirname7(this.#path), { recursive: true, mode: 448 }); const temporary = `${this.#path}.tmp`; - await writeFile5(temporary, `${JSON.stringify(document, null, 2)} + await writeFile7(temporary, `${JSON.stringify(document, null, 2)} `, { encoding: "utf8", mode: 384 }); - await rename5(temporary, this.#path); - } -}; - -// src/channels/weixin/harness-client.mjs -import { spawn as spawn3 } from "node:child_process"; -import { randomUUID as randomUUID8 } from "node:crypto"; -var sleep3 = (ms) => new Promise((resolve6) => setTimeout(resolve6, ms)); -function assistantMessageText2(event) { - return (event?.data?.message?.content ?? []).filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim(); -} -var HarnessReplyTracker3 = class { - #promptRpcId; - #lastSeq; - #openTurn = null; - #targetTurn = null; - #stepText = /* @__PURE__ */ new Map(); - #latestText = ""; - #finished = false; - #reason = null; - constructor({ promptRpcId, afterSeq = -1 }) { - this.#promptRpcId = promptRpcId; - this.#lastSeq = afterSeq; - } - get finished() { - return this.#finished; - } - get answer() { - return this.#latestText.trim(); - } - get reason() { - return this.#reason; - } - consume(entries) { - let update = null; - const ordered = [...entries].map((entry) => entry?.event ?? entry).filter(Boolean).sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1)); - for (const event of ordered) { - const seq = event.seq ?? -1; - if (seq <= this.#lastSeq) continue; - this.#lastSeq = seq; - if (event.type === "turn/start") this.#openTurn = event.data?.turn ?? null; - if (event.type === "user/message" && event.data?.source?.rpcId === this.#promptRpcId) { - this.#targetTurn = this.#openTurn; - continue; - } - if (this.#targetTurn === null) continue; - if (event.type === "turn/end") { - if (event.data?.turn !== this.#targetTurn) continue; - this.#finished = true; - this.#reason = event.data?.reason ?? null; - this.#openTurn = null; - continue; - } - if (event.data?.turn !== this.#targetTurn) continue; - if (event.type === "assistant/chunk" && event.data?.chunk?.type === "text-delta") { - const step = event.data?.step ?? 0; - const index = event.data.chunk.index ?? 0; - const key = `${step}:${index}`; - this.#stepText.set(key, (this.#stepText.get(key) ?? "") + event.data.chunk.text); - const prefix = `${step}:`; - const text = [...this.#stepText.entries()].filter(([partKey]) => partKey.startsWith(prefix)).sort(([left], [right]) => Number(left.split(":")[1]) - Number(right.split(":")[1])).map(([, part]) => part).join("\n").trim(); - if (text && text !== this.#latestText) { - this.#latestText = text; - update = { type: "text", text }; - } - continue; - } - if (event.type === "assistant/message") { - const text = assistantMessageText2(event); - if (text && text !== this.#latestText) { - this.#latestText = text; - update = { type: "text", text }; - } - continue; - } - if (event.type === "tool/call") { - update = { type: "tool", name: event.data?.name ?? "\u5DE5\u5177" }; - } else if (event.type === "tool/result") { - update = { type: "status", text: "\u6B63\u5728\u6574\u7406\u7ED3\u679C\u2026" }; - } - } - return update; - } -}; -var HarnessRpcError3 = class extends Error { - constructor(method, error) { - super(`${method}: ${error?.message ?? "unknown Harness RPC error"}`); - this.name = "HarnessRpcError"; - this.method = method; - this.code = error?.code ?? "internal"; - this.details = error?.details ?? {}; - } -}; -var HarnessClient3 = class { - #baseUrl; - #workspace; - #agentPreset; - #autostart; - #dshBin; - #managedProcess = null; - constructor({ baseUrl, workspace, agentPreset = "standard", autostart = false, dshBin = "dsh" }) { - this.#baseUrl = new URL(baseUrl); - this.#workspace = workspace; - this.#agentPreset = agentPreset; - this.#autostart = autostart; - this.#dshBin = dshBin; - } - async rpc(method, payload = {}, timeoutMs = 3e4, options = {}) { - const rpcId = options.rpcId ?? `weixin-${randomUUID8()}`; - const response = await fetch(new URL(`/api/${method}`, this.#baseUrl), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "client-request", rpcId, method, payload }), - signal: AbortSignal.timeout(timeoutMs) - }); - if (!response.ok) throw new Error(`Harness transport ${method} failed: HTTP ${response.status}`); - const body = await response.json(); - if (body?.type !== "server-response" || body?.rpcId !== rpcId) { - throw new Error(`Harness returned an invalid response for ${method}`); - } - if (!body.result?.ok) throw new HarnessRpcError3(method, body.result?.error); - return body.result.value; - } - async health() { - await this.rpc("host.describe", {}, 5e3); - return true; - } - async ensureRunning() { - try { - return await this.health(); - } catch (firstError) { - if (!this.#autostart) throw firstError; - } - if (!this.#managedProcess || this.#managedProcess.exitCode !== null) { - const port = this.#baseUrl.port || (this.#baseUrl.protocol === "https:" ? "443" : "80"); - this.#managedProcess = spawn3(this.#dshBin, [ - "web", - "--host", - this.#baseUrl.hostname, - "--port", - port - ], { - cwd: this.#workspace, - env: process.env, - stdio: ["ignore", "inherit", "inherit"] - }); - this.#managedProcess.on("error", (error) => { - console.error("[dsh-weixin] failed to start Harness:", error.message); - }); - } - const deadline = Date.now() + 6e4; - let lastError; - while (Date.now() < deadline) { - await sleep3(1e3); - try { - return await this.health(); - } catch (error) { - lastError = error; - } - } - throw new Error(`Harness did not become ready: ${lastError?.message ?? "timeout"}`); - } - async workspaceId() { - const { items } = await this.rpc("workspace.list", {}); - const existing = items.find((item) => item.path === this.#workspace); - if (existing) return existing.workspaceId; - const created = await this.rpc("workspace.create", { path: this.#workspace }); - return created.workspace.workspaceId; - } - async createSession() { - await this.ensureRunning(); - const workspaceId = await this.workspaceId(); - const created = await this.rpc("session.create", { - workspaceId, - agentPreset: this.#agentPreset - }); - return created.sessionId; - } - async sessionExists(sessionId) { - try { - await this.rpc("session.history", { sessionId, maxMessages: 1 }); - return true; - } catch (error) { - if (error instanceof HarnessRpcError3 && error.code === "session-not-found") return false; - throw error; - } - } - async ask(sessionId, text, options = {}) { - if (typeof options === "number") options = { timeoutMs: options }; - const timeoutMs = options.timeoutMs ?? 6e5; - const onUpdate = typeof options.onUpdate === "function" ? options.onUpdate : null; - await this.ensureRunning(); - const before = await this.rpc("session.history", { sessionId, maxMessages: 1 }); - const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1)); - const promptRpcId = `weixin-${randomUUID8()}`; - const tracker = new HarnessReplyTracker3({ promptRpcId, afterSeq: baselineSeq }); - await this.rpc("session.prompt", { - sessionId, - mode: "queue", - content: [{ type: "text", text }], - clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone - }, 3e4, { rpcId: promptRpcId }); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - await sleep3(300); - const history = await this.rpc("session.history", { sessionId, maxMessages: 50 }); - const update = tracker.consume(history.events ?? []); - if (update && onUpdate) { - try { - await onUpdate(update); - } catch (error) { - console.warn("[dsh-weixin] ignored a progress update failure:", error.message); - } - } - if (!tracker.finished) continue; - if (tracker.answer) return tracker.answer; - throw new Error( - `Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ""}` - ); - } - throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1e3)} seconds`); - } - stopManagedProcess() { - if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill("SIGTERM"); + await rename7(temporary, this.#path); } }; // src/channels/qq/harness-client.mjs -var QqHarnessClient = class extends HarnessClient3 { +var QqHarnessClient = class extends HarnessClient2 { }; // src/channels/qq/qq-controller.mjs @@ -6508,10 +8298,10 @@ import { randomUUID as randomUUID9 } from "node:crypto"; var ACTIVE_ATTEMPT_STATES2 = /* @__PURE__ */ new Set(["starting", "pending", "refreshing", "connecting"]); var TERMINAL_ATTEMPT_STATES2 = /* @__PURE__ */ new Set(["connected", "failed", "cancelled"]); var QR_TTL_MS = 5 * 6e4; -function cleanString6(value) { +function cleanString9(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } -function safeError2(code, message) { +function safeError3(code, message) { return Object.freeze({ code, message }); } function publicAttempt2(record) { @@ -6572,14 +8362,14 @@ var QqController = class { if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return; const appSecret = await this.#resolveSecret(config.secretRef); if (!appSecret) { - this.#errors.set(config.botId, safeError2("missing-secret", "QQ \u673A\u5668\u4EBA\u51ED\u636E\u7F3A\u5931\uFF0C\u8BF7\u79FB\u9664\u540E\u91CD\u65B0\u626B\u7801\u3002")); + this.#errors.set(config.botId, safeError3("missing-secret", "QQ \u673A\u5668\u4EBA\u51ED\u636E\u7F3A\u5931\uFF0C\u8BF7\u79FB\u9664\u540E\u91CD\u65B0\u626B\u7801\u3002")); return; } try { await this.#startRuntime(config, appSecret); this.#errors.delete(config.botId); } catch (error) { - this.#errors.set(config.botId, safeError2("connection-failed", "QQ \u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u91CD\u8BD5\u3002")); + this.#errors.set(config.botId, safeError3("connection-failed", "QQ \u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u91CD\u8BD5\u3002")); this.#logger.warn?.(`[dsh-im:qq] bot ${config.botId} failed to initialize:`, error); } finally { this.#touch(); @@ -6593,8 +8383,8 @@ var QqController = class { if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); let firstQrResolve; let firstQrReject; - const firstQr = new Promise((resolve6, reject) => { - firstQrResolve = resolve6; + const firstQr = new Promise((resolve7, reject) => { + firstQrResolve = resolve7; firstQrReject = reject; }); const record = { @@ -6617,7 +8407,7 @@ var QqController = class { record.dispose = this.#qrAuth.start({ onQrDisplayed: (url) => { if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES2.has(record.state)) return; - const verificationUrl = cleanString6(url); + const verificationUrl = cleanString9(url); if (!verificationUrl) return; record.verificationUrl = verificationUrl; record.qrRevision += 1; @@ -6640,7 +8430,7 @@ var QqController = class { onFailure: (error) => { if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES2.has(record.state)) return; record.state = "failed"; - record.error = safeError2("qr-connect-failed", "QQ \u626B\u7801\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801\u3002"); + record.error = safeError3("qr-connect-failed", "QQ \u626B\u7801\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801\u3002"); if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; this.#touch(); firstQrReject(error); @@ -6651,10 +8441,10 @@ var QqController = class { } catch (error) { if (record.controller.signal.aborted) { record.state = "cancelled"; - record.error = safeError2("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); + record.error = safeError3("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); } else if (!TERMINAL_ATTEMPT_STATES2.has(record.state)) { record.state = "failed"; - record.error = safeError2("qr-start-failed", "\u65E0\u6CD5\u751F\u6210 QQ \u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"); + record.error = safeError3("qr-start-failed", "\u65E0\u6CD5\u751F\u6210 QQ \u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"); } if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; this.#touch(); @@ -6666,8 +8456,8 @@ var QqController = class { } async bindCredentials({ appId, appSecret } = {}) { if (this.#closed) throw new Error("QQ controller is closed"); - const normalizedAppId = cleanString6(appId); - const normalizedSecret = cleanString6(appSecret); + const normalizedAppId = cleanString9(appId); + const normalizedSecret = cleanString9(appSecret); if (!normalizedAppId || !normalizedSecret) { throw new TypeError("QQ AppID and AppSecret are required"); } @@ -6698,7 +8488,7 @@ var QqController = class { await this.#startRuntime(config, normalizedSecret); this.#errors.delete(identity.botId); } catch (error) { - this.#errors.set(identity.botId, safeError2("connection-failed", "QQ \u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002")); + this.#errors.set(identity.botId, safeError3("connection-failed", "QQ \u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002")); this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} credential connection failed:`, error); } this.#touch(); @@ -6713,7 +8503,7 @@ var QqController = class { record.dispose?.(); await record.task?.catch(() => void 0); if (!TERMINAL_ATTEMPT_STATES2.has(record.state)) record.state = "cancelled"; - record.error ??= safeError2("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); + record.error ??= safeError3("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); } if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; this.#touch(); @@ -6729,7 +8519,7 @@ var QqController = class { await this.#startRuntime(config, secret); this.#errors.delete(botId); } catch (error) { - this.#errors.set(botId, safeError2("connection-failed", "QQ \u8FDE\u63A5\u4ECD\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002")); + this.#errors.set(botId, safeError3("connection-failed", "QQ \u8FDE\u63A5\u4ECD\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002")); throw error; } finally { this.#touch(); @@ -6810,9 +8600,9 @@ var QqController = class { record.expiresAt = null; this.#touch(); const credential = Array.isArray(credentials) ? credentials[0] : null; - const appId = cleanString6(credential?.appId); - const appSecret = cleanString6(credential?.appSecret); - const ownerUserOpenid = cleanString6(credential?.userOpenid); + const appId = cleanString9(credential?.appId); + const appSecret = cleanString9(credential?.appSecret); + const ownerUserOpenid = cleanString9(credential?.userOpenid); if (!appId || !appSecret || !ownerUserOpenid) { throw new Error("QQ authorization returned incomplete credentials"); } @@ -6822,10 +8612,10 @@ var QqController = class { } catch (error) { if (record.controller.signal.aborted) { record.state = "cancelled"; - record.error = safeError2("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); + record.error = safeError3("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); } else { record.state = "failed"; - record.error = safeError2("activation-failed", "QQ \u5DF2\u6388\u6743\uFF0C\u4F46\u65E0\u6CD5\u5B89\u5168\u4FDD\u5B58\u63A5\u5165\u914D\u7F6E\u3002"); + record.error = safeError3("activation-failed", "QQ \u5DF2\u6388\u6743\uFF0C\u4F46\u65E0\u6CD5\u5B89\u5168\u4FDD\u5B58\u63A5\u5165\u914D\u7F6E\u3002"); this.#logger.error?.("[dsh-im:qq] provisioning failed:", error); } } finally { @@ -6867,7 +8657,7 @@ var QqController = class { await this.#restoreCredential(identity.secretRef, previousSecret); throw error; } - this.#errors.set(identity.botId, safeError2("connection-failed", "QQ \u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002")); + this.#errors.set(identity.botId, safeError3("connection-failed", "QQ \u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002")); this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} activation connection failed:`, error); } this.#touch(); @@ -6900,7 +8690,7 @@ var QqController = class { } async #resolveSecret(ref) { const result = await this.#credentials.resolve(ref).catch(() => void 0); - return cleanString6(result?.value); + return cleanString9(result?.value); } async #restoreCredential(ref, previous) { if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => void 0); @@ -7194,8 +8984,8 @@ var QqRuntime = class { this.#abortController = controller; let readyResolve; let readyReject; - const ready = new Promise((resolve6, reject) => { - readyResolve = resolve6; + const ready = new Promise((resolve7, reject) => { + readyResolve = resolve7; readyReject = reject; }); const onReady = () => { @@ -7268,7 +9058,7 @@ var QqRuntime = class { } await Promise.race([ runTask?.catch(() => void 0) ?? Promise.resolve(), - new Promise((resolve6) => setTimeout(resolve6, 2e3)) + new Promise((resolve7) => setTimeout(resolve7, 2e3)) ]); await bridge?.waitForIdle(); this.#status.ready = false; @@ -7300,11 +9090,11 @@ var QqQrAuth = class { }; // src/channels/qq/state-store.mjs -import { mkdir as mkdir6, readFile as readFile6, rename as rename6, unlink as unlink7, writeFile as writeFile6 } from "node:fs/promises"; -import { dirname as dirname6 } from "node:path"; -var EMPTY_STATE3 = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] }); -function normalizeState2(value) { - if (!value || typeof value !== "object") return structuredClone(EMPTY_STATE3); +import { mkdir as mkdir8, readFile as readFile8, rename as rename8, unlink as unlink10, writeFile as writeFile8 } from "node:fs/promises"; +import { dirname as dirname8 } from "node:path"; +var EMPTY_STATE4 = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] }); +function normalizeState3(value) { + if (!value || typeof value !== "object") return structuredClone(EMPTY_STATE4); const sessions = {}; if (value.sessions && typeof value.sessions === "object" && !Array.isArray(value.sessions)) { for (const [key, sessionId] of Object.entries(value.sessions)) { @@ -7321,17 +9111,17 @@ function normalizeState2(value) { } var QqStateStore = class { #path; - #state = structuredClone(EMPTY_STATE3); + #state = structuredClone(EMPTY_STATE4); #writeQueue = Promise.resolve(); constructor(path) { this.#path = path; } async load() { try { - this.#state = normalizeState2(JSON.parse(await readFile6(this.#path, "utf8"))); + this.#state = normalizeState3(JSON.parse(await readFile8(this.#path, "utf8"))); } catch (error) { if (error?.code !== "ENOENT") throw error; - this.#state = structuredClone(EMPTY_STATE3); + this.#state = structuredClone(EMPTY_STATE4); await this.#persist(); } return this; @@ -7363,20 +9153,20 @@ var QqStateStore = class { } async remove() { try { - await unlink7(this.#path); + await unlink10(this.#path); } catch (error) { if (error?.code !== "ENOENT") throw error; } - this.#state = structuredClone(EMPTY_STATE3); + this.#state = structuredClone(EMPTY_STATE4); } async #persist() { const snapshot = `${JSON.stringify(this.#state, null, 2)} `; const operation = this.#writeQueue.then(async () => { - await mkdir6(dirname6(this.#path), { recursive: true, mode: 448 }); + await mkdir8(dirname8(this.#path), { recursive: true, mode: 448 }); const temporary = `${this.#path}.tmp`; - await writeFile6(temporary, snapshot, { encoding: "utf8", mode: 384 }); - await rename6(temporary, this.#path); + await writeFile8(temporary, snapshot, { encoding: "utf8", mode: 384 }); + await rename8(temporary, this.#path); }); this.#writeQueue = operation.then(() => void 0, () => void 0); await operation; @@ -7384,11 +9174,11 @@ var QqStateStore = class { }; // plugin-src/host/channels/qq/connection-supervisor.mjs -var DEFAULT_RETRY_DELAYS_MS3 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); -function retryDelays2(value) { - if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS3]; - const valid = value.filter((delay2) => Number.isFinite(delay2) && delay2 >= 0); - return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS3]; +var DEFAULT_RETRY_DELAYS_MS4 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); +function retryDelays3(value) { + if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS4]; + const valid = value.filter((delay3) => Number.isFinite(delay3) && delay3 >= 0); + return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS4]; } var ConnectionSupervisor3 = class { #controller; @@ -7423,12 +9213,12 @@ var ConnectionSupervisor3 = class { this.#controller = controller; this.#harness = harness; this.#logger = logger; - this.#retryDelays = retryDelays2(retryDelaysMs); + this.#retryDelays = retryDelays3(retryDelaysMs); this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 ? healthyIntervalMs : 15e3; this.#setTimeout = setTimeoutImpl; this.#clearTimeout = clearTimeoutImpl; - this.#ready = new Promise((resolve6) => { - this.#resolveReady = resolve6; + this.#ready = new Promise((resolve7) => { + this.#resolveReady = resolve7; }); } get ready() { @@ -7477,20 +9267,20 @@ var ConnectionSupervisor3 = class { this.#resolveReady = null; const { configured, connected } = status.totals; if (connected < configured) { - const delay2 = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + const delay3 = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; this.#retryIndex += 1; - this.#logger.warn?.(`[dsh-im:qq] ${connected}/${configured} bots connected; retrying in ${delay2}ms`); - this.#schedule(delay2); + this.#logger.warn?.(`[dsh-im:qq] ${connected}/${configured} bots connected; retrying in ${delay3}ms`); + this.#schedule(delay3); return; } this.#retryIndex = 0; this.#schedule(this.#healthyIntervalMs); } catch (error) { if (this.#closed) return; - const delay2 = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + const delay3 = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; this.#retryIndex += 1; - this.#logger.warn?.(`[dsh-im:qq] connection reconciliation failed; retrying in ${delay2}ms`, error); - this.#schedule(delay2); + this.#logger.warn?.(`[dsh-im:qq] connection reconciliation failed; retrying in ${delay3}ms`, error); + this.#schedule(delay3); } } }; @@ -7499,7 +9289,7 @@ function createConnectionSupervisor3(options) { } // plugin-src/host/channels/qq/production.mjs -function harnessOrigin3(webServer, configured) { +function harnessOrigin4(webServer, configured) { if (configured !== void 0) return new URL(configured); const port = webServer?.port; if (!Number.isInteger(port) || port < 1 || port > 65535) { @@ -7507,15 +9297,15 @@ function harnessOrigin3(webServer, configured) { } return new URL(`http://127.0.0.1:${port}`); } -function pluginPaths3(config) { - const dshHome = resolve3(config.dshHome ?? process.env.DSH_HOME ?? join3(homedir3(), ".dsh")); - const root = resolve3(config.dataDir ?? join3(dshHome, "integrations", "dsh-qq")); +function pluginPaths4(config) { + const dshHome = resolve4(config.dshHome ?? process.env.DSH_HOME ?? join4(homedir4(), ".dsh")); + const root = resolve4(config.dataDir ?? join4(dshHome, "integrations", "dsh-qq")); return { - config: resolve3(config.configPath ?? join3(root, "config.json")), - bots: resolve3(config.botsDir ?? join3(root, "bots")) + config: resolve4(config.configPath ?? join4(root, "config.json")), + bots: resolve4(config.botsDir ?? join4(root, "bots")) }; } -async function createProductionController3(ctx, config = {}, internals = {}) { +async function createProductionController4(ctx, config = {}, internals = {}) { if (!ctx?.credentials) throw new TypeError("dsh-im QQ requires ctx.credentials"); if (!ctx?.webServer) throw new TypeError("dsh-im QQ requires ctx.webServer"); const ConfigStore = internals.ConfigStore ?? QqConfigStore; @@ -7526,11 +9316,11 @@ async function createProductionController3(ctx, config = {}, internals = {}) { const QrAuth = internals.QrAuth ?? QqQrAuth; const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor3; const logger = typeof ctx.logger === "function" ? ctx.logger("dsh-im:qq") : ctx.logger ?? console; - const paths = pluginPaths3(config); + const paths = pluginPaths4(config); const configStore = await new ConfigStore(paths.config).load(); const qrAuth = internals.qrAuth ?? new QrAuth({ source: config.qrSource ?? "deepseek-harness" }); const stateStores = /* @__PURE__ */ new Map(); - const statePath = (botId) => resolve3(paths.bots, botId, "state.json"); + const statePath = (botId) => resolve4(paths.bots, botId, "state.json"); const stateFor = async (botId) => { let state = stateStores.get(botId); if (!state) { @@ -7540,8 +9330,8 @@ async function createProductionController3(ctx, config = {}, internals = {}) { return state; }; const harness = new Harness({ - baseUrl: harnessOrigin3(ctx.webServer, config.harnessBaseUrl), - workspace: resolve3(config.workspace ?? process.cwd()), + baseUrl: harnessOrigin4(ctx.webServer, config.harnessBaseUrl), + workspace: resolve4(config.workspace ?? process.cwd()), agentPreset: config.agentPreset ?? "standard", autostart: false, dshBin: config.dshBin ?? "dsh" @@ -7570,7 +9360,7 @@ async function createProductionController3(ctx, config = {}, internals = {}) { stateStores.delete(botId); if (state && typeof state.remove === "function") return state.remove(); try { - await unlink8(statePath(botId)); + await unlink11(statePath(botId)); } catch (error) { if (error?.code !== "ENOENT") throw error; } @@ -7607,7 +9397,7 @@ var QQ_ENDPOINTS = Object.freeze({ deleteBot: "bot.delete" }); var QQ_RPC_ENDPOINTS = Object.freeze(Object.values(QQ_ENDPOINTS)); -var FORBIDDEN_PUBLIC_KEYS2 = /* @__PURE__ */ new Set([ +var FORBIDDEN_PUBLIC_KEYS3 = /* @__PURE__ */ new Set([ "appSecret", "app_secret", "secretRef", @@ -7615,44 +9405,44 @@ var FORBIDDEN_PUBLIC_KEYS2 = /* @__PURE__ */ new Set([ "userOpenid", "verificationUrl" ]); -function isRecord2(value) { +function isRecord3(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } -function exactKeys2(value, allowed) { - return isRecord2(value) && Object.keys(value).every((key) => allowed.includes(key)); +function exactKeys3(value, allowed) { + return isRecord3(value) && Object.keys(value).every((key) => allowed.includes(key)); } -function validId2(value) { +function validId3(value) { return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); } function validCredential3(value, maxLength) { return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength; } -function payloadFailure2(endpoint, payload) { - if (!isRecord2(payload)) return "Payload must be an object."; - if (endpoint === QQ_ENDPOINTS.status) return exactKeys2(payload, []) ? null : "connection.status does not accept fields."; +function payloadFailure3(endpoint, payload) { + if (!isRecord3(payload)) return "Payload must be an object."; + if (endpoint === QQ_ENDPOINTS.status) return exactKeys3(payload, []) ? null : "connection.status does not accept fields."; if (endpoint === QQ_ENDPOINTS.beginProvisioning) { - return exactKeys2(payload, ["locale"]) && (payload.locale === void 0 || payload.locale === "zh-CN") ? null : "provision.begin received unsupported fields."; + return exactKeys3(payload, ["locale"]) && (payload.locale === void 0 || payload.locale === "zh-CN") ? null : "provision.begin received unsupported fields."; } if ([QQ_ENDPOINTS.pollProvisioning, QQ_ENDPOINTS.cancelProvisioning].includes(endpoint)) { - return exactKeys2(payload, ["attemptId"]) && validId2(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; + return exactKeys3(payload, ["attemptId"]) && validId3(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; } if (endpoint === QQ_ENDPOINTS.bindCredentials) { - return exactKeys2(payload, ["appId", "appSecret"]) && validCredential3(payload.appId, 256) && validCredential3(payload.appSecret, 1024) ? null : "bot.bind-credentials requires AppID and AppSecret."; + return exactKeys3(payload, ["appId", "appSecret"]) && validCredential3(payload.appId, 256) && validCredential3(payload.appSecret, 1024) ? null : "bot.bind-credentials requires AppID and AppSecret."; } if (endpoint === QQ_ENDPOINTS.reconnectBot) { - return exactKeys2(payload, ["botId"]) && validId2(payload.botId) ? null : "bot.reconnect requires a botId."; + return exactKeys3(payload, ["botId"]) && validId3(payload.botId) ? null : "bot.reconnect requires a botId."; } if (endpoint === QQ_ENDPOINTS.deleteBot) { - return exactKeys2(payload, ["botId", "confirm"]) && validId2(payload.botId) && payload.confirm === true ? null : "bot.delete requires a botId and confirm=true."; + return exactKeys3(payload, ["botId", "confirm"]) && validId3(payload.botId) && payload.confirm === true ? null : "bot.delete requires a botId and confirm=true."; } return "Unknown QQ endpoint."; } -function sanitizePublic2(value) { - if (Array.isArray(value)) return value.map(sanitizePublic2); - if (!isRecord2(value)) return value; +function sanitizePublic3(value) { + if (Array.isArray(value)) return value.map(sanitizePublic3); + if (!isRecord3(value)) return value; const safe = {}; for (const [key, child] of Object.entries(value)) { - if (!FORBIDDEN_PUBLIC_KEYS2.has(key)) safe[key] = sanitizePublic2(child); + if (!FORBIDDEN_PUBLIC_KEYS3.has(key)) safe[key] = sanitizePublic3(child); } return safe; } @@ -7665,13 +9455,13 @@ async function qrDataUrl2(value) { }); } async function withEncodedQr2(value, encodeQr) { - if (!value || typeof value.verificationUrl !== "string") return sanitizePublic2(value); - return sanitizePublic2({ ...value, qrCodeDataUrl: await encodeQr(value.verificationUrl) }); + if (!value || typeof value.verificationUrl !== "string") return sanitizePublic3(value); + return sanitizePublic3({ ...value, qrCodeDataUrl: await encodeQr(value.verificationUrl) }); } async function publicStatus2(status, encodeQr) { const value = structuredClone(status); if (value?.provisioning) value.provisioning = await withEncodedQr2(value.provisioning, encodeQr); - return sanitizePublic2(value); + return sanitizePublic3(value); } function createQqRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) { for (const method of ["status", "startProvisioning", "registrationStatus", "cancelProvisioning", "bindCredentials", "reconnectBot", "deleteBot"]) { @@ -7690,7 +9480,7 @@ function createQqRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) { return async (endpoint, payload, signal) => { if (signal?.aborted) return { ok: false, error: { code: "cancelled", message: "The request was cancelled." } }; if (!QQ_RPC_ENDPOINTS.includes(endpoint)) return { ok: false, error: { code: "bad-request", message: "Unknown QQ endpoint." } }; - const invalid = payloadFailure2(endpoint, payload); + const invalid = payloadFailure3(endpoint, payload); if (invalid) return { ok: false, error: { code: "bad-request", message: invalid } }; try { let value; @@ -7701,7 +9491,7 @@ function createQqRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) { if (!current) return { ok: false, error: { code: "bad-request", message: "The provisioning attempt no longer exists." } }; value = await withEncodedQr2(current, cachedEncode); } else if (endpoint === QQ_ENDPOINTS.cancelProvisioning) { - value = sanitizePublic2(await controller.cancelProvisioning(payload.attemptId)); + value = sanitizePublic3(await controller.cancelProvisioning(payload.attemptId)); } else if (endpoint === QQ_ENDPOINTS.bindCredentials) { value = await publicStatus2(await controller.bindCredentials(payload), cachedEncode); } else if (endpoint === QQ_ENDPOINTS.reconnectBot) { @@ -7715,54 +9505,512 @@ function createQqRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) { } }; } -function installQqRpc(ctx, controller, options) { +function installQqRpc(ctx, controller, options, authority) { if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== "function") { throw new TypeError("DSH Host Connection RPC is required"); } - return ctx.connection.rpc.handle(QQ_RPC_CHANNEL, createQqRpcHandler(controller, options), { authority: "loopback" }); + return ctx.connection.rpc.handle( + QQ_RPC_CHANNEL, + createQqRpcHandler(controller, options), + { authority: resolveRpcAuthority(authority) } + ); } // plugin-src/host/channels/qq/index.mjs -async function apply3(ctx, config = {}) { - if (config?.controller) return installQqRpc(ctx, config.controller, config.rpcOptions); - const production = await createProductionController3(ctx, config, config.internals); - const disposeRpc = installQqRpc(ctx, production.controller, config.rpcOptions); +async function apply4(ctx, config = {}) { + if (config?.controller) { + return installQqRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority); + } + const production = await createProductionController4(ctx, config, config.internals); + const disposeRpc = installQqRpc( + ctx, + production.controller, + config.rpcOptions, + config.rpcAuthority + ); ctx.effect(() => async () => production.close(), "dsh-im: close QQ bot connections"); return disposeRpc; } +// src/channels/telegram/config-store.mjs +var IDENTITY_OPTIONS2 = Object.freeze({ + botPrefix: "telegram", + tokenRefPrefix: "DSH_TELEGRAM_BOT_TOKEN" +}); +function deriveTelegramBotIdentity(platformId) { + return deriveTokenBotIdentity(platformId, IDENTITY_OPTIONS2); +} +function maskTelegramBotId(platformId) { + return maskPlatformId(platformId, "Telegram\u673A\u5668\u4EBA"); +} +var TelegramConfigStore = class extends TokenBotConfigStore { + constructor(path) { + super(path, { channel: "Telegram", ...IDENTITY_OPTIONS2 }); + } +}; + +// src/channels/telegram/harness-client.mjs +var TelegramHarnessClient = class extends HarnessClient2 { +}; + +// src/channels/telegram/telegram-api.mjs +var DEFAULT_BASE_URL2 = "https://api.telegram.org/"; +function cleanString10(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function requestSignal2(signal, timeoutMs) { + const timeout = AbortSignal.timeout(timeoutMs); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} +function validTelegramToken(value) { + return typeof value === "string" && /^\d{5,20}:[A-Za-z0-9_-]{20,}$/.test(value.trim()); +} +var TelegramApi = class { + #token; + #fetch; + #baseUrl; + constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL2 }) { + if (!validTelegramToken(token)) throw new TypeError("Telegram Bot Token is invalid"); + if (typeof fetchImpl !== "function") throw new TypeError("TelegramApi requires fetch"); + this.#token = token.trim(); + this.#fetch = fetchImpl; + this.#baseUrl = new URL(baseUrl); + } + async getMe(options = {}) { + return this.#call("getMe", {}, options); + } + async getWebhookInfo(options = {}) { + return this.#call("getWebhookInfo", {}, options); + } + async getUpdates({ offset, timeout = 25, signal } = {}) { + const payload = { + timeout, + limit: 100, + allowed_updates: ["message"], + ...Number.isSafeInteger(offset) ? { offset } : {} + }; + return this.#call("getUpdates", payload, { + signal, + timeoutMs: Math.max(1e4, (timeout + 10) * 1e3) + }); + } + async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) { + return this.#call("sendMessage", { + chat_id: chatId, + text, + link_preview_options: { is_disabled: true }, + ...replyToMessageId ? { + reply_parameters: { message_id: replyToMessageId, allow_sending_without_reply: true } + } : {}, + ...messageThreadId ? { message_thread_id: messageThreadId } : {} + }, { signal }); + } + async editMessageText({ chatId, messageId, text, signal }) { + return this.#call("editMessageText", { + chat_id: chatId, + message_id: messageId, + text, + link_preview_options: { is_disabled: true } + }, { signal }); + } + async sendChatAction({ chatId, messageThreadId, signal }) { + return this.#call("sendChatAction", { + chat_id: chatId, + action: "typing", + ...messageThreadId ? { message_thread_id: messageThreadId } : {} + }, { signal }); + } + async #call(method, payload, { signal, timeoutMs = 15e3 } = {}) { + const url = new URL(this.#baseUrl); + url.pathname = `${url.pathname.replace(/\/$/, "")}/bot${this.#token}/${method}`; + let response; + try { + response = await this.#fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + signal: requestSignal2(signal, timeoutMs), + redirect: "error" + }); + } catch (error) { + if (error?.name === "AbortError" || error?.name === "TimeoutError") throw error; + throw new Error(`Telegram ${method} transport failed`); + } + let body; + try { + body = await response.json(); + } catch { + throw new Error(`Telegram ${method} returned invalid JSON`); + } + if (!response.ok || body?.ok !== true) { + const description = cleanString10(body?.description); + const error = new Error(description ?? `Telegram ${method} failed`); + error.code = Number.isInteger(body?.error_code) ? `telegram-${body.error_code}` : "telegram-api-error"; + throw error; + } + return body.result; + } +}; +async function inspectTelegramToken(token, options = {}) { + const api = new TelegramApi({ token, ...options }); + const bot = await api.getMe(); + if (!bot?.id || bot?.is_bot !== true) throw new Error("Telegram token does not belong to a bot"); + return { + platformId: String(bot.id), + name: cleanString10([bot.first_name, bot.last_name].filter(Boolean).join(" ")) ?? "Telegram\u673A\u5668\u4EBA", + username: cleanString10(bot.username) + }; +} + +// src/channels/telegram/telegram-bridge.mjs +var TELEGRAM_DESCRIPTOR = Object.freeze({ + key: "telegram", + label: "Telegram", + connectionLabel: " Bot API \u957F\u8F6E\u8BE2" +}); +var TelegramHarnessBridge = class extends TextHarnessBridge { + constructor(options) { + super({ descriptor: TELEGRAM_DESCRIPTOR, ...options }); + } +}; + +// src/channels/telegram/telegram-controller.mjs +var TelegramController = class extends TokenBotController { + constructor(options) { + super({ + ...options, + descriptor: TELEGRAM_DESCRIPTOR, + inspectToken: options.inspectToken ?? inspectTelegramToken, + deriveIdentity: deriveTelegramBotIdentity, + maskPlatformId: maskTelegramBotId + }); + } +}; + +// src/channels/telegram/telegram-runtime.mjs +function escaped(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +function mentionedUsername(message, username) { + if (!username || typeof message?.text !== "string" || !Array.isArray(message.entities)) return false; + return message.entities.some((entity) => { + if (entity?.type !== "mention" || !Number.isInteger(entity.offset) || !Number.isInteger(entity.length)) { + return false; + } + return message.text.slice(entity.offset, entity.offset + entity.length).toLowerCase() === `@${username.toLowerCase()}`; + }); +} +function withoutBotMention(text, username) { + if (!username || typeof text !== "string") return text; + return text.replace(new RegExp(`@${escaped(username)}\\b`, "ig"), "").trim(); +} +function normalizeTelegramUpdate(update, { botId, username }) { + const message = update?.message; + const chatId = message?.chat?.id; + const senderId = message?.from?.id; + const messageId = message?.message_id; + if (!Number.isSafeInteger(update?.update_id) || chatId === void 0 || senderId === void 0 || !Number.isSafeInteger(messageId)) return null; + if (!["private", "group", "supergroup"].includes(message.chat?.type)) return null; + const direct = message.chat.type === "private"; + const addressed = direct || String(message.reply_to_message?.from?.id ?? "") === String(botId) || mentionedUsername(message, username); + return { + messageId: String(update.update_id), + senderId: String(senderId), + senderIsBot: message.from?.is_bot === true, + kind: direct ? "direct" : "group", + conversationId: String(chatId), + content: withoutBotMention(message.text ?? message.caption ?? "", username), + addressed, + replyTarget: { + chatId, + replyToMessageId: messageId, + messageThreadId: Number.isSafeInteger(message.message_thread_id) ? message.message_thread_id : void 0 + } + }; +} +var TelegramBotClient = class { + #api; + #signal; + constructor({ api, signal }) { + this.#api = api; + this.#signal = signal; + } + async sendText(target, text) { + const chunks = splitMessageText(text, 4e3); + let result = null; + for (const [index, chunk] of chunks.entries()) { + result = await this.#api.sendMessage({ + chatId: target.chatId, + text: chunk, + replyToMessageId: index === 0 ? target.replyToMessageId : void 0, + messageThreadId: target.messageThreadId, + signal: this.#signal + }); + } + return result; + } + sendTyping(target) { + return this.#api.sendChatAction({ + chatId: target.chatId, + messageThreadId: target.messageThreadId, + signal: this.#signal + }); + } + async openStream(target) { + const stream = createEditableMessageStream({ + limit: 4e3, + create: async (text) => { + const message = await this.#api.sendMessage({ + chatId: target.chatId, + text, + replyToMessageId: target.replyToMessageId, + messageThreadId: target.messageThreadId, + signal: this.#signal + }); + return message.message_id; + }, + edit: (messageId, text) => this.#api.editMessageText({ + chatId: target.chatId, + messageId, + text, + signal: this.#signal + }), + sendRemainder: (text) => this.#api.sendMessage({ + chatId: target.chatId, + text, + messageThreadId: target.messageThreadId, + signal: this.#signal + }) + }); + return stream.start(); + } +}; +function createTelegramRuntimeStatus() { + return { + startedAt: null, + ready: false, + connectionState: "idle", + harnessReachable: false, + lastCheckedAt: null, + lastConnectedAt: null, + lastError: null, + ...createTextBridgeStatus() + }; +} +var TelegramRuntime = class { + #config; + #token; + #harness; + #state; + #logger; + #replyTimeoutMs; + #createApi; + #status = createTelegramRuntimeStatus(); + #api = null; + #bridge = null; + #abortController = null; + #pollTask = null; + #starting = null; + constructor({ + config, + token, + harness, + state, + logger = console, + replyTimeoutMs = 6e5, + createApi = (options) => new TelegramApi(options) + }) { + if (!config || !token || !harness || !state) { + throw new TypeError("TelegramRuntime requires config, token, Harness, and state"); + } + this.#config = config; + this.#token = token; + this.#harness = harness; + this.#state = state; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#createApi = createApi; + } + get status() { + return structuredClone(this.#status); + } + async start() { + if (this.#status.ready && this.#pollTask) return this.status; + if (this.#starting) return this.#starting; + this.#starting = this.#start().finally(() => { + this.#starting = null; + }); + return this.#starting; + } + async #start() { + await this.stop(); + this.#status.startedAt = (/* @__PURE__ */ new Date()).toISOString(); + this.#status.connectionState = "connecting"; + this.#status.lastError = null; + await this.#harness.ensureRunning(); + this.#status.harnessReachable = true; + const controller = new AbortController(); + this.#abortController = controller; + const api = this.#createApi({ token: this.#token }); + this.#api = api; + try { + const bot = await api.getMe({ signal: controller.signal }); + if (String(bot?.id ?? "") !== this.#config.platformId || bot?.is_bot !== true) { + throw new Error("Telegram token identity does not match the saved bot"); + } + const webhook = await api.getWebhookInfo({ signal: controller.signal }); + if (typeof webhook?.url === "string" && webhook.url) { + const error = new Error("\u8BE5 Telegram \u673A\u5668\u4EBA\u5DF2\u914D\u7F6E Webhook\uFF0C\u8BF7\u5148\u5728\u539F\u670D\u52A1\u4E2D\u79FB\u9664 Webhook \u540E\u91CD\u8BD5\u3002"); + error.code = "webhook-configured"; + throw error; + } + const client = new TelegramBotClient({ api, signal: controller.signal }); + this.#bridge = new TelegramHarnessBridge({ + bot: client, + harness: this.#harness, + state: this.#state, + status: this.#status, + logger: this.#logger, + replyTimeoutMs: this.#replyTimeoutMs + }); + let cursor = this.#state.cursor(); + if (cursor === null) { + const latest = await api.getUpdates({ offset: -1, timeout: 0, signal: controller.signal }); + cursor = latest.length > 0 ? latest.at(-1).update_id + 1 : 0; + await this.#state.setCursor(cursor); + } + const now = Date.now(); + this.#status.ready = true; + this.#status.connectionState = "connected"; + this.#status.lastCheckedAt = now; + this.#status.lastConnectedAt = now; + this.#pollTask = this.#poll(cursor, controller.signal); + this.#pollTask.catch((error) => { + if (controller.signal.aborted) return; + this.#status.ready = false; + this.#status.connectionState = "failed"; + this.#status.lastError = error?.message ?? String(error); + this.#logger.error?.(`[dsh-im:telegram] bot ${this.#config.botId} polling stopped:`, error); + }); + return this.status; + } catch (error) { + this.#status.ready = false; + this.#status.connectionState = "failed"; + this.#status.lastError = error?.message ?? String(error); + await this.stop(); + throw error; + } + } + async #poll(initialCursor, signal) { + let cursor = initialCursor; + while (!signal.aborted) { + const updates = await this.#api.getUpdates({ offset: cursor, timeout: 25, signal }); + this.#status.lastCheckedAt = Date.now(); + for (const update of updates) { + if (signal.aborted) return; + const message = normalizeTelegramUpdate(update, { + botId: this.#config.platformId, + username: this.#config.username + }); + if (message) await this.#bridge.accept(message); + cursor = update.update_id + 1; + await this.#state.setCursor(cursor); + } + } + } + async stop() { + const pollTask = this.#pollTask; + const bridge = this.#bridge; + this.#abortController?.abort(); + this.#abortController = null; + this.#pollTask = null; + this.#api = null; + this.#bridge = null; + await Promise.race([ + pollTask?.catch(() => void 0) ?? Promise.resolve(), + new Promise((resolve7) => setTimeout(resolve7, 2e3)) + ]); + await Promise.race([ + bridge?.waitForIdle() ?? Promise.resolve(), + new Promise((resolve7) => setTimeout(resolve7, 2e3)) + ]); + this.#status.ready = false; + this.#status.connectionState = "idle"; + return this.status; + } +}; + +// src/channels/telegram/state-store.mjs +var TelegramStateStore = class extends ConversationStateStore { +}; + +// plugin-src/host/channels/telegram/production.mjs +function createProductionController5(ctx, config = {}, internals = {}) { + return createTokenProductionController(ctx, config, internals, { + channel: "telegram", + ConfigStore: TelegramConfigStore, + StateStore: TelegramStateStore, + HarnessClient: TelegramHarnessClient, + Controller: TelegramController, + Runtime: TelegramRuntime + }); +} + +// plugin-src/host/channels/telegram/rpc.mjs +var TELEGRAM_RPC_CHANNEL = "/telegram"; +var TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS; +var TELEGRAM_RPC_ENDPOINTS = Object.freeze(Object.values(TELEGRAM_ENDPOINTS)); +function installTelegramRpc(ctx, controller, authority) { + return installTokenBotRpc(ctx, controller, { + channel: "Telegram", + rpcChannel: TELEGRAM_RPC_CHANNEL, + authority + }); +} + +// plugin-src/host/channels/telegram/index.mjs +async function apply5(ctx, config = {}) { + if (config?.controller) { + return installTelegramRpc(ctx, config.controller, config.rpcAuthority); + } + const production = await createProductionController5(ctx, config, config.internals ?? {}); + const disposeRpc = installTelegramRpc(ctx, production.controller, config.rpcAuthority); + ctx.effect(() => async () => production.close(), "dsh-im: close Telegram bot connections"); + return disposeRpc; +} + // plugin-src/host/channels/wecom/production.mjs -import { unlink as unlink11 } from "node:fs/promises"; -import { homedir as homedir4 } from "node:os"; -import { join as join4, resolve as resolve4 } from "node:path"; +import { unlink as unlink14 } from "node:fs/promises"; +import { homedir as homedir5 } from "node:os"; +import { join as join5, resolve as resolve5 } from "node:path"; // src/channels/wecom/config-store.mjs -import { createHash as createHash4 } from "node:crypto"; -import { mkdir as mkdir7, readFile as readFile7, rename as rename7, unlink as unlink9, writeFile as writeFile7 } from "node:fs/promises"; -import { dirname as dirname7 } from "node:path"; -var EMPTY_DOCUMENT3 = Object.freeze({ version: 1, bots: Object.freeze([]) }); -function cleanString7(value) { +import { createHash as createHash5 } from "node:crypto"; +import { mkdir as mkdir9, readFile as readFile9, rename as rename9, unlink as unlink12, writeFile as writeFile9 } from "node:fs/promises"; +import { dirname as dirname9 } from "node:path"; +var EMPTY_DOCUMENT4 = Object.freeze({ version: 1, bots: Object.freeze([]) }); +function cleanString11(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } function safeIntegrationId(value) { - const id = cleanString7(value); + const id = cleanString11(value); return id && /^wecom_[a-f0-9]{24}$/.test(id) ? id : null; } function safeSecretRef3(value) { - const ref = cleanString7(value); + const ref = cleanString11(value); return ref && /^DSH_WECOM_BOT_SECRET_[A-F0-9]{24}$/.test(ref) ? ref : null; } function deriveWecomBotIdentity(remoteBotId) { - const raw = cleanString7(remoteBotId); + const raw = cleanString11(remoteBotId); if (!raw) throw new TypeError("Enterprise WeChat bot ID is required"); - const digest2 = createHash4("sha256").update(raw).digest("hex").slice(0, 24); + const digest2 = createHash5("sha256").update(raw).digest("hex").slice(0, 24); return { botId: `wecom_${digest2}`, secretRef: `DSH_WECOM_BOT_SECRET_${digest2.toUpperCase()}` }; } function maskWecomBotId(remoteBotId) { - const value = cleanString7(remoteBotId) ?? ""; + const value = cleanString11(remoteBotId) ?? ""; if (!value) return "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA"; if (value.length <= 10) return `${value.slice(0, 3)}\u2022\u2022\u2022`; return `${value.slice(0, 6)}\u2022\u2022\u2022\u2022${value.slice(-4)}`; @@ -7770,7 +10018,7 @@ function maskWecomBotId(remoteBotId) { function normalizeBot4(value) { if (!value || typeof value !== "object") return null; const botId = safeIntegrationId(value.botId); - const remoteBotId = cleanString7(value.remoteBotId); + const remoteBotId = cleanString11(value.remoteBotId); const secretRef = safeSecretRef3(value.secretRef); if (!botId || !remoteBotId || !secretRef) return null; const derived = deriveWecomBotIdentity(remoteBotId); @@ -7779,8 +10027,8 @@ function normalizeBot4(value) { botId, remoteBotId, secretRef, - createdAt: cleanString7(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), - connectedAt: cleanString7(value.connectedAt) + createdAt: cleanString11(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: cleanString11(value.connectedAt) }); } function normalizeDocument4(value) { @@ -7800,19 +10048,19 @@ function normalizeDocument4(value) { } var WecomConfigStore = class { #path; - #value = EMPTY_DOCUMENT3; + #value = EMPTY_DOCUMENT4; #writeQueue = Promise.resolve(); constructor(path) { this.#path = path; } async load() { try { - const normalized = normalizeDocument4(JSON.parse(await readFile7(this.#path, "utf8"))); + const normalized = normalizeDocument4(JSON.parse(await readFile9(this.#path, "utf8"))); if (!normalized) throw new Error("dsh-im Enterprise WeChat config contains invalid bot data"); this.#value = normalized; } catch (error) { if (error?.code !== "ENOENT") throw error; - this.#value = EMPTY_DOCUMENT3; + this.#value = EMPTY_DOCUMENT4; } return this; } @@ -7856,11 +10104,11 @@ var WecomConfigStore = class { async clear() { const operation = this.#writeQueue.then(async () => { try { - await unlink9(this.#path); + await unlink12(this.#path); } catch (error) { if (error?.code !== "ENOENT") throw error; } - this.#value = EMPTY_DOCUMENT3; + this.#value = EMPTY_DOCUMENT4; }); this.#writeQueue = operation.then(() => void 0, () => void 0); await operation; @@ -7879,19 +10127,19 @@ var WecomConfigStore = class { return result; } async #write(document) { - await mkdir7(dirname7(this.#path), { recursive: true, mode: 448 }); + await mkdir9(dirname9(this.#path), { recursive: true, mode: 448 }); const temporary = `${this.#path}.tmp`; - await writeFile7(temporary, `${JSON.stringify(document, null, 2)} + await writeFile9(temporary, `${JSON.stringify(document, null, 2)} `, { encoding: "utf8", mode: 384 }); - await rename7(temporary, this.#path); + await rename9(temporary, this.#path); } }; // src/channels/wecom/harness-client.mjs -var WecomHarnessClient = class extends HarnessClient3 { +var WecomHarnessClient = class extends HarnessClient2 { }; // src/channels/wecom/qr-auth.mjs @@ -7904,11 +10152,11 @@ function defaultPlatform() { if (process.platform === "linux") return 3; return 1; } -function cleanString8(value) { +function cleanString12(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } function safeVerificationUrl(value) { - const raw = cleanString8(value); + const raw = cleanString12(value); if (!raw) return null; try { const url = new URL(raw); @@ -7952,7 +10200,7 @@ var WecomQrAuth = class { url.searchParams.set("source", this.#source); url.searchParams.set("plat", String(this.#platform)); const body = await requestJson2(this.#fetch, url, signal); - const scode = cleanString8(body?.data?.scode); + const scode = cleanString12(body?.data?.scode); const verificationUrl = safeVerificationUrl(body?.data?.auth_url); if (!scode || !verificationUrl) throw new Error("Enterprise WeChat QR service returned invalid data"); return { @@ -7963,15 +10211,15 @@ var WecomQrAuth = class { }; } async poll({ scode, signal } = {}) { - const code = cleanString8(scode); + const code = cleanString12(scode); if (!code) throw new TypeError("Enterprise WeChat QR poll code is required"); const url = new URL(POLL_URL); url.searchParams.set("scode", code); const body = await requestJson2(this.#fetch, url, signal); - const state = cleanString8(body?.data?.status)?.toLowerCase(); + const state = cleanString12(body?.data?.status)?.toLowerCase(); if (state === "success") { - const remoteBotId = cleanString8(body?.data?.bot_info?.botid); - const secret = cleanString8(body?.data?.bot_info?.secret); + const remoteBotId = cleanString12(body?.data?.bot_info?.botid); + const secret = cleanString12(body?.data?.bot_info?.secret); if (!remoteBotId || !secret) throw new Error("Enterprise WeChat QR result omitted bot credentials"); return { status: "success", remoteBotId, secret }; } @@ -7982,11 +10230,11 @@ var WecomQrAuth = class { }; // src/channels/wecom/state-store.mjs -import { mkdir as mkdir8, readFile as readFile8, rename as rename8, unlink as unlink10, writeFile as writeFile8 } from "node:fs/promises"; -import { dirname as dirname8 } from "node:path"; -var EMPTY_STATE4 = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] }); -function normalizeState3(value) { - if (!value || typeof value !== "object") return structuredClone(EMPTY_STATE4); +import { mkdir as mkdir10, readFile as readFile10, rename as rename10, unlink as unlink13, writeFile as writeFile10 } from "node:fs/promises"; +import { dirname as dirname10 } from "node:path"; +var EMPTY_STATE5 = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] }); +function normalizeState4(value) { + if (!value || typeof value !== "object") return structuredClone(EMPTY_STATE5); const sessions = {}; if (value.sessions && typeof value.sessions === "object" && !Array.isArray(value.sessions)) { for (const [key, sessionId] of Object.entries(value.sessions)) { @@ -8001,17 +10249,17 @@ function normalizeState3(value) { } var WecomStateStore = class { #path; - #state = structuredClone(EMPTY_STATE4); + #state = structuredClone(EMPTY_STATE5); #writeQueue = Promise.resolve(); constructor(path) { this.#path = path; } async load() { try { - this.#state = normalizeState3(JSON.parse(await readFile8(this.#path, "utf8"))); + this.#state = normalizeState4(JSON.parse(await readFile10(this.#path, "utf8"))); } catch (error) { if (error?.code !== "ENOENT") throw error; - this.#state = structuredClone(EMPTY_STATE4); + this.#state = structuredClone(EMPTY_STATE5); await this.#persist(); } return this; @@ -8040,20 +10288,20 @@ var WecomStateStore = class { } async remove() { try { - await unlink10(this.#path); + await unlink13(this.#path); } catch (error) { if (error?.code !== "ENOENT") throw error; } - this.#state = structuredClone(EMPTY_STATE4); + this.#state = structuredClone(EMPTY_STATE5); } async #persist() { const snapshot = `${JSON.stringify(this.#state, null, 2)} `; const operation = this.#writeQueue.then(async () => { - await mkdir8(dirname8(this.#path), { recursive: true, mode: 448 }); + await mkdir10(dirname10(this.#path), { recursive: true, mode: 448 }); const temporary = `${this.#path}.tmp`; - await writeFile8(temporary, snapshot, { encoding: "utf8", mode: 384 }); - await rename8(temporary, this.#path); + await writeFile10(temporary, snapshot, { encoding: "utf8", mode: 384 }); + await rename10(temporary, this.#path); }); this.#writeQueue = operation.then(() => void 0, () => void 0); await operation; @@ -8064,10 +10312,10 @@ var WecomStateStore = class { import { randomUUID as randomUUID10 } from "node:crypto"; var ACTIVE_ATTEMPT_STATES3 = /* @__PURE__ */ new Set(["pending", "connecting"]); var TERMINAL_ATTEMPT_STATES3 = /* @__PURE__ */ new Set(["connected", "failed", "cancelled", "expired"]); -function cleanString9(value) { +function cleanString13(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } -function safeError3(code, message) { +function safeError4(code, message) { return Object.freeze({ code, message }); } function publicAttempt3(record) { @@ -8131,14 +10379,14 @@ var WecomController = class { if (this.#closed || existing?.ready || existing?.wecomConnectionState === "connecting") return; const secret = await this.#resolveSecret(config.secretRef); if (!secret) { - this.#errors.set(config.botId, safeError3("missing-secret", "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u51ED\u636E\u7F3A\u5931\uFF0C\u8BF7\u79FB\u9664\u540E\u91CD\u65B0\u626B\u7801\u3002")); + this.#errors.set(config.botId, safeError4("missing-secret", "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u51ED\u636E\u7F3A\u5931\uFF0C\u8BF7\u79FB\u9664\u540E\u91CD\u65B0\u626B\u7801\u3002")); return; } try { await this.#startRuntime(config, secret); this.#errors.delete(config.botId); } catch (error) { - this.#errors.set(config.botId, safeError3("connection-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u91CD\u8BD5\u3002")); + this.#errors.set(config.botId, safeError4("connection-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u91CD\u8BD5\u3002")); this.#logger.warn?.(`[dsh-im:wecom] bot ${config.botId} failed to initialize`); } finally { this.#touch(); @@ -8170,8 +10418,8 @@ var WecomController = class { this.#touch(); try { const started = await this.#qrAuth.start({ signal: record.controller.signal }); - record.scode = cleanString9(started.scode); - record.verificationUrl = cleanString9(started.verificationUrl); + record.scode = cleanString13(started.scode); + record.verificationUrl = cleanString13(started.verificationUrl); record.expiresAt = Number(started.expiresAt); record.pollIntervalMs = Math.min(1e4, Math.max(500, Number(started.pollIntervalMs) || 3e3)); if (!record.scode || !record.verificationUrl || !Number.isFinite(record.expiresAt)) { @@ -8181,7 +10429,7 @@ var WecomController = class { return publicAttempt3(record); } catch (error) { record.state = record.controller.signal.aborted ? "cancelled" : "failed"; - record.error = record.controller.signal.aborted ? safeError3("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002") : safeError3("qr-start-failed", "\u65E0\u6CD5\u751F\u6210\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"); + record.error = record.controller.signal.aborted ? safeError4("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002") : safeError4("qr-start-failed", "\u65E0\u6CD5\u751F\u6210\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002"); this.#finishAttempt(record); throw error; } @@ -8195,7 +10443,7 @@ var WecomController = class { } if (Date.now() >= record.expiresAt) { record.state = "expired"; - record.error = safeError3("expired", "\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"); + record.error = safeError4("expired", "\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"); record.controller.abort(); this.#finishAttempt(record); return publicAttempt3(record); @@ -8211,8 +10459,8 @@ var WecomController = class { } async bindCredentials({ botId, secret } = {}) { if (this.#closed) throw new Error("Enterprise WeChat controller is closed"); - const remoteBotId = cleanString9(botId); - const normalizedSecret = cleanString9(secret); + const remoteBotId = cleanString13(botId); + const normalizedSecret = cleanString13(secret); if (!remoteBotId || !normalizedSecret) { throw new TypeError("Enterprise WeChat Bot ID and Secret are required"); } @@ -8244,7 +10492,7 @@ var WecomController = class { } catch { this.#errors.set( identity.botId, - safeError3("connection-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002") + safeError4("connection-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002") ); this.#logger.warn?.(`[dsh-im:wecom] bot ${identity.botId} credential connection failed`); } @@ -8259,7 +10507,7 @@ var WecomController = class { record.controller.abort(); await Promise.allSettled([record.polling, record.transition].filter(Boolean)); if (!TERMINAL_ATTEMPT_STATES3.has(record.state)) record.state = "cancelled"; - record.error ??= safeError3("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); + record.error ??= safeError4("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); this.#finishAttempt(record); } return publicAttempt3(record); @@ -8274,7 +10522,7 @@ var WecomController = class { await this.#startRuntime(config, secret); this.#errors.delete(botId); } catch (error) { - this.#errors.set(botId, safeError3("connection-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u4ECD\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002")); + this.#errors.set(botId, safeError4("connection-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u8FDE\u63A5\u4ECD\u672A\u5C31\u7EEA\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002")); throw error; } finally { this.#touch(); @@ -8355,13 +10603,13 @@ var WecomController = class { if (result.status === "waiting") return; if (result.status === "expired") { record.state = "expired"; - record.error = safeError3("expired", "\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"); + record.error = safeError4("expired", "\u4F01\u4E1A\u5FAE\u4FE1\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"); this.#finishAttempt(record); return; } if (result.status !== "success") { record.state = "failed"; - record.error = safeError3("qr-connect-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u626B\u7801\u6CA1\u6709\u5B8C\u6210\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801\u3002"); + record.error = safeError4("qr-connect-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u626B\u7801\u6CA1\u6709\u5B8C\u6210\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801\u3002"); this.#finishAttempt(record); return; } @@ -8376,15 +10624,15 @@ var WecomController = class { } catch (error) { if (record.controller.signal.aborted) return; record.state = "failed"; - record.error = safeError3("qr-connect-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u626B\u7801\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801\u3002"); + record.error = safeError4("qr-connect-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u626B\u7801\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801\u3002"); this.#logger.warn?.("[dsh-im:wecom] QR polling failed"); this.#finishAttempt(record); } } async #completeProvisioning(record, result) { try { - const remoteBotId = cleanString9(result.remoteBotId); - const secret = cleanString9(result.secret); + const remoteBotId = cleanString13(result.remoteBotId); + const secret = cleanString13(result.secret); if (!remoteBotId || !secret) throw new Error("Enterprise WeChat authorization returned incomplete credentials"); record.botId = await this.#activateBot(record, { remoteBotId, secret }); record.state = "connected"; @@ -8392,10 +10640,10 @@ var WecomController = class { } catch (error) { if (record.controller.signal.aborted) { record.state = "cancelled"; - record.error = safeError3("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); + record.error = safeError4("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); } else { record.state = "failed"; - record.error = safeError3("activation-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u65E0\u6CD5\u5B89\u5168\u4FDD\u5B58\u63A5\u5165\u914D\u7F6E\u3002"); + record.error = safeError4("activation-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u65E0\u6CD5\u5B89\u5168\u4FDD\u5B58\u63A5\u5165\u914D\u7F6E\u3002"); this.#logger.error?.("[dsh-im:wecom] provisioning failed"); } } finally { @@ -8434,7 +10682,7 @@ var WecomController = class { await this.#restoreCredential(identity.secretRef, previousSecret); throw error; } - this.#errors.set(identity.botId, safeError3("connection-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002")); + this.#errors.set(identity.botId, safeError4("connection-failed", "\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u5DF2\u7ED1\u5B9A\uFF0C\u6D88\u606F\u8FDE\u63A5\u6682\u672A\u5C31\u7EEA\u3002")); this.#logger.warn?.(`[dsh-im:wecom] bot ${identity.botId} activation connection failed`); } this.#touch(); @@ -8467,7 +10715,7 @@ var WecomController = class { } async #resolveSecret(ref) { const result = await this.#credentials.resolve(ref).catch(() => void 0); - return cleanString9(result?.value); + return cleanString13(result?.value); } async #restoreCredential(ref, previous) { if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => void 0); @@ -8820,8 +11068,8 @@ var WecomRuntime = class { let readyResolve; let readyReject; let authenticated = false; - const ready = new Promise((resolve6, reject) => { - readyResolve = resolve6; + const ready = new Promise((resolve7, reject) => { + readyResolve = resolve7; readyReject = reject; }); const onAuthenticated = () => { @@ -8916,11 +11164,11 @@ var WecomRuntime = class { }; // plugin-src/host/channels/wecom/connection-supervisor.mjs -var DEFAULT_RETRY_DELAYS_MS4 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); -function retryDelays3(value) { - if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS4]; - const valid = value.filter((delay2) => Number.isFinite(delay2) && delay2 >= 0); - return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS4]; +var DEFAULT_RETRY_DELAYS_MS5 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); +function retryDelays4(value) { + if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS5]; + const valid = value.filter((delay3) => Number.isFinite(delay3) && delay3 >= 0); + return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS5]; } var ConnectionSupervisor4 = class { #controller; @@ -8955,12 +11203,12 @@ var ConnectionSupervisor4 = class { this.#controller = controller; this.#harness = harness; this.#logger = logger; - this.#retryDelays = retryDelays3(retryDelaysMs); + this.#retryDelays = retryDelays4(retryDelaysMs); this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 ? healthyIntervalMs : 15e3; this.#setTimeout = setTimeoutImpl; this.#clearTimeout = clearTimeoutImpl; - this.#ready = new Promise((resolve6) => { - this.#resolveReady = resolve6; + this.#ready = new Promise((resolve7) => { + this.#resolveReady = resolve7; }); } get ready() { @@ -9009,20 +11257,20 @@ var ConnectionSupervisor4 = class { this.#resolveReady = null; const { configured, connected } = status.totals; if (connected < configured) { - const delay2 = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + const delay3 = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; this.#retryIndex += 1; - this.#logger.warn?.(`[dsh-im:wecom] ${connected}/${configured} bots connected; retrying in ${delay2}ms`); - this.#schedule(delay2); + this.#logger.warn?.(`[dsh-im:wecom] ${connected}/${configured} bots connected; retrying in ${delay3}ms`); + this.#schedule(delay3); return; } this.#retryIndex = 0; this.#schedule(this.#healthyIntervalMs); } catch (error) { if (this.#closed) return; - const delay2 = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + const delay3 = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; this.#retryIndex += 1; - this.#logger.warn?.(`[dsh-im:wecom] connection reconciliation failed; retrying in ${delay2}ms`, error); - this.#schedule(delay2); + this.#logger.warn?.(`[dsh-im:wecom] connection reconciliation failed; retrying in ${delay3}ms`, error); + this.#schedule(delay3); } } }; @@ -9031,7 +11279,7 @@ function createConnectionSupervisor4(options) { } // plugin-src/host/channels/wecom/production.mjs -function harnessOrigin4(webServer, configured) { +function harnessOrigin5(webServer, configured) { if (configured !== void 0) return new URL(configured); const port = webServer?.port; if (!Number.isInteger(port) || port < 1 || port > 65535) { @@ -9039,15 +11287,15 @@ function harnessOrigin4(webServer, configured) { } return new URL(`http://127.0.0.1:${port}`); } -function pluginPaths4(config) { - const dshHome = resolve4(config.dshHome ?? process.env.DSH_HOME ?? join4(homedir4(), ".dsh")); - const root = resolve4(config.dataDir ?? join4(dshHome, "integrations", "dsh-wecom")); +function pluginPaths5(config) { + const dshHome = resolve5(config.dshHome ?? process.env.DSH_HOME ?? join5(homedir5(), ".dsh")); + const root = resolve5(config.dataDir ?? join5(dshHome, "integrations", "dsh-wecom")); return { - config: resolve4(config.configPath ?? join4(root, "config.json")), - bots: resolve4(config.botsDir ?? join4(root, "bots")) + config: resolve5(config.configPath ?? join5(root, "config.json")), + bots: resolve5(config.botsDir ?? join5(root, "bots")) }; } -async function createProductionController4(ctx, config = {}, internals = {}) { +async function createProductionController6(ctx, config = {}, internals = {}) { if (!ctx?.credentials) throw new TypeError("dsh-im Enterprise WeChat requires ctx.credentials"); if (!ctx?.webServer) throw new TypeError("dsh-im Enterprise WeChat requires ctx.webServer"); const ConfigStore = internals.ConfigStore ?? WecomConfigStore; @@ -9058,14 +11306,14 @@ async function createProductionController4(ctx, config = {}, internals = {}) { const QrAuth = internals.QrAuth ?? WecomQrAuth; const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor4; const logger = typeof ctx.logger === "function" ? ctx.logger("dsh-im:wecom") : ctx.logger ?? console; - const paths = pluginPaths4(config); + const paths = pluginPaths5(config); const configStore = await new ConfigStore(paths.config).load(); const qrAuth = internals.qrAuth ?? new QrAuth({ source: config.qrSource ?? "deepseek-harness", platform: config.qrPlatform }); const stateStores = /* @__PURE__ */ new Map(); - const statePath = (botId) => resolve4(paths.bots, botId, "state.json"); + const statePath = (botId) => resolve5(paths.bots, botId, "state.json"); const stateFor = async (botId) => { let state = stateStores.get(botId); if (!state) { @@ -9075,8 +11323,8 @@ async function createProductionController4(ctx, config = {}, internals = {}) { return state; }; const harness = new Harness({ - baseUrl: harnessOrigin4(ctx.webServer, config.harnessBaseUrl), - workspace: resolve4(config.workspace ?? process.cwd()), + baseUrl: harnessOrigin5(ctx.webServer, config.harnessBaseUrl), + workspace: resolve5(config.workspace ?? process.cwd()), agentPreset: config.agentPreset ?? "standard", autostart: false, dshBin: config.dshBin ?? "dsh" @@ -9106,7 +11354,7 @@ async function createProductionController4(ctx, config = {}, internals = {}) { stateStores.delete(botId); if (state && typeof state.remove === "function") return state.remove(); try { - await unlink11(statePath(botId)); + await unlink14(statePath(botId)); } catch (error) { if (error?.code !== "ENOENT") throw error; } @@ -9143,7 +11391,7 @@ var WECOM_ENDPOINTS = Object.freeze({ deleteBot: "bot.delete" }); var WECOM_RPC_ENDPOINTS = Object.freeze(Object.values(WECOM_ENDPOINTS)); -var FORBIDDEN_PUBLIC_KEYS3 = /* @__PURE__ */ new Set([ +var FORBIDDEN_PUBLIC_KEYS4 = /* @__PURE__ */ new Set([ "secret", "secretRef", "scode", @@ -9152,44 +11400,44 @@ var FORBIDDEN_PUBLIC_KEYS3 = /* @__PURE__ */ new Set([ "bot_info", "botid" ]); -function isRecord3(value) { +function isRecord4(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } -function exactKeys3(value, allowed) { - return isRecord3(value) && Object.keys(value).every((key) => allowed.includes(key)); +function exactKeys4(value, allowed) { + return isRecord4(value) && Object.keys(value).every((key) => allowed.includes(key)); } -function validId3(value) { +function validId4(value) { return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); } function validCredential4(value, maxLength) { return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength; } -function payloadFailure3(endpoint, payload) { - if (!isRecord3(payload)) return "Payload must be an object."; - if (endpoint === WECOM_ENDPOINTS.status) return exactKeys3(payload, []) ? null : "connection.status does not accept fields."; +function payloadFailure4(endpoint, payload) { + if (!isRecord4(payload)) return "Payload must be an object."; + if (endpoint === WECOM_ENDPOINTS.status) return exactKeys4(payload, []) ? null : "connection.status does not accept fields."; if (endpoint === WECOM_ENDPOINTS.beginProvisioning) { - return exactKeys3(payload, ["locale"]) && (payload.locale === void 0 || payload.locale === "zh-CN") ? null : "provision.begin received unsupported fields."; + return exactKeys4(payload, ["locale"]) && (payload.locale === void 0 || payload.locale === "zh-CN") ? null : "provision.begin received unsupported fields."; } if ([WECOM_ENDPOINTS.pollProvisioning, WECOM_ENDPOINTS.cancelProvisioning].includes(endpoint)) { - return exactKeys3(payload, ["attemptId"]) && validId3(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; + return exactKeys4(payload, ["attemptId"]) && validId4(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; } if (endpoint === WECOM_ENDPOINTS.bindCredentials) { - return exactKeys3(payload, ["botId", "secret"]) && validCredential4(payload.botId, 512) && validCredential4(payload.secret, 1024) ? null : "bot.bind-credentials requires Bot ID and Secret."; + return exactKeys4(payload, ["botId", "secret"]) && validCredential4(payload.botId, 512) && validCredential4(payload.secret, 1024) ? null : "bot.bind-credentials requires Bot ID and Secret."; } if (endpoint === WECOM_ENDPOINTS.reconnectBot) { - return exactKeys3(payload, ["botId"]) && validId3(payload.botId) ? null : "bot.reconnect requires a botId."; + return exactKeys4(payload, ["botId"]) && validId4(payload.botId) ? null : "bot.reconnect requires a botId."; } if (endpoint === WECOM_ENDPOINTS.deleteBot) { - return exactKeys3(payload, ["botId", "confirm"]) && validId3(payload.botId) && payload.confirm === true ? null : "bot.delete requires a botId and confirm=true."; + return exactKeys4(payload, ["botId", "confirm"]) && validId4(payload.botId) && payload.confirm === true ? null : "bot.delete requires a botId and confirm=true."; } return "Unknown Enterprise WeChat endpoint."; } -function sanitizePublic3(value) { - if (Array.isArray(value)) return value.map(sanitizePublic3); - if (!isRecord3(value)) return value; +function sanitizePublic4(value) { + if (Array.isArray(value)) return value.map(sanitizePublic4); + if (!isRecord4(value)) return value; const safe = {}; for (const [key, child] of Object.entries(value)) { - if (!FORBIDDEN_PUBLIC_KEYS3.has(key)) safe[key] = sanitizePublic3(child); + if (!FORBIDDEN_PUBLIC_KEYS4.has(key)) safe[key] = sanitizePublic4(child); } return safe; } @@ -9202,13 +11450,13 @@ async function qrDataUrl3(value) { }); } async function withEncodedQr3(value, encodeQr) { - if (!value || typeof value.verificationUrl !== "string") return sanitizePublic3(value); - return sanitizePublic3({ ...value, qrCodeDataUrl: await encodeQr(value.verificationUrl) }); + if (!value || typeof value.verificationUrl !== "string") return sanitizePublic4(value); + return sanitizePublic4({ ...value, qrCodeDataUrl: await encodeQr(value.verificationUrl) }); } async function publicStatus3(status, encodeQr) { const value = structuredClone(status); if (value?.provisioning) value.provisioning = await withEncodedQr3(value.provisioning, encodeQr); - return sanitizePublic3(value); + return sanitizePublic4(value); } function createWecomRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) { for (const method of ["status", "startProvisioning", "registrationStatus", "cancelProvisioning", "bindCredentials", "reconnectBot", "deleteBot"]) { @@ -9231,7 +11479,7 @@ function createWecomRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) { if (!WECOM_RPC_ENDPOINTS.includes(endpoint)) { return { ok: false, error: { code: "bad-request", message: "Unknown Enterprise WeChat endpoint." } }; } - const invalid = payloadFailure3(endpoint, payload); + const invalid = payloadFailure4(endpoint, payload); if (invalid) return { ok: false, error: { code: "bad-request", message: invalid } }; try { let value; @@ -9243,7 +11491,7 @@ function createWecomRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) { if (!current) return { ok: false, error: { code: "bad-request", message: "The provisioning attempt no longer exists." } }; value = await withEncodedQr3(current, cachedEncode); } else if (endpoint === WECOM_ENDPOINTS.cancelProvisioning) { - value = sanitizePublic3(await controller.cancelProvisioning(payload.attemptId)); + value = sanitizePublic4(await controller.cancelProvisioning(payload.attemptId)); } else if (endpoint === WECOM_ENDPOINTS.bindCredentials) { value = await publicStatus3(await controller.bindCredentials(payload), cachedEncode); } else if (endpoint === WECOM_ENDPOINTS.reconnectBot) { @@ -9257,35 +11505,42 @@ function createWecomRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) { } }; } -function installWecomRpc(ctx, controller, options) { +function installWecomRpc(ctx, controller, options, authority) { if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== "function") { throw new TypeError("DSH Host Connection RPC is required"); } return ctx.connection.rpc.handle( WECOM_RPC_CHANNEL, createWecomRpcHandler(controller, options), - { authority: "loopback" } + { authority: resolveRpcAuthority(authority) } ); } // plugin-src/host/channels/wecom/index.mjs -async function apply4(ctx, config = {}) { - if (config?.controller) return installWecomRpc(ctx, config.controller, config.rpcOptions); - const production = await createProductionController4(ctx, config, config.internals); - const disposeRpc = installWecomRpc(ctx, production.controller, config.rpcOptions); +async function apply6(ctx, config = {}) { + if (config?.controller) { + return installWecomRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority); + } + const production = await createProductionController6(ctx, config, config.internals); + const disposeRpc = installWecomRpc( + ctx, + production.controller, + config.rpcOptions, + config.rpcAuthority + ); ctx.effect(() => async () => production.close(), "dsh-im: close Enterprise WeChat bot connections"); return disposeRpc; } // plugin-src/host/channels/weixin/production.mjs -import { unlink as unlink14 } from "node:fs/promises"; -import { homedir as homedir5 } from "node:os"; -import { join as join5, resolve as resolve5 } from "node:path"; +import { unlink as unlink17 } from "node:fs/promises"; +import { homedir as homedir6 } from "node:os"; +import { join as join6, resolve as resolve6 } from "node:path"; // src/channels/weixin/config-store.mjs -import { createHash as createHash5 } from "node:crypto"; -import { mkdir as mkdir9, readFile as readFile9, rename as rename9, unlink as unlink12, writeFile as writeFile9 } from "node:fs/promises"; -import { dirname as dirname9 } from "node:path"; +import { createHash as createHash6 } from "node:crypto"; +import { mkdir as mkdir11, readFile as readFile11, rename as rename11, unlink as unlink15, writeFile as writeFile11 } from "node:fs/promises"; +import { dirname as dirname11 } from "node:path"; // src/channels/weixin/weixin-api.mjs import { randomBytes, randomUUID as randomUUID11 } from "node:crypto"; @@ -9583,36 +11838,36 @@ function splitWeixinText(text, maxChars = 4e3) { } // src/channels/weixin/config-store.mjs -var EMPTY_DOCUMENT4 = Object.freeze({ version: 1, accounts: Object.freeze([]) }); -function cleanString10(value) { +var EMPTY_DOCUMENT5 = Object.freeze({ version: 1, accounts: Object.freeze([]) }); +function cleanString14(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } function safeBotId3(value) { - const id = cleanString10(value); + const id = cleanString14(value); return id && /^wx_[a-f0-9]{24}$/.test(id) ? id : null; } function safeTokenRef(value) { - const ref = cleanString10(value); + const ref = cleanString14(value); return ref && /^DSH_WEIXIN_BOT_TOKEN_[A-F0-9]{24}$/.test(ref) ? ref : null; } function deriveWeixinBotIdentity(accountId) { - const raw = cleanString10(accountId); + const raw = cleanString14(accountId); if (!raw) throw new TypeError("accountId is required"); - const digest2 = createHash5("sha256").update(raw).digest("hex").slice(0, 24); + const digest2 = createHash6("sha256").update(raw).digest("hex").slice(0, 24); return { botId: `wx_${digest2}`, tokenRef: `DSH_WEIXIN_BOT_TOKEN_${digest2.toUpperCase()}` }; } function maskWeixinAccountId(accountId) { - const value = cleanString10(accountId) ?? ""; + const value = cleanString14(accountId) ?? ""; if (value.length <= 10) return value ? `${value.slice(0, 3)}\u2022\u2022\u2022` : "\u5FAE\u4FE1\u673A\u5668\u4EBA"; return `${value.slice(0, 6)}\u2022\u2022\u2022\u2022${value.slice(-4)}`; } function normalizeAccount(value) { if (!value || typeof value !== "object") return null; - const accountId = cleanString10(value.accountId); - const ownerUserId = cleanString10(value.ownerUserId); + const accountId = cleanString14(value.accountId); + const ownerUserId = cleanString14(value.ownerUserId); const botId = safeBotId3(value.botId); const tokenRef = safeTokenRef(value.tokenRef); if (!accountId || !ownerUserId || !botId || !tokenRef) return null; @@ -9630,8 +11885,8 @@ function normalizeAccount(value) { tokenRef, ownerUserId, baseUrl, - createdAt: cleanString10(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), - connectedAt: cleanString10(value.connectedAt) + createdAt: cleanString14(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: cleanString14(value.connectedAt) }); } function normalizeDocument5(value) { @@ -9653,19 +11908,19 @@ function normalizeDocument5(value) { } var WeixinConfigStore = class { #path; - #value = EMPTY_DOCUMENT4; + #value = EMPTY_DOCUMENT5; #writeQueue = Promise.resolve(); constructor(path) { this.#path = path; } async load() { try { - const normalized = normalizeDocument5(JSON.parse(await readFile9(this.#path, "utf8"))); + const normalized = normalizeDocument5(JSON.parse(await readFile11(this.#path, "utf8"))); if (!normalized) throw new Error("dsh-weixin config contains invalid account data"); this.#value = normalized; } catch (error) { if (error?.code !== "ENOENT") throw error; - this.#value = EMPTY_DOCUMENT4; + this.#value = EMPTY_DOCUMENT5; } return this; } @@ -9709,11 +11964,11 @@ var WeixinConfigStore = class { async clear() { const operation = this.#writeQueue.then(async () => { try { - await unlink12(this.#path); + await unlink15(this.#path); } catch (error) { if (error?.code !== "ENOENT") throw error; } - this.#value = EMPTY_DOCUMENT4; + this.#value = EMPTY_DOCUMENT5; }); this.#writeQueue = operation.then(() => void 0, () => void 0); await operation; @@ -9732,28 +11987,28 @@ var WeixinConfigStore = class { return result; } async #write(document) { - await mkdir9(dirname9(this.#path), { recursive: true, mode: 448 }); + await mkdir11(dirname11(this.#path), { recursive: true, mode: 448 }); const temporary = `${this.#path}.tmp`; - await writeFile9(temporary, `${JSON.stringify(document, null, 2)} + await writeFile11(temporary, `${JSON.stringify(document, null, 2)} `, { encoding: "utf8", mode: 384 }); - await rename9(temporary, this.#path); + await rename11(temporary, this.#path); } }; // src/channels/weixin/state-store.mjs -import { mkdir as mkdir10, readFile as readFile10, rename as rename10, unlink as unlink13, writeFile as writeFile10 } from "node:fs/promises"; -import { dirname as dirname10 } from "node:path"; -var EMPTY_STATE5 = Object.freeze({ +import { mkdir as mkdir12, readFile as readFile12, rename as rename12, unlink as unlink16, writeFile as writeFile12 } from "node:fs/promises"; +import { dirname as dirname12 } from "node:path"; +var EMPTY_STATE6 = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [], getUpdatesBuf: "" }); -function normalizeState4(value) { - if (!value || typeof value !== "object") return structuredClone(EMPTY_STATE5); +function normalizeState5(value) { + if (!value || typeof value !== "object") return structuredClone(EMPTY_STATE6); const sessions = {}; if (value.sessions && typeof value.sessions === "object" && !Array.isArray(value.sessions)) { for (const [key, sessionId] of Object.entries(value.sessions)) { @@ -9771,17 +12026,17 @@ function normalizeState4(value) { } var WeixinStateStore = class { #path; - #state = structuredClone(EMPTY_STATE5); + #state = structuredClone(EMPTY_STATE6); #writeQueue = Promise.resolve(); constructor(path) { this.#path = path; } async load() { try { - this.#state = normalizeState4(JSON.parse(await readFile10(this.#path, "utf8"))); + this.#state = normalizeState5(JSON.parse(await readFile12(this.#path, "utf8"))); } catch (error) { if (error?.code !== "ENOENT") throw error; - this.#state = structuredClone(EMPTY_STATE5); + this.#state = structuredClone(EMPTY_STATE6); await this.#persist(); } return this; @@ -9821,20 +12076,20 @@ var WeixinStateStore = class { } async remove() { try { - await unlink13(this.#path); + await unlink16(this.#path); } catch (error) { if (error?.code !== "ENOENT") throw error; } - this.#state = structuredClone(EMPTY_STATE5); + this.#state = structuredClone(EMPTY_STATE6); } async #persist() { const snapshot = `${JSON.stringify(this.#state, null, 2)} `; const operation = this.#writeQueue.then(async () => { - await mkdir10(dirname10(this.#path), { recursive: true, mode: 448 }); + await mkdir12(dirname12(this.#path), { recursive: true, mode: 448 }); const temporary = `${this.#path}.tmp`; - await writeFile10(temporary, snapshot, { encoding: "utf8", mode: 384 }); - await rename10(temporary, this.#path); + await writeFile12(temporary, snapshot, { encoding: "utf8", mode: 384 }); + await rename12(temporary, this.#path); }); this.#writeQueue = operation.then(() => void 0, () => void 0); await operation; @@ -9852,14 +12107,14 @@ var ACTIVE_ATTEMPT_STATES4 = /* @__PURE__ */ new Set([ ]); var TERMINAL_ATTEMPT_STATES4 = /* @__PURE__ */ new Set(["connected", "expired", "failed", "cancelled"]); var QR_TTL_MS3 = 5 * 6e4; -function cleanString11(value) { +function cleanString15(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } function abortError4() { return new DOMException("Provisioning was cancelled", "AbortError"); } function apiBaseFromServer(value, fallback) { - const raw = cleanString11(value); + const raw = cleanString15(value); if (!raw) return normalizeWeixinApiBaseUrl(fallback); return normalizeWeixinApiBaseUrl(raw.includes("://") ? raw : `https://${raw}`); } @@ -10011,7 +12266,7 @@ var WeixinController = class { if (!record || record.state !== "needs_verification") { throw new Error("The provisioning attempt is not waiting for a verification code"); } - const code = cleanString11(verifyCode); + const code = cleanString15(verifyCode); if (!code || !/^\d{4,8}$/.test(code)) { throw new TypeError("Verification code must contain 4 to 8 digits"); } @@ -10129,9 +12384,9 @@ var WeixinController = class { try { while (!record.controller.signal.aborted && Date.now() < record.expiresAt) { if (record.state === "needs_verification" && !record.pendingVerifyCode) { - await new Promise((resolve6) => { - record.verifyResolve = resolve6; - if (record.controller.signal.aborted) resolve6(); + await new Promise((resolve7) => { + record.verifyResolve = resolve7; + if (record.controller.signal.aborted) resolve7(); }); record.verifyResolve = null; this.#assertAttemptActive(record); @@ -10176,9 +12431,9 @@ var WeixinController = class { } break; } else if (response.status === "confirmed") { - const token = cleanString11(response.bot_token); - const accountId = cleanString11(response.ilink_bot_id); - const ownerUserId = cleanString11(response.ilink_user_id); + const token = cleanString15(response.bot_token); + const accountId = cleanString15(response.ilink_bot_id); + const ownerUserId = cleanString15(response.ilink_user_id); if (!token || !accountId || !ownerUserId) { throw new WeixinApiError("incomplete-login", "\u5FAE\u4FE1\u6388\u6743\u6210\u529F\uFF0C\u4F46\u8FD4\u56DE\u7684\u8D26\u53F7\u51ED\u636E\u4E0D\u5B8C\u6574\u3002"); } @@ -10283,7 +12538,7 @@ var WeixinController = class { } async #resolveToken(ref) { const result = await this.#credentials.resolve(ref).catch(() => void 0); - return cleanString11(result?.value); + return cleanString15(result?.value); } async #restoreCredential(ref, previous) { try { @@ -10471,15 +12726,15 @@ var WeixinHarnessBridge = class { }; // src/channels/weixin/weixin-runtime.mjs -function delay(ms, signal) { - return new Promise((resolve6, reject) => { +function delay2(ms, signal) { + return new Promise((resolve7, reject) => { if (signal?.aborted) { reject(signal.reason ?? new DOMException("Aborted", "AbortError")); return; } const finish = () => { signal?.removeEventListener("abort", onAbort); - resolve6(); + resolve7(); }; const timer = setTimeout(finish, ms); const onAbort = () => { @@ -10632,7 +12887,7 @@ var WeixinRuntime = class { ); if (error instanceof WeixinApiError && error.code === "stale-token") throw error; if (consecutiveFailures >= 3) throw error; - await delay(Math.min(2e3 * 2 ** (consecutiveFailures - 1), 1e4), signal); + await delay2(Math.min(2e3 * 2 ** (consecutiveFailures - 1), 1e4), signal); } } } @@ -10664,11 +12919,11 @@ var WeixinRuntime = class { }; // plugin-src/host/channels/weixin/connection-supervisor.mjs -var DEFAULT_RETRY_DELAYS_MS5 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); -function retryDelays4(value) { - if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS5]; - const valid = value.filter((delay2) => Number.isFinite(delay2) && delay2 >= 0); - return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS5]; +var DEFAULT_RETRY_DELAYS_MS6 = Object.freeze([250, 1e3, 3e3, 5e3, 1e4, 3e4]); +function retryDelays5(value) { + if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS6]; + const valid = value.filter((delay3) => Number.isFinite(delay3) && delay3 >= 0); + return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS6]; } var ConnectionSupervisor5 = class { #controller; @@ -10703,12 +12958,12 @@ var ConnectionSupervisor5 = class { this.#controller = controller; this.#harness = harness; this.#logger = logger; - this.#retryDelays = retryDelays4(retryDelaysMs); + this.#retryDelays = retryDelays5(retryDelaysMs); this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 ? healthyIntervalMs : 15e3; this.#setTimeout = setTimeoutImpl; this.#clearTimeout = clearTimeoutImpl; - this.#ready = new Promise((resolve6) => { - this.#resolveReady = resolve6; + this.#ready = new Promise((resolve7) => { + this.#resolveReady = resolve7; }); } get ready() { @@ -10781,7 +13036,7 @@ function createConnectionSupervisor5(options) { } // plugin-src/host/channels/weixin/production.mjs -function harnessOrigin5(webServer, configured) { +function harnessOrigin6(webServer, configured) { if (configured !== void 0) return new URL(configured); const port = webServer?.port; if (!Number.isInteger(port) || port < 1 || port > 65535) { @@ -10789,30 +13044,30 @@ function harnessOrigin5(webServer, configured) { } return new URL(`http://127.0.0.1:${port}`); } -function pluginPaths5(config) { - const dshHome = resolve5(config.dshHome ?? process.env.DSH_HOME ?? join5(homedir5(), ".dsh")); - const root = resolve5(config.dataDir ?? join5(dshHome, "integrations", "dsh-weixin")); +function pluginPaths6(config) { + const dshHome = resolve6(config.dshHome ?? process.env.DSH_HOME ?? join6(homedir6(), ".dsh")); + const root = resolve6(config.dataDir ?? join6(dshHome, "integrations", "dsh-weixin")); return { root, - config: resolve5(config.configPath ?? join5(root, "config.json")), - accounts: resolve5(config.accountsDir ?? join5(root, "accounts")) + config: resolve6(config.configPath ?? join6(root, "config.json")), + accounts: resolve6(config.accountsDir ?? join6(root, "accounts")) }; } -async function createProductionController5(ctx, config = {}, internals = {}) { +async function createProductionController7(ctx, config = {}, internals = {}) { if (!ctx?.credentials) throw new TypeError("dsh-weixin requires ctx.credentials"); if (!ctx?.webServer) throw new TypeError("dsh-weixin requires ctx.webServer"); const ConfigStore = internals.ConfigStore ?? WeixinConfigStore; const StateStore2 = internals.StateStore ?? WeixinStateStore; - const Harness = internals.HarnessClient ?? HarnessClient3; + const Harness = internals.HarnessClient ?? HarnessClient2; const Controller = internals.Controller ?? WeixinController; const Runtime = internals.Runtime ?? WeixinRuntime; const api = internals.api ?? createWeixinApi(); const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor5; const logger = typeof ctx.logger === "function" ? ctx.logger("dsh-weixin") : ctx.logger ?? console; - const paths = pluginPaths5(config); + const paths = pluginPaths6(config); const configStore = await new ConfigStore(paths.config).load(); const stateStores = /* @__PURE__ */ new Map(); - const statePath = (botId) => resolve5(paths.accounts, botId, "state.json"); + const statePath = (botId) => resolve6(paths.accounts, botId, "state.json"); const stateFor = async (botId) => { let state = stateStores.get(botId); if (!state) { @@ -10822,8 +13077,8 @@ async function createProductionController5(ctx, config = {}, internals = {}) { return state; }; const harness = new Harness({ - baseUrl: harnessOrigin5(ctx.webServer, config.harnessBaseUrl), - workspace: resolve5(config.workspace ?? process.cwd()), + baseUrl: harnessOrigin6(ctx.webServer, config.harnessBaseUrl), + workspace: resolve6(config.workspace ?? process.cwd()), agentPreset: config.agentPreset ?? "standard", autostart: false, dshBin: config.dshBin ?? "dsh" @@ -10859,7 +13114,7 @@ async function createProductionController5(ctx, config = {}, internals = {}) { return; } try { - await unlink14(statePath(botId)); + await unlink17(statePath(botId)); } catch (error) { if (error?.code !== "ENOENT") throw error; } @@ -10896,34 +13151,34 @@ var WEIXIN_ENDPOINTS = Object.freeze({ deleteBot: "bot.delete" }); var WEIXIN_RPC_ENDPOINTS = Object.freeze(Object.values(WEIXIN_ENDPOINTS)); -function isRecord4(value) { +function isRecord5(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } -function exactKeys4(value, allowed) { - return isRecord4(value) && Object.keys(value).every((key) => allowed.includes(key)); +function exactKeys5(value, allowed) { + return isRecord5(value) && Object.keys(value).every((key) => allowed.includes(key)); } -function validId4(value) { +function validId5(value) { return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); } -function payloadFailure4(endpoint, payload) { - if (!isRecord4(payload)) return "Payload must be an object."; +function payloadFailure5(endpoint, payload) { + if (!isRecord5(payload)) return "Payload must be an object."; if (endpoint === WEIXIN_ENDPOINTS.status) { - return exactKeys4(payload, []) ? null : "connection.status does not accept fields."; + return exactKeys5(payload, []) ? null : "connection.status does not accept fields."; } if (endpoint === WEIXIN_ENDPOINTS.beginProvisioning) { - return exactKeys4(payload, ["locale"]) && (payload.locale === void 0 || payload.locale === "zh-CN") ? null : "provision.begin received unsupported fields."; + return exactKeys5(payload, ["locale"]) && (payload.locale === void 0 || payload.locale === "zh-CN") ? null : "provision.begin received unsupported fields."; } if ([WEIXIN_ENDPOINTS.pollProvisioning, WEIXIN_ENDPOINTS.cancelProvisioning].includes(endpoint)) { - return exactKeys4(payload, ["attemptId"]) && validId4(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; + return exactKeys5(payload, ["attemptId"]) && validId5(payload.attemptId) ? null : `${endpoint} requires an attemptId.`; } if (endpoint === WEIXIN_ENDPOINTS.submitVerification) { - return exactKeys4(payload, ["attemptId", "verifyCode"]) && validId4(payload.attemptId) && typeof payload.verifyCode === "string" && /^\d{4,8}$/.test(payload.verifyCode) ? null : "provision.verify requires an attemptId and a 4-to-8-digit code."; + return exactKeys5(payload, ["attemptId", "verifyCode"]) && validId5(payload.attemptId) && typeof payload.verifyCode === "string" && /^\d{4,8}$/.test(payload.verifyCode) ? null : "provision.verify requires an attemptId and a 4-to-8-digit code."; } if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) { - return exactKeys4(payload, ["botId"]) && validId4(payload.botId) ? null : "bot.reconnect requires a botId."; + return exactKeys5(payload, ["botId"]) && validId5(payload.botId) ? null : "bot.reconnect requires a botId."; } if (endpoint === WEIXIN_ENDPOINTS.deleteBot) { - return exactKeys4(payload, ["botId", "confirm"]) && validId4(payload.botId) && payload.confirm === true ? null : "bot.delete requires a botId and confirm=true."; + return exactKeys5(payload, ["botId", "confirm"]) && validId5(payload.botId) && payload.confirm === true ? null : "bot.delete requires a botId and confirm=true."; } return "Unknown Weixin endpoint."; } @@ -10979,7 +13234,7 @@ function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl4 } = {}) { return async (endpoint, payload, signal) => { if (signal?.aborted) return cancelled3(); if (!WEIXIN_RPC_ENDPOINTS.includes(endpoint)) return badRequest3("Unknown Weixin endpoint."); - const invalid = payloadFailure4(endpoint, payload); + const invalid = payloadFailure5(endpoint, payload); if (invalid) return badRequest3(invalid); try { let value; @@ -11015,22 +13270,29 @@ function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl4 } = {}) { } }; } -function installWeixinRpc(ctx, controller, options) { +function installWeixinRpc(ctx, controller, options, authority) { if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== "function") { throw new TypeError("DSH Host Connection RPC is required"); } return ctx.connection.rpc.handle( WEIXIN_RPC_CHANNEL, createWeixinRpcHandler(controller, options), - { authority: "loopback" } + { authority: resolveRpcAuthority(authority) } ); } // plugin-src/host/channels/weixin/index.mjs -async function apply5(ctx, config = {}) { - if (config?.controller) return installWeixinRpc(ctx, config.controller, config.rpcOptions); - const production = await createProductionController5(ctx, config, config.internals); - const disposeRpc = installWeixinRpc(ctx, production.controller, config.rpcOptions); +async function apply7(ctx, config = {}) { + if (config?.controller) { + return installWeixinRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority); + } + const production = await createProductionController7(ctx, config, config.internals); + const disposeRpc = installWeixinRpc( + ctx, + production.controller, + config.rpcOptions, + config.rpcAuthority + ); ctx.effect(() => async () => { await production.close(); }, "dsh-weixin: close account connections"); @@ -11040,29 +13302,37 @@ async function apply5(ctx, config = {}) { // plugin-src/host/index.mjs var name = "dsh-im-host"; var inject = ["connection", "credentials", "webServer"]; +function channelConfig(config, name2) { + const channel = config[name2] ?? {}; + return config.rpcAuthority === void 0 ? channel : { ...channel, rpcAuthority: config.rpcAuthority }; +} function createImHostPlugin(internals = {}) { - const startFeishu = internals.applyFeishu ?? apply2; - const startWeixin = internals.applyWeixin ?? apply5; + const startFeishu = internals.applyFeishu ?? apply3; + const startWeixin = internals.applyWeixin ?? apply7; const startDingtalk = internals.applyDingtalk ?? apply; - const startWecom = internals.applyWecom ?? apply4; - const startQq = internals.applyQq ?? apply3; + const startWecom = internals.applyWecom ?? apply6; + const startQq = internals.applyQq ?? apply4; + const startTelegram = internals.applyTelegram ?? apply5; + const startDiscord = internals.applyDiscord ?? apply2; return Object.freeze({ name, inject, async apply(ctx, config = {}) { - await startFeishu(ctx, config.feishu ?? {}); - await startWeixin(ctx, config.weixin ?? {}); - await startDingtalk(ctx, config.dingtalk ?? {}); - await startWecom(ctx, config.wecom ?? {}); - await startQq(ctx, config.qq ?? {}); + await startFeishu(ctx, channelConfig(config, "feishu")); + await startWeixin(ctx, channelConfig(config, "weixin")); + await startDingtalk(ctx, channelConfig(config, "dingtalk")); + await startWecom(ctx, channelConfig(config, "wecom")); + await startQq(ctx, channelConfig(config, "qq")); + await startTelegram(ctx, channelConfig(config, "telegram")); + await startDiscord(ctx, channelConfig(config, "discord")); } }); } -async function apply6(ctx, config = {}) { +async function apply8(ctx, config = {}) { return createImHostPlugin().apply(ctx, config); } export { - apply6 as apply, + apply8 as apply, createImHostPlugin, inject, name diff --git a/package.json b/package.json index 287dbe3..bc4e56c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@xmanrui/dsh-im", "version": "0.1.0", - "description": "通过扫码或应用凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。", + "description": "通过扫码或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Telegram和Discord)。", "license": "MIT", "type": "module", "repository": { diff --git a/plugin-src/client/channel-logos.js b/plugin-src/client/channel-logos.js index 82c8fb5..9c7f747 100644 --- a/plugin-src/client/channel-logos.js +++ b/plugin-src/client/channel-logos.js @@ -79,3 +79,29 @@ export function WecomLogoGlyph({ size } = {}) { h('path', { fill: '#FF7A00', d: 'M19.55 18.05a1.16 1.16 0 1 1 1.64 1.64 1.16 1.16 0 0 1-1.64-1.64Z' }), h('path', { fill: '#3370FF', d: 'M15.25 18.75a.92.92 0 1 1 1.3 1.3.92.92 0 0 1-1.3-1.3Z' })); } + +export function TelegramLogoGlyph({ size } = {}) { + return h('svg', { + ...dimensions(size), + viewBox: '0 0 24 24', + focusable: 'false', + 'aria-hidden': 'true', + 'data-im-channel-logo': 'telegram', + }, h('path', { + fill: 'currentColor', + d: 'M23.95 4.57c-.36-1.45-1.43-1.76-2.82-1.24L1.5 10.9c-1.34.52-1.32 1.27-.24 1.6l5.03 1.57 11.66-7.36c.55-.34 1.05-.16.64.21l-9.44 8.52-.37 5.12c.54 0 .78-.24 1.08-.53l2.59-2.51 5.38 3.97c.99.55 1.7.27 1.95-.92L23.95 4.57Z', + })); +} + +export function DiscordLogoGlyph({ size } = {}) { + return h('svg', { + ...dimensions(size), + viewBox: '0 0 24 24', + focusable: 'false', + 'aria-hidden': 'true', + 'data-im-channel-logo': 'discord', + }, h('path', { + fill: 'currentColor', + d: 'M20.32 4.37a19.8 19.8 0 0 0-4.89-1.51c-.21.38-.46.89-.63 1.29a18.4 18.4 0 0 0-5.59 0 13 13 0 0 0-.64-1.29c-1.71.29-3.36.8-4.89 1.52C.59 9.09-.25 13.68.17 18.2a19.9 19.9 0 0 0 6 3.04c.48-.66.91-1.36 1.28-2.1-.7-.26-1.37-.58-2-.96.17-.12.33-.25.49-.38 3.86 1.79 8.04 1.79 11.86 0 .16.13.32.26.49.38-.64.38-1.31.7-2.01.97.37.73.8 1.44 1.28 2.09a19.8 19.8 0 0 0 6-3.04c.49-5.24-.84-9.79-3.24-13.83ZM8.02 15.42c-1.16 0-2.11-1.07-2.11-2.38s.93-2.38 2.11-2.38c1.18 0 2.13 1.08 2.11 2.38 0 1.31-.93 2.38-2.11 2.38Zm7.95 0c-1.16 0-2.11-1.07-2.11-2.38s.93-2.38 2.11-2.38c1.18 0 2.13 1.08 2.11 2.38 0 1.31-.93 2.38-2.11 2.38Z', + })); +} diff --git a/plugin-src/client/channels/discord/api.js b/plugin-src/client/channels/discord/api.js new file mode 100644 index 0000000..66bfc81 --- /dev/null +++ b/plugin-src/client/channels/discord/api.js @@ -0,0 +1,11 @@ +import { TOKEN_BOT_ENDPOINTS, createTokenChannelApi } from '../shared/token-api.js'; + +export const DISCORD_RPC_CHANNEL = '/discord'; +export const DISCORD_ENDPOINTS = TOKEN_BOT_ENDPOINTS; + +const api = createTokenChannelApi('Discord', ' Gateway 长连接'); + +export const unwrapRpcResult = api.unwrapRpcResult; +export const normalizeSnapshot = api.normalizeSnapshot; +export const presentError = api.presentError; +export { api as discordClientApi }; diff --git a/plugin-src/client/channels/discord/index.js b/plugin-src/client/channels/discord/index.js new file mode 100644 index 0000000..67dd3e6 --- /dev/null +++ b/plugin-src/client/channels/discord/index.js @@ -0,0 +1,25 @@ +import { DiscordLogoGlyph } from '../../channel-logos.js'; +import { createTokenChannelSettings } from '../shared/token-channel.js'; +import { + DISCORD_ENDPOINTS, + discordClientApi, +} from './api.js'; +import { installDiscordStyles } from './styles.js'; + +const channel = createTokenChannelSettings({ + channel: 'Discord', + endpoints: DISCORD_ENDPOINTS, + api: discordClientApi, + LogoGlyph: DiscordLogoGlyph, + installStyles: installDiscordStyles, + pageClass: 'ddc-page', + avatarClass: 'ddc-avatar', + connectionLabel: 'Gateway 长连接', + tokenPlaceholder: '填写 Discord Developer Portal 的 Bot Token', + emptyTitle: '接入 Discord 机器人', + emptyDescription: '先在 Developer Portal 创建 Bot 并邀请到服务器,再在这里完成接入。', + platformLabel: 'Discord Developer Portal', +}); + +export const DiscordSettingsTab = channel.SettingsTab; +export const DiscordAccountCard = channel.AccountCard; diff --git a/plugin-src/client/channels/discord/styles.js b/plugin-src/client/channels/discord/styles.js new file mode 100644 index 0000000..9a67dd2 --- /dev/null +++ b/plugin-src/client/channels/discord/styles.js @@ -0,0 +1,19 @@ +export const DISCORD_STYLE_ID = 'xmanrui-dsh-im-discord-settings'; + +const CSS = String.raw` +.ddc-page { --ddt-accent: #5865f2; --ddt-accent-deep: #4752c4; --ddt-accent-wash: #eef0ff; } +.ddc-avatar { color: #fff; background: #5865f2; } +.ddc-avatar svg { display: block; } +`; + +export function installDiscordStyles() { + if (typeof document === 'undefined') return () => {}; + const existing = document.querySelector(`style[data-plugin-css="${DISCORD_STYLE_ID}"]`); + if (existing) return () => {}; + const style = document.createElement('style'); + style.dataset.plugin = '@xmanrui/dsh-im'; + style.dataset.pluginCss = DISCORD_STYLE_ID; + style.textContent = CSS; + document.head.appendChild(style); + return () => style.remove(); +} diff --git a/plugin-src/client/channels/shared/token-api.js b/plugin-src/client/channels/shared/token-api.js new file mode 100644 index 0000000..38143e2 --- /dev/null +++ b/plugin-src/client/channels/shared/token-api.js @@ -0,0 +1,88 @@ +const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']); + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function text(value, fallback, max = 240) { + return typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : fallback; +} + +function id(value) { + const result = text(value, '', 128); + return /^[a-z\d_-]+$/i.test(result) ? result : undefined; +} + +function timestamp(value) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + const parsed = typeof value === 'string' ? Date.parse(value) : Number.NaN; + return Number.isNaN(parsed) ? undefined : parsed; +} + +export const TOKEN_BOT_ENDPOINTS = Object.freeze({ + status: 'connection.status', + bindCredentials: 'bot.bind-credentials', + reconnectBot: 'bot.reconnect', + deleteBot: 'bot.delete', +}); + +export function createTokenChannelApi(channel, connectionSummary) { + const unwrapRpcResult = (result) => { + if (!isRecord(result) || typeof result.ok !== 'boolean') { + throw new Error(`${channel} 服务返回了无法识别的响应`); + } + if (!result.ok) { + const error = new Error(text(result.error?.message, `${channel} 操作失败`)); + error.code = text(result.error?.code, `${channel.toUpperCase()}_RPC_ERROR`, 80); + throw error; + } + return result.value; + }; + + const normalizeBot = (value) => { + if (!isRecord(value) || !id(value.botId)) return undefined; + const connected = value.connected === true; + const state = ACCOUNT_STATES.has(value.state) ? value.state : 'offline'; + return { + botId: id(value.botId), + connected, + state: connected ? 'connected' : state, + bot: { + name: text(value.bot?.name, `${channel}机器人`, 100), + username: text(value.bot?.username, '', 100), + idMasked: text(value.bot?.idMasked, '机器人标识已安全保存', 140), + }, + health: { + summary: text( + value.health?.summary, + connected ? `${channel}${connectionSummary}运行正常` : `${channel}连接尚未就绪`, + ), + lastCheckedAt: timestamp(value.health?.lastCheckedAt), + }, + error: isRecord(value.error) ? { + code: text(value.error.code, `${channel.toUpperCase()}_ACCOUNT_ERROR`, 80), + message: text(value.error.message, `${channel}连接尚未就绪`), + } : null, + }; + }; + + const normalizeSnapshot = (value) => { + const source = isRecord(value?.snapshot) ? value.snapshot : value; + if (!isRecord(source) || !Array.isArray(source.bots)) { + throw new Error(`${channel} 服务没有返回有效的机器人列表`); + } + const bots = source.bots.map(normalizeBot).filter(Boolean); + return { + revision: Number.isSafeInteger(source.revision) ? source.revision : 0, + bots, + totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length }, + }; + }; + + const presentError = (error) => ({ + code: text(error?.code, `${channel.toUpperCase()}_ERROR`, 80), + message: text(error?.message, `${channel}操作失败,请稍后重试`), + }); + + return Object.freeze({ unwrapRpcResult, normalizeSnapshot, presentError }); +} diff --git a/plugin-src/client/channels/shared/token-channel.js b/plugin-src/client/channels/shared/token-channel.js new file mode 100644 index 0000000..f1d6e21 --- /dev/null +++ b/plugin-src/client/channels/shared/token-channel.js @@ -0,0 +1,277 @@ +import * as React from 'react'; + +import { CredentialActionIcon, CredentialBindingPanel } from '../../credential-binding.js'; +import { installDingtalkStyles } from '../dingtalk/styles.js'; + +const h = React.createElement; + +const Button = React.forwardRef(function Button( + { children, kind = 'secondary', className = '', ...props }, + ref, +) { + return h('button', { + ...props, + ref, + type: 'button', + className: `ddt-button ${className}`.trim(), + 'data-kind': kind, + }, children); +}); + +function checkedTime(value) { + if (!value) return '尚未检查'; + try { + return new Intl.DateTimeFormat('zh-CN', { + hour: '2-digit', minute: '2-digit', second: '2-digit', + }).format(new Date(value)); + } catch { + return '刚刚'; + } +} + +export function createTokenChannelSettings(definition) { + const { + channel, + endpoints, + api, + LogoGlyph, + installStyles, + pageClass, + avatarClass, + connectionLabel, + tokenPlaceholder, + emptyTitle, + emptyDescription, + platformLabel, + } = definition; + + function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { + const state = busy === 'reconnect' ? 'connecting' : account.state; + const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning'; + const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪'; + const summary = account.error?.message ?? (account.connected ? null : account.health.summary); + const identity = account.bot.username ? `@${account.bot.username}` : account.bot.idMasked; + return h('article', { className: 'ddt-card dim-botCard', 'data-bot-id': account.botId }, + h('div', { className: 'ddt-cardBody dim-botCardBody' }, + h('div', { className: 'ddt-accountTop dim-botCardTop' }, + h('div', { className: 'ddt-accountIdentity dim-botIdentity' }, + h('div', { className: `ddt-avatar dim-botAvatar ${avatarClass}`, 'aria-hidden': 'true' }, + h(LogoGlyph, { size: 29 })), + h('div', { className: 'dim-botName' }, + h('h3', null, account.bot.name), h('p', null, identity))), + h('div', { className: 'ddt-health dim-botHealth' }, + h('span', { className: 'ddt-dot dim-healthDot', 'data-tone': tone }), + h('span', null, stateLabel))), + h('dl', { className: 'ddt-metrics dim-botMetrics' }, + h('div', { className: 'ddt-metric dim-botMetric' }, + h('dt', null, '消息通道'), h('dd', null, account.connected ? connectionLabel : '离线')), + h('div', { className: 'ddt-metric dim-botMetric' }, + h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))), + h('div', { className: 'ddt-accountFooter dim-cardFooter' }, + summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null, + h('div', { className: 'ddt-actions dim-cardActions' }, + h(Button, { + className: 'dim-cardAction', + onClick: onReconnect, + disabled: Boolean(busy), + }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'), + h(Button, { + className: 'dim-cardAction', + kind: 'danger', + onClick: onRequestRemove, + disabled: Boolean(busy), + }, '移除接入')))), + removing ? h('div', { className: 'ddt-confirm dim-confirm', role: 'alertdialog' }, + h('strong', null, `从 DeepSeek Harness 移除“${account.bot.name}”?`), + h('p', null, `这会停止消息连接,并删除本机保存的 Bot Token、机器人配置及会话映射。${platformLabel}中的机器人不会被自动删除。`), + h('div', { className: 'ddt-actions dim-viewActions' }, + h(Button, { onClick: onCancelRemove, disabled: Boolean(busy) }, '保留机器人'), + h(Button, { kind: 'danger', onClick: onConfirmRemove, disabled: Boolean(busy) }, + busy === 'delete' ? '正在移除…' : '确认移除接入'))) : null); + } + + function SettingsTab({ rpcCall }) { + const [model, setModel] = React.useState({ + phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null, + }); + const [credentialOpen, setCredentialOpen] = React.useState(false); + const [credentialError, setCredentialError] = React.useState(null); + const [busy, setBusy] = React.useState(false); + const [busyByBot, setBusyByBot] = React.useState({}); + const [removeTarget, setRemoveTarget] = React.useState(null); + const mounted = React.useRef(true); + + React.useEffect(() => { + const disposeDingtalk = installDingtalkStyles(); + const disposeChannel = installStyles(); + mounted.current = true; + return () => { + mounted.current = false; + disposeChannel(); + disposeDingtalk(); + }; + }, []); + + const invoke = React.useCallback(async (endpoint, payload = {}, signal) => { + if (typeof rpcCall !== 'function') throw new TypeError(`${channel} 设置页缺少 RPC 连接`); + return api.unwrapRpcResult(await rpcCall(endpoint, payload, signal)); + }, [rpcCall]); + + const loadStatus = React.useCallback(async ({ signal, silent = false } = {}) => { + if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null })); + try { + const snapshot = api.normalizeSnapshot(await invoke(endpoints.status, {}, signal)); + if (!mounted.current || signal?.aborted) return; + setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null }); + } catch (error) { + if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) { + setModel((current) => ({ + ...current, + phase: silent ? current.phase : 'error', + error: api.presentError(error), + })); + } + } + }, [invoke]); + + React.useEffect(() => { + const controller = new AbortController(); + void loadStatus({ signal: controller.signal }); + return () => controller.abort(); + }, [loadStatus]); + + React.useEffect(() => { + if (model.phase !== 'ready') return undefined; + const controller = new AbortController(); + const timer = window.setInterval( + () => void loadStatus({ signal: controller.signal, silent: true }), + 15_000, + ); + return () => { + controller.abort(); + window.clearInterval(timer); + }; + }, [loadStatus, model.phase]); + + const bindCredentials = React.useCallback(async ({ secret }) => { + setBusy(true); + setCredentialError(null); + try { + const snapshot = api.normalizeSnapshot(await invoke(endpoints.bindCredentials, { token: secret })); + if (!mounted.current) return; + setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null }); + setCredentialOpen(false); + } catch (error) { + if (mounted.current) setCredentialError(api.presentError(error)); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke]); + + const botAction = React.useCallback(async (account, operation, endpoint, payload) => { + setBusyByBot((current) => ({ ...current, [account.botId]: operation })); + try { + const snapshot = api.normalizeSnapshot(await invoke(endpoint, payload)); + if (mounted.current) { + setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null }); + } + } finally { + if (mounted.current) setBusyByBot((current) => { + const next = { ...current }; + delete next[account.botId]; + return next; + }); + } + }, [invoke]); + + const botList = model.bots.length > 0 + ? h('section', { className: 'dim-listSection' }, + h('div', { className: 'ddt-listHeading dim-listHeading' }, + h('h3', null, `已接入的 ${channel} 机器人`)), + h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) => + h('li', { key: account.botId }, h(AccountCard, { + account, + busy: busyByBot[account.botId], + removing: removeTarget === account.botId, + onReconnect: () => void botAction( + account, + 'reconnect', + endpoints.reconnectBot, + { botId: account.botId }, + ), + onRequestRemove: () => setRemoveTarget(account.botId), + onCancelRemove: () => setRemoveTarget(null), + onConfirmRemove: async () => { + await botAction(account, 'delete', endpoints.deleteBot, { + botId: account.botId, + confirm: true, + }); + if (mounted.current) setRemoveTarget(null); + }, + }))))) + : null; + + return h('section', { + className: `ddt-page ${pageClass} dim-channelPage`, + 'aria-label': `${channel} 设置`, + }, + h('div', { className: 'ddt-heading' }, + h('div', { className: 'ddt-tools' }, + h('div', { className: 'dim-bindActions' }, + h(Button, { + kind: 'credential', + className: 'dim-credentialButton', + onClick: () => { setCredentialOpen((value) => !value); setCredentialError(null); }, + disabled: busy, + 'aria-pressed': credentialOpen, + 'aria-label': `使用 Bot Token 接入 ${channel} 机器人`, + }, h(CredentialActionIcon), credentialOpen ? '收起凭据' : '手动接入')), + model.totals.configured > 0 + ? h('div', { className: 'ddt-badge dim-onlineBadge' }, + h('span', null, `${model.totals.connected} / ${model.totals.configured} 在线`)) + : null)), + model.phase === 'loading' + ? h('div', { + className: 'ddt-card ddt-loading dim-surfaceCard dim-loadingView', + 'aria-busy': 'true', + }, h('div', { className: 'ddt-spinner dim-spinner' }), `正在读取 ${channel} 机器人状态…`) + : model.phase === 'error' + ? h('div', { className: 'ddt-card dim-surfaceCard' }, + h('div', { className: 'ddt-inlineError dim-inlineError' }, + h('h3', null, `无法读取 ${channel} 机器人状态`), + h('p', null, model.error?.message), + h(Button, { onClick: () => void loadStatus() }, '重新读取'))) + : h(React.Fragment, null, + credentialOpen ? h(CredentialBindingPanel, { + channel, + secretLabel: 'Bot Token', + secretPlaceholder: tokenPlaceholder, + busy, + error: credentialError, + onSubmit: bindCredentials, + onCancel: () => { setCredentialOpen(false); setCredentialError(null); }, + }) : null, + model.bots.length === 0 && !credentialOpen + ? h('div', { className: 'ddt-card dim-surfaceCard' }, + h('div', { className: 'ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView' }, + h('div', { className: 'dim-emptyCopy' }, + h('div', { className: 'ddt-stateLabel dim-stateLabel' }, + h('span', { className: 'ddt-dot dim-stateDot' }), + h('span', null, `尚未接入 ${channel} 机器人`)), + h('h3', null, emptyTitle), + h('p', null, emptyDescription), + h('div', { className: 'ddt-actions dim-viewActions' }, + h(Button, { + kind: 'primary', + onClick: () => setCredentialOpen(true), + }, '填写 Bot Token'))), + h('div', { + className: `ddt-brandMark dim-emptyBrand ${avatarClass}`, + 'aria-hidden': 'true', + }, h(LogoGlyph, { size: 64 })))) + : null, + botList)); + } + + return { SettingsTab, AccountCard }; +} diff --git a/plugin-src/client/channels/telegram/api.js b/plugin-src/client/channels/telegram/api.js new file mode 100644 index 0000000..b75746c --- /dev/null +++ b/plugin-src/client/channels/telegram/api.js @@ -0,0 +1,11 @@ +import { TOKEN_BOT_ENDPOINTS, createTokenChannelApi } from '../shared/token-api.js'; + +export const TELEGRAM_RPC_CHANNEL = '/telegram'; +export const TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS; + +const api = createTokenChannelApi('Telegram', ' Bot API 长轮询'); + +export const unwrapRpcResult = api.unwrapRpcResult; +export const normalizeSnapshot = api.normalizeSnapshot; +export const presentError = api.presentError; +export { api as telegramClientApi }; diff --git a/plugin-src/client/channels/telegram/index.js b/plugin-src/client/channels/telegram/index.js new file mode 100644 index 0000000..560039a --- /dev/null +++ b/plugin-src/client/channels/telegram/index.js @@ -0,0 +1,25 @@ +import { TelegramLogoGlyph } from '../../channel-logos.js'; +import { createTokenChannelSettings } from '../shared/token-channel.js'; +import { + TELEGRAM_ENDPOINTS, + telegramClientApi, +} from './api.js'; +import { installTelegramStyles } from './styles.js'; + +const channel = createTokenChannelSettings({ + channel: 'Telegram', + endpoints: TELEGRAM_ENDPOINTS, + api: telegramClientApi, + LogoGlyph: TelegramLogoGlyph, + installStyles: installTelegramStyles, + pageClass: 'dtg-page', + avatarClass: 'dtg-avatar', + connectionLabel: 'Bot API 长轮询', + tokenPlaceholder: '填写 @BotFather 生成的 Bot Token', + emptyTitle: '接入 Telegram 机器人', + emptyDescription: '先通过 @BotFather 获取 Bot Token,再在这里完成接入。', + platformLabel: 'Telegram', +}); + +export const TelegramSettingsTab = channel.SettingsTab; +export const TelegramAccountCard = channel.AccountCard; diff --git a/plugin-src/client/channels/telegram/styles.js b/plugin-src/client/channels/telegram/styles.js new file mode 100644 index 0000000..9c9c6bb --- /dev/null +++ b/plugin-src/client/channels/telegram/styles.js @@ -0,0 +1,19 @@ +export const TELEGRAM_STYLE_ID = 'xmanrui-dsh-im-telegram-settings'; + +const CSS = String.raw` +.dtg-page { --ddt-accent: #229ed9; --ddt-accent-deep: #1687bd; --ddt-accent-wash: #eaf7fd; } +.dtg-avatar { color: #fff; background: #229ed9; } +.dtg-avatar svg { display: block; } +`; + +export function installTelegramStyles() { + if (typeof document === 'undefined') return () => {}; + const existing = document.querySelector(`style[data-plugin-css="${TELEGRAM_STYLE_ID}"]`); + if (existing) return () => {}; + const style = document.createElement('style'); + style.dataset.plugin = '@xmanrui/dsh-im'; + style.dataset.pluginCss = TELEGRAM_STYLE_ID; + style.textContent = CSS; + document.head.appendChild(style); + return () => style.remove(); +} diff --git a/plugin-src/client/credential-binding.js b/plugin-src/client/credential-binding.js index ab27e8f..05d5d26 100644 --- a/plugin-src/client/credential-binding.js +++ b/plugin-src/client/credential-binding.js @@ -52,12 +52,13 @@ export function CredentialBindingPanel({ const [identity, setIdentity] = React.useState(''); const [secret, setSecret] = React.useState(''); const headingId = React.useId(); + const hasIdentity = Boolean(identityLabel); const submit = (event) => { event.preventDefault(); const normalizedIdentity = identity.trim(); const normalizedSecret = secret.trim(); - if (!normalizedIdentity || !normalizedSecret || busy) return; + if ((hasIdentity && !normalizedIdentity) || !normalizedSecret || busy) return; void onSubmit?.({ identity: normalizedIdentity, secret: normalizedSecret }); }; @@ -66,8 +67,11 @@ export function CredentialBindingPanel({ 'aria-labelledby': headingId, }, h('h3', { id: headingId, className: 'dim-credentialTitle' }, `手动接入${channel}机器人`), - h('form', { className: 'dim-credentialForm', onSubmit: submit }, - h('label', { className: 'dim-credentialField' }, + h('form', { + className: `dim-credentialForm${hasIdentity ? '' : ' dim-credentialFormSingle'}`, + onSubmit: submit, + }, + hasIdentity ? h('label', { className: 'dim-credentialField' }, h('span', null, identityLabel), h('input', { value: identity, @@ -80,7 +84,7 @@ export function CredentialBindingPanel({ autoComplete: 'off', disabled: busy, required: true, - })), + })) : null, h('label', { className: 'dim-credentialField' }, h('span', null, secretLabel), h('input', { @@ -102,7 +106,7 @@ export function CredentialBindingPanel({ type: 'submit', className: 'ddt-button', 'data-kind': 'primary', - disabled: busy || !identity.trim() || !secret.trim(), + disabled: busy || (hasIdentity && !identity.trim()) || !secret.trim(), }, busy ? '正在绑定…' : '绑定并连接'), h('button', { type: 'button', diff --git a/plugin-src/client/index.js b/plugin-src/client/index.js index ecc0953..f773d90 100644 --- a/plugin-src/client/index.js +++ b/plugin-src/client/index.js @@ -2,19 +2,27 @@ import * as React from 'react'; import { DingtalkLogoGlyph, + DiscordLogoGlyph, FeishuLogoGlyph, QqLogoGlyph, + TelegramLogoGlyph, WecomLogoGlyph, WeixinLogoGlyph, } from './channel-logos.js'; import { DINGTALK_RPC_CHANNEL } from './channels/dingtalk/api.js'; import { DingtalkSettingsTab } from './channels/dingtalk/index.js'; +import { DISCORD_RPC_CHANNEL } from './channels/discord/api.js'; +import { DiscordSettingsTab } from './channels/discord/index.js'; +import { installDiscordStyles } from './channels/discord/styles.js'; import { FeishuSettingsTab } from './channels/feishu/index.js'; import { FEISHU_RPC_CHANNEL } from './channels/feishu/api.js'; import { installFeishuStyles } from './channels/feishu/styles.js'; import { QQ_RPC_CHANNEL } from './channels/qq/api.js'; import { QqSettingsTab } from './channels/qq/index.js'; import { installQqStyles } from './channels/qq/styles.js'; +import { TELEGRAM_RPC_CHANNEL } from './channels/telegram/api.js'; +import { TelegramSettingsTab } from './channels/telegram/index.js'; +import { installTelegramStyles } from './channels/telegram/styles.js'; import { WECOM_RPC_CHANNEL } from './channels/wecom/api.js'; import { WecomSettingsTab } from './channels/wecom/index.js'; import { installWecomStyles } from './channels/wecom/styles.js'; @@ -34,6 +42,8 @@ const CHANNELS = Object.freeze([ { id: 'dingtalk', label: '钉钉' }, { id: 'wecom', label: '企业微信' }, { id: 'qq', label: 'QQ' }, + { id: 'telegram', label: 'Telegram' }, + { id: 'discord', label: 'Discord' }, ]); function WeixinLogo() { @@ -59,20 +69,40 @@ function WecomLogo() { return h('span', { className: 'dim-logo dim-logoWecom', 'aria-hidden': 'true' }, h(WecomLogoGlyph)); } +function TelegramLogo() { + return h('span', { className: 'dim-logo dim-logoTelegram', 'aria-hidden': 'true' }, + h(TelegramLogoGlyph)); +} + +function DiscordLogo() { + return h('span', { className: 'dim-logo dim-logoDiscord', 'aria-hidden': 'true' }, + h(DiscordLogoGlyph)); +} + function ChannelLogo({ channel }) { if (channel === 'weixin') return h(WeixinLogo); if (channel === 'feishu') return h(FeishuLogo); if (channel === 'dingtalk') return h(DingtalkLogo); if (channel === 'wecom') return h(WecomLogo); - return h(QqLogo); + if (channel === 'qq') return h(QqLogo); + if (channel === 'telegram') return h(TelegramLogo); + return h(DiscordLogo); } -export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall, weixinRpcCall }) { +export function IMSettingsTab({ + dingtalkRpcCall, + discordRpcCall, + feishuRpcCall, + qqRpcCall, + telegramRpcCall, + wecomRpcCall, + weixinRpcCall, +}) { const [selected, setSelected] = React.useState('weixin'); const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0]; return h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' }, h('header', { className: 'dim-title' }, - h('p', null, '通过扫码把机器人接入 DeepSeek Harness'), + h('p', null, '把机器人接入 DeepSeek Harness'), ), h('div', { className: 'dim-layout' }, h('nav', { className: 'dim-rail', role: 'tablist', 'aria-label': 'IM 渠道' }, @@ -104,7 +134,11 @@ export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecom ? h(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall }) : active.id === 'wecom' ? h(WecomSettingsTab, { rpcCall: wecomRpcCall }) - : h(QqSettingsTab, { rpcCall: qqRpcCall })), + : active.id === 'qq' + ? h(QqSettingsTab, { rpcCall: qqRpcCall }) + : active.id === 'telegram' + ? h(TelegramSettingsTab, { rpcCall: telegramRpcCall }) + : h(DiscordSettingsTab, { rpcCall: discordRpcCall })), ), ); } @@ -116,6 +150,8 @@ export function apply(ctx) { installWeixinStyles(), installWecomStyles(), installQqStyles(), + installTelegramStyles(), + installDiscordStyles(), installImStyles(), ]; return () => { @@ -133,12 +169,24 @@ export function apply(ctx) { ctx.connection.rpc.call(QQ_RPC_CHANNEL, endpoint, payload, signal); const wecomRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(WECOM_RPC_CHANNEL, endpoint, payload, signal); + const telegramRpcCall = (endpoint, payload, signal) => + ctx.connection.rpc.call(TELEGRAM_RPC_CHANNEL, endpoint, payload, signal); + const discordRpcCall = (endpoint, payload, signal) => + ctx.connection.rpc.call(DISCORD_RPC_CHANNEL, endpoint, payload, signal); ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ name: 'settings.plugins.tab', id: 'im', order: 20, label: 'IM机器人', - inject: () => ({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall, weixinRpcCall }), + inject: () => ({ + dingtalkRpcCall, + discordRpcCall, + feishuRpcCall, + qqRpcCall, + telegramRpcCall, + wecomRpcCall, + weixinRpcCall, + }), }, IMSettingsTab)); } diff --git a/plugin-src/client/styles.js b/plugin-src/client/styles.js index adfaf01..8d71d44 100644 --- a/plugin-src/client/styles.js +++ b/plugin-src/client/styles.js @@ -33,6 +33,10 @@ const CSS = String.raw` .dim-logoQq svg { width: 21px; height: 21px; } .dim-logoWecom { background: white; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); } .dim-logoWecom svg { width: 22px; height: 22px; } +.dim-logoTelegram { color: white; background: #229ed9; } +.dim-logoTelegram svg { width: 21px; height: 21px; } +.dim-logoDiscord { color: white; background: #5865f2; } +.dim-logoDiscord svg { width: 21px; height: 21px; } .dim-channelCopy { min-width: 0; display: block; } .dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 14px; line-height: 20px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; } .dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-line-divider, #eef0f3); } @@ -55,6 +59,7 @@ const CSS = String.raw` .dim-panel .dim-credentialPanel { display: grid; gap: 18px; padding: 20px; } .dim-panel .dim-credentialTitle { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: 1.35; font-weight: 650; } .dim-panel .dim-credentialForm { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 12px; } +.dim-panel .dim-credentialFormSingle { grid-template-columns: minmax(0, 1fr); } .dim-panel .dim-credentialField { min-width: 0; display: grid; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: normal; font-weight: 560; } .dim-panel .dim-credentialField input { width: 100%; min-width: 0; height: 38px; padding: 0 11px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 8px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; transition: border-color .16s ease, box-shadow .16s ease; } .dim-panel .dim-credentialField input:focus { border-color: #4e5969; box-shadow: 0 0 0 3px rgb(78 89 105 / 10%); } diff --git a/plugin-src/host/channels/dingtalk/index.mjs b/plugin-src/host/channels/dingtalk/index.mjs index d377158..f4aca2b 100644 --- a/plugin-src/host/channels/dingtalk/index.mjs +++ b/plugin-src/host/channels/dingtalk/index.mjs @@ -5,10 +5,17 @@ export const name = 'dsh-dingtalk-host'; export const inject = ['connection', 'credentials', 'webServer']; export async function apply(ctx, config = {}) { - if (config?.controller) return installDingtalkRpc(ctx, config.controller, config.rpcOptions); + if (config?.controller) { + return installDingtalkRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority); + } const production = await createProductionController(ctx, config, config.internals); - const disposeRpc = installDingtalkRpc(ctx, production.controller, config.rpcOptions); + const disposeRpc = installDingtalkRpc( + ctx, + production.controller, + config.rpcOptions, + config.rpcAuthority, + ); ctx.effect(() => async () => { await production.close(); }, 'dsh-dingtalk: close bot connections'); diff --git a/plugin-src/host/channels/dingtalk/rpc.mjs b/plugin-src/host/channels/dingtalk/rpc.mjs index 57823ab..a624e0d 100644 --- a/plugin-src/host/channels/dingtalk/rpc.mjs +++ b/plugin-src/host/channels/dingtalk/rpc.mjs @@ -1,4 +1,5 @@ import QRCode from 'qrcode'; +import { resolveRpcAuthority } from '../../rpc-authority.mjs'; export const DINGTALK_RPC_CHANNEL = '/dingtalk'; export const DINGTALK_ENDPOINTS = Object.freeze({ @@ -224,13 +225,13 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } = }; } -export function installDingtalkRpc(ctx, controller, options) { +export function installDingtalkRpc(ctx, controller, options, authority) { if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') { throw new TypeError('DSH Host Connection RPC is required'); } return ctx.connection.rpc.handle( DINGTALK_RPC_CHANNEL, createDingtalkRpcHandler(controller, options), - { authority: 'loopback' }, + { authority: resolveRpcAuthority(authority) }, ); } diff --git a/plugin-src/host/channels/discord/index.mjs b/plugin-src/host/channels/discord/index.mjs new file mode 100644 index 0000000..1f1fe56 --- /dev/null +++ b/plugin-src/host/channels/discord/index.mjs @@ -0,0 +1,30 @@ +import { createProductionController } from './production.mjs'; +import { installDiscordRpc } from './rpc.mjs'; + +export const name = 'dsh-im-discord-host'; +export const inject = ['connection', 'credentials', 'webServer']; + +export async function apply(ctx, config = {}) { + if (config?.controller) { + return installDiscordRpc(ctx, config.controller, config.rpcAuthority); + } + const production = await createProductionController(ctx, config, config.internals ?? {}); + const disposeRpc = installDiscordRpc(ctx, production.controller, config.rpcAuthority); + ctx.effect(() => async () => production.close(), 'dsh-im: close Discord bot connections'); + return disposeRpc; +} + +export function createDiscordHostPlugin(config) { + return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) }); +} + +export { createProductionController } from './production.mjs'; +export { + DISCORD_ENDPOINTS, + DISCORD_RPC_CHANNEL, + DISCORD_RPC_ENDPOINTS, + createDiscordRpcHandler, + installDiscordRpc, +} from './rpc.mjs'; +export { DiscordController } from '../../../../src/channels/discord/discord-controller.mjs'; +export { DiscordRuntime } from '../../../../src/channels/discord/discord-runtime.mjs'; diff --git a/plugin-src/host/channels/discord/production.mjs b/plugin-src/host/channels/discord/production.mjs new file mode 100644 index 0000000..537253c --- /dev/null +++ b/plugin-src/host/channels/discord/production.mjs @@ -0,0 +1,17 @@ +import { DiscordConfigStore } from '../../../../src/channels/discord/config-store.mjs'; +import { DiscordController } from '../../../../src/channels/discord/discord-controller.mjs'; +import { DiscordHarnessClient } from '../../../../src/channels/discord/harness-client.mjs'; +import { DiscordRuntime } from '../../../../src/channels/discord/discord-runtime.mjs'; +import { DiscordStateStore } from '../../../../src/channels/discord/state-store.mjs'; +import { createTokenProductionController } from '../shared/production.mjs'; + +export function createProductionController(ctx, config = {}, internals = {}) { + return createTokenProductionController(ctx, config, internals, { + channel: 'discord', + ConfigStore: DiscordConfigStore, + StateStore: DiscordStateStore, + HarnessClient: DiscordHarnessClient, + Controller: DiscordController, + Runtime: DiscordRuntime, + }); +} diff --git a/plugin-src/host/channels/discord/rpc.mjs b/plugin-src/host/channels/discord/rpc.mjs new file mode 100644 index 0000000..3537ada --- /dev/null +++ b/plugin-src/host/channels/discord/rpc.mjs @@ -0,0 +1,21 @@ +import { + TOKEN_BOT_ENDPOINTS, + createTokenBotRpcHandler, + installTokenBotRpc, +} from '../shared/rpc.mjs'; + +export const DISCORD_RPC_CHANNEL = '/discord'; +export const DISCORD_ENDPOINTS = TOKEN_BOT_ENDPOINTS; +export const DISCORD_RPC_ENDPOINTS = Object.freeze(Object.values(DISCORD_ENDPOINTS)); + +export function createDiscordRpcHandler(controller) { + return createTokenBotRpcHandler(controller, { channel: 'Discord' }); +} + +export function installDiscordRpc(ctx, controller, authority) { + return installTokenBotRpc(ctx, controller, { + channel: 'Discord', + rpcChannel: DISCORD_RPC_CHANNEL, + authority, + }); +} diff --git a/plugin-src/host/channels/feishu/index.mjs b/plugin-src/host/channels/feishu/index.mjs index 4bf6919..0795312 100644 --- a/plugin-src/host/channels/feishu/index.mjs +++ b/plugin-src/host/channels/feishu/index.mjs @@ -24,10 +24,17 @@ function controllerFrom(ctx, config) { */ export async function apply(ctx, config = {}) { const controller = controllerFrom(ctx, config); - if (controller) return installFeishuRpc(ctx, controller); + if (controller) { + return installFeishuRpc(ctx, controller, config.rpcOptions, config.rpcAuthority); + } const production = await createProductionController(ctx, config); - const disposeRpc = installFeishuRpc(ctx, production.controller); + const disposeRpc = installFeishuRpc( + ctx, + production.controller, + config.rpcOptions, + config.rpcAuthority, + ); ctx.effect(() => async () => { await production.close(); }, 'dsh-feishu: close controller and live connection'); diff --git a/plugin-src/host/channels/feishu/rpc.mjs b/plugin-src/host/channels/feishu/rpc.mjs index 78098d5..efdf488 100644 --- a/plugin-src/host/channels/feishu/rpc.mjs +++ b/plugin-src/host/channels/feishu/rpc.mjs @@ -1,4 +1,5 @@ import QRCode from 'qrcode'; +import { resolveRpcAuthority } from '../../rpc-authority.mjs'; import { FEISHU_ENDPOINTS, FEISHU_RPC_CHANNEL, @@ -440,14 +441,14 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl } }; } -/** Register the loopback-only `/feishu` logical channel. */ -export function installFeishuRpc(ctx, controller, options) { +/** Register the `/feishu` logical channel with its configured browser authority. */ +export function installFeishuRpc(ctx, controller, options, authority) { if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') { throw new TypeError('DSH Host Connection RPC is required'); } return ctx.connection.rpc.handle( FEISHU_RPC_CHANNEL, createFeishuRpcHandler(controller, options), - { authority: 'loopback' }, + { authority: resolveRpcAuthority(authority) }, ); } diff --git a/plugin-src/host/channels/qq/index.mjs b/plugin-src/host/channels/qq/index.mjs index 69adbf1..9e21862 100644 --- a/plugin-src/host/channels/qq/index.mjs +++ b/plugin-src/host/channels/qq/index.mjs @@ -5,9 +5,16 @@ export const name = 'dsh-im-qq-host'; export const inject = ['connection', 'credentials', 'webServer']; export async function apply(ctx, config = {}) { - if (config?.controller) return installQqRpc(ctx, config.controller, config.rpcOptions); + if (config?.controller) { + return installQqRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority); + } const production = await createProductionController(ctx, config, config.internals); - const disposeRpc = installQqRpc(ctx, production.controller, config.rpcOptions); + const disposeRpc = installQqRpc( + ctx, + production.controller, + config.rpcOptions, + config.rpcAuthority, + ); ctx.effect(() => async () => production.close(), 'dsh-im: close QQ bot connections'); return disposeRpc; } diff --git a/plugin-src/host/channels/qq/rpc.mjs b/plugin-src/host/channels/qq/rpc.mjs index 58865df..c6df515 100644 --- a/plugin-src/host/channels/qq/rpc.mjs +++ b/plugin-src/host/channels/qq/rpc.mjs @@ -1,4 +1,5 @@ import QRCode from 'qrcode'; +import { resolveRpcAuthority } from '../../rpc-authority.mjs'; export const QQ_RPC_CHANNEL = '/qq'; export const QQ_ENDPOINTS = Object.freeze({ @@ -133,9 +134,13 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) { }; } -export function installQqRpc(ctx, controller, options) { +export function installQqRpc(ctx, controller, options, authority) { if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') { throw new TypeError('DSH Host Connection RPC is required'); } - return ctx.connection.rpc.handle(QQ_RPC_CHANNEL, createQqRpcHandler(controller, options), { authority: 'loopback' }); + return ctx.connection.rpc.handle( + QQ_RPC_CHANNEL, + createQqRpcHandler(controller, options), + { authority: resolveRpcAuthority(authority) }, + ); } diff --git a/plugin-src/host/channels/shared/connection-supervisor.mjs b/plugin-src/host/channels/shared/connection-supervisor.mjs new file mode 100644 index 0000000..d9b0991 --- /dev/null +++ b/plugin-src/host/channels/shared/connection-supervisor.mjs @@ -0,0 +1,133 @@ +const DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000, 5_000, 10_000, 30_000]); + +function retryDelays(value) { + if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS]; + const valid = value.filter((delay) => Number.isFinite(delay) && delay >= 0); + return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS]; +} + +export class TokenConnectionSupervisor { + #channel; + #controller; + #harness; + #logger; + #retryDelays; + #healthyIntervalMs; + #setTimeout; + #clearTimeout; + #timer = null; + #running = null; + #retryIndex = 0; + #closed = false; + #started = false; + #ready; + #resolveReady; + + constructor({ + channel, + controller, + harness, + logger = console, + retryDelaysMs, + healthyIntervalMs = 15_000, + setTimeoutImpl = setTimeout, + clearTimeoutImpl = clearTimeout, + }) { + if (!channel) throw new TypeError('TokenConnectionSupervisor requires a channel'); + if (!controller || typeof controller.initialize !== 'function' || typeof controller.status !== 'function') { + throw new TypeError('TokenConnectionSupervisor requires a controller'); + } + if (!harness || typeof harness.ensureRunning !== 'function') { + throw new TypeError('TokenConnectionSupervisor requires a Harness client'); + } + this.#channel = channel; + this.#controller = controller; + this.#harness = harness; + this.#logger = logger; + this.#retryDelays = retryDelays(retryDelaysMs); + this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 + ? healthyIntervalMs : 15_000; + this.#setTimeout = setTimeoutImpl; + this.#clearTimeout = clearTimeoutImpl; + this.#ready = new Promise((resolve) => { + this.#resolveReady = resolve; + }); + } + + get ready() { + return this.#ready; + } + + start() { + if (this.#started || this.#closed) return this; + this.#started = true; + this.#schedule(0); + return this; + } + + async close() { + if (this.#closed) return; + this.#closed = true; + if (this.#timer !== null) this.#clearTimeout(this.#timer); + this.#timer = null; + await this.#running?.catch(() => undefined); + this.#resolveReady?.(null); + this.#resolveReady = null; + } + + #schedule(delayMs) { + if (this.#closed) return; + this.#timer = this.#setTimeout(() => { + this.#timer = null; + void this.#run(); + }, delayMs); + this.#timer?.unref?.(); + } + + async #run() { + if (this.#closed || this.#running) return; + const operation = this.#reconcile(); + this.#running = operation; + try { + await operation; + } finally { + if (this.#running === operation) this.#running = null; + } + } + + async #reconcile() { + try { + await this.#harness.ensureRunning(); + if (this.#closed) return; + const status = await this.#controller.initialize(); + if (this.#closed) return; + this.#resolveReady?.(status); + this.#resolveReady = null; + const { configured, connected } = status.totals; + if (connected < configured) { + const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-im:${this.#channel}] ${connected}/${configured} bots connected; retrying in ${delay}ms`, + ); + this.#schedule(delay); + return; + } + this.#retryIndex = 0; + this.#schedule(this.#healthyIntervalMs); + } catch (error) { + if (this.#closed) return; + const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)]; + this.#retryIndex += 1; + this.#logger.warn?.( + `[dsh-im:${this.#channel}] connection reconciliation failed; retrying in ${delay}ms`, + error, + ); + this.#schedule(delay); + } + } +} + +export function createTokenConnectionSupervisor(options) { + return new TokenConnectionSupervisor(options); +} diff --git a/plugin-src/host/channels/shared/production.mjs b/plugin-src/host/channels/shared/production.mjs new file mode 100644 index 0000000..52a5cbc --- /dev/null +++ b/plugin-src/host/channels/shared/production.mjs @@ -0,0 +1,104 @@ +import { unlink } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { createTokenConnectionSupervisor } from './connection-supervisor.mjs'; + +function harnessOrigin(webServer, configured) { + if (configured !== undefined) return new URL(configured); + const port = webServer?.port; + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error('dsh-im token channel requires an initialized DSH webServer port'); + } + return new URL(`http://127.0.0.1:${port}`); +} + +function pluginPaths(config, channel) { + const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')); + const root = resolve(config.dataDir ?? join(dshHome, 'integrations', `dsh-${channel}`)); + return { + config: resolve(config.configPath ?? join(root, 'config.json')), + bots: resolve(config.botsDir ?? join(root, 'bots')), + }; +} + +export async function createTokenProductionController(ctx, config, internals, definitions) { + const { channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime } = definitions; + if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`); + if (!ctx?.webServer) throw new TypeError(`dsh-im ${channel} requires ctx.webServer`); + + const ResolvedConfigStore = internals.ConfigStore ?? ConfigStore; + const ResolvedStateStore = internals.StateStore ?? StateStore; + const ResolvedHarness = internals.HarnessClient ?? HarnessClient; + const ResolvedController = internals.Controller ?? Controller; + const ResolvedRuntime = internals.Runtime ?? Runtime; + const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor; + const logger = typeof ctx.logger === 'function' + ? ctx.logger(`dsh-im:${channel}`) : (ctx.logger ?? console); + const paths = pluginPaths(config, channel); + const configStore = await new ResolvedConfigStore(paths.config).load(); + const stateStores = new Map(); + const statePath = (botId) => resolve(paths.bots, botId, 'state.json'); + const stateFor = async (botId) => { + let state = stateStores.get(botId); + if (!state) { + state = await new ResolvedStateStore(statePath(botId)).load(); + stateStores.set(botId, state); + } + return state; + }; + const harness = new ResolvedHarness({ + baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl), + workspace: resolve(config.workspace ?? process.cwd()), + agentPreset: config.agentPreset ?? 'standard', + autostart: false, + dshBin: config.dshBin ?? 'dsh', + }); + const controller = new ResolvedController({ + credentials: ctx.credentials, + configStore, + logger, + ...(internals.inspectToken ? { inspectToken: internals.inspectToken } : {}), + createRuntime: async ({ botId, config: botConfig, token }) => new ResolvedRuntime({ + config: botConfig, + token, + harness, + state: await stateFor(botId), + replyTimeoutMs: config.replyTimeoutMs ?? 600_000, + connectTimeoutMs: config.connectTimeoutMs ?? 20_000, + logger: { + error: (...args) => logger.error?.(`[${botId}]`, ...args), + warn: (...args) => logger.warn?.(`[${botId}]`, ...args), + info: (...args) => logger.info?.(`[${botId}]`, ...args), + debug: (...args) => logger.debug?.(`[${botId}]`, ...args), + }, + }), + deleteState: async ({ botId }) => { + const state = stateStores.get(botId); + stateStores.delete(botId); + if (state && typeof state.remove === 'function') return state.remove(); + try { + await unlink(statePath(botId)); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + }, + }); + const supervisor = createSupervisor({ + channel, + controller, + harness, + logger, + retryDelaysMs: config.retryDelaysMs, + healthyIntervalMs: config.healthyIntervalMs, + }).start(); + return { + controller, + ready: supervisor.ready, + async close() { + await supervisor.close(); + await controller.close(); + harness.stopManagedProcess(); + }, + }; +} diff --git a/plugin-src/host/channels/shared/rpc.mjs b/plugin-src/host/channels/shared/rpc.mjs new file mode 100644 index 0000000..4ec97ec --- /dev/null +++ b/plugin-src/host/channels/shared/rpc.mjs @@ -0,0 +1,119 @@ +import { resolveRpcAuthority } from '../../rpc-authority.mjs'; + +export const TOKEN_BOT_ENDPOINTS = Object.freeze({ + status: 'connection.status', + bindCredentials: 'bot.bind-credentials', + reconnectBot: 'bot.reconnect', + deleteBot: 'bot.delete', +}); + +const ENDPOINTS = Object.freeze(Object.values(TOKEN_BOT_ENDPOINTS)); +const FORBIDDEN_PUBLIC_KEYS = new Set([ + 'token', 'botToken', 'tokenRef', 'platformId', 'secret', 'secretRef', +]); + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function exactKeys(value, allowed) { + return isRecord(value) && Object.keys(value).every((key) => allowed.includes(key)); +} + +function validId(value) { + return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value); +} + +function validToken(value) { + return typeof value === 'string' && value.trim().length >= 20 && value.length <= 4_096; +} + +function payloadFailure(endpoint, payload) { + if (!isRecord(payload)) return 'Payload must be an object.'; + if (endpoint === TOKEN_BOT_ENDPOINTS.status) { + return exactKeys(payload, []) ? null : 'connection.status does not accept fields.'; + } + if (endpoint === TOKEN_BOT_ENDPOINTS.bindCredentials) { + return exactKeys(payload, ['token']) && validToken(payload.token) + ? null : 'bot.bind-credentials requires a Bot Token.'; + } + if (endpoint === TOKEN_BOT_ENDPOINTS.reconnectBot) { + return exactKeys(payload, ['botId']) && validId(payload.botId) + ? null : 'bot.reconnect requires a botId.'; + } + if (endpoint === TOKEN_BOT_ENDPOINTS.deleteBot) { + return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true + ? null : 'bot.delete requires a botId and confirm=true.'; + } + return 'Unknown bot endpoint.'; +} + +function sanitizePublic(value) { + if (Array.isArray(value)) return value.map(sanitizePublic); + if (!isRecord(value)) return value; + const safe = {}; + for (const [key, child] of Object.entries(value)) { + if (!FORBIDDEN_PUBLIC_KEYS.has(key)) safe[key] = sanitizePublic(child); + } + return safe; +} + +function operationError(channel, error) { + if (error?.code === 'webhook-configured') { + return { code: 'webhook-configured', message: error.message }; + } + if (error?.code === 'telegram-401' || error?.code === 'discord-401') { + return { code: 'invalid-token', message: `${channel} Bot Token 无效,请重新填写。` }; + } + if (error?.code === 'discord-intents') { + return { code: 'discord-intents', message: error.message }; + } + return { code: `${channel.toLowerCase()}-operation-failed`, message: `${channel} 操作失败,请稍后重试。` }; +} + +export function createTokenBotRpcHandler(controller, { channel }) { + for (const method of ['status', 'bindCredentials', 'reconnectBot', 'deleteBot']) { + if (typeof controller?.[method] !== 'function') { + throw new TypeError(`A complete ${channel} controller is required (${method})`); + } + } + return async (endpoint, payload, signal) => { + if (signal?.aborted) { + return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }; + } + if (!ENDPOINTS.includes(endpoint)) { + return { ok: false, error: { code: 'bad-request', message: `Unknown ${channel} endpoint.` } }; + } + const invalid = payloadFailure(endpoint, payload); + if (invalid) return { ok: false, error: { code: 'bad-request', message: invalid } }; + try { + let value; + if (endpoint === TOKEN_BOT_ENDPOINTS.status) value = await controller.status(); + else if (endpoint === TOKEN_BOT_ENDPOINTS.bindCredentials) { + value = await controller.bindCredentials(payload); + } else if (endpoint === TOKEN_BOT_ENDPOINTS.reconnectBot) { + value = await controller.reconnectBot(payload.botId); + } else { + value = await controller.deleteBot(payload.botId); + } + return signal?.aborted + ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } } + : { ok: true, value: sanitizePublic(value) }; + } catch (error) { + return signal?.aborted + ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } } + : { ok: false, error: operationError(channel, error) }; + } + }; +} + +export function installTokenBotRpc(ctx, controller, { channel, rpcChannel, authority }) { + if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') { + throw new TypeError('DSH Host Connection RPC is required'); + } + return ctx.connection.rpc.handle( + rpcChannel, + createTokenBotRpcHandler(controller, { channel }), + { authority: resolveRpcAuthority(authority) }, + ); +} diff --git a/plugin-src/host/channels/telegram/index.mjs b/plugin-src/host/channels/telegram/index.mjs new file mode 100644 index 0000000..af8aed8 --- /dev/null +++ b/plugin-src/host/channels/telegram/index.mjs @@ -0,0 +1,30 @@ +import { createProductionController } from './production.mjs'; +import { installTelegramRpc } from './rpc.mjs'; + +export const name = 'dsh-im-telegram-host'; +export const inject = ['connection', 'credentials', 'webServer']; + +export async function apply(ctx, config = {}) { + if (config?.controller) { + return installTelegramRpc(ctx, config.controller, config.rpcAuthority); + } + const production = await createProductionController(ctx, config, config.internals ?? {}); + const disposeRpc = installTelegramRpc(ctx, production.controller, config.rpcAuthority); + ctx.effect(() => async () => production.close(), 'dsh-im: close Telegram bot connections'); + return disposeRpc; +} + +export function createTelegramHostPlugin(config) { + return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) }); +} + +export { createProductionController } from './production.mjs'; +export { + TELEGRAM_ENDPOINTS, + TELEGRAM_RPC_CHANNEL, + TELEGRAM_RPC_ENDPOINTS, + createTelegramRpcHandler, + installTelegramRpc, +} from './rpc.mjs'; +export { TelegramController } from '../../../../src/channels/telegram/telegram-controller.mjs'; +export { TelegramRuntime } from '../../../../src/channels/telegram/telegram-runtime.mjs'; diff --git a/plugin-src/host/channels/telegram/production.mjs b/plugin-src/host/channels/telegram/production.mjs new file mode 100644 index 0000000..f519604 --- /dev/null +++ b/plugin-src/host/channels/telegram/production.mjs @@ -0,0 +1,17 @@ +import { TelegramConfigStore } from '../../../../src/channels/telegram/config-store.mjs'; +import { TelegramHarnessClient } from '../../../../src/channels/telegram/harness-client.mjs'; +import { TelegramController } from '../../../../src/channels/telegram/telegram-controller.mjs'; +import { TelegramRuntime } from '../../../../src/channels/telegram/telegram-runtime.mjs'; +import { TelegramStateStore } from '../../../../src/channels/telegram/state-store.mjs'; +import { createTokenProductionController } from '../shared/production.mjs'; + +export function createProductionController(ctx, config = {}, internals = {}) { + return createTokenProductionController(ctx, config, internals, { + channel: 'telegram', + ConfigStore: TelegramConfigStore, + StateStore: TelegramStateStore, + HarnessClient: TelegramHarnessClient, + Controller: TelegramController, + Runtime: TelegramRuntime, + }); +} diff --git a/plugin-src/host/channels/telegram/rpc.mjs b/plugin-src/host/channels/telegram/rpc.mjs new file mode 100644 index 0000000..12ec901 --- /dev/null +++ b/plugin-src/host/channels/telegram/rpc.mjs @@ -0,0 +1,21 @@ +import { + TOKEN_BOT_ENDPOINTS, + createTokenBotRpcHandler, + installTokenBotRpc, +} from '../shared/rpc.mjs'; + +export const TELEGRAM_RPC_CHANNEL = '/telegram'; +export const TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS; +export const TELEGRAM_RPC_ENDPOINTS = Object.freeze(Object.values(TELEGRAM_ENDPOINTS)); + +export function createTelegramRpcHandler(controller) { + return createTokenBotRpcHandler(controller, { channel: 'Telegram' }); +} + +export function installTelegramRpc(ctx, controller, authority) { + return installTokenBotRpc(ctx, controller, { + channel: 'Telegram', + rpcChannel: TELEGRAM_RPC_CHANNEL, + authority, + }); +} diff --git a/plugin-src/host/channels/wecom/index.mjs b/plugin-src/host/channels/wecom/index.mjs index 80c71e2..05d7ed0 100644 --- a/plugin-src/host/channels/wecom/index.mjs +++ b/plugin-src/host/channels/wecom/index.mjs @@ -5,9 +5,16 @@ export const name = 'dsh-im-wecom-host'; export const inject = ['connection', 'credentials', 'webServer']; export async function apply(ctx, config = {}) { - if (config?.controller) return installWecomRpc(ctx, config.controller, config.rpcOptions); + if (config?.controller) { + return installWecomRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority); + } const production = await createProductionController(ctx, config, config.internals); - const disposeRpc = installWecomRpc(ctx, production.controller, config.rpcOptions); + const disposeRpc = installWecomRpc( + ctx, + production.controller, + config.rpcOptions, + config.rpcAuthority, + ); ctx.effect(() => async () => production.close(), 'dsh-im: close Enterprise WeChat bot connections'); return disposeRpc; } diff --git a/plugin-src/host/channels/wecom/rpc.mjs b/plugin-src/host/channels/wecom/rpc.mjs index 9be4009..5413195 100644 --- a/plugin-src/host/channels/wecom/rpc.mjs +++ b/plugin-src/host/channels/wecom/rpc.mjs @@ -1,4 +1,5 @@ import QRCode from 'qrcode'; +import { resolveRpcAuthority } from '../../rpc-authority.mjs'; export const WECOM_RPC_CHANNEL = '/wecom'; export const WECOM_ENDPOINTS = Object.freeze({ @@ -138,13 +139,13 @@ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {}) }; } -export function installWecomRpc(ctx, controller, options) { +export function installWecomRpc(ctx, controller, options, authority) { if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') { throw new TypeError('DSH Host Connection RPC is required'); } return ctx.connection.rpc.handle( WECOM_RPC_CHANNEL, createWecomRpcHandler(controller, options), - { authority: 'loopback' }, + { authority: resolveRpcAuthority(authority) }, ); } diff --git a/plugin-src/host/channels/weixin/index.mjs b/plugin-src/host/channels/weixin/index.mjs index 45b28a8..29d9644 100644 --- a/plugin-src/host/channels/weixin/index.mjs +++ b/plugin-src/host/channels/weixin/index.mjs @@ -5,10 +5,17 @@ export const name = 'dsh-weixin-host'; export const inject = ['connection', 'credentials', 'webServer']; export async function apply(ctx, config = {}) { - if (config?.controller) return installWeixinRpc(ctx, config.controller, config.rpcOptions); + if (config?.controller) { + return installWeixinRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority); + } const production = await createProductionController(ctx, config, config.internals); - const disposeRpc = installWeixinRpc(ctx, production.controller, config.rpcOptions); + const disposeRpc = installWeixinRpc( + ctx, + production.controller, + config.rpcOptions, + config.rpcAuthority, + ); ctx.effect(() => async () => { await production.close(); }, 'dsh-weixin: close account connections'); diff --git a/plugin-src/host/channels/weixin/rpc.mjs b/plugin-src/host/channels/weixin/rpc.mjs index c783bc8..b9e9073 100644 --- a/plugin-src/host/channels/weixin/rpc.mjs +++ b/plugin-src/host/channels/weixin/rpc.mjs @@ -1,4 +1,5 @@ import QRCode from 'qrcode'; +import { resolveRpcAuthority } from '../../rpc-authority.mjs'; export const WEIXIN_RPC_CHANNEL = '/weixin'; export const WEIXIN_ENDPOINTS = Object.freeze({ @@ -165,13 +166,13 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {} }; } -export function installWeixinRpc(ctx, controller, options) { +export function installWeixinRpc(ctx, controller, options, authority) { if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') { throw new TypeError('DSH Host Connection RPC is required'); } return ctx.connection.rpc.handle( WEIXIN_RPC_CHANNEL, createWeixinRpcHandler(controller, options), - { authority: 'loopback' }, + { authority: resolveRpcAuthority(authority) }, ); } diff --git a/plugin-src/host/index.mjs b/plugin-src/host/index.mjs index 247f623..2eedeb1 100644 --- a/plugin-src/host/index.mjs +++ b/plugin-src/host/index.mjs @@ -1,27 +1,40 @@ import { apply as applyDingtalk } from './channels/dingtalk/index.mjs'; +import { apply as applyDiscord } from './channels/discord/index.mjs'; import { apply as applyFeishu } from './channels/feishu/index.mjs'; import { apply as applyQq } from './channels/qq/index.mjs'; +import { apply as applyTelegram } from './channels/telegram/index.mjs'; import { apply as applyWecom } from './channels/wecom/index.mjs'; import { apply as applyWeixin } from './channels/weixin/index.mjs'; export const name = 'dsh-im-host'; export const inject = ['connection', 'credentials', 'webServer']; +function channelConfig(config, name) { + const channel = config[name] ?? {}; + return config.rpcAuthority === undefined + ? channel + : { ...channel, rpcAuthority: config.rpcAuthority }; +} + export function createImHostPlugin(internals = {}) { const startFeishu = internals.applyFeishu ?? applyFeishu; const startWeixin = internals.applyWeixin ?? applyWeixin; const startDingtalk = internals.applyDingtalk ?? applyDingtalk; const startWecom = internals.applyWecom ?? applyWecom; const startQq = internals.applyQq ?? applyQq; + const startTelegram = internals.applyTelegram ?? applyTelegram; + const startDiscord = internals.applyDiscord ?? applyDiscord; return Object.freeze({ name, inject, async apply(ctx, config = {}) { - await startFeishu(ctx, config.feishu ?? {}); - await startWeixin(ctx, config.weixin ?? {}); - await startDingtalk(ctx, config.dingtalk ?? {}); - await startWecom(ctx, config.wecom ?? {}); - await startQq(ctx, config.qq ?? {}); + await startFeishu(ctx, channelConfig(config, 'feishu')); + await startWeixin(ctx, channelConfig(config, 'weixin')); + await startDingtalk(ctx, channelConfig(config, 'dingtalk')); + await startWecom(ctx, channelConfig(config, 'wecom')); + await startQq(ctx, channelConfig(config, 'qq')); + await startTelegram(ctx, channelConfig(config, 'telegram')); + await startDiscord(ctx, channelConfig(config, 'discord')); }, }); } diff --git a/plugin-src/host/rpc-authority.mjs b/plugin-src/host/rpc-authority.mjs new file mode 100644 index 0000000..e1cdc86 --- /dev/null +++ b/plugin-src/host/rpc-authority.mjs @@ -0,0 +1,11 @@ +const RPC_AUTHORITIES = new Set(['loopback', 'trusted-host']); + +/** + * Resolve the browser authority accepted by an IM management RPC channel. + * The default keeps credential and bot-management operations on loopback. + */ +export function resolveRpcAuthority(value) { + if (value === undefined) return 'loopback'; + if (RPC_AUTHORITIES.has(value)) return value; + throw new TypeError('dsh-im rpcAuthority must be "loopback" or "trusted-host"'); +} diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index c379f13..70b4332 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -15,11 +15,15 @@ const required = [ 'plugin-src/host/channels/dingtalk/index.mjs', 'plugin-src/host/channels/qq/index.mjs', 'plugin-src/host/channels/wecom/index.mjs', + 'plugin-src/host/channels/telegram/index.mjs', + 'plugin-src/host/channels/discord/index.mjs', 'src/channels/feishu/feishu-runtime.mjs', 'src/channels/weixin/weixin-runtime.mjs', 'src/channels/dingtalk/dingtalk-runtime.mjs', 'src/channels/qq/qq-runtime.mjs', 'src/channels/wecom/wecom-runtime.mjs', + 'src/channels/telegram/telegram-runtime.mjs', + 'src/channels/discord/discord-runtime.mjs', ]; await Promise.all(required.map((path) => access(resolve(root, path)))); @@ -51,7 +55,7 @@ if (!client.includes('container-type: inline-size') || !client.includes('@container (max-width: 680px)')) { throw new Error('client bundle does not contain the narrow-panel DingTalk QR layout'); } -for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq']) { +for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/telegram', '/discord']) { if (!host.includes(marker)) { throw new Error(`host bundle does not contain the internal ${marker} RPC provider`); } diff --git a/src/channels/discord/config-store.mjs b/src/channels/discord/config-store.mjs new file mode 100644 index 0000000..51d261a --- /dev/null +++ b/src/channels/discord/config-store.mjs @@ -0,0 +1,24 @@ +import { + deriveTokenBotIdentity, + maskPlatformId, + TokenBotConfigStore, +} from '../shared/token-config-store.mjs'; + +const IDENTITY_OPTIONS = Object.freeze({ + botPrefix: 'discord', + tokenRefPrefix: 'DSH_DISCORD_BOT_TOKEN', +}); + +export function deriveDiscordBotIdentity(platformId) { + return deriveTokenBotIdentity(platformId, IDENTITY_OPTIONS); +} + +export function maskDiscordBotId(platformId) { + return maskPlatformId(platformId, 'Discord机器人'); +} + +export class DiscordConfigStore extends TokenBotConfigStore { + constructor(path) { + super(path, { channel: 'Discord', ...IDENTITY_OPTIONS }); + } +} diff --git a/src/channels/discord/discord-api.mjs b/src/channels/discord/discord-api.mjs new file mode 100644 index 0000000..eea04c5 --- /dev/null +++ b/src/channels/discord/discord-api.mjs @@ -0,0 +1,153 @@ +const DEFAULT_BASE_URL = 'https://discord.com/api/v10/'; + +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function requestSignal(signal, timeoutMs) { + const timeout = AbortSignal.timeout(timeoutMs); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +function delay(ms, signal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + return; + } + const timer = setTimeout(resolve, ms); + timer?.unref?.(); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + }, { once: true }); + }); +} + +function snowflake(value, name) { + const id = cleanString(value); + if (!id || !/^\d{5,30}$/.test(id)) throw new TypeError(`Invalid Discord ${name}`); + return id; +} + +export function validDiscordToken(value) { + return typeof value === 'string' + && /^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{20,}$/.test(value.trim()); +} + +export class DiscordApi { + #token; + #fetch; + #baseUrl; + + constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) { + if (!validDiscordToken(token)) throw new TypeError('Discord Bot Token is invalid'); + if (typeof fetchImpl !== 'function') throw new TypeError('DiscordApi requires fetch'); + this.#token = token.trim(); + this.#fetch = fetchImpl; + this.#baseUrl = new URL(baseUrl); + } + + getCurrentUser(options = {}) { + return this.#request('users/@me', { ...options, method: 'GET' }); + } + + getGatewayBot(options = {}) { + return this.#request('gateway/bot', { ...options, method: 'GET' }); + } + + createMessage({ channelId, content, replyToMessageId, signal }) { + return this.#request(`channels/${snowflake(channelId, 'channel id')}/messages`, { + method: 'POST', + signal, + body: { + content, + allowed_mentions: { parse: [], replied_user: false }, + ...(replyToMessageId ? { + message_reference: { + message_id: snowflake(replyToMessageId, 'message id'), + channel_id: snowflake(channelId, 'channel id'), + fail_if_not_exists: false, + }, + } : {}), + }, + }); + } + + editMessage({ channelId, messageId, content, signal }) { + return this.#request( + `channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`, + { + method: 'PATCH', + signal, + body: { content, allowed_mentions: { parse: [], replied_user: false } }, + }, + ); + } + + sendTyping({ channelId, signal }) { + return this.#request(`channels/${snowflake(channelId, 'channel id')}/typing`, { + method: 'POST', + signal, + expectBody: false, + }); + } + + async #request(path, { + method, + body, + signal, + timeoutMs = 15_000, + expectBody = true, + retry = true, + }) { + let response; + try { + response = await this.#fetch(new URL(path, this.#baseUrl), { + method, + headers: { + authorization: `Bot ${this.#token}`, + 'content-type': 'application/json', + 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.1.0)', + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: requestSignal(signal, timeoutMs), + redirect: 'error', + }); + } catch (error) { + if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error; + throw new Error(`Discord ${method} transport failed`); + } + + let parsed = null; + if (expectBody || response.status === 429 || !response.ok) { + try { + parsed = await response.json(); + } catch { + if (expectBody) throw new Error(`Discord ${method} returned invalid JSON`); + } + } + if (response.status === 429 && retry) { + const retryAfterMs = Math.min(10_000, Math.max(50, Number(parsed?.retry_after) * 1_000 || 1_000)); + await delay(retryAfterMs, signal); + return this.#request(path, { method, body, signal, timeoutMs, expectBody, retry: false }); + } + if (!response.ok) { + const error = new Error(cleanString(parsed?.message) ?? `Discord API failed with HTTP ${response.status}`); + error.code = `discord-${response.status}`; + throw error; + } + return expectBody ? parsed : null; + } +} + +export async function inspectDiscordToken(token, options = {}) { + const api = new DiscordApi({ token, ...options }); + const bot = await api.getCurrentUser(); + if (!bot?.id || bot?.bot !== true) throw new Error('Discord token does not belong to a bot'); + return { + platformId: String(bot.id), + name: cleanString(bot.global_name) ?? cleanString(bot.username) ?? 'Discord机器人', + username: cleanString(bot.username), + }; +} diff --git a/src/channels/discord/discord-bridge.mjs b/src/channels/discord/discord-bridge.mjs new file mode 100644 index 0000000..557a0cf --- /dev/null +++ b/src/channels/discord/discord-bridge.mjs @@ -0,0 +1,15 @@ +import { TextHarnessBridge, createTextBridgeStatus } from '../shared/text-harness-bridge.mjs'; + +export const DISCORD_DESCRIPTOR = Object.freeze({ + key: 'discord', + label: 'Discord', + connectionLabel: ' Gateway 长连接', +}); + +export class DiscordHarnessBridge extends TextHarnessBridge { + constructor(options) { + super({ descriptor: DISCORD_DESCRIPTOR, ...options }); + } +} + +export { createTextBridgeStatus as createDiscordBridgeStatus }; diff --git a/src/channels/discord/discord-controller.mjs b/src/channels/discord/discord-controller.mjs new file mode 100644 index 0000000..e7175bb --- /dev/null +++ b/src/channels/discord/discord-controller.mjs @@ -0,0 +1,16 @@ +import { TokenBotController } from '../shared/token-bot-controller.mjs'; +import { deriveDiscordBotIdentity, maskDiscordBotId } from './config-store.mjs'; +import { inspectDiscordToken } from './discord-api.mjs'; +import { DISCORD_DESCRIPTOR } from './discord-bridge.mjs'; + +export class DiscordController extends TokenBotController { + constructor(options) { + super({ + ...options, + descriptor: DISCORD_DESCRIPTOR, + inspectToken: options.inspectToken ?? inspectDiscordToken, + deriveIdentity: deriveDiscordBotIdentity, + maskPlatformId: maskDiscordBotId, + }); + } +} diff --git a/src/channels/discord/discord-runtime.mjs b/src/channels/discord/discord-runtime.mjs new file mode 100644 index 0000000..4f8841f --- /dev/null +++ b/src/channels/discord/discord-runtime.mjs @@ -0,0 +1,460 @@ +import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs'; +import { DiscordApi } from './discord-api.mjs'; +import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs'; + +const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12); +const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]); + +function socketUrl(value) { + const url = new URL(value); + if (url.protocol !== 'wss:') throw new Error('Discord returned an insecure Gateway URL'); + url.searchParams.set('v', '10'); + url.searchParams.set('encoding', 'json'); + return url.href; +} + +function addSocketListener(socket, event, listener) { + if (typeof socket.addEventListener === 'function') socket.addEventListener(event, listener); + else if (typeof socket.on === 'function') socket.on(event, listener); + else throw new TypeError('Discord WebSocket does not support events'); +} + +function eventData(event) { + if (typeof event === 'string') return event; + if (typeof event?.data === 'string') return event.data; + if (Buffer.isBuffer(event)) return event.toString('utf8'); + if (Buffer.isBuffer(event?.data)) return event.data.toString('utf8'); + return null; +} + +function gatewayCloseError(code) { + if (code === 4004) { + const error = new Error('Discord Bot Token 无效,请重新填写。'); + error.code = 'discord-401'; + return error; + } + if (code === 4013 || code === 4014) { + const error = new Error('Discord Gateway Intents 配置不正确,请检查 Developer Portal 的 Bot 设置。'); + error.code = 'discord-intents'; + return error; + } + const error = new Error(`Discord Gateway closed (${code || 'unknown'})`); + error.code = 'discord-gateway-closed'; + return error; +} + +function stripBotMention(text, botId) { + if (typeof text !== 'string') return ''; + return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim(); +} + +export function normalizeDiscordMessage(message, botId) { + if (!message?.id || !message?.channel_id || !message?.author?.id) return null; + const direct = !message.guild_id; + const addressed = direct + || message.mentions?.some((mention) => String(mention?.id) === String(botId)); + return { + messageId: String(message.id), + senderId: String(message.author.id), + senderIsBot: message.author.bot === true, + kind: direct ? 'direct' : 'group', + conversationId: String(message.channel_id), + content: stripBotMention(message.content ?? '', botId), + addressed, + replyTarget: { + channelId: String(message.channel_id), + replyToMessageId: String(message.id), + }, + }; +} + +class DiscordBotClient { + #api; + #signal; + + constructor({ api, signal }) { + this.#api = api; + this.#signal = signal; + } + + async sendText(target, text) { + const chunks = splitMessageText(text, 1_900); + let result = null; + for (const [index, chunk] of chunks.entries()) { + result = await this.#api.createMessage({ + channelId: target.channelId, + content: chunk, + replyToMessageId: index === 0 ? target.replyToMessageId : undefined, + signal: this.#signal, + }); + } + return result; + } + + sendTyping(target) { + return this.#api.sendTyping({ channelId: target.channelId, signal: this.#signal }); + } + + async openStream(target) { + const stream = createEditableMessageStream({ + limit: 1_900, + create: async (content) => { + const message = await this.#api.createMessage({ + channelId: target.channelId, + content, + replyToMessageId: target.replyToMessageId, + signal: this.#signal, + }); + return message.id; + }, + edit: (messageId, content) => this.#api.editMessage({ + channelId: target.channelId, + messageId, + content, + signal: this.#signal, + }), + sendRemainder: (content) => this.#api.createMessage({ + channelId: target.channelId, + content, + signal: this.#signal, + }), + }); + return stream.start(); + } +} + +export function createDiscordRuntimeStatus() { + return { + startedAt: null, + ready: false, + connectionState: 'idle', + harnessReachable: false, + lastCheckedAt: null, + lastConnectedAt: null, + lastError: null, + ...createDiscordBridgeStatus(), + }; +} + +export class DiscordRuntime { + #config; + #token; + #harness; + #state; + #logger; + #replyTimeoutMs; + #connectTimeoutMs; + #createApi; + #createWebSocket; + #random; + #status = createDiscordRuntimeStatus(); + #api = null; + #bridge = null; + #abortController = null; + #socket = null; + #gatewayUrl = null; + #resumeUrl = null; + #sessionId = null; + #sequence = null; + #heartbeatTimer = null; + #heartbeatAcked = true; + #reconnectTimer = null; + #reconnectAttempt = 0; + #generation = 0; + #stopped = true; + #starting = null; + + constructor({ + config, + token, + harness, + state, + logger = console, + replyTimeoutMs = 600_000, + connectTimeoutMs = 20_000, + createApi = (options) => new DiscordApi(options), + createWebSocket = (url) => new WebSocket(url), + random = Math.random, + }) { + if (!config || !token || !harness || !state) { + throw new TypeError('DiscordRuntime requires config, token, Harness, and state'); + } + if (typeof createWebSocket !== 'function') throw new TypeError('DiscordRuntime requires WebSocket'); + this.#config = config; + this.#token = token; + this.#harness = harness; + this.#state = state; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#connectTimeoutMs = connectTimeoutMs; + this.#createApi = createApi; + this.#createWebSocket = createWebSocket; + this.#random = random; + } + + get status() { + return structuredClone(this.#status); + } + + async start() { + if (this.#status.ready && this.#socket) return this.status; + if (this.#starting) return this.#starting; + this.#starting = this.#start().finally(() => { + this.#starting = null; + }); + return this.#starting; + } + + async #start() { + await this.stop(); + this.#stopped = false; + this.#sessionId = null; + this.#resumeUrl = null; + this.#sequence = null; + this.#reconnectAttempt = 0; + this.#status.startedAt = new Date().toISOString(); + this.#status.connectionState = 'connecting'; + this.#status.lastError = null; + await this.#harness.ensureRunning(); + this.#status.harnessReachable = true; + const controller = new AbortController(); + this.#abortController = controller; + const api = this.#createApi({ token: this.#token }); + this.#api = api; + try { + const [bot, gateway] = await Promise.all([ + api.getCurrentUser({ signal: controller.signal }), + api.getGatewayBot({ signal: controller.signal }), + ]); + if (String(bot?.id ?? '') !== this.#config.platformId || bot?.bot !== true) { + throw new Error('Discord token identity does not match the saved bot'); + } + this.#gatewayUrl = gateway?.url; + const client = new DiscordBotClient({ api, signal: controller.signal }); + this.#bridge = new DiscordHarnessBridge({ + bot: client, + harness: this.#harness, + state: this.#state, + status: this.#status, + logger: this.#logger, + replyTimeoutMs: this.#replyTimeoutMs, + }); + let timer; + try { + await Promise.race([ + this.#openSocket(false), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Discord Gateway did not become ready in time')), this.#connectTimeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } + return this.status; + } catch (error) { + this.#status.ready = false; + this.#status.connectionState = 'failed'; + this.#status.lastError = error?.message ?? String(error); + await this.stop(); + throw error; + } + } + + #openSocket(resume) { + if (this.#stopped) return Promise.reject(new Error('Discord runtime is stopped')); + const generation = ++this.#generation; + const url = socketUrl(resume && this.#resumeUrl ? this.#resumeUrl : this.#gatewayUrl); + const socket = this.#createWebSocket(url); + this.#socket = socket; + let settled = false; + return new Promise((resolve, reject) => { + const markReady = () => { + if (settled || generation !== this.#generation) return; + settled = true; + this.#reconnectAttempt = 0; + const now = Date.now(); + this.#status.ready = true; + this.#status.connectionState = 'connected'; + this.#status.lastCheckedAt = now; + this.#status.lastConnectedAt = now; + this.#status.lastError = null; + resolve(); + }; + addSocketListener(socket, 'message', (event) => { + if (generation !== this.#generation || this.#stopped) return; + const raw = eventData(event); + if (!raw) return; + let packet; + try { + packet = JSON.parse(raw); + } catch { + this.#logger.warn?.('[dsh-im:discord] ignored malformed Gateway JSON'); + return; + } + if (Number.isSafeInteger(packet.s)) this.#sequence = packet.s; + if (packet.op === 10) { + this.#startHeartbeat(packet.d?.heartbeat_interval, socket, generation); + if (resume && this.#sessionId) { + this.#sendGateway(socket, { + op: 6, + d: { token: this.#token, session_id: this.#sessionId, seq: this.#sequence }, + }); + } else { + this.#sendGateway(socket, { + op: 2, + d: { + token: this.#token, + intents: DISCORD_GATEWAY_INTENTS, + properties: { + os: process.platform, + browser: 'dsh-im', + device: 'dsh-im', + }, + }, + }); + } + return; + } + if (packet.op === 11) { + this.#heartbeatAcked = true; + this.#status.lastCheckedAt = Date.now(); + return; + } + if (packet.op === 1) { + this.#heartbeat(socket); + return; + } + if (packet.op === 7) { + socket.close(4000, 'Reconnect requested'); + return; + } + if (packet.op === 9) { + if (packet.d !== true) { + this.#sessionId = null; + this.#resumeUrl = null; + this.#sequence = null; + } + socket.close(4000, 'Invalid session'); + return; + } + if (packet.op !== 0) return; + if (packet.t === 'READY') { + this.#sessionId = packet.d?.session_id ?? null; + this.#resumeUrl = packet.d?.resume_gateway_url ?? null; + markReady(); + } else if (packet.t === 'RESUMED') { + markReady(); + } else if (packet.t === 'MESSAGE_CREATE') { + const message = normalizeDiscordMessage(packet.d, this.#config.platformId); + if (message) void this.#bridge?.accept(message); + } + }); + addSocketListener(socket, 'close', (event = {}) => { + if (generation !== this.#generation) return; + this.#clearHeartbeat(); + if (this.#socket === socket) this.#socket = null; + if (this.#stopped) { + if (!settled) reject(new DOMException('Stopped', 'AbortError')); + return; + } + const error = gatewayCloseError(Number(event.code) || 0); + this.#status.ready = false; + this.#status.connectionState = 'connecting'; + this.#status.lastError = error.message; + if (!settled) { + settled = true; + reject(error); + } + if (error.code === 'discord-401' || error.code === 'discord-intents') { + this.#status.connectionState = 'failed'; + return; + } + this.#scheduleReconnect(); + }); + addSocketListener(socket, 'error', () => { + if (generation !== this.#generation || this.#stopped) return; + this.#status.lastError = 'Discord Gateway WebSocket error'; + }); + }); + } + + #sendGateway(socket, payload) { + if (socket.readyState !== 1) return; + socket.send(JSON.stringify(payload)); + } + + #startHeartbeat(interval, socket, generation) { + this.#clearHeartbeat(); + if (!Number.isFinite(interval) || interval < 1_000) { + socket.close(4000, 'Invalid heartbeat interval'); + return; + } + this.#heartbeatAcked = true; + const schedule = (delay) => { + this.#heartbeatTimer = setTimeout(() => { + if (this.#stopped || generation !== this.#generation || this.#socket !== socket) return; + if (!this.#heartbeatAcked) { + socket.close(4000, 'Heartbeat was not acknowledged'); + return; + } + this.#heartbeat(socket); + schedule(interval); + }, delay); + this.#heartbeatTimer?.unref?.(); + }; + schedule(Math.floor(interval * this.#random())); + } + + #heartbeat(socket) { + this.#heartbeatAcked = false; + this.#sendGateway(socket, { op: 1, d: this.#sequence }); + } + + #clearHeartbeat() { + if (this.#heartbeatTimer !== null) clearTimeout(this.#heartbeatTimer); + this.#heartbeatTimer = null; + this.#heartbeatAcked = true; + } + + #scheduleReconnect() { + if (this.#stopped || this.#reconnectTimer !== null) return; + const delay = RECONNECT_DELAYS_MS[Math.min(this.#reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)]; + this.#reconnectAttempt += 1; + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = null; + void this.#openSocket(Boolean(this.#sessionId)).catch((error) => { + if (this.#stopped) return; + this.#logger.warn?.('[dsh-im:discord] Gateway reconnect failed:', error); + this.#scheduleReconnect(); + }); + }, delay); + this.#reconnectTimer?.unref?.(); + } + + async stop() { + this.#stopped = true; + this.#generation += 1; + this.#abortController?.abort(); + this.#abortController = null; + if (this.#reconnectTimer !== null) clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + this.#clearHeartbeat(); + const socket = this.#socket; + const bridge = this.#bridge; + this.#socket = null; + this.#bridge = null; + this.#api = null; + try { + if (socket && socket.readyState < 2) socket.close(1000, 'Plugin stopped'); + } catch (error) { + this.#logger.warn?.(`[dsh-im:discord] bot ${this.#config.botId} failed to close Gateway:`, error); + } + await Promise.race([ + bridge?.waitForIdle() ?? Promise.resolve(), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); + this.#status.ready = false; + this.#status.connectionState = 'idle'; + return this.status; + } +} diff --git a/src/channels/discord/harness-client.mjs b/src/channels/discord/harness-client.mjs new file mode 100644 index 0000000..a0d8596 --- /dev/null +++ b/src/channels/discord/harness-client.mjs @@ -0,0 +1,3 @@ +import { HarnessClient } from '../weixin/harness-client.mjs'; + +export class DiscordHarnessClient extends HarnessClient {} diff --git a/src/channels/discord/state-store.mjs b/src/channels/discord/state-store.mjs new file mode 100644 index 0000000..2fa81db --- /dev/null +++ b/src/channels/discord/state-store.mjs @@ -0,0 +1,3 @@ +import { ConversationStateStore } from '../shared/conversation-state-store.mjs'; + +export class DiscordStateStore extends ConversationStateStore {} diff --git a/src/channels/shared/conversation-state-store.mjs b/src/channels/shared/conversation-state-store.mjs new file mode 100644 index 0000000..e8d9dec --- /dev/null +++ b/src/channels/shared/conversation-state-store.mjs @@ -0,0 +1,107 @@ +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [], cursor: null }); + +function normalizeState(value) { + if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE); + const sessions = {}; + if (value.sessions && typeof value.sessions === 'object' && !Array.isArray(value.sessions)) { + for (const [key, sessionId] of Object.entries(value.sessions)) { + if (typeof key === 'string' && key && typeof sessionId === 'string' && sessionId) { + sessions[key] = sessionId; + } + } + } + return { + version: 1, + sessions, + seenMessageIds: Array.isArray(value.seenMessageIds) + ? value.seenMessageIds.filter((id) => typeof id === 'string' && id).slice(-1_000) + : [], + cursor: Number.isSafeInteger(value.cursor) && value.cursor >= 0 ? value.cursor : null, + }; +} + +export class ConversationStateStore { + #path; + #state = structuredClone(EMPTY_STATE); + #writeQueue = Promise.resolve(); + + constructor(path) { + this.#path = path; + } + + async load() { + try { + this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8'))); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + this.#state = structuredClone(EMPTY_STATE); + await this.#persist(); + } + return this; + } + + sessionFor(key) { + return this.#state.sessions[key] ?? null; + } + + async setSession(key, sessionId) { + this.#state.sessions[key] = sessionId; + await this.#persist(); + } + + async clearSession(key) { + delete this.#state.sessions[key]; + await this.#persist(); + } + + hasSeen(messageId) { + return this.#state.seenMessageIds.includes(messageId); + } + + async markSeen(messageId) { + if (this.hasSeen(messageId)) return; + this.#state.seenMessageIds.push(messageId); + if (this.#state.seenMessageIds.length > 1_000) { + this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1_000); + } + await this.#persist(); + } + + cursor() { + return this.#state.cursor; + } + + async setCursor(cursor) { + if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TypeError('Invalid update cursor'); + this.#state.cursor = cursor; + await this.#persist(); + } + + snapshot() { + return structuredClone(this.#state); + } + + async remove() { + try { + await unlink(this.#path); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + this.#state = structuredClone(EMPTY_STATE); + } + + async #persist() { + const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`; + const operation = this.#writeQueue.then(async () => { + await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 }); + const temporary = `${this.#path}.tmp`; + await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 }); + await rename(temporary, this.#path); + }); + this.#writeQueue = operation.then(() => undefined, () => undefined); + await operation; + } +} diff --git a/src/channels/shared/editable-message-stream.mjs b/src/channels/shared/editable-message-stream.mjs new file mode 100644 index 0000000..81859b5 --- /dev/null +++ b/src/channels/shared/editable-message-stream.mjs @@ -0,0 +1,81 @@ +export function splitMessageText(value, limit) { + const text = typeof value === 'string' ? value.trim() : ''; + if (!text) return []; + const chunks = []; + let remaining = text; + while (remaining.length > limit) { + let cut = remaining.lastIndexOf('\n', limit); + if (cut < Math.floor(limit * 0.55)) cut = remaining.lastIndexOf(' ', limit); + if (cut < Math.floor(limit * 0.55)) cut = limit; + chunks.push(remaining.slice(0, cut).trim()); + remaining = remaining.slice(cut).trimStart(); + } + if (remaining) chunks.push(remaining); + return chunks; +} + +export function createEditableMessageStream({ + initialText = '正在处理…', + limit, + updateIntervalMs = 800, + create, + edit, + sendRemainder, + logger = console, +}) { + let messageId; + let pending = null; + let timer = null; + let inFlight = null; + let closed = false; + let lastSent = initialText; + + const schedule = () => { + if (closed || timer !== null || inFlight || !pending) return; + timer = setTimeout(() => { + timer = null; + const text = pending; + pending = null; + const next = splitMessageText(text, limit)[0] ?? initialText; + inFlight = Promise.resolve(next === lastSent ? undefined : edit(messageId, next)) + .then(() => { lastSent = next; }) + .catch((error) => logger.warn?.('[dsh-im] streamed message update failed:', error)) + .finally(() => { + inFlight = null; + schedule(); + }); + }, updateIntervalMs); + timer?.unref?.(); + }; + + return { + async start() { + messageId = await create(initialText); + return this; + }, + update(text) { + if (closed || typeof text !== 'string' || !text.trim()) return; + pending = text; + schedule(); + }, + async finish(text) { + if (closed) throw new Error('Message stream is already closed'); + closed = true; + if (timer !== null) clearTimeout(timer); + timer = null; + pending = null; + await inFlight?.catch(() => undefined); + const chunks = splitMessageText(text, limit); + const first = chunks[0] ?? '处理完成。'; + if (first !== lastSent) await edit(messageId, first); + lastSent = first; + for (const chunk of chunks.slice(1)) await sendRemainder(chunk); + }, + cancel() { + closed = true; + pending = null; + if (timer !== null) clearTimeout(timer); + timer = null; + }, + }; +} diff --git a/src/channels/shared/text-harness-bridge.mjs b/src/channels/shared/text-harness-bridge.mjs new file mode 100644 index 0000000..5583de4 --- /dev/null +++ b/src/channels/shared/text-harness-bridge.mjs @@ -0,0 +1,180 @@ +function cleanText(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +export function createTextBridgeStatus() { + return { + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + lastMessageAt: null, + lastReplyAt: null, + lastRejectedAt: null, + lastError: null, + }; +} + +export class TextHarnessBridge { + #descriptor; + #bot; + #harness; + #state; + #status; + #logger; + #replyTimeoutMs; + #queues = new Map(); + + constructor({ + descriptor, + bot, + harness, + state, + status = createTextBridgeStatus(), + logger = console, + replyTimeoutMs = 600_000, + }) { + if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required'); + if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required'); + if (!harness || !state) throw new TypeError('Harness client and state store are required'); + this.#descriptor = descriptor; + this.#bot = bot; + this.#harness = harness; + this.#state = state; + this.#status = status; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + } + + get status() { + return structuredClone(this.#status); + } + + accept(message) { + const conversationId = cleanText(message?.conversationId); + const kind = message?.kind === 'group' ? 'group' : 'direct'; + const key = `${kind}:${conversationId}`; + const previous = this.#queues.get(key) ?? Promise.resolve(); + const current = previous + .catch(() => undefined) + .then(() => this.#process({ ...message, kind, conversationId })) + .finally(() => { + if (this.#queues.get(key) === current) this.#queues.delete(key); + }); + this.#queues.set(key, current); + return current; + } + + async waitForIdle() { + await Promise.allSettled([...this.#queues.values()]); + } + + async #process(message) { + const messageId = cleanText(message.messageId); + const senderId = cleanText(message.senderId); + if (!messageId || !senderId || !message.conversationId || message.senderIsBot === true) return; + if (this.#state.hasSeen(messageId)) return; + + this.#status.messagesReceived += 1; + this.#status.lastMessageAt = new Date().toISOString(); + if (message.kind === 'group' && message.addressed !== true) { + this.#status.messagesRejected += 1; + this.#status.lastRejectedAt = new Date().toISOString(); + return; + } + + const target = message.replyTarget; + const text = cleanText(message.content); + try { + if (!text) { + await this.#bot.sendText(target, '目前仅支持文字消息。'); + await this.#state.markSeen(messageId); + return; + } + const command = text.toLowerCase(); + if (command === '/help') { + await this.#bot.sendText(target, [ + `${this.#descriptor.label}机器人已连接 DeepSeek Harness。`, + '', + '直接发送文字即可继续当前会话。', + '/new 开启一个全新会话', + '/status 检查连接状态', + '/help 显示本帮助', + ].join('\n')); + await this.#state.markSeen(messageId); + return; + } + if (command === '/status') { + await this.#harness.ensureRunning(); + await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`); + await this.#state.markSeen(messageId); + return; + } + const conversationKey = `${message.kind}:${message.conversationId}`; + if (command === '/new') { + await this.#state.clearSession(conversationKey); + await this.#bot.sendText(target, '已开启新会话。请发送你的问题。'); + await this.#state.markSeen(messageId); + return; + } + + let sessionId = this.#state.sessionFor(conversationKey); + if (!sessionId || !(await this.#harness.sessionExists(sessionId))) { + sessionId = await this.#harness.createSession(); + await this.#state.setSession(conversationKey, sessionId); + } + + await this.#bot.sendTyping?.(target).catch((error) => { + this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error); + }); + let stream = null; + let streamFinished = false; + if (typeof this.#bot.openStream === 'function') { + try { + stream = await this.#bot.openStream(target); + } catch (error) { + this.#logger.warn?.( + `[dsh-im:${this.#descriptor.key}] unable to start a streamed reply; using text:`, + error, + ); + } + } + const answer = await this.#harness.ask(sessionId, text, { + timeoutMs: this.#replyTimeoutMs, + onUpdate: stream ? async (update) => { + const progress = update.type === 'text' ? update.text + : update.type === 'tool' ? `正在使用${update.name}…` : update.text; + if (progress) await stream.update(progress); + } : undefined, + }); + if (stream) { + try { + await stream.finish(answer); + streamFinished = true; + } catch (error) { + stream.cancel?.(); + this.#logger.warn?.( + `[dsh-im:${this.#descriptor.key}] streamed reply finalization failed; using text:`, + error, + ); + } + } + if (!streamFinished) await this.#bot.sendText(target, answer); + await this.#state.markSeen(messageId); + this.#status.messagesReplied += 1; + this.#status.lastReplyAt = new Date().toISOString(); + this.#status.lastError = null; + } catch (error) { + this.#status.lastError = error?.message ?? String(error); + this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error); + try { + await this.#bot.sendText(target, '消息处理失败,请稍后重试。'); + await this.#state.markSeen(messageId); + } catch (sendError) { + this.#logger.error?.( + `[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`, + sendError, + ); + } + } + } +} diff --git a/src/channels/shared/token-bot-controller.mjs b/src/channels/shared/token-bot-controller.mjs new file mode 100644 index 0000000..6152870 --- /dev/null +++ b/src/channels/shared/token-bot-controller.mjs @@ -0,0 +1,298 @@ +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function safeError(code, message) { + return Object.freeze({ code, message }); +} + +export class TokenBotController { + #descriptor; + #credentials; + #configStore; + #inspectToken; + #deriveIdentity; + #maskPlatformId; + #createRuntime; + #deleteState; + #logger; + #runtimes = new Map(); + #errors = new Map(); + #transitions = new Map(); + #revision = 0; + #closed = false; + + constructor({ + descriptor, + credentials, + configStore, + inspectToken, + deriveIdentity, + maskPlatformId, + createRuntime, + deleteState = async () => {}, + logger = console, + }) { + if (!descriptor?.key || !descriptor?.label || !descriptor?.connectionLabel) { + throw new TypeError('TokenBotController requires a channel descriptor'); + } + if (!credentials || typeof credentials.resolve !== 'function' + || typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') { + throw new TypeError(`${descriptor.label} requires the DSH credential provider`); + } + if (!configStore || typeof configStore.list !== 'function' + || typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') { + throw new TypeError(`${descriptor.label} requires a config store`); + } + if (typeof inspectToken !== 'function' || typeof deriveIdentity !== 'function' + || typeof maskPlatformId !== 'function' || typeof createRuntime !== 'function') { + throw new TypeError(`${descriptor.label} controller dependencies are incomplete`); + } + this.#descriptor = descriptor; + this.#credentials = credentials; + this.#configStore = configStore; + this.#inspectToken = inspectToken; + this.#deriveIdentity = deriveIdentity; + this.#maskPlatformId = maskPlatformId; + this.#createRuntime = createRuntime; + this.#deleteState = deleteState; + this.#logger = logger; + } + + async initialize() { + if (this.#closed) return this.status(); + for (const config of this.#configStore.list()) { + await this.#withBotTransition(config.botId, async () => { + if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return; + const token = await this.#resolveToken(config.tokenRef); + if (!token) { + this.#errors.set(config.botId, safeError( + 'missing-token', + `${this.#descriptor.label}机器人凭据缺失,请移除后重新接入。`, + )); + return; + } + try { + await this.#startRuntime(config, token); + this.#errors.delete(config.botId); + } catch (error) { + this.#errors.set(config.botId, safeError( + 'connection-failed', + `${this.#descriptor.label}连接未就绪,插件会自动重试。`, + )); + this.#logger.warn?.( + `[dsh-im:${this.#descriptor.key}] bot ${config.botId} failed to initialize:`, + error, + ); + } finally { + this.#touch(); + } + }); + } + return this.status(); + } + + async bindCredentials({ token } = {}) { + if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`); + const normalizedToken = cleanString(token); + if (!normalizedToken) throw new TypeError(`${this.#descriptor.label} Bot Token is required`); + const inspected = await this.#inspectToken(normalizedToken); + const platformId = cleanString(inspected?.platformId); + const name = cleanString(inspected?.name); + if (!platformId || !name) throw new Error(`${this.#descriptor.label} returned an invalid bot identity`); + const identity = this.#deriveIdentity(platformId); + await this.#withBotTransition(identity.botId, async () => { + if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`); + const previousConfig = this.#configStore.getByPlatformId(platformId); + const previousToken = await this.#credentials.resolve(identity.tokenRef).catch(() => undefined); + const config = { + botId: identity.botId, + platformId, + tokenRef: identity.tokenRef, + name, + username: cleanString(inspected.username), + createdAt: previousConfig?.createdAt ?? new Date().toISOString(), + connectedAt: new Date().toISOString(), + }; + await this.#credentials.set(identity.tokenRef, normalizedToken); + try { + await this.#configStore.save(config); + } catch (error) { + await this.#restoreCredential(identity.tokenRef, previousToken); + throw error; + } + try { + await this.#startRuntime(config, normalizedToken); + this.#errors.delete(identity.botId); + } catch (error) { + this.#errors.set(identity.botId, safeError( + 'connection-failed', + `${this.#descriptor.label}机器人已接入,消息连接暂未就绪。`, + )); + this.#logger.warn?.( + `[dsh-im:${this.#descriptor.key}] bot ${identity.botId} credential connection failed:`, + error, + ); + } + this.#touch(); + }); + return this.status(); + } + + async reconnectBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`); + await this.#withBotTransition(botId, async () => { + const token = await this.#resolveToken(config.tokenRef); + if (!token) throw new Error(`${this.#descriptor.label} bot token is missing`); + try { + await this.#startRuntime(config, token); + this.#errors.delete(botId); + } catch (error) { + this.#errors.set(botId, safeError( + 'connection-failed', + `${this.#descriptor.label}连接仍未就绪,请稍后重试。`, + )); + throw error; + } finally { + this.#touch(); + } + }); + return this.status(); + } + + async deleteBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`); + await this.#withBotTransition(botId, async () => { + const previous = await this.#credentials.resolve(config.tokenRef).catch(() => undefined); + await this.#stopRuntime(botId); + try { + await this.#credentials.unset(config.tokenRef); + await this.#configStore.remove(botId); + } catch (error) { + if (previous?.value) { + await this.#credentials.set(config.tokenRef, previous.value).catch(() => undefined); + await this.#startRuntime(config, previous.value).catch(() => undefined); + } + throw new Error(`Unable to remove the ${this.#descriptor.label} bot safely.`, { cause: error }); + } + await this.#deleteState({ botId, config }).catch((error) => { + this.#logger.warn?.( + `[dsh-im:${this.#descriptor.key}] bot ${botId} state cleanup failed:`, + error, + ); + }); + this.#errors.delete(botId); + this.#touch(); + }); + return this.status(); + } + + status() { + const bots = this.#configStore.list().map((config) => { + const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null; + const connected = runtimeStatus?.ready === true + && runtimeStatus.connectionState === 'connected' + && runtimeStatus.harnessReachable === true; + const state = connected ? 'connected' + : runtimeStatus?.connectionState === 'connecting' ? 'connecting' + : this.#errors.has(config.botId) || runtimeStatus?.connectionState === 'failed' + ? 'error' : 'offline'; + return { + botId: config.botId, + state, + connected, + configured: true, + bot: { + name: config.name, + username: config.username, + idMasked: this.#maskPlatformId(config.platformId), + }, + health: { + status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline', + summary: connected ? `${this.#descriptor.label}${this.#descriptor.connectionLabel}运行正常` + : state === 'error' ? `${this.#descriptor.label}连接未就绪,插件会自动重试` + : `${this.#descriptor.label}连接当前离线`, + lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null, + lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null, + }, + stats: { + messagesReceived: runtimeStatus?.messagesReceived ?? 0, + messagesReplied: runtimeStatus?.messagesReplied ?? 0, + }, + error: structuredClone(this.#errors.get(config.botId) ?? null), + }; + }); + const connectedCount = bots.filter((bot) => bot.connected).length; + return { + schemaVersion: 1, + revision: this.#revision, + state: bots.length === 0 ? 'disconnected' + : connectedCount === bots.length ? 'connected' + : connectedCount > 0 ? 'degraded' : 'offline', + bots, + totals: { configured: bots.length, connected: connectedCount }, + }; + } + + async close() { + if (this.#closed) return; + this.#closed = true; + await Promise.allSettled([...this.#transitions.values()]); + await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId))); + } + + async #startRuntime(config, token) { + if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`); + await this.#stopRuntime(config.botId); + if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`); + const runtime = await this.#createRuntime({ botId: config.botId, config, token }); + if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') { + throw new TypeError(`createRuntime returned an invalid ${this.#descriptor.label} runtime`); + } + this.#runtimes.set(config.botId, runtime); + try { + await runtime.start(); + } catch (error) { + await runtime.stop().catch(() => undefined); + this.#runtimes.delete(config.botId); + throw error; + } + } + + async #stopRuntime(botId) { + const runtime = this.#runtimes.get(botId); + this.#runtimes.delete(botId); + await runtime?.stop().catch((error) => { + this.#logger.warn?.( + `[dsh-im:${this.#descriptor.key}] bot ${botId} failed to stop cleanly:`, + error, + ); + }); + } + + async #resolveToken(ref) { + const result = await this.#credentials.resolve(ref).catch(() => undefined); + return cleanString(result?.value); + } + + async #restoreCredential(ref, previous) { + if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined); + else await this.#credentials.unset(ref).catch(() => undefined); + } + + #withBotTransition(botId, operation) { + const previous = this.#transitions.get(botId) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(operation); + const settled = current.finally(() => { + if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId); + }); + this.#transitions.set(botId, settled); + return settled; + } + + #touch() { + this.#revision += 1; + } +} diff --git a/src/channels/shared/token-config-store.mjs b/src/channels/shared/token-config-store.mjs new file mode 100644 index 0000000..0166006 --- /dev/null +++ b/src/channels/shared/token-config-store.mjs @@ -0,0 +1,180 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) }); + +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function escapePattern(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function deriveTokenBotIdentity(platformId, { botPrefix, tokenRefPrefix }) { + const raw = cleanString(platformId); + if (!raw) throw new TypeError('platformId is required'); + const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24); + return { + botId: `${botPrefix}_${digest}`, + tokenRef: `${tokenRefPrefix}_${digest.toUpperCase()}`, + }; +} + +export function maskPlatformId(platformId, fallback) { + const value = cleanString(platformId) ?? ''; + if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : fallback; + return `${value.slice(0, 6)}••••${value.slice(-4)}`; +} + +export class TokenBotConfigStore { + #path; + #channel; + #botPrefix; + #tokenRefPrefix; + #botIdPattern; + #tokenRefPattern; + #value = EMPTY_DOCUMENT; + #writeQueue = Promise.resolve(); + + constructor(path, { channel, botPrefix, tokenRefPrefix }) { + this.#path = path; + this.#channel = channel; + this.#botPrefix = botPrefix; + this.#tokenRefPrefix = tokenRefPrefix; + this.#botIdPattern = new RegExp(`^${escapePattern(botPrefix)}_[a-f0-9]{24}$`); + this.#tokenRefPattern = new RegExp(`^${escapePattern(tokenRefPrefix)}_[A-F0-9]{24}$`); + } + + async load() { + try { + const normalized = this.#normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8'))); + if (!normalized) throw new Error(`dsh-im ${this.#channel} config contains invalid bot data`); + this.#value = normalized; + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + this.#value = EMPTY_DOCUMENT; + } + return this; + } + + list() { + return structuredClone(this.#value.bots); + } + + get(botId) { + const bot = this.#value.bots.find((candidate) => candidate.botId === botId); + return bot ? structuredClone(bot) : null; + } + + getByPlatformId(platformId) { + const bot = this.#value.bots.find((candidate) => candidate.platformId === platformId); + return bot ? structuredClone(bot) : null; + } + + async save(value) { + const normalized = this.#normalizeBot(value); + if (!normalized) throw new Error(`Refusing to persist incomplete ${this.#channel} bot data`); + return this.#mutate((bots) => { + const identityCollision = bots.find( + (bot) => bot.platformId === normalized.platformId && bot.botId !== normalized.botId, + ); + const refCollision = bots.find( + (bot) => bot.tokenRef === normalized.tokenRef && bot.botId !== normalized.botId, + ); + if (identityCollision || refCollision) throw new Error(`Duplicate ${this.#channel} bot identity`); + const index = bots.findIndex((bot) => bot.botId === normalized.botId); + if (index === -1) bots.push(normalized); + else bots[index] = normalized; + return structuredClone(normalized); + }); + } + + async remove(botId) { + if (!this.#botIdPattern.test(botId)) throw new TypeError(`Invalid ${this.#channel} bot id`); + return this.#mutate((bots) => { + const index = bots.findIndex((bot) => bot.botId === botId); + if (index === -1) return null; + const [removed] = bots.splice(index, 1); + return structuredClone(removed); + }); + } + + async clear() { + const operation = this.#writeQueue.then(async () => { + try { + await unlink(this.#path); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + this.#value = EMPTY_DOCUMENT; + }); + this.#writeQueue = operation.then(() => undefined, () => undefined); + await operation; + } + + #normalizeBot(value) { + if (!value || typeof value !== 'object') return null; + const platformId = cleanString(value.platformId); + const botId = cleanString(value.botId); + const tokenRef = cleanString(value.tokenRef); + const name = cleanString(value.name); + if (!platformId || !botId || !tokenRef || !name + || !this.#botIdPattern.test(botId) || !this.#tokenRefPattern.test(tokenRef)) return null; + const derived = deriveTokenBotIdentity(platformId, { + botPrefix: this.#botPrefix, + tokenRefPrefix: this.#tokenRefPrefix, + }); + if (derived.botId !== botId || derived.tokenRef !== tokenRef) return null; + return Object.freeze({ + botId, + platformId, + tokenRef, + name, + username: cleanString(value.username), + createdAt: cleanString(value.createdAt) ?? new Date().toISOString(), + connectedAt: cleanString(value.connectedAt), + }); + } + + #normalizeDocument(value) { + if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null; + const bots = value.bots.map((bot) => this.#normalizeBot(bot)); + if (bots.some((bot) => bot === null)) return null; + const ids = new Set(); + const platformIds = new Set(); + const refs = new Set(); + for (const bot of bots) { + if (ids.has(bot.botId) || platformIds.has(bot.platformId) || refs.has(bot.tokenRef)) return null; + ids.add(bot.botId); + platformIds.add(bot.platformId); + refs.add(bot.tokenRef); + } + return Object.freeze({ version: 1, bots: Object.freeze(bots) }); + } + + async #mutate(mutator) { + let result; + const operation = this.#writeQueue.then(async () => { + const bots = [...this.#value.bots]; + result = mutator(bots); + const document = Object.freeze({ version: 1, bots: Object.freeze(bots) }); + await this.#write(document); + this.#value = document; + }); + this.#writeQueue = operation.then(() => undefined, () => undefined); + await operation; + return result; + } + + async #write(document) { + await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 }); + const temporary = `${this.#path}.tmp`; + await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + await rename(temporary, this.#path); + } +} diff --git a/src/channels/telegram/config-store.mjs b/src/channels/telegram/config-store.mjs new file mode 100644 index 0000000..c6706f8 --- /dev/null +++ b/src/channels/telegram/config-store.mjs @@ -0,0 +1,24 @@ +import { + deriveTokenBotIdentity, + maskPlatformId, + TokenBotConfigStore, +} from '../shared/token-config-store.mjs'; + +const IDENTITY_OPTIONS = Object.freeze({ + botPrefix: 'telegram', + tokenRefPrefix: 'DSH_TELEGRAM_BOT_TOKEN', +}); + +export function deriveTelegramBotIdentity(platformId) { + return deriveTokenBotIdentity(platformId, IDENTITY_OPTIONS); +} + +export function maskTelegramBotId(platformId) { + return maskPlatformId(platformId, 'Telegram机器人'); +} + +export class TelegramConfigStore extends TokenBotConfigStore { + constructor(path) { + super(path, { channel: 'Telegram', ...IDENTITY_OPTIONS }); + } +} diff --git a/src/channels/telegram/harness-client.mjs b/src/channels/telegram/harness-client.mjs new file mode 100644 index 0000000..a903d9f --- /dev/null +++ b/src/channels/telegram/harness-client.mjs @@ -0,0 +1,3 @@ +import { HarnessClient } from '../weixin/harness-client.mjs'; + +export class TelegramHarnessClient extends HarnessClient {} diff --git a/src/channels/telegram/state-store.mjs b/src/channels/telegram/state-store.mjs new file mode 100644 index 0000000..fc2dd09 --- /dev/null +++ b/src/channels/telegram/state-store.mjs @@ -0,0 +1,3 @@ +import { ConversationStateStore } from '../shared/conversation-state-store.mjs'; + +export class TelegramStateStore extends ConversationStateStore {} diff --git a/src/channels/telegram/telegram-api.mjs b/src/channels/telegram/telegram-api.mjs new file mode 100644 index 0000000..b00a622 --- /dev/null +++ b/src/channels/telegram/telegram-api.mjs @@ -0,0 +1,120 @@ +const DEFAULT_BASE_URL = 'https://api.telegram.org/'; + +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function requestSignal(signal, timeoutMs) { + const timeout = AbortSignal.timeout(timeoutMs); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +export function validTelegramToken(value) { + return typeof value === 'string' && /^\d{5,20}:[A-Za-z0-9_-]{20,}$/.test(value.trim()); +} + +export class TelegramApi { + #token; + #fetch; + #baseUrl; + + constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) { + if (!validTelegramToken(token)) throw new TypeError('Telegram Bot Token is invalid'); + if (typeof fetchImpl !== 'function') throw new TypeError('TelegramApi requires fetch'); + this.#token = token.trim(); + this.#fetch = fetchImpl; + this.#baseUrl = new URL(baseUrl); + } + + async getMe(options = {}) { + return this.#call('getMe', {}, options); + } + + async getWebhookInfo(options = {}) { + return this.#call('getWebhookInfo', {}, options); + } + + async getUpdates({ offset, timeout = 25, signal } = {}) { + const payload = { + timeout, + limit: 100, + allowed_updates: ['message'], + ...(Number.isSafeInteger(offset) ? { offset } : {}), + }; + return this.#call('getUpdates', payload, { + signal, + timeoutMs: Math.max(10_000, (timeout + 10) * 1_000), + }); + } + + async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) { + return this.#call('sendMessage', { + chat_id: chatId, + text, + link_preview_options: { is_disabled: true }, + ...(replyToMessageId ? { + reply_parameters: { message_id: replyToMessageId, allow_sending_without_reply: true }, + } : {}), + ...(messageThreadId ? { message_thread_id: messageThreadId } : {}), + }, { signal }); + } + + async editMessageText({ chatId, messageId, text, signal }) { + return this.#call('editMessageText', { + chat_id: chatId, + message_id: messageId, + text, + link_preview_options: { is_disabled: true }, + }, { signal }); + } + + async sendChatAction({ chatId, messageThreadId, signal }) { + return this.#call('sendChatAction', { + chat_id: chatId, + action: 'typing', + ...(messageThreadId ? { message_thread_id: messageThreadId } : {}), + }, { signal }); + } + + async #call(method, payload, { signal, timeoutMs = 15_000 } = {}) { + const url = new URL(this.#baseUrl); + url.pathname = `${url.pathname.replace(/\/$/, '')}/bot${this.#token}/${method}`; + let response; + try { + response = await this.#fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + signal: requestSignal(signal, timeoutMs), + redirect: 'error', + }); + } catch (error) { + if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error; + throw new Error(`Telegram ${method} transport failed`); + } + let body; + try { + body = await response.json(); + } catch { + throw new Error(`Telegram ${method} returned invalid JSON`); + } + if (!response.ok || body?.ok !== true) { + const description = cleanString(body?.description); + const error = new Error(description ?? `Telegram ${method} failed`); + error.code = Number.isInteger(body?.error_code) ? `telegram-${body.error_code}` : 'telegram-api-error'; + throw error; + } + return body.result; + } +} + +export async function inspectTelegramToken(token, options = {}) { + const api = new TelegramApi({ token, ...options }); + const bot = await api.getMe(); + if (!bot?.id || bot?.is_bot !== true) throw new Error('Telegram token does not belong to a bot'); + return { + platformId: String(bot.id), + name: cleanString([bot.first_name, bot.last_name].filter(Boolean).join(' ')) ?? 'Telegram机器人', + username: cleanString(bot.username), + }; +} diff --git a/src/channels/telegram/telegram-bridge.mjs b/src/channels/telegram/telegram-bridge.mjs new file mode 100644 index 0000000..5a76a3a --- /dev/null +++ b/src/channels/telegram/telegram-bridge.mjs @@ -0,0 +1,15 @@ +import { TextHarnessBridge, createTextBridgeStatus } from '../shared/text-harness-bridge.mjs'; + +export const TELEGRAM_DESCRIPTOR = Object.freeze({ + key: 'telegram', + label: 'Telegram', + connectionLabel: ' Bot API 长轮询', +}); + +export class TelegramHarnessBridge extends TextHarnessBridge { + constructor(options) { + super({ descriptor: TELEGRAM_DESCRIPTOR, ...options }); + } +} + +export { createTextBridgeStatus as createTelegramBridgeStatus }; diff --git a/src/channels/telegram/telegram-controller.mjs b/src/channels/telegram/telegram-controller.mjs new file mode 100644 index 0000000..d04e69b --- /dev/null +++ b/src/channels/telegram/telegram-controller.mjs @@ -0,0 +1,16 @@ +import { TokenBotController } from '../shared/token-bot-controller.mjs'; +import { deriveTelegramBotIdentity, maskTelegramBotId } from './config-store.mjs'; +import { inspectTelegramToken } from './telegram-api.mjs'; +import { TELEGRAM_DESCRIPTOR } from './telegram-bridge.mjs'; + +export class TelegramController extends TokenBotController { + constructor(options) { + super({ + ...options, + descriptor: TELEGRAM_DESCRIPTOR, + inspectToken: options.inspectToken ?? inspectTelegramToken, + deriveIdentity: deriveTelegramBotIdentity, + maskPlatformId: maskTelegramBotId, + }); + } +} diff --git a/src/channels/telegram/telegram-runtime.mjs b/src/channels/telegram/telegram-runtime.mjs new file mode 100644 index 0000000..05524e0 --- /dev/null +++ b/src/channels/telegram/telegram-runtime.mjs @@ -0,0 +1,278 @@ +import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs'; +import { TelegramApi } from './telegram-api.mjs'; +import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs'; + +function escaped(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function mentionedUsername(message, username) { + if (!username || typeof message?.text !== 'string' || !Array.isArray(message.entities)) return false; + return message.entities.some((entity) => { + if (entity?.type !== 'mention' || !Number.isInteger(entity.offset) || !Number.isInteger(entity.length)) { + return false; + } + return message.text.slice(entity.offset, entity.offset + entity.length).toLowerCase() + === `@${username.toLowerCase()}`; + }); +} + +function withoutBotMention(text, username) { + if (!username || typeof text !== 'string') return text; + return text.replace(new RegExp(`@${escaped(username)}\\b`, 'ig'), '').trim(); +} + +export function normalizeTelegramUpdate(update, { botId, username }) { + const message = update?.message; + const chatId = message?.chat?.id; + const senderId = message?.from?.id; + const messageId = message?.message_id; + if (!Number.isSafeInteger(update?.update_id) || chatId === undefined || senderId === undefined + || !Number.isSafeInteger(messageId)) return null; + if (!['private', 'group', 'supergroup'].includes(message.chat?.type)) return null; + const direct = message.chat.type === 'private'; + const addressed = direct + || String(message.reply_to_message?.from?.id ?? '') === String(botId) + || mentionedUsername(message, username); + return { + messageId: String(update.update_id), + senderId: String(senderId), + senderIsBot: message.from?.is_bot === true, + kind: direct ? 'direct' : 'group', + conversationId: String(chatId), + content: withoutBotMention(message.text ?? message.caption ?? '', username), + addressed, + replyTarget: { + chatId, + replyToMessageId: messageId, + messageThreadId: Number.isSafeInteger(message.message_thread_id) + ? message.message_thread_id : undefined, + }, + }; +} + +class TelegramBotClient { + #api; + #signal; + + constructor({ api, signal }) { + this.#api = api; + this.#signal = signal; + } + + async sendText(target, text) { + const chunks = splitMessageText(text, 4_000); + let result = null; + for (const [index, chunk] of chunks.entries()) { + result = await this.#api.sendMessage({ + chatId: target.chatId, + text: chunk, + replyToMessageId: index === 0 ? target.replyToMessageId : undefined, + messageThreadId: target.messageThreadId, + signal: this.#signal, + }); + } + return result; + } + + sendTyping(target) { + return this.#api.sendChatAction({ + chatId: target.chatId, + messageThreadId: target.messageThreadId, + signal: this.#signal, + }); + } + + async openStream(target) { + const stream = createEditableMessageStream({ + limit: 4_000, + create: async (text) => { + const message = await this.#api.sendMessage({ + chatId: target.chatId, + text, + replyToMessageId: target.replyToMessageId, + messageThreadId: target.messageThreadId, + signal: this.#signal, + }); + return message.message_id; + }, + edit: (messageId, text) => this.#api.editMessageText({ + chatId: target.chatId, + messageId, + text, + signal: this.#signal, + }), + sendRemainder: (text) => this.#api.sendMessage({ + chatId: target.chatId, + text, + messageThreadId: target.messageThreadId, + signal: this.#signal, + }), + }); + return stream.start(); + } +} + +export function createTelegramRuntimeStatus() { + return { + startedAt: null, + ready: false, + connectionState: 'idle', + harnessReachable: false, + lastCheckedAt: null, + lastConnectedAt: null, + lastError: null, + ...createTelegramBridgeStatus(), + }; +} + +export class TelegramRuntime { + #config; + #token; + #harness; + #state; + #logger; + #replyTimeoutMs; + #createApi; + #status = createTelegramRuntimeStatus(); + #api = null; + #bridge = null; + #abortController = null; + #pollTask = null; + #starting = null; + + constructor({ + config, + token, + harness, + state, + logger = console, + replyTimeoutMs = 600_000, + createApi = (options) => new TelegramApi(options), + }) { + if (!config || !token || !harness || !state) { + throw new TypeError('TelegramRuntime requires config, token, Harness, and state'); + } + this.#config = config; + this.#token = token; + this.#harness = harness; + this.#state = state; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#createApi = createApi; + } + + get status() { + return structuredClone(this.#status); + } + + async start() { + if (this.#status.ready && this.#pollTask) return this.status; + if (this.#starting) return this.#starting; + this.#starting = this.#start().finally(() => { + this.#starting = null; + }); + return this.#starting; + } + + async #start() { + await this.stop(); + this.#status.startedAt = new Date().toISOString(); + this.#status.connectionState = 'connecting'; + this.#status.lastError = null; + await this.#harness.ensureRunning(); + this.#status.harnessReachable = true; + + const controller = new AbortController(); + this.#abortController = controller; + const api = this.#createApi({ token: this.#token }); + this.#api = api; + try { + const bot = await api.getMe({ signal: controller.signal }); + if (String(bot?.id ?? '') !== this.#config.platformId || bot?.is_bot !== true) { + throw new Error('Telegram token identity does not match the saved bot'); + } + const webhook = await api.getWebhookInfo({ signal: controller.signal }); + if (typeof webhook?.url === 'string' && webhook.url) { + const error = new Error('该 Telegram 机器人已配置 Webhook,请先在原服务中移除 Webhook 后重试。'); + error.code = 'webhook-configured'; + throw error; + } + const client = new TelegramBotClient({ api, signal: controller.signal }); + this.#bridge = new TelegramHarnessBridge({ + bot: client, + harness: this.#harness, + state: this.#state, + status: this.#status, + logger: this.#logger, + replyTimeoutMs: this.#replyTimeoutMs, + }); + + let cursor = this.#state.cursor(); + if (cursor === null) { + const latest = await api.getUpdates({ offset: -1, timeout: 0, signal: controller.signal }); + cursor = latest.length > 0 ? latest.at(-1).update_id + 1 : 0; + await this.#state.setCursor(cursor); + } + const now = Date.now(); + this.#status.ready = true; + this.#status.connectionState = 'connected'; + this.#status.lastCheckedAt = now; + this.#status.lastConnectedAt = now; + this.#pollTask = this.#poll(cursor, controller.signal); + this.#pollTask.catch((error) => { + if (controller.signal.aborted) return; + this.#status.ready = false; + this.#status.connectionState = 'failed'; + this.#status.lastError = error?.message ?? String(error); + this.#logger.error?.(`[dsh-im:telegram] bot ${this.#config.botId} polling stopped:`, error); + }); + return this.status; + } catch (error) { + this.#status.ready = false; + this.#status.connectionState = 'failed'; + this.#status.lastError = error?.message ?? String(error); + await this.stop(); + throw error; + } + } + + async #poll(initialCursor, signal) { + let cursor = initialCursor; + while (!signal.aborted) { + const updates = await this.#api.getUpdates({ offset: cursor, timeout: 25, signal }); + this.#status.lastCheckedAt = Date.now(); + for (const update of updates) { + if (signal.aborted) return; + const message = normalizeTelegramUpdate(update, { + botId: this.#config.platformId, + username: this.#config.username, + }); + if (message) await this.#bridge.accept(message); + cursor = update.update_id + 1; + await this.#state.setCursor(cursor); + } + } + } + + async stop() { + const pollTask = this.#pollTask; + const bridge = this.#bridge; + this.#abortController?.abort(); + this.#abortController = null; + this.#pollTask = null; + this.#api = null; + this.#bridge = null; + await Promise.race([ + pollTask?.catch(() => undefined) ?? Promise.resolve(), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); + await Promise.race([ + bridge?.waitForIdle() ?? Promise.resolve(), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); + this.#status.ready = false; + this.#status.connectionState = 'idle'; + return this.status; + } +} diff --git a/test/channels/discord/client-ui.test.mjs b/test/channels/discord/client-ui.test.mjs new file mode 100644 index 0000000..a227189 --- /dev/null +++ b/test/channels/discord/client-ui.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { + DiscordAccountCard, + DiscordSettingsTab, +} from '../../../plugin-src/client/channels/discord/index.js'; + +test('Discord settings exposes a Bot Token action without a fake QR action', () => { + const markup = renderToStaticMarkup(React.createElement(DiscordSettingsTab, { + rpcCall: async () => ({ ok: true, value: { bots: [] } }), + })); + assert.match(markup, /aria-label="使用 Bot Token 接入 Discord 机器人"/); + assert.match(markup, />手动接入 { + const markup = renderToStaticMarkup(React.createElement(DiscordAccountCard, { + account: { + botId: 'discord_test', + connected: true, + state: 'connected', + bot: { name: 'Harness Bot', username: 'HarnessBot', idMasked: '123•••' }, + health: { summary: 'Discord Gateway 长连接运行正常', lastCheckedAt: Date.now() }, + error: null, + }, + onReconnect() {}, + onRequestRemove() {}, + onConfirmRemove() {}, + onCancelRemove() {}, + })); + assert.match(markup, /data-im-channel-logo="discord"/); + assert.match(markup, /@HarnessBot/); + assert.match(markup, />Gateway 长连接检查连接移除接入 { + assert.equal(validDiscordToken(TOKEN), true); + assert.equal(validDiscordToken('not-a-token'), false); + const calls = []; + const bot = await inspectDiscordToken(TOKEN, { + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return jsonResponse({ + id: '1234567890123456789', + bot: true, + username: 'HarnessBot', + global_name: 'Harness Discord', + }); + }, + }); + assert.deepEqual(bot, { + platformId: '1234567890123456789', + name: 'Harness Discord', + username: 'HarnessBot', + }); + assert.equal(calls[0].options.headers.authorization, `Bot ${TOKEN}`); + assert.match(calls[0].url.pathname, /users\/@me$/); + + const api = new DiscordApi({ + token: TOKEN, + fetchImpl: async () => jsonResponse({ message: '401: Unauthorized' }, 401), + }); + await assert.rejects(() => api.getCurrentUser(), (error) => { + assert.equal(error.code, 'discord-401'); + assert.doesNotMatch(error.message, new RegExp(TOKEN.replaceAll('.', '\\.'))); + return true; + }); +}); + +test('Discord API retries one rate-limited message request', async () => { + let attempts = 0; + const api = new DiscordApi({ + token: TOKEN, + fetchImpl: async () => { + attempts += 1; + return attempts === 1 + ? jsonResponse({ message: 'rate limited', retry_after: 0.001 }, 429) + : jsonResponse({ id: '987654321012345678', content: 'hello' }); + }, + }); + const message = await api.createMessage({ + channelId: '123456789012345678', + content: 'hello', + }); + assert.equal(message.id, '987654321012345678'); + assert.equal(attempts, 2); +}); + +test('Discord controller persists a credential reference and exposes only masked identity', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-im-discord-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const configPath = join(directory, 'config.json'); + const configStore = await new DiscordConfigStore(configPath).load(); + const credentialStore = credentials(); + const controller = new DiscordController({ + credentials: credentialStore, + configStore, + inspectToken: async () => ({ + platformId: '1234567890123456789', + name: 'Harness Discord', + username: 'HarnessBot', + }), + createRuntime: async () => ({ + status: { + ready: true, + connectionState: 'connected', + harnessReachable: true, + lastCheckedAt: 20, + }, + async start() {}, + async stop() {}, + }), + }); + const status = await controller.bindCredentials({ token: TOKEN }); + assert.equal(status.totals.connected, 1); + assert.equal(status.bots[0].bot.name, 'Harness Discord'); + const identity = deriveDiscordBotIdentity('1234567890123456789'); + assert.equal(credentialStore.values.get(identity.tokenRef), TOKEN); + assert.doesNotMatch(await readFile(configPath, 'utf8'), new RegExp(TOKEN.replaceAll('.', '\\.'))); + await controller.deleteBot(identity.botId); + assert.equal(credentialStore.values.has(identity.tokenRef), false); +}); + +test('Discord RPC rejects extra credential fields and removes token internals', async () => { + const controller = { + status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }), + bindCredentials: async () => ({ + bots: [{ + botId: 'discord_123', + token: TOKEN, + tokenRef: 'DSH_DISCORD_BOT_TOKEN_ABC', + bot: { name: 'Discord机器人', idMasked: '123•••' }, + }], + totals: { configured: 1, connected: 0 }, + }), + reconnectBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }), + deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }), + }; + const handler = createDiscordRpcHandler(controller); + const result = await handler(DISCORD_ENDPOINTS.bindCredentials, { token: TOKEN }); + assert.equal(result.ok, true); + assert.equal(result.value.bots[0].token, undefined); + assert.equal(result.value.bots[0].tokenRef, undefined); + const rejected = await handler(DISCORD_ENDPOINTS.bindCredentials, { token: TOKEN, appId: 'x' }); + assert.equal(rejected.ok, false); + assert.equal(rejected.error.code, 'bad-request'); +}); + +test('Discord normalizes DMs and only addressed server messages', () => { + const direct = normalizeDiscordMessage({ + id: '111111111111111111', + channel_id: '222222222222222222', + author: { id: '333333333333333333', bot: false }, + content: 'hello', + }, '1234567890123456789'); + assert.equal(direct.kind, 'direct'); + assert.equal(direct.addressed, true); + + const group = normalizeDiscordMessage({ + id: '111111111111111112', + channel_id: '222222222222222223', + guild_id: '444444444444444444', + author: { id: '333333333333333334', bot: false }, + mentions: [{ id: '1234567890123456789' }], + content: '<@1234567890123456789> run this', + }, '1234567890123456789'); + assert.equal(group.kind, 'group'); + assert.equal(group.addressed, true); + assert.equal(group.content, 'run this'); + + const unmentionedReply = normalizeDiscordMessage({ + id: '111111111111111113', + channel_id: '222222222222222223', + guild_id: '444444444444444444', + author: { id: '333333333333333334', bot: false }, + mentions: [], + referenced_message: { author: { id: '1234567890123456789', bot: true } }, + content: '', + }, '1234567890123456789'); + assert.equal(unmentionedReply.addressed, false); +}); + +class FakeSocket { + #listeners = new Map(); + sent = []; + readyState = 1; + + addEventListener(name, listener) { + const listeners = this.#listeners.get(name) ?? []; + listeners.push(listener); + this.#listeners.set(name, listeners); + } + + send(value) { + const packet = JSON.parse(value); + this.sent.push(packet); + if (packet.op === 2) { + queueMicrotask(() => this.emit('message', { + data: JSON.stringify({ + op: 0, + t: 'READY', + s: 1, + d: { + session_id: 'session', + resume_gateway_url: 'wss://gateway.discord.gg', + }, + }), + })); + } + } + + close(code = 1000) { + if (this.readyState >= 2) return; + this.readyState = 3; + this.emit('close', { code }); + } + + emit(name, event) { + for (const listener of this.#listeners.get(name) ?? []) listener(event); + } +} + +test('Discord runtime identifies on Gateway v10 and becomes ready', async () => { + let socket; + const runtime = new DiscordRuntime({ + config: { + botId: 'discord_test', + platformId: '1234567890123456789', + name: 'Harness Discord', + }, + token: TOKEN, + harness: { ensureRunning: async () => true }, + state: { + sessionFor: () => null, + setSession: async () => {}, + clearSession: async () => {}, + hasSeen: () => false, + markSeen: async () => {}, + }, + createApi: () => ({ + getCurrentUser: async () => ({ id: '1234567890123456789', bot: true }), + getGatewayBot: async () => ({ url: 'wss://gateway.discord.gg' }), + }), + createWebSocket: () => { + socket = new FakeSocket(); + queueMicrotask(() => socket.emit('message', { + data: JSON.stringify({ op: 10, d: { heartbeat_interval: 45_000 } }), + })); + return socket; + }, + random: () => 0.5, + }); + await runtime.start(); + assert.equal(runtime.status.ready, true); + const identify = socket.sent.find((packet) => packet.op === 2); + assert.equal(identify.d.token, TOKEN); + assert.equal(identify.d.intents, 4_609); + assert.equal(identify.d.properties.browser, 'dsh-im'); + await runtime.stop(); + assert.equal(runtime.status.ready, false); +}); diff --git a/test/channels/telegram/client-ui.test.mjs b/test/channels/telegram/client-ui.test.mjs new file mode 100644 index 0000000..5f8c2e2 --- /dev/null +++ b/test/channels/telegram/client-ui.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { + TelegramAccountCard, + TelegramSettingsTab, +} from '../../../plugin-src/client/channels/telegram/index.js'; + +test('Telegram settings exposes a Bot Token action without a fake QR action', () => { + const markup = renderToStaticMarkup(React.createElement(TelegramSettingsTab, { + rpcCall: async () => ({ ok: true, value: { bots: [] } }), + })); + assert.match(markup, /aria-label="使用 Bot Token 接入 Telegram 机器人"/); + assert.match(markup, />手动接入 { + const markup = renderToStaticMarkup(React.createElement(TelegramAccountCard, { + account: { + botId: 'telegram_test', + connected: true, + state: 'connected', + bot: { name: 'Harness Bot', username: 'harness_bot', idMasked: '123•••' }, + health: { summary: 'Telegram Bot API 长轮询运行正常', lastCheckedAt: Date.now() }, + error: null, + }, + onReconnect() {}, + onRequestRemove() {}, + onConfirmRemove() {}, + onCancelRemove() {}, + })); + assert.match(markup, /data-im-channel-logo="telegram"/); + assert.match(markup, /@harness_bot/); + assert.match(markup, />Bot API 长轮询检查连接移除接入 sessions.get(key) ?? null, + setSession: async (key, value) => sessions.set(key, value), + clearSession: async (key) => sessions.delete(key), + hasSeen: (id) => seen.has(id), + markSeen: async (id) => seen.add(id), + }; +} + +test('Telegram API validates a Bot Token without exposing it in requests or errors', async () => { + assert.equal(validTelegramToken(TOKEN), true); + assert.equal(validTelegramToken('short'), false); + const calls = []; + const bot = await inspectTelegramToken(TOKEN, { + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return jsonResponse({ ok: true, result: { + id: 123456789, + is_bot: true, + first_name: 'Harness', + username: 'HarnessBot', + } }); + }, + }); + assert.deepEqual(bot, { + platformId: '123456789', + name: 'Harness', + username: 'HarnessBot', + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].url.protocol, 'https:'); + assert.equal(calls[0].url.hostname, 'api.telegram.org'); + assert.match(calls[0].url.pathname, /^\/bot/); + assert.match(calls[0].url.pathname, /getMe$/); + assert.equal(calls[0].options.method, 'POST'); + + const api = new TelegramApi({ + token: TOKEN, + fetchImpl: async () => jsonResponse({ ok: false, error_code: 401, description: 'Unauthorized' }, 401), + }); + await assert.rejects(() => api.getMe(), (error) => { + assert.equal(error.code, 'telegram-401'); + assert.doesNotMatch(error.message, new RegExp(TOKEN)); + return true; + }); +}); + +test('Telegram config and controller store only a credential reference in bot data', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const configPath = join(directory, 'config.json'); + const configStore = await new TelegramConfigStore(configPath).load(); + const credentialStore = credentials(); + const runtimes = []; + const controller = new TelegramController({ + credentials: credentialStore, + configStore, + inspectToken: async () => ({ + platformId: '123456789', name: 'Harness Telegram', username: 'harness_bot', + }), + createRuntime: async () => { + const runtime = { + status: { + ready: true, + connectionState: 'connected', + harnessReachable: true, + lastCheckedAt: 10, + }, + async start() {}, + async stop() {}, + }; + runtimes.push(runtime); + return runtime; + }, + }); + + const status = await controller.bindCredentials({ token: TOKEN }); + assert.equal(status.totals.connected, 1); + assert.equal(status.bots[0].bot.name, 'Harness Telegram'); + assert.equal(status.bots[0].bot.username, 'harness_bot'); + const identity = deriveTelegramBotIdentity('123456789'); + assert.equal(credentialStore.values.get(identity.tokenRef), TOKEN); + const persisted = await readFile(configPath, 'utf8'); + assert.doesNotMatch(persisted, new RegExp(TOKEN)); + assert.match(persisted, new RegExp(identity.tokenRef)); + + await controller.reconnectBot(identity.botId); + assert.equal(runtimes.length, 2); + await controller.deleteBot(identity.botId); + assert.equal(credentialStore.values.has(identity.tokenRef), false); + assert.equal(controller.status().totals.configured, 0); +}); + +test('Telegram RPC accepts only token binding and strips credential internals', async () => { + const calls = []; + const controller = { + status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }), + bindCredentials: async (payload) => { + calls.push(payload); + return { + bots: [{ + botId: 'telegram_123', + tokenRef: 'DSH_TELEGRAM_BOT_TOKEN_ABC', + token: TOKEN, + bot: { name: 'Telegram机器人', idMasked: '123•••' }, + }], + totals: { configured: 1, connected: 0 }, + }; + }, + reconnectBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }), + deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }), + }; + const handler = createTelegramRpcHandler(controller); + const result = await handler(TELEGRAM_ENDPOINTS.bindCredentials, { token: TOKEN }); + assert.equal(result.ok, true); + assert.deepEqual(calls, [{ token: TOKEN }]); + assert.equal(result.value.bots[0].token, undefined); + assert.equal(result.value.bots[0].tokenRef, undefined); + const rejected = await handler(TELEGRAM_ENDPOINTS.bindCredentials, { token: TOKEN, extra: true }); + assert.equal(rejected.ok, false); + assert.equal(rejected.error.code, 'bad-request'); +}); + +test('Telegram normalizes private messages and requires an explicit group address', () => { + const privateMessage = normalizeTelegramUpdate({ + update_id: 10, + message: { + message_id: 4, + chat: { id: 88, type: 'private' }, + from: { id: 42, is_bot: false }, + text: 'hello', + }, + }, { botId: '123456789', username: 'HarnessBot' }); + assert.equal(privateMessage.kind, 'direct'); + assert.equal(privateMessage.addressed, true); + + const groupMessage = normalizeTelegramUpdate({ + update_id: 11, + message: { + message_id: 5, + chat: { id: -1001, type: 'supergroup' }, + from: { id: 43, is_bot: false }, + text: '@HarnessBot run this', + entities: [{ type: 'mention', offset: 0, length: 11 }], + }, + }, { botId: '123456789', username: 'HarnessBot' }); + assert.equal(groupMessage.kind, 'group'); + assert.equal(groupMessage.addressed, true); + assert.equal(groupMessage.content, 'run this'); +}); + +test('Telegram bridge ignores unaddressed groups and streams direct replies', async () => { + const sent = []; + const updates = []; + const bot = { + sendText: async (_target, text) => sent.push(text), + sendTyping: async () => {}, + openStream: async () => ({ + update: async (text) => updates.push(text), + finish: async (text) => sent.push(text), + }), + }; + let askCount = 0; + const harness = { + ensureRunning: async () => true, + sessionExists: async () => true, + createSession: async () => 'session-1', + ask: async (_session, _text, options) => { + askCount += 1; + await options.onUpdate({ type: 'tool', name: '搜索' }); + await options.onUpdate({ type: 'text', text: '处理中' }); + return '完成'; + }, + }; + const bridge = new TelegramHarnessBridge({ bot, harness, state: memoryState() }); + await bridge.accept({ + messageId: '1', senderId: 'u1', kind: 'group', conversationId: 'g1', content: 'ignored', + addressed: false, replyTarget: {}, + }); + assert.equal(askCount, 0); + await bridge.accept({ + messageId: '2', senderId: 'u1', kind: 'direct', conversationId: 'u1', content: 'hello', + addressed: true, replyTarget: {}, + }); + assert.equal(askCount, 1); + assert.deepEqual(updates, ['正在使用搜索…', '处理中']); + assert.deepEqual(sent, ['完成']); +}); + +test('Telegram runtime validates webhook state and starts a cancellable long poll', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-runtime-')); + const state = await new TelegramStateStore(join(directory, 'state.json')).load(); + const calls = []; + const fakeApi = { + getMe: async () => ({ id: 123456789, is_bot: true }), + getWebhookInfo: async () => ({ url: '' }), + getUpdates: async ({ offset, timeout, signal }) => { + calls.push({ offset, timeout }); + if (timeout === 0) return []; + return new Promise((resolve, reject) => signal.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }, { once: true })); + }, + }; + const runtime = new TelegramRuntime({ + config: { + botId: 'telegram_test', + platformId: '123456789', + username: 'HarnessBot', + }, + token: TOKEN, + harness: { ensureRunning: async () => true }, + state, + createApi: () => fakeApi, + }); + await runtime.start(); + assert.equal(runtime.status.ready, true); + assert.equal(runtime.status.connectionState, 'connected'); + await runtime.stop(); + assert.equal(runtime.status.ready, false); + assert.deepEqual(calls[0], { offset: -1, timeout: 0 }); + await rm(directory, { recursive: true, force: true }); +}); diff --git a/test/channels/weixin/plugin-host.test.mjs b/test/channels/weixin/plugin-host.test.mjs index 91c8495..f87ca34 100644 --- a/test/channels/weixin/plugin-host.test.mjs +++ b/test/channels/weixin/plugin-host.test.mjs @@ -57,6 +57,21 @@ test('Host plugin registers the Weixin RPC channel as loopback-only', async () = assert.deepEqual(registration.options, { authority: 'loopback' }); }); +test('Host plugin opts the Weixin RPC channel into trusted Host authorities', async () => { + let registration; + const ctx = { + connection: { rpc: { handle: (channel, handler, options) => { + registration = { channel, handler, options }; + return async () => {}; + } } }, + }; + await apply(ctx, { + controller: controllerFixture(), + rpcAuthority: 'trusted-host', + }); + assert.deepEqual(registration.options, { authority: 'trusted-host' }); +}); + test('RPC returns QR data and verification states without exposing secret-shaped fields', async () => { const controller = controllerFixture(); const handler = createWeixinRpcHandler(controller, { diff --git a/test/client-ui.test.mjs b/test/client-ui.test.mjs index c0a392b..4be813e 100644 --- a/test/client-ui.test.mjs +++ b/test/client-ui.test.mjs @@ -65,7 +65,7 @@ const QQ_SOURCE_URL = new URL( import.meta.url, ); -test('IM settings renders five compact logo channel tabs without enable switches', async () => { +test('IM settings renders seven compact logo channel tabs without enable switches', async () => { const styles = await readFile(STYLES_URL, 'utf8'); const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, { feishuRpcCall: async () => ({ ok: true, value: {} }), @@ -73,23 +73,29 @@ test('IM settings renders five compact logo channel tabs without enable switches dingtalkRpcCall: async () => ({ ok: true, value: {} }), wecomRpcCall: async () => ({ ok: true, value: {} }), qqRpcCall: async () => ({ ok: true, value: {} }), + telegramRpcCall: async () => ({ ok: true, value: {} }), + discordRpcCall: async () => ({ ok: true, value: {} }), })); assert.match(markup, /IM机器人/); - assert.match(markup, /通过扫码把机器人接入 DeepSeek Harness/); + assert.match(markup, /把机器人接入 DeepSeek Harness/); assert.doesNotMatch(markup, /\d+ 个渠道|dim-channelCount/); assert.match(markup, />微信飞书钉钉企业微信QQTelegramDiscord|扫码接入<\/small>/); diff --git a/test/host.test.mjs b/test/host.test.mjs index cf5e877..e0bbfd3 100644 --- a/test/host.test.mjs +++ b/test/host.test.mjs @@ -3,7 +3,7 @@ import test from 'node:test'; import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs'; -test('Host composes Feishu, Weixin, DingTalk, Enterprise WeChat, and QQ inside one plugin context', async () => { +test('Host composes all seven IM channels inside one plugin context', async () => { const calls = []; const plugin = createImHostPlugin({ applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]), @@ -11,14 +11,19 @@ test('Host composes Feishu, Weixin, DingTalk, Enterprise WeChat, and QQ inside o applyDingtalk: async (ctx, config) => calls.push(['dingtalk', ctx, config]), applyWecom: async (ctx, config) => calls.push(['wecom', ctx, config]), applyQq: async (ctx, config) => calls.push(['qq', ctx, config]), + applyTelegram: async (ctx, config) => calls.push(['telegram', ctx, config]), + applyDiscord: async (ctx, config) => calls.push(['discord', ctx, config]), }); const ctx = { marker: 'shared-context' }; const config = { + rpcAuthority: 'trusted-host', feishu: { domain: 'feishu' }, weixin: { timeout: 30 }, dingtalk: { replyTimeoutMs: 60_000 }, wecom: { replyTimeoutMs: 60_000 }, qq: { replyTimeoutMs: 60_000 }, + telegram: { replyTimeoutMs: 60_000 }, + discord: { replyTimeoutMs: 60_000 }, }; await plugin.apply(ctx, config); @@ -26,11 +31,13 @@ test('Host composes Feishu, Weixin, DingTalk, Enterprise WeChat, and QQ inside o assert.equal(name, 'dsh-im-host'); assert.deepEqual(inject, ['connection', 'credentials', 'webServer']); assert.deepEqual(calls, [ - ['feishu', ctx, config.feishu], - ['weixin', ctx, config.weixin], - ['dingtalk', ctx, config.dingtalk], - ['wecom', ctx, config.wecom], - ['qq', ctx, config.qq], + ['feishu', ctx, { ...config.feishu, rpcAuthority: 'trusted-host' }], + ['weixin', ctx, { ...config.weixin, rpcAuthority: 'trusted-host' }], + ['dingtalk', ctx, { ...config.dingtalk, rpcAuthority: 'trusted-host' }], + ['wecom', ctx, { ...config.wecom, rpcAuthority: 'trusted-host' }], + ['qq', ctx, { ...config.qq, rpcAuthority: 'trusted-host' }], + ['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host' }], + ['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }], ]); }); diff --git a/test/rpc-authority.test.mjs b/test/rpc-authority.test.mjs new file mode 100644 index 0000000..6e9d782 --- /dev/null +++ b/test/rpc-authority.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { resolveRpcAuthority } from '../plugin-src/host/rpc-authority.mjs'; + +test('IM management RPC authority defaults to loopback and accepts explicit trusted hosts', () => { + assert.equal(resolveRpcAuthority(), 'loopback'); + assert.equal(resolveRpcAuthority('loopback'), 'loopback'); + assert.equal(resolveRpcAuthority('trusted-host'), 'trusted-host'); +}); + +test('IM management RPC authority rejects unknown policy values', () => { + assert.throws( + () => resolveRpcAuthority('public'), + /rpcAuthority must be "loopback" or "trusted-host"/, + ); +});