@soimy/dingtalk 3.3.0 → 3.4.0

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.
@@ -0,0 +1,97 @@
1
+ import {
2
+ resolveByQuotedRef,
3
+ type MessageRecord,
4
+ } from "../message-context-store";
5
+ import type { DingTalkInboundMessage, Logger, MessageContent, QuotedRef } from "../types";
6
+
7
+ function firstTrimmedString(...candidates: Array<string | undefined>): string | undefined {
8
+ for (const candidate of candidates) {
9
+ if (typeof candidate === "string" && candidate.trim()) {
10
+ return candidate.trim();
11
+ }
12
+ }
13
+ return undefined;
14
+ }
15
+
16
+ function firstFiniteNumber(...candidates: Array<number | undefined>): number | undefined {
17
+ for (const candidate of candidates) {
18
+ if (typeof candidate === "number" && Number.isFinite(candidate)) {
19
+ return candidate;
20
+ }
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ export function buildInboundQuotedRef(
26
+ data: DingTalkInboundMessage,
27
+ content: MessageContent,
28
+ ): QuotedRef | undefined {
29
+ const repliedMsg = data.text?.repliedMsg;
30
+ const repliedMsgId = firstTrimmedString(repliedMsg?.msgId, data.originalMsgId, content.quoted?.msgId);
31
+ const fallbackCreatedAt = firstFiniteNumber(
32
+ repliedMsg?.createdAt,
33
+ content.quoted?.cardCreatedAt,
34
+ content.quoted?.fileCreatedAt,
35
+ );
36
+ const isOutboundQuoted =
37
+ firstTrimmedString(data.originalProcessQueryKey) !== undefined ||
38
+ repliedMsg?.senderId === data.chatbotUserId ||
39
+ content.quoted?.isQuotedCard === true;
40
+ if (isOutboundQuoted) {
41
+ const processQueryKey = firstTrimmedString(data.originalProcessQueryKey, content.quoted?.processQueryKey);
42
+ if (processQueryKey) {
43
+ return {
44
+ targetDirection: "outbound",
45
+ key: "processQueryKey",
46
+ value: processQueryKey,
47
+ fallbackCreatedAt,
48
+ };
49
+ }
50
+ if (!fallbackCreatedAt) {
51
+ return undefined;
52
+ }
53
+ return {
54
+ targetDirection: "outbound",
55
+ fallbackCreatedAt,
56
+ };
57
+ }
58
+ if (!repliedMsgId) {
59
+ return undefined;
60
+ }
61
+ return {
62
+ targetDirection: "inbound",
63
+ key: "msgId",
64
+ value: repliedMsgId,
65
+ };
66
+ }
67
+
68
+ export function createReplyQuotedRef(msgId: string | undefined): QuotedRef | undefined {
69
+ const value = firstTrimmedString(msgId);
70
+ if (!value) {
71
+ return undefined;
72
+ }
73
+ return {
74
+ targetDirection: "inbound",
75
+ key: "msgId",
76
+ value,
77
+ };
78
+ }
79
+
80
+ export function resolveQuotedRecord(params: {
81
+ storePath?: string;
82
+ accountId: string;
83
+ conversationId: string | null;
84
+ quotedRef?: QuotedRef;
85
+ log?: Logger;
86
+ }): MessageRecord | null {
87
+ if (!params.quotedRef) {
88
+ return null;
89
+ }
90
+ return resolveByQuotedRef({
91
+ storePath: params.storePath,
92
+ accountId: params.accountId,
93
+ conversationId: params.conversationId,
94
+ quotedRef: params.quotedRef,
95
+ log: params.log,
96
+ });
97
+ }
package/src/onboarding.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { OpenClawConfig, ChannelOnboardingAdapter, WizardPrompter } from "openclaw/plugin-sdk";
2
2
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId, formatDocsLink } from "openclaw/plugin-sdk";
3
+ import { DEFAULT_MESSAGE_CONTEXT_TTL_DAYS } from "./message-context-store.js";
3
4
  import type { DingTalkConfig, DingTalkChannelConfig } from "./types.js";
4
5
  import { listDingTalkAccountIds, resolveDingTalkAccount } from "./types.js";
5
- import { DEFAULT_JOURNAL_TTL_DAYS } from "./quote-journal.js";
6
6
 
7
7
  const channel = "dingtalk" as const;
8
8
 
@@ -104,6 +104,10 @@ function applyAccountConfig(params: {
104
104
  ...(input.dmPolicy ? { dmPolicy: input.dmPolicy } : {}),
105
105
  ...(input.groupPolicy ? { groupPolicy: input.groupPolicy } : {}),
106
106
  ...(input.allowFrom && input.allowFrom.length > 0 ? { allowFrom: input.allowFrom } : {}),
107
+ ...(input.groupAllowFrom && input.groupAllowFrom.length > 0 ? { groupAllowFrom: input.groupAllowFrom } : {}),
108
+ ...(input.displayNameResolution
109
+ ? { displayNameResolution: input.displayNameResolution }
110
+ : {}),
107
111
  ...(input.messageType ? { messageType: input.messageType } : {}),
108
112
  ...(input.cardTemplateId ? { cardTemplateId: input.cardTemplateId } : {}),
109
113
  ...(input.cardTemplateKey ? { cardTemplateKey: input.cardTemplateKey } : {}),
@@ -317,10 +321,61 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
317
321
  options: [
318
322
  { label: "Open - any group can use bot", value: "open" },
319
323
  { label: "Allowlist - only allowed groups", value: "allowlist" },
324
+ { label: "Disabled - block all group messages", value: "disabled" },
320
325
  ],
321
326
  initialValue: resolved.groupPolicy ?? "open",
322
327
  });
323
328
 
329
+ if (groupPolicyValue === "allowlist") {
330
+ await prompter.note(
331
+ [
332
+ 'groupPolicy=allowlist requires "groups" config to specify allowed group IDs.',
333
+ "After setup, manually add group conversationIds to your config:",
334
+ "",
335
+ ' "groups": { "cidXXX": {}, "cidYYY": { "systemPrompt": "..." } }',
336
+ "",
337
+ "Groups not listed will be blocked. Use \"*\" as key to allow all groups.",
338
+ ].join("\n"),
339
+ );
340
+ }
341
+
342
+ let groupAllowFrom: string[] | undefined;
343
+ if (groupPolicyValue !== "disabled") {
344
+ const groupAllowFromEntry = await prompter.text({
345
+ message: "Group sender allowlist - user IDs allowed in groups (comma-separated, optional)",
346
+ placeholder: "user1, user2",
347
+ initialValue: (resolved.groupAllowFrom || []).join(", ") || undefined,
348
+ });
349
+ const parsedGroupAllowFrom = parseList(String(groupAllowFromEntry ?? ""));
350
+ groupAllowFrom = parsedGroupAllowFrom.length > 0 ? parsedGroupAllowFrom : undefined;
351
+ }
352
+
353
+ await prompter.note(
354
+ [
355
+ "Enabling learned displayName target resolution has tradeoffs:",
356
+ "- learned names come from observed inbound messages and can become stale",
357
+ "- duplicate display names can resolve to the wrong group or user",
358
+ "- current upstream target resolution does not provide requester authz, so \"all\" applies to every caller that can reach the send flow",
359
+ "Use explicit IDs for sensitive or high-risk deliveries.",
360
+ ].join("\n"),
361
+ "displayName resolution risk",
362
+ );
363
+
364
+ const displayNameResolutionValue = await prompter.select({
365
+ message: "Learned displayName target resolution",
366
+ options: [
367
+ {
368
+ label: "Disabled - require explicit IDs",
369
+ value: "disabled",
370
+ },
371
+ {
372
+ label: "All - learned lookup for all callers (higher risk)",
373
+ value: "all",
374
+ },
375
+ ],
376
+ initialValue: resolved.displayNameResolution ?? "disabled",
377
+ });
378
+
324
379
  let maxReconnectCycles: number | undefined;
325
380
  const wantsReconnectLimits = await prompter.confirm({
326
381
  message: "Configure runtime reconnect cycle limit? (recommended)",
@@ -390,11 +445,11 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
390
445
  String(
391
446
  await prompter.text({
392
447
  message: "Quote journal retention days",
393
- placeholder: String(DEFAULT_JOURNAL_TTL_DAYS),
448
+ placeholder: String(DEFAULT_MESSAGE_CONTEXT_TTL_DAYS),
394
449
  initialValue:
395
450
  typeof resolved.journalTTLDays === "number"
396
451
  ? String(resolved.journalTTLDays)
397
- : String(DEFAULT_JOURNAL_TTL_DAYS),
452
+ : String(DEFAULT_MESSAGE_CONTEXT_TTL_DAYS),
398
453
  validate: (value) => {
399
454
  const raw = String(value ?? "").trim();
400
455
  const num = Number(raw);
@@ -412,7 +467,7 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
412
467
  journalTTLDays =
413
468
  Number.isInteger(parsedJournalTTL) && parsedJournalTTL > 0
414
469
  ? parsedJournalTTL
415
- : DEFAULT_JOURNAL_TTL_DAYS;
470
+ : DEFAULT_MESSAGE_CONTEXT_TTL_DAYS;
416
471
  }
417
472
 
418
473
  const next = applyAccountConfig({
@@ -425,8 +480,10 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
425
480
  corpId,
426
481
  agentId,
427
482
  dmPolicy: dmPolicyValue as "open" | "allowlist",
428
- groupPolicy: groupPolicyValue as "open" | "allowlist",
483
+ groupPolicy: groupPolicyValue as "open" | "allowlist" | "disabled",
429
484
  allowFrom,
485
+ groupAllowFrom,
486
+ displayNameResolution: displayNameResolutionValue as "disabled" | "all",
430
487
  mediaUrlAllowlist,
431
488
  messageType,
432
489
  cardTemplateId,
@@ -0,0 +1,225 @@
1
+ /**
2
+ * AI Card reply strategy.
3
+ *
4
+ * Encapsulates the card draft controller lifecycle, deliver routing
5
+ * (final / tool / block), finalization, and failure fallback so that
6
+ * inbound-handler only coordinates — it no longer owns card state.
7
+ */
8
+
9
+ import {
10
+ finishAICard,
11
+ formatContentForCard,
12
+ isCardInTerminalState,
13
+ } from "./card-service";
14
+ import { createCardDraftController } from "./card-draft-controller";
15
+ import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
16
+ import { sendBySession, sendMessage } from "./send-service";
17
+ import type { AICardInstance } from "./types";
18
+ import { AICardStatus } from "./types";
19
+ import { formatDingTalkErrorPayloadLog } from "./utils";
20
+
21
+ export function createCardReplyStrategy(
22
+ ctx: ReplyStrategyContext & { card: AICardInstance },
23
+ ): ReplyStrategy {
24
+ const { card, config, log } = ctx;
25
+
26
+ const controller = createCardDraftController({ card, log });
27
+ let finalTextForFallback: string | undefined;
28
+
29
+ return {
30
+ getReplyOptions(): ReplyOptions {
31
+ return {
32
+ // Card mode: intermediate blocks are unused — card updates go through
33
+ // onPartialReply (real-time) or deliver(final) -> finishAICard.
34
+ disableBlockStreaming: true,
35
+
36
+ onAssistantMessageStart: () => {
37
+ controller.notifyNewAssistantTurn();
38
+ },
39
+
40
+ onPartialReply: config.cardRealTimeStream
41
+ ? (payload) => {
42
+ if (payload.text) {
43
+ controller.updateAnswer(payload.text);
44
+ }
45
+ }
46
+ : undefined,
47
+
48
+ onReasoningStream: (payload) => {
49
+ if (payload.text) {
50
+ controller.updateReasoning(payload.text);
51
+ }
52
+ },
53
+ };
54
+ },
55
+
56
+ async deliver(payload: DeliverPayload): Promise<void> {
57
+ const textToSend = payload.text;
58
+
59
+ // Empty-payload guard — card final is an exception (e.g. file-only response).
60
+ if ((typeof textToSend !== "string" || textToSend.length === 0) && payload.mediaUrls.length === 0) {
61
+ if (payload.kind !== "final") {
62
+ return;
63
+ }
64
+ }
65
+
66
+ // ---- final: defer to finalize, just save text ----
67
+ if (payload.kind === "final") {
68
+ log?.info?.(
69
+ `[DingTalk][Finalize] deliver(final) received — cardState=${card.state} ` +
70
+ `textLen=${typeof textToSend === "string" ? textToSend.length : "null"} ` +
71
+ `mediaUrls=${payload.mediaUrls.length} ` +
72
+ `lastAnswer="${(controller.getLastAnswerContent() ?? "").slice(0, 80)}" ` +
73
+ `lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
74
+ );
75
+ if (payload.mediaUrls.length > 0) {
76
+ await ctx.deliverMedia(payload.mediaUrls);
77
+ }
78
+ const rawFinalText = typeof textToSend === "string" ? textToSend : "";
79
+ if (rawFinalText) {
80
+ finalTextForFallback = rawFinalText;
81
+ }
82
+ return;
83
+ }
84
+
85
+ // ---- tool: append to card ----
86
+ if (payload.kind === "tool") {
87
+ if (controller.isFailed() || isCardInTerminalState(card.state)) {
88
+ log?.debug?.("[DingTalk] Card failed, skipping tool result (will send full reply on final)");
89
+ return;
90
+ }
91
+ await controller.flush();
92
+ await controller.waitForInFlight();
93
+ log?.info?.(
94
+ `[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
95
+ );
96
+ const toolText = typeof textToSend === "string" ? formatContentForCard(textToSend, "tool") : "";
97
+ if (toolText) {
98
+ const sendResult = await sendMessage(ctx.config, ctx.to, toolText, {
99
+ sessionWebhook: ctx.sessionWebhook,
100
+ atUserId: !ctx.isDirect ? ctx.senderId : null,
101
+ log,
102
+ card,
103
+ accountId: ctx.accountId,
104
+ storePath: ctx.storePath,
105
+ conversationId: ctx.groupId,
106
+ cardUpdateMode: "append",
107
+ });
108
+ if (!sendResult.ok) {
109
+ throw new Error(sendResult.error || "Tool stream send failed");
110
+ }
111
+ }
112
+ return;
113
+ }
114
+
115
+ // ---- block: only handle media (text blocks are unused) ----
116
+ if (payload.mediaUrls.length > 0) {
117
+ await ctx.deliverMedia(payload.mediaUrls);
118
+ }
119
+ },
120
+
121
+ async finalize(): Promise<void> {
122
+ log?.info?.(
123
+ `[DingTalk][Finalize] Step 5 entry — ` +
124
+ `cardState=${card.state ?? "N/A"} ` +
125
+ `controllerFailed=${controller.isFailed()} ` +
126
+ `finalTextForFallback="${(finalTextForFallback ?? "").slice(0, 80)}" ` +
127
+ `lastAnswer="${(controller.getLastAnswerContent() ?? "").slice(0, 80)}" ` +
128
+ `lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
129
+ );
130
+
131
+ if (card.state === AICardStatus.FINISHED) {
132
+ log?.info?.("[DingTalk][Finalize] Skipping — card already FINISHED");
133
+ return;
134
+ }
135
+
136
+ // Card failed -> markdown fallback (bypass sendMessage to avoid duplicate card).
137
+ if (card.state === AICardStatus.FAILED || controller.isFailed()) {
138
+ const fallbackText = finalTextForFallback
139
+ || controller.getLastAnswerContent()
140
+ || controller.getLastContent()
141
+ || card.lastStreamedContent;
142
+ if (fallbackText) {
143
+ log?.debug?.("[DingTalk] Card failed during streaming, sending markdown fallback");
144
+ const sendResult = await sendMessage(ctx.config, ctx.to, fallbackText, {
145
+ sessionWebhook: ctx.sessionWebhook,
146
+ atUserId: !ctx.isDirect ? ctx.senderId : null,
147
+ log,
148
+ accountId: ctx.accountId,
149
+ storePath: ctx.storePath,
150
+ conversationId: ctx.groupId,
151
+ quotedRef: ctx.replyQuotedRef,
152
+ forceMarkdown: true,
153
+ });
154
+ if (!sendResult.ok) {
155
+ throw new Error(sendResult.error || "Markdown fallback send failed after card failure");
156
+ }
157
+ } else {
158
+ log?.debug?.("[DingTalk] Card failed but no content to fallback with");
159
+ }
160
+ return;
161
+ }
162
+
163
+ // Normal finalize.
164
+ try {
165
+ await controller.flush();
166
+ await controller.waitForInFlight();
167
+ controller.stop();
168
+ const finalText = controller.getLastAnswerContent()
169
+ || finalTextForFallback
170
+ || "✅ Done";
171
+ log?.info?.(
172
+ `[DingTalk][Finalize] Calling finishAICard — finalTextLen=${finalText.length} ` +
173
+ `source=${controller.getLastAnswerContent() ? "lastAnswerContent" : finalTextForFallback ? "finalTextForFallback" : "fallbackDone"} ` +
174
+ `preview="${finalText.slice(0, 120)}"`,
175
+ );
176
+ await finishAICard(card, finalText, log, {
177
+ quotedRef: ctx.replyQuotedRef,
178
+ });
179
+
180
+ // In group chats, send a lightweight @mention via session webhook
181
+ // so the sender gets a notification — card API doesn't support @mention.
182
+ const cardAtSenderText = (ctx.config.cardAtSender || "").trim();
183
+ if (!ctx.isDirect && ctx.senderId && ctx.sessionWebhook && cardAtSenderText) {
184
+ try {
185
+ await sendBySession(ctx.config, ctx.sessionWebhook, cardAtSenderText, {
186
+ atUserId: ctx.senderId,
187
+ log,
188
+ });
189
+ } catch (atErr: unknown) {
190
+ const msg = atErr instanceof Error ? atErr.message : String(atErr);
191
+ log?.debug?.(`[DingTalk] Post-card @mention send failed: ${msg}`);
192
+ }
193
+ }
194
+ } catch (err: unknown) {
195
+ log?.debug?.(`[DingTalk] AI Card finalization failed: ${(err as Error).message}`);
196
+ const errObj = err as { response?: { data?: unknown } };
197
+ if (errObj?.response?.data !== undefined) {
198
+ log?.debug?.(formatDingTalkErrorPayloadLog("inbound.cardFinalize", errObj.response.data));
199
+ }
200
+ if ((card.state as string) !== AICardStatus.FINISHED) {
201
+ card.state = AICardStatus.FAILED;
202
+ card.lastUpdated = Date.now();
203
+ }
204
+ }
205
+ },
206
+
207
+ async abort(_error: Error): Promise<void> {
208
+ if (!isCardInTerminalState(card.state)) {
209
+ controller.stop();
210
+ await controller.waitForInFlight();
211
+ try {
212
+ await finishAICard(card, "❌ 处理失败", log);
213
+ } catch (cardCloseErr: unknown) {
214
+ log?.debug?.(`[DingTalk] Failed to finalize card after dispatch error: ${(cardCloseErr as Error).message}`);
215
+ card.state = AICardStatus.FAILED;
216
+ card.lastUpdated = Date.now();
217
+ }
218
+ }
219
+ },
220
+
221
+ getFinalText(): string | undefined {
222
+ return controller.getLastAnswerContent() || finalTextForFallback;
223
+ },
224
+ };
225
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Markdown / text reply strategy.
3
+ *
4
+ * Buffers all blocks (disableBlockStreaming=true) and delivers the
5
+ * final text as a single message via sendMessage.
6
+ */
7
+
8
+ import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
9
+ import { sendMessage } from "./send-service";
10
+
11
+ export function createMarkdownReplyStrategy(
12
+ ctx: ReplyStrategyContext,
13
+ ): ReplyStrategy {
14
+ let finalText: string | undefined;
15
+
16
+ return {
17
+ getReplyOptions(): ReplyOptions {
18
+ return { disableBlockStreaming: true };
19
+ },
20
+
21
+ async deliver(payload: DeliverPayload): Promise<void> {
22
+ if (payload.mediaUrls.length > 0) {
23
+ await ctx.deliverMedia(payload.mediaUrls);
24
+ }
25
+
26
+ if (payload.kind === "final" && typeof payload.text === "string" && payload.text.length > 0) {
27
+ finalText = payload.text;
28
+ const sendResult = await sendMessage(ctx.config, ctx.to, payload.text, {
29
+ sessionWebhook: ctx.sessionWebhook,
30
+ atUserId: !ctx.isDirect ? ctx.senderId : null,
31
+ log: ctx.log,
32
+ accountId: ctx.accountId,
33
+ storePath: ctx.storePath,
34
+ conversationId: ctx.groupId,
35
+ quotedRef: ctx.replyQuotedRef,
36
+ });
37
+ if (!sendResult.ok) {
38
+ throw new Error(sendResult.error || "Reply send failed");
39
+ }
40
+ }
41
+ },
42
+
43
+ async finalize(): Promise<void> {
44
+ // Markdown mode: delivery already happened in deliver(final).
45
+ },
46
+
47
+ async abort(): Promise<void> {
48
+ // Nothing to clean up.
49
+ },
50
+
51
+ getFinalText(): string | undefined {
52
+ return finalText;
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Dynamic ack-reaction decorator for ReplyStrategy.
3
+ *
4
+ * When ackReaction is set to "emoji", wraps an inner strategy to switch
5
+ * the native ack-reaction emoji based on runtime tool execution events.
6
+ *
7
+ * Usage:
8
+ * let strategy = createReplyStrategy({ ... });
9
+ * if (ackReaction === "emoji" && runtimeEvents) {
10
+ * strategy = withDynamicReaction(strategy, { ... });
11
+ * }
12
+ *
13
+ * This decorator:
14
+ * - Subscribes to rt.events.onAgentEvent before dispatch
15
+ * - Maps tool_execution_start events to emoji via resolveToolReactionEmoji
16
+ * - Recalls the previous reaction and attaches the new one
17
+ * - Fires a heartbeat reaction if no tool event arrives for 55+ seconds
18
+ * - On dispose: recalls the current reaction and notifies the caller via
19
+ * onReactionDisposed so the finally block does not double-recall
20
+ * - Cleans up (unsubscribe + clear timer) on finalize/abort
21
+ *
22
+ * Important: the subscription MUST filter by runId or sessionKey to avoid
23
+ * cross-session reaction contamination when multiple messages are processed
24
+ * concurrently. The `sessionFilter` parameter is required for this reason.
25
+ */
26
+
27
+ import type { DeliverPayload, ReplyOptions, ReplyStrategy } from "./reply-strategy";
28
+ import type { DingTalkConfig, Logger } from "./types";
29
+
30
+ const TOOL_REACTION_SILENCE_MS = 55_000;
31
+ const TOOL_HEARTBEAT_INTERVAL_MS = 60_000;
32
+ const TOOL_HEARTBEAT_REACTION = "⏳";
33
+
34
+ export interface DynamicReactionParams {
35
+ config: DingTalkConfig;
36
+ msgId: string;
37
+ conversationId?: string;
38
+ initialReaction: string;
39
+ /** Only process events matching this predicate (session isolation). */
40
+ sessionFilter: (event: unknown) => boolean;
41
+ onAttachReaction: (reactionName: string) => Promise<boolean>;
42
+ onRecallReaction: (reactionName: string) => Promise<void>;
43
+ subscribeAgentEvents: (listener: (event: unknown) => void) => () => void;
44
+ /**
45
+ * Called when the decorator recalls its current reaction during dispose.
46
+ * The caller should set ackReactionAttached=false so the finally block
47
+ * does not attempt to recall the same (already-removed) reaction.
48
+ */
49
+ onReactionDisposed?: () => void;
50
+ log?: Logger;
51
+ }
52
+
53
+ function resolveToolReactionEmoji(toolName: unknown): string {
54
+ const name = typeof toolName === "string" ? toolName.trim().toLowerCase() : "";
55
+ switch (name) {
56
+ case "bash":
57
+ case "exec":
58
+ case "process":
59
+ return "🛠️";
60
+ case "read":
61
+ case "view":
62
+ return "📂";
63
+ case "write":
64
+ case "edit":
65
+ case "patch":
66
+ return "✍️";
67
+ case "web_search":
68
+ case "search":
69
+ case "browser.search":
70
+ case "browser_search":
71
+ return "🌐";
72
+ case "fetch":
73
+ case "open":
74
+ case "open_url":
75
+ case "browser.open":
76
+ case "browser_open":
77
+ return "🔗";
78
+ default:
79
+ return "🛠️";
80
+ }
81
+ }
82
+
83
+ export function withDynamicReaction(
84
+ inner: ReplyStrategy,
85
+ params: DynamicReactionParams,
86
+ ): ReplyStrategy {
87
+ const { log } = params;
88
+ let currentReaction = params.initialReaction;
89
+ let reactionChanged = false;
90
+ let disposed = false;
91
+ let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
92
+ let lastEventAt = 0;
93
+ let updateChain: Promise<void> = Promise.resolve();
94
+
95
+ const switchReaction = async (nextReaction: string) => {
96
+ if (disposed || nextReaction === currentReaction) {
97
+ return;
98
+ }
99
+ const prev = currentReaction;
100
+ await params.onRecallReaction(prev);
101
+ const ok = await params.onAttachReaction(nextReaction);
102
+ if (ok) {
103
+ currentReaction = nextReaction;
104
+ reactionChanged = true;
105
+ lastEventAt = Date.now();
106
+ }
107
+ };
108
+
109
+ const queueSwitch = (nextReaction: string) => {
110
+ updateChain = updateChain
111
+ .then(() => switchReaction(nextReaction))
112
+ .catch((err: unknown) => {
113
+ log?.warn?.(`[DingTalk] Dynamic reaction update failed: ${(err as Error).message}`);
114
+ });
115
+ };
116
+
117
+ const handleEvent = (event: unknown) => {
118
+ if (disposed) {
119
+ return;
120
+ }
121
+ if (!params.sessionFilter(event)) {
122
+ return;
123
+ }
124
+ const toolEvent = event as { stream?: string; data?: { phase?: string; name?: string } };
125
+ if (toolEvent?.stream !== "tool" || toolEvent?.data?.phase !== "start") {
126
+ return;
127
+ }
128
+ lastEventAt = Date.now();
129
+ queueSwitch(resolveToolReactionEmoji(toolEvent.data?.name));
130
+ };
131
+
132
+ const unsubscribe = params.subscribeAgentEvents(handleEvent);
133
+
134
+ heartbeatTimer = setInterval(() => {
135
+ if (disposed || lastEventAt === 0) {
136
+ return;
137
+ }
138
+ if (Date.now() - lastEventAt >= TOOL_REACTION_SILENCE_MS) {
139
+ queueSwitch(TOOL_HEARTBEAT_REACTION);
140
+ }
141
+ }, TOOL_HEARTBEAT_INTERVAL_MS);
142
+
143
+ const dispose = async () => {
144
+ if (disposed) {
145
+ return;
146
+ }
147
+ disposed = true;
148
+ unsubscribe();
149
+ if (heartbeatTimer) {
150
+ clearInterval(heartbeatTimer);
151
+ heartbeatTimer = undefined;
152
+ }
153
+ // Wait for any in-flight reaction update to finish.
154
+ await updateChain.catch(() => undefined);
155
+ // Recall whatever reaction is currently displayed, then notify the
156
+ // caller so the finally block does not double-recall.
157
+ if (reactionChanged) {
158
+ try {
159
+ await params.onRecallReaction(currentReaction);
160
+ params.onReactionDisposed?.();
161
+ } catch (err: unknown) {
162
+ log?.warn?.(`[DingTalk] Failed to recall reaction on dispose: ${(err as Error).message}`);
163
+ }
164
+ }
165
+ };
166
+
167
+ return {
168
+ getReplyOptions(): ReplyOptions {
169
+ return inner.getReplyOptions();
170
+ },
171
+
172
+ async deliver(payload: DeliverPayload): Promise<void> {
173
+ await inner.deliver(payload);
174
+ },
175
+
176
+ async finalize(): Promise<void> {
177
+ await dispose();
178
+ await inner.finalize();
179
+ },
180
+
181
+ async abort(_error: Error): Promise<void> {
182
+ await dispose();
183
+ await inner.abort(_error);
184
+ },
185
+
186
+ getFinalText(): string | undefined {
187
+ return inner.getFinalText();
188
+ },
189
+ };
190
+ }