@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.
@@ -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,
@@ -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 "axios";
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 state === AICardStatus.FINISHED || state === AICardStatus.FAILED;
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
- const cardTemplateKey = config.cardTemplateKey || "content";
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
- [cardTemplateKey]: "",
708
+ [template.contentKey]: "",
709
+ stop_action: STOP_ACTION_VISIBLE,
585
710
  };
586
711
  const createAndDeliverBody = {
587
- cardTemplateId: config.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 === AICardStatus.FINISHED) {
845
+ if (isCardInTerminalState(card.state)) {
708
846
  log?.debug?.(
709
- `[DingTalk][AICard] Skip stream update because card already finalized: outTrackId=${card.cardInstanceId}`,
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
- const streamResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, streamBody, {
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 (card.accountId && shouldTriggerAICardDegrade(err)) {
831
- activateAICardDegrade(
832
- card.accountId,
833
- `card.stream:${err?.response?.status || "unknown"}`,
834
- card.config,
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,