@xmanrui/dsh-im 4.37.0 → 4.38.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 (49) hide show
  1. package/PROACTIVE_DELIVERY.en.md +18 -2
  2. package/PROACTIVE_DELIVERY.md +18 -2
  3. package/README.en.md +2 -0
  4. package/README.md +2 -0
  5. package/assets/logo-plugin-message-link-gradient.webp +0 -0
  6. package/lib/client.js +158 -62
  7. package/lib/index.js +322 -317
  8. package/locale/en.json +1 -1
  9. package/locale/zh.json +1 -1
  10. package/package.json +1 -1
  11. package/plugin-src/client/bot-general-settings.js +79 -42
  12. package/plugin-src/client/channels/feishu/api.js +3 -1
  13. package/plugin-src/client/delivery-settings.js +2 -2
  14. package/plugin-src/client/i18n.js +8 -7
  15. package/plugin-src/client/styles.js +19 -0
  16. package/plugin-src/host/channels/feishu/production.mjs +1 -0
  17. package/plugin-src/host/channels/feishu/rpc.mjs +18 -0
  18. package/plugin-src/host/delivery-adapter.mjs +9 -1
  19. package/plugin-src/host/delivery-service.mjs +4 -3
  20. package/plugin-src/host/session-sync-coordinator.mjs +9 -5
  21. package/scripts/verify-package.mjs +11 -6
  22. package/src/channels/dingtalk/dingtalk-api.mjs +41 -24
  23. package/src/channels/dingtalk/dingtalk-runtime.mjs +3 -2
  24. package/src/channels/discord/discord-runtime.mjs +13 -2
  25. package/src/channels/feishu/bridge.mjs +30 -11
  26. package/src/channels/feishu/feishu-cards.mjs +8 -7
  27. package/src/channels/feishu/feishu-runtime.mjs +10 -1
  28. package/src/channels/feishu/multi-bot-controller.mjs +18 -0
  29. package/src/channels/feishu/plugin-config-store.mjs +2 -1
  30. package/src/channels/feishu/step-push-mode.mjs +21 -0
  31. package/src/channels/matrix/matrix-crypto.mjs +19 -5
  32. package/src/channels/matrix/matrix-runtime.mjs +16 -6
  33. package/src/channels/qq/markdown-reply.mjs +7 -340
  34. package/src/channels/qq/qq-runtime.mjs +11 -3
  35. package/src/channels/shared/harness-approval.mjs +102 -2
  36. package/src/channels/shared/harness-client.mjs +15 -3
  37. package/src/channels/shared/i18n-en/shared-c.mjs +4 -0
  38. package/src/channels/shared/i18n-en/wecom.mjs +3 -0
  39. package/src/channels/shared/markdown-chunks.mjs +338 -0
  40. package/src/channels/shared/message-failure.mjs +1 -1
  41. package/src/channels/shared/text-harness-bridge.mjs +4 -1
  42. package/src/channels/slack/slack-api.mjs +2 -1
  43. package/src/channels/slack/slack-runtime.mjs +30 -0
  44. package/src/channels/telegram/telegram-runtime.mjs +1 -0
  45. package/src/channels/wecom/wecom-bridge.mjs +45 -10
  46. package/src/channels/wecom/wecom-runtime.mjs +11 -5
  47. package/src/channels/weixin/weixin-runtime.mjs +17 -10
  48. package/src/channels/whatsapp/whatsapp-markdown.mjs +59 -0
  49. package/src/channels/whatsapp/whatsapp-runtime.mjs +26 -1
@@ -1,6 +1,7 @@
1
1
  import { BusyMessageDispatcher, canAutomaticallySteer } from '../shared/busy-message-dispatcher.mjs';
2
2
  import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { chunkMarkdownText } from '../shared/markdown-chunks.mjs';
4
5
  import QRCode from 'qrcode';
5
6
  import { waitForFeishuOperation } from './feishu-channel.mjs';
