@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/README.md +86 -1421
- package/package.json +17 -4
- package/src/ack-reaction-service.ts +45 -27
- 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-draft-controller.ts +270 -52
- package/src/card-service.ts +198 -138
- package/src/channel.ts +21 -7
- package/src/command/card-stop-command.ts +96 -0
- package/src/config-schema.ts +0 -18
- package/src/config.ts +28 -11
- package/src/feedback-learning-service.ts +4 -6
- package/src/inbound-handler.ts +193 -29
- package/src/message-context-store.ts +74 -0
- package/src/message-utils.ts +22 -0
- package/src/onboarding.ts +4 -77
- package/src/reply-strategy-card.ts +46 -35
- package/src/reply-strategy.ts +4 -3
- package/src/send-service.ts +42 -64
- package/src/targeting/agent-routing.ts +12 -6
- package/src/types.ts +10 -28
package/src/card-service.ts
CHANGED
|
@@ -3,13 +3,17 @@ 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 {
|
|
6
|
+
import { updateCardVariables } from "./card-callback-service";
|
|
7
|
+
import { DINGTALK_CARD_TEMPLATE, STOP_ACTION_VISIBLE, STOP_ACTION_HIDDEN } from "./card/card-template";
|
|
8
|
+
import { resolveRobotCode, stripTargetPrefix } from "./config";
|
|
7
9
|
import { resolveOriginalPeerId } from "./peer-id-registry";
|
|
8
10
|
import {
|
|
9
11
|
createSyntheticOutboundMsgId,
|
|
10
12
|
clearMessageContextCacheForTest,
|
|
11
13
|
DEFAULT_CARD_CONTENT_TTL_MS,
|
|
12
14
|
DEFAULT_CREATED_AT_MATCH_WINDOW_MS,
|
|
15
|
+
DEFAULT_OUTBOUND_SENDER,
|
|
16
|
+
inferConversationChatType,
|
|
13
17
|
resolveByCreatedAtWindow,
|
|
14
18
|
upsertOutboundMessageContext,
|
|
15
19
|
} from "./message-context-store";
|
|
@@ -40,6 +44,26 @@ const CARD_CACHE_MAX_CONVERSATIONS = 500;
|
|
|
40
44
|
const DYNAMIC_SUMMARY_EXTENSION = { dynamicSummary: "true" } as const;
|
|
41
45
|
|
|
42
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
|
+
|
|
43
67
|
const inMemoryCardContentStore = new Map<
|
|
44
68
|
string,
|
|
45
69
|
{
|
|
@@ -196,6 +220,105 @@ function extractCardProcessQueryKey(payload: unknown): string | undefined {
|
|
|
196
220
|
return undefined;
|
|
197
221
|
}
|
|
198
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
|
+
|
|
199
322
|
interface CreateAICardOptions {
|
|
200
323
|
accountId?: string;
|
|
201
324
|
storePath?: string;
|
|
@@ -370,7 +493,11 @@ function normalizeRecoveredState(state: string): AICardInstance["state"] {
|
|
|
370
493
|
|
|
371
494
|
// Helper to identify card terminal states.
|
|
372
495
|
export function isCardInTerminalState(state: string): boolean {
|
|
373
|
-
return
|
|
496
|
+
return (
|
|
497
|
+
state === AICardStatus.FINISHED
|
|
498
|
+
|| state === AICardStatus.STOPPED
|
|
499
|
+
|| state === AICardStatus.FAILED
|
|
500
|
+
);
|
|
374
501
|
}
|
|
375
502
|
|
|
376
503
|
export function formatContentForCard(content: string | undefined, type: "thinking" | "tool"): string {
|
|
@@ -409,7 +536,7 @@ async function sendTemplateMismatchNotification(
|
|
|
409
536
|
|
|
410
537
|
// Direct markdown fallback notification to user/group, without re-entering sendMessage card flow.
|
|
411
538
|
const payload: Record<string, unknown> = {
|
|
412
|
-
robotCode: config
|
|
539
|
+
robotCode: resolveRobotCode(config),
|
|
413
540
|
msgKey: "sampleMarkdown",
|
|
414
541
|
msgParam: JSON.stringify({ title: "OpenClaw 提醒", text }),
|
|
415
542
|
};
|
|
@@ -564,6 +691,7 @@ export async function createAICard(
|
|
|
564
691
|
const shouldPersistPending =
|
|
565
692
|
options.persistPending ?? Boolean(options.accountId && options.storePath);
|
|
566
693
|
const token = await getAccessToken(config, log);
|
|
694
|
+
const template = DINGTALK_CARD_TEMPLATE;
|
|
567
695
|
// Use randomUUID to avoid collisions across workers/restarts.
|
|
568
696
|
const cardInstanceId = `card_${randomUUID()}`;
|
|
569
697
|
|
|
@@ -571,18 +699,17 @@ export async function createAICard(
|
|
|
571
699
|
|
|
572
700
|
const isGroup = conversationId.startsWith("cid");
|
|
573
701
|
|
|
574
|
-
if (!config.cardTemplateId) {
|
|
575
|
-
throw new Error("DingTalk cardTemplateId is not configured.");
|
|
576
|
-
}
|
|
577
|
-
|
|
578
702
|
// DingTalk createAndDeliver API payload.
|
|
579
|
-
|
|
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.
|
|
580
706
|
const cardParamMap = {
|
|
581
707
|
config: JSON.stringify({ autoLayout: true, enableForward: true }),
|
|
582
|
-
[
|
|
708
|
+
[template.contentKey]: "",
|
|
709
|
+
stop_action: STOP_ACTION_VISIBLE,
|
|
583
710
|
};
|
|
584
711
|
const createAndDeliverBody = {
|
|
585
|
-
cardTemplateId:
|
|
712
|
+
cardTemplateId: template.templateId,
|
|
586
713
|
outTrackId: cardInstanceId,
|
|
587
714
|
cardData: {
|
|
588
715
|
cardParamMap,
|
|
@@ -596,26 +723,19 @@ export async function createAICard(
|
|
|
596
723
|
userIdType: 1,
|
|
597
724
|
imGroupOpenDeliverModel: isGroup
|
|
598
725
|
? {
|
|
599
|
-
robotCode: config
|
|
726
|
+
robotCode: resolveRobotCode(config),
|
|
600
727
|
extension: DYNAMIC_SUMMARY_EXTENSION,
|
|
601
728
|
}
|
|
602
729
|
: undefined,
|
|
603
730
|
imRobotOpenDeliverModel: !isGroup
|
|
604
731
|
? {
|
|
605
732
|
spaceType: "IM_ROBOT",
|
|
606
|
-
robotCode: config
|
|
733
|
+
robotCode: resolveRobotCode(config),
|
|
607
734
|
extension: DYNAMIC_SUMMARY_EXTENSION,
|
|
608
735
|
}
|
|
609
736
|
: undefined,
|
|
610
737
|
};
|
|
611
738
|
|
|
612
|
-
if (isGroup && !config.robotCode) {
|
|
613
|
-
log?.warn?.(
|
|
614
|
-
"[DingTalk][AICard] robotCode not configured, using clientId as fallback. " +
|
|
615
|
-
"For best compatibility, set robotCode explicitly in config.",
|
|
616
|
-
);
|
|
617
|
-
}
|
|
618
|
-
|
|
619
739
|
log?.debug?.(
|
|
620
740
|
`[DingTalk][AICard] POST /v1.0/card/instances/createAndDeliver body=${JSON.stringify(createAndDeliverBody)}`,
|
|
621
741
|
);
|
|
@@ -679,6 +799,19 @@ export async function createAICard(
|
|
|
679
799
|
}
|
|
680
800
|
|
|
681
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
|
+
|
|
682
815
|
return aiCardInstance;
|
|
683
816
|
} catch (err: any) {
|
|
684
817
|
log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
|
|
@@ -709,55 +842,16 @@ export async function streamAICard(
|
|
|
709
842
|
finished: boolean = false,
|
|
710
843
|
log?: Logger,
|
|
711
844
|
): Promise<void> {
|
|
712
|
-
if (card.state
|
|
845
|
+
if (isCardInTerminalState(card.state)) {
|
|
713
846
|
log?.debug?.(
|
|
714
|
-
`[DingTalk][AICard] Skip stream update because card already
|
|
847
|
+
`[DingTalk][AICard] Skip stream update because card already terminal: outTrackId=${card.cardInstanceId} state=${card.state}`,
|
|
715
848
|
);
|
|
716
849
|
return;
|
|
717
850
|
}
|
|
718
|
-
|
|
719
|
-
// Refresh token defensively before DingTalk 2h token horizon.
|
|
720
|
-
const tokenAge = Date.now() - card.createdAt;
|
|
721
|
-
const tokenRefreshThreshold = 90 * 60 * 1000;
|
|
722
|
-
|
|
723
|
-
if (tokenAge > tokenRefreshThreshold && card.config) {
|
|
724
|
-
log?.debug?.("[DingTalk][AICard] Token age exceeds threshold, refreshing...");
|
|
725
|
-
try {
|
|
726
|
-
card.accessToken = await getAccessToken(card.config, log);
|
|
727
|
-
log?.debug?.("[DingTalk][AICard] Token refreshed successfully");
|
|
728
|
-
} catch (err: any) {
|
|
729
|
-
log?.warn?.(`[DingTalk][AICard] Failed to refresh token: ${err.message}`);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// Always use full replacement to make client rendering deterministic.
|
|
734
|
-
const streamBody: AICardStreamingRequest = {
|
|
735
|
-
outTrackId: card.outTrackId || card.cardInstanceId,
|
|
736
|
-
guid: randomUUID(),
|
|
737
|
-
key: card.config?.cardTemplateKey || "content",
|
|
738
|
-
content: content,
|
|
739
|
-
isFull: true,
|
|
740
|
-
isFinalize: finished,
|
|
741
|
-
isError: false,
|
|
742
|
-
};
|
|
743
|
-
|
|
744
|
-
log?.debug?.(
|
|
745
|
-
`[DingTalk][AICard] PUT /v1.0/card/streaming contentLen=${content.length} isFull=true isFinalize=${finished} guid=${streamBody.guid} payload=${JSON.stringify(streamBody)}`,
|
|
746
|
-
);
|
|
851
|
+
const template = DINGTALK_CARD_TEMPLATE;
|
|
747
852
|
|
|
748
853
|
try {
|
|
749
|
-
|
|
750
|
-
headers: {
|
|
751
|
-
"x-acs-dingtalk-access-token": card.accessToken,
|
|
752
|
-
"Content-Type": "application/json",
|
|
753
|
-
},
|
|
754
|
-
...(card.config ? getProxyBypassOption(card.config) : {}),
|
|
755
|
-
});
|
|
756
|
-
log?.debug?.(
|
|
757
|
-
`[DingTalk][AICard] Streaming response: status=${streamResp.status}, data=${JSON.stringify(streamResp.data)}`,
|
|
758
|
-
);
|
|
759
|
-
|
|
760
|
-
card.lastUpdated = Date.now();
|
|
854
|
+
await putAICardStreamingField(card, template.contentKey, content, finished, log);
|
|
761
855
|
card.lastStreamedContent = content;
|
|
762
856
|
if (finished) {
|
|
763
857
|
card.state = AICardStatus.FINISHED;
|
|
@@ -766,85 +860,14 @@ export async function streamAICard(
|
|
|
766
860
|
card.state = AICardStatus.INPUTING;
|
|
767
861
|
}
|
|
768
862
|
} catch (err: any) {
|
|
769
|
-
// 500 unknownError usually means cardTemplateKey mismatch with template variable names.
|
|
770
|
-
if (err.response?.status === 500 && err.response?.data?.code === "unknownError") {
|
|
771
|
-
const usedKey = streamBody.key;
|
|
772
|
-
const cardTemplateId = card.config?.cardTemplateId || "(unknown)";
|
|
773
|
-
const errorMsg =
|
|
774
|
-
`⚠️ **[DingTalk] AI Card 串流更新失败 (500 unknownError)**\n\n` +
|
|
775
|
-
`这通常是因为 \`cardTemplateKey\` (当前值: \`${usedKey}\`) 与钉钉卡片模板 \`${cardTemplateId}\` 中定义的正文变量名不匹配。\n\n` +
|
|
776
|
-
`**建议操作**:\n` +
|
|
777
|
-
`1. 前往钉钉开发者后台检查该模板的“变量管理”\n` +
|
|
778
|
-
`2. 确保配置中的 \`cardTemplateKey\` 与模板中用于显示内容的字段变量名完全一致\n\n` +
|
|
779
|
-
`*注意:当前及后续消息将自动转为 Markdown 发送,直到问题修复。*\n` +
|
|
780
|
-
`*参考文档: 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`;
|
|
781
|
-
|
|
782
|
-
log?.error?.(
|
|
783
|
-
`[DingTalk][AICard] Streaming failed with 500 unknownError. Key: ${usedKey}, Template: ${cardTemplateId}. ` +
|
|
784
|
-
`Verify that "cardTemplateKey" matches the content field variable name in your card template.`,
|
|
785
|
-
);
|
|
786
|
-
|
|
787
|
-
card.state = AICardStatus.FAILED;
|
|
788
|
-
card.lastUpdated = Date.now();
|
|
789
|
-
removePendingCard(card, log);
|
|
790
|
-
await sendTemplateMismatchNotification(card, errorMsg, log);
|
|
791
|
-
throw err;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// Retry once on 401 with refreshed token.
|
|
795
|
-
if (err.response?.status === 401 && card.config) {
|
|
796
|
-
log?.warn?.("[DingTalk][AICard] Received 401 error, attempting token refresh and retry...");
|
|
797
|
-
try {
|
|
798
|
-
card.accessToken = await getAccessToken(card.config, log);
|
|
799
|
-
const retryResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, streamBody, {
|
|
800
|
-
headers: {
|
|
801
|
-
"x-acs-dingtalk-access-token": card.accessToken,
|
|
802
|
-
"Content-Type": "application/json",
|
|
803
|
-
},
|
|
804
|
-
...(card.config ? getProxyBypassOption(card.config) : {}),
|
|
805
|
-
});
|
|
806
|
-
log?.debug?.(
|
|
807
|
-
`[DingTalk][AICard] Retry after token refresh succeeded: status=${retryResp.status}`,
|
|
808
|
-
);
|
|
809
|
-
card.lastUpdated = Date.now();
|
|
810
|
-
card.lastStreamedContent = content;
|
|
811
|
-
if (finished) {
|
|
812
|
-
card.state = AICardStatus.FINISHED;
|
|
813
|
-
removePendingCard(card, log);
|
|
814
|
-
} else if (card.state === AICardStatus.PROCESSING) {
|
|
815
|
-
card.state = AICardStatus.INPUTING;
|
|
816
|
-
}
|
|
817
|
-
return;
|
|
818
|
-
} catch (retryErr: any) {
|
|
819
|
-
log?.error?.(`[DingTalk][AICard] Retry after token refresh failed: ${retryErr.message}`);
|
|
820
|
-
if (retryErr.response?.data !== undefined) {
|
|
821
|
-
log?.error?.(
|
|
822
|
-
formatDingTalkErrorPayloadLog(
|
|
823
|
-
"card.stream.retryAfterRefresh",
|
|
824
|
-
retryErr.response.data,
|
|
825
|
-
"[DingTalk][AICard]",
|
|
826
|
-
),
|
|
827
|
-
);
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
863
|
card.state = AICardStatus.FAILED;
|
|
833
864
|
card.lastUpdated = Date.now();
|
|
834
865
|
removePendingCard(card, log);
|
|
835
|
-
if (
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
log,
|
|
841
|
-
);
|
|
842
|
-
}
|
|
843
|
-
log?.error?.(`[DingTalk][AICard] Streaming update failed: ${err.message}`);
|
|
844
|
-
if (err.response?.data !== undefined) {
|
|
845
|
-
log?.error?.(
|
|
846
|
-
formatDingTalkErrorPayloadLog("card.stream", err.response.data, "[DingTalk][AICard]"),
|
|
847
|
-
);
|
|
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);
|
|
848
871
|
}
|
|
849
872
|
throw err;
|
|
850
873
|
}
|
|
@@ -858,6 +881,15 @@ export async function finishAICard(
|
|
|
858
881
|
): Promise<void> {
|
|
859
882
|
log?.debug?.(`[DingTalk][AICard] Starting finish, final content length=${content.length}`);
|
|
860
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
|
+
}
|
|
861
893
|
if (card.conversationId && content.trim() && card.accountId && card.processQueryKey) {
|
|
862
894
|
const primaryConversationId = card.contextConversationId || card.conversationId;
|
|
863
895
|
cacheCardContentByProcessQueryKey(
|
|
@@ -872,6 +904,30 @@ export async function finishAICard(
|
|
|
872
904
|
}
|
|
873
905
|
}
|
|
874
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
|
+
|
|
875
931
|
function cacheCardContentByProcessQueryKey(
|
|
876
932
|
accountId: string,
|
|
877
933
|
conversationId: string,
|
|
@@ -895,6 +951,8 @@ function cacheCardContentByProcessQueryKey(
|
|
|
895
951
|
createdAt: Date.now(),
|
|
896
952
|
text: content,
|
|
897
953
|
messageType: "card",
|
|
954
|
+
...DEFAULT_OUTBOUND_SENDER,
|
|
955
|
+
chatType: inferConversationChatType(conversationId),
|
|
898
956
|
ttlMs: DEFAULT_CARD_CONTENT_TTL_MS,
|
|
899
957
|
topic: null,
|
|
900
958
|
quotedRef,
|
|
@@ -932,6 +990,8 @@ export function cacheCardContent(
|
|
|
932
990
|
createdAt,
|
|
933
991
|
text: content,
|
|
934
992
|
messageType: "card",
|
|
993
|
+
...DEFAULT_OUTBOUND_SENDER,
|
|
994
|
+
chatType: inferConversationChatType(conversationId),
|
|
935
995
|
ttlMs: DEFAULT_CARD_CONTENT_TTL_MS,
|
|
936
996
|
topic: null,
|
|
937
997
|
});
|
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,
|
|
@@ -20,14 +21,15 @@ import {
|
|
|
20
21
|
mergeAccountWithDefaults,
|
|
21
22
|
resolveGroupConfig,
|
|
22
23
|
resolveRelativePath,
|
|
24
|
+
resolveRobotCode,
|
|
23
25
|
stripTargetPrefix,
|
|
24
26
|
} from "./config";
|
|
25
27
|
import { DingTalkConfigSchema } from "./config-schema.js";
|
|
26
28
|
import { ConnectionManager } from "./connection-manager";
|
|
27
29
|
import { isMessageProcessed, markMessageProcessed } from "./dedup";
|
|
28
30
|
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
isLearningAutoApplyEnabled,
|
|
32
|
+
isLearningEnabled,
|
|
31
33
|
recordExplicitFeedbackLearning,
|
|
32
34
|
} from "./feedback-learning-service";
|
|
33
35
|
import { handleDingTalkMessage } from "./inbound-handler";
|
|
@@ -708,7 +710,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
708
710
|
try {
|
|
709
711
|
const data = JSON.parse(res.data) as DingTalkInboundMessage;
|
|
710
712
|
|
|
711
|
-
const robotKey = config
|
|
713
|
+
const robotKey = resolveRobotCode(config) || account.accountId;
|
|
712
714
|
const msgId = data.msgId || messageId;
|
|
713
715
|
const dedupKey = msgId ? `${robotKey}:${msgId}` : undefined;
|
|
714
716
|
|
|
@@ -807,15 +809,15 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
807
809
|
|
|
808
810
|
if (analysis.feedbackTarget && analysis.feedbackAckText) {
|
|
809
811
|
recordExplicitFeedbackLearning({
|
|
810
|
-
enabled:
|
|
811
|
-
autoApply:
|
|
812
|
+
enabled: isLearningEnabled(config),
|
|
813
|
+
autoApply: isLearningAutoApplyEnabled(config),
|
|
812
814
|
storePath: accountStorePath,
|
|
813
815
|
accountId: account.accountId,
|
|
814
816
|
targetId: analysis.feedbackTarget,
|
|
815
817
|
feedbackType: analysis.actionId === "feedback_up" ? "feedback_up" : "feedback_down",
|
|
816
818
|
userId: analysis.userId,
|
|
817
819
|
processQueryKey: analysis.processQueryKey,
|
|
818
|
-
noteTtlMs: config.learningNoteTtlMs
|
|
820
|
+
noteTtlMs: config.learningNoteTtlMs,
|
|
819
821
|
});
|
|
820
822
|
try {
|
|
821
823
|
await sendProactiveTextOrMarkdown(
|
|
@@ -836,6 +838,18 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
836
838
|
);
|
|
837
839
|
}
|
|
838
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
|
+
}
|
|
839
853
|
} catch (error: any) {
|
|
840
854
|
ctx.log?.error?.(
|
|
841
855
|
`[${account.accountId}] [DingTalk][CardCallback] Failed to parse callback: ${error.message}`,
|
|
@@ -984,7 +998,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
984
998
|
// Clear stale in-flight locks for this account on disconnect.
|
|
985
999
|
// DingTalk will redeliver unacknowledged messages on reconnect; without
|
|
986
1000
|
// this cleanup the redelivered messages would be silently skipped forever.
|
|
987
|
-
const robotKey = config
|
|
1001
|
+
const robotKey = resolveRobotCode(config) || account.accountId;
|
|
988
1002
|
let cleared = 0;
|
|
989
1003
|
for (const key of processingDedupKeys.keys()) {
|
|
990
1004
|
if (key.startsWith(`${robotKey}:`)) {
|
|
@@ -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/config-schema.ts
CHANGED
|
@@ -20,15 +20,6 @@ const DingTalkAccountConfigShape = {
|
|
|
20
20
|
/** DingTalk App Secret (Client Secret) - required for authentication */
|
|
21
21
|
clientSecret: z.string().optional(),
|
|
22
22
|
|
|
23
|
-
/** DingTalk Robot Code for media download */
|
|
24
|
-
robotCode: z.string().optional(),
|
|
25
|
-
|
|
26
|
-
/** DingTalk Corporation ID */
|
|
27
|
-
corpId: z.string().optional(),
|
|
28
|
-
|
|
29
|
-
/** DingTalk Application ID (Agent ID) */
|
|
30
|
-
agentId: z.union([z.string(), z.number()]).optional(),
|
|
31
|
-
|
|
32
23
|
/** Direct message policy: open, pairing, or allowlist */
|
|
33
24
|
dmPolicy: z.enum(["open", "pairing", "allowlist"]).optional().default("open"),
|
|
34
25
|
|
|
@@ -134,15 +125,6 @@ const DingTalkAccountConfigShape = {
|
|
|
134
125
|
/** Session learning note TTL in milliseconds (default: 6 hours) */
|
|
135
126
|
learningNoteTtlMs: z.number().int().min(60_000).optional(),
|
|
136
127
|
|
|
137
|
-
/** @deprecated Use learningEnabled */
|
|
138
|
-
feedbackLearningEnabled: z.boolean().optional(),
|
|
139
|
-
|
|
140
|
-
/** @deprecated Use learningAutoApply */
|
|
141
|
-
feedbackLearningAutoApply: z.boolean().optional(),
|
|
142
|
-
|
|
143
|
-
/** @deprecated Use learningNoteTtlMs */
|
|
144
|
-
feedbackLearningNoteTtlMs: z.number().int().min(60_000).optional(),
|
|
145
|
-
|
|
146
128
|
/** Whether to convert markdown tables to plain text for better rendering on some clients (default: true) */
|
|
147
129
|
convertMarkdownTables: z.boolean().optional().default(true),
|
|
148
130
|
|