@xmanrui/dsh-im 4.19.1 → 4.20.0
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 +96 -4
- package/README.md +96 -4
- package/lib/client.js +666 -217
- package/lib/index.js +277 -273
- package/package.json +13 -1
- package/plugin-src/client/channels/weixin/api.js +35 -17
- package/plugin-src/client/channels/weixin/connection-error.js +68 -0
- package/plugin-src/client/channels/weixin/index.js +36 -12
- package/plugin-src/client/i18n.js +2 -0
- 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 +30 -4
- package/plugin-src/host/channels/weixin/connection-supervisor.mjs +13 -1
- package/plugin-src/host/channels/weixin/index.mjs +12 -3
- package/plugin-src/host/channels/weixin/production.mjs +53 -3
- package/plugin-src/host/channels/weixin/rpc.mjs +22 -17
- 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/dingtalk/dingtalk-bridge.mjs +4 -1
- package/src/channels/dingtalk/dingtalk-menu.mjs +8 -4
- package/src/channels/discord/discord-runtime.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +44 -13
- package/src/channels/qq/qq-bridge.mjs +12 -4
- package/src/channels/qq/qq-menu.mjs +11 -8
- package/src/channels/shared/bot-workspace-store.mjs +532 -40
- package/src/channels/shared/command-catalog.mjs +5 -0
- package/src/channels/shared/compact-command.mjs +14 -4
- package/src/channels/shared/control-command.mjs +1 -1
- package/src/channels/shared/deferred-delivery-coordinator.mjs +1 -1
- package/src/channels/shared/history-command.mjs +1 -1
- package/src/channels/shared/i18n-en/discord.mjs +2 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +41 -0
- package/src/channels/shared/i18n-en/telegram.mjs +5 -0
- package/src/channels/shared/i18n-en/weixin.mjs +2 -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/model-command.mjs +5 -3
- package/src/channels/shared/token-bot-controller.mjs +26 -0
- package/src/channels/shared/workspace-command.mjs +114 -9
- package/src/channels/shared/workspace-session.mjs +55 -5
- package/src/channels/telegram/telegram-runtime.mjs +76 -14
- package/src/channels/wecom/wecom-bridge.mjs +2 -2
- package/src/channels/weixin/connection-error.en.mjs +116 -0
- package/src/channels/weixin/connection-error.mjs +204 -0
- package/src/channels/weixin/diagnostic-details.mjs +40 -0
- package/src/channels/weixin/state-store.mjs +4 -3
- package/src/channels/weixin/weixin-api.mjs +20 -8
- package/src/channels/weixin/weixin-bridge.mjs +3 -2
- package/src/channels/weixin/weixin-controller.mjs +133 -104
- package/src/channels/weixin/weixin-runtime.mjs +35 -24
|
@@ -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
|
+
}
|
|
@@ -852,7 +852,10 @@ export class DingtalkHarnessBridge {
|
|
|
852
852
|
});
|
|
853
853
|
return;
|
|
854
854
|
}
|
|
855
|
+
const sessionWorkspace = typeof this.#harness.currentConversationWorkspace === 'function'
|
|
856
|
+
? this.#harness.currentConversationWorkspace(key) : this.#harness.currentWorkspace?.();
|
|
855
857
|
if (entry.workspace !== this.#harness.currentWorkspace?.()
|
|
858
|
+
|| entry.sessionWorkspace !== sessionWorkspace
|
|
856
859
|
|| entry.sessionId !== this.#state.sessionFor(key)) {
|
|
857
860
|
result = { message: t('会话或工作区已变化,菜单已刷新,请重新选择。') };
|
|
858
861
|
} else if ((this.#queues.has(key) || options.pendingInteraction || this.#batchInputs.status(key).phase !== 'idle')
|
|
@@ -1029,7 +1032,7 @@ export class DingtalkHarnessBridge {
|
|
|
1029
1032
|
if (quotedAt === null) return { unavailableReason: 'not-delivered' };
|
|
1030
1033
|
const sessionId = this.#state.sessionFor(key);
|
|
1031
1034
|
const session = typeof sessionId === 'string' && sessionId
|
|
1032
|
-
? this.#harness.workspaceSession?.(sessionId)
|
|
1035
|
+
? this.#harness.workspaceSession?.(sessionId, key)
|
|
1033
1036
|
: null;
|
|
1034
1037
|
const text = await recoverAssistantTextByTimestamp({
|
|
1035
1038
|
session,
|
|
@@ -19,20 +19,24 @@ const presetCommand = (id) => `/preset ${/^\d+$/u.test(id) ? 'id:' : ''}${id}`;
|
|
|
19
19
|
// as a command supplied by the client or as a prompt for the model.
|
|
20
20
|
export async function dingtalkMenuSnapshot(harness, state, key, signal) {
|
|
21
21
|
const workspace = harness.currentWorkspace?.();
|
|
22
|
+
const currentSessionWorkspace = () => typeof harness.currentConversationWorkspace === 'function'
|
|
23
|
+
? harness.currentConversationWorkspace(key) : harness.currentWorkspace?.();
|
|
24
|
+
const sessionWorkspace = currentSessionWorkspace();
|
|
22
25
|
const sessionId = state.sessionFor(key);
|
|
23
26
|
const options = { signal };
|
|
24
27
|
const results = await Promise.allSettled([
|
|
25
28
|
workspacePathSnapshot(harness, options),
|
|
26
|
-
harness.listWorkspaceSessions?.(
|
|
29
|
+
harness.listWorkspaceSessions?.(sessionWorkspace, options),
|
|
27
30
|
(async () => {
|
|
28
|
-
const session = sessionId ? harness.workspaceSession?.(sessionId) : null;
|
|
31
|
+
const session = sessionId ? harness.workspaceSession?.(sessionId, key) : null;
|
|
29
32
|
return typeof session?.models === 'function'
|
|
30
33
|
? session.models(options) : harness.listModels?.(options);
|
|
31
34
|
})(),
|
|
32
35
|
harness.agentPresetSettings?.(options),
|
|
33
36
|
]);
|
|
34
37
|
signal?.throwIfAborted();
|
|
35
|
-
if (harness.currentWorkspace?.() !== workspace
|
|
38
|
+
if (harness.currentWorkspace?.() !== workspace
|
|
39
|
+
|| currentSessionWorkspace() !== sessionWorkspace || state.sessionFor(key) !== sessionId) {
|
|
36
40
|
throw new Error(t('会话或工作区已变化,请重新发送 /m。'));
|
|
37
41
|
}
|
|
38
42
|
const [paths, listed, catalog, settings] = results.map((r) => r.status === 'fulfilled' ? r.value : null);
|
|
@@ -65,7 +69,7 @@ export async function dingtalkMenuSnapshot(harness, state, key, signal) {
|
|
|
65
69
|
}));
|
|
66
70
|
data[`${name}_index`] = entries.findIndex(([, command]) => command === current[name]);
|
|
67
71
|
}
|
|
68
|
-
return { workspace, sessionId, selections, data };
|
|
72
|
+
return { workspace, sessionWorkspace, sessionId, selections, data };
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
export function dingtalkMenuCommand(entry, callback) {
|
|
@@ -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) {
|
|
@@ -2266,6 +2266,7 @@ export class FeishuHarnessBridge {
|
|
|
2266
2266
|
chatId,
|
|
2267
2267
|
key,
|
|
2268
2268
|
messageId = null,
|
|
2269
|
+
conversationWorkspace,
|
|
2269
2270
|
sessionWorkspace = null,
|
|
2270
2271
|
sessionPage = 0,
|
|
2271
2272
|
sessionLimit = null,
|
|
@@ -2513,6 +2514,10 @@ export class FeishuHarnessBridge {
|
|
|
2513
2514
|
return;
|
|
2514
2515
|
}
|
|
2515
2516
|
if (action.startsWith('use:')) {
|
|
2517
|
+
if (conversationWorkspace !== undefined && conversationWorkspace !== this.#conversationWorkspace(key)) {
|
|
2518
|
+
await reply(t('这个菜单已过期,请回复 /m 重新打开。'));
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2516
2521
|
await this.#bindSession(key, chatId, action.slice('use:'.length), { updateMessageId: messageId, replyTo: messageId });
|
|
2517
2522
|
return;
|
|
2518
2523
|
}
|
|
@@ -2594,7 +2599,9 @@ export class FeishuHarnessBridge {
|
|
|
2594
2599
|
return;
|
|
2595
2600
|
}
|
|
2596
2601
|
// The number label sits on the session (bind) button of the row.
|
|
2597
|
-
await this.#handleCardAction(`use:${session.sessionId}`, {
|
|
2602
|
+
await this.#handleCardAction(`use:${session.sessionId}`, {
|
|
2603
|
+
chatId, key, messageId: replyTo, conversationWorkspace: menu.conversationWorkspace,
|
|
2604
|
+
});
|
|
2598
2605
|
return;
|
|
2599
2606
|
}
|
|
2600
2607
|
if (menu.kind === 'workspaces') {
|
|
@@ -2624,6 +2631,12 @@ export class FeishuHarnessBridge {
|
|
|
2624
2631
|
return sessions;
|
|
2625
2632
|
}
|
|
2626
2633
|
|
|
2634
|
+
#conversationWorkspace(key) {
|
|
2635
|
+
return typeof this.#harness.currentConversationWorkspace === 'function'
|
|
2636
|
+
? this.#harness.currentConversationWorkspace(key)
|
|
2637
|
+
: this.#harness.currentWorkspace?.();
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2627
2640
|
async #showSessions(
|
|
2628
2641
|
{ chatId, key, replyTo = null },
|
|
2629
2642
|
selector,
|
|
@@ -2631,13 +2644,20 @@ export class FeishuHarnessBridge {
|
|
|
2631
2644
|
{ updateMessageId = null, limit = null } = {},
|
|
2632
2645
|
) {
|
|
2633
2646
|
try {
|
|
2647
|
+
const conversationWorkspace = this.#conversationWorkspace(key);
|
|
2634
2648
|
const signal = this.#cardDataSignal();
|
|
2635
|
-
const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness, {
|
|
2649
|
+
const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness, {
|
|
2650
|
+
signal, conversationKey: key,
|
|
2651
|
+
});
|
|
2636
2652
|
if (resolved.error) {
|
|
2637
2653
|
await this.#send(chatId, resolved.error, { replyTo });
|
|
2638
2654
|
return;
|
|
2639
2655
|
}
|
|
2640
2656
|
const listed = await this.#harness.listWorkspaceSessions(resolved.workspace, { signal });
|
|
2657
|
+
if (conversationWorkspace !== undefined && conversationWorkspace !== this.#conversationWorkspace(key)) {
|
|
2658
|
+
await this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。'), { replyTo });
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2641
2661
|
const visibleSessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
|
|
2642
2662
|
const sessionLimit = Number.isSafeInteger(limit) && limit > 0 ? limit : null;
|
|
2643
2663
|
const sessions = sessionLimit === null
|
|
@@ -2656,6 +2676,7 @@ export class FeishuHarnessBridge {
|
|
|
2656
2676
|
const pageSlice = sessions.slice(safePage * MENU_PAGE_SIZE, (safePage + 1) * MENU_PAGE_SIZE);
|
|
2657
2677
|
this.#rememberMenu(key, {
|
|
2658
2678
|
kind: 'sessions',
|
|
2679
|
+
conversationWorkspace,
|
|
2659
2680
|
sessions: pageSlice.map((session) => ({ ...session, watched: watchedSet.has(session.sessionId) })),
|
|
2660
2681
|
});
|
|
2661
2682
|
await this.#sendCard(
|
|
@@ -2665,6 +2686,9 @@ export class FeishuHarnessBridge {
|
|
|
2665
2686
|
key,
|
|
2666
2687
|
updateMessageId,
|
|
2667
2688
|
replyTo,
|
|
2689
|
+
// The effective conversation workspace is separate from an explicit
|
|
2690
|
+
// list selector, which may intentionally point at another workspace.
|
|
2691
|
+
conversationWorkspace,
|
|
2668
2692
|
// Keep the canonical selector result for later page callbacks. The
|
|
2669
2693
|
// list response's workspace is display data and is not authoritative.
|
|
2670
2694
|
sessionWorkspace: resolved.workspace,
|
|
@@ -2732,6 +2756,7 @@ export class FeishuHarnessBridge {
|
|
|
2732
2756
|
this.#cardKeys.set(messageId, {
|
|
2733
2757
|
key: options.key,
|
|
2734
2758
|
chatId,
|
|
2759
|
+
conversationWorkspace: options.conversationWorkspace,
|
|
2735
2760
|
sessionWorkspace: typeof options.sessionWorkspace === 'string' && options.sessionWorkspace
|
|
2736
2761
|
? options.sessionWorkspace
|
|
2737
2762
|
: null,
|
|
@@ -2850,13 +2875,14 @@ export class FeishuHarnessBridge {
|
|
|
2850
2875
|
}
|
|
2851
2876
|
|
|
2852
2877
|
async #sendMenuCard(key, chatId, { updateMessageId = null, replyTo = null } = {}) {
|
|
2878
|
+
const conversationWorkspace = this.#conversationWorkspace(key);
|
|
2853
2879
|
let currentSessionId = null;
|
|
2854
2880
|
let directSessionTitle = null;
|
|
2855
2881
|
try {
|
|
2856
2882
|
const sessionId = this.#state.sessionFor(key);
|
|
2857
2883
|
if (typeof sessionId === 'string' && sessionId) {
|
|
2858
2884
|
currentSessionId = sessionId;
|
|
2859
|
-
const session = this.#harness.workspaceSession?.(sessionId);
|
|
2885
|
+
const session = this.#harness.workspaceSession?.(sessionId, key);
|
|
2860
2886
|
directSessionTitle = nonEmptyString(session?.title)
|
|
2861
2887
|
?? nonEmptyString(session?.name)
|
|
2862
2888
|
?? nonEmptyString(session?.displayName);
|
|
@@ -2874,12 +2900,13 @@ export class FeishuHarnessBridge {
|
|
|
2874
2900
|
return { current, paths: current ? [current] : [] };
|
|
2875
2901
|
});
|
|
2876
2902
|
const sessionTask = (async () => {
|
|
2877
|
-
|
|
2878
|
-
? this.#harness.currentWorkspace()
|
|
2879
|
-
: null;
|
|
2880
|
-
if (!current || typeof this.#harness.listWorkspaceSessions !== 'function') return [];
|
|
2903
|
+
if (typeof this.#harness.listWorkspaceSessions !== 'function') return [];
|
|
2881
2904
|
try {
|
|
2882
|
-
const
|
|
2905
|
+
const resolved = await resolveSessionListWorkspace('', this.#harness, {
|
|
2906
|
+
signal: dataSignal, conversationKey: key,
|
|
2907
|
+
});
|
|
2908
|
+
if (resolved.error) return [];
|
|
2909
|
+
const listed = await this.#harness.listWorkspaceSessions(resolved.workspace, { signal: dataSignal });
|
|
2883
2910
|
return this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
|
|
2884
2911
|
} catch {
|
|
2885
2912
|
return [];
|
|
@@ -2896,7 +2923,7 @@ export class FeishuHarnessBridge {
|
|
|
2896
2923
|
const modelTask = (async () => {
|
|
2897
2924
|
try {
|
|
2898
2925
|
if (currentSessionId) {
|
|
2899
|
-
const session = this.#harness.workspaceSession?.(currentSessionId);
|
|
2926
|
+
const session = this.#harness.workspaceSession?.(currentSessionId, key);
|
|
2900
2927
|
if (typeof session?.models === 'function') {
|
|
2901
2928
|
return await session.models({ signal: dataSignal });
|
|
2902
2929
|
}
|
|
@@ -2913,6 +2940,10 @@ export class FeishuHarnessBridge {
|
|
|
2913
2940
|
presetTask,
|
|
2914
2941
|
modelTask,
|
|
2915
2942
|
]);
|
|
2943
|
+
if (conversationWorkspace !== undefined && conversationWorkspace !== this.#conversationWorkspace(key)) {
|
|
2944
|
+
await this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。'), { replyTo });
|
|
2945
|
+
return;
|
|
2946
|
+
}
|
|
2916
2947
|
const workspaces = Array.isArray(snapshot.paths) ? snapshot.paths : [];
|
|
2917
2948
|
const currentWorkspace = snapshot.current ?? null;
|
|
2918
2949
|
const currentMatch = listedSessions.find((session) => session.sessionId === currentSessionId);
|
|
@@ -2944,7 +2975,7 @@ export class FeishuHarnessBridge {
|
|
|
2944
2975
|
currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
|
|
2945
2976
|
sessions, archiveVisible, presetCatalog, modelCatalog,
|
|
2946
2977
|
}),
|
|
2947
|
-
{ key, updateMessageId, replyTo },
|
|
2978
|
+
{ key, updateMessageId, replyTo, conversationWorkspace },
|
|
2948
2979
|
);
|
|
2949
2980
|
}
|
|
2950
2981
|
|
|
@@ -2956,7 +2987,7 @@ export class FeishuHarnessBridge {
|
|
|
2956
2987
|
async #resolveSessionTitle(key, sessionId) {
|
|
2957
2988
|
try {
|
|
2958
2989
|
if (typeof this.#harness.workspaceSession === 'function') {
|
|
2959
|
-
const session = this.#harness.workspaceSession(sessionId);
|
|
2990
|
+
const session = this.#harness.workspaceSession(sessionId, key);
|
|
2960
2991
|
if (session && typeof session === 'object') {
|
|
2961
2992
|
const direct = nonEmptyString(session.title)
|
|
2962
2993
|
?? nonEmptyString(session.name)
|
|
@@ -3009,7 +3040,7 @@ export class FeishuHarnessBridge {
|
|
|
3009
3040
|
const sessionId = this.#state?.sessionFor?.(key);
|
|
3010
3041
|
let catalog;
|
|
3011
3042
|
if (typeof sessionId === 'string' && sessionId) {
|
|
3012
|
-
const session = this.#harness.workspaceSession(sessionId);
|
|
3043
|
+
const session = this.#harness.workspaceSession(sessionId, key);
|
|
3013
3044
|
if (session?.models) {
|
|
3014
3045
|
catalog = await session.models({ signal });
|
|
3015
3046
|
}
|
|
@@ -3078,7 +3109,7 @@ export class FeishuHarnessBridge {
|
|
|
3078
3109
|
try {
|
|
3079
3110
|
const sessionId = this.#state?.sessionFor?.(key);
|
|
3080
3111
|
if (typeof sessionId === 'string' && sessionId) {
|
|
3081
|
-
const session = this.#harness.workspaceSession(sessionId);
|
|
3112
|
+
const session = this.#harness.workspaceSession(sessionId, key);
|
|
3082
3113
|
if (session?.models) {
|
|
3083
3114
|
const cat = await session.models({ signal });
|
|
3084
3115
|
if (cat.current) info.model = `${cat.current.provider}/${cat.current.model}`;
|
|
@@ -702,7 +702,10 @@ export class QqHarnessBridge {
|
|
|
702
702
|
}
|
|
703
703
|
|
|
704
704
|
#menuContext(key) {
|
|
705
|
-
|
|
705
|
+
const workspace = this.#harness.currentWorkspace?.();
|
|
706
|
+
const sessionWorkspace = typeof this.#harness.currentConversationWorkspace === 'function'
|
|
707
|
+
? this.#harness.currentConversationWorkspace(key) : workspace;
|
|
708
|
+
return { workspace, sessionWorkspace, sessionId: this.#state.sessionFor(key) };
|
|
706
709
|
}
|
|
707
710
|
|
|
708
711
|
async #showMenu(message, key, name, pageView = null) {
|
|
@@ -716,7 +719,8 @@ export class QqHarnessBridge {
|
|
|
716
719
|
this.#signal?.throwIfAborted();
|
|
717
720
|
this.#harness.assertWorkspaceScope?.();
|
|
718
721
|
const current = this.#menuContext(key);
|
|
719
|
-
if (current.workspace !== context.workspace || current.
|
|
722
|
+
if (current.workspace !== context.workspace || current.sessionWorkspace !== context.sessionWorkspace
|
|
723
|
+
|| current.sessionId !== context.sessionId) {
|
|
720
724
|
return { message: t('会话或工作区已变化,请重新发送 /m。') };
|
|
721
725
|
}
|
|
722
726
|
if (!this.#menus.publish(key, actor, entry, view)) return { messages: [] };
|
|
@@ -753,7 +757,9 @@ export class QqHarnessBridge {
|
|
|
753
757
|
const execute = async () => {
|
|
754
758
|
this.#signal?.throwIfAborted();
|
|
755
759
|
const current = this.#menuContext(key);
|
|
756
|
-
if (current.workspace !== choice.context.workspace
|
|
760
|
+
if (current.workspace !== choice.context.workspace
|
|
761
|
+
|| current.sessionWorkspace !== choice.context.sessionWorkspace
|
|
762
|
+
|| current.sessionId !== choice.context.sessionId) {
|
|
757
763
|
return { message: t('会话或工作区已变化,请重新发送 /m。') };
|
|
758
764
|
}
|
|
759
765
|
// A prompt may have started while this action waited for a prior menu command.
|
|
@@ -764,7 +770,9 @@ export class QqHarnessBridge {
|
|
|
764
770
|
if (command === '/new') return withSessionBindingLock(this.#state, key, async () => {
|
|
765
771
|
if (isBusy()) return { message: t('当前任务仍在运行,请先停止任务或等待任务完成后再执行此操作。') };
|
|
766
772
|
const locked = this.#menuContext(key);
|
|
767
|
-
if (locked.workspace !== choice.context.workspace
|
|
773
|
+
if (locked.workspace !== choice.context.workspace
|
|
774
|
+
|| locked.sessionWorkspace !== choice.context.sessionWorkspace
|
|
775
|
+
|| locked.sessionId !== choice.context.sessionId) {
|
|
768
776
|
return { message: t('会话或工作区已变化,请重新发送 /m。') };
|
|
769
777
|
}
|
|
770
778
|
await this.#state.clearSession(key);
|