@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
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
:
|
|
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 {
|
|
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
|
-
|
|
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) {
|