mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +08:00
Add Telegram and Discord IM channels
This commit is contained in:
parent
7123fce47a
commit
cb74e1c4c7
66 changed files with 8579 additions and 1910 deletions
50
README.md
50
README.md
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
## 中文
|
||||
|
||||
通过扫码或已有应用凭据把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信和 QQ 机器人。
|
||||
通过扫码或已有机器人凭据把 IM 机器人接入 DeepSeek Harness。一个插件、一个设置入口,统一管理飞书、微信、钉钉、企业微信、QQ、Telegram 和 Discord 机器人。
|
||||
|
||||
> GitHub 简介:通过扫码或应用凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。
|
||||
> GitHub 简介:通过扫码或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Telegram和Discord)。
|
||||
|
||||
## 界面
|
||||
|
||||
|
|
@ -17,6 +17,8 @@
|
|||
- 钉钉:扫码创建机器人,或使用已有 Client ID + Client Secret 绑定机器人,使用钉钉 Stream 长连接收消息,并通过 AI Card 流式显示 Harness 回答。
|
||||
- 企业微信:使用企业微信 App 扫码创建智能机器人,或使用已有 Bot ID + Secret 绑定机器人,通过官方 WebSocket 长连接收消息,原生显示“正在思考中”、工具执行进度和流式回答。
|
||||
- QQ:使用手机 QQ 扫码创建机器人,或使用已有 AppID + AppSecret 绑定机器人,通过 WebSocket 长连接收消息;私聊支持原生“正在输入”和流式回答,群聊在机器人被 @ 后回复。
|
||||
- Telegram:使用 @BotFather 生成的 Bot Token 接入机器人,通过官方 Bot API 长轮询收消息;私聊直接回复,群聊仅在机器人被提及或收到对机器人消息的回复时响应,并通过编辑消息流式显示 Harness 回答。
|
||||
- Discord:使用 Developer Portal 生成的 Bot Token 接入机器人,通过 Gateway v10 长连接收消息;私信直接回复,服务器频道仅在机器人被提及时响应,并通过编辑消息流式显示 Harness 回答。
|
||||
|
||||
其他 IM 平台可继续按同一渠道适配器结构接入。
|
||||
|
||||
|
|
@ -30,6 +32,8 @@ npx -y github:xmanrui/dsh-im install
|
|||
|
||||
飞书、QQ、钉钉和企业微信页面都提供两种入口:带二维码图标的蓝色「扫码接入机器人」按钮走平台官方扫码流程,右侧带钥匙图标的白色描边「手动接入」按钮连接已经创建的机器人应用。飞书和 QQ 分别填写 App ID + App Secret、AppID + AppSecret;钉钉填写官方 Client ID + Client Secret;企业微信填写官方 Bot ID + Secret。Secret 只提交给本机 Harness Host,并写入受保护的凭据存储;状态接口和机器人列表不会回传 Secret。
|
||||
|
||||
Telegram 和 Discord 没有官方扫码创建机器人流程,因此页面只显示带钥匙图标的「手动接入」入口,并只要求 Bot Token。Telegram Token 由 @BotFather 生成;若该机器人已经配置 Webhook,需要先由原服务移除 Webhook,Bot API 长轮询才能接管消息。Discord Token 来自 Developer Portal 的 Bot 页面;还需把机器人邀请到目标服务器,并授予查看频道、发送消息和读取历史消息权限。本插件只读取私信和明确提及机器人的服务器消息,因此不要求 Message Content 特权 Intent。
|
||||
|
||||
钉钉扫码接入时,请使用已加入企业/组织且有权创建机器人的钉钉账号扫描页面二维码,再在钉钉授权页点击「一键创建新机器人」。若提示“该账号还未加入组织”,请先创建组织或换用已加入组织的账号后重新扫码。插件不设置本机二次批准流程,钉钉中的机器人可见范围就是入站访问范围,请只开放给信任的组织、群或成员。
|
||||
|
||||
企业微信扫码接入时,请使用已加入企业且具有机器人创建或管理权限的企业微信账号,并在手机端确认创建智能机器人。扫码创建的是企业微信智能机器人,不是让插件直接登录个人微信账号。无论扫码还是凭据绑定,企业微信中的机器人可见范围就是入站访问范围,请只开放给信任的企业成员和群聊。
|
||||
|
|
@ -41,10 +45,10 @@ QQ 扫码接入使用腾讯 QQBot v2 官方流程。默认腾讯授权页会把
|
|||
## 设计
|
||||
|
||||
- Harness 中只注册一个「IM机器人」设置页;
|
||||
- 飞书、微信、钉钉、企业微信和 QQ 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件;
|
||||
- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信和 QQ,不使用启用/停用开关;
|
||||
- 五个渠道保持独立的 RPC、凭据、连接监督和会话映射;
|
||||
- 浏览器只获得二维码和脱敏状态;手动输入的 Secret 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret` 或原始用户标识。
|
||||
- 七个渠道的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立渠道插件;
|
||||
- 左侧使用渠道 Logo 切换微信、飞书、钉钉、企业微信、QQ、Telegram 和 Discord,不使用启用/停用开关;
|
||||
- 七个渠道保持独立的 RPC、凭据、连接监督和会话映射;
|
||||
- 浏览器只获得二维码和脱敏状态;手动输入的 Secret 或 Bot Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Telegram/Discord Bot Token 或原始用户标识。
|
||||
|
||||
## 本地开发
|
||||
|
||||
|
|
@ -56,13 +60,23 @@ node bin/dsh-im.mjs install --source .
|
|||
|
||||
`npm run check` 运行单元测试、构建 Host/Client 产物,并验证发布包不包含凭据或独立渠道设置页注册。
|
||||
|
||||
IM 管理 RPC 默认仅接受回环浏览器。如果 Web profile 在受信任的局域网内对外提供服务,可在该 profile 的 `cordis.patch.yml` 中显式开放给 Connection 已信任的 Host authority:
|
||||
|
||||
```yaml
|
||||
- id: xmanrui-dsh-im
|
||||
config:
|
||||
rpcAuthority: trusted-host
|
||||
```
|
||||
|
||||
`trusted-host` 只复用 Harness 的 Host/Origin 防护,不是用户认证。启用后,能访问该局域网地址的人也能查看机器人状态、扫码或提交应用凭据、重连和删除机器人;只应在可信网络中使用。
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing application credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, and QQ bots.
|
||||
Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing bot credentials. One plugin and one settings entry provide unified management for Feishu, WeChat, DingTalk, WeCom, QQ, Telegram, and Discord bots.
|
||||
|
||||
> GitHub description: Connect IM bots to DeepSeek Harness by QR code or application credentials (supports Feishu, WeChat, DingTalk, WeCom, and QQ).
|
||||
> GitHub description: Connect IM bots to DeepSeek Harness by QR code or bot credentials (supports Feishu, WeChat, DingTalk, WeCom, QQ, Telegram, and Discord).
|
||||
|
||||
## Interface
|
||||
|
||||
|
|
@ -75,6 +89,8 @@ Connect IM bots to DeepSeek Harness by scanning a QR code or entering existing a
|
|||
- DingTalk: create a bot by QR code or bind an existing bot with Client ID + Client Secret, receive messages through DingTalk Stream, and stream Harness replies through AI Cards.
|
||||
- WeCom: create an intelligent bot by QR code or bind an existing bot with Bot ID + Secret, receive messages over the official WebSocket connection, and natively show a thinking state, tool progress, and streaming replies.
|
||||
- QQ: create a bot by QR code or bind an existing bot with AppID + AppSecret, receive messages over a WebSocket connection, stream private-chat replies with a native typing indicator, and reply in groups when mentioned.
|
||||
- Telegram: bind a BotFather-created bot with its Bot Token, receive messages through Bot API long polling, reply directly in private chats, require a mention or reply in groups, and stream Harness output by editing the reply.
|
||||
- Discord: bind a Developer Portal bot with its Bot Token, receive events through Gateway v10, reply directly in DMs, require a mention in server channels, and stream Harness output by editing the reply.
|
||||
|
||||
Other IM platforms can be added through the same channel-adapter structure.
|
||||
|
||||
|
|
@ -88,6 +104,8 @@ Restart `dsh web`, then open **Settings → Plugins → IM Bot**. The installer
|
|||
|
||||
Feishu, QQ, DingTalk, and WeCom each provide two entry points. The blue **QR access** action uses the platform QR flow; the key-marked, outlined **Manual access** action immediately to its right connects an existing bot application. Feishu and QQ use App ID + App Secret and AppID + AppSecret respectively, DingTalk uses Client ID + Client Secret, and WeCom uses Bot ID + Secret. Secrets are sent only to the local Harness Host and stored through its protected credential provider; status responses and bot lists never return them.
|
||||
|
||||
Telegram and Discord do not provide an official QR flow for creating bots, so their pages expose only the key-marked **Manual access** action and request a Bot Token. Generate the Telegram token with BotFather; an existing webhook must be removed by its current service before Bot API long polling can receive updates. Generate the Discord token on the Developer Portal's Bot page, invite the bot to the target server, and grant View Channel, Send Messages, and Read Message History. The plugin reads DMs and server messages that explicitly mention the bot, so it does not request the privileged Message Content intent.
|
||||
|
||||
For DingTalk QR binding, scan with an account that belongs to an enterprise or organization and can create bots, then choose **Create a new bot** on the authorization page. If DingTalk reports that the account has not joined an organization, create one or switch to an account that has, then scan again. There is no second local sender-approval flow: the bot's DingTalk visibility is its inbound access scope, so restrict it to trusted organizations, groups, or members.
|
||||
|
||||
For WeCom QR binding, scan with an account that belongs to an enterprise and can create or manage bots, then confirm creation of the intelligent bot in the mobile app. This creates a WeCom intelligent bot; it does not sign the plugin into a personal WeChat account. For both QR and credential binding, restrict the bot's WeCom visibility to trusted enterprise members and group chats.
|
||||
|
|
@ -99,10 +117,10 @@ Feishu QR binding records the scanner as an allowed user. Manual credentials can
|
|||
## Design
|
||||
|
||||
- Registers a single **IM Bot** settings page in Harness.
|
||||
- Maintains the Feishu, WeChat, DingTalk, WeCom, and QQ Host, client, and runtime sources in this repository without external standalone channel plugins.
|
||||
- Uses channel logos for WeChat, Feishu, DingTalk, WeCom, and QQ navigation without enable/disable switches.
|
||||
- Maintains all seven channel Host, client, and runtime sources in this repository without external standalone channel plugins.
|
||||
- Uses channel logos for WeChat, Feishu, DingTalk, WeCom, QQ, Telegram, and Discord navigation without enable/disable switches.
|
||||
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by channel.
|
||||
- Returns only QR codes and redacted status data to the browser. Manually entered secrets travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, or raw user identifiers.
|
||||
- Returns only QR codes and redacted status data to the browser. Manually entered secrets and Bot Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Telegram/Discord Bot Tokens, or raw user identifiers.
|
||||
|
||||
## Local development
|
||||
|
||||
|
|
@ -113,3 +131,13 @@ node bin/dsh-im.mjs install --source .
|
|||
```
|
||||
|
||||
`npm run check` runs unit tests, builds the Host and Client artifacts, and verifies that the published package contains neither credentials nor standalone channel settings-page registrations.
|
||||
|
||||
IM management RPCs accept loopback browsers by default. When a Web profile is deliberately served on a trusted LAN, opt the plugin into the Host authorities already trusted by Connection in that profile's `cordis.patch.yml`:
|
||||
|
||||
```yaml
|
||||
- id: xmanrui-dsh-im
|
||||
config:
|
||||
rpcAuthority: trusted-host
|
||||
```
|
||||
|
||||
`trusted-host` reuses Harness's Host/Origin fence; it is not user authentication. Anyone who can reach that LAN authority can inspect bot status, scan or submit application credentials, reconnect bots, and remove bots. Enable it only on a trusted network.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,6 @@ This package depends on [`@larksuiteoapi/node-sdk`](https://github.com/larksuite
|
|||
|
||||
QQ QR binding uses Tencent Connect's official [`@tencent-connect/qqbot-connector`](https://www.npmjs.com/package/@tencent-connect/qqbot-connector) 1.2.0 package as an external runtime dependency. Its npm metadata declares `UNLICENSED`; no connector source is copied into this project.
|
||||
|
||||
This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, WeCom, Feishu, DingTalk, or QQ.
|
||||
This project is an independent DeepSeek Harness integration. It does not bundle OpenClaw and is not endorsed by Tencent, WeCom, Feishu, DingTalk, QQ, Telegram, or Discord.
|
||||
|
||||
The WeChat and QQ marks use path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu, DingTalk, and WeCom marks are inline vectors used for channel identification. Product names and logos remain trademarks of their respective owners.
|
||||
The WeChat, QQ, Telegram, and Discord marks use path data published by Simple Icons under the CC0 1.0 Universal license. The Feishu, DingTalk, and WeCom marks are inline vectors used for channel identification. Product names and logos remain trademarks of their respective owners.
|
||||
|
|
|
|||
2941
lib/client.js
2941
lib/client.js
File diff suppressed because it is too large
Load diff
3624
lib/index.js
3624
lib/index.js
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@xmanrui/dsh-im",
|
||||
"version": "0.1.0",
|
||||
"description": "通过扫码或应用凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信和QQ)。",
|
||||
"description": "通过扫码或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Telegram和Discord)。",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -79,3 +79,29 @@ export function WecomLogoGlyph({ size } = {}) {
|
|||
h('path', { fill: '#FF7A00', d: 'M19.55 18.05a1.16 1.16 0 1 1 1.64 1.64 1.16 1.16 0 0 1-1.64-1.64Z' }),
|
||||
h('path', { fill: '#3370FF', d: 'M15.25 18.75a.92.92 0 1 1 1.3 1.3.92.92 0 0 1-1.3-1.3Z' }));
|
||||
}
|
||||
|
||||
export function TelegramLogoGlyph({ size } = {}) {
|
||||
return h('svg', {
|
||||
...dimensions(size),
|
||||
viewBox: '0 0 24 24',
|
||||
focusable: 'false',
|
||||
'aria-hidden': 'true',
|
||||
'data-im-channel-logo': 'telegram',
|
||||
}, h('path', {
|
||||
fill: 'currentColor',
|
||||
d: 'M23.95 4.57c-.36-1.45-1.43-1.76-2.82-1.24L1.5 10.9c-1.34.52-1.32 1.27-.24 1.6l5.03 1.57 11.66-7.36c.55-.34 1.05-.16.64.21l-9.44 8.52-.37 5.12c.54 0 .78-.24 1.08-.53l2.59-2.51 5.38 3.97c.99.55 1.7.27 1.95-.92L23.95 4.57Z',
|
||||
}));
|
||||
}
|
||||
|
||||
export function DiscordLogoGlyph({ size } = {}) {
|
||||
return h('svg', {
|
||||
...dimensions(size),
|
||||
viewBox: '0 0 24 24',
|
||||
focusable: 'false',
|
||||
'aria-hidden': 'true',
|
||||
'data-im-channel-logo': 'discord',
|
||||
}, h('path', {
|
||||
fill: 'currentColor',
|
||||
d: 'M20.32 4.37a19.8 19.8 0 0 0-4.89-1.51c-.21.38-.46.89-.63 1.29a18.4 18.4 0 0 0-5.59 0 13 13 0 0 0-.64-1.29c-1.71.29-3.36.8-4.89 1.52C.59 9.09-.25 13.68.17 18.2a19.9 19.9 0 0 0 6 3.04c.48-.66.91-1.36 1.28-2.1-.7-.26-1.37-.58-2-.96.17-.12.33-.25.49-.38 3.86 1.79 8.04 1.79 11.86 0 .16.13.32.26.49.38-.64.38-1.31.7-2.01.97.37.73.8 1.44 1.28 2.09a19.8 19.8 0 0 0 6-3.04c.49-5.24-.84-9.79-3.24-13.83ZM8.02 15.42c-1.16 0-2.11-1.07-2.11-2.38s.93-2.38 2.11-2.38c1.18 0 2.13 1.08 2.11 2.38 0 1.31-.93 2.38-2.11 2.38Zm7.95 0c-1.16 0-2.11-1.07-2.11-2.38s.93-2.38 2.11-2.38c1.18 0 2.13 1.08 2.11 2.38 0 1.31-.93 2.38-2.11 2.38Z',
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
11
plugin-src/client/channels/discord/api.js
Normal file
11
plugin-src/client/channels/discord/api.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { TOKEN_BOT_ENDPOINTS, createTokenChannelApi } from '../shared/token-api.js';
|
||||
|
||||
export const DISCORD_RPC_CHANNEL = '/discord';
|
||||
export const DISCORD_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
|
||||
|
||||
const api = createTokenChannelApi('Discord', ' Gateway 长连接');
|
||||
|
||||
export const unwrapRpcResult = api.unwrapRpcResult;
|
||||
export const normalizeSnapshot = api.normalizeSnapshot;
|
||||
export const presentError = api.presentError;
|
||||
export { api as discordClientApi };
|
||||
25
plugin-src/client/channels/discord/index.js
Normal file
25
plugin-src/client/channels/discord/index.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { DiscordLogoGlyph } from '../../channel-logos.js';
|
||||
import { createTokenChannelSettings } from '../shared/token-channel.js';
|
||||
import {
|
||||
DISCORD_ENDPOINTS,
|
||||
discordClientApi,
|
||||
} from './api.js';
|
||||
import { installDiscordStyles } from './styles.js';
|
||||
|
||||
const channel = createTokenChannelSettings({
|
||||
channel: 'Discord',
|
||||
endpoints: DISCORD_ENDPOINTS,
|
||||
api: discordClientApi,
|
||||
LogoGlyph: DiscordLogoGlyph,
|
||||
installStyles: installDiscordStyles,
|
||||
pageClass: 'ddc-page',
|
||||
avatarClass: 'ddc-avatar',
|
||||
connectionLabel: 'Gateway 长连接',
|
||||
tokenPlaceholder: '填写 Discord Developer Portal 的 Bot Token',
|
||||
emptyTitle: '接入 Discord 机器人',
|
||||
emptyDescription: '先在 Developer Portal 创建 Bot 并邀请到服务器,再在这里完成接入。',
|
||||
platformLabel: 'Discord Developer Portal',
|
||||
});
|
||||
|
||||
export const DiscordSettingsTab = channel.SettingsTab;
|
||||
export const DiscordAccountCard = channel.AccountCard;
|
||||
19
plugin-src/client/channels/discord/styles.js
Normal file
19
plugin-src/client/channels/discord/styles.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export const DISCORD_STYLE_ID = 'xmanrui-dsh-im-discord-settings';
|
||||
|
||||
const CSS = String.raw`
|
||||
.ddc-page { --ddt-accent: #5865f2; --ddt-accent-deep: #4752c4; --ddt-accent-wash: #eef0ff; }
|
||||
.ddc-avatar { color: #fff; background: #5865f2; }
|
||||
.ddc-avatar svg { display: block; }
|
||||
`;
|
||||
|
||||
export function installDiscordStyles() {
|
||||
if (typeof document === 'undefined') return () => {};
|
||||
const existing = document.querySelector(`style[data-plugin-css="${DISCORD_STYLE_ID}"]`);
|
||||
if (existing) return () => {};
|
||||
const style = document.createElement('style');
|
||||
style.dataset.plugin = '@xmanrui/dsh-im';
|
||||
style.dataset.pluginCss = DISCORD_STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
return () => style.remove();
|
||||
}
|
||||
88
plugin-src/client/channels/shared/token-api.js
Normal file
88
plugin-src/client/channels/shared/token-api.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function text(value, fallback, max = 240) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : fallback;
|
||||
}
|
||||
|
||||
function id(value) {
|
||||
const result = text(value, '', 128);
|
||||
return /^[a-z\d_-]+$/i.test(result) ? result : undefined;
|
||||
}
|
||||
|
||||
function timestamp(value) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
const parsed = typeof value === 'string' ? Date.parse(value) : Number.NaN;
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
export const TOKEN_BOT_ENDPOINTS = Object.freeze({
|
||||
status: 'connection.status',
|
||||
bindCredentials: 'bot.bind-credentials',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
|
||||
export function createTokenChannelApi(channel, connectionSummary) {
|
||||
const unwrapRpcResult = (result) => {
|
||||
if (!isRecord(result) || typeof result.ok !== 'boolean') {
|
||||
throw new Error(`${channel} 服务返回了无法识别的响应`);
|
||||
}
|
||||
if (!result.ok) {
|
||||
const error = new Error(text(result.error?.message, `${channel} 操作失败`));
|
||||
error.code = text(result.error?.code, `${channel.toUpperCase()}_RPC_ERROR`, 80);
|
||||
throw error;
|
||||
}
|
||||
return result.value;
|
||||
};
|
||||
|
||||
const normalizeBot = (value) => {
|
||||
if (!isRecord(value) || !id(value.botId)) return undefined;
|
||||
const connected = value.connected === true;
|
||||
const state = ACCOUNT_STATES.has(value.state) ? value.state : 'offline';
|
||||
return {
|
||||
botId: id(value.botId),
|
||||
connected,
|
||||
state: connected ? 'connected' : state,
|
||||
bot: {
|
||||
name: text(value.bot?.name, `${channel}机器人`, 100),
|
||||
username: text(value.bot?.username, '', 100),
|
||||
idMasked: text(value.bot?.idMasked, '机器人标识已安全保存', 140),
|
||||
},
|
||||
health: {
|
||||
summary: text(
|
||||
value.health?.summary,
|
||||
connected ? `${channel}${connectionSummary}运行正常` : `${channel}连接尚未就绪`,
|
||||
),
|
||||
lastCheckedAt: timestamp(value.health?.lastCheckedAt),
|
||||
},
|
||||
error: isRecord(value.error) ? {
|
||||
code: text(value.error.code, `${channel.toUpperCase()}_ACCOUNT_ERROR`, 80),
|
||||
message: text(value.error.message, `${channel}连接尚未就绪`),
|
||||
} : null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeSnapshot = (value) => {
|
||||
const source = isRecord(value?.snapshot) ? value.snapshot : value;
|
||||
if (!isRecord(source) || !Array.isArray(source.bots)) {
|
||||
throw new Error(`${channel} 服务没有返回有效的机器人列表`);
|
||||
}
|
||||
const bots = source.bots.map(normalizeBot).filter(Boolean);
|
||||
return {
|
||||
revision: Number.isSafeInteger(source.revision) ? source.revision : 0,
|
||||
bots,
|
||||
totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
|
||||
};
|
||||
};
|
||||
|
||||
const presentError = (error) => ({
|
||||
code: text(error?.code, `${channel.toUpperCase()}_ERROR`, 80),
|
||||
message: text(error?.message, `${channel}操作失败,请稍后重试`),
|
||||
});
|
||||
|
||||
return Object.freeze({ unwrapRpcResult, normalizeSnapshot, presentError });
|
||||
}
|
||||
277
plugin-src/client/channels/shared/token-channel.js
Normal file
277
plugin-src/client/channels/shared/token-channel.js
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { CredentialActionIcon, CredentialBindingPanel } from '../../credential-binding.js';
|
||||
import { installDingtalkStyles } from '../dingtalk/styles.js';
|
||||
|
||||
const h = React.createElement;
|
||||
|
||||
const Button = React.forwardRef(function Button(
|
||||
{ children, kind = 'secondary', className = '', ...props },
|
||||
ref,
|
||||
) {
|
||||
return h('button', {
|
||||
...props,
|
||||
ref,
|
||||
type: 'button',
|
||||
className: `ddt-button ${className}`.trim(),
|
||||
'data-kind': kind,
|
||||
}, children);
|
||||
});
|
||||
|
||||
function checkedTime(value) {
|
||||
if (!value) return '尚未检查';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return '刚刚';
|
||||
}
|
||||
}
|
||||
|
||||
export function createTokenChannelSettings(definition) {
|
||||
const {
|
||||
channel,
|
||||
endpoints,
|
||||
api,
|
||||
LogoGlyph,
|
||||
installStyles,
|
||||
pageClass,
|
||||
avatarClass,
|
||||
connectionLabel,
|
||||
tokenPlaceholder,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
platformLabel,
|
||||
} = definition;
|
||||
|
||||
function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
|
||||
const state = busy === 'reconnect' ? 'connecting' : account.state;
|
||||
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
|
||||
const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪';
|
||||
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
|
||||
const identity = account.bot.username ? `@${account.bot.username}` : account.bot.idMasked;
|
||||
return h('article', { className: 'ddt-card dim-botCard', 'data-bot-id': account.botId },
|
||||
h('div', { className: 'ddt-cardBody dim-botCardBody' },
|
||||
h('div', { className: 'ddt-accountTop dim-botCardTop' },
|
||||
h('div', { className: 'ddt-accountIdentity dim-botIdentity' },
|
||||
h('div', { className: `ddt-avatar dim-botAvatar ${avatarClass}`, 'aria-hidden': 'true' },
|
||||
h(LogoGlyph, { size: 29 })),
|
||||
h('div', { className: 'dim-botName' },
|
||||
h('h3', null, account.bot.name), h('p', null, identity))),
|
||||
h('div', { className: 'ddt-health dim-botHealth' },
|
||||
h('span', { className: 'ddt-dot dim-healthDot', 'data-tone': tone }),
|
||||
h('span', null, stateLabel))),
|
||||
h('dl', { className: 'ddt-metrics dim-botMetrics' },
|
||||
h('div', { className: 'ddt-metric dim-botMetric' },
|
||||
h('dt', null, '消息通道'), h('dd', null, account.connected ? connectionLabel : '离线')),
|
||||
h('div', { className: 'ddt-metric dim-botMetric' },
|
||||
h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
|
||||
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
|
||||
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
|
||||
h('div', { className: 'ddt-actions dim-cardActions' },
|
||||
h(Button, {
|
||||
className: 'dim-cardAction',
|
||||
onClick: onReconnect,
|
||||
disabled: Boolean(busy),
|
||||
}, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
|
||||
h(Button, {
|
||||
className: 'dim-cardAction',
|
||||
kind: 'danger',
|
||||
onClick: onRequestRemove,
|
||||
disabled: Boolean(busy),
|
||||
}, '移除接入')))),
|
||||
removing ? h('div', { className: 'ddt-confirm dim-confirm', role: 'alertdialog' },
|
||||
h('strong', null, `从 DeepSeek Harness 移除“${account.bot.name}”?`),
|
||||
h('p', null, `这会停止消息连接,并删除本机保存的 Bot Token、机器人配置及会话映射。${platformLabel}中的机器人不会被自动删除。`),
|
||||
h('div', { className: 'ddt-actions dim-viewActions' },
|
||||
h(Button, { onClick: onCancelRemove, disabled: Boolean(busy) }, '保留机器人'),
|
||||
h(Button, { kind: 'danger', onClick: onConfirmRemove, disabled: Boolean(busy) },
|
||||
busy === 'delete' ? '正在移除…' : '确认移除接入'))) : null);
|
||||
}
|
||||
|
||||
function SettingsTab({ rpcCall }) {
|
||||
const [model, setModel] = React.useState({
|
||||
phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null,
|
||||
});
|
||||
const [credentialOpen, setCredentialOpen] = React.useState(false);
|
||||
const [credentialError, setCredentialError] = React.useState(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [busyByBot, setBusyByBot] = React.useState({});
|
||||
const [removeTarget, setRemoveTarget] = React.useState(null);
|
||||
const mounted = React.useRef(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
const disposeDingtalk = installDingtalkStyles();
|
||||
const disposeChannel = installStyles();
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
disposeChannel();
|
||||
disposeDingtalk();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
||||
if (typeof rpcCall !== 'function') throw new TypeError(`${channel} 设置页缺少 RPC 连接`);
|
||||
return api.unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
||||
}, [rpcCall]);
|
||||
|
||||
const loadStatus = React.useCallback(async ({ signal, silent = false } = {}) => {
|
||||
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
|
||||
try {
|
||||
const snapshot = api.normalizeSnapshot(await invoke(endpoints.status, {}, signal));
|
||||
if (!mounted.current || signal?.aborted) return;
|
||||
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
|
||||
setModel((current) => ({
|
||||
...current,
|
||||
phase: silent ? current.phase : 'error',
|
||||
error: api.presentError(error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [invoke]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void loadStatus({ signal: controller.signal });
|
||||
return () => controller.abort();
|
||||
}, [loadStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (model.phase !== 'ready') return undefined;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setInterval(
|
||||
() => void loadStatus({ signal: controller.signal, silent: true }),
|
||||
15_000,
|
||||
);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [loadStatus, model.phase]);
|
||||
|
||||
const bindCredentials = React.useCallback(async ({ secret }) => {
|
||||
setBusy(true);
|
||||
setCredentialError(null);
|
||||
try {
|
||||
const snapshot = api.normalizeSnapshot(await invoke(endpoints.bindCredentials, { token: secret }));
|
||||
if (!mounted.current) return;
|
||||
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
setCredentialOpen(false);
|
||||
} catch (error) {
|
||||
if (mounted.current) setCredentialError(api.presentError(error));
|
||||
} finally {
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [invoke]);
|
||||
|
||||
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
|
||||
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
|
||||
try {
|
||||
const snapshot = api.normalizeSnapshot(await invoke(endpoint, payload));
|
||||
if (mounted.current) {
|
||||
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
|
||||
}
|
||||
} finally {
|
||||
if (mounted.current) setBusyByBot((current) => {
|
||||
const next = { ...current };
|
||||
delete next[account.botId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [invoke]);
|
||||
|
||||
const botList = model.bots.length > 0
|
||||
? h('section', { className: 'dim-listSection' },
|
||||
h('div', { className: 'ddt-listHeading dim-listHeading' },
|
||||
h('h3', null, `已接入的 ${channel} 机器人`)),
|
||||
h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) =>
|
||||
h('li', { key: account.botId }, h(AccountCard, {
|
||||
account,
|
||||
busy: busyByBot[account.botId],
|
||||
removing: removeTarget === account.botId,
|
||||
onReconnect: () => void botAction(
|
||||
account,
|
||||
'reconnect',
|
||||
endpoints.reconnectBot,
|
||||
{ botId: account.botId },
|
||||
),
|
||||
onRequestRemove: () => setRemoveTarget(account.botId),
|
||||
onCancelRemove: () => setRemoveTarget(null),
|
||||
onConfirmRemove: async () => {
|
||||
await botAction(account, 'delete', endpoints.deleteBot, {
|
||||
botId: account.botId,
|
||||
confirm: true,
|
||||
});
|
||||
if (mounted.current) setRemoveTarget(null);
|
||||
},
|
||||
})))))
|
||||
: null;
|
||||
|
||||
return h('section', {
|
||||
className: `ddt-page ${pageClass} dim-channelPage`,
|
||||
'aria-label': `${channel} 设置`,
|
||||
},
|
||||
h('div', { className: 'ddt-heading' },
|
||||
h('div', { className: 'ddt-tools' },
|
||||
h('div', { className: 'dim-bindActions' },
|
||||
h(Button, {
|
||||
kind: 'credential',
|
||||
className: 'dim-credentialButton',
|
||||
onClick: () => { setCredentialOpen((value) => !value); setCredentialError(null); },
|
||||
disabled: busy,
|
||||
'aria-pressed': credentialOpen,
|
||||
'aria-label': `使用 Bot Token 接入 ${channel} 机器人`,
|
||||
}, h(CredentialActionIcon), credentialOpen ? '收起凭据' : '手动接入')),
|
||||
model.totals.configured > 0
|
||||
? h('div', { className: 'ddt-badge dim-onlineBadge' },
|
||||
h('span', null, `${model.totals.connected} / ${model.totals.configured} 在线`))
|
||||
: null)),
|
||||
model.phase === 'loading'
|
||||
? h('div', {
|
||||
className: 'ddt-card ddt-loading dim-surfaceCard dim-loadingView',
|
||||
'aria-busy': 'true',
|
||||
}, h('div', { className: 'ddt-spinner dim-spinner' }), `正在读取 ${channel} 机器人状态…`)
|
||||
: model.phase === 'error'
|
||||
? h('div', { className: 'ddt-card dim-surfaceCard' },
|
||||
h('div', { className: 'ddt-inlineError dim-inlineError' },
|
||||
h('h3', null, `无法读取 ${channel} 机器人状态`),
|
||||
h('p', null, model.error?.message),
|
||||
h(Button, { onClick: () => void loadStatus() }, '重新读取')))
|
||||
: h(React.Fragment, null,
|
||||
credentialOpen ? h(CredentialBindingPanel, {
|
||||
channel,
|
||||
secretLabel: 'Bot Token',
|
||||
secretPlaceholder: tokenPlaceholder,
|
||||
busy,
|
||||
error: credentialError,
|
||||
onSubmit: bindCredentials,
|
||||
onCancel: () => { setCredentialOpen(false); setCredentialError(null); },
|
||||
}) : null,
|
||||
model.bots.length === 0 && !credentialOpen
|
||||
? h('div', { className: 'ddt-card dim-surfaceCard' },
|
||||
h('div', { className: 'ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView' },
|
||||
h('div', { className: 'dim-emptyCopy' },
|
||||
h('div', { className: 'ddt-stateLabel dim-stateLabel' },
|
||||
h('span', { className: 'ddt-dot dim-stateDot' }),
|
||||
h('span', null, `尚未接入 ${channel} 机器人`)),
|
||||
h('h3', null, emptyTitle),
|
||||
h('p', null, emptyDescription),
|
||||
h('div', { className: 'ddt-actions dim-viewActions' },
|
||||
h(Button, {
|
||||
kind: 'primary',
|
||||
onClick: () => setCredentialOpen(true),
|
||||
}, '填写 Bot Token'))),
|
||||
h('div', {
|
||||
className: `ddt-brandMark dim-emptyBrand ${avatarClass}`,
|
||||
'aria-hidden': 'true',
|
||||
}, h(LogoGlyph, { size: 64 }))))
|
||||
: null,
|
||||
botList));
|
||||
}
|
||||
|
||||
return { SettingsTab, AccountCard };
|
||||
}
|
||||
11
plugin-src/client/channels/telegram/api.js
Normal file
11
plugin-src/client/channels/telegram/api.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { TOKEN_BOT_ENDPOINTS, createTokenChannelApi } from '../shared/token-api.js';
|
||||
|
||||
export const TELEGRAM_RPC_CHANNEL = '/telegram';
|
||||
export const TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
|
||||
|
||||
const api = createTokenChannelApi('Telegram', ' Bot API 长轮询');
|
||||
|
||||
export const unwrapRpcResult = api.unwrapRpcResult;
|
||||
export const normalizeSnapshot = api.normalizeSnapshot;
|
||||
export const presentError = api.presentError;
|
||||
export { api as telegramClientApi };
|
||||
25
plugin-src/client/channels/telegram/index.js
Normal file
25
plugin-src/client/channels/telegram/index.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { TelegramLogoGlyph } from '../../channel-logos.js';
|
||||
import { createTokenChannelSettings } from '../shared/token-channel.js';
|
||||
import {
|
||||
TELEGRAM_ENDPOINTS,
|
||||
telegramClientApi,
|
||||
} from './api.js';
|
||||
import { installTelegramStyles } from './styles.js';
|
||||
|
||||
const channel = createTokenChannelSettings({
|
||||
channel: 'Telegram',
|
||||
endpoints: TELEGRAM_ENDPOINTS,
|
||||
api: telegramClientApi,
|
||||
LogoGlyph: TelegramLogoGlyph,
|
||||
installStyles: installTelegramStyles,
|
||||
pageClass: 'dtg-page',
|
||||
avatarClass: 'dtg-avatar',
|
||||
connectionLabel: 'Bot API 长轮询',
|
||||
tokenPlaceholder: '填写 @BotFather 生成的 Bot Token',
|
||||
emptyTitle: '接入 Telegram 机器人',
|
||||
emptyDescription: '先通过 @BotFather 获取 Bot Token,再在这里完成接入。',
|
||||
platformLabel: 'Telegram',
|
||||
});
|
||||
|
||||
export const TelegramSettingsTab = channel.SettingsTab;
|
||||
export const TelegramAccountCard = channel.AccountCard;
|
||||
19
plugin-src/client/channels/telegram/styles.js
Normal file
19
plugin-src/client/channels/telegram/styles.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export const TELEGRAM_STYLE_ID = 'xmanrui-dsh-im-telegram-settings';
|
||||
|
||||
const CSS = String.raw`
|
||||
.dtg-page { --ddt-accent: #229ed9; --ddt-accent-deep: #1687bd; --ddt-accent-wash: #eaf7fd; }
|
||||
.dtg-avatar { color: #fff; background: #229ed9; }
|
||||
.dtg-avatar svg { display: block; }
|
||||
`;
|
||||
|
||||
export function installTelegramStyles() {
|
||||
if (typeof document === 'undefined') return () => {};
|
||||
const existing = document.querySelector(`style[data-plugin-css="${TELEGRAM_STYLE_ID}"]`);
|
||||
if (existing) return () => {};
|
||||
const style = document.createElement('style');
|
||||
style.dataset.plugin = '@xmanrui/dsh-im';
|
||||
style.dataset.pluginCss = TELEGRAM_STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
return () => style.remove();
|
||||
}
|
||||
|
|
@ -52,12 +52,13 @@ export function CredentialBindingPanel({
|
|||
const [identity, setIdentity] = React.useState('');
|
||||
const [secret, setSecret] = React.useState('');
|
||||
const headingId = React.useId();
|
||||
const hasIdentity = Boolean(identityLabel);
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault();
|
||||
const normalizedIdentity = identity.trim();
|
||||
const normalizedSecret = secret.trim();
|
||||
if (!normalizedIdentity || !normalizedSecret || busy) return;
|
||||
if ((hasIdentity && !normalizedIdentity) || !normalizedSecret || busy) return;
|
||||
void onSubmit?.({ identity: normalizedIdentity, secret: normalizedSecret });
|
||||
};
|
||||
|
||||
|
|
@ -66,8 +67,11 @@ export function CredentialBindingPanel({
|
|||
'aria-labelledby': headingId,
|
||||
},
|
||||
h('h3', { id: headingId, className: 'dim-credentialTitle' }, `手动接入${channel}机器人`),
|
||||
h('form', { className: 'dim-credentialForm', onSubmit: submit },
|
||||
h('label', { className: 'dim-credentialField' },
|
||||
h('form', {
|
||||
className: `dim-credentialForm${hasIdentity ? '' : ' dim-credentialFormSingle'}`,
|
||||
onSubmit: submit,
|
||||
},
|
||||
hasIdentity ? h('label', { className: 'dim-credentialField' },
|
||||
h('span', null, identityLabel),
|
||||
h('input', {
|
||||
value: identity,
|
||||
|
|
@ -80,7 +84,7 @@ export function CredentialBindingPanel({
|
|||
autoComplete: 'off',
|
||||
disabled: busy,
|
||||
required: true,
|
||||
})),
|
||||
})) : null,
|
||||
h('label', { className: 'dim-credentialField' },
|
||||
h('span', null, secretLabel),
|
||||
h('input', {
|
||||
|
|
@ -102,7 +106,7 @@ export function CredentialBindingPanel({
|
|||
type: 'submit',
|
||||
className: 'ddt-button',
|
||||
'data-kind': 'primary',
|
||||
disabled: busy || !identity.trim() || !secret.trim(),
|
||||
disabled: busy || (hasIdentity && !identity.trim()) || !secret.trim(),
|
||||
}, busy ? '正在绑定…' : '绑定并连接'),
|
||||
h('button', {
|
||||
type: 'button',
|
||||
|
|
|
|||
|
|
@ -2,19 +2,27 @@ import * as React from 'react';
|
|||
|
||||
import {
|
||||
DingtalkLogoGlyph,
|
||||
DiscordLogoGlyph,
|
||||
FeishuLogoGlyph,
|
||||
QqLogoGlyph,
|
||||
TelegramLogoGlyph,
|
||||
WecomLogoGlyph,
|
||||
WeixinLogoGlyph,
|
||||
} from './channel-logos.js';
|
||||
import { DINGTALK_RPC_CHANNEL } from './channels/dingtalk/api.js';
|
||||
import { DingtalkSettingsTab } from './channels/dingtalk/index.js';
|
||||
import { DISCORD_RPC_CHANNEL } from './channels/discord/api.js';
|
||||
import { DiscordSettingsTab } from './channels/discord/index.js';
|
||||
import { installDiscordStyles } from './channels/discord/styles.js';
|
||||
import { FeishuSettingsTab } from './channels/feishu/index.js';
|
||||
import { FEISHU_RPC_CHANNEL } from './channels/feishu/api.js';
|
||||
import { installFeishuStyles } from './channels/feishu/styles.js';
|
||||
import { QQ_RPC_CHANNEL } from './channels/qq/api.js';
|
||||
import { QqSettingsTab } from './channels/qq/index.js';
|
||||
import { installQqStyles } from './channels/qq/styles.js';
|
||||
import { TELEGRAM_RPC_CHANNEL } from './channels/telegram/api.js';
|
||||
import { TelegramSettingsTab } from './channels/telegram/index.js';
|
||||
import { installTelegramStyles } from './channels/telegram/styles.js';
|
||||
import { WECOM_RPC_CHANNEL } from './channels/wecom/api.js';
|
||||
import { WecomSettingsTab } from './channels/wecom/index.js';
|
||||
import { installWecomStyles } from './channels/wecom/styles.js';
|
||||
|
|
@ -34,6 +42,8 @@ const CHANNELS = Object.freeze([
|
|||
{ id: 'dingtalk', label: '钉钉' },
|
||||
{ id: 'wecom', label: '企业微信' },
|
||||
{ id: 'qq', label: 'QQ' },
|
||||
{ id: 'telegram', label: 'Telegram' },
|
||||
{ id: 'discord', label: 'Discord' },
|
||||
]);
|
||||
|
||||
function WeixinLogo() {
|
||||
|
|
@ -59,20 +69,40 @@ function WecomLogo() {
|
|||
return h('span', { className: 'dim-logo dim-logoWecom', 'aria-hidden': 'true' }, h(WecomLogoGlyph));
|
||||
}
|
||||
|
||||
function TelegramLogo() {
|
||||
return h('span', { className: 'dim-logo dim-logoTelegram', 'aria-hidden': 'true' },
|
||||
h(TelegramLogoGlyph));
|
||||
}
|
||||
|
||||
function DiscordLogo() {
|
||||
return h('span', { className: 'dim-logo dim-logoDiscord', 'aria-hidden': 'true' },
|
||||
h(DiscordLogoGlyph));
|
||||
}
|
||||
|
||||
function ChannelLogo({ channel }) {
|
||||
if (channel === 'weixin') return h(WeixinLogo);
|
||||
if (channel === 'feishu') return h(FeishuLogo);
|
||||
if (channel === 'dingtalk') return h(DingtalkLogo);
|
||||
if (channel === 'wecom') return h(WecomLogo);
|
||||
return h(QqLogo);
|
||||
if (channel === 'qq') return h(QqLogo);
|
||||
if (channel === 'telegram') return h(TelegramLogo);
|
||||
return h(DiscordLogo);
|
||||
}
|
||||
|
||||
export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall, weixinRpcCall }) {
|
||||
export function IMSettingsTab({
|
||||
dingtalkRpcCall,
|
||||
discordRpcCall,
|
||||
feishuRpcCall,
|
||||
qqRpcCall,
|
||||
telegramRpcCall,
|
||||
wecomRpcCall,
|
||||
weixinRpcCall,
|
||||
}) {
|
||||
const [selected, setSelected] = React.useState('weixin');
|
||||
const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
|
||||
return h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
|
||||
h('header', { className: 'dim-title' },
|
||||
h('p', null, '通过扫码把机器人接入 DeepSeek Harness'),
|
||||
h('p', null, '把机器人接入 DeepSeek Harness'),
|
||||
),
|
||||
h('div', { className: 'dim-layout' },
|
||||
h('nav', { className: 'dim-rail', role: 'tablist', 'aria-label': 'IM 渠道' },
|
||||
|
|
@ -104,7 +134,11 @@ export function IMSettingsTab({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecom
|
|||
? h(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall })
|
||||
: active.id === 'wecom'
|
||||
? h(WecomSettingsTab, { rpcCall: wecomRpcCall })
|
||||
: h(QqSettingsTab, { rpcCall: qqRpcCall })),
|
||||
: active.id === 'qq'
|
||||
? h(QqSettingsTab, { rpcCall: qqRpcCall })
|
||||
: active.id === 'telegram'
|
||||
? h(TelegramSettingsTab, { rpcCall: telegramRpcCall })
|
||||
: h(DiscordSettingsTab, { rpcCall: discordRpcCall })),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -116,6 +150,8 @@ export function apply(ctx) {
|
|||
installWeixinStyles(),
|
||||
installWecomStyles(),
|
||||
installQqStyles(),
|
||||
installTelegramStyles(),
|
||||
installDiscordStyles(),
|
||||
installImStyles(),
|
||||
];
|
||||
return () => {
|
||||
|
|
@ -133,12 +169,24 @@ export function apply(ctx) {
|
|||
ctx.connection.rpc.call(QQ_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const wecomRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(WECOM_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const telegramRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(TELEGRAM_RPC_CHANNEL, endpoint, payload, signal);
|
||||
const discordRpcCall = (endpoint, payload, signal) =>
|
||||
ctx.connection.rpc.call(DISCORD_RPC_CHANNEL, endpoint, payload, signal);
|
||||
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
id: 'im',
|
||||
order: 20,
|
||||
label: 'IM机器人',
|
||||
inject: () => ({ dingtalkRpcCall, feishuRpcCall, qqRpcCall, wecomRpcCall, weixinRpcCall }),
|
||||
inject: () => ({
|
||||
dingtalkRpcCall,
|
||||
discordRpcCall,
|
||||
feishuRpcCall,
|
||||
qqRpcCall,
|
||||
telegramRpcCall,
|
||||
wecomRpcCall,
|
||||
weixinRpcCall,
|
||||
}),
|
||||
}, IMSettingsTab));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ const CSS = String.raw`
|
|||
.dim-logoQq svg { width: 21px; height: 21px; }
|
||||
.dim-logoWecom { background: white; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); }
|
||||
.dim-logoWecom svg { width: 22px; height: 22px; }
|
||||
.dim-logoTelegram { color: white; background: #229ed9; }
|
||||
.dim-logoTelegram svg { width: 21px; height: 21px; }
|
||||
.dim-logoDiscord { color: white; background: #5865f2; }
|
||||
.dim-logoDiscord svg { width: 21px; height: 21px; }
|
||||
.dim-channelCopy { min-width: 0; display: block; }
|
||||
.dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 14px; line-height: 20px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-line-divider, #eef0f3); }
|
||||
|
|
@ -55,6 +59,7 @@ const CSS = String.raw`
|
|||
.dim-panel .dim-credentialPanel { display: grid; gap: 18px; padding: 20px; }
|
||||
.dim-panel .dim-credentialTitle { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: 1.35; font-weight: 650; }
|
||||
.dim-panel .dim-credentialForm { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 12px; }
|
||||
.dim-panel .dim-credentialFormSingle { grid-template-columns: minmax(0, 1fr); }
|
||||
.dim-panel .dim-credentialField { min-width: 0; display: grid; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: normal; font-weight: 560; }
|
||||
.dim-panel .dim-credentialField input { width: 100%; min-width: 0; height: 38px; padding: 0 11px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 8px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; transition: border-color .16s ease, box-shadow .16s ease; }
|
||||
.dim-panel .dim-credentialField input:focus { border-color: #4e5969; box-shadow: 0 0 0 3px rgb(78 89 105 / 10%); }
|
||||
|
|
|
|||
|
|
@ -5,10 +5,17 @@ export const name = 'dsh-dingtalk-host';
|
|||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) return installDingtalkRpc(ctx, config.controller, config.rpcOptions);
|
||||
if (config?.controller) {
|
||||
return installDingtalkRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
|
||||
}
|
||||
|
||||
const production = await createProductionController(ctx, config, config.internals);
|
||||
const disposeRpc = installDingtalkRpc(ctx, production.controller, config.rpcOptions);
|
||||
const disposeRpc = installDingtalkRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
config.rpcOptions,
|
||||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => {
|
||||
await production.close();
|
||||
}, 'dsh-dingtalk: close bot connections');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import QRCode from 'qrcode';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
|
||||
export const DINGTALK_RPC_CHANNEL = '/dingtalk';
|
||||
export const DINGTALK_ENDPOINTS = Object.freeze({
|
||||
|
|
@ -224,13 +225,13 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
|
|||
};
|
||||
}
|
||||
|
||||
export function installDingtalkRpc(ctx, controller, options) {
|
||||
export function installDingtalkRpc(ctx, controller, options, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
DINGTALK_RPC_CHANNEL,
|
||||
createDingtalkRpcHandler(controller, options),
|
||||
{ authority: 'loopback' },
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
30
plugin-src/host/channels/discord/index.mjs
Normal file
30
plugin-src/host/channels/discord/index.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { createProductionController } from './production.mjs';
|
||||
import { installDiscordRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-discord-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
return installDiscordRpc(ctx, config.controller, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals ?? {});
|
||||
const disposeRpc = installDiscordRpc(ctx, production.controller, config.rpcAuthority);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close Discord bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
export function createDiscordHostPlugin(config) {
|
||||
return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) });
|
||||
}
|
||||
|
||||
export { createProductionController } from './production.mjs';
|
||||
export {
|
||||
DISCORD_ENDPOINTS,
|
||||
DISCORD_RPC_CHANNEL,
|
||||
DISCORD_RPC_ENDPOINTS,
|
||||
createDiscordRpcHandler,
|
||||
installDiscordRpc,
|
||||
} from './rpc.mjs';
|
||||
export { DiscordController } from '../../../../src/channels/discord/discord-controller.mjs';
|
||||
export { DiscordRuntime } from '../../../../src/channels/discord/discord-runtime.mjs';
|
||||
17
plugin-src/host/channels/discord/production.mjs
Normal file
17
plugin-src/host/channels/discord/production.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { DiscordConfigStore } from '../../../../src/channels/discord/config-store.mjs';
|
||||
import { DiscordController } from '../../../../src/channels/discord/discord-controller.mjs';
|
||||
import { DiscordHarnessClient } from '../../../../src/channels/discord/harness-client.mjs';
|
||||
import { DiscordRuntime } from '../../../../src/channels/discord/discord-runtime.mjs';
|
||||
import { DiscordStateStore } from '../../../../src/channels/discord/state-store.mjs';
|
||||
import { createTokenProductionController } from '../shared/production.mjs';
|
||||
|
||||
export function createProductionController(ctx, config = {}, internals = {}) {
|
||||
return createTokenProductionController(ctx, config, internals, {
|
||||
channel: 'discord',
|
||||
ConfigStore: DiscordConfigStore,
|
||||
StateStore: DiscordStateStore,
|
||||
HarnessClient: DiscordHarnessClient,
|
||||
Controller: DiscordController,
|
||||
Runtime: DiscordRuntime,
|
||||
});
|
||||
}
|
||||
21
plugin-src/host/channels/discord/rpc.mjs
Normal file
21
plugin-src/host/channels/discord/rpc.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import {
|
||||
TOKEN_BOT_ENDPOINTS,
|
||||
createTokenBotRpcHandler,
|
||||
installTokenBotRpc,
|
||||
} from '../shared/rpc.mjs';
|
||||
|
||||
export const DISCORD_RPC_CHANNEL = '/discord';
|
||||
export const DISCORD_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
|
||||
export const DISCORD_RPC_ENDPOINTS = Object.freeze(Object.values(DISCORD_ENDPOINTS));
|
||||
|
||||
export function createDiscordRpcHandler(controller) {
|
||||
return createTokenBotRpcHandler(controller, { channel: 'Discord' });
|
||||
}
|
||||
|
||||
export function installDiscordRpc(ctx, controller, authority) {
|
||||
return installTokenBotRpc(ctx, controller, {
|
||||
channel: 'Discord',
|
||||
rpcChannel: DISCORD_RPC_CHANNEL,
|
||||
authority,
|
||||
});
|
||||
}
|
||||
|
|
@ -24,10 +24,17 @@ function controllerFrom(ctx, config) {
|
|||
*/
|
||||
export async function apply(ctx, config = {}) {
|
||||
const controller = controllerFrom(ctx, config);
|
||||
if (controller) return installFeishuRpc(ctx, controller);
|
||||
if (controller) {
|
||||
return installFeishuRpc(ctx, controller, config.rpcOptions, config.rpcAuthority);
|
||||
}
|
||||
|
||||
const production = await createProductionController(ctx, config);
|
||||
const disposeRpc = installFeishuRpc(ctx, production.controller);
|
||||
const disposeRpc = installFeishuRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
config.rpcOptions,
|
||||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => {
|
||||
await production.close();
|
||||
}, 'dsh-feishu: close controller and live connection');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import QRCode from 'qrcode';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
import {
|
||||
FEISHU_ENDPOINTS,
|
||||
FEISHU_RPC_CHANNEL,
|
||||
|
|
@ -440,14 +441,14 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
|
|||
};
|
||||
}
|
||||
|
||||
/** Register the loopback-only `/feishu` logical channel. */
|
||||
export function installFeishuRpc(ctx, controller, options) {
|
||||
/** Register the `/feishu` logical channel with its configured browser authority. */
|
||||
export function installFeishuRpc(ctx, controller, options, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
FEISHU_RPC_CHANNEL,
|
||||
createFeishuRpcHandler(controller, options),
|
||||
{ authority: 'loopback' },
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,16 @@ export const name = 'dsh-im-qq-host';
|
|||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) return installQqRpc(ctx, config.controller, config.rpcOptions);
|
||||
if (config?.controller) {
|
||||
return installQqRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals);
|
||||
const disposeRpc = installQqRpc(ctx, production.controller, config.rpcOptions);
|
||||
const disposeRpc = installQqRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
config.rpcOptions,
|
||||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close QQ bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import QRCode from 'qrcode';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
|
||||
export const QQ_RPC_CHANNEL = '/qq';
|
||||
export const QQ_ENDPOINTS = Object.freeze({
|
||||
|
|
@ -133,9 +134,13 @@ export function createQqRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
export function installQqRpc(ctx, controller, options) {
|
||||
export function installQqRpc(ctx, controller, options, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(QQ_RPC_CHANNEL, createQqRpcHandler(controller, options), { authority: 'loopback' });
|
||||
return ctx.connection.rpc.handle(
|
||||
QQ_RPC_CHANNEL,
|
||||
createQqRpcHandler(controller, options),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
133
plugin-src/host/channels/shared/connection-supervisor.mjs
Normal file
133
plugin-src/host/channels/shared/connection-supervisor.mjs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
const DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000, 5_000, 10_000, 30_000]);
|
||||
|
||||
function retryDelays(value) {
|
||||
if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS];
|
||||
const valid = value.filter((delay) => Number.isFinite(delay) && delay >= 0);
|
||||
return valid.length > 0 ? valid : [...DEFAULT_RETRY_DELAYS_MS];
|
||||
}
|
||||
|
||||
export class TokenConnectionSupervisor {
|
||||
#channel;
|
||||
#controller;
|
||||
#harness;
|
||||
#logger;
|
||||
#retryDelays;
|
||||
#healthyIntervalMs;
|
||||
#setTimeout;
|
||||
#clearTimeout;
|
||||
#timer = null;
|
||||
#running = null;
|
||||
#retryIndex = 0;
|
||||
#closed = false;
|
||||
#started = false;
|
||||
#ready;
|
||||
#resolveReady;
|
||||
|
||||
constructor({
|
||||
channel,
|
||||
controller,
|
||||
harness,
|
||||
logger = console,
|
||||
retryDelaysMs,
|
||||
healthyIntervalMs = 15_000,
|
||||
setTimeoutImpl = setTimeout,
|
||||
clearTimeoutImpl = clearTimeout,
|
||||
}) {
|
||||
if (!channel) throw new TypeError('TokenConnectionSupervisor requires a channel');
|
||||
if (!controller || typeof controller.initialize !== 'function' || typeof controller.status !== 'function') {
|
||||
throw new TypeError('TokenConnectionSupervisor requires a controller');
|
||||
}
|
||||
if (!harness || typeof harness.ensureRunning !== 'function') {
|
||||
throw new TypeError('TokenConnectionSupervisor requires a Harness client');
|
||||
}
|
||||
this.#channel = channel;
|
||||
this.#controller = controller;
|
||||
this.#harness = harness;
|
||||
this.#logger = logger;
|
||||
this.#retryDelays = retryDelays(retryDelaysMs);
|
||||
this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0
|
||||
? healthyIntervalMs : 15_000;
|
||||
this.#setTimeout = setTimeoutImpl;
|
||||
this.#clearTimeout = clearTimeoutImpl;
|
||||
this.#ready = new Promise((resolve) => {
|
||||
this.#resolveReady = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
get ready() {
|
||||
return this.#ready;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.#started || this.#closed) return this;
|
||||
this.#started = true;
|
||||
this.#schedule(0);
|
||||
return this;
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
if (this.#timer !== null) this.#clearTimeout(this.#timer);
|
||||
this.#timer = null;
|
||||
await this.#running?.catch(() => undefined);
|
||||
this.#resolveReady?.(null);
|
||||
this.#resolveReady = null;
|
||||
}
|
||||
|
||||
#schedule(delayMs) {
|
||||
if (this.#closed) return;
|
||||
this.#timer = this.#setTimeout(() => {
|
||||
this.#timer = null;
|
||||
void this.#run();
|
||||
}, delayMs);
|
||||
this.#timer?.unref?.();
|
||||
}
|
||||
|
||||
async #run() {
|
||||
if (this.#closed || this.#running) return;
|
||||
const operation = this.#reconcile();
|
||||
this.#running = operation;
|
||||
try {
|
||||
await operation;
|
||||
} finally {
|
||||
if (this.#running === operation) this.#running = null;
|
||||
}
|
||||
}
|
||||
|
||||
async #reconcile() {
|
||||
try {
|
||||
await this.#harness.ensureRunning();
|
||||
if (this.#closed) return;
|
||||
const status = await this.#controller.initialize();
|
||||
if (this.#closed) return;
|
||||
this.#resolveReady?.(status);
|
||||
this.#resolveReady = null;
|
||||
const { configured, connected } = status.totals;
|
||||
if (connected < configured) {
|
||||
const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
|
||||
this.#retryIndex += 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#channel}] ${connected}/${configured} bots connected; retrying in ${delay}ms`,
|
||||
);
|
||||
this.#schedule(delay);
|
||||
return;
|
||||
}
|
||||
this.#retryIndex = 0;
|
||||
this.#schedule(this.#healthyIntervalMs);
|
||||
} catch (error) {
|
||||
if (this.#closed) return;
|
||||
const delay = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
|
||||
this.#retryIndex += 1;
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#channel}] connection reconciliation failed; retrying in ${delay}ms`,
|
||||
error,
|
||||
);
|
||||
this.#schedule(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createTokenConnectionSupervisor(options) {
|
||||
return new TokenConnectionSupervisor(options);
|
||||
}
|
||||
104
plugin-src/host/channels/shared/production.mjs
Normal file
104
plugin-src/host/channels/shared/production.mjs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { unlink } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { createTokenConnectionSupervisor } from './connection-supervisor.mjs';
|
||||
|
||||
function harnessOrigin(webServer, configured) {
|
||||
if (configured !== undefined) return new URL(configured);
|
||||
const port = webServer?.port;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||||
throw new Error('dsh-im token channel requires an initialized DSH webServer port');
|
||||
}
|
||||
return new URL(`http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
function pluginPaths(config, channel) {
|
||||
const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
|
||||
const root = resolve(config.dataDir ?? join(dshHome, 'integrations', `dsh-${channel}`));
|
||||
return {
|
||||
config: resolve(config.configPath ?? join(root, 'config.json')),
|
||||
bots: resolve(config.botsDir ?? join(root, 'bots')),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createTokenProductionController(ctx, config, internals, definitions) {
|
||||
const { channel, ConfigStore, StateStore, HarnessClient, Controller, Runtime } = definitions;
|
||||
if (!ctx?.credentials) throw new TypeError(`dsh-im ${channel} requires ctx.credentials`);
|
||||
if (!ctx?.webServer) throw new TypeError(`dsh-im ${channel} requires ctx.webServer`);
|
||||
|
||||
const ResolvedConfigStore = internals.ConfigStore ?? ConfigStore;
|
||||
const ResolvedStateStore = internals.StateStore ?? StateStore;
|
||||
const ResolvedHarness = internals.HarnessClient ?? HarnessClient;
|
||||
const ResolvedController = internals.Controller ?? Controller;
|
||||
const ResolvedRuntime = internals.Runtime ?? Runtime;
|
||||
const createSupervisor = internals.createConnectionSupervisor ?? createTokenConnectionSupervisor;
|
||||
const logger = typeof ctx.logger === 'function'
|
||||
? ctx.logger(`dsh-im:${channel}`) : (ctx.logger ?? console);
|
||||
const paths = pluginPaths(config, channel);
|
||||
const configStore = await new ResolvedConfigStore(paths.config).load();
|
||||
const stateStores = new Map();
|
||||
const statePath = (botId) => resolve(paths.bots, botId, 'state.json');
|
||||
const stateFor = async (botId) => {
|
||||
let state = stateStores.get(botId);
|
||||
if (!state) {
|
||||
state = await new ResolvedStateStore(statePath(botId)).load();
|
||||
stateStores.set(botId, state);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
const harness = new ResolvedHarness({
|
||||
baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
|
||||
workspace: resolve(config.workspace ?? process.cwd()),
|
||||
agentPreset: config.agentPreset ?? 'standard',
|
||||
autostart: false,
|
||||
dshBin: config.dshBin ?? 'dsh',
|
||||
});
|
||||
const controller = new ResolvedController({
|
||||
credentials: ctx.credentials,
|
||||
configStore,
|
||||
logger,
|
||||
...(internals.inspectToken ? { inspectToken: internals.inspectToken } : {}),
|
||||
createRuntime: async ({ botId, config: botConfig, token }) => new ResolvedRuntime({
|
||||
config: botConfig,
|
||||
token,
|
||||
harness,
|
||||
state: await stateFor(botId),
|
||||
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
||||
connectTimeoutMs: config.connectTimeoutMs ?? 20_000,
|
||||
logger: {
|
||||
error: (...args) => logger.error?.(`[${botId}]`, ...args),
|
||||
warn: (...args) => logger.warn?.(`[${botId}]`, ...args),
|
||||
info: (...args) => logger.info?.(`[${botId}]`, ...args),
|
||||
debug: (...args) => logger.debug?.(`[${botId}]`, ...args),
|
||||
},
|
||||
}),
|
||||
deleteState: async ({ botId }) => {
|
||||
const state = stateStores.get(botId);
|
||||
stateStores.delete(botId);
|
||||
if (state && typeof state.remove === 'function') return state.remove();
|
||||
try {
|
||||
await unlink(statePath(botId));
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
const supervisor = createSupervisor({
|
||||
channel,
|
||||
controller,
|
||||
harness,
|
||||
logger,
|
||||
retryDelaysMs: config.retryDelaysMs,
|
||||
healthyIntervalMs: config.healthyIntervalMs,
|
||||
}).start();
|
||||
return {
|
||||
controller,
|
||||
ready: supervisor.ready,
|
||||
async close() {
|
||||
await supervisor.close();
|
||||
await controller.close();
|
||||
harness.stopManagedProcess();
|
||||
},
|
||||
};
|
||||
}
|
||||
119
plugin-src/host/channels/shared/rpc.mjs
Normal file
119
plugin-src/host/channels/shared/rpc.mjs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
|
||||
export const TOKEN_BOT_ENDPOINTS = Object.freeze({
|
||||
status: 'connection.status',
|
||||
bindCredentials: 'bot.bind-credentials',
|
||||
reconnectBot: 'bot.reconnect',
|
||||
deleteBot: 'bot.delete',
|
||||
});
|
||||
|
||||
const ENDPOINTS = Object.freeze(Object.values(TOKEN_BOT_ENDPOINTS));
|
||||
const FORBIDDEN_PUBLIC_KEYS = new Set([
|
||||
'token', 'botToken', 'tokenRef', 'platformId', 'secret', 'secretRef',
|
||||
]);
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value, allowed) {
|
||||
return isRecord(value) && Object.keys(value).every((key) => allowed.includes(key));
|
||||
}
|
||||
|
||||
function validId(value) {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
}
|
||||
|
||||
function validToken(value) {
|
||||
return typeof value === 'string' && value.trim().length >= 20 && value.length <= 4_096;
|
||||
}
|
||||
|
||||
function payloadFailure(endpoint, payload) {
|
||||
if (!isRecord(payload)) return 'Payload must be an object.';
|
||||
if (endpoint === TOKEN_BOT_ENDPOINTS.status) {
|
||||
return exactKeys(payload, []) ? null : 'connection.status does not accept fields.';
|
||||
}
|
||||
if (endpoint === TOKEN_BOT_ENDPOINTS.bindCredentials) {
|
||||
return exactKeys(payload, ['token']) && validToken(payload.token)
|
||||
? null : 'bot.bind-credentials requires a Bot Token.';
|
||||
}
|
||||
if (endpoint === TOKEN_BOT_ENDPOINTS.reconnectBot) {
|
||||
return exactKeys(payload, ['botId']) && validId(payload.botId)
|
||||
? null : 'bot.reconnect requires a botId.';
|
||||
}
|
||||
if (endpoint === TOKEN_BOT_ENDPOINTS.deleteBot) {
|
||||
return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
|
||||
? null : 'bot.delete requires a botId and confirm=true.';
|
||||
}
|
||||
return 'Unknown bot endpoint.';
|
||||
}
|
||||
|
||||
function sanitizePublic(value) {
|
||||
if (Array.isArray(value)) return value.map(sanitizePublic);
|
||||
if (!isRecord(value)) return value;
|
||||
const safe = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (!FORBIDDEN_PUBLIC_KEYS.has(key)) safe[key] = sanitizePublic(child);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
function operationError(channel, error) {
|
||||
if (error?.code === 'webhook-configured') {
|
||||
return { code: 'webhook-configured', message: error.message };
|
||||
}
|
||||
if (error?.code === 'telegram-401' || error?.code === 'discord-401') {
|
||||
return { code: 'invalid-token', message: `${channel} Bot Token 无效,请重新填写。` };
|
||||
}
|
||||
if (error?.code === 'discord-intents') {
|
||||
return { code: 'discord-intents', message: error.message };
|
||||
}
|
||||
return { code: `${channel.toLowerCase()}-operation-failed`, message: `${channel} 操作失败,请稍后重试。` };
|
||||
}
|
||||
|
||||
export function createTokenBotRpcHandler(controller, { channel }) {
|
||||
for (const method of ['status', 'bindCredentials', 'reconnectBot', 'deleteBot']) {
|
||||
if (typeof controller?.[method] !== 'function') {
|
||||
throw new TypeError(`A complete ${channel} controller is required (${method})`);
|
||||
}
|
||||
}
|
||||
return async (endpoint, payload, signal) => {
|
||||
if (signal?.aborted) {
|
||||
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
|
||||
}
|
||||
if (!ENDPOINTS.includes(endpoint)) {
|
||||
return { ok: false, error: { code: 'bad-request', message: `Unknown ${channel} endpoint.` } };
|
||||
}
|
||||
const invalid = payloadFailure(endpoint, payload);
|
||||
if (invalid) return { ok: false, error: { code: 'bad-request', message: invalid } };
|
||||
try {
|
||||
let value;
|
||||
if (endpoint === TOKEN_BOT_ENDPOINTS.status) value = await controller.status();
|
||||
else if (endpoint === TOKEN_BOT_ENDPOINTS.bindCredentials) {
|
||||
value = await controller.bindCredentials(payload);
|
||||
} else if (endpoint === TOKEN_BOT_ENDPOINTS.reconnectBot) {
|
||||
value = await controller.reconnectBot(payload.botId);
|
||||
} else {
|
||||
value = await controller.deleteBot(payload.botId);
|
||||
}
|
||||
return signal?.aborted
|
||||
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
|
||||
: { ok: true, value: sanitizePublic(value) };
|
||||
} catch (error) {
|
||||
return signal?.aborted
|
||||
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
|
||||
: { ok: false, error: operationError(channel, error) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function installTokenBotRpc(ctx, controller, { channel, rpcChannel, authority }) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
rpcChannel,
|
||||
createTokenBotRpcHandler(controller, { channel }),
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
30
plugin-src/host/channels/telegram/index.mjs
Normal file
30
plugin-src/host/channels/telegram/index.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { createProductionController } from './production.mjs';
|
||||
import { installTelegramRpc } from './rpc.mjs';
|
||||
|
||||
export const name = 'dsh-im-telegram-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) {
|
||||
return installTelegramRpc(ctx, config.controller, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals ?? {});
|
||||
const disposeRpc = installTelegramRpc(ctx, production.controller, config.rpcAuthority);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close Telegram bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
||||
export function createTelegramHostPlugin(config) {
|
||||
return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) });
|
||||
}
|
||||
|
||||
export { createProductionController } from './production.mjs';
|
||||
export {
|
||||
TELEGRAM_ENDPOINTS,
|
||||
TELEGRAM_RPC_CHANNEL,
|
||||
TELEGRAM_RPC_ENDPOINTS,
|
||||
createTelegramRpcHandler,
|
||||
installTelegramRpc,
|
||||
} from './rpc.mjs';
|
||||
export { TelegramController } from '../../../../src/channels/telegram/telegram-controller.mjs';
|
||||
export { TelegramRuntime } from '../../../../src/channels/telegram/telegram-runtime.mjs';
|
||||
17
plugin-src/host/channels/telegram/production.mjs
Normal file
17
plugin-src/host/channels/telegram/production.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { TelegramConfigStore } from '../../../../src/channels/telegram/config-store.mjs';
|
||||
import { TelegramHarnessClient } from '../../../../src/channels/telegram/harness-client.mjs';
|
||||
import { TelegramController } from '../../../../src/channels/telegram/telegram-controller.mjs';
|
||||
import { TelegramRuntime } from '../../../../src/channels/telegram/telegram-runtime.mjs';
|
||||
import { TelegramStateStore } from '../../../../src/channels/telegram/state-store.mjs';
|
||||
import { createTokenProductionController } from '../shared/production.mjs';
|
||||
|
||||
export function createProductionController(ctx, config = {}, internals = {}) {
|
||||
return createTokenProductionController(ctx, config, internals, {
|
||||
channel: 'telegram',
|
||||
ConfigStore: TelegramConfigStore,
|
||||
StateStore: TelegramStateStore,
|
||||
HarnessClient: TelegramHarnessClient,
|
||||
Controller: TelegramController,
|
||||
Runtime: TelegramRuntime,
|
||||
});
|
||||
}
|
||||
21
plugin-src/host/channels/telegram/rpc.mjs
Normal file
21
plugin-src/host/channels/telegram/rpc.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import {
|
||||
TOKEN_BOT_ENDPOINTS,
|
||||
createTokenBotRpcHandler,
|
||||
installTokenBotRpc,
|
||||
} from '../shared/rpc.mjs';
|
||||
|
||||
export const TELEGRAM_RPC_CHANNEL = '/telegram';
|
||||
export const TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
|
||||
export const TELEGRAM_RPC_ENDPOINTS = Object.freeze(Object.values(TELEGRAM_ENDPOINTS));
|
||||
|
||||
export function createTelegramRpcHandler(controller) {
|
||||
return createTokenBotRpcHandler(controller, { channel: 'Telegram' });
|
||||
}
|
||||
|
||||
export function installTelegramRpc(ctx, controller, authority) {
|
||||
return installTokenBotRpc(ctx, controller, {
|
||||
channel: 'Telegram',
|
||||
rpcChannel: TELEGRAM_RPC_CHANNEL,
|
||||
authority,
|
||||
});
|
||||
}
|
||||
|
|
@ -5,9 +5,16 @@ export const name = 'dsh-im-wecom-host';
|
|||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) return installWecomRpc(ctx, config.controller, config.rpcOptions);
|
||||
if (config?.controller) {
|
||||
return installWecomRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
|
||||
}
|
||||
const production = await createProductionController(ctx, config, config.internals);
|
||||
const disposeRpc = installWecomRpc(ctx, production.controller, config.rpcOptions);
|
||||
const disposeRpc = installWecomRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
config.rpcOptions,
|
||||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => production.close(), 'dsh-im: close Enterprise WeChat bot connections');
|
||||
return disposeRpc;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import QRCode from 'qrcode';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
|
||||
export const WECOM_RPC_CHANNEL = '/wecom';
|
||||
export const WECOM_ENDPOINTS = Object.freeze({
|
||||
|
|
@ -138,13 +139,13 @@ export function createWecomRpcHandler(controller, { encodeQr = qrDataUrl } = {})
|
|||
};
|
||||
}
|
||||
|
||||
export function installWecomRpc(ctx, controller, options) {
|
||||
export function installWecomRpc(ctx, controller, options, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
WECOM_RPC_CHANNEL,
|
||||
createWecomRpcHandler(controller, options),
|
||||
{ authority: 'loopback' },
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,17 @@ export const name = 'dsh-weixin-host';
|
|||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
|
||||
export async function apply(ctx, config = {}) {
|
||||
if (config?.controller) return installWeixinRpc(ctx, config.controller, config.rpcOptions);
|
||||
if (config?.controller) {
|
||||
return installWeixinRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
|
||||
}
|
||||
|
||||
const production = await createProductionController(ctx, config, config.internals);
|
||||
const disposeRpc = installWeixinRpc(ctx, production.controller, config.rpcOptions);
|
||||
const disposeRpc = installWeixinRpc(
|
||||
ctx,
|
||||
production.controller,
|
||||
config.rpcOptions,
|
||||
config.rpcAuthority,
|
||||
);
|
||||
ctx.effect(() => async () => {
|
||||
await production.close();
|
||||
}, 'dsh-weixin: close account connections');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import QRCode from 'qrcode';
|
||||
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
|
||||
|
||||
export const WEIXIN_RPC_CHANNEL = '/weixin';
|
||||
export const WEIXIN_ENDPOINTS = Object.freeze({
|
||||
|
|
@ -165,13 +166,13 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}
|
|||
};
|
||||
}
|
||||
|
||||
export function installWeixinRpc(ctx, controller, options) {
|
||||
export function installWeixinRpc(ctx, controller, options, authority) {
|
||||
if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
|
||||
throw new TypeError('DSH Host Connection RPC is required');
|
||||
}
|
||||
return ctx.connection.rpc.handle(
|
||||
WEIXIN_RPC_CHANNEL,
|
||||
createWeixinRpcHandler(controller, options),
|
||||
{ authority: 'loopback' },
|
||||
{ authority: resolveRpcAuthority(authority) },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,40 @@
|
|||
import { apply as applyDingtalk } from './channels/dingtalk/index.mjs';
|
||||
import { apply as applyDiscord } from './channels/discord/index.mjs';
|
||||
import { apply as applyFeishu } from './channels/feishu/index.mjs';
|
||||
import { apply as applyQq } from './channels/qq/index.mjs';
|
||||
import { apply as applyTelegram } from './channels/telegram/index.mjs';
|
||||
import { apply as applyWecom } from './channels/wecom/index.mjs';
|
||||
import { apply as applyWeixin } from './channels/weixin/index.mjs';
|
||||
|
||||
export const name = 'dsh-im-host';
|
||||
export const inject = ['connection', 'credentials', 'webServer'];
|
||||
|
||||
function channelConfig(config, name) {
|
||||
const channel = config[name] ?? {};
|
||||
return config.rpcAuthority === undefined
|
||||
? channel
|
||||
: { ...channel, rpcAuthority: config.rpcAuthority };
|
||||
}
|
||||
|
||||
export function createImHostPlugin(internals = {}) {
|
||||
const startFeishu = internals.applyFeishu ?? applyFeishu;
|
||||
const startWeixin = internals.applyWeixin ?? applyWeixin;
|
||||
const startDingtalk = internals.applyDingtalk ?? applyDingtalk;
|
||||
const startWecom = internals.applyWecom ?? applyWecom;
|
||||
const startQq = internals.applyQq ?? applyQq;
|
||||
const startTelegram = internals.applyTelegram ?? applyTelegram;
|
||||
const startDiscord = internals.applyDiscord ?? applyDiscord;
|
||||
return Object.freeze({
|
||||
name,
|
||||
inject,
|
||||
async apply(ctx, config = {}) {
|
||||
await startFeishu(ctx, config.feishu ?? {});
|
||||
await startWeixin(ctx, config.weixin ?? {});
|
||||
await startDingtalk(ctx, config.dingtalk ?? {});
|
||||
await startWecom(ctx, config.wecom ?? {});
|
||||
await startQq(ctx, config.qq ?? {});
|
||||
await startFeishu(ctx, channelConfig(config, 'feishu'));
|
||||
await startWeixin(ctx, channelConfig(config, 'weixin'));
|
||||
await startDingtalk(ctx, channelConfig(config, 'dingtalk'));
|
||||
await startWecom(ctx, channelConfig(config, 'wecom'));
|
||||
await startQq(ctx, channelConfig(config, 'qq'));
|
||||
await startTelegram(ctx, channelConfig(config, 'telegram'));
|
||||
await startDiscord(ctx, channelConfig(config, 'discord'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
11
plugin-src/host/rpc-authority.mjs
Normal file
11
plugin-src/host/rpc-authority.mjs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
const RPC_AUTHORITIES = new Set(['loopback', 'trusted-host']);
|
||||
|
||||
/**
|
||||
* Resolve the browser authority accepted by an IM management RPC channel.
|
||||
* The default keeps credential and bot-management operations on loopback.
|
||||
*/
|
||||
export function resolveRpcAuthority(value) {
|
||||
if (value === undefined) return 'loopback';
|
||||
if (RPC_AUTHORITIES.has(value)) return value;
|
||||
throw new TypeError('dsh-im rpcAuthority must be "loopback" or "trusted-host"');
|
||||
}
|
||||
|
|
@ -15,11 +15,15 @@ const required = [
|
|||
'plugin-src/host/channels/dingtalk/index.mjs',
|
||||
'plugin-src/host/channels/qq/index.mjs',
|
||||
'plugin-src/host/channels/wecom/index.mjs',
|
||||
'plugin-src/host/channels/telegram/index.mjs',
|
||||
'plugin-src/host/channels/discord/index.mjs',
|
||||
'src/channels/feishu/feishu-runtime.mjs',
|
||||
'src/channels/weixin/weixin-runtime.mjs',
|
||||
'src/channels/dingtalk/dingtalk-runtime.mjs',
|
||||
'src/channels/qq/qq-runtime.mjs',
|
||||
'src/channels/wecom/wecom-runtime.mjs',
|
||||
'src/channels/telegram/telegram-runtime.mjs',
|
||||
'src/channels/discord/discord-runtime.mjs',
|
||||
];
|
||||
await Promise.all(required.map((path) => access(resolve(root, path))));
|
||||
|
||||
|
|
@ -51,7 +55,7 @@ if (!client.includes('container-type: inline-size')
|
|||
|| !client.includes('@container (max-width: 680px)')) {
|
||||
throw new Error('client bundle does not contain the narrow-panel DingTalk QR layout');
|
||||
}
|
||||
for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq']) {
|
||||
for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/telegram', '/discord']) {
|
||||
if (!host.includes(marker)) {
|
||||
throw new Error(`host bundle does not contain the internal ${marker} RPC provider`);
|
||||
}
|
||||
|
|
|
|||
24
src/channels/discord/config-store.mjs
Normal file
24
src/channels/discord/config-store.mjs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import {
|
||||
deriveTokenBotIdentity,
|
||||
maskPlatformId,
|
||||
TokenBotConfigStore,
|
||||
} from '../shared/token-config-store.mjs';
|
||||
|
||||
const IDENTITY_OPTIONS = Object.freeze({
|
||||
botPrefix: 'discord',
|
||||
tokenRefPrefix: 'DSH_DISCORD_BOT_TOKEN',
|
||||
});
|
||||
|
||||
export function deriveDiscordBotIdentity(platformId) {
|
||||
return deriveTokenBotIdentity(platformId, IDENTITY_OPTIONS);
|
||||
}
|
||||
|
||||
export function maskDiscordBotId(platformId) {
|
||||
return maskPlatformId(platformId, 'Discord机器人');
|
||||
}
|
||||
|
||||
export class DiscordConfigStore extends TokenBotConfigStore {
|
||||
constructor(path) {
|
||||
super(path, { channel: 'Discord', ...IDENTITY_OPTIONS });
|
||||
}
|
||||
}
|
||||
153
src/channels/discord/discord-api.mjs
Normal file
153
src/channels/discord/discord-api.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
const DEFAULT_BASE_URL = 'https://discord.com/api/v10/';
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function requestSignal(signal, timeoutMs) {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
function delay(ms, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ms);
|
||||
timer?.unref?.();
|
||||
signal?.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function snowflake(value, name) {
|
||||
const id = cleanString(value);
|
||||
if (!id || !/^\d{5,30}$/.test(id)) throw new TypeError(`Invalid Discord ${name}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function validDiscordToken(value) {
|
||||
return typeof value === 'string'
|
||||
&& /^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{20,}$/.test(value.trim());
|
||||
}
|
||||
|
||||
export class DiscordApi {
|
||||
#token;
|
||||
#fetch;
|
||||
#baseUrl;
|
||||
|
||||
constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
|
||||
if (!validDiscordToken(token)) throw new TypeError('Discord Bot Token is invalid');
|
||||
if (typeof fetchImpl !== 'function') throw new TypeError('DiscordApi requires fetch');
|
||||
this.#token = token.trim();
|
||||
this.#fetch = fetchImpl;
|
||||
this.#baseUrl = new URL(baseUrl);
|
||||
}
|
||||
|
||||
getCurrentUser(options = {}) {
|
||||
return this.#request('users/@me', { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
getGatewayBot(options = {}) {
|
||||
return this.#request('gateway/bot', { ...options, method: 'GET' });
|
||||
}
|
||||
|
||||
createMessage({ channelId, content, replyToMessageId, signal }) {
|
||||
return this.#request(`channels/${snowflake(channelId, 'channel id')}/messages`, {
|
||||
method: 'POST',
|
||||
signal,
|
||||
body: {
|
||||
content,
|
||||
allowed_mentions: { parse: [], replied_user: false },
|
||||
...(replyToMessageId ? {
|
||||
message_reference: {
|
||||
message_id: snowflake(replyToMessageId, 'message id'),
|
||||
channel_id: snowflake(channelId, 'channel id'),
|
||||
fail_if_not_exists: false,
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
editMessage({ channelId, messageId, content, signal }) {
|
||||
return this.#request(
|
||||
`channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
signal,
|
||||
body: { content, allowed_mentions: { parse: [], replied_user: false } },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
sendTyping({ channelId, signal }) {
|
||||
return this.#request(`channels/${snowflake(channelId, 'channel id')}/typing`, {
|
||||
method: 'POST',
|
||||
signal,
|
||||
expectBody: false,
|
||||
});
|
||||
}
|
||||
|
||||
async #request(path, {
|
||||
method,
|
||||
body,
|
||||
signal,
|
||||
timeoutMs = 15_000,
|
||||
expectBody = true,
|
||||
retry = true,
|
||||
}) {
|
||||
let response;
|
||||
try {
|
||||
response = await this.#fetch(new URL(path, this.#baseUrl), {
|
||||
method,
|
||||
headers: {
|
||||
authorization: `Bot ${this.#token}`,
|
||||
'content-type': 'application/json',
|
||||
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.1.0)',
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
signal: requestSignal(signal, timeoutMs),
|
||||
redirect: 'error',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
|
||||
throw new Error(`Discord ${method} transport failed`);
|
||||
}
|
||||
|
||||
let parsed = null;
|
||||
if (expectBody || response.status === 429 || !response.ok) {
|
||||
try {
|
||||
parsed = await response.json();
|
||||
} catch {
|
||||
if (expectBody) throw new Error(`Discord ${method} returned invalid JSON`);
|
||||
}
|
||||
}
|
||||
if (response.status === 429 && retry) {
|
||||
const retryAfterMs = Math.min(10_000, Math.max(50, Number(parsed?.retry_after) * 1_000 || 1_000));
|
||||
await delay(retryAfterMs, signal);
|
||||
return this.#request(path, { method, body, signal, timeoutMs, expectBody, retry: false });
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = new Error(cleanString(parsed?.message) ?? `Discord API failed with HTTP ${response.status}`);
|
||||
error.code = `discord-${response.status}`;
|
||||
throw error;
|
||||
}
|
||||
return expectBody ? parsed : null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function inspectDiscordToken(token, options = {}) {
|
||||
const api = new DiscordApi({ token, ...options });
|
||||
const bot = await api.getCurrentUser();
|
||||
if (!bot?.id || bot?.bot !== true) throw new Error('Discord token does not belong to a bot');
|
||||
return {
|
||||
platformId: String(bot.id),
|
||||
name: cleanString(bot.global_name) ?? cleanString(bot.username) ?? 'Discord机器人',
|
||||
username: cleanString(bot.username),
|
||||
};
|
||||
}
|
||||
15
src/channels/discord/discord-bridge.mjs
Normal file
15
src/channels/discord/discord-bridge.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { TextHarnessBridge, createTextBridgeStatus } from '../shared/text-harness-bridge.mjs';
|
||||
|
||||
export const DISCORD_DESCRIPTOR = Object.freeze({
|
||||
key: 'discord',
|
||||
label: 'Discord',
|
||||
connectionLabel: ' Gateway 长连接',
|
||||
});
|
||||
|
||||
export class DiscordHarnessBridge extends TextHarnessBridge {
|
||||
constructor(options) {
|
||||
super({ descriptor: DISCORD_DESCRIPTOR, ...options });
|
||||
}
|
||||
}
|
||||
|
||||
export { createTextBridgeStatus as createDiscordBridgeStatus };
|
||||
16
src/channels/discord/discord-controller.mjs
Normal file
16
src/channels/discord/discord-controller.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { TokenBotController } from '../shared/token-bot-controller.mjs';
|
||||
import { deriveDiscordBotIdentity, maskDiscordBotId } from './config-store.mjs';
|
||||
import { inspectDiscordToken } from './discord-api.mjs';
|
||||
import { DISCORD_DESCRIPTOR } from './discord-bridge.mjs';
|
||||
|
||||
export class DiscordController extends TokenBotController {
|
||||
constructor(options) {
|
||||
super({
|
||||
...options,
|
||||
descriptor: DISCORD_DESCRIPTOR,
|
||||
inspectToken: options.inspectToken ?? inspectDiscordToken,
|
||||
deriveIdentity: deriveDiscordBotIdentity,
|
||||
maskPlatformId: maskDiscordBotId,
|
||||
});
|
||||
}
|
||||
}
|
||||
460
src/channels/discord/discord-runtime.mjs
Normal file
460
src/channels/discord/discord-runtime.mjs
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
||||
import { DiscordApi } from './discord-api.mjs';
|
||||
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
|
||||
|
||||
const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12);
|
||||
const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
|
||||
|
||||
function socketUrl(value) {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'wss:') throw new Error('Discord returned an insecure Gateway URL');
|
||||
url.searchParams.set('v', '10');
|
||||
url.searchParams.set('encoding', 'json');
|
||||
return url.href;
|
||||
}
|
||||
|
||||
function addSocketListener(socket, event, listener) {
|
||||
if (typeof socket.addEventListener === 'function') socket.addEventListener(event, listener);
|
||||
else if (typeof socket.on === 'function') socket.on(event, listener);
|
||||
else throw new TypeError('Discord WebSocket does not support events');
|
||||
}
|
||||
|
||||
function eventData(event) {
|
||||
if (typeof event === 'string') return event;
|
||||
if (typeof event?.data === 'string') return event.data;
|
||||
if (Buffer.isBuffer(event)) return event.toString('utf8');
|
||||
if (Buffer.isBuffer(event?.data)) return event.data.toString('utf8');
|
||||
return null;
|
||||
}
|
||||
|
||||
function gatewayCloseError(code) {
|
||||
if (code === 4004) {
|
||||
const error = new Error('Discord Bot Token 无效,请重新填写。');
|
||||
error.code = 'discord-401';
|
||||
return error;
|
||||
}
|
||||
if (code === 4013 || code === 4014) {
|
||||
const error = new Error('Discord Gateway Intents 配置不正确,请检查 Developer Portal 的 Bot 设置。');
|
||||
error.code = 'discord-intents';
|
||||
return error;
|
||||
}
|
||||
const error = new Error(`Discord Gateway closed (${code || 'unknown'})`);
|
||||
error.code = 'discord-gateway-closed';
|
||||
return error;
|
||||
}
|
||||
|
||||
function stripBotMention(text, botId) {
|
||||
if (typeof text !== 'string') return '';
|
||||
return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
|
||||
}
|
||||
|
||||
export function normalizeDiscordMessage(message, botId) {
|
||||
if (!message?.id || !message?.channel_id || !message?.author?.id) return null;
|
||||
const direct = !message.guild_id;
|
||||
const addressed = direct
|
||||
|| message.mentions?.some((mention) => String(mention?.id) === String(botId));
|
||||
return {
|
||||
messageId: String(message.id),
|
||||
senderId: String(message.author.id),
|
||||
senderIsBot: message.author.bot === true,
|
||||
kind: direct ? 'direct' : 'group',
|
||||
conversationId: String(message.channel_id),
|
||||
content: stripBotMention(message.content ?? '', botId),
|
||||
addressed,
|
||||
replyTarget: {
|
||||
channelId: String(message.channel_id),
|
||||
replyToMessageId: String(message.id),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class DiscordBotClient {
|
||||
#api;
|
||||
#signal;
|
||||
|
||||
constructor({ api, signal }) {
|
||||
this.#api = api;
|
||||
this.#signal = signal;
|
||||
}
|
||||
|
||||
async sendText(target, text) {
|
||||
const chunks = splitMessageText(text, 1_900);
|
||||
let result = null;
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
result = await this.#api.createMessage({
|
||||
channelId: target.channelId,
|
||||
content: chunk,
|
||||
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
|
||||
signal: this.#signal,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
sendTyping(target) {
|
||||
return this.#api.sendTyping({ channelId: target.channelId, signal: this.#signal });
|
||||
}
|
||||
|
||||
async openStream(target) {
|
||||
const stream = createEditableMessageStream({
|
||||
limit: 1_900,
|
||||
create: async (content) => {
|
||||
const message = await this.#api.createMessage({
|
||||
channelId: target.channelId,
|
||||
content,
|
||||
replyToMessageId: target.replyToMessageId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
return message.id;
|
||||
},
|
||||
edit: (messageId, content) => this.#api.editMessage({
|
||||
channelId: target.channelId,
|
||||
messageId,
|
||||
content,
|
||||
signal: this.#signal,
|
||||
}),
|
||||
sendRemainder: (content) => this.#api.createMessage({
|
||||
channelId: target.channelId,
|
||||
content,
|
||||
signal: this.#signal,
|
||||
}),
|
||||
});
|
||||
return stream.start();
|
||||
}
|
||||
}
|
||||
|
||||
export function createDiscordRuntimeStatus() {
|
||||
return {
|
||||
startedAt: null,
|
||||
ready: false,
|
||||
connectionState: 'idle',
|
||||
harnessReachable: false,
|
||||
lastCheckedAt: null,
|
||||
lastConnectedAt: null,
|
||||
lastError: null,
|
||||
...createDiscordBridgeStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
export class DiscordRuntime {
|
||||
#config;
|
||||
#token;
|
||||
#harness;
|
||||
#state;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#connectTimeoutMs;
|
||||
#createApi;
|
||||
#createWebSocket;
|
||||
#random;
|
||||
#status = createDiscordRuntimeStatus();
|
||||
#api = null;
|
||||
#bridge = null;
|
||||
#abortController = null;
|
||||
#socket = null;
|
||||
#gatewayUrl = null;
|
||||
#resumeUrl = null;
|
||||
#sessionId = null;
|
||||
#sequence = null;
|
||||
#heartbeatTimer = null;
|
||||
#heartbeatAcked = true;
|
||||
#reconnectTimer = null;
|
||||
#reconnectAttempt = 0;
|
||||
#generation = 0;
|
||||
#stopped = true;
|
||||
#starting = null;
|
||||
|
||||
constructor({
|
||||
config,
|
||||
token,
|
||||
harness,
|
||||
state,
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
connectTimeoutMs = 20_000,
|
||||
createApi = (options) => new DiscordApi(options),
|
||||
createWebSocket = (url) => new WebSocket(url),
|
||||
random = Math.random,
|
||||
}) {
|
||||
if (!config || !token || !harness || !state) {
|
||||
throw new TypeError('DiscordRuntime requires config, token, Harness, and state');
|
||||
}
|
||||
if (typeof createWebSocket !== 'function') throw new TypeError('DiscordRuntime requires WebSocket');
|
||||
this.#config = config;
|
||||
this.#token = token;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#connectTimeoutMs = connectTimeoutMs;
|
||||
this.#createApi = createApi;
|
||||
this.#createWebSocket = createWebSocket;
|
||||
this.#random = random;
|
||||
}
|
||||
|
||||
get status() {
|
||||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.#status.ready && this.#socket) return this.status;
|
||||
if (this.#starting) return this.#starting;
|
||||
this.#starting = this.#start().finally(() => {
|
||||
this.#starting = null;
|
||||
});
|
||||
return this.#starting;
|
||||
}
|
||||
|
||||
async #start() {
|
||||
await this.stop();
|
||||
this.#stopped = false;
|
||||
this.#sessionId = null;
|
||||
this.#resumeUrl = null;
|
||||
this.#sequence = null;
|
||||
this.#reconnectAttempt = 0;
|
||||
this.#status.startedAt = new Date().toISOString();
|
||||
this.#status.connectionState = 'connecting';
|
||||
this.#status.lastError = null;
|
||||
await this.#harness.ensureRunning();
|
||||
this.#status.harnessReachable = true;
|
||||
const controller = new AbortController();
|
||||
this.#abortController = controller;
|
||||
const api = this.#createApi({ token: this.#token });
|
||||
this.#api = api;
|
||||
try {
|
||||
const [bot, gateway] = await Promise.all([
|
||||
api.getCurrentUser({ signal: controller.signal }),
|
||||
api.getGatewayBot({ signal: controller.signal }),
|
||||
]);
|
||||
if (String(bot?.id ?? '') !== this.#config.platformId || bot?.bot !== true) {
|
||||
throw new Error('Discord token identity does not match the saved bot');
|
||||
}
|
||||
this.#gatewayUrl = gateway?.url;
|
||||
const client = new DiscordBotClient({ api, signal: controller.signal });
|
||||
this.#bridge = new DiscordHarnessBridge({
|
||||
bot: client,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
replyTimeoutMs: this.#replyTimeoutMs,
|
||||
});
|
||||
let timer;
|
||||
try {
|
||||
await Promise.race([
|
||||
this.#openSocket(false),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('Discord Gateway did not become ready in time')), this.#connectTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
return this.status;
|
||||
} catch (error) {
|
||||
this.#status.ready = false;
|
||||
this.#status.connectionState = 'failed';
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
await this.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
#openSocket(resume) {
|
||||
if (this.#stopped) return Promise.reject(new Error('Discord runtime is stopped'));
|
||||
const generation = ++this.#generation;
|
||||
const url = socketUrl(resume && this.#resumeUrl ? this.#resumeUrl : this.#gatewayUrl);
|
||||
const socket = this.#createWebSocket(url);
|
||||
this.#socket = socket;
|
||||
let settled = false;
|
||||
return new Promise((resolve, reject) => {
|
||||
const markReady = () => {
|
||||
if (settled || generation !== this.#generation) return;
|
||||
settled = true;
|
||||
this.#reconnectAttempt = 0;
|
||||
const now = Date.now();
|
||||
this.#status.ready = true;
|
||||
this.#status.connectionState = 'connected';
|
||||
this.#status.lastCheckedAt = now;
|
||||
this.#status.lastConnectedAt = now;
|
||||
this.#status.lastError = null;
|
||||
resolve();
|
||||
};
|
||||
addSocketListener(socket, 'message', (event) => {
|
||||
if (generation !== this.#generation || this.#stopped) return;
|
||||
const raw = eventData(event);
|
||||
if (!raw) return;
|
||||
let packet;
|
||||
try {
|
||||
packet = JSON.parse(raw);
|
||||
} catch {
|
||||
this.#logger.warn?.('[dsh-im:discord] ignored malformed Gateway JSON');
|
||||
return;
|
||||
}
|
||||
if (Number.isSafeInteger(packet.s)) this.#sequence = packet.s;
|
||||
if (packet.op === 10) {
|
||||
this.#startHeartbeat(packet.d?.heartbeat_interval, socket, generation);
|
||||
if (resume && this.#sessionId) {
|
||||
this.#sendGateway(socket, {
|
||||
op: 6,
|
||||
d: { token: this.#token, session_id: this.#sessionId, seq: this.#sequence },
|
||||
});
|
||||
} else {
|
||||
this.#sendGateway(socket, {
|
||||
op: 2,
|
||||
d: {
|
||||
token: this.#token,
|
||||
intents: DISCORD_GATEWAY_INTENTS,
|
||||
properties: {
|
||||
os: process.platform,
|
||||
browser: 'dsh-im',
|
||||
device: 'dsh-im',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (packet.op === 11) {
|
||||
this.#heartbeatAcked = true;
|
||||
this.#status.lastCheckedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
if (packet.op === 1) {
|
||||
this.#heartbeat(socket);
|
||||
return;
|
||||
}
|
||||
if (packet.op === 7) {
|
||||
socket.close(4000, 'Reconnect requested');
|
||||
return;
|
||||
}
|
||||
if (packet.op === 9) {
|
||||
if (packet.d !== true) {
|
||||
this.#sessionId = null;
|
||||
this.#resumeUrl = null;
|
||||
this.#sequence = null;
|
||||
}
|
||||
socket.close(4000, 'Invalid session');
|
||||
return;
|
||||
}
|
||||
if (packet.op !== 0) return;
|
||||
if (packet.t === 'READY') {
|
||||
this.#sessionId = packet.d?.session_id ?? null;
|
||||
this.#resumeUrl = packet.d?.resume_gateway_url ?? null;
|
||||
markReady();
|
||||
} else if (packet.t === 'RESUMED') {
|
||||
markReady();
|
||||
} else if (packet.t === 'MESSAGE_CREATE') {
|
||||
const message = normalizeDiscordMessage(packet.d, this.#config.platformId);
|
||||
if (message) void this.#bridge?.accept(message);
|
||||
}
|
||||
});
|
||||
addSocketListener(socket, 'close', (event = {}) => {
|
||||
if (generation !== this.#generation) return;
|
||||
this.#clearHeartbeat();
|
||||
if (this.#socket === socket) this.#socket = null;
|
||||
if (this.#stopped) {
|
||||
if (!settled) reject(new DOMException('Stopped', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
const error = gatewayCloseError(Number(event.code) || 0);
|
||||
this.#status.ready = false;
|
||||
this.#status.connectionState = 'connecting';
|
||||
this.#status.lastError = error.message;
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
if (error.code === 'discord-401' || error.code === 'discord-intents') {
|
||||
this.#status.connectionState = 'failed';
|
||||
return;
|
||||
}
|
||||
this.#scheduleReconnect();
|
||||
});
|
||||
addSocketListener(socket, 'error', () => {
|
||||
if (generation !== this.#generation || this.#stopped) return;
|
||||
this.#status.lastError = 'Discord Gateway WebSocket error';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#sendGateway(socket, payload) {
|
||||
if (socket.readyState !== 1) return;
|
||||
socket.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
#startHeartbeat(interval, socket, generation) {
|
||||
this.#clearHeartbeat();
|
||||
if (!Number.isFinite(interval) || interval < 1_000) {
|
||||
socket.close(4000, 'Invalid heartbeat interval');
|
||||
return;
|
||||
}
|
||||
this.#heartbeatAcked = true;
|
||||
const schedule = (delay) => {
|
||||
this.#heartbeatTimer = setTimeout(() => {
|
||||
if (this.#stopped || generation !== this.#generation || this.#socket !== socket) return;
|
||||
if (!this.#heartbeatAcked) {
|
||||
socket.close(4000, 'Heartbeat was not acknowledged');
|
||||
return;
|
||||
}
|
||||
this.#heartbeat(socket);
|
||||
schedule(interval);
|
||||
}, delay);
|
||||
this.#heartbeatTimer?.unref?.();
|
||||
};
|
||||
schedule(Math.floor(interval * this.#random()));
|
||||
}
|
||||
|
||||
#heartbeat(socket) {
|
||||
this.#heartbeatAcked = false;
|
||||
this.#sendGateway(socket, { op: 1, d: this.#sequence });
|
||||
}
|
||||
|
||||
#clearHeartbeat() {
|
||||
if (this.#heartbeatTimer !== null) clearTimeout(this.#heartbeatTimer);
|
||||
this.#heartbeatTimer = null;
|
||||
this.#heartbeatAcked = true;
|
||||
}
|
||||
|
||||
#scheduleReconnect() {
|
||||
if (this.#stopped || this.#reconnectTimer !== null) return;
|
||||
const delay = RECONNECT_DELAYS_MS[Math.min(this.#reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
||||
this.#reconnectAttempt += 1;
|
||||
this.#reconnectTimer = setTimeout(() => {
|
||||
this.#reconnectTimer = null;
|
||||
void this.#openSocket(Boolean(this.#sessionId)).catch((error) => {
|
||||
if (this.#stopped) return;
|
||||
this.#logger.warn?.('[dsh-im:discord] Gateway reconnect failed:', error);
|
||||
this.#scheduleReconnect();
|
||||
});
|
||||
}, delay);
|
||||
this.#reconnectTimer?.unref?.();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.#stopped = true;
|
||||
this.#generation += 1;
|
||||
this.#abortController?.abort();
|
||||
this.#abortController = null;
|
||||
if (this.#reconnectTimer !== null) clearTimeout(this.#reconnectTimer);
|
||||
this.#reconnectTimer = null;
|
||||
this.#clearHeartbeat();
|
||||
const socket = this.#socket;
|
||||
const bridge = this.#bridge;
|
||||
this.#socket = null;
|
||||
this.#bridge = null;
|
||||
this.#api = null;
|
||||
try {
|
||||
if (socket && socket.readyState < 2) socket.close(1000, 'Plugin stopped');
|
||||
} catch (error) {
|
||||
this.#logger.warn?.(`[dsh-im:discord] bot ${this.#config.botId} failed to close Gateway:`, error);
|
||||
}
|
||||
await Promise.race([
|
||||
bridge?.waitForIdle() ?? Promise.resolve(),
|
||||
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
||||
]);
|
||||
this.#status.ready = false;
|
||||
this.#status.connectionState = 'idle';
|
||||
return this.status;
|
||||
}
|
||||
}
|
||||
3
src/channels/discord/harness-client.mjs
Normal file
3
src/channels/discord/harness-client.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { HarnessClient } from '../weixin/harness-client.mjs';
|
||||
|
||||
export class DiscordHarnessClient extends HarnessClient {}
|
||||
3
src/channels/discord/state-store.mjs
Normal file
3
src/channels/discord/state-store.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { ConversationStateStore } from '../shared/conversation-state-store.mjs';
|
||||
|
||||
export class DiscordStateStore extends ConversationStateStore {}
|
||||
107
src/channels/shared/conversation-state-store.mjs
Normal file
107
src/channels/shared/conversation-state-store.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [], cursor: null });
|
||||
|
||||
function normalizeState(value) {
|
||||
if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
|
||||
const sessions = {};
|
||||
if (value.sessions && typeof value.sessions === 'object' && !Array.isArray(value.sessions)) {
|
||||
for (const [key, sessionId] of Object.entries(value.sessions)) {
|
||||
if (typeof key === 'string' && key && typeof sessionId === 'string' && sessionId) {
|
||||
sessions[key] = sessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
sessions,
|
||||
seenMessageIds: Array.isArray(value.seenMessageIds)
|
||||
? value.seenMessageIds.filter((id) => typeof id === 'string' && id).slice(-1_000)
|
||||
: [],
|
||||
cursor: Number.isSafeInteger(value.cursor) && value.cursor >= 0 ? value.cursor : null,
|
||||
};
|
||||
}
|
||||
|
||||
export class ConversationStateStore {
|
||||
#path;
|
||||
#state = structuredClone(EMPTY_STATE);
|
||||
#writeQueue = Promise.resolve();
|
||||
|
||||
constructor(path) {
|
||||
this.#path = path;
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
this.#state = normalizeState(JSON.parse(await readFile(this.#path, 'utf8')));
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
this.#state = structuredClone(EMPTY_STATE);
|
||||
await this.#persist();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
sessionFor(key) {
|
||||
return this.#state.sessions[key] ?? null;
|
||||
}
|
||||
|
||||
async setSession(key, sessionId) {
|
||||
this.#state.sessions[key] = sessionId;
|
||||
await this.#persist();
|
||||
}
|
||||
|
||||
async clearSession(key) {
|
||||
delete this.#state.sessions[key];
|
||||
await this.#persist();
|
||||
}
|
||||
|
||||
hasSeen(messageId) {
|
||||
return this.#state.seenMessageIds.includes(messageId);
|
||||
}
|
||||
|
||||
async markSeen(messageId) {
|
||||
if (this.hasSeen(messageId)) return;
|
||||
this.#state.seenMessageIds.push(messageId);
|
||||
if (this.#state.seenMessageIds.length > 1_000) {
|
||||
this.#state.seenMessageIds.splice(0, this.#state.seenMessageIds.length - 1_000);
|
||||
}
|
||||
await this.#persist();
|
||||
}
|
||||
|
||||
cursor() {
|
||||
return this.#state.cursor;
|
||||
}
|
||||
|
||||
async setCursor(cursor) {
|
||||
if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TypeError('Invalid update cursor');
|
||||
this.#state.cursor = cursor;
|
||||
await this.#persist();
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return structuredClone(this.#state);
|
||||
}
|
||||
|
||||
async remove() {
|
||||
try {
|
||||
await unlink(this.#path);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
this.#state = structuredClone(EMPTY_STATE);
|
||||
}
|
||||
|
||||
async #persist() {
|
||||
const snapshot = `${JSON.stringify(this.#state, null, 2)}\n`;
|
||||
const operation = this.#writeQueue.then(async () => {
|
||||
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.#path}.tmp`;
|
||||
await writeFile(temporary, snapshot, { encoding: 'utf8', mode: 0o600 });
|
||||
await rename(temporary, this.#path);
|
||||
});
|
||||
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
||||
await operation;
|
||||
}
|
||||
}
|
||||
81
src/channels/shared/editable-message-stream.mjs
Normal file
81
src/channels/shared/editable-message-stream.mjs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
export function splitMessageText(value, limit) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
if (!text) return [];
|
||||
const chunks = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > limit) {
|
||||
let cut = remaining.lastIndexOf('\n', limit);
|
||||
if (cut < Math.floor(limit * 0.55)) cut = remaining.lastIndexOf(' ', limit);
|
||||
if (cut < Math.floor(limit * 0.55)) cut = limit;
|
||||
chunks.push(remaining.slice(0, cut).trim());
|
||||
remaining = remaining.slice(cut).trimStart();
|
||||
}
|
||||
if (remaining) chunks.push(remaining);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function createEditableMessageStream({
|
||||
initialText = '正在处理…',
|
||||
limit,
|
||||
updateIntervalMs = 800,
|
||||
create,
|
||||
edit,
|
||||
sendRemainder,
|
||||
logger = console,
|
||||
}) {
|
||||
let messageId;
|
||||
let pending = null;
|
||||
let timer = null;
|
||||
let inFlight = null;
|
||||
let closed = false;
|
||||
let lastSent = initialText;
|
||||
|
||||
const schedule = () => {
|
||||
if (closed || timer !== null || inFlight || !pending) return;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
const text = pending;
|
||||
pending = null;
|
||||
const next = splitMessageText(text, limit)[0] ?? initialText;
|
||||
inFlight = Promise.resolve(next === lastSent ? undefined : edit(messageId, next))
|
||||
.then(() => { lastSent = next; })
|
||||
.catch((error) => logger.warn?.('[dsh-im] streamed message update failed:', error))
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
schedule();
|
||||
});
|
||||
}, updateIntervalMs);
|
||||
timer?.unref?.();
|
||||
};
|
||||
|
||||
return {
|
||||
async start() {
|
||||
messageId = await create(initialText);
|
||||
return this;
|
||||
},
|
||||
update(text) {
|
||||
if (closed || typeof text !== 'string' || !text.trim()) return;
|
||||
pending = text;
|
||||
schedule();
|
||||
},
|
||||
async finish(text) {
|
||||
if (closed) throw new Error('Message stream is already closed');
|
||||
closed = true;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = null;
|
||||
pending = null;
|
||||
await inFlight?.catch(() => undefined);
|
||||
const chunks = splitMessageText(text, limit);
|
||||
const first = chunks[0] ?? '处理完成。';
|
||||
if (first !== lastSent) await edit(messageId, first);
|
||||
lastSent = first;
|
||||
for (const chunk of chunks.slice(1)) await sendRemainder(chunk);
|
||||
},
|
||||
cancel() {
|
||||
closed = true;
|
||||
pending = null;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
180
src/channels/shared/text-harness-bridge.mjs
Normal file
180
src/channels/shared/text-harness-bridge.mjs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
function cleanText(value) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function createTextBridgeStatus() {
|
||||
return {
|
||||
messagesReceived: 0,
|
||||
messagesReplied: 0,
|
||||
messagesRejected: 0,
|
||||
lastMessageAt: null,
|
||||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
export class TextHarnessBridge {
|
||||
#descriptor;
|
||||
#bot;
|
||||
#harness;
|
||||
#state;
|
||||
#status;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#queues = new Map();
|
||||
|
||||
constructor({
|
||||
descriptor,
|
||||
bot,
|
||||
harness,
|
||||
state,
|
||||
status = createTextBridgeStatus(),
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
}) {
|
||||
if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required');
|
||||
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required');
|
||||
if (!harness || !state) throw new TypeError('Harness client and state store are required');
|
||||
this.#descriptor = descriptor;
|
||||
this.#bot = bot;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#status = status;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
}
|
||||
|
||||
get status() {
|
||||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
accept(message) {
|
||||
const conversationId = cleanText(message?.conversationId);
|
||||
const kind = message?.kind === 'group' ? 'group' : 'direct';
|
||||
const key = `${kind}:${conversationId}`;
|
||||
const previous = this.#queues.get(key) ?? Promise.resolve();
|
||||
const current = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => this.#process({ ...message, kind, conversationId }))
|
||||
.finally(() => {
|
||||
if (this.#queues.get(key) === current) this.#queues.delete(key);
|
||||
});
|
||||
this.#queues.set(key, current);
|
||||
return current;
|
||||
}
|
||||
|
||||
async waitForIdle() {
|
||||
await Promise.allSettled([...this.#queues.values()]);
|
||||
}
|
||||
|
||||
async #process(message) {
|
||||
const messageId = cleanText(message.messageId);
|
||||
const senderId = cleanText(message.senderId);
|
||||
if (!messageId || !senderId || !message.conversationId || message.senderIsBot === true) return;
|
||||
if (this.#state.hasSeen(messageId)) return;
|
||||
|
||||
this.#status.messagesReceived += 1;
|
||||
this.#status.lastMessageAt = new Date().toISOString();
|
||||
if (message.kind === 'group' && message.addressed !== true) {
|
||||
this.#status.messagesRejected += 1;
|
||||
this.#status.lastRejectedAt = new Date().toISOString();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = message.replyTarget;
|
||||
const text = cleanText(message.content);
|
||||
try {
|
||||
if (!text) {
|
||||
await this.#bot.sendText(target, '目前仅支持文字消息。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const command = text.toLowerCase();
|
||||
if (command === '/help') {
|
||||
await this.#bot.sendText(target, [
|
||||
`${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
|
||||
'',
|
||||
'直接发送文字即可继续当前会话。',
|
||||
'/new 开启一个全新会话',
|
||||
'/status 检查连接状态',
|
||||
'/help 显示本帮助',
|
||||
].join('\n'));
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
if (command === '/status') {
|
||||
await this.#harness.ensureRunning();
|
||||
await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
const conversationKey = `${message.kind}:${message.conversationId}`;
|
||||
if (command === '/new') {
|
||||
await this.#state.clearSession(conversationKey);
|
||||
await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
|
||||
await this.#state.markSeen(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionId = this.#state.sessionFor(conversationKey);
|
||||
if (!sessionId || !(await this.#harness.sessionExists(sessionId))) {
|
||||
sessionId = await this.#harness.createSession();
|
||||
await this.#state.setSession(conversationKey, sessionId);
|
||||
}
|
||||
|
||||
await this.#bot.sendTyping?.(target).catch((error) => {
|
||||
this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
|
||||
});
|
||||
let stream = null;
|
||||
let streamFinished = false;
|
||||
if (typeof this.#bot.openStream === 'function') {
|
||||
try {
|
||||
stream = await this.#bot.openStream(target);
|
||||
} catch (error) {
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] unable to start a streamed reply; using text:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
const answer = await this.#harness.ask(sessionId, text, {
|
||||
timeoutMs: this.#replyTimeoutMs,
|
||||
onUpdate: stream ? async (update) => {
|
||||
const progress = update.type === 'text' ? update.text
|
||||
: update.type === 'tool' ? `正在使用${update.name}…` : update.text;
|
||||
if (progress) await stream.update(progress);
|
||||
} : undefined,
|
||||
});
|
||||
if (stream) {
|
||||
try {
|
||||
await stream.finish(answer);
|
||||
streamFinished = true;
|
||||
} catch (error) {
|
||||
stream.cancel?.();
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] streamed reply finalization failed; using text:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!streamFinished) await this.#bot.sendText(target, answer);
|
||||
await this.#state.markSeen(messageId);
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
} catch (error) {
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
|
||||
try {
|
||||
await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
|
||||
await this.#state.markSeen(messageId);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`,
|
||||
sendError,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
298
src/channels/shared/token-bot-controller.mjs
Normal file
298
src/channels/shared/token-bot-controller.mjs
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function safeError(code, message) {
|
||||
return Object.freeze({ code, message });
|
||||
}
|
||||
|
||||
export class TokenBotController {
|
||||
#descriptor;
|
||||
#credentials;
|
||||
#configStore;
|
||||
#inspectToken;
|
||||
#deriveIdentity;
|
||||
#maskPlatformId;
|
||||
#createRuntime;
|
||||
#deleteState;
|
||||
#logger;
|
||||
#runtimes = new Map();
|
||||
#errors = new Map();
|
||||
#transitions = new Map();
|
||||
#revision = 0;
|
||||
#closed = false;
|
||||
|
||||
constructor({
|
||||
descriptor,
|
||||
credentials,
|
||||
configStore,
|
||||
inspectToken,
|
||||
deriveIdentity,
|
||||
maskPlatformId,
|
||||
createRuntime,
|
||||
deleteState = async () => {},
|
||||
logger = console,
|
||||
}) {
|
||||
if (!descriptor?.key || !descriptor?.label || !descriptor?.connectionLabel) {
|
||||
throw new TypeError('TokenBotController requires a channel descriptor');
|
||||
}
|
||||
if (!credentials || typeof credentials.resolve !== 'function'
|
||||
|| typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') {
|
||||
throw new TypeError(`${descriptor.label} requires the DSH credential provider`);
|
||||
}
|
||||
if (!configStore || typeof configStore.list !== 'function'
|
||||
|| typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
|
||||
throw new TypeError(`${descriptor.label} requires a config store`);
|
||||
}
|
||||
if (typeof inspectToken !== 'function' || typeof deriveIdentity !== 'function'
|
||||
|| typeof maskPlatformId !== 'function' || typeof createRuntime !== 'function') {
|
||||
throw new TypeError(`${descriptor.label} controller dependencies are incomplete`);
|
||||
}
|
||||
this.#descriptor = descriptor;
|
||||
this.#credentials = credentials;
|
||||
this.#configStore = configStore;
|
||||
this.#inspectToken = inspectToken;
|
||||
this.#deriveIdentity = deriveIdentity;
|
||||
this.#maskPlatformId = maskPlatformId;
|
||||
this.#createRuntime = createRuntime;
|
||||
this.#deleteState = deleteState;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.#closed) return this.status();
|
||||
for (const config of this.#configStore.list()) {
|
||||
await this.#withBotTransition(config.botId, async () => {
|
||||
if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
|
||||
const token = await this.#resolveToken(config.tokenRef);
|
||||
if (!token) {
|
||||
this.#errors.set(config.botId, safeError(
|
||||
'missing-token',
|
||||
`${this.#descriptor.label}机器人凭据缺失,请移除后重新接入。`,
|
||||
));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.#startRuntime(config, token);
|
||||
this.#errors.delete(config.botId);
|
||||
} catch (error) {
|
||||
this.#errors.set(config.botId, safeError(
|
||||
'connection-failed',
|
||||
`${this.#descriptor.label}连接未就绪,插件会自动重试。`,
|
||||
));
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] bot ${config.botId} failed to initialize:`,
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
this.#touch();
|
||||
}
|
||||
});
|
||||
}
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async bindCredentials({ token } = {}) {
|
||||
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
|
||||
const normalizedToken = cleanString(token);
|
||||
if (!normalizedToken) throw new TypeError(`${this.#descriptor.label} Bot Token is required`);
|
||||
const inspected = await this.#inspectToken(normalizedToken);
|
||||
const platformId = cleanString(inspected?.platformId);
|
||||
const name = cleanString(inspected?.name);
|
||||
if (!platformId || !name) throw new Error(`${this.#descriptor.label} returned an invalid bot identity`);
|
||||
const identity = this.#deriveIdentity(platformId);
|
||||
await this.#withBotTransition(identity.botId, async () => {
|
||||
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
|
||||
const previousConfig = this.#configStore.getByPlatformId(platformId);
|
||||
const previousToken = await this.#credentials.resolve(identity.tokenRef).catch(() => undefined);
|
||||
const config = {
|
||||
botId: identity.botId,
|
||||
platformId,
|
||||
tokenRef: identity.tokenRef,
|
||||
name,
|
||||
username: cleanString(inspected.username),
|
||||
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
||||
connectedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.#credentials.set(identity.tokenRef, normalizedToken);
|
||||
try {
|
||||
await this.#configStore.save(config);
|
||||
} catch (error) {
|
||||
await this.#restoreCredential(identity.tokenRef, previousToken);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await this.#startRuntime(config, normalizedToken);
|
||||
this.#errors.delete(identity.botId);
|
||||
} catch (error) {
|
||||
this.#errors.set(identity.botId, safeError(
|
||||
'connection-failed',
|
||||
`${this.#descriptor.label}机器人已接入,消息连接暂未就绪。`,
|
||||
));
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] bot ${identity.botId} credential connection failed:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
this.#touch();
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async reconnectBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
|
||||
await this.#withBotTransition(botId, async () => {
|
||||
const token = await this.#resolveToken(config.tokenRef);
|
||||
if (!token) throw new Error(`${this.#descriptor.label} bot token is missing`);
|
||||
try {
|
||||
await this.#startRuntime(config, token);
|
||||
this.#errors.delete(botId);
|
||||
} catch (error) {
|
||||
this.#errors.set(botId, safeError(
|
||||
'connection-failed',
|
||||
`${this.#descriptor.label}连接仍未就绪,请稍后重试。`,
|
||||
));
|
||||
throw error;
|
||||
} finally {
|
||||
this.#touch();
|
||||
}
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
|
||||
async deleteBot(botId) {
|
||||
const config = this.#configStore.get(botId);
|
||||
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
|
||||
await this.#withBotTransition(botId, async () => {
|
||||
const previous = await this.#credentials.resolve(config.tokenRef).catch(() => undefined);
|
||||
await this.#stopRuntime(botId);
|
||||
try {
|
||||
await this.#credentials.unset(config.tokenRef);
|
||||
await this.#configStore.remove(botId);
|
||||
} catch (error) {
|
||||
if (previous?.value) {
|
||||
await this.#credentials.set(config.tokenRef, previous.value).catch(() => undefined);
|
||||
await this.#startRuntime(config, previous.value).catch(() => undefined);
|
||||
}
|
||||
throw new Error(`Unable to remove the ${this.#descriptor.label} bot safely.`, { cause: error });
|
||||
}
|
||||
await this.#deleteState({ botId, config }).catch((error) => {
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] bot ${botId} state cleanup failed:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
this.#errors.delete(botId);
|
||||
this.#touch();
|
||||
});
|
||||
return this.status();
|
||||
}
|
||||
|
||||
status() {
|
||||
const bots = this.#configStore.list().map((config) => {
|
||||
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
|
||||
const connected = runtimeStatus?.ready === true
|
||||
&& runtimeStatus.connectionState === 'connected'
|
||||
&& runtimeStatus.harnessReachable === true;
|
||||
const state = connected ? 'connected'
|
||||
: runtimeStatus?.connectionState === 'connecting' ? 'connecting'
|
||||
: this.#errors.has(config.botId) || runtimeStatus?.connectionState === 'failed'
|
||||
? 'error' : 'offline';
|
||||
return {
|
||||
botId: config.botId,
|
||||
state,
|
||||
connected,
|
||||
configured: true,
|
||||
bot: {
|
||||
name: config.name,
|
||||
username: config.username,
|
||||
idMasked: this.#maskPlatformId(config.platformId),
|
||||
},
|
||||
health: {
|
||||
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
||||
summary: connected ? `${this.#descriptor.label}${this.#descriptor.connectionLabel}运行正常`
|
||||
: state === 'error' ? `${this.#descriptor.label}连接未就绪,插件会自动重试`
|
||||
: `${this.#descriptor.label}连接当前离线`,
|
||||
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
|
||||
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
|
||||
},
|
||||
stats: {
|
||||
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
||||
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
||||
},
|
||||
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
||||
};
|
||||
});
|
||||
const connectedCount = bots.filter((bot) => bot.connected).length;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
revision: this.#revision,
|
||||
state: bots.length === 0 ? 'disconnected'
|
||||
: connectedCount === bots.length ? 'connected'
|
||||
: connectedCount > 0 ? 'degraded' : 'offline',
|
||||
bots,
|
||||
totals: { configured: bots.length, connected: connectedCount },
|
||||
};
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
await Promise.allSettled([...this.#transitions.values()]);
|
||||
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
||||
}
|
||||
|
||||
async #startRuntime(config, token) {
|
||||
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
|
||||
await this.#stopRuntime(config.botId);
|
||||
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
|
||||
const runtime = await this.#createRuntime({ botId: config.botId, config, token });
|
||||
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
||||
throw new TypeError(`createRuntime returned an invalid ${this.#descriptor.label} runtime`);
|
||||
}
|
||||
this.#runtimes.set(config.botId, runtime);
|
||||
try {
|
||||
await runtime.start();
|
||||
} catch (error) {
|
||||
await runtime.stop().catch(() => undefined);
|
||||
this.#runtimes.delete(config.botId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #stopRuntime(botId) {
|
||||
const runtime = this.#runtimes.get(botId);
|
||||
this.#runtimes.delete(botId);
|
||||
await runtime?.stop().catch((error) => {
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] bot ${botId} failed to stop cleanly:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async #resolveToken(ref) {
|
||||
const result = await this.#credentials.resolve(ref).catch(() => undefined);
|
||||
return cleanString(result?.value);
|
||||
}
|
||||
|
||||
async #restoreCredential(ref, previous) {
|
||||
if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined);
|
||||
else await this.#credentials.unset(ref).catch(() => undefined);
|
||||
}
|
||||
|
||||
#withBotTransition(botId, operation) {
|
||||
const previous = this.#transitions.get(botId) ?? Promise.resolve();
|
||||
const current = previous.catch(() => undefined).then(operation);
|
||||
const settled = current.finally(() => {
|
||||
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
|
||||
});
|
||||
this.#transitions.set(botId, settled);
|
||||
return settled;
|
||||
}
|
||||
|
||||
#touch() {
|
||||
this.#revision += 1;
|
||||
}
|
||||
}
|
||||
180
src/channels/shared/token-config-store.mjs
Normal file
180
src/channels/shared/token-config-store.mjs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function escapePattern(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function deriveTokenBotIdentity(platformId, { botPrefix, tokenRefPrefix }) {
|
||||
const raw = cleanString(platformId);
|
||||
if (!raw) throw new TypeError('platformId is required');
|
||||
const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
|
||||
return {
|
||||
botId: `${botPrefix}_${digest}`,
|
||||
tokenRef: `${tokenRefPrefix}_${digest.toUpperCase()}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function maskPlatformId(platformId, fallback) {
|
||||
const value = cleanString(platformId) ?? '';
|
||||
if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : fallback;
|
||||
return `${value.slice(0, 6)}••••${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
export class TokenBotConfigStore {
|
||||
#path;
|
||||
#channel;
|
||||
#botPrefix;
|
||||
#tokenRefPrefix;
|
||||
#botIdPattern;
|
||||
#tokenRefPattern;
|
||||
#value = EMPTY_DOCUMENT;
|
||||
#writeQueue = Promise.resolve();
|
||||
|
||||
constructor(path, { channel, botPrefix, tokenRefPrefix }) {
|
||||
this.#path = path;
|
||||
this.#channel = channel;
|
||||
this.#botPrefix = botPrefix;
|
||||
this.#tokenRefPrefix = tokenRefPrefix;
|
||||
this.#botIdPattern = new RegExp(`^${escapePattern(botPrefix)}_[a-f0-9]{24}$`);
|
||||
this.#tokenRefPattern = new RegExp(`^${escapePattern(tokenRefPrefix)}_[A-F0-9]{24}$`);
|
||||
}
|
||||
|
||||
async load() {
|
||||
try {
|
||||
const normalized = this.#normalizeDocument(JSON.parse(await readFile(this.#path, 'utf8')));
|
||||
if (!normalized) throw new Error(`dsh-im ${this.#channel} config contains invalid bot data`);
|
||||
this.#value = normalized;
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
this.#value = EMPTY_DOCUMENT;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
list() {
|
||||
return structuredClone(this.#value.bots);
|
||||
}
|
||||
|
||||
get(botId) {
|
||||
const bot = this.#value.bots.find((candidate) => candidate.botId === botId);
|
||||
return bot ? structuredClone(bot) : null;
|
||||
}
|
||||
|
||||
getByPlatformId(platformId) {
|
||||
const bot = this.#value.bots.find((candidate) => candidate.platformId === platformId);
|
||||
return bot ? structuredClone(bot) : null;
|
||||
}
|
||||
|
||||
async save(value) {
|
||||
const normalized = this.#normalizeBot(value);
|
||||
if (!normalized) throw new Error(`Refusing to persist incomplete ${this.#channel} bot data`);
|
||||
return this.#mutate((bots) => {
|
||||
const identityCollision = bots.find(
|
||||
(bot) => bot.platformId === normalized.platformId && bot.botId !== normalized.botId,
|
||||
);
|
||||
const refCollision = bots.find(
|
||||
(bot) => bot.tokenRef === normalized.tokenRef && bot.botId !== normalized.botId,
|
||||
);
|
||||
if (identityCollision || refCollision) throw new Error(`Duplicate ${this.#channel} bot identity`);
|
||||
const index = bots.findIndex((bot) => bot.botId === normalized.botId);
|
||||
if (index === -1) bots.push(normalized);
|
||||
else bots[index] = normalized;
|
||||
return structuredClone(normalized);
|
||||
});
|
||||
}
|
||||
|
||||
async remove(botId) {
|
||||
if (!this.#botIdPattern.test(botId)) throw new TypeError(`Invalid ${this.#channel} bot id`);
|
||||
return this.#mutate((bots) => {
|
||||
const index = bots.findIndex((bot) => bot.botId === botId);
|
||||
if (index === -1) return null;
|
||||
const [removed] = bots.splice(index, 1);
|
||||
return structuredClone(removed);
|
||||
});
|
||||
}
|
||||
|
||||
async clear() {
|
||||
const operation = this.#writeQueue.then(async () => {
|
||||
try {
|
||||
await unlink(this.#path);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
this.#value = EMPTY_DOCUMENT;
|
||||
});
|
||||
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
||||
await operation;
|
||||
}
|
||||
|
||||
#normalizeBot(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const platformId = cleanString(value.platformId);
|
||||
const botId = cleanString(value.botId);
|
||||
const tokenRef = cleanString(value.tokenRef);
|
||||
const name = cleanString(value.name);
|
||||
if (!platformId || !botId || !tokenRef || !name
|
||||
|| !this.#botIdPattern.test(botId) || !this.#tokenRefPattern.test(tokenRef)) return null;
|
||||
const derived = deriveTokenBotIdentity(platformId, {
|
||||
botPrefix: this.#botPrefix,
|
||||
tokenRefPrefix: this.#tokenRefPrefix,
|
||||
});
|
||||
if (derived.botId !== botId || derived.tokenRef !== tokenRef) return null;
|
||||
return Object.freeze({
|
||||
botId,
|
||||
platformId,
|
||||
tokenRef,
|
||||
name,
|
||||
username: cleanString(value.username),
|
||||
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
|
||||
connectedAt: cleanString(value.connectedAt),
|
||||
});
|
||||
}
|
||||
|
||||
#normalizeDocument(value) {
|
||||
if (!value || value.version !== 1 || !Array.isArray(value.bots)) return null;
|
||||
const bots = value.bots.map((bot) => this.#normalizeBot(bot));
|
||||
if (bots.some((bot) => bot === null)) return null;
|
||||
const ids = new Set();
|
||||
const platformIds = new Set();
|
||||
const refs = new Set();
|
||||
for (const bot of bots) {
|
||||
if (ids.has(bot.botId) || platformIds.has(bot.platformId) || refs.has(bot.tokenRef)) return null;
|
||||
ids.add(bot.botId);
|
||||
platformIds.add(bot.platformId);
|
||||
refs.add(bot.tokenRef);
|
||||
}
|
||||
return Object.freeze({ version: 1, bots: Object.freeze(bots) });
|
||||
}
|
||||
|
||||
async #mutate(mutator) {
|
||||
let result;
|
||||
const operation = this.#writeQueue.then(async () => {
|
||||
const bots = [...this.#value.bots];
|
||||
result = mutator(bots);
|
||||
const document = Object.freeze({ version: 1, bots: Object.freeze(bots) });
|
||||
await this.#write(document);
|
||||
this.#value = document;
|
||||
});
|
||||
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
||||
await operation;
|
||||
return result;
|
||||
}
|
||||
|
||||
async #write(document) {
|
||||
await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
|
||||
const temporary = `${this.#path}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
});
|
||||
await rename(temporary, this.#path);
|
||||
}
|
||||
}
|
||||
24
src/channels/telegram/config-store.mjs
Normal file
24
src/channels/telegram/config-store.mjs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import {
|
||||
deriveTokenBotIdentity,
|
||||
maskPlatformId,
|
||||
TokenBotConfigStore,
|
||||
} from '../shared/token-config-store.mjs';
|
||||
|
||||
const IDENTITY_OPTIONS = Object.freeze({
|
||||
botPrefix: 'telegram',
|
||||
tokenRefPrefix: 'DSH_TELEGRAM_BOT_TOKEN',
|
||||
});
|
||||
|
||||
export function deriveTelegramBotIdentity(platformId) {
|
||||
return deriveTokenBotIdentity(platformId, IDENTITY_OPTIONS);
|
||||
}
|
||||
|
||||
export function maskTelegramBotId(platformId) {
|
||||
return maskPlatformId(platformId, 'Telegram机器人');
|
||||
}
|
||||
|
||||
export class TelegramConfigStore extends TokenBotConfigStore {
|
||||
constructor(path) {
|
||||
super(path, { channel: 'Telegram', ...IDENTITY_OPTIONS });
|
||||
}
|
||||
}
|
||||
3
src/channels/telegram/harness-client.mjs
Normal file
3
src/channels/telegram/harness-client.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { HarnessClient } from '../weixin/harness-client.mjs';
|
||||
|
||||
export class TelegramHarnessClient extends HarnessClient {}
|
||||
3
src/channels/telegram/state-store.mjs
Normal file
3
src/channels/telegram/state-store.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { ConversationStateStore } from '../shared/conversation-state-store.mjs';
|
||||
|
||||
export class TelegramStateStore extends ConversationStateStore {}
|
||||
120
src/channels/telegram/telegram-api.mjs
Normal file
120
src/channels/telegram/telegram-api.mjs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
const DEFAULT_BASE_URL = 'https://api.telegram.org/';
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function requestSignal(signal, timeoutMs) {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
export function validTelegramToken(value) {
|
||||
return typeof value === 'string' && /^\d{5,20}:[A-Za-z0-9_-]{20,}$/.test(value.trim());
|
||||
}
|
||||
|
||||
export class TelegramApi {
|
||||
#token;
|
||||
#fetch;
|
||||
#baseUrl;
|
||||
|
||||
constructor({ token, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
|
||||
if (!validTelegramToken(token)) throw new TypeError('Telegram Bot Token is invalid');
|
||||
if (typeof fetchImpl !== 'function') throw new TypeError('TelegramApi requires fetch');
|
||||
this.#token = token.trim();
|
||||
this.#fetch = fetchImpl;
|
||||
this.#baseUrl = new URL(baseUrl);
|
||||
}
|
||||
|
||||
async getMe(options = {}) {
|
||||
return this.#call('getMe', {}, options);
|
||||
}
|
||||
|
||||
async getWebhookInfo(options = {}) {
|
||||
return this.#call('getWebhookInfo', {}, options);
|
||||
}
|
||||
|
||||
async getUpdates({ offset, timeout = 25, signal } = {}) {
|
||||
const payload = {
|
||||
timeout,
|
||||
limit: 100,
|
||||
allowed_updates: ['message'],
|
||||
...(Number.isSafeInteger(offset) ? { offset } : {}),
|
||||
};
|
||||
return this.#call('getUpdates', payload, {
|
||||
signal,
|
||||
timeoutMs: Math.max(10_000, (timeout + 10) * 1_000),
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {
|
||||
return this.#call('sendMessage', {
|
||||
chat_id: chatId,
|
||||
text,
|
||||
link_preview_options: { is_disabled: true },
|
||||
...(replyToMessageId ? {
|
||||
reply_parameters: { message_id: replyToMessageId, allow_sending_without_reply: true },
|
||||
} : {}),
|
||||
...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
|
||||
}, { signal });
|
||||
}
|
||||
|
||||
async editMessageText({ chatId, messageId, text, signal }) {
|
||||
return this.#call('editMessageText', {
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
text,
|
||||
link_preview_options: { is_disabled: true },
|
||||
}, { signal });
|
||||
}
|
||||
|
||||
async sendChatAction({ chatId, messageThreadId, signal }) {
|
||||
return this.#call('sendChatAction', {
|
||||
chat_id: chatId,
|
||||
action: 'typing',
|
||||
...(messageThreadId ? { message_thread_id: messageThreadId } : {}),
|
||||
}, { signal });
|
||||
}
|
||||
|
||||
async #call(method, payload, { signal, timeoutMs = 15_000 } = {}) {
|
||||
const url = new URL(this.#baseUrl);
|
||||
url.pathname = `${url.pathname.replace(/\/$/, '')}/bot${this.#token}/${method}`;
|
||||
let response;
|
||||
try {
|
||||
response = await this.#fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: requestSignal(signal, timeoutMs),
|
||||
redirect: 'error',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
|
||||
throw new Error(`Telegram ${method} transport failed`);
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new Error(`Telegram ${method} returned invalid JSON`);
|
||||
}
|
||||
if (!response.ok || body?.ok !== true) {
|
||||
const description = cleanString(body?.description);
|
||||
const error = new Error(description ?? `Telegram ${method} failed`);
|
||||
error.code = Number.isInteger(body?.error_code) ? `telegram-${body.error_code}` : 'telegram-api-error';
|
||||
throw error;
|
||||
}
|
||||
return body.result;
|
||||
}
|
||||
}
|
||||
|
||||
export async function inspectTelegramToken(token, options = {}) {
|
||||
const api = new TelegramApi({ token, ...options });
|
||||
const bot = await api.getMe();
|
||||
if (!bot?.id || bot?.is_bot !== true) throw new Error('Telegram token does not belong to a bot');
|
||||
return {
|
||||
platformId: String(bot.id),
|
||||
name: cleanString([bot.first_name, bot.last_name].filter(Boolean).join(' ')) ?? 'Telegram机器人',
|
||||
username: cleanString(bot.username),
|
||||
};
|
||||
}
|
||||
15
src/channels/telegram/telegram-bridge.mjs
Normal file
15
src/channels/telegram/telegram-bridge.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { TextHarnessBridge, createTextBridgeStatus } from '../shared/text-harness-bridge.mjs';
|
||||
|
||||
export const TELEGRAM_DESCRIPTOR = Object.freeze({
|
||||
key: 'telegram',
|
||||
label: 'Telegram',
|
||||
connectionLabel: ' Bot API 长轮询',
|
||||
});
|
||||
|
||||
export class TelegramHarnessBridge extends TextHarnessBridge {
|
||||
constructor(options) {
|
||||
super({ descriptor: TELEGRAM_DESCRIPTOR, ...options });
|
||||
}
|
||||
}
|
||||
|
||||
export { createTextBridgeStatus as createTelegramBridgeStatus };
|
||||
16
src/channels/telegram/telegram-controller.mjs
Normal file
16
src/channels/telegram/telegram-controller.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { TokenBotController } from '../shared/token-bot-controller.mjs';
|
||||
import { deriveTelegramBotIdentity, maskTelegramBotId } from './config-store.mjs';
|
||||
import { inspectTelegramToken } from './telegram-api.mjs';
|
||||
import { TELEGRAM_DESCRIPTOR } from './telegram-bridge.mjs';
|
||||
|
||||
export class TelegramController extends TokenBotController {
|
||||
constructor(options) {
|
||||
super({
|
||||
...options,
|
||||
descriptor: TELEGRAM_DESCRIPTOR,
|
||||
inspectToken: options.inspectToken ?? inspectTelegramToken,
|
||||
deriveIdentity: deriveTelegramBotIdentity,
|
||||
maskPlatformId: maskTelegramBotId,
|
||||
});
|
||||
}
|
||||
}
|
||||
278
src/channels/telegram/telegram-runtime.mjs
Normal file
278
src/channels/telegram/telegram-runtime.mjs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
||||
import { TelegramApi } from './telegram-api.mjs';
|
||||
import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
|
||||
|
||||
function escaped(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function mentionedUsername(message, username) {
|
||||
if (!username || typeof message?.text !== 'string' || !Array.isArray(message.entities)) return false;
|
||||
return message.entities.some((entity) => {
|
||||
if (entity?.type !== 'mention' || !Number.isInteger(entity.offset) || !Number.isInteger(entity.length)) {
|
||||
return false;
|
||||
}
|
||||
return message.text.slice(entity.offset, entity.offset + entity.length).toLowerCase()
|
||||
=== `@${username.toLowerCase()}`;
|
||||
});
|
||||
}
|
||||
|
||||
function withoutBotMention(text, username) {
|
||||
if (!username || typeof text !== 'string') return text;
|
||||
return text.replace(new RegExp(`@${escaped(username)}\\b`, 'ig'), '').trim();
|
||||
}
|
||||
|
||||
export function normalizeTelegramUpdate(update, { botId, username }) {
|
||||
const message = update?.message;
|
||||
const chatId = message?.chat?.id;
|
||||
const senderId = message?.from?.id;
|
||||
const messageId = message?.message_id;
|
||||
if (!Number.isSafeInteger(update?.update_id) || chatId === undefined || senderId === undefined
|
||||
|| !Number.isSafeInteger(messageId)) return null;
|
||||
if (!['private', 'group', 'supergroup'].includes(message.chat?.type)) return null;
|
||||
const direct = message.chat.type === 'private';
|
||||
const addressed = direct
|
||||
|| String(message.reply_to_message?.from?.id ?? '') === String(botId)
|
||||
|| mentionedUsername(message, username);
|
||||
return {
|
||||
messageId: String(update.update_id),
|
||||
senderId: String(senderId),
|
||||
senderIsBot: message.from?.is_bot === true,
|
||||
kind: direct ? 'direct' : 'group',
|
||||
conversationId: String(chatId),
|
||||
content: withoutBotMention(message.text ?? message.caption ?? '', username),
|
||||
addressed,
|
||||
replyTarget: {
|
||||
chatId,
|
||||
replyToMessageId: messageId,
|
||||
messageThreadId: Number.isSafeInteger(message.message_thread_id)
|
||||
? message.message_thread_id : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class TelegramBotClient {
|
||||
#api;
|
||||
#signal;
|
||||
|
||||
constructor({ api, signal }) {
|
||||
this.#api = api;
|
||||
this.#signal = signal;
|
||||
}
|
||||
|
||||
async sendText(target, text) {
|
||||
const chunks = splitMessageText(text, 4_000);
|
||||
let result = null;
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
result = await this.#api.sendMessage({
|
||||
chatId: target.chatId,
|
||||
text: chunk,
|
||||
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
|
||||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
sendTyping(target) {
|
||||
return this.#api.sendChatAction({
|
||||
chatId: target.chatId,
|
||||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
}
|
||||
|
||||
async openStream(target) {
|
||||
const stream = createEditableMessageStream({
|
||||
limit: 4_000,
|
||||
create: async (text) => {
|
||||
const message = await this.#api.sendMessage({
|
||||
chatId: target.chatId,
|
||||
text,
|
||||
replyToMessageId: target.replyToMessageId,
|
||||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
});
|
||||
return message.message_id;
|
||||
},
|
||||
edit: (messageId, text) => this.#api.editMessageText({
|
||||
chatId: target.chatId,
|
||||
messageId,
|
||||
text,
|
||||
signal: this.#signal,
|
||||
}),
|
||||
sendRemainder: (text) => this.#api.sendMessage({
|
||||
chatId: target.chatId,
|
||||
text,
|
||||
messageThreadId: target.messageThreadId,
|
||||
signal: this.#signal,
|
||||
}),
|
||||
});
|
||||
return stream.start();
|
||||
}
|
||||
}
|
||||
|
||||
export function createTelegramRuntimeStatus() {
|
||||
return {
|
||||
startedAt: null,
|
||||
ready: false,
|
||||
connectionState: 'idle',
|
||||
harnessReachable: false,
|
||||
lastCheckedAt: null,
|
||||
lastConnectedAt: null,
|
||||
lastError: null,
|
||||
...createTelegramBridgeStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
export class TelegramRuntime {
|
||||
#config;
|
||||
#token;
|
||||
#harness;
|
||||
#state;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#createApi;
|
||||
#status = createTelegramRuntimeStatus();
|
||||
#api = null;
|
||||
#bridge = null;
|
||||
#abortController = null;
|
||||
#pollTask = null;
|
||||
#starting = null;
|
||||
|
||||
constructor({
|
||||
config,
|
||||
token,
|
||||
harness,
|
||||
state,
|
||||
logger = console,
|
||||
replyTimeoutMs = 600_000,
|
||||
createApi = (options) => new TelegramApi(options),
|
||||
}) {
|
||||
if (!config || !token || !harness || !state) {
|
||||
throw new TypeError('TelegramRuntime requires config, token, Harness, and state');
|
||||
}
|
||||
this.#config = config;
|
||||
this.#token = token;
|
||||
this.#harness = harness;
|
||||
this.#state = state;
|
||||
this.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#createApi = createApi;
|
||||
}
|
||||
|
||||
get status() {
|
||||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.#status.ready && this.#pollTask) return this.status;
|
||||
if (this.#starting) return this.#starting;
|
||||
this.#starting = this.#start().finally(() => {
|
||||
this.#starting = null;
|
||||
});
|
||||
return this.#starting;
|
||||
}
|
||||
|
||||
async #start() {
|
||||
await this.stop();
|
||||
this.#status.startedAt = new Date().toISOString();
|
||||
this.#status.connectionState = 'connecting';
|
||||
this.#status.lastError = null;
|
||||
await this.#harness.ensureRunning();
|
||||
this.#status.harnessReachable = true;
|
||||
|
||||
const controller = new AbortController();
|
||||
this.#abortController = controller;
|
||||
const api = this.#createApi({ token: this.#token });
|
||||
this.#api = api;
|
||||
try {
|
||||
const bot = await api.getMe({ signal: controller.signal });
|
||||
if (String(bot?.id ?? '') !== this.#config.platformId || bot?.is_bot !== true) {
|
||||
throw new Error('Telegram token identity does not match the saved bot');
|
||||
}
|
||||
const webhook = await api.getWebhookInfo({ signal: controller.signal });
|
||||
if (typeof webhook?.url === 'string' && webhook.url) {
|
||||
const error = new Error('该 Telegram 机器人已配置 Webhook,请先在原服务中移除 Webhook 后重试。');
|
||||
error.code = 'webhook-configured';
|
||||
throw error;
|
||||
}
|
||||
const client = new TelegramBotClient({ api, signal: controller.signal });
|
||||
this.#bridge = new TelegramHarnessBridge({
|
||||
bot: client,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
replyTimeoutMs: this.#replyTimeoutMs,
|
||||
});
|
||||
|
||||
let cursor = this.#state.cursor();
|
||||
if (cursor === null) {
|
||||
const latest = await api.getUpdates({ offset: -1, timeout: 0, signal: controller.signal });
|
||||
cursor = latest.length > 0 ? latest.at(-1).update_id + 1 : 0;
|
||||
await this.#state.setCursor(cursor);
|
||||
}
|
||||
const now = Date.now();
|
||||
this.#status.ready = true;
|
||||
this.#status.connectionState = 'connected';
|
||||
this.#status.lastCheckedAt = now;
|
||||
this.#status.lastConnectedAt = now;
|
||||
this.#pollTask = this.#poll(cursor, controller.signal);
|
||||
this.#pollTask.catch((error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
this.#status.ready = false;
|
||||
this.#status.connectionState = 'failed';
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.(`[dsh-im:telegram] bot ${this.#config.botId} polling stopped:`, error);
|
||||
});
|
||||
return this.status;
|
||||
} catch (error) {
|
||||
this.#status.ready = false;
|
||||
this.#status.connectionState = 'failed';
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
await this.stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #poll(initialCursor, signal) {
|
||||
let cursor = initialCursor;
|
||||
while (!signal.aborted) {
|
||||
const updates = await this.#api.getUpdates({ offset: cursor, timeout: 25, signal });
|
||||
this.#status.lastCheckedAt = Date.now();
|
||||
for (const update of updates) {
|
||||
if (signal.aborted) return;
|
||||
const message = normalizeTelegramUpdate(update, {
|
||||
botId: this.#config.platformId,
|
||||
username: this.#config.username,
|
||||
});
|
||||
if (message) await this.#bridge.accept(message);
|
||||
cursor = update.update_id + 1;
|
||||
await this.#state.setCursor(cursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
const pollTask = this.#pollTask;
|
||||
const bridge = this.#bridge;
|
||||
this.#abortController?.abort();
|
||||
this.#abortController = null;
|
||||
this.#pollTask = null;
|
||||
this.#api = null;
|
||||
this.#bridge = null;
|
||||
await Promise.race([
|
||||
pollTask?.catch(() => undefined) ?? Promise.resolve(),
|
||||
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
||||
]);
|
||||
await Promise.race([
|
||||
bridge?.waitForIdle() ?? Promise.resolve(),
|
||||
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
||||
]);
|
||||
this.#status.ready = false;
|
||||
this.#status.connectionState = 'idle';
|
||||
return this.status;
|
||||
}
|
||||
}
|
||||
42
test/channels/discord/client-ui.test.mjs
Normal file
42
test/channels/discord/client-ui.test.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import {
|
||||
DiscordAccountCard,
|
||||
DiscordSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/discord/index.js';
|
||||
|
||||
test('Discord settings exposes a Bot Token action without a fake QR action', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(DiscordSettingsTab, {
|
||||
rpcCall: async () => ({ ok: true, value: { bots: [] } }),
|
||||
}));
|
||||
assert.match(markup, /aria-label="使用 Bot Token 接入 Discord 机器人"/);
|
||||
assert.match(markup, />手动接入</);
|
||||
assert.doesNotMatch(markup, /扫码接入机器人|dim-scanButton/);
|
||||
});
|
||||
|
||||
test('Discord account card matches the unified compact card layout', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(DiscordAccountCard, {
|
||||
account: {
|
||||
botId: 'discord_test',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
bot: { name: 'Harness Bot', username: 'HarnessBot', idMasked: '123•••' },
|
||||
health: { summary: 'Discord Gateway 长连接运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /data-im-channel-logo="discord"/);
|
||||
assert.match(markup, /@HarnessBot/);
|
||||
assert.match(markup, />Gateway 长连接</);
|
||||
assert.match(markup, />检查连接</);
|
||||
assert.match(markup, />移除接入</);
|
||||
assert.doesNotMatch(markup, /dim-cardSummary/);
|
||||
});
|
||||
272
test/channels/discord/discord.test.mjs
Normal file
272
test/channels/discord/discord.test.mjs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
DiscordConfigStore,
|
||||
deriveDiscordBotIdentity,
|
||||
} from '../../../src/channels/discord/config-store.mjs';
|
||||
import { DiscordController } from '../../../src/channels/discord/discord-controller.mjs';
|
||||
import {
|
||||
DiscordApi,
|
||||
inspectDiscordToken,
|
||||
validDiscordToken,
|
||||
} from '../../../src/channels/discord/discord-api.mjs';
|
||||
import {
|
||||
DiscordRuntime,
|
||||
normalizeDiscordMessage,
|
||||
} from '../../../src/channels/discord/discord-runtime.mjs';
|
||||
import {
|
||||
DISCORD_ENDPOINTS,
|
||||
createDiscordRpcHandler,
|
||||
} from '../../../plugin-src/host/channels/discord/rpc.mjs';
|
||||
|
||||
const TOKEN = 'MTIzNDU2Nzg5MDEyMzQ1Njc4OQ.ABCD.abcdefghijklmnopqrstuvwxyz123456';
|
||||
|
||||
function jsonResponse(body, status = 200, headers = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function credentials() {
|
||||
const values = new Map();
|
||||
return {
|
||||
values,
|
||||
async resolve(ref) {
|
||||
return values.has(ref) ? { value: values.get(ref), source: 'test' } : undefined;
|
||||
},
|
||||
async set(ref, value) { values.set(ref, value); },
|
||||
async unset(ref) { values.delete(ref); },
|
||||
};
|
||||
}
|
||||
|
||||
test('Discord API authenticates with a Bot header and validates the current bot', async () => {
|
||||
assert.equal(validDiscordToken(TOKEN), true);
|
||||
assert.equal(validDiscordToken('not-a-token'), false);
|
||||
const calls = [];
|
||||
const bot = await inspectDiscordToken(TOKEN, {
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return jsonResponse({
|
||||
id: '1234567890123456789',
|
||||
bot: true,
|
||||
username: 'HarnessBot',
|
||||
global_name: 'Harness Discord',
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.deepEqual(bot, {
|
||||
platformId: '1234567890123456789',
|
||||
name: 'Harness Discord',
|
||||
username: 'HarnessBot',
|
||||
});
|
||||
assert.equal(calls[0].options.headers.authorization, `Bot ${TOKEN}`);
|
||||
assert.match(calls[0].url.pathname, /users\/@me$/);
|
||||
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => jsonResponse({ message: '401: Unauthorized' }, 401),
|
||||
});
|
||||
await assert.rejects(() => api.getCurrentUser(), (error) => {
|
||||
assert.equal(error.code, 'discord-401');
|
||||
assert.doesNotMatch(error.message, new RegExp(TOKEN.replaceAll('.', '\\.')));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('Discord API retries one rate-limited message request', async () => {
|
||||
let attempts = 0;
|
||||
const api = new DiscordApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => {
|
||||
attempts += 1;
|
||||
return attempts === 1
|
||||
? jsonResponse({ message: 'rate limited', retry_after: 0.001 }, 429)
|
||||
: jsonResponse({ id: '987654321012345678', content: 'hello' });
|
||||
},
|
||||
});
|
||||
const message = await api.createMessage({
|
||||
channelId: '123456789012345678',
|
||||
content: 'hello',
|
||||
});
|
||||
assert.equal(message.id, '987654321012345678');
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
|
||||
test('Discord controller persists a credential reference and exposes only masked identity', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-discord-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const configPath = join(directory, 'config.json');
|
||||
const configStore = await new DiscordConfigStore(configPath).load();
|
||||
const credentialStore = credentials();
|
||||
const controller = new DiscordController({
|
||||
credentials: credentialStore,
|
||||
configStore,
|
||||
inspectToken: async () => ({
|
||||
platformId: '1234567890123456789',
|
||||
name: 'Harness Discord',
|
||||
username: 'HarnessBot',
|
||||
}),
|
||||
createRuntime: async () => ({
|
||||
status: {
|
||||
ready: true,
|
||||
connectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
lastCheckedAt: 20,
|
||||
},
|
||||
async start() {},
|
||||
async stop() {},
|
||||
}),
|
||||
});
|
||||
const status = await controller.bindCredentials({ token: TOKEN });
|
||||
assert.equal(status.totals.connected, 1);
|
||||
assert.equal(status.bots[0].bot.name, 'Harness Discord');
|
||||
const identity = deriveDiscordBotIdentity('1234567890123456789');
|
||||
assert.equal(credentialStore.values.get(identity.tokenRef), TOKEN);
|
||||
assert.doesNotMatch(await readFile(configPath, 'utf8'), new RegExp(TOKEN.replaceAll('.', '\\.')));
|
||||
await controller.deleteBot(identity.botId);
|
||||
assert.equal(credentialStore.values.has(identity.tokenRef), false);
|
||||
});
|
||||
|
||||
test('Discord RPC rejects extra credential fields and removes token internals', async () => {
|
||||
const controller = {
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
bindCredentials: async () => ({
|
||||
bots: [{
|
||||
botId: 'discord_123',
|
||||
token: TOKEN,
|
||||
tokenRef: 'DSH_DISCORD_BOT_TOKEN_ABC',
|
||||
bot: { name: 'Discord机器人', idMasked: '123•••' },
|
||||
}],
|
||||
totals: { configured: 1, connected: 0 },
|
||||
}),
|
||||
reconnectBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
};
|
||||
const handler = createDiscordRpcHandler(controller);
|
||||
const result = await handler(DISCORD_ENDPOINTS.bindCredentials, { token: TOKEN });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.value.bots[0].token, undefined);
|
||||
assert.equal(result.value.bots[0].tokenRef, undefined);
|
||||
const rejected = await handler(DISCORD_ENDPOINTS.bindCredentials, { token: TOKEN, appId: 'x' });
|
||||
assert.equal(rejected.ok, false);
|
||||
assert.equal(rejected.error.code, 'bad-request');
|
||||
});
|
||||
|
||||
test('Discord normalizes DMs and only addressed server messages', () => {
|
||||
const direct = normalizeDiscordMessage({
|
||||
id: '111111111111111111',
|
||||
channel_id: '222222222222222222',
|
||||
author: { id: '333333333333333333', bot: false },
|
||||
content: 'hello',
|
||||
}, '1234567890123456789');
|
||||
assert.equal(direct.kind, 'direct');
|
||||
assert.equal(direct.addressed, true);
|
||||
|
||||
const group = normalizeDiscordMessage({
|
||||
id: '111111111111111112',
|
||||
channel_id: '222222222222222223',
|
||||
guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333334', bot: false },
|
||||
mentions: [{ id: '1234567890123456789' }],
|
||||
content: '<@1234567890123456789> run this',
|
||||
}, '1234567890123456789');
|
||||
assert.equal(group.kind, 'group');
|
||||
assert.equal(group.addressed, true);
|
||||
assert.equal(group.content, 'run this');
|
||||
|
||||
const unmentionedReply = normalizeDiscordMessage({
|
||||
id: '111111111111111113',
|
||||
channel_id: '222222222222222223',
|
||||
guild_id: '444444444444444444',
|
||||
author: { id: '333333333333333334', bot: false },
|
||||
mentions: [],
|
||||
referenced_message: { author: { id: '1234567890123456789', bot: true } },
|
||||
content: '',
|
||||
}, '1234567890123456789');
|
||||
assert.equal(unmentionedReply.addressed, false);
|
||||
});
|
||||
|
||||
class FakeSocket {
|
||||
#listeners = new Map();
|
||||
sent = [];
|
||||
readyState = 1;
|
||||
|
||||
addEventListener(name, listener) {
|
||||
const listeners = this.#listeners.get(name) ?? [];
|
||||
listeners.push(listener);
|
||||
this.#listeners.set(name, listeners);
|
||||
}
|
||||
|
||||
send(value) {
|
||||
const packet = JSON.parse(value);
|
||||
this.sent.push(packet);
|
||||
if (packet.op === 2) {
|
||||
queueMicrotask(() => this.emit('message', {
|
||||
data: JSON.stringify({
|
||||
op: 0,
|
||||
t: 'READY',
|
||||
s: 1,
|
||||
d: {
|
||||
session_id: 'session',
|
||||
resume_gateway_url: 'wss://gateway.discord.gg',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
close(code = 1000) {
|
||||
if (this.readyState >= 2) return;
|
||||
this.readyState = 3;
|
||||
this.emit('close', { code });
|
||||
}
|
||||
|
||||
emit(name, event) {
|
||||
for (const listener of this.#listeners.get(name) ?? []) listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
test('Discord runtime identifies on Gateway v10 and becomes ready', async () => {
|
||||
let socket;
|
||||
const runtime = new DiscordRuntime({
|
||||
config: {
|
||||
botId: 'discord_test',
|
||||
platformId: '1234567890123456789',
|
||||
name: 'Harness Discord',
|
||||
},
|
||||
token: TOKEN,
|
||||
harness: { ensureRunning: async () => true },
|
||||
state: {
|
||||
sessionFor: () => null,
|
||||
setSession: async () => {},
|
||||
clearSession: async () => {},
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => {},
|
||||
},
|
||||
createApi: () => ({
|
||||
getCurrentUser: async () => ({ id: '1234567890123456789', bot: true }),
|
||||
getGatewayBot: async () => ({ url: 'wss://gateway.discord.gg' }),
|
||||
}),
|
||||
createWebSocket: () => {
|
||||
socket = new FakeSocket();
|
||||
queueMicrotask(() => socket.emit('message', {
|
||||
data: JSON.stringify({ op: 10, d: { heartbeat_interval: 45_000 } }),
|
||||
}));
|
||||
return socket;
|
||||
},
|
||||
random: () => 0.5,
|
||||
});
|
||||
await runtime.start();
|
||||
assert.equal(runtime.status.ready, true);
|
||||
const identify = socket.sent.find((packet) => packet.op === 2);
|
||||
assert.equal(identify.d.token, TOKEN);
|
||||
assert.equal(identify.d.intents, 4_609);
|
||||
assert.equal(identify.d.properties.browser, 'dsh-im');
|
||||
await runtime.stop();
|
||||
assert.equal(runtime.status.ready, false);
|
||||
});
|
||||
42
test/channels/telegram/client-ui.test.mjs
Normal file
42
test/channels/telegram/client-ui.test.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import {
|
||||
TelegramAccountCard,
|
||||
TelegramSettingsTab,
|
||||
} from '../../../plugin-src/client/channels/telegram/index.js';
|
||||
|
||||
test('Telegram settings exposes a Bot Token action without a fake QR action', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(TelegramSettingsTab, {
|
||||
rpcCall: async () => ({ ok: true, value: { bots: [] } }),
|
||||
}));
|
||||
assert.match(markup, /aria-label="使用 Bot Token 接入 Telegram 机器人"/);
|
||||
assert.match(markup, />手动接入</);
|
||||
assert.doesNotMatch(markup, /扫码接入机器人|dim-scanButton/);
|
||||
});
|
||||
|
||||
test('Telegram account card matches the unified compact card layout', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(TelegramAccountCard, {
|
||||
account: {
|
||||
botId: 'telegram_test',
|
||||
connected: true,
|
||||
state: 'connected',
|
||||
bot: { name: 'Harness Bot', username: 'harness_bot', idMasked: '123•••' },
|
||||
health: { summary: 'Telegram Bot API 长轮询运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
assert.match(markup, /data-im-channel-logo="telegram"/);
|
||||
assert.match(markup, /@harness_bot/);
|
||||
assert.match(markup, />Bot API 长轮询</);
|
||||
assert.match(markup, />检查连接</);
|
||||
assert.match(markup, />移除接入</);
|
||||
assert.doesNotMatch(markup, /dim-cardSummary/);
|
||||
});
|
||||
274
test/channels/telegram/telegram.test.mjs
Normal file
274
test/channels/telegram/telegram.test.mjs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
TelegramConfigStore,
|
||||
deriveTelegramBotIdentity,
|
||||
} from '../../../src/channels/telegram/config-store.mjs';
|
||||
import { TelegramController } from '../../../src/channels/telegram/telegram-controller.mjs';
|
||||
import {
|
||||
TelegramApi,
|
||||
inspectTelegramToken,
|
||||
validTelegramToken,
|
||||
} from '../../../src/channels/telegram/telegram-api.mjs';
|
||||
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
|
||||
import {
|
||||
TelegramRuntime,
|
||||
normalizeTelegramUpdate,
|
||||
} from '../../../src/channels/telegram/telegram-runtime.mjs';
|
||||
import { TelegramStateStore } from '../../../src/channels/telegram/state-store.mjs';
|
||||
import {
|
||||
TELEGRAM_ENDPOINTS,
|
||||
createTelegramRpcHandler,
|
||||
} from '../../../plugin-src/host/channels/telegram/rpc.mjs';
|
||||
|
||||
const TOKEN = '123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef123456';
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function credentials() {
|
||||
const values = new Map();
|
||||
return {
|
||||
values,
|
||||
async resolve(ref) {
|
||||
return values.has(ref) ? { value: values.get(ref), source: 'test' } : undefined;
|
||||
},
|
||||
async set(ref, value) { values.set(ref, value); },
|
||||
async unset(ref) { values.delete(ref); },
|
||||
};
|
||||
}
|
||||
|
||||
function memoryState() {
|
||||
const sessions = new Map();
|
||||
const seen = new Set();
|
||||
return {
|
||||
sessionFor: (key) => sessions.get(key) ?? null,
|
||||
setSession: async (key, value) => sessions.set(key, value),
|
||||
clearSession: async (key) => sessions.delete(key),
|
||||
hasSeen: (id) => seen.has(id),
|
||||
markSeen: async (id) => seen.add(id),
|
||||
};
|
||||
}
|
||||
|
||||
test('Telegram API validates a Bot Token without exposing it in requests or errors', async () => {
|
||||
assert.equal(validTelegramToken(TOKEN), true);
|
||||
assert.equal(validTelegramToken('short'), false);
|
||||
const calls = [];
|
||||
const bot = await inspectTelegramToken(TOKEN, {
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return jsonResponse({ ok: true, result: {
|
||||
id: 123456789,
|
||||
is_bot: true,
|
||||
first_name: 'Harness',
|
||||
username: 'HarnessBot',
|
||||
} });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(bot, {
|
||||
platformId: '123456789',
|
||||
name: 'Harness',
|
||||
username: 'HarnessBot',
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url.protocol, 'https:');
|
||||
assert.equal(calls[0].url.hostname, 'api.telegram.org');
|
||||
assert.match(calls[0].url.pathname, /^\/bot/);
|
||||
assert.match(calls[0].url.pathname, /getMe$/);
|
||||
assert.equal(calls[0].options.method, 'POST');
|
||||
|
||||
const api = new TelegramApi({
|
||||
token: TOKEN,
|
||||
fetchImpl: async () => jsonResponse({ ok: false, error_code: 401, description: 'Unauthorized' }, 401),
|
||||
});
|
||||
await assert.rejects(() => api.getMe(), (error) => {
|
||||
assert.equal(error.code, 'telegram-401');
|
||||
assert.doesNotMatch(error.message, new RegExp(TOKEN));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('Telegram config and controller store only a credential reference in bot data', async (t) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-'));
|
||||
t.after(() => rm(directory, { recursive: true, force: true }));
|
||||
const configPath = join(directory, 'config.json');
|
||||
const configStore = await new TelegramConfigStore(configPath).load();
|
||||
const credentialStore = credentials();
|
||||
const runtimes = [];
|
||||
const controller = new TelegramController({
|
||||
credentials: credentialStore,
|
||||
configStore,
|
||||
inspectToken: async () => ({
|
||||
platformId: '123456789', name: 'Harness Telegram', username: 'harness_bot',
|
||||
}),
|
||||
createRuntime: async () => {
|
||||
const runtime = {
|
||||
status: {
|
||||
ready: true,
|
||||
connectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
lastCheckedAt: 10,
|
||||
},
|
||||
async start() {},
|
||||
async stop() {},
|
||||
};
|
||||
runtimes.push(runtime);
|
||||
return runtime;
|
||||
},
|
||||
});
|
||||
|
||||
const status = await controller.bindCredentials({ token: TOKEN });
|
||||
assert.equal(status.totals.connected, 1);
|
||||
assert.equal(status.bots[0].bot.name, 'Harness Telegram');
|
||||
assert.equal(status.bots[0].bot.username, 'harness_bot');
|
||||
const identity = deriveTelegramBotIdentity('123456789');
|
||||
assert.equal(credentialStore.values.get(identity.tokenRef), TOKEN);
|
||||
const persisted = await readFile(configPath, 'utf8');
|
||||
assert.doesNotMatch(persisted, new RegExp(TOKEN));
|
||||
assert.match(persisted, new RegExp(identity.tokenRef));
|
||||
|
||||
await controller.reconnectBot(identity.botId);
|
||||
assert.equal(runtimes.length, 2);
|
||||
await controller.deleteBot(identity.botId);
|
||||
assert.equal(credentialStore.values.has(identity.tokenRef), false);
|
||||
assert.equal(controller.status().totals.configured, 0);
|
||||
});
|
||||
|
||||
test('Telegram RPC accepts only token binding and strips credential internals', async () => {
|
||||
const calls = [];
|
||||
const controller = {
|
||||
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
bindCredentials: async (payload) => {
|
||||
calls.push(payload);
|
||||
return {
|
||||
bots: [{
|
||||
botId: 'telegram_123',
|
||||
tokenRef: 'DSH_TELEGRAM_BOT_TOKEN_ABC',
|
||||
token: TOKEN,
|
||||
bot: { name: 'Telegram机器人', idMasked: '123•••' },
|
||||
}],
|
||||
totals: { configured: 1, connected: 0 },
|
||||
};
|
||||
},
|
||||
reconnectBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
|
||||
};
|
||||
const handler = createTelegramRpcHandler(controller);
|
||||
const result = await handler(TELEGRAM_ENDPOINTS.bindCredentials, { token: TOKEN });
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, [{ token: TOKEN }]);
|
||||
assert.equal(result.value.bots[0].token, undefined);
|
||||
assert.equal(result.value.bots[0].tokenRef, undefined);
|
||||
const rejected = await handler(TELEGRAM_ENDPOINTS.bindCredentials, { token: TOKEN, extra: true });
|
||||
assert.equal(rejected.ok, false);
|
||||
assert.equal(rejected.error.code, 'bad-request');
|
||||
});
|
||||
|
||||
test('Telegram normalizes private messages and requires an explicit group address', () => {
|
||||
const privateMessage = normalizeTelegramUpdate({
|
||||
update_id: 10,
|
||||
message: {
|
||||
message_id: 4,
|
||||
chat: { id: 88, type: 'private' },
|
||||
from: { id: 42, is_bot: false },
|
||||
text: 'hello',
|
||||
},
|
||||
}, { botId: '123456789', username: 'HarnessBot' });
|
||||
assert.equal(privateMessage.kind, 'direct');
|
||||
assert.equal(privateMessage.addressed, true);
|
||||
|
||||
const groupMessage = normalizeTelegramUpdate({
|
||||
update_id: 11,
|
||||
message: {
|
||||
message_id: 5,
|
||||
chat: { id: -1001, type: 'supergroup' },
|
||||
from: { id: 43, is_bot: false },
|
||||
text: '@HarnessBot run this',
|
||||
entities: [{ type: 'mention', offset: 0, length: 11 }],
|
||||
},
|
||||
}, { botId: '123456789', username: 'HarnessBot' });
|
||||
assert.equal(groupMessage.kind, 'group');
|
||||
assert.equal(groupMessage.addressed, true);
|
||||
assert.equal(groupMessage.content, 'run this');
|
||||
});
|
||||
|
||||
test('Telegram bridge ignores unaddressed groups and streams direct replies', async () => {
|
||||
const sent = [];
|
||||
const updates = [];
|
||||
const bot = {
|
||||
sendText: async (_target, text) => sent.push(text),
|
||||
sendTyping: async () => {},
|
||||
openStream: async () => ({
|
||||
update: async (text) => updates.push(text),
|
||||
finish: async (text) => sent.push(text),
|
||||
}),
|
||||
};
|
||||
let askCount = 0;
|
||||
const harness = {
|
||||
ensureRunning: async () => true,
|
||||
sessionExists: async () => true,
|
||||
createSession: async () => 'session-1',
|
||||
ask: async (_session, _text, options) => {
|
||||
askCount += 1;
|
||||
await options.onUpdate({ type: 'tool', name: '搜索' });
|
||||
await options.onUpdate({ type: 'text', text: '处理中' });
|
||||
return '完成';
|
||||
},
|
||||
};
|
||||
const bridge = new TelegramHarnessBridge({ bot, harness, state: memoryState() });
|
||||
await bridge.accept({
|
||||
messageId: '1', senderId: 'u1', kind: 'group', conversationId: 'g1', content: 'ignored',
|
||||
addressed: false, replyTarget: {},
|
||||
});
|
||||
assert.equal(askCount, 0);
|
||||
await bridge.accept({
|
||||
messageId: '2', senderId: 'u1', kind: 'direct', conversationId: 'u1', content: 'hello',
|
||||
addressed: true, replyTarget: {},
|
||||
});
|
||||
assert.equal(askCount, 1);
|
||||
assert.deepEqual(updates, ['正在使用搜索…', '处理中']);
|
||||
assert.deepEqual(sent, ['完成']);
|
||||
});
|
||||
|
||||
test('Telegram runtime validates webhook state and starts a cancellable long poll', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-runtime-'));
|
||||
const state = await new TelegramStateStore(join(directory, 'state.json')).load();
|
||||
const calls = [];
|
||||
const fakeApi = {
|
||||
getMe: async () => ({ id: 123456789, is_bot: true }),
|
||||
getWebhookInfo: async () => ({ url: '' }),
|
||||
getUpdates: async ({ offset, timeout, signal }) => {
|
||||
calls.push({ offset, timeout });
|
||||
if (timeout === 0) return [];
|
||||
return new Promise((resolve, reject) => signal.addEventListener('abort', () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
}, { once: true }));
|
||||
},
|
||||
};
|
||||
const runtime = new TelegramRuntime({
|
||||
config: {
|
||||
botId: 'telegram_test',
|
||||
platformId: '123456789',
|
||||
username: 'HarnessBot',
|
||||
},
|
||||
token: TOKEN,
|
||||
harness: { ensureRunning: async () => true },
|
||||
state,
|
||||
createApi: () => fakeApi,
|
||||
});
|
||||
await runtime.start();
|
||||
assert.equal(runtime.status.ready, true);
|
||||
assert.equal(runtime.status.connectionState, 'connected');
|
||||
await runtime.stop();
|
||||
assert.equal(runtime.status.ready, false);
|
||||
assert.deepEqual(calls[0], { offset: -1, timeout: 0 });
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
|
@ -57,6 +57,21 @@ test('Host plugin registers the Weixin RPC channel as loopback-only', async () =
|
|||
assert.deepEqual(registration.options, { authority: 'loopback' });
|
||||
});
|
||||
|
||||
test('Host plugin opts the Weixin RPC channel into trusted Host authorities', async () => {
|
||||
let registration;
|
||||
const ctx = {
|
||||
connection: { rpc: { handle: (channel, handler, options) => {
|
||||
registration = { channel, handler, options };
|
||||
return async () => {};
|
||||
} } },
|
||||
};
|
||||
await apply(ctx, {
|
||||
controller: controllerFixture(),
|
||||
rpcAuthority: 'trusted-host',
|
||||
});
|
||||
assert.deepEqual(registration.options, { authority: 'trusted-host' });
|
||||
});
|
||||
|
||||
test('RPC returns QR data and verification states without exposing secret-shaped fields', async () => {
|
||||
const controller = controllerFixture();
|
||||
const handler = createWeixinRpcHandler(controller, {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ const QQ_SOURCE_URL = new URL(
|
|||
import.meta.url,
|
||||
);
|
||||
|
||||
test('IM settings renders five compact logo channel tabs without enable switches', async () => {
|
||||
test('IM settings renders seven compact logo channel tabs without enable switches', async () => {
|
||||
const styles = await readFile(STYLES_URL, 'utf8');
|
||||
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
||||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||
|
|
@ -73,23 +73,29 @@ test('IM settings renders five compact logo channel tabs without enable switches
|
|||
dingtalkRpcCall: async () => ({ ok: true, value: {} }),
|
||||
wecomRpcCall: async () => ({ ok: true, value: {} }),
|
||||
qqRpcCall: async () => ({ ok: true, value: {} }),
|
||||
telegramRpcCall: async () => ({ ok: true, value: {} }),
|
||||
discordRpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
|
||||
assert.match(markup, /IM机器人/);
|
||||
assert.match(markup, /通过扫码把机器人接入 DeepSeek Harness/);
|
||||
assert.match(markup, /把机器人接入 DeepSeek Harness/);
|
||||
assert.doesNotMatch(markup, /\d+ 个渠道|dim-channelCount/);
|
||||
assert.match(markup, />微信</);
|
||||
assert.match(markup, />飞书</);
|
||||
assert.match(markup, />钉钉</);
|
||||
assert.match(markup, />企业微信</);
|
||||
assert.match(markup, />QQ</);
|
||||
assert.match(markup, />Telegram</);
|
||||
assert.match(markup, />Discord</);
|
||||
assert.match(markup, /dim-logoWeixin/);
|
||||
assert.match(markup, /dim-logoFeishu/);
|
||||
assert.match(markup, /dim-logoDingtalk/);
|
||||
assert.match(markup, /dim-logoWecom/);
|
||||
assert.match(markup, /dim-logoQq/);
|
||||
assert.match(markup, /dim-logoTelegram/);
|
||||
assert.match(markup, /dim-logoDiscord/);
|
||||
assert.match(styles, /\.dim-logoFeishu svg \{ width: 28px; height: 28px; \}/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 5);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 7);
|
||||
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
||||
assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import test from 'node:test';
|
|||
|
||||
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
|
||||
|
||||
test('Host composes Feishu, Weixin, DingTalk, Enterprise WeChat, and QQ inside one plugin context', async () => {
|
||||
test('Host composes all seven IM channels inside one plugin context', async () => {
|
||||
const calls = [];
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
|
||||
|
|
@ -11,14 +11,19 @@ test('Host composes Feishu, Weixin, DingTalk, Enterprise WeChat, and QQ inside o
|
|||
applyDingtalk: async (ctx, config) => calls.push(['dingtalk', ctx, config]),
|
||||
applyWecom: async (ctx, config) => calls.push(['wecom', ctx, config]),
|
||||
applyQq: async (ctx, config) => calls.push(['qq', ctx, config]),
|
||||
applyTelegram: async (ctx, config) => calls.push(['telegram', ctx, config]),
|
||||
applyDiscord: async (ctx, config) => calls.push(['discord', ctx, config]),
|
||||
});
|
||||
const ctx = { marker: 'shared-context' };
|
||||
const config = {
|
||||
rpcAuthority: 'trusted-host',
|
||||
feishu: { domain: 'feishu' },
|
||||
weixin: { timeout: 30 },
|
||||
dingtalk: { replyTimeoutMs: 60_000 },
|
||||
wecom: { replyTimeoutMs: 60_000 },
|
||||
qq: { replyTimeoutMs: 60_000 },
|
||||
telegram: { replyTimeoutMs: 60_000 },
|
||||
discord: { replyTimeoutMs: 60_000 },
|
||||
};
|
||||
|
||||
await plugin.apply(ctx, config);
|
||||
|
|
@ -26,11 +31,13 @@ test('Host composes Feishu, Weixin, DingTalk, Enterprise WeChat, and QQ inside o
|
|||
assert.equal(name, 'dsh-im-host');
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'webServer']);
|
||||
assert.deepEqual(calls, [
|
||||
['feishu', ctx, config.feishu],
|
||||
['weixin', ctx, config.weixin],
|
||||
['dingtalk', ctx, config.dingtalk],
|
||||
['wecom', ctx, config.wecom],
|
||||
['qq', ctx, config.qq],
|
||||
['feishu', ctx, { ...config.feishu, rpcAuthority: 'trusted-host' }],
|
||||
['weixin', ctx, { ...config.weixin, rpcAuthority: 'trusted-host' }],
|
||||
['dingtalk', ctx, { ...config.dingtalk, rpcAuthority: 'trusted-host' }],
|
||||
['wecom', ctx, { ...config.wecom, rpcAuthority: 'trusted-host' }],
|
||||
['qq', ctx, { ...config.qq, rpcAuthority: 'trusted-host' }],
|
||||
['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host' }],
|
||||
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
17
test/rpc-authority.test.mjs
Normal file
17
test/rpc-authority.test.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveRpcAuthority } from '../plugin-src/host/rpc-authority.mjs';
|
||||
|
||||
test('IM management RPC authority defaults to loopback and accepts explicit trusted hosts', () => {
|
||||
assert.equal(resolveRpcAuthority(), 'loopback');
|
||||
assert.equal(resolveRpcAuthority('loopback'), 'loopback');
|
||||
assert.equal(resolveRpcAuthority('trusted-host'), 'trusted-host');
|
||||
});
|
||||
|
||||
test('IM management RPC authority rejects unknown policy values', () => {
|
||||
assert.throws(
|
||||
() => resolveRpcAuthority('public'),
|
||||
/rpcAuthority must be "loopback" or "trusted-host"/,
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue