@soimy/dingtalk 3.5.0 → 3.5.2

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/README.md CHANGED
@@ -10,6 +10,7 @@
10
10
  <a href="https://www.npmjs.com/package/@soimy/dingtalk"><img alt="npm downloads" src="https://img.shields.io/npm/dm/%40soimy%2Fdingtalk"></a>
11
11
  <a href="https://github.com/soimy/openclaw-channel-dingtalk/actions/workflows/docs-pages.yml"><img alt="Docs" src="https://img.shields.io/github/actions/workflow/status/soimy/openclaw-channel-dingtalk/docs-pages.yml?branch=main&label=Docs"></a>
12
12
  <a href="https://github.com/soimy/openclaw-channel-dingtalk/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/soimy/openclaw-channel-dingtalk"></a>
13
+ <a href="https://github.com/soimy/openclaw-channel-dingtalk/blob/main/CITATION.cff"><img alt="Citation" src="https://img.shields.io/badge/Citation-CITATION.cff-1277B5"></a>
13
14
  </p>
14
15
 
15
16
  针对 OpenClaw 的钉钉企业内部机器人 Channel 渠道插件,使用 Stream 模式,无需公网 IP。
@@ -33,6 +34,11 @@
33
34
  - 发布记录:[docs/releases/index.md](docs/releases/index.md)
34
35
  - 英文入口:[docs/en/index.md](docs/en/index.md)
35
36
 
