fix: preserve Telegram compatibility and stabilize Office

This commit is contained in:
xmanrui 2026-08-21 01:37:21 +08:00
parent 7f114e1c36
commit 24e690498b
23 changed files with 1679 additions and 314 deletions

View file

@ -51,7 +51,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
| WeCom | Create an intelligent bot by QR code, or bind one with Bot ID + Secret | Official WebSocket connection; native thinking state, tool progress, and streaming replies |
| QQ | Create a bot with mobile QQ QR scanning, or bind one with AppID + AppSecret | WebSocket connection; native typing and streaming replies in private chats, replies when mentioned in groups |
| Slack | Create an app from the bundled App Manifest, then enter a Bot Token (`xoxb-`) and App Token (`xapp-`) | Socket Mode connection; direct DM replies, mention-only channel replies, and preferred native streaming API |
| Telegram | Enter a Bot Token generated by @BotFather | Bot API long polling; all groups ignored, private chats restricted to numeric IDs in `telegram.allowedUsers`, and streaming through message edits |
| Telegram | Enter a Bot Token generated by @BotFather | Bot API long polling; DMs work by default and groups respond to mentions or replies, while each bot can optionally enable a private-DM allowlist; streaming uses message edits |
| Discord | Enter a Bot Token generated in the Developer Portal | Gateway v10 connection; direct DM replies, mention-only server replies, and streaming through message edits |
| WhatsApp | Scan a QR code with mobile WhatsApp to link a device | WhatsApp Web connection; read receipt and typing indicator followed by the final answer |
@ -94,17 +94,7 @@ After installation, follow the built-in instructions on each channel page to sca
| Bot workspace | Each bot stores its workspace independently. New bots start with the Host's current working directory, which can later be changed from the bot card. |
| Agent Preset | New Sessions inherit Harness's `agent-presets.default` unless the channel explicitly overrides it. Later changes do not affect existing Sessions. |
Telegram denies all inbound messages by default. Configure the numeric User IDs allowed to message the bot privately in the Web profile's `cordis.patch.yml`; group messages are ignored even when they mention the bot:
```yaml
- id: xmanrui-dsh-im
config:
telegram:
allowedUsers:
- 123456789
```
`allowedUsers` accepts numbers or decimal strings, removes duplicates, and rejects invalid values when the plugin starts. An empty allowlist remains deny-all.
Each Telegram bot has its own access-mode control on its bot card. Existing and newly connected bots both default to **Compatible mode**: DMs receive replies, while group messages require a mention of or reply to the bot. Restrictions apply only after explicitly switching that bot to **Safe mode (private-chat allowlist)**. Safe mode ignores every group message and admits only numeric User IDs in that bot's allowlist. Enter one ID per line. Switching back to Compatible mode retains the allowlist without enforcing it, so it is available when Safe mode is enabled again. An empty allowlist in Safe mode rejects all inbound messages for that bot.
## Bot commands
@ -146,7 +136,7 @@ Example: send `/models`, then `/model 2` to switch to the second model in the li
- `/session` accepts exactly one Session ID obtained from `/sessionlist`. It neither creates a session nor immediately prompts the model; later messages in the current chat continue the bound session. Regular archived sessions can be bound without being unarchived, while subagent sessions cannot be bound.
- `/session` locates the session's unique workspace automatically. Binding inside the current workspace replaces only this chat's mapping. A cross-workspace binding switches the bot workspace, clears the old session mappings for all of that bot's chats, and then binds this chat, so it affects the bot's other chats. A reply already being generated may still finish.
- Workspace switches and session bindings only clear or replace dsh-im chat mappings. They never delete, empty, or archive old Session contents; an old Session can still be listed and bound again.
- Except on Telegram, any user who is already within the platform bot's visibility scope and can normally message it can run these commands; Telegram admits only private users in `telegram.allowedUsers` and always ignores group commands.
- Any user within the platform bot's visibility scope who can normally message it can run these commands; there is no separate administrator role. Telegram Compatible mode follows the original DM and group mention/reply rules. Safe mode admits only private users in that bot's allowlist and always ignores group commands.
- The list comes from the Harness Host's global registry and can include local absolute paths for other bots, other channels, or non-IM projects. Restrict the bot's visibility to trusted users.
- Session results also come from the global Harness Host. Session IDs and titles can belong to other bots, other channels, or non-IM projects, and may contain sensitive metadata. Enable these commands only when every user in the bot's visibility scope is trusted.
- Any user who can run `/session` can continue the selected session and use later messages to write to it or invoke its available tools. Expose the bot and session list only to trusted users.
@ -169,7 +159,7 @@ Example: send `/models`, then `/model 2` to switch to the second model in the li
- Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English.
- Uses logos for WeChat, Feishu, DingTalk, WeCom, QQ, Slack, Telegram, Discord, WhatsApp, and AI Office navigation without enable/disable switches.
- Keeps RPC endpoints, credentials, connection supervision, and session mappings isolated by IM channel; the Office Connector separately owns Device credentials, Job leases, approval waits, and concurrency limits.
- Returns only QR codes, the public Slack Manifest, and redacted status data to the browser. Manually entered secrets and Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Slack Bot/App Tokens, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, AI Office Device Tokens, or raw user identifiers.
- Returns only QR codes, the public Slack Manifest, redacted status data, and the access mode and allowlist User IDs that the user explicitly saved for the current Telegram bot. Manually entered secrets and Tokens travel one way to the local Host; no RPC response returns App Secrets, `bot_token`, DingTalk `client_secret`, WeCom Secrets, QQ `app_secret`, Slack Bot/App Tokens, Telegram/Discord Bot Tokens, WhatsApp linked-device keys, AI Office Device Tokens, or other raw user identifiers observed from platform messages.
## Local development

View file

@ -54,7 +54,7 @@ Connect IM bots to DeepSeek Harness by scanning a QR code, using an App Manifest
| 企业微信 | 使用企业微信 App 扫码创建智能机器人,或使用 Bot ID + Secret 手动绑定 | 官方 WebSocket 长连接;原生显示“正在思考中”、工具执行进度和流式回答 |
| QQ | 使用手机 QQ 扫码创建机器人,或使用 AppID + AppSecret 手动绑定 | WebSocket 长连接;私聊显示“正在输入”和流式回答,群聊被 @ 后回复 |
| Slack | 使用预置 App Manifest 创建应用,再填写 Bot Token(`xoxb-`)和 App Token(`xapp-`) | Socket Mode 长连接;私聊直接回复,频道被 @ 后响应,优先使用官方流式消息 API |
| Telegram | 使用 @BotFather 生成的 Bot Token | Bot API 长轮询;群聊全部忽略,私聊仅接受 `telegram.allowedUsers` 中的数字 User ID,通过编辑消息流式显示回答 |
| Telegram | 使用 @BotFather 生成的 Bot Token | Bot API 长轮询;默认私聊直接响应、群聊被提及或回复时响应,也可为每个机器人独立启用私聊白名单安全模式;通过编辑消息流式显示回答 |
| Discord | 使用 Developer Portal 生成的 Bot Token | Gateway v10 长连接;私信直接回复,服务器频道被提及时响应,通过编辑消息流式显示回答 |
| WhatsApp | 使用手机 WhatsApp 扫码关联设备 | WhatsApp Web 长连接;显示已读和“正在输入”,再发送最终回答 |
@ -97,17 +97,7 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可
| 机器人工作区 | 每个机器人独立保存工作区。新机器人默认使用 Host 当时的工作目录;之后可在机器人卡片中修改。 |
| Agent Preset | 新会话默认继承 Harness 的 `agent-presets.default`;渠道显式配置优先,已有会话不受后续修改影响。 |
Telegram 默认拒绝所有入站消息。请在 Web profile 的 `cordis.patch.yml` 中配置允许私聊机器人的数字 User ID;群聊无论是否提及机器人都会被忽略:
```yaml
- id: xmanrui-dsh-im
config:
telegram:
allowedUsers:
- 123456789
```
`allowedUsers` 接受数字或十进制字符串、会去重,并在插件启动时拒绝无效值。空白名单保持全部拒绝。
每个 Telegram 机器人都可以在自己的卡片中切换访问模式。旧机器人和新接入机器人均默认使用**兼容模式**:私聊直接响应,群聊仅在提及机器人或回复机器人消息时响应。只有主动切换到**安全模式(私聊白名单)**后,机器人才会忽略全部群聊,并只接受该机器人白名单中的数字 User ID。白名单每行一个 ID、按机器人独立保存;切回兼容模式时会保留但不使用,再切回安全模式即可继续使用。安全模式的空白名单会拒绝该机器人的所有入站消息。
## 机器人命令
@ -149,7 +139,7 @@ Telegram 默认拒绝所有入站消息。请在 Web profile 的 `cordis.patch.y
- `/session` 只接受一个由 `/sessionlist` 获得的 Session ID。它不会新建会话或立即向模型发送消息;绑定成功后,当前聊天的后续消息会继续该会话。普通归档会话可以绑定但不会自动取消归档,子代理会话不能绑定。
- `/session` 会自动定位会话唯一所属的工作区。同工作区绑定只替换当前聊天的映射;跨工作区绑定会切换该机器人的工作区、清除该机器人所有聊天的旧会话映射,再绑定当前聊天,因此会影响该机器人的其他聊天。已经开始生成的回复仍可完成。
- 工作区切换和会话绑定只会清除或替换 dsh-im 的聊天映射,不会删除、清空或归档任何旧 Session 内容;旧 Session 仍可再次列出和绑定。
- 除 Telegram 外,任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行这些命令,不区分管理员和普通用户;Telegram 仅允许 `telegram.allowedUsers` 中的私聊用户执行,群聊命令始终忽略。
- 任何已在对应平台可见范围内、能够正常向机器人发消息的用户都可以执行这些命令,不区分管理员和普通用户。Telegram 兼容模式遵循原有私聊及群聊提及/回复规则;安全模式只允许当前机器人白名单中的私聊用户执行,群聊命令始终忽略。
- 工作区列表来自 Harness Host 的全局登记信息,可能包含其他机器人、其他渠道或非 IM 项目的本机绝对路径。请将机器人可见范围限制给可信用户。
- 会话列表同样来自该全局 Harness Host;会话 ID 和标题可能属于其他机器人、其他渠道或非 IM 项目,并可能包含敏感元数据。开放命令前请确保所有可见用户都可信。
- 任何能执行 `/session` 的用户都能接续所选会话,并通过后续消息写入会话或触发其可用工具。请只向可信用户开放机器人及其会话列表。
@ -172,7 +162,7 @@ Telegram 默认拒绝所有入站消息。请在 Web profile 的 `cordis.patch.y
- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;
- 左侧使用 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 和 AI Office,不使用启用/停用开关;
- 九个 IM 渠道保持独立的 RPC、凭据、连接监督和会话映射;Office Connector 另行维护设备凭据、Job 租约、审批等待与并发上限;
- 浏览器只获得二维码、Manifest 和脱敏状态;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥、AI Office Device Token 或原始用户标识。
- 浏览器只获得二维码、Manifest、脱敏状态,以及用户为当前 Telegram 机器人主动保存的访问模式和白名单 User ID;手动输入的 Secret 或 Token 仅单向提交给本机 Host,任何 RPC 响应都不会返回 App Secret、`bot_token`、钉钉 `client_secret`、企业微信 Secret、QQ `app_secret`、Slack Bot/App Token、Telegram/Discord Bot Token、WhatsApp 关联设备密钥、AI Office Device Token,或从平台消息中观察到的其他原始用户标识。
## 本地开发

View file

