diff --git a/README.md b/README.md index 62c1262..4134206 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ ## 中文 -通过扫码把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信和钉钉机器人。 +通过扫码把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉和 QQ 机器人。 -> GitHub 简介:通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉等)。 +> GitHub 简介:通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉和QQ)。 ## 界面 @@ -15,6 +15,7 @@ - 飞书:扫码创建并绑定机器人,使用长连接收发消息; - 微信:扫码绑定微信机器人,使用腾讯 iLink 长轮询收发消息; - 钉钉:扫码创建并授权机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。 +- QQ:使用手机 QQ 扫码创建或绑定 QQ 机器人,通过 WebSocket 长连接收消息;私聊支持原生“正在输入”和流式回答,群聊在机器人被 @ 后回复。 其他 IM 平台可继续按同一渠道适配器结构接入。 @@ -26,15 +27,17 @@ npx -y github:xmanrui/dsh-im install 重启 `dsh web`,然后打开「设置 → 插件 → IM机器人」。安装器会用 `dsh-im` 替换 profile 中直接安装的 `dsh-feishu`、`dsh-weixin` 和 `dsh-dingtalk`,但不删除任何渠道数据;原有渠道凭据和扫码绑定会继续使用。 -钉钉接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。钉钉当前可能在该官方授权页显示 OpenClaw 品牌;扫码后的机器人连接、凭据和消息均由 DeepSeek Harness 管理。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。 +钉钉接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。 + +QQ 接入使用腾讯 QQBot v2 官方扫码流程,不需要手动填写 AppID 或 AppSecret。默认腾讯授权页会把接入方显示为“第三方机器人”;扫码成功后创建或绑定的是 QQ 开放平台机器人,并不是让插件直接控制个人 QQ 账号。扫码者会自动成为该机器人在 Harness 中的使用者,不增加本机二次批准步骤。 ## 设计 - Harness 中只注册一个「IM机器人」设置页; -- 飞书、微信和钉钉的 Host、客户端与运行时源码都在本仓库维护,不依赖外部 `dsh-feishu`、`dsh-weixin` 或 `dsh-dingtalk` 插件; -- 左侧使用渠道 Logo 切换微信、飞书和钉钉,不使用启用/停用开关; -- 三个渠道保持独立的 RPC、凭据、连接监督和会话映射; -- 浏览器只获得二维码和脱敏状态,不获得 App Secret、`bot_token`、钉钉 `client_secret` 或原始 staff ID。 +- 飞书、微信、钉钉和 QQ 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件; +- 左侧使用渠道 Logo 切换微信、飞书、钉钉和 QQ,不使用启用/停用开关; +- 四个渠道保持独立的 RPC、凭据、连接监督和会话映射; +- 浏览器只获得二维码和脱敏状态,不获得 App Secret、`bot_token`、钉钉 `client_secret`、QQ `app_secret` 或原始用户标识。 ## 本地开发 @@ -50,9 +53,9 @@ node bin/dsh-im.mjs install --source . ## English -Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one settings entry provide unified management for Feishu, WeChat, and DingTalk bots. +Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, and QQ bots. -> GitHub description: Connect IM bots to DeepSeek Harness by scanning a QR code (supports Feishu, WeChat, DingTalk, and more). +> GitHub description: Connect IM bots to DeepSeek Harness by scanning a QR code (supports Feishu, WeChat, DingTalk, and QQ). ## Interface @@ -63,6 +66,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code. One plugin and one se - Feishu: create and bind a bot by scanning a QR code, then send and receive messages over a persistent connection. - WeChat: bind a WeChat bot by scanning a QR code, then send and receive messages through Tencent iLink long polling. - DingTalk: create and authorize a bot by scanning a QR code, receive messages through DingTalk Stream, and stream Harness replies through AI Cards. +- QQ: create or bind a QQ bot by scanning with mobile QQ, receive messages over a WebSocket connection, stream private-chat replies with a native typing indicator, and reply in groups when mentioned. Other IM platforms can be added through the same channel-adapter structure. @@ -74,15 +78,17 @@ npx -y github:xmanrui/dsh-im install Restart `dsh web`, then open **Settings → Plugins → IM Bot**. The installer replaces directly installed `dsh-feishu`, `dsh-weixin`, and `dsh-dingtalk` entries in the profile with `dsh-im` without deleting channel data. -For DingTalk, 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. That DingTalk-hosted page may currently display OpenClaw branding; the resulting connection, credentials, and messages are managed by DeepSeek Harness. 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 DingTalk, 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. + +QQ uses Tencent's official QQBot v2 QR flow and does not require manually entering an AppID or AppSecret. Tencent's default authorization page labels the integration as a third-party bot. Scanning creates or binds a QQ Open Platform bot; it does not give the plugin direct control of a personal QQ account. The scanner becomes the Harness user for that bot without an additional local approval step. ## Design - Registers a single **IM Bot** settings page in Harness. -- Maintains the Feishu, WeChat, and DingTalk Host, client, and runtime sources in this repository without external `dsh-feishu`, `dsh-weixin`, or `dsh-dingtalk` plugin dependencies. -- Uses channel logos for WeChat, Feishu, and DingTalk navigation without enable/disable switches. +- Maintains the Feishu, WeChat, DingTalk, and QQ Host, client, and runtime sources in this repository without external standalone channel plugins. +- Uses channel logos for WeChat, Feishu, DingTalk, and QQ navigation without enable/disable switches. - Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by channel. -- Sends only QR codes and redacted status data to the browser, never App Secrets, `bot_token`, DingTalk `client_secret`, or raw staff IDs. +- Sends only QR codes and redacted status data to the browser, never App Secrets, `bot_token`, DingTalk `client_secret`, QQ `app_secret`, or raw user identifiers. ## Local development diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index de912d5..8dd52fa 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4,8 +4,10 @@ The Weixin iLink request format, QR-login states, and message fields are adapted The DingTalk device-authorization request sequence and AI Card streaming protocol are adapted from DingTalk Real Team's [`dingtalk-openclaw-connector`](https://github.com/DingTalk-Real-AI/dingtalk-openclaw-connector) project at commit `b2fd6e5ea2ff99bd213faac637d3da541b2bfaf4`, licensed under the MIT License and copyright 2026 DingTalk Real Team. -This package depends on [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0, [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. Each is licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队. +This package depends on [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0, [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队. -This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, Feishu, or DingTalk. +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. -The WeChat mark uses path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu and DingTalk marks are inline vectors used for channel identification. Product names and logos remain trademarks of their respective owners. +This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, Feishu, DingTalk, or QQ. + +The WeChat and QQ marks use path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu and DingTalk 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 65ebb88..30d8fcc 100644 --- a/lib/client.js +++ b/lib/client.js @@ -40,7 +40,7 @@ __export(index_exports, { name: () => name }); module.exports = __toCommonJS(index_exports); -var React6 = __toESM(require("react"), 1); +var React7 = __toESM(require("react"), 1); // plugin-src/client/channel-logos.js var React = __toESM(require("react"), 1); @@ -87,6 +87,18 @@ function DingtalkLogoGlyph({ size } = {}) { d: "M37.05 22.783c-6.758-5.216-14.378-12.128-22.73-19.538-.655-.585-1.242-.354-1.536.42-1.88 4.973-.058 9.386 2.889 11.932s7.368 4.912 10.058 6.155c.105.049.013.203-.093.163-4.953-2.182-8.397-3.765-13.07-7.368-.497-.388-1.01-.242-1.07.521-.384 4.748 2.657 8.483 6.058 9.745 2.1.781 4.398 1.212 6.53 1.474.109.015.084.178-.027.178-2.747.01-6.058-.654-8.935-1.751-.606-.233-.818.25-.722.633.491 2.008 2.974 5.076 6.926 5.73a12 12 0 0 0 2.228.115c.164 0 .208.089.154.217q-2.685 4.6-2.803 4.797c-.091.152-.036.275.156.275h3.543c.164 0 .264.106.18.246l-4.958 8.196c-.191.328.035.565.395.301s15.212-11.133 15.636-11.448c.195-.142.148-.327-.124-.327h-3.18c-.206 0-.252-.14-.111-.28.14-.141 3.602-3.594 4.837-4.888 1.283-1.35 1.938-3.825-.231-5.498" })); } +function QqLogoGlyph({ size } = {}) { + return h("svg", { + ...dimensions(size), + viewBox: "0 0 24 24", + focusable: "false", + "aria-hidden": "true", + "data-im-channel-logo": "qq" + }, h("path", { + fill: "currentColor", + d: "M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673" + })); +} // plugin-src/client/channels/dingtalk/api.js var DINGTALK_RPC_CHANNEL = "/dingtalk"; @@ -125,8 +137,8 @@ function optionalString(value, maxLength = 240) { return trimmed ? trimmed.slice(0, maxLength) : void 0; } function opaqueId(value) { - const id = optionalString(value, 128); - return id && /^[a-z\d_-]+$/i.test(id) ? id : void 0; + const id2 = optionalString(value, 128); + return id2 && /^[a-z\d_-]+$/i.test(id2) ? id2 : void 0; } function timestamp(value) { if (typeof value === "number" && Number.isFinite(value)) return value; @@ -2231,14 +2243,14 @@ var HEALTH_LABELS = { offline: "\u8FDE\u63A5\u4E2D\u65AD", error: "\u9700\u8981\u5904\u7406" }; -function formatCheckedTime(timestamp3) { - if (!timestamp3) return "\u5C1A\u672A\u68C0\u67E5"; +function formatCheckedTime(timestamp4) { + if (!timestamp4) return "\u5C1A\u672A\u68C0\u67E5"; try { return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" - }).format(new Date(timestamp3)); + }).format(new Date(timestamp4)); } catch { return "\u521A\u521A"; } @@ -2573,14 +2585,14 @@ function FeishuSettingsTab({ rpcCall }) { FEISHU_ENDPOINTS.beginProvisioning, { locale: "zh-CN" } )); - const timestamp3 = Date.now(); - setNow(timestamp3); + const timestamp4 = Date.now(); + setNow(timestamp4); setModel((current) => ({ ...current, provisioning: { phase: "qr", ...provision2, - durationMs: Math.max(1, provision2.expiresAt - timestamp3), + durationMs: Math.max(1, provision2.expiresAt - timestamp4), expired: false } })); @@ -2619,9 +2631,9 @@ function FeishuSettingsTab({ rpcCall }) { React4.useEffect(() => { if (!countdownAttemptId || countdownPhase !== "qr" || countdownExpired) return void 0; const tick = () => { - const timestamp3 = Date.now(); - setNow(timestamp3); - if (timestamp3 >= countdownExpiresAt) { + const timestamp4 = Date.now(); + setNow(timestamp4); + if (timestamp4 >= countdownExpiresAt) { setModel((current) => current.provisioning?.attemptId === countdownAttemptId ? { ...current, provisioning: { ...current.provisioning, expired: true } } : current); } }; @@ -2850,9 +2862,574 @@ function FeishuSettingsTab({ rpcCall }) { ); } -// plugin-src/client/channels/weixin/index.js +// plugin-src/client/channels/qq/api.js +var QQ_RPC_CHANNEL = "/qq"; +var QQ_ENDPOINTS = Object.freeze({ + status: "connection.status", + beginProvisioning: "provision.begin", + pollProvisioning: "provision.poll", + cancelProvisioning: "provision.cancel", + reconnectBot: "bot.reconnect", + 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 React5 = __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 h4 = React5.createElement; +var ACTIVE_STATES = /* @__PURE__ */ new Set(["pending", "refreshing", "connecting"]); +var Button5 = React5.forwardRef(function Button6({ 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 Heading3({ totals, adding, busy, onAdd, addButtonRef }) { + return h4( + "div", + { className: "ddt-heading" }, + h4( + "div", + { className: "ddt-tools" }, + h4(Button5, { + kind: "primary", + className: "dim-scanButton", + onClick: onAdd, + disabled: adding || busy, + ref: addButtonRef + }, adding ? "\u6B63\u5728\u7ED1\u5B9A" : "\u626B\u7801\u7ED1\u5B9AQQ\u673A\u5668\u4EBA"), + totals.configured > 0 ? h4( + "div", + { className: "ddt-badge dim-onlineBadge" }, + h4("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) + ) : null + ) + ); +} +function LoadingView3() { + return h4( + "div", + { className: "ddt-card ddt-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, + h4("div", { className: "ddt-spinner dim-spinner" }), + h4("span", null, "\u6B63\u5728\u8BFB\u53D6 QQ \u673A\u5668\u4EBA\u72B6\u6001\u2026") + ); +} +function EmptyView3({ busy, onStart }) { + return 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\u7ED1\u5B9A QQ \u673A\u5668\u4EBA") + ), + h4("h3", null, "\u4F7F\u7528\u624B\u673A QQ \u626B\u7801\u521B\u5EFA\u5E76\u7ED1\u5B9A\u673A\u5668\u4EBA"), + h4("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"), + h4( + "div", + { className: "ddt-actions dim-viewActions" }, + h4( + Button5, + { kind: "primary", onClick: onStart, disabled: busy }, + busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210 QQ \u4E8C\u7EF4\u7801" + ) + ) + ), + h4( + "div", + { className: "ddt-brandMark dim-emptyBrand dqq-brand", "aria-hidden": "true" }, + h4(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 h4( + "div", + { className: "ddt-card dim-surfaceCard" }, + h4( + "div", + { className: "ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout" }, + h4( + "div", + { className: "ddt-qrColumn dim-qrColumn" }, + h4( + "div", + { className: "ddt-qrFrame dim-qrFrame" }, + source ? h4("img", { src: source, alt: "\u7528\u4E8E\u7ED1\u5B9A QQ \u673A\u5668\u4EBA\u7684\u4E00\u6B21\u6027\u4E8C\u7EF4\u7801" }) : h4( + "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" + ) + ), + h4( + "div", + { className: "ddt-countdown dim-countdown" }, + h4( + "div", + { className: "ddt-countdownTop dim-countdownTop" }, + h4("span", null, "\u5F53\u524D\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), + h4("strong", null, refreshing ? "--:--" : formatRemaining3(remaining)) + ), + h4("div", { className: "ddt-progress dim-progress", style: { "--ddt-progress": `${progress}%` } }, h4("span")) + ) + ), + h4( + "div", + { className: "ddt-qrCopy dim-qrCopy" }, + h4( + "div", + { className: "ddt-stateLabel dim-stateLabel" }, + h4("span", { className: "ddt-dot dim-stateDot", "data-tone": "warning" }), + h4("span", null, refreshing ? "\u6B63\u5728\u5237\u65B0\u4E8C\u7EF4\u7801" : "\u7B49\u5F85\u624B\u673A QQ \u626B\u7801") + ), + h4("h3", null, "\u4F7F\u7528\u624B\u673A QQ \u5B8C\u6210\u673A\u5668\u4EBA\u7ED1\u5B9A"), + h4("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"), + h4( + "ol", + { className: "ddt-steps dim-steps" }, + h4("li", null, "\u6253\u5F00\u624B\u673A QQ\uFF0C\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), + h4("li", null, "\u5728\u817E\u8BAF\u6388\u6743\u9875\u9762\u786E\u8BA4\u521B\u5EFA\u6216\u7ED1\u5B9A\u673A\u5668\u4EBA"), + h4("li", null, "\u8FD4\u56DE\u8FD9\u91CC\u7B49\u5F85\u8FDE\u63A5\u5B8C\u6210") + ), + h4( + "div", + { className: "ddt-actions dim-viewActions" }, + h4(Button5, { onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), + h4(Button5, { kind: "quiet", onClick: onCancel, disabled: busy }, "\u53D6\u6D88") + ) + ) + ) + ); +} +function ProvisionView({ provision, busy, onRetry, onClose }) { + if (provision.status === "connecting") { + return h4( + "div", + { className: "ddt-card ddt-loading dim-surfaceCard dim-specialView", "aria-busy": "true" }, + h4("div", { className: "ddt-spinner dim-spinner" }), + h4("h3", null, "QQ \u5DF2\u6388\u6743\uFF0C\u6B63\u5728\u8FDE\u63A5\u673A\u5668\u4EBA"), + h4("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 h4( + "div", + { className: "ddt-card dim-surfaceCard" }, + h4( + "div", + { className: "ddt-inlineError dim-inlineError", role: "alert" }, + h4("h3", null, "QQ \u673A\u5668\u4EBA\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), + h4("p", null, error.message), + h4("span", { className: "ddt-errorCode" }, error.code), + h4( + "div", + { className: "ddt-actions dim-viewActions" }, + h4(Button5, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), + h4(Button5, { onClick: onClose, disabled: busy }, "\u5173\u95ED") + ) + ) + ); +} +function RemoveConfirmation3({ account, busy, onConfirm, onCancel }) { + return 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\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"), + h4( + "div", + { className: "ddt-actions dim-viewActions" }, + h4(Button5, { onClick: onCancel, disabled: busy }, "\u4FDD\u7559\u673A\u5668\u4EBA"), + h4(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"; + 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 dqq-avatar", "aria-hidden": "true" }, h4(QqLogoGlyph, { size: 29 })), + h4( + "div", + { className: "dim-botName" }, + h4("h3", null, account.bot.name), + h4("p", null, account.bot.appIdMasked) + ) + ), + 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 ? "WebSocket \u957F\u8FDE\u63A5" : "\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" }, + h4("div", { className: "ddt-summary dim-cardSummary" }, account.error?.message ?? account.health.summary), + h4( + "div", + { className: "ddt-actions dim-cardActions" }, + h4(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"), + h4(Button5, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") + ) + ) + ), + removing ? h4(RemoveConfirmation3, { + account, + busy: busy === "delete", + onConfirm: onConfirmRemove, + onCancel: onCancelRemove + }) : null + ); +} +function QqSettingsTab({ rpcCall }) { + const [model, setModel] = React5.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); + const [provision, setProvision] = React5.useState(null); + const [busy, setBusy] = React5.useState(false); + const [busyByBot, setBusyByBot] = React5.useState({}); + const [removeTarget, setRemoveTarget] = React5.useState(null); + const [now, setNow] = React5.useState(Date.now()); + const mounted = React5.useRef(true); + const addButtonRef = React5.useRef(null); + React5.useEffect(() => { + const disposeDingtalk = installDingtalkStyles(); + const disposeQq = installQqStyles(); + mounted.current = true; + return () => { + mounted.current = false; + disposeQq(); + disposeDingtalk(); + }; + }, []); + const invoke = React5.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 = React5.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]); + React5.useEffect(() => { + const controller = new AbortController(); + void loadStatus({ signal: controller.signal, restore: true }); + return () => controller.abort(); + }, [loadStatus]); + React5.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]); + React5.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 = React5.useCallback(async (replace = false) => { + 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 closeProvision = React5.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]); + React5.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 = React5.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 = h4("div", { className: "ddt-card ddt-loading dim-surfaceCard" }, h4("div", { className: "ddt-spinner" }), "\u6B63\u5728\u7533\u8BF7 QQ \u4E8C\u7EF4\u7801\u2026"); + else if (["pending", "refreshing"].includes(provision?.status)) provisionView = h4(QrPanel2, { + provision, + now, + busy, + onRefresh: () => void startProvisioning(true), + onCancel: () => void closeProvision() + }); + else if (provision) provisionView = h4(ProvisionView, { + provision, + busy, + onRetry: () => void startProvisioning(true), + onClose: () => void closeProvision() + }); + const botList = model.bots.length > 0 ? h4( + "section", + { className: "dim-listSection" }, + h4( + "div", + { className: "ddt-listHeading dim-listHeading" }, + h4("h3", null, "\u5DF2\u7ED1\u5B9A\u7684 QQ \u673A\u5668\u4EBA"), + h4("span", null, `${model.bots.length} \u4E2A`) + ), + h4("ul", { className: "ddt-list dim-botList" }, model.bots.map((account) => h4("li", { key: account.botId }, h4(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; + return h4( + "section", + { className: "ddt-page dqq-page dim-channelPage", "aria-label": "QQ \u8BBE\u7F6E" }, + h4(Heading3, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }), + model.phase === "loading" ? h4(LoadingView3) : model.phase === "error" ? h4("div", { className: "ddt-card dim-surfaceCard" }, h4("div", { className: "ddt-inlineError dim-inlineError" }, h4("h3", null, "\u65E0\u6CD5\u8BFB\u53D6 QQ \u673A\u5668\u4EBA\u72B6\u6001"), h4("p", null, model.error?.message), h4(Button5, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6"))) : h4( + React5.Fragment, + null, + provisionView, + model.bots.length === 0 && !provision ? h4(EmptyView3, { busy, onStart: () => void startProvisioning() }) : null, + botList + ) + ); +} + +// plugin-src/client/channels/weixin/index.js +var React6 = __toESM(require("react"), 1); + // plugin-src/client/channels/weixin/api.js var WEIXIN_RPC_CHANNEL = "/weixin"; var WEIXIN_ENDPOINTS = Object.freeze({ @@ -2864,8 +3441,8 @@ var WEIXIN_ENDPOINTS = Object.freeze({ reconnectBot: "bot.reconnect", deleteBot: "bot.delete" }); -var ACCOUNT_STATES2 = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]); -var PROVISION_STATES2 = /* @__PURE__ */ new Set([ +var ACCOUNT_STATES3 = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]); +var PROVISION_STATES3 = /* @__PURE__ */ new Set([ "starting", "pending", "scanned", @@ -2876,17 +3453,17 @@ var PROVISION_STATES2 = /* @__PURE__ */ new Set([ "failed", "cancelled" ]); -function isRecord3(value) { +function isRecord4(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function string(value, fallback = "") { return typeof value === "string" && value.trim() ? value.trim() : fallback; } -function timestamp2(value) { +function timestamp3(value) { return typeof value === "number" && Number.isFinite(value) ? value : null; } -function unwrapRpcResult3(result) { - if (!isRecord3(result) || typeof result.ok !== "boolean") { +function unwrapRpcResult4(result) { + if (!isRecord4(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) { @@ -2896,7 +3473,7 @@ function unwrapRpcResult3(result) { } return result.value; } -function safeQrSource3(value) { +function safeQrSource4(value) { return typeof value === "string" && /^data:image\/(?:png|webp|svg\+xml)(?:;charset=[^;,]+)?;base64,/i.test(value) ? value : void 0; } function safeVerificationUrl(value) { @@ -2909,25 +3486,25 @@ function safeVerificationUrl(value) { return void 0; } } -function normalizeProvisioning3(value) { - if (!isRecord3(value) || !string(value.attemptId)) { +function normalizeProvisioning4(value) { + if (!isRecord4(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_STATES2.has(value.status) ? value.status : "failed"; + const status = PROVISION_STATES3.has(value.status) ? value.status : "failed"; const result = { attemptId: string(value.attemptId), status, - expiresAt: timestamp2(value.expiresAt) ?? Date.now(), + expiresAt: timestamp3(value.expiresAt) ?? Date.now(), pollIntervalMs: Math.min(5e3, Math.max(500, Number(value.pollIntervalMs) || 1e3)), verificationRequired: value.verificationRequired === true || status === "needs_verification" }; const verificationUrl = safeVerificationUrl(value.verificationUrl); - const qrCodeDataUrl = safeQrSource3(value.qrCodeDataUrl); + const qrCodeDataUrl = safeQrSource4(value.qrCodeDataUrl); if (verificationUrl) result.verificationUrl = verificationUrl; if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl; if (string(value.botId)) result.botId = string(value.botId); if (value.alreadyConnected === true) result.alreadyConnected = true; - if (isRecord3(value.error)) { + if (isRecord4(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") @@ -2935,9 +3512,9 @@ function normalizeProvisioning3(value) { } return result; } -function normalizeBot3(value) { - if (!isRecord3(value) || !string(value.botId) || !isRecord3(value.bot)) return null; - const state = ACCOUNT_STATES2.has(value.state) ? value.state : "error"; +function normalizeBot4(value) { + if (!isRecord4(value) || !string(value.botId) || !isRecord4(value.bot)) return null; + const state = ACCOUNT_STATES3.has(value.state) ? value.state : "error"; const connected = value.connected === true; return { botId: string(value.botId), @@ -2951,23 +3528,23 @@ function normalizeBot3(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: timestamp2(value.health?.lastCheckedAt) + lastCheckedAt: timestamp3(value.health?.lastCheckedAt) }, stats: { messagesReceived: Math.max(0, Number(value.stats?.messagesReceived) || 0), messagesReplied: Math.max(0, Number(value.stats?.messagesReplied) || 0) }, - error: isRecord3(value.error) ? { + error: isRecord4(value.error) ? { code: string(value.error.code, "WEIXIN_ACCOUNT_ERROR"), message: string(value.error.message, "\u5FAE\u4FE1\u8FDE\u63A5\u672A\u5C31\u7EEA") } : null }; } -function normalizeSnapshot2(value) { - if (!isRecord3(value) || !Array.isArray(value.bots)) { +function normalizeSnapshot3(value) { + if (!isRecord4(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(normalizeBot3).filter(Boolean); + const bots = value.bots.map(normalizeBot4).filter(Boolean); return { schemaVersion: Number(value.schemaVersion) || 1, revision: Number(value.revision) || 0, @@ -2977,23 +3554,23 @@ function normalizeSnapshot2(value) { configured: bots.length, connected: bots.filter((bot) => bot.connected).length }, - provisioning: value.provisioning ? normalizeProvisioning3(value.provisioning) : null + provisioning: value.provisioning ? normalizeProvisioning4(value.provisioning) : null }; } -function presentError3(error) { +function presentError4(error) { return { code: string(error?.code, "WEIXIN_ERROR"), message: string(error?.message, "\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5") }; } -function formatRemaining3(milliseconds) { +function formatRemaining4(milliseconds) { const seconds = Math.max(0, Math.ceil(milliseconds / 1e3)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; } // plugin-src/client/channels/weixin/styles.js var WEIXIN_STYLE_ID = "xmanrui-dsh-weixin-settings"; -var CSS3 = String.raw` +var CSS4 = String.raw` .dxw-page { --dxw-accent: #07c160; --dxw-accent-dark: #05994c; @@ -3109,15 +3686,15 @@ function installWeixinStyles() { const style = document.createElement("style"); style.dataset.plugin = "@xmanrui/dsh-weixin"; style.dataset.pluginCss = WEIXIN_STYLE_ID; - style.textContent = CSS3; + style.textContent = CSS4; document.head.appendChild(style); return () => style.remove(); } // plugin-src/client/channels/weixin/index.js -var h4 = React5.createElement; -var Button5 = React5.forwardRef(function Button6({ children, kind = "secondary", className = "", ...props }, ref) { - return h4("button", { +var h5 = React6.createElement; +var Button7 = React6.forwardRef(function Button8({ children, kind = "secondary", className = "", ...props }, ref) { + return h5("button", { ...props, ref, type: "button", @@ -3125,150 +3702,150 @@ var Button5 = React5.forwardRef(function Button6({ children, kind = "secondary", "data-kind": kind }, children); }); -function Heading3({ totals, adding, busy, onAdd, addButtonRef }) { - return h4( +function Heading4({ totals, adding, busy, onAdd, addButtonRef }) { + return h5( "div", { className: "dxw-heading" }, - h4( + h5( "div", { className: "dxw-tools" }, - h4(Button5, { + h5(Button7, { kind: "primary", className: "dim-scanButton", onClick: onAdd, disabled: adding || busy, ref: addButtonRef }, adding ? "\u6B63\u5728\u7ED1\u5B9A" : "\u626B\u7801\u7ED1\u5B9A\u5FAE\u4FE1"), - totals.configured > 0 ? h4( + totals.configured > 0 ? h5( "div", { className: "dxw-badge dim-onlineBadge" }, - h4("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) + h5("span", null, `${totals.connected} / ${totals.configured} \u5728\u7EBF`) ) : null ) ); } -function LoadingView3() { - return h4( +function LoadingView4() { + return h5( "div", { className: "dxw-card dxw-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h4("div", { className: "dxw-spinner dim-spinner" }), - h4("span", null, "\u6B63\u5728\u8BFB\u53D6\u5FAE\u4FE1\u8FDE\u63A5\u72B6\u6001\u2026") + h5("div", { className: "dxw-spinner dim-spinner" }), + h5("span", null, "\u6B63\u5728\u8BFB\u53D6\u5FAE\u4FE1\u8FDE\u63A5\u72B6\u6001\u2026") ); } -function EmptyView3({ onStart, busy }) { - return h4( +function EmptyView4({ onStart, busy }) { + return h5( "div", { className: "dxw-card dim-surfaceCard" }, - h4( + h5( "div", { className: "dxw-cardBody dxw-empty dim-surfaceBody dim-emptyView" }, - h4( + h5( "div", { className: "dim-emptyCopy" }, - h4( + h5( "div", { className: "dxw-stateLabel dim-stateLabel" }, - h4("span", { className: "dxw-dot dim-stateDot" }), - h4("span", null, "\u5C1A\u672A\u7ED1\u5B9A\u5FAE\u4FE1") + h5("span", { className: "dxw-dot dim-stateDot" }), + h5("span", null, "\u5C1A\u672A\u7ED1\u5B9A\u5FAE\u4FE1") ), - h4("h3", null, "\u626B\u4E00\u6B21\u7801\uFF0C\u5C31\u80FD\u5728\u5FAE\u4FE1\u91CC\u4F7F\u7528 Harness"), - h4("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"), - h4( + h5("h3", null, "\u626B\u4E00\u6B21\u7801\uFF0C\u5C31\u80FD\u5728\u5FAE\u4FE1\u91CC\u4F7F\u7528 Harness"), + h5("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"), + h5( "div", { className: "dxw-actions dim-viewActions" }, - h4( - Button5, + h5( + Button7, { kind: "primary", onClick: onStart, disabled: busy }, busy ? "\u6B63\u5728\u751F\u6210\u4E8C\u7EF4\u7801\u2026" : "\u751F\u6210\u5FAE\u4FE1\u4E8C\u7EF4\u7801" ) ) ), - h4("div", { className: "dxw-logo dim-emptyBrand", "aria-hidden": "true" }, h4(WeixinLogoGlyph, { size: 64 })) + h5("div", { className: "dxw-logo dim-emptyBrand", "aria-hidden": "true" }, h5(WeixinLogoGlyph, { size: 64 })) ) ); } -function QrPanel2({ provision, now, busy, onRefresh, onCancel }) { - const [imageFailed, setImageFailed] = React5.useState(false); - const source = safeQrSource3(provision.qrCodeDataUrl); +function QrPanel3({ provision, now, busy, onRefresh, onCancel }) { + const [imageFailed, setImageFailed] = React6.useState(false); + const source = safeQrSource4(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); - React5.useEffect(() => setImageFailed(false), [source]); - return h4( + React6.useEffect(() => setImageFailed(false), [source]); + return h5( "div", { className: "dxw-card dim-surfaceCard" }, - h4( + h5( "div", { className: "dxw-cardBody dxw-qrLayout dim-surfaceBody dim-qrLayout" }, - h4( + h5( "div", { className: "dxw-qrColumn dim-qrColumn" }, - h4( + h5( "div", { className: "dxw-qrFrame dim-qrFrame" }, - source && !imageFailed ? h4("img", { + source && !imageFailed ? h5("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) - }) : h4("div", { className: "dxw-qrFallback dim-qrFallback" }, "\u4E8C\u7EF4\u7801\u56FE\u7247\u672A\u5C31\u7EEA\uFF0C\u8BF7\u4F7F\u7528\u5907\u7528\u94FE\u63A5\u3002"), - expired ? h4("div", { className: "dxw-expired dim-qrExpired" }, "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\n\u8BF7\u91CD\u65B0\u751F\u6210") : null + }) : h5("div", { className: "dxw-qrFallback dim-qrFallback" }, "\u4E8C\u7EF4\u7801\u56FE\u7247\u672A\u5C31\u7EEA\uFF0C\u8BF7\u4F7F\u7528\u5907\u7528\u94FE\u63A5\u3002"), + expired ? h5("div", { className: "dxw-expired dim-qrExpired" }, "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\n\u8BF7\u91CD\u65B0\u751F\u6210") : null ), - h4( + h5( "div", { className: "dxw-countdown dim-countdown" }, - h4("div", { className: "dim-countdownTop" }, h4("span", null, "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), h4("strong", null, formatRemaining3(remaining))), - h4( + h5("div", { className: "dim-countdownTop" }, h5("span", null, "\u4E8C\u7EF4\u7801\u6709\u6548\u65F6\u95F4"), h5("strong", null, formatRemaining4(remaining))), + h5( "div", { className: "dxw-progress dim-progress", "aria-hidden": "true" }, - h4("span", { style: { "--dxw-progress": `${progress}%` } }) + h5("span", { style: { "--dxw-progress": `${progress}%` } }) ) ) ), - h4( + h5( "div", { className: "dxw-qrCopy dim-qrCopy" }, - h4( + h5( "div", { className: "dxw-stateLabel dim-stateLabel" }, - h4("span", { className: "dxw-dot dim-stateDot", "data-tone": provision.status === "scanned" ? "success" : "warning" }), - h4("span", null, provision.status === "scanned" ? "\u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u4E0A\u786E\u8BA4" : "\u7B49\u5F85\u5FAE\u4FE1\u626B\u7801") + h5("span", { className: "dxw-dot dim-stateDot", "data-tone": provision.status === "scanned" ? "success" : "warning" }), + h5("span", null, provision.status === "scanned" ? "\u5DF2\u626B\u7801\uFF0C\u8BF7\u5728\u624B\u673A\u4E0A\u786E\u8BA4" : "\u7B49\u5F85\u5FAE\u4FE1\u626B\u7801") ), - h4("h3", null, expired ? "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548" : "\u4F7F\u7528\u624B\u673A\u5FAE\u4FE1\u626B\u63CF\u4E8C\u7EF4\u7801"), - h4("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"), - h4( + h5("h3", null, expired ? "\u4E8C\u7EF4\u7801\u5DF2\u5931\u6548" : "\u4F7F\u7528\u624B\u673A\u5FAE\u4FE1\u626B\u63CF\u4E8C\u7EF4\u7801"), + h5("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"), + h5( "ol", { className: "dxw-steps dim-steps" }, - h4("li", null, "\u6253\u5F00\u624B\u673A\u5FAE\u4FE1\u5E76\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), - h4("li", null, "\u5728\u5FAE\u4FE1\u4E2D\u786E\u8BA4\u8FDE\u63A5\u8BE5\u673A\u5668\u4EBA"), - h4("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u6D88\u606F\u957F\u8F6E\u8BE2\u53D8\u4E3A\u5728\u7EBF") + h5("li", null, "\u6253\u5F00\u624B\u673A\u5FAE\u4FE1\u5E76\u626B\u63CF\u5DE6\u4FA7\u4E8C\u7EF4\u7801"), + h5("li", null, "\u5728\u5FAE\u4FE1\u4E2D\u786E\u8BA4\u8FDE\u63A5\u8BE5\u673A\u5668\u4EBA"), + h5("li", null, "\u4FDD\u6301\u672C\u9875\u6253\u5F00\uFF0C\u7B49\u5F85\u6D88\u606F\u957F\u8F6E\u8BE2\u53D8\u4E3A\u5728\u7EBF") ), - h4( + h5( "div", { className: "dxw-actions dim-viewActions" }, - expired ? h4(Button5, { kind: "primary", onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801") : null, - href ? h4("a", { + expired ? h5(Button7, { kind: "primary", onClick: onRefresh, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801") : null, + href ? h5("a", { className: "dxw-button", href, target: "_blank", rel: "noopener noreferrer" }, "\u6253\u5F00\u5907\u7528\u94FE\u63A5") : null, - !expired ? h4(Button5, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, - h4(Button5, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") + !expired ? h5(Button7, { onClick: onRefresh, disabled: busy }, "\u6362\u4E00\u4E2A\u4E8C\u7EF4\u7801") : null, + h5(Button7, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") ) ) ) ); } function VerificationPanel({ provision, busy, onSubmit, onCancel }) { - const [code, setCode] = React5.useState(""); + const [code, setCode] = React6.useState(""); const valid = /^\d{4,8}$/.test(code); - React5.useEffect(() => setCode(""), [provision.attemptId]); - return h4( + React6.useEffect(() => setCode(""), [provision.attemptId]); + return h5( "div", { className: "dxw-card dim-surfaceCard" }, - h4( + h5( "form", { className: "dxw-verify dim-specialView", @@ -3277,18 +3854,18 @@ function VerificationPanel({ provision, busy, onSubmit, onCancel }) { if (valid && !busy) onSubmit(code); } }, - h4( + h5( "div", { className: "dxw-stateLabel" }, - h4("span", { className: "dxw-dot", "data-tone": "warning" }), - h4("span", null, "\u9700\u8981\u914D\u5BF9\u7801") + h5("span", { className: "dxw-dot", "data-tone": "warning" }), + h5("span", null, "\u9700\u8981\u914D\u5BF9\u7801") ), - h4("h3", null, "\u8F93\u5165\u624B\u673A\u5FAE\u4FE1\u663E\u793A\u7684\u6570\u5B57"), - h4("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"), - h4( + h5("h3", null, "\u8F93\u5165\u624B\u673A\u5FAE\u4FE1\u663E\u793A\u7684\u6570\u5B57"), + h5("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"), + h5( "div", { className: "dxw-codeRow" }, - h4("input", { + h5("input", { className: "dxw-input", value: code, inputMode: "numeric", @@ -3298,131 +3875,131 @@ function VerificationPanel({ provision, busy, onSubmit, onCancel }) { onChange: (event) => setCode(event.target.value.replace(/\D/g, "").slice(0, 8)), autoFocus: true }), - h4("button", { + h5("button", { type: "submit", className: "dxw-button", "data-kind": "primary", disabled: !valid || busy }, busy ? "\u6B63\u5728\u9A8C\u8BC1\u2026" : "\u7EE7\u7EED\u8FDE\u63A5") ), - h4(Button5, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u7ED1\u5B9A") + h5(Button7, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88\u7ED1\u5B9A") ) ); } function ProgressPanel2({ scanned, onCancel, busy }) { - return h4( + return h5( "div", { className: "dxw-card dxw-loading dim-surfaceCard dim-loadingView", "aria-busy": "true" }, - h4("div", { className: "dxw-spinner dim-spinner" }), - h4("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"), - h4("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 ? h4( + h5("div", { className: "dxw-spinner dim-spinner" }), + h5("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"), + h5("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 ? h5( "div", { className: "dxw-actions dim-viewActions", style: { justifyContent: "center", marginTop: 14 } }, - h4(Button5, { onClick: onCancel, disabled: busy }, "\u53D6\u6D88") + h5(Button7, { 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 h4( + return h5( "div", { className: "dxw-card dim-surfaceCard" }, - h4( + h5( "div", { className: "dxw-error dim-inlineError", role: "alert" }, - h4("h3", null, provision.status === "expired" ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F" : "\u5FAE\u4FE1\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), - h4("p", null, error.message), - h4("span", { className: "dxw-errorCode" }, error.code), - h4( + h5("h3", null, provision.status === "expired" ? "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F" : "\u5FAE\u4FE1\u6CA1\u6709\u7ED1\u5B9A\u5B8C\u6210"), + h5("p", null, error.message), + h5("span", { className: "dxw-errorCode" }, error.code), + h5( "div", { className: "dxw-actions dim-viewActions" }, - h4(Button5, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), - h4(Button5, { onClick: onClose, disabled: busy }, "\u5173\u95ED") + h5(Button7, { kind: "primary", onClick: onRetry, disabled: busy }, "\u91CD\u65B0\u751F\u6210\u4E8C\u7EF4\u7801"), + h5(Button7, { onClick: onClose, disabled: busy }, "\u5173\u95ED") ) ) ); } -function checkedTime2(timestamp3) { - if (!timestamp3) return "\u5C1A\u672A\u68C0\u67E5"; +function checkedTime3(timestamp4) { + if (!timestamp4) return "\u5C1A\u672A\u68C0\u67E5"; try { return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" - }).format(new Date(timestamp3)); + }).format(new Date(timestamp4)); } catch { return "\u521A\u521A"; } } -function AccountCard2({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { +function AccountCard3({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { const state = busy === "reconnect" ? "connecting" : account.state; const tone = account.connected ? "success" : state === "error" ? "error" : "warning"; - return h4( + return h5( "article", { className: "dxw-card dim-botCard", tabIndex: -1, "data-bot-id": account.botId }, - h4( + h5( "div", { className: "dxw-cardBody dim-botCardBody" }, - h4( + h5( "div", { className: "dxw-accountTop dim-botCardTop" }, - h4( + h5( "div", { className: "dxw-accountIdentity dim-botIdentity" }, - h4("div", { className: "dxw-avatar dim-botAvatar", "aria-hidden": "true" }, h4(WeixinLogoGlyph, { size: 27 })), - h4("div", { className: "dim-botName" }, h4("h3", null, account.bot.name), h4("p", null, account.bot.accountIdMasked)) + h5("div", { className: "dxw-avatar dim-botAvatar", "aria-hidden": "true" }, h5(WeixinLogoGlyph, { size: 27 })), + h5("div", { className: "dim-botName" }, h5("h3", null, account.bot.name), h5("p", null, account.bot.accountIdMasked)) ), - h4( + h5( "div", { className: "dxw-health dim-botHealth" }, - h4("span", { className: "dxw-dot dim-healthDot", "data-tone": tone }), - h4("span", null, account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA") + h5("span", { className: "dxw-dot dim-healthDot", "data-tone": tone }), + h5("span", null, account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA") ) ), - h4( + h5( "dl", { className: "dxw-metrics dim-botMetrics" }, - h4( + h5( "div", { className: "dxw-metric dim-botMetric" }, - h4("dt", null, "\u6D88\u606F\u901A\u9053"), - h4("dd", null, account.connected ? "iLink \u957F\u8F6E\u8BE2" : "\u79BB\u7EBF") + h5("dt", null, "\u6D88\u606F\u901A\u9053"), + h5("dd", null, account.connected ? "iLink \u957F\u8F6E\u8BE2" : "\u79BB\u7EBF") ), - h4( + h5( "div", { className: "dxw-metric dim-botMetric" }, - h4("dt", null, "\u6700\u8FD1\u68C0\u67E5"), - h4("dd", null, checkedTime2(account.health.lastCheckedAt)) + h5("dt", null, "\u6700\u8FD1\u68C0\u67E5"), + h5("dd", null, checkedTime3(account.health.lastCheckedAt)) ) ), - h4( + h5( "div", { className: "dxw-accountFooter dim-cardFooter" }, - h4("div", { className: "dxw-summary dim-cardSummary" }, account.error?.message ?? account.health.summary), - h4( + h5("div", { className: "dxw-summary dim-cardSummary" }, account.error?.message ?? account.health.summary), + h5( "div", { className: "dxw-actions dim-cardActions" }, - h4( - Button5, + h5( + Button7, { 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(Button5, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") + h5(Button7, { className: "dim-cardAction", kind: "danger", onClick: onRequestRemove, disabled: Boolean(busy) }, "\u79FB\u9664\u63A5\u5165") ) ) ), - removing ? h4( + removing ? h5( "div", { className: "dxw-confirm dim-confirm", role: "alertdialog" }, - h4("strong", null, "\u4ECE\u6B64 Harness \u79FB\u9664\u8FD9\u4E2A\u5FAE\u4FE1\u8D26\u53F7\uFF1F"), - h4("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"), - h4( + h5("strong", null, "\u4ECE\u6B64 Harness \u79FB\u9664\u8FD9\u4E2A\u5FAE\u4FE1\u8D26\u53F7\uFF1F"), + h5("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"), + h5( "div", { className: "dxw-actions dim-viewActions" }, - h4(Button5, { onClick: onCancelRemove, disabled: busy === "delete" }, "\u4FDD\u7559\u8D26\u53F7"), - h4( - Button5, + h5(Button7, { onClick: onCancelRemove, disabled: busy === "delete" }, "\u4FDD\u7559\u8D26\u53F7"), + h5( + Button7, { kind: "danger", onClick: onConfirmRemove, disabled: busy === "delete" }, busy === "delete" ? "\u6B63\u5728\u79FB\u9664\u2026" : "\u786E\u8BA4\u79FB\u9664" ) @@ -3431,14 +4008,14 @@ function AccountCard2({ account, busy, removing, onReconnect, onRequestRemove, o ); } function AccountList2(props) { - return h4( + return h5( "section", { className: "dim-listSection" }, - h4("div", { className: "dxw-listHeading dim-listHeading" }, h4("h3", null, "\u5DF2\u63A5\u5165\u7684\u5FAE\u4FE1\u8D26\u53F7"), h4("span", null, `${props.bots.length} \u4E2A`)), - h4("ul", { className: "dxw-list dim-botList" }, props.bots.map((account) => h4( + h5("div", { className: "dxw-listHeading dim-listHeading" }, h5("h3", null, "\u5DF2\u63A5\u5165\u7684\u5FAE\u4FE1\u8D26\u53F7"), h5("span", null, `${props.bots.length} \u4E2A`)), + h5("ul", { className: "dxw-list dim-botList" }, props.bots.map((account) => h5( "li", { key: account.botId }, - h4(AccountCard2, { + h5(AccountCard3, { account, busy: props.busyByBot[account.botId], removing: props.removeTarget === account.botId, @@ -3461,38 +4038,38 @@ function mergeWeixinProvisioningSnapshot(current, incoming, { restoreProvisionin }; } function WeixinSettingsTab({ rpcCall }) { - const [model, setModel] = React5.useState({ + const [model, setModel] = React6.useState({ phase: "loading", bots: [], totals: EMPTY_TOTALS3, revision: 0, error: null }); - const [provision, setProvision] = React5.useState(null); - const [busy, setBusy] = React5.useState(false); - const [busyByBot, setBusyByBot] = React5.useState({}); - const [removeTarget, setRemoveTarget] = React5.useState(null); - const [notice, setNotice] = React5.useState(""); - const [now, setNow] = React5.useState(() => Date.now()); - const addButtonRef = React5.useRef(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 [notice, setNotice] = React6.useState(""); + const [now, setNow] = React6.useState(() => Date.now()); + const addButtonRef = React6.useRef(null); const scheduleAnimationFrame = useAnimationFrameScheduler(); - const announce = React5.useCallback((value) => { + const announce = React6.useCallback((value) => { setNotice(""); scheduleAnimationFrame(() => { if (value) setNotice(value); }, "announcement"); }, [scheduleAnimationFrame]); - const invoke = React5.useCallback(async (endpoint, payload = {}, signal) => { - return unwrapRpcResult3(await rpcCall(endpoint, payload, signal)); + const invoke = React6.useCallback(async (endpoint, payload = {}, signal) => { + return unwrapRpcResult4(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React5.useCallback(async ({ + const loadStatus = React6.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => { if (!silent) setModel((current) => ({ ...current, phase: "loading", error: null })); try { - const snapshot = normalizeSnapshot2(await invoke(WEIXIN_ENDPOINTS.status, {}, signal)); + const snapshot = normalizeSnapshot3(await invoke(WEIXIN_ENDPOINTS.status, {}, signal)); if (signal?.aborted) return void 0; setModel({ phase: "ready", @@ -3514,17 +4091,17 @@ function WeixinSettingsTab({ rpcCall }) { setModel((current) => ({ ...current, phase: silent && current.phase === "ready" ? "ready" : "error", - error: presentError3(error) + error: presentError4(error) })); return void 0; } }, [invoke]); - 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 running = false; @@ -3543,33 +4120,33 @@ function WeixinSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React5.useEffect(() => { + React6.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 = React5.useCallback(async ({ replace = false } = {}) => { + const startProvisioning = React6.useCallback(async ({ replace = false } = {}) => { setBusy(true); try { if (replace && provision?.attemptId) { await invoke(WEIXIN_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); } setProvision({ status: "starting" }); - const started = normalizeProvisioning3(await invoke(WEIXIN_ENDPOINTS.beginProvisioning, { locale: "zh-CN" })); + const started = normalizeProvisioning4(await invoke(WEIXIN_ENDPOINTS.beginProvisioning, { locale: "zh-CN" })); setNow(Date.now()); setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) }); announce("\u5FAE\u4FE1\u4E8C\u7EF4\u7801\u5DF2\u751F\u6210\uFF0C\u8BF7\u4F7F\u7528\u624B\u673A\u5FAE\u4FE1\u626B\u63CF\u3002"); } catch (error) { setProvision({ status: "failed", - error: presentError3(error), + error: presentError4(error), ...provision?.attemptId ? { attemptId: provision.attemptId } : {} }); } finally { setBusy(false); } }, [announce, invoke, provision?.attemptId]); - const cancelProvisioning = React5.useCallback(async () => { + const cancelProvisioning = React6.useCallback(async () => { setBusy(true); try { if (provision?.attemptId && !["failed", "expired", "cancelled"].includes(provision.status)) { @@ -3579,28 +4156,28 @@ 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: presentError3(error) })); + setProvision((current) => ({ ...current, status: "failed", error: presentError4(error) })); } finally { setBusy(false); } }, [announce, invoke, provision?.attemptId, provision?.status, scheduleAnimationFrame]); - const submitVerification = React5.useCallback(async (verifyCode) => { + const submitVerification = React6.useCallback(async (verifyCode) => { if (!provision?.attemptId) return; setBusy(true); try { - const next = normalizeProvisioning3(await invoke(WEIXIN_ENDPOINTS.submitVerification, { + const next = normalizeProvisioning4(await invoke(WEIXIN_ENDPOINTS.submitVerification, { attemptId: provision.attemptId, verifyCode })); 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: presentError3(error) })); + setProvision((current) => ({ ...current, status: "failed", error: presentError4(error) })); } finally { setBusy(false); } }, [announce, invoke, provision?.attemptId]); - React5.useEffect(() => { + React6.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !["pending", "scanned", "connecting"].includes(provision.status)) return void 0; const controller = new AbortController(); @@ -3610,7 +4187,7 @@ function WeixinSettingsTab({ rpcCall }) { }); const poll = async () => { try { - const result = normalizeProvisioning3(await invoke( + const result = normalizeProvisioning4(await invoke( WEIXIN_ENDPOINTS.pollProvisioning, { attemptId }, controller.signal @@ -3639,7 +4216,7 @@ function WeixinSettingsTab({ rpcCall }) { } } catch (error) { if (scheduler.disposed || error?.name === "AbortError") return; - setProvision((current) => current?.attemptId === attemptId ? { ...current, status: "failed", error: presentError3(error) } : current); + setProvision((current) => current?.attemptId === attemptId ? { ...current, status: "failed", error: presentError4(error) } : current); } }; scheduler.schedule(poll, provision.pollIntervalMs ?? 1e3); @@ -3648,7 +4225,7 @@ function WeixinSettingsTab({ rpcCall }) { controller.abort(); }; }, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]); - const setBotBusy = React5.useCallback((botId, value) => { + const setBotBusy = React6.useCallback((botId, value) => { setBusyByBot((current) => { const next = { ...current }; if (value) next[botId] = value; @@ -3656,23 +4233,23 @@ function WeixinSettingsTab({ rpcCall }) { return next; }); }, []); - const reconnect = React5.useCallback(async (account) => { + const reconnect = React6.useCallback(async (account) => { setBotBusy(account.botId, "reconnect"); try { - const snapshot = normalizeSnapshot2(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId })); + const snapshot = normalizeSnapshot3(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${presentError3(error).message}`); + announce(`\u8FDE\u63A5\u68C0\u67E5\u5931\u8D25\uFF1A${presentError4(error).message}`); } finally { setBotBusy(account.botId, null); } }, [announce, invoke, setBotBusy]); - const remove = React5.useCallback(async (account) => { + const remove = React6.useCallback(async (account) => { setBotBusy(account.botId, "delete"); try { - const snapshot = normalizeSnapshot2(await invoke(WEIXIN_ENDPOINTS.deleteBot, { + const snapshot = normalizeSnapshot3(await invoke(WEIXIN_ENDPOINTS.deleteBot, { botId: account.botId, confirm: true })); @@ -3680,16 +4257,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${presentError3(error).message}`); + announce(`\u79FB\u9664\u5931\u8D25\uFF1A${presentError4(error).message}`); } finally { setBotBusy(account.botId, null); } }, [announce, invoke, setBotBusy]); let provisionView = null; if (provision?.status === "starting") { - provisionView = h4(ProgressPanel2, { busy }); + provisionView = h5(ProgressPanel2, { busy }); } else if (["pending", "scanned"].includes(provision?.status)) { - provisionView = h4(QrPanel2, { + provisionView = h5(QrPanel3, { provision, now, busy, @@ -3697,54 +4274,54 @@ function WeixinSettingsTab({ rpcCall }) { onCancel: () => void cancelProvisioning() }); } else if (provision?.status === "needs_verification") { - provisionView = h4(VerificationPanel, { + provisionView = h5(VerificationPanel, { provision, busy, onSubmit: (code) => void submitVerification(code), onCancel: () => void cancelProvisioning() }); } else if (provision?.status === "connecting") { - provisionView = h4(ProgressPanel2, { + provisionView = h5(ProgressPanel2, { scanned: true, busy, onCancel: () => void cancelProvisioning() }); } else if (provision && ["failed", "expired", "cancelled"].includes(provision.status)) { - provisionView = h4(ProvisionError3, { + provisionView = h5(ProvisionError3, { provision, busy, onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }), onClose: () => void cancelProvisioning() }); } - return h4( + return h5( "section", { className: "dxw-page dim-channelPage", "aria-label": "\u5FAE\u4FE1\u8BBE\u7F6E" }, - h4(Heading3, { + h5(Heading4, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }), - h4("div", { className: "dxw-visuallyHidden", role: "status", "aria-live": "polite" }, notice), - model.error && model.phase === "ready" ? h4("div", { className: "dxw-statusNotice dim-statusNotice" }, `\u72B6\u6001\u5237\u65B0\u5931\u8D25\uFF1A${model.error.message}`) : null, - model.phase === "loading" ? h4(LoadingView3) : model.phase === "error" ? h4( + h5("div", { className: "dxw-visuallyHidden", role: "status", "aria-live": "polite" }, notice), + model.error && model.phase === "ready" ? h5("div", { className: "dxw-statusNotice dim-statusNotice" }, `\u72B6\u6001\u5237\u65B0\u5931\u8D25\uFF1A${model.error.message}`) : null, + model.phase === "loading" ? h5(LoadingView4) : model.phase === "error" ? h5( "div", { className: "dxw-card dim-surfaceCard" }, - h4( + h5( "div", { className: "dxw-error dim-inlineError" }, - h4("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u5FAE\u4FE1\u72B6\u6001"), - h4("p", null, model.error?.message ?? "\u8BF7\u7A0D\u540E\u91CD\u8BD5"), - h4(Button5, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") + h5("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u5FAE\u4FE1\u72B6\u6001"), + h5("p", null, model.error?.message ?? "\u8BF7\u7A0D\u540E\u91CD\u8BD5"), + h5(Button7, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") ) - ) : h4( - React5.Fragment, + ) : h5( + React6.Fragment, null, provisionView, - model.bots.length === 0 && !provision ? h4(EmptyView3, { onStart: () => void startProvisioning(), busy }) : null, - model.bots.length > 0 ? h4(AccountList2, { + model.bots.length === 0 && !provision ? h5(EmptyView4, { onStart: () => void startProvisioning(), busy }) : null, + model.bots.length > 0 ? h5(AccountList2, { bots: model.bots, busyByBot, removeTarget, @@ -3759,7 +4336,7 @@ function WeixinSettingsTab({ rpcCall }) { // plugin-src/client/styles.js var IM_STYLE_ID = "xmanrui-dsh-im-settings"; -var CSS4 = String.raw` +var CSS5 = String.raw` .dim-page { --dim-blue: #3370ff; --dim-blue-soft: color-mix(in srgb, var(--dim-blue) 9%, transparent); @@ -3788,11 +4365,13 @@ var CSS4 = String.raw` .dim-logoFeishu svg { width: 22px; height: 22px; } .dim-logoDingtalk { color: white; background: #1677ff; } .dim-logoDingtalk svg { width: 24px; height: 24px; } +.dim-logoQq { color: white; background: #1677ff; } +.dim-logoQq 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); } .dim-panel { min-width: 0; container-type: inline-size; } -.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page { width: 100%; max-width: none; padding: 0 0 24px; } +.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page { width: 100%; max-width: none; padding: 0 0 24px; } .dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; } .dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; justify-content: space-between; } .dim-panel .bxf-headingTools .dim-scanButton, .dim-panel .dxw-tools .dim-scanButton, .dim-panel .ddt-tools .dim-scanButton { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; box-shadow: none; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; } @@ -3921,64 +4500,69 @@ function installImStyles() { const style = document.createElement("style"); style.dataset.plugin = "@xmanrui/dsh-im"; style.dataset.pluginCss = IM_STYLE_ID; - style.textContent = CSS4; + style.textContent = CSS5; document.head.appendChild(style); return () => style.remove(); } // plugin-src/client/index.js -var h5 = React6.createElement; +var h6 = React7.createElement; var name = "im-settings"; var inject = ["slots", "connection"]; var CHANNELS = Object.freeze([ { id: "weixin", label: "\u5FAE\u4FE1" }, { id: "feishu", label: "\u98DE\u4E66" }, - { id: "dingtalk", label: "\u9489\u9489" } + { id: "dingtalk", label: "\u9489\u9489" }, + { id: "qq", label: "QQ" } ]); function WeixinLogo() { - return h5( + return h6( "span", { className: "dim-logo dim-logoWeixin", "aria-hidden": "true" }, - h5(WeixinLogoGlyph) + h6(WeixinLogoGlyph) ); } function FeishuLogo() { - return h5( + return h6( "span", { className: "dim-logo dim-logoFeishu", "aria-hidden": "true" }, - h5(FeishuLogoGlyph) + h6(FeishuLogoGlyph) ); } function DingtalkLogo() { - return h5( + return h6( "span", { className: "dim-logo dim-logoDingtalk", "aria-hidden": "true" }, - h5(DingtalkLogoGlyph) + h6(DingtalkLogoGlyph) ); } -function ChannelLogo({ channel }) { - if (channel === "weixin") return h5(WeixinLogo); - if (channel === "feishu") return h5(FeishuLogo); - return h5(DingtalkLogo); +function QqLogo() { + return h6("span", { className: "dim-logo dim-logoQq", "aria-hidden": "true" }, h6(QqLogoGlyph)); } -function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, weixinRpcCall }) { - const [selected, setSelected] = React6.useState("weixin"); +function ChannelLogo({ channel }) { + if (channel === "weixin") return h6(WeixinLogo); + if (channel === "feishu") return h6(FeishuLogo); + if (channel === "dingtalk") return h6(DingtalkLogo); + return h6(QqLogo); +} +function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixinRpcCall }) { + const [selected, setSelected] = React7.useState("weixin"); const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0]; - return h5( + return h6( "section", { className: "dim-page", "aria-label": "IM\u673A\u5668\u4EBA\u8BBE\u7F6E" }, - h5( + h6( "header", { className: "dim-title" }, - h5("p", null, "\u901A\u8FC7\u626B\u7801\u628A\u673A\u5668\u4EBA\u63A5\u5165 DeepSeek Harness") + h6("p", null, "\u901A\u8FC7\u626B\u7801\u628A\u673A\u5668\u4EBA\u63A5\u5165 DeepSeek Harness") ), - h5( + h6( "div", { className: "dim-layout" }, - h5( + h6( "nav", { className: "dim-rail", role: "tablist", "aria-label": "IM \u6E20\u9053" }, - CHANNELS.map((channel) => h5( + CHANNELS.map((channel) => h6( "button", { key: channel.id, @@ -3990,27 +4574,27 @@ function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, weixinRpcCall }) { "aria-controls": `dim-panel-${channel.id}`, onClick: () => setSelected(channel.id) }, - h5(ChannelLogo, { channel: channel.id }), - h5( + h6(ChannelLogo, { channel: channel.id }), + h6( "span", { className: "dim-channelCopy" }, - h5("strong", null, channel.label) + h6("strong", null, channel.label) ) )) ), - h5("div", { className: "dim-divider", "aria-hidden": "true" }), - h5("main", { + h6("div", { className: "dim-divider", "aria-hidden": "true" }), + h6("main", { className: "dim-panel", role: "tabpanel", id: `dim-panel-${active.id}`, "aria-labelledby": `dim-tab-${active.id}` - }, active.id === "weixin" ? h5(WeixinSettingsTab, { rpcCall: weixinRpcCall }) : active.id === "feishu" ? h5(FeishuSettingsTab, { rpcCall: feishuRpcCall }) : h5(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall })) + }, active.id === "weixin" ? h6(WeixinSettingsTab, { rpcCall: weixinRpcCall }) : active.id === "feishu" ? h6(FeishuSettingsTab, { rpcCall: feishuRpcCall }) : active.id === "dingtalk" ? h6(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall }) : h6(QqSettingsTab, { rpcCall: qqRpcCall })) ) ); } function apply(ctx) { ctx.effect(() => { - const disposers = [installFeishuStyles(), installWeixinStyles(), installImStyles()]; + const disposers = [installFeishuStyles(), installWeixinStyles(), installQqStyles(), installImStyles()]; return () => { for (const dispose of disposers.reverse()) dispose(); }; @@ -4018,12 +4602,13 @@ function apply(ctx) { const feishuRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(FEISHU_RPC_CHANNEL, endpoint, payload, signal); const weixinRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(WEIXIN_RPC_CHANNEL, endpoint, payload, signal); 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); 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, weixinRpcCall }) + inject: () => ({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixinRpcCall }) }, IMSettingsTab)); } diff --git a/lib/index.js b/lib/index.js index 8f559ad..6fd24d8 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1136,14 +1136,14 @@ function abortError2(signal) { } function abortableDelay(ms, signal) { if (ms <= 0) return Promise.resolve(); - return new Promise((resolve4, reject) => { + return new Promise((resolve5, reject) => { if (signal?.aborted) { reject(abortError2(signal)); return; } const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); - resolve4(); + resolve5(); }, ms); const onAbort = () => { clearTimeout(timer); @@ -2055,7 +2055,7 @@ function streamIsOpen(client) { return client?.connected === true || client?.socket?.readyState === 1; } function abortable(promise, signal) { - return new Promise((resolve4, reject) => { + return new Promise((resolve5, reject) => { if (signal.aborted) { reject(signal.reason); return; @@ -2065,7 +2065,7 @@ function abortable(promise, signal) { Promise.resolve(promise).then( (value) => { signal.removeEventListener("abort", onAbort); - resolve4(value); + resolve5(value); }, (error) => { signal.removeEventListener("abort", onAbort); @@ -2078,10 +2078,10 @@ async function waitForStreamOpen(client, pollIntervalMs, signal) { while (true) { signal?.throwIfAborted(); if (streamIsOpen(client)) return; - await new Promise((resolve4, reject) => { + await new Promise((resolve5, reject) => { const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); - resolve4(); + resolve5(); }, pollIntervalMs); const onAbort = () => { clearTimeout(timer); @@ -2356,14 +2356,14 @@ var DingtalkRuntime = class { import { spawn } from "node:child_process"; import { randomUUID as randomUUID4 } from "node:crypto"; function sleep(ms, signal) { - return new Promise((resolve4, reject) => { + return new Promise((resolve5, reject) => { if (signal?.aborted) { reject(signal.reason ?? new DOMException("Aborted", "AbortError")); return; } const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); - resolve4(); + resolve5(); }, ms); const onAbort = () => { clearTimeout(timer); @@ -2834,8 +2834,8 @@ var ConnectionSupervisor = class { this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0 ? healthyIntervalMs : 15e3; this.#setTimeout = setTimeoutImpl; this.#clearTimeout = clearTimeoutImpl; - this.#ready = new Promise((resolve4) => { - this.#resolveReady = resolve4; + this.#ready = new Promise((resolve5) => { + this.#resolveReady = resolve5; }); } get ready() { @@ -3410,8 +3410,8 @@ var ConnectionSupervisor2 = class { this.#healthyIntervalMs = safeDelay(healthyIntervalMs, 15e3); this.#setTimeout = setTimeoutImpl; this.#clearTimeout = clearTimeoutImpl; - this.#ready = new Promise((resolve4) => { - this.#resolveReady = resolve4; + this.#ready = new Promise((resolve5) => { + this.#resolveReady = resolve5; }); } get ready() { @@ -4045,7 +4045,7 @@ var FeishuRuntime = class { }); let settleReady; let settleError; - const ready = new Promise((resolve4, reject) => { + const ready = new Promise((resolve5, reject) => { let settled = false; const timer = setTimeout(() => { if (settled) return; @@ -4056,7 +4056,7 @@ var FeishuRuntime = class { if (settled) return; settled = true; clearTimeout(timer); - resolve4(); + resolve5(); }; settleError = (error) => { if (settled) return; @@ -4126,7 +4126,7 @@ 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((resolve4) => setTimeout(resolve4, ms)); +var sleep2 = (ms) => new Promise((resolve5) => setTimeout(resolve5, 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(); } @@ -5810,7 +5810,7 @@ function validPayload(endpoint, payload) { return "Unknown Feishu endpoint."; } function abortableDelay2(milliseconds, signal) { - return new Promise((resolve4, reject) => { + return new Promise((resolve5, reject) => { if (signal?.aborted) { reject(signal.reason ?? new Error("aborted")); return; @@ -5819,7 +5819,7 @@ function abortableDelay2(milliseconds, signal) { timer.unref?.(); function done() { signal?.removeEventListener("abort", aborted); - resolve4(); + resolve5(); } function aborted() { clearTimeout(timer); @@ -5881,8 +5881,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 payloadFailure3 = validPayload(endpoint, payload); - if (payloadFailure3) return badRequest2(payloadFailure3); + const payloadFailure4 = validPayload(endpoint, payload); + if (payloadFailure4) return badRequest2(payloadFailure4); try { let value; if (endpoint === FEISHU_ENDPOINTS.status) { @@ -5984,18 +5984,1580 @@ async function apply2(ctx, config = {}) { return disposeRpc; } -// plugin-src/host/channels/weixin/production.mjs +// 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"; -// src/channels/weixin/config-store.mjs +// 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) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function safeBotId2(value) { + const id = cleanString5(value); + return id && /^qq_[a-f0-9]{24}$/.test(id) ? id : null; +} +function safeSecretRef2(value) { + const ref = cleanString5(value); + return ref && /^DSH_QQBOT_APP_SECRET_[A-F0-9]{24}$/.test(ref) ? ref : null; +} +function deriveQqBotIdentity(appId) { + const raw = cleanString5(appId); + if (!raw) throw new TypeError("appId is required"); + const digest2 = createHash3("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) ?? ""; + 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 botId = safeBotId2(value.botId); + const secretRef = safeSecretRef2(value.secretRef); + if (!appId || !ownerUserOpenid || !botId || !secretRef) return null; + const derived = deriveQqBotIdentity(appId); + if (derived.botId !== botId || derived.secretRef !== secretRef) return null; + return Object.freeze({ + botId, + appId, + secretRef, + ownerUserOpenid, + createdAt: cleanString5(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: cleanString5(value.connectedAt) + }); +} +function normalizeDocument3(value) { + if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null; + const bots = value.bots.map(normalizeBot3); + if (bots.some((bot) => bot === null)) return null; + const ids = /* @__PURE__ */ new Set(); + const appIds = /* @__PURE__ */ new Set(); + const refs = /* @__PURE__ */ new Set(); + for (const bot of bots) { + if (ids.has(bot.botId) || appIds.has(bot.appId) || refs.has(bot.secretRef)) return null; + ids.add(bot.botId); + appIds.add(bot.appId); + refs.add(bot.secretRef); + } + return Object.freeze({ version: 1, bots: Object.freeze(bots) }); +} +var QqConfigStore = class { + #path; + #value = EMPTY_DOCUMENT2; + #writeQueue = Promise.resolve(); + constructor(path) { + this.#path = path; + } + async load() { + try { + const normalized = normalizeDocument3(JSON.parse(await readFile5(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; + } + 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; + } + getByAppId(appId) { + const bot = this.#value.bots.find((candidate) => candidate.appId === appId); + return bot ? structuredClone(bot) : null; + } + async save(value) { + const normalized = normalizeBot3(value); + if (!normalized) throw new Error("Refusing to persist incomplete QQ bot data"); + return this.#mutate((bots) => { + const appCollision = bots.find( + (bot) => bot.appId === normalized.appId && bot.botId !== normalized.botId + ); + const refCollision = bots.find( + (bot) => bot.secretRef === normalized.secretRef && bot.botId !== normalized.botId + ); + if (appCollision || refCollision) throw new Error("Duplicate QQ 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 (!safeBotId2(botId)) throw new TypeError("Invalid QQ 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 unlink6(this.#path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + this.#value = EMPTY_DOCUMENT2; + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + } + 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 mkdir5(dirname5(this.#path), { recursive: true, mode: 448 }); + const temporary = `${this.#path}.tmp`; + await writeFile5(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((resolve5) => setTimeout(resolve5, 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"); + } +}; + +// src/channels/qq/harness-client.mjs +var QqHarnessClient = class extends HarnessClient3 { +}; + +// src/channels/qq/qq-controller.mjs +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) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} +function safeError2(code, message) { + return Object.freeze({ code, message }); +} +function publicAttempt2(record) { + if (!record) return null; + return { + attemptId: record.id, + status: record.state, + qrRevision: record.qrRevision, + pollIntervalMs: 1e3, + ...record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}, + ...record.expiresAt ? { expiresAt: record.expiresAt } : {}, + ...record.botId ? { botId: record.botId } : {}, + ...record.error ? { error: structuredClone(record.error) } : {} + }; +} +var QqController = class { + #qrAuth; + #credentials; + #configStore; + #createRuntime; + #deleteState; + #logger; + #runtimes = /* @__PURE__ */ new Map(); + #errors = /* @__PURE__ */ new Map(); + #attempts = /* @__PURE__ */ new Map(); + #activeAttemptId = null; + #transitions = /* @__PURE__ */ new Map(); + #revision = 0; + #closed = false; + constructor({ + qrAuth, + credentials, + configStore, + createRuntime, + deleteState = async () => { + }, + logger = console + }) { + if (!qrAuth || typeof qrAuth.start !== "function") throw new TypeError("QQ QR auth is required"); + if (!credentials || typeof credentials.resolve !== "function" || typeof credentials.set !== "function" || typeof credentials.unset !== "function") { + throw new TypeError("QqController requires the DSH credential provider"); + } + if (!configStore || typeof configStore.list !== "function" || typeof configStore.save !== "function" || typeof configStore.remove !== "function") { + throw new TypeError("QqController requires a config store"); + } + if (typeof createRuntime !== "function") throw new TypeError("createRuntime is required"); + this.#qrAuth = qrAuth; + this.#credentials = credentials; + this.#configStore = configStore; + 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 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")); + 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.#logger.warn?.(`[dsh-im:qq] bot ${config.botId} failed to initialize:`, error); + } finally { + this.#touch(); + } + }); + } + return this.status(); + } + async startProvisioning() { + if (this.#closed) throw new Error("QQ controller is closed"); + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + let firstQrResolve; + let firstQrReject; + const firstQr = new Promise((resolve5, reject) => { + firstQrResolve = resolve5; + firstQrReject = reject; + }); + const record = { + id: randomUUID9(), + state: "starting", + createdAt: Date.now(), + expiresAt: null, + qrRevision: 0, + verificationUrl: null, + controller: new AbortController(), + dispose: null, + task: null, + error: null, + botId: null + }; + this.#attempts.set(record.id, record); + this.#activeAttemptId = record.id; + this.#touch(); + try { + record.dispose = this.#qrAuth.start({ + onQrDisplayed: (url) => { + if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES2.has(record.state)) return; + const verificationUrl = cleanString6(url); + if (!verificationUrl) return; + record.verificationUrl = verificationUrl; + record.qrRevision += 1; + record.expiresAt = Date.now() + QR_TTL_MS; + record.state = "pending"; + this.#touch(); + firstQrResolve(); + }, + onQrExpired: () => { + if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES2.has(record.state)) return; + record.state = "refreshing"; + record.verificationUrl = null; + record.expiresAt = null; + this.#touch(); + }, + onSuccess: (credentials) => { + if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES2.has(record.state)) return; + record.task = this.#completeProvisioning(record, credentials); + }, + 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"); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + firstQrReject(error); + } + }, { signal: record.controller.signal }); + await firstQr; + return publicAttempt2(record); + } catch (error) { + if (record.controller.signal.aborted) { + record.state = "cancelled"; + record.error = safeError2("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"); + } + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + throw error; + } + } + registrationStatus(attemptId) { + return publicAttempt2(this.#attempts.get(attemptId)); + } + async cancelProvisioning(attemptId) { + const record = this.#attempts.get(attemptId); + if (!record) return null; + if (!TERMINAL_ATTEMPT_STATES2.has(record.state)) { + record.controller.abort(); + 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"); + } + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + return publicAttempt2(record); + } + async reconnectBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error("Unknown QQ bot"); + await this.#withBotTransition(botId, async () => { + const secret = await this.#resolveSecret(config.secretRef); + if (!secret) throw new Error("QQ bot secret is missing"); + try { + 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")); + throw error; + } finally { + this.#touch(); + } + }); + return this.status(); + } + async deleteBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error("Unknown QQ bot"); + await this.#withBotTransition(botId, async () => { + const previous = await this.#credentials.resolve(config.secretRef).catch(() => void 0); + await this.#stopRuntime(botId); + try { + await this.#credentials.unset(config.secretRef); + await this.#configStore.remove(botId); + } catch (error) { + if (previous?.value) { + await this.#credentials.set(config.secretRef, previous.value).catch(() => void 0); + await this.#startRuntime(config, previous.value).catch(() => void 0); + } + throw new Error("Unable to remove the QQ bot safely.", { cause: error }); + } + await this.#deleteState({ botId, config }).catch((error) => { + this.#logger.warn?.(`[dsh-im:qq] 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.qqConnectionState === "connected" && runtimeStatus2.harnessReachable === true; + const state = connected ? "connected" : runtimeStatus2?.qqConnectionState === "connecting" ? "connecting" : this.#errors.has(config.botId) || runtimeStatus2?.qqConnectionState === "failed" ? "error" : "offline"; + return { + botId: config.botId, + state, + connected, + configured: true, + bot: { name: "QQ\u673A\u5668\u4EBA", appIdMasked: maskQqAppId(config.appId) }, + health: { + status: connected ? "healthy" : state === "error" ? "error" : "offline", + summary: connected ? "QQ WebSocket \u957F\u8FDE\u63A5\u8FD0\u884C\u6B63\u5E38" : state === "error" ? "QQ \u8FDE\u63A5\u672A\u5C31\u7EEA\uFF0C\u63D2\u4EF6\u4F1A\u81EA\u52A8\u91CD\u8BD5" : "QQ \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; + const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null; + return { + schemaVersion: 1, + revision: this.#revision, + state: active && ACTIVE_ATTEMPT_STATES2.has(active.state) ? "provisioning" : bots.length === 0 ? "disconnected" : connectedCount === bots.length ? "connected" : connectedCount > 0 ? "degraded" : "offline", + bots, + totals: { configured: bots.length, connected: connectedCount }, + ...active && ACTIVE_ATTEMPT_STATES2.has(active.state) ? { provisioning: publicAttempt2(active) } : {} + }; + } + async close() { + if (this.#closed) return; + this.#closed = true; + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId))); + } + async #completeProvisioning(record, credentials) { + try { + record.state = "connecting"; + record.verificationUrl = null; + 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); + if (!appId || !appSecret || !ownerUserOpenid) { + throw new Error("QQ authorization returned incomplete credentials"); + } + record.botId = await this.#activateBot(record, { appId, appSecret, ownerUserOpenid }); + record.state = "connected"; + record.error = null; + } catch (error) { + if (record.controller.signal.aborted) { + record.state = "cancelled"; + record.error = safeError2("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"); + this.#logger.error?.("[dsh-im:qq] provisioning failed:", error); + } + } finally { + record.dispose?.(); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + } + } + async #activateBot(record, { appId, appSecret, ownerUserOpenid }) { + const identity = deriveQqBotIdentity(appId); + const previousConfig = this.#configStore.getByAppId(appId); + const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => void 0); + const config = { + botId: identity.botId, + appId, + secretRef: identity.secretRef, + ownerUserOpenid, + createdAt: previousConfig?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: (/* @__PURE__ */ new Date()).toISOString() + }; + return this.#withBotTransition(identity.botId, async () => { + await this.#credentials.set(identity.secretRef, appSecret); + try { + if (record.controller.signal.aborted) throw new DOMException("Cancelled", "AbortError"); + await this.#configStore.save(config); + } catch (error) { + await this.#restoreCredential(identity.secretRef, previousSecret); + throw error; + } + try { + if (record.controller.signal.aborted) throw new DOMException("Cancelled", "AbortError"); + await this.#startRuntime(config, appSecret); + this.#errors.delete(identity.botId); + } catch (error) { + if (record.controller.signal.aborted) { + await this.#stopRuntime(identity.botId); + if (previousConfig) await this.#configStore.save(previousConfig).catch(() => void 0); + else await this.#configStore.remove(identity.botId).catch(() => void 0); + 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.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} activation connection failed:`, error); + } + this.#touch(); + return identity.botId; + }); + } + async #startRuntime(config, appSecret) { + await this.#stopRuntime(config.botId); + const runtime = await this.#createRuntime({ botId: config.botId, config, appSecret }); + if (!runtime || typeof runtime.start !== "function" || typeof runtime.stop !== "function") { + throw new TypeError("createRuntime returned an invalid QQ 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:qq] bot ${botId} failed to stop cleanly:`, error); + }); + } + async #resolveSecret(ref) { + const result = await this.#credentials.resolve(ref).catch(() => void 0); + return cleanString6(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/qq/qq-runtime.mjs +import { QQBot, typingIndicator } from "@tencent-connect/qqbot-nodejs"; + +// src/channels/qq/qq-bridge.mjs +var HELP_TEXT3 = [ + "QQ \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"); +function conversationKey3(message) { + return `${message.kind}:${message.kind === "group" ? message.groupOpenid : message.senderId}`; +} +function safeText(message) { + return typeof message?.content === "string" ? message.content.trim() : ""; +} +function createQqBridgeStatus() { + return { + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + lastMessageAt: null, + lastReplyAt: null, + lastRejectedAt: null, + lastError: null + }; +} +var QqHarnessBridge = class { + #bot; + #ownerUserOpenid; + #harness; + #state; + #status; + #logger; + #replyTimeoutMs; + #queues = /* @__PURE__ */ new Map(); + constructor({ + bot, + ownerUserOpenid, + harness, + state, + status = createQqBridgeStatus(), + logger = console, + replyTimeoutMs = 6e5 + }) { + if (!bot || typeof bot.sendText !== "function") throw new TypeError("QQ bot client is required"); + if (!ownerUserOpenid) throw new TypeError("QQ scanner identity is required"); + if (!harness || !state) throw new TypeError("Harness client and state store are required"); + this.#bot = bot; + this.#ownerUserOpenid = ownerUserOpenid; + this.#harness = harness; + this.#state = state; + this.#status = status; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + } + get status() { + return structuredClone(this.#status); + } + accept(message) { + const key = conversationKey3(message); + const previous = this.#queues.get(key) ?? Promise.resolve(); + const current = previous.catch(() => void 0).then(() => this.#process(message)).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 = typeof message?.messageId === "string" ? message.messageId : ""; + const sender = typeof message?.senderId === "string" ? message.senderId : ""; + if (!messageId || !sender || message.senderIsBot === true) return; + if (!["c2c", "group"].includes(message.kind) || this.#state.hasSeen(messageId)) return; + this.#status.messagesReceived += 1; + this.#status.lastMessageAt = (/* @__PURE__ */ new Date()).toISOString(); + if (sender !== this.#ownerUserOpenid) { + this.#status.messagesRejected += 1; + this.#status.lastRejectedAt = (/* @__PURE__ */ new Date()).toISOString(); + return; + } + if (message.kind === "group" && message.rawEventType !== "GROUP_AT_MESSAGE_CREATE") return; + const target = message.replyTarget; + const text = safeText(message); + 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, HELP_TEXT3); + await this.#state.markSeen(messageId); + return; + } + if (command === "/status") { + await this.#harness.ensureRunning(); + await this.#bot.sendText(target, "QQ \u673A\u5668\u4EBA\u4E0E DeepSeek Harness \u8FDE\u63A5\u6B63\u5E38\u3002"); + await this.#state.markSeen(messageId); + return; + } + const key = conversationKey3(message); + if (command === "/new") { + await this.#state.clearSession(key); + 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(key); + if (!sessionId || !await this.#harness.sessionExists(sessionId)) { + sessionId = await this.#harness.createSession(); + await this.#state.setSession(key, sessionId); + } + let stream = null; + let streamFinished = false; + if (message.kind === "c2c" && target?.msgId && typeof this.#bot.openStream === "function") { + try { + stream = this.#bot.openStream({ target }); + } catch (error) { + this.#logger.warn?.("[dsh-im:qq] unable to start a QQ stream; using a text reply:", 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.update(answer); + await stream.complete(); + streamFinished = true; + } catch (error) { + stream.cancel?.(); + this.#logger.warn?.("[dsh-im:qq] QQ stream finalization failed; using a text reply:", 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:qq] failed to process an inbound 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:qq] failed to send the safe error reply:", sendError); + } + } + } +}; + +// src/channels/qq/qq-runtime.mjs +function timeoutError() { + const error = new Error("QQ WebSocket did not become ready in time"); + error.code = "connect-timeout"; + return error; +} +function createQqRuntimeStatus() { + return { + startedAt: null, + ready: false, + qqConnectionState: "idle", + harnessReachable: false, + lastCheckedAt: null, + lastConnectedAt: null, + lastError: null, + ...createQqBridgeStatus() + }; +} +var QqRuntime = class { + #config; + #appSecret; + #harness; + #state; + #logger; + #replyTimeoutMs; + #connectTimeoutMs; + #createBot; + #typingMiddleware; + #status = createQqRuntimeStatus(); + #bot = null; + #bridge = null; + #abortController = null; + #runTask = null; + #starting = null; + constructor({ + config, + appSecret, + harness, + state, + logger = console, + replyTimeoutMs = 6e5, + connectTimeoutMs = 2e4, + createBot = (options) => new QQBot(options), + typingMiddleware = typingIndicator + }) { + if (!config || !appSecret || !harness || !state) { + throw new TypeError("QqRuntime requires config, app secret, Harness, and state"); + } + this.#config = config; + this.#appSecret = appSecret; + this.#harness = harness; + this.#state = state; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#connectTimeoutMs = connectTimeoutMs; + this.#createBot = createBot; + this.#typingMiddleware = typingMiddleware; + } + get status() { + return structuredClone(this.#status); + } + async start() { + if (this.#status.ready && this.#bot) 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.qqConnectionState = "connecting"; + this.#status.lastError = null; + await this.#harness.ensureRunning(); + this.#status.harnessReachable = true; + const sdkLogger = { + error: (...args) => this.#logger.error?.(...args), + warn: (...args) => this.#logger.warn?.(...args), + info: (...args) => this.#logger.info?.(...args), + debug: () => { + } + }; + const bot = this.#createBot({ + appId: this.#config.appId, + appSecret: this.#appSecret, + accountId: this.#config.botId, + logger: sdkLogger, + transport: "websocket", + tokenPrefetch: "sync" + }); + if (!bot || typeof bot.start !== "function" || typeof bot.stop !== "function") { + throw new TypeError("QQ bot factory returned an invalid client"); + } + this.#bot = bot; + this.#bridge = new QqHarnessBridge({ + bot, + ownerUserOpenid: this.#config.ownerUserOpenid, + harness: this.#harness, + state: this.#state, + status: this.#status, + logger: this.#logger, + replyTimeoutMs: this.#replyTimeoutMs + }); + bot.use?.(this.#typingMiddleware({ + keepAlive: true, + predicate: (ctx) => ctx?.message?.senderId === this.#config.ownerUserOpenid + })); + const controller = new AbortController(); + this.#abortController = controller; + let readyResolve; + let readyReject; + const ready = new Promise((resolve5, reject) => { + readyResolve = resolve5; + readyReject = reject; + }); + const onReady = () => { + const now = Date.now(); + this.#status.ready = true; + this.#status.qqConnectionState = "connected"; + this.#status.lastCheckedAt = now; + this.#status.lastConnectedAt = now; + this.#status.lastError = null; + readyResolve(); + }; + const onError = (error) => { + if (!this.#status.ready) readyReject(error); + else { + this.#status.lastError = error?.message ?? String(error); + this.#logger.warn?.(`[dsh-im:qq] bot ${this.#config.botId} connection error:`, error); + } + }; + const onMessage = (_ctx, message) => this.#bridge?.accept(message); + bot.on("ready", onReady); + bot.on("resumed", onReady); + bot.on("error", onError); + bot.on("message", onMessage); + const runTask = Promise.resolve().then(() => bot.start(controller.signal)); + this.#runTask = runTask; + runTask.catch((error) => { + if (controller.signal.aborted) return; + readyReject(error); + this.#status.ready = false; + this.#status.qqConnectionState = "failed"; + this.#status.lastError = error?.message ?? String(error); + this.#logger.error?.(`[dsh-im:qq] bot ${this.#config.botId} connection stopped:`, error); + }); + let timer; + try { + await Promise.race([ + ready, + new Promise((_, reject) => { + timer = setTimeout(() => reject(timeoutError()), this.#connectTimeoutMs); + }) + ]); + this.#status.ready = true; + this.#status.qqConnectionState = "connected"; + this.#status.lastCheckedAt = Date.now(); + this.#status.lastConnectedAt = Date.now(); + return this.status; + } catch (error) { + this.#status.ready = false; + this.#status.qqConnectionState = "failed"; + this.#status.lastError = error?.message ?? String(error); + await this.stop(); + throw error; + } finally { + clearTimeout(timer); + } + } + async stop() { + const bot = this.#bot; + const bridge = this.#bridge; + const runTask = this.#runTask; + this.#abortController?.abort(); + this.#abortController = null; + this.#bot = null; + this.#bridge = null; + this.#runTask = null; + try { + bot?.stop(); + } catch (error) { + this.#logger.warn?.(`[dsh-im:qq] bot ${this.#config.botId} failed to stop cleanly:`, error); + } + await Promise.race([ + runTask?.catch(() => void 0) ?? Promise.resolve(), + new Promise((resolve5) => setTimeout(resolve5, 2e3)) + ]); + await bridge?.waitForIdle(); + this.#status.ready = false; + this.#status.qqConnectionState = "idle"; + return this.status; + } +}; + +// src/channels/qq/qr-auth.mjs +import { startQrConnect } from "@tencent-connect/qqbot-connector"; +var QqQrAuth = class { + #start; + #source; + constructor({ start = startQrConnect, source = "deepseek-harness" } = {}) { + if (typeof start !== "function") throw new TypeError("QQ QR connector is required"); + this.#start = start; + this.#source = source; + } + start(callbacks, { signal } = {}) { + if (!callbacks || typeof callbacks.onSuccess !== "function" || typeof callbacks.onFailure !== "function") { + throw new TypeError("QQ QR callbacks are required"); + } + return this.#start(callbacks, { + displayQrCodeToConsole: false, + source: this.#source, + signal + }); + } +}; + +// 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); + 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" && typeof sessionId === "string" && sessionId) { + sessions[key] = sessionId; + } + } + } + return { + version: 1, + sessions, + seenMessageIds: Array.isArray(value.seenMessageIds) ? value.seenMessageIds.filter((id) => typeof id === "string").slice(-1e3) : [] + }; +} +var QqStateStore = class { + #path; + #state = structuredClone(EMPTY_STATE3); + #writeQueue = Promise.resolve(); + constructor(path) { + this.#path = path; + } + async load() { + try { + this.#state = normalizeState2(JSON.parse(await readFile6(this.#path, "utf8"))); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + this.#state = structuredClone(EMPTY_STATE3); + 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(); + } + snapshot() { + return structuredClone(this.#state); + } + async remove() { + try { + await unlink7(this.#path); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + this.#state = structuredClone(EMPTY_STATE3); + } + 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 }); + const temporary = `${this.#path}.tmp`; + await writeFile6(temporary, snapshot, { encoding: "utf8", mode: 384 }); + await rename6(temporary, this.#path); + }); + this.#writeQueue = operation.then(() => void 0, () => void 0); + await operation; + } +}; + +// 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 ConnectionSupervisor3 = class { + #controller; + #harness; + #logger; + #retryDelays; + #healthyIntervalMs; + #setTimeout; + #clearTimeout; + #timer = null; + #running = null; + #retryIndex = 0; + #closed = false; + #started = false; + #ready; + #resolveReady; + constructor({ + controller, + harness, + logger = console, + retryDelaysMs, + healthyIntervalMs = 15e3, + setTimeoutImpl = setTimeout, + clearTimeoutImpl = clearTimeout + }) { + if (!controller || typeof controller.initialize !== "function" || typeof controller.status !== "function") { + throw new TypeError("ConnectionSupervisor requires a controller"); + } + if (!harness || typeof harness.ensureRunning !== "function") { + throw new TypeError("ConnectionSupervisor requires a Harness client"); + } + 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((resolve5) => { + this.#resolveReady = resolve5; + }); + } + 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 delay2 = 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); + 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)]; + this.#retryIndex += 1; + this.#logger.warn?.(`[dsh-im:qq] connection reconciliation failed; retrying in ${delay2}ms`, error); + this.#schedule(delay2); + } + } +}; +function createConnectionSupervisor3(options) { + return new ConnectionSupervisor3(options); +} + +// plugin-src/host/channels/qq/production.mjs +function harnessOrigin3(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 QQ requires an initialized DSH webServer port"); + } + 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")); + return { + config: resolve3(config.configPath ?? join3(root, "config.json")), + bots: resolve3(config.botsDir ?? join3(root, "bots")) + }; +} +async function createProductionController3(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; + const StateStore2 = internals.StateStore ?? QqStateStore; + const Harness = internals.HarnessClient ?? QqHarnessClient; + const Controller = internals.Controller ?? QqController; + const Runtime = internals.Runtime ?? QqRuntime; + 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 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 stateFor = async (botId) => { + let state = stateStores.get(botId); + if (!state) { + state = await new StateStore2(statePath(botId)).load(); + stateStores.set(botId, state); + } + return state; + }; + const harness = new Harness({ + baseUrl: harnessOrigin3(ctx.webServer, config.harnessBaseUrl), + workspace: resolve3(config.workspace ?? process.cwd()), + agentPreset: config.agentPreset ?? "standard", + autostart: false, + dshBin: config.dshBin ?? "dsh" + }); + const controller = new Controller({ + qrAuth, + credentials: ctx.credentials, + configStore, + logger, + createRuntime: async ({ botId, config: botConfig, appSecret }) => new Runtime({ + config: botConfig, + appSecret, + 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 unlink8(statePath(botId)); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + }); + const supervisor = createSupervisor({ + 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/qq/rpc.mjs +import QRCode3 from "qrcode"; +var QQ_RPC_CHANNEL = "/qq"; +var QQ_ENDPOINTS = Object.freeze({ + status: "connection.status", + beginProvisioning: "provision.begin", + pollProvisioning: "provision.poll", + cancelProvisioning: "provision.cancel", + reconnectBot: "bot.reconnect", + deleteBot: "bot.delete" +}); +var QQ_RPC_ENDPOINTS = Object.freeze(Object.values(QQ_ENDPOINTS)); +var FORBIDDEN_PUBLIC_KEYS2 = /* @__PURE__ */ new Set([ + "appSecret", + "app_secret", + "secretRef", + "ownerUserOpenid", + "userOpenid", + "verificationUrl" +]); +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 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."; + if (endpoint === QQ_ENDPOINTS.beginProvisioning) { + return exactKeys2(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.`; + } + if (endpoint === QQ_ENDPOINTS.reconnectBot) { + return exactKeys2(payload, ["botId"]) && validId2(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 "Unknown QQ 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; +} +async function qrDataUrl2(value) { + return QRCode3.toDataURL(value, { + type: "image/png", + errorCorrectionLevel: "M", + margin: 2, + width: 320 + }); +} +async function withEncodedQr2(value, encodeQr) { + if (!value || typeof value.verificationUrl !== "string") return sanitizePublic2(value); + return sanitizePublic2({ ...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); +} +function createQqRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) { + for (const method of ["status", "startProvisioning", "registrationStatus", "cancelProvisioning", "reconnectBot", "deleteBot"]) { + if (typeof controller?.[method] !== "function") throw new TypeError(`A complete QQ controller is required (${method})`); + } + const qrCache = /* @__PURE__ */ new Map(); + const cachedEncode = (url) => { + let encoded = qrCache.get(url); + if (!encoded) { + if (qrCache.size >= 16) qrCache.delete(qrCache.keys().next().value); + encoded = Promise.resolve().then(() => encodeQr(url)); + qrCache.set(url, encoded); + } + return encoded; + }; + 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); + if (invalid) return { ok: false, error: { code: "bad-request", message: invalid } }; + try { + let value; + if (endpoint === QQ_ENDPOINTS.status) value = await publicStatus2(await controller.status(), cachedEncode); + else if (endpoint === QQ_ENDPOINTS.beginProvisioning) value = await withEncodedQr2(await controller.startProvisioning(), cachedEncode); + else if (endpoint === QQ_ENDPOINTS.pollProvisioning) { + const current = await controller.registrationStatus(payload.attemptId); + 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)); + } else if (endpoint === QQ_ENDPOINTS.reconnectBot) { + value = await publicStatus2(await controller.reconnectBot(payload.botId), cachedEncode); + } else { + value = await publicStatus2(await controller.deleteBot(payload.botId), cachedEncode); + } + return signal?.aborted ? { ok: false, error: { code: "cancelled", message: "The request was cancelled." } } : { ok: true, value }; + } catch { + return signal?.aborted ? { ok: false, error: { code: "cancelled", message: "The request was cancelled." } } : { ok: false, error: { code: "qq-operation-failed", message: "QQ \u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" } }; + } + }; +} +function installQqRpc(ctx, controller, options) { + 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" }); +} + +// 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); + ctx.effect(() => async () => production.close(), "dsh-im: close QQ bot connections"); + return disposeRpc; +} + +// plugin-src/host/channels/weixin/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"; + +// src/channels/weixin/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"; // src/channels/weixin/weixin-api.mjs -import { randomBytes, randomUUID as randomUUID8 } from "node:crypto"; +import { randomBytes, randomUUID as randomUUID10 } from "node:crypto"; var WEIXIN_QR_BASE_URL = "https://ilinkai.weixin.qq.com/"; var WEIXIN_PROTOCOL_VERSION = "2.4.6"; var DEFAULT_BOT_TYPE = "3"; @@ -6213,7 +7775,7 @@ function createWeixinApi({ fetchImpl = fetch } = {}) { msg: { from_user_id: "", to_user_id: recipient, - client_id: `dsh-weixin-${randomUUID8()}`, + client_id: `dsh-weixin-${randomUUID10()}`, message_type: 2, message_state: 2, item_list: [{ type: 1, text_item: { text: content } }], @@ -6290,37 +7852,37 @@ function splitWeixinText(text, maxChars = 4e3) { } // src/channels/weixin/config-store.mjs -var EMPTY_DOCUMENT2 = Object.freeze({ version: 1, accounts: Object.freeze([]) }); -function cleanString5(value) { +var EMPTY_DOCUMENT3 = Object.freeze({ version: 1, accounts: Object.freeze([]) }); +function cleanString7(value) { return typeof value === "string" && value.trim() ? value.trim() : null; } -function safeBotId2(value) { - const id = cleanString5(value); +function safeBotId3(value) { + const id = cleanString7(value); return id && /^wx_[a-f0-9]{24}$/.test(id) ? id : null; } function safeTokenRef(value) { - const ref = cleanString5(value); + const ref = cleanString7(value); return ref && /^DSH_WEIXIN_BOT_TOKEN_[A-F0-9]{24}$/.test(ref) ? ref : null; } function deriveWeixinBotIdentity(accountId) { - const raw = cleanString5(accountId); + const raw = cleanString7(accountId); if (!raw) throw new TypeError("accountId 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: `wx_${digest2}`, tokenRef: `DSH_WEIXIN_BOT_TOKEN_${digest2.toUpperCase()}` }; } function maskWeixinAccountId(accountId) { - const value = cleanString5(accountId) ?? ""; + const value = cleanString7(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 = cleanString5(value.accountId); - const ownerUserId = cleanString5(value.ownerUserId); - const botId = safeBotId2(value.botId); + const accountId = cleanString7(value.accountId); + const ownerUserId = cleanString7(value.ownerUserId); + const botId = safeBotId3(value.botId); const tokenRef = safeTokenRef(value.tokenRef); if (!accountId || !ownerUserId || !botId || !tokenRef) return null; const derived = deriveWeixinBotIdentity(accountId); @@ -6337,11 +7899,11 @@ function normalizeAccount(value) { tokenRef, ownerUserId, baseUrl, - createdAt: cleanString5(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), - connectedAt: cleanString5(value.connectedAt) + createdAt: cleanString7(value.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(), + connectedAt: cleanString7(value.connectedAt) }); } -function normalizeDocument3(value) { +function normalizeDocument4(value) { if (!value || value.version !== 1 || !Array.isArray(value.accounts)) return null; const accounts = value.accounts.map(normalizeAccount); if (accounts.some((account) => account === null)) return null; @@ -6360,19 +7922,19 @@ function normalizeDocument3(value) { } var WeixinConfigStore = 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 = normalizeDocument4(JSON.parse(await readFile7(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_DOCUMENT2; + this.#value = EMPTY_DOCUMENT3; } return this; } @@ -6405,7 +7967,7 @@ var WeixinConfigStore = class { }); } async remove(botId) { - if (!safeBotId2(botId)) throw new TypeError("Invalid Weixin bot id"); + if (!safeBotId3(botId)) throw new TypeError("Invalid Weixin bot id"); return this.#mutate((accounts) => { const index = accounts.findIndex((account) => account.botId === botId); if (index === -1) return null; @@ -6416,11 +7978,11 @@ var WeixinConfigStore = 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; @@ -6439,251 +8001,28 @@ var WeixinConfigStore = 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 randomUUID9 } from "node:crypto"; -var sleep3 = (ms) => new Promise((resolve4) => setTimeout(resolve4, 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-${randomUUID9()}`; - 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-${randomUUID9()}`; - 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/weixin/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({ +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: [], getUpdatesBuf: "" }); -function normalizeState2(value) { - if (!value || typeof value !== "object") return structuredClone(EMPTY_STATE3); +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)) { @@ -6701,17 +8040,17 @@ function normalizeState2(value) { } var WeixinStateStore = 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; @@ -6751,20 +8090,20 @@ var WeixinStateStore = 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; @@ -6772,28 +8111,28 @@ var WeixinStateStore = class { }; // src/channels/weixin/weixin-controller.mjs -import { randomUUID as randomUUID10 } from "node:crypto"; -var ACTIVE_ATTEMPT_STATES2 = /* @__PURE__ */ new Set([ +import { randomUUID as randomUUID11 } from "node:crypto"; +var ACTIVE_ATTEMPT_STATES3 = /* @__PURE__ */ new Set([ "starting", "pending", "scanned", "needs_verification", "connecting" ]); -var TERMINAL_ATTEMPT_STATES2 = /* @__PURE__ */ new Set(["connected", "expired", "failed", "cancelled"]); -var QR_TTL_MS = 5 * 6e4; -function cleanString6(value) { +var TERMINAL_ATTEMPT_STATES3 = /* @__PURE__ */ new Set(["connected", "expired", "failed", "cancelled"]); +var QR_TTL_MS2 = 5 * 6e4; +function cleanString8(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 = cleanString6(value); + const raw = cleanString8(value); if (!raw) return normalizeWeixinApiBaseUrl(fallback); return normalizeWeixinApiBaseUrl(raw.includes("://") ? raw : `https://${raw}`); } -function publicAttempt2(record) { +function publicAttempt3(record) { if (!record) return null; return { attemptId: record.id, @@ -6886,10 +8225,10 @@ var WeixinController = class { if (this.#closed) throw new Error("dsh-weixin controller is closed"); if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); const record = { - id: randomUUID10(), + id: randomUUID11(), state: "starting", createdAt: Date.now(), - expiresAt: Date.now() + QR_TTL_MS, + expiresAt: Date.now() + QR_TTL_MS2, controller: new AbortController(), pendingVerifyCode: null, verifyResolve: null, @@ -6913,10 +8252,10 @@ var WeixinController = class { record.qrcode = login.qrcode; record.verificationUrl = login.qrcodeUrl; record.state = "pending"; - record.expiresAt = Date.now() + QR_TTL_MS; + record.expiresAt = Date.now() + QR_TTL_MS2; this.#touch(); record.task = this.#runProvisioning(record); - return publicAttempt2(record); + return publicAttempt3(record); } catch (error) { if (record.controller.signal.aborted) { record.state = "cancelled"; @@ -6934,14 +8273,14 @@ var WeixinController = class { } } registrationStatus(attemptId) { - return publicAttempt2(this.#attempts.get(attemptId)); + return publicAttempt3(this.#attempts.get(attemptId)); } async submitVerification(attemptId, verifyCode) { const record = this.#attempts.get(attemptId); if (!record || record.state !== "needs_verification") { throw new Error("The provisioning attempt is not waiting for a verification code"); } - const code = cleanString6(verifyCode); + const code = cleanString8(verifyCode); if (!code || !/^\d{4,8}$/.test(code)) { throw new TypeError("Verification code must contain 4 to 8 digits"); } @@ -6950,22 +8289,22 @@ var WeixinController = class { record.verifyResolve?.(); record.verifyResolve = null; this.#touch(); - return publicAttempt2(record); + return publicAttempt3(record); } async cancelProvisioning(attemptId) { const record = this.#attempts.get(attemptId); if (!record) return null; - if (!TERMINAL_ATTEMPT_STATES2.has(record.state)) { + if (!TERMINAL_ATTEMPT_STATES3.has(record.state)) { record.controller.abort(); record.verifyResolve?.(); record.verifyResolve = null; await record.task?.catch(() => void 0); - if (!TERMINAL_ATTEMPT_STATES2.has(record.state)) record.state = "cancelled"; + if (!TERMINAL_ATTEMPT_STATES3.has(record.state)) record.state = "cancelled"; record.error ??= safeAccountError("cancelled", "\u626B\u7801\u7ED1\u5B9A\u5DF2\u53D6\u6D88\u3002"); } if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; this.#touch(); - return publicAttempt2(record); + return publicAttempt3(record); } async reconnectBot(botId) { const config = this.#configStore.get(botId); @@ -7043,10 +8382,10 @@ var WeixinController = class { return { schemaVersion: 1, revision: this.#revision, - state: active && ACTIVE_ATTEMPT_STATES2.has(active.state) ? "provisioning" : accounts.length === 0 ? "disconnected" : connectedCount === accounts.length ? "connected" : connectedCount > 0 ? "degraded" : "offline", + state: active && ACTIVE_ATTEMPT_STATES3.has(active.state) ? "provisioning" : accounts.length === 0 ? "disconnected" : connectedCount === accounts.length ? "connected" : connectedCount > 0 ? "degraded" : "offline", bots: accounts, totals: { configured: accounts.length, connected: connectedCount }, - ...active && ACTIVE_ATTEMPT_STATES2.has(active.state) ? { provisioning: publicAttempt2(active) } : {} + ...active && ACTIVE_ATTEMPT_STATES3.has(active.state) ? { provisioning: publicAttempt3(active) } : {} }; } async close() { @@ -7059,9 +8398,9 @@ var WeixinController = class { try { while (!record.controller.signal.aborted && Date.now() < record.expiresAt) { if (record.state === "needs_verification" && !record.pendingVerifyCode) { - await new Promise((resolve4) => { - record.verifyResolve = resolve4; - if (record.controller.signal.aborted) resolve4(); + await new Promise((resolve5) => { + record.verifyResolve = resolve5; + if (record.controller.signal.aborted) resolve5(); }); record.verifyResolve = null; this.#assertAttemptActive(record); @@ -7106,9 +8445,9 @@ var WeixinController = class { } break; } else if (response.status === "confirmed") { - const token = cleanString6(response.bot_token); - const accountId = cleanString6(response.ilink_bot_id); - const ownerUserId = cleanString6(response.ilink_user_id); + const token = cleanString8(response.bot_token); + const accountId = cleanString8(response.ilink_bot_id); + const ownerUserId = cleanString8(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"); } @@ -7127,7 +8466,7 @@ var WeixinController = class { } this.#touch(); } - if (!record.controller.signal.aborted && Date.now() >= record.expiresAt && !TERMINAL_ATTEMPT_STATES2.has(record.state)) { + if (!record.controller.signal.aborted && Date.now() >= record.expiresAt && !TERMINAL_ATTEMPT_STATES3.has(record.state)) { record.state = "expired"; record.error = safeAccountError("expired", "\u4E8C\u7EF4\u7801\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u751F\u6210\u3002"); } @@ -7213,7 +8552,7 @@ var WeixinController = class { } async #resolveToken(ref) { const result = await this.#credentials.resolve(ref).catch(() => void 0); - return cleanString6(result?.value); + return cleanString8(result?.value); } async #restoreCredential(ref, previous) { try { @@ -7237,7 +8576,7 @@ var WeixinController = class { } #pruneAttempts() { for (const [id, record] of this.#attempts) { - if (id !== this.#activeAttemptId && TERMINAL_ATTEMPT_STATES2.has(record.state) && this.#attempts.size > 16) { + if (id !== this.#activeAttemptId && TERMINAL_ATTEMPT_STATES3.has(record.state) && this.#attempts.size > 16) { this.#attempts.delete(id); } } @@ -7248,7 +8587,7 @@ var WeixinController = class { }; // src/channels/weixin/weixin-bridge.mjs -var HELP_TEXT3 = [ +var HELP_TEXT4 = [ "\u5FAE\u4FE1\u5DF2\u8FDE\u63A5 DeepSeek Harness\u3002", "", "\u76F4\u63A5\u53D1\u9001\u6587\u5B57\u6216\u5E26\u6587\u5B57\u8BC6\u522B\u7ED3\u679C\u7684\u8BED\u97F3\u5373\u53EF\u7EE7\u7EED\u5F53\u524D\u4F1A\u8BDD\u3002", @@ -7256,7 +8595,7 @@ var HELP_TEXT3 = [ "/status \u68C0\u67E5\u8FDE\u63A5\u72B6\u6001", "/help \u663E\u793A\u672C\u5E2E\u52A9" ].join("\n"); -function conversationKey3(userId) { +function conversationKey4(userId) { return `p2p:${userId}`; } function createWeixinBridgeStatus() { @@ -7347,7 +8686,7 @@ var WeixinHarnessBridge = class { } const command = text.trim().toLowerCase(); if (command === "/help") { - await this.#send(sender, HELP_TEXT3, contextToken, runId); + await this.#send(sender, HELP_TEXT4, contextToken, runId); await this.#state.markSeen(messageId); return; } @@ -7358,12 +8697,12 @@ var WeixinHarnessBridge = class { return; } if (command === "/new") { - await this.#state.clearSession(conversationKey3(sender)); + await this.#state.clearSession(conversationKey4(sender)); await this.#send(sender, "\u5DF2\u5F00\u542F\u65B0\u4F1A\u8BDD\u3002\u8BF7\u53D1\u9001\u4F60\u7684\u95EE\u9898\u3002", contextToken, runId); await this.#state.markSeen(messageId); return; } - const key = conversationKey3(sender); + const key = conversationKey4(sender); let sessionId = this.#state.sessionFor(key); if (!sessionId || !await this.#harness.sessionExists(sessionId)) { sessionId = await this.#harness.createSession(); @@ -7402,14 +8741,14 @@ var WeixinHarnessBridge = class { // src/channels/weixin/weixin-runtime.mjs function delay(ms, signal) { - return new Promise((resolve4, reject) => { + return new Promise((resolve5, reject) => { if (signal?.aborted) { reject(signal.reason ?? new DOMException("Aborted", "AbortError")); return; } const finish = () => { signal?.removeEventListener("abort", onAbort); - resolve4(); + resolve5(); }; const timer = setTimeout(finish, ms); const onAbort = () => { @@ -7594,13 +8933,13 @@ var WeixinRuntime = class { }; // plugin-src/host/channels/weixin/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]; +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_MS3]; + return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS4]; } -var ConnectionSupervisor3 = class { +var ConnectionSupervisor4 = class { #controller; #harness; #logger; @@ -7633,12 +8972,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((resolve4) => { - this.#resolveReady = resolve4; + this.#ready = new Promise((resolve5) => { + this.#resolveReady = resolve5; }); } get ready() { @@ -7706,12 +9045,12 @@ var ConnectionSupervisor3 = class { } } }; -function createConnectionSupervisor3(options) { - return new ConnectionSupervisor3(options); +function createConnectionSupervisor4(options) { + return new ConnectionSupervisor4(options); } // plugin-src/host/channels/weixin/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) { @@ -7719,16 +9058,16 @@ 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-weixin")); +function pluginPaths4(config) { + const dshHome = resolve4(config.dshHome ?? process.env.DSH_HOME ?? join4(homedir4(), ".dsh")); + const root = resolve4(config.dataDir ?? join4(dshHome, "integrations", "dsh-weixin")); return { root, - config: resolve3(config.configPath ?? join3(root, "config.json")), - accounts: resolve3(config.accountsDir ?? join3(root, "accounts")) + config: resolve4(config.configPath ?? join4(root, "config.json")), + accounts: resolve4(config.accountsDir ?? join4(root, "accounts")) }; } -async function createProductionController3(ctx, config = {}, internals = {}) { +async function createProductionController4(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; @@ -7737,12 +9076,12 @@ async function createProductionController3(ctx, config = {}, internals = {}) { const Controller = internals.Controller ?? WeixinController; const Runtime = internals.Runtime ?? WeixinRuntime; const api = internals.api ?? createWeixinApi(); - const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor3; + const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor4; const logger = typeof ctx.logger === "function" ? ctx.logger("dsh-weixin") : ctx.logger ?? console; - const paths = pluginPaths3(config); + const paths = pluginPaths4(config); const configStore = await new ConfigStore(paths.config).load(); const stateStores = /* @__PURE__ */ new Map(); - const statePath = (botId) => resolve3(paths.accounts, botId, "state.json"); + const statePath = (botId) => resolve4(paths.accounts, botId, "state.json"); const stateFor = async (botId) => { let state = stateStores.get(botId); if (!state) { @@ -7752,8 +9091,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" @@ -7789,7 +9128,7 @@ async function createProductionController3(ctx, config = {}, internals = {}) { return; } try { - await unlink8(statePath(botId)); + await unlink11(statePath(botId)); } catch (error) { if (error?.code !== "ENOENT") throw error; } @@ -7814,7 +9153,7 @@ async function createProductionController3(ctx, config = {}, internals = {}) { } // plugin-src/host/channels/weixin/rpc.mjs -import QRCode3 from "qrcode"; +import QRCode4 from "qrcode"; var WEIXIN_RPC_CHANNEL = "/weixin"; var WEIXIN_ENDPOINTS = Object.freeze({ status: "connection.status", @@ -7826,34 +9165,34 @@ var WEIXIN_ENDPOINTS = Object.freeze({ deleteBot: "bot.delete" }); var WEIXIN_RPC_ENDPOINTS = Object.freeze(Object.values(WEIXIN_ENDPOINTS)); -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 payloadFailure2(endpoint, payload) { - if (!isRecord2(payload)) return "Payload must be an object."; +function payloadFailure3(endpoint, payload) { + if (!isRecord3(payload)) return "Payload must be an object."; if (endpoint === WEIXIN_ENDPOINTS.status) { - return exactKeys2(payload, []) ? null : "connection.status does not accept fields."; + return exactKeys3(payload, []) ? null : "connection.status does not accept fields."; } if (endpoint === WEIXIN_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 ([WEIXIN_ENDPOINTS.pollProvisioning, WEIXIN_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 === WEIXIN_ENDPOINTS.submitVerification) { - return exactKeys2(payload, ["attemptId", "verifyCode"]) && validId2(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 exactKeys3(payload, ["attemptId", "verifyCode"]) && validId3(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 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 === WEIXIN_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 Weixin endpoint."; } @@ -7869,24 +9208,24 @@ function internalFailure3() { error: { code: "weixin-operation-failed", message: "\u5FAE\u4FE1\u64CD\u4F5C\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002" } }; } -async function qrDataUrl2(value) { - return QRCode3.toDataURL(value, { +async function qrDataUrl3(value) { + return QRCode4.toDataURL(value, { type: "image/png", errorCorrectionLevel: "M", margin: 2, width: 320 }); } -async function withEncodedQr2(value, encodeQr) { +async function withEncodedQr3(value, encodeQr) { if (!value || !value.verificationUrl) return value; return { ...value, qrCodeDataUrl: await encodeQr(value.verificationUrl) }; } -async function publicStatus2(status, encodeQr) { +async function publicStatus3(status, encodeQr) { const safe = structuredClone(status); - if (safe.provisioning) safe.provisioning = await withEncodedQr2(safe.provisioning, encodeQr); + if (safe.provisioning) safe.provisioning = await withEncodedQr3(safe.provisioning, encodeQr); return safe; } function assertController3(controller) { @@ -7894,7 +9233,7 @@ function assertController3(controller) { throw new TypeError("A complete Weixin controller is required"); } } -function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) { +function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl3 } = {}) { assertController3(controller); const qrCache = /* @__PURE__ */ new Map(); const cachedEncode = (url) => { @@ -7909,25 +9248,25 @@ function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) { return async (endpoint, payload, signal) => { if (signal?.aborted) return cancelled3(); if (!WEIXIN_RPC_ENDPOINTS.includes(endpoint)) return badRequest3("Unknown Weixin endpoint."); - const invalid = payloadFailure2(endpoint, payload); + const invalid = payloadFailure3(endpoint, payload); if (invalid) return badRequest3(invalid); try { let value; if (endpoint === WEIXIN_ENDPOINTS.status) { - value = await publicStatus2(await controller.status(), cachedEncode); + value = await publicStatus3(await controller.status(), cachedEncode); } else if (endpoint === WEIXIN_ENDPOINTS.beginProvisioning) { const started = await controller.startProvisioning(); if (signal?.aborted) { await controller.cancelProvisioning(started.attemptId); return cancelled3(); } - value = await withEncodedQr2(started, cachedEncode); + value = await withEncodedQr3(started, cachedEncode); } else if (endpoint === WEIXIN_ENDPOINTS.pollProvisioning) { const current = await controller.registrationStatus(payload.attemptId); if (!current) return badRequest3("The provisioning attempt no longer exists."); - value = await withEncodedQr2(current, cachedEncode); + value = await withEncodedQr3(current, cachedEncode); } else if (endpoint === WEIXIN_ENDPOINTS.submitVerification) { - value = await withEncodedQr2( + value = await withEncodedQr3( await controller.submitVerification(payload.attemptId, payload.verifyCode), cachedEncode ); @@ -7935,9 +9274,9 @@ function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl2 } = {}) { value = await controller.cancelProvisioning(payload.attemptId); if (!value) return badRequest3("The provisioning attempt no longer exists."); } else if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) { - value = await publicStatus2(await controller.reconnectBot(payload.botId), cachedEncode); + value = await publicStatus3(await controller.reconnectBot(payload.botId), cachedEncode); } else { - value = await publicStatus2(await controller.deleteBot(payload.botId), cachedEncode); + value = await publicStatus3(await controller.deleteBot(payload.botId), cachedEncode); } return signal?.aborted ? cancelled3() : { ok: true, value }; } catch { @@ -7957,9 +9296,9 @@ function installWeixinRpc(ctx, controller, options) { } // plugin-src/host/channels/weixin/index.mjs -async function apply3(ctx, config = {}) { +async function apply4(ctx, config = {}) { if (config?.controller) return installWeixinRpc(ctx, config.controller, config.rpcOptions); - const production = await createProductionController3(ctx, config, config.internals); + const production = await createProductionController4(ctx, config, config.internals); const disposeRpc = installWeixinRpc(ctx, production.controller, config.rpcOptions); ctx.effect(() => async () => { await production.close(); @@ -7972,8 +9311,9 @@ var name = "dsh-im-host"; var inject = ["connection", "credentials", "webServer"]; function createImHostPlugin(internals = {}) { const startFeishu = internals.applyFeishu ?? apply2; - const startWeixin = internals.applyWeixin ?? apply3; + const startWeixin = internals.applyWeixin ?? apply4; const startDingtalk = internals.applyDingtalk ?? apply; + const startQq = internals.applyQq ?? apply3; return Object.freeze({ name, inject, @@ -7981,14 +9321,15 @@ function createImHostPlugin(internals = {}) { await startFeishu(ctx, config.feishu ?? {}); await startWeixin(ctx, config.weixin ?? {}); await startDingtalk(ctx, config.dingtalk ?? {}); + await startQq(ctx, config.qq ?? {}); } }); } -async function apply4(ctx, config = {}) { +async function apply5(ctx, config = {}) { return createImHostPlugin().apply(ctx, config); } export { - apply4 as apply, + apply5 as apply, createImHostPlugin, inject, name diff --git a/package-lock.json b/package-lock.json index 735c6f8..c38084f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "license": "MIT", "dependencies": { "@larksuiteoapi/node-sdk": "1.73.0", + "@tencent-connect/qqbot-connector": "1.2.0", + "@tencent-connect/qqbot-nodejs": "1.0.4", "dingtalk-stream": "2.1.4", "qrcode": "1.5.4" }, @@ -540,6 +542,42 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, + "node_modules/@tencent-connect/qqbot-connector": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@tencent-connect/qqbot-connector/-/qqbot-connector-1.2.0.tgz", + "integrity": "sha512-FAOUCvgxP4M9UiYbHrtVgJ7BavSlh1CHJPjeEQuTAkP9MZ91lX4yATqv6I/lB9yp15nVq+G2DaGSSJwQTSoSsA==", + "license": "UNLICENSED", + "dependencies": { + "qrcode-terminal": "^0.12" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@tencent-connect/qqbot-nodejs": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tencent-connect/qqbot-nodejs/-/qqbot-nodejs-1.0.4.tgz", + "integrity": "sha512-gU5HySLplczZXMUjM7NtiUACY7YfX9YlI/R9PKzCLMgLmHvwsX9L2sitsrYPMentGUr9b8NLfSaSTsndF77NBA==", + "license": "MIT", + "dependencies": { + "ws": "^8.21.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "mpg123-decoder": "^1.0.3", + "silk-wasm": "^3.7.1" + }, + "peerDependenciesMeta": { + "mpg123-decoder": { + "optional": true + }, + "silk-wasm": { + "optional": true + } + } + }, "node_modules/@types/node": { "version": "26.2.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", @@ -1235,6 +1273,14 @@ "node": ">=10.13.0" } }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", diff --git a/package.json b/package.json index 352a4a3..6505b50 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@xmanrui/dsh-im", "version": "0.1.0", - "description": "通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉等)。", + "description": "通过扫码把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉和QQ)。", "license": "MIT", "type": "module", "repository": { @@ -58,6 +58,8 @@ }, "dependencies": { "@larksuiteoapi/node-sdk": "1.73.0", + "@tencent-connect/qqbot-connector": "1.2.0", + "@tencent-connect/qqbot-nodejs": "1.0.4", "dingtalk-stream": "2.1.4", "qrcode": "1.5.4" }, diff --git a/plugin-src/client/channel-logos.js b/plugin-src/client/channel-logos.js index 02ad399..1b74e3b 100644 --- a/plugin-src/client/channel-logos.js +++ b/plugin-src/client/channel-logos.js @@ -44,3 +44,16 @@ export function DingtalkLogoGlyph({ size } = {}) { d: 'M37.05 22.783c-6.758-5.216-14.378-12.128-22.73-19.538-.655-.585-1.242-.354-1.536.42-1.88 4.973-.058 9.386 2.889 11.932s7.368 4.912 10.058 6.155c.105.049.013.203-.093.163-4.953-2.182-8.397-3.765-13.07-7.368-.497-.388-1.01-.242-1.07.521-.384 4.748 2.657 8.483 6.058 9.745 2.1.781 4.398 1.212 6.53 1.474.109.015.084.178-.027.178-2.747.01-6.058-.654-8.935-1.751-.606-.233-.818.25-.722.633.491 2.008 2.974 5.076 6.926 5.73a12 12 0 0 0 2.228.115c.164 0 .208.089.154.217q-2.685 4.6-2.803 4.797c-.091.152-.036.275.156.275h3.543c.164 0 .264.106.18.246l-4.958 8.196c-.191.328.035.565.395.301s15.212-11.133 15.636-11.448c.195-.142.148-.327-.124-.327h-3.18c-.206 0-.252-.14-.111-.28.14-.141 3.602-3.594 4.837-4.888 1.283-1.35 1.938-3.825-.231-5.498', })); } + +export function QqLogoGlyph({ size } = {}) { + return h('svg', { + ...dimensions(size), + viewBox: '0 0 24 24', + focusable: 'false', + 'aria-hidden': 'true', + 'data-im-channel-logo': 'qq', + }, h('path', { + fill: 'currentColor', + d: 'M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673', + })); +} diff --git a/plugin-src/client/channels/qq/api.js b/plugin-src/client/channels/qq/api.js new file mode 100644 index 0000000..cf091cf --- /dev/null +++ b/plugin-src/client/channels/qq/api.js @@ -0,0 +1,118 @@ +export const QQ_RPC_CHANNEL = '/qq'; + +export const QQ_ENDPOINTS = Object.freeze({ + status: 'connection.status', + beginProvisioning: 'provision.begin', + pollProvisioning: 'provision.poll', + cancelProvisioning: 'provision.cancel', + reconnectBot: 'bot.reconnect', + deleteBot: 'bot.delete', +}); + +const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']); +const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']); +const QR_DATA_URL = /^data:image\/(?:png|webp);base64,[a-z\d+/]+={0,2}$/i; + +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 function unwrapRpcResult(result) { + if (!isRecord(result) || typeof result.ok !== 'boolean') throw new Error('QQ 服务返回了无法识别的响应'); + if (!result.ok) { + const error = new Error(text(result.error?.message, 'QQ 操作失败')); + error.code = text(result.error?.code, 'QQ_RPC_ERROR', 80); + throw error; + } + return result.value; +} + +export function safeQrSource(value) { + return typeof value === 'string' && value.length <= 2 * 1024 * 1024 && QR_DATA_URL.test(value) + ? value : undefined; +} + +export function normalizeProvisioning(value, now = Date.now()) { + const source = isRecord(value?.provisioning) ? value.provisioning : value; + if (!isRecord(source)) throw new Error('QQ 服务没有返回扫码绑定进度'); + const attemptId = id(source.attemptId); + if (!attemptId) throw new Error('QQ 扫码服务没有返回有效的绑定任务'); + const reported = text(source.status, 'failed', 32); + const result = { + attemptId, + status: PROVISION_STATES.has(reported) ? reported : 'failed', + expiresAt: timestamp(source.expiresAt) ?? now + 5 * 60_000, + pollIntervalMs: Math.min(10_000, Math.max(500, Number(source.pollIntervalMs) || 1_000)), + qrRevision: Number.isSafeInteger(source.qrRevision) ? source.qrRevision : 0, + }; + const qrCodeDataUrl = safeQrSource(source.qrCodeDataUrl); + if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl; + if (id(source.botId)) result.botId = id(source.botId); + if (isRecord(source.error)) result.error = { + code: text(source.error.code, 'QQ_PROVISION_FAILED', 80), + message: text(source.error.message, 'QQ 机器人没有接入完成'), + }; + return result; +} + +function 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, 'QQ机器人', 100), + appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140), + }, + health: { + summary: text(value.health?.summary, connected ? 'QQ WebSocket 长连接运行正常' : 'QQ 连接尚未就绪'), + lastCheckedAt: timestamp(value.health?.lastCheckedAt), + }, + error: isRecord(value.error) ? { + code: text(value.error.code, 'QQ_ACCOUNT_ERROR', 80), + message: text(value.error.message, 'QQ 连接尚未就绪'), + } : null, + }; +} + +export function normalizeSnapshot(value) { + const source = isRecord(value?.snapshot) ? value.snapshot : value; + if (!isRecord(source) || !Array.isArray(source.bots)) throw new Error('QQ 服务没有返回有效的机器人列表'); + 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 }, + provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null, + }; +} + +export function presentError(error) { + return { + code: text(error?.code, 'QQ_ERROR', 80), + message: text(error?.message, 'QQ 操作失败,请稍后重试'), + }; +} + +export function formatRemaining(milliseconds) { + const seconds = Math.max(0, Math.ceil(Number(milliseconds) / 1_000) || 0); + return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; +} diff --git a/plugin-src/client/channels/qq/index.js b/plugin-src/client/channels/qq/index.js new file mode 100644 index 0000000..50381b6 --- /dev/null +++ b/plugin-src/client/channels/qq/index.js @@ -0,0 +1,344 @@ +import * as React from 'react'; + +import { QqLogoGlyph } from '../../channel-logos.js'; +import { installDingtalkStyles } from '../dingtalk/styles.js'; +import { + QQ_ENDPOINTS, + QQ_RPC_CHANNEL, + formatRemaining, + normalizeProvisioning, + normalizeSnapshot, + presentError, + safeQrSource, + unwrapRpcResult, +} from './api.js'; +import { installQqStyles } from './styles.js'; + +const h = React.createElement; +const ACTIVE_STATES = new Set(['pending', 'refreshing', 'connecting']); + +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 '刚刚'; + } +} + +function Heading({ totals, adding, busy, onAdd, addButtonRef }) { + return h('div', { className: 'ddt-heading' }, + h('div', { className: 'ddt-tools' }, + h(Button, { + kind: 'primary', + className: 'dim-scanButton', + onClick: onAdd, + disabled: adding || busy, + ref: addButtonRef, + }, adding ? '正在绑定' : '扫码绑定QQ机器人'), + totals.configured > 0 + ? h('div', { className: 'ddt-badge dim-onlineBadge' }, + h('span', null, `${totals.connected} / ${totals.configured} 在线`)) + : null)); +} + +function LoadingView() { + return h('div', { className: 'ddt-card ddt-loading dim-surfaceCard dim-loadingView', 'aria-busy': 'true' }, + h('div', { className: 'ddt-spinner dim-spinner' }), + h('span', null, '正在读取 QQ 机器人状态…')); +} + +function EmptyView({ busy, onStart }) { + return 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, '尚未绑定 QQ 机器人')), + h('h3', null, '使用手机 QQ 扫码创建并绑定机器人'), + h('p', null, '扫码由腾讯官方页面完成,不需要手动填写 AppID 或 AppSecret。扫码成功后,机器人会自动连接 DeepSeek Harness。'), + h('div', { className: 'ddt-actions dim-viewActions' }, + h(Button, { kind: 'primary', onClick: onStart, disabled: busy }, + busy ? '正在生成二维码…' : '生成 QQ 二维码'))), + h('div', { className: 'ddt-brandMark dim-emptyBrand dqq-brand', 'aria-hidden': 'true' }, + h(QqLogoGlyph, { size: 64 })))); +} + +function QrPanel({ provision, now, busy, onRefresh, onCancel }) { + const source = safeQrSource(provision.qrCodeDataUrl); + const remaining = Math.max(0, provision.expiresAt - now); + const duration = Math.max(1, provision.durationMs ?? 5 * 60_000); + const progress = Math.round(Math.min(1, remaining / duration) * 100); + const refreshing = provision.status === 'refreshing'; + return h('div', { className: 'ddt-card dim-surfaceCard' }, + h('div', { className: 'ddt-cardBody ddt-qrLayout dim-surfaceBody dim-qrLayout' }, + h('div', { className: 'ddt-qrColumn dim-qrColumn' }, + h('div', { className: 'ddt-qrFrame dim-qrFrame' }, + source ? h('img', { src: source, alt: '用于绑定 QQ 机器人的一次性二维码' }) + : h('div', { className: 'ddt-qrFallback dim-qrFallback' }, + refreshing ? '二维码正在自动刷新…' : '二维码图片正在生成…')), + h('div', { className: 'ddt-countdown dim-countdown' }, + h('div', { className: 'ddt-countdownTop dim-countdownTop' }, + h('span', null, '当前二维码有效时间'), + h('strong', null, refreshing ? '--:--' : formatRemaining(remaining))), + h('div', { className: 'ddt-progress dim-progress', style: { '--ddt-progress': `${progress}%` } }, h('span')))), + h('div', { className: 'ddt-qrCopy dim-qrCopy' }, + h('div', { className: 'ddt-stateLabel dim-stateLabel' }, + h('span', { className: 'ddt-dot dim-stateDot', 'data-tone': 'warning' }), + h('span', null, refreshing ? '正在刷新二维码' : '等待手机 QQ 扫码')), + h('h3', null, '使用手机 QQ 完成机器人绑定'), + h('p', null, '腾讯页面会创建或绑定一个 QQ 机器人,并把连接凭据安全交给本机 Harness Host。'), + h('ol', { className: 'ddt-steps dim-steps' }, + h('li', null, '打开手机 QQ,扫描左侧二维码'), + h('li', null, '在腾讯授权页面确认创建或绑定机器人'), + h('li', null, '返回这里等待连接完成')), + h('div', { className: 'ddt-actions dim-viewActions' }, + h(Button, { onClick: onRefresh, disabled: busy }, '重新生成二维码'), + h(Button, { kind: 'quiet', onClick: onCancel, disabled: busy }, '取消'))))); +} + +function ProvisionView({ provision, busy, onRetry, onClose }) { + if (provision.status === 'connecting') { + return h('div', { className: 'ddt-card ddt-loading dim-surfaceCard dim-specialView', 'aria-busy': 'true' }, + h('div', { className: 'ddt-spinner dim-spinner' }), + h('h3', null, 'QQ 已授权,正在连接机器人'), + h('p', null, '凭据正在写入本机,并启动 QQ WebSocket 消息连接。')); + } + const error = provision.error ?? { code: 'QQ_PROVISION_FAILED', message: 'QQ 机器人没有绑定完成' }; + return h('div', { className: 'ddt-card dim-surfaceCard' }, + h('div', { className: 'ddt-inlineError dim-inlineError', role: 'alert' }, + h('h3', null, 'QQ 机器人没有绑定完成'), + h('p', null, error.message), + h('span', { className: 'ddt-errorCode' }, error.code), + h('div', { className: 'ddt-actions dim-viewActions' }, + h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'), + h(Button, { onClick: onClose, disabled: busy }, '关闭')))); +} + +function RemoveConfirmation({ account, busy, onConfirm, onCancel }) { + return h('div', { className: 'ddt-confirm dim-confirm', role: 'alertdialog' }, + h('strong', null, `从 DeepSeek Harness 移除“${account.bot.name}”?`), + h('p', null, '这会停止消息连接,并删除本机保存的应用凭据、机器人配置及会话映射。腾讯平台中的机器人不会被自动删除。'), + h('div', { className: 'ddt-actions dim-viewActions' }, + h(Button, { onClick: onCancel, disabled: busy }, '保留机器人'), + h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy }, busy ? '正在移除…' : '确认移除接入'))); +} + +export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) { + const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning'; + const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪'; + 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 dqq-avatar', 'aria-hidden': 'true' }, h(QqLogoGlyph, { size: 29 })), + h('div', { className: 'dim-botName' }, + h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))), + 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 ? 'WebSocket 长连接' : '离线')), + h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))), + h('div', { className: 'ddt-accountFooter dim-cardFooter' }, + h('div', { className: 'ddt-summary dim-cardSummary' }, account.error?.message ?? account.health.summary), + 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(RemoveConfirmation, { + account, busy: busy === 'delete', onConfirm: onConfirmRemove, onCancel: onCancelRemove, + }) : null); +} + +export function QqSettingsTab({ rpcCall }) { + const [model, setModel] = React.useState({ phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null }); + const [provision, setProvision] = React.useState(null); + const [busy, setBusy] = React.useState(false); + const [busyByBot, setBusyByBot] = React.useState({}); + const [removeTarget, setRemoveTarget] = React.useState(null); + const [now, setNow] = React.useState(Date.now()); + const mounted = React.useRef(true); + const addButtonRef = React.useRef(null); + + React.useEffect(() => { + const disposeDingtalk = installDingtalkStyles(); + const disposeQq = installQqStyles(); + mounted.current = true; + return () => { + mounted.current = false; + disposeQq(); + disposeDingtalk(); + }; + }, []); + + const invoke = React.useCallback(async (endpoint, payload = {}, signal) => { + if (typeof rpcCall !== 'function') throw new TypeError('QQ 设置页缺少 RPC 连接'); + return unwrapRpcResult(await rpcCall(endpoint, payload, signal)); + }, [rpcCall]); + + const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => { + if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null })); + try { + const snapshot = normalizeSnapshot(await invoke(QQ_ENDPOINTS.status, {}, signal)); + if (!mounted.current || signal?.aborted) return undefined; + 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: presentError(error) })); + } + return undefined; + } + }, [invoke]); + + React.useEffect(() => { + const controller = new AbortController(); + void loadStatus({ signal: controller.signal, restore: true }); + 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]); + + React.useEffect(() => { + if (!provision || !ACTIVE_STATES.has(provision.status)) return undefined; + const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1_000); + return () => window.clearInterval(timer); + }, [provision?.attemptId, provision?.status]); + + const startProvisioning = React.useCallback(async (replace = false) => { + setBusy(true); + try { + if (replace && provision?.attemptId) await invoke(QQ_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId }); + if (!mounted.current) return; + setProvision({ status: 'starting' }); + const started = normalizeProvisioning(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: presentError(error) }); + } finally { + if (mounted.current) setBusy(false); + } + }, [invoke, provision?.attemptId]); + + const closeProvision = React.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]); + + React.useEffect(() => { + const attemptId = provision?.attemptId; + if (!attemptId || !ACTIVE_STATES.has(provision.status)) return undefined; + const controller = new AbortController(); + let disposed = false; + let timer; + const poll = async () => { + try { + const current = normalizeProvisioning(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: presentError(error) })); + } + } + }; + timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1_000); + return () => { disposed = true; controller.abort(); window.clearTimeout(timer); }; + }, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]); + + const botAction = React.useCallback(async (account, operation, endpoint, payload) => { + setBusyByBot((current) => ({ ...current, [account.botId]: operation })); + try { + const snapshot = normalizeSnapshot(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 = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请 QQ 二维码…'); + else if (['pending', 'refreshing'].includes(provision?.status)) provisionView = h(QrPanel, { + provision, now, busy, onRefresh: () => void startProvisioning(true), onCancel: () => void closeProvision(), + }); + else if (provision) provisionView = h(ProvisionView, { + provision, busy, onRetry: () => void startProvisioning(true), onClose: () => void closeProvision(), + }); + + const botList = model.bots.length > 0 + ? h('section', { className: 'dim-listSection' }, + h('div', { className: 'ddt-listHeading dim-listHeading' }, + h('h3', null, '已绑定的 QQ 机器人'), h('span', null, `${model.bots.length} 个`)), + 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', 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; + + return h('section', { className: 'ddt-page dqq-page dim-channelPage', 'aria-label': 'QQ 设置' }, + h(Heading, { totals: model.totals, adding: Boolean(provision), busy, onAdd: () => void startProvisioning(), addButtonRef }), + model.phase === 'loading' ? h(LoadingView) + : model.phase === 'error' + ? h('div', { className: 'ddt-card dim-surfaceCard' }, h('div', { className: 'ddt-inlineError dim-inlineError' }, h('h3', null, '无法读取 QQ 机器人状态'), h('p', null, model.error?.message), h(Button, { onClick: () => void loadStatus() }, '重新读取'))) + : h(React.Fragment, null, + provisionView, + model.bots.length === 0 && !provision ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null, + botList)); +} + +export function apply(ctx) { + ctx.effect(() => installQqStyles(), 'qq-settings: install client styles'); + const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(QQ_RPC_CHANNEL, endpoint, payload, signal); + ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ + name: 'settings.plugins.tab', id: 'qq', order: 50, label: 'QQ', inject: () => ({ rpcCall }), + }, QqSettingsTab)); +} diff --git a/plugin-src/client/channels/qq/styles.js b/plugin-src/client/channels/qq/styles.js new file mode 100644 index 0000000..6131c08 --- /dev/null +++ b/plugin-src/client/channels/qq/styles.js @@ -0,0 +1,19 @@ +export const QQ_STYLE_ID = 'xmanrui-dsh-im-qq-settings'; + +const CSS = 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; } +`; + +export 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 = CSS; + document.head.appendChild(style); + return () => style.remove(); +} diff --git a/plugin-src/client/index.js b/plugin-src/client/index.js index 4d1cfd0..4ec08a3 100644 --- a/plugin-src/client/index.js +++ b/plugin-src/client/index.js @@ -1,11 +1,14 @@ import * as React from 'react'; -import { DingtalkLogoGlyph, FeishuLogoGlyph, WeixinLogoGlyph } from './channel-logos.js'; +import { DingtalkLogoGlyph, FeishuLogoGlyph, QqLogoGlyph, WeixinLogoGlyph } from './channel-logos.js'; import { DINGTALK_RPC_CHANNEL } from './channels/dingtalk/api.js'; import { DingtalkSettingsTab } from './channels/dingtalk/index.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 { WeixinSettingsTab } from './channels/weixin/index.js'; import { WEIXIN_RPC_CHANNEL } from './channels/weixin/api.js'; import { installWeixinStyles } from './channels/weixin/styles.js'; @@ -20,6 +23,7 @@ const CHANNELS = Object.freeze([ { id: 'weixin', label: '微信' }, { id: 'feishu', label: '飞书' }, { id: 'dingtalk', label: '钉钉' }, + { id: 'qq', label: 'QQ' }, ]); function WeixinLogo() { @@ -37,13 +41,18 @@ function DingtalkLogo() { h(DingtalkLogoGlyph)); } +function QqLogo() { + return h('span', { className: 'dim-logo dim-logoQq', 'aria-hidden': 'true' }, h(QqLogoGlyph)); +} + function ChannelLogo({ channel }) { if (channel === 'weixin') return h(WeixinLogo); if (channel === 'feishu') return h(FeishuLogo); - return h(DingtalkLogo); + if (channel === 'dingtalk') return h(DingtalkLogo); + return h(QqLogo); } -export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, weixinRpcCall }) { +export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, 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机器人设置' }, @@ -76,14 +85,16 @@ export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, weixinRpcCall }) ? h(WeixinSettingsTab, { rpcCall: weixinRpcCall }) : active.id === 'feishu' ? h(FeishuSettingsTab, { rpcCall: feishuRpcCall }) - : h(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall })), + : active.id === 'dingtalk' + ? h(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall }) + : h(QqSettingsTab, { rpcCall: qqRpcCall })), ), ); } export function apply(ctx) { ctx.effect(() => { - const disposers = [installFeishuStyles(), installWeixinStyles(), installImStyles()]; + const disposers = [installFeishuStyles(), installWeixinStyles(), installQqStyles(), installImStyles()]; return () => { for (const dispose of disposers.reverse()) dispose(); }; @@ -95,12 +106,14 @@ export function apply(ctx) { ctx.connection.rpc.call(WEIXIN_RPC_CHANNEL, endpoint, payload, signal); 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); ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ name: 'settings.plugins.tab', id: 'im', order: 20, label: 'IM机器人', - inject: () => ({ dingtalkRpcCall, feishuRpcCall, weixinRpcCall }), + inject: () => ({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, weixinRpcCall }), }, IMSettingsTab)); } diff --git a/plugin-src/client/styles.js b/plugin-src/client/styles.js index b6b0bed..75762b5 100644 --- a/plugin-src/client/styles.js +++ b/plugin-src/client/styles.js @@ -29,11 +29,13 @@ const CSS = String.raw` .dim-logoFeishu svg { width: 22px; height: 22px; } .dim-logoDingtalk { color: white; background: #1677ff; } .dim-logoDingtalk svg { width: 24px; height: 24px; } +.dim-logoQq { color: white; background: #1677ff; } +.dim-logoQq 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); } .dim-panel { min-width: 0; container-type: inline-size; } -.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page { width: 100%; max-width: none; padding: 0 0 24px; } +.dim-panel .bxf-page, .dim-panel .dxw-page, .dim-panel .ddt-page, .dim-panel .dqq-page { width: 100%; max-width: none; padding: 0 0 24px; } .dim-panel .bxf-heading, .dim-panel .dxw-heading, .dim-panel .ddt-heading { justify-content: flex-end; } .dim-panel .bxf-headingTools, .dim-panel .dxw-tools, .dim-panel .ddt-tools { width: 100%; justify-content: space-between; } .dim-panel .bxf-headingTools .dim-scanButton, .dim-panel .dxw-tools .dim-scanButton, .dim-panel .ddt-tools .dim-scanButton { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid #1677ff; border-radius: 8px; color: #fff; background: #1677ff; box-shadow: none; font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; } diff --git a/plugin-src/host/channels/qq/connection-supervisor.mjs b/plugin-src/host/channels/qq/connection-supervisor.mjs new file mode 100644 index 0000000..f63b8b8 --- /dev/null +++ b/plugin-src/host/channels/qq/connection-supervisor.mjs @@ -0,0 +1,124 @@ +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 ConnectionSupervisor { + #controller; + #harness; + #logger; + #retryDelays; + #healthyIntervalMs; + #setTimeout; + #clearTimeout; + #timer = null; + #running = null; + #retryIndex = 0; + #closed = false; + #started = false; + #ready; + #resolveReady; + + constructor({ + controller, + harness, + logger = console, + retryDelaysMs, + healthyIntervalMs = 15_000, + setTimeoutImpl = setTimeout, + clearTimeoutImpl = clearTimeout, + }) { + if (!controller || typeof controller.initialize !== 'function' || typeof controller.status !== 'function') { + throw new TypeError('ConnectionSupervisor requires a controller'); + } + if (!harness || typeof harness.ensureRunning !== 'function') { + throw new TypeError('ConnectionSupervisor requires a Harness client'); + } + 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:qq] ${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:qq] connection reconciliation failed; retrying in ${delay}ms`, error); + this.#schedule(delay); + } + } +} + +export function createConnectionSupervisor(options) { + return new ConnectionSupervisor(options); +} diff --git a/plugin-src/host/channels/qq/index.mjs b/plugin-src/host/channels/qq/index.mjs new file mode 100644 index 0000000..69adbf1 --- /dev/null +++ b/plugin-src/host/channels/qq/index.mjs @@ -0,0 +1,23 @@ +import { createProductionController } from './production.mjs'; +import { installQqRpc } from './rpc.mjs'; + +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); + const production = await createProductionController(ctx, config, config.internals); + const disposeRpc = installQqRpc(ctx, production.controller, config.rpcOptions); + ctx.effect(() => async () => production.close(), 'dsh-im: close QQ bot connections'); + return disposeRpc; +} + +export function createQqHostPlugin(config) { + return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) }); +} + +export { createConnectionSupervisor, ConnectionSupervisor } from './connection-supervisor.mjs'; +export { createProductionController } from './production.mjs'; +export { QQ_ENDPOINTS, QQ_RPC_CHANNEL, QQ_RPC_ENDPOINTS, createQqRpcHandler, installQqRpc } from './rpc.mjs'; +export { QqController } from '../../../../src/channels/qq/qq-controller.mjs'; +export { QqRuntime } from '../../../../src/channels/qq/qq-runtime.mjs'; diff --git a/plugin-src/host/channels/qq/production.mjs b/plugin-src/host/channels/qq/production.mjs new file mode 100644 index 0000000..869a40c --- /dev/null +++ b/plugin-src/host/channels/qq/production.mjs @@ -0,0 +1,109 @@ +import { unlink } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { QqConfigStore } from '../../../../src/channels/qq/config-store.mjs'; +import { QqHarnessClient } from '../../../../src/channels/qq/harness-client.mjs'; +import { QqController } from '../../../../src/channels/qq/qq-controller.mjs'; +import { QqRuntime } from '../../../../src/channels/qq/qq-runtime.mjs'; +import { QqQrAuth } from '../../../../src/channels/qq/qr-auth.mjs'; +import { QqStateStore } from '../../../../src/channels/qq/state-store.mjs'; +import { createConnectionSupervisor } 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 QQ requires an initialized DSH webServer port'); + } + return new URL(`http://127.0.0.1:${port}`); +} + +function pluginPaths(config) { + const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')); + const root = resolve(config.dataDir ?? join(dshHome, 'integrations', 'dsh-qq')); + return { + config: resolve(config.configPath ?? join(root, 'config.json')), + bots: resolve(config.botsDir ?? join(root, 'bots')), + }; +} + +export async function createProductionController(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; + const StateStore = internals.StateStore ?? QqStateStore; + const Harness = internals.HarnessClient ?? QqHarnessClient; + const Controller = internals.Controller ?? QqController; + const Runtime = internals.Runtime ?? QqRuntime; + const QrAuth = internals.QrAuth ?? QqQrAuth; + const createSupervisor = internals.createConnectionSupervisor ?? createConnectionSupervisor; + const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-im:qq') : (ctx.logger ?? console); + const paths = pluginPaths(config); + const configStore = await new ConfigStore(paths.config).load(); + const qrAuth = internals.qrAuth ?? new QrAuth({ source: config.qrSource ?? 'deepseek-harness' }); + 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 StateStore(statePath(botId)).load(); + stateStores.set(botId, state); + } + return state; + }; + const harness = new Harness({ + 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 Controller({ + qrAuth, + credentials: ctx.credentials, + configStore, + logger, + createRuntime: async ({ botId, config: botConfig, appSecret }) => new Runtime({ + config: botConfig, + appSecret, + 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({ + 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/qq/rpc.mjs b/plugin-src/host/channels/qq/rpc.mjs new file mode 100644 index 0000000..c2212e4 --- /dev/null +++ b/plugin-src/host/channels/qq/rpc.mjs @@ -0,0 +1,128 @@ +import QRCode from 'qrcode'; + +export const QQ_RPC_CHANNEL = '/qq'; +export const QQ_ENDPOINTS = Object.freeze({ + status: 'connection.status', + beginProvisioning: 'provision.begin', + pollProvisioning: 'provision.poll', + cancelProvisioning: 'provision.cancel', + reconnectBot: 'bot.reconnect', + deleteBot: 'bot.delete', +}); +export const QQ_RPC_ENDPOINTS = Object.freeze(Object.values(QQ_ENDPOINTS)); + +const FORBIDDEN_PUBLIC_KEYS = new Set([ + 'appSecret', 'app_secret', 'secretRef', 'ownerUserOpenid', 'userOpenid', 'verificationUrl', +]); + +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 payloadFailure(endpoint, payload) { + if (!isRecord(payload)) return 'Payload must be an object.'; + if (endpoint === QQ_ENDPOINTS.status) return exactKeys(payload, []) ? null : 'connection.status does not accept fields.'; + if (endpoint === QQ_ENDPOINTS.beginProvisioning) { + return exactKeys(payload, ['locale']) && (payload.locale === undefined || payload.locale === 'zh-CN') + ? null : 'provision.begin received unsupported fields.'; + } + if ([QQ_ENDPOINTS.pollProvisioning, QQ_ENDPOINTS.cancelProvisioning].includes(endpoint)) { + return exactKeys(payload, ['attemptId']) && validId(payload.attemptId) + ? null : `${endpoint} requires an attemptId.`; + } + if (endpoint === QQ_ENDPOINTS.reconnectBot) { + return exactKeys(payload, ['botId']) && validId(payload.botId) ? null : 'bot.reconnect requires a botId.'; + } + if (endpoint === QQ_ENDPOINTS.deleteBot) { + return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true + ? null : 'bot.delete requires a botId and confirm=true.'; + } + return 'Unknown QQ 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; +} + +async function qrDataUrl(value) { + return QRCode.toDataURL(value, { + type: 'image/png', errorCorrectionLevel: 'M', margin: 2, width: 320, + }); +} + +async function withEncodedQr(value, encodeQr) { + if (!value || typeof value.verificationUrl !== 'string') return sanitizePublic(value); + return sanitizePublic({ ...value, qrCodeDataUrl: await encodeQr(value.verificationUrl) }); +} + +async function publicStatus(status, encodeQr) { + const value = structuredClone(status); + if (value?.provisioning) value.provisioning = await withEncodedQr(value.provisioning, encodeQr); + return sanitizePublic(value); +} + +export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) { + for (const method of ['status', 'startProvisioning', 'registrationStatus', 'cancelProvisioning', 'reconnectBot', 'deleteBot']) { + if (typeof controller?.[method] !== 'function') throw new TypeError(`A complete QQ controller is required (${method})`); + } + const qrCache = new Map(); + const cachedEncode = (url) => { + let encoded = qrCache.get(url); + if (!encoded) { + if (qrCache.size >= 16) qrCache.delete(qrCache.keys().next().value); + encoded = Promise.resolve().then(() => encodeQr(url)); + qrCache.set(url, encoded); + } + return encoded; + }; + 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 = payloadFailure(endpoint, payload); + if (invalid) return { ok: false, error: { code: 'bad-request', message: invalid } }; + try { + let value; + if (endpoint === QQ_ENDPOINTS.status) value = await publicStatus(await controller.status(), cachedEncode); + else if (endpoint === QQ_ENDPOINTS.beginProvisioning) value = await withEncodedQr(await controller.startProvisioning(), cachedEncode); + else if (endpoint === QQ_ENDPOINTS.pollProvisioning) { + const current = await controller.registrationStatus(payload.attemptId); + if (!current) return { ok: false, error: { code: 'bad-request', message: 'The provisioning attempt no longer exists.' } }; + value = await withEncodedQr(current, cachedEncode); + } else if (endpoint === QQ_ENDPOINTS.cancelProvisioning) { + value = sanitizePublic(await controller.cancelProvisioning(payload.attemptId)); + } else if (endpoint === QQ_ENDPOINTS.reconnectBot) { + value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode); + } else { + value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode); + } + return signal?.aborted + ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } } + : { ok: true, value }; + } catch { + return signal?.aborted + ? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } } + : { ok: false, error: { code: 'qq-operation-failed', message: 'QQ 操作失败,请稍后重试。' } }; + } + }; +} + +export function installQqRpc(ctx, controller, options) { + 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' }); +} diff --git a/plugin-src/host/index.mjs b/plugin-src/host/index.mjs index 3436fb0..5533ea3 100644 --- a/plugin-src/host/index.mjs +++ b/plugin-src/host/index.mjs @@ -1,5 +1,6 @@ import { apply as applyDingtalk } from './channels/dingtalk/index.mjs'; import { apply as applyFeishu } from './channels/feishu/index.mjs'; +import { apply as applyQq } from './channels/qq/index.mjs'; import { apply as applyWeixin } from './channels/weixin/index.mjs'; export const name = 'dsh-im-host'; @@ -9,6 +10,7 @@ export function createImHostPlugin(internals = {}) { const startFeishu = internals.applyFeishu ?? applyFeishu; const startWeixin = internals.applyWeixin ?? applyWeixin; const startDingtalk = internals.applyDingtalk ?? applyDingtalk; + const startQq = internals.applyQq ?? applyQq; return Object.freeze({ name, inject, @@ -16,6 +18,7 @@ export function createImHostPlugin(internals = {}) { await startFeishu(ctx, config.feishu ?? {}); await startWeixin(ctx, config.weixin ?? {}); await startDingtalk(ctx, config.dingtalk ?? {}); + await startQq(ctx, config.qq ?? {}); }, }); } diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 8ac21b8..2503e3e 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -13,9 +13,11 @@ const required = [ 'plugin-src/host/channels/feishu/index.mjs', 'plugin-src/host/channels/weixin/index.mjs', 'plugin-src/host/channels/dingtalk/index.mjs', + 'plugin-src/host/channels/qq/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', ]; await Promise.all(required.map((path) => access(resolve(root, path)))); @@ -47,7 +49,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']) { +for (const marker of ['/feishu', '/weixin', '/dingtalk', '/qq']) { if (!host.includes(marker)) { throw new Error(`host bundle does not contain the internal ${marker} RPC provider`); } @@ -71,6 +73,8 @@ for (const name of ['@xmanrui/dsh-feishu', '@xmanrui/dsh-weixin', '@xmanrui/dsh- const directDependencies = { '@larksuiteoapi/node-sdk': '1.73.0', 'dingtalk-stream': '2.1.4', + '@tencent-connect/qqbot-connector': '1.2.0', + '@tencent-connect/qqbot-nodejs': '1.0.4', qrcode: '1.5.4', }; for (const [name, version] of Object.entries(directDependencies)) { diff --git a/src/channels/qq/config-store.mjs b/src/channels/qq/config-store.mjs new file mode 100644 index 0000000..60fb89b --- /dev/null +++ b/src/channels/qq/config-store.mjs @@ -0,0 +1,171 @@ +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 safeBotId(value) { + const id = cleanString(value); + return id && /^qq_[a-f0-9]{24}$/.test(id) ? id : null; +} + +function safeSecretRef(value) { + const ref = cleanString(value); + return ref && /^DSH_QQBOT_APP_SECRET_[A-F0-9]{24}$/.test(ref) ? ref : null; +} + +export function deriveQqBotIdentity(appId) { + const raw = cleanString(appId); + if (!raw) throw new TypeError('appId is required'); + const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24); + return { + botId: `qq_${digest}`, + secretRef: `DSH_QQBOT_APP_SECRET_${digest.toUpperCase()}`, + }; +} + +export function maskQqAppId(appId) { + const value = cleanString(appId) ?? ''; + if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : 'QQ机器人'; + return `${value.slice(0, 6)}••••${value.slice(-4)}`; +} + +function normalizeBot(value) { + if (!value || typeof value !== 'object') return null; + const appId = cleanString(value.appId); + const ownerUserOpenid = cleanString(value.ownerUserOpenid); + const botId = safeBotId(value.botId); + const secretRef = safeSecretRef(value.secretRef); + if (!appId || !ownerUserOpenid || !botId || !secretRef) return null; + const derived = deriveQqBotIdentity(appId); + if (derived.botId !== botId || derived.secretRef !== secretRef) return null; + return Object.freeze({ + botId, + appId, + secretRef, + ownerUserOpenid, + createdAt: cleanString(value.createdAt) ?? new Date().toISOString(), + connectedAt: cleanString(value.connectedAt), + }); +} + +function normalizeDocument(value) { + if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null; + const bots = value.bots.map(normalizeBot); + if (bots.some((bot) => bot === null)) return null; + const ids = new Set(); + const appIds = new Set(); + const refs = new Set(); + for (const bot of bots) { + if (ids.has(bot.botId) || appIds.has(bot.appId) || refs.has(bot.secretRef)) return null; + ids.add(bot.botId); + appIds.add(bot.appId); + refs.add(bot.secretRef); + } + return Object.freeze({ version: 1, bots: Object.freeze(bots) }); +} + +export class QqConfigStore { + #path; + #value = EMPTY_DOCUMENT; + #writeQueue = Promise.resolve(); + + constructor(path) { + this.#path = path; + } + + async load() { + try { + const normalized = normalizeDocument(JSON.parse(await readFile(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_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; + } + + getByAppId(appId) { + const bot = this.#value.bots.find((candidate) => candidate.appId === appId); + return bot ? structuredClone(bot) : null; + } + + async save(value) { + const normalized = normalizeBot(value); + if (!normalized) throw new Error('Refusing to persist incomplete QQ bot data'); + return this.#mutate((bots) => { + const appCollision = bots.find( + (bot) => bot.appId === normalized.appId && bot.botId !== normalized.botId, + ); + const refCollision = bots.find( + (bot) => bot.secretRef === normalized.secretRef && bot.botId !== normalized.botId, + ); + if (appCollision || refCollision) throw new Error('Duplicate QQ 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 (!safeBotId(botId)) throw new TypeError('Invalid QQ 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; + } + + 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/qq/harness-client.mjs b/src/channels/qq/harness-client.mjs new file mode 100644 index 0000000..33049b1 --- /dev/null +++ b/src/channels/qq/harness-client.mjs @@ -0,0 +1,3 @@ +import { HarnessClient } from '../weixin/harness-client.mjs'; + +export class QqHarnessClient extends HarnessClient {} diff --git a/src/channels/qq/qq-bridge.mjs b/src/channels/qq/qq-bridge.mjs new file mode 100644 index 0000000..a086663 --- /dev/null +++ b/src/channels/qq/qq-bridge.mjs @@ -0,0 +1,177 @@ +const HELP_TEXT = [ + 'QQ 机器人已连接 DeepSeek Harness。', + '', + '直接发送文字即可继续当前会话。', + '/new 开启一个全新会话', + '/status 检查连接状态', + '/help 显示本帮助', +].join('\n'); + +function conversationKey(message) { + return `${message.kind}:${message.kind === 'group' ? message.groupOpenid : message.senderId}`; +} + +function safeText(message) { + return typeof message?.content === 'string' ? message.content.trim() : ''; +} + +export function createQqBridgeStatus() { + return { + messagesReceived: 0, + messagesReplied: 0, + messagesRejected: 0, + lastMessageAt: null, + lastReplyAt: null, + lastRejectedAt: null, + lastError: null, + }; +} + +export class QqHarnessBridge { + #bot; + #ownerUserOpenid; + #harness; + #state; + #status; + #logger; + #replyTimeoutMs; + #queues = new Map(); + + constructor({ + bot, + ownerUserOpenid, + harness, + state, + status = createQqBridgeStatus(), + logger = console, + replyTimeoutMs = 600_000, + }) { + if (!bot || typeof bot.sendText !== 'function') throw new TypeError('QQ bot client is required'); + if (!ownerUserOpenid) throw new TypeError('QQ scanner identity is required'); + if (!harness || !state) throw new TypeError('Harness client and state store are required'); + this.#bot = bot; + this.#ownerUserOpenid = ownerUserOpenid; + this.#harness = harness; + this.#state = state; + this.#status = status; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + } + + get status() { + return structuredClone(this.#status); + } + + accept(message) { + const key = conversationKey(message); + const previous = this.#queues.get(key) ?? Promise.resolve(); + const current = previous + .catch(() => undefined) + .then(() => this.#process(message)) + .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 = typeof message?.messageId === 'string' ? message.messageId : ''; + const sender = typeof message?.senderId === 'string' ? message.senderId : ''; + if (!messageId || !sender || message.senderIsBot === true) return; + if (!['c2c', 'group'].includes(message.kind) || this.#state.hasSeen(messageId)) return; + + this.#status.messagesReceived += 1; + this.#status.lastMessageAt = new Date().toISOString(); + if (sender !== this.#ownerUserOpenid) { + this.#status.messagesRejected += 1; + this.#status.lastRejectedAt = new Date().toISOString(); + return; + } + if (message.kind === 'group' && message.rawEventType !== 'GROUP_AT_MESSAGE_CREATE') return; + + const target = message.replyTarget; + const text = safeText(message); + 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, HELP_TEXT); + await this.#state.markSeen(messageId); + return; + } + if (command === '/status') { + await this.#harness.ensureRunning(); + await this.#bot.sendText(target, 'QQ 机器人与 DeepSeek Harness 连接正常。'); + await this.#state.markSeen(messageId); + return; + } + const key = conversationKey(message); + if (command === '/new') { + await this.#state.clearSession(key); + await this.#bot.sendText(target, '已开启新会话。请发送你的问题。'); + await this.#state.markSeen(messageId); + return; + } + + let sessionId = this.#state.sessionFor(key); + if (!sessionId || !(await this.#harness.sessionExists(sessionId))) { + sessionId = await this.#harness.createSession(); + await this.#state.setSession(key, sessionId); + } + + let stream = null; + let streamFinished = false; + if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') { + try { + stream = this.#bot.openStream({ target }); + } catch (error) { + this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using a text reply:', 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.update(answer); + await stream.complete(); + streamFinished = true; + } catch (error) { + stream.cancel?.(); + this.#logger.warn?.('[dsh-im:qq] QQ stream finalization failed; using a text reply:', 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:qq] failed to process an inbound message:', error); + try { + await this.#bot.sendText(target, '消息处理失败,请稍后重试。'); + await this.#state.markSeen(messageId); + } catch (sendError) { + this.#logger.error?.('[dsh-im:qq] failed to send the safe error reply:', sendError); + } + } + } +} diff --git a/src/channels/qq/qq-controller.mjs b/src/channels/qq/qq-controller.mjs new file mode 100644 index 0000000..0126fdf --- /dev/null +++ b/src/channels/qq/qq-controller.mjs @@ -0,0 +1,407 @@ +import { randomUUID } from 'node:crypto'; + +import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs'; + +const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']); +const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']); +const QR_TTL_MS = 5 * 60_000; + +function cleanString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function safeError(code, message) { + return Object.freeze({ code, message }); +} + +function publicAttempt(record) { + if (!record) return null; + return { + attemptId: record.id, + status: record.state, + qrRevision: record.qrRevision, + pollIntervalMs: 1_000, + ...(record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}), + ...(record.expiresAt ? { expiresAt: record.expiresAt } : {}), + ...(record.botId ? { botId: record.botId } : {}), + ...(record.error ? { error: structuredClone(record.error) } : {}), + }; +} + +export class QqController { + #qrAuth; + #credentials; + #configStore; + #createRuntime; + #deleteState; + #logger; + #runtimes = new Map(); + #errors = new Map(); + #attempts = new Map(); + #activeAttemptId = null; + #transitions = new Map(); + #revision = 0; + #closed = false; + + constructor({ + qrAuth, + credentials, + configStore, + createRuntime, + deleteState = async () => {}, + logger = console, + }) { + if (!qrAuth || typeof qrAuth.start !== 'function') throw new TypeError('QQ QR auth is required'); + if (!credentials || typeof credentials.resolve !== 'function' + || typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') { + throw new TypeError('QqController requires the DSH credential provider'); + } + if (!configStore || typeof configStore.list !== 'function' + || typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') { + throw new TypeError('QqController requires a config store'); + } + if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required'); + this.#qrAuth = qrAuth; + this.#credentials = credentials; + this.#configStore = configStore; + 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 appSecret = await this.#resolveSecret(config.secretRef); + if (!appSecret) { + this.#errors.set(config.botId, safeError('missing-secret', 'QQ 机器人凭据缺失,请移除后重新扫码。')); + return; + } + try { + await this.#startRuntime(config, appSecret); + this.#errors.delete(config.botId); + } catch (error) { + this.#errors.set(config.botId, safeError('connection-failed', 'QQ 连接未就绪,插件会自动重试。')); + this.#logger.warn?.(`[dsh-im:qq] bot ${config.botId} failed to initialize:`, error); + } finally { + this.#touch(); + } + }); + } + return this.status(); + } + + async startProvisioning() { + if (this.#closed) throw new Error('QQ controller is closed'); + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + let firstQrResolve; + let firstQrReject; + const firstQr = new Promise((resolve, reject) => { + firstQrResolve = resolve; + firstQrReject = reject; + }); + const record = { + id: randomUUID(), + state: 'starting', + createdAt: Date.now(), + expiresAt: null, + qrRevision: 0, + verificationUrl: null, + controller: new AbortController(), + dispose: null, + task: null, + error: null, + botId: null, + }; + this.#attempts.set(record.id, record); + this.#activeAttemptId = record.id; + this.#touch(); + + try { + record.dispose = this.#qrAuth.start({ + onQrDisplayed: (url) => { + if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return; + const verificationUrl = cleanString(url); + if (!verificationUrl) return; + record.verificationUrl = verificationUrl; + record.qrRevision += 1; + record.expiresAt = Date.now() + QR_TTL_MS; + record.state = 'pending'; + this.#touch(); + firstQrResolve(); + }, + onQrExpired: () => { + if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return; + record.state = 'refreshing'; + record.verificationUrl = null; + record.expiresAt = null; + this.#touch(); + }, + onSuccess: (credentials) => { + if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return; + record.task = this.#completeProvisioning(record, credentials); + }, + onFailure: (error) => { + if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return; + record.state = 'failed'; + record.error = safeError('qr-connect-failed', 'QQ 扫码服务暂时不可用,请重新生成二维码。'); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + firstQrReject(error); + }, + }, { signal: record.controller.signal }); + await firstQr; + return publicAttempt(record); + } catch (error) { + if (record.controller.signal.aborted) { + record.state = 'cancelled'; + record.error = safeError('cancelled', '扫码绑定已取消。'); + } else if (!TERMINAL_ATTEMPT_STATES.has(record.state)) { + record.state = 'failed'; + record.error = safeError('qr-start-failed', '无法生成 QQ 二维码,请稍后重试。'); + } + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + throw error; + } + } + + registrationStatus(attemptId) { + return publicAttempt(this.#attempts.get(attemptId)); + } + + async cancelProvisioning(attemptId) { + const record = this.#attempts.get(attemptId); + if (!record) return null; + if (!TERMINAL_ATTEMPT_STATES.has(record.state)) { + record.controller.abort(); + record.dispose?.(); + await record.task?.catch(() => undefined); + if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled'; + record.error ??= safeError('cancelled', '扫码绑定已取消。'); + } + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + return publicAttempt(record); + } + + async reconnectBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error('Unknown QQ bot'); + await this.#withBotTransition(botId, async () => { + const secret = await this.#resolveSecret(config.secretRef); + if (!secret) throw new Error('QQ bot secret is missing'); + try { + await this.#startRuntime(config, secret); + this.#errors.delete(botId); + } catch (error) { + this.#errors.set(botId, safeError('connection-failed', 'QQ 连接仍未就绪,请稍后重试。')); + throw error; + } finally { + this.#touch(); + } + }); + return this.status(); + } + + async deleteBot(botId) { + const config = this.#configStore.get(botId); + if (!config) throw new Error('Unknown QQ bot'); + await this.#withBotTransition(botId, async () => { + const previous = await this.#credentials.resolve(config.secretRef).catch(() => undefined); + await this.#stopRuntime(botId); + try { + await this.#credentials.unset(config.secretRef); + await this.#configStore.remove(botId); + } catch (error) { + if (previous?.value) { + await this.#credentials.set(config.secretRef, previous.value).catch(() => undefined); + await this.#startRuntime(config, previous.value).catch(() => undefined); + } + throw new Error('Unable to remove the QQ bot safely.', { cause: error }); + } + await this.#deleteState({ botId, config }).catch((error) => { + this.#logger.warn?.(`[dsh-im:qq] 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.qqConnectionState === 'connected' + && runtimeStatus.harnessReachable === true; + const state = connected ? 'connected' + : runtimeStatus?.qqConnectionState === 'connecting' ? 'connecting' + : this.#errors.has(config.botId) || runtimeStatus?.qqConnectionState === 'failed' + ? 'error' : 'offline'; + return { + botId: config.botId, + state, + connected, + configured: true, + bot: { name: 'QQ机器人', appIdMasked: maskQqAppId(config.appId) }, + health: { + status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline', + summary: connected ? 'QQ WebSocket 长连接运行正常' + : state === 'error' ? 'QQ 连接未就绪,插件会自动重试' : 'QQ 连接当前离线', + 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; + const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null; + return { + schemaVersion: 1, + revision: this.#revision, + state: active && ACTIVE_ATTEMPT_STATES.has(active.state) ? 'provisioning' + : bots.length === 0 ? 'disconnected' + : connectedCount === bots.length ? 'connected' + : connectedCount > 0 ? 'degraded' : 'offline', + bots, + totals: { configured: bots.length, connected: connectedCount }, + ...(active && ACTIVE_ATTEMPT_STATES.has(active.state) + ? { provisioning: publicAttempt(active) } : {}), + }; + } + + async close() { + if (this.#closed) return; + this.#closed = true; + if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId); + await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId))); + } + + async #completeProvisioning(record, credentials) { + try { + record.state = 'connecting'; + record.verificationUrl = null; + record.expiresAt = null; + this.#touch(); + const credential = Array.isArray(credentials) ? credentials[0] : null; + const appId = cleanString(credential?.appId); + const appSecret = cleanString(credential?.appSecret); + const ownerUserOpenid = cleanString(credential?.userOpenid); + if (!appId || !appSecret || !ownerUserOpenid) { + throw new Error('QQ authorization returned incomplete credentials'); + } + record.botId = await this.#activateBot(record, { appId, appSecret, ownerUserOpenid }); + record.state = 'connected'; + record.error = null; + } catch (error) { + if (record.controller.signal.aborted) { + record.state = 'cancelled'; + record.error = safeError('cancelled', '扫码绑定已取消。'); + } else { + record.state = 'failed'; + record.error = safeError('activation-failed', 'QQ 已授权,但无法安全保存接入配置。'); + this.#logger.error?.('[dsh-im:qq] provisioning failed:', error); + } + } finally { + record.dispose?.(); + if (this.#activeAttemptId === record.id) this.#activeAttemptId = null; + this.#touch(); + } + } + + async #activateBot(record, { appId, appSecret, ownerUserOpenid }) { + const identity = deriveQqBotIdentity(appId); + const previousConfig = this.#configStore.getByAppId(appId); + const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined); + const config = { + botId: identity.botId, + appId, + secretRef: identity.secretRef, + ownerUserOpenid, + createdAt: previousConfig?.createdAt ?? new Date().toISOString(), + connectedAt: new Date().toISOString(), + }; + return this.#withBotTransition(identity.botId, async () => { + await this.#credentials.set(identity.secretRef, appSecret); + try { + if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError'); + await this.#configStore.save(config); + } catch (error) { + await this.#restoreCredential(identity.secretRef, previousSecret); + throw error; + } + try { + if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError'); + await this.#startRuntime(config, appSecret); + this.#errors.delete(identity.botId); + } catch (error) { + if (record.controller.signal.aborted) { + await this.#stopRuntime(identity.botId); + if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined); + else await this.#configStore.remove(identity.botId).catch(() => undefined); + await this.#restoreCredential(identity.secretRef, previousSecret); + throw error; + } + this.#errors.set(identity.botId, safeError('connection-failed', 'QQ 机器人已绑定,消息连接暂未就绪。')); + this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} activation connection failed:`, error); + } + this.#touch(); + return identity.botId; + }); + } + + async #startRuntime(config, appSecret) { + await this.#stopRuntime(config.botId); + const runtime = await this.#createRuntime({ botId: config.botId, config, appSecret }); + if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') { + throw new TypeError('createRuntime returned an invalid QQ 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:qq] bot ${botId} failed to stop cleanly:`, error); + }); + } + + async #resolveSecret(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/qq/qq-runtime.mjs b/src/channels/qq/qq-runtime.mjs new file mode 100644 index 0000000..97ef707 --- /dev/null +++ b/src/channels/qq/qq-runtime.mjs @@ -0,0 +1,207 @@ +import { QQBot, typingIndicator } from '@tencent-connect/qqbot-nodejs'; + +import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs'; + +function timeoutError() { + const error = new Error('QQ WebSocket did not become ready in time'); + error.code = 'connect-timeout'; + return error; +} + +export function createQqRuntimeStatus() { + return { + startedAt: null, + ready: false, + qqConnectionState: 'idle', + harnessReachable: false, + lastCheckedAt: null, + lastConnectedAt: null, + lastError: null, + ...createQqBridgeStatus(), + }; +} + +export class QqRuntime { + #config; + #appSecret; + #harness; + #state; + #logger; + #replyTimeoutMs; + #connectTimeoutMs; + #createBot; + #typingMiddleware; + #status = createQqRuntimeStatus(); + #bot = null; + #bridge = null; + #abortController = null; + #runTask = null; + #starting = null; + + constructor({ + config, + appSecret, + harness, + state, + logger = console, + replyTimeoutMs = 600_000, + connectTimeoutMs = 20_000, + createBot = (options) => new QQBot(options), + typingMiddleware = typingIndicator, + }) { + if (!config || !appSecret || !harness || !state) { + throw new TypeError('QqRuntime requires config, app secret, Harness, and state'); + } + this.#config = config; + this.#appSecret = appSecret; + this.#harness = harness; + this.#state = state; + this.#logger = logger; + this.#replyTimeoutMs = replyTimeoutMs; + this.#connectTimeoutMs = connectTimeoutMs; + this.#createBot = createBot; + this.#typingMiddleware = typingMiddleware; + } + + get status() { + return structuredClone(this.#status); + } + + async start() { + if (this.#status.ready && this.#bot) 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.qqConnectionState = 'connecting'; + this.#status.lastError = null; + await this.#harness.ensureRunning(); + this.#status.harnessReachable = true; + + const sdkLogger = { + error: (...args) => this.#logger.error?.(...args), + warn: (...args) => this.#logger.warn?.(...args), + info: (...args) => this.#logger.info?.(...args), + debug: () => {}, + }; + const bot = this.#createBot({ + appId: this.#config.appId, + appSecret: this.#appSecret, + accountId: this.#config.botId, + logger: sdkLogger, + transport: 'websocket', + tokenPrefetch: 'sync', + }); + if (!bot || typeof bot.start !== 'function' || typeof bot.stop !== 'function') { + throw new TypeError('QQ bot factory returned an invalid client'); + } + this.#bot = bot; + this.#bridge = new QqHarnessBridge({ + bot, + ownerUserOpenid: this.#config.ownerUserOpenid, + harness: this.#harness, + state: this.#state, + status: this.#status, + logger: this.#logger, + replyTimeoutMs: this.#replyTimeoutMs, + }); + bot.use?.(this.#typingMiddleware({ + keepAlive: true, + predicate: (ctx) => ctx?.message?.senderId === this.#config.ownerUserOpenid, + })); + + const controller = new AbortController(); + this.#abortController = controller; + let readyResolve; + let readyReject; + const ready = new Promise((resolve, reject) => { + readyResolve = resolve; + readyReject = reject; + }); + const onReady = () => { + const now = Date.now(); + this.#status.ready = true; + this.#status.qqConnectionState = 'connected'; + this.#status.lastCheckedAt = now; + this.#status.lastConnectedAt = now; + this.#status.lastError = null; + readyResolve(); + }; + const onError = (error) => { + if (!this.#status.ready) readyReject(error); + else { + this.#status.lastError = error?.message ?? String(error); + this.#logger.warn?.(`[dsh-im:qq] bot ${this.#config.botId} connection error:`, error); + } + }; + const onMessage = (_ctx, message) => this.#bridge?.accept(message); + bot.on('ready', onReady); + bot.on('resumed', onReady); + bot.on('error', onError); + bot.on('message', onMessage); + + const runTask = Promise.resolve().then(() => bot.start(controller.signal)); + this.#runTask = runTask; + runTask.catch((error) => { + if (controller.signal.aborted) return; + readyReject(error); + this.#status.ready = false; + this.#status.qqConnectionState = 'failed'; + this.#status.lastError = error?.message ?? String(error); + this.#logger.error?.(`[dsh-im:qq] bot ${this.#config.botId} connection stopped:`, error); + }); + + let timer; + try { + await Promise.race([ + ready, + new Promise((_, reject) => { + timer = setTimeout(() => reject(timeoutError()), this.#connectTimeoutMs); + }), + ]); + this.#status.ready = true; + this.#status.qqConnectionState = 'connected'; + this.#status.lastCheckedAt = Date.now(); + this.#status.lastConnectedAt = Date.now(); + return this.status; + } catch (error) { + this.#status.ready = false; + this.#status.qqConnectionState = 'failed'; + this.#status.lastError = error?.message ?? String(error); + await this.stop(); + throw error; + } finally { + clearTimeout(timer); + } + } + + async stop() { + const bot = this.#bot; + const bridge = this.#bridge; + const runTask = this.#runTask; + this.#abortController?.abort(); + this.#abortController = null; + this.#bot = null; + this.#bridge = null; + this.#runTask = null; + try { + bot?.stop(); + } catch (error) { + this.#logger.warn?.(`[dsh-im:qq] bot ${this.#config.botId} failed to stop cleanly:`, error); + } + await Promise.race([ + runTask?.catch(() => undefined) ?? Promise.resolve(), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); + await bridge?.waitForIdle(); + this.#status.ready = false; + this.#status.qqConnectionState = 'idle'; + return this.status; + } +} diff --git a/src/channels/qq/qr-auth.mjs b/src/channels/qq/qr-auth.mjs new file mode 100644 index 0000000..c476442 --- /dev/null +++ b/src/channels/qq/qr-auth.mjs @@ -0,0 +1,24 @@ +import { startQrConnect } from '@tencent-connect/qqbot-connector'; + +export class QqQrAuth { + #start; + #source; + + constructor({ start = startQrConnect, source = 'deepseek-harness' } = {}) { + if (typeof start !== 'function') throw new TypeError('QQ QR connector is required'); + this.#start = start; + this.#source = source; + } + + start(callbacks, { signal } = {}) { + if (!callbacks || typeof callbacks.onSuccess !== 'function' + || typeof callbacks.onFailure !== 'function') { + throw new TypeError('QQ QR callbacks are required'); + } + return this.#start(callbacks, { + displayQrCodeToConsole: false, + source: this.#source, + signal, + }); + } +} diff --git a/src/channels/qq/state-store.mjs b/src/channels/qq/state-store.mjs new file mode 100644 index 0000000..882f6d7 --- /dev/null +++ b/src/channels/qq/state-store.mjs @@ -0,0 +1,96 @@ +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [] }); + +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' && typeof sessionId === 'string' && sessionId) { + sessions[key] = sessionId; + } + } + } + return { + version: 1, + sessions, + seenMessageIds: Array.isArray(value.seenMessageIds) + ? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000) + : [], + }; +} + +export class QqStateStore { + #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(); + } + + 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/test/channels/qq/bridge.test.mjs b/test/channels/qq/bridge.test.mjs new file mode 100644 index 0000000..9f49acc --- /dev/null +++ b/test/channels/qq/bridge.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs'; + +function message(overrides = {}) { + return { + kind: 'c2c', + rawEventType: 'C2C_MESSAGE_CREATE', + senderId: 'owner-openid', + senderIsBot: false, + content: '请回答', + messageId: 'msg-1', + replyTarget: { scope: 'c2c', targetId: 'owner-openid', msgId: 'msg-1' }, + ...overrides, + }; +} + +test('QQ private messages stream Harness snapshots and finalize once', async () => { + const frames = []; + const sent = []; + const seen = new Set(); + const bridge = new QqHarnessBridge({ + bot: { + sendText: async (_target, text) => sent.push(text), + openStream: () => ({ + update: async (text) => frames.push(text), + complete: async () => frames.push('DONE'), + cancel() {}, + }), + }, + ownerUserOpenid: 'owner-openid', + harness: { + sessionExists: async () => true, + createSession: async () => 'session-new', + ensureRunning: async () => true, + ask: async (_session, _text, { onUpdate }) => { + await onUpdate({ type: 'text', text: '回答中' }); + return '最终回答'; + }, + }, + state: { + hasSeen: (id) => seen.has(id), + markSeen: async (id) => seen.add(id), + sessionFor: () => 'session-existing', + setSession: async () => {}, + clearSession: async () => {}, + }, + }); + + await bridge.accept(message()); + assert.deepEqual(frames, ['回答中', '最终回答', 'DONE']); + assert.deepEqual(sent, []); + assert.equal(seen.has('msg-1'), true); + assert.equal(bridge.status.messagesReplied, 1); +}); + +test('QQ bridge accepts only the scanner and requires an at-message event in groups', async () => { + let asks = 0; + const state = { + hasSeen: () => false, + markSeen: async () => {}, + sessionFor: () => 'session', + sessionExists: async () => true, + setSession: async () => {}, + clearSession: async () => {}, + }; + const bridge = new QqHarnessBridge({ + bot: { sendText: async () => {} }, + ownerUserOpenid: 'owner-openid', + harness: { sessionExists: async () => true, ask: async () => { asks += 1; return 'ok'; } }, + state, + }); + await bridge.accept(message({ messageId: 'other', senderId: 'other-openid' })); + await bridge.accept(message({ + messageId: 'group', kind: 'group', groupOpenid: 'group-1', rawEventType: 'GROUP_MESSAGE_CREATE', + replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'group' }, + })); + assert.equal(asks, 0); + assert.equal(bridge.status.messagesRejected, 1); +}); diff --git a/test/channels/qq/client-api.test.mjs b/test/channels/qq/client-api.test.mjs new file mode 100644 index 0000000..4005301 --- /dev/null +++ b/test/channels/qq/client-api.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { normalizeProvisioning, normalizeSnapshot, safeQrSource } from '../../../plugin-src/client/channels/qq/api.js'; + +test('QQ client keeps only redacted bot and host-rendered QR state', () => { + const qr = 'data:image/png;base64,YWJjZA=='; + assert.equal(safeQrSource(qr), qr); + const provision = normalizeProvisioning({ + attemptId: 'attempt_1', status: 'pending', expiresAt: Date.now() + 1_000, qrCodeDataUrl: qr, + }); + assert.equal(provision.qrCodeDataUrl, qr); + const snapshot = normalizeSnapshot({ + bots: [{ + botId: 'qq_abc', connected: true, state: 'connected', + bot: { name: 'QQ机器人', appIdMasked: '123••••456' }, + health: { summary: '运行正常' }, + }], + }); + assert.equal(snapshot.totals.connected, 1); + assert.equal(snapshot.bots[0].bot.appIdMasked, '123••••456'); +}); diff --git a/test/channels/qq/client-ui.test.mjs b/test/channels/qq/client-ui.test.mjs new file mode 100644 index 0000000..cfd3593 --- /dev/null +++ b/test/channels/qq/client-ui.test.mjs @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { AccountCard, QqSettingsTab } from '../../../plugin-src/client/channels/qq/index.js'; + +test('QQ settings uses the shared compact channel toolbar', () => { + const markup = renderToStaticMarkup(React.createElement(QqSettingsTab, { + rpcCall: async () => ({ ok: true, value: {} }), + })); + assert.match(markup, /class="ddt-page dqq-page dim-channelPage"/); + assert.match(markup, /class="ddt-button dim-scanButton"/); + assert.match(markup, />扫码绑定QQ机器人 { + const markup = renderToStaticMarkup(React.createElement(AccountCard, { + account: { + botId: 'qq_bot', + connected: true, + state: 'connected', + bot: { name: 'QQ机器人', appIdMasked: '123••••456' }, + health: { summary: 'QQ WebSocket 长连接运行正常', lastCheckedAt: Date.now() }, + error: null, + }, + onReconnect() {}, + onRequestRemove() {}, + onConfirmRemove() {}, + onCancelRemove() {}, + })); + assert.match(markup, /class="ddt-card dim-botCard"/); + assert.match(markup, /data-im-channel-logo="qq"/); + assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2); + assert.match(markup, />消息通道<[^]*>最近检查检查连接<[^]*>移除接入 { + const root = await mkdtemp(join(tmpdir(), 'dsh-im-qq-config-')); + t.after(() => rm(root, { recursive: true, force: true })); + const path = join(root, 'config.json'); + const identity = deriveQqBotIdentity('102345678'); + const store = await new QqConfigStore(path).load(); + await store.save({ + ...identity, + appId: '102345678', + ownerUserOpenid: 'owner-openid', + createdAt: '2026-08-15T00:00:00.000Z', + }); + const document = await readFile(path, 'utf8'); + assert.match(document, /102345678/); + assert.match(document, /owner-openid/); + assert.doesNotMatch(document, /appSecret|private-secret/); + assert.deepEqual(store.get(identity.botId), { + ...identity, + appId: '102345678', + ownerUserOpenid: 'owner-openid', + createdAt: '2026-08-15T00:00:00.000Z', + connectedAt: null, + }); +}); + +test('QQ QR wrapper disables console output and identifies DeepSeek Harness', () => { + let observed; + const auth = new QqQrAuth({ + start(callbacks, options) { + observed = { callbacks, options }; + return () => {}; + }, + }); + const callbacks = { onSuccess() {}, onFailure() {} }; + const signal = new AbortController().signal; + const dispose = auth.start(callbacks, { signal }); + assert.equal(typeof dispose, 'function'); + assert.equal(observed.callbacks, callbacks); + assert.deepEqual(observed.options, { + displayQrCodeToConsole: false, + source: 'deepseek-harness', + signal, + }); +}); diff --git a/test/channels/qq/controller-and-rpc.test.mjs b/test/channels/qq/controller-and-rpc.test.mjs new file mode 100644 index 0000000..9764487 --- /dev/null +++ b/test/channels/qq/controller-and-rpc.test.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { QqController } from '../../../src/channels/qq/qq-controller.mjs'; +import { QQ_ENDPOINTS, createQqRpcHandler } from '../../../plugin-src/host/channels/qq/rpc.mjs'; + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +test('QQ QR success stores the secret off-config and starts a scanner-owned runtime', async () => { + const qr = deferred(); + let callbacks; + const values = new Map(); + const configs = []; + let runtimeArgs; + const controller = new QqController({ + qrAuth: { + start(next) { + callbacks = next; + queueMicrotask(() => next.onQrDisplayed('https://q.qq.com/connect?task=opaque')); + return () => {}; + }, + }, + credentials: { + resolve: async (ref) => values.has(ref) ? { value: values.get(ref) } : undefined, + set: async (ref, value) => values.set(ref, value), + unset: async (ref) => values.delete(ref), + }, + configStore: { + list: () => [...configs], + get: (id) => configs.find((value) => value.botId === id) ?? null, + getByAppId: (id) => configs.find((value) => value.appId === id) ?? null, + save: async (value) => { configs.splice(0, configs.length, value); }, + remove: async () => null, + }, + createRuntime: async (args) => { + runtimeArgs = args; + return { status: { ready: true, qqConnectionState: 'connected', harnessReachable: true }, start: async () => {}, stop: async () => {} }; + }, + }); + const started = await controller.startProvisioning(); + assert.equal(started.status, 'pending'); + callbacks.onSuccess([{ appId: 'app-id', appSecret: 'private-secret', userOpenid: 'scanner-openid' }]); + while (controller.registrationStatus(started.attemptId).status !== 'connected') await new Promise((resolve) => setImmediate(resolve)); + const status = controller.status(); + assert.equal(status.bots[0].connected, true); + assert.equal(configs[0].ownerUserOpenid, 'scanner-openid'); + assert.equal(values.get(configs[0].secretRef), 'private-secret'); + assert.equal(runtimeArgs.appSecret, 'private-secret'); + assert.doesNotMatch(JSON.stringify(status), /private-secret|scanner-openid|secretRef/); + qr.resolve(); +}); + +test('QQ RPC turns the host-only QR URL into an image and strips credential fields', async () => { + const handler = createQqRpcHandler({ + status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }), + startProvisioning: async () => ({ + attemptId: 'attempt_1', status: 'pending', verificationUrl: 'https://q.qq.com/opaque', + appSecret: 'never-public', ownerUserOpenid: 'never-public', expiresAt: Date.now() + 1_000, + }), + registrationStatus: () => null, + cancelProvisioning: async () => ({}), + reconnectBot: async () => ({}), + deleteBot: async () => ({}), + }, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' }); + const result = await handler(QQ_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' }); + assert.equal(result.ok, true); + assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA=='); + assert.doesNotMatch(JSON.stringify(result), /q\.qq\.com|never-public|ownerUserOpenid|appSecret/); +}); diff --git a/test/channels/qq/runtime.test.mjs b/test/channels/qq/runtime.test.mjs new file mode 100644 index 0000000..bb17efa --- /dev/null +++ b/test/channels/qq/runtime.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +import { QqRuntime } from '../../../src/channels/qq/qq-runtime.mjs'; + +class FakeBot extends EventEmitter { + middlewares = []; + stopped = false; + use(value) { this.middlewares.push(value); } + async start(signal) { + queueMicrotask(() => this.emit('ready', {})); + await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true })); + } + stop() { this.stopped = true; } + async sendText() {} +} + +test('QQ runtime waits for gateway ready, installs typing, and stops its client', async () => { + const bot = new FakeBot(); + let botOptions; + const sdkLogs = []; + const runtime = new QqRuntime({ + config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner' }, + appSecret: 'secret', + harness: { ensureRunning: async () => true }, + state: {}, + createBot: (options) => { + botOptions = options; + return bot; + }, + logger: { + debug: (...args) => sdkLogs.push(['debug', ...args]), + info: (...args) => sdkLogs.push(['info', ...args]), + }, + typingMiddleware: (options) => ({ name: 'typing-middleware', options }), + connectTimeoutMs: 100, + }); + const status = await runtime.start(); + assert.equal(status.ready, true); + assert.equal(status.qqConnectionState, 'connected'); + assert.equal(bot.middlewares[0].name, 'typing-middleware'); + assert.equal(bot.middlewares[0].options.keepAlive, true); + assert.equal(bot.middlewares[0].options.predicate({ message: { senderId: 'owner' } }), true); + assert.equal(bot.middlewares[0].options.predicate({ message: { senderId: 'other' } }), false); + botOptions.logger.debug('raw gateway payload'); + botOptions.logger.info('gateway ready'); + assert.deepEqual(sdkLogs, [['info', 'gateway ready']]); + bot.emit('error', new Error('temporary disconnect')); + bot.emit('resumed'); + assert.equal(runtime.status.ready, true); + assert.equal(runtime.status.lastError, null); + await runtime.stop(); + assert.equal(bot.stopped, true); + assert.equal(runtime.status.ready, false); +}); + +test('QQ runtime never reports ready when the gateway does not emit ready', async () => { + const bot = new FakeBot(); + bot.start = async (signal) => new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true })); + const runtime = new QqRuntime({ + config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner' }, + appSecret: 'secret', + harness: { ensureRunning: async () => true }, + state: {}, + createBot: () => bot, + typingMiddleware: () => 'typing', + connectTimeoutMs: 5, + }); + await assert.rejects(() => runtime.start(), /did not become ready/); + assert.equal(runtime.status.ready, false); +}); diff --git a/test/client-ui.test.mjs b/test/client-ui.test.mjs index 06bcda3..5d995e9 100644 --- a/test/client-ui.test.mjs +++ b/test/client-ui.test.mjs @@ -47,11 +47,12 @@ const DINGTALK_CLIENT_SOURCE_URL = new URL( import.meta.url, ); -test('IM settings renders three compact logo channel tabs without enable switches', () => { +test('IM settings renders four compact logo channel tabs without enable switches', () => { const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, { feishuRpcCall: async () => ({ ok: true, value: {} }), weixinRpcCall: async () => ({ ok: true, value: {} }), dingtalkRpcCall: async () => ({ ok: true, value: {} }), + qqRpcCall: async () => ({ ok: true, value: {} }), })); assert.match(markup, /IM机器人/); @@ -60,10 +61,12 @@ test('IM settings renders three compact logo channel tabs without enable switche assert.match(markup, />微信飞书钉钉QQ|扫码接入<\/small>/); diff --git a/test/host.test.mjs b/test/host.test.mjs index 3d02de4..5c844a5 100644 --- a/test/host.test.mjs +++ b/test/host.test.mjs @@ -3,18 +3,20 @@ import test from 'node:test'; import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs'; -test('Host composes Feishu, Weixin, and DingTalk inside one plugin context', async () => { +test('Host composes Feishu, Weixin, DingTalk, and QQ inside one plugin context', async () => { const calls = []; const plugin = createImHostPlugin({ applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]), applyWeixin: async (ctx, config) => calls.push(['weixin', ctx, config]), applyDingtalk: async (ctx, config) => calls.push(['dingtalk', ctx, config]), + applyQq: async (ctx, config) => calls.push(['qq', ctx, config]), }); const ctx = { marker: 'shared-context' }; const config = { feishu: { domain: 'feishu' }, weixin: { timeout: 30 }, dingtalk: { replyTimeoutMs: 60_000 }, + qq: { replyTimeoutMs: 60_000 }, }; await plugin.apply(ctx, config); @@ -25,6 +27,7 @@ test('Host composes Feishu, Weixin, and DingTalk inside one plugin context', asy ['feishu', ctx, config.feishu], ['weixin', ctx, config.weixin], ['dingtalk', ctx, config.dingtalk], + ['qq', ctx, config.qq], ]); }); @@ -34,6 +37,7 @@ test('Host does not start Weixin when Feishu activation fails', async () => { applyFeishu: async () => { throw new Error('feishu unavailable'); }, applyWeixin: async () => { weixinStarted = true; }, applyDingtalk: async () => { throw new Error('DingTalk must not start'); }, + applyQq: async () => { throw new Error('QQ must not start'); }, }); await assert.rejects(() => plugin.apply({}, {}), /feishu unavailable/); @@ -46,8 +50,22 @@ test('Host does not start DingTalk when Weixin activation fails', async () => { applyFeishu: async () => {}, applyWeixin: async () => { throw new Error('weixin unavailable'); }, applyDingtalk: async () => { dingtalkStarted = true; }, + applyQq: async () => { throw new Error('QQ must not start'); }, }); await assert.rejects(() => plugin.apply({}, {}), /weixin unavailable/); assert.equal(dingtalkStarted, false); }); + +test('Host does not start QQ when DingTalk activation fails', async () => { + let qqStarted = false; + const plugin = createImHostPlugin({ + applyFeishu: async () => {}, + applyWeixin: async () => {}, + applyDingtalk: async () => { throw new Error('dingtalk unavailable'); }, + applyQq: async () => { qqStarted = true; }, + }); + + await assert.rejects(() => plugin.apply({}, {}), /dingtalk unavailable/); + assert.equal(qqStarted, false); +});