@xmanrui/dsh-im 4.17.1 → 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.
Files changed (38) hide show
  1. package/README.en.md +24 -16
  2. package/README.md +23 -18
  3. package/lib/client.js +428 -246
  4. package/lib/index.js +280 -258
  5. package/package.json +11 -2
  6. package/plugin-src/client/access-policy-settings.js +5 -0
  7. package/plugin-src/client/channel-logos.js +10 -0
  8. package/plugin-src/client/channels/imessage/api.js +12 -0
  9. package/plugin-src/client/channels/imessage/index.js +45 -0
  10. package/plugin-src/client/channels/imessage/styles.js +19 -0
  11. package/plugin-src/client/channels/shared/token-api.js +1 -0
  12. package/plugin-src/client/channels/shared/token-channel.js +5 -1
  13. package/plugin-src/client/delivery-settings.js +5 -0
  14. package/plugin-src/client/i18n.js +23 -1
  15. package/plugin-src/client/index.js +20 -0
  16. package/plugin-src/client/session-channel-logos.js +2 -0
  17. package/plugin-src/client/styles.js +1 -0
  18. package/plugin-src/host/channels/imessage/index.mjs +21 -0
  19. package/plugin-src/host/channels/imessage/production.mjs +13 -0
  20. package/plugin-src/host/channels/imessage/rpc.mjs +70 -0
  21. package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
  22. package/plugin-src/host/delivery-adapter.mjs +4 -0
  23. package/plugin-src/host/index.mjs +3 -0
  24. package/plugin-src/host/modern-harness-api.mjs +1 -1
  25. package/plugin-src/host/rpc-authority.mjs +3 -2
  26. package/scripts/verify-lan-management.mjs +176 -0
  27. package/src/channels/imessage/config-store.mjs +24 -0
  28. package/src/channels/imessage/controller.mjs +37 -0
  29. package/src/channels/imessage/harness-client.mjs +7 -0
  30. package/src/channels/imessage/imessage-api.mjs +194 -0
  31. package/src/channels/imessage/imessage-bridge.mjs +16 -0
  32. package/src/channels/imessage/runtime.mjs +98 -0
  33. package/src/channels/imessage/state-store.mjs +3 -0
  34. package/src/channels/shared/i18n-en/shared-a.mjs +2 -2
  35. package/src/channels/shared/message-failure.mjs +5 -5
  36. package/src/channels/shared/session-channel-labels.mjs +1 -0
  37. package/src/channels/shared/text-harness-bridge.mjs +38 -0
  38. package/src/channels/telegram/telegram-runtime.mjs +77 -25
@@ -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'));