@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
@@ -4,33 +4,51 @@
4
4
  * The controller keeps a single rendered card timeline made of:
5
5
  * - sealed process blocks (`thinking` / `tool`)
6
6
  * - an optional live thinking block
7
- * - accumulated answer turns rendered as plain markdown
7
+ * - accumulated answer turns rendered as JSON CardBlock[]
8
8
  *
9
9
  * It delegates throttling and single-flight transport guarantees to
10
10
  * {@link createDraftStreamLoop}.
11
11
  */
12
12
 
13
- import { streamAICard } from "./card-service";
13
+ import {
14
+ clearAICardStreamingContent,
15
+ streamAICardContent,
16
+ updateAICardBlockList,
17
+ } from "./card-service";
14
18
  import { createDraftStreamLoop } from "./draft-stream-loop";
15
- import type { AICardInstance, Logger } from "./types";
19
+ import type { AICardInstance, CardBlock, Logger } from "./types";
16
20
 
17
- type TimelineEntryKind = "thinking" | "tool" | "answer";
21
+ type TimelineEntryKind = "thinking" | "tool" | "answer" | "image";
18
22
 
19
23
  type TimelineEntry = {
20
24
  kind: TimelineEntryKind;
21
25
  text: string;
26
+ mediaId?: string;
22
27
  };
23
28
 
