@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.
@@ -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 { stripTargetPrefix } from "./config";
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 state === AICardStatus.FINISHED || state === AICardStatus.FAILED;
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.robotCode || config.clientId,
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
- 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.
580
706
  const cardParamMap = {
581
707
  config: JSON.stringify({ autoLayout: true, enableForward: true }),
582
- [cardTemplateKey]: "",
708
+ [template.contentKey]: "",
709
+ stop_action: STOP_ACTION_VISIBLE,
583
710
  };
584
711
  const createAndDeliverBody = {
585
- cardTemplateId: config.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.robotCode || config.clientId,
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.robotCode || config.clientId,
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 === AICardStatus.FINISHED) {
845
+ if (isCardInTerminalState(card.state)) {
713
846
  log?.debug?.(
714
- `[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}`,
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
- const streamResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, streamBody, {
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 (card.accountId && shouldTriggerAICardDegrade(err)) {
836
- activateAICardDegrade(
837
- card.accountId,
838
- `card.stream:${err?.response?.status || "unknown"}`,
839
- card.config,
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
- isFeedbackLearningAutoApplyEnabled,
30
- isFeedbackLearningEnabled,
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.robotCode || config.clientId || account.accountId;
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: isFeedbackLearningEnabled(config),
811
- autoApply: isFeedbackLearningAutoApplyEnabled(config),
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 ?? config.feedbackLearningNoteTtlMs,
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.robotCode || config.clientId || account.accountId;
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
+ }
@@ -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