@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.18.0",
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
- * The default keeps credential and bot-management operations on loopback.
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 'loopback';
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({ update, finish, fail, providerMessageIds = [], presentation, logger }) {
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
- async update(value) {
343
- if (this.#closed) return undefined;
344
- const block = createTextDeliveryBlock(value);
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
- async finish(value) {
357
- if (this.#closed) throw new Error('Message stream is already closed');
358
- this.#closed = true;
359
- const result = await this.#finish(createTextDeliveryBlock(value));
360
- this.#providerMessageIds.push(...(result?.providerMessageIds ?? []));
361
- return result;
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
- async fail(text) {
365
- if (this.#closed) return undefined;
366
- this.#closed = true;
367
- const result = await this.#fail(createTextDeliveryBlock(text, 'plain'));
368
- this.#providerMessageIds.push(...(result?.providerMessageIds ?? []));
369
- return result;
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'));