@soimy/dingtalk 3.6.4 → 3.6.5

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.
@@ -1,6 +1,7 @@
1
1
  import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
2
  import type { CardCallbackAnalysis } from "../card-callback-service";
3
3
  import type { DingTalkConfig, Logger } from "../types";
4
+ import { handleDingTalkAskUserCardCallback } from "./ask-user-question";
4
5
  import { resolveCardRun } from "./card-run-registry";
5
6
  import { stopCardRun } from "./card-stop-handler";
6
7
 
@@ -9,12 +10,25 @@ export interface CardActionResult {
9
10
  }
10
11
 
11
12
  export async function handleCardAction(params: {
13
+ payload: unknown;
12
14
  analysis: CardCallbackAnalysis;
13
15
  cfg: OpenClawConfig;
14
16
  accountId: string;
15
17
  config: DingTalkConfig;
16
18
  log?: Logger;
17
19
  }): Promise<CardActionResult> {
20
+ const askUserResult = await handleDingTalkAskUserCardCallback({
21
+ payload: params.payload,
22
+ cfg: params.cfg,
23
+ accountId: params.accountId,
24
+ config: params.config,
25
+ clickerUserId: params.analysis.userId,
26
+ log: params.log,
27
+ });
28
+ if (askUserResult.handled) {
29
+ return askUserResult;
30
+ }
31
+
18
32
  if (params.analysis.actionId !== "btn_stop") {
19
33
  return { handled: false };
20
34
  }
@@ -8,6 +8,8 @@ export const BUILTIN_DINGTALK_CARD_TEMPLATE_ID =
8
8
  export const BUILTIN_DINGTALK_CARD_CONTENT_KEY = "content";
9
9
  export const BUILTIN_DINGTALK_CARD_BLOCK_LIST_KEY = "blockList";
10
10
  export const BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY = "copy_content";
11
+ export const BUILTIN_DINGTALK_ASK_USER_CARD_TEMPLATE_ID =
12
+ "89d0c6fe-3822-44c8-950e-e950f562546d.schema";
11
13
 
12
14
  export interface DingTalkCardTemplateContract {
13
15
  templateId: string;
@@ -20,6 +22,10 @@ export interface DingTalkCardTemplateContract {
20
22
  copyContentKey: string;
21
23
  }
22
24
 
25
+ export interface DingTalkAskUserCardTemplateContract {
26
+ templateId: string;
27
+ }
28
+
23
29
  /** Frozen singleton — no allocation on every call. */
24
30
  export const DINGTALK_CARD_TEMPLATE: Readonly<DingTalkCardTemplateContract> = Object.freeze({
25
31
  templateId: BUILTIN_DINGTALK_CARD_TEMPLATE_ID,
@@ -29,3 +35,7 @@ export const DINGTALK_CARD_TEMPLATE: Readonly<DingTalkCardTemplateContract> = Ob
29
35
  copyContentKey: BUILTIN_DINGTALK_CARD_COPY_CONTENT_KEY,
30
36
  });
31
37
 
38
+ export const DINGTALK_ASK_USER_CARD_TEMPLATE: Readonly<DingTalkAskUserCardTemplateContract> =
39
+ Object.freeze({
40
+ templateId: BUILTIN_DINGTALK_ASK_USER_CARD_TEMPLATE_ID,
41
+ });
@@ -1,10 +1,7 @@
1
1
  import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
2
2
  import { analyzeCardCallback } from "../card-callback-service";
3
+ import { finalizeActiveCardsForAccount, recoverPendingCardsForAccount } from "../card-service";
3
4
  import { handleCardAction } from "../card/card-action-handler";
4
- import {
5
- finalizeActiveCardsForAccount,
6
- recoverPendingCardsForAccount,
7
- } from "../card-service";
8
5
  import { resolveRobotCode, resolveRuntimeConfig } from "../config";
9
6
  import { ConnectionManager } from "../connection-manager";
10
7
  import { isMessageProcessed, markMessageProcessed } from "../dedup";
@@ -381,6 +378,7 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
381
378
  }
382
379
  }
