@soimy/dingtalk 3.4.1 → 3.5.0

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": "@soimy/dingtalk",
3
- "version": "3.4.1",
3
+ "version": "3.5.0",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
@@ -25,6 +25,7 @@
25
25
  "clawbot.plugin.json"
26
26
  ],
27
27
  "type": "module",
28
+ "packageManager": "pnpm@10.33.0",
28
29
  "main": "index.ts",
29
30
  "publishConfig": {
30
31
  "access": "public",
@@ -35,6 +36,10 @@
35
36
  "format:check": "oxfmt --check package.json tsconfig.json index.ts src/*.ts",
36
37
  "lint": "oxlint --type-aware index.ts src",
37
38
  "lint:fix": "oxlint --type-aware --fix index.ts src && pnpm format",
39
+ "docs:dev": "vitepress dev docs --host 127.0.0.1 --port 8000",
40
+ "docs:serv": "vitepress dev docs --host 127.0.0.1 --port 8000",
41
+ "docs:build": "vitepress build docs",
42
+ "docs:preview": "vitepress preview docs --host 127.0.0.1 --port 4173",
38
43
  "monitor:stream": "node scripts/dingtalk-stream-monitor.mjs",
39
44
  "test": "vitest run",
40
45
  "test:coverage": "vitest run --coverage",
@@ -51,15 +56,15 @@
51
56
  "devDependencies": {
52
57
  "@types/node": "^25.2.0",
53
58
  "@vitest/coverage-v8": "^3.2.4",
54
- "openclaw": "2026.3.22",
55
59
  "oxfmt": "0.34.0",
56
60
  "oxlint": "^1.49.0",
57
61
  "oxlint-tsgolint": "^0.15.0",
58
62
  "typescript": "^5.3.0",
63
+ "vitepress": "1.6.4",
59
64
  "vitest": "^3.2.4"
60
65
  },
61
66
  "peerDependencies": {
62
- "openclaw": ">=2026.3.14"
67
+ "openclaw": ">=2026.3.24"
63
68
  },
64
69
  "openclaw": {
65
70
  "extensions": [
@@ -73,8 +78,8 @@
73
78
  "id": "dingtalk",
74
79
  "label": "DingTalk",
75
80
  "selectionLabel": "DingTalk (钉钉)",
76
- "docsPath": "https://github.com/soimy/openclaw-channel-dingtalk",
77
- "docsLabel": "plugin docs",
81
+ "docsPath": "https://soimy.github.io/openclaw-channel-dingtalk/",
82
+ "docsLabel": "documentation",
78
83
  "blurb": "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
79
84
  "order": 70,
80
85
  "aliases": [
@@ -83,6 +88,7 @@
83
88
  ]
84
89
  },
85
90
  "install": {
91
+ "minHostVersion": ">=2026.3.24",
86
92
  "npmSpec": "@soimy/dingtalk",
87
93
  "localPath": ".",
88
94
  "defaultChoice": "npm"
@@ -23,59 +23,69 @@ type AckReactionLogger = {
23
23
  type AckReactionTarget = {
24
24
  msgId: string;
25
25
  conversationId: string;
26
- robotCode?: string;
27
26
  reactionName?: string;
28
27
  };
29
28
 
29
+ type ResolvedAckReactionRequest = {
30
+ msgId: string;
31
+ conversationId: string;
32
+ robotCode: string;
33
+ reactionName: string;
34
+ };
35
+
30
36
  function asRecord(value: unknown): Record<string, unknown> | undefined {
31
37
  return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
32
38
  }
33
39
 
34
- function formatAckReactionTarget(data: AckReactionTarget): string {
40
+ function formatAckReactionTarget(data: {
41
+ msgId: string;
42
+ conversationId: string;
43
+ reactionName: string;
44
+ }): string {
35
45
  return `msgId=${data.msgId || "-"} conversationId=${data.conversationId || "-"} reactionName=${data.reactionName || DINGTALK_NATIVE_ACK_REACTION}`;
36
46
  }
37
47
 
38
- function resolveAckReactionPayload(config: DingTalkConfig, data: AckReactionTarget): {
39
- robotCode: string;
40
- reactionName: string;
41
- } | null {
42
- const robotCode = (data.robotCode || config.robotCode || config.clientId || "").trim();
48
+ function resolveAckReactionRequest(
49
+ config: DingTalkConfig,
50
+ data: AckReactionTarget,
51
+ ): ResolvedAckReactionRequest | null {
52
+ const robotCode = (config.clientId || "").trim();
43
53
  const reactionName =
44
54
  (data.reactionName || DINGTALK_NATIVE_ACK_REACTION).trim() || DINGTALK_NATIVE_ACK_REACTION;
45
55
  if (!robotCode || !data.msgId || !data.conversationId) {
46
56
  return null;
47
57
  }
48
- return { robotCode, reactionName };
58
+ return {
59
+ msgId: data.msgId,
60
+ conversationId: data.conversationId,
61
+ robotCode,
62
+ reactionName,
63
+ };
49
64
  }
50
65
 
51
66
  async function callEmotionApi(
52
67
  config: DingTalkConfig,
53
- data: AckReactionTarget,
68
+ request: ResolvedAckReactionRequest,
54
69
  endpoint: "reply" | "recall",
55
70
  successLog: string,
56
71
  errorLogPrefix: string,
57
72
  errorPayloadKey: "inbound.ackReactionAttach" | "inbound.ackReactionRecall",
58
73
  log?: AckReactionLogger,
59
74
  ): Promise<{ ok: boolean; error?: unknown }> {
60
- const payload = resolveAckReactionPayload(config, data);
61
- if (!payload) {
62
- return { ok: false };
63
- }
64
-
65
75
  try {
66
76
  const token = await getAccessToken(config, log as any);
67
77
  await axios.post(
68
78
  `https://api.dingtalk.com/v1.0/robot/emotion/${endpoint}`,
69
79
  {
70
- robotCode: payload.robotCode,
71
- openMsgId: data.msgId,
72
- openConversationId: data.conversationId,
80
+ robotCode: request.robotCode,
81
+ openMsgId: request.msgId,
82
+ openConversationId: request.conversationId,
73
83
  emotionType: 2,
74
- emotionName: payload.reactionName,
84
+ emotionName: request.reactionName,
75
85
  textEmotion: {
76
86
  emotionId: THINKING_EMOTION_ID,
77
- emotionName: payload.reactionName,
78
- text: payload.reactionName,
87
+ emotionName: request.reactionName,
88
+ text: request.reactionName,
79
89
  backgroundId: THINKING_EMOTION_BACKGROUND_ID,
80
90
  },
81
91
  },
@@ -119,6 +129,10 @@ export async function attachNativeAckReaction(
119
129
  data: AckReactionTarget,
120
130
  log?: AckReactionLogger,
121
131
  ): Promise<boolean> {
132
+ const request = resolveAckReactionRequest(config, data);
133
+ if (!request) {
134
+ return false;
135
+ }
122
136
  for (let index = 0; index < THINKING_REACTION_ATTACH_DELAYS_MS.length; index += 1) {
123
137
  const delayMs = THINKING_REACTION_ATTACH_DELAYS_MS[index];
124
138
  if (delayMs > 0) {
@@ -128,10 +142,10 @@ export async function attachNativeAckReaction(
128
142
  const attemptLabel = `${attempt}/${THINKING_REACTION_ATTACH_DELAYS_MS.length}`;
129
143
  const result = await callEmotionApi(
130
144
  config,
131
- data,
145
+ request,
132
146
  "reply",
133
- `[DingTalk] Native ack reaction attach succeeded (${formatAckReactionTarget(data)} attempt=${attemptLabel})`,
134
- `[DingTalk] Native ack reaction attach failed (${formatAckReactionTarget(data)} attempt=${attemptLabel})`,
147
+ `[DingTalk] Native ack reaction attach succeeded (${formatAckReactionTarget(request)} attempt=${attemptLabel})`,
148
+ `[DingTalk] Native ack reaction attach failed (${formatAckReactionTarget(request)} attempt=${attemptLabel})`,
135
149
  "inbound.ackReactionAttach",
136
150
  log,
137
151
  );
@@ -143,7 +157,7 @@ export async function attachNativeAckReaction(
143
157
  break;
144
158
  }
145
159
  log?.debug?.(
146
- `[DingTalk] Retrying native ack reaction attach (${formatAckReactionTarget(data)} nextAttempt=${attempt + 1}/${THINKING_REACTION_ATTACH_DELAYS_MS.length})`,
160
+ `[DingTalk] Retrying native ack reaction attach (${formatAckReactionTarget(request)} nextAttempt=${attempt + 1}/${THINKING_REACTION_ATTACH_DELAYS_MS.length})`,
147
161
  );
148
162
  }
149
163
  return false;
@@ -151,12 +165,12 @@ export async function attachNativeAckReaction(
151
165
 
152
166
  async function recallNativeAckReaction(
153
167
  config: DingTalkConfig,
154
- data: AckReactionTarget,
168
+ request: ResolvedAckReactionRequest,
155
169
  log?: AckReactionLogger,
156
170
  ): Promise<boolean> {
157
171
  const result = await callEmotionApi(
158
172
  config,
159
- data,
173
+ request,
160
174
  "recall",
161
175
  "[DingTalk] Native ack reaction recall succeeded",
162
176
  "[DingTalk] Native ack reaction recall failed",
@@ -171,11 +185,15 @@ export async function recallNativeAckReactionWithRetry(
171
185
  data: AckReactionTarget,
172
186
  log?: AckReactionLogger,
173
187
  ): Promise<void> {
188
+ const request = resolveAckReactionRequest(config, data);
189
+ if (!request) {
190
+ return;
191
+ }
174
192
  for (const delayMs of THINKING_REACTION_RECALL_DELAYS_MS) {
175
193
  if (delayMs > 0) {
176
194
  await new Promise(resolve => setTimeout(resolve, delayMs));
177
195
  }
178
- if (await recallNativeAckReaction(config, data, log)) {
196
+ if (await recallNativeAckReaction(config, request, log)) {
179
197
  return;
180
198
  }
181
199
  }
@@ -1,61 +1,224 @@
1
1
  /**
2
2
  * Card draft controller for throttled AI Card streaming updates.
3
3
  *
4
- * Wraps {@link createDraftStreamLoop} with a phase-based state machine
5
- * (idle reasoning answer) that manages what content is sent to the
6
- * DingTalk AI Card during the reply lifecycle.
4
+ * The controller keeps a single rendered card timeline made of:
5
+ * - sealed process blocks (`thinking` / `tool`)
6
+ * - an optional live thinking block
7
+ * - accumulated answer turns rendered as plain markdown
7
8
  *
8
- * Responsibilities (and non-responsibilities):
9
- * - DOES manage throttled card preview updates via streamAICard
10
- * - DOES enforce single-flight, latest-wins, phase-gated semantics
11
- * - Does NOT handle tool append, finalize, or markdown fallback —
12
- * those stay in inbound-handler's deliver callback.
9
+ * It delegates throttling and single-flight transport guarantees to
10
+ * {@link createDraftStreamLoop}.
13
11
  */
14
12
 
15
- import { formatContentForCard, streamAICard } from "./card-service";
13
+ import { streamAICard } from "./card-service";
16
14
  import { createDraftStreamLoop } from "./draft-stream-loop";
17
15
  import type { AICardInstance, Logger } from "./types";
18
16
 
19
- export type CardDraftPhase = "idle" | "reasoning" | "answer";
17
+ type TimelineEntryKind = "thinking" | "tool" | "answer";
18
+
19
+ type TimelineEntry = {
20
+ kind: TimelineEntryKind;
21
+ text: string;
22
+ };
20
23
 
21
24
  export interface CardDraftController {
22
- updateAnswer: (text: string) => void;
23
- updateReasoning: (text: string) => void;
25
+ updateAnswer: (text: string) => Promise<void>;
26
+ updateReasoning: (text: string) => Promise<void>;
27
+ updateThinking: (text: string) => Promise<void>;
28
+ updateTool: (text: string) => Promise<void>;
29
+ appendTool: (text: string) => Promise<void>;
24
30
  /** Signal that a new assistant turn has started (e.g. after a tool call). */
25
- notifyNewAssistantTurn: () => void;
31
+ notifyNewAssistantTurn: () => Promise<void>;
32
+ startAssistantTurn: () => Promise<void>;
26
33
  flush: () => Promise<void>;
27
34
  waitForInFlight: () => Promise<void>;
28
35
  stop: () => void;
29
36
  isFailed: () => boolean;
30
- /** Last content sent to card (reasoning or answer). */
37
+ /** Last content successfully sent to card. */
31
38
  getLastContent: () => string;
32
- /** Last content sent to card during answer phase only. */
39
+ /** Last answer-only content successfully sent to card. */
33
40
  getLastAnswerContent: () => string;
41
+ /** Current answer-only content composed from all completed answer turns. */
42
+ getFinalAnswerContent: () => string;
43
+ /** Current rendered timeline, including process blocks and answer text. */
44
+ getRenderedContent: (options?: {
45
+ fallbackAnswer?: string;
46
+ overrideAnswer?: string;
47
+ compactProcessAnswerSpacing?: boolean;
48
+ }) => string;
49
+ }
50
+
51
+ function normalizeProcessText(text: string | undefined): string {
52
+ return typeof text === "string" ? text.trim() : "";
53
+ }
54
+
55
+ function normalizeAnswerText(text: string | undefined): string {
56
+ return typeof text === "string" ? text.trimStart() : "";
57
+ }
58
+
59
+ function quoteMarkdown(text: string): string {
60
+ return text
61
+ .split("\n")
62
+ .map((line) => line.trim() ? `> ${line.trim()}` : ">")
63
+ .join("\n");
64
+ }
65
+
66
+ function renderProcessBlock(_kind: "thinking" | "tool", text: string): string {
67
+ return quoteMarkdown(text);
34
68
  }
35
69
 
36
70
  export function createCardDraftController(params: {
37
71
  card: AICardInstance;
38
72
  throttleMs?: number;
73
+ /** Legacy compatibility: verbose mode previously lowered the throttle. */
74
+ verboseMode?: boolean;
39
75
  log?: Logger;
40
76
  }): CardDraftController {
41
- let phase: CardDraftPhase = "idle";
42
77
  let failed = false;
43
78
  let stopped = false;
44
79
  let lastSentContent = "";
45
80
  let lastAnswerContent = "";
46
- let answerPrefix = "";
47
- let turnBoundaryPending = false;
81
+
82
+ let timelineEntries: TimelineEntry[] = [];
83
+ let activeThinkingIndex: number | null = null;
84
+ let activeAnswerIndex: number | null = null;
85
+ let pendingBoundaryPromise: Promise<void> | null = null;
86
+
87
+ const effectiveThrottleMs = params.throttleMs ?? (params.verboseMode ? 50 : 300);
88
+
89
+ const getFinalAnswerContent = (): string => {
90
+ return timelineEntries
91
+ .filter((entry) => entry.kind === "answer" && entry.text)
92
+ .map((entry) => entry.text)
93
+ .join("\n\n");
94
+ };
95
+
96
+ const removeTimelineEntry = (index: number) => {
97
+ timelineEntries.splice(index, 1);
98
+ if (activeThinkingIndex !== null) {
99
+ if (activeThinkingIndex === index) {
100
+ activeThinkingIndex = null;
101
+ } else if (activeThinkingIndex > index) {
102
+ activeThinkingIndex -= 1;
103
+ }
104
+ }
105
+ if (activeAnswerIndex !== null) {
106
+ if (activeAnswerIndex === index) {
107
+ activeAnswerIndex = null;
108
+ } else if (activeAnswerIndex > index) {
109
+ activeAnswerIndex -= 1;
110
+ }
111
+ }
112
+ };
113
+
114
+ const appendTimelineEntry = (kind: TimelineEntryKind, text: string): number => {
115
+ timelineEntries.push({ kind, text });
116
+ return timelineEntries.length - 1;
117
+ };
118
+
119
+ const renderTimeline = (options: {
120
+ fallbackAnswer?: string;
121
+ overrideAnswer?: string;
122
+ compactProcessAnswerSpacing?: boolean;
123
+ } = {}): string => {
124
+ const entries = timelineEntries.map((entry) => ({ ...entry }));
125
+
126
+ const overrideAnswer = normalizeAnswerText(options.overrideAnswer);
127
+ if (overrideAnswer) {
128
+ const lastAnswerIndex = [...entries]
129
+ .map((entry, index) => ({ entry, index }))
130
+ .toReversed()
131
+ .find(({ entry }) => entry.kind === "answer")?.index;
132
+ if (lastAnswerIndex !== undefined) {
133
+ entries[lastAnswerIndex] = { kind: "answer", text: overrideAnswer };
134
+ } else {
135
+ entries.push({ kind: "answer", text: overrideAnswer });
136
+ }
137
+ } else if (!entries.some((entry) => entry.kind === "answer" && entry.text)) {
138
+ const fallbackAnswer = normalizeAnswerText(options.fallbackAnswer);
139
+ if (fallbackAnswer) {
140
+ entries.push({ kind: "answer", text: fallbackAnswer });
141
+ }
142
+ }
143
+
144
+ let rendered = "";
145
+ const compactProcessAnswerSpacing = options.compactProcessAnswerSpacing === true;
146
+ for (let index = 0; index < entries.length; index += 1) {
147
+ const entry = entries[index];
148
+ if (!entry?.text) {
149
+ continue;
150
+ }
151
+ const part = entry.kind === "answer"
152
+ ? entry.text
153
+ : renderProcessBlock(entry.kind, entry.text);
154
+ if (!rendered) {
155
+ rendered = part;
156
+ continue;
157
+ }
158
+ const previousKind = entries[index - 1]?.kind;
159
+ const separator =
160
+ compactProcessAnswerSpacing && previousKind
161
+ ? "\n"
162
+ : "\n\n";
163
+ rendered += `${separator}${part}`;
164
+ }
165
+
166
+ return rendered;
167
+ };
168
+
169
+ const sealLiveThinking = () => {
170
+ activeThinkingIndex = null;
171
+ };
172
+
173
+ const sealCurrentAnswer = () => {
174
+ activeAnswerIndex = null;
175
+ };
176
+
177
+ const queueRender = () => {
178
+ const rendered = renderTimeline({ compactProcessAnswerSpacing: true });
179
+ if (rendered) {
180
+ loop.update(rendered);
181
+ return;
182
+ }
183
+ loop.resetPending();
184
+ };
185
+
186
+ const flushBoundaryFrame = async () => {
187
+ if (stopped || failed) {
188
+ return;
189
+ }
190
+ await loop.flush();
191
+ await loop.waitForInFlight();
192
+ loop.resetThrottleWindow();
193
+ };
194
+
195
+ const beginBoundaryFlush = () => {
196
+ if (pendingBoundaryPromise) {
197
+ return pendingBoundaryPromise;
198
+ }
199
+ const current = flushBoundaryFrame().finally(() => {
200
+ if (pendingBoundaryPromise === current) {
201
+ pendingBoundaryPromise = null;
202
+ }
203
+ });
204
+ pendingBoundaryPromise = current;
205
+ return current;
206
+ };
207
+
208
+ const waitForPendingBoundary = async () => {
209
+ if (pendingBoundaryPromise) {
210
+ await pendingBoundaryPromise;
211
+ }
212
+ };
48
213
 
49
214
  const loop = createDraftStreamLoop({
50
- throttleMs: params.throttleMs ?? 300,
215
+ throttleMs: effectiveThrottleMs,
51
216
  isStopped: () => stopped || failed,
52
217
  sendOrEditStreamMessage: async (content: string) => {
53
218
  try {
54
219
  await streamAICard(params.card, content, false, params.log);
55
220
  lastSentContent = content;
56
- if (phase === "answer") {
57
- lastAnswerContent = content;
58
- }
221
+ lastAnswerContent = getFinalAnswerContent();
59
222
  } catch (err: unknown) {
60
223
  failed = true;
61
224
  const message = err instanceof Error ? err.message : String(err);
@@ -64,41 +227,94 @@ export function createCardDraftController(params: {
64
227
  },
65
228
  });
66
229
 
67
- return {
68
- updateReasoning: (text: string) => {
69
- if (stopped || failed || phase === "answer") { return; }
70
- phase = "reasoning";
71
- const formatted = formatContentForCard(text, "thinking");
72
- if (formatted) {
73
- loop.update(formatted);
230
+ const updateReasoning = async (text: string) => {
231
+ await waitForPendingBoundary();
232
+ if (stopped || failed || activeAnswerIndex !== null) {
233
+ return;
234
+ }
235
+ const normalized = normalizeProcessText(text);
236
+ if (!normalized) {
237
+ return;
238
+ }
239
+ if (activeThinkingIndex === null && timelineEntries.length > 0) {
240
+ const lastKind = timelineEntries.at(-1)?.kind;
241
+ if (lastKind && lastKind !== "thinking") {
242
+ await flushBoundaryFrame();
74
243
  }
75
- },
244
+ }
245
+ if (activeThinkingIndex !== null) {
246
+ timelineEntries[activeThinkingIndex] = { kind: "thinking", text: normalized };
247
+ } else {
248
+ activeThinkingIndex = appendTimelineEntry("thinking", normalized);
249
+ }
250
+ queueRender();
251
+ };
76
252
 
77
- updateAnswer: (text: string) => {
78
- if (stopped || failed) { return; }
79
- if (phase !== "answer") {
80
- params.log?.debug?.(`[DingTalk][Draft] phase ${phase} → answer`);
81
- phase = "answer";
82
- if (turnBoundaryPending && lastAnswerContent) {
83
- answerPrefix = lastAnswerContent + "\n\n";
84
- }
85
- turnBoundaryPending = false;
253
+ const updateAnswer = async (text: string) => {
254
+ await waitForPendingBoundary();
255
+ if (stopped || failed) {
256
+ return;
257
+ }
258
+ const normalized = normalizeAnswerText(text);
259
+ if (!normalized.trim()) {
260
+ return;
261
+ }
262
+ if (activeAnswerIndex === null && timelineEntries.length > 0) {
263
+ const lastKind = timelineEntries.at(-1)?.kind;
264
+ if (lastKind && lastKind !== "answer") {
265
+ await flushBoundaryFrame();
86
266
  }
87
- const trimmed = text?.trimStart();
88
- if (trimmed) {
89
- loop.update(answerPrefix + trimmed);
90
- }
91
- },
267
+ }
268
+ sealLiveThinking();
269
+ if (activeAnswerIndex !== null) {
270
+ timelineEntries[activeAnswerIndex] = { kind: "answer", text: normalized };
271
+ } else {
272
+ activeAnswerIndex = appendTimelineEntry("answer", normalized);
273
+ }
274
+ queueRender();
275
+ };
92
276
 
93
- notifyNewAssistantTurn: () => {
94
- if (stopped || failed) { return; }
95
- turnBoundaryPending = true;
96
- if (phase === "reasoning") {
97
- loop.resetPending();
98
- }
99
- phase = "idle";
100
- },
277
+ const updateTool = async (text: string) => {
278
+ await waitForPendingBoundary();
279
+ if (stopped || failed) {
280
+ return;
281
+ }
282
+ const normalized = normalizeProcessText(text);
283
+ if (!normalized) {
284
+ return;
285
+ }
286
+ if (timelineEntries.length > 0) {
287
+ await flushBoundaryFrame();
288
+ }
289
+ sealLiveThinking();
290
+ sealCurrentAnswer();
291
+ appendTimelineEntry("tool", normalized);
292
+ queueRender();
293
+ };
101
294
 
295
+ const notifyNewAssistantTurn = async () => {
296
+ if (stopped || failed) {
297
+ return;
298
+ }
299
+ if (activeAnswerIndex !== null) {
300
+ sealCurrentAnswer();
301
+ await beginBoundaryFlush();
302
+ return;
303
+ }
304
+ if (activeThinkingIndex !== null) {
305
+ removeTimelineEntry(activeThinkingIndex);
306
+ loop.resetPending();
307
+ }
308
+ };
309
+
310
+ return {
311
+ updateAnswer,
312
+ updateReasoning,
313
+ updateThinking: updateReasoning,
314
+ updateTool,
315
+ appendTool: updateTool,
316
+ notifyNewAssistantTurn,
317
+ startAssistantTurn: notifyNewAssistantTurn,
102
318
  flush: () => loop.flush(),
103
319
  waitForInFlight: () => loop.waitForInFlight(),
104
320
 
@@ -110,5 +326,7 @@ export function createCardDraftController(params: {
110
326
  isFailed: () => failed,
111
327
  getLastContent: () => lastSentContent,
112
328
  getLastAnswerContent: () => lastAnswerContent,
329
+ getFinalAnswerContent,
330
+ getRenderedContent: renderTimeline,
113
331
  };
114
332
  }
@@ -3,13 +3,15 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import axios from "axios";
5
5
  import { getAccessToken } from "./auth";
6
- import { stripTargetPrefix } from "./config";
6
+ import { resolveRobotCode, stripTargetPrefix } from "./config";
7
7
  import { resolveOriginalPeerId } from "./peer-id-registry";
8
8
  import {
9
9
  createSyntheticOutboundMsgId,
10
10
  clearMessageContextCacheForTest,
11
11
  DEFAULT_CARD_CONTENT_TTL_MS,
12
12
  DEFAULT_CREATED_AT_MATCH_WINDOW_MS,
13
+ DEFAULT_OUTBOUND_SENDER,
14
+ inferConversationChatType,
13
15
  resolveByCreatedAtWindow,
14
16
  upsertOutboundMessageContext,
15
17
  } from "./message-context-store";
@@ -409,7 +411,7 @@ async function sendTemplateMismatchNotification(
409
411
 
410
412
  // Direct markdown fallback notification to user/group, without re-entering sendMessage card flow.
411
413
  const payload: Record<string, unknown> = {
412
- robotCode: config.robotCode || config.clientId,
414
+ robotCode: resolveRobotCode(config),
413
415
  msgKey: "sampleMarkdown",
414
416
  msgParam: JSON.stringify({ title: "OpenClaw 提醒", text }),
415
417
  };
@@ -596,26 +598,19 @@ export async function createAICard(
596
598
  userIdType: 1,
597
599
  imGroupOpenDeliverModel: isGroup
598
600
  ? {
599
- robotCode: config.robotCode || config.clientId,
601
+ robotCode: resolveRobotCode(config),
600
602
  extension: DYNAMIC_SUMMARY_EXTENSION,
601
603
  }
602
604
  : undefined,
603
605
  imRobotOpenDeliverModel: !isGroup
604
606
  ? {
605
607
  spaceType: "IM_ROBOT",
606
- robotCode: config.robotCode || config.clientId,
608
+ robotCode: resolveRobotCode(config),
607
609
  extension: DYNAMIC_SUMMARY_EXTENSION,
608
610
  }
609
611
  : undefined,
610
612
  };
611
613
 
612
- if (isGroup && !config.robotCode) {
613
- log?.warn?.(
614
- "[DingTalk][AICard] robotCode not configured, using clientId as fallback. " +
615
- "For best compatibility, set robotCode explicitly in config.",
616
- );
617
- }
618
-
619
614
  log?.debug?.(
620
615
  `[DingTalk][AICard] POST /v1.0/card/instances/createAndDeliver body=${JSON.stringify(createAndDeliverBody)}`,
621
616
  );
@@ -895,6 +890,8 @@ function cacheCardContentByProcessQueryKey(
895
890
  createdAt: Date.now(),
896
891
  text: content,
897
892
  messageType: "card",
893
+ ...DEFAULT_OUTBOUND_SENDER,
894
+ chatType: inferConversationChatType(conversationId),
898
895
  ttlMs: DEFAULT_CARD_CONTENT_TTL_MS,
899
896
  topic: null,
900
897
  quotedRef,
@@ -932,6 +929,8 @@ export function cacheCardContent(
932
929
  createdAt,
933
930
  text: content,
934
931
  messageType: "card",
932
+ ...DEFAULT_OUTBOUND_SENDER,
933
+ chatType: inferConversationChatType(conversationId),
935
934
  ttlMs: DEFAULT_CARD_CONTENT_TTL_MS,
936
935
  topic: null,
937
936
  });