@soimy/dingtalk 3.4.2 → 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.4.2",
3
+ "version": "3.5.1",
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",
@@ -55,12 +60,19 @@
55
60
  "oxlint": "^1.49.0",
56
61
  "oxlint-tsgolint": "^0.15.0",
57
62
  "typescript": "^5.3.0",
63
+ "vitepress": "1.6.4",
58
64
  "vitest": "^3.2.4"
59
65
  },
60
66
  "peerDependencies": {
61
- "openclaw": ">=2026.3.14"
67
+ "openclaw": ">=2026.3.24"
62
68
  },
63
69
  "openclaw": {
70
+ "compat": {
71
+ "pluginApi": ">=2026.3.24"
72
+ },
73
+ "build": {
74
+ "openclawVersion": "2026.3.24"
75
+ },
64
76
  "extensions": [
65
77
  "./index.ts"
66
78
  ],
@@ -72,8 +84,8 @@
72
84
  "id": "dingtalk",
73
85
  "label": "DingTalk",
74
86
  "selectionLabel": "DingTalk (钉钉)",
75
- "docsPath": "https://github.com/soimy/openclaw-channel-dingtalk",
76
- "docsLabel": "plugin docs",
87
+ "docsPath": "https://soimy.github.io/openclaw-channel-dingtalk/",
88
+ "docsLabel": "documentation",
77
89
  "blurb": "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
78
90
  "order": 70,
79
91
  "aliases": [
@@ -82,6 +94,7 @@
82
94
  ]
83
95
  },
84
96
  "install": {
97
+ "minHostVersion": ">=2026.3.24",
85
98
  "npmSpec": "@soimy/dingtalk",
86
99
  "localPath": ".",
87
100
  "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
  }
@@ -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
+ }