@soimy/dingtalk 3.5.0 → 3.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "3.5.0",
3
+ "version": "3.5.1",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
@@ -67,6 +67,12 @@
67
67
  "openclaw": ">=2026.3.24"
68
68
  },
69
69
  "openclaw": {
70
+ "compat": {
71
+ "pluginApi": ">=2026.3.24"
72
+ },
73
+ "build": {
74
+ "openclawVersion": "2026.3.24"
75
+ },
70
76
  "extensions": [
71
77
  "./index.ts"
72
78
  ],
@@ -0,0 +1,62 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import type { CardCallbackAnalysis } from "../card-callback-service";
3
+ import type { DingTalkConfig, Logger } from "../types";
4
+ import { resolveCardRun } from "./card-run-registry";
5
+ import { stopCardRun } from "./card-stop-handler";
6
+
7
+ export interface CardActionResult {
8
+ handled: boolean;
9
+ }
10
+
11
+ export async function handleCardAction(params: {
12
+ analysis: CardCallbackAnalysis;
13
+ cfg: OpenClawConfig;
14
+ accountId: string;
15
+ config: DingTalkConfig;
16
+ log?: Logger;
17
+ }): Promise<CardActionResult> {
18
+ if (params.analysis.actionId !== "btn_stop") {
19
+ return { handled: false };
20
+ }
21
+
22
+ const outTrackId = params.analysis.outTrackId;
23
+ if (!outTrackId) {
24
+ params.log?.warn?.(
25
+ `[${params.accountId}] [DingTalk][CardStop] stop callback missing outTrackId — cannot route stop request`,
26
+ );
27
+ return { handled: false };
28
+ }
29
+
30
+ // In group chats, only the user who initiated the conversation can stop it.
31
+ // Fail-closed: reject when clicker identity is missing but owner is known.
32
+ const clickerUserId = params.analysis.userId;
33
+ const record = resolveCardRun(outTrackId);
34
+ if (record?.ownerUserId) {
35
+ if (!clickerUserId || record.ownerUserId !== clickerUserId) {
36
+ params.log?.info?.(
37
+ `[${params.accountId}] [DingTalk][CardStop] rejected: clicker=${clickerUserId ?? "unknown"} owner=${record.ownerUserId}`,
38
+ );
39
+ return { handled: true };
40
+ }
41
+ }
42
+
43
+ const result = await stopCardRun({
44
+ cfg: params.cfg,
45
+ accountId: params.accountId,
46
+ outTrackId,
47
+ config: params.config,
48
+ clickerUserId,
49
+ log: params.log,
50
+ });
51
+ if (!result.ok) {
52
+ params.log?.warn?.(
53
+ `[${params.accountId}] [DingTalk][CardStop] stop failed status=${result.status} reason=${result.reason ?? "unknown"}`,
54
+ );
55
+ } else {
56
+ params.log?.info?.(
57
+ `[${params.accountId}] [DingTalk][CardStop] stop succeeded outTrackId=${outTrackId}`,
58
+ );
59
+ }
60
+
61
+ return { handled: true };
62
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * In-process card run registry for tracking active AI card runs.
3
+ *
4
+ * DEPLOYMENT CONSTRAINT: This registry uses a process-local Map. In multi-process
5
+ * deployments (cluster/PM2), stop callbacks may route to a different worker than
6
+ * the one that created the card run, causing resolveCardRun to return null and
7
+ * the stop button to silently fail. Ensure single-process deployment or sticky
8
+ * routing per card callback when using the stop button feature.
9
+ */
10
+ import type { CardDraftController } from "../card-draft-controller";
11
+ import type { AICardInstance } from "../types";
12
+
13
+ export interface CardRunRecord {
14
+ outTrackId: string;
15
+ accountId: string;
16
+ sessionKey: string;
17
+ /** OpenClaw agent ID resolved from the inbound route. */
18
+ agentId: string;
19
+ /** DingTalk userId of the user who initiated this card run. */
20
+ ownerUserId?: string;
21
+ card?: AICardInstance;
22
+ controller?: CardDraftController;
23
+ stopRequestedAt?: number;
24
+ registeredAt: number;
25
+ }
26
+
27
+ const CARD_RUN_TTL_MS = 30 * 60 * 1000;
28
+ const SWEEP_INTERVAL_MS = 5 * 60 * 1000;
29
+
30
+ const records = new Map<string, CardRunRecord>();
31
+
32
+ let sweepTimer: ReturnType<typeof setInterval> | null = null;
33
+
34
+ function ensureSweepTimer(): void {
35
+ if (sweepTimer) {
36
+ return;
37
+ }
38
+ sweepTimer = setInterval(() => {
39
+ const now = Date.now();
40
+ for (const [key, record] of records) {
41
+ if (now - record.registeredAt > CARD_RUN_TTL_MS) {
42
+ records.delete(key);
43
+ }
44
+ }
45
+ if (records.size === 0 && sweepTimer) {
46
+ clearInterval(sweepTimer);
47
+ sweepTimer = null;
48
+ }
49
+ }, SWEEP_INTERVAL_MS);
50
+ // Allow the process to exit without waiting for this timer.
51
+ if (typeof sweepTimer === "object" && "unref" in sweepTimer) {
52
+ sweepTimer.unref();
53
+ }
54
+ }
55
+
56
+ export function registerCardRun(
57
+ outTrackId: string,
58
+ params: {
59
+ accountId: string;
60
+ sessionKey: string;
61
+ agentId: string;
62
+ ownerUserId?: string;
63
+ card?: AICardInstance;
64
+ },
65
+ ): void {
66
+ const trimmed = outTrackId.trim();
67
+ if (!trimmed) {
68
+ return;
69
+ }
70
+ records.set(trimmed, {
71
+ outTrackId: trimmed,
72
+ accountId: params.accountId,
73
+ sessionKey: params.sessionKey,
74
+ agentId: params.agentId,
75
+ ownerUserId: params.ownerUserId,
76
+ card: params.card,
77
+ registeredAt: Date.now(),
78
+ });
79
+ ensureSweepTimer();
80
+ }
81
+
82
+ export function attachCardRunController(outTrackId: string, controller: CardDraftController): void {
83
+ const record = records.get(outTrackId.trim());
84
+ if (record) {
85
+ record.controller = controller;
86
+ }
87
+ }
88
+
89
+ export function resolveCardRun(outTrackId: string): CardRunRecord | null {
90
+ return records.get(outTrackId.trim()) ?? null;
91
+ }
92
+
93
+ export function markCardRunStopRequested(outTrackId: string): void {
94
+ const record = records.get(outTrackId.trim());
95
+ if (record && !record.stopRequestedAt) {
96
+ record.stopRequestedAt = Date.now();
97
+ }
98
+ }
99
+
100
+ export function isCardRunStopRequested(outTrackId: string): boolean {
101
+ return Boolean(records.get(outTrackId.trim())?.stopRequestedAt);
102
+ }
103
+
104
+ export function removeCardRun(outTrackId: string): void {
105
+ records.delete(outTrackId.trim());
106
+ if (records.size === 0 && sweepTimer) {
107
+ clearInterval(sweepTimer);
108
+ sweepTimer = null;
109
+ }
110
+ }
111
+
112
+ export function clearCardRunRegistryForTest(): void {
113
+ records.clear();
114
+ if (sweepTimer) {
115
+ clearInterval(sweepTimer);
116
+ sweepTimer = null;
117
+ }
118
+ }
@@ -0,0 +1,94 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { getAccessToken } from "../auth";
3
+ import { finishStoppedAICard, hideCardStopButton } from "../card-service";
4
+ import { dispatchDingTalkCardStopCommand } from "../command/card-stop-command";
5
+ import type { DingTalkConfig, Logger } from "../types";
6
+ import { AICardStatus } from "../types";
7
+ import { markCardRunStopRequested, resolveCardRun } from "./card-run-registry";
8
+
9
+ export interface StopCardRunResult {
10
+ ok: boolean;
11
+ status: string;
12
+ reason?: string;
13
+ /** Last streamed content before stop, so callback response can preserve it. */
14
+ lastContent?: string;
15
+ }
16
+
17
+ export async function stopCardRun(params: {
18
+ cfg: OpenClawConfig;
19
+ accountId: string;
20
+ outTrackId: string;
21
+ config?: DingTalkConfig;
22
+ clickerUserId?: string;
23
+ log?: Logger;
24
+ }): Promise<StopCardRunResult> {
25
+ const record = resolveCardRun(params.outTrackId);
26
+ if (!record) {
27
+ return { ok: false, status: "missing-run", reason: "No active card run registration found." };
28
+ }
29
+
30
+ if (record.stopRequestedAt || record.card?.state === AICardStatus.STOPPED) {
31
+ return { ok: true, status: "already-stopped", lastContent: record.card?.lastStreamedContent };
32
+ }
33
+
34
+ const lastContent = record.card?.lastStreamedContent;
35
+
36
+ markCardRunStopRequested(params.outTrackId);
37
+ record.controller?.stop();
38
+
39
+ // --- Phase 1: Abort agent execution via native /stop command ---
40
+ // Dispatch FIRST so that the current run is guaranteed to still be active
41
+ // on the session. If we finalized the card first, a new run could start on
42
+ // the same sessionKey in the async gap.
43
+ //
44
+ // Only abort when this card is actively dispatching (has a controller).
45
+ // Cards still queued behind session lock have no controller yet — the stop
46
+ // guard in inbound-handler will skip dispatch when the lock is acquired.
47
+ let nativeStopStatus: string | undefined;
48
+ if (record.controller) {
49
+ try {
50
+ await dispatchDingTalkCardStopCommand({
51
+ cfg: params.cfg,
52
+ accountId: params.accountId,
53
+ agentId: record.agentId,
54
+ targetSessionKey: record.sessionKey,
55
+ clickerUserId: params.clickerUserId ?? record.ownerUserId ?? "unknown",
56
+ log: params.log,
57
+ });
58
+ nativeStopStatus = "stopped";
59
+ } catch (error) {
60
+ params.log?.warn?.(
61
+ `[${params.accountId}] [DingTalk][CardStop] native stop dispatch failed: ${error instanceof Error ? error.message : String(error)}`,
62
+ );
63
+ nativeStopStatus = "stopped-dispatch-error";
64
+ }
65
+ }
66
+
67
+ // --- Phase 2: Finalize card via streaming API (isFinalize=true) ---
68
+ if (record.card) {
69
+ const stoppedContent = lastContent
70
+ ? `${lastContent}\n\n---\n*⏹️ 已停止*`
71
+ : "⏹️ 已停止";
72
+ try {
73
+ await finishStoppedAICard(record.card, stoppedContent, params.log);
74
+ } catch (error) {
75
+ params.log?.warn?.(
76
+ `[${params.accountId}] [DingTalk][CardStop] failed to finalize stopped card: ${error instanceof Error ? error.message : String(error)}`,
77
+ );
78
+ }
79
+ }
80
+
81
+ // --- Phase 3: Hide stop button (with retry, consistent with finishAICard path) ---
82
+ if (params.config) {
83
+ try {
84
+ const token = await getAccessToken(params.config, params.log);
85
+ await hideCardStopButton(params.outTrackId, token, params.config);
86
+ } catch (error) {
87
+ params.log?.debug?.(
88
+ `[${params.accountId}] [DingTalk][CardStop] non-critical: failed to hide stop button: ${error instanceof Error ? error.message : String(error)}`,
89
+ );
90
+ }
91
+ }
92
+
93
+ return { ok: true, status: nativeStopStatus ?? "stopped-pending", lastContent };
94
+ }
@@ -0,0 +1,20 @@
1
+ /** Card variable value that shows the stop button. */
2
+ export const STOP_ACTION_VISIBLE = "true";
3
+ /** Card variable value that hides the stop button. */
4
+ export const STOP_ACTION_HIDDEN = "false";
5
+
6
+ export const BUILTIN_DINGTALK_CARD_TEMPLATE_ID =
7
+ process.env.DINGTALK_CARD_TEMPLATE_ID || "51cd8c7e-0e7e-4464-a795-5b81499ada7a.schema";
8
+ export const BUILTIN_DINGTALK_CARD_CONTENT_KEY = "content";
9
+
10
+ export interface DingTalkCardTemplateContract {
11
+ templateId: string;
12
+ contentKey: string;
13
+ }
14
+
15
+ /** Frozen singleton — no allocation on every call. */
16
+ export const DINGTALK_CARD_TEMPLATE: Readonly<DingTalkCardTemplateContract> = Object.freeze({
17
+ templateId: BUILTIN_DINGTALK_CARD_TEMPLATE_ID,
18
+ contentKey: BUILTIN_DINGTALK_CARD_CONTENT_KEY,
19
+ });
20
+
@@ -1,10 +1,18 @@
1
+ import axios from "axios";
2
+ import { getProxyBypassOption } from "./utils";
3
+
4
+ const DINGTALK_API = "https://api.dingtalk.com";
5
+
1
6
  export interface CardCallbackAnalysis {
2
7
  summary: string;
3
8
  actionId?: string;
4
9
  feedbackTarget?: string;
5
10
  feedbackAckText?: string;
6
11
  userId?: string;
12
+ spaceId?: string;
7
13
  processQueryKey?: string;
14
+ outTrackId?: string;
15
+ cardInstanceId?: string;
8
16
  }
9
17
 
10
18
  function stringifyCandidate(value: unknown): string {
@@ -89,20 +97,58 @@ export function analyzeCardCallback(data: unknown): CardCallbackAnalysis {
89
97
  const actionId = extractCardActionId(data);
90
98
  const embeddedValue = asRecord(parseEmbeddedJson(record?.value));
91
99
  const embeddedContent = asRecord(parseEmbeddedJson(record?.content));
100
+ const embeddedCardPrivateData = asRecord(parseEmbeddedJson(record?.cardPrivateData));
101
+ const embeddedValuePrivateData = asRecord(parseEmbeddedJson(embeddedValue?.cardPrivateData));
102
+ const embeddedContentPrivateData = asRecord(parseEmbeddedJson(embeddedContent?.cardPrivateData));
103
+ const candidateRecords = [
104
+ record,
105
+ embeddedValue,
106
+ embeddedContent,
107
+ embeddedCardPrivateData,
108
+ embeddedValuePrivateData,
109
+ embeddedContentPrivateData,
110
+ ].filter(Boolean) as Array<Record<string, unknown>>;
111
+ const pickString = (...keys: string[]): string | undefined => {
112
+ for (const source of candidateRecords) {
113
+ for (const key of keys) {
114
+ const value = source[key];
115
+ if (typeof value === "string" && value.trim()) {
116
+ return value.trim();
117
+ }
118
+ }
119
+ }
120
+ return undefined;
121
+ };
92
122
  const processQueryKey =
93
- (typeof record?.processQueryKey === "string" && record.processQueryKey.trim()) ||
94
- (typeof embeddedValue?.processQueryKey === "string" && embeddedValue.processQueryKey.trim()) ||
95
- (typeof embeddedContent?.processQueryKey === "string" && embeddedContent.processQueryKey.trim()) ||
96
- undefined;
123
+ pickString("processQueryKey");
124
+ const outTrackId = pickString("outTrackId");
125
+ const cardInstanceId = pickString("cardInstanceId");
126
+ // For non-feedback paths, pickString is fine for spaceId/userId (broad search).
127
+ // For feedback paths, use precise extraction from record to avoid picking a
128
+ // same-named but different-valued spaceId from nested embedded objects.
129
+ const spaceId = pickString("spaceId");
130
+ const userId = pickString("userId");
97
131
 
98
132
  if (actionId !== "feedback_up" && actionId !== "feedback_down") {
99
- return { summary, actionId, processQueryKey };
133
+ return {
134
+ summary,
135
+ actionId,
136
+ userId,
137
+ spaceId,
138
+ processQueryKey,
139
+ outTrackId,
140
+ cardInstanceId,
141
+ };
100
142
  }
101
143
 
144
+ // Feedback path: use precise extraction for spaceId/userId to avoid misrouting
145
+ // feedbackTarget when nested objects contain same-named fields with different values.
146
+ const preciseSpaceId =
147
+ (typeof record?.spaceId === "string" && record.spaceId.trim()) || spaceId;
148
+ const preciseUserId =
149
+ (typeof record?.userId === "string" && record.userId.trim()) || userId;
102
150
  const spaceType = typeof record?.spaceType === "string" ? record.spaceType.trim().toLowerCase() : "";
103
- const spaceId = typeof record?.spaceId === "string" ? record.spaceId.trim() : "";
104
- const userId = typeof record?.userId === "string" ? record.userId.trim() : "";
105
- const feedbackTarget = spaceType === "im" ? userId : spaceId;
151
+ const feedbackTarget = spaceType === "im" ? preciseUserId : preciseSpaceId;
106
152
  const feedbackAckText =
107
153
  actionId === "feedback_up"
108
154
  ? "✅ 已收到你的点赞(反馈已记录)"
@@ -114,6 +160,42 @@ export function analyzeCardCallback(data: unknown): CardCallbackAnalysis {
114
160
  feedbackTarget: feedbackTarget || undefined,
115
161
  feedbackAckText,
116
162
  userId: userId || undefined,
163
+ spaceId: spaceId || undefined,
117
164
  processQueryKey,
165
+ outTrackId,
166
+ cardInstanceId,
118
167
  };
119
168
  }
169
+
170
+ /**
171
+ * Update card variables via PUT /v1.0/card/instances.
172
+ * Echoes params back into cardParamMap so the template can use conditional
173
+ * rendering (e.g. hiding buttons when a param is set).
174
+ */
175
+ export async function updateCardVariables(
176
+ outTrackId: string,
177
+ params: Record<string, unknown>,
178
+ token: string,
179
+ config?: { bypassProxyForSend?: boolean },
180
+ ): Promise<number> {
181
+ const stringMap: Record<string, string> = {};
182
+ for (const [k, v] of Object.entries(params)) {
183
+ stringMap[k] = typeof v === "string" ? v : JSON.stringify(v);
184
+ }
185
+ const resp = await axios.put(
186
+ `${DINGTALK_API}/v1.0/card/instances`,
187
+ {
188
+ outTrackId,
189
+ cardData: { cardParamMap: stringMap },
190
+ cardUpdateOptions: { updateCardDataByKey: true, updatePrivateDataByKey: true },
191
+ },
192
+ {
193
+ headers: {
194
+ "Content-Type": "application/json",
195
+ "x-acs-dingtalk-access-token": token,
196
+ },
197
+ ...getProxyBypassOption(config),
198
+ },
199
+ );
200
+ return resp.status;
201
+ }
@@ -3,6 +3,8 @@ 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 { updateCardVariables } from "./card-callback-service";
7
+ import { DINGTALK_CARD_TEMPLATE, STOP_ACTION_VISIBLE, STOP_ACTION_HIDDEN } from "./card/card-template";
6
8
  import { resolveRobotCode, stripTargetPrefix } from "./config";
7
9
  import { resolveOriginalPeerId } from "./peer-id-registry";
8
10
  import {
@@ -42,6 +44,26 @@ const CARD_CACHE_MAX_CONVERSATIONS = 500;
42
44
  const DYNAMIC_SUMMARY_EXTENSION = { dynamicSummary: "true" } as const;
43
45
 
44
46
  const aicardDegradeByAccount = new Map<string, { untilMs: number; reason: string }>();
47
+
48
+ export async function hideCardStopButton(
49
+ outTrackId: string,
50
+ token: string,
51
+ config?: { bypassProxyForSend?: boolean },
52
+ retries = 2,
53
+ ): Promise<void> {
54
+ for (let attempt = 0; ; attempt++) {
55
+ try {
56
+ await updateCardVariables(outTrackId, { stop_action: STOP_ACTION_HIDDEN }, token, config);
57
+ return;
58
+ } catch (err) {
59
+ if (attempt >= retries) {
60
+ throw err;
61
+ }
62
+ await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
63
+ }
64
+ }
65
+ }
66
+
45
67
  const inMemoryCardContentStore = new Map<
46
68
  string,
47
69
  {
@@ -198,6 +220,105 @@ function extractCardProcessQueryKey(payload: unknown): string | undefined {
198
220
  return undefined;
199
221
  }
200
222
 
223
+ async function putAICardStreamingField(
224
+ card: AICardInstance,
225
+ key: string,
226
+ content: string,
227
+ finished: boolean,
228
+ log?: Logger,
229
+ ): Promise<void> {
230
+ const tokenAge = Date.now() - card.createdAt;
231
+ const tokenRefreshThreshold = 90 * 60 * 1000;
232
+ let tokenAlreadyRefreshed = false;
233
+
234
+ if (tokenAge > tokenRefreshThreshold && card.config) {
235
+ log?.debug?.("[DingTalk][AICard] Token age exceeds threshold, refreshing...");
236
+ try {
237
+ card.accessToken = await getAccessToken(card.config, log);
238
+ tokenAlreadyRefreshed = true;
239
+ log?.debug?.("[DingTalk][AICard] Token refreshed successfully");
240
+ } catch (err: any) {
241
+ log?.warn?.(`[DingTalk][AICard] Failed to refresh token: ${err.message}`);
242
+ }
243
+ }
244
+
245
+ const streamBody: AICardStreamingRequest = {
246
+ outTrackId: card.outTrackId || card.cardInstanceId,
247
+ guid: randomUUID(),
248
+ key,
249
+ content,
250
+ isFull: true,
251
+ isFinalize: finished,
252
+ isError: false,
253
+ };
254
+
255
+ log?.debug?.(
256
+ `[DingTalk][AICard] PUT /v1.0/card/streaming key=${key} contentLen=${content.length} isFull=true isFinalize=${finished} guid=${streamBody.guid} payload=${JSON.stringify(streamBody)}`,
257
+ );
258
+
259
+ const requestConfig = {
260
+ headers: {
261
+ "x-acs-dingtalk-access-token": card.accessToken,
262
+ "Content-Type": "application/json",
263
+ },
264
+ ...(card.config ? getProxyBypassOption(card.config) : {}),
265
+ };
266
+
267
+ try {
268
+ const streamResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, streamBody, requestConfig);
269
+ log?.debug?.(
270
+ `[DingTalk][AICard] Streaming response: status=${streamResp.status}, data=${JSON.stringify(streamResp.data)}`,
271
+ );
272
+ card.lastUpdated = Date.now();
273
+ } catch (err: any) {
274
+ if (err.response?.status === 401 && card.config && !tokenAlreadyRefreshed) {
275
+ log?.warn?.("[DingTalk][AICard] Received 401 error, attempting token refresh and retry...");
276
+ try {
277
+ card.accessToken = await getAccessToken(card.config, log);
278
+ const retryResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, streamBody, {
279
+ ...requestConfig,
280
+ headers: {
281
+ ...requestConfig.headers,
282
+ "x-acs-dingtalk-access-token": card.accessToken,
283
+ },
284
+ });
285
+ log?.debug?.(
286
+ `[DingTalk][AICard] Retry after token refresh succeeded: status=${retryResp.status}`,
287
+ );
288
+ card.lastUpdated = Date.now();
289
+ return;
290
+ } catch (retryErr: any) {
291
+ log?.error?.(`[DingTalk][AICard] Retry after token refresh failed: ${retryErr.message}`);
292
+ if (retryErr.response?.data !== undefined) {
293
+ log?.error?.(
294
+ formatDingTalkErrorPayloadLog(
295
+ "card.stream.retryAfterRefresh",
296
+ retryErr.response.data,
297
+ "[DingTalk][AICard]",
298
+ ),
299
+ );
300
+ }
301
+ }
302
+ }
303
+
304
+ if (card.accountId && shouldTriggerAICardDegrade(err)) {
305
+ activateAICardDegrade(
306
+ card.accountId,
307
+ `card.stream:${err?.response?.status || "unknown"}`,
308
+ card.config,
309
+ log,
310
+ );
311
+ }
312
+ log?.error?.(`[DingTalk][AICard] Streaming update failed: key=${key} ${err.message}`);
313
+ if (err.response?.data !== undefined) {
314
+ log?.error?.(
315
+ formatDingTalkErrorPayloadLog("card.stream", err.response.data, "[DingTalk][AICard]"),
316
+ );
317
+ }
318
+ throw err;
319
+ }
320
+ }
321
+
201
322
  interface CreateAICardOptions {
202
323
  accountId?: string;
203
324
  storePath?: string;
@@ -372,7 +493,11 @@ function normalizeRecoveredState(state: string): AICardInstance["state"] {
372
493
 
373
494
  // Helper to identify card terminal states.
374
495
  export function isCardInTerminalState(state: string): boolean {
375
- return state === AICardStatus.FINISHED || state === AICardStatus.FAILED;
496
+ return (
497
+ state === AICardStatus.FINISHED
498
+ || state === AICardStatus.STOPPED
499
+ || state === AICardStatus.FAILED
500
+ );
376
501
  }
377
502
 
378
503
  export function formatContentForCard(content: string | undefined, type: "thinking" | "tool"): string {
@@ -566,6 +691,7 @@ export async function createAICard(
566
691
  const shouldPersistPending =
567
692
  options.persistPending ?? Boolean(options.accountId && options.storePath);
568
693
  const token = await getAccessToken(config, log);
694
+ const template = DINGTALK_CARD_TEMPLATE;
569
695
  // Use randomUUID to avoid collisions across workers/restarts.
570
696
  const cardInstanceId = `card_${randomUUID()}`;
571
697
 
@@ -573,18 +699,17 @@ export async function createAICard(
573
699
 
574
700
  const isGroup = conversationId.startsWith("cid");
575
701
 
576
- if (!config.cardTemplateId) {
577
- throw new Error("DingTalk cardTemplateId is not configured.");
578
- }
579
-
580
702
  // DingTalk createAndDeliver API payload.
581
- const cardTemplateKey = config.cardTemplateKey || "content";
703
+ // Note: do NOT include template.statusKey here — the createAndDeliver API may
704
+ // reject unknown fields if the template variable is not yet provisioned.
705
+ // Status is set to "streaming" via the streaming API immediately after creation.
582
706
  const cardParamMap = {
583
707
  config: JSON.stringify({ autoLayout: true, enableForward: true }),
584
- [cardTemplateKey]: "",
708
+ [template.contentKey]: "",
709
+ stop_action: STOP_ACTION_VISIBLE,
585
710
  };
586
711
  const createAndDeliverBody = {
587
- cardTemplateId: config.cardTemplateId,
712
+ cardTemplateId: template.templateId,
588
713
  outTrackId: cardInstanceId,
589
714
  cardData: {
590
715
  cardParamMap,
@@ -674,6 +799,19 @@ export async function createAICard(
674
799
  }
675
800
 
676
801
  clearAICardDegrade(accountId, log);
802
+
803
+ // Kick the card into streaming mode immediately so the UI shows "输出中" and the
804
+ // stop button becomes visible. Without this, the card sits in "创建中" skeleton state
805
+ // until the first real content arrives — which may never happen for non-streaming replies.
806
+ // This sends an empty content stream (isFull=true, isFinalize=false) which transitions
807
+ // the card from PROCESSING to INPUTING on the DingTalk side.
808
+ try {
809
+ await putAICardStreamingField(aiCardInstance, template.contentKey, "", false, log);
810
+ aiCardInstance.state = AICardStatus.INPUTING;
811
+ } catch (kickErr: any) {
812
+ log?.debug?.(`[DingTalk][AICard] Non-critical: failed to kick card into streaming mode: ${kickErr.message}`);
813
+ }
814
+
677
815
  return aiCardInstance;
678
816
  } catch (err: any) {
679
817
  log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
@@ -704,55 +842,16 @@ export async function streamAICard(
704
842
  finished: boolean = false,
705
843
  log?: Logger,
706
844
  ): Promise<void> {
707
- if (card.state === AICardStatus.FINISHED) {
845
+ if (isCardInTerminalState(card.state)) {
708
846
  log?.debug?.(
709
- `[DingTalk][AICard] Skip stream update because card already finalized: outTrackId=${card.cardInstanceId}`,
847
+ `[DingTalk][AICard] Skip stream update because card already terminal: outTrackId=${card.cardInstanceId} state=${card.state}`,
710
848
  );
711
849
  return;
712
850
  }
713
-
714
- // Refresh token defensively before DingTalk 2h token horizon.
715
- const tokenAge = Date.now() - card.createdAt;
716
- const tokenRefreshThreshold = 90 * 60 * 1000;
717
-
718
- if (tokenAge > tokenRefreshThreshold && card.config) {
719
- log?.debug?.("[DingTalk][AICard] Token age exceeds threshold, refreshing...");
720
- try {
721
- card.accessToken = await getAccessToken(card.config, log);
722
- log?.debug?.("[DingTalk][AICard] Token refreshed successfully");
723
- } catch (err: any) {
724
- log?.warn?.(`[DingTalk][AICard] Failed to refresh token: ${err.message}`);
725
- }
726
- }
727
-
728
- // Always use full replacement to make client rendering deterministic.
729
- const streamBody: AICardStreamingRequest = {
730
- outTrackId: card.outTrackId || card.cardInstanceId,
731
- guid: randomUUID(),
732
- key: card.config?.cardTemplateKey || "content",
733
- content: content,
734
- isFull: true,
735
- isFinalize: finished,
736
- isError: false,
737
- };
738
-
739
- log?.debug?.(
740
- `[DingTalk][AICard] PUT /v1.0/card/streaming contentLen=${content.length} isFull=true isFinalize=${finished} guid=${streamBody.guid} payload=${JSON.stringify(streamBody)}`,
741
- );
851
+ const template = DINGTALK_CARD_TEMPLATE;
742
852
 
743
853
  try {
744
- const streamResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, streamBody, {
745
- headers: {
746
- "x-acs-dingtalk-access-token": card.accessToken,
747
- "Content-Type": "application/json",
748
- },
749
- ...(card.config ? getProxyBypassOption(card.config) : {}),
750
- });
751
- log?.debug?.(
752
- `[DingTalk][AICard] Streaming response: status=${streamResp.status}, data=${JSON.stringify(streamResp.data)}`,
753
- );
754
-
755
- card.lastUpdated = Date.now();
854
+ await putAICardStreamingField(card, template.contentKey, content, finished, log);
756
855
  card.lastStreamedContent = content;
757
856
  if (finished) {
758
857
  card.state = AICardStatus.FINISHED;
@@ -761,85 +860,14 @@ export async function streamAICard(
761
860
  card.state = AICardStatus.INPUTING;
762
861
  }
763
862
  } catch (err: any) {
764
- // 500 unknownError usually means cardTemplateKey mismatch with template variable names.
765
- if (err.response?.status === 500 && err.response?.data?.code === "unknownError") {
766
- const usedKey = streamBody.key;
767
- const cardTemplateId = card.config?.cardTemplateId || "(unknown)";
768
- const errorMsg =
769
- `⚠️ **[DingTalk] AI Card 串流更新失败 (500 unknownError)**\n\n` +
770
- `这通常是因为 \`cardTemplateKey\` (当前值: \`${usedKey}\`) 与钉钉卡片模板 \`${cardTemplateId}\` 中定义的正文变量名不匹配。\n\n` +
771
- `**建议操作**:\n` +
772
- `1. 前往钉钉开发者后台检查该模板的“变量管理”\n` +
773
- `2. 确保配置中的 \`cardTemplateKey\` 与模板中用于显示内容的字段变量名完全一致\n\n` +
774
- `*注意:当前及后续消息将自动转为 Markdown 发送,直到问题修复。*\n` +
775
- `*参考文档: https://github.com/soimy/openclaw-channel-dingtalk/blob/main/README.md#3-%E5%BB%BA%E7%AB%8B%E5%8D%A1%E7%89%87%E6%A8%A1%E6%9D%BF%E5%8F%AF%E9%80%89`;
776
-
777
- log?.error?.(
778
- `[DingTalk][AICard] Streaming failed with 500 unknownError. Key: ${usedKey}, Template: ${cardTemplateId}. ` +
779
- `Verify that "cardTemplateKey" matches the content field variable name in your card template.`,
780
- );
781
-
782
- card.state = AICardStatus.FAILED;
783
- card.lastUpdated = Date.now();
784
- removePendingCard(card, log);
785
- await sendTemplateMismatchNotification(card, errorMsg, log);
786
- throw err;
787
- }
788
-
789
- // Retry once on 401 with refreshed token.
790
- if (err.response?.status === 401 && card.config) {
791
- log?.warn?.("[DingTalk][AICard] Received 401 error, attempting token refresh and retry...");
792
- try {
793
- card.accessToken = await getAccessToken(card.config, log);
794
- const retryResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, streamBody, {
795
- headers: {
796
- "x-acs-dingtalk-access-token": card.accessToken,
797
- "Content-Type": "application/json",
798
- },
799
- ...(card.config ? getProxyBypassOption(card.config) : {}),
800
- });
801
- log?.debug?.(
802
- `[DingTalk][AICard] Retry after token refresh succeeded: status=${retryResp.status}`,
803
- );
804
- card.lastUpdated = Date.now();
805
- card.lastStreamedContent = content;
806
- if (finished) {
807
- card.state = AICardStatus.FINISHED;
808
- removePendingCard(card, log);
809
- } else if (card.state === AICardStatus.PROCESSING) {
810
- card.state = AICardStatus.INPUTING;
811
- }
812
- return;
813
- } catch (retryErr: any) {
814
- log?.error?.(`[DingTalk][AICard] Retry after token refresh failed: ${retryErr.message}`);
815
- if (retryErr.response?.data !== undefined) {
816
- log?.error?.(
817
- formatDingTalkErrorPayloadLog(
818
- "card.stream.retryAfterRefresh",
819
- retryErr.response.data,
820
- "[DingTalk][AICard]",
821
- ),
822
- );
823
- }
824
- }
825
- }
826
-
827
863
  card.state = AICardStatus.FAILED;
828
864
  card.lastUpdated = Date.now();
829
865
  removePendingCard(card, log);
830
- if (card.accountId && shouldTriggerAICardDegrade(err)) {
831
- activateAICardDegrade(
832
- card.accountId,
833
- `card.stream:${err?.response?.status || "unknown"}`,
834
- card.config,
835
- log,
836
- );
837
- }
838
- log?.error?.(`[DingTalk][AICard] Streaming update failed: ${err.message}`);
839
- if (err.response?.data !== undefined) {
840
- log?.error?.(
841
- formatDingTalkErrorPayloadLog("card.stream", err.response.data, "[DingTalk][AICard]"),
842
- );
866
+ if (err.response?.status === 500 && err.response?.data?.code === "unknownError") {
867
+ const errorMsg =
868
+ "⚠️ **[DingTalk] AI Card 串流更新失败 (500 unknownError)**\n\n"
869
+ + "这通常表示当前内置模板契约与钉钉侧模板字段不一致,当前及后续消息将自动回退为 Markdown 发送。";
870
+ await sendTemplateMismatchNotification(card, errorMsg, log);
843
871
  }
844
872
  throw err;
845
873
  }
@@ -853,6 +881,15 @@ export async function finishAICard(
853
881
  ): Promise<void> {
854
882
  log?.debug?.(`[DingTalk][AICard] Starting finish, final content length=${content.length}`);
855
883
  await streamAICard(card, content, true, log);
884
+ // Hide stop button on normal completion (symmetric with card-stop-handler).
885
+ if (card.outTrackId && card.config) {
886
+ try {
887
+ const token = await getAccessToken(card.config, log);
888
+ await hideCardStopButton(card.outTrackId, token, card.config);
889
+ } catch (err: any) {
890
+ log?.debug?.(`[DingTalk][AICard] Non-critical: failed to hide stop button on finish: ${err.message}`);
891
+ }
892
+ }
856
893
  if (card.conversationId && content.trim() && card.accountId && card.processQueryKey) {
857
894
  const primaryConversationId = card.contextConversationId || card.conversationId;
858
895
  cacheCardContentByProcessQueryKey(
@@ -867,6 +904,30 @@ export async function finishAICard(
867
904
  }
868
905
  }
869
906
 
907
+ export async function finishStoppedAICard(
908
+ card: AICardInstance,
909
+ content: string,
910
+ log?: Logger,
911
+ ): Promise<void> {
912
+ if (isCardInTerminalState(card.state)) {
913
+ log?.debug?.(
914
+ `[DingTalk][AICard] finishStoppedAICard skipped — already terminal: ${card.state}`,
915
+ );
916
+ return;
917
+ }
918
+ const template = DINGTALK_CARD_TEMPLATE;
919
+ try {
920
+ await putAICardStreamingField(card, template.contentKey, content, true, log);
921
+ } finally {
922
+ // Ensure local state is consistent even when the streaming API call fails.
923
+ // The card is logically stopped regardless of whether DingTalk acknowledged it.
924
+ card.lastStreamedContent = content;
925
+ card.state = AICardStatus.STOPPED;
926
+ card.lastUpdated = Date.now();
927
+ removePendingCard(card, log);
928
+ }
929
+ }
930
+
870
931
  function cacheCardContentByProcessQueryKey(
871
932
  accountId: string,
872
933
  conversationId: string,
package/src/channel.ts CHANGED
@@ -7,6 +7,7 @@ import { readStringParam } from "openclaw/plugin-sdk/param-readers";
7
7
  import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
8
8
  import { getAccessToken } from "./auth";
9
9
  import { analyzeCardCallback } from "./card-callback-service";
10
+ import { handleCardAction } from "./card/card-action-handler";
10
11
  import {
11
12
  createAICard,
12
13
  streamAICard,
@@ -837,6 +838,18 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
837
838
  );
838
839
  }
839
840
  }
841
+ const actionResult = await handleCardAction({
842
+ analysis,
843
+ cfg,
844
+ accountId: account.accountId,
845
+ config,
846
+ log: ctx.log,
847
+ });
848
+ if (!actionResult.handled && analysis.actionId && analysis.actionId !== "feedback_up" && analysis.actionId !== "feedback_down") {
849
+ ctx.log?.debug?.(
850
+ `[${account.accountId}] [DingTalk][CardCallback] Unhandled actionId=${analysis.actionId}`,
851
+ );
852
+ }
840
853
  } catch (error: any) {
841
854
  ctx.log?.error?.(
842
855
  `[${account.accountId}] [DingTalk][CardCallback] Failed to parse callback: ${error.message}`,
@@ -0,0 +1,96 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { getDingTalkRuntime } from "../runtime";
3
+ import type { Logger } from "../types";
4
+
5
+ /**
6
+ * Local implementation of the same logic as
7
+ * `resolveNativeCommandSessionTargets` from `openclaw/plugin-sdk/command-auth`.
8
+ *
9
+ * Inlined because the CI openclaw package does not yet export that sub-path.
10
+ * Replace with a direct import once the upstream package is updated.
11
+ */
12
+ function resolveNativeCommandSessionTargets(params: {
13
+ agentId: string;
14
+ sessionPrefix: string;
15
+ userId: string;
16
+ targetSessionKey: string;
17
+ }): { sessionKey: string; commandTargetSessionKey: string } {
18
+ return {
19
+ sessionKey: `agent:${params.agentId}:${params.sessionPrefix}:${params.userId}`,
20
+ commandTargetSessionKey: params.targetSessionKey,
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Dispatch a native targeted `/stop` command through the OpenClaw SDK,
26
+ * replacing the previous self-built Gateway WebSocket `chat.abort` approach.
27
+ *
28
+ * Uses the same `resolveNativeCommandSessionTargets` + `CommandSource: "native"`
29
+ * model as Telegram / Discord / Slack slash commands, producing:
30
+ * - A dedicated command SessionKey (`agent:<agentId>:dingtalk:card-stop:<userId>`)
31
+ * - A CommandTargetSessionKey pointing at the real conversation session
32
+ *
33
+ * Inside the SDK, `dispatch-from-config` → `tryFastAbortFromMessage` picks up
34
+ * the `/stop` body, resolves the target session via `CommandTargetSessionKey`,
35
+ * and executes `abortEmbeddedPiRun` + `clearSessionQueues`.
36
+ *
37
+ * Accesses SDK functions via `getDingTalkRuntime().channel.reply` — the same
38
+ * pattern used by `inbound-handler.ts` — to avoid direct sub-path imports
39
+ * that may not be available in the CI openclaw package version.
40
+ */
41
+ export async function dispatchDingTalkCardStopCommand(params: {
42
+ cfg: OpenClawConfig;
43
+ accountId: string;
44
+ agentId: string;
45
+ targetSessionKey: string;
46
+ clickerUserId: string;
47
+ log?: Logger;
48
+ }): Promise<{ ok: boolean }> {
49
+ const rt = getDingTalkRuntime();
50
+
51
+ const { sessionKey: commandSessionKey, commandTargetSessionKey } =
52
+ resolveNativeCommandSessionTargets({
53
+ agentId: params.agentId,
54
+ sessionPrefix: "dingtalk:card-stop",
55
+ userId: params.clickerUserId,
56
+ targetSessionKey: params.targetSessionKey,
57
+ });
58
+
59
+ const ctx = rt.channel.reply.finalizeInboundContext({
60
+ Body: "/stop",
61
+ RawBody: "/stop",
62
+ CommandBody: "/stop",
63
+ SessionKey: commandSessionKey,
64
+ CommandTargetSessionKey: commandTargetSessionKey,
65
+ CommandSource: "native" as const,
66
+ CommandAuthorized: true,
67
+ AccountId: params.accountId,
68
+ Provider: "dingtalk",
69
+ Surface: "dingtalk",
70
+ // "direct" because the synthetic /stop body contains no @mentions to strip.
71
+ // The actual chat type of the target session is irrelevant for abort routing.
72
+ ChatType: "direct",
73
+ From: `dingtalk:card-stop:${params.clickerUserId}`,
74
+ To: `card-stop:${params.clickerUserId}`,
75
+ SenderId: params.clickerUserId,
76
+ OriginatingChannel: "dingtalk",
77
+ });
78
+
79
+ // DispatchInboundResult = { queuedFinal, counts } — the return value does
80
+ // not expose whether tryFastAbortFromMessage took the fast-abort path.
81
+ // Treat successful dispatch as best-effort abort, consistent with the
82
+ // previous gateway chat.abort approach.
83
+ await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
84
+ ctx,
85
+ cfg: params.cfg,
86
+ dispatcherOptions: {
87
+ responsePrefix: "",
88
+ deliver: async () => {
89
+ // SDK abort confirmation text is swallowed here; the card
90
+ // finalize path handles stopped content independently.
91
+ },
92
+ },
93
+ });
94
+
95
+ return { ok: true };
96
+ }
@@ -8,6 +8,12 @@ import { extractAttachmentText } from "./attachment-text-extractor";
8
8
  import { getAccessToken } from "./auth";
9
9
  import { createAICard, finishAICard, isCardInTerminalState } from "./card-service";
10
10
  import { resolveAckReactionSetting, resolveGroupConfig, resolveRobotCode } from "./config";
11
+ import { AICardStatus } from "./types";
12
+ import {
13
+ isCardRunStopRequested,
14
+ registerCardRun,
15
+ removeCardRun,
16
+ } from "./card/card-run-registry";
11
17
  import {
12
18
  applyManualTargetLearningRule,
13
19
  applyManualTargetsLearningRule,
@@ -83,7 +89,6 @@ import {
83
89
  upsertObservedGroupTarget,
84
90
  upsertObservedUserTarget,
85
91
  } from "./targeting/target-directory-store";
86
- import { AICardStatus } from "./types";
87
92
  import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
88
93
  import { formatDingTalkErrorPayloadLog, getErrorMessage, getErrorResponseData, maskSensitiveData } from "./utils";
89
94
  import { isAbortRequestText } from "openclaw/plugin-sdk/reply-runtime";
@@ -1046,7 +1051,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1046
1051
  // Card creation runs BEFORE media download so the user sees immediate visual
1047
1052
  // feedback while large files are still being downloaded.
1048
1053
  let useCardMode = dingtalkConfig.messageType === "card";
1049
- let currentAICard = undefined;
1054
+ let currentAICard: import("./types").AICardInstance | undefined;
1050
1055
 
1051
1056
  if (useCardMode) {
1052
1057
  try {
@@ -1060,6 +1065,15 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1060
1065
  });
1061
1066
  if (aiCard) {
1062
1067
  currentAICard = aiCard;
1068
+ if (aiCard.outTrackId) {
1069
+ registerCardRun(aiCard.outTrackId, {
1070
+ accountId,
1071
+ sessionKey: route.sessionKey,
1072
+ agentId: route.agentId,
1073
+ ownerUserId: senderId,
1074
+ card: aiCard,
1075
+ });
1076
+ }
1063
1077
  } else {
1064
1078
  useCardMode = false;
1065
1079
  log?.warn?.(
@@ -1798,6 +1812,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1798
1812
  // causes empty replies for all but the first caller.
1799
1813
  // Each sub-agent call acquires its own lock since sub-agent sessions have
1800
1814
  // different session keys (different agentId), so no deadlock risk.
1815
+ const currentOutTrackId = currentAICard?.outTrackId;
1801
1816
  const shouldTrackDynamicAckReaction =
1802
1817
  (normalizedAckReaction === "emoji" || normalizedAckReaction === "kaomoji")
1803
1818
  && shouldAttachAckReaction;
@@ -1826,6 +1841,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1826
1841
  if (!ackReactionAttached && shouldAttachAckReaction) {
1827
1842
  log?.debug?.("[DingTalk] Native ack reaction unavailable; skipping fallback.");
1828
1843
  }
1844
+ const isCurrentCardStopRequested = () =>
1845
+ Boolean(
1846
+ currentAICard
1847
+ && (
1848
+ currentAICard.state === AICardStatus.STOPPED
1849
+ || (currentOutTrackId && isCardRunStopRequested(currentOutTrackId))
1850
+ ),
1851
+ );
1852
+
1853
+ if (isCurrentCardStopRequested()) {
1854
+ log?.info?.("[DingTalk][CardStop] Skip dispatch because card was already stopped before session lock was acquired");
1855
+ return;
1856
+ }
1829
1857
 
1830
1858
  // ---- Create reply strategy (card or markdown) ----
1831
1859
  const strategy = createReplyStrategy({
@@ -1842,6 +1870,7 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1842
1870
  log,
1843
1871
  replyQuotedRef,
1844
1872
  deliverMedia: deliverMediaAttachments,
1873
+ isStopRequested: isCurrentCardStopRequested,
1845
1874
  });
1846
1875
 
1847
1876
  try {
@@ -1851,6 +1880,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1851
1880
  dispatcherOptions: {
1852
1881
  responsePrefix: subAgentOptions?.responsePrefix || "",
1853
1882
  deliver: async (payload: ReplyStreamPayload, info?: ReplyChunkInfo) => {
1883
+ if (isCurrentCardStopRequested()) {
1884
+ log?.debug?.("[DingTalk][CardStop] Ignoring reply delivery because stop was already requested");
1885
+ return;
1886
+ }
1854
1887
  try {
1855
1888
  const mediaUrls = extractMediaUrls(payload);
1856
1889
  await strategy.deliver({
@@ -1878,6 +1911,13 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
1878
1911
 
1879
1912
  await strategy.finalize();
1880
1913
  } finally {
1914
+ // Only remove the registry entry if no stop was requested. When a stop is
1915
+ // in progress, card-stop-handler may still be running async operations
1916
+ // (finalize card, hide button, gateway abort) that read the record.
1917
+ // In that case, let the 30-minute TTL sweep handle cleanup.
1918
+ if (currentOutTrackId && !isCardRunStopRequested(currentOutTrackId)) {
1919
+ removeCardRun(currentOutTrackId);
1920
+ }
1881
1921
  await waitForDynamicAckDispose({
1882
1922
  dispose: () => dynamicAckReactionController.dispose(MIN_THINKING_REACTION_VISIBLE_MS),
1883
1923
  log,
package/src/onboarding.ts CHANGED
@@ -126,8 +126,6 @@ function applyAccountConfig(params: {
126
126
  ? { mediaUrlAllowlist: input.mediaUrlAllowlist }
127
127
  : {}),
128
128
  ...(input.messageType ? { messageType: input.messageType } : {}),
129
- ...(input.cardTemplateId ? { cardTemplateId: input.cardTemplateId } : {}),
130
- ...(input.cardTemplateKey ? { cardTemplateKey: input.cardTemplateKey } : {}),
131
129
  ...(typeof input.maxReconnectCycles === "number"
132
130
  ? { maxReconnectCycles: input.maxReconnectCycles }
133
131
  : {}),
@@ -234,41 +232,17 @@ async function configureDingTalkAccount(params: {
234
232
  initialValue: resolved.messageType === "card",
235
233
  });
236
234
 
237
- let cardTemplateId: string | undefined;
238
- let cardTemplateKey: string | undefined;
239
235
  let messageType: "markdown" | "card" = "markdown";
240
236
 
241
237
  if (wantsCardMode) {
242
238
  await prompter.note(
243
239
  [
244
- "Create an AI card template in DingTalk Developer Console:",
245
- "https://open-dev.dingtalk.com/fe/card",
246
- "1. Go to 'My Templates' > 'Create Template'",
247
- "2. Select 'AI Card' scenario",
248
- "3. Design your card and publish",
249
- "4. Copy the Template ID (e.g., xxx.schema)",
240
+ "AI interactive card mode now uses the built-in DingTalk template contract.",
241
+ "No manual Template ID or content field configuration is required.",
242
+ "Legacy cardTemplateId/cardTemplateKey config is deprecated and ignored.",
250
243
  ].join("\n"),
251
- "Card Template Setup",
244
+ "Built-in AI Card Template",
252
245
  );
253
-
254
- cardTemplateId =
255
- String(
256
- await prompter.text({
257
- message: "Card Template ID",
258
- placeholder: "xxxxx-xxxxx-xxxxx.schema",
259
- initialValue: resolved.cardTemplateId ?? undefined,
260
- }),
261
- ).trim() || undefined;
262
-
263
- cardTemplateKey =
264
- String(
265
- await prompter.text({
266
- message: "Card Template Key (content field name)",
267
- placeholder: "content",
268
- initialValue: resolved.cardTemplateKey ?? "content",
269
- }),
270
- ).trim() || "content";
271
-
272
246
  messageType = "card";
273
247
  }
274
248
 
@@ -436,8 +410,6 @@ async function configureDingTalkAccount(params: {
436
410
  displayNameResolution: displayNameResolutionValue as "disabled" | "all",
437
411
  mediaUrlAllowlist,
438
412
  messageType,
439
- cardTemplateId,
440
- cardTemplateKey,
441
413
  maxReconnectCycles,
442
414
  mediaMaxMb,
443
415
  journalTTLDays,
@@ -11,6 +11,7 @@ import {
11
11
  isCardInTerminalState,
12
12
  } from "./card-service";
13
13
  import { createCardDraftController } from "./card-draft-controller";
14
+ import { attachCardRunController } from "./card/card-run-registry";
14
15
  import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
15
16
  import { sendBySession, sendMessage } from "./send-service";
16
17
  import type { AICardInstance } from "./types";
@@ -20,11 +21,14 @@ import { formatDingTalkErrorPayloadLog } from "./utils";
20
21
  const FILE_ONLY_FALLBACK_ANSWER = "附件已发送,请查收。";
21
22
 
22
23
  export function createCardReplyStrategy(
23
- ctx: ReplyStrategyContext & { card: AICardInstance },
24
+ ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
24
25
  ): ReplyStrategy {
25
- const { card, config, log } = ctx;
26
+ const { card, config, log, isStopRequested } = ctx;
26
27
 
27
28
  const controller = createCardDraftController({ card, log });
29
+ if (card.outTrackId) {
30
+ attachCardRunController(card.outTrackId, controller);
31
+ }
28
32
  let finalTextForFallback: string | undefined;
29
33
  let sawFinalDelivery = false;
30
34
 
@@ -44,19 +48,22 @@ export function createCardReplyStrategy(
44
48
  disableBlockStreaming: true,
45
49
 
46
50
  onAssistantMessageStart: async () => {
51
+ if (isStopRequested?.()) {
52
+ return;
53
+ }
47
54
  await controller.notifyNewAssistantTurn();
48
55
  },
49
56
 
50
57
  onPartialReply: config.cardRealTimeStream
51
58
  ? async (payload) => {
52
- if (payload.text) {
59
+ if (payload.text && !isStopRequested?.()) {
53
60
  await controller.updateAnswer(payload.text);
54
61
  }
55
62
  }
56
63
  : undefined,
57
64
 
58
65
  onReasoningStream: async (payload) => {
59
- if (payload.text) {
66
+ if (payload.text && !isStopRequested?.()) {
60
67
  await controller.updateThinking(payload.text);
61
68
  }
62
69
  },
@@ -122,11 +129,21 @@ export function createCardReplyStrategy(
122
129
  `lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
123
130
  );
124
131
 
132
+ if (isStopRequested?.()) {
133
+ log?.info?.("[DingTalk][Finalize] Skipping — card stop was requested");
134
+ return;
135
+ }
136
+
125
137
  if (card.state === AICardStatus.FINISHED) {
126
138
  log?.info?.("[DingTalk][Finalize] Skipping — card already FINISHED");
127
139
  return;
128
140
  }
129
141
 
142
+ if (card.state === AICardStatus.STOPPED) {
143
+ log?.info?.("[DingTalk][Finalize] Skipping — card already STOPPED");
144
+ return;
145
+ }
146
+
130
147
  // Card failed -> markdown fallback (bypass sendMessage to avoid duplicate card).
131
148
  if (card.state === AICardStatus.FAILED || controller.isFailed()) {
132
149
  const fallbackText = getRenderedTimeline({ preferFinalAnswer: true })
@@ -55,6 +55,7 @@ export interface ReplyStrategyContext {
55
55
  log?: Logger;
56
56
  replyQuotedRef?: QuotedRef;
57
57
  deliverMedia: (urls: string[]) => Promise<void>;
58
+ isStopRequested?: () => boolean;
58
59
  }
59
60
 
60
61
  // ---- Factory -----------------------------------------------------
@@ -225,7 +225,7 @@ export async function sendProactiveTextOrMarkdown(
225
225
 
226
226
  // In card mode, use card API to avoid oToMessages/batchSend permission requirement.
227
227
  const messageType = config.messageType || "markdown";
228
- if (messageType === "card" && config.cardTemplateId && !options.forceMarkdown) {
228
+ if (messageType === "card" && !options.forceMarkdown) {
229
229
  log?.debug?.(
230
230
  `[DingTalk] Using card API for proactive message to user ${resolvedTarget}${proactiveRiskTag}`,
231
231
  );
@@ -582,20 +582,18 @@ export async function sendMessage(
582
582
  return { ok: true };
583
583
  }
584
584
 
585
- if (config.cardTemplateId) {
586
- const proactiveResult = await sendProactiveCardText(config, conversationId, text, log);
587
- if (!proactiveResult.ok) {
588
- return { ok: false, error: proactiveResult.error || "Card send failed" };
589
- }
590
- return {
591
- ok: true,
592
- tracking: {
593
- processQueryKey: proactiveResult.processQueryKey,
594
- outTrackId: proactiveResult.outTrackId,
595
- cardInstanceId: proactiveResult.cardInstanceId,
596
- },
597
- };
585
+ const proactiveResult = await sendProactiveCardText(config, conversationId, text, log);
586
+ if (!proactiveResult.ok) {
587
+ return { ok: false, error: proactiveResult.error || "Card send failed" };
598
588
  }
589
+ return {
590
+ ok: true,
591
+ tracking: {
592
+ processQueryKey: proactiveResult.processQueryKey,
593
+ outTrackId: proactiveResult.outTrackId,
594
+ cardInstanceId: proactiveResult.cardInstanceId,
595
+ },
596
+ };
599
597
  }
600
598
  }
601
599
 
package/src/types.ts CHANGED
@@ -44,7 +44,9 @@ export interface DingTalkConfig extends OpenClawConfig {
44
44
  ackReaction?: AckReactionConfigValue;
45
45
  debug?: boolean;
46
46
  messageType?: "markdown" | "card";
47
+ /** @deprecated 已固定使用内置模板契约 */
47
48
  cardTemplateId?: string;
49
+ /** @deprecated 已固定使用内置模板契约 */
48
50
  cardTemplateKey?: string;
49
51
  groups?: Record<string, { systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] }>;
50
52
  accounts?: Record<string, DingTalkConfig>;
@@ -103,7 +105,9 @@ export interface DingTalkChannelConfig {
103
105
  ackReaction?: AckReactionConfigValue;
104
106
  debug?: boolean;
105
107
  messageType?: "markdown" | "card";
108
+ /** @deprecated 已固定使用内置模板契约 */
106
109
  cardTemplateId?: string;
110
+ /** @deprecated 已固定使用内置模板契约 */
107
111
  cardTemplateKey?: string;
108
112
  groups?: Record<string, { systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] }>;
109
113
  accounts?: Record<string, DingTalkConfig>;
@@ -618,6 +622,7 @@ export const AICardStatus = {
618
622
  PROCESSING: "1",
619
623
  INPUTING: "2",
620
624
  FINISHED: "3",
625
+ STOPPED: "4",
621
626
  FAILED: "5",
622
627
  } as const;
623
628
 
@@ -639,7 +644,7 @@ export interface AICardInstance {
639
644
  storePath?: string;
640
645
  createdAt: number;
641
646
  lastUpdated: number;
642
- state: AICardState; // Current card state: PROCESSING, INPUTING, FINISHED, FAILED
647
+ state: AICardState; // Current card state: PROCESSING, INPUTING, FINISHED, STOPPED, FAILED
643
648
  config?: DingTalkConfig; // Store config reference for token refresh
644
649
  lastStreamedContent?: string;
645
650
  outTrackId?: string;