@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 +7 -1
- package/package.json +14 -3
- package/src/ack-reaction-service.ts +1 -1
- package/src/auth.ts +1 -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/reasoning-block-assembler.ts +157 -0
- package/src/card-callback-service.ts +90 -8
- package/src/card-draft-controller.ts +32 -0
- package/src/card-service.ts +189 -128
- package/src/channel.ts +82 -53
- package/src/command/card-stop-command.ts +78 -0
- package/src/command/inbound-command-dispatch-service.ts +464 -0
- package/src/docs-service.ts +5 -5
- package/src/http-client.ts +20 -0
- package/src/inbound-handler.ts +162 -465
- package/src/logger-context.ts +16 -2
- package/src/media-utils.ts +3 -3
- package/src/{attachment-text-extractor.ts → messaging/attachment-text-extractor.ts} +1 -1
- package/src/{quoted-file-service.ts → messaging/quoted-file-service.ts} +5 -5
- package/src/onboarding.ts +4 -32
- package/src/reply-strategy-card.ts +85 -17
- package/src/reply-strategy-markdown.ts +123 -18
- package/src/reply-strategy.ts +5 -0
- package/src/send-service.ts +80 -17
- package/src/targeting/agent-routing.ts +11 -4
- package/src/{group-members-store.ts → targeting/group-members-store.ts} +1 -1
- package/src/types.ts +6 -1
- package/src/utils.ts +165 -0
|
@@ -25,6 +25,7 @@ export interface CardDraftController {
|
|
|
25
25
|
updateAnswer: (text: string) => Promise<void>;
|
|
26
26
|
updateReasoning: (text: string) => Promise<void>;
|
|
27
27
|
updateThinking: (text: string) => Promise<void>;
|
|
28
|
+
appendThinkingBlock: (text: string) => Promise<void>;
|
|
28
29
|
updateTool: (text: string) => Promise<void>;
|
|
29
30
|
appendTool: (text: string) => Promise<void>;
|
|
30
31
|
/** Signal that a new assistant turn has started (e.g. after a tool call). */
|
|
@@ -116,6 +117,10 @@ export function createCardDraftController(params: {
|
|
|
116
117
|
return timelineEntries.length - 1;
|
|
117
118
|
};
|
|
118
119
|
|
|
120
|
+
const findCurrentSegmentAnswerIndex = (): number | null => {
|
|
121
|
+
return activeAnswerIndex;
|
|
122
|
+
};
|
|
123
|
+
|
|
119
124
|
const renderTimeline = (options: {
|
|
120
125
|
fallbackAnswer?: string;
|
|
121
126
|
overrideAnswer?: string;
|
|
@@ -292,6 +297,32 @@ export function createCardDraftController(params: {
|
|
|
292
297
|
queueRender();
|
|
293
298
|
};
|
|
294
299
|
|
|
300
|
+
const appendThinkingBlock = async (text: string) => {
|
|
301
|
+
await waitForPendingBoundary();
|
|
302
|
+
if (stopped || failed) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const normalized = normalizeProcessText(text);
|
|
306
|
+
if (!normalized) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (timelineEntries.length > 0) {
|
|
310
|
+
await flushBoundaryFrame();
|
|
311
|
+
}
|
|
312
|
+
sealLiveThinking();
|
|
313
|
+
const currentSegmentAnswerIndex = findCurrentSegmentAnswerIndex();
|
|
314
|
+
if (currentSegmentAnswerIndex !== null) {
|
|
315
|
+
timelineEntries.splice(currentSegmentAnswerIndex, 0, { kind: "thinking", text: normalized });
|
|
316
|
+
if (activeAnswerIndex !== null && activeAnswerIndex >= currentSegmentAnswerIndex) {
|
|
317
|
+
activeAnswerIndex += 1;
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
sealCurrentAnswer();
|
|
321
|
+
appendTimelineEntry("thinking", normalized);
|
|
322
|
+
}
|
|
323
|
+
queueRender();
|
|
324
|
+
};
|
|
325
|
+
|
|
295
326
|
const notifyNewAssistantTurn = async () => {
|
|
296
327
|
if (stopped || failed) {
|
|
297
328
|
return;
|
|
@@ -311,6 +342,7 @@ export function createCardDraftController(params: {
|
|
|
311
342
|
updateAnswer,
|
|
312
343
|
updateReasoning,
|
|
313
344
|
updateThinking: updateReasoning,
|
|
345
|
+
appendThinkingBlock,
|
|
314
346
|
updateTool,
|
|
315
347
|
appendTool: updateTool,
|
|
316
348
|
notifyNewAssistantTurn,
|
package/src/card-service.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import axios from "
|
|
4
|
+
import axios from "./http-client";
|
|
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,
|