@soimy/dingtalk 3.5.3 → 3.6.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 (39) hide show
  1. package/README.md +4 -1
  2. package/index.ts +7 -0
  3. package/openclaw.plugin.json +153 -5
  4. package/package.json +1 -1
  5. package/src/auth.ts +5 -2
  6. package/src/card/card-markdown-image-reroute.ts +106 -0
  7. package/src/card/card-run-registry.ts +54 -1
  8. package/src/card/card-stop-handler.ts +10 -20
  9. package/src/card/card-template.ts +14 -3
  10. package/src/card/statusline-renderer.ts +94 -0
  11. package/src/card-draft-controller.ts +245 -52
  12. package/src/card-service.ts +408 -23
  13. package/src/channel.ts +24 -1083
  14. package/src/config-schema.ts +21 -1
  15. package/src/config.ts +139 -66
  16. package/src/device-registration.ts +245 -0
  17. package/src/gateway/channel-gateway.ts +637 -0
  18. package/src/inbound-handler.ts +1276 -975
  19. package/src/media-utils.ts +6 -0
  20. package/src/message-context-store.ts +183 -85
  21. package/src/message-utils.ts +124 -16
  22. package/src/messaging/btw-deliver.ts +85 -0
  23. package/src/messaging/channel-actions.ts +174 -0
  24. package/src/messaging/channel-outbound.ts +158 -0
  25. package/src/onboarding.ts +333 -235
  26. package/src/path-utils.ts +49 -0
  27. package/src/platform/channel-status.ts +81 -0
  28. package/src/reply-strategy-card.ts +373 -64
  29. package/src/reply-strategy-markdown.ts +1 -1
  30. package/src/reply-strategy-types.ts +93 -0
  31. package/src/reply-strategy-with-reaction.ts +1 -1
  32. package/src/reply-strategy.ts +14 -72
  33. package/src/run-usage-store.ts +59 -0
  34. package/src/secret-input.ts +216 -0
  35. package/src/send-service.ts +115 -3
  36. package/src/session-state.ts +62 -0
  37. package/src/targeting/agent-name-matcher.ts +28 -0
  38. package/src/targeting/agent-routing.ts +30 -5
  39. package/src/types.ts +48 -157
@@ -534,6 +534,12 @@ export function resolveOutboundMediaType(params: {
534
534
  return explicitType;
535
535
  }
536
536
 
537
+ // Audio files default to "file" (attachment) unless asVoice is explicitly set.
538
+ // This prevents mp3/wav/ogg/amr from being sent as voice messages unexpectedly.
539
+ if (detectedType === "voice") {
540
+ return "file";
541
+ }
542
+
537
543
  return detectedType;
538
544
  }
539
545
 
@@ -11,8 +11,17 @@ export const DEFAULT_CREATED_AT_MATCH_WINDOW_MS = 2000;
11
11
  const MAX_RECORDS_PER_SCOPE = 1000;
12
12
 
13
13
  export type MessageContextDirection = "inbound" | "outbound";
