@xmanrui/dsh-im 4.18.0 → 4.18.1
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 +7 -3
- package/README.md +7 -3
- package/lib/client.js +5 -1
- package/lib/index.js +55 -55
- package/package.json +5 -1
- package/plugin-src/host/rpc-authority.mjs +3 -2
- package/scripts/verify-lan-management.mjs +176 -0
- package/src/channels/shared/text-harness-bridge.mjs +38 -0
- package/src/channels/telegram/telegram-runtime.mjs +77 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.18.
|
|
3
|
+
"version": "4.18.1",
|
|
4
4
|
"description": "把十一种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect eleven IM channels and a public AI Office to a local DeepSeek Harness.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -48,6 +48,10 @@
|
|
|
48
48
|
{
|
|
49
49
|
"name": "cherryFloris",
|
|
50
50
|
"url": "https://github.com/cherryFloris"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "geekyfoxlab",
|
|
54
|
+
"url": "https://github.com/geekyfoxlab"
|
|
51
55
|
}
|
|
52
56
|
],
|
|
53
57
|
"license": "MIT",
|
|
@@ -2,10 +2,11 @@ const RPC_AUTHORITIES = new Set(['loopback', 'trusted-host']);
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Resolve the browser authority accepted by an IM management RPC channel.
|
|
5
|
-
*
|
|
5
|
+
* By default, use the browser authentication and Host/Origin trust checks
|
|
6
|
+
* already enforced by Harness before it dispatches a management Fetch route.
|
|
6
7
|
*/
|
|
7
8
|
export function resolveRpcAuthority(value) {
|
|
8
|
-
if (value === undefined) return '
|
|
9
|
+
if (value === undefined) return 'trusted-host';
|
|
9
10
|
if (RPC_AUTHORITIES.has(value)) return value;
|
|
10
11
|
throw new TypeError('dsh-im rpcAuthority must be "loopback" or "trusted-host"');
|
|
11
12
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** Run the built plugin through an original DSH CLI with an isolated, empty home. */
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { access, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { request as httpRequest } from 'node:http';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const harnessRoot = process.argv[2];
|
|
10
|
+
if (!harnessRoot || process.argv.includes('--help')) {
|
|
11
|
+
console.log('Usage: node scripts/verify-lan-management.mjs /path/to/built/deepseek-harness');
|
|
12
|
+
process.exit(harnessRoot ? 0 : 1);
|
|
13
|
+
}
|
|
14
|
+
const pluginRoot = resolve(import.meta.dirname, '..');
|
|
15
|
+
const cli = resolve(harnessRoot, 'apps/cli/lib/bin.js');
|
|
16
|
+
await access(cli);
|
|
17
|
+
await access(join(pluginRoot, 'lib/index.js'));
|
|
18
|
+
// DSH 0.1.5 CLI intentionally permits only loopback listening. Exercise its
|
|
19
|
+
// real HTTP carrier with a trusted LAN authority, while keeping TCP local.
|
|
20
|
+
const lanIp = '192.168.1.100';
|
|
21
|
+
|
|
22
|
+
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-lan-test-'));
|
|
23
|
+
const home = join(directory, 'home');
|
|
24
|
+
const profile = join(home, 'profiles/web');
|
|
25
|
+
const results = [];
|
|
26
|
+
let child;
|
|
27
|
+
|
|
28
|
+
function request(url, { method = 'GET', headers = {}, body } = {}) {
|
|
29
|
+
return new Promise((resolveRequest, reject) => {
|
|
30
|
+
const req = httpRequest({
|
|
31
|
+
hostname: '127.0.0.1', port: url.port, path: `${url.pathname}${url.search}`,
|
|
32
|
+
method, headers: { host: url.host, ...headers },
|
|
33
|
+
}, res => {
|
|
34
|
+
let text = '';
|
|
35
|
+
res.setEncoding('utf8');
|
|
36
|
+
res.on('data', chunk => { text += chunk; });
|
|
37
|
+
res.on('error', reject);
|
|
38
|
+
res.on('end', () => resolveRequest({ status: res.statusCode, headers: res.headers, body: text }));
|
|
39
|
+
});
|
|
40
|
+
req.on('error', reject);
|
|
41
|
+
req.setTimeout(10_000, () => req.destroy(new Error('HTTP request timed out')));
|
|
42
|
+
req.end(body);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function start() {
|
|
47
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => (
|
|
48
|
+
!/^DSH_/i.test(key) && !/(?:KEY|SECRET|TOKEN|PASSWORD|PROXY)/i.test(key)
|
|
49
|
+
)));
|
|
50
|
+
child = spawn(process.execPath, [cli, 'web', '--no-open', '--host', '127.0.0.1',
|
|
51
|
+
'--port', '0', '--trusted-host', lanIp], {
|
|
52
|
+
cwd: directory,
|
|
53
|
+
env: { ...env, DSH_HOME: home, DSH_AGENTS_HOME: join(directory, '.agents'),
|
|
54
|
+
DSH_TELEMETRY_DISABLED: '1', SSH_CONNECTION: '', SSH_TTY: '' },
|
|
55
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
56
|
+
});
|
|
57
|
+
return new Promise((resolveStart, reject) => {
|
|
58
|
+
let output = '';
|
|
59
|
+
const timer = setTimeout(() => finish(new Error('DSH startup timed out')), 45_000);
|
|
60
|
+
let settled = false;
|
|
61
|
+
function finish(error, url) {
|
|
62
|
+
if (settled) return;
|
|
63
|
+
settled = true;
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
if (error) {
|
|
66
|
+
const safeOutput = output.replace(/([?&]token=)[^\s)]+/g, '$1<redacted>');
|
|
67
|
+
reject(new Error(`${error.message}\n${safeOutput}`));
|
|
68
|
+
} else resolveStart(new URL(url));
|
|
69
|
+
}
|
|
70
|
+
function append(chunk) {
|
|
71
|
+
output = `${output}${chunk}`.slice(-100_000);
|
|
72
|
+
const match = /dsh web: (http:\/\/[^\s]+)/.exec(output);
|
|
73
|
+
if (match) finish(null, match[1]);
|
|
74
|
+
}
|
|
75
|
+
child.stdout.on('data', append);
|
|
76
|
+
child.stderr.on('data', append);
|
|
77
|
+
child.once('error', error => finish(error));
|
|
78
|
+
child.once('exit', code => finish(new Error(`DSH exited before readiness: ${code}`)));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function stop() {
|
|
83
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
84
|
+
const stopped = new Promise(resolveStop => child.once('exit', resolveStop));
|
|
85
|
+
child.kill('SIGTERM');
|
|
86
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 10_000);
|
|
87
|
+
try { await stopped; } finally { clearTimeout(timer); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function login(launchUrl, hostname) {
|
|
91
|
+
const url = new URL(launchUrl);
|
|
92
|
+
url.hostname = hostname;
|
|
93
|
+
const response = await request(url);
|
|
94
|
+
assert.equal(response.status, 303, 'Harness must exchange its launch token for a browser cookie');
|
|
95
|
+
const cookie = response.headers['set-cookie']?.map(value => value.split(';', 1)[0]).join('; ');
|
|
96
|
+
assert.ok(cookie, 'Harness did not issue a browser cookie');
|
|
97
|
+
return { origin: url.origin, cookie };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function rpc(browser, channel = 'feishu', method = 'connection.status', headers = {}, payload = {}) {
|
|
101
|
+
return request(new URL(`/api/dsh-im/${channel}`, browser.origin), {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'content-type': 'application/json', origin: browser.origin,
|
|
104
|
+
...(browser.cookie ? { cookie: browser.cookie } : {}), ...headers },
|
|
105
|
+
body: JSON.stringify({ type: 'client-request', rpcId: 'lan-test',
|
|
106
|
+
method: `dsh-im/${channel}`, payload: { method, payload } }),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function expectStatus(name, response, expected, businessOk = false) {
|
|
111
|
+
assert.equal(response.status, expected, `${name}: ${response.body.slice(0, 300)}`);
|
|
112
|
+
if (businessOk) {
|
|
113
|
+
const envelope = JSON.parse(response.body);
|
|
114
|
+
assert.equal(envelope.type, 'server-response', name);
|
|
115
|
+
assert.equal(envelope.rpcId, 'lan-test', name);
|
|
116
|
+
assert.equal(envelope.result.ok, true, `${name}: ${response.body.slice(0, 300)}`);
|
|
117
|
+
}
|
|
118
|
+
results.push({ check: name, status: response.status, result: 'PASS' });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await mkdir(profile, { recursive: true });
|
|
123
|
+
const packages = {
|
|
124
|
+
'@deepseek-ai/dsh-base': resolve(harnessRoot, 'packages/bundle/base'),
|
|
125
|
+
'@deepseek-ai/dsh-web-app': resolve(harnessRoot, 'packages/bundle/web-app'),
|
|
126
|
+
'@xmanrui/dsh-im': pluginRoot,
|
|
127
|
+
};
|
|
128
|
+
for (const [name, path] of Object.entries(packages)) {
|
|
129
|
+
const target = join(profile, 'node_modules', name);
|
|
130
|
+
await mkdir(dirname(target), { recursive: true });
|
|
131
|
+
await symlink(path, target, 'dir');
|
|
132
|
+
}
|
|
133
|
+
await writeFile(join(profile, 'cordis.yml'), '[]\n');
|
|
134
|
+
await writeFile(join(profile, 'package.json'), JSON.stringify({
|
|
135
|
+
name: 'dsh-im-lan-test', private: true,
|
|
136
|
+
dependencies: Object.fromEntries(Object.entries(packages).map(([name, path]) => [name, `link:${path}`])),
|
|
137
|
+
dsh: { profile: { bundles: Object.keys(packages) } },
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
const launchUrl = await start();
|
|
141
|
+
const lanUrl = new URL(launchUrl);
|
|
142
|
+
lanUrl.hostname = lanIp;
|
|
143
|
+
const anonymous = { origin: lanUrl.origin };
|
|
144
|
+
expectStatus('LAN without login', await rpc(anonymous), 401);
|
|
145
|
+
const lan = await login(launchUrl, lanIp);
|
|
146
|
+
expectStatus('LAN authenticated web page', await request(new URL('/', lan.origin), {
|
|
147
|
+
headers: { cookie: lan.cookie },
|
|
148
|
+
}), 200);
|
|
149
|
+
for (const channel of ['feishu', 'weixin', 'dingtalk', 'wecom', 'wecom-app', 'qq',
|
|
150
|
+
'slack', 'telegram', 'discord', 'whatsapp', 'imessage', 'office']) {
|
|
151
|
+
expectStatus(`LAN default: ${channel}`, await rpc(lan, channel), 200, true);
|
|
152
|
+
}
|
|
153
|
+
const delivery = await rpc(lan, 'dsh-im-delivery', 'target.list', {}, { botId: 'bot_missing' });
|
|
154
|
+
expectStatus('LAN delivery reaches business handler', delivery, 200);
|
|
155
|
+
assert.equal(JSON.parse(delivery.body).result.error.code, 'unknown-bot');
|
|
156
|
+
expectStatus('LAN forged cookie', await rpc({ ...lan, cookie: 'invalid=invalid' }), 401);
|
|
157
|
+
expectStatus('Untrusted Host', await rpc(lan, 'feishu', 'connection.status', { host: 'untrusted.invalid' }), 403);
|
|
158
|
+
expectStatus('Cross-origin request', await rpc(lan, 'feishu', 'connection.status', { origin: 'https://untrusted.invalid' }), 403);
|
|
159
|
+
expectStatus('LAN update remains local-only', await rpc(lan, 'dsh-im', 'update.status'), 403);
|
|
160
|
+
expectStatus('LAN TTL remains local-only', await rpc(lan, 'dsh-im-settings', 'settings.inbound-ttl.get'), 403);
|
|
161
|
+
const local = await login(launchUrl, '127.0.0.1');
|
|
162
|
+
expectStatus('Loopback default: feishu', await rpc(local), 200, true);
|
|
163
|
+
|
|
164
|
+
await stop();
|
|
165
|
+
await writeFile(join(profile, 'cordis.patch.yml'), '- id: xmanrui-dsh-im\n config:\n rpcAuthority: loopback\n');
|
|
166
|
+
const restrictedUrl = await start();
|
|
167
|
+
expectStatus('Explicit loopback rejects LAN', await rpc(await login(restrictedUrl, lanIp)), 403);
|
|
168
|
+
expectStatus('Explicit loopback accepts localhost', await rpc(await login(restrictedUrl, '127.0.0.1')), 200, true);
|
|
169
|
+
console.table(results);
|
|
170
|
+
console.log(`Passed ${results.length} real HTTP checks using LAN Host/Origin ${lanIp}.`);
|
|
171
|
+
console.log('TCP connections stayed on loopback. This checks the original CLI, authentication and built plugin, not a second-device browser.');
|
|
172
|
+
console.log('The temporary profile contains no bot credentials and is removed after the server stops.');
|
|
173
|
+
} finally {
|
|
174
|
+
await stop();
|
|
175
|
+
await rm(directory, { recursive: true, force: true });
|
|
176
|
+
}
|
|
@@ -144,6 +144,7 @@ export class TextHarnessBridge {
|
|
|
144
144
|
#logger;
|
|
145
145
|
#replyTimeoutMs;
|
|
146
146
|
#signal;
|
|
147
|
+
#keepaliveIntervalMs;
|
|
147
148
|
#queues = new Map();
|
|
148
149
|
#pendingInteractions = new Map();
|
|
149
150
|
#interactionKeys = new Map();
|
|
@@ -165,6 +166,7 @@ export class TextHarnessBridge {
|
|
|
165
166
|
logger = console,
|
|
166
167
|
replyTimeoutMs = 600_000,
|
|
167
168
|
signal,
|
|
169
|
+
keepaliveIntervalMs = 4_000,
|
|
168
170
|
}) {
|
|
169
171
|
if (!descriptor?.key || !descriptor?.label) throw new TypeError('A channel descriptor is required');
|
|
170
172
|
if (!bot || typeof bot.sendText !== 'function') throw new TypeError('A bot client is required');
|
|
@@ -179,6 +181,7 @@ export class TextHarnessBridge {
|
|
|
179
181
|
this.#logger = logger;
|
|
180
182
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
181
183
|
this.#signal = signal;
|
|
184
|
+
this.#keepaliveIntervalMs = keepaliveIntervalMs;
|
|
182
185
|
this.#deferred = createDeferredDeliveryCoordinator({ harness, state, signal, logger,
|
|
183
186
|
deliver: (entry, outcome) => this.#deliverDeferredOutcome(entry, outcome),
|
|
184
187
|
});
|
|
@@ -586,6 +589,15 @@ export class TextHarnessBridge {
|
|
|
586
589
|
const batchSubmission = message.batchSubmission;
|
|
587
590
|
let stream = null;
|
|
588
591
|
let semanticStream = false;
|
|
592
|
+
// A keepalive heartbeat keeps short-lived carriers (e.g. Telegram's
|
|
593
|
+
// private-chat Rich Draft) visible during long silent stretches such as a
|
|
594
|
+
// running tool call. Declared outside the try so every exit path (including
|
|
595
|
+
// pre-prompt failures like image parsing) clears the timer.
|
|
596
|
+
let keepaliveTimer = null;
|
|
597
|
+
const stopKeepalive = () => {
|
|
598
|
+
if (keepaliveTimer !== null) clearInterval(keepaliveTimer);
|
|
599
|
+
keepaliveTimer = null;
|
|
600
|
+
};
|
|
589
601
|
try {
|
|
590
602
|
this.#signal?.throwIfAborted();
|
|
591
603
|
if (message.kind === 'group' && message.addressed !== true) {
|
|
@@ -685,6 +697,29 @@ export class TextHarnessBridge {
|
|
|
685
697
|
}));
|
|
686
698
|
contextEnhanced = content !== originalContent;
|
|
687
699
|
}
|
|
700
|
+
// Start the keepalive only after the inbound payload is ready, so a
|
|
701
|
+
// pre-prompt failure (image parsing, context building) cannot leave the
|
|
702
|
+
// timer running; the outermost finally below clears it on every path.
|
|
703
|
+
if (stream && stream.keepalive === true && typeof stream.refresh === 'function') {
|
|
704
|
+
let refreshing = false;
|
|
705
|
+
keepaliveTimer = setInterval(async () => {
|
|
706
|
+
// Skip ticks while the previous heartbeat is pending so redundant
|
|
707
|
+
// refreshes cannot queue ahead of the final answer on a slow network.
|
|
708
|
+
if (refreshing) return;
|
|
709
|
+
refreshing = true;
|
|
710
|
+
try {
|
|
711
|
+
await Promise.allSettled([
|
|
712
|
+
this.#bot.sendTyping?.(target),
|
|
713
|
+
stream.refresh(),
|
|
714
|
+
]);
|
|
715
|
+
} catch {
|
|
716
|
+
// Keepalive is best-effort, including synchronous adapter failures.
|
|
717
|
+
} finally {
|
|
718
|
+
refreshing = false;
|
|
719
|
+
}
|
|
720
|
+
}, this.#keepaliveIntervalMs);
|
|
721
|
+
keepaliveTimer.unref?.();
|
|
722
|
+
}
|
|
688
723
|
const { answer, artifacts = [] } = await askInWorkspaceSession({
|
|
689
724
|
deferredDelivery: () => ({ coordinator: this.#deferred, target: this.#descriptor.key === 'whatsapp' ? { jid: target.jid, selfChat: target.selfChat } : target }),
|
|
690
725
|
harness: this.#harness,
|
|
@@ -719,6 +754,7 @@ export class TextHarnessBridge {
|
|
|
719
754
|
files: message.files,
|
|
720
755
|
},
|
|
721
756
|
});
|
|
757
|
+
stopKeepalive();
|
|
722
758
|
if (batchSubmission) {
|
|
723
759
|
this.#batches.complete(conversationKey, batchSubmission.token);
|
|
724
760
|
}
|
|
@@ -805,6 +841,7 @@ export class TextHarnessBridge {
|
|
|
805
841
|
}
|
|
806
842
|
return delivery.receipt;
|
|
807
843
|
} catch (error) {
|
|
844
|
+
stopKeepalive();
|
|
808
845
|
const turnStopped = error?.code === 'turn-stopped';
|
|
809
846
|
if (batchSubmission && turnStopped) {
|
|
810
847
|
this.#batches.complete(conversationKey, batchSubmission.token);
|
|
@@ -875,6 +912,7 @@ export class TextHarnessBridge {
|
|
|
875
912
|
}
|
|
876
913
|
return error.deliveryReceipt;
|
|
877
914
|
} finally {
|
|
915
|
+
stopKeepalive();
|
|
878
916
|
await Promise.allSettled([
|
|
879
917
|
this.#cancelPendingInteraction(conversationKey),
|
|
880
918
|
this.#approvals.closeRoute(conversationKey),
|
|
@@ -325,48 +325,99 @@ class TelegramDeliveryStream {
|
|
|
325
325
|
#providerMessageIds;
|
|
326
326
|
#closed = false;
|
|
327
327
|
#lastUpdate = null;
|
|
328
|
+
#lastBlock = null;
|
|
329
|
+
#keepalive = false;
|
|
330
|
+
#chain = Promise.resolve();
|
|
328
331
|
|
|
329
|
-
constructor({
|
|
332
|
+
constructor({
|
|
333
|
+
update,
|
|
334
|
+
finish,
|
|
335
|
+
fail,
|
|
336
|
+
providerMessageIds = [],
|
|
337
|
+
presentation,
|
|
338
|
+
logger,
|
|
339
|
+
keepalive = false,
|
|
340
|
+
}) {
|
|
330
341
|
this.#update = update;
|
|
331
342
|
this.#finish = finish;
|
|
332
343
|
this.#fail = fail;
|
|
333
344
|
this.#providerMessageIds = providerMessageIds;
|
|
334
345
|
this.presentation = presentation;
|
|
335
346
|
this.#logger = logger;
|
|
347
|
+
// Only short-lived carriers (e.g. the private-chat Rich Draft) need a
|
|
348
|
+
// keepalive refresh; editing a real placeholder message with identical
|
|
349
|
+
// content would be rejected by the platform.
|
|
350
|
+
this.#keepalive = keepalive === true;
|
|
336
351
|
}
|
|
337
352
|
|
|
338
353
|
get providerMessageIds() {
|
|
339
354
|
return [...this.#providerMessageIds];
|
|
340
355
|
}
|
|
341
356
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const key = `${block.format}:${block.text}`;
|
|
346
|
-
if (key === this.#lastUpdate) return undefined;
|
|
347
|
-
this.#lastUpdate = key;
|
|
348
|
-
try {
|
|
349
|
-
return await this.#update(block);
|
|
350
|
-
} catch (error) {
|
|
351
|
-
this.#logger.warn?.('[dsh-im:telegram] rich stream update failed:', error);
|
|
352
|
-
return undefined;
|
|
353
|
-
}
|
|
357
|
+
/** Whether this carrier is short-lived and wants a keepalive heartbeat. */
|
|
358
|
+
get keepalive() {
|
|
359
|
+
return this.#keepalive;
|
|
354
360
|
}
|
|
355
361
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
this.#
|
|
361
|
-
return
|
|
362
|
+
/** Serialize every write so an in-flight keepalive refresh can never land
|
|
363
|
+
* after the final frame: finish()/fail() queue behind refresh()/update(). */
|
|
364
|
+
#enqueue(task) {
|
|
365
|
+
const run = this.#chain.then(task);
|
|
366
|
+
this.#chain = run.catch(() => undefined);
|
|
367
|
+
return run;
|
|
362
368
|
}
|
|
363
369
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
+
update(value) {
|
|
371
|
+
return this.#enqueue(async () => {
|
|
372
|
+
if (this.#closed) return undefined;
|
|
373
|
+
const block = createTextDeliveryBlock(value);
|
|
374
|
+
this.#lastBlock = block;
|
|
375
|
+
const key = `${block.format}:${block.text}`;
|
|
376
|
+
if (key === this.#lastUpdate) return undefined;
|
|
377
|
+
this.#lastUpdate = key;
|
|
378
|
+
try {
|
|
379
|
+
return await this.#update(block);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
this.#logger.warn?.('[dsh-im:telegram] rich stream update failed:', error);
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Re-send the most recent frame even when unchanged, to keep a short-lived
|
|
388
|
+
* carrier (the private-chat Rich Draft) visible during long silent
|
|
389
|
+
* stretches such as a running tool call. Serialized like update() so it
|
|
390
|
+
* never overtakes a later finish(). No-op for carriers without keepalive. */
|
|
391
|
+
refresh() {
|
|
392
|
+
return this.#enqueue(async () => {
|
|
393
|
+
if (this.#closed || !this.#keepalive || !this.#lastBlock) return undefined;
|
|
394
|
+
try {
|
|
395
|
+
return await this.#update(this.#lastBlock);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
this.#logger.warn?.('[dsh-im:telegram] rich stream refresh failed:', error);
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
finish(value) {
|
|
404
|
+
return this.#enqueue(async () => {
|
|
405
|
+
if (this.#closed) throw new Error('Message stream is already closed');
|
|
406
|
+
this.#closed = true;
|
|
407
|
+
const result = await this.#finish(createTextDeliveryBlock(value));
|
|
408
|
+
this.#providerMessageIds.push(...(result?.providerMessageIds ?? []));
|
|
409
|
+
return result;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
fail(text) {
|
|
414
|
+
return this.#enqueue(async () => {
|
|
415
|
+
if (this.#closed) return undefined;
|
|
416
|
+
this.#closed = true;
|
|
417
|
+
const result = await this.#fail(createTextDeliveryBlock(text, 'plain'));
|
|
418
|
+
this.#providerMessageIds.push(...(result?.providerMessageIds ?? []));
|
|
419
|
+
return result;
|
|
420
|
+
});
|
|
370
421
|
}
|
|
371
422
|
|
|
372
423
|
cancel() {
|
|
@@ -663,6 +714,7 @@ export class TelegramBotClient {
|
|
|
663
714
|
finish: (block) => this.#sendRich(target, block),
|
|
664
715
|
fail: (block) => this.#sendPlain(target, block.text),
|
|
665
716
|
presentation: 'telegram-rich-draft',
|
|
717
|
+
keepalive: true,
|
|
666
718
|
logger: this.#logger,
|
|
667
719
|
});
|
|
668
720
|
await stream.update(createTextDeliveryBlock('正在处理…', 'plain'));
|