383
380
  const actionResult = await handleCardAction({
381
+ payload,
384
382
  analysis,
385
383
  cfg,
386
384
  accountId: account.accountId,
@@ -500,7 +498,9 @@ export function createDingTalkGateway(): NonNullable<DingTalkChannelPlugin["gate
500
498
  lastStartAt: getCurrentTimestamp(),
501
499
  lastError: null,
502
500
  });
503
- pluginLog?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
501
+ pluginLog?.info?.(
502
+ `[${account.accountId}] DingTalk Stream client connected successfully`,
503
+ );
504
504
  await nativeStopPromise;
505
505
  }
506
506
  } catch (err: any) {
@@ -7,9 +7,19 @@ import { classifyAckReactionEmoji } from "./ack-reaction-classifier";
7
7
  import { attachNativeAckReaction } from "./ack-reaction-service";
8
8
  import { createDynamicAckReactionController } from "./ack-reaction/dynamic-ack-reaction-controller";
9
9
  import { getAccessToken } from "./auth";
10
- import { createAICard, commitAICardBlocks, isCardInTerminalState } from "./card-service";
10
+ import {
11
+ createAICard,
12
+ commitAICardBlocks,
13
+ isCardInTerminalState,
14
+ recallAICardMessage,
15
+ } from "./card-service";
16
+ import {
17
+ getDingTalkQuestionContext,
18
+ withDingTalkQuestionContext,
19
+ } from "./card/ask-user-question-context";
11
20
  import { isCardRunStopRequested, registerCardRun, removeCardRun } from "./card/card-run-registry";
12
21
  import { renderStatusLine } from "./card/statusline-renderer";
22
+ import { dispatchDingTalkCardStopCommand } from "./command/card-stop-command";
13
23
  import { handleInboundCommandDispatch } from "./command/inbound-command-dispatch-service";
14
24
  import {
15
25
  resolveAckReactionSetting,
@@ -548,6 +558,23 @@ export async function downloadMedia(
548
558
  }
549
559
 
550
560
  export async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
561
+ // Keep context creation inside this public inbound entry. Ask-user synthetic
562
+ // reinjections call handleDingTalkMessage directly, so moving this wrapper to
563
+ // gateway callbacks would lose per-message isolation for reinjected answers.
564
+ return withDingTalkQuestionContext(
565
+ {
566
+ cfg: params.cfg,
567
+ accountId: params.accountId,
568
+ data: params.data,
569
+ sessionWebhook: params.sessionWebhook,
570
+ log: params.log,
571
+ dingtalkConfig: params.dingtalkConfig,
572
+ },
573
+ () => handleDingTalkMessageInner(params),
574
+ );
575
+ }
576
+
577
+ async function handleDingTalkMessageInner(params: HandleDingTalkMessageParams): Promise<void> {
551
578
  const {
552
579
  cfg,
553
580
  accountId,
@@ -809,6 +836,10 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
809
836
  peer: { kind: sessionPeer.kind, id: sessionPeer.peerId },
810
837
  });
811
838
  }
839
+ const questionContext = getDingTalkQuestionContext();
840
+ if (questionContext) {
841
+ questionContext.questionScopeKey = `${accountId}:${route.sessionKey}:${senderId}`;
842
+ }
812
843
 
813
844
  // @Sub-Agent routing: dispatch @mention-targeted messages to their agent(s).
814
845
  // Both content (`@agent <message>`) and commands (`@agent /new`) re-enter
@@ -930,8 +961,52 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
930
961
  // sending a separate plain-text message.
931
962
  let useCardMode = dingtalkConfig.messageType === "card";
932
963
  let currentAICard: import("./types").AICardInstance | undefined;
964
+ let questionCardTookOver = false;
933
965
 
934
966
  let cardFlightKey: string | undefined;