37
+ ## 引用与署名
38
+
39
+ - GitHub / 机器可读引用元数据:[CITATION.cff](https://github.com/soimy/openclaw-channel-dingtalk/blob/main/CITATION.cff)
40
+ - 维护者对复用、引用与 AI 协作场景的署名请求:[docs/contributor/citation-and-attribution.md](docs/contributor/citation-and-attribution.md)
41
+
36
42
  ## 安装
37
43
 
38
44
  > [!IMPORTANT]
@@ -180,4 +186,4 @@ pnpm test
180
186
 
181
187
  ## 许可
182
188
 
183
- [MIT](LICENSE)
189
+ [MIT](https://github.com/soimy/openclaw-channel-dingtalk/blob/main/LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soimy/dingtalk",
3
- "version": "3.5.0",
3
+ "version": "3.5.2",
4
4
  "description": "DingTalk (钉钉) channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
@@ -46,7 +46,7 @@
46
46
  "type-check": "tsc -p tsconfig.json"
47
47
  },
48
48
  "dependencies": {
49
- "axios": "^1.6.0",
49
+ "axios": "1.13.6",
50
50
  "dingtalk-stream": "^2.1.4",
51
51
  "form-data": "^4.0.0",
52
52
  "mammoth": "^1.12.0",
@@ -58,7 +58,7 @@
58
58
  "@vitest/coverage-v8": "^3.2.4",
59
59
  "oxfmt": "0.34.0",
60
60
  "oxlint": "^1.49.0",
61
- "oxlint-tsgolint": "^0.15.0",
61
+ "oxlint-tsgolint": "^0.18.0",
62
62
  "typescript": "^5.3.0",
63
63
  "vitepress": "1.6.4",
64
64
  "vitest": "^3.2.4"
@@ -66,7 +66,18 @@
66
66
  "peerDependencies": {
67
67
  "openclaw": ">=2026.3.24"
68
68
  },
69
+ "peerDependenciesMeta": {
70
+ "openclaw": {
71
+ "optional": true
72
+ }
73
+ },
69
74
  "openclaw": {
75
+ "compat": {
76
+ "pluginApi": ">=2026.3.24"
77
+ },
78
+ "build": {
79
+ "openclawVersion": "2026.3.24"
80
+ },
70
81
  "extensions": [
71
82
  "./index.ts"
72
83
  ],
@@ -1,4 +1,4 @@
1
- import axios from "axios";
1
+ import axios from "./http-client";
2
2
  import { getAccessToken } from "./auth";
3
3
  import type { DingTalkConfig } from "./types";
4
4
  import { formatDingTalkErrorPayloadLog, getErrorMessage, getProxyBypassOption } from "./utils";
package/src/auth.ts CHANGED
@@ -1,4 +1,4 @@
1
- import axios from "axios";
1
+ import axios from "./http-client";
2
2
  import type { DingTalkConfig, Logger, TokenInfo } from "./types";
3
3
  import { retryWithBackoff } from "./utils";
4
4
 
@@ -0,0 +1,62 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
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/core";
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
+
@@ -0,0 +1,157 @@
1
+ export interface ReasoningBlockAssembler {
2
+ ingestSnapshot: (text: string | undefined) => string[];
3
+ flushPendingAtBoundary: () => string[];
4
+ reset: () => void;
5
+ }
6
+
7
+ function stripReasoningPrefix(text: string): string {
8
+ const trimmed = text.trim();
9
+ if (trimmed.startsWith("Reasoning:")) {
10
+ return trimmed.slice("Reasoning:".length).trimStart();
11
+ }
12
+ return trimmed;
13
+ }
14
+
15
+ function cleanReasoningLine(line: string): string {
16
+ return line.trim().replace(/^_/, "").replace(/_$/, "").trim();
17
+ }
18
+
19
+ function isClosedReasoningLine(line: string): boolean {
20
+ const trimmed = line.trim();
21
+ return trimmed.startsWith("_") && trimmed.endsWith("_") && trimmed.length >= 2;
22
+ }
23
+
24
+ function startsReasonBlock(line: string): boolean {
25
+ return cleanReasoningLine(line).startsWith("Reason:");
26
+ }
27
+
28
+ function blocksStartWithPrefix(blocks: string[], prefix: string[]): boolean {
29
+ if (prefix.length > blocks.length) {
30
+ return false;
31
+ }
32
+ return prefix.every((entry, index) => blocks[index] === entry);
33
+ }
34
+
35
+ function parseReasoningSnapshot(text: string | undefined): {
36
+ completeBlocks: string[];
37
+ pendingBlock: string;
38
+ } {
39
+ const normalized = typeof text === "string" ? stripReasoningPrefix(text) : "";
40
+ if (!normalized.trim()) {
41
+ return {
42
+ completeBlocks: [],
43
+ pendingBlock: "",
44
+ };
45
+ }
46
+
47
+ const lines = normalized
48
+ .split("\n")
49
+ .map((line) => line.trim())
50
+ .filter((line) => line.length > 0);
51
+
52
+ const completeBlocks: string[] = [];
53
+ let currentLines: string[] = [];
54
+ let currentMode: "explicit" | "implicit" | null = null;
55
+ let currentComplete = true;
56
+
57
+ const finalizeCurrent = () => {
58
+ if (currentLines.length === 0) {
59
+ return;
60
+ }
61
+ if (currentMode === "explicit" && currentComplete) {
62
+ completeBlocks.push(currentLines.join("\n"));
63
+ }
64
+ };
65
+
66
+ for (const line of lines) {
67
+ if (startsReasonBlock(line)) {
68
+ finalizeCurrent();
69
+ currentLines = [cleanReasoningLine(line)];
70
+ currentMode = "explicit";
71
+ currentComplete = isClosedReasoningLine(line);
72
+ continue;
73
+ }
74
+
75
+ if (currentLines.length === 0) {
76
+ const trimmedLine = line.trim();
77
+ const cleanedLine = cleanReasoningLine(line);
78
+ if (!cleanedLine) {
79
+ continue;
80
+ }
81
+ if (!trimmedLine.startsWith("_")) {
82
+ continue;
83
+ }
84
+ currentLines = [cleanedLine];
85
+ currentMode = "implicit";
86
+ currentComplete = false;
87
+ continue;
88
+ }
89
+
90
+ currentLines.push(cleanReasoningLine(line));
91
+ currentComplete = currentMode === "explicit"
92
+ ? currentComplete && isClosedReasoningLine(line)
93
+ : false;
94
+ }
95
+
96
+ if (currentLines.length === 0) {
97
+ return {
98
+ completeBlocks,
99
+ pendingBlock: "",
100
+ };
101
+ }
102
+
103
+ if (currentMode === "explicit" && currentComplete) {
104
+ completeBlocks.push(currentLines.join("\n"));
105
+ return {
106
+ completeBlocks,
107
+ pendingBlock: "",
108
+ };
109
+ }
110
+
111
+ return {
112
+ completeBlocks,
113
+ pendingBlock: currentLines.join("\n").trim(),
114
+ };
115
+ }
116
+
117
+ export function createReasoningBlockAssembler(): ReasoningBlockAssembler {
118
+ let emittedBlocks: string[] = [];
119
+ let pendingBlock = "";
120
+
121
+ return {
122
+ ingestSnapshot(text: string | undefined): string[] {
123
+ const parsed = parseReasoningSnapshot(text);
124
+ pendingBlock = parsed.pendingBlock;
125
+
126
+ if (parsed.completeBlocks.length === 0) {
127
+ return [];
128
+ }
129
+
130
+ if (blocksStartWithPrefix(parsed.completeBlocks, emittedBlocks)) {
131
+ const nextBlocks = parsed.completeBlocks.slice(emittedBlocks.length);
132
+ emittedBlocks = [...parsed.completeBlocks];
133
+ return nextBlocks;
134
+ }
135
+
136
+ if (blocksStartWithPrefix(emittedBlocks, parsed.completeBlocks)) {
137
+ return [];
138
+ }
139
+
140
+ return [];
141
+ },
142
+
143
+ flushPendingAtBoundary(): string[] {
144
+ if (!pendingBlock.trim()) {
145
+ return [];
146
+ }
147
+ const flushed = pendingBlock;
148
+ pendingBlock = "";
149
+ return [flushed];
150
+ },
151
+
152
+ reset(): void {
153
+ emittedBlocks = [];
154
+ pendingBlock = "";
155
+ },
156
+ };
157
+ }
@@ -1,10 +1,18 @@
1
+ import axios from "./http-client";
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
+ }