feat(i18n): add English support for host-side chat messages

Wrap all user-facing chat text in src/channels with a host-side t()
translator (Chinese is the identity default, so untranslated text is
always sent verbatim in Chinese). English is selected via the host
plugin config `language: en` or the DSH_IM_LANGUAGE environment
variable, and the settings UI keeps following the DSH web locale.

- add src/channels/shared/i18n.mjs with setImHostLanguage/t() and a
  Chinese-key -> English dictionary split by area (shared + channels)
- complete the English dictionary for every t() key in src/channels,
  including the feishu channel (welcome/help, repair flow, interactive
  cards, watch flow) and the shared command/approval/stream text
- wrap previously untranslated chat-facing strings in feishu bridge,
  feishu runtime, editable-message-stream, harness-client,
  connection-test and image-prompt
- dedupe dictionary entries that existed in multiple channel files and
  canonicalize conflicting translations into the shared dictionaries
- extend the i18n unit test to decode string-literal escapes so keys
  containing \n are checked correctly
- document the language switch in README.md, README.en.md and
  CHANGELOG.md, and rebuild lib/index.js

Added English support for bot chat messages across all nine IM
channels and AI Office without changing any Chinese output.
This commit is contained in:
Zaakir 2026-08-23 22:34:11 +05:00
parent 3ff8c2a519
commit dcd7cd64d8
78 changed files with 2283 additions and 992 deletions

View file

@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { after, test } from 'node:test';
import {
getImHostLanguage,
setImHostLanguage,
t,
} from '../../../src/channels/shared/i18n.mjs';
import { EN } from '../../../src/channels/shared/i18n-en.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const srcChannelsDir = join(here, '..', '..', '..', 'src', 'channels');
after(() => {
setImHostLanguage('zh');
});
test('host language defaults to Chinese and t() is the identity function', () => {
setImHostLanguage(undefined);
assert.equal(getImHostLanguage(), 'zh');
assert.equal(t('连接成功'), '连接成功');
assert.equal(t('共 {count} 个机器人', { count: 3 }), '共 3 个机器人');
});
test('setImHostLanguage accepts English spellings and falls back to Chinese', () => {
for (const value of ['en', 'EN', 'en-US', ' english ']) {
setImHostLanguage(value);
assert.equal(getImHostLanguage(), 'en', `expected ${value} to select English`);
}
for (const value of [undefined, null, '', 'zh', 'zh-CN', 'fr', 42]) {
setImHostLanguage(value);
assert.equal(getImHostLanguage(), 'zh', `expected ${value} to select Chinese`);
}
});
test('t() translates known keys and fills placeholders in English mode', () => {
setImHostLanguage('en');
const [key] = Object.keys(EN);
if (key) {
assert.equal(t(key), EN[key]);
}
assert.equal(t('未收录的中文'), '未收录的中文');
assert.equal(t('{value} 测试', { value: 'x' }), EN['{value} 测试'] ?? 'x 测试');
assert.equal(t(42), 42);
});
test('every English dictionary entry is a non-empty translation of a Chinese key', () => {
for (const [key, value] of Object.entries(EN)) {
assert.ok(/[一-鿿]/.test(key), `dictionary key is not Chinese: ${key}`);
assert.ok(typeof value === 'string' && value.trim().length > 0, `empty translation for: ${key}`);
}
});
test('every literal t() key in src/channels has an English dictionary entry', () => {
const files = [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
if (statSync(path).isDirectory()) {
if (entry !== 'i18n-en') walk(path);
} else if (entry.endsWith('.mjs') && entry !== 'i18n.mjs' && entry !== 'i18n-en.mjs') {
files.push(path);
}
}
};
walk(srcChannelsDir);
const keyPattern = /\bt\(\s*(['`])((?:[^\\`'"$]|\$(?!\{)|\\.)*)\1/g;
// Decode the escapes a real string literal would apply, so keys written as
// `...\n...` in source compare against the runtime dictionary key (newline).
const decodeLiteral = (raw) => {
let out = '';
for (let i = 0; i < raw.length; i += 1) {
if (raw[i] !== '\\' || i + 1 >= raw.length) {
out += raw[i];
continue;
}
const next = raw[i + 1];
if (next === 'n') out += '\n';
else if (next === 't') out += '\t';
else if (next === 'r') out += '\r';
else out += next; // \', \", \\, \` and anything else keep the char
i += 1;
}
return out;
};
const missing = [];
for (const file of files) {
const source = readFileSync(file, 'utf8');
for (const match of source.matchAll(keyPattern)) {
const key = decodeLiteral(match[2]);
if (!/[一-鿿]/.test(key)) continue;
if (!Object.hasOwn(EN, key)) {
missing.push(`${file}: ${match[2]}`);
}
}
}
assert.deepEqual(missing, [], 't() keys missing from the English dictionary');
});