@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.
Files changed (53) hide show
  1. package/README.en.md +96 -4
  2. package/README.md +96 -4
  3. package/lib/client.js +666 -217
  4. package/lib/index.js +277 -273
  5. package/package.json +13 -1
  6. package/plugin-src/client/channels/weixin/api.js +35 -17
  7. package/plugin-src/client/channels/weixin/connection-error.js +68 -0
  8. package/plugin-src/client/channels/weixin/index.js +36 -12
  9. package/plugin-src/client/i18n.js +2 -0
  10. package/plugin-src/client/index.js +15 -0
  11. package/plugin-src/client/interface-language.js +89 -0
  12. package/plugin-src/host/channels/shared/startup.mjs +30 -4
  13. package/plugin-src/host/channels/weixin/connection-supervisor.mjs +13 -1
  14. package/plugin-src/host/channels/weixin/index.mjs +12 -3
  15. package/plugin-src/host/channels/weixin/production.mjs +53 -3
  16. package/plugin-src/host/channels/weixin/rpc.mjs +22 -17
  17. package/plugin-src/host/host-language-rpc.mjs +71 -0
  18. package/plugin-src/host/host-language.mjs +157 -0
  19. package/plugin-src/host/index.mjs +15 -2
  20. package/scripts/verify-interface-language.mjs +333 -0
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +4 -1
  22. package/src/channels/dingtalk/dingtalk-menu.mjs +8 -4
  23. package/src/channels/discord/discord-runtime.mjs +1 -1
  24. package/src/channels/feishu/bridge.mjs +44 -13
  25. package/src/channels/qq/qq-bridge.mjs +12 -4
  26. package/src/channels/qq/qq-menu.mjs +11 -8
  27. package/src/channels/shared/bot-workspace-store.mjs +532 -40
  28. package/src/channels/shared/command-catalog.mjs +5 -0
  29. package/src/channels/shared/compact-command.mjs +14 -4
  30. package/src/channels/shared/control-command.mjs +1 -1
  31. package/src/channels/shared/deferred-delivery-coordinator.mjs +1 -1
  32. package/src/channels/shared/history-command.mjs +1 -1
  33. package/src/channels/shared/i18n-en/discord.mjs +2 -0
  34. package/src/channels/shared/i18n-en/shared-a.mjs +41 -0
  35. package/src/channels/shared/i18n-en/telegram.mjs +5 -0
  36. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  37. package/src/channels/shared/i18n.mjs +46 -3
  38. package/src/channels/shared/interface-language-store.mjs +127 -0
  39. package/src/channels/shared/interface-language.mjs +51 -0
  40. package/src/channels/shared/model-command.mjs +5 -3
  41. package/src/channels/shared/token-bot-controller.mjs +26 -0
  42. package/src/channels/shared/workspace-command.mjs +114 -9
  43. package/src/channels/shared/workspace-session.mjs +55 -5
  44. package/src/channels/telegram/telegram-runtime.mjs +76 -14
  45. package/src/channels/wecom/wecom-bridge.mjs +2 -2
  46. package/src/channels/weixin/connection-error.en.mjs +116 -0
  47. package/src/channels/weixin/connection-error.mjs +204 -0
  48. package/src/channels/weixin/diagnostic-details.mjs +40 -0
  49. package/src/channels/weixin/state-store.mjs +4 -3
  50. package/src/channels/weixin/weixin-api.mjs +20 -8
  51. package/src/channels/weixin/weixin-bridge.mjs +3 -2
  52. package/src/channels/weixin/weixin-controller.mjs +133 -104
  53. 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?.(workspace, options),
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 || state.sessionFor(key) !== sessionId) {
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}`, { chatId, key, messageId: replyTo });
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, { signal });
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
- const current = typeof this.#harness.currentWorkspace === 'function'
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 listed = await this.#harness.listWorkspaceSessions(current, { signal: dataSignal });
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
- return { workspace: this.#harness.currentWorkspace?.(), sessionId: this.#state.sessionFor(key) };
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.sessionId !== context.sessionId) {
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 || current.sessionId !== choice.context.sessionId) {
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 || locked.sessionId !== choice.context.sessionId) {
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);