@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
@@ -11,6 +11,7 @@ import {
11
11
  } from '../../../../src/channels/weixin/weixin-api.mjs';
12
12
  import { WeixinController } from '../../../../src/channels/weixin/weixin-controller.mjs';
13
13
  import { WeixinRuntime } from '../../../../src/channels/weixin/weixin-runtime.mjs';
14
+ import { createWeixinDiagnostics, knownWeixinErrorCode, weixinStageError } from '../../../../src/channels/weixin/connection-error.mjs';
14
15
  import {
15
16
  BotWorkspaceStore,
16
17
  createBotWorkspaceScope,
@@ -44,6 +45,33 @@ function pluginPaths(config) {
44
45
  };
45
46
  }
46
47
 
48
+ // Keep shared workspace lifecycle semantics; expose its fallible persistence only to Weixin.
49
+ function diagnosticWorkspaces(store) {
50
+ const writes = new Set(['ensure', 'setWorkspace', 'setModel', 'setAgentPreset', 'setAlias', 'setContextEnhancement', 'setAccessPolicy', 'flushPendingRemoval']);
51
+ const removals = new Set(['retireAfterConfigCommit', 'finishRemoval']);
52
+ return new Proxy(store, {
53
+ get(target, property) {
54
+ const value = Reflect.get(target, property, target);
55
+ if (typeof value !== 'function') return value;
56
+ if (!writes.has(property) && !removals.has(property)) return value.bind(target);
57
+ return (...args) => {
58
+ const failure = error => {
59
+ throw knownWeixinErrorCode(error?.code) ? error
60
+ : weixinStageError(removals.has(property) ? 'workspace-cleanup-failed' : 'workspace-save-failed', error);
61
+ };
62
+ try {
63
+ const result = value.apply(target, args);
64
+ // flushPendingRemoval must remain synchronous when no cleanup is pending.
65
+ return result?.then ? result.then(value => {
66
+ if (removals.has(property) && value?.error) failure(value.error);
67
+ return value;
68
+ }, failure) : result;
69
+ } catch (error) { return failure(error); }
70
+ };
71
+ },
72
+ });
73
+ }
74
+
47
75
  export async function createProductionController(ctx, config = {}, internals = {}) {
48
76
  if (!ctx?.credentials) throw new TypeError('dsh-weixin requires ctx.credentials');
49
77
  const connection = harnessConnection(ctx, config);
@@ -58,13 +86,14 @@ export async function createProductionController(ctx, config = {}, internals = {
58
86
  const logger = typeof ctx.logger === 'function'
59
87
  ? ctx.logger('dsh-weixin')
60
88
  : (ctx.logger ?? console);
89
+ const diagnostics = internals.diagnostics ?? createWeixinDiagnostics({ logger });
61
90
  const agentPresetCatalog = () => listAgentPresetCatalog(ctx);
62
91
  const paths = pluginPaths(config);
63
92
  const configStore = await new ConfigStore(paths.config).load();
64
93
  const defaultWorkspace = resolve(config.workspace ?? process.cwd());
65
94
  const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
66
- const workspaces = internals.workspaces
67
- ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
95
+ const workspaces = diagnosticWorkspaces(internals.workspaces
96
+ ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load());
68
97
  const configuredBots = configStore.list();
69
98
  await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
70
99
  await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId, {
@@ -115,6 +144,7 @@ export async function createProductionController(ctx, config = {}, internals = {
115
144
  credentials: ctx.credentials,
116
145
  configStore: observedConfigStore,
117
146
  logger,
147
+ diagnostics,
118
148
  createRuntime: async ({ botId, config: accountConfig, token }) => {
119
149
  const state = await stateFor(botId);
120
150
  await workspaces.ensure(botId, {
@@ -125,6 +155,7 @@ export async function createProductionController(ctx, config = {}, internals = {
125
155
  botId, workspaces, state, agentPresetCatalog,
126
156
  });
127
157
  return new Runtime({
158
+ diagnostics,
128
159
  api,
129
160
  config: accountConfig,
130
161
  token,
@@ -158,16 +189,35 @@ export async function createProductionController(ctx, config = {}, internals = {
158
189
  }
159
190
  },
160
191
  });
161
- const controller = createWorkspaceAwareController(coreController, {
192
+ const workspaceController = createWorkspaceAwareController(coreController, {
162
193
  workspaces,
163
194
  stateFor,
164
195
  agentPresetCatalog,
165
196
  modelCatalog,
166
197
  });
198
+ const controller = new Proxy(workspaceController, {
199
+ get(target, property) {
200
+ if (property === 'deleteBot') return async (...args) => {
201
+ const existed = Boolean(configStore.get(args[0]));
202
+ try { return await target.deleteBot(...args); }
203
+ catch (error) {
204
+ if (!existed || configStore.get(args[0])) throw error;
205
+ // The shared workspace wrapper can fail cleaning up after the account commit.
206
+ const warning = diagnostics.report(weixinStageError('workspace-cleanup-failed', error), {
207
+ operation: 'bot.delete', botId: args[0], warning: true,
208
+ }).publicError;
209
+ try { return { ...await target.status(), warnings: [warning] }; }
210
+ catch (readError) { throw weixinStageError('status-read-failed', readError, 'status.read'); }
211
+ }
212
+ };
213
+ return Reflect.get(target, property);
214
+ },
215
+ });
167
216
  const supervisor = createSupervisor({
168
217
  controller,
169
218
  harness,
170
219
  logger,
220
+ diagnostics,
171
221
  retryDelaysMs: config.retryDelaysMs,
172
222
  healthyIntervalMs: config.healthyIntervalMs,
173
223
  }).start();
@@ -1,11 +1,11 @@
1
1
  import { SET_ALIAS_ENDPOINT, validAliasPayload } from '../shared/bot-alias-rpc.mjs';
2
2
  import { registerManagementRpc } from '../../../management-rpc.mjs';
3
3
  import QRCode from 'qrcode';
4
+ import { createWeixinDiagnostics, weixinStageError } from '../../../../src/channels/weixin/connection-error.mjs';
4
5
  import { SET_CONTEXT_ENHANCEMENT_ENDPOINT, validContextEnhancementPayload } from '../shared/context-enhancement-rpc.mjs';
5
6
  import { SET_ACCESS_POLICY_ENDPOINT, validAccessPolicyPayload } from '../shared/access-policy-rpc.mjs';
6
7
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
7
8
  import {
8
- publicWorkspaceError,
9
9
  SET_WORKSPACE_ENDPOINT,
10
10
  validWorkspacePayload,
11
11
  } from '../shared/workspace-rpc.mjs';
@@ -118,13 +118,6 @@ function cancelled() {
118
118
  return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
119
119
  }
120
120
 
121
- function internalFailure() {
122
- return {
123
- ok: false,
124
- error: { code: 'weixin-operation-failed', message: '微信操作失败,请稍后重试。' },
125
- };
126
- }
127
-
128
121
  async function qrDataUrl(value) {
129
122
  return QRCode.toDataURL(value, {
130
123
  type: 'image/png',
@@ -143,7 +136,9 @@ async function withEncodedQr(value, encodeQr) {
143
136
  }
144
137
 
145
138
  async function publicStatus(status, encodeQr) {
146
- const safe = structuredClone(status);
139
+ let safe;
140
+ try { safe = structuredClone(status); }
141
+ catch (error) { throw weixinStageError('status-read-failed', error); }
147
142
  if (safe.provisioning) safe.provisioning = await withEncodedQr(safe.provisioning, encodeQr);
148
143
  return safe;
149
144
  }
@@ -161,14 +156,17 @@ function assertController(controller) {
161
156
  }
162
157
  }
163
158
 
164
- export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
159
+ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl, logger = console, diagnostics = createWeixinDiagnostics({ logger }) } = {}) {
165
160
  assertController(controller);
166
161
  const qrCache = new Map();
167
162
  const cachedEncode = (url) => {
168
163
  let encoded = qrCache.get(url);
169
164
  if (!encoded) {
170
165
  if (qrCache.size >= 16) qrCache.delete(qrCache.keys().next().value);
171
- encoded = Promise.resolve().then(() => encodeQr(url));
166
+ encoded = Promise.resolve().then(() => encodeQr(url)).catch(error => {
167
+ qrCache.delete(url);
168
+ throw weixinStageError('qr-encode-failed', error);
169
+ });
172
170
  qrCache.set(url, encoded);
173
171
  }
174
172
  return encoded;
@@ -193,7 +191,7 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}
193
191
  value = await withEncodedQr(started, cachedEncode);
194
192
  } else if (endpoint === WEIXIN_ENDPOINTS.pollProvisioning) {
195
193
  const current = await controller.registrationStatus(payload.attemptId);
196
- if (!current) return badRequest('The provisioning attempt no longer exists.');
194
+ if (!current) throw weixinStageError('provision-attempt-not-found', undefined, 'qr.poll');
197
195
  value = await withEncodedQr(current, cachedEncode);
198
196
  } else if (endpoint === WEIXIN_ENDPOINTS.submitVerification) {
199
197
  value = await withEncodedQr(
@@ -202,7 +200,7 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}
202
200
  );
203
201
  } else if (endpoint === WEIXIN_ENDPOINTS.cancelProvisioning) {
204
202
  value = await controller.cancelProvisioning(payload.attemptId);
205
- if (!value) return badRequest('The provisioning attempt no longer exists.');
203
+ if (!value) throw weixinStageError('provision-attempt-not-found', undefined, 'qr.cancel');
206
204
  } else if (endpoint === WEIXIN_ENDPOINTS.reconnectBot) {
207
205
  const snapshot = await controller.reconnectBot(payload.botId);
208
206
  if (signal?.aborted) return cancelled();
@@ -263,10 +261,17 @@ export function createWeixinRpcHandler(controller, { encodeQr = qrDataUrl } = {}
263
261
  }
264
262
  return signal?.aborted ? cancelled() : { ok: true, value };
265
263
  } catch (error) {
266
- const workspaceError = publicWorkspaceError(error);
267
- return signal?.aborted ? cancelled() : workspaceError
268
- ? { ok: false, error: workspaceError }
269
- : internalFailure();
264
+ if (signal?.aborted) return cancelled();
265
+ const context = {
266
+ 'connection.status': ['status.read', 'status-read-failed'],
267
+ 'provision.begin': ['qr.begin', 'qr-start-failed'],
268
+ 'provision.poll': ['qr.poll'], 'provision.verify': ['qr.verify'], 'provision.cancel': ['qr.cancel'],
269
+ 'bot.reconnect': ['connection.start', 'connection-start-failed'],
270
+ 'bot.delete': ['account.remove'], 'bot.workspace.set': ['workspace.write', 'workspace-save-failed'],
271
+ }[endpoint] ?? ['workspace.write', 'workspace-save-failed'];
272
+ return { ok: false, error: diagnostics.report(error, {
273
+ operation: endpoint, stage: context[0], code: context[1], botId: payload.botId,
274
+ }).publicError };
270
275
  }
271
276
  };
272
277
  }
@@ -0,0 +1,71 @@
1
+ import { registerManagementRpc } from '../management-rpc.mjs';
2
+ import { normalizeInterfaceLanguageTag } from '../../src/channels/shared/interface-language.mjs';
3
+
4
+ export const HOST_LANGUAGE_RPC_CHANNEL = '/dsh-im-language';
5
+ export const HOST_LANGUAGE_ENDPOINTS = Object.freeze({
6
+ get: 'settings.language.get',
7
+ mirror: 'settings.language.mirror',
8
+ });
9
+
10
+ const ENDPOINTS = new Set(Object.values(HOST_LANGUAGE_ENDPOINTS));
11
+
12
+ export function validHostLanguagePayload(endpoint, payload) {
13
+ if (!ENDPOINTS.has(endpoint)) return false;
14
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
15
+ const keys = Object.keys(payload);
16
+ if (endpoint === HOST_LANGUAGE_ENDPOINTS.mirror) {
17
+ if (keys.length !== 1 || keys[0] !== 'locale') return false;
18
+ // An explicit null clears the mirror; anything else must be a usable tag.
19
+ return payload.locale === null || normalizeInterfaceLanguageTag(payload.locale) !== null;
20
+ }
21
+ return keys.length === 0;
22
+ }
23
+
24
+ /**
25
+ * Serve the interface-language mirror. The settings UI is the only caller: it
26
+ * knows the locale the interface is actually rendered in, including a
27
+ * browser-derived one that DSH never stores.
28
+ */
29
+ export function createHostLanguageRpcHandler({ controller, logger = null } = {}) {
30
+ if (!controller || typeof controller.snapshot !== 'function'
31
+ || typeof controller.mirror !== 'function') {
32
+ throw new TypeError('createHostLanguageRpcHandler requires a host language controller');
33
+ }
34
+ return async (endpoint, payload, signal) => {
35
+ if (!validHostLanguagePayload(endpoint, payload)) {
36
+ return { ok: false, error: { code: 'bad-request', message: 'Invalid interface language request.' } };
37
+ }
38
+ if (signal?.aborted) {
39
+ return { ok: false, error: { code: 'cancelled', message: 'Request cancelled.' } };
40
+ }
41
+ try {
42
+ if (endpoint === HOST_LANGUAGE_ENDPOINTS.get) {
43
+ return { ok: true, value: controller.snapshot() };
44
+ }
45
+ return { ok: true, value: await controller.mirror(payload.locale) };
46
+ } catch (error) {
47
+ // The failure is almost always a settings-directory write problem; keep
48
+ // the path and the underlying message out of the browser response.
49
+ logger?.warn?.('[dsh-im] could not persist the mirrored DSH interface language', error);
50
+ return {
51
+ ok: false,
52
+ error: {
53
+ code: 'interface-language-unavailable',
54
+ message: 'interface-language-unavailable',
55
+ },
56
+ };
57
+ }
58
+ };
59
+ }
60
+
61
+ export function installHostLanguageRpc(ctx, controller, authority) {
62
+ const logger = typeof ctx?.logger === 'function'
63
+ ? ctx.logger('dsh-im:language')
64
+ : (ctx?.logger ?? null);
65
+ return registerManagementRpc(
66
+ ctx,
67
+ HOST_LANGUAGE_RPC_CHANNEL,
68
+ createHostLanguageRpcHandler({ controller, logger }),
69
+ { authority },
70
+ );
71
+ }
@@ -0,0 +1,157 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+
4
+ import {
5
+ getImHostLanguage,
6
+ normalizeImHostLanguage,
7
+ onImHostLanguageChange,
8
+ setImHostLanguage,
9
+ } from '../../src/channels/shared/i18n.mjs';
10
+ import {
11
+ normalizeInterfaceLanguageTag,
12
+ resolveHostLanguageTag,
13
+ } from '../../src/channels/shared/interface-language.mjs';
14
+ import { InterfaceLanguageStore } from '../../src/channels/shared/interface-language-store.mjs';
15
+
16
+ /** Settings namespace and field owned by @deepseek-ai/dsh-client-locale. */
17
+ export const DSH_LOCALE_NAMESPACE = 'locale';
18
+ export const DSH_LOCALE_PREFERENCE_FIELD = 'preference';
19
+
20
+ /**
21
+ * Resolve the durable interface-language mirror. The dshHome resolution
22
+ * mirrors pluginPaths: config.dshHome, then DSH_HOME, then the user's home
23
+ * directory. Channel-specific dataDir values never apply.
24
+ */
25
+ export function interfaceLanguageSettingsPath(config = {}) {
26
+ const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
27
+ return resolve(dshHome, 'integrations', 'dsh-im', 'interface-language.json');
28
+ }
29
+
30
+ /**
31
+ * Own the host message language across its resolution layers. `apply()` is
32
+ * idempotent: it re-resolves the layers and hands the winner to
33
+ * setImHostLanguage, which notifies observers only on a real change.
34
+ */
35
+ export function createHostLanguageController({
36
+ store = null,
37
+ config: configLanguage,
38
+ readSettingsPreference = () => undefined,
39
+ logger = null,
40
+ } = {}) {
41
+ const pinned = normalizeInterfaceLanguageTag(configLanguage);
42
+ let readPreference = typeof readSettingsPreference === 'function'
43
+ ? readSettingsPreference
44
+ : () => undefined;
45
+
46
+ const settingsTag = () => {
47
+ // A pinned language wins outright, so never consult the settings service
48
+ // for it: a detached provider must not produce a spurious diagnostic.
49
+ if (pinned !== null) return undefined;
50
+ try {
51
+ return readPreference();
52
+ } catch (error) {
53
+ logger?.warn?.(
54
+ '[dsh-im] could not read the DSH interface language preference; using the mirrored language',
55
+ error,
56
+ );
57
+ return undefined;
58
+ }
59
+ };
60
+
61
+ const mirrorTag = () => (typeof store?.getLanguageTag === 'function' ? store.getLanguageTag() : null);
62
+
63
+ const resolution = () => resolveHostLanguageTag({
64
+ config: configLanguage,
65
+ settings: settingsTag(),
66
+ mirror: mirrorTag(),
67
+ });
68
+
69
+ const describe = (resolved) => Object.freeze({
70
+ language: normalizeImHostLanguage(resolved.tag),
71
+ tag: resolved.tag,
72
+ source: resolved.source,
73
+ pinned: pinned !== null,
74
+ });
75
+
76
+ const apply = () => {
77
+ const resolved = resolution();
78
+ setImHostLanguage(resolved.tag ?? undefined);
79
+ return describe(resolved);
80
+ };
81
+
82
+ return Object.freeze({
83
+ apply,
84
+
85
+ /** The resolution as it stands, without switching the active language. */
86
+ snapshot: () => describe(resolution()),
87
+
88
+ /** Record the interface locale reported by the settings UI, then re-resolve. */
89
+ async mirror(value) {
90
+ if (typeof store?.setLanguageTag !== 'function') return apply();
91
+ await store.setLanguageTag(value ?? null);
92
+ return apply();
93
+ },
94
+
95
+ /** Replace the settings-preference reader once the service is injectable. */
96
+ observeSettings(read) {
97
+ readPreference = typeof read === 'function' ? read : () => undefined;
98
+ return apply();
99
+ },
100
+
101
+ /** Observe committed host language changes (see onImHostLanguageChange). */
102
+ observe: onImHostLanguageChange,
103
+
104
+ /** The active host message language, for callers that only need the switch. */
105
+ language: () => getImHostLanguage(),
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Bind the host message language to DSH's interface language.
111
+ *
112
+ * The plugin's own `language` option stays authoritative for operators who set
113
+ * it. Otherwise the language follows DSH: the explicit Language-row selection
114
+ * from the Host user-settings document, and — because DSH stores nothing for a
115
+ * browser-derived locale — the language mirrored by the settings UI.
116
+ */
117
+ export function installHostLanguage(ctx, config = {}, internals = {}) {
118
+ const logger = typeof ctx?.logger === 'function'
119
+ ? ctx.logger('dsh-im:language')
120
+ : (ctx?.logger ?? null);
121
+ const store = internals.store
122
+ ?? new InterfaceLanguageStore(interfaceLanguageSettingsPath(config));
123
+ const controller = createHostLanguageController({
124
+ store,
125
+ config: config.language ?? process.env.DSH_IM_LANGUAGE,
126
+ logger,
127
+ });
128
+ // Read an already-attached settings service synchronously: channels start
129
+ // inside the same activation, and waiting for the injection callback would
130
+ // let the first bot register its command menu in the previous language and
131
+ // then need a second push. The injection below keeps it live afterwards and
132
+ // covers a provider that attaches later.
133
+ controller.observeSettings(
134
+ () => ctx?.settings?.get?.(DSH_LOCALE_NAMESPACE)?.[DSH_LOCALE_PREFERENCE_FIELD],
135
+ );
136
+ const ready = Promise.resolve()
137
+ .then(() => store.load?.())
138
+ .then(() => controller.apply(), (error) => {
139
+ logger?.error?.(
140
+ '[dsh-im] could not read the mirrored DSH interface language; falling back to Chinese',
141
+ error,
142
+ );
143
+ return controller.snapshot();
144
+ });
145
+ if (typeof ctx?.inject === 'function') {
146
+ ctx.inject(['settings'], (settingsCtx) => {
147
+ controller.observeSettings(
148
+ () => settingsCtx.settings?.get?.(DSH_LOCALE_NAMESPACE)?.[DSH_LOCALE_PREFERENCE_FIELD],
149
+ );
150
+ settingsCtx.on('settings/updated', (namespace) => {
151
+ if (namespace !== DSH_LOCALE_NAMESPACE) return;
152
+ controller.apply();
153
+ });
154
+ });
155
+ }
156
+ return Object.freeze({ ...controller, ready });
157
+ }
@@ -11,7 +11,8 @@ import { apply as applyWeixin } from './channels/weixin/index.mjs';
11
11
  import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
12
12
  import { apply as applyIMessage } from './channels/imessage/index.mjs';
13
13
  import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
14
- import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
14
+ import { installHostLanguage } from './host-language.mjs';
15
+ import { installHostLanguageRpc } from './host-language-rpc.mjs';
15
16
  import { installDeliveryRpc } from './delivery-rpc.mjs';
16
17
  import { installDeliveryHttp } from './delivery-http.mjs';
17
18
  import { createDeliveryService } from './delivery-service.mjs';
@@ -36,6 +37,8 @@ function channelConfig(config, name, deliveryService) {
36
37
  }
37
38
 
38
39
  export function createImHostPlugin(internals = {}) {
40
+ const startHostLanguage = internals.installHostLanguage ?? installHostLanguage;
41
+ const startHostLanguageRpc = internals.installHostLanguageRpc ?? installHostLanguageRpc;
39
42
  const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
40
43
  const startInboundTtl = internals.installInboundTtlRpc ?? installInboundTtlRpc;
41
44
  const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
@@ -109,7 +112,10 @@ export function createImHostPlugin(internals = {}) {
109
112
  });
110
113
 
111
114
  async function activateChannels(ctx, config, deliveryService) {
112
- setImHostLanguage(config.language ?? process.env.DSH_IM_LANGUAGE);
115
+ // Bind the bot message language before any channel connects, so the first
116
+ // command menu a platform stores is already in the interface language.
117
+ const hostLanguage = startHostLanguage(ctx, config);
118
+ await hostLanguage?.ready;
113
119
  const startTitlePrefix = (titleCtx) => {
114
120
  // The installer owns its cleanup through ctx.effect(). Cordis startup
115
121
  // callbacks must not return its controller object as an effect.
@@ -134,6 +140,13 @@ export function createImHostPlugin(internals = {}) {
134
140
  ? ctx.logger(name)
135
141
  : (ctx?.logger ?? console);
136
142
  if (ctx?.connection?.fetch) {
143
+ if (hostLanguage) {
144
+ try {
145
+ startHostLanguageRpc(ctx, hostLanguage, config.rpcAuthority);
146
+ } catch (error) {
147
+ logger.error?.('[dsh-im] failed to activate interface language mirroring; continuing with channels', error);
148
+ }
149
+ }
137
150
  try {
138
151
  startUpdate(ctx);
139
152
  } catch (error) {