14
- export type MessageAliasKind = "inboundMsgId" | "messageId" | "processQueryKey" | "outTrackId" | "cardInstanceId";
15
- export type MessageDeliveryKind = "session" | "proactive-text" | "proactive-card" | "proactive-media";
14
+ export type MessageAliasKind =
15
+ | "inboundMsgId"
16
+ | "messageId"
17
+ | "processQueryKey"
18
+ | "outTrackId"
19
+ | "cardInstanceId";
20
+ export type MessageDeliveryKind =
21
+ | "session"
22
+ | "proactive-text"
23
+ | "proactive-card"
24
+ | "proactive-media";
16
25
  export const DEFAULT_OUTBOUND_SENDER = {
17
26
  senderId: "bot",
18
27
  senderName: "OpenClaw",
@@ -47,6 +56,7 @@ export interface MessageRecord {
47
56
  quotedMessageId?: string;
48
57
  media?: {
49
58
  downloadCode?: string;
59
+ downloadCodes?: string[];
50
60
  spaceId?: string;
51
61
  fileId?: string;
52
62
  };
@@ -96,6 +106,7 @@ interface BaseUpsertParams {
96
106
  quotedMessageId?: string;
97
107
  media?: {
98
108
  downloadCode?: string;
109
+ downloadCodes?: string[];
99
110
  spaceId?: string;
100
111
  fileId?: string;
101
112
  };
@@ -120,7 +131,11 @@ interface ScopeParams {
120
131
  const stateCache = new Map<string, MessageContextState>();
121
132
 
122
133
  function getScopeKey(params: ScopeParams): string {
123
- return JSON.stringify([params.storePath || "__memory__", params.accountId, params.conversationId || null]);
134
+ return JSON.stringify([
135
+ params.storePath || "__memory__",
136
+ params.accountId,
137
+ params.conversationId || null,
138
+ ]);
124
139
  }
125
140
 
126
141
  function fallbackState(): MessageContextState {
@@ -145,19 +160,26 @@ function normalizeMedia(value: unknown): MessageRecord["media"] | undefined {
145
160
  if (!candidate) {
146
161
  return undefined;
147
162
  }
148
- const downloadCode = typeof candidate.downloadCode === "string" && candidate.downloadCode.trim()
149
- ? candidate.downloadCode.trim()
150
- : undefined;
151
- const spaceId = typeof candidate.spaceId === "string" && candidate.spaceId.trim()
152
- ? candidate.spaceId.trim()
153
- : undefined;
154
- const fileId = typeof candidate.fileId === "string" && candidate.fileId.trim()
155
- ? candidate.fileId.trim()
156
- : undefined;
157
- if (!downloadCode && !spaceId && !fileId) {
163
+ const downloadCode =
164
+ typeof candidate.downloadCode === "string" && candidate.downloadCode.trim()
165
+ ? candidate.downloadCode.trim()
166
+ : undefined;
167
+ const downloadCodes =
168
+ Array.isArray(candidate.downloadCodes) && candidate.downloadCodes.length > 0
169
+ ? candidate.downloadCodes.filter((c: unknown) => typeof c === "string" && c.trim())
170
+ : undefined;
171
+ const spaceId =
172
+ typeof candidate.spaceId === "string" && candidate.spaceId.trim()
173
+ ? candidate.spaceId.trim()
174
+ : undefined;
175
+ const fileId =
176
+ typeof candidate.fileId === "string" && candidate.fileId.trim()
177
+ ? candidate.fileId.trim()
178
+ : undefined;
179
+ if (!downloadCode && (!downloadCodes || downloadCodes.length === 0) && !spaceId && !fileId) {
158
180
  return undefined;
159
181
  }
160
- return { downloadCode, spaceId, fileId };
182
+ return { downloadCode, downloadCodes, spaceId, fileId };
161
183
  }
162
184
 
163
185
  function normalizeQuotedRef(value: unknown): QuotedRef | undefined {
@@ -180,7 +202,9 @@ function normalizeQuotedRef(value: unknown): QuotedRef | undefined {
180
202
  ? candidate.key
181
203
  : undefined;
182
204
  const valueString =
183
- typeof candidate.value === "string" && candidate.value.trim() ? candidate.value.trim() : undefined;
205
+ typeof candidate.value === "string" && candidate.value.trim()
206
+ ? candidate.value.trim()
207
+ : undefined;
184
208
  const fallbackCreatedAt =
185
209
  typeof candidate.fallbackCreatedAt === "number" && Number.isFinite(candidate.fallbackCreatedAt)
186
210
  ? candidate.fallbackCreatedAt
@@ -213,21 +237,26 @@ function normalizeDelivery(value: unknown): MessageRecord["delivery"] | undefine
213
237
  if (!candidate) {
214
238
  return undefined;
215
239
  }
216
- const messageId = typeof candidate.messageId === "string" && candidate.messageId.trim()
217
- ? candidate.messageId.trim()
218
- : undefined;
219
- const processQueryKey = typeof candidate.processQueryKey === "string" && candidate.processQueryKey.trim()
220
- ? candidate.processQueryKey.trim()
221
- : undefined;
222
- const outTrackId = typeof candidate.outTrackId === "string" && candidate.outTrackId.trim()
223
- ? candidate.outTrackId.trim()
224
- : undefined;
225
- const cardInstanceId = typeof candidate.cardInstanceId === "string" && candidate.cardInstanceId.trim()
226
- ? candidate.cardInstanceId.trim()
227
- : undefined;
228
- const kind = typeof candidate.kind === "string" && candidate.kind.trim()
229
- ? (candidate.kind.trim() as MessageDeliveryKind)
230
- : undefined;
240
+ const messageId =
241
+ typeof candidate.messageId === "string" && candidate.messageId.trim()
242
+ ? candidate.messageId.trim()
243
+ : undefined;
244
+ const processQueryKey =
245
+ typeof candidate.processQueryKey === "string" && candidate.processQueryKey.trim()
246
+ ? candidate.processQueryKey.trim()
247
+ : undefined;
248
+ const outTrackId =
249
+ typeof candidate.outTrackId === "string" && candidate.outTrackId.trim()
250
+ ? candidate.outTrackId.trim()
251
+ : undefined;
252
+ const cardInstanceId =
253
+ typeof candidate.cardInstanceId === "string" && candidate.cardInstanceId.trim()
254
+ ? candidate.cardInstanceId.trim()
255
+ : undefined;
256
+ const kind =
257
+ typeof candidate.kind === "string" && candidate.kind.trim()
258
+ ? (candidate.kind.trim() as MessageDeliveryKind)
259
+ : undefined;
231
260
  if (!messageId && !processQueryKey && !outTrackId && !cardInstanceId && !kind) {
232
261
  return undefined;
233
262
  }
@@ -248,30 +277,46 @@ function normalizeMessageRecord(value: unknown): MessageRecord | null {
248
277
  if (!candidate) {
249
278
  return null;
250
279
  }
251
- const msgId = typeof candidate.msgId === "string" && candidate.msgId.trim() ? candidate.msgId.trim() : "";
252
- const direction = candidate.direction === "outbound" ? "outbound" : candidate.direction === "inbound" ? "inbound" : null;
253
- const accountId = typeof candidate.accountId === "string" && candidate.accountId.trim()
254
- ? candidate.accountId.trim()
255
- : "";
256
- const conversationId = typeof candidate.conversationId === "string" && candidate.conversationId.trim()
257
- ? candidate.conversationId.trim()
258
- : null;
259
- const createdAt = typeof candidate.createdAt === "number" && Number.isFinite(candidate.createdAt)
260
- ? candidate.createdAt
261
- : NaN;
262
- const updatedAt = typeof candidate.updatedAt === "number" && Number.isFinite(candidate.updatedAt)
263
- ? candidate.updatedAt
264
- : Date.now();
280
+ const msgId =
281
+ typeof candidate.msgId === "string" && candidate.msgId.trim() ? candidate.msgId.trim() : "";
282
+ const direction =
283
+ candidate.direction === "outbound"
284
+ ? "outbound"
285
+ : candidate.direction === "inbound"
286
+ ? "inbound"
287
+ : null;
288
+ const accountId =
289
+ typeof candidate.accountId === "string" && candidate.accountId.trim()
290
+ ? candidate.accountId.trim()
291
+ : "";
292
+ const conversationId =
293
+ typeof candidate.conversationId === "string" && candidate.conversationId.trim()
294
+ ? candidate.conversationId.trim()
295
+ : null;
296
+ const createdAt =
297
+ typeof candidate.createdAt === "number" && Number.isFinite(candidate.createdAt)
298
+ ? candidate.createdAt
299
+ : NaN;
300
+ const updatedAt =
301
+ typeof candidate.updatedAt === "number" && Number.isFinite(candidate.updatedAt)
302
+ ? candidate.updatedAt
303
+ : Date.now();
265
304
  if (!msgId || !direction || !accountId || !Number.isFinite(createdAt)) {
266
305
  return null;
267
306
  }
268
- const expiresAt = typeof candidate.expiresAt === "number" && Number.isFinite(candidate.expiresAt)
269
- ? candidate.expiresAt
270
- : undefined;
307
+ const expiresAt =
308
+ typeof candidate.expiresAt === "number" && Number.isFinite(candidate.expiresAt)
309
+ ? candidate.expiresAt
310
+ : undefined;
271
311
  return {
272
312
  msgId,
273
313
  direction,
274
- topic: candidate.topic === null ? null : typeof candidate.topic === "string" ? candidate.topic : null,
314
+ topic:
315
+ candidate.topic === null
316
+ ? null
317
+ : typeof candidate.topic === "string"
318
+ ? candidate.topic
319
+ : null,
275
320
  accountId,
276
321
  conversationId,
277
322
  createdAt,
@@ -279,16 +324,26 @@ function normalizeMessageRecord(value: unknown): MessageRecord | null {
279
324
  expiresAt,
280
325
  messageType: typeof candidate.messageType === "string" ? candidate.messageType : undefined,
281
326
  text: typeof candidate.text === "string" ? candidate.text : undefined,
282
- attachmentText: typeof candidate.attachmentText === "string" ? candidate.attachmentText : undefined,
327
+ attachmentText:
328
+ typeof candidate.attachmentText === "string" ? candidate.attachmentText : undefined,
283
329
  attachmentTextSource: normalizeAttachmentTextSource(candidate.attachmentTextSource),
284
330
  attachmentTextTruncated: candidate.attachmentTextTruncated === true ? true : undefined,
285
331
  attachmentFileName:
286
332
  typeof candidate.attachmentFileName === "string" ? candidate.attachmentFileName : undefined,
287
333
  quotedRef: normalizeQuotedRef(candidate.quotedRef),
288
- senderId: typeof candidate.senderId === "string" && candidate.senderId.trim() ? candidate.senderId.trim() : undefined,
289
- senderName: typeof candidate.senderName === "string" && candidate.senderName.trim() ? candidate.senderName.trim() : undefined,
334
+ senderId:
335
+ typeof candidate.senderId === "string" && candidate.senderId.trim()
336
+ ? candidate.senderId.trim()
337
+ : undefined,
338
+ senderName:
339
+ typeof candidate.senderName === "string" && candidate.senderName.trim()
340
+ ? candidate.senderName.trim()
341
+ : undefined,
290
342
  mentions: normalizeMentions(candidate.mentions),
291
- chatType: candidate.chatType === "direct" || candidate.chatType === "group" ? candidate.chatType : undefined,
343
+ chatType:
344
+ candidate.chatType === "direct" || candidate.chatType === "group"
345
+ ? candidate.chatType
346
+ : undefined,
292
347
  quotedMessageId:
293
348
  typeof candidate.quotedMessageId === "string" && candidate.quotedMessageId.trim()
294
349
  ? candidate.quotedMessageId.trim()
@@ -327,10 +382,17 @@ function buildAliasEntries(record: MessageRecord): Array<[string, string]> {
327
382
  }
328
383
 
329
384
  function isRecordExpired(record: MessageRecord, nowMs: number): boolean {
330
- return typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt) && nowMs >= record.expiresAt;
385
+ return (
386
+ typeof record.expiresAt === "number" &&
387
+ Number.isFinite(record.expiresAt) &&
388
+ nowMs >= record.expiresAt
389
+ );
331
390
  }
332
391
 
333
- function normalizeState(state: MessageContextState, nowMs: number): { state: MessageContextState; removed: number } {
392
+ function normalizeState(
393
+ state: MessageContextState,
394
+ nowMs: number,
395
+ ): { state: MessageContextState; removed: number } {
334
396
  const normalizedRecords: Record<string, MessageRecord> = {};
335
397
  const sortedRecords = Object.values(state.records)
336
398
  .map((record) => normalizeMessageRecord(record))
@@ -363,12 +425,15 @@ function hydrateState(params: ScopeParams, nowMs: number): MessageContextState {
363
425
  if (!params.storePath) {
364
426
  return fallbackState();
365
427
  }
366
- const persisted = readNamespaceJson<Partial<PersistedMessageContextState>>(MESSAGE_CONTEXT_NAMESPACE, {
367
- storePath: params.storePath,
368
- scope: { accountId: params.accountId, conversationId: params.conversationId || undefined },
369
- format: "json",
370
- fallback: { version: MESSAGE_CONTEXT_VERSION, updatedAt: Date.now(), records: {} },
371
- });
428
+ const persisted = readNamespaceJson<Partial<PersistedMessageContextState>>(
429
+ MESSAGE_CONTEXT_NAMESPACE,
430
+ {
431
+ storePath: params.storePath,
432
+ scope: { accountId: params.accountId, conversationId: params.conversationId || undefined },
433
+ format: "json",
434
+ fallback: { version: MESSAGE_CONTEXT_VERSION, updatedAt: Date.now(), records: {} },
435
+ },
436
+ );
372
437
  const parsedRecords = asRecord(persisted.records) || {};
373
438
  const hydrated = fallbackState();
374
439
  hydrated.updatedAt = typeof persisted.updatedAt === "number" ? persisted.updatedAt : Date.now();
@@ -426,14 +491,20 @@ function mergeText(existing: string | undefined, next: string | undefined): stri
426
491
  return next;
427
492
  }
428
493
 
429
- function mergeAttachmentText(existing: string | undefined, next: string | undefined): string | undefined {
494
+ function mergeAttachmentText(
495
+ existing: string | undefined,
496
+ next: string | undefined,
497
+ ): string | undefined {
430
498
  if (typeof next !== "string") {
431
499
  return existing;
432
500
  }
433
501
  return next;
434
502
  }
435
503
 
436
- function mergeQuotedRef(existing: QuotedRef | undefined, next: QuotedRef | undefined): QuotedRef | undefined {
504
+ function mergeQuotedRef(
505
+ existing: QuotedRef | undefined,
506
+ next: QuotedRef | undefined,
507
+ ): QuotedRef | undefined {
437
508
  if (!existing) {
438
509
  return next;
439
510
  }
@@ -448,14 +519,20 @@ function mergeQuotedRef(existing: QuotedRef | undefined, next: QuotedRef | undef
448
519
  };
449
520
  }
450
521
 
451
- function mergeStringField(existing: string | undefined, next: string | undefined): string | undefined {
522
+ function mergeStringField(
523
+ existing: string | undefined,
524
+ next: string | undefined,
525
+ ): string | undefined {
452
526
  if (typeof next !== "string" || !next.trim()) {
453
527
  return existing;
454
528
  }
455
529
  return next.trim();
456
530
  }
457
531
 
458
- function mergeMentions(existing: string[] | undefined, next: string[] | undefined): string[] | undefined {
532
+ function mergeMentions(
533
+ existing: string[] | undefined,
534
+ next: string[] | undefined,
535
+ ): string[] | undefined {
459
536
  if (!next) {
460
537
  return existing;
461
538
  }
@@ -474,6 +551,7 @@ function mergeMedia(
474
551
  }
475
552
  return {
476
553
  downloadCode: next.downloadCode || existing.downloadCode,
554
+ downloadCodes: next.downloadCodes || existing.downloadCodes,
477
555
  spaceId: next.spaceId || existing.spaceId,
478
556
  fileId: next.fileId || existing.fileId,
479
557
  };
@@ -512,7 +590,10 @@ function mergeAttachmentTextTruncated(
512
590
  return next === undefined ? existing : next;
513
591
  }
514
592
 
515
- function mergeAttachmentFileName(existing: string | undefined, next: string | undefined): string | undefined {
593
+ function mergeAttachmentFileName(
594
+ existing: string | undefined,
595
+ next: string | undefined,
596
+ ): string | undefined {
516
597
  if (typeof next !== "string") {
517
598
  return existing;
518
599
  }
@@ -521,7 +602,11 @@ function mergeAttachmentFileName(existing: string | undefined, next: string | un
521
602
 
522
603
  function resolveExistingMsgId(
523
604
  state: MessageContextState,
524
- params: { direction: MessageContextDirection; msgId?: string; delivery?: MessageRecord["delivery"] },
605
+ params: {
606
+ direction: MessageContextDirection;
607
+ msgId?: string;
608
+ delivery?: MessageRecord["delivery"];
609
+ },
525
610
  nowMs: number,
526
611
  ): string | undefined {
527
612
  if (params.direction === "inbound" && params.msgId) {
@@ -556,11 +641,19 @@ function resolveExistingMsgId(
556
641
  return undefined;
557
642
  }
558
643
 
559
- function computeExpiresAt(nowMs: number, ttlMs?: number, ttlReferenceMs?: number): number | undefined {
644
+ function computeExpiresAt(
645
+ nowMs: number,
646
+ ttlMs?: number,
647
+ ttlReferenceMs?: number,
648
+ ): number | undefined {
560
649
  if (typeof ttlMs !== "number" || !Number.isFinite(ttlMs) || ttlMs <= 0) {
561
650
  return undefined;
562
651
  }
563
- return (typeof ttlReferenceMs === "number" && Number.isFinite(ttlReferenceMs) ? ttlReferenceMs : nowMs) + ttlMs;
652
+ return (
653
+ (typeof ttlReferenceMs === "number" && Number.isFinite(ttlReferenceMs)
654
+ ? ttlReferenceMs
655
+ : nowMs) + ttlMs
656
+ );
564
657
  }
565
658
 
566
659
  function pruneStateByCreatedAt(
@@ -622,11 +715,15 @@ function upsertRecord(
622
715
  if (params.cleanupCreatedAtTtlDays && params.cleanupCreatedAtTtlDays > 0) {
623
716
  state = pruneStateByCreatedAt(state, params.cleanupCreatedAtTtlDays, nowMs).state;
624
717
  }
625
- const existingMsgId = resolveExistingMsgId(state, {
626
- direction: params.direction,
627
- msgId: params.msgId,
628
- delivery: params.delivery,
629
- }, nowMs);
718
+ const existingMsgId = resolveExistingMsgId(
719
+ state,
720
+ {
721
+ direction: params.direction,
722
+ msgId: params.msgId,
723
+ delivery: params.delivery,
724
+ },
725
+ nowMs,
726
+ );
630
727
  const canonicalMsgId =
631
728
  existingMsgId ||
632
729
  params.msgId ||
@@ -648,9 +745,7 @@ function upsertRecord(
648
745
  createdAt: existing?.createdAt ?? params.createdAt,
649
746
  updatedAt: nowMs,
650
747
  expiresAt:
651
- expiresAt === undefined
652
- ? existing?.expiresAt
653
- : Math.max(expiresAt, existing?.expiresAt ?? 0),
748
+ expiresAt === undefined ? existing?.expiresAt : Math.max(expiresAt, existing?.expiresAt ?? 0),
654
749
  messageType: params.messageType || existing?.messageType,
655
750
  text: mergeText(existing?.text, params.text),
656
751
  attachmentText: mergeAttachmentText(existing?.attachmentText, params.attachmentText),
@@ -692,7 +787,9 @@ export function upsertInboundMessageContext(params: UpsertInboundMessageContextP
692
787
  );
693
788
  }
694
789
 
695
- export function upsertOutboundMessageContext(params: UpsertOutboundMessageContextParams): string | undefined {
790
+ export function upsertOutboundMessageContext(
791
+ params: UpsertOutboundMessageContextParams,
792
+ ): string | undefined {
696
793
  return upsertRecord({
697
794
  ...params,
698
795
  direction: "outbound",
@@ -780,7 +877,10 @@ export function resolveByQuotedRef(
780
877
  `[DingTalk][QuotedRef] Resolve outbound by ${quotedRef.key}=${quotedRef.value} hit=no accountId=${params.accountId} conversationId=${params.conversationId || "(none)"}`,
781
878
  );
782
879
  }
783
- if (typeof quotedRef.fallbackCreatedAt === "number" && Number.isFinite(quotedRef.fallbackCreatedAt)) {
880
+ if (
881
+ typeof quotedRef.fallbackCreatedAt === "number" &&
882
+ Number.isFinite(quotedRef.fallbackCreatedAt)
883
+ ) {
784
884
  const record = resolveByCreatedAtWindow({
785
885
  ...params,
786
886
  createdAt: quotedRef.fallbackCreatedAt,
@@ -828,9 +928,7 @@ export function resolveByCreatedAtWindow(
828
928
  return bestRecord;
829
929
  }
830
930
 
831
- export function cleanupExpiredMessageContexts(
832
- params: ScopeParams & { nowMs?: number },
833
- ): number {
931
+ export function cleanupExpiredMessageContexts(params: ScopeParams & { nowMs?: number }): number {
834
932
  const nowMs = params.nowMs ?? Date.now();
835
933
  const state = loadState(params, nowMs);
836
934
  const beforeCount = Object.keys(state.records).length;
@@ -850,12 +948,12 @@ export function clearMessageContextCacheForTest(): void {
850
948
  /**
851
949
  * Lists non-expired message-context records for one account/conversation scope in createdAt ascending order.
852
950
  */
853
- export function listMessageContexts(
854
- params: ScopeParams & { nowMs?: number },
855
- ): MessageRecord[] {
951
+ export function listMessageContexts(params: ScopeParams & { nowMs?: number }): MessageRecord[] {
856
952
  const nowMs = params.nowMs ?? Date.now();
857
953
  const state = loadState(params, nowMs);
858
954
  return state.recentByCreatedAt
859
955
  .map((msgId) => state.records[msgId])
860
- .filter((record): record is MessageRecord => Boolean(record) && !isRecordExpired(record, nowMs));
956
+ .filter(
957
+ (record): record is MessageRecord => Boolean(record) && !isRecordExpired(record, nowMs),
958
+ );
861
959
  }
@@ -1,11 +1,18 @@
1
- import type { AtMention, DingTalkInboundMessage, MessageContent, QuotedInfo, SendMessageOptions } from "./types";
2
-
1
+ import type {
2
+ AtMention,
3
+ DingTalkInboundMessage,
4
+ MessageContent,
5
+ QuotedInfo,
6
+ SendMessageOptions,
7
+ } from "./types";
3
8
 
4
9
  interface DingTalkDocMeta {
5
10
  spaceId: string;
6
11
  fileId: string;
7
12
  }
8
13
 
14
+ const UNKNOWN_PERSON_LABEL = "某人";
15
+
9
16
  function parseBizCustomActionUrl(url: string | undefined): DingTalkDocMeta | null {
10
17
  if (!url || typeof url !== "string") {
11
18
  return null;
@@ -76,7 +83,7 @@ function extractRichTextQuoteParts(
76
83
  ? part.atName
77
84
  : typeof textValue === "string"
78
85
  ? textValue
79
- : "某人";
86
+ : UNKNOWN_PERSON_LABEL;
80
87
  textParts.push(`@${atName}`);
81
88
  continue;
82
89
  }
@@ -94,7 +101,8 @@ function extractRichTextQuoteParts(
94
101
  return {
95
102
  summary,
96
103
  pictureDownloadCode,
97
- pictureDownloadCodes: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
104
+ pictureDownloadCodes:
105
+ uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
98
106
  };
99
107
  }
100
108
 
@@ -115,7 +123,101 @@ function trimString(value: string | undefined): string | undefined {
115
123
  return trimmed.length > 0 ? trimmed : undefined;
116
124
  }
117
125
 
118
- function buildQuotedMessageTypePlaceholder(messageType: string | undefined, fileName?: string): string | undefined {
126
+ const MAX_CHAT_RECORD_ENTRIES = 30;
127
+
128
+ function stringifyChatRecordContentValue(value: unknown): string | undefined {
129
+ if (typeof value === "string") {
130
+ return trimString(value);
131
+ }
132
+ if (!value || typeof value !== "object") {
133
+ return undefined;
134
+ }
135
+ const record = value as Record<string, unknown>;
136
+ const content = record.content;
137
+ const contentText =
138
+ typeof content === "string"
139
+ ? trimString(content)
140
+ : content && typeof content === "object"
141
+ ? trimString((content as Record<string, unknown>).text as string | undefined)
142
+ : undefined;
143
+
144
+ // Preserve DingTalk's observed chatRecord text priority: explicit text first,
145
+ // then nested content text, then the legacy message fallback.
146
+ return (
147
+ trimString(record.text as string | undefined) ||
148
+ contentText ||
149
+ trimString(record.message as string | undefined)
150
+ );
151
+ }
152
+
153
+ function getChatRecordEntriesSource(content: Record<string, unknown> | undefined): unknown {
154
+ return content?.chatRecord ?? content?.records ?? content?.messages;
155
+ }
156
+
157
+ function formatChatRecordEntries(rawRecord: unknown): string[] {
158
+ let entries = rawRecord;
159
+ if (typeof rawRecord === "string") {
160
+ const trimmed = rawRecord.trim();
161
+ if (!trimmed || trimmed === "[]") {
162
+ return [];
163
+ }
164
+ try {
165
+ entries = JSON.parse(trimmed);
166
+ } catch {
167
+ return [];
168
+ }
169
+ }
170
+ if (!Array.isArray(entries)) {
171
+ return [];
172
+ }
173
+ return entries
174
+ .map((entry) => {
175
+ if (!entry || typeof entry !== "object") {
176
+ return undefined;
177
+ }
178
+ const record = entry as Record<string, unknown>;
179
+ const sender =
180
+ trimString(record.senderName as string | undefined) ||
181
+ trimString(record.senderNick as string | undefined) ||
182
+ trimString(record.sender as string | undefined) ||
183
+ trimString(record.senderId as string | undefined) ||
184
+ UNKNOWN_PERSON_LABEL;
185
+ const body = stringifyChatRecordContentValue(
186
+ record.content ?? record.text ?? record.message ?? record.body,
187
+ );
188
+ return body ? `${sender}: ${body}` : undefined;
189
+ })
190
+ .filter((line): line is string => Boolean(line))
191
+ .slice(0, MAX_CHAT_RECORD_ENTRIES);
192
+ }
193
+
194
+ function formatChatRecordPreview(
195
+ content: Record<string, unknown> | undefined,
196
+ options: { useTitleAsLabel?: boolean } = {},
197
+ ): string | undefined {
198
+ const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
199
+ const title = typeof content?.title === "string" ? content.title.trim() : "";
200
+ const rawRecord = getChatRecordEntriesSource(content);
201
+ const recordLines = formatChatRecordEntries(rawRecord);
202
+ const parts: string[] = [];
203
+ if (summary && summary !== "[]") {
204
+ const label = options.useTitleAsLabel
205
+ ? title
206
+ ? `[${title}] `
207
+ : "[聊天记录] "
208
+ : "[聊天记录摘要] ";
209
+ parts.push(`${label}${summary}`);
210
+ }
211
+ if (recordLines.length > 0) {
212
+ parts.push(`[聊天记录内容]\n${recordLines.join("\n")}`);
213
+ }
214
+ return parts.join("\n\n") || undefined;
215
+ }
216
+
217
+ function buildQuotedMessageTypePlaceholder(
218
+ messageType: string | undefined,
219
+ fileName?: string,
220
+ ): string | undefined {
119
221
  switch (messageType) {
120
222
  case "text":
121
223
  return undefined;
@@ -147,8 +249,7 @@ function buildLegacyQuoteMessagePreview(message: DingTalkInboundMessage["quoteMe
147
249
  const previewMessageType = trimString(message?.msgtype);
148
250
  return {
149
251
  previewText:
150
- trimString(message?.text?.content) ||
151
- buildQuotedMessageTypePlaceholder(previewMessageType),
252
+ trimString(message?.text?.content) || buildQuotedMessageTypePlaceholder(previewMessageType),
152
253
  previewMessageType,
153
254
  previewSenderId: trimString(message?.senderId),
154
255
  };
@@ -202,7 +303,10 @@ function buildRepliedMessagePreview(params: {
202
303
  return {
203
304
  isQuotedFile: true,
204
305
  fileCreatedAt: repliedMsg.createdAt,
205
- previewText: buildQuotedMessageTypePlaceholder(repliedMsgType, hasFileName ? fileName : undefined),
306
+ previewText: buildQuotedMessageTypePlaceholder(
307
+ repliedMsgType,
308
+ hasFileName ? fileName : undefined,
309
+ ),
206
310
  previewMessageType: repliedMsgType,
207
311
  ...(hasFileName ? { previewFileName: fileName } : {}),
208
312
  previewSenderId: trimString(repliedMsg.senderId),
@@ -235,11 +339,11 @@ function buildRepliedMessagePreview(params: {
235
339
  }
236
340
 
237
341
  if (repliedMsgType === "chatRecord") {
238
- const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
239
- const title = typeof content?.title === "string" ? content.title.trim() : "";
240
- const chatRecordLabel = title ? `[${title}] ` : "[聊天记录] ";
241
342
  return {
242
- previewText: summary ? `${chatRecordLabel}${summary}` : buildQuotedMessageTypePlaceholder("chatRecord"),
343
+ previewText:
344
+ formatChatRecordPreview(content as Record<string, unknown> | undefined, {
345
+ useTitleAsLabel: true,
346
+ }) || buildQuotedMessageTypePlaceholder("chatRecord"),
243
347
  previewMessageType: "chatRecord",
244
348
  previewSenderId: trimString(repliedMsg.senderId),
245
349
  };
@@ -517,7 +621,10 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
517
621
  mediaPath: pictureDownloadCode,
518
622
  mediaPaths: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes : undefined,
519
623
  mediaType: pictureDownloadCode ? "image" : undefined,
520
- mediaTypes: uniquePictureDownloadCodes.length > 0 ? uniquePictureDownloadCodes.map(() => "image") : undefined,
624
+ mediaTypes:
625
+ uniquePictureDownloadCodes.length > 0
626
+ ? uniquePictureDownloadCodes.map(() => "image")
627
+ : undefined,
521
628
  messageType: "richText",
522
629
  quoted: quoted ?? undefined,
523
630
  atMentions,
@@ -605,7 +712,8 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
605
712
  if (msgtype === "chatRecord") {
606
713
  const content = data.content as Record<string, unknown> | undefined;
607
714
  const summary = typeof content?.summary === "string" ? content.summary.trim() : "";
608
- const rawRecord = content?.chatRecord;
715
+ const rawRecord = getChatRecordEntriesSource(content);
716
+ const chatRecordText = formatChatRecordPreview(content);
609
717
  if (
610
718
  summary === "[]" ||
611
719
  (typeof rawRecord === "string" && rawRecord.trim() === "[]") ||
@@ -619,9 +727,9 @@ export function extractMessageContent(data: DingTalkInboundMessage): MessageCont
619
727
  atUserDingtalkIds,
620
728
  };
621
729
  }
622
- if (summary) {
730
+ if (chatRecordText) {
623
731
  return {
624
- text: `[聊天记录摘要] ${summary}`,
732
+ text: chatRecordText,
625
733
  messageType: "chatRecord",
626
734
  quoted: quoted ?? undefined,
627
735
  atMentions,