6
7
  import {
@@ -122,6 +123,7 @@ import {
122
123
  import {
123
124
  FEISHU_STEP_PUSH_MODES,
124
125
  normalizeFeishuStepPushMode,
126
+ normalizeFeishuStepCardPanels,
125
127
  } from './step-push-mode.mjs';
126
128
  import { FeishuLiveCot } from './live-cot.mjs';
127
129
 
@@ -680,6 +682,7 @@ export class FeishuHarnessBridge {
680
682
  #stepPush = false;
681
683
  /** Step push presentation: discrete posts, a CardKit card, or native live CoT. */
682
684
  #stepPushMode = FEISHU_STEP_PUSH_MODES.POST;
685
+ #stepCardPanels;
683
686
  /** Per-conversation step push state: key → { lastSentAt, count, breakerLogged }. */
684
687
  #stepPushSendState = new Map();
685
688
  /** Live streaming step cards: key → per-turn card state (streaming_card mode). */
@@ -745,6 +748,7 @@ export class FeishuHarnessBridge {
745
748
  mentionTopicReply = true,
746
749
  stepPush = false,
747
750
  stepPushMode = FEISHU_STEP_PUSH_MODES.POST,
751
+ stepCardPanels,
748
752
  stepPushClock = null,
749
753
  repair,
750
754
  repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
@@ -805,6 +809,7 @@ export class FeishuHarnessBridge {
805
809
  this.#mentionTopicReply = mentionTopicReply !== false;
806
810
  this.#stepPush = stepPush === true;
807
811
  this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
812
+ this.#stepCardPanels = normalizeFeishuStepCardPanels(stepCardPanels);
808
813
  this.#stepPushClock = stepPushClock ?? DEFAULT_STEP_PUSH_CLOCK;
809
814
  this.#voice = voice ?? null;
810
815
  this.#repair = repair ?? null;
@@ -901,6 +906,10 @@ export class FeishuHarnessBridge {
901
906
  this.#stepPushMode = normalizeFeishuStepPushMode(value);
902
907
  }
903
908
 
909
+ setStepCardPanels(value) {
910
+ this.#stepCardPanels = normalizeFeishuStepCardPanels(value);
911
+ }
912
+
904
913
  setVoice(voice) {
905
914
  this.#voice = voice ?? null;
906
915
  }
@@ -4782,7 +4791,13 @@ export class FeishuHarnessBridge {
4782
4791
 
4783
4792
  /** Replace the draft interval with `text` chunked into splitter-safe blocks. */
4784
4793
  #writeStepCardAnswer(card, text) {
4785
- const blocks = splitStepPostMarkdown(text, STEP_STREAM_ANSWER_CHUNK_MAX_BYTES)
4794
+ // JSON can expand one UTF-16 unit to six bytes (for example a control
4795
+ // character). Leave room for the element wrapper, and let the existing
4796
+ // Markdown splitter reopen fences in each independently rendered block.
4797
+ const chunks = Buffer.byteLength(JSON.stringify(text), 'utf8') + 128 <= STEP_STREAM_ANSWER_CHUNK_MAX_BYTES
4798
+ ? [text]
4799
+ : chunkMarkdownText(text, Math.floor((STEP_STREAM_ANSWER_CHUNK_MAX_BYTES - 128) / 6));
4800
+ const blocks = chunks
4786
4801
  .map((chunk) => ({ kind: 'message', text: chunk }));
4787
4802
  if (card.answerStart !== null) {
4788
4803
  card.blocks.splice(card.answerStart, card.answerEnd - card.answerStart + 1, ...blocks);
@@ -4845,7 +4860,7 @@ export class FeishuHarnessBridge {
4845
4860
  * their lifecycle is owned by the ask path, not the mirror recovery.
4846
4861
  * lastContent keeps stepStreamCard's raw JSON string (single-encoded).
4847
4862
  */
4848
- async #persistMirrorState(card, liveBlocks, status) {
4863
+ async #persistMirrorState(card, liveBlocks, status, stepCardPanels) {
4849
4864
  const sessionId = card.sessionSyncSessionId;
4850
4865
  if (!sessionId || typeof this.#state.setMirror !== 'function') return;
4851
4866
  await this.#state.setMirror(card.sessionSyncKey, {
@@ -4853,7 +4868,7 @@ export class FeishuHarnessBridge {
4853
4868
  chatId: card.chatId,
4854
4869
  cardIds: [...card.cardIds],
4855
4870
  claimedAt: Date.now(),
4856
- lastContent: stepStreamCard(liveBlocks, { status }),
4871
+ lastContent: stepStreamCard(liveBlocks, { status, stepCardPanels }),
4857
4872
  blocks: structuredClone(card.blocks), answerStart: card.answerStart, answerEnd: card.answerEnd,
4858
4873
  lastSeq: this.#sessionSyncTurns.get(card.sessionSyncKey)?.lastSeq ?? -1,
4859
4874
  pendingStep: this.#sessionSyncTurns.get(card.sessionSyncKey)?.pendingStep ?? null,
@@ -4861,7 +4876,7 @@ export class FeishuHarnessBridge {
4861
4876
  }
4862
4877
 
4863
4878
  /** Sync only this interaction's cards; earlier interactions remain sealed. */
4864
- async #syncStepCardChunks(card, chunks, status) {
4879
+ async #syncStepCardChunks(card, chunks, status, stepCardPanels) {
4865
4880
  const answerVersion = card.answerVersion ?? 0;
4866
4881
  const activeIds = card.cardIds.slice(card.activeCardStart);
4867
4882
  const groups = chunks.length ? [...chunks] : [[]];
@@ -4874,6 +4889,7 @@ export class FeishuHarnessBridge {
4874
4889
  // payload halfway through this render or be marked as already delivered.
4875
4890
  const contents = groups.map((blocks, index) => stepStreamCard(blocks, {
4876
4891
  status: index === groups.length - 1 ? status : 'sealed',
4892
+ stepCardPanels,
4877
4893
  }));
4878
4894
  for (let index = 0; index < contents.length; index += 1) {
4879
4895
  let id = activeIds[index];
@@ -4898,12 +4914,13 @@ export class FeishuHarnessBridge {
4898
4914
 
4899
4915
  async #renderStepCardNow(chatId, card) {
4900
4916
  if (card.broken) return;
4901
- const chunks = splitStepStreamCardBlocks(card.blocks);
4917
+ const stepCardPanels = this.#stepCardPanels;
4918
+ const chunks = splitStepStreamCardBlocks(card.blocks, undefined, undefined, stepCardPanels);
4902
4919
  if (!chunks.length && card.messageId === null) return;
4903
4920
  const live = chunks[chunks.length - 1] ?? [];
4904
4921
  try {
4905
- await this.#syncStepCardChunks(card, chunks, 'running');
4906
- await this.#persistMirrorState(card, live, 'running');
4922
+ await this.#syncStepCardChunks(card, chunks, 'running', stepCardPanels);
4923
+ await this.#persistMirrorState(card, live, 'running', stepCardPanels);
4907
4924
  card.lastRenderAt = this.#stepPushClock.now();
4908
4925
  } catch (error) {
4909
4926
  card.broken = true;
@@ -4943,9 +4960,10 @@ export class FeishuHarnessBridge {
4943
4960
  if (body.trim()) {
4944
4961
  this.#writeStepCardAnswer(card, body);
4945
4962
  }
4946
- const chunks = splitStepStreamCardBlocks(card.blocks);
4963
+ const stepCardPanels = this.#stepCardPanels;
4964
+ const chunks = splitStepStreamCardBlocks(card.blocks, undefined, undefined, stepCardPanels);
4947
4965
  try {
4948
- await this.#syncStepCardChunks(card, chunks, status);
4966
+ await this.#syncStepCardChunks(card, chunks, status, stepCardPanels);
4949
4967
  return { ok: true, cardIds: card.cardIds };
4950
4968
  } catch (error) {
4951
4969
  // Only trust the streamed draft when the LAST SUCCESSFUL render actually
@@ -4996,8 +5014,9 @@ export class FeishuHarnessBridge {
4996
5014
  if (card.broken) return;
4997
5015
  if (card.messageId === null) return;
4998
5016
  try {
4999
- const chunks = splitStepStreamCardBlocks(card.blocks);
5000
- await this.#syncStepCardChunks(card, chunks, 'sealed');
5017
+ const stepCardPanels = this.#stepCardPanels;
5018
+ const chunks = splitStepStreamCardBlocks(card.blocks, undefined, undefined, stepCardPanels);
5019
+ await this.#syncStepCardChunks(card, chunks, 'sealed', stepCardPanels);
5001
5020
  } catch (error) {
5002
5021
  this.#logger.warn?.(
5003
5022
  '[dsh-feishu] step streaming card rotate seal failed:',
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { posix, win32 } from 'node:path';
18
18
  import { t } from '../shared/i18n.mjs';
19
+ import { normalizeFeishuStepCardPanels } from './step-push-mode.mjs';
19
20
 
20
21
  export const MENU_PAGE_SIZE = 10;
21
22
 
@@ -1000,15 +1001,16 @@ export const STEP_STREAM_CARD_MAX_BYTES = 24_000;
1000
1001
  * Build one streaming step card from the accumulated process blocks:
1001
1002
  * { kind: 'message', text } — interim note / warning / context line
1002
1003
  * { kind: 'tools', lines: string[] } — tool-call summary panel
1003
- * `status`: 'running' keeps panels expanded and ends with an italic status
1004
+ * `status`: 'running' uses the configured panel expansion and ends with an italic status
1004
1005
  * line; 'completed' / 'stopped' collapse the panels and swap the status text;
1005
1006
  * 'sealed' is an overflow spill chunk with no status line at all. Finished
1006
1007
  * turns (and sealed spill chunks) merge every tool/thinking panel into one
1007
1008
  * collapsed "process details" panel so the sealed card stays compact.
1008
1009
  */
1009
1010
 
1010
- export function stepStreamCard(rawBlocks, { status = 'running' } = {}) {
1011
+ export function stepStreamCard(rawBlocks, { status = 'running', stepCardPanels } = {}) {
1011
1012
  const running = status === 'running';
1013
+ const panels = normalizeFeishuStepCardPanels(stepCardPanels);
1012
1014
  const elements = [];
1013
1015
  const panelElements = [];
1014
1016
  for (const block of Array.isArray(rawBlocks) ? rawBlocks : []) {
@@ -1021,10 +1023,8 @@ export function stepStreamCard(rawBlocks, { status = 'running' } = {}) {
1021
1023
  title: block.kind === 'tools'
1022
1024
  ? t('🛠️ 工具摘要({count})', { count })
1023
1025
  : t('💭 思考过程({count})', { count }),
1024
- // Tool summaries stay visible while the turn runs; thinking notes
1025
- // remain folded at all times. Finished turns keep both panels but
1026
- // tuck them inside one collapsed "process details" wrapper.
1027
- expanded: block.kind === 'tools' && running,
1026
+ // Finished turns keep both panels inside the collapsed details wrapper.
1027
+ expanded: running && (block.kind === 'tools' ? panels.toolsExpanded : panels.thinkingExpanded),
1028
1028
  });
1029
1029
  if (running) elements.push(panel);
1030
1030
  else panelElements.push(panel);
@@ -1265,6 +1265,7 @@ export function splitStepStreamCardBlocks(
1265
1265
  blocks,
1266
1266
  limit = STEP_STREAM_CARD_MAX_BYTES,
1267
1267
  maxTables = STEP_STREAM_CARD_MAX_TABLES,
1268
+ stepCardPanels,
1268
1269
  ) {
1269
1270
  // A single block can carry more tables than a card accepts — one short answer
1270
1271
  // with six small tables — and splitting only between blocks cannot help. Each
@@ -1277,7 +1278,7 @@ export function splitStepStreamCardBlocks(
1277
1278
  const chunks = [];
1278
1279
  let current = [];
1279
1280
  for (const block of list) {
1280
- const tooLarge = Buffer.byteLength(stepStreamCard([...current, block]), 'utf8') > limit;
1281
+ const tooLarge = Buffer.byteLength(stepStreamCard([...current, block], { stepCardPanels }), 'utf8') > limit;
1281
1282
  const tooManyTables = stepStreamCardTableCount([...current, block]) > maxTables;
1282
1283
  if (current.length > 0 && (tooLarge || tooManyTables)) {
1283
1284
  chunks.push(current);
@@ -5,7 +5,7 @@ import { FeishuHarnessBridge } from './bridge.mjs';
5
5
  import { cardActionProbeCard } from './feishu-cards.mjs';
6
6
  import { VerifiedFeishuChannel, waitForFeishuOperation } from './feishu-channel.mjs';
7
7
  import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
8
- import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
8
+ import { normalizeFeishuStepPushMode, normalizeFeishuStepCardPanels } from './step-push-mode.mjs';
9
9
  import { createVoice } from './voice.mjs';
10
10
  import {
11
11
  syncSlashCommands,
@@ -119,6 +119,7 @@ export class FeishuRuntime {
119
119
  #mentionTopicReply;
120
120
  #stepPush;
121
121
  #stepPushMode;
122
+ #stepCardPanels;
122
123
  #voice = null;
123
124
  #sessionSyncTargetsFor;
124
125
  #ownerOpenIds;
@@ -171,6 +172,7 @@ export class FeishuRuntime {
171
172
  mentionTopicReply = true,
172
173
  stepPush = false,
173
174
  stepPushMode = 'post',
175
+ stepCardPanels,
174
176
  voice = null,
175
177
  sessionSyncTargetsFor = null,
176
178
  ownerOpenId,
@@ -214,6 +216,7 @@ export class FeishuRuntime {
214
216
  this.#mentionTopicReply = mentionTopicReply !== false;
215
217
  this.#stepPush = stepPush === true;
216
218
  this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
219
+ this.#stepCardPanels = normalizeFeishuStepCardPanels(stepCardPanels);
217
220
  // voice 为 { config, secret } 来源;凭据缺失时 createVoice 返回禁用对象,
218
221
  // 桥接层完全跳过语音路径,不影响连接。
219
222
  this.#voice = voice == null
@@ -262,6 +265,11 @@ export class FeishuRuntime {
262
265
  this.#bridge?.setStepPushMode(this.#stepPushMode);
263
266
  }
264
267
 
268
+ setStepCardPanels(value) {
269
+ this.#stepCardPanels = normalizeFeishuStepCardPanels(value);
270
+ this.#bridge?.setStepCardPanels(this.#stepCardPanels);
271
+ }
272
+
265
273
  setVoice(source) {
266
274
  this.#voice = source == null
267
275
  ? null
@@ -371,6 +379,7 @@ export class FeishuRuntime {
371
379
  mentionTopicReply: this.#mentionTopicReply,
372
380
  stepPush: this.#stepPush,
373
381
  stepPushMode: this.#stepPushMode,
382
+ stepCardPanels: this.#stepCardPanels,
374
383
  voice: this.#voice,
375
384
  sessionSyncTargetsFor: this.#sessionSyncTargetsFor,
376
385
  repair: this.#repair,
@@ -21,6 +21,8 @@ import {
21
21
  DEFAULT_FEISHU_STEP_PUSH_MODE,
22
22
  isFeishuStepPushMode,
23
23
  normalizeFeishuStepPushMode,
24
+ isFeishuStepCardPanels,
25
+ normalizeFeishuStepCardPanels,
24
26
  } from './step-push-mode.mjs';
25
27
  import { normalizeFeishuVoiceConfig } from './voice-config.mjs';
26
28
  import {
@@ -106,6 +108,7 @@ function configuredBotFingerprint(config) {
106
108
  mentionTopicReply: config.mentionTopicReply !== false,
107
109
  stepPush: config.stepPush === true,
108
110
  stepPushMode: normalizeFeishuStepPushMode(config.stepPushMode),
111
+ stepCardPanels: normalizeFeishuStepCardPanels(config.stepCardPanels),
109
112
  voice: config.voice,
110
113
  slashPanel: normalizeSlashPanelConfig(config.slashPanel),
111
114
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
@@ -816,6 +819,20 @@ export class MultiBotDshFeishuController {
816
819
  }));
817
820
  }
818
821
 
822
+ async updateStepCardPanels(botId, stepCardPanels) {
823
+ this.#assertOpen();
824
+ if (!isFeishuStepCardPanels(stepCardPanels)) {
825
+ throw new TypeError('Invalid Feishu step card panels');
826
+ }
827
+ return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
828
+ const config = this.#requireBot(botId);
829
+ const saved = await this.#configStore.saveBot({ ...config, stepCardPanels });
830
+ this.#runtimes.get(botId)?.setStepCardPanels?.(saved.stepCardPanels);
831
+ this.#touch();
832
+ return this.status(botId);
833
+ }));
834
+ }
835
+
819
836
  async updateVoice(botId, voice) {
820
837
  this.#assertOpen();
821
838
  if (voice !== null && (typeof voice !== 'object' || Array.isArray(voice))) {
@@ -901,6 +918,7 @@ export class MultiBotDshFeishuController {
901
918
  mentionTopicReply: config.mentionTopicReply !== false,
902
919
  stepPush: config.stepPush === true,
903
920
  stepPushMode: normalizeFeishuStepPushMode(config.stepPushMode),
921
+ stepCardPanels: normalizeFeishuStepCardPanels(config.stepCardPanels),
904
922
  voice: normalizeFeishuVoiceConfig(config.voice),
905
923
  slashPanel: normalizeSlashPanelConfig(config.slashPanel),
906
924
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
3
  import { dirname } from 'node:path';
4
4
  import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
5
- import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
5
+ import { normalizeFeishuStepPushMode, normalizeFeishuStepCardPanels } from './step-push-mode.mjs';
6
6
  import { normalizeFeishuVoiceConfig } from './voice-config.mjs';
7
7
  import { normalizeSlashPanelConfig } from './slash-command-panel.mjs';
8
8
 
@@ -52,6 +52,7 @@ function normalizeBot(value, { legacy = false } = {}) {
52
52
  mentionTopicReply: value.mentionTopicReply !== false,
53
53
  stepPush: value.stepPush === true,
54
54
  stepPushMode: normalizeFeishuStepPushMode(value.stepPushMode),
55
+ stepCardPanels: normalizeFeishuStepCardPanels(value.stepCardPanels),
55
56
  voice: normalizeFeishuVoiceConfig(value.voice),
56
57
  slashPanel: normalizeSlashPanelConfig(value.slashPanel),
57
58
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
@@ -7,6 +7,27 @@ export const FEISHU_STEP_PUSH_MODES = Object.freeze({
7
7
  /** New connections explicitly opt into the process-card presentation. */
8
8
  export const DEFAULT_FEISHU_STEP_PUSH_MODE = FEISHU_STEP_PUSH_MODES.STREAMING_CARD;
9
9
 
10
+ export const DEFAULT_FEISHU_STEP_CARD_PANELS = Object.freeze({
11
+ thinkingExpanded: false,
12
+ toolsExpanded: true,
13
+ });
14
+
15
+ export function normalizeFeishuStepCardPanels(value) {
16
+ return Object.freeze({
17
+ thinkingExpanded: typeof value?.thinkingExpanded === 'boolean'
18
+ ? value.thinkingExpanded : DEFAULT_FEISHU_STEP_CARD_PANELS.thinkingExpanded,
19
+ toolsExpanded: typeof value?.toolsExpanded === 'boolean'
20
+ ? value.toolsExpanded : DEFAULT_FEISHU_STEP_CARD_PANELS.toolsExpanded,
21
+ });
22
+ }
23
+
24
+ export function isFeishuStepCardPanels(value) {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
26
+ && Object.keys(value).every((key) => key === 'thinkingExpanded' || key === 'toolsExpanded')
27
+ && typeof value.thinkingExpanded === 'boolean'
28
+ && typeof value.toolsExpanded === 'boolean';
29
+ }
30
+
10
31
  export function normalizeFeishuStepPushMode(value) {
11
32
  // Bots created before modes existed used posts when step push was enabled.
12
33
  if (value === FEISHU_STEP_PUSH_MODES.STREAMING_CARD) {
@@ -97,6 +97,7 @@ export class MatrixCryptoEngine {
97
97
  #seenIndex = new Map();
98
98
  #pendingEvents = new Map();
99
99
  #requestState = new Map();
100
+ #pendingTasks = new Set();
100
101
  #lastKeyMaintenanceAt = 0;
101
102
  #stats = {
102
103
  undecryptable: 0,
@@ -239,6 +240,12 @@ export class MatrixCryptoEngine {
239
240
  }
240
241
 
241
242
  async stop() {
243
+ this.#ready = false;
244
+ // A key request can enqueue persistence after shutdown starts. Drain both
245
+ // before releasing sessions or allowing the caller to remove the store.
246
+ while (this.#pendingTasks.size > 0) {
247
+ await Promise.allSettled([...this.#pendingTasks]);
248
+ }
242
249
  for (const entry of this.#inbound.values()) entry.session.free?.();
243
250
  for (const entry of this.#outbound.values()) entry.session.free?.();
244
251
  this.#inbound.clear();
@@ -247,7 +254,12 @@ export class MatrixCryptoEngine {
247
254
  this.#memberCache.clear();
248
255
  this.#pendingEvents.clear();
249
256
  this.#seenIndex.clear();
250
- this.#ready = false;
257
+ }
258
+
259
+ #trackTask(task) {
260
+ const tracked = task.finally(() => this.#pendingTasks.delete(tracked));
261
+ this.#pendingTasks.add(tracked);
262
+ return tracked;
251
263
  }
252
264
 
253
265
  async maintain() {
@@ -717,7 +729,9 @@ export class MatrixCryptoEngine {
717
729
  if (!entry) {
718
730
  this.#stats.undecryptable += 1;
719
731
  this.#pendingEvents.set(`${key}|${event?.event_id ?? 'x'}`, { roomId, event, at: this.#now() });
720
- void this.#requestRoomKey(roomId, event);
732
+ void this.#trackTask(this.#requestRoomKey(roomId, event).catch((error) => {
733
+ this.#logger.warn?.('[dsh-im:matrix] a room key request failed:', error?.message ?? error);
734
+ }));
721
735
  return null;
722
736
  }
723
737
  const seen = this.#seenIndexes(key);
@@ -908,7 +922,7 @@ export class MatrixCryptoEngine {
908
922
  } catch {
909
923
  return Promise.resolve();
910
924
  }
911
- return this.#store.apply({ groupOutbound }).catch(() => undefined);
925
+ return this.#trackTask(this.#store.apply({ groupOutbound }).catch(() => undefined));
912
926
  }
913
927
 
914
928
  #persistInbound(key) {
@@ -936,7 +950,7 @@ export class MatrixCryptoEngine {
936
950
  lastUsedAt: entry.lastUsedAt,
937
951
  },
938
952
  ].slice(-900);
939
- return this.#store.apply({ groupInbound }).catch(() => undefined);
953
+ return this.#trackTask(this.#store.apply({ groupInbound }).catch(() => undefined));
940
954
  }
941
955
 
942
956
  #persistRequestState() {
@@ -944,7 +958,7 @@ export class MatrixCryptoEngine {
944
958
  if (!snapshot) return Promise.resolve();
945
959
  const requestState = {};
946
960
  for (const [key, state] of [...this.#requestState].slice(-200)) requestState[key] = { ...state };
947
- return this.#store.apply({ requestState }).catch(() => undefined);
961
+ return this.#trackTask(this.#store.apply({ requestState }).catch(() => undefined));
948
962
  }
949
963
 
950
964
  // ---- key registration ---------------------------------------------------
@@ -36,6 +36,7 @@ import {
36
36
  extractOutboundMentions,
37
37
  hasRoomMention,
38
38
  } from './matrix-rich-text.mjs';
39
+ import { chunkMarkdownParts } from '../shared/markdown-chunks.mjs';
39
40
 
40
41
  const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
41
42
  const SYNC_LONG_POLL_MS = 30_000;
@@ -917,14 +918,23 @@ export class MatrixRuntime {
917
918
  ? target.threadId : null;
918
919
  const replyToEventId = typeof target?.replyToEventId === 'string' && isMatrixEventId(target.replyToEventId)
919
920
  ? target.replyToEventId : null;
920
- const chunks = splitMessageText(text, this.#config.maxMessageLength);
921
+ const chunks = options.format === 'markdown'
922
+ ? chunkMarkdownParts(text, this.#config.maxMessageLength)
923
+ : splitMessageText(text, this.#config.maxMessageLength).map(plain => ({ markdown: plain, plain }));
921
924
  const providerMessageIds = [];
922
925
  for (const chunk of chunks) {
923
- const content = buildMatrixTextContent({
924
- text: chunk,
925
- mentionUserIds: extractOutboundMentions(chunk),
926
- roomMention: this.#config.allowRoomMentions && hasRoomMention(chunk),
927
- });
926
+ let content;
927
+ try {
928
+ content = buildMatrixTextContent({
929
+ text: chunk.markdown,
930
+ mentionUserIds: extractOutboundMentions(chunk.markdown),
931
+ roomMention: this.#config.allowRoomMentions && hasRoomMention(chunk.markdown),
932
+ });
933
+ } catch {
934
+ this.#logger.warn?.('[dsh-im:matrix] Rich text could not be prepared; sending plain text');
935
+ if (!chunk.plain) continue;
936
+ content = { msgtype: 'm.text', body: chunk.plain };
937
+ }
928
938
  applyMatrixRelations(content, { threadId, replyToEventId });
929
939
  const result = await this.#sendRoomEvent(roomId, 'm.room.message', content, {
930
940
  ...(options.signal ? { signal: options.signal } : {}),