From 45f430fb287f87cc4ba5b77c5386549621591b82 Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 14 Sep 2026 22:38:16 +0800 Subject: [PATCH] Sync bot chat language with DSH system locale. Localize WhatsApp access-grant approvals via t() and push Host language from the UI on load and locale change. Co-authored-by: Cursor --- CHANGELOG.md | 5 ++ README.en.md | 8 ++-- README.md | 8 ++-- plugin-src/client/index.js | 19 ++++++++ plugin-src/host/update-rpc.mjs | 20 +++++++- src/channels/shared/access-grant.mjs | 39 +++++++++++----- src/channels/shared/i18n-en/whatsapp.mjs | 29 ++++++++++++ .../whatsapp/whatsapp-access-gate.mjs | 15 +++--- test/channels/shared/access-grant.test.mjs | 25 ++++++++++ test/update-rpc.test.mjs | 46 ++++++++++++++++--- 10 files changed, 181 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bbe35c..13a38cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows ## [Unreleased] +### Changed / 变更 + +- 机器人聊天文案(含 WhatsApp 访问审批)跟随 DeepSeek Harness 系统语言:打开 UI 或切换语言时通过 `host.language.set` 同步到 Host;无界面时仍可用 `language` / `DSH_IM_LANGUAGE` 作为启动默认值。 + Bot chat copy (including WhatsApp access-grant approvals) now follows the DeepSeek Harness system language: opening the UI or switching language syncs the Host via `host.language.set`. Headless runs can still bootstrap with `language` / `DSH_IM_LANGUAGE`. + ## [4.9.1] - 2026-09-04 ### Fixed / 修复 diff --git a/README.en.md b/README.en.md index 23f2885..8299431 100644 --- a/README.en.md +++ b/README.en.md @@ -197,7 +197,7 @@ See the [Proactive Delivery Guide](PROACTIVE_DELIVERY.en.md) ([简体中文](PRO - Registers one top-level **IM Bot** settings page containing nine IM channels and one AI Office Connector. - Maintains the Host, client, and runtime sources for all nine channels and the Office Connector in this repository without external standalone plugins. -- Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English. Bot chat messages follow the Host's `language` config (Chinese by default; `en` switches them to English), with Chinese always as the fallback so untranslated text is sent verbatim. +- Follows the DeepSeek Harness language preference and switches the settings UI live between Chinese and English. Bot chat messages follow the same Harness system language (synced when the settings UI loads or the language changes), with Chinese always as the fallback so untranslated text is sent verbatim. - 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, redacted status data, and access modes or allowlist identifiers explicitly saved for the current Telegram or WhatsApp 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. @@ -224,7 +224,9 @@ IM management RPCs accept loopback browsers by default. When a Web profile is de ### Bot chat message language -Bot chat messages are in Chinese by default. To switch them to English, set `language: en` in the plugin config (also accepts `en-US` or `english`), or set the `DSH_IM_LANGUAGE=en` environment variable: +Bot chat messages follow the **DeepSeek Harness system language** (Settings → Language): Chinese UI → Chinese bot copy, English UI → English bot copy. Opening the settings UI or switching language syncs the Host automatically. + +Before any UI connects (or in headless runs), you can still set a bootstrap default via plugin config or env: ```yaml - id: xmanrui-dsh-im @@ -232,7 +234,7 @@ Bot chat messages are in Chinese by default. To switch them to English, set `lan language: en ``` -Without a setting, Chinese is used. Chinese is always the fallback language — any text missing from the English dictionary is sent verbatim in Chinese, so this feature never changes the behavior of existing Chinese users. +or `DSH_IM_LANGUAGE=en`. After the UI syncs, the active Harness language wins. Chinese is always the dictionary fallback — any text missing from the English dictionary is sent verbatim in Chinese. --- diff --git a/README.md b/README.md index f65a535..52028d2 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ dsh web - Harness 一级设置菜单中只注册一个「IM机器人」设置页,其中包含九个 IM 渠道和一个 AI Office Connector; - 九个渠道及 Office Connector 的 Host、客户端与运行时源码都在本仓库维护,不依赖外部独立插件; -- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;机器人发出的聊天消息跟随 Host 的 `language` 配置(默认中文;设为 `en` 即为英文),中文始终为兜底,未收录的文案原样输出; +- 设置页跟随 DeepSeek Harness 的语言选择,在中文和 English 之间即时切换;机器人发出的聊天消息同样跟随 Harness 系统语言(打开设置页或切换语言时同步到 Host),中文始终为兜底,未收录的文案原样输出; - 左侧使用 Logo 切换微信、飞书、钉钉、企业微信、QQ、Slack、Telegram、Discord、WhatsApp 和 AI Office,不使用启用/停用开关; - 九个 IM 渠道保持独立的 RPC、凭据、连接监督和会话映射;Office Connector 另行维护设备凭据、Job 租约、审批等待与并发上限; - 浏览器只获得二维码、Manifest、脱敏状态,以及用户为当前 Telegram 或 WhatsApp 机器人主动保存的访问模式和白名单标识;手动输入的 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,或从平台消息中观察到的其他原始用户标识。 @@ -227,7 +227,9 @@ IM 管理 RPC 默认仅接受回环浏览器。如果 Web profile 在受信任 ### 聊天消息语言 -机器人发出的聊天消息默认使用中文。要切换为英文,在插件配置中设置 `language: en`(也接受 `en-US`、`english`),或设置环境变量 `DSH_IM_LANGUAGE=en`: +机器人发出的聊天消息默认跟随 **DeepSeek Harness 的系统语言**(设置 → 语言):界面为中文时发中文,为 English 时发英文。打开设置页或切换语言时会自动同步到 Host。 + +在尚未打开过 UI、或纯无界面运行时,仍可用插件配置 / 环境变量作为启动默认值: ```yaml - id: xmanrui-dsh-im @@ -235,7 +237,7 @@ IM 管理 RPC 默认仅接受回环浏览器。如果 Web profile 在受信任 language: en ``` -未设置时保持中文;中文始终是兜底语言,任何未收录到英文词典的文案都会原样以中文输出,因此该功能不会改变现有中文用户的行为。 +或 `DSH_IM_LANGUAGE=en`。UI 连上并同步后,以 Harness 当前语言为准。中文始终是词典兜底:未收录的英文文案会原样以中文输出。 --- diff --git a/plugin-src/client/index.js b/plugin-src/client/index.js index 70de31a..498e929 100644 --- a/plugin-src/client/index.js +++ b/plugin-src/client/index.js @@ -64,6 +64,18 @@ export const name = 'im-settings'; export const inject = ['slots', 'connection', 'locale', 'workspaces']; export const IM_PLUGIN_VERSION = manifest.version; +/** Push the active DSH UI locale to the Host so bot chat copy stays aligned. */ +function syncHostChatLanguage(ctx, language) { + if (typeof language !== 'string' || !language.trim()) return; + void ctx.connection.rpc.call( + UPDATE_RPC_CHANNEL, + 'host.language.set', + { language }, + ).catch(() => { + // Host may be offline or on an older build without the endpoint. + }); +} + function callWorkspaceDirectoryApi(ctx, method, ...args) { // Current DSH owns directory operations on uiWorkspace; legacy Hosts keep them on workspaces. const uiWorkspace = typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : undefined; @@ -332,6 +344,13 @@ export function apply(ctx) { const t = ctx.locale.bind(IM_LOCALE_NAMESPACE); setImTranslator(t); + ctx.effect(() => { + syncHostChatLanguage(ctx, ctx.locale.getLocale().active); + return ctx.on('locale/change', (snapshot) => { + syncHostChatLanguage(ctx, snapshot?.active); + }); + }, 'im-settings: sync host chat language with DSH locale'); + ctx.effect(() => { const disposers = [ installFeishuStyles(), diff --git a/plugin-src/host/update-rpc.mjs b/plugin-src/host/update-rpc.mjs index 949f915..59f7ff0 100644 --- a/plugin-src/host/update-rpc.mjs +++ b/plugin-src/host/update-rpc.mjs @@ -1,3 +1,4 @@ +import { getImHostLanguage, setImHostLanguage } from '../../src/channels/shared/i18n.mjs'; import { createUpdateRuntime } from './update-runtime.mjs'; import { createUpdateService } from './update-service.mjs'; import { installConnectionRpcChannel } from './connection-rpc-mount.mjs'; @@ -5,8 +6,20 @@ import { resolveRpcAuthority } from './rpc-authority.mjs'; export const UPDATE_RPC_CHANNEL = '/dsh-im'; export const UPDATE_ENDPOINTS = Object.freeze(['update.status', 'update.check', 'update.install']); +export const HOST_LANGUAGE_SET_ENDPOINT = 'host.language.set'; + +function validLanguagePayload(payload) { + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false; + const keys = Object.keys(payload); + return keys.length === 1 + && keys[0] === 'language' + && typeof payload.language === 'string' + && payload.language.trim().length > 0 + && payload.language.length <= 32; +} function validPayload(endpoint, payload) { + if (endpoint === HOST_LANGUAGE_SET_ENDPOINT) return validLanguagePayload(payload); if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false; const keys = Object.keys(payload); if (endpoint !== 'update.install') return keys.length === 0; @@ -25,9 +38,14 @@ const PUBLIC_ERRORS = new Set([ export function createUpdateRpcHandler(service) { return async (endpoint, payload, signal) => { - if (!UPDATE_ENDPOINTS.includes(endpoint) || !validPayload(endpoint, payload)) { + if (endpoint !== HOST_LANGUAGE_SET_ENDPOINT && !UPDATE_ENDPOINTS.includes(endpoint) + || !validPayload(endpoint, payload)) { return { ok: false, error: { code: 'bad-request', message: 'Invalid update request.' } }; } + if (endpoint === HOST_LANGUAGE_SET_ENDPOINT) { + setImHostLanguage(payload.language); + return { ok: true, value: { language: getImHostLanguage() } }; + } if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'Request cancelled.' } }; try { // The submitted install belongs to the Host, not the lifetime of this browser request. diff --git a/src/channels/shared/access-grant.mjs b/src/channels/shared/access-grant.mjs index 272d666..e1ad5c6 100644 --- a/src/channels/shared/access-grant.mjs +++ b/src/channels/shared/access-grant.mjs @@ -744,7 +744,10 @@ export function parsePendingIdFromNotifyText(text) { return match?.[1] ?? null; } -/** Notify / ack copy (zh). */ +/** + * Notify / ack copy keys (Chinese source literals for host `t()`). + * Callers must pass through `t()` after `setImHostLanguage` — do not send raw. + */ export const ACCESS_GRANT_COPY = Object.freeze({ pendingAckDirect: '已提交私聊访问申请,请等待全局管理员审批。', pendingAckGroup: '已提交本群访问申请,请等待管理员审批。', @@ -755,26 +758,38 @@ export const ACCESS_GRANT_COPY = Object.freeze({ denied: '访问申请未通过。', adminApproved: '已批准该访问申请。', adminDenied: '已拒绝该访问申请。', + processFailed: '无法处理该审批。', }); +/** Identity translator that still fills `{name}` placeholders (zh default). */ +function fillPlaceholders(text, params) { + if (typeof text !== 'string' || params == null) return text; + return text.replace(/\{(\w+)\}/g, (match, name) => + Object.hasOwn(params, name) ? String(params[name]) : match); +} + /** * @param {object} pending + * @param {(text: string, params?: Record) => string} [translate] * @returns {string} */ -export function formatPendingNotifyBody(pending) { +export function formatPendingNotifyBody(pending, translate = fillPlaceholders) { const scene = pending.kind === 'group' - ? `群聊 ${pending.groupJid ?? ''}` - : '私聊'; + ? translate('群聊 {jid}', { jid: pending.groupJid ?? '' }) + : translate('私聊'); + const unknown = translate('未知'); const who = pending.pushName - ? `${pending.pushName} (${pending.phone || pending.lid || '未知'})` - : (pending.phone || pending.lid || '未知'); - const text = pending.requestText ? `\n原文:${pending.requestText.slice(0, 200)}` : ''; + ? `${pending.pushName} (${pending.phone || pending.lid || unknown})` + : (pending.phone || pending.lid || unknown); + const text = pending.requestText + ? `\n${translate('原文:{text}', { text: pending.requestText.slice(0, 200) })}` + : ''; return [ - ACCESS_GRANT_COPY.notifyTitle, - `场景:${scene}`, - `申请人:${who}`, - `请求编号:${pending.id}`, - '请引用本条消息回复「同意」或「拒绝」。', + translate(ACCESS_GRANT_COPY.notifyTitle), + translate('场景:{scene}', { scene }), + translate('申请人:{who}', { who }), + translate('请求编号:{id}', { id: pending.id }), + translate('请引用本条消息回复「同意」或「拒绝」。'), text, ].filter(Boolean).join('\n'); } diff --git a/src/channels/shared/i18n-en/whatsapp.mjs b/src/channels/shared/i18n-en/whatsapp.mjs index cfb464c..2eeea53 100644 --- a/src/channels/shared/i18n-en/whatsapp.mjs +++ b/src/channels/shared/i18n-en/whatsapp.mjs @@ -14,4 +14,33 @@ export default { 'WhatsApp账号': 'WhatsApp account', ' Web 关联设备': ' Web linked device', + + // Access-grant / whitelist approval (WhatsApp DM to admins & requesters) + '已提交私聊访问申请,请等待全局管理员审批。': + 'Your DM access request was submitted. Please wait for a global admin to approve.', + '已提交本群访问申请,请等待管理员审批。': + 'Your group access request was submitted. Please wait for an admin to approve.', + '已记录访问申请,但尚未解析到电话号码;请管理员在设置中补全后再批准。': + 'Access request recorded, but the phone number is unresolved; an admin must complete it in settings before approving.', + '[dsh-im-ops] 访问申请': '[dsh-im-ops] Access request', + '私聊访问已批准,现在可以直接对话。': 'DM access approved. You can chat directly now.', + '本群访问已批准,请在本群 @ 机器人继续。': 'Group access approved. @ the bot in this group to continue.', + '访问申请未通过。': 'Access request denied.', + '已批准该访问申请。': 'Access request approved.', + '已拒绝该访问申请。': 'Access request rejected.', + '无法处理该审批。': 'Unable to process this approval.', + '找不到待批申请。': 'No pending request found.', + '该申请已失效。': 'This request is no longer valid.', + '无权审批该申请。': 'You are not allowed to approve this request.', + '审批动作无效。': 'Invalid approval action.', + '申请人电话尚未解析,请在设置中补全后再批准。': + 'Requester phone is unresolved; complete it in settings before approving.', + '私聊': 'Direct chat', + '群聊 {jid}': 'Group {jid}', + '场景:{scene}': 'Scene: {scene}', + '申请人:{who}': 'Requester: {who}', + '请求编号:{id}': 'Request: {id}', + '原文:{text}': 'Original message: {text}', + '请引用本条消息回复「同意」或「拒绝」。': + 'Quote this message and reply with 同意 (approve) or 拒绝 (reject). Also: yes / no.', }; diff --git a/src/channels/whatsapp/whatsapp-access-gate.mjs b/src/channels/whatsapp/whatsapp-access-gate.mjs index 81cf5d0..b5af201 100644 --- a/src/channels/whatsapp/whatsapp-access-gate.mjs +++ b/src/channels/whatsapp/whatsapp-access-gate.mjs @@ -22,6 +22,7 @@ import { upsertAccessContact, } from '../shared/access-grant.mjs'; import { isSharedLocalCommand } from '../shared/command-permission.mjs'; +import { t } from '../shared/i18n.mjs'; import { isWhatsappLidJid } from './whatsapp-identity.mjs'; /** @@ -178,17 +179,17 @@ export async function tryHandleWhatsappApprovalReply({ } await sendText( { jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` }, - intent === 'approve' ? ACCESS_GRANT_COPY.adminApproved : ACCESS_GRANT_COPY.adminDenied, + t(intent === 'approve' ? ACCESS_GRANT_COPY.adminApproved : ACCESS_GRANT_COPY.adminDenied), ); const requesterJid = resolved.phone ? `${resolved.phone}@s.whatsapp.net` : (resolved.lid ?? null); if (requesterJid) { - const body = intent === 'approve' + const body = t(intent === 'approve' ? (resolved.kind === 'group' ? ACCESS_GRANT_COPY.approvedGroup : ACCESS_GRANT_COPY.approvedDirect) - : ACCESS_GRANT_COPY.denied; + : ACCESS_GRANT_COPY.denied); try { await sendText({ jid: requesterJid }, body); } catch { @@ -201,7 +202,7 @@ export async function tryHandleWhatsappApprovalReply({ || error?.code === 'pending-unresolved' || error?.code === 'pending-not-found') { await sendText( { jid: message.replyTarget?.jid ?? `${phone}@s.whatsapp.net` }, - error.message || '无法处理该审批。', + t(error.message || ACCESS_GRANT_COPY.processFailed), ); return true; } @@ -322,11 +323,11 @@ export async function gateWhatsappInbound({ await workspaces.setAccessGrant(botId, current); } - const ack = !phone + const ack = t(!phone ? ACCESS_GRANT_COPY.pendingUnresolved : (scene === 'group' ? ACCESS_GRANT_COPY.pendingAckGroup - : ACCESS_GRANT_COPY.pendingAckDirect); + : ACCESS_GRANT_COPY.pendingAckDirect)); try { await sendText(message.replyTarget, ack); } catch { @@ -335,7 +336,7 @@ export async function gateWhatsappInbound({ if (created) { const admins = approverPhonesForPending(current, pending); - const body = formatPendingNotifyBody(pending); + const body = formatPendingNotifyBody(pending, t); const refs = []; for (const adminPhone of admins) { // Never DM the linked bot account as if it were a peer when it is the only self-chat path. diff --git a/test/channels/shared/access-grant.test.mjs b/test/channels/shared/access-grant.test.mjs index ebba91a..ab52642 100644 --- a/test/channels/shared/access-grant.test.mjs +++ b/test/channels/shared/access-grant.test.mjs @@ -191,7 +191,32 @@ test('quote approval helpers parse intent, pending id, and notify refs', () => { pushName: 'Carol', requestText: 'hi', }); + assert.match(body, /请求编号:p_abc123/); assert.equal(parsePendingIdFromNotifyText(body), 'p_abc123'); + const enBody = formatPendingNotifyBody({ + id: 'p_abc123', + kind: 'direct', + phone: '8618888888888', + pushName: 'Carol', + requestText: 'hi', + }, (text, params) => { + const map = { + '[dsh-im-ops] 访问申请': '[dsh-im-ops] Access request', + '私聊': 'Direct chat', + '场景:{scene}': 'Scene: {scene}', + '申请人:{who}': 'Requester: {who}', + '请求编号:{id}': 'Request: {id}', + '原文:{text}': 'Original message: {text}', + '请引用本条消息回复「同意」或「拒绝」。': 'Quote this message…', + '未知': 'unknown', + }; + const translated = map[text] ?? text; + if (params == null) return translated; + return translated.replace(/\{(\w+)\}/g, (match, name) => + Object.hasOwn(params, name) ? String(params[name]) : match); + }); + assert.match(enBody, /Request: p_abc123/); + assert.equal(parsePendingIdFromNotifyText(enBody), 'p_abc123'); const withRef = { ...grant(), pending: [{ diff --git a/test/update-rpc.test.mjs b/test/update-rpc.test.mjs index f18ff0a..36ee4bc 100644 --- a/test/update-rpc.test.mjs +++ b/test/update-rpc.test.mjs @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createUpdateRpcHandler, installUpdateRpc } from '../plugin-src/host/update-rpc.mjs'; +import { createUpdateRpcHandler, installUpdateRpc, HOST_LANGUAGE_SET_ENDPOINT } from '../plugin-src/host/update-rpc.mjs'; import { createImHostPlugin } from '../plugin-src/host/index.mjs'; +import { getImHostLanguage, setImHostLanguage } from '../src/channels/shared/i18n.mjs'; test('update RPC rejects arbitrary commands, paths, profiles, sources and invalid request identifiers', async () => { const calls = []; @@ -13,22 +14,53 @@ test('update RPC rejects arbitrary commands, paths, profiles, sources and invali ['update.install', { checkId: 'id', requestId: 'request', command: 'anything' }], ['update.install', { checkId: 'id', requestId: '; rm -rf /' }], ['update.install', { checkId: 'id', requestId: 'request', version: '9.0.0' }], + [HOST_LANGUAGE_SET_ENDPOINT, {}], + [HOST_LANGUAGE_SET_ENDPOINT, { language: 'en', extra: true }], + [HOST_LANGUAGE_SET_ENDPOINT, { language: '' }], ]) { assert.equal((await handle(endpoint, payload)).error.code, 'bad-request'); } assert.deepEqual(calls, []); }); -test('update RPC keeps local authority even when other management uses trusted-host', () => { - const calls = []; +test('host.language.set follows DSH locale ids and returns the normalized host language', async () => { + const handle = createUpdateRpcHandler({}); + setImHostLanguage('zh'); + try { + assert.deepEqual(await handle(HOST_LANGUAGE_SET_ENDPOINT, { language: 'en' }), { + ok: true, value: { language: 'en' }, + }); + assert.equal(getImHostLanguage(), 'en'); + assert.deepEqual(await handle(HOST_LANGUAGE_SET_ENDPOINT, { language: 'zh-CN' }), { + ok: true, value: { language: 'zh' }, + }); + assert.equal(getImHostLanguage(), 'zh'); + } finally { + setImHostLanguage('zh'); + } +}); + +test('update RPC mounts on /dsh-im through webServer', () => { + const routes = []; const service = { close: async () => {} }; const ctx = { - connection: { rpc: { handle: (...args) => calls.push(args) } }, - effect: () => {}, + connection: { + requestRejection: () => undefined, + }, + webServer: { + register: (route) => { + routes.push(route); + return () => {}; + }, + }, + effect: (factory) => { + const dispose = factory(); + return typeof dispose === 'function' ? dispose : () => {}; + }, }; installUpdateRpc(ctx, { service, runtime: {} }); - assert.equal(calls[0][0], '/dsh-im'); - assert.deepEqual(calls[0][2], { authority: 'loopback' }); + assert.equal(routes[0]?.path, '/dsh-im'); + assert.equal(routes[0]?.kind, 'prefix'); }); test('update RPC returns only safe codes for unanticipated runtime errors', async () => {