@xmanrui/dsh-im 4.19.1 → 4.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +18 -4
- package/README.md +18 -4
- package/lib/client.js +73 -1
- package/lib/index.js +261 -260
- package/package.json +5 -1
- package/plugin-src/client/index.js +15 -0
- package/plugin-src/client/interface-language.js +89 -0
- package/plugin-src/host/channels/shared/startup.mjs +23 -0
- package/plugin-src/host/host-language-rpc.mjs +71 -0
- package/plugin-src/host/host-language.mjs +157 -0
- package/plugin-src/host/index.mjs +15 -2
- package/scripts/verify-interface-language.mjs +333 -0
- package/src/channels/discord/discord-runtime.mjs +1 -1
- package/src/channels/shared/i18n-en/discord.mjs +2 -0
- package/src/channels/shared/i18n-en/telegram.mjs +5 -0
- package/src/channels/shared/i18n.mjs +46 -3
- package/src/channels/shared/interface-language-store.mjs +127 -0
- package/src/channels/shared/interface-language.mjs +51 -0
- package/src/channels/shared/token-bot-controller.mjs +26 -0
- package/src/channels/telegram/telegram-runtime.mjs +75 -13
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run the built plugin through an original DSH CLI and prove that bot messages
|
|
3
|
+
* follow the DSH interface language (issue #185).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors scripts/verify-lan-management.mjs: an isolated, empty home, the
|
|
6
|
+
* unmodified CLI, and real HTTP against the public /api carrier. Nothing is
|
|
7
|
+
* mocked — the assertions are on what the Host resolves from DSH's own
|
|
8
|
+
* user-settings document.
|
|
9
|
+
*
|
|
10
|
+
* Set DSH_IM_TELEGRAM_TOKEN to additionally bind a real bot and assert the
|
|
11
|
+
* command menu Telegram itself stores. That step is skipped without a token,
|
|
12
|
+
* and it restores the bot's original menu when it finishes.
|
|
13
|
+
*/
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { request as httpRequest } from 'node:http';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { dirname, join, resolve } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const harnessRoot = process.argv[2];
|
|
22
|
+
if (!harnessRoot || process.argv.includes('--help')) {
|
|
23
|
+
console.log('Usage: node scripts/verify-interface-language.mjs /path/to/built/deepseek-harness');
|
|
24
|
+
console.log(' DSH_IM_TELEGRAM_TOKEN=<token> node scripts/verify-interface-language.mjs ... (adds the live menu check)');
|
|
25
|
+
process.exit(harnessRoot ? 0 : 1);
|
|
26
|
+
}
|
|
27
|
+
const pluginRoot = resolve(import.meta.dirname, '..');
|
|
28
|
+
await access(join(pluginRoot, 'lib/index.js'));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Accept either a built Harness checkout or an installed @deepseek-ai/dsh
|
|
32
|
+
* package, so this runs against a release as well as a working tree.
|
|
33
|
+
*/
|
|
34
|
+
async function resolveHarness(root) {
|
|
35
|
+
const layouts = [
|
|
36
|
+
{ kind: 'checkout', cli: 'apps/cli/lib/bin.js', base: 'packages/bundle/base', webApp: 'packages/bundle/web-app' },
|
|
37
|
+
{ kind: 'package', cli: 'lib/bin.js', base: 'node_modules/@deepseek-ai/dsh-base', webApp: 'node_modules/@deepseek-ai/dsh-web-app' },
|
|
38
|
+
];
|
|
39
|
+
for (const layout of layouts) {
|
|
40
|
+
const paths = {
|
|
41
|
+
kind: layout.kind,
|
|
42
|
+
cli: resolve(root, layout.cli),
|
|
43
|
+
base: resolve(root, layout.base),
|
|
44
|
+
webApp: resolve(root, layout.webApp),
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
await Promise.all([access(paths.cli), access(paths.base), access(paths.webApp)]);
|
|
48
|
+
return paths;
|
|
49
|
+
} catch {
|
|
50
|
+
// Try the next known layout.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`no DSH CLI found under ${root}: expected apps/cli/lib/bin.js or lib/bin.js`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const harness = await resolveHarness(harnessRoot);
|
|
57
|
+
const cli = harness.cli;
|
|
58
|
+
const botToken = process.env.DSH_IM_TELEGRAM_TOKEN;
|
|
59
|
+
let telegramMenu;
|
|
60
|
+
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-language-test-'));
|
|
61
|
+
const home = join(directory, 'home');
|
|
62
|
+
const profile = join(home, 'profiles/web');
|
|
63
|
+
const settingsPath = join(home, 'settings.yaml');
|
|
64
|
+
const mirrorPath = join(home, 'integrations/dsh-im/interface-language.json');
|
|
65
|
+
const results = [];
|
|
66
|
+
let child;
|
|
67
|
+
|
|
68
|
+
function request(url, { method = 'GET', headers = {}, body } = {}) {
|
|
69
|
+
return new Promise((resolveRequest, reject) => {
|
|
70
|
+
const req = httpRequest({
|
|
71
|
+
hostname: '127.0.0.1', port: url.port, path: `${url.pathname}${url.search}`,
|
|
72
|
+
method, headers: { host: url.host, ...headers },
|
|
73
|
+
}, res => {
|
|
74
|
+
let text = '';
|
|
75
|
+
res.setEncoding('utf8');
|
|
76
|
+
res.on('data', chunk => { text += chunk; });
|
|
77
|
+
res.on('error', reject);
|
|
78
|
+
res.on('end', () => resolveRequest({ status: res.statusCode, headers: res.headers, body: text }));
|
|
79
|
+
});
|
|
80
|
+
req.on('error', reject);
|
|
81
|
+
req.setTimeout(15_000, () => req.destroy(new Error('HTTP request timed out')));
|
|
82
|
+
req.end(body);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function start() {
|
|
87
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => (
|
|
88
|
+
!/^DSH_/i.test(key) && !/(?:KEY|SECRET|TOKEN|PASSWORD|PROXY)/i.test(key)
|
|
89
|
+
)));
|
|
90
|
+
child = spawn(process.execPath, [cli, 'web', '--no-open', '--host', '127.0.0.1', '--port', '0'], {
|
|
91
|
+
cwd: directory,
|
|
92
|
+
env: { ...env, DSH_HOME: home, DSH_AGENTS_HOME: join(directory, '.agents'),
|
|
93
|
+
DSH_TELEMETRY_DISABLED: '1', SSH_CONNECTION: '', SSH_TTY: '' },
|
|
94
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
95
|
+
});
|
|
96
|
+
return new Promise((resolveStart, reject) => {
|
|
97
|
+
let output = '';
|
|
98
|
+
const timer = setTimeout(() => finish(new Error('DSH startup timed out')), 60_000);
|
|
99
|
+
let settled = false;
|
|
100
|
+
function finish(error, url) {
|
|
101
|
+
if (settled) return;
|
|
102
|
+
settled = true;
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
if (error) reject(new Error(`${error.message}\n${output.replace(/([?&]token=)[^\s)]+/g, '$1<redacted>')}`));
|
|
105
|
+
else resolveStart(new URL(url));
|
|
106
|
+
}
|
|
107
|
+
function append(chunk) {
|
|
108
|
+
output = `${output}${chunk}`.slice(-100_000);
|
|
109
|
+
const match = /dsh web: (http:\/\/[^\s]+)/.exec(output);
|
|
110
|
+
if (match) finish(null, match[1]);
|
|
111
|
+
}
|
|
112
|
+
child.stdout.on('data', append);
|
|
113
|
+
child.stderr.on('data', append);
|
|
114
|
+
child.once('error', error => finish(error));
|
|
115
|
+
child.once('exit', code => finish(new Error(`DSH exited before readiness: ${code}`)));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function stop() {
|
|
120
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
121
|
+
const stopped = new Promise(resolveStop => child.once('exit', resolveStop));
|
|
122
|
+
child.kill('SIGTERM');
|
|
123
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 10_000);
|
|
124
|
+
try { await stopped; } finally { clearTimeout(timer); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function login(launchUrl) {
|
|
128
|
+
const response = await request(launchUrl);
|
|
129
|
+
assert.equal(response.status, 303, 'Harness must exchange its launch token for a browser cookie');
|
|
130
|
+
const cookie = response.headers['set-cookie']?.map(value => value.split(';', 1)[0]).join('; ');
|
|
131
|
+
assert.ok(cookie, 'Harness did not issue a browser cookie');
|
|
132
|
+
const browser = { origin: launchUrl.origin, cookie };
|
|
133
|
+
// HTTP readiness precedes asynchronous plugin activation, notably after
|
|
134
|
+
// restart. Wait only for the route to appear; other failures stay fatal.
|
|
135
|
+
await waitFor('the plugin language route becomes ready after HTTP startup', async () => {
|
|
136
|
+
try {
|
|
137
|
+
const result = await rpc(browser, 'dsh-im-language', 'settings.language.get');
|
|
138
|
+
return result.ok === true;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error.code === 'ERR_ASSERTION' && error.actual === 404) return false;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return browser;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function rpc(browser, channel, method, payload = {}) {
|
|
148
|
+
const response = await request(new URL(`/api/dsh-im/${channel}`, browser.origin), {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { 'content-type': 'application/json', origin: browser.origin, cookie: browser.cookie },
|
|
151
|
+
body: JSON.stringify({ type: 'client-request', rpcId: 'language-test',
|
|
152
|
+
method: `dsh-im/${channel}`, payload: { method, payload } }),
|
|
153
|
+
});
|
|
154
|
+
assert.equal(response.status, 200, `${channel}/${method} -> HTTP ${response.status}: ${response.body.slice(0, 300)}`);
|
|
155
|
+
return JSON.parse(response.body).result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Rewrite only the `locale` section, leaving the rest of the document alone. */
|
|
159
|
+
async function selectInterfaceLanguage(value) {
|
|
160
|
+
let raw = '';
|
|
161
|
+
try { raw = await readFile(settingsPath, 'utf8'); } catch { raw = ''; }
|
|
162
|
+
const withoutLocale = raw.replace(/(^|\n)locale:\n(?:[ \t]+.*\n?)*/g, '$1').trimEnd();
|
|
163
|
+
await writeFile(settingsPath, value === null
|
|
164
|
+
? `${withoutLocale}\n`
|
|
165
|
+
: `${withoutLocale}\nlocale:\n preference: ${value}\n`, 'utf8');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function waitFor(describe, predicate, { attempts = 60, delayMs = 250 } = {}) {
|
|
169
|
+
let last;
|
|
170
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
171
|
+
last = await predicate();
|
|
172
|
+
if (last) return last;
|
|
173
|
+
await new Promise(sleep => setTimeout(sleep, delayMs));
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`${describe} never held (last: ${JSON.stringify(last)?.slice(0, 300)})`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function record(check, detail) {
|
|
179
|
+
results.push({ check, detail: detail ?? '', result: 'PASS' });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const HAN = /[\p{Script=Han}]/u;
|
|
183
|
+
|
|
184
|
+
async function telegram(method, payload) {
|
|
185
|
+
const response = await fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { 'content-type': 'application/json' },
|
|
188
|
+
body: JSON.stringify(payload ?? {}),
|
|
189
|
+
});
|
|
190
|
+
const parsed = await response.json();
|
|
191
|
+
assert.equal(parsed.ok, true, `telegram ${method} failed`);
|
|
192
|
+
return parsed.result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
await mkdir(profile, { recursive: true });
|
|
197
|
+
const packages = {
|
|
198
|
+
'@deepseek-ai/dsh-base': harness.base,
|
|
199
|
+
'@deepseek-ai/dsh-web-app': harness.webApp,
|
|
200
|
+
'@xmanrui/dsh-im': pluginRoot,
|
|
201
|
+
};
|
|
202
|
+
for (const [name, path] of Object.entries(packages)) {
|
|
203
|
+
const target = join(profile, 'node_modules', name);
|
|
204
|
+
await mkdir(dirname(target), { recursive: true });
|
|
205
|
+
await symlink(path, target, 'dir');
|
|
206
|
+
}
|
|
207
|
+
await writeFile(join(profile, 'cordis.yml'), '[]\n');
|
|
208
|
+
await writeFile(join(profile, 'package.json'), JSON.stringify({
|
|
209
|
+
name: 'dsh-im-language-test', private: true,
|
|
210
|
+
dependencies: Object.fromEntries(Object.entries(packages).map(([name, path]) => [name, `link:${path}`])),
|
|
211
|
+
dsh: { profile: { bundles: Object.keys(packages) } },
|
|
212
|
+
}));
|
|
213
|
+
|
|
214
|
+
// A Chinese interface selection, exactly as DSH's Language row stores it.
|
|
215
|
+
await selectInterfaceLanguage('zh');
|
|
216
|
+
let browser = await login(await start());
|
|
217
|
+
const language = (method, payload) => rpc(browser, 'dsh-im-language', method, payload);
|
|
218
|
+
|
|
219
|
+
const initial = await language('settings.language.get');
|
|
220
|
+
assert.equal(initial.ok, true, JSON.stringify(initial));
|
|
221
|
+
assert.deepEqual(initial.value, { language: 'zh', tag: 'zh', source: 'settings', pinned: false },
|
|
222
|
+
'the plugin must read DSH\'s own locale preference');
|
|
223
|
+
record('DSH locale namespace is registered and read Host-side', JSON.stringify(initial.value));
|
|
224
|
+
|
|
225
|
+
await selectInterfaceLanguage('en');
|
|
226
|
+
const switched = await waitFor('the bot language follows DSH to English', async () => {
|
|
227
|
+
const snapshot = await language('settings.language.get');
|
|
228
|
+
return snapshot.value?.language === 'en' ? snapshot.value : null;
|
|
229
|
+
});
|
|
230
|
+
assert.deepEqual(switched, { language: 'en', tag: 'en', source: 'settings', pinned: false });
|
|
231
|
+
record('switching DSH\'s interface language switches the bot language live', 'no restart');
|
|
232
|
+
|
|
233
|
+
const mirrored = await language('settings.language.mirror', { locale: 'zh-CN' });
|
|
234
|
+
assert.deepEqual(mirrored.value, { language: 'en', tag: 'en', source: 'settings', pinned: false },
|
|
235
|
+
'the mirror must never outrank an explicit selection');
|
|
236
|
+
record('an explicit DSH selection outranks the mirrored interface locale', 'settings > mirror');
|
|
237
|
+
|
|
238
|
+
await selectInterfaceLanguage(null);
|
|
239
|
+
const fallback = await waitFor('the mirror applies once the selection is cleared', async () => {
|
|
240
|
+
const snapshot = await language('settings.language.get');
|
|
241
|
+
return snapshot.value?.source === 'mirror' ? snapshot.value : null;
|
|
242
|
+
});
|
|
243
|
+
assert.deepEqual(fallback, { language: 'zh', tag: 'zh-CN', source: 'mirror', pinned: false });
|
|
244
|
+
record('clearing the selection falls back to the mirrored interface locale', 'mirror > default');
|
|
245
|
+
|
|
246
|
+
// The reported case: an English interface that DSH never stored, because it
|
|
247
|
+
// came from the browser's language list rather than the Language row.
|
|
248
|
+
const browserEnglish = await language('settings.language.mirror', { locale: 'en-GB' });
|
|
249
|
+
assert.deepEqual(browserEnglish.value, { language: 'en', tag: 'en-GB', source: 'mirror', pinned: false });
|
|
250
|
+
assert.deepEqual(JSON.parse(await readFile(mirrorPath, 'utf8')),
|
|
251
|
+
{ version: 1, interfaceLanguage: 'en-GB' });
|
|
252
|
+
record('a browser-derived English interface reaches the Host and is persisted', 'issue #185 case');
|
|
253
|
+
|
|
254
|
+
for (const [method, payload] of [
|
|
255
|
+
['settings.language.mirror', { locale: 'not a tag' }],
|
|
256
|
+
['settings.language.mirror', {}],
|
|
257
|
+
['settings.language.get', { locale: 'en' }],
|
|
258
|
+
['settings.language.unknown', {}],
|
|
259
|
+
]) {
|
|
260
|
+
const refused = await language(method, payload);
|
|
261
|
+
assert.equal(refused.ok, false, `${method} must be refused`);
|
|
262
|
+
assert.equal(refused.error.code, 'bad-request');
|
|
263
|
+
}
|
|
264
|
+
record('the language route refuses malformed payloads', '4 cases');
|
|
265
|
+
|
|
266
|
+
if (botToken) {
|
|
267
|
+
telegramMenu = await telegram('getMyCommands');
|
|
268
|
+
const bound = await rpc(browser, 'telegram', 'bot.bind-credentials', { token: botToken });
|
|
269
|
+
assert.equal(bound.ok, true, 'the bot must bind');
|
|
270
|
+
const botId = bound.value.bots.at(-1).botId;
|
|
271
|
+
await waitFor('the bot connects', async () => {
|
|
272
|
+
const status = await rpc(browser, 'telegram', 'connection.status');
|
|
273
|
+
return status.value?.bots?.find(item => item.botId === botId)?.connected ? true : null;
|
|
274
|
+
});
|
|
275
|
+
const english = await waitFor('Telegram stores the English menu', async () => {
|
|
276
|
+
const commands = await telegram('getMyCommands');
|
|
277
|
+
const help = commands.find(item => item.command === 'help');
|
|
278
|
+
return help && !HAN.test(help.description) ? commands : null;
|
|
279
|
+
});
|
|
280
|
+
record('Telegram stores the English command menu while DSH is English', `${english.length} commands`);
|
|
281
|
+
|
|
282
|
+
await selectInterfaceLanguage('zh');
|
|
283
|
+
const chinese = await waitFor('Telegram stores the Chinese menu after the switch', async () => {
|
|
284
|
+
const commands = await telegram('getMyCommands');
|
|
285
|
+
const help = commands.find(item => item.command === 'help');
|
|
286
|
+
return help && HAN.test(help.description) ? commands : null;
|
|
287
|
+
});
|
|
288
|
+
assert.equal(chinese.length, english.length, 'the same catalog in another language');
|
|
289
|
+
const still = await rpc(browser, 'telegram', 'connection.status');
|
|
290
|
+
assert.equal(still.value.bots.find(item => item.botId === botId).connected, true,
|
|
291
|
+
'the command menu must be re-sent without reconnecting the bot');
|
|
292
|
+
record('switching language re-sends the Telegram menu with no reconnect', `${chinese.length} commands`);
|
|
293
|
+
await selectInterfaceLanguage(null);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// A restart must keep answering in the mirrored language, before any browser
|
|
297
|
+
// connects, and must resolve it before the first bot registers its menu.
|
|
298
|
+
await stop();
|
|
299
|
+
const restarted = await login(await start());
|
|
300
|
+
browser = restarted;
|
|
301
|
+
const afterRestart = await rpc(restarted, 'dsh-im-language', 'settings.language.get');
|
|
302
|
+
assert.deepEqual(afterRestart.value, { language: 'en', tag: 'en-GB', source: 'mirror', pinned: false });
|
|
303
|
+
record('the mirrored language survives a Host restart', 'resolved before channels start');
|
|
304
|
+
|
|
305
|
+
// Back-compatibility: an operator pin keeps winning and ignores DSH entirely.
|
|
306
|
+
await stop();
|
|
307
|
+
await writeFile(join(profile, 'cordis.patch.yml'),
|
|
308
|
+
'- id: xmanrui-dsh-im\n config:\n language: zh-CN\n');
|
|
309
|
+
await selectInterfaceLanguage('en');
|
|
310
|
+
const pinnedBrowser = await login(await start());
|
|
311
|
+
const pinned = await rpc(pinnedBrowser, 'dsh-im-language', 'settings.language.get');
|
|
312
|
+
assert.deepEqual(pinned.value, { language: 'zh', tag: 'zh-CN', source: 'config', pinned: true },
|
|
313
|
+
'a configured language must ignore DSH\'s interface language');
|
|
314
|
+
record('an operator-configured language still wins', 'existing setups unchanged');
|
|
315
|
+
|
|
316
|
+
console.table(results);
|
|
317
|
+
console.log(`Passed ${results.length} real HTTP checks against an unmodified DSH CLI (${harness.kind} layout).`);
|
|
318
|
+
console.log(botToken
|
|
319
|
+
? 'The live Telegram menu was exercised and the bot\'s original menu was restored.'
|
|
320
|
+
: 'Set DSH_IM_TELEGRAM_TOKEN to additionally assert the menu Telegram itself stores.');
|
|
321
|
+
console.log('The temporary home contains no bot credentials and is removed after the server stops.');
|
|
322
|
+
} finally {
|
|
323
|
+
await stop();
|
|
324
|
+
if (botToken && telegramMenu !== undefined) {
|
|
325
|
+
try {
|
|
326
|
+
if (telegramMenu.length > 0) await telegram('setMyCommands', { commands: telegramMenu });
|
|
327
|
+
else await telegram('deleteMyCommands');
|
|
328
|
+
} catch {
|
|
329
|
+
console.error('could not restore the bot\'s original command menu');
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
await rm(directory, { recursive: true, force: true });
|
|
333
|
+
}
|
|
@@ -149,7 +149,7 @@ async function sendThreadUncertainNotice(api, normalized, signal) {
|
|
|
149
149
|
await api.createMessage({
|
|
150
150
|
channelId: normalized.replyTarget.channelId,
|
|
151
151
|
replyToMessageId: normalized.replyTarget.replyToMessageId,
|
|
152
|
-
content: 'Thread 创建结果暂时无法确认。若已创建,请在对应 Thread 中重试;若未创建,请稍后重新 @机器人。',
|
|
152
|
+
content: t('Thread 创建结果暂时无法确认。若已创建,请在对应 Thread 中重试;若未创建,请稍后重新 @机器人。'),
|
|
153
153
|
signal,
|
|
154
154
|
});
|
|
155
155
|
} catch (error) {
|
|
@@ -5,4 +5,6 @@ export default {
|
|
|
5
5
|
'The Discord Gateway Intents are misconfigured. Please check the Bot settings in the Developer Portal.',
|
|
6
6
|
'Discord机器人': 'Discord Bot',
|
|
7
7
|
' Gateway 长连接': ' Gateway long-lived connection',
|
|
8
|
+
'Thread 创建结果暂时无法确认。若已创建,请在对应 Thread 中重试;若未创建,请稍后重新 @机器人。':
|
|
9
|
+
'The Thread creation result cannot be confirmed yet. If the Thread was created, retry inside it; if it was not, mention the bot again shortly.',
|
|
8
10
|
};
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// English translations (telegram area). Keys are exact Chinese literals passed to t().
|
|
2
2
|
export default {
|
|
3
|
+
// Terminal status written back over a placeholder whose in-place edit was
|
|
4
|
+
// rejected, so it is the last thing a reader sees on a degraded reply.
|
|
5
|
+
'回复已发送。': 'The reply was sent.',
|
|
6
|
+
'回复发送结果未能确认。': 'The reply delivery result could not be confirmed.',
|
|
7
|
+
'消息发送失败,请稍后重试。': 'The message could not be sent. Try again later.',
|
|
3
8
|
'开启一个全新会话': 'Start a brand-new Session',
|
|
4
9
|
'压缩当前会话的较早上下文': 'Compact the earlier context of the current Session',
|
|
5
10
|
'切换工作区': 'Switch Workspace',
|
|
@@ -10,17 +10,60 @@ import { EN } from './i18n-en.mjs';
|
|
|
10
10
|
|
|
11
11
|
let language = 'zh';
|
|
12
12
|
|
|
13
|
+
const listeners = new Set();
|
|
14
|
+
|
|
13
15
|
// Accepts 'en', 'en-US', 'english' (any case) as English; anything else
|
|
14
|
-
// (including undefined and unrecognized values) selects Chinese.
|
|
15
|
-
|
|
16
|
+
// (including undefined and unrecognized values) selects Chinese. Pure: use it
|
|
17
|
+
// to judge a candidate tag without switching the active language.
|
|
18
|
+
export function normalizeImHostLanguage(lang) {
|
|
16
19
|
const normalized = typeof lang === 'string' ? lang.trim().toLowerCase() : '';
|
|
17
|
-
|
|
20
|
+
return normalized === 'english' || /^en(?:[-_].*)?$/u.test(normalized) ? 'en' : 'zh';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Select the language of every host-side message. Subscribers registered
|
|
25
|
+
* through onImHostLanguageChange are notified only when the resolved language
|
|
26
|
+
* actually changes, so re-applying the same selection in a different spelling
|
|
27
|
+
* (or an unrecognized tag that keeps falling back to Chinese) is free.
|
|
28
|
+
*/
|
|
29
|
+
export function setImHostLanguage(lang) {
|
|
30
|
+
const next = normalizeImHostLanguage(lang);
|
|
31
|
+
if (next === language) return language;
|
|
32
|
+
const previous = language;
|
|
33
|
+
language = next;
|
|
34
|
+
// Snapshot first: a subscriber may unsubscribe (or subscribe) while running.
|
|
35
|
+
for (const listener of [...listeners]) {
|
|
36
|
+
if (!listeners.has(listener)) continue;
|
|
37
|
+
try {
|
|
38
|
+
listener(next, previous);
|
|
39
|
+
} catch {
|
|
40
|
+
// Each subscriber owns its own diagnostics; one that fails must not
|
|
41
|
+
// strand the rest, nor abandon a language switch that already happened.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return language;
|
|
18
45
|
}
|
|
19
46
|
|
|
20
47
|
export function getImHostLanguage() {
|
|
21
48
|
return language;
|
|
22
49
|
}
|
|
23
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Observe committed changes to the host message language. Platform-side
|
|
53
|
+
* surfaces that were localized once at connect time (the Telegram command
|
|
54
|
+
* menu, for example) re-synchronize from here instead of waiting for a
|
|
55
|
+
* reconnect. Subscribers must not throw; the disposer is idempotent.
|
|
56
|
+
*/
|
|
57
|
+
export function onImHostLanguageChange(listener) {
|
|
58
|
+
if (typeof listener !== 'function') {
|
|
59
|
+
throw new TypeError('onImHostLanguageChange requires a listener function');
|
|
60
|
+
}
|
|
61
|
+
listeners.add(listener);
|
|
62
|
+
return () => {
|
|
63
|
+
listeners.delete(listener);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
24
67
|
// Translate a user-facing Chinese literal. In zh mode (the default) this is
|
|
25
68
|
// the identity function. Optional `params` fills `{name}` placeholders in
|
|
26
69
|
// both the Chinese key and its translation, e.g.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { normalizeInterfaceLanguageTag } from './interface-language.mjs';
|
|
6
|
+
|
|
7
|
+
const DOCUMENT_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
function invalidTagError() {
|
|
10
|
+
const error = new Error('Invalid DSH interface language tag.');
|
|
11
|
+
error.code = 'interface-language-invalid';
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Mirrors the atomic settings writes of the inbound attachment TTL store and
|
|
16
|
+
// the update service: create a private temporary file, then rename it in.
|
|
17
|
+
async function writeSettingsDocument(path, document) {
|
|
18
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
19
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
20
|
+
try {
|
|
21
|
+
await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
mode: 0o600,
|
|
24
|
+
flag: 'wx',
|
|
25
|
+
});
|
|
26
|
+
await rename(temporary, path);
|
|
27
|
+
} finally {
|
|
28
|
+
await unlink(temporary).catch((error) => {
|
|
29
|
+
if (error.code !== 'ENOENT') throw error;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Durable mirror of the DSH interface language, so bot messages keep following
|
|
36
|
+
* it across Host restarts and before any browser has connected. Only the
|
|
37
|
+
* lowest resolution layer lives here (see ./interface-language.mjs): an
|
|
38
|
+
* unreadable document resolves to "nothing mirrored" rather than pinning a
|
|
39
|
+
* language nobody chose.
|
|
40
|
+
*/
|
|
41
|
+
export class InterfaceLanguageStore {
|
|
42
|
+
#path;
|
|
43
|
+
#tag = null;
|
|
44
|
+
// Whether the document on disk is known to already say what #tag says. False
|
|
45
|
+
// for a missing or unreadable document, so the next report repairs it.
|
|
46
|
+
#stored = false;
|
|
47
|
+
|
|
48
|
+
constructor(path) {
|
|
49
|
+
if (typeof path !== 'string' || !path) {
|
|
50
|
+
throw new TypeError('interface language store path is required');
|
|
51
|
+
}
|
|
52
|
+
this.#path = path;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async load() {
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = await readFile(this.#path, 'utf8');
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
61
|
+
this.#tag = null;
|
|
62
|
+
this.#stored = false;
|
|
63
|
+
await this.#removeStaleTemporaries();
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
const read = this.#readTag(raw);
|
|
67
|
+
this.#tag = read === undefined ? null : read;
|
|
68
|
+
this.#stored = read !== undefined;
|
|
69
|
+
await this.#removeStaleTemporaries();
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Crash leftovers from an interrupted atomic write are unreferenced by
|
|
74
|
+
// anyone; remove them so the settings directory stays clean.
|
|
75
|
+
async #removeStaleTemporaries() {
|
|
76
|
+
const directory = dirname(this.#path);
|
|
77
|
+
const prefix = `${basename(this.#path)}.`;
|
|
78
|
+
try {
|
|
79
|
+
const entries = await readdir(directory);
|
|
80
|
+
await Promise.all(entries
|
|
81
|
+
.filter((name) => name.startsWith(prefix) && name.endsWith('.tmp'))
|
|
82
|
+
.map((name) => unlink(join(directory, name)).catch(() => {})));
|
|
83
|
+
} catch {
|
|
84
|
+
// A missing directory or concurrent removal is fine; cleanup is best-effort.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Returns the mirrored tag (null when the document mirrors nothing), or
|
|
89
|
+
// undefined when the document is damaged or from an unknown future version.
|
|
90
|
+
#readTag(raw) {
|
|
91
|
+
let document;
|
|
92
|
+
try {
|
|
93
|
+
document = JSON.parse(raw);
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
if (!document || typeof document !== 'object' || Array.isArray(document)) return undefined;
|
|
98
|
+
if (document.version !== DOCUMENT_VERSION) return undefined;
|
|
99
|
+
if (document.interfaceLanguage === undefined) return null;
|
|
100
|
+
return normalizeInterfaceLanguageTag(document.interfaceLanguage) ?? undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getLanguageTag() {
|
|
104
|
+
return this.#tag;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Record the interface language reported by the settings UI. Passing null
|
|
109
|
+
* clears the mirror, returning resolution to the layers above it. The
|
|
110
|
+
* settings page reports its locale on every mount, so an unchanged value is
|
|
111
|
+
* accepted without rewriting the document.
|
|
112
|
+
*/
|
|
113
|
+
async setLanguageTag(value) {
|
|
114
|
+
const tag = value === null || value === undefined
|
|
115
|
+
? null
|
|
116
|
+
: normalizeInterfaceLanguageTag(value);
|
|
117
|
+
if (tag === null && value !== null && value !== undefined) throw invalidTagError();
|
|
118
|
+
if (tag === this.#tag && this.#stored) return tag;
|
|
119
|
+
await writeSettingsDocument(this.#path, {
|
|
120
|
+
version: DOCUMENT_VERSION,
|
|
121
|
+
...(tag === null ? {} : { interfaceLanguage: tag }),
|
|
122
|
+
});
|
|
123
|
+
this.#tag = tag;
|
|
124
|
+
this.#stored = true;
|
|
125
|
+
return tag;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Resolution policy for the DSH interface language that dsh-im's bot messages
|
|
2
|
+
// follow.
|
|
3
|
+
//
|
|
4
|
+
// The host message language (./i18n.mjs) is a two-value switch: English, or
|
|
5
|
+
// Chinese as the always-available fallback. The DSH interface language is a
|
|
6
|
+
// BCP 47-style tag that a language pack may extend beyond the shipped zh/en
|
|
7
|
+
// pair, so a tag is carried verbatim through resolution and persistence and is
|
|
8
|
+
// collapsed to a dictionary language only where it reaches setImHostLanguage().
|
|
9
|
+
|
|
10
|
+
/** Tag shape accepted by DSH's own locale preference (@deepseek-ai/dsh-client-locale). */
|
|
11
|
+
const LANGUAGE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolution layers, highest precedence first.
|
|
15
|
+
*
|
|
16
|
+
* - `config`: the plugin's own `language` option (or `DSH_IM_LANGUAGE`). An
|
|
17
|
+
* operator who pinned a language in the Host composition keeps it, whatever
|
|
18
|
+
* any individual browser reads the interface in.
|
|
19
|
+
* - `settings`: the explicit selection in DSH's Language row, read from the
|
|
20
|
+
* Host user-settings document. This is the setting users mean by "DSH is set
|
|
21
|
+
* to English".
|
|
22
|
+
* - `mirror`: the last effective interface locale reported by the settings UI.
|
|
23
|
+
* DSH stores nothing when the interface language came from the browser's
|
|
24
|
+
* language list, so without this layer a reader who never opened the
|
|
25
|
+
* Language row would still be answered in Chinese.
|
|
26
|
+
*/
|
|
27
|
+
export const HOST_LANGUAGE_SOURCES = Object.freeze(['config', 'settings', 'mirror']);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Normalize one interface-language tag. Surrounding whitespace is trimmed;
|
|
31
|
+
* anything that is not a BCP 47-style tag returns null so a malformed layer is
|
|
32
|
+
* skipped rather than silently overriding the layers below it.
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeInterfaceLanguageTag(value) {
|
|
35
|
+
if (typeof value !== 'string') return null;
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
return LANGUAGE_TAG.test(trimmed) ? trimmed : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pick the winning interface-language tag across the layers, in
|
|
42
|
+
* HOST_LANGUAGE_SOURCES order. Returns `{ tag: null, source: 'default' }` when
|
|
43
|
+
* no layer names a usable tag, which keeps Chinese as the shipped default.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveHostLanguageTag(layers = {}) {
|
|
46
|
+
for (const source of HOST_LANGUAGE_SOURCES) {
|
|
47
|
+
const tag = normalizeInterfaceLanguageTag(layers[source]);
|
|
48
|
+
if (tag !== null) return { tag, source };
|
|
49
|
+
}
|
|
50
|
+
return { tag: null, source: 'default' };
|
|
51
|
+
}
|
|
@@ -324,6 +324,32 @@ export class TokenBotController {
|
|
|
324
324
|
};
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Re-synchronize the platform-side command menu of every connected bot.
|
|
329
|
+
*
|
|
330
|
+
* Called when the host message language changes: a menu the platform stored
|
|
331
|
+
* at connect time would otherwise keep the previous language until the bot
|
|
332
|
+
* reconnected. Runtimes of channels without a platform-side menu expose no
|
|
333
|
+
* refresh hook and are skipped, and one bot's failure never hides the rest.
|
|
334
|
+
* @returns the number of bots that accepted a refreshed menu.
|
|
335
|
+
*/
|
|
336
|
+
async refreshCommandMenus() {
|
|
337
|
+
if (this.#closed) return 0;
|
|
338
|
+
const refreshed = await Promise.all([...this.#runtimes].map(async ([botId, runtime]) => {
|
|
339
|
+
if (typeof runtime?.refreshCommandMenu !== 'function') return false;
|
|
340
|
+
try {
|
|
341
|
+
return await runtime.refreshCommandMenu() === true;
|
|
342
|
+
} catch (error) {
|
|
343
|
+
this.#logger.warn?.(
|
|
344
|
+
`[dsh-im:${this.#descriptor.key}] bot ${botId} command menu refresh failed:`,
|
|
345
|
+
error,
|
|
346
|
+
);
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}));
|
|
350
|
+
return refreshed.filter(Boolean).length;
|
|
351
|
+
}
|
|
352
|
+
|
|
327
353
|
async close() {
|
|
328
354
|
if (this.#closed) return;
|
|
329
355
|
this.#closed = true;
|