@xmanrui/dsh-im 4.19.0 → 4.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,17 +10,60 @@ import { EN } from './i18n-en.mjs';
10
10
 
11
11
  let language = 'zh';
12
12
 
13
+ const listeners = new Set();
14
+
13
15
  // Accepts 'en', 'en-US', 'english' (any case) as English; anything else
14
- // (including undefined and unrecognized values) selects Chinese.
15
- export function setImHostLanguage(lang) {
16
+ // (including undefined and unrecognized values) selects Chinese. Pure: use it
17
+ // to judge a candidate tag without switching the active language.
18
+ export function normalizeImHostLanguage(lang) {
16
19
  const normalized = typeof lang === 'string' ? lang.trim().toLowerCase() : '';
17
- language = normalized === 'english' || /^en(?:[-_].*)?$/u.test(normalized) ? 'en' : 'zh';
20
+ return normalized === 'english' || /^en(?:[-_].*)?$/u.test(normalized) ? 'en' : 'zh';
21
+ }
22
+
23
+ /**
24
+ * Select the language of every host-side message. Subscribers registered
25
+ * through onImHostLanguageChange are notified only when the resolved language
26
+ * actually changes, so re-applying the same selection in a different spelling
27
+ * (or an unrecognized tag that keeps falling back to Chinese) is free.
28
+ */
29
+ export function setImHostLanguage(lang) {
30
+ const next = normalizeImHostLanguage(lang);
31
+ if (next === language) return language;
32
+ const previous = language;
33
+ language = next;
34
+ // Snapshot first: a subscriber may unsubscribe (or subscribe) while running.
35
+ for (const listener of [...listeners]) {
36
+ if (!listeners.has(listener)) continue;
37
+ try {
38
+ listener(next, previous);
39
+ } catch {
40
+ // Each subscriber owns its own diagnostics; one that fails must not
41
+ // strand the rest, nor abandon a language switch that already happened.
42
+ }
43
+ }
44
+ return language;
18
45
  }
19
46
 
20
47
  export function getImHostLanguage() {
21
48
  return language;
22
49
  }
23
50
 
51
+ /**
52
+ * Observe committed changes to the host message language. Platform-side
53
+ * surfaces that were localized once at connect time (the Telegram command
54
+ * menu, for example) re-synchronize from here instead of waiting for a
55
+ * reconnect. Subscribers must not throw; the disposer is idempotent.
56
+ */
57
+ export function onImHostLanguageChange(listener) {
58
+ if (typeof listener !== 'function') {
59
+ throw new TypeError('onImHostLanguageChange requires a listener function');
60
+ }
61
+ listeners.add(listener);
62
+ return () => {
63
+ listeners.delete(listener);
64
+ };
65
+ }
66
+
24
67
  // Translate a user-facing Chinese literal. In zh mode (the default) this is
25
68
  // the identity function. Optional `params` fills `{name}` placeholders in
26
69
  // both the Chinese key and its translation, e.g.
@@ -0,0 +1,127 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+
5
+ import { normalizeInterfaceLanguageTag } from './interface-language.mjs';
6
+
7
+ const DOCUMENT_VERSION = 1;
8
+
9
+ function invalidTagError() {
10
+ const error = new Error('Invalid DSH interface language tag.');
11
+ error.code = 'interface-language-invalid';
12
+ return error;
13
+ }
14
+
15
+ // Mirrors the atomic settings writes of the inbound attachment TTL store and
16
+ // the update service: create a private temporary file, then rename it in.
17
+ async function writeSettingsDocument(path, document) {
18
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
19
+ const temporary = `${path}.${randomUUID()}.tmp`;
20
+ try {
21
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, {
22
+ encoding: 'utf8',
23
+ mode: 0o600,
24
+ flag: 'wx',
25
+ });
26
+ await rename(temporary, path);
27
+ } finally {
28
+ await unlink(temporary).catch((error) => {
29
+ if (error.code !== 'ENOENT') throw error;
30
+ });
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Durable mirror of the DSH interface language, so bot messages keep following
36
+ * it across Host restarts and before any browser has connected. Only the
37
+ * lowest resolution layer lives here (see ./interface-language.mjs): an
38
+ * unreadable document resolves to "nothing mirrored" rather than pinning a
39
+ * language nobody chose.
40
+ */
41
+ export class InterfaceLanguageStore {
42
+ #path;
43
+ #tag = null;
44
+ // Whether the document on disk is known to already say what #tag says. False
45
+ // for a missing or unreadable document, so the next report repairs it.
46
+ #stored = false;
47
+
48
+ constructor(path) {
49
+ if (typeof path !== 'string' || !path) {
50
+ throw new TypeError('interface language store path is required');
51
+ }
52
+ this.#path = path;
53
+ }
54
+
55
+ async load() {
56
+ let raw;
57
+ try {
58
+ raw = await readFile(this.#path, 'utf8');
59
+ } catch (error) {
60
+ if (error?.code !== 'ENOENT') throw error;
61
+ this.#tag = null;
62
+ this.#stored = false;
63
+ await this.#removeStaleTemporaries();
64
+ return this;
65
+ }
66
+ const read = this.#readTag(raw);
67
+ this.#tag = read === undefined ? null : read;
68
+ this.#stored = read !== undefined;
69
+ await this.#removeStaleTemporaries();
70
+ return this;
71
+ }
72
+
73
+ // Crash leftovers from an interrupted atomic write are unreferenced by
74
+ // anyone; remove them so the settings directory stays clean.
75
+ async #removeStaleTemporaries() {
76
+ const directory = dirname(this.#path);
77
+ const prefix = `${basename(this.#path)}.`;
78
+ try {
79
+ const entries = await readdir(directory);
80
+ await Promise.all(entries
81
+ .filter((name) => name.startsWith(prefix) && name.endsWith('.tmp'))
82
+ .map((name) => unlink(join(directory, name)).catch(() => {})));
83
+ } catch {
84
+ // A missing directory or concurrent removal is fine; cleanup is best-effort.
85
+ }
86
+ }
87
+
88
+ // Returns the mirrored tag (null when the document mirrors nothing), or
89
+ // undefined when the document is damaged or from an unknown future version.
90
+ #readTag(raw) {
91
+ let document;
92
+ try {
93
+ document = JSON.parse(raw);
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ if (!document || typeof document !== 'object' || Array.isArray(document)) return undefined;
98
+ if (document.version !== DOCUMENT_VERSION) return undefined;
99
+ if (document.interfaceLanguage === undefined) return null;
100
+ return normalizeInterfaceLanguageTag(document.interfaceLanguage) ?? undefined;
101
+ }
102
+
103
+ getLanguageTag() {
104
+ return this.#tag;
105
+ }
106
+
107
+ /**
108
+ * Record the interface language reported by the settings UI. Passing null
109
+ * clears the mirror, returning resolution to the layers above it. The
110
+ * settings page reports its locale on every mount, so an unchanged value is
111
+ * accepted without rewriting the document.
112
+ */
113
+ async setLanguageTag(value) {
114
+ const tag = value === null || value === undefined
115
+ ? null
116
+ : normalizeInterfaceLanguageTag(value);
117
+ if (tag === null && value !== null && value !== undefined) throw invalidTagError();
118
+ if (tag === this.#tag && this.#stored) return tag;
119
+ await writeSettingsDocument(this.#path, {
120
+ version: DOCUMENT_VERSION,
121
+ ...(tag === null ? {} : { interfaceLanguage: tag }),
122
+ });
123
+ this.#tag = tag;
124
+ this.#stored = true;
125
+ return tag;
126
+ }
127
+ }
@@ -0,0 +1,51 @@
1
+ // Resolution policy for the DSH interface language that dsh-im's bot messages
2
+ // follow.
3
+ //
4
+ // The host message language (./i18n.mjs) is a two-value switch: English, or
5
+ // Chinese as the always-available fallback. The DSH interface language is a
6
+ // BCP 47-style tag that a language pack may extend beyond the shipped zh/en
7
+ // pair, so a tag is carried verbatim through resolution and persistence and is
8
+ // collapsed to a dictionary language only where it reaches setImHostLanguage().
9
+
10
+ /** Tag shape accepted by DSH's own locale preference (@deepseek-ai/dsh-client-locale). */
11
+ const LANGUAGE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u;
12
+
13
+ /**
14
+ * Resolution layers, highest precedence first.
15
+ *
16
+ * - `config`: the plugin's own `language` option (or `DSH_IM_LANGUAGE`). An
17
+ * operator who pinned a language in the Host composition keeps it, whatever
18
+ * any individual browser reads the interface in.
19
+ * - `settings`: the explicit selection in DSH's Language row, read from the
20
+ * Host user-settings document. This is the setting users mean by "DSH is set
21
+ * to English".
22
+ * - `mirror`: the last effective interface locale reported by the settings UI.
23
+ * DSH stores nothing when the interface language came from the browser's
24
+ * language list, so without this layer a reader who never opened the
25
+ * Language row would still be answered in Chinese.
26
+ */
27
+ export const HOST_LANGUAGE_SOURCES = Object.freeze(['config', 'settings', 'mirror']);
28
+
29
+ /**
30
+ * Normalize one interface-language tag. Surrounding whitespace is trimmed;
31
+ * anything that is not a BCP 47-style tag returns null so a malformed layer is
32
+ * skipped rather than silently overriding the layers below it.
33
+ */
34
+ export function normalizeInterfaceLanguageTag(value) {
35
+ if (typeof value !== 'string') return null;
36
+ const trimmed = value.trim();
37
+ return LANGUAGE_TAG.test(trimmed) ? trimmed : null;
38
+ }
39
+
40
+ /**
41
+ * Pick the winning interface-language tag across the layers, in
42
+ * HOST_LANGUAGE_SOURCES order. Returns `{ tag: null, source: 'default' }` when
43
+ * no layer names a usable tag, which keeps Chinese as the shipped default.
44
+ */
45
+ export function resolveHostLanguageTag(layers = {}) {
46
+ for (const source of HOST_LANGUAGE_SOURCES) {
47
+ const tag = normalizeInterfaceLanguageTag(layers[source]);
48
+ if (tag !== null) return { tag, source };
49
+ }
50
+ return { tag: null, source: 'default' };
51
+ }
@@ -1,34 +1,22 @@
1
- /**
2
- * Session-sync ownership registry: records which delivery targets of a
3
- * session are being mirrored as process cards by their owning bridge.
4
- *
5
- * The plain-text session-sync coordinator and the per-bridge card mirror
6
- * observe the same global session events. When a bridge mirrors a turn to a
7
- * specific target, the coordinator must suppress the plain-text delivery for
8
- * THAT target only — other targets (same or other channels) still receive
9
- * their normal text. Both sides live in the same Host process, so a
10
- * module-level registry is the whole contract: the mirror claims a target
11
- * when it opens the mirror card and releases it when the turn ends.
12
- */
13
- const claimed = new Map();
1
+ /** Optional per-bot card delivery. A registration is not a delivery receipt:
2
+ * the coordinator must await the renderer before suppressing final text. */
3
+ const renderers = new Map();
14
4
 
15
- function keyOf(sessionId, targetId) {
16
- return `${sessionId}\0${targetId ?? ''}`;
5
+ function botKey({ channel, botId }) {
6
+ return JSON.stringify([channel, botId]);
17
7
  }
18
8
 
19
- export function claimSessionSyncMirror(sessionId, targetId = '', turn = null) {
20
- if (typeof sessionId !== 'string' || !sessionId) return;
21
- claimed.set(keyOf(sessionId, targetId), { turn, claimedAt: Date.now() });
9
+ export function registerSessionSyncMirror(target, render) {
10
+ const key = botKey(target);
11
+ const entry = { render };
12
+ renderers.set(key, entry);
13
+ return () => {
14
+ if (renderers.get(key) === entry) renderers.delete(key);
15
+ };
22
16
  }
23
17
 
24
- export function releaseSessionSyncMirror(sessionId, targetId = '') {
25
- claimed.delete(keyOf(sessionId, targetId));
26
- }
27
-
28
- /** True when a live mirror claim covers this session and target. */
29
- export function isSessionSyncMirrored(sessionId, targetId = '', turn = null) {
30
- const claim = claimed.get(keyOf(sessionId, targetId));
31
- if (!claim) return false;
32
- if (turn !== null && claim.turn !== null && claim.turn !== turn) return false;
33
- return true;
18
+ export async function deliverSessionSyncMirror(target, sessionId, turn, text) {
19
+ const entry = renderers.get(botKey(target));
20
+ if (!entry) return false;
21
+ return await entry.render({ target, sessionId, turn, text }) === true;
34
22
  }
@@ -324,6 +324,32 @@ export class TokenBotController {
324
324
  };
325
325
  }
326
326
 
327
+ /**
328
+ * Re-synchronize the platform-side command menu of every connected bot.
329
+ *
330
+ * Called when the host message language changes: a menu the platform stored
331
+ * at connect time would otherwise keep the previous language until the bot
332
+ * reconnected. Runtimes of channels without a platform-side menu expose no
333
+ * refresh hook and are skipped, and one bot's failure never hides the rest.
334
+ * @returns the number of bots that accepted a refreshed menu.
335
+ */
336
+ async refreshCommandMenus() {
337
+ if (this.#closed) return 0;
338
+ const refreshed = await Promise.all([...this.#runtimes].map(async ([botId, runtime]) => {
339
+ if (typeof runtime?.refreshCommandMenu !== 'function') return false;
340
+ try {
341
+ return await runtime.refreshCommandMenu() === true;
342
+ } catch (error) {
343
+ this.#logger.warn?.(
344
+ `[dsh-im:${this.#descriptor.key}] bot ${botId} command menu refresh failed:`,
345
+ error,
346
+ );
347
+ return false;
348
+ }
349
+ }));
350
+ return refreshed.filter(Boolean).length;
351
+ }
352
+
327
353
  async close() {
328
354
  if (this.#closed) return;
329
355
  this.#closed = true;
@@ -524,10 +524,10 @@ export class TelegramBotClient {
524
524
 
525
525
  const fallback = await this.#sendPlain(target, text);
526
526
  const terminalText = fallback.deliveryOutcome === 'sent'
527
- ? '回复已发送。'
527
+ ? t('回复已发送。')
528
528
  : fallback.deliveryOutcome === 'unknown'
529
- ? '回复发送结果未能确认。'
530
- : '消息发送失败,请稍后重试。';
529
+ ? t('回复发送结果未能确认。')
530
+ : t('消息发送失败,请稍后重试。');
531
531
  try {
532
532
  await this.#api.editMessageText({
533
533
  chatId: target.chatId,
@@ -717,13 +717,13 @@ export class TelegramBotClient {
717
717
  keepalive: true,
718
718
  logger: this.#logger,
719
719
  });
720
- await stream.update(createTextDeliveryBlock('正在处理…', 'plain'));
720
+ await stream.update(createTextDeliveryBlock(t('正在处理…'), 'plain'));
721
721
  return stream;
722
722
  }
723
723
 
724
724
  const placeholder = await this.#api.sendMessage({
725
725
  chatId: target.chatId,
726
- text: '正在处理…',
726
+ text: t('正在处理…'),
727
727
  replyToMessageId: target.replyToMessageId,
728
728
  messageThreadId: target.messageThreadId,
729
729
  signal: this.#signal,
@@ -824,6 +824,14 @@ export class TelegramRuntime {
824
824
  #abortController = null;
825
825
  #pollTask = null;
826
826
  #starting = null;
827
+ // True only while #start() is between "started connecting" and "ready",
828
+ // which is the one window a skipped refresh must be reconciled. A refresh on
829
+ // a never-started or already-stopped runtime stays a no-op.
830
+ #connecting = false;
831
+ // True when a language switch arrived while the runtime was connecting, so a
832
+ // refreshCommandMenu() had nothing to push. Reconciled once startup
833
+ // completes, so the platform always ends up with the latest language.
834
+ #menuDirty = false;
827
835
 
828
836
  constructor({
829
837
  config,
@@ -902,6 +910,7 @@ export class TelegramRuntime {
902
910
 
903
911
  async #start() {
904
912
  await this.stop();
913
+ this.#connecting = true;
905
914
  this.#status.startedAt = new Date().toISOString();
906
915
  this.#status.connectionState = 'connecting';
907
916
  this.#status.lastError = null;
@@ -930,14 +939,7 @@ export class TelegramRuntime {
930
939
  throw error;
931
940
  }
932
941
  try {
933
- const commands = telegramCommandMenu(this.#commandCatalog);
934
- // Both operations use the existing default scope and language. Sending
935
- // the full list replaces old entries; an empty catalog clears that list.
936
- if (commands.length > 0) {
937
- await api.setMyCommands({ commands, signal: controller.signal });
938
- } else {
939
- await api.deleteMyCommands({ signal: controller.signal });
940
- }
942
+ await this.#sendCommandMenu(api, controller.signal);
941
943
  await api.setChatMenuButton({ menuButton: COMMANDS_MENU_BUTTON, signal: controller.signal });
942
944
  } catch (error) {
943
945
  this.#logger.warn?.(
@@ -973,6 +975,21 @@ export class TelegramRuntime {
973
975
  this.#status.connectionState = 'connected';
974
976
  this.#status.lastCheckedAt = now;
975
977
  this.#status.lastConnectedAt = now;
978
+ // A language switch that landed while we were connecting was skipped by
979
+ // refreshCommandMenu(); re-send the menu now that the API is usable, so
980
+ // the platform ends with the latest language instead of the one that was
981
+ // current when #sendCommandMenu first ran.
982
+ if (this.#menuDirty) {
983
+ this.#menuDirty = false;
984
+ try {
985
+ await this.#sendCommandMenu(api, controller.signal);
986
+ } catch (error) {
987
+ this.#logger.warn?.(
988
+ `[dsh-im:telegram] bot ${this.#config.botId} command menu catch-up failed:`,
989
+ error,
990
+ );
991
+ }
992
+ }
976
993
  this.#pollTask = this.#poll(cursor, controller.signal);
977
994
  this.#pollTask.catch((error) => {
978
995
  if (controller.signal.aborted) return;
@@ -988,6 +1005,51 @@ export class TelegramRuntime {
988
1005
  this.#status.lastError = error?.message ?? String(error);
989
1006
  await this.stop();
990
1007
  throw error;
1008
+ } finally {
1009
+ // Whether the bot is now ready or the attempt failed, the connecting
1010
+ // window is over. A failed attempt also drops any unreconciled dirty
1011
+ // flag: a later retry re-sends the menu from scratch in the current
1012
+ // language anyway.
1013
+ this.#connecting = false;
1014
+ this.#menuDirty = false;
1015
+ }
1016
+ }
1017
+
1018
+ // Both operations use the existing default scope and language. Sending the
1019
+ // full list replaces old entries; an empty catalog clears that list.
1020
+ async #sendCommandMenu(api, signal) {
1021
+ const commands = telegramCommandMenu(this.#commandCatalog);
1022
+ if (commands.length > 0) await api.setMyCommands({ commands, signal });
1023
+ else await api.deleteMyCommands({ signal });
1024
+ }
1025
+
1026
+ /**
1027
+ * Re-send the command menu in the current host message language.
1028
+ *
1029
+ * The menu Telegram shows is registered once per connection, so a language
1030
+ * change would otherwise stay invisible until the bot reconnected. This
1031
+ * replaces the text only: the chat menu button is owned by connect.
1032
+ * @returns whether a connected bot accepted the refreshed menu.
1033
+ */
1034
+ async refreshCommandMenu() {
1035
+ const api = this.#api;
1036
+ const signal = this.#abortController?.signal;
1037
+ if (!this.#status.ready || !api || !signal || signal.aborted) {
1038
+ // A refresh during an in-progress connection must not be lost: mark the
1039
+ // menu dirty so #start() reconciles it the moment the bot is ready. A
1040
+ // never-started or stopped runtime stays a no-op, as before.
1041
+ if (this.#connecting) this.#menuDirty = true;
1042
+ return false;
1043
+ }
1044
+ try {
1045
+ await this.#sendCommandMenu(api, signal);
1046
+ return true;
1047
+ } catch (error) {
1048
+ this.#logger.warn?.(
1049
+ `[dsh-im:telegram] bot ${this.#config.botId} command menu refresh failed:`,
1050
+ error,
1051
+ );
1052
+ return false;
991
1053
  }
992
1054
  }
993
1055