29
+ // DingTalk markdown variable token definitions:
30
+ // https://open.dingtalk.com/document/development/markdown-variable-new
31
+ const PROCESS_BLOCK_FONT_SIZE_TOKEN = "common_footnote_text_style__font_size";
32
+ // DingTalk markdown variable token definitions:
33
+ // https://open.dingtalk.com/document/development/markdown-variable-new
34
+ const PROCESS_BLOCK_FONT_COLOR_TOKEN_V2 = "common_level2_base_color";
35
+
24
36
  export interface CardDraftController {
25
- updateAnswer: (text: string, options?: { stream?: boolean }) => Promise<void>;
37
+ updateAnswer: (text: string, options?: { stream?: boolean; renderBlocks?: boolean }) => Promise<void>;
26
38
  updateReasoning: (text: string) => Promise<void>;
27
39
  updateThinking: (text: string) => Promise<void>;
28
40
  appendThinkingBlock: (text: string) => Promise<void>;
29
41
  updateTool: (text: string) => Promise<void>;
30
42
  appendTool: (text: string) => Promise<void>;
43
+ /** Append an image block (type=3) with an uploaded mediaId. */
44
+ appendImageBlock: (mediaId: string, text?: string) => Promise<void>;
31
45
  appendToolBeforeCurrentAnswer: (text: string) => Promise<void>;
46
+ /** Drop the current answer draft while keeping sealed earlier turns intact. */
47
+ discardCurrentAnswer: () => void;
32
48
  /** Signal that a new assistant turn has started (e.g. after a tool call). */
33
- notifyNewAssistantTurn: () => Promise<void>;
49
+ notifyNewAssistantTurn: (options?: {
50
+ discardActiveAnswer?: boolean;
51
+ }) => Promise<void>;
34
52
  startAssistantTurn: () => Promise<void>;
35
53
  /** Seal the active thinking entry (keep it in timeline) without removing it. */
36
54
  sealActiveThinking: () => Promise<void>;
@@ -44,12 +62,24 @@ export interface CardDraftController {
44
62
  getLastAnswerContent: () => string;
45
63
  /** Current answer-only content composed from all completed answer turns. */
46
64
  getFinalAnswerContent: () => string;
47
- /** Current rendered timeline, including process blocks and answer text. */
65
+ /** Current rendered timeline as CardBlock[] JSON string for blockList parameter. */
66
+ getRenderedBlocks: (options?: {
67
+ fallbackAnswer?: string;
68
+ overrideAnswer?: string;
69
+ compactProcessAnswerSpacing?: boolean;
70
+ }) => string;
71
+ /** Current rendered timeline as pure markdown text for content parameter and fallback. */
48
72
  getRenderedContent: (options?: {
49
73
  fallbackAnswer?: string;
50
74
  overrideAnswer?: string;
51
75
  compactProcessAnswerSpacing?: boolean;
52
76
  }) => string;
77
+ /** Stream answer text to content key for real-time display. Only available when realTimeStreamEnabled=true. */
78
+ streamContent?: (text: string) => Promise<void>;
79
+ /** Clear the streaming content key. Only available when realTimeStreamEnabled=true. */
80
+ clearStreamingContent?: () => Promise<void>;
81
+ /** Whether real-time streaming is enabled. */
82
+ isRealTimeStreamEnabled: () => boolean;
53
83
  }
54
84
 
55
85
  function normalizeProcessText(text: string | undefined): string {
@@ -60,22 +90,22 @@ function normalizeAnswerText(text: string | undefined): string {
60
90
  return typeof text === "string" ? text.trimStart() : "";
61
91
  }
62
92
 
63
- function quoteMarkdown(text: string): string {
64
- return text
65
- .split("\n")
66
- .map((line) => line.trim() ? `> ${line.trim()}` : ">")
93
+ function wrapProcessBlockMarkdown(text: string): string {
94
+ const lines = text.split("\n").filter((line) => line.trim());
95
+ return lines
96
+ .map((line) => `> <font sizeToken=${PROCESS_BLOCK_FONT_SIZE_TOKEN} colorTokenV2=${PROCESS_BLOCK_FONT_COLOR_TOKEN_V2}>${line}</font>`)
67
97
  .join("\n");
68
98
  }
69
99
 
70
- function renderProcessBlock(_kind: "thinking" | "tool", text: string): string {
71
- return quoteMarkdown(text);
72
- }
73
-
74
100
  export function createCardDraftController(params: {
75
101
  card: AICardInstance;
76
102
  throttleMs?: number;
77
103
  /** Legacy compatibility: verbose mode previously lowered the throttle. */
78
104
  verboseMode?: boolean;
105
+ /** Enable real-time streaming to content key for answer display. */
106
+ realTimeStreamEnabled?: boolean;
107
+ /** Optional callback to get the current statusLine for piggy-backing on blockList updates. */
108
+ getStatusLine?: () => string | undefined;
79
109
  log?: Logger;
80
110
  }): CardDraftController {
81
111
  let failed = false;
@@ -84,6 +114,9 @@ export function createCardDraftController(params: {
84
114
  let lastQueuedContent = "";
85
115
  let inFlightContent = "";
86
116
  let lastAnswerContent = "";
117
+ let lastSentStreamingContent = "";
118
+ let lastQueuedStreamingContent = "";
119
+ let inFlightStreamingContent = "";
87
120
 
88
121
  let timelineEntries: TimelineEntry[] = [];
89
122
  let activeThinkingIndex: number | null = null;
@@ -91,6 +124,52 @@ export function createCardDraftController(params: {
91
124
  let pendingBoundaryPromise: Promise<void> | null = null;
92
125
 
93
126
  const effectiveThrottleMs = params.throttleMs ?? (params.verboseMode ? 50 : 300);
127
+ const realTimeStreamEnabled = params.realTimeStreamEnabled ?? false;
128
+ let hasStreamingContent = false;
129
+
130
+ const clearPendingStreamingContent = () => {
131
+ contentLoop.resetPending();
132
+ lastQueuedStreamingContent = "";
133
+ };
134
+
135
+ const streamContentToCard = async (text: string) => {
136
+ if (!realTimeStreamEnabled) {
137
+ return;
138
+ }
139
+ const normalized = normalizeAnswerText(text);
140
+ if (!normalized.trim()) {
141
+ clearPendingStreamingContent();
142
+ return;
143
+ }
144
+ if (normalized === lastSentStreamingContent) {
145
+ const hasNewerInFlight = !!inFlightStreamingContent && inFlightStreamingContent !== normalized;
146
+ if (!hasNewerInFlight) {
147
+ clearPendingStreamingContent();
148
+ return;
149
+ }
150
+ }
151
+ if (normalized === lastQueuedStreamingContent) {
152
+ return;
153
+ }
154
+ lastQueuedStreamingContent = normalized;
155
+ contentLoop.update(normalized);
156
+ };
157
+
158
+ const clearStreamingContentFromCard = async () => {
159
+ clearPendingStreamingContent();
160
+ await contentLoop.waitForInFlight();
161
+ if (!realTimeStreamEnabled || !hasStreamingContent) {
162
+ return;
163
+ }
164
+ try {
165
+ await clearAICardStreamingContent(params.card, params.log);
166
+ hasStreamingContent = false;
167
+ lastSentStreamingContent = "";
168
+ } catch (err: unknown) {
169
+ const message = err instanceof Error ? err.message : String(err);
170
+ params.log?.debug?.(`[DingTalk][AICard] Failed to clear streaming content: ${message}`);
171
+ }
172
+ };
94
173
 
95
174
  const getFinalAnswerContent = (): string => {
96
175
  return timelineEntries
@@ -135,13 +214,21 @@ export function createCardDraftController(params: {
135
214
  return null;
136
215
  };
137
216
 
138
- const renderTimeline = (options: {
217
+ const renderTimelineAsBlocks = (options: {
139
218
  fallbackAnswer?: string;
140
219
  overrideAnswer?: string;
141
- compactProcessAnswerSpacing?: boolean;
142
- } = {}): string => {
220
+ } = {}): CardBlock[] => {
143
221
  const entries = timelineEntries.map((entry) => ({ ...entry }));
144
222
 
223
+ const insertAnswerEntry = (text: string) => {
224
+ const firstImageIndex = entries.findIndex((entry) => entry.kind === "image");
225
+ if (firstImageIndex >= 0) {
226
+ entries.splice(firstImageIndex, 0, { kind: "answer", text });
227
+ return;
228
+ }
229
+ entries.push({ kind: "answer", text });
230
+ };
231
+
145
232
  const overrideAnswer = normalizeAnswerText(options.overrideAnswer);
146
233
  if (overrideAnswer) {
147
234
  const lastAnswerIndex = [...entries]
@@ -151,38 +238,46 @@ export function createCardDraftController(params: {
151
238
  if (lastAnswerIndex !== undefined) {
152
239
  entries[lastAnswerIndex] = { kind: "answer", text: overrideAnswer };
153
240
  } else {
154
- entries.push({ kind: "answer", text: overrideAnswer });
241
+ insertAnswerEntry(overrideAnswer);
155
242
  }
156
243
  } else if (!entries.some((entry) => entry.kind === "answer" && entry.text)) {
157
244
  const fallbackAnswer = normalizeAnswerText(options.fallbackAnswer);
158
245
  if (fallbackAnswer) {
159
- entries.push({ kind: "answer", text: fallbackAnswer });
246
+ insertAnswerEntry(fallbackAnswer);
160
247
  }
161
248
  }
162
249
 
163
- let rendered = "";
164
- const compactProcessAnswerSpacing = options.compactProcessAnswerSpacing === true;
165
- for (let index = 0; index < entries.length; index += 1) {
166
- const entry = entries[index];
167
- if (!entry?.text) {
168
- continue;
169
- }
170
- const part = entry.kind === "answer"
171
- ? entry.text
172
- : renderProcessBlock(entry.kind, entry.text);
173
- if (!rendered) {
174
- rendered = part;
175
- continue;
250
+ const blocks: CardBlock[] = [];
251
+ for (const entry of entries) {
252
+ if (!entry) { continue; }
253
+ switch (entry.kind) {
254
+ case "answer":
255
+ if (entry.text?.trim()) {
256
+ blocks.push({ type: 0, markdown: entry.text });
257
+ }
258
+ break;
259
+ case "thinking":
260
+ if (entry.text?.trim()) {
261
+ blocks.push({ type: 1, markdown: wrapProcessBlockMarkdown(entry.text) });
262
+ }
263
+ break;
264
+ case "tool":
265
+ if (entry.text?.trim()) {
266
+ blocks.push({ type: 2, markdown: wrapProcessBlockMarkdown(entry.text) });
267
+ }
268
+ break;
269
+ case "image":
270
+ if (entry.mediaId?.trim()) {
271
+ blocks.push({
272
+ type: 3,
273
+ mediaId: entry.mediaId,
274
+ ...(entry.text?.trim() ? { text: entry.text } : {}),
275
+ });
276
+ }
277
+ break;
176
278
  }
177
- const previousKind = entries[index - 1]?.kind;
178
- const separator =
179
- compactProcessAnswerSpacing && previousKind
180
- ? "\n"
181
- : "\n\n";
182
- rendered += `${separator}${part}`;
183
279
  }
184
-
185
- return rendered;
280
+ return blocks;
186
281
  };
187
282
 
188
283
  const sealLiveThinking = () => {
@@ -193,14 +288,25 @@ export function createCardDraftController(params: {
193
288
  activeAnswerIndex = null;
194
289
  };
195
290
 
291
+ const discardCurrentAnswer = () => {
292
+ if (activeAnswerIndex === null) {
293
+ return;
294
+ }
295
+ removeTimelineEntry(activeAnswerIndex);
296
+ queueRender();
297
+ };
298
+
196
299
  const clearPendingRender = () => {
197
300
  loop.resetPending();
198
301
  lastQueuedContent = "";
199
302
  };
200
303
 
201
304
  const queueRender = () => {
202
- const rendered = renderTimeline({ compactProcessAnswerSpacing: true });
203
- if (!rendered) {
305
+ const blocks = renderTimelineAsBlocks();
306
+ const rendered = JSON.stringify(blocks);
307
+
308
+ // Always update blockList via instances API (throttled)
309
+ if (blocks.length === 0) {
204
310
  clearPendingRender();
205
311
  return;
206
312
  }
@@ -222,8 +328,15 @@ export function createCardDraftController(params: {
222
328
  if (stopped || failed) {
223
329
  return;
224
330
  }
331
+ await contentLoop.flush();
332
+ await contentLoop.waitForInFlight();
333
+ // Clear streaming content before committing blockList at boundary
334
+ if (hasStreamingContent) {
335
+ await clearStreamingContentFromCard();
336
+ }
225
337
  await loop.flush();
226
338
  await loop.waitForInFlight();
339
+ contentLoop.resetThrottleWindow();
227
340
  loop.resetThrottleWindow();
228
341
  };
229
342
 
@@ -252,14 +365,16 @@ export function createCardDraftController(params: {
252
365
  sendOrEditStreamMessage: async (content: string) => {
253
366
  inFlightContent = content;
254
367
  try {
255
- await streamAICard(params.card, content, false, params.log);
368
+ // Use instances API for blockList (not streaming API)
369
+ const statusLine = params.getStatusLine?.();
370
+ await updateAICardBlockList(params.card, content, params.log, statusLine ? { statusLine } : undefined);
256
371
  lastSentContent = content;
257
372
  lastQueuedContent = "";
258
373
  lastAnswerContent = getFinalAnswerContent();
259
374
  } catch (err: unknown) {
260
375
  failed = true;
261
376
  const message = err instanceof Error ? err.message : String(err);
262
- params.log?.warn?.(`[DingTalk][AICard] Stream failed: ${message}`);
377
+ params.log?.warn?.(`[DingTalk][AICard] BlockList update failed: ${message}`);
263
378
  } finally {
264
379
  if (inFlightContent === content) {
265
380
  inFlightContent = "";
@@ -268,6 +383,28 @@ export function createCardDraftController(params: {
268
383
  },
269
384
  });
270
385
 
386
+ const contentLoop = createDraftStreamLoop({
387
+ throttleMs: effectiveThrottleMs,
388
+ isStopped: () => stopped || failed,
389
+ sendOrEditStreamMessage: async (content: string) => {
390
+ inFlightStreamingContent = content;
391
+ try {
392
+ await streamAICardContent(params.card, content, params.log);
393
+ hasStreamingContent = true;
394
+ lastSentStreamingContent = content;
395
+ lastQueuedStreamingContent = "";
396
+ lastAnswerContent = content;
397
+ } catch (err: unknown) {
398
+ const message = err instanceof Error ? err.message : String(err);
399
+ params.log?.debug?.(`[DingTalk][AICard] Failed to stream content: ${message}`);
400
+ } finally {
401
+ if (inFlightStreamingContent === content) {
402
+ inFlightStreamingContent = "";
403
+ }
404
+ }
405
+ },
406
+ });
407
+
271
408
  const updateReasoning = async (text: string) => {
272
409
  await waitForPendingBoundary();
273
410
  if (stopped || failed || activeAnswerIndex !== null) {
@@ -291,7 +428,7 @@ export function createCardDraftController(params: {
291
428
  queueRender();
292
429
  };
293
430
 
294
- const updateAnswer = async (text: string, options: { stream?: boolean } = {}) => {
431
+ const updateAnswer = async (text: string, options: { stream?: boolean; renderBlocks?: boolean } = {}) => {
295
432
  await waitForPendingBoundary();
296
433
  if (stopped || failed) {
297
434
  return;
@@ -312,7 +449,13 @@ export function createCardDraftController(params: {
312
449
  } else {
313
450
  activeAnswerIndex = appendTimelineEntry("answer", normalized);
314
451
  }
315
- if (options.stream === false) {
452
+ if (options.stream !== false) {
453
+ await streamContentToCard(normalized);
454
+ } else {
455
+ clearPendingStreamingContent();
456
+ }
457
+ const shouldRenderBlocks = options.renderBlocks ?? (options.stream !== false);
458
+ if (!shouldRenderBlocks) {
316
459
  clearPendingRender();
317
460
  return;
318
461
  }
@@ -392,12 +535,18 @@ export function createCardDraftController(params: {
392
535
  queueRender();
393
536
  };
394
537
 
395
- const notifyNewAssistantTurn = async () => {
538
+ const notifyNewAssistantTurn = async (options: {
539
+ discardActiveAnswer?: boolean;
540
+ } = {}) => {
396
541
  if (stopped || failed) {
397
542
  return;
398
543
  }
399
544
  if (activeAnswerIndex !== null) {
400
- sealCurrentAnswer();
545
+ if (options.discardActiveAnswer) {
546
+ discardCurrentAnswer();
547
+ } else {
548
+ sealCurrentAnswer();
549
+ }
401
550
  await beginBoundaryFlush();
402
551
  return;
403
552
  }
@@ -407,6 +556,23 @@ export function createCardDraftController(params: {
407
556
  }
408
557
  };
409
558
 
559
+ const appendImageBlock = async (mediaId: string, text = "") => {
560
+ await waitForPendingBoundary();
561
+ if (stopped || failed) {
562
+ return;
563
+ }
564
+ if (!mediaId.trim()) {
565
+ return;
566
+ }
567
+ if (timelineEntries.length > 0) {
568
+ await flushBoundaryFrame();
569
+ }
570
+ sealLiveThinking();
571
+ sealCurrentAnswer();
572
+ timelineEntries.push({ kind: "image", text, mediaId });
573
+ queueRender();
574
+ };
575
+
410
576
  return {
411
577
  updateAnswer,
412
578
  updateReasoning,
@@ -414,7 +580,9 @@ export function createCardDraftController(params: {
414
580
  appendThinkingBlock,
415
581
  updateTool,
416
582
  appendTool: updateTool,
583
+ appendImageBlock,
417
584
  appendToolBeforeCurrentAnswer,
585
+ discardCurrentAnswer,
418
586
  notifyNewAssistantTurn,
419
587
  startAssistantTurn: notifyNewAssistantTurn,
420
588
  sealActiveThinking: async () => {
@@ -426,11 +594,18 @@ export function createCardDraftController(params: {
426
594
  await beginBoundaryFlush();
427
595
  }
428
596
  },
429
- flush: () => loop.flush(),
430
- waitForInFlight: () => loop.waitForInFlight(),
597
+ flush: async () => {
598
+ await contentLoop.flush();
599
+ await loop.flush();
600
+ },
601
+ waitForInFlight: async () => {
602
+ await contentLoop.waitForInFlight();
603
+ await loop.waitForInFlight();
604
+ },
431
605
 
432
606
  stop: () => {
433
607
  stopped = true;
608
+ contentLoop.stop();
434
609
  loop.stop();
435
610
  },
436
611
 
@@ -438,6 +613,24 @@ export function createCardDraftController(params: {
438
613
  getLastContent: () => lastSentContent,
439
614
  getLastAnswerContent: () => lastAnswerContent,
440
615
  getFinalAnswerContent,
441
- getRenderedContent: renderTimeline,
616
+ getRenderedBlocks: (options?: { fallbackAnswer?: string; overrideAnswer?: string }) => {
617
+ const blocks = renderTimelineAsBlocks(options);
618
+ if (blocks.length === 0) {
619
+ return "";
620
+ }
621
+ return JSON.stringify(blocks);
622
+ },
623
+ getRenderedContent: (options?: { fallbackAnswer?: string; overrideAnswer?: string; compactProcessAnswerSpacing?: boolean }) => {
624
+ const blocks = renderTimelineAsBlocks(options);
625
+ // Extract markdown from answer blocks (type: 0) and join with double newlines
626
+ const answerTexts = blocks
627
+ .filter((block) => block.type === 0 && "markdown" in block && block.markdown)
628
+ .map((block) => ("markdown" in block ? block.markdown : ""));
629
+ return answerTexts.join("\n\n");
630
+ },
631
+
632
+ streamContent: realTimeStreamEnabled ? streamContentToCard : undefined,
633
+ clearStreamingContent: realTimeStreamEnabled ? clearStreamingContentFromCard : undefined,
634
+ isRealTimeStreamEnabled: () => realTimeStreamEnabled,
442
635
  };
443
636
  }