@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 +7 -1
- package/src/card/card-action-handler.ts +62 -0
- package/src/card/card-run-registry.ts +118 -0
- package/src/card/card-stop-handler.ts +94 -0
- package/src/card/card-template.ts +20 -0
- package/src/card-callback-service.ts +90 -8
- package/src/card-service.ts +188 -127
- package/src/channel.ts +13 -0
- package/src/command/card-stop-command.ts +96 -0
- package/src/inbound-handler.ts +42 -2
- package/src/onboarding.ts +4 -32
- package/src/reply-strategy-card.ts +21 -4
- package/src/reply-strategy.ts +1 -0
- package/src/send-service.ts +12 -14
- package/src/types.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soimy/dingtalk",
|
|
3
|
-
"version": "3.5.
|
|
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
|
-
(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 {
|
|
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
|
|
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
|
+
}
|
package/src/card-service.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
[
|
|
708
|
+
[template.contentKey]: "",
|
|
709
|
+
stop_action: STOP_ACTION_VISIBLE,
|
|
585
710
|
};
|
|
586
711
|
const createAndDeliverBody = {
|
|
587
|
-
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
|
|
845
|
+
if (isCardInTerminalState(card.state)) {
|
|
708
846
|
log?.debug?.(
|
|
709
|
-
`[DingTalk][AICard] Skip stream update because card already
|
|
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
|
-
|
|
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 (
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
+
}
|
package/src/inbound-handler.ts
CHANGED
|
@@ -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
|
|
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
|
-
"
|
|
245
|
-
"
|
|
246
|
-
"
|
|
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
|
|
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 })
|
package/src/reply-strategy.ts
CHANGED
package/src/send-service.ts
CHANGED
|
@@ -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" &&
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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;
|