967
+ if (questionContext) {
968
+ questionContext.onQuestionCardSent = async ({ questionId, outTrackId }) => {
969
+ questionCardTookOver = true;
970
+ if (cardFlightKey) {
971
+ cardCreationInFlight.delete(cardFlightKey);
972
+ cardFlightKey = undefined;
973
+ }
974
+ try {
975
+ await dispatchDingTalkCardStopCommand({
976
+ cfg,
977
+ accountId,
978
+ agentId: route.agentId,
979
+ targetSessionKey: route.sessionKey,
980
+ clickerUserId: senderId || "unknown",
981
+ log,
982
+ });
983
+ log?.info?.(
984
+ `[DingTalk][AskUser] Dispatched targeted stop after question card sent question=${questionId} outTrackId=${outTrackId} targetSessionKey=${route.sessionKey}`,
985
+ );
986
+ } catch (err) {
987
+ log?.warn?.(
988
+ `[DingTalk][AskUser] Question card sent, but targeted stop failed question=${questionId} outTrackId=${outTrackId} targetSessionKey=${route.sessionKey}: ${err instanceof Error ? err.message : String(err)}`,
989
+ );
990
+ }
991
+ if (!currentAICard) {
992
+ return;
993
+ }
994
+ if (isCardInTerminalState(currentAICard.state)) {
995
+ return;
996
+ }
997
+ const recalled = await recallAICardMessage(currentAICard, log);
998
+ if (!recalled) {
999
+ log?.warn?.(
1000
+ `[DingTalk][AskUser] Question card sent, but AI card recall failed; normal replies remain suppressed question=${questionId} outTrackId=${outTrackId}`,
1001
+ );
1002
+ return;
1003
+ }
1004
+ log?.info?.(
1005
+ `[DingTalk][AskUser] Recalled empty AI card after question card sent question=${questionId} outTrackId=${outTrackId}`,
1006
+ );
1007
+ };
1008
+ }
1009
+
935
1010
  if (useCardMode && !isBtwBypass) {
936
1011
  const key = `${accountId}:${to}`;
937
1012
  if (cardCreationInFlight.has(key)) {
@@ -2163,11 +2238,18 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
2163
2238
  const inlineReplyPayload = parseInlineReplyPayloadText(payload.text);
2164
2239
  const mediaUrls = extractMediaUrls(payload, inlineReplyPayload);
2165
2240
  const richPayload = payload as ReplyStreamPayload & { isReasoning?: boolean };
2241
+ const replyKind = (info?.kind as DeliverPayload["kind"]) || "block";
2242
+ if (questionCardTookOver) {
2243
+ log?.info?.(
2244
+ `[DingTalk][AskUser] Suppressed ${replyKind} reply after question card took over`,
2245
+ );
2246
+ return;
2247
+ }
2166
2248
  await strategy.deliver({
2167
2249
  text: inlineReplyPayload.text,
2168
2250
  mediaUrls,
2169
2251
  audioAsVoice: extractSharedAudioAsVoice(payload, inlineReplyPayload),
2170
- kind: (info?.kind as DeliverPayload["kind"]) || "block",
2252
+ kind: replyKind,
2171
2253
  isReasoning: richPayload.isReasoning === true,
2172
2254
  });
2173
2255
  } catch (err: unknown) {
@@ -2212,13 +2294,19 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
2212
2294
  typeof bufferedFinalPayload.text === "string" &&
2213
2295
  bufferedFinalPayload.text.trim().length > 0;
2214
2296
  if (hasBufferedText || mediaUrls.length > 0) {
2215
- await strategy.deliver({
2216
- text: inlineReplyPayload.text,
2217
- mediaUrls,
2218
- audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
2219
- kind: "final",
2220
- isReasoning: bufferedFinalPayload.isReasoning === true,
2221
- });
2297
+ if (questionCardTookOver) {
2298
+ log?.info?.(
2299
+ "[DingTalk][AskUser] Suppressed buffered final after question card took over",
2300
+ );
2301
+ } else {
2302
+ await strategy.deliver({
2303
+ text: inlineReplyPayload.text,
2304
+ mediaUrls,
2305
+ audioAsVoice: extractSharedAudioAsVoice(bufferedFinalPayload, inlineReplyPayload),
2306
+ kind: "final",
2307
+ isReasoning: bufferedFinalPayload.isReasoning === true,
2308
+ });
2309
+ }
2222
2310
  }
2223
2311
  }
2224
2312
  } catch (dispatchErr: unknown) {
@@ -2228,6 +2316,12 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
2228
2316
  throw dispatchErr;
2229
2317
  }
2230
2318
 
2319
+ if (questionCardTookOver) {
2320
+ log?.info?.(
2321
+ "[DingTalk][AskUser] Skipping normal AI card finalize after question card took over",
2322
+ );
2323
+ return;
2324
+ }
2231
2325
  await strategy.finalize();
2232
2326
  } finally {
2233
2327
  // Only remove the registry entry if no stop was requested. When a stop is