@ -40,7 +40,7 @@ __export(index_exports, {
name: () => name
});
module.exports = __toCommonJS(index_exports);
var React17 = __toESM(require("react"), 1);
var React18 = __toESM(require("react"), 1);
// plugin-src/client/channel-logos.js
var React = __toESM(require("react"), 1);
@ -671,6 +671,28 @@ var EN = Object.freeze({
"\u63A5\u5165 Telegram \u673A\u5668\u4EBA": "Connect a Telegram bot",
"\u5148\u901A\u8FC7 @BotFather \u83B7\u53D6 Bot Token\uFF0C\u518D\u5728\u8FD9\u91CC\u5B8C\u6210\u63A5\u5165\u3002": "Get a Bot Token from @BotFather, then connect it here.",
"\u586B\u5199 @BotFather \u751F\u6210\u7684 Bot Token": "Enter the Bot Token from @BotFather",
"\u8BBF\u95EE\u6A21\u5F0F": "Access mode",
"\u8BBF\u95EE\u8BBE\u7F6E": "Access settings",
"Telegram \u8BBF\u95EE\u6A21\u5F0F": "Telegram access mode",
"\u7FA4\u804A\u5168\u90E8\u5FFD\u7565\uFF0C\u79C1\u804A\u4EC5\u5141\u8BB8\u767D\u540D\u5355\u7528\u6237\u3002": "All group messages are ignored; only allowlisted users may send DMs.",
"\u4FDD\u6301\u539F\u6709\u884C\u4E3A\uFF1A\u79C1\u804A\u76F4\u63A5\u54CD\u5E94\uFF0C\u7FA4\u804A\u5728\u88AB\u63D0\u53CA\u6216\u56DE\u590D\u65F6\u54CD\u5E94\u3002": "Keep the original behavior: respond to DMs and to group mentions or replies.",
"\u5B89\u5168\u6A21\u5F0F": "Safe mode",
"\u517C\u5BB9\u6A21\u5F0F": "Compatible mode",
"\u5DF2\u751F\u6548\uFF1A\u5B89\u5168\u6A21\u5F0F": "Active: Safe mode",
"\u5DF2\u751F\u6548\uFF1A\u517C\u5BB9\u6A21\u5F0F": "Active: Compatible mode",
"\u6A21\u5F0F": "Mode",
"\u517C\u5BB9\u6A21\u5F0F\uFF08\u9ED8\u8BA4\uFF09": "Compatible mode (default)",
"\u5B89\u5168\u6A21\u5F0F\uFF08\u79C1\u804A\u767D\u540D\u5355\uFF09": "Safe mode (private-chat allowlist)",
"\u5141\u8BB8\u79C1\u804A\u7684 Telegram User ID": "Telegram User IDs allowed to send DMs",
"\u6BCF\u884C\u4E00\u4E2A\u6570\u5B57 User ID": "One numeric User ID per line",
"\u767D\u540D\u5355\u4EC5\u5C5E\u4E8E\u5F53\u524D\u673A\u5668\u4EBA\u3002": "This allowlist belongs only to the current bot.",
"\u517C\u5BB9\u6A21\u5F0F\u4E0B\u6682\u4E0D\u4F7F\u7528\u767D\u540D\u5355\uFF0C\u5207\u6362\u6A21\u5F0F\u65F6\u4F1A\u4FDD\u7559\u3002": "Compatible mode does not enforce the allowlist; it is retained when modes change.",
"\u767D\u540D\u5355\u4E3A\u7A7A\uFF1B\u4FDD\u5B58\u540E\u8BE5\u673A\u5668\u4EBA\u4F1A\u62D2\u7EDD\u6240\u6709\u5165\u7AD9\u6D88\u606F\u3002": "The allowlist is empty; this bot will reject all inbound messages after saving.",
"\u6B63\u5728\u4FDD\u5B58\u2026": "Saving\u2026",
"\u4FDD\u5B58\u8BBF\u95EE\u8BBE\u7F6E": "Save access settings",
"User ID \u5FC5\u987B\u662F 1\u201316 \u4F4D\u6B63\u6574\u6570\uFF0C\u6BCF\u884C\u4E00\u4E2A\u3002": "Each User ID must be a 1\u201316 digit positive integer on its own line.",
"Telegram \u8BBF\u95EE\u8BBE\u7F6E\u6682\u4E0D\u53EF\u7528\u3002": "Telegram access settings are currently unavailable.",
"Telegram \u8BBF\u95EE\u8BBE\u7F6E\u4FDD\u5B58\u5931\u8D25\u3002": "Could not save Telegram access settings.",
"\u63A5\u5165 Discord \u673A\u5668\u4EBA": "Connect a Discord bot",
"\u5148\u5728 Developer Portal \u521B\u5EFA Bot \u5E76\u9080\u8BF7\u5230\u670D\u52A1\u5668\uFF0C\u518D\u5728\u8FD9\u91CC\u5B8C\u6210\u63A5\u5165\u3002": "Create a bot in the Developer Portal and invite it to your server, then connect it here.",
"\u586B\u5199 Discord Developer Portal \u7684 Bot Token": "Enter the Bot Token from the Discord Developer Portal",
@ -2464,7 +2486,9 @@ var TOKEN_BOT_ENDPOINTS = Object.freeze({
deleteBot: "bot.delete",
setWorkspace: "bot.workspace.set"
});
function createTokenChannelApi(channel4, connectionSummary) {
function createTokenChannelApi(channel4, connectionSummary, {
normalizeBotExtension = () => ({})
} = {}) {
const unwrapRpcResult10 = (result) => {
if (!isRecord2(result) || typeof result.ok !== "boolean") {
throw new Error(`${channel4} \u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6CD5\u8BC6\u522B\u7684\u54CD\u5E94`);
@ -2480,6 +2504,7 @@ function createTokenChannelApi(channel4, connectionSummary) {
if (!isRecord2(value) || !id(value.botId)) return void 0;
const connected = value.connected === true;
const state = ACCOUNT_STATES2.has(value.state) ? value.state : "offline";
const extension = normalizeBotExtension(value);
return {
botId: id(value.botId),
connected,
@ -2500,7 +2525,8 @@ function createTokenChannelApi(channel4, connectionSummary) {
error: isRecord2(value.error) ? {
code: text(value.error.code, `${channel4.toUpperCase()}_ACCOUNT_ERROR`, 80),
message: text(value.error.message, `${channel4}\u8FDE\u63A5\u5C1A\u672A\u5C31\u7EEA`)
} : null
} : null,
...isRecord2(extension) ? extension : {}
};
};
const normalizeSnapshot9 = (value) => {
@ -2580,9 +2606,11 @@ function createTokenChannelSettings(definition) {
credentialOpenLabel = "\u624B\u52A8\u63A5\u5165",
credentialCloseLabel = "\u6536\u8D77\u51ED\u636E",
credentialNoun = "Bot Token",
emptyActionLabel = "\u586B\u5199 Bot Token"
emptyActionLabel = "\u586B\u5199 Bot Token",
AccountSettings = null,
accountSettingsEndpoint = null
} = definition;
function AccountCard5({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
function AccountCard5({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onAccountSettingsSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
const state = busy === "reconnect" ? "connecting" : account.state;
const tone = account.connected ? "success" : state === "error" ? "error" : "warning";
const stateLabel2 = account.connected ? "\u8FD0\u884C\u6B63\u5E38" : state === "connecting" ? "\u6B63\u5728\u8FDE\u63A5" : "\u8FDE\u63A5\u672A\u5C31\u7EEA";
@ -2640,6 +2668,11 @@ function createTokenChannelSettings(definition) {
disabled: Boolean(busy),
onSave: onWorkspaceSave
}),
AccountSettings ? h2(AccountSettings, {
account,
busy: Boolean(busy),
onSave: onAccountSettingsSave
}) : null,
h2(
"div",
{ className: "ddt-accountFooter dim-cardFooter" },
@ -2824,6 +2857,12 @@ function createTokenChannelSettings(definition) {
endpoints.setWorkspace,
{ botId: account.botId, workspace }
),
onAccountSettingsSave: AccountSettings && accountSettingsEndpoint ? (payload) => botAction(
account,
"settings",
accountSettingsEndpoint,
{ botId: account.botId, ...payload }
) : void 0,
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {
@ -6117,18 +6156,46 @@ var SlackAccountCard = channel2.AccountCard;
// plugin-src/client/channels/telegram/api.js
var TELEGRAM_RPC_CHANNEL = "/telegram";
var TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
var api3 = createTokenChannelApi("Telegram", " Bot API \u957F\u8F6E\u8BE2");
var TELEGRAM_ENDPOINTS = Object.freeze({
...TOKEN_BOT_ENDPOINTS,
setAccessPolicy: "bot.access-policy.set"
});
var api3 = createTokenChannelApi("Telegram", " Bot API \u957F\u8F6E\u8BE2", {
normalizeBotExtension: (value) => {
const source = value?.accessPolicy;
const accessMode = source?.accessMode === "private-allowlist" ? "private-allowlist" : "compatible";
const allowedUsers = Array.isArray(source?.allowedUsers) ? [...new Set(source.allowedUsers.filter((entry) => typeof entry === "string" && /^[1-9]\d{0,15}$/.test(entry)))] : [];
return { accessPolicy: { accessMode, allowedUsers } };
}
});
var unwrapRpcResult6 = api3.unwrapRpcResult;
var normalizeSnapshot5 = api3.normalizeSnapshot;
var presentError6 = api3.presentError;
// plugin-src/client/channels/telegram/index.js
var React14 = __toESM(require("react"), 1);
// plugin-src/client/channels/telegram/styles.js
var TELEGRAM_STYLE_ID = "xmanrui-dsh-im-telegram-settings";
var CSS7 = 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; }
.dtg-access { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-layer-2, #f7f8fa); }
.dtg-accessHeading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.dtg-accessHeading strong { font-size: 13px; }
.dtg-accessHeading p { margin: 3px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.5; }
.dtg-accessBadge { flex: none; padding: 3px 8px; border-radius: 999px; color: #1687bd; background: #eaf7fd; font-size: 11px; font-weight: 700; }
.dtg-accessBadge[data-mode="private-allowlist"] { color: #a15c00; background: #fff3d6; }
.dtg-accessField { display: grid; gap: 5px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; font-weight: 600; }
.dtg-accessField select, .dtg-accessField textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1, #c9cdd4); border-radius: 7px; color: inherit; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-weight: 400; }
.dtg-accessField select { height: 34px; padding: 0 9px; }
.dtg-accessField textarea { min-height: 68px; padding: 8px 9px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dtg-accessField small { color: var(--dsw-alias-label-secondary, #646a73); font-weight: 400; }
.dtg-accessWarning, .dtg-accessError { margin: 0; font-size: 12px; line-height: 1.5; }
.dtg-accessWarning { color: #a15c00; }
.dtg-accessError { color: var(--dsw-alias-state-error-primary, #d83931); }
.dtg-accessActions { display: flex; justify-content: flex-end; }
`;
function installTelegramStyles() {
if (typeof document === "undefined") return () => {
@ -6145,6 +6212,116 @@ function installTelegramStyles() {
}
// plugin-src/client/channels/telegram/index.js
function policyFor(account) {
return {
accessMode: account?.accessPolicy?.accessMode === "private-allowlist" ? "private-allowlist" : "compatible",
allowedUsers: Array.isArray(account?.accessPolicy?.allowedUsers) ? account.accessPolicy.allowedUsers : []
};
}
function allowedUsersFromText(value) {
const entries = value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
if (entries.some((entry) => !/^[1-9]\d{0,15}$/.test(entry))) {
throw new TypeError("User ID \u5FC5\u987B\u662F 1\u201316 \u4F4D\u6B63\u6574\u6570\uFF0C\u6BCF\u884C\u4E00\u4E2A\u3002");
}
return [...new Set(entries)];
}
function TelegramAccessSettings({ account, busy = false, onSave }) {
const policy = policyFor(account);
const sourceUsers = policy.allowedUsers.join("\n");
const [accessMode, setAccessMode] = React14.useState(policy.accessMode);
const [allowedUsers, setAllowedUsers] = React14.useState(sourceUsers);
const [error, setError] = React14.useState(null);
React14.useEffect(() => {
setAccessMode(policy.accessMode);
setAllowedUsers(sourceUsers);
setError(null);
}, [policy.accessMode, sourceUsers]);
const save = async (event) => {
event.preventDefault();
setError(null);
try {
const normalized = allowedUsersFromText(allowedUsers);
if (typeof onSave !== "function") throw new Error("Telegram \u8BBF\u95EE\u8BBE\u7F6E\u6682\u4E0D\u53EF\u7528\u3002");
await onSave({ accessMode, allowedUsers: normalized });
} catch (caught) {
setError(caught?.message ?? "Telegram \u8BBF\u95EE\u8BBE\u7F6E\u4FDD\u5B58\u5931\u8D25\u3002");
}
};
const privateAllowlist = accessMode === "private-allowlist";
const savedPrivateAllowlist = policy.accessMode === "private-allowlist";
const emptyAllowlist = privateAllowlist && allowedUsers.trim() === "";
return h2(
"form",
{ className: "dtg-access", onSubmit: save },
h2(
"div",
{ className: "dtg-accessHeading" },
h2(
"div",
null,
h2("strong", null, "\u8BBF\u95EE\u8BBE\u7F6E"),
h2("p", null, savedPrivateAllowlist ? "\u7FA4\u804A\u5168\u90E8\u5FFD\u7565\uFF0C\u79C1\u804A\u4EC5\u5141\u8BB8\u767D\u540D\u5355\u7528\u6237\u3002" : "\u4FDD\u6301\u539F\u6709\u884C\u4E3A\uFF1A\u79C1\u804A\u76F4\u63A5\u54CD\u5E94\uFF0C\u7FA4\u804A\u5728\u88AB\u63D0\u53CA\u6216\u56DE\u590D\u65F6\u54CD\u5E94\u3002")
),
h2(
"span",
{ className: "dtg-accessBadge", "data-mode": policy.accessMode },
savedPrivateAllowlist ? "\u5DF2\u751F\u6548\uFF1A\u5B89\u5168\u6A21\u5F0F" : "\u5DF2\u751F\u6548\uFF1A\u517C\u5BB9\u6A21\u5F0F"
)
),
h2(
"label",
{ className: "dtg-accessField" },
h2("span", null, "\u6A21\u5F0F"),
h2(
"select",
{
value: accessMode,
disabled: busy,
"aria-label": "Telegram \u8BBF\u95EE\u6A21\u5F0F",
onChange: (event) => {
setAccessMode(event.target.value);
setError(null);
}
},
h2("option", { value: "compatible" }, "\u517C\u5BB9\u6A21\u5F0F\uFF08\u9ED8\u8BA4\uFF09"),
h2("option", { value: "private-allowlist" }, "\u5B89\u5168\u6A21\u5F0F\uFF08\u79C1\u804A\u767D\u540D\u5355\uFF09")
)
),
h2(
"label",
{ className: "dtg-accessField" },
h2("span", null, "\u5141\u8BB8\u79C1\u804A\u7684 Telegram User ID"),
h2("textarea", {
value: allowedUsers,
disabled: busy,
rows: 3,
placeholder: "\u6BCF\u884C\u4E00\u4E2A\u6570\u5B57 User ID",
"aria-label": "\u5141\u8BB8\u79C1\u804A\u7684 Telegram User ID",
onChange: (event) => {
setAllowedUsers(event.target.value);
setError(null);
}
}),
h2("small", null, privateAllowlist ? "\u767D\u540D\u5355\u4EC5\u5C5E\u4E8E\u5F53\u524D\u673A\u5668\u4EBA\u3002" : "\u517C\u5BB9\u6A21\u5F0F\u4E0B\u6682\u4E0D\u4F7F\u7528\u767D\u540D\u5355\uFF0C\u5207\u6362\u6A21\u5F0F\u65F6\u4F1A\u4FDD\u7559\u3002")
),
emptyAllowlist ? h2(
"p",
{ className: "dtg-accessWarning", role: "status" },
"\u767D\u540D\u5355\u4E3A\u7A7A\uFF1B\u4FDD\u5B58\u540E\u8BE5\u673A\u5668\u4EBA\u4F1A\u62D2\u7EDD\u6240\u6709\u5165\u7AD9\u6D88\u606F\u3002"
) : null,
error ? h2("p", { className: "dtg-accessError", role: "alert" }, error) : null,
h2(
"div",
{ className: "dtg-accessActions" },
h2("button", {
type: "submit",
className: "ddt-button",
"data-kind": "secondary",
disabled: busy
}, busy ? "\u6B63\u5728\u4FDD\u5B58\u2026" : "\u4FDD\u5B58\u8BBF\u95EE\u8BBE\u7F6E")
)
);
}
var channel3 = createTokenChannelSettings({
channel: "Telegram",
endpoints: TELEGRAM_ENDPOINTS,
@ -6157,7 +6334,9 @@ var channel3 = createTokenChannelSettings({
tokenPlaceholder: "\u586B\u5199 @BotFather \u751F\u6210\u7684 Bot Token",
emptyTitle: "\u63A5\u5165 Telegram \u673A\u5668\u4EBA",
emptyDescription: "\u5148\u901A\u8FC7 @BotFather \u83B7\u53D6 Bot Token\uFF0C\u518D\u5728\u8FD9\u91CC\u5B8C\u6210\u63A5\u5165\u3002",
platformLabel: "Telegram"
platformLabel: "Telegram",
AccountSettings: TelegramAccessSettings,
accountSettingsEndpoint: TELEGRAM_ENDPOINTS.setAccessPolicy
});
var TelegramSettingsTab = channel3.SettingsTab;
var TelegramAccountCard = channel3.AccountCard;
@ -6280,7 +6459,7 @@ function formatRemaining4(milliseconds) {
}
// plugin-src/client/channels/wecom/index.js
var React14 = __toESM(require("react"), 1);
var React15 = __toESM(require("react"), 1);
// plugin-src/client/channels/wecom/styles.js
var WECOM_STYLE_ID = "xmanrui-dsh-im-wecom-settings";
@ -6305,7 +6484,7 @@ function installWecomStyles() {
// plugin-src/client/channels/wecom/index.js
var ACTIVE_STATES2 = /* @__PURE__ */ new Set(["pending", "refreshing", "connecting"]);
var Button10 = React14.forwardRef(function Button11({ children, kind = "secondary", className = "", ...props }, ref) {
var Button10 = React15.forwardRef(function Button11({ children, kind = "secondary", className = "", ...props }, ref) {
return h2("button", {
...props,
ref,
@ -6590,21 +6769,21 @@ function AccountCard3({
);
}
function WecomSettingsTab({ rpcCall }) {
const [model, setModel] = React14.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null });
const [provision, setProvision] = React14.useState(null);
const [busy, setBusy] = React14.useState(false);
const [busyByBot, setBusyByBot] = React14.useState({});
const [feedbackByBot, setFeedbackByBot] = React14.useState({});
const [removeTarget, setRemoveTarget] = React14.useState(null);
const [credentialOpen, setCredentialOpen] = React14.useState(false);
const [credentialError, setCredentialError] = React14.useState(null);
const [notice, setNotice] = React14.useState("");
const [now, setNow] = React14.useState(Date.now());
const mounted = React14.useRef(true);
const [model, setModel] = React15.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null });
const [provision, setProvision] = React15.useState(null);
const [busy, setBusy] = React15.useState(false);
const [busyByBot, setBusyByBot] = React15.useState({});
const [feedbackByBot, setFeedbackByBot] = React15.useState({});
const [removeTarget, setRemoveTarget] = React15.useState(null);
const [credentialOpen, setCredentialOpen] = React15.useState(false);
const [credentialError, setCredentialError] = React15.useState(null);
const [notice, setNotice] = React15.useState("");
const [now, setNow] = React15.useState(Date.now());
const mounted = React15.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const addButtonRef = React14.useRef(null);
const noticeFrameRef = React14.useRef(null);
const announce = React14.useCallback((message) => {
const addButtonRef = React15.useRef(null);
const noticeFrameRef = React15.useRef(null);
const announce = React15.useCallback((message) => {
if (!mounted.current) return;
if (noticeFrameRef.current !== null) {
window.cancelAnimationFrame(noticeFrameRef.current);
@ -6618,7 +6797,7 @@ function WecomSettingsTab({ rpcCall }) {
});
}
}, []);
React14.useEffect(() => {
React15.useEffect(() => {
const disposeDingtalk = installDingtalkStyles();
const disposeWecom = installWecomStyles();
mounted.current = true;
@ -6632,11 +6811,11 @@ function WecomSettingsTab({ rpcCall }) {
disposeDingtalk();
};
}, []);
const invoke = React14.useCallback(async (endpoint, payload = {}, signal) => {
const invoke = React15.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== "function") throw new TypeError("\u4F01\u4E1A\u5FAE\u4FE1\u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5");
return unwrapRpcResult7(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
const loadStatus = React14.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
const loadStatus = React15.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null) return void 0;
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null }));
@ -6656,12 +6835,12 @@ function WecomSettingsTab({ rpcCall }) {
return void 0;
}
}, [invoke, workspaceFence]);
React14.useEffect(() => {
React15.useEffect(() => {
const controller = new AbortController();
void loadStatus({ signal: controller.signal, restore: true });
return () => controller.abort();
}, [loadStatus]);
React14.useEffect(() => {
React15.useEffect(() => {
if (model.phase !== "ready") return void 0;
const controller = new AbortController();
const timer = window.setInterval(() => void loadStatus({ signal: controller.signal, silent: true }), 15e3);
@ -6670,12 +6849,12 @@ function WecomSettingsTab({ rpcCall }) {
window.clearInterval(timer);
};
}, [loadStatus, model.phase]);
React14.useEffect(() => {
React15.useEffect(() => {
if (!provision || !ACTIVE_STATES2.has(provision.status)) return void 0;
const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1e3);
return () => window.clearInterval(timer);
}, [provision?.attemptId, provision?.status]);
const startProvisioning = React14.useCallback(async (replace = false) => {
const startProvisioning = React15.useCallback(async (replace = false) => {
setCredentialOpen(false);
setCredentialError(null);
setBusy(true);
@ -6693,7 +6872,7 @@ function WecomSettingsTab({ rpcCall }) {
if (mounted.current) setBusy(false);
}
}, [invoke, provision?.attemptId]);
const bindCredentials = React14.useCallback(async ({ identity, secret }) => {
const bindCredentials = React15.useCallback(async ({ identity, secret }) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusy(true);
setCredentialError(null);
@ -6715,7 +6894,7 @@ function WecomSettingsTab({ rpcCall }) {
if (mounted.current) setBusy(false);
}
}, [invoke, loadStatus, workspaceFence]);
const closeProvision = React14.useCallback(async () => {
const closeProvision = React15.useCallback(async () => {
setBusy(true);
try {
if (provision?.attemptId && ACTIVE_STATES2.has(provision.status)) {
@ -6726,7 +6905,7 @@ function WecomSettingsTab({ rpcCall }) {
if (mounted.current) setBusy(false);
}
}, [invoke, provision?.attemptId, provision?.status]);
React14.useEffect(() => {
React15.useEffect(() => {
const attemptId = provision?.attemptId;
if (!attemptId || !ACTIVE_STATES2.has(provision.status)) return void 0;
const controller = new AbortController();
@ -6756,7 +6935,7 @@ function WecomSettingsTab({ rpcCall }) {
window.clearTimeout(timer);
};
}, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]);
const botAction = React14.useCallback(async (account, operation, endpoint, payload) => {
const botAction = React15.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
try {
@ -6775,7 +6954,7 @@ function WecomSettingsTab({ rpcCall }) {
});
}
}, [invoke, loadStatus, workspaceFence]);
const reconnect = React14.useCallback(async (account) => {
const reconnect = React15.useCallback(async (account) => {
setFeedbackByBot((current) => {
const next = { ...current };
delete next[account.botId];
@ -6888,7 +7067,7 @@ function WecomSettingsTab({ rpcCall }) {
}),
h2("div", { className: "ddt-visuallyHidden", role: "status", "aria-live": "polite" }, notice),
model.phase === "loading" ? h2(LoadingView4) : model.phase === "error" ? h2("div", { className: "ddt-card dim-surfaceCard" }, h2("div", { className: "ddt-inlineError dim-inlineError" }, h2("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u72B6\u6001"), h2("p", null, model.error?.message), h2(Button10, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6"))) : h2(
React14.Fragment,
React15.Fragment,
null,
credentialView,
provisionView,
@ -6899,7 +7078,7 @@ function WecomSettingsTab({ rpcCall }) {
}
// plugin-src/client/channels/weixin/index.js
var React15 = __toESM(require("react"), 1);
var React16 = __toESM(require("react"), 1);
// plugin-src/client/channels/weixin/api.js
var WEIXIN_RPC_CHANNEL = "/weixin";
@ -7172,7 +7351,7 @@ function installWeixinStyles() {
}
// plugin-src/client/channels/weixin/index.js
var Button12 = React15.forwardRef(function Button13({ children, kind = "secondary", className = "", ...props }, ref) {
var Button12 = React16.forwardRef(function Button13({ children, kind = "secondary", className = "", ...props }, ref) {
return h2("button", {
...props,
ref,
@ -7245,14 +7424,14 @@ function EmptyView5({ onStart, busy }) {
);
}
function QrPanel4({ provision, now, busy, onRefresh, onCancel }) {
const [imageFailed, setImageFailed] = React15.useState(false);
const [imageFailed, setImageFailed] = React16.useState(false);
const source = safeQrSource5(provision.qrCodeDataUrl);
const href = safeVerificationUrl(provision.verificationUrl);
const remaining = Math.max(0, provision.expiresAt - now);
const expired = remaining === 0 || provision.status === "expired";
const duration = Math.max(1, provision.durationMs ?? 5 * 6e4);
const progress = Math.round(Math.min(1, remaining / duration) * 100);
React15.useEffect(() => setImageFailed(false), [source]);
React16.useEffect(() => setImageFailed(false), [source]);
return h2(
"div",
{ className: "dxw-card dim-surfaceCard" },
@ -7319,9 +7498,9 @@ function QrPanel4({ provision, now, busy, onRefresh, onCancel }) {
);
}
function VerificationPanel({ provision, busy, onSubmit, onCancel }) {
const [code, setCode] = React15.useState("");
const [code, setCode] = React16.useState("");
const valid = /^\d{4,8}$/.test(code);
React15.useEffect(() => setCode(""), [provision.attemptId]);
React16.useEffect(() => setCode(""), [provision.attemptId]);
return h2(
"div",
{ className: "dxw-card dim-surfaceCard" },
@ -7541,40 +7720,40 @@ function mergeWeixinProvisioningSnapshot(current, incoming, { restoreProvisionin
};
}
function WeixinSettingsTab({ rpcCall }) {
const [model, setModel] = React15.useState({
const [model, setModel] = React16.useState({
phase: "loading",
bots: [],
totals: EMPTY_TOTALS3,
revision: 0,
error: null
});
const [provision, setProvision] = React15.useState(null);
const [busy, setBusy] = React15.useState(false);
const [busyByBot, setBusyByBot] = React15.useState({});
const [feedbackByBot, setFeedbackByBot] = React15.useState({});
const [removeTarget, setRemoveTarget] = React15.useState(null);
const [notice, setNotice] = React15.useState("");
const [now, setNow] = React15.useState(() => Date.now());
const addButtonRef = React15.useRef(null);
const mountedRef = React15.useRef(true);
const [provision, setProvision] = React16.useState(null);
const [busy, setBusy] = React16.useState(false);
const [busyByBot, setBusyByBot] = React16.useState({});
const [feedbackByBot, setFeedbackByBot] = React16.useState({});
const [removeTarget, setRemoveTarget] = React16.useState(null);
const [notice, setNotice] = React16.useState("");
const [now, setNow] = React16.useState(() => Date.now());
const addButtonRef = React16.useRef(null);
const mountedRef = React16.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const scheduleAnimationFrame = useAnimationFrameScheduler();
React15.useEffect(() => {
React16.useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const announce = React15.useCallback((value) => {
const announce = React16.useCallback((value) => {
setNotice("");
scheduleAnimationFrame(() => {
if (value) setNotice(value);
}, "announcement");
}, [scheduleAnimationFrame]);
const invoke = React15.useCallback(async (endpoint, payload = {}, signal) => {
const invoke = React16.useCallback(async (endpoint, payload = {}, signal) => {
return unwrapRpcResult8(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
const loadStatus = React15.useCallback(async ({
const loadStatus = React16.useCallback(async ({
signal,
silent = false,
restoreProvisioning = false
@ -7610,12 +7789,12 @@ function WeixinSettingsTab({ rpcCall }) {
return void 0;
}
}, [invoke, workspaceFence]);
React15.useEffect(() => {
React16.useEffect(() => {
const controller = new AbortController();
void loadStatus({ signal: controller.signal, restoreProvisioning: true });
return () => controller.abort();
}, [loadStatus]);
React15.useEffect(() => {
React16.useEffect(() => {
if (model.phase !== "ready") return void 0;
const controller = new AbortController();
let running = false;
@ -7634,12 +7813,12 @@ function WeixinSettingsTab({ rpcCall }) {
window.clearInterval(timer);
};
}, [loadStatus, model.phase]);
React15.useEffect(() => {
React16.useEffect(() => {
if (!provision || !["pending", "scanned"].includes(provision.status)) return void 0;
const timer = window.setInterval(() => setNow(Date.now()), 1e3);
return () => window.clearInterval(timer);
}, [provision?.attemptId, provision?.status]);
const startProvisioning = React15.useCallback(async ({ replace = false } = {}) => {
const startProvisioning = React16.useCallback(async ({ replace = false } = {}) => {
setBusy(true);
try {
if (replace && provision?.attemptId) {
@ -7660,7 +7839,7 @@ function WeixinSettingsTab({ rpcCall }) {
setBusy(false);
}
}, [announce, invoke, provision?.attemptId]);
const cancelProvisioning = React15.useCallback(async () => {
const cancelProvisioning = React16.useCallback(async () => {
setBusy(true);
try {
if (provision?.attemptId && !["failed", "expired", "cancelled"].includes(provision.status)) {
@ -7675,7 +7854,7 @@ function WeixinSettingsTab({ rpcCall }) {
setBusy(false);
}
}, [announce, invoke, provision?.attemptId, provision?.status, scheduleAnimationFrame]);
const submitVerification = React15.useCallback(async (verifyCode) => {
const submitVerification = React16.useCallback(async (verifyCode) => {
if (!provision?.attemptId) return;
setBusy(true);
try {
@ -7691,7 +7870,7 @@ function WeixinSettingsTab({ rpcCall }) {
setBusy(false);
}
}, [announce, invoke, provision?.attemptId]);
React15.useEffect(() => {
React16.useEffect(() => {
const attemptId = provision?.attemptId;
if (!attemptId || !["pending", "scanned", "connecting"].includes(provision.status)) return void 0;
const controller = new AbortController();
@ -7739,7 +7918,7 @@ function WeixinSettingsTab({ rpcCall }) {
controller.abort();
};
}, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]);
const setBotBusy = React15.useCallback((botId, value) => {
const setBotBusy = React16.useCallback((botId, value) => {
setBusyByBot((current) => {
const next = { ...current };
if (value) next[botId] = value;
@ -7747,7 +7926,7 @@ function WeixinSettingsTab({ rpcCall }) {
return next;
});
}, []);
const reconnect = React15.useCallback(async (account) => {
const reconnect = React16.useCallback(async (account) => {
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, "reconnect");
setFeedbackByBot((current) => {
@ -7792,7 +7971,7 @@ function WeixinSettingsTab({ rpcCall }) {
setBotBusy(account.botId, null);
}
}, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
const saveWorkspace = React15.useCallback(async (account, workspace) => {
const saveWorkspace = React16.useCallback(async (account, workspace) => {
const workspaceVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, "workspace");
try {
@ -7815,7 +7994,7 @@ function WeixinSettingsTab({ rpcCall }) {
if (mountedRef.current) setBotBusy(account.botId, null);
}
}, [invoke, loadStatus, setBotBusy, workspaceFence]);
const remove = React15.useCallback(async (account) => {
const remove = React16.useCallback(async (account) => {
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, "delete");
try {
@ -7891,7 +8070,7 @@ function WeixinSettingsTab({ rpcCall }) {
h2(Button12, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6")
)
) : h2(
React15.Fragment,
React16.Fragment,
null,
provisionView,
model.bots.length === 0 && !provision ? h2(EmptyView5, { onStart: () => void startProvisioning(), busy }) : null,
@ -8023,7 +8202,7 @@ function formatRemaining6(milliseconds) {
}
// plugin-src/client/channels/whatsapp/index.js
var React16 = __toESM(require("react"), 1);
var React17 = __toESM(require("react"), 1);
// plugin-src/client/channels/whatsapp/styles.js
var WHATSAPP_STYLE_ID = "xmanrui-dsh-im-whatsapp-settings";
@ -8048,7 +8227,7 @@ function installWhatsappStyles() {
// plugin-src/client/channels/whatsapp/index.js
var ACTIVE_STATES3 = /* @__PURE__ */ new Set(["pending", "connecting"]);
var Button14 = React16.forwardRef(function Button15({ children, kind = "secondary", className = "", ...props }, ref) {
var Button14 = React17.forwardRef(function Button15({ children, kind = "secondary", className = "", ...props }, ref) {
return h2("button", {
...props,
ref,
@ -8363,22 +8542,22 @@ function WhatsappAccountCard({
);
}
function WhatsappSettingsTab({ rpcCall }) {
const [model, setModel] = React16.useState({
const [model, setModel] = React17.useState({
phase: "loading",
bots: [],
totals: { configured: 0, connected: 0 },
error: null
});
const [provision, setProvision] = React16.useState(null);
const [busy, setBusy] = React16.useState(false);
const [busyByBot, setBusyByBot] = React16.useState({});
const [testNoticeByBot, setTestNoticeByBot] = React16.useState({});
const [removeTarget, setRemoveTarget] = React16.useState(null);
const [now, setNow] = React16.useState(Date.now());
const mounted = React16.useRef(true);
const [provision, setProvision] = React17.useState(null);
const [busy, setBusy] = React17.useState(false);
const [busyByBot, setBusyByBot] = React17.useState({});
const [testNoticeByBot, setTestNoticeByBot] = React17.useState({});
const [removeTarget, setRemoveTarget] = React17.useState(null);
const [now, setNow] = React17.useState(Date.now());
const mounted = React17.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const addButtonRef = React16.useRef(null);
React16.useEffect(() => {
const addButtonRef = React17.useRef(null);
React17.useEffect(() => {
const disposeDingtalk = installDingtalkStyles();
const disposeWhatsapp = installWhatsappStyles();
mounted.current = true;
@ -8388,11 +8567,11 @@ function WhatsappSettingsTab({ rpcCall }) {
disposeDingtalk();
};
}, []);
const invoke = React16.useCallback(async (endpoint, payload = {}, signal) => {
const invoke = React17.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== "function") throw new TypeError("WhatsApp \u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5");
return unwrapRpcResult9(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
const loadStatus = React16.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
const loadStatus = React17.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null) return void 0;
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null }));
@ -8416,12 +8595,12 @@ function WhatsappSettingsTab({ rpcCall }) {
return void 0;
}
}, [invoke, workspaceFence]);
React16.useEffect(() => {
React17.useEffect(() => {
const controller = new AbortController();
void loadStatus({ signal: controller.signal, restore: true });
return () => controller.abort();
}, [loadStatus]);
React16.useEffect(() => {
React17.useEffect(() => {
if (model.phase !== "ready") return void 0;
const controller = new AbortController();
const timer = window.setInterval(
@ -8433,12 +8612,12 @@ function WhatsappSettingsTab({ rpcCall }) {
window.clearInterval(timer);
};
}, [loadStatus, model.phase]);
React16.useEffect(() => {
React17.useEffect(() => {
if (!provision || !ACTIVE_STATES3.has(provision.status)) return void 0;
const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1e3);
return () => window.clearInterval(timer);
}, [provision?.attemptId, provision?.status]);
const startProvisioning = React16.useCallback(async (replace = false) => {
const startProvisioning = React17.useCallback(async (replace = false) => {
setBusy(true);
try {
if (replace && provision?.attemptId) {
@ -8456,7 +8635,7 @@ function WhatsappSettingsTab({ rpcCall }) {
if (mounted.current) setBusy(false);
}
}, [invoke, provision?.attemptId]);
const closeProvision = React16.useCallback(async () => {
const closeProvision = React17.useCallback(async () => {
setBusy(true);
try {
if (provision?.attemptId && ACTIVE_STATES3.has(provision.status)) {
@ -8467,7 +8646,7 @@ function WhatsappSettingsTab({ rpcCall }) {
if (mounted.current) setBusy(false);
}
}, [invoke, provision?.attemptId, provision?.status]);
React16.useEffect(() => {
React17.useEffect(() => {
const attemptId = provision?.attemptId;
if (!attemptId || !ACTIVE_STATES3.has(provision.status)) return void 0;
const controller = new AbortController();
@ -8508,7 +8687,7 @@ function WhatsappSettingsTab({ rpcCall }) {
if (timer) window.clearTimeout(timer);
};
}, [invoke, loadStatus, provision?.attemptId, provision?.status]);
const botAction = React16.useCallback(async (account, operation, endpoint, payload) => {
const botAction = React17.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
if (operation === "reconnect") {
@ -8607,7 +8786,7 @@ function WhatsappSettingsTab({ rpcCall }) {
h2(Button14, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6")
)
) : h2(
React16.Fragment,
React17.Fragment,
null,
provision?.status === "pending" ? h2(QrPanel5, {
provision,
@ -8999,8 +9178,8 @@ function IMSettingsTab({
officeRpcCall,
workspaceDirectoryPicker
}) {
const [selected, setSelected] = React17.useState("weixin");
const githubTooltipId = React17.useId();
const [selected, setSelected] = React18.useState("weixin");
const githubTooltipId = React18.useId();
const active = CHANNELS.find((channel4) => channel4.id === selected) ?? CHANNELS[0];
return h2(
WorkspaceDirectoryPickerContext.Provider,

File diff suppressed because one or more lines are too long

View file

@ -27,7 +27,9 @@ export const TOKEN_BOT_ENDPOINTS = Object.freeze({
setWorkspace: 'bot.workspace.set',
});
export function createTokenChannelApi(channel, connectionSummary) {
export function createTokenChannelApi(channel, connectionSummary, {
normalizeBotExtension = () => ({}),
} = {}) {
const unwrapRpcResult = (result) => {
if (!isRecord(result) || typeof result.ok !== 'boolean') {
throw new Error(`${channel} 服务返回了无法识别的响应`);
@ -44,6 +46,7 @@ export function createTokenChannelApi(channel, connectionSummary) {
if (!isRecord(value) || !id(value.botId)) return undefined;
const connected = value.connected === true;
const state = ACCOUNT_STATES.has(value.state) ? value.state : 'offline';
const extension = normalizeBotExtension(value);
return {
botId: id(value.botId),
connected,
@ -65,6 +68,7 @@ export function createTokenChannelApi(channel, connectionSummary) {
code: text(value.error.code, `${channel.toUpperCase()}_ACCOUNT_ERROR`, 80),
message: text(value.error.message, `${channel}连接尚未就绪`),
} : null,
...(isRecord(extension) ? extension : {}),
};
};

View file

@ -59,9 +59,11 @@ export function createTokenChannelSettings(definition) {
credentialCloseLabel = '收起凭据',
credentialNoun = 'Bot Token',
emptyActionLabel = '填写 Bot Token',
AccountSettings = null,
accountSettingsEndpoint = null,
} = definition;
function AccountCard({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
function AccountCard({ account, busy, testNotice, removing, onReconnect, onWorkspaceSave, onAccountSettingsSave, 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' ? '正在连接' : '连接未就绪';
@ -88,6 +90,11 @@ export function createTokenChannelSettings(definition) {
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
AccountSettings ? h(AccountSettings, {
account,
busy: Boolean(busy),
onSave: onAccountSettingsSave,
}) : null,
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
testNotice ? h('div', { className: 'ddt-summary dim-cardSummary', role: 'status' }, testNotice) : null,
@ -260,6 +267,14 @@ export function createTokenChannelSettings(definition) {
endpoints.setWorkspace,
{ botId: account.botId, workspace },
),
onAccountSettingsSave: AccountSettings && accountSettingsEndpoint
? (payload) => botAction(
account,
'settings',
accountSettingsEndpoint,
{ botId: account.botId, ...payload },
)
: undefined,
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

View file

@ -1,9 +1,24 @@
import { TOKEN_BOT_ENDPOINTS, createTokenChannelApi } from '../shared/token-api.js';
export const TELEGRAM_RPC_CHANNEL = '/telegram';
export const TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
export const TELEGRAM_ENDPOINTS = Object.freeze({
...TOKEN_BOT_ENDPOINTS,
setAccessPolicy: 'bot.access-policy.set',
});
const api = createTokenChannelApi('Telegram', ' Bot API 长轮询');
const api = createTokenChannelApi('Telegram', ' Bot API 长轮询', {
normalizeBotExtension: (value) => {
const source = value?.accessPolicy;
const accessMode = source?.accessMode === 'private-allowlist'
? 'private-allowlist' : 'compatible';
const allowedUsers = Array.isArray(source?.allowedUsers)
? [...new Set(source.allowedUsers.filter((entry) => (
typeof entry === 'string' && /^[1-9]\d{0,15}$/.test(entry)
)))]
: [];
return { accessPolicy: { accessMode, allowedUsers } };
},
});
export const unwrapRpcResult = api.unwrapRpcResult;
export const normalizeSnapshot = api.normalizeSnapshot;

View file

@ -1,11 +1,105 @@
import * as React from 'react';
import { TelegramLogoGlyph } from '../../channel-logos.js';
import { createTokenChannelSettings } from '../shared/token-channel.js';
import { h } from '../../i18n.js';
import {
TELEGRAM_ENDPOINTS,
telegramClientApi,
} from './api.js';
import { installTelegramStyles } from './styles.js';
function policyFor(account) {
return {
accessMode: account?.accessPolicy?.accessMode === 'private-allowlist'
? 'private-allowlist' : 'compatible',
allowedUsers: Array.isArray(account?.accessPolicy?.allowedUsers)
? account.accessPolicy.allowedUsers : [],
};
}
function allowedUsersFromText(value) {
const entries = value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
if (entries.some((entry) => !/^[1-9]\d{0,15}$/.test(entry))) {
throw new TypeError('User ID 必须是 1–16 位正整数,每行一个。');
}
return [...new Set(entries)];
}
export function TelegramAccessSettings({ account, busy = false, onSave }) {
const policy = policyFor(account);
const sourceUsers = policy.allowedUsers.join('\n');
const [accessMode, setAccessMode] = React.useState(policy.accessMode);
const [allowedUsers, setAllowedUsers] = React.useState(sourceUsers);
const [error, setError] = React.useState(null);
React.useEffect(() => {
setAccessMode(policy.accessMode);
setAllowedUsers(sourceUsers);
setError(null);
}, [policy.accessMode, sourceUsers]);
const save = async (event) => {
event.preventDefault();
setError(null);
try {
const normalized = allowedUsersFromText(allowedUsers);
if (typeof onSave !== 'function') throw new Error('Telegram 访问设置暂不可用。');
await onSave({ accessMode, allowedUsers: normalized });
} catch (caught) {
setError(caught?.message ?? 'Telegram 访问设置保存失败。');
}
};
const privateAllowlist = accessMode === 'private-allowlist';
const savedPrivateAllowlist = policy.accessMode === 'private-allowlist';
const emptyAllowlist = privateAllowlist && allowedUsers.trim() === '';
return h('form', { className: 'dtg-access', onSubmit: save },
h('div', { className: 'dtg-accessHeading' },
h('div', null,
h('strong', null, '访问设置'),
h('p', null, savedPrivateAllowlist
? '群聊全部忽略,私聊仅允许白名单用户。'
: '保持原有行为:私聊直接响应,群聊在被提及或回复时响应。')),
h('span', { className: 'dtg-accessBadge', 'data-mode': policy.accessMode },
savedPrivateAllowlist ? '已生效:安全模式' : '已生效:兼容模式')),
h('label', { className: 'dtg-accessField' },
h('span', null, '模式'),
h('select', {
value: accessMode,
disabled: busy,
'aria-label': 'Telegram 访问模式',
onChange: (event) => { setAccessMode(event.target.value); setError(null); },
},
h('option', { value: 'compatible' }, '兼容模式(默认)'),
h('option', { value: 'private-allowlist' }, '安全模式(私聊白名单)'))),
h('label', { className: 'dtg-accessField' },
h('span', null, '允许私聊的 Telegram User ID'),
h('textarea', {
value: allowedUsers,
disabled: busy,
rows: 3,
placeholder: '每行一个数字 User ID',
'aria-label': '允许私聊的 Telegram User ID',
onChange: (event) => { setAllowedUsers(event.target.value); setError(null); },
}),
h('small', null, privateAllowlist
? '白名单仅属于当前机器人。'
: '兼容模式下暂不使用白名单,切换模式时会保留。')),
emptyAllowlist
? h('p', { className: 'dtg-accessWarning', role: 'status' },
'白名单为空;保存后该机器人会拒绝所有入站消息。')
: null,
error ? h('p', { className: 'dtg-accessError', role: 'alert' }, error) : null,
h('div', { className: 'dtg-accessActions' },
h('button', {
type: 'submit',
className: 'ddt-button',
'data-kind': 'secondary',
disabled: busy,
}, busy ? '正在保存…' : '保存访问设置')));
}
const channel = createTokenChannelSettings({
channel: 'Telegram',
endpoints: TELEGRAM_ENDPOINTS,
@ -19,6 +113,8 @@ const channel = createTokenChannelSettings({
emptyTitle: '接入 Telegram 机器人',
emptyDescription: '先通过 @BotFather 获取 Bot Token,再在这里完成接入。',
platformLabel: 'Telegram',
AccountSettings: TelegramAccessSettings,
accountSettingsEndpoint: TELEGRAM_ENDPOINTS.setAccessPolicy,
});
export const TelegramSettingsTab = channel.SettingsTab;

View file

@ -4,6 +4,21 @@ 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; }
.dtg-access { display: grid; gap: 10px; padding: 12px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 10px; background: var(--dsw-alias-bg-layer-2, #f7f8fa); }
.dtg-accessHeading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.dtg-accessHeading strong { font-size: 13px; }
.dtg-accessHeading p { margin: 3px 0 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.5; }
.dtg-accessBadge { flex: none; padding: 3px 8px; border-radius: 999px; color: #1687bd; background: #eaf7fd; font-size: 11px; font-weight: 700; }
.dtg-accessBadge[data-mode="private-allowlist"] { color: #a15c00; background: #fff3d6; }
.dtg-accessField { display: grid; gap: 5px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; font-weight: 600; }
.dtg-accessField select, .dtg-accessField textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l1, #c9cdd4); border-radius: 7px; color: inherit; background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-weight: 400; }
.dtg-accessField select { height: 34px; padding: 0 9px; }
.dtg-accessField textarea { min-height: 68px; padding: 8px 9px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dtg-accessField small { color: var(--dsw-alias-label-secondary, #646a73); font-weight: 400; }
.dtg-accessWarning, .dtg-accessError { margin: 0; font-size: 12px; line-height: 1.5; }
.dtg-accessWarning { color: #a15c00; }
.dtg-accessError { color: var(--dsw-alias-state-error-primary, #d83931); }
.dtg-accessActions { display: flex; justify-content: flex-end; }
`;
export function installTelegramStyles() {

View file

@ -281,6 +281,28 @@ const EN = Object.freeze({
'接入 Telegram 机器人': 'Connect a Telegram bot',
'先通过 @BotFather 获取 Bot Token,再在这里完成接入。': 'Get a Bot Token from @BotFather, then connect it here.',
'填写 @BotFather 生成的 Bot Token': 'Enter the Bot Token from @BotFather',
'访问模式': 'Access mode',
'访问设置': 'Access settings',
'Telegram 访问模式': 'Telegram access mode',
'群聊全部忽略,私聊仅允许白名单用户。': 'All group messages are ignored; only allowlisted users may send DMs.',
'保持原有行为:私聊直接响应,群聊在被提及或回复时响应。': 'Keep the original behavior: respond to DMs and to group mentions or replies.',
'安全模式': 'Safe mode',
'兼容模式': 'Compatible mode',
'已生效:安全模式': 'Active: Safe mode',
'已生效:兼容模式': 'Active: Compatible mode',
'模式': 'Mode',
'兼容模式(默认)': 'Compatible mode (default)',
'安全模式(私聊白名单)': 'Safe mode (private-chat allowlist)',
'允许私聊的 Telegram User ID': 'Telegram User IDs allowed to send DMs',
'每行一个数字 User ID': 'One numeric User ID per line',
'白名单仅属于当前机器人。': 'This allowlist belongs only to the current bot.',
'兼容模式下暂不使用白名单,切换模式时会保留。': 'Compatible mode does not enforce the allowlist; it is retained when modes change.',
'白名单为空;保存后该机器人会拒绝所有入站消息。': 'The allowlist is empty; this bot will reject all inbound messages after saving.',
'正在保存…': 'Saving…',
'保存访问设置': 'Save access settings',
'User ID 必须是 1–16 位正整数,每行一个。': 'Each User ID must be a 1–16 digit positive integer on its own line.',
'Telegram 访问设置暂不可用。': 'Telegram access settings are currently unavailable.',
'Telegram 访问设置保存失败。': 'Could not save Telegram access settings.',
'接入 Discord 机器人': 'Connect a Discord bot',
'先在 Developer Portal 创建 Bot 并邀请到服务器,再在这里完成接入。': 'Create a bot in the Developer Portal and invite it to your server, then connect it here.',
'填写 Discord Developer Portal 的 Bot Token': 'Enter the Bot Token from the Discord Developer Portal',

View file

@ -5,23 +5,7 @@ import { TelegramRuntime } from '../../../../src/channels/telegram/telegram-runt
import { TelegramStateStore } from '../../../../src/channels/telegram/state-store.mjs';
import { createTokenProductionController } from '../shared/production.mjs';
const TELEGRAM_USER_ID = /^[1-9]\d{0,15}$/;
export function normalizeTelegramAllowedUsers(value) {
if (value === undefined) return Object.freeze([]);
if (!Array.isArray(value)) {
throw new TypeError('telegram.allowedUsers must be an array of numeric Telegram User IDs');
}
const normalized = value.map((entry) => {
const userId = typeof entry === 'number' && Number.isSafeInteger(entry)
? String(entry) : typeof entry === 'string' ? entry.trim() : '';
if (!TELEGRAM_USER_ID.test(userId)) {
throw new TypeError('telegram.allowedUsers contains an invalid Telegram User ID');
}
return userId;
});
return Object.freeze([...new Set(normalized)]);
}
export { normalizeTelegramAllowedUsers } from '../../../../src/channels/telegram/config-store.mjs';
export function createProductionController(ctx, config = {}, internals = {}) {
return createTokenProductionController(ctx, config, internals, {
@ -31,8 +15,5 @@ export function createProductionController(ctx, config = {}, internals = {}) {
HarnessClient: TelegramHarnessClient,
Controller: TelegramController,
Runtime: TelegramRuntime,
runtimeOptions: (runtimeConfig) => ({
allowedPrivateUserIds: normalizeTelegramAllowedUsers(runtimeConfig.allowedUsers),
}),
});
}

View file

@ -1,21 +1,71 @@
import {
TOKEN_BOT_ENDPOINTS,
createTokenBotRpcHandler,
installTokenBotRpc,
} from '../shared/rpc.mjs';
import { resolveRpcAuthority } from '../../rpc-authority.mjs';
import { normalizeTelegramAccessPolicy } from '../../../../src/channels/telegram/config-store.mjs';
export const TELEGRAM_RPC_CHANNEL = '/telegram';
export const TELEGRAM_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
export const TELEGRAM_ENDPOINTS = Object.freeze({
...TOKEN_BOT_ENDPOINTS,
setAccessPolicy: 'bot.access-policy.set',
});
export const TELEGRAM_RPC_ENDPOINTS = Object.freeze(Object.values(TELEGRAM_ENDPOINTS));
export function createTelegramRpcHandler(controller) {
return createTokenBotRpcHandler(controller, { channel: 'Telegram' });
if (typeof controller?.setAccessPolicy !== 'function') {
throw new TypeError('A complete Telegram controller is required (setAccessPolicy)');
}
const sharedHandler = createTokenBotRpcHandler(controller, { channel: 'Telegram' });
return async (endpoint, payload, signal) => {
if (endpoint !== TELEGRAM_ENDPOINTS.setAccessPolicy) {
return sharedHandler(endpoint, payload, signal);
}
if (signal?.aborted) {
return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
}
const keys = payload && typeof payload === 'object' && !Array.isArray(payload)
? Object.keys(payload) : [];
if (keys.length !== 3 || !keys.every((key) => (
['botId', 'accessMode', 'allowedUsers'].includes(key)
)) || typeof payload.botId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)) {
return {
ok: false,
error: { code: 'bad-request', message: 'bot.access-policy.set requires a valid policy.' },
};
}
let accessPolicy;
try {
accessPolicy = normalizeTelegramAccessPolicy(payload);
} catch {
return {
ok: false,
error: { code: 'bad-request', message: '请输入有效的 Telegram 访问模式和数字 User ID。' },
};
}
try {
const value = await controller.setAccessPolicy(payload.botId, accessPolicy);
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
: { ok: true, value };
} catch {
return signal?.aborted
? { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } }
: {
ok: false,
error: { code: 'telegram-operation-failed', message: 'Telegram 操作失败,请稍后重试。' },
};
}
};
}
export function installTelegramRpc(ctx, controller, authority) {
return installTokenBotRpc(ctx, controller, {
channel: 'Telegram',
rpcChannel: TELEGRAM_RPC_CHANNEL,
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(
TELEGRAM_RPC_CHANNEL,
createTelegramRpcHandler(controller),
{ authority: resolveRpcAuthority(authority) },
);
}

View file

@ -95,6 +95,7 @@ export class OfficeJobExecutor {
#transport;
#createHarness;
#logger;
#sleep;
#active = new Map();
#queued = new Set();
#completed = new Set();
@ -107,7 +108,13 @@ export class OfficeJobExecutor {
lastJobAt: null,
};
constructor({ config, transport, createHarness, logger = console }) {
constructor({
config,
transport,
createHarness,
logger = console,
sleepImpl = sleep,
}) {
if (!config || !transport || typeof createHarness !== 'function') {
throw new TypeError('OfficeJobExecutor requires config, transport, and createHarness');
}
@ -115,6 +122,7 @@ export class OfficeJobExecutor {
this.#transport = transport;
this.#createHarness = createHarness;
this.#logger = logger;
this.#sleep = sleepImpl;
}
get status() { return structuredClone(this.#status); }
@ -256,18 +264,10 @@ export class OfficeJobExecutor {
async #renew(jobId, entry) {
while (!entry.controller.signal.aborted) {
try { await sleep(RENEW_MS, undefined, { signal: entry.controller.signal }); }
try { await this.#sleep(RENEW_MS, undefined, { signal: entry.controller.signal }); }
catch { return; }
try {
await this.#transport.renewJob(jobId, entry.leaseToken, { signal: entry.controller.signal });
const snapshot = await this.#transport.getJob(jobId, { signal: entry.controller.signal });
const approval = snapshot?.job?.approval;
if (approval && (approval.status === 'approved' || approval.status === 'rejected')) {
entry.approvals.get(approval.id)?.resolve({
decision: approval.status,
answer: clean(approval.answer),
});
}
} catch (error) {
if (entry.controller.signal.aborted) return;
entry.cancelled = true;
@ -280,6 +280,22 @@ export class OfficeJobExecutor {
}
return;
}
try {
const snapshot = await this.#transport.getJob(jobId, { signal: entry.controller.signal });
const approval = snapshot?.job?.approval;
if (approval && (approval.status === 'approved' || approval.status === 'rejected')) {
entry.approvals.get(approval.id)?.resolve({
decision: approval.status,
answer: clean(approval.answer),
});
}
} catch (error) {
if (entry.controller.signal.aborted) return;
this.#logger.warn?.(
`[dsh-im:office] Job ${jobId} approval poll failed; will retry after the next renewal:`,
error.message,
);
}
}
}

View file

@ -22,15 +22,25 @@ export class OfficeRuntime {
#token;
#logger;
#transport;
#sleep;
#controller = null;
#task = null;
#status;
#jobs;
constructor({ config, token, logger = console, transport, createHarness, jobExecutor }) {
constructor({
config,
token,
logger = console,
transport,
createHarness,
jobExecutor,
sleepImpl = sleep,
}) {
this.#config = config;
this.#token = token;
this.#logger = logger;
this.#sleep = sleepImpl;
this.#transport = transport ?? new OfficeTransport({
baseUrl: config.baseUrl, deviceId: config.deviceId, token,
});
@ -91,7 +101,10 @@ export class OfficeRuntime {
const heartbeat = await this.#transport.heartbeat(this.capabilities(), { signal: attemptSignal });
this.#offerJobs(heartbeat?.jobs);
this.#status.lastHeartbeatAt = new Date().toISOString();
const heartbeatTask = this.#heartbeatLoop(attemptSignal);
let streamOpened = false;
const heartbeatTask = this.#heartbeatLoop(attemptSignal, () => {
if (streamOpened && !attemptSignal.aborted) attempt = 0;
});
const stream = this.#transport.stream({
signal: attemptSignal,
lastEventId: this.#status.lastEventId,
@ -99,7 +112,7 @@ export class OfficeRuntime {
this.#status.connected = true;
this.#status.state = 'connected';
this.#status.error = null;
attempt = 0;
streamOpened = true;
},
onEvent: async (event) => {
this.#status.lastEventAt = new Date().toISOString();
@ -112,13 +125,14 @@ export class OfficeRuntime {
await Promise.race([stream, heartbeatTask]);
} catch (error) {
if (signal.aborted) break;
attemptController.abort();
this.#status.connected = false;
this.#status.state = 'reconnecting';
this.#status.error = safeConnectionError(error);
this.#status.reconnects += 1;
const delay = RETRY_DELAYS[Math.min(attempt, RETRY_DELAYS.length - 1)];
attempt += 1;
try { await sleep(delay, undefined, { signal }); } catch { break; }
try { await this.#sleep(delay, undefined, { signal }); } catch { break; }
} finally {
attemptController.abort();
}
@ -127,12 +141,13 @@ export class OfficeRuntime {
this.#status.state = 'idle';
}
async #heartbeatLoop(signal) {
async #heartbeatLoop(signal, onSuccess) {
while (!signal.aborted) {
await sleep(this.#config.heartbeatSeconds * 1_000, undefined, { signal });
await this.#sleep(this.#config.heartbeatSeconds * 1_000, undefined, { signal });
const heartbeat = await this.#transport.heartbeat(this.capabilities(), { signal });
this.#offerJobs(heartbeat?.jobs);
this.#status.lastHeartbeatAt = new Date().toISOString();
onSuccess?.();
}
}

View file

@ -163,6 +163,34 @@ export class TokenBotController {
return this.status();
}
async updateBotConfig(botId, update) {
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
if (typeof update !== 'function') throw new TypeError('Bot config update must be a function');
await this.#withBotTransition(botId, async () => {
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
const config = this.#configStore.get(botId);
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
const token = await this.#resolveToken(config.tokenRef);
if (!token) throw new Error(`${this.#descriptor.label} bot token is missing`);
if (this.#closed) throw new Error(`${this.#descriptor.label} controller is closed`);
const nextConfig = update(config);
const savedConfig = await this.#configStore.save(nextConfig);
try {
await this.#startRuntime(savedConfig, 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 sendConnectionTest(botId) {
const config = this.#configStore.get(botId);
if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);

View file

@ -33,16 +33,26 @@ export class TokenBotConfigStore {
#channel;
#botPrefix;
#tokenRefPrefix;
#normalizeBotExtension;
#botIdPattern;
#tokenRefPattern;
#value = EMPTY_DOCUMENT;
#writeQueue = Promise.resolve();
constructor(path, { channel, botPrefix, tokenRefPrefix }) {
constructor(path, {
channel,
botPrefix,
tokenRefPrefix,
normalizeBotExtension = () => ({}),
}) {
if (typeof normalizeBotExtension !== 'function') {
throw new TypeError('normalizeBotExtension must be a function');
}
this.#path = path;
this.#channel = channel;
this.#botPrefix = botPrefix;
this.#tokenRefPrefix = tokenRefPrefix;
this.#normalizeBotExtension = normalizeBotExtension;
this.#botIdPattern = new RegExp(`^${escapePattern(botPrefix)}_[a-f0-9]{24}$`);
this.#tokenRefPattern = new RegExp(`^${escapePattern(tokenRefPrefix)}_[A-F0-9]{24}$`);
}
@ -127,6 +137,8 @@ export class TokenBotConfigStore {
tokenRefPrefix: this.#tokenRefPrefix,
});
if (derived.botId !== botId || derived.tokenRef !== tokenRef) return null;
const extension = this.#normalizeBotExtension(value);
if (!extension || typeof extension !== 'object' || Array.isArray(extension)) return null;
return Object.freeze({
botId,
platformId,
@ -135,6 +147,7 @@ export class TokenBotConfigStore {
username: cleanString(value.username),
createdAt: cleanString(value.createdAt) ?? new Date().toISOString(),
connectedAt: cleanString(value.connectedAt),
...extension,
});
}

View file

@ -9,6 +9,58 @@ const IDENTITY_OPTIONS = Object.freeze({
tokenRefPrefix: 'DSH_TELEGRAM_BOT_TOKEN',
});
export const TELEGRAM_ACCESS_MODES = Object.freeze({
compatible: 'compatible',
privateAllowlist: 'private-allowlist',
});
const TELEGRAM_USER_ID = /^[1-9]\d{0,15}$/;
export function normalizeTelegramAllowedUsers(value) {
if (value === undefined) return Object.freeze([]);
if (!Array.isArray(value)) {
throw new TypeError('allowedUsers must be an array of numeric Telegram User IDs');
}
const normalized = value.map((entry) => {
const userId = typeof entry === 'number' && Number.isSafeInteger(entry)
? String(entry) : typeof entry === 'string' ? entry.trim() : '';
if (!TELEGRAM_USER_ID.test(userId)) {
throw new TypeError('allowedUsers contains an invalid Telegram User ID');
}
return userId;
});
return Object.freeze([...new Set(normalized)]);
}
export function normalizeTelegramAccessPolicy(value = {}) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError('Telegram access policy must be an object');
}
const accessMode = value.accessMode ?? TELEGRAM_ACCESS_MODES.compatible;
if (!Object.values(TELEGRAM_ACCESS_MODES).includes(accessMode)) {
throw new TypeError('Telegram accessMode must be compatible or private-allowlist');
}
return Object.freeze({
accessMode,
allowedUsers: normalizeTelegramAllowedUsers(value.allowedUsers),
});
}
function normalizeTelegramBotExtension(value) {
const hasAccessMode = Object.hasOwn(value, 'accessMode');
const hasAllowedUsers = Object.hasOwn(value, 'allowedUsers');
if (!hasAccessMode && !hasAllowedUsers) return {};
try {
const policy = normalizeTelegramAccessPolicy(value);
return {
...(hasAccessMode ? { accessMode: policy.accessMode } : {}),
...(hasAllowedUsers || hasAccessMode ? { allowedUsers: policy.allowedUsers } : {}),
};
} catch {
return null;
}
}
export function deriveTelegramBotIdentity(platformId) {
return deriveTokenBotIdentity(platformId, IDENTITY_OPTIONS);
}
@ -19,6 +71,15 @@ export function maskTelegramBotId(platformId) {
export class TelegramConfigStore extends TokenBotConfigStore {
constructor(path) {
super(path, { channel: 'Telegram', ...IDENTITY_OPTIONS });
super(path, {
channel: 'Telegram',
...IDENTITY_OPTIONS,
normalizeBotExtension: normalizeTelegramBotExtension,
});
}
async save(value) {
const previous = value?.platformId ? this.getByPlatformId(String(value.platformId)) : null;
return super.save({ ...previous, ...value });
}
}

View file

@ -1,9 +1,15 @@
import { TokenBotController } from '../shared/token-bot-controller.mjs';
import { deriveTelegramBotIdentity, maskTelegramBotId } from './config-store.mjs';
import {
deriveTelegramBotIdentity,
maskTelegramBotId,
normalizeTelegramAccessPolicy,
} from './config-store.mjs';
import { inspectTelegramToken } from './telegram-api.mjs';
import { TELEGRAM_DESCRIPTOR } from './telegram-bridge.mjs';
export class TelegramController extends TokenBotController {
#configStore;
constructor(options) {
super({
...options,
@ -12,5 +18,23 @@ export class TelegramController extends TokenBotController {
deriveIdentity: deriveTelegramBotIdentity,
maskPlatformId: maskTelegramBotId,
});
this.#configStore = options.configStore;
}
status() {
const snapshot = super.status();
return {
...snapshot,
bots: snapshot.bots.map((bot) => {
const config = this.#configStore.get(bot.botId);
const accessPolicy = normalizeTelegramAccessPolicy(config ?? {});
return { ...bot, accessPolicy };
}),
};
}
async setAccessPolicy(botId, value) {
const accessPolicy = normalizeTelegramAccessPolicy(value);
return this.updateBotConfig(botId, (config) => ({ ...config, ...accessPolicy }));
}
}

View file

@ -1,6 +1,10 @@
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
import { TelegramApi } from './telegram-api.mjs';
import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
import {
TELEGRAM_ACCESS_MODES,
normalizeTelegramAccessPolicy,
} from './config-store.mjs';
function escaped(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@ -115,7 +119,11 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
};
}
export function telegramInboundAllowed(message, allowedPrivateUserIds) {
export function telegramInboundAllowed(message, {
accessMode = TELEGRAM_ACCESS_MODES.compatible,
allowedPrivateUserIds = new Set(),
} = {}) {
if (accessMode !== TELEGRAM_ACCESS_MODES.privateAllowlist) return true;
return message?.kind === 'direct'
&& allowedPrivateUserIds instanceof Set
&& allowedPrivateUserIds.has(String(message.senderId));
@ -204,6 +212,7 @@ export class TelegramRuntime {
#logger;
#replyTimeoutMs;
#createApi;
#accessMode;
#allowedPrivateUserIds;
#status = createTelegramRuntimeStatus();
#api = null;
@ -220,7 +229,6 @@ export class TelegramRuntime {
logger = console,
replyTimeoutMs = 600_000,
createApi = (options) => new TelegramApi(options),
allowedPrivateUserIds = [],
}) {
if (!config || !token || !harness || !state) {
throw new TypeError('TelegramRuntime requires config, token, Harness, and state');
@ -232,9 +240,9 @@ export class TelegramRuntime {
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#createApi = createApi;
this.#allowedPrivateUserIds = new Set(
Array.isArray(allowedPrivateUserIds) ? allowedPrivateUserIds.map(String) : [],
);
const accessPolicy = normalizeTelegramAccessPolicy(config);
this.#accessMode = accessPolicy.accessMode;
this.#allowedPrivateUserIds = new Set(accessPolicy.allowedUsers);
}
get status() {
@ -334,7 +342,10 @@ export class TelegramRuntime {
username: this.#config.username,
loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
});
if (message && telegramInboundAllowed(message, this.#allowedPrivateUserIds)) {
if (message && telegramInboundAllowed(message, {
accessMode: this.#accessMode,
allowedPrivateUserIds: this.#allowedPrivateUserIds,
})) {
void this.#bridge.accept(message).catch((error) => {
if (signal.aborted) return;
this.#logger.error?.(

View file

@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
@ -11,6 +12,7 @@ import { OfficeConfigStore } from '../../../src/channels/office/config-store.mjs
import { OfficeController } from '../../../src/channels/office/office-controller.mjs';
import { OfficeTransport } from '../../../src/channels/office/office-transport.mjs';
import { OfficeJobExecutor } from '../../../src/channels/office/office-job-executor.mjs';
import { OfficeRuntime } from '../../../src/channels/office/office-runtime.mjs';
import {
OFFICE_PROTOCOL_VERSION,
OFFICE_RPC_ENDPOINTS,
@ -30,6 +32,44 @@ async function eventually(predicate, timeoutMs = 2_000) {
assert.fail('condition did not become true');
}
function controlledSleep() {
const calls = [];
const sleep = (delay, _value, { signal } = {}) => new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
signal?.removeEventListener('abort', onAbort);
callback(value);
};
const onAbort = () => finish(
reject,
signal?.reason ?? new DOMException('The operation was aborted', 'AbortError'),
);
const call = {
delay,
get settled() { return settled; },
resolve: () => finish(resolve),
};
calls.push(call);
if (signal?.aborted) onAbort();
else signal?.addEventListener('abort', onAbort, { once: true });
});
return { calls, sleep };
}
function pendingUntilAbort(signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'));
return;
}
signal.addEventListener('abort', () => {
reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'));
}, { once: true });
});
}
function config(overrides = {}) {
return {
version: 1,
@ -127,6 +167,103 @@ test('AI Office transport uses fixed Job hooks and keeps the lease outside JSON
assert.equal(calls[2].options.body.includes('lease-secret'), false);
});
test('AI Office transport satisfies the loopback Office HTTP and SSE contract', async (t) => {
const jobId = 'job-1234567890abcdef1234567890abcdef';
const leaseToken = 'lease-local-contract-1234567890';
const records = [];
const json = (response, value, status = 200) => {
response.writeHead(status, { 'content-type': 'application/json' });
response.end(JSON.stringify(value));
};
const server = createServer((request, response) => {
void (async () => {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const rawBody = Buffer.concat(chunks).toString('utf8');
const path = new URL(request.url, 'http://127.0.0.1').pathname;
records.push({
path,
method: request.method,
headers: { ...request.headers },
body: rawBody ? JSON.parse(rawBody) : undefined,
});
if (path.endsWith('/heartbeat')) {
json(response, { ok: true, protocolVersion: OFFICE_PROTOCOL_VERSION, jobs: [] });
return;
}
if (path.endsWith('/stream')) {
response.writeHead(200, { 'content-type': 'text/event-stream' });
response.end([
`id: evt-available\nevent: job.available\ndata: {"type":"job.available","jobId":"${jobId}"}\n\n`,
`id: evt-approval\nevent: approval.reply\ndata: {"type":"approval.reply","jobId":"${jobId}","approvalId":"approval-local","decision":"approved"}\n\n`,
`id: evt-cancel\nevent: job.cancel\ndata: {"type":"job.cancel","jobId":"${jobId}"}\n\n`,
].join(''));
return;
}
if (path === `/api/harness/connector/jobs/${jobId}`) {
json(response, { job: { id: jobId } });
return;
}
if (path.endsWith('/accept')) {
json(response, { leaseToken });
return;
}
if (/\/(renew|progress|approval|result|fail)$/.test(path)) {
json(response, { ok: true });
return;
}
json(response, { error: 'not-found' }, 404);
})().catch((error) => {
json(response, { error: error.message }, 500);
});
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
t.after(() => new Promise((resolve) => server.close(resolve)));
const address = server.address();
assert.equal(typeof address, 'object');
const transport = new OfficeTransport({
baseUrl: `http://127.0.0.1:${address.port}`,
deviceId: 'local-contract-device',
token: TOKEN,
});
await transport.heartbeat({ protocolVersion: OFFICE_PROTOCOL_VERSION });
const events = [];
let opened = false;
await assert.rejects(() => transport.stream({
lastEventId: 'evt-previous',
onOpen: () => { opened = true; },
onEvent: (event) => events.push(event),
}), /stream ended/);
await transport.getJob(jobId);
assert.equal((await transport.acceptJob(jobId)).leaseToken, leaseToken);
await transport.renewJob(jobId, leaseToken);
await transport.progressJob(jobId, leaseToken, { kind: 'status', message: 'running' });
await transport.requestApproval(jobId, leaseToken, { id: 'approval-local', kind: 'approval' });
await transport.completeJob(jobId, leaseToken, { resultMarkdown: 'done', sessionId: 'session-local' });
await transport.failJob(jobId, leaseToken, { error: 'contract-only failure payload' });
assert.equal(opened, true);
assert.deepEqual(events.map((event) => event.type), [
'job.available', 'approval.reply', 'job.cancel',
]);
assert.ok(records.every((record) => record.headers.authorization === `Bearer ${TOKEN}`));
assert.ok(records.every((record) => record.headers['x-harness-device-id'] === 'local-contract-device'));
assert.equal(records.find((record) => record.path.endsWith('/stream')).headers['last-event-id'], 'evt-previous');
const leased = records.filter((record) => /\/(renew|progress|approval|result|fail)$/.test(record.path));
assert.equal(leased.length, 5);
for (const record of leased) {
assert.equal(record.headers['x-harness-lease-token'], leaseToken);
assert.equal(JSON.stringify(record.body ?? null).includes(leaseToken), false);
}
assert.equal(records.find((record) => record.path.endsWith('/progress')).body.message, 'running');
assert.equal(records.find((record) => record.path.endsWith('/result')).body.resultMarkdown, 'done');
assert.equal(records.find((record) => record.path.endsWith('/approval')).body.id, 'approval-local');
});
test('AI Office controller stores the token in credentials and returns only safe status', async () => {
let stored = null;
const credentialStore = credentials();
@ -295,3 +432,301 @@ test('AI Office Job executor claims, reports, approves, and returns one Harness
assert.deepEqual(results, [{ resultMarkdown: '# Completed\n\nVerified.', sessionId: 'session-office-one' }]);
await executor.close();
});
test('AI Office Job continues when approval polling fails after a successful renewal', async () => {
const jobId = 'job-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const clock = controlledSleep();
const warnings = [];
let getJobCalls = 0;
let renewals = 0;
let cancellations = 0;
let completions = 0;
let failures = 0;
let approvalRequests = 0;
const responses = [];
const transport = {
getJob: async () => {
getJobCalls += 1;
if (getJobCalls === 1) return { job: {
id: jobId,
workspaceAlias: 'office-project',
instructionPreset: 'execute',
markdown: '# Poll recovery test',
} };
if (getJobCalls === 2) throw new Error('temporary approval poll outage');
return { job: { approval: {
id: 'approval-poll-recovery',
status: 'approved',
} } };
},
acceptJob: async () => ({ leaseToken: 'lease-poll-recovery' }),
renewJob: async () => { renewals += 1; return { ok: true }; },
progressJob: async () => ({ ok: true }),
requestApproval: async () => { approvalRequests += 1; return { ok: true }; },
completeJob: async () => { completions += 1; return { ok: true }; },
failJob: async () => { failures += 1; return { ok: true }; },
};
const executor = new OfficeJobExecutor({
config: {
maxConcurrency: 1,
workspaces: { 'office-project': '/tmp/office-project' },
instructionPresets: { execute: 'Execute carefully.' },
},
transport,
createHarness: () => ({
createSession: async () => 'session-poll-recovery',
ask: async (_sessionId, _prompt, options) => {
await options.onInteraction({
kind: 'approval',
interactionId: 'approval-poll-recovery',
sessionId: 'session-poll-recovery',
payload: {
type: 'approval/requested',
sessionId: 'session-poll-recovery',
approvalId: 'approval-poll-recovery',
toolName: 'apply_patch',
callId: 'call-poll-recovery',
},
toolCall: {
callId: 'call-poll-recovery',
name: 'apply_patch',
arguments: '{"patch":"safe"}',
},
respond: async (value) => { responses.push(value); return { accepted: true }; },
});
return '# Completed after transient poll failure';
},
rpc: async () => { cancellations += 1; return { ok: true }; },
}),
logger: { warn: (...args) => warnings.push(args) },
sleepImpl: clock.sleep,
});
assert.equal(executor.offer(jobId), true);
await eventually(() => approvalRequests === 1
&& clock.calls.some((call) => call.delay === 30_000 && !call.settled));
clock.calls.find((call) => call.delay === 30_000 && !call.settled).resolve();
await eventually(() => renewals === 1 && warnings.length === 1
&& clock.calls.some((call) => call.delay === 30_000 && !call.settled));
assert.equal(executor.status.running, 1);
assert.equal(cancellations, 0);
assert.equal(responses.length, 0);
assert.match(warnings[0].join(' '), /approval poll failed/);
clock.calls.find((call) => call.delay === 30_000 && !call.settled).resolve();
await eventually(() => executor.status.completed === 1);
assert.equal(getJobCalls, 3);
assert.equal(renewals, 2);
assert.equal(responses[0].value.outcome, 'allowed-once');
assert.equal(completions, 1);
assert.equal(failures, 0);
assert.equal(cancellations, 0);
await executor.close();
});
test('AI Office Job safely cancels the Harness session when lease renewal fails', async () => {
const jobId = 'job-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
const clock = controlledSleep();
let sessionStarted = false;
let cancellations = 0;
let completions = 0;
let failures = 0;
const transport = {
getJob: async () => ({ job: {
id: jobId,
workspaceAlias: 'office-project',
instructionPreset: 'execute',
markdown: '# Renewal failure test',
} }),
acceptJob: async () => ({ leaseToken: 'lease-renew-failure' }),
renewJob: async () => { throw new Error('lease renewal unavailable'); },
progressJob: async () => ({ ok: true }),
completeJob: async () => { completions += 1; return { ok: true }; },
failJob: async () => { failures += 1; return { ok: true }; },
};
const executor = new OfficeJobExecutor({
config: {
maxConcurrency: 1,
workspaces: { 'office-project': '/tmp/office-project' },
instructionPresets: { execute: 'Execute carefully.' },
},
transport,
createHarness: () => ({
createSession: async () => 'session-renew-failure',
ask: async (_sessionId, _prompt, { signal }) => {
sessionStarted = true;
return pendingUntilAbort(signal);
},
rpc: async (method, payload) => {
assert.equal(method, 'session.cancel');
assert.equal(payload.sessionId, 'session-renew-failure');
cancellations += 1;
return { ok: true };
},
}),
sleepImpl: clock.sleep,
});
assert.equal(executor.offer(jobId), true);
await eventually(() => sessionStarted
&& clock.calls.some((call) => call.delay === 30_000 && !call.settled));
clock.calls.find((call) => call.delay === 30_000 && !call.settled).resolve();
await eventually(() => executor.status.running === 0 && cancellations === 1);
assert.equal(completions, 0);
assert.equal(failures, 0);
await executor.close();
});
test('AI Office job.cancel SSE event stops only the active Harness job', async () => {
const jobId = 'job-cccccccccccccccccccccccccccccccc';
const clock = controlledSleep();
let sessionStarted = false;
let cancellations = 0;
let completions = 0;
let failures = 0;
const executor = new OfficeJobExecutor({
config: {
maxConcurrency: 1,
workspaces: { 'office-project': '/tmp/office-project' },
instructionPresets: { execute: 'Execute carefully.' },
},
transport: {
getJob: async () => ({ job: {
id: jobId,
workspaceAlias: 'office-project',
instructionPreset: 'execute',
markdown: '# Cancellation test',
} }),
acceptJob: async () => ({ leaseToken: 'lease-cancellation' }),
renewJob: async () => ({ ok: true }),
progressJob: async () => ({ ok: true }),
completeJob: async () => { completions += 1; return { ok: true }; },
failJob: async () => { failures += 1; return { ok: true }; },
},
createHarness: () => ({
createSession: async () => 'session-cancellation',
ask: async (_sessionId, _prompt, { signal }) => {
sessionStarted = true;
return pendingUntilAbort(signal);
},
rpc: async (method, payload) => {
assert.equal(method, 'session.cancel');
assert.equal(payload.sessionId, 'session-cancellation');
cancellations += 1;
return { ok: true };
},
}),
sleepImpl: clock.sleep,
});
assert.equal(executor.offer(jobId), true);
await eventually(() => sessionStarted);
assert.equal(executor.handleEvent({ type: 'job.cancel', data: { jobId } }), true);
await eventually(() => executor.status.running === 0 && cancellations === 1);
assert.equal(completions, 0);
assert.equal(failures, 0);
await executor.close();
});
test('AI Office SSE short connections keep increasing retry backoff', async () => {
const clock = controlledSleep();
let streamCalls = 0;
const jobs = {
status: { running: 0 },
offer: () => false,
handleEvent() {},
close: async () => {},
};
const runtime = new OfficeRuntime({
config: config({ heartbeatSeconds: 60 }),
token: TOKEN,
transport: {
heartbeat: async () => ({ ok: true, jobs: [] }),
stream: async ({ signal, onOpen }) => {
streamCalls += 1;
onOpen();
if (streamCalls <= 4) throw new Error('short-lived SSE connection');
return pendingUntilAbort(signal);
},
},
jobExecutor: jobs,
sleepImpl: clock.sleep,
logger: { error() {} },
});
runtime.start();
for (const expected of [1_000, 3_000, 10_000, 30_000]) {
await eventually(() => clock.calls.some((call) => call.delay === expected && !call.settled));
clock.calls.find((call) => call.delay === expected && !call.settled).resolve();
}
await eventually(() => streamCalls === 5);
assert.deepEqual(
clock.calls.filter((call) => call.delay < 60_000).map((call) => call.delay),
[1_000, 3_000, 10_000, 30_000],
);
assert.equal(runtime.status.reconnects, 4);
await Promise.race([
runtime.stop(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Office Runtime stop timed out')), 250)),
]);
assert.equal(runtime.status.state, 'idle');
});
test('AI Office resets retry backoff only after a post-open heartbeat', async () => {
const clock = controlledSleep();
let heartbeatCalls = 0;
let streamCalls = 0;
let rejectStableStream;
const runtime = new OfficeRuntime({
config: config({ heartbeatSeconds: 60 }),
token: TOKEN,
transport: {
heartbeat: async () => { heartbeatCalls += 1; return { ok: true, jobs: [] }; },
stream: async ({ signal, onOpen }) => {
streamCalls += 1;
onOpen();
if (streamCalls === 1) throw new Error('first short-lived SSE connection');
return new Promise((resolve, reject) => {
rejectStableStream = reject;
signal.addEventListener('abort', () => {
reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'));
}, { once: true });
});
},
},
jobExecutor: {
status: { running: 0 },
offer: () => false,
handleEvent() {},
close: async () => {},
},
sleepImpl: clock.sleep,
logger: { error() {} },
});
runtime.start();
await eventually(() => clock.calls.some((call) => call.delay === 1_000 && !call.settled));
clock.calls.find((call) => call.delay === 1_000 && !call.settled).resolve();
await eventually(() => streamCalls === 2
&& clock.calls.some((call) => call.delay === 60_000 && !call.settled));
const heartbeatSleep = clock.calls.filter(
(call) => call.delay === 60_000 && !call.settled,
).at(-1);
heartbeatSleep.resolve();
await eventually(() => heartbeatCalls === 3
&& clock.calls.filter((call) => call.delay === 60_000 && !call.settled).length === 1);
rejectStableStream(new Error('stable SSE connection later ended'));
await eventually(() => clock.calls.filter(
(call) => call.delay === 1_000 && !call.settled,
).length === 1);
assert.deepEqual(
clock.calls.filter((call) => call.delay < 60_000).map((call) => call.delay),
[1_000, 1_000],
);
await Promise.race([
runtime.stop(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Office Runtime stop timed out')), 250)),
]);
assert.equal(runtime.status.state, 'idle');
});

View file

@ -3,12 +3,16 @@ import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import TestRenderer from 'react-test-renderer';
import {
TelegramAccessSettings,
TelegramAccountCard,
TelegramSettingsTab,
} from '../../../plugin-src/client/channels/telegram/index.js';
const { act } = TestRenderer;
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: [] } }),
@ -38,5 +42,54 @@ test('Telegram account card matches the unified compact card layout', () => {
assert.match(markup, />Bot API 长轮询</);
assert.match(markup, />检查连接</);
assert.match(markup, />移除接入</);
assert.match(markup, />访问设置</);
assert.match(markup, /aria-label="Telegram 访问模式"/);
assert.match(markup, />兼容模式(默认)</);
assert.doesNotMatch(markup, /dim-cardSummary/);
});
test('Telegram access settings edits and saves one bot policy', async () => {
const saved = [];
let renderer;
await act(async () => {
renderer = TestRenderer.create(React.createElement(TelegramAccessSettings, {
account: {
botId: 'telegram_test',
accessPolicy: { accessMode: 'compatible', allowedUsers: ['111111111'] },
},
onSave: async (policy) => saved.push(policy),
}));
});
const select = renderer.root.findByProps({ 'aria-label': 'Telegram 访问模式' });
const textarea = renderer.root.findByProps({
'aria-label': '允许私聊的 Telegram User ID',
});
await act(async () => {
select.props.onChange({ target: { value: 'private-allowlist' } });
textarea.props.onChange({ target: { value: '6087707998\n1202499116\n6087707998' } });
});
assert.deepEqual(
renderer.root.findByProps({ className: 'dtg-accessBadge' }).children,
['已生效:兼容模式'],
);
await act(async () => {
await renderer.root.findByType('form').props.onSubmit({ preventDefault() {} });
});
assert.deepEqual(saved, [{
accessMode: 'private-allowlist',
allowedUsers: ['6087707998', '1202499116'],
}]);
await act(async () => renderer.unmount());
});
test('Telegram access settings warns when safe mode has an empty allowlist', () => {
const markup = renderToStaticMarkup(React.createElement(TelegramAccessSettings, {
account: {
botId: 'telegram_test',
accessPolicy: { accessMode: 'private-allowlist', allowedUsers: [] },
},
onSave() {},
}));
assert.match(markup, /白名单为空;保存后该机器人会拒绝所有入站消息。/);
});

View file

@ -3,7 +3,7 @@ import test from 'node:test';
import { normalizeTelegramAllowedUsers } from '../../../plugin-src/host/channels/telegram/production.mjs';
test('Telegram production normalizes and validates private-message allowlists', () => {
test('Telegram per-bot policy normalizes and validates private-message allowlists', () => {
assert.deepEqual(normalizeTelegramAllowedUsers(undefined), []);
assert.deepEqual(
normalizeTelegramAllowedUsers([6087707998, '1202499116', '6087707998']),

View file

@ -1,12 +1,14 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
TELEGRAM_ACCESS_MODES,
TelegramConfigStore,
deriveTelegramBotIdentity,
normalizeTelegramAccessPolicy,
} from '../../../src/channels/telegram/config-store.mjs';
import { TelegramController } from '../../../src/channels/telegram/telegram-controller.mjs';
import {
@ -187,11 +189,16 @@ test('Telegram config and controller store only a credential reference in bot da
assert.equal(status.totals.connected, 1);
assert.equal(status.bots[0].bot.name, 'Harness Telegram');
assert.equal(status.bots[0].bot.username, 'harness_bot');
assert.deepEqual(status.bots[0].accessPolicy, {
accessMode: TELEGRAM_ACCESS_MODES.compatible,
allowedUsers: [],
});
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));
assert.doesNotMatch(persisted, /accessMode|allowedUsers/);
await controller.reconnectBot(identity.botId);
assert.equal(runtimes.length, 2);
@ -203,6 +210,220 @@ test('Telegram config and controller store only a credential reference in bot da
assert.equal(controller.status().totals.configured, 0);
});
test('Telegram loads legacy bots without an access policy as compatible mode', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-legacy-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const configPath = join(directory, 'config.json');
const identity = deriveTelegramBotIdentity('123456789');
await writeFile(configPath, `${JSON.stringify({
version: 1,
bots: [{
...identity,
platformId: '123456789',
name: 'Legacy Telegram',
username: 'legacy_bot',
createdAt: '2026-01-01T00:00:00.000Z',
connectedAt: '2026-01-01T00:00:00.000Z',
}],
}, null, 2)}\n`);
const store = await new TelegramConfigStore(configPath).load();
const saved = store.get(identity.botId);
assert.equal(saved.accessMode, undefined);
assert.equal(saved.allowedUsers, undefined);
assert.deepEqual(normalizeTelegramAccessPolicy(saved), {
accessMode: TELEGRAM_ACCESS_MODES.compatible,
allowedUsers: [],
});
});
test('Telegram access policy persists per bot, switches freely, and restarts only that bot', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-policy-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const configPath = join(directory, 'config.json');
const configStore = await new TelegramConfigStore(configPath).load();
const runtimeRecords = [];
let inspected = 0;
const controller = new TelegramController({
credentials: credentials(),
configStore,
inspectToken: async () => {
inspected += 1;
return {
platformId: inspected === 1 ? '111111111' : inspected === 2 ? '222222222' : '111111111',
name: inspected === 2 ? 'Bot B' : 'Bot A',
username: inspected === 2 ? 'bot_b' : 'bot_a',
};
},
createRuntime: async ({ botId, config }) => {
const record = { botId, config: structuredClone(config), starts: 0, stops: 0 };
runtimeRecords.push(record);
return {
status: {
ready: true,
connectionState: 'connected',
harnessReachable: true,
lastCheckedAt: 10,
},
async start() { record.starts += 1; },
async stop() { record.stops += 1; },
};
},
});
await controller.bindCredentials({ token: TOKEN });
await controller.bindCredentials({ token: '222222222:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef123456' });
const botA = deriveTelegramBotIdentity('111111111').botId;
const botB = deriveTelegramBotIdentity('222222222').botId;
assert.deepEqual(controller.status().bots.map((bot) => bot.accessPolicy), [
{ accessMode: TELEGRAM_ACCESS_MODES.compatible, allowedUsers: [] },
{ accessMode: TELEGRAM_ACCESS_MODES.compatible, allowedUsers: [] },
]);
await controller.setAccessPolicy(botA, {
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['6087707998', '1202499116'],
});
assert.equal(runtimeRecords.filter((record) => record.botId === botA).length, 2);
assert.equal(runtimeRecords.filter((record) => record.botId === botB).length, 1);
assert.equal(runtimeRecords.find((record) => record.botId === botA).stops, 1);
assert.equal(runtimeRecords.find((record) => record.botId === botB).stops, 0);
assert.deepEqual(controller.status().bots.find((bot) => bot.botId === botA).accessPolicy, {
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['6087707998', '1202499116'],
});
assert.deepEqual(controller.status().bots.find((bot) => bot.botId === botB).accessPolicy, {
accessMode: TELEGRAM_ACCESS_MODES.compatible,
allowedUsers: [],
});
await controller.setAccessPolicy(botA, {
accessMode: TELEGRAM_ACCESS_MODES.compatible,
allowedUsers: ['6087707998', '1202499116'],
});
assert.equal(configStore.get(botA).accessMode, TELEGRAM_ACCESS_MODES.compatible);
assert.deepEqual(configStore.get(botA).allowedUsers, ['6087707998', '1202499116']);
await controller.setAccessPolicy(botA, {
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['6087707998', '1202499116'],
});
assert.deepEqual(controller.status().bots.find((bot) => bot.botId === botA).accessPolicy, {
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['6087707998', '1202499116'],
});
await controller.bindCredentials({ token: TOKEN });
assert.deepEqual(configStore.get(botA).allowedUsers, ['6087707998', '1202499116']);
assert.equal(configStore.get(botA).accessMode, TELEGRAM_ACCESS_MODES.privateAllowlist);
const reloaded = await new TelegramConfigStore(configPath).load();
assert.deepEqual(reloaded.get(botA).allowedUsers, ['6087707998', '1202499116']);
assert.equal(reloaded.get(botB).accessMode, undefined);
await controller.close();
});
test('Telegram access policy rejects invalid modes and user IDs', () => {
assert.throws(() => normalizeTelegramAccessPolicy({
accessMode: 'allow-everything',
allowedUsers: [],
}), /accessMode/);
assert.throws(() => normalizeTelegramAccessPolicy({
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['0', '-1001', '@username'],
}), /invalid Telegram User ID/);
});
test('Telegram policy update is serialized with deletion and cannot restore a deleted bot', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-policy-delete-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const configStore = await new TelegramConfigStore(join(directory, 'config.json')).load();
const credentialStore = credentials();
const unsetStarted = deferred();
const releaseUnset = deferred();
const unset = credentialStore.unset;
credentialStore.unset = async (ref) => {
unsetStarted.resolve();
await releaseUnset.promise;
return unset(ref);
};
const controller = new TelegramController({
credentials: credentialStore,
configStore,
inspectToken: async () => ({
platformId: '123456789', name: 'Harness Telegram', username: 'harness_bot',
}),
createRuntime: async () => ({
status: { ready: true, connectionState: 'connected', harnessReachable: true },
async start() {},
async stop() {},
}),
});
await controller.bindCredentials({ token: TOKEN });
const botId = deriveTelegramBotIdentity('123456789').botId;
const deletion = controller.deleteBot(botId);
await unsetStarted.promise;
const policyUpdate = controller.setAccessPolicy(botId, {
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['6087707998'],
});
releaseUnset.resolve();
await deletion;
await assert.rejects(policyUpdate, /Unknown Telegram bot/);
assert.equal(configStore.get(botId), null);
assert.equal(credentialStore.values.size, 0);
assert.equal(controller.status().totals.configured, 0);
});
test('Telegram queued policy update cannot persist after controller close begins', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-policy-close-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const configStore = await new TelegramConfigStore(join(directory, 'config.json')).load();
const reconnectStarted = deferred();
const releaseReconnect = deferred();
let runtimeCount = 0;
const controller = new TelegramController({
credentials: credentials(),
configStore,
inspectToken: async () => ({
platformId: '123456789', name: 'Harness Telegram', username: 'harness_bot',
}),
createRuntime: async () => {
runtimeCount += 1;
const current = runtimeCount;
return {
status: { ready: true, connectionState: 'connected', harnessReachable: true },
async start() {
if (current === 2) {
reconnectStarted.resolve();
await releaseReconnect.promise;
}
},
async stop() {},
};
},
});
await controller.bindCredentials({ token: TOKEN });
const botId = deriveTelegramBotIdentity('123456789').botId;
const reconnect = controller.reconnectBot(botId);
await reconnectStarted.promise;
const policyUpdate = controller.setAccessPolicy(botId, {
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['6087707998'],
});
const rejectedPolicy = assert.rejects(policyUpdate, /controller is closed/);
const closing = controller.close();
releaseReconnect.resolve();
await reconnect;
await rejectedPolicy;
await closing;
assert.equal(configStore.get(botId).accessMode, undefined);
assert.equal(configStore.get(botId).allowedUsers, undefined);
});
test('Telegram RPC accepts only token binding and strips credential internals', async () => {
const calls = [];
const connectionTests = [];
@ -226,6 +447,13 @@ test('Telegram RPC accepts only token binding and strips credential internals',
}),
sendConnectionTest: async (botId) => { connectionTests.push(botId); },
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
setAccessPolicy: async (botId, policy) => {
calls.push({ botId, policy });
return {
bots: [{ botId, accessPolicy: policy }],
totals: { configured: 1, connected: 0 },
};
},
};
const handler = createTelegramRpcHandler(controller);
const result = await handler(TELEGRAM_ENDPOINTS.bindCredentials, { token: TOKEN });
@ -266,6 +494,31 @@ test('Telegram RPC accepts only token binding and strips credential internals',
sent: false,
code: 'test-target-unavailable',
});
const access = await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
botId: 'telegram_123',
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['6087707998', '6087707998'],
});
assert.equal(access.ok, true);
assert.deepEqual(calls.at(-1), {
botId: 'telegram_123',
policy: {
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['6087707998'],
},
});
assert.equal((await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
botId: 'telegram_123',
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['@username'],
})).error.code, 'bad-request');
assert.equal((await handler(TELEGRAM_ENDPOINTS.setAccessPolicy, {
botId: 'telegram_123',
accessMode: TELEGRAM_ACCESS_MODES.compatible,
allowedUsers: [],
extra: true,
})).error.code, 'bad-request');
});
test('shared token RPC never sends a connection test after reconnect is cancelled', async () => {
@ -278,6 +531,7 @@ test('shared token RPC never sends a connection test after reconnect is cancelle
reconnectBot: async () => reconnect,
sendConnectionTest: async () => { sendCalls += 1; },
deleteBot: async () => ({ bots: [] }),
setAccessPolicy: async () => ({ bots: [] }),
};
const abort = new AbortController();
const result = createTelegramRpcHandler(controller)(TELEGRAM_ENDPOINTS.reconnectBot, {
@ -352,12 +606,21 @@ test('Telegram normalizes private messages and requires an explicit group addres
assert.equal(topicTwo.replyTarget.messageThreadId, 200);
});
test('Telegram blocks every group and admits only allowlisted private senders', () => {
test('Telegram compatible mode preserves old routing and private allowlist mode restricts inbound messages', () => {
const allowed = new Set(['6087707998', '1202499116']);
assert.equal(telegramInboundAllowed({ kind: 'group', senderId: '6087707998' }, allowed), false);
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, allowed), true);
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '999999999' }, allowed), false);
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, new Set()), false);
assert.equal(telegramInboundAllowed({ kind: 'group', senderId: '6087707998' }), true);
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '999999999' }), true);
const policy = {
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedPrivateUserIds: allowed,
};
assert.equal(telegramInboundAllowed({ kind: 'group', senderId: '6087707998' }, policy), false);
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, policy), true);
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '999999999' }, policy), false);
assert.equal(telegramInboundAllowed({ kind: 'direct', senderId: '6087707998' }, {
...policy,
allowedPrivateUserIds: new Set(),
}), false);
});
test('Telegram normalizes photo captions and image documents into one downloadable image', async () => {
@ -514,6 +777,95 @@ test('Telegram runtime validates webhook state and starts a cancellable long pol
await rm(directory, { recursive: true, force: true });
});
test('Telegram runtime enforces the selected bot private allowlist', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-allowlist-runtime-'));
const state = await new TelegramStateStore(join(directory, 'state.json')).load();
const asked = [];
let delivered = false;
let nextMessageId = 500;
const updates = [
{
update_id: 0,
message: {
message_id: 100,
chat: { id: -1001, type: 'group' },
from: { id: 7, is_bot: false },
text: '@HarnessBot group',
entities: [{ type: 'mention', offset: 0, length: 11 }],
},
},
{
update_id: 1,
message: {
message_id: 101,
chat: { id: 7, type: 'private' },
from: { id: 7, is_bot: false },
text: 'allowed direct',
},
},
{
update_id: 2,
message: {
message_id: 102,
chat: { id: 8, type: 'private' },
from: { id: 8, is_bot: false },
text: 'denied direct',
},
},
];
const fakeApi = {
getMe: async () => ({ id: 123456789, is_bot: true }),
getWebhookInfo: async () => ({ url: '' }),
getUpdates: async ({ timeout, signal }) => {
if (timeout === 0) return [];
if (!delivered) {
delivered = true;
return updates;
}
return new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
},
sendChatAction: async () => true,
sendMessage: async () => ({ message_id: nextMessageId++ }),
editMessageText: async () => true,
};
const runtime = new TelegramRuntime({
config: {
botId: 'telegram_allowlist',
platformId: '123456789',
username: 'HarnessBot',
accessMode: TELEGRAM_ACCESS_MODES.privateAllowlist,
allowedUsers: ['7'],
},
token: TOKEN,
harness: {
ensureRunning: async () => true,
createSession: async () => 'session-allowlist',
ask: async (_sessionId, text) => {
asked.push(text);
return 'done';
},
},
state,
createApi: () => fakeApi,
});
try {
await runtime.start();
await bounded((async () => {
while (state.cursor() !== 3 || asked.length !== 1) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
})(), 'Telegram safe-mode updates were not processed');
assert.deepEqual(asked, ['allowed direct']);
assert.equal(runtime.status.messagesRejected, 2);
} finally {
await runtime.stop();
await rm(directory, { recursive: true, force: true });
}
});
test('Telegram runtime keeps polling while a Harness question waits for its answer', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-interaction-'));
const state = await new TelegramStateStore(join(directory, 'state.json')).load();