@soimy/dingtalk 3.5.3 → 3.6.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 +4 -1
- package/index.ts +7 -0
- package/openclaw.plugin.json +153 -5
- package/package.json +1 -1
- package/src/auth.ts +5 -2
- package/src/card/card-markdown-image-reroute.ts +106 -0
- package/src/card/card-run-registry.ts +54 -1
- package/src/card/card-stop-handler.ts +10 -20
- package/src/card/card-template.ts +14 -3
- package/src/card/statusline-renderer.ts +94 -0
- package/src/card-draft-controller.ts +245 -52
- package/src/card-service.ts +408 -23
- package/src/channel.ts +24 -1083
- package/src/config-schema.ts +21 -1
- package/src/config.ts +139 -66
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +637 -0
- package/src/inbound-handler.ts +1276 -975
- package/src/media-utils.ts +6 -0
- package/src/message-context-store.ts +183 -85
- package/src/message-utils.ts +124 -16
- package/src/messaging/btw-deliver.ts +85 -0
- package/src/messaging/channel-actions.ts +174 -0
- package/src/messaging/channel-outbound.ts +158 -0
- package/src/onboarding.ts +333 -235
- package/src/path-utils.ts +49 -0
- package/src/platform/channel-status.ts +81 -0
- package/src/reply-strategy-card.ts +373 -64
- package/src/reply-strategy-markdown.ts +1 -1
- package/src/reply-strategy-types.ts +93 -0
- package/src/reply-strategy-with-reaction.ts +1 -1
- package/src/reply-strategy.ts +14 -72
- package/src/run-usage-store.ts +59 -0
- package/src/secret-input.ts +216 -0
- package/src/send-service.ts +115 -3
- package/src/session-state.ts +62 -0
- package/src/targeting/agent-name-matcher.ts +28 -0
- package/src/targeting/agent-routing.ts +30 -5
- package/src/types.ts +48 -157
package/src/card-service.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import type {
|
|
26
26
|
AICardInstance,
|
|
27
27
|
AICardStreamingRequest,
|
|
28
|
+
CardBlock,
|
|
28
29
|
DingTalkConfig,
|
|
29
30
|
DingTalkTrackingMetadata,
|
|
30
31
|
Logger,
|
|
@@ -53,7 +54,12 @@ export async function hideCardStopButton(
|
|
|
53
54
|
): Promise<void> {
|
|
54
55
|
for (let attempt = 0; ; attempt++) {
|
|
55
56
|
try {
|
|
56
|
-
await updateCardVariables(
|
|
57
|
+
await updateCardVariables(
|
|
58
|
+
outTrackId,
|
|
59
|
+
{ hasAction: String(STOP_ACTION_HIDDEN), stop_action: String(STOP_ACTION_HIDDEN) },
|
|
60
|
+
token,
|
|
61
|
+
config,
|
|
62
|
+
);
|
|
57
63
|
return;
|
|
58
64
|
} catch (err) {
|
|
59
65
|
if (attempt >= retries) {
|
|
@@ -204,6 +210,16 @@ export function clearAICardDegrade(accountId: string, log?: Logger): void {
|
|
|
204
210
|
log?.info?.(`[DingTalk][AICard][Degrade] Cleared for account=${accountId}, lastReason=${reason}`);
|
|
205
211
|
}
|
|
206
212
|
|
|
213
|
+
export function incrementCardDapiCount(card: AICardInstance): number {
|
|
214
|
+
const next = (card.dapiUsage || 0) + 1;
|
|
215
|
+
card.dapiUsage = next;
|
|
216
|
+
return next;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function markStreamingLifecycleAcknowledged(card: AICardInstance, finished: boolean): void {
|
|
220
|
+
card.streamLifecycleOpened = !finished;
|
|
221
|
+
}
|
|
222
|
+
|
|
207
223
|
function extractCardProcessQueryKey(payload: unknown): string | undefined {
|
|
208
224
|
if (!payload || typeof payload !== "object") {
|
|
209
225
|
return undefined;
|
|
@@ -226,6 +242,7 @@ async function putAICardStreamingField(
|
|
|
226
242
|
content: string,
|
|
227
243
|
finished: boolean,
|
|
228
244
|
log?: Logger,
|
|
245
|
+
options: { suppressDegrade?: boolean } = {},
|
|
229
246
|
): Promise<void> {
|
|
230
247
|
const tokenAge = Date.now() - card.createdAt;
|
|
231
248
|
const tokenRefreshThreshold = 90 * 60 * 1000;
|
|
@@ -270,6 +287,8 @@ async function putAICardStreamingField(
|
|
|
270
287
|
`[DingTalk][AICard] Streaming response: status=${streamResp.status}, data=${JSON.stringify(streamResp.data)}`,
|
|
271
288
|
);
|
|
272
289
|
card.lastUpdated = Date.now();
|
|
290
|
+
incrementCardDapiCount(card);
|
|
291
|
+
markStreamingLifecycleAcknowledged(card, finished);
|
|
273
292
|
} catch (err: any) {
|
|
274
293
|
if (err.response?.status === 401 && card.config && !tokenAlreadyRefreshed) {
|
|
275
294
|
log?.warn?.("[DingTalk][AICard] Received 401 error, attempting token refresh and retry...");
|
|
@@ -286,6 +305,8 @@ async function putAICardStreamingField(
|
|
|
286
305
|
`[DingTalk][AICard] Retry after token refresh succeeded: status=${retryResp.status}`,
|
|
287
306
|
);
|
|
288
307
|
card.lastUpdated = Date.now();
|
|
308
|
+
incrementCardDapiCount(card);
|
|
309
|
+
markStreamingLifecycleAcknowledged(card, finished);
|
|
289
310
|
return;
|
|
290
311
|
} catch (retryErr: any) {
|
|
291
312
|
log?.error?.(`[DingTalk][AICard] Retry after token refresh failed: ${retryErr.message}`);
|
|
@@ -301,7 +322,7 @@ async function putAICardStreamingField(
|
|
|
301
322
|
}
|
|
302
323
|
}
|
|
303
324
|
|
|
304
|
-
if (card.accountId && shouldTriggerAICardDegrade(err)) {
|
|
325
|
+
if (!options.suppressDegrade && card.accountId && shouldTriggerAICardDegrade(err)) {
|
|
305
326
|
activateAICardDegrade(
|
|
306
327
|
card.accountId,
|
|
307
328
|
`card.stream:${err?.response?.status || "unknown"}`,
|
|
@@ -324,6 +345,10 @@ interface CreateAICardOptions {
|
|
|
324
345
|
storePath?: string;
|
|
325
346
|
persistPending?: boolean;
|
|
326
347
|
contextConversationId?: string;
|
|
348
|
+
/** Quote content to display in card header (shown when non-empty) */
|
|
349
|
+
quoteContent?: string;
|
|
350
|
+
/** Initial statusLine string to show on the first createAndDeliver render. */
|
|
351
|
+
statusLine?: string;
|
|
327
352
|
}
|
|
328
353
|
|
|
329
354
|
interface PendingCardRecord {
|
|
@@ -335,6 +360,9 @@ interface PendingCardRecord {
|
|
|
335
360
|
createdAt: number;
|
|
336
361
|
lastUpdated: number;
|
|
337
362
|
state: string;
|
|
363
|
+
lastContent?: string;
|
|
364
|
+
lastBlockListJson?: string;
|
|
365
|
+
streamLifecycleOpened?: boolean;
|
|
338
366
|
}
|
|
339
367
|
|
|
340
368
|
interface PendingCardStateFile {
|
|
@@ -371,7 +399,10 @@ function normalizePendingState(parsed: Partial<PendingCardStateFile>): PendingCa
|
|
|
371
399
|
typeof entry.accountId === "string" &&
|
|
372
400
|
typeof entry.cardInstanceId === "string" &&
|
|
373
401
|
(entry.outTrackId === undefined || typeof entry.outTrackId === "string") &&
|
|
374
|
-
typeof entry.conversationId === "string"
|
|
402
|
+
typeof entry.conversationId === "string" &&
|
|
403
|
+
(entry.lastContent === undefined || typeof entry.lastContent === "string") &&
|
|
404
|
+
(entry.lastBlockListJson === undefined || typeof entry.lastBlockListJson === "string") &&
|
|
405
|
+
(entry.streamLifecycleOpened === undefined || typeof entry.streamLifecycleOpened === "boolean"),
|
|
375
406
|
),
|
|
376
407
|
),
|
|
377
408
|
};
|
|
@@ -443,6 +474,9 @@ function upsertPendingCard(card: AICardInstance, storePath?: string, log?: Logge
|
|
|
443
474
|
createdAt: card.createdAt,
|
|
444
475
|
lastUpdated: card.lastUpdated,
|
|
445
476
|
state: card.state,
|
|
477
|
+
lastContent: card.lastStreamedContent,
|
|
478
|
+
lastBlockListJson: card.lastBlockListJson,
|
|
479
|
+
streamLifecycleOpened: card.streamLifecycleOpened,
|
|
446
480
|
};
|
|
447
481
|
const index = state.pendingCards.findIndex((item) => item.cardInstanceId === card.cardInstanceId);
|
|
448
482
|
if (index >= 0) {
|
|
@@ -454,6 +488,36 @@ function upsertPendingCard(card: AICardInstance, storePath?: string, log?: Logge
|
|
|
454
488
|
writePendingCardState(state, storePath, log);
|
|
455
489
|
}
|
|
456
490
|
|
|
491
|
+
function parseStoredBlockList(blockListJson?: string): CardBlock[] {
|
|
492
|
+
if (!blockListJson?.trim()) {
|
|
493
|
+
return [];
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
const parsed = JSON.parse(blockListJson) as unknown;
|
|
497
|
+
return Array.isArray(parsed) ? (parsed as CardBlock[]) : [];
|
|
498
|
+
} catch {
|
|
499
|
+
return [];
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function buildStoppedCardFinalizePayload(params: {
|
|
504
|
+
reason: string;
|
|
505
|
+
previousContent?: string;
|
|
506
|
+
previousBlockListJson?: string;
|
|
507
|
+
}): { blockListJson: string; content: string } {
|
|
508
|
+
const markerText = params.reason.trim();
|
|
509
|
+
const baseContent = params.previousContent?.trim() || "";
|
|
510
|
+
const blocks = parseStoredBlockList(params.previousBlockListJson);
|
|
511
|
+
blocks.push({ type: 0, markdown: markerText });
|
|
512
|
+
const content = baseContent
|
|
513
|
+
? `${baseContent}\n\n---\n*${markerText}*`
|
|
514
|
+
: markerText;
|
|
515
|
+
return {
|
|
516
|
+
blockListJson: JSON.stringify(blocks),
|
|
517
|
+
content,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
457
521
|
function removePendingCard(card: AICardInstance, log?: Logger): void {
|
|
458
522
|
if (!card.accountId || !card.storePath) {
|
|
459
523
|
return;
|
|
@@ -500,6 +564,26 @@ export function isCardInTerminalState(state: string): boolean {
|
|
|
500
564
|
);
|
|
501
565
|
}
|
|
502
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Ensure card access token is fresh (refresh if >90min old).
|
|
569
|
+
* Mutates card.accessToken in place if refreshed.
|
|
570
|
+
*/
|
|
571
|
+
async function ensureFreshToken(card: AICardInstance, log?: Logger): Promise<void> {
|
|
572
|
+
const tokenAge = Date.now() - card.createdAt;
|
|
573
|
+
const tokenRefreshThreshold = 90 * 60 * 1000;
|
|
574
|
+
|
|
575
|
+
if (tokenAge > tokenRefreshThreshold && card.config) {
|
|
576
|
+
log?.debug?.("[DingTalk][AICard] Token age exceeds threshold, refreshing...");
|
|
577
|
+
try {
|
|
578
|
+
card.accessToken = await getAccessToken(card.config, log);
|
|
579
|
+
log?.debug?.("[DingTalk][AICard] Token refreshed successfully");
|
|
580
|
+
} catch (err: unknown) {
|
|
581
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
582
|
+
log?.warn?.(`[DingTalk][AICard] Failed to refresh token: ${msg}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
503
587
|
export function formatContentForCard(content: string | undefined, type: "thinking" | "tool"): string {
|
|
504
588
|
if (!content) {
|
|
505
589
|
return "";
|
|
@@ -573,7 +657,11 @@ export async function sendProactiveCardText(
|
|
|
573
657
|
if (!card) {
|
|
574
658
|
return { ok: false, error: "Failed to create AI card" };
|
|
575
659
|
}
|
|
576
|
-
|
|
660
|
+
const blockListJson = JSON.stringify([{ type: 0, markdown: content } satisfies CardBlock]);
|
|
661
|
+
await commitAICardBlocks(card, {
|
|
662
|
+
blockListJson,
|
|
663
|
+
content,
|
|
664
|
+
}, log);
|
|
577
665
|
return {
|
|
578
666
|
ok: true,
|
|
579
667
|
processQueryKey: card.processQueryKey,
|
|
@@ -657,16 +745,24 @@ async function finalizePendingCardsByAccount(
|
|
|
657
745
|
lastUpdated: entry.lastUpdated || Date.now(),
|
|
658
746
|
state: normalizeRecoveredState(entry.state),
|
|
659
747
|
config,
|
|
748
|
+
lastStreamedContent: entry.lastContent,
|
|
749
|
+
lastBlockListJson: entry.lastBlockListJson,
|
|
750
|
+
streamLifecycleOpened: entry.streamLifecycleOpened,
|
|
660
751
|
};
|
|
661
752
|
try {
|
|
662
|
-
await
|
|
753
|
+
await finalizeStoppedAICard(card, {
|
|
754
|
+
reason,
|
|
755
|
+
previousContent: entry.lastContent,
|
|
756
|
+
previousBlockListJson: entry.lastBlockListJson,
|
|
757
|
+
}, log);
|
|
663
758
|
finalizedCount += 1;
|
|
664
|
-
} catch (err:
|
|
759
|
+
} catch (err: unknown) {
|
|
665
760
|
const action = mode === "recover" ? "recover" : "finalize";
|
|
761
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
666
762
|
log?.warn?.(
|
|
667
|
-
`[DingTalk][AICard] Failed to ${action} active card ${entry.cardInstanceId}: ${
|
|
763
|
+
`[DingTalk][AICard] Failed to ${action} active card ${entry.cardInstanceId}: ${message}`,
|
|
668
764
|
);
|
|
669
|
-
|
|
765
|
+
// Pending record intentionally kept for manual investigation
|
|
670
766
|
}
|
|
671
767
|
}
|
|
672
768
|
return finalizedCount;
|
|
@@ -702,11 +798,17 @@ export async function createAICard(
|
|
|
702
798
|
// DingTalk createAndDeliver API payload.
|
|
703
799
|
// Note: do NOT include template.statusKey here — the createAndDeliver API may
|
|
704
800
|
// reject unknown fields if the template variable is not yet provisioned.
|
|
705
|
-
//
|
|
801
|
+
// flowStatus=2 (INPUTING) is set directly so the card shows "输出中" immediately.
|
|
706
802
|
const cardParamMap = {
|
|
707
803
|
config: JSON.stringify({ autoLayout: true, enableForward: true }),
|
|
708
|
-
[template.
|
|
709
|
-
|
|
804
|
+
[template.streamingKey]: "",
|
|
805
|
+
quoteContent: options.quoteContent || "",
|
|
806
|
+
...(options.statusLine?.trim() ? { statusLine: options.statusLine } : {}),
|
|
807
|
+
flowStatus: AICardStatus.INPUTING,
|
|
808
|
+
// V2 template uses hasAction (string), V1 uses stop_action (string)
|
|
809
|
+
// DingTalk cardParamMap requires all values to be strings
|
|
810
|
+
hasAction: String(STOP_ACTION_VISIBLE),
|
|
811
|
+
stop_action: String(STOP_ACTION_VISIBLE),
|
|
710
812
|
};
|
|
711
813
|
const createAndDeliverBody = {
|
|
712
814
|
cardTemplateId: template.templateId,
|
|
@@ -758,6 +860,13 @@ export async function createAICard(
|
|
|
758
860
|
cardInstanceId?: unknown;
|
|
759
861
|
}
|
|
760
862
|
| undefined;
|
|
863
|
+
const deliverResults = (responseData?.result as { deliverResults?: Array<{ success?: boolean; errorMsg?: string }> } | undefined)?.deliverResults;
|
|
864
|
+
if (Array.isArray(deliverResults)) {
|
|
865
|
+
const failedDelivery = deliverResults.find((item) => item?.success === false);
|
|
866
|
+
if (failedDelivery) {
|
|
867
|
+
throw new Error(failedDelivery.errorMsg?.trim() || "DingTalk card delivery failed");
|
|
868
|
+
}
|
|
869
|
+
}
|
|
761
870
|
const responseTracking = responseData?.result;
|
|
762
871
|
const processQueryKey =
|
|
763
872
|
typeof responseTracking?.processQueryKey === "string" &&
|
|
@@ -793,6 +902,7 @@ export async function createAICard(
|
|
|
793
902
|
config,
|
|
794
903
|
processQueryKey: processQueryKey || extractCardProcessQueryKey(resp.data),
|
|
795
904
|
outTrackId,
|
|
905
|
+
dapiUsage: 1,
|
|
796
906
|
};
|
|
797
907
|
if (shouldPersistPending) {
|
|
798
908
|
upsertPendingCard(aiCardInstance, options.storePath, log);
|
|
@@ -800,18 +910,6 @@ export async function createAICard(
|
|
|
800
910
|
|
|
801
911
|
clearAICardDegrade(accountId, log);
|
|
802
912
|
|
|
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
|
-
|
|
815
913
|
return aiCardInstance;
|
|
816
914
|
} catch (err: any) {
|
|
817
915
|
log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
|
|
@@ -836,6 +934,236 @@ export async function createAICard(
|
|
|
836
934
|
}
|
|
837
935
|
}
|
|
838
936
|
|
|
937
|
+
/**
|
|
938
|
+
* Update statusLine via PUT /v1.0/card/instances API.
|
|
939
|
+
*/
|
|
940
|
+
export async function updateAICardStatusLine(
|
|
941
|
+
card: AICardInstance,
|
|
942
|
+
statusLine: string,
|
|
943
|
+
log?: Logger,
|
|
944
|
+
): Promise<void> {
|
|
945
|
+
if (isCardInTerminalState(card.state) || !statusLine.trim()) {
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
await ensureFreshToken(card, log);
|
|
950
|
+
|
|
951
|
+
try {
|
|
952
|
+
await updateCardVariables(
|
|
953
|
+
card.outTrackId || card.cardInstanceId,
|
|
954
|
+
{ statusLine },
|
|
955
|
+
card.accessToken,
|
|
956
|
+
card.config,
|
|
957
|
+
);
|
|
958
|
+
incrementCardDapiCount(card);
|
|
959
|
+
card.lastUpdated = Date.now();
|
|
960
|
+
} catch (err: unknown) {
|
|
961
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
962
|
+
log?.warn?.(`[DingTalk][AICard] StatusLine update failed: ${message}`);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
export async function updateAICardBlockList(
|
|
967
|
+
card: AICardInstance,
|
|
968
|
+
blockListJson: string,
|
|
969
|
+
log?: Logger,
|
|
970
|
+
options?: { statusLine?: string },
|
|
971
|
+
): Promise<void> {
|
|
972
|
+
if (isCardInTerminalState(card.state)) {
|
|
973
|
+
log?.debug?.(
|
|
974
|
+
`[DingTalk][AICard] Skip blockList update because card already terminal: outTrackId=${card.cardInstanceId} state=${card.state}`,
|
|
975
|
+
);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Ensure token is fresh before API call
|
|
980
|
+
await ensureFreshToken(card, log);
|
|
981
|
+
|
|
982
|
+
const template = DINGTALK_CARD_TEMPLATE;
|
|
983
|
+
const params: Record<string, unknown> = {
|
|
984
|
+
[template.blockListKey]: blockListJson,
|
|
985
|
+
};
|
|
986
|
+
if (options?.statusLine?.trim()) {
|
|
987
|
+
params.statusLine = options.statusLine;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
try {
|
|
991
|
+
await updateCardVariables(
|
|
992
|
+
card.outTrackId || card.cardInstanceId,
|
|
993
|
+
params,
|
|
994
|
+
card.accessToken,
|
|
995
|
+
card.config,
|
|
996
|
+
);
|
|
997
|
+
incrementCardDapiCount(card);
|
|
998
|
+
card.lastBlockListJson = blockListJson;
|
|
999
|
+
card.lastUpdated = Date.now();
|
|
1000
|
+
if (card.state === AICardStatus.PROCESSING) {
|
|
1001
|
+
card.state = AICardStatus.INPUTING;
|
|
1002
|
+
}
|
|
1003
|
+
upsertPendingCard(card, card.storePath, log);
|
|
1004
|
+
} catch (err: unknown) {
|
|
1005
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1006
|
+
log?.error?.(`[DingTalk][AICard] BlockList update failed: ${message}`);
|
|
1007
|
+
throw err;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Stream answer text to content key for real-time display.
|
|
1013
|
+
* Only used when cardRealTimeStream=true.
|
|
1014
|
+
* Uses streaming API because content is a simple string type.
|
|
1015
|
+
*/
|
|
1016
|
+
export async function streamAICardContent(
|
|
1017
|
+
card: AICardInstance,
|
|
1018
|
+
text: string,
|
|
1019
|
+
log?: Logger,
|
|
1020
|
+
): Promise<void> {
|
|
1021
|
+
if (isCardInTerminalState(card.state)) {
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const template = DINGTALK_CARD_TEMPLATE;
|
|
1025
|
+
await putAICardStreamingField(card, template.streamingKey, text, false, log);
|
|
1026
|
+
card.lastStreamedContent = text;
|
|
1027
|
+
upsertPendingCard(card, card.storePath, log);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Clear the streaming content key.
|
|
1032
|
+
* Called when transitioning from streaming to blockList commit.
|
|
1033
|
+
*/
|
|
1034
|
+
export async function clearAICardStreamingContent(
|
|
1035
|
+
card: AICardInstance,
|
|
1036
|
+
log?: Logger,
|
|
1037
|
+
): Promise<void> {
|
|
1038
|
+
if (isCardInTerminalState(card.state)) {
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
const template = DINGTALK_CARD_TEMPLATE;
|
|
1042
|
+
try {
|
|
1043
|
+
await putAICardStreamingField(card, template.streamingKey, "", false, log);
|
|
1044
|
+
} catch (err: unknown) {
|
|
1045
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1046
|
+
log?.debug?.(`[DingTalk][AICard] Non-critical: failed to clear streaming content: ${message}`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function finalizeAICardStreamingLifecycleIfNeeded(
|
|
1051
|
+
card: AICardInstance,
|
|
1052
|
+
content: string,
|
|
1053
|
+
log?: Logger,
|
|
1054
|
+
): Promise<void> {
|
|
1055
|
+
if (!card.streamLifecycleOpened) {
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
const template = DINGTALK_CARD_TEMPLATE;
|
|
1059
|
+
try {
|
|
1060
|
+
await putAICardStreamingField(card, template.streamingKey, content, true, log, {
|
|
1061
|
+
suppressDegrade: true,
|
|
1062
|
+
});
|
|
1063
|
+
} catch (err: unknown) {
|
|
1064
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1065
|
+
log?.warn?.(`[DingTalk][AICard] Streaming lifecycle finalize failed; continuing instances finalize: ${message}`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Options for finalizing an AI Card via instances API.
|
|
1071
|
+
* All variables are written in a single API call for V2 template compatibility.
|
|
1072
|
+
*/
|
|
1073
|
+
export interface FinalizeCardOptions {
|
|
1074
|
+
/** CardBlock[] JSON string for blockList variable */
|
|
1075
|
+
blockListJson: string;
|
|
1076
|
+
/** Pure markdown answer text for copy action (content variable) */
|
|
1077
|
+
content: string;
|
|
1078
|
+
/** Optional quoted message preview text */
|
|
1079
|
+
quoteContent?: string;
|
|
1080
|
+
/** Optional statusLine string for card template */
|
|
1081
|
+
statusLine?: string;
|
|
1082
|
+
/** Optional quoted message reference for caching */
|
|
1083
|
+
quotedRef?: QuotedRef;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Commit blocks and finalize card via single instances API call.
|
|
1088
|
+
* V2 template requires finalize through instances API (not streaming API).
|
|
1089
|
+
* Writes blockList, content, quoteContent, statusLine, and flowStatus in one call.
|
|
1090
|
+
*/
|
|
1091
|
+
export async function commitAICardBlocks(
|
|
1092
|
+
card: AICardInstance,
|
|
1093
|
+
options: FinalizeCardOptions,
|
|
1094
|
+
log?: Logger,
|
|
1095
|
+
): Promise<void> {
|
|
1096
|
+
if (isCardInTerminalState(card.state)) {
|
|
1097
|
+
log?.debug?.(
|
|
1098
|
+
`[DingTalk][AICard] Skip finalize because card already terminal: outTrackId=${card.cardInstanceId} state=${card.state}`,
|
|
1099
|
+
);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
await ensureFreshToken(card, log);
|
|
1104
|
+
await finalizeAICardStreamingLifecycleIfNeeded(card, options.content, log);
|
|
1105
|
+
|
|
1106
|
+
const template = DINGTALK_CARD_TEMPLATE;
|
|
1107
|
+
const updates: Record<string, unknown> = {
|
|
1108
|
+
[template.blockListKey]: options.blockListJson,
|
|
1109
|
+
[template.streamingKey]: options.content, // markdown content for display
|
|
1110
|
+
[template.copyContentKey]: options.content, // same markdown as String type for card copy action
|
|
1111
|
+
flowStatus: 3, // completed state - V2 template hides stop button automatically
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
// Optional fields
|
|
1115
|
+
if (options.quoteContent?.trim()) {
|
|
1116
|
+
updates.quoteContent = options.quoteContent;
|
|
1117
|
+
}
|
|
1118
|
+
if (options.statusLine?.trim()) {
|
|
1119
|
+
updates.statusLine = options.statusLine;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
log?.debug?.(
|
|
1123
|
+
`[DingTalk][AICard] Finalizing via instances API: outTrackId=${card.outTrackId || card.cardInstanceId} ` +
|
|
1124
|
+
`blockListLen=${options.blockListJson.length} contentLen=${options.content.length} flowStatus=3` +
|
|
1125
|
+
(options.statusLine ? ` statusLine="${options.statusLine}"` : ""),
|
|
1126
|
+
);
|
|
1127
|
+
|
|
1128
|
+
try {
|
|
1129
|
+
await updateCardVariables(
|
|
1130
|
+
card.outTrackId || card.cardInstanceId,
|
|
1131
|
+
updates,
|
|
1132
|
+
card.accessToken,
|
|
1133
|
+
card.config,
|
|
1134
|
+
);
|
|
1135
|
+
incrementCardDapiCount(card);
|
|
1136
|
+
card.lastBlockListJson = options.blockListJson;
|
|
1137
|
+
card.lastStreamedContent = options.content;
|
|
1138
|
+
card.lastUpdated = Date.now();
|
|
1139
|
+
} catch (err: unknown) {
|
|
1140
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1141
|
+
log?.error?.(`[DingTalk][AICard] Finalize via instances API failed: ${message}`);
|
|
1142
|
+
throw err;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Cache card content for quote recovery
|
|
1146
|
+
if (card.conversationId && options.content.trim() && card.accountId && card.processQueryKey) {
|
|
1147
|
+
const primaryConversationId = card.contextConversationId || card.conversationId;
|
|
1148
|
+
cacheCardContentByProcessQueryKey(
|
|
1149
|
+
card.accountId,
|
|
1150
|
+
primaryConversationId,
|
|
1151
|
+
card.processQueryKey,
|
|
1152
|
+
options.content,
|
|
1153
|
+
card.storePath,
|
|
1154
|
+
options.quotedRef,
|
|
1155
|
+
log,
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Update local state
|
|
1160
|
+
card.state = AICardStatus.FINISHED;
|
|
1161
|
+
card.lastUpdated = Date.now();
|
|
1162
|
+
removePendingCard(card, log);
|
|
1163
|
+
log?.info?.(`[DingTalk][AICard] Card finalized: outTrackId=${card.outTrackId || card.cardInstanceId} state=FINISHED`);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
|
|
839
1167
|
export async function streamAICard(
|
|
840
1168
|
card: AICardInstance,
|
|
841
1169
|
content: string,
|
|
@@ -858,6 +1186,7 @@ export async function streamAICard(
|
|
|
858
1186
|
removePendingCard(card, log);
|
|
859
1187
|
} else if (card.state === AICardStatus.PROCESSING) {
|
|
860
1188
|
card.state = AICardStatus.INPUTING;
|
|
1189
|
+
upsertPendingCard(card, card.storePath, log);
|
|
861
1190
|
}
|
|
862
1191
|
} catch (err: any) {
|
|
863
1192
|
card.state = AICardStatus.FAILED;
|
|
@@ -873,6 +1202,14 @@ export async function streamAICard(
|
|
|
873
1202
|
}
|
|
874
1203
|
}
|
|
875
1204
|
|
|
1205
|
+
/**
|
|
1206
|
+
* Finalize AI Card via streaming API.
|
|
1207
|
+
*
|
|
1208
|
+
* @deprecated For V2 template, use `commitAICardBlocks()` instead which finalizes
|
|
1209
|
+
* via instances API (single call writes blockList, content, flowStatus=3).
|
|
1210
|
+
* This function is kept for backward compatibility with V1 template and for
|
|
1211
|
+
* card-stop-handler which uses streaming API for immediate stop acknowledgment.
|
|
1212
|
+
*/
|
|
876
1213
|
export async function finishAICard(
|
|
877
1214
|
card: AICardInstance,
|
|
878
1215
|
content: string,
|
|
@@ -1039,6 +1376,54 @@ export async function finishStoppedAICard(
|
|
|
1039
1376
|
}
|
|
1040
1377
|
}
|
|
1041
1378
|
|
|
1379
|
+
export async function finalizeStoppedAICard(
|
|
1380
|
+
card: AICardInstance,
|
|
1381
|
+
options: {
|
|
1382
|
+
reason: string;
|
|
1383
|
+
previousContent?: string;
|
|
1384
|
+
previousBlockListJson?: string;
|
|
1385
|
+
},
|
|
1386
|
+
log?: Logger,
|
|
1387
|
+
): Promise<void> {
|
|
1388
|
+
if (isCardInTerminalState(card.state)) {
|
|
1389
|
+
log?.debug?.(
|
|
1390
|
+
`[DingTalk][AICard] finalizeStoppedAICard skipped — already terminal: ${card.state}`,
|
|
1391
|
+
);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
await ensureFreshToken(card, log);
|
|
1396
|
+
const template = DINGTALK_CARD_TEMPLATE;
|
|
1397
|
+
const payload = buildStoppedCardFinalizePayload(options);
|
|
1398
|
+
await finalizeAICardStreamingLifecycleIfNeeded(card, payload.content, log);
|
|
1399
|
+
try {
|
|
1400
|
+
await updateCardVariables(
|
|
1401
|
+
card.outTrackId || card.cardInstanceId,
|
|
1402
|
+
{
|
|
1403
|
+
[template.blockListKey]: payload.blockListJson,
|
|
1404
|
+
[template.streamingKey]: payload.content,
|
|
1405
|
+
[template.copyContentKey]: payload.content,
|
|
1406
|
+
flowStatus: 3,
|
|
1407
|
+
},
|
|
1408
|
+
card.accessToken,
|
|
1409
|
+
card.config,
|
|
1410
|
+
);
|
|
1411
|
+
incrementCardDapiCount(card);
|
|
1412
|
+
card.lastBlockListJson = payload.blockListJson;
|
|
1413
|
+
card.lastStreamedContent = payload.content;
|
|
1414
|
+
card.state = AICardStatus.STOPPED;
|
|
1415
|
+
card.lastUpdated = Date.now();
|
|
1416
|
+
removePendingCard(card, log);
|
|
1417
|
+
} catch (err: unknown) {
|
|
1418
|
+
card.lastBlockListJson = payload.blockListJson;
|
|
1419
|
+
card.lastStreamedContent = payload.content;
|
|
1420
|
+
card.state = AICardStatus.STOPPED;
|
|
1421
|
+
card.lastUpdated = Date.now();
|
|
1422
|
+
// Keep pending record for manual investigation on API failure
|
|
1423
|
+
throw err;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1042
1427
|
function cacheCardContentByProcessQueryKey(
|
|
1043
1428
|
accountId: string,
|
|
1044
1429
|
conversationId: string,
|