diff --git a/CHANGELOG.md b/CHANGELOG.md index 695198e..1933e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,23 @@ This file records the notable changes in each dsh-im release. Its format follows ### Fixed / 修复 +- WhatsApp 设置页按私聊与管理员、群授权、最近联系人、待审批分区,支持键盘切换、群与联系人搜索、联系人加载更多、可折叠群配置和固定保存栏。工作区、预设和默认模型以紧凑布局展示;修复保存时丢失群 Agent Preset,以及审批/同步群名覆盖未保存修改的问题。 + WhatsApp settings now have keyboard-accessible sections, group/contact search, contact pagination, collapsible group configuration, and a sticky save bar. Session defaults use a compact layout. Group Agent Presets survive saving, and approval/name-sync actions preserve unsaved edits. + +- `4.9.1-ops.38` 通过 Cordis `ctx.get('udsAuth')` 读取可选认证服务,避免未注入时直接访问 `ctx.udsAuth` 抛错,修复 `ops.37` 引入的微信与 WhatsApp 健康检查 `harness-connect-failed`。新增未声明服务属性访问的回归检查,并用真实 Cordis 插件上下文验证连接。 + `4.9.1-ops.38` resolves optional authentication through Cordis `ctx.get('udsAuth')`, avoiding undeclared service property access. This fixes the Weixin and WhatsApp health-check `harness-connect-failed` regression introduced in `ops.37`, with strict service-access regression coverage and real Cordis context validation. + +- `4.9.1-ops.37` 配合 `uds-auth 0.2.21`,为同 Host IM RPC、事件流和命令隔离网页认证上下文,修复 WhatsApp 后台建会话被误判未登录的问题;保留 UDS 结构化拒绝原因,认证失败不清空聊天绑定。无认证插件时保持原调用流程。 + With `uds-auth 0.2.21`, `4.9.1-ops.37` isolates browser authentication context for same-Host IM RPCs, event streams, and commands, fixing WhatsApp background Session creation rejected as signed out. UDS rejection details survive adaptation and authentication failures preserve chat bindings. Hosts without the auth plugin retain their existing call flow. + - WhatsApp 成员/群 Agent Preset 空值(UI 原「跟随全局」)现在明确回落到**频道工作区映射预设**,不再让人误以为跟 Host 全局默认;设置页文案改为「跟随频道映射」。`/new` 后建会话也会带上该映射,避免改 Host 全局预设后误用坏默认导致 `SESSION_CREATE`。 Empty WhatsApp member/group Agent Preset (UI formerly “Follow global”) now clearly falls back to the **channel workspace mapping preset**, not the Host catalog default; settings copy is “Follow channel mapping”. `/new` session creation also carries that mapping so a broken Host global default after operators change it does not surface as `SESSION_CREATE`. - Host 带斜杠的错误码(如 `gateway/internal`、`workspace/invalid-path`、`agent-preset/not-found`)现在会正确映射为 `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE` / `PRESET_UNAVAILABLE`;`last-message-failure.json` 额外写入 workspace、Host details 与脱敏 message,便于现场对上真实 RPC。 Host slash-branded failure codes (e.g. `gateway/internal`, `workspace/invalid-path`, `agent-preset/not-found`) now map to `SESSION_CREATE` / `WORKSPACE_UNAVAILABLE` / `PRESET_UNAVAILABLE`; `last-message-failure.json` also records workspace, Host details, and redacted messages for field triage. -- `session.create` 在 Agent Preset 失败或 Host `internal` 时会自动去掉该 preset 重试一次,避免坏的成员/机器人预设覆盖把首次建会话永久卡死;工作区路径匹配进一步用 `realpath`(存在时)对齐 Host 规范路径。 - `session.create` now retries once without the Agent Preset after preset failures or Host `internal`, so a bad member/bot preset override cannot permanently block first Session creation; workspace path matching also prefers `realpath` when the directory exists, matching Host canonicalization. +- `session.create` 仅在明确的 Agent Preset 错误时去掉该 preset 重试一次;Host `internal`、认证和传输错误不再触发改预设重试。通用建会话提示引导检查 Host 日志、工作区、预设和认证,避免一律误报目录问题;工作区路径匹配用 `realpath`(存在时)对齐 Host 规范路径。 + `session.create` retries once without the Agent Preset only for explicit preset errors. Host `internal`, authentication, and transport errors no longer trigger a preset retry. Generic creation errors direct operators to Host logs, workspace, preset, and authentication settings; workspace matching prefers `realpath` when available. - Windows 下机器人工作区路径与 Host `workspace.list` 仅盘符/路径大小写不同(如 `D:\…` vs `d:\…`)时,不再因严格字符串匹配漏掉已有工作区而去 `workspace.create` / `session.create`,避免误报 `SESSION_CREATE`。 On Windows, when the bot workspace path differs from a Host `workspace.list` entry only by drive/path casing (e.g. `D:\…` vs `d:\…`), IM now reuses that workspace instead of missing it via exact string match and falling through to `workspace.create` / `session.create` (which surfaced as `SESSION_CREATE`). diff --git a/README.en.md b/README.en.md index 8299431..ccea779 100644 --- a/README.en.md +++ b/README.en.md @@ -106,6 +106,8 @@ A GitHub-source installation fetches and builds a Git dependency directly. With After installation, follow the built-in instructions on each channel page to scan a QR code or enter credentials. Secrets and Tokens are sent only to the local Harness Host and stored through its protected credential provider; status responses and bot lists never return them. +When also using `uds-auth`, install `dsh-im-ops >= 4.9.1-ops.38` and `uds-auth >= 0.2.21`, then restart the Host. IM background calls use the optional `udsAuth.runAsHost()` callback to isolate browser login context. Hosts without that authentication plugin need no additional configuration. Authentication rejections report `HARNESS_LOGIN_REQUIRED` / `HARNESS_ACCESS` and preserve chat bindings. See [Host authentication integration and troubleshooting](docs/host-auth-integration.md). + If this machine must use a forward proxy to reach Feishu, set `HTTPS_PROXY` to a full HTTP proxy URL before starting `dsh web` (for example, `http://proxy:8080`; lowercase `https_proxy` is also supported, with `HTTP_PROXY` accepted as a fallback), then restart the Host after changing it. Feishu registration and credential verification reuse the SDK's proxy-aware HTTP client, while the message WebSocket explicitly uses that proxy; the WebSocket path does not currently read `ALL_PROXY` or `NO_PROXY`. If this machine cannot reach the Telegram Bot API directly, use Node.js 22.21 or newer and enable Node's environment proxy support before starting `dsh web`: diff --git a/README.md b/README.md index 52028d2..9b7decd 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ GitHub 源安装会直接拉取并构建 Git 依赖;pnpm 10 及以上版本可 安装后,在对应渠道页面按照内置引导完成扫码或凭据配置。所有 Secret 和 Token 只提交给本机 Harness Host,并写入受保护的凭据存储;状态接口和机器人列表不会回传这些凭据。 +同时安装 `uds-auth` 时,使用 `dsh-im-ops >= 4.9.1-ops.38` 和 `uds-auth >= 0.2.21`,更新两者后重启 Host。IM 后台调用通过可选的 `udsAuth.runAsHost()` 隔离网页登录上下文;未安装认证插件的环境不需要额外配置。认证拒绝显示 `HARNESS_LOGIN_REQUIRED` / `HARNESS_ACCESS`,不会按工作区错误重建会话。详见[Host 认证集成与排错](docs/host-auth-integration.md)。 + 如果本机必须通过正向代理访问飞书,请在启动 `dsh web` 前把 `HTTPS_PROXY` 设置为包含协议的 HTTP 代理 URL(例如 `http://proxy:8080`;也支持小写 `https_proxy`,并兼容使用 `HTTP_PROXY` 作为回退),修改后重启 Host。飞书注册和凭据验证会复用 SDK 的代理感知 HTTP 客户端,消息长连接会显式通过这个代理建立 WebSocket;长连接目前不读取 `ALL_PROXY` 或 `NO_PROXY`。 如果本机无法直连 Telegram Bot API,请使用 Node.js 22.21 或更高版本,并在启动 `dsh web` 前启用 Node 的环境变量代理支持: diff --git a/docs/host-auth-integration.md b/docs/host-auth-integration.md new file mode 100644 index 0000000..63f1094 --- /dev/null +++ b/docs/host-auth-integration.md @@ -0,0 +1,19 @@ +# Host 认证集成与排错 / Host authentication integration + +`dsh-im-ops 4.9.1-ops.38` 与 `uds-auth 0.2.21` 配合时,同 Host 的机器人 RPC、事件流迭代和命令调用使用 `udsAuth.runAsHost(callback)`。该可选服务只向进程内插件开放,不增加 HTTP 或 Connection RPC 端点。回调期间 UDS 用户上下文为 `undefined`,符合 UDS 已有的 Host 后台调用规则;调用结束后恢复原网页身份。网页未登录请求和普通用户工作区权限继续由 UDS ACL 检查,后台会话不继承网页用户所有者。 + +没有安装 `uds-auth` 时无需配置。认证插件晚于 IM 就绪或重载后,IM 每次调用都会重新查找服务。显式配置 `harnessBaseUrl` 的远程 HTTP 连接继续使用远程 Host 的访问控制,不使用本机后台回调。 + +同时更新两个插件,然后重启运行中的 Harness Host。本机开发链接会加载最新源码及重新构建的 `lib/index.js`;复制发布包时也必须包含对应版本的 UDS 服务方法。不要通过关闭网页 ACL 或复用最后一位网页登录用户来解决后台消息处理问题。 + +`HARNESS_LOGIN_REQUIRED` 表示认证插件要求登录,`HARNESS_ACCESS` 表示访问被拒绝。这两类错误保留已有聊天绑定。`WORKSPACE_UNAVAILABLE` 和 `PRESET_UNAVAILABLE` 表示明确的工作区与预设错误;`SESSION_CREATE` 表示尚未细分的建会话失败,应根据参考号检查 Host 日志,不能仅凭该错误码判定目录无效。模型选择发生在创建 Session 之后,只有模型阶段的错误才能证明是模型问题。 + +本机诊断文件位于 `%USERPROFILE%\.dsh\integrations\dsh-im-ops\last-message-failure.json`(Linux/macOS 为 `~/.dsh/integrations/dsh-im-ops/last-message-failure.json`)。核对 `referenceId`、`method`、`messages` 与 `details.udsError`;文件包含本机路径等诊断信息,分享前检查内容。旧版本的 `internal` 加“登录后才能创建会话”也会识别为认证问题。只有明确的 `agent-preset/*` / `agent-preset-*` 错误允许去掉预设重试一次,通用错误不触发改预设重试。 + +## English + +With `uds-auth 0.2.21`, `dsh-im-ops 4.9.1-ops.38` runs same-Host bot RPCs, event iterator operations, and commands through the optional `udsAuth.runAsHost(callback)` service. The callback clears only the UDS browser identity for trusted in-process operations and restores the caller afterward. This service adds no remote endpoint. Browser login checks and user workspace ACLs remain active; background Sessions do not inherit a browser owner. + +Update both plugins and restart the Harness Host. Hosts without UDS need no additional setup. Service lookup happens on every call to support late initialization and reload. Explicit `harnessBaseUrl` connections retain remote access controls and do not use the local callback. Distribute the rebuilt IM bundle and the matching UDS implementation together when both plugins are installed. + +`HARNESS_LOGIN_REQUIRED` and `HARNESS_ACCESS` preserve existing chat bindings. Inspect the matching reference ID in the local diagnostic file and Host logs before treating a generic `SESSION_CREATE` error as a workspace problem. Model selection occurs after Session creation. Only explicit preset errors permit a retry without the preset; generic, authentication, and transport errors do not. diff --git a/package-lock.json b/package-lock.json index 398bac9..c6d0bc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@xmanrui/dsh-im", - "version": "4.9.1", + "name": "dsh-im-ops", + "version": "4.9.1-ops.38", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@xmanrui/dsh-im", - "version": "4.9.1", + "name": "dsh-im-ops", + "version": "4.9.1-ops.38", "license": "MIT", "dependencies": { "@tencent-connect/qqbot-connector": "1.2.0", diff --git a/package.json b/package.json index 3bbe95b..2b0d9c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-im-ops", - "version": "4.9.1-ops.36", + "version": "4.9.1-ops.38", "description": "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 - access/session policies owned here.", "keywords": [ "deepseek-harness", @@ -66,6 +66,7 @@ "plugin-src", "scripts", "src", + "docs/host-auth-integration.md", ".npmrc", "PROACTIVE_DELIVERY.md", "PROACTIVE_DELIVERY.en.md", diff --git a/plugin-src/client/access-grant-settings.js b/plugin-src/client/access-grant-settings.js index cacddf9..cd4cd11 100644 --- a/plugin-src/client/access-grant-settings.js +++ b/plugin-src/client/access-grant-settings.js @@ -1,6 +1,7 @@ import * as React from 'react'; import { + ACCESS_GRANT_GROUP_JID_PATTERN, normalizeAccessGrant, normalizeAccessPhone, validateAccessGrant, @@ -22,6 +23,22 @@ export const ACCESS_PENDING_RESOLVE_ENDPOINT = 'bot.access-pending.resolve'; export const ACCESS_GROUP_TITLES_REFRESH_ENDPOINT = 'bot.access-group-titles.refresh'; export const GROUP_SESSION_SCOPE_ENDPOINT = 'bot.group-session-scope.set'; +const ACCESS_SECTIONS = [ + { id: 'direct', label: '私聊与管理员' }, + { id: 'groups', label: '群授权' }, + { id: 'contacts', label: '最近联系人' }, + { id: 'pending', label: '待审批' }, +]; + +function configurationKey(grant, scope) { + return JSON.stringify({ + globalAdmins: grant.globalAdmins, + directMembers: grant.directMembers, + groups: grant.groups, + groupSessionScope: scope, + }); +} + function unwrapRpcResult(result) { if (result?.ok === true) return result.value; if (result?.ok === false) { @@ -152,7 +169,7 @@ function PhoneTypeahead({ return false; } if (!q) return Boolean(contact.phone); - return (contact.phone ?? '').includes(queryDigits) + return (queryDigits.length > 0 && (contact.phone ?? '').includes(queryDigits)) || (contact.pushName ?? '').toLowerCase().includes(q); }) .slice(0, 8); @@ -395,29 +412,51 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) } const ownerPhone = ownerPhoneFromAccount(account); const initialGrant = normalizeAccessGrant(account?.accessGrant); - const initialKey = JSON.stringify(initialGrant); const initialScope = normalizeGroupSessionScope(account?.groupSessionScope); const [draft, setDraft] = React.useState(() => cloneGrant(initialGrant, ownerPhone)); const [groupSessionScope, setGroupSessionScope] = React.useState(initialScope); + const [baseline, setBaseline] = React.useState(() => ({ + grant: cloneGrant(initialGrant, ownerPhone), scope: initialScope, + })); + const [activeSection, setActiveSection] = React.useState('direct'); + const [groupQuery, setGroupQuery] = React.useState(''); + const [contactQuery, setContactQuery] = React.useState(''); + const [contactLimit, setContactLimit] = React.useState(30); + const sectionId = React.useId(); + const sectionButtons = React.useRef({}); const [saving, setSaving] = React.useState(false); const [feedback, setFeedback] = React.useState(null); + const editDraft = React.useCallback(update => { + setDraft(update); + setFeedback(null); + }, []); const [newGroupJid, setNewGroupJid] = React.useState(''); const [loadingGrant, setLoadingGrant] = React.useState(!initialGrant); + const [grantReady, setGrantReady] = React.useState(Boolean(initialGrant)); const contextCatalog = React.useContext(AgentPresetCatalogContext); const [presetCatalog, setPresetCatalog] = React.useState(() => normalizeAgentPresetCatalog( account?.agentPresetCatalog ?? contextCatalog ?? EMPTY_AGENT_PRESET_CATALOG, )); - React.useEffect(() => { - setDraft(cloneGrant(normalizeAccessGrant(account?.accessGrant), ownerPhone)); - setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope)); - }, [account?.botId, initialKey, account?.groupSessionScope, ownerPhone]); + const replaceSaved = React.useCallback((grant, scope) => { + const next = cloneGrant(grant, ownerPhone); + setDraft(next); + setGroupSessionScope(scope); + setBaseline({ grant: cloneGrant(grant, ownerPhone), scope }); + setGrantReady(Boolean(grant)); + }, [ownerPhone]); // Settings open payload historically omitted accessGrant; always refresh from status. React.useEffect(() => { if (typeof rpcCall !== 'function' || !account?.botId) return undefined; let cancelled = false; + replaceSaved(initialGrant, initialScope); setLoadingGrant(true); + setFeedback(null); + setActiveSection('direct'); + setGroupQuery(''); + setContactQuery(''); + setContactLimit(30); void (async () => { try { const value = unwrapRpcResult(await rpcCall('connection.status', {})); @@ -428,14 +467,11 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) const bot = Array.isArray(value?.bots) ? value.bots.find((entry) => entry?.botId === account.botId) : null; - let grant = normalizeAccessGrant(bot?.accessGrant); - if (grant) { - setDraft(cloneGrant(grant, ownerPhone)); - onSaved?.(grant); - } - if (bot?.groupSessionScope) { - setGroupSessionScope(normalizeGroupSessionScope(bot.groupSessionScope)); - } + const grant = normalizeAccessGrant(bot?.accessGrant); + if (!grant) throw new Error('无法读取访问授权,请返回刷新后重试。'); + const scope = normalizeGroupSessionScope(bot.groupSessionScope); + replaceSaved(grant, scope); + onSaved?.(grant); const missingTitles = Object.entries(grant?.groups ?? {}) .filter(([, group]) => !(typeof group?.title === 'string' && group.title.trim())) @@ -453,7 +489,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) if (cancelled) return; const next = grantFromSnapshot(refreshed, account.botId); if (next) { - setDraft(cloneGrant(next, ownerPhone)); + replaceSaved(next, scope); onSaved?.(next); } } catch { @@ -472,7 +508,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) } })(); return () => { cancelled = true; }; - }, [account?.botId, rpcCall, ownerPhone]); + }, [account?.botId, rpcCall, ownerPhone, replaceSaved]); const contacts = draft.contacts ?? []; const knownGroupJids = React.useMemo(() => { @@ -489,8 +525,43 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) () => contacts.filter((contact) => contactNeedsGrantAction(contact, draft, knownGroupJids)), [contacts, draft, knownGroupJids], ); + const pendingRequests = (draft.pending ?? []) + .filter((entry) => entry.status === 'pending' || !entry.status); + const filteredGroupJids = knownGroupJids.filter(jid => + `${groupDisplayName(jid, draft.groups)} ${jid}`.toLowerCase().includes(groupQuery.trim().toLowerCase())); + const filteredContacts = actionableContacts.filter(contact => + [contact.phone, contact.pushName, ...(contact.groupJids ?? []).map(jid => groupDisplayName(jid, draft.groups))] + .join(' ').toLowerCase().includes(contactQuery.trim().toLowerCase())); + const dirty = configurationKey(draft, groupSessionScope) !== configurationKey(baseline.grant, baseline.scope); + const editorDisabled = saving || loadingGrant || !grantReady; + const sectionCounts = { + direct: draft.directMembers.length, groups: knownGroupJids.length, + contacts: actionableContacts.length, pending: pendingRequests.length, + }; + const panelProps = id => ({ + className: 'dim-waAccessPanel', + id: `${sectionId}-${id}-panel`, role: 'tabpanel', tabIndex: 0, + 'aria-labelledby': `${sectionId}-${id}-tab`, hidden: activeSection !== id, + }); + const changeSectionWithKeyboard = (event, index) => { + let next; + if (event.key === 'ArrowRight') next = (index + 1) % ACCESS_SECTIONS.length; + else if (event.key === 'ArrowLeft') next = (index + ACCESS_SECTIONS.length - 1) % ACCESS_SECTIONS.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = ACCESS_SECTIONS.length - 1; + else return; + event.preventDefault(); + const id = ACCESS_SECTIONS[next].id; + setActiveSection(id); + sectionButtons.current[id]?.focus(); + }; const resolvePending = async (pendingId, action) => { + if (editorDisabled) return; + if (dirty) { + setFeedback({ tone: 'error', message: '请先保存当前修改,再处理审批或同步群名。' }); + return; + } setFeedback(null); setSaving(true); try { @@ -505,7 +576,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) })); const saved = grantFromSnapshot(value, account.botId); if (!saved) throw new Error('服务没有返回已保存的访问授权。'); - setDraft(cloneGrant(saved, ownerPhone)); + replaceSaved(saved, groupSessionScope); onSaved?.(saved); setFeedback({ tone: 'success', @@ -520,6 +591,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) const save = async (event) => { event.preventDefault(); + if (editorDisabled || !dirty) return; setFeedback(null); setSaving(true); try { @@ -535,6 +607,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) .filter((m) => m.phone), groups: Object.fromEntries(Object.entries(draft.groups).map(([jid, group]) => [jid, { title: group.title, + ...(group.agentPreset ? { agentPreset: group.agentPreset } : {}), admins: group.admins.map(normalizeAccessPhone).filter(Boolean), members: group.members .map((m) => ({ ...m, phone: normalizeAccessPhone(m.phone) })) @@ -552,8 +625,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) groupSessionScope: scope, })); const saved = grantFromSnapshot(value, account.botId) ?? grant; - setDraft(cloneGrant(saved, ownerPhone)); - setGroupSessionScope(scope); + replaceSaved(saved, scope); onSaved?.(saved); setFeedback({ tone: 'success', message: '分级访问设置已保存。' }); } catch (error) { @@ -567,14 +639,28 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) }; return h('form', { - className: 'dim-accessPage', + className: 'dim-accessPage dim-waAccessPage', onSubmit: (event) => void save(event), }, loadingGrant ? h('div', { className: 'dim-accessState', 'aria-busy': 'true' }, '正在读取分级访问设置…') : null, - h('p', { className: 'dim-accessUsersEmpty' }, + h('p', { className: 'dim-waAccessIntro' }, 'WhatsApp 按电话号码授权:全局管理员管私聊;群管理员只批本群。成员不可跨群、有群权也不自动获得私聊权。'), + h('div', { className: 'dim-waAccessTabs', role: 'tablist', 'aria-label': 'WhatsApp 访问设置分区' }, + ACCESS_SECTIONS.map((section, index) => h('button', { + key: section.id, type: 'button', role: 'tab', + id: `${sectionId}-${section.id}-tab`, + 'aria-controls': `${sectionId}-${section.id}-panel`, + 'aria-selected': activeSection === section.id, + tabIndex: activeSection === section.id ? 0 : -1, + ref: element => { sectionButtons.current[section.id] = element; }, + onClick: () => setActiveSection(section.id), + onKeyDown: event => changeSectionWithKeyboard(event, index), + }, h('span', null, section.label), + h('span', { className: 'dim-waAccessCount' }, sectionCounts[section.id])))), + h('fieldset', { className: 'dim-waAccessEditor', disabled: editorDisabled }, + h('section', panelProps('direct'), h(AdminPhones, { title: '全局管理员', help: '绑定账号自动为全局管理员,至少保留一位。', @@ -583,7 +669,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) disabled: saving, lockedPhone: ownerPhone || null, onChange: (globalAdmins) => { - setDraft((current) => ({ ...current, globalAdmins })); + editDraft((current) => ({ ...current, globalAdmins })); setFeedback(null); }, }), @@ -595,17 +681,18 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) showAgentPreset: true, presetCatalog, onChange: (directMembers) => { - setDraft((current) => ({ ...current, directMembers })); + editDraft((current) => ({ ...current, directMembers })); setFeedback(null); }, - }), + })), + h('section', panelProps('pending'), h('fieldset', { className: 'dim-accessScene', disabled: saving }, h('legend', null, '待审批'), - (draft.pending ?? []).filter((entry) => entry.status === 'pending' || !entry.status).length === 0 + dirty ? h('p', { className: 'dim-accessHint' }, '请先保存当前修改,再处理审批或同步群名。') : null, + pendingRequests.length === 0 ? h('div', { className: 'dim-accessUsersEmpty' }, '暂无待批申请') : h('ul', { className: 'dim-accessUserList' }, - (draft.pending ?? []) - .filter((entry) => entry.status === 'pending' || !entry.status) + pendingRequests .map((entry) => h('li', { key: entry.id, className: 'dim-accessUserRow' }, h('div', { className: 'dim-accessField' }, h('span', null, ...(entry.kind === 'group' @@ -622,27 +709,44 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) type: 'button', className: 'dim-deliveryButton', 'data-kind': 'primary', - disabled: saving || entry.unresolved, + disabled: editorDisabled || dirty || entry.unresolved, onClick: () => void resolvePending(entry.id, 'approve'), }, '批准'), h('button', { type: 'button', className: 'dim-deliveryButton', 'data-kind': 'danger', - disabled: saving, + disabled: editorDisabled || dirty, onClick: () => void resolvePending(entry.id, 'deny'), - }, '拒绝'))))), + }, '拒绝')))))), + h('section', panelProps('groups'), h('fieldset', { className: 'dim-accessScene', disabled: saving }, h('legend', null, '群授权'), + h('label', { className: 'dim-accessField dim-waAccessSearch' }, + h('span', null, '搜索群名称或 JID'), + h('input', { + type: 'search', value: groupQuery, placeholder: '搜索群名称或 JID', + onChange: event => setGroupQuery(event.target.value), + onKeyDown: event => { if (event.key === 'Enter') event.preventDefault(); }, + })), knownGroupJids.length === 0 ? h('div', { className: 'dim-accessUsersEmpty' }, '尚无已知群。可在下方粘贴群 JID(…@g.us),或等群内有人 @ 机器人后自动出现。') - : knownGroupJids.map((groupJid) => { + : filteredGroupJids.length === 0 + ? h('div', { className: 'dim-accessUsersEmpty' }, '没有匹配的群,请调整搜索条件。') + : filteredGroupJids.map((groupJid) => { const group = draft.groups[groupJid] ?? { title: '', admins: [], members: [] }; - return h('div', { key: groupJid, className: 'dim-accessGroupCard' }, + return h('details', { + key: groupJid, className: 'dim-accessGroupCard dim-waAccessGroup', + open: filteredGroupJids.length === 1 ? true : undefined, + }, + h('summary', { className: 'dim-waAccessGroupSummary' }, h('div', { className: 'dim-accessGroupHeading' }, h('h3', null, groupDisplayName(groupJid, draft.groups)), h('code', { className: 'dim-accessGroupJid' }, groupJid)), + h('span', { className: 'dim-waAccessGroupMeta' }, + h('span', null, '本群管理员'), ` ${group.admins.length} · `, + h('span', null, '本群授权成员'), ` ${group.members.length}`)), h('div', { className: 'dim-accessGroupTitleRow' }, h('label', { className: 'dim-accessField' }, h('span', null, '群名称'), @@ -650,7 +754,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) value: group.title ?? '', maxLength: 128, placeholder: '填写便于识别的群名称', - onChange: (event) => setDraft((current) => ({ + onChange: (event) => editDraft((current) => ({ ...current, groups: { ...current.groups, @@ -661,8 +765,10 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) h('button', { type: 'button', className: 'dim-deliveryButton', - disabled: saving, + disabled: editorDisabled || dirty, + title: dirty ? '请先保存当前修改,再处理审批或同步群名。' : undefined, onClick: () => void (async () => { + if (editorDisabled || dirty) return; setSaving(true); setFeedback(null); try { @@ -672,7 +778,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) )); const next = grantFromSnapshot(refreshed, account.botId); if (!next) throw new Error('服务没有返回已保存的访问授权。'); - setDraft(cloneGrant(next, ownerPhone)); + replaceSaved(next, groupSessionScope); onSaved?.(next); const title = next.groups?.[groupJid]?.title; setFeedback({ @@ -698,7 +804,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) catalog: presetCatalog, disabled: saving, ariaLabel: '群 Agent Preset', - onChange: (preset) => setDraft((current) => { + onChange: (preset) => editDraft((current) => { const existing = current.groups[groupJid] ?? { title: '', admins: [], members: [] }; const nextGroup = { ...existing }; if (preset) nextGroup.agentPreset = preset; @@ -720,7 +826,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) disabled: saving, lockedPhone: null, nested: true, - onChange: (admins) => setDraft((current) => ({ + onChange: (admins) => editDraft((current) => ({ ...current, groups: { ...current.groups, @@ -734,7 +840,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) contacts, disabled: saving, nested: true, - onChange: (members) => setDraft((current) => ({ + onChange: (members) => editDraft((current) => ({ ...current, groups: { ...current.groups, @@ -756,11 +862,11 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) className: 'dim-deliveryButton', onClick: () => { const jid = newGroupJid.trim(); - if (!/^\d{5,32}@g\.us$/.test(jid)) { + if (!ACCESS_GRANT_GROUP_JID_PATTERN.test(jid)) { setFeedback({ tone: 'error', message: '群 JID 格式无效。' }); return; } - setDraft((current) => ({ + editDraft((current) => ({ ...current, groups: { ...current.groups, @@ -768,19 +874,40 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) }, })); setNewGroupJid(''); + setGroupQuery(''); setFeedback(null); }, }, '添加群'))), + h('fieldset', { className: 'dim-accessScene', disabled: saving }, + h('legend', null, '群会话策略'), + h('label', { className: 'dim-accessField' }, + h('span', null, '群内 Session 绑定'), + h('select', { + value: groupSessionScope || DEFAULT_GROUP_SESSION_SCOPE, + onChange: (event) => { setGroupSessionScope(event.target.value); setFeedback(null); }, + }, + h('option', { value: 'user_in_chat' }, '按发言人拆分(推荐)'), + h('option', { value: 'chat' }, '整群共享一个 Session'))))), + h('section', panelProps('contacts'), h('fieldset', { className: 'dim-accessScene', disabled: saving }, h('legend', null, '最近联系人(自动沉淀)'), h('p', { className: 'dim-accessHint' }, '仅记录私聊机器人,或在群里 @ 机器人的人。已有对应权限的人会自动隐藏;电话是唯一授权键,可一键加入私聊/本群授权。'), + h('label', { className: 'dim-accessField dim-waAccessSearch' }, + h('span', null, '搜索昵称、电话或群名称'), + h('input', { + type: 'search', value: contactQuery, placeholder: '搜索昵称、电话或群名称', + onChange: event => { setContactQuery(event.target.value); setContactLimit(30); }, + onKeyDown: event => { if (event.key === 'Enter') event.preventDefault(); }, + })), actionableContacts.length === 0 ? h('div', { className: 'dim-accessUsersEmpty' }, contacts.length === 0 ? '暂无联系人。有人私聊或 @ 机器人后会出现在此。' : '当前联系人均已具备对应权限,无需再授权。') - : h('div', { className: 'dim-accessTableWrap' }, + : filteredContacts.length === 0 + ? h('div', { className: 'dim-accessUsersEmpty' }, '没有匹配的联系人,请调整搜索条件。') + : h('div', { className: 'dim-accessTableWrap' }, h('table', { className: 'dim-accessTable', 'data-kind': 'contact' }, h('thead', null, h('tr', null, @@ -788,7 +915,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) h('th', { scope: 'col' }, '电话'), h('th', { scope: 'col' }, '来源'), h('th', { scope: 'col', className: 'dim-accessTableActions' }, '操作'))), - h('tbody', null, actionableContacts.slice(0, 30).map((contact) => { + h('tbody', null, filteredContacts.slice(0, contactLimit).map((contact) => { const phone = contact.phone || ''; const nickname = (typeof contact.pushName === 'string' && contact.pushName.trim()) ? contact.pushName.trim() @@ -824,7 +951,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) 'data-kind': 'primary', disabled: saving, onClick: () => { - setDraft((current) => { + editDraft((current) => { if (current.directMembers.some((m) => m.phone === phone)) return current; return { ...current, @@ -845,7 +972,7 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) className: 'dim-deliveryButton', disabled: saving || !phone, onClick: () => { - setDraft((current) => { + editDraft((current) => { const existing = current.groups[groupJid] ?? { title: '', admins: [], members: [] }; if (existing.admins.includes(phone) || existing.members.some((m) => m.phone === phone)) { @@ -872,27 +999,29 @@ export function AccessGrantSettingsPage({ channel, account, rpcCall, onSaved }) }, }, ['加入群:', name].join('')); }))); - }))))), - h('fieldset', { className: 'dim-accessScene', disabled: saving }, - h('legend', null, '群会话策略'), - h('label', { className: 'dim-accessField' }, - h('span', null, '群内 Session 绑定'), - h('select', { - value: groupSessionScope || DEFAULT_GROUP_SESSION_SCOPE, - onChange: (event) => setGroupSessionScope(event.target.value), - }, - h('option', { value: 'user_in_chat' }, '按发言人拆分(推荐)'), - h('option', { value: 'chat' }, '整群共享一个 Session')))), + })))), + filteredContacts.length > contactLimit ? h('button', { + type: 'button', className: 'dim-deliveryButton dim-waAccessMore', + onClick: () => setContactLimit(current => current + 30), + }, '显示更多联系人') : null))), + h('div', { className: 'dim-waAccessSaveBar' }, feedback ? h('p', { className: 'dim-accessFeedback', 'data-tone': feedback.tone, role: feedback.tone === 'error' ? 'alert' : 'status', }, feedback.message) : null, - h('div', { className: 'dim-accessActions' }, + h('div', { className: 'dim-accessActions dim-waAccessSaveActions' }, + h('span', { className: 'dim-waAccessSaveState', role: 'status' }, + loadingGrant ? '正在读取分级访问设置…' : !grantReady ? '访问授权暂不可用。' + : dirty ? '有未保存的修改' : '所有修改已保存'), + h('button', { + type: 'button', className: 'dim-deliveryButton', disabled: editorDisabled || !dirty, + onClick: () => { replaceSaved(baseline.grant, baseline.scope); setFeedback(null); }, + }, '撤销修改'), h('button', { type: 'submit', className: 'dim-deliveryButton', 'data-kind': 'primary', - disabled: saving, - }, saving ? '正在保存…' : '保存分级访问设置'))); + disabled: editorDisabled || !dirty, + }, saving ? '正在保存…' : '保存分级访问设置')))); } diff --git a/plugin-src/client/channels/whatsapp/index.js b/plugin-src/client/channels/whatsapp/index.js index 824d7f4..931d1fe 100644 --- a/plugin-src/client/channels/whatsapp/index.js +++ b/plugin-src/client/channels/whatsapp/index.js @@ -2,7 +2,7 @@ import * as React from 'react'; import { WhatsappLogoGlyph } from '../../channel-logos.js'; import { QrActionIcon } from '../../credential-binding.js'; -import { h } from '../../i18n.js'; +import { h, isEnglish } from '../../i18n.js'; import { WorkspaceEditor } from '../../workspace-editor.js'; import { ContextEnhancementEditor } from '../../context-enhancement.js'; import { @@ -52,7 +52,7 @@ const Button = React.forwardRef(function Button( function checkedTime(value) { if (!value) return '尚未检查'; try { - return new Intl.DateTimeFormat('zh-CN', { + return new Intl.DateTimeFormat(isEnglish() ? 'en-US' : 'zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit', }).format(new Date(value)); } catch { @@ -197,9 +197,11 @@ export function WhatsappAccountCard({ onConfirmRemove, onCancelRemove, }) { - const state = busy === 'reconnect' ? 'connecting' : account.state; - const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning'; - const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪'; + const settingsId = React.useId(); + const checking = busy === 'reconnect'; + const state = checking ? 'connecting' : account.state; + const tone = checking ? 'warning' : account.connected ? 'success' : state === 'error' ? 'error' : 'warning'; + const stateLabel = checking ? '正在检查连接' : account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪'; const summary = account.error?.message ?? (account.connected ? null : account.health.summary); return h('article', { className: 'ddt-card dim-botCard', 'data-bot-id': account.botId }, h('div', { className: 'ddt-cardBody dim-botCardBody' }, @@ -229,6 +231,11 @@ export function WhatsappAccountCard({ accessGrant: account.accessGrant, groupSessionScope: account.groupSessionScope, }))), + h('section', { className: 'dwa-sessionSettings', 'aria-labelledby': settingsId }, + h('div', { className: 'dwa-settingsHeading' }, + h('h4', { id: settingsId }, '会话默认设置'), + h('span', null, '修改后自动保存')), + h('div', { className: 'dwa-settingsGrid' }, h(WorkspaceEditor, { workspace: account.workspace, disabled: Boolean(busy), @@ -243,7 +250,11 @@ export function WhatsappAccountCard({ defaultModel: account.defaultModel, disabled: Boolean(busy), onSave: onDefaultModelSave, - }), + })), + h('p', { className: 'dwa-settingsHint' }, + '预设和模型只影响新会话。已有聊天请先发送 ', + h('code', null, '/new'), + ',再发送普通消息。')), h(ContextEnhancementEditor, { config: account.contextEnhancement, disabled: Boolean(busy), diff --git a/plugin-src/client/channels/whatsapp/styles.js b/plugin-src/client/channels/whatsapp/styles.js index 524733f..5b32320 100644 --- a/plugin-src/client/channels/whatsapp/styles.js +++ b/plugin-src/client/channels/whatsapp/styles.js @@ -1,9 +1,21 @@ export const WHATSAPP_STYLE_ID = 'xmanrui-dsh-im-whatsapp-settings'; const CSS = String.raw` -.dwa-page { --ddt-accent: #25d366; --ddt-accent-deep: #128c7e; --ddt-accent-wash: #eafbf0; } +.dwa-page { --ddt-accent: #128c7e; --ddt-accent-deep: #075e54; --ddt-accent-wash: color-mix(in srgb, #128c7e 10%, var(--dsw-alias-bg-layer-1, #fff)); } .dwa-avatar { color: #fff; background: #25d366; } .dwa-avatar svg { display: block; } +.dwa-sessionSettings { min-width: 0; display: grid; gap: 10px; margin-top: 16px; } +.dwa-settingsHeading { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 6px 12px; } +.dwa-settingsHeading h4 { font-size: 13px; line-height: 20px; font-weight: 650; } +.dwa-settingsHeading > span, .dwa-settingsHint { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 18px; } +.dwa-settingsGrid { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.dim-panel .dwa-settingsGrid > .dim-workspace { grid-column: 1 / -1; } +.dim-panel .dwa-settingsGrid > .dim-workspace, .dim-panel .dwa-settingsGrid > .dim-preset { margin-top: 0; padding: 10px 12px; } +.dwa-settingsHint { margin: 0; } +.dwa-settingsHint code { color: var(--dsw-alias-label-secondary, #646a73); } +@container (max-width: 520px) { + .dwa-settingsGrid { grid-template-columns: minmax(0, 1fr); } +} `; export function installWhatsappStyles() { diff --git a/plugin-src/client/i18n.js b/plugin-src/client/i18n.js index 6646ed1..374d425 100644 --- a/plugin-src/client/i18n.js +++ b/plugin-src/client/i18n.js @@ -11,6 +11,23 @@ const EN = Object.freeze({ '机器人设置页签': 'Bot settings tabs', '投递设置': 'Delivery settings', '访问设置': 'Access settings', + '会话默认设置': 'Session defaults', + '修改后自动保存': 'Changes save automatically', + '预设和模型只影响新会话。已有聊天请先发送 ': 'Presets and models apply to new sessions. In an existing chat, send ', + ',再发送普通消息。': ', then send a regular message.', + '正在检查连接': 'Checking connection', + '私聊与管理员': 'DMs and admins', + '最近联系人': 'Recent contacts', + 'WhatsApp 访问设置分区': 'WhatsApp access sections', + '搜索群名称或 JID': 'Search group names or JIDs', + '没有匹配的群,请调整搜索条件。': 'No matching groups. Try another search.', + '搜索昵称、电话或群名称': 'Search names, phones or groups', + '没有匹配的联系人,请调整搜索条件。': 'No matching contacts. Try another search.', + '显示更多联系人': 'Show more contacts', + '请先保存当前修改,再处理审批或同步群名。': 'Save your changes before processing requests or syncing group names.', + '有未保存的修改': 'You have unsaved changes', + '所有修改已保存': 'All changes saved', + '撤销修改': 'Discard changes', '渠道用户': 'Channel user', "(不可用)": " (unavailable)", "群 Agent Preset": "Group Agent Preset", diff --git a/plugin-src/client/styles.js b/plugin-src/client/styles.js index 6a60caf..a903097 100644 --- a/plugin-src/client/styles.js +++ b/plugin-src/client/styles.js @@ -443,6 +443,36 @@ body[data-ds-dark-theme] .dim-page select option, body[data-ds-dark-theme] .dim- .dim-targetFormError { margin: 10px 0 0; font-size: 12px; line-height: 18px; } .dim-targetFormActions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 12px; } .dim-accessPage { min-width: 0; display: grid; gap: 14px; } +.dim-waAccessPage { --dim-wa-accent: #128c7e; container-type: inline-size; } +.dim-waAccessIntro { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 20px; } +.dim-waAccessTabs { display: flex; gap: 4px; padding: 4px; overflow-x: auto; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 10px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); } +.dim-waAccessTabs > button { flex: 1 0 auto; display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 36px; padding: 6px 12px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--dsw-alias-label-secondary, #646a73); font: inherit; font-size: 12px; cursor: pointer; } +.dim-waAccessTabs > button:hover { background: var(--dsw-alias-bg-layer-1, #fff); } +.dim-waAccessTabs > button[aria-selected="true"] { border-color: var(--dsw-alias-border-l2, #dfe1e5); color: var(--dim-wa-accent); background: var(--dsw-alias-bg-layer-1, #fff); font-weight: 650; } +.dim-waAccessTabs > button:focus-visible, .dim-waAccessGroupSummary:focus-visible { outline: 2px solid var(--dim-wa-accent); outline-offset: -2px; } +.dim-waAccessCount { min-width: 20px; padding: 1px 5px; border-radius: 5px; background: var(--dsw-alias-bg-module-platform, #f2f3f5); font-size: 11px; font-variant-numeric: tabular-nums; } +.dim-waAccessEditor { min-width: 0; margin: 0; padding: 0; border: 0; } +.dim-waAccessPanel { min-width: 0; display: grid; gap: 14px; } +.dim-waAccessPanel[hidden] { display: none; } +.dim-waAccessPanel:focus-visible { outline: 2px solid var(--dim-wa-accent); outline-offset: 3px; border-radius: 12px; } +.dim-waAccessSearch { margin: 8px 0 12px; } +.dim-waAccessGroup:not([open]) > :not(summary) { display: none; } +.dim-waAccessGroupSummary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; cursor: pointer; list-style: none; } +.dim-waAccessGroupSummary::-webkit-details-marker { display: none; } +.dim-waAccessGroupSummary::before { content: '›'; flex: none; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 22px; line-height: 24px; transition: transform .15s; } +.dim-waAccessGroup[open] > .dim-waAccessGroupSummary::before { transform: rotate(90deg); } +.dim-waAccessGroupSummary .dim-accessGroupHeading { flex: 1; } +.dim-waAccessGroupMeta { color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 18px; } +.dim-waAccessMore { margin-top: 12px; } +.dim-waAccessSaveBar { position: sticky; z-index: 8; bottom: 0; display: grid; gap: 8px; padding: 12px 0; border-top: 1px solid var(--dsw-alias-border-l2, #dfe1e5); background: var(--dsw-alias-bg-layer-1, #fff); } +.dim-waAccessSaveActions { align-items: center; gap: 8px; flex-wrap: wrap; } +.dim-waAccessSaveState { margin-right: auto; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 20px; } +@container (max-width: 520px) { + .dim-waAccessTabs > button { padding-inline: 8px; gap: 5px; } + .dim-waAccessSaveState { flex-basis: 100%; } + .dim-waAccessSaveActions > button { width: auto; flex: 1; } + .dim-waAccessGroupMeta { flex-basis: 100%; padding-left: 24px; } +} .dim-accessScene { position: relative; min-width: 0; margin: 0; padding: 16px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 12px; background: var(--dsw-alias-bg-layer-3, #fff); } .dim-accessScene > legend { padding: 0 6px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 15px; line-height: 22px; font-weight: 650; } .dim-accessLegendContent { display: inline-flex; align-items: center; gap: 6px; } diff --git a/plugin-src/host/harness-command-executor.mjs b/plugin-src/host/harness-command-executor.mjs index 235d665..1e509ee 100644 --- a/plugin-src/host/harness-command-executor.mjs +++ b/plugin-src/host/harness-command-executor.mjs @@ -1,3 +1,5 @@ +import { runHarnessHostOperation } from './harness-host-context.mjs'; + /** * Adapt the Host's Typert command gateway to the channel Harness client. * Programmatic production fixtures may omit the gateway and exercise other assembly paths. @@ -20,7 +22,7 @@ export function createHarnessCommandExecutor(ctx, provided) { signal: options.signal, }; try { - return await gateway.invoke(request); + return await runHarnessHostOperation(ctx, () => gateway.invoke(request)); } catch (error) { // Newer Hosts require images; older Hosts reject that field before // invoking the command. Retry only that exact pre-dispatch failure so @@ -32,10 +34,10 @@ export function createHarnessCommandExecutor(ctx, provided) { throw error; } options.signal?.throwIfAborted(); - return gateway.invoke({ + return runHarnessHostOperation(ctx, () => gateway.invoke({ ...request, args: { agentId: sessionId, line }, - }); + })); } }; } diff --git a/plugin-src/host/harness-connection.mjs b/plugin-src/host/harness-connection.mjs index 196ef81..231e578 100644 --- a/plugin-src/host/harness-connection.mjs +++ b/plugin-src/host/harness-connection.mjs @@ -1,4 +1,5 @@ import { modernHarnessApi } from './modern-harness-api.mjs'; +import { runHarnessHostOperation } from './harness-host-context.mjs'; function optionalService(ctx, name) { if (ctx && Object.hasOwn(ctx, name)) return ctx[name]; @@ -13,6 +14,7 @@ export function harnessConnection(ctx, config = {}) { const apiProxy = optionalService(ctx, 'apiProxy') ?? modernHarnessApi(ctx); return { apiProxy, + runHostOperation: (operation) => runHarnessHostOperation(ctx, operation), // Cordis child contexts share one root; different Hosts must not share // ownership of pending questions and approvals. interactionScope: ctx.root ?? ctx, diff --git a/plugin-src/host/harness-host-context.mjs b/plugin-src/host/harness-host-context.mjs new file mode 100644 index 0000000..166dce8 --- /dev/null +++ b/plugin-src/host/harness-host-context.mjs @@ -0,0 +1,10 @@ +/** Run same-process IM operations without inheriting a browser's UDS identity. */ +export function runHarnessHostOperation(ctx, operation) { + // Cordis property access requires inject even for an optional service. + // ctx.get() supports dynamic lookup without making UDS a startup dependency. + const auth = typeof ctx?.get === 'function' ? ctx.get('udsAuth') : ctx?.udsAuth; + // Resolve on every call: auth can become ready or reload after IM starts. + return typeof auth?.runAsHost === 'function' + ? auth.runAsHost(operation) + : operation(); +} diff --git a/plugin-src/host/modern-harness-api.mjs b/plugin-src/host/modern-harness-api.mjs index ddbfb3f..19e2afe 100644 --- a/plugin-src/host/modern-harness-api.mjs +++ b/plugin-src/host/modern-harness-api.mjs @@ -5,7 +5,7 @@ import { hasActiveHarnessInteractionOwner } from '../../src/channels/shared/harn const modernApis = new WeakMap(); function failureOf(error) { - const failure = error?.failure; + const failure = error?.failure ?? (error?.isDSHRemoteError === true ? error : null); if (failure && typeof failure === 'object' && typeof failure.code === 'string' && typeof failure.message === 'string') { diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 25c8b7e..b2c5af5 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -28,6 +28,8 @@ const required = [ 'PROACTIVE_DELIVERY.md', 'PROACTIVE_DELIVERY.en.md', 'THIRD_PARTY_NOTICES.md', + 'docs/host-auth-integration.md', + 'plugin-src/host/harness-host-context.mjs', 'plugin-src/client/channels/dingtalk/index.js', 'plugin-src/client/channels/slack/index.js', 'plugin-src/client/i18n.js', @@ -83,6 +85,12 @@ const [ ]); const manifest = JSON.parse(manifestText); const lock = JSON.parse(lockText); +if (lock.name !== manifest.name || lock.version !== manifest.version + || lock.packages?.['']?.name !== manifest.name || lock.packages?.['']?.version !== manifest.version) { + throw new Error('package lock identity must match the package manifest'); +} +const loaderId = process.env.DSH_IM_CLIENT_ID ?? manifest.name; +const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // DSH runtime packages use module-local Symbol keys, so a second physical copy breaks Host lookup. const forbiddenDshDependency = /^@deepseek-ai\/dsh-/; @@ -118,7 +126,7 @@ if (forbiddenDshLockPaths.length > 0) { ); } -if (!/\bid\s*:\s*["']@xmanrui\/dsh-im["']/u.test(client)) { +if (!new RegExp(`\\bid\\s*:\\s*["']${escapeRegex(loaderId)}["']`, 'u').test(client)) { throw new Error('client bundle does not register the dsh-im loader id'); } const sourceSectionMarkers = [ @@ -186,7 +194,8 @@ if (/@xmanrui\/dsh-(?:feishu|weixin|dingtalk)/.test( )) { throw new Error('source or package metadata still depends on an external channel plugin'); } -if (!patch.includes("name: '@xmanrui/dsh-im'") || /dsh-(?:feishu|weixin|dingtalk)/.test(patch)) { +if (!new RegExp(`^\\s*name:\\s*['"]?${escapeRegex(manifest.name)}['"]?\\s*$`, 'mu').test(patch) + || /dsh-(?:feishu|weixin|dingtalk)/.test(patch)) { throw new Error('bundle patch must activate only dsh-im'); } for (const name of ['@xmanrui/dsh-feishu', '@xmanrui/dsh-weixin', '@xmanrui/dsh-dingtalk']) { @@ -232,7 +241,9 @@ if (/(?:from\s*|import\s*\(|require\s*\()\s*["'](?:@larksuiteoapi\/node-sdk|@whi if (!/(?:from\s*|import\s*\()\s*["']undici["']/.test(host)) { throw new Error('host bundle must retain undici as an external runtime dependency'); } -if ((executable.mode & 0o111) === 0) throw new Error('dsh-im CLI is not executable'); +if (process.platform !== 'win32' && (executable.mode & 0o111) === 0) { + throw new Error('dsh-im CLI is not executable'); +} if (/private-bot-token|must-be-rolled-back|DEEPSEEK_API_KEY=/.test(client + host)) { throw new Error('built artifacts contain a test or environment secret marker'); } diff --git a/src/channels/shared/harness-client.mjs b/src/channels/shared/harness-client.mjs index f8302f1..2bb255d 100644 --- a/src/channels/shared/harness-client.mjs +++ b/src/channels/shared/harness-client.mjs @@ -246,15 +246,6 @@ function isPresetCreateFailure(error) { return code.startsWith('agent-preset/') || code.startsWith('agent-preset-'); } -function isInternalCreateFailure(error) { - const code = typeof error?.code === 'string' ? error.code : ''; - return code === 'internal' - || code === 'gateway/internal' - || code.endsWith('/internal') - || code === 'harness-http-failed' - || code === 'harness-rpc-rejected'; -} - function findListedWorkspace(items, workspacePath) { if (!Array.isArray(items)) return null; return items.find((item) => sameWorkspacePath(item?.path, workspacePath)) ?? null; @@ -682,6 +673,7 @@ function harnessTurnSucceeded(reason) { export class HarnessClient { #baseUrl; #apiProxy; + #runHostOperation; #workspace; #agentPreset; #autostart; @@ -704,6 +696,7 @@ export class HarnessClient { constructor({ baseUrl, apiProxy, + runHostOperation = (operation) => operation(), interactionScope = apiProxy, workspace, agentPreset, @@ -746,6 +739,8 @@ export class HarnessClient { } this.#baseUrl = baseUrl === undefined ? null : new URL(baseUrl); this.#apiProxy = this.#baseUrl ? null : apiProxy; + if (typeof runHostOperation !== 'function') throw new TypeError('runHostOperation must be a function'); + this.#runHostOperation = runHostOperation; if (!this.#baseUrl && (!this.#apiProxy || typeof this.#apiProxy !== 'object')) { throw new TypeError('HarnessClient requires the current Host apiProxy or an explicit baseUrl'); } @@ -789,7 +784,9 @@ export class HarnessClient { || typeof api[action] !== 'function') { throw new HarnessTransportError('harness-api-not-found', method); } - const response = await callWithSignal(() => api[action]({ rpcId, payload }, signal), signal); + const response = await callWithSignal(() => this.#runHostOperation( + () => api[action]({ rpcId, payload }, signal), + ), signal); body = { type: 'server-response', ...response }; } else { const response = await this.#fetch(new URL(`/api/${method}`, this.#baseUrl), { @@ -995,12 +992,9 @@ export class HarnessClient { const created = await this.rpc('session.create', payload, 30_000, rpcOptions); return created.sessionId; } catch (error) { - // Broken per-chat/bot presets often surface as Host internal on create. - // Retry once without the preset so a bad override does not block the chat. - if ( - payload.agentPreset - && (isPresetCreateFailure(error) || isInternalCreateFailure(error)) - ) { + // Only a verified preset failure permits a different preset. Generic + // failures may be ACL rejections or an already-created Session. + if (payload.agentPreset && isPresetCreateFailure(error)) { console.warn( `[${this.#logPrefix}] session.create failed with agentPreset ` + `${payload.agentPreset} (${error?.code ?? 'unknown'}); retrying without preset`, @@ -1112,7 +1106,9 @@ export class HarnessClient { const envelope = { type: 'client-response', rpcId, result }; let receipt; if (this.#apiProxy) { - receipt = await callWithSignal(() => this.#apiProxy.respond(envelope), signal); + receipt = await callWithSignal(() => this.#runHostOperation( + () => this.#apiProxy.respond(envelope), + ), signal); } else { const response = await this.#fetch(new URL('/api/respond', this.#baseUrl), { method: 'POST', @@ -1866,6 +1862,7 @@ export class HarnessClient { #watchMux(options) { return watchHarnessMux({ apiProxy: this.#apiProxy, + runHostOperation: this.#runHostOperation, baseUrl: this.#baseUrl, createWebSocket: this.#createWebSocket, rpcId: `${this.#rpcIdPrefix}-${randomUUID()}`, diff --git a/src/channels/shared/harness-mux.mjs b/src/channels/shared/harness-mux.mjs index acb7d0b..8d9cf5f 100644 --- a/src/channels/shared/harness-mux.mjs +++ b/src/channels/shared/harness-mux.mjs @@ -9,7 +9,8 @@ function invoke(callback, value, onError) { } } -async function watchInProcessMux({ apiProxy, rpcId, signal, onOpen, onEnvelope }) { +async function watchInProcessMux({ apiProxy, rpcId, signal, onOpen, onEnvelope, + runHostOperation = (operation) => operation() }) { const controller = new AbortController(); const close = () => controller.abort(signal.reason); let iterator; @@ -22,10 +23,12 @@ async function watchInProcessMux({ apiProxy, rpcId, signal, onOpen, onEnvelope } }; signal.addEventListener('abort', close, { once: true }); try { - iterator = apiProxy.events.mux({ rpcId, payload: {} }, controller.signal)[Symbol.asyncIterator](); + iterator = runHostOperation(() => ( + apiProxy.events.mux({ rpcId, payload: {} }, controller.signal)[Symbol.asyncIterator]() + )); // mux registers listeners eagerly, but its generator installs abort/finally // cleanup only on the first next(). Start it even if mux itself aborted us. - pending = Promise.resolve(iterator.next()); + pending = Promise.resolve(runHostOperation(() => iterator.next())); if (!controller.signal.aborted) invoke(onOpen, close, fail); while (!controller.signal.aborted) { const { value, done } = await pending; @@ -36,7 +39,7 @@ async function watchInProcessMux({ apiProxy, rpcId, signal, onOpen, onEnvelope } method: value?.payload?.type, payload: value?.payload, }, fail); - if (!controller.signal.aborted) pending = Promise.resolve(iterator.next()); + if (!controller.signal.aborted) pending = Promise.resolve(runHostOperation(() => iterator.next())); } } catch (error) { fail(error); @@ -48,7 +51,7 @@ async function watchInProcessMux({ apiProxy, rpcId, signal, onOpen, onEnvelope } close(); await pending?.catch(() => undefined); try { - await iterator?.return?.(); + await runHostOperation(() => iterator?.return?.()); } catch (error) { if (!cancelled) failure ??= error; } diff --git a/src/channels/shared/i18n-en/shared-a.mjs b/src/channels/shared/i18n-en/shared-a.mjs index 24af75f..2624670 100644 --- a/src/channels/shared/i18n-en/shared-a.mjs +++ b/src/channels/shared/i18n-en/shared-a.mjs @@ -41,12 +41,14 @@ export default { 'Task progress is temporarily unavailable and the task may still be running. Wait or send /stop before submitting it again.', '处理服务拒绝了机器人连接。请管理员检查 Harness 地址、代理或访问配置后重试。': 'The processing service rejected the bot connection. Ask an administrator to check the Harness address, proxy, or access settings.', + 'Host 认证插件拒绝了机器人后台请求。请管理员检查认证插件与 IM 的后台调用集成后重试。': + 'The Host authentication plugin rejected the bot background request. Ask an administrator to check the authentication plugin integration with IM background operations.', '机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。': 'The bot and DeepSeek Harness use incompatible APIs. Ask an administrator to check the Harness address and update the related components.', 'DeepSeek Harness 暂时无法完成请求,请稍后重试。': 'DeepSeek Harness could not complete the request. Please try again later.', - '无法在当前工作区创建新会话。请检查机器人工作区是否为绝对路径、目录存在、且运行 Host 的账号可访问;改回可用工作区后重试。': - 'Could not create a new Session in the current workspace. Check that the bot workspace is an absolute path, the directory exists, and the Host process can access it; then switch back to a working workspace and retry.', + '无法创建新会话。请管理员根据参考号检查 Host 日志,确认工作区、Agent Preset 和认证配置后重试。': + 'Could not create a new Session. Ask an administrator to use the reference ID to check Host logs and verify the workspace, Agent Preset, and authentication settings.', '等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。': 'Waiting for the model reply timed out and the task may still be running. Wait or send /stop before submitting it again.', '模型凭据缺失或已失效。请管理员检查模型配置后重试。': diff --git a/src/channels/shared/message-failure.mjs b/src/channels/shared/message-failure.mjs index a7838f9..cffc731 100644 --- a/src/channels/shared/message-failure.mjs +++ b/src/channels/shared/message-failure.mjs @@ -88,12 +88,14 @@ const FAILURE_MESSAGES = Object.freeze({ '暂时无法确认任务状态,任务可能已经开始。请先等待或发送 /stop,不要立即重复提交。', HARNESS_ACCESS: '处理服务拒绝了机器人连接。请管理员检查 Harness 地址、代理或访问配置后重试。', + HARNESS_LOGIN_REQUIRED: + 'Host 认证插件拒绝了机器人后台请求。请管理员检查认证插件与 IM 的后台调用集成后重试。', HARNESS_PROTOCOL: '机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。', HARNESS_SERVICE: 'DeepSeek Harness 暂时无法完成请求,请稍后重试。', SESSION_CREATE: - '无法在当前工作区创建新会话。请检查机器人工作区是否为绝对路径、目录存在、且运行 Host 的账号可访问;改回可用工作区后重试。', + '无法创建新会话。请管理员根据参考号检查 Host 日志,确认工作区、Agent Preset 和认证配置后重试。', MODEL_REPLY_TIMEOUT: '等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。', MODEL_AUTH: @@ -182,11 +184,32 @@ function isHostPresetCode(code) { || code.startsWith('agent-preset-'); } +function isHarnessLoginRequired(error) { + const reason = error?.details?.udsError; + if (typeof reason === 'string' && reason.startsWith('login_required_')) return true; + // Older adapters flattened UDS RemoteError into internal with only this text. + return ['session.create', 'session.history', 'session.prompt'].includes(rpcMethod(error)) + && /(?:^|:\s*)(?:登录后才能创建会话|Sign in to create a session)\s*$/iu.test(error?.message ?? ''); +} + +/** Host ACL failures must not cause a chat binding to be deleted or recreated. */ +export function isHarnessAccessError(error) { + return isHarnessLoginRequired(error) + || [ + 'harness-auth-required', 'harness-proxy-auth-required', + 'harness-loopback-forbidden', 'harness-host-untrusted', 'harness-request-forbidden', + ].includes(error?.code) + || ['session_forbidden', 'session_workspace_only', 'workspace_path_only', + 'workspace_create_forbidden', 'forbidden_settings'].includes(error?.details?.udsError); +} + function failureCode(error) { const code = typeof error?.code === 'string' ? error.code : ''; const method = rpcMethod(error); const providerCode = providerFailureCode(error); if (providerCode) return providerCode; + if (isHarnessLoginRequired(error)) return 'HARNESS_LOGIN_REQUIRED'; + if (isHarnessAccessError(error)) return 'HARNESS_ACCESS'; // Transport wording can appear on non-turn errors (RPC/cause chain) too. if (isTransportProviderMessage(providerErrorMessage(error))) return 'MODEL_TRANSPORT'; @@ -281,6 +304,7 @@ const PERSISTED_FAILURE_CODES = new Set([ 'HARNESS_CONNECT', 'HARNESS_TIMEOUT', 'HARNESS_ACCESS', + 'HARNESS_LOGIN_REQUIRED', 'HARNESS_PROTOCOL', 'MODEL_TRANSPORT', ]); @@ -302,6 +326,7 @@ function diagnosticDetails(error) { for (const key of [ 'path', 'workspaceId', 'sessionId', 'agentPreset', 'requestedPreset', 'existingPreset', 'requestedCwd', 'existingCwd', 'reason', 'name', + 'udsError', ]) { if (!Object.hasOwn(details, key)) continue; const value = redactedDetail(details[key]); diff --git a/src/channels/shared/workspace-session.mjs b/src/channels/shared/workspace-session.mjs index f4d9276..47058d1 100644 --- a/src/channels/shared/workspace-session.mjs +++ b/src/channels/shared/workspace-session.mjs @@ -1,5 +1,5 @@ import { withSessionBindingLock } from './session-binding-lock.mjs'; -import { isMissingSessionError } from './message-failure.mjs'; +import { isHarnessAccessError, isMissingSessionError } from './message-failure.mjs'; import { initialSessionTitle } from './session-title.mjs'; export const WORKSPACE_SESSION_STALE = 'workspace-session-stale'; @@ -64,6 +64,7 @@ async function boundSessionReusable({ if (!(await sessionExists(session, existsOptions))) return false; } catch (error) { if (error?.code === WORKSPACE_SESSION_STALE || existsOptions?.signal?.aborted) throw error; + if (isHarnessAccessError(error)) throw error; if (isMissingSessionError(error)) return false; // History probe failed with a non-missing code (often Host `internal` on a // zombie binding). Recreate from bot settings instead of surfacing HARNESS_SERVICE. @@ -92,6 +93,7 @@ async function boundSessionReusable({ return belongs; } catch (error) { if (error?.code === WORKSPACE_SESSION_STALE || existsOptions?.signal?.aborted) throw error; + if (isHarnessAccessError(error)) throw error; console.warn( '[dsh-im] unable to verify Session workspace membership; recreating:', error?.message ?? error, diff --git a/test/channels/weixin/harness-client.test.mjs b/test/channels/weixin/harness-client.test.mjs index d4951fb..39af5d3 100644 --- a/test/channels/weixin/harness-client.test.mjs +++ b/test/channels/weixin/harness-client.test.mjs @@ -49,7 +49,7 @@ test('all legacy channel clients now use the shared Harness RPC transport', asyn } }); -test('shared Harness health checks expose precise safe availability codes', async () => { +test('shared Harness health checks expose precise safe availability codes', async (t) => { const clientWithFetch = (fetchImpl, baseUrl = 'http://127.0.0.1:3080') => new HarnessClient({ baseUrl, workspace: '/tmp/default-workspace', @@ -73,6 +73,9 @@ test('shared Harness health checks expose precise safe availability codes', asyn if (signal.aborted) rejectTimeout(); else signal.addEventListener('abort', rejectTimeout, { once: true }); })); + // AbortSignal.timeout is unref'ed; the mock has no socket to keep Node alive. + const keepAlive = setTimeout(() => {}, 100); + t.after(() => clearTimeout(keepAlive)); await assert.rejects(timeoutClient.rpc('host.describe', {}, 1), (error) => { assert.ok(error instanceof HarnessTransportError); assert.equal(error.code, 'harness-timeout'); @@ -191,6 +194,29 @@ test('HarnessClient forwards a per-session agent preset override', async () => { }); }); +test('HarnessClient retries only verified preset creation failures', async () => { + for (const code of ['agent-preset/not-found', 'internal', 'gateway/internal', 'harness-http-failed']) { + const client = new HarnessClient({ baseUrl: 'http://127.0.0.1:3080', agentPreset: 'broken' }); + client.ensureRunning = async () => true; + client.workspaceId = async () => 'workspace'; + const calls = []; + client.rpc = async (_method, payload) => { + calls.push(payload); + if (calls.length === 1) throw Object.assign(new Error('create failed'), { code }); + return { sessionId: 'recovered' }; + }; + if (code.startsWith('agent-preset/')) { + assert.equal(await client.createSession(), 'recovered'); + assert.deepEqual(calls, [ + { workspaceId: 'workspace', agentPreset: 'broken' }, { workspaceId: 'workspace' }, + ]); + } else { + await assert.rejects(client.createSession(), { code }); + assert.equal(calls.length, 1, code); + } + } +}); + test('HarnessClient lists only absolute workspace paths', async () => { const client = new HarnessClient({ baseUrl: 'http://127.0.0.1:3080', diff --git a/test/channels/whatsapp/access-grant-settings.test.mjs b/test/channels/whatsapp/access-grant-settings.test.mjs new file mode 100644 index 0000000..dc3ed4e --- /dev/null +++ b/test/channels/whatsapp/access-grant-settings.test.mjs @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import React from 'react'; +import TestRenderer from 'react-test-renderer'; + +import { AccessGrantSettingsPage } from '../../../plugin-src/client/access-grant-settings.js'; +import { en, setImTranslator } from '../../../plugin-src/client/i18n.js'; + +const { act, create } = TestRenderer; +const groupJid = '120363111111111111@g.us'; +const otherGroupJid = '120363222222222222@g.us'; +const ownerPhone = '8618111111111'; +const textOf = node => typeof node === 'string' || typeof node === 'number' + ? String(node) : node?.children?.map(textOf).join('') ?? ''; +const button = (root, text) => root.findAllByType('button').find(node => textOf(node) === text); +const tab = (root, label) => root.findAllByProps({ role: 'tab' }) + .find(node => textOf(node).startsWith(label)); +async function flush() { for (let i = 0; i < 12; i += 1) await Promise.resolve(); } + +function fixture() { + return { + version: 1, globalAdmins: [ownerPhone], + directMembers: [{ phone: '8618222222222', canExecuteCommands: true }], + groups: { + [groupJid]: { title: 'Ops', agentPreset: 'netxops', admins: [], members: [] }, + [otherGroupJid]: { title: 'Support', admins: [], members: [] }, + }, + pending: [{ id: 'p_test01', kind: 'direct', phone: '8618444444444', + createdAt: '2026-10-11T00:00:00.000Z', status: 'pending', notifyRefs: [] }], + contacts: Array.from({ length: 35 }, (_, index) => ({ + phone: `861900000${String(index).padStart(4, '0')}`, + pushName: index === 34 ? 'Alice' : `Contact ${index}`, + scenes: ['direct'], groupJids: [], lids: [], lastSeenAt: '2026-10-11T00:00:00.000Z', + })), + }; +} + +async function mount(t, options = {}) { + const calls = []; + let savedGrant = fixture(); + let savedScope = 'user_in_chat'; + const rpcCall = async (endpoint, payload) => { + calls.push({ endpoint, payload }); + if (endpoint === 'connection.status' && options.load) return options.load; + if (endpoint === 'bot.access-grant.set') { + if (options.failSave) throw new Error('Save rejected'); + savedGrant = payload.grant; + } + if (endpoint === 'bot.group-session-scope.set') savedScope = payload.groupSessionScope; + return { ok: true, value: { + bots: [{ botId: 'wa-test', accessGrant: savedGrant, groupSessionScope: savedScope }], + agentPresetCatalog: { defaultId: 'netxops', items: [{ id: 'netxops' }, { id: 'safe' }] }, + } }; + }; + function Page() { + const [accessGrant, setAccessGrant] = React.useState(savedGrant); + return React.createElement(AccessGrantSettingsPage, { + channel: 'whatsapp', account: { + botId: 'wa-test', phone: ownerPhone, accessGrant, groupSessionScope: 'user_in_chat', + }, rpcCall, onSaved: setAccessGrant, + }); + } + let renderer; + await act(async () => { renderer = create(React.createElement(Page)); await flush(); }); + t.after(async () => { await act(async () => renderer.unmount()); }); + return { renderer, calls }; +} + +test('access sections support keyboard navigation and preserve edits across sections', async t => { + const { renderer, calls } = await mount(t); + const root = renderer.root; + assert.equal(root.findAllByProps({ role: 'tabpanel' }).filter(node => !node.props.hidden).length, 1); + assert.equal(button(root, '保存分级访问设置').props.disabled, true); + await act(async () => tab(root, '私聊与管理员').props.onKeyDown({ key: 'ArrowRight', preventDefault() {} })); + assert.equal(tab(root, '群授权').props['aria-selected'], true); + const preset = root.findAllByType('select').find(node => node.props['aria-label'] === '群 Agent Preset'); + await act(async () => preset.props.onChange({ target: { value: 'safe' } })); + await act(async () => tab(root, '最近联系人').props.onClick()); + await act(async () => tab(root, '群授权').props.onClick()); + assert.equal(root.findAllByType('select').find(node => node.props['aria-label'] === '群 Agent Preset').props.value, 'safe'); + assert.match(textOf(root.findByProps({ className: 'dim-waAccessSaveState' })), /未保存/); + assert.equal(calls.filter(call => call.endpoint !== 'connection.status').length, 0); +}); + +test('saving retains the group preset and session scope when the parent updates its grant', async t => { + const { renderer, calls } = await mount(t); + const root = renderer.root; + await act(async () => { + root.findAllByType('select').find(node => node.props['aria-label'] === '群 Agent Preset') + .props.onChange({ target: { value: 'safe' } }); + root.findAllByType('select').find(node => node.props.value === 'user_in_chat') + .props.onChange({ target: { value: 'chat' } }); + }); + await act(async () => { root.findByType('form').props.onSubmit({ preventDefault() {} }); await flush(); }); + const grantCall = calls.find(call => call.endpoint === 'bot.access-grant.set'); + assert.equal(grantCall.payload.grant.groups[groupJid].agentPreset, 'safe'); + assert.deepEqual(calls.find(call => call.endpoint === 'bot.group-session-scope.set').payload, + { botId: 'wa-test', groupSessionScope: 'chat' }); + assert.ok(root.findAllByType('select').some(node => node.props.value === 'chat')); + assert.equal(button(root, '保存分级访问设置').props.disabled, true); + assert.match(textOf(root.findByProps({ className: 'dim-waAccessSaveState' })), /所有修改已保存/); +}); + +test('unsaved edits block approval and group sync; discard restores the last saved configuration', async t => { + const { renderer, calls } = await mount(t); + const root = renderer.root; + await act(async () => root.findAllByType('input').find(node => node.props.value === 'Ops') + .props.onChange({ target: { value: 'Draft title' } })); + assert.equal(button(root, '批准').props.disabled, true); + assert.equal(button(root, '同步群名').props.disabled, true); + await act(async () => { button(root, '批准').props.onClick(); button(root, '同步群名').props.onClick(); await flush(); }); + assert.equal(calls.filter(call => call.endpoint !== 'connection.status').length, 0); + await act(async () => button(root, '撤销修改').props.onClick()); + assert.ok(root.findAllByType('input').some(node => node.props.value === 'Ops')); + assert.equal(button(root, '批准').props.disabled, false); + assert.equal(button(root, '同步群名').props.disabled, false); + assert.equal(button(root, '保存分级访问设置').props.disabled, true); +}); + +test('contact search reaches beyond the first thirty contacts and groups match names or JIDs', async t => { + const { renderer } = await mount(t); + const root = renderer.root; + const contactTable = () => root.findByProps({ 'data-kind': 'contact' }); + assert.doesNotMatch(textOf(contactTable()), /Alice/); + await act(async () => button(root, '显示更多联系人').props.onClick()); + assert.match(textOf(contactTable()), /Alice/); + const contactSearch = root.findAllByType('input').find(node => node.props.placeholder === '搜索昵称、电话或群名称'); + await act(async () => contactSearch.props.onChange({ target: { value: 'alice' } })); + assert.match(textOf(contactTable()), /Alice/); + assert.doesNotMatch(textOf(contactTable()), /Contact 0/); + const groupSearch = root.findAllByType('input').find(node => node.props.placeholder === '搜索群名称或 JID'); + await act(async () => groupSearch.props.onChange({ target: { value: 'Support' } })); + assert.equal(root.findAllByType('details').length, 1); + assert.match(textOf(root.findByType('details')), /Support/); + await act(async () => groupSearch.props.onChange({ target: { value: groupJid } })); + assert.match(textOf(root.findByType('details')), /Ops/); +}); + +test('failed saves retain the draft, keep it dirty and expose the error', async t => { + const { renderer } = await mount(t, { failSave: true }); + const root = renderer.root; + await act(async () => root.findAllByType('input').find(node => node.props.value === 'Ops') + .props.onChange({ target: { value: 'Unsaved title' } })); + await act(async () => { root.findByType('form').props.onSubmit({ preventDefault() {} }); await flush(); }); + assert.equal(textOf(root.findByProps({ role: 'alert' })), 'Save rejected'); + assert.ok(root.findAllByType('input').some(node => node.props.value === 'Unsaved title')); + assert.equal(button(root, '保存分级访问设置').props.disabled, false); +}); + +test('phone suggestions filter names without matching every contact through an empty digit query', async t => { + const { renderer } = await mount(t); + const input = renderer.root.findAllByType('input').find(node => node.props.value === '8618222222222'); + await act(async () => { + input.props.onFocus(); + input.props.onChange({ target: { value: 'Alice' } }); + }); + const suggestions = renderer.root.findByProps({ role: 'listbox' }); + assert.match(textOf(suggestions), /Alice/); + assert.doesNotMatch(textOf(suggestions), /Contact 0/); +}); + +test('access editing waits for status and new section labels translate into English', async t => { + let resolveLoad; + const load = new Promise(resolve => { resolveLoad = resolve; }); + setImTranslator(key => en[key] ?? key); + t.after(() => setImTranslator(null)); + const { renderer, calls } = await mount(t, { load }); + assert.equal(renderer.root.findByProps({ className: 'dim-waAccessEditor' }).props.disabled, true); + assert.doesNotMatch(textOf(renderer.root.findByProps({ role: 'tablist' })), /[\p{Script=Han}]/u); + await act(async () => { renderer.root.findByType('form').props.onSubmit({ preventDefault() {} }); await flush(); }); + assert.equal(calls.length, 1); + await act(async () => { + resolveLoad({ ok: true, value: { bots: [{ botId: 'wa-test', accessGrant: fixture(), groupSessionScope: 'user_in_chat' }] } }); + await flush(); + }); + assert.equal(renderer.root.findByProps({ className: 'dim-waAccessEditor' }).props.disabled, false); + assert.equal(textOf(renderer.root.findByProps({ className: 'dim-waAccessSaveState' })), 'All changes saved'); +}); diff --git a/test/channels/whatsapp/client-ui.test.mjs b/test/channels/whatsapp/client-ui.test.mjs index 20b1322..35f9b79 100644 --- a/test/channels/whatsapp/client-ui.test.mjs +++ b/test/channels/whatsapp/client-ui.test.mjs @@ -73,11 +73,27 @@ test('WhatsApp account card uses the unified compact channel layout', () => { assert.match(markup, /class="dim-presetSelect"/); assert.match(markup, /默认模型/); assert.match(markup, /Agent Preset/); + assert.match(markup, /会话默认设置/); + assert.match(markup, /修改后自动保存/); + assert.match(markup, /dwa-settingsGrid/); + assert.match(markup, /\/new<\/code>/); assert.match(markup, /aria-label="更多机器人设置"/); assert.doesNotMatch(markup, /仅自己模式(默认)|指定联系人模式|开放响应模式/); assert.match(markup, /role="status"[^>]*>测试消息已发送/); }); +test('a connection check shows progress even when the last snapshot was connected', () => { + const markup = renderToStaticMarkup(React.createElement(WhatsappAccountCard, { + account: { + botId: 'wa-checking', connected: true, state: 'connected', + bot: { name: 'Bot', idMasked: '1650••••0123' }, health: {}, + }, busy: 'reconnect', + })); + assert.match(markup, /正在检查连接/); + assert.doesNotMatch(markup, /运行正常/); + assert.match(markup, /data-tone="warning"/); +}); + test('WhatsApp connection check requests a test message from the existing reconnect endpoint', async () => { const source = await readFile(new URL( '../../../plugin-src/client/channels/whatsapp/index.js', diff --git a/test/client-delivery-settings.test.mjs b/test/client-delivery-settings.test.mjs index 44e5714..2d454df 100644 --- a/test/client-delivery-settings.test.mjs +++ b/test/client-delivery-settings.test.mjs @@ -258,7 +258,7 @@ test('the card gear opens a bot-scoped page in the current channel panel and ret await flush(); }); assert.ok(renderer.root.findByProps({ 'data-bot-id': 'wx_stable_bot' })); - assert.equal(renderer.root.findByProps({ id: 'dim-tab-weixin' }).props['aria-selected'], true); + assert.equal(renderer.root.findByProps({ className: 'dim-channelPickerSelect' }).props.value, 'weixin'); }); test('access settings preserve independent mode drafts and save direct and group atomically', async (t) => { @@ -287,7 +287,8 @@ test('access settings preserve independent mode drafts and save direct and group }); assert.equal(renderer.root.findAllByProps({ role: 'tab' }).length, 2); - assert.equal(renderer.root.findAllByProps({ className: 'dim-accessScene' }).length, 2); + assert.equal(renderer.root.findAllByProps({ className: 'dim-accessScene' }) + .filter(node => ['direct', 'group'].includes(node.props['data-scene'])).length, 2); assert.equal(renderer.root.findAllByProps({ className: 'dim-accessOwnerNotice' }).length, 0); assert.equal(accessHelpButtons(renderer.root).length, 2); for (const [scene, title] of [['direct', '私聊'], ['group', '群聊']]) { @@ -457,9 +458,12 @@ test('access settings preserve independent mode drafts and save direct and group }, }, }, + }, { + endpoint: 'bot.group-session-scope.set', + payload: { botId: connectedAccount.botId, groupSessionScope: 'user_in_chat' }, }]); const feedback = renderer.root.findByProps({ className: 'dim-accessFeedback' }); - assert.match(textOf(feedback), /访问设置已保存/); + assert.match(textOf(feedback), /访问与群会话设置已保存/); assert.equal(feedback.props['data-tone'], 'success'); assert.equal(feedback.props.role, 'status'); diff --git a/test/host-auth-context.test.mjs b/test/host-auth-context.test.mjs new file mode 100644 index 0000000..b4120e2 --- /dev/null +++ b/test/host-auth-context.test.mjs @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import test from 'node:test'; + +import { harnessConnection } from '../plugin-src/host/harness-connection.mjs'; +import { createHarnessCommandExecutor } from '../plugin-src/host/harness-command-executor.mjs'; +import { HarnessClient } from '../src/channels/shared/harness-client.mjs'; +import { watchHarnessMux } from '../src/channels/shared/harness-mux.mjs'; +import { classifyMessageFailure } from '../src/channels/shared/message-failure.mjs'; +import { askInWorkspaceSession } from '../src/channels/shared/workspace-session.mjs'; +import { runHarnessHostOperation } from '../plugin-src/host/harness-host-context.mjs'; +import { WhatsappHarnessClient } from '../src/channels/whatsapp/harness-client.mjs'; +import { HarnessClient as WeixinHarnessClient } from '../src/channels/weixin/harness-client.mjs'; + +function authFixture() { + const storage = new AsyncLocalStorage(); + return { storage, auth: { runAsHost: (operation) => storage.run(undefined, operation) } }; +} + +test('optional UDS lookup never reads a service property that requires Cordis inject', async () => { + const { storage, auth } = authFixture(); + let service; + let propertyReads = 0; + const ctx = { + root: {}, + get(name) { + if (name === 'apiProxy') return undefined; + assert.equal(name, 'udsAuth'); + return service; + }, + get udsAuth() { + propertyReads += 1; + throw new Error('cannot get property "udsAuth" without inject'); + }, + typertGateway: { invoke() { assert.fail('health must not call the gateway'); }, stream() {} }, + }; + // host.describe is local in the modern adapter, but must survive the auth lookup. + for (const Client of [WeixinHarnessClient, WhatsappHarnessClient]) { + const client = new Client(harnessConnection(ctx)); + assert.equal(await client.health(), true, 'Host without UDS must remain reachable'); + service = auth; + await storage.run(null, async () => { + assert.equal(await client.health(), true, 'Host with UDS must remain reachable'); + assert.equal(await runHarnessHostOperation(ctx, async () => { + await Promise.resolve(); + assert.equal(storage.getStore(), undefined); + return 'host'; + }), 'host'); + assert.equal(storage.getStore(), null); + }); + service = undefined; + } + assert.equal(propertyReads, 0); +}); + +test('modern Host session creation and model selection ignore inherited browser identities', async () => { + const { storage, auth } = authFixture(); + const calls = []; + const ctx = { + root: {}, udsAuth: auth, + typertGateway: { + async invoke({ namespace, method, args }) { + assert.equal(storage.getStore(), undefined); + await Promise.resolve(); + assert.equal(storage.getStore(), undefined); + calls.push({ namespace, method, args }); + if (method === 'create') return { sessionId: `session-${calls.length}` }; + if (method === 'selectModel') return { selected: args.request }; + return { accepted: true }; + }, + stream() { + assert.equal(storage.getStore(), undefined); + return (async function* () { + await Promise.resolve(); + assert.equal(storage.getStore(), undefined); + yield { type: 'baseline', value: { items: [{ + workspaceId: 'bot-workspace', path: process.cwd(), sessionIds: [], + }], archivedSessionIds: [] } }; + })(); + }, + }, + }; + const client = new HarnessClient({ ...harnessConnection(ctx), workspace: process.cwd() }); + const browser = { empNo: 'browser-user' }; + await Promise.all([null, browser].map((identity) => storage.run(identity, async () => { + const sessionId = await client.createSession({ agentPreset: 'netxops' }); + await client.selectSessionModel(sessionId, { provider: 'provider', model: 'model' }); + await client.rpc('session.prompt', { sessionId, content: [{ type: 'text', text: 'hello' }] }); + assert.equal(storage.getStore(), identity); + }))); + assert.equal(calls.filter(({ method }) => method === 'create').length, 2); + assert.deepEqual(calls.find(({ method }) => method === 'create').args.request, { + workspaceId: 'bot-workspace', agentPreset: 'netxops', + }); +}); + +test('legacy Host calls preserve receivers and use auth services that become ready later', async () => { + const { storage, auth } = authFixture(); + let service; + const api = { history() { + assert.equal(this, api); + assert.equal(storage.getStore(), undefined); + const { rpcId } = arguments[0]; + return { rpcId, result: { ok: true, value: { events: [] } } }; + } }; + const client = new HarnessClient({ ...harnessConnection({ + apiProxy: { sessions: api }, get: () => service, + }) }); + service = auth; + await storage.run(null, () => client.rpc('session.history', { sessionId: 'existing' })); +}); + +test('lazy IM event streams and cleanup run in Host context and restore the caller', async () => { + const { storage, auth } = authFixture(); + const controller = new AbortController(); + let closed = false; + const apiProxy = { events: { mux() { + assert.equal(storage.getStore(), undefined); + return (async function* () { + try { + await Promise.resolve(); + assert.equal(storage.getStore(), undefined); + yield { rpcId: 'event', payload: { type: 'session/event' } }; + } finally { + assert.equal(storage.getStore(), undefined); + closed = true; + } + })(); + } } }; + await storage.run(null, async () => { + await watchHarnessMux({ + apiProxy, signal: controller.signal, rpcId: 'mux', runHostOperation: auth.runAsHost, + onEnvelope() { assert.equal(storage.getStore(), null); controller.abort(); }, + }); + assert.equal(storage.getStore(), null); + }); + assert.equal(closed, true); +}); + +test('IM commands use the optional Host auth runner without affecting browser context', async () => { + const { storage, auth } = authFixture(); + const execute = createHarnessCommandExecutor({ udsAuth: auth, typertGateway: { + async invoke(request) { + await Promise.resolve(); + assert.equal(storage.getStore(), undefined); + assert.equal(request.args.agentId, 'session'); + return 'done'; + }, + } }); + await storage.run({ empNo: 'browser' }, async () => { + assert.equal(await execute('session', '/compact'), 'done'); + assert.equal(storage.getStore().empNo, 'browser'); + }); +}); + +test('UDS RemoteError details survive the modern adapter and prevent preset retry', async () => { + let creations = 0; + const ctx = { root: {}, typertGateway: { + async invoke() { + creations += 1; + const error = new Error('登录后才能创建会话'); + Object.assign(error, { name: 'RemoteError', isDSHRemoteError: true, + code: 'gateway/bad-request', details: { udsError: 'login_required_create_session' } }); + throw error; + }, + stream() { return (async function* () { + yield { type: 'baseline', value: { items: [{ + workspaceId: 'workspace', path: process.cwd(), sessionIds: [], + }], archivedSessionIds: [] } }; + })(); }, + } }; + const client = new HarnessClient({ ...harnessConnection(ctx), workspace: process.cwd() }); + await assert.rejects(client.createSession({ agentPreset: 'netxops' }), (error) => { + assert.equal(error.code, 'gateway/bad-request'); + assert.equal(error.details.udsError, 'login_required_create_session'); + assert.equal(classifyMessageFailure(error).code, 'HARNESS_LOGIN_REQUIRED'); + return true; + }); + assert.equal(creations, 1); +}); + +test('ACL rejections preserve existing chat bindings without creating replacement Sessions', async () => { + for (const stage of ['history', 'membership']) { + const error = Object.assign(new Error('denied'), { + code: 'gateway/bad-request', details: { udsError: 'session_forbidden' }, + }); + const harness = { + currentWorkspace: () => process.cwd(), + async sessionExists() { if (stage === 'history') throw error; return true; }, + async sessionBelongsToWorkspace() { throw error; }, + createSession: () => assert.fail('ACL failures must not recreate'), + }; + await assert.rejects(askInWorkspaceSession({ + harness, key: 'chat', text: 'hi', state: { + sessionFor: () => 'existing', + clearSession: () => assert.fail('ACL failures must preserve the binding'), + }, + }), (actual) => actual === error); + } +}); diff --git a/test/host-harness-connection.test.mjs b/test/host-harness-connection.test.mjs index e41d067..cc08d36 100644 --- a/test/host-harness-connection.test.mjs +++ b/test/host-harness-connection.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; import test from 'node:test'; import { harnessConnection } from '../plugin-src/host/harness-connection.mjs'; @@ -8,13 +9,16 @@ const IM_CHANNELS = [ 'weixin', 'feishu', 'dingtalk', 'wecom', 'qq', 'slack', 'telegram', 'discord', 'whatsapp', ]; +const TEST_WORKSPACE = resolve('test-workspace'); test('Host connections share the current Cordis root without depending on a webServer', () => { const root = {}; const apiProxy = {}; const first = harnessConnection({ root, apiProxy }); const second = harnessConnection({ root, apiProxy }); - assert.deepEqual(first, { apiProxy, interactionScope: root }); + assert.equal(first.apiProxy, apiProxy); + assert.equal(first.interactionScope, root); + assert.equal(first.runHostOperation(() => 'result'), 'result'); assert.equal(first.interactionScope, second.interactionScope); assert.notEqual(first.interactionScope, harnessConnection({ root: {}, apiProxy }).interactionScope); @@ -37,9 +41,9 @@ test('a Host with neither legacy apiProxy nor a modern gateway fails clearly', ( ); }); -test('Host and all IM channel plugins require only services shared by old and new Harness', async () => { +test('Host injects webServer for RPC mounts while channel Harness clients use shared gateway services', async () => { assert.equal(hostInject.includes('apiProxy'), false); - assert.equal(hostInject.includes('webServer'), false); + assert.equal(hostInject.includes('webServer'), true); assert.ok(hostInject.includes('typertGateway')); for (const channel of IM_CHANNELS) { const { inject } = await import(`../plugin-src/host/channels/${channel}/index.mjs`); @@ -71,7 +75,7 @@ async function assembledHarness(channel, ctx, config = {}) { constructor(options) { constructed.runtime = options; } } const production = await createProductionController(ctx, { - workspace: '/test/workspace', + workspace: TEST_WORKSPACE, ...config, }, { ConfigStore, @@ -98,7 +102,7 @@ async function assembledHarness(channel, ctx, config = {}) { try { if (channel === 'office') { constructed.controller.createRuntime({}); - constructed.runtime.createHarness({ workspace: '/test/workspace' }); + constructed.runtime.createHarness({ workspace: TEST_WORKSPACE }); } return constructed.harness; } finally { @@ -114,7 +118,7 @@ for (const channel of [...IM_CHANNELS, 'office']) { assert.equal(options.apiProxy, apiProxy); assert.equal(options.interactionScope, root); assert.equal(Object.hasOwn(options, 'baseUrl'), false); - assert.equal(options.workspace, '/test/workspace'); + assert.equal(options.workspace, TEST_WORKSPACE); assert.equal(options.autostart, false); }); diff --git a/test/message-failure.test.mjs b/test/message-failure.test.mjs index 45ca14d..92ccf08 100644 --- a/test/message-failure.test.mjs +++ b/test/message-failure.test.mjs @@ -108,13 +108,31 @@ test('RPC internal and UNKNOWN provider codes no longer fall through to INTERNAL assert.equal(plain.reason, 'ERROR'); }); -test('session.create failures tell operators to check the bot workspace path', () => { +test('generic session.create failures refer operators to Host logs and configuration', () => { const failure = classifyMessageFailure({ code: 'internal', method: 'session.create', }, options); assert.equal(failure.code, 'SESSION_CREATE'); assert.equal(failure.reason, 'SESSION_CREATE'); assert.match(failure.message, /工作区/); + assert.match(failure.message, /Agent Preset.*认证/); +}); + +test('login rejections are distinguished from workspace, preset and model failures', () => { + for (const error of [ + { code: 'internal', method: 'session.create', message: 'session.create: 登录后才能创建会话' }, + { code: 'internal', method: 'session.create', message: 'session.create: Sign in to create a session' }, + { code: 'gateway/bad-request', method: 'session.create', + details: { udsError: 'login_required_create_session' }, message: 'private diagnostic' }, + ]) { + const failure = classifyMessageFailure(error, options); + assert.equal(failure.code, 'HARNESS_LOGIN_REQUIRED'); + assert.match(failure.message, /认证插件/); + assert.doesNotMatch(failure.message, /工作区|private/); + } + assert.equal(classifyMessageFailure({ code: 'gateway/bad-request', + details: { udsError: 'session_forbidden' }, method: 'session.history', + }, options).code, 'HARNESS_ACCESS'); }); test('Host branded gateway/internal on session.create maps to SESSION_CREATE', () => {