@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.
- package/README.en.md +18 -4
- package/README.md +18 -4
- package/lib/client.js +188 -10
- package/lib/index.js +262 -261
- package/package.json +5 -1
- package/plugin-src/client/bot-alias.js +78 -1
- package/plugin-src/client/index.js +15 -0
- package/plugin-src/client/interface-language.js +89 -0
- package/plugin-src/client/styles.js +17 -8
- package/plugin-src/host/channels/shared/startup.mjs +23 -0
- 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/plugin-src/host/session-sync-coordinator.mjs +14 -13
- package/scripts/verify-interface-language.mjs +333 -0
- package/src/channels/discord/discord-runtime.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +219 -262
- package/src/channels/shared/i18n-en/discord.mjs +2 -0
- package/src/channels/shared/i18n-en/telegram.mjs +5 -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/session-sync-registry.mjs +16 -28
- package/src/channels/shared/token-bot-controller.mjs +26 -0
- package/src/channels/telegram/telegram-runtime.mjs +75 -13
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
3
|
-
|
|
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
|
|
16
|
-
return
|
|
5
|
+
function botKey({ channel, botId }) {
|
|
6
|
+
return JSON.stringify([channel, botId]);
|
|
17
7
|
}
|
|
18
8
|
|
|
19
|
-
export function
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|