@soimy/dingtalk 3.3.0 → 3.4.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.
Files changed (36) hide show
  1. package/README.md +141 -12
  2. package/index.ts +71 -66
  3. package/package.json +6 -5
  4. package/src/access-control.ts +65 -0
  5. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  8. package/src/ack-reaction-classifier.ts +17 -4
  9. package/src/ack-reaction-service.ts +66 -19
  10. package/src/attachment-text-extractor.ts +2 -1
  11. package/src/card-service.ts +145 -257
  12. package/src/channel.ts +106 -47
  13. package/src/config-schema.ts +28 -6
  14. package/src/config.ts +30 -6
  15. package/src/connection-manager.ts +16 -5
  16. package/src/inbound-handler.ts +694 -520
  17. package/src/media-utils.ts +99 -36
  18. package/src/message-context-store.ts +787 -0
  19. package/src/message-utils.ts +221 -42
  20. package/src/messaging/quoted-context.ts +269 -0
  21. package/src/messaging/quoted-ref.ts +97 -0
  22. package/src/onboarding.ts +381 -269
  23. package/src/reply-strategy-card.ts +225 -0
  24. package/src/reply-strategy-markdown.ts +55 -0
  25. package/src/reply-strategy-with-reaction.ts +190 -0
  26. package/src/reply-strategy.ts +72 -0
  27. package/src/runtime.ts +5 -7
  28. package/src/send-service.ts +164 -62
  29. package/src/targeting/agent-name-matcher.ts +148 -0
  30. package/src/targeting/agent-routing.ts +181 -0
  31. package/src/targeting/target-directory-adapter.ts +152 -0
  32. package/src/targeting/target-directory-store.ts +396 -0
  33. package/src/targeting/target-input.ts +62 -0
  34. package/src/types.ts +124 -21
  35. package/src/quote-journal.ts +0 -242
  36. package/src/quoted-msg-cache.ts +0 -226
@@ -0,0 +1,271 @@
1
+ import { attachNativeAckReaction, recallNativeAckReactionWithRetry } from "../ack-reaction-service";
2
+ import type { DingTalkConfig } from "../types";
3
+ import { getErrorMessage } from "../utils";
4
+ import {
5
+ createDynamicAckReactionCorrelator,
6
+ describeEvent,
7
+ type DynamicAckReactionLogger,
8
+ type RuntimeAgentEvent,
9
+ type RuntimeEventsSurface,
10
+ } from "./dynamic-ack-reaction-events";
11
+ import { resolveToolProgressReaction } from "./dynamic-ack-reaction-progress";
12
+
13
+ type DynamicAckReactionControllerParams = {
14
+ enabled: boolean;
15
+ initialReaction: string;
16
+ initialAttached: boolean;
17
+ initialAttachedAt: number;
18
+ dingtalkConfig: DingTalkConfig;
19
+ msgId: string;
20
+ conversationId: string;
21
+ sessionKey: string;
22
+ log?: DynamicAckReactionLogger;
23
+ runtimeEvents?: RuntimeEventsSurface;
24
+ onReactionDisposed?: () => void;
25
+ };
26
+
27
+ const TOOL_REACTION_SILENCE_MS = 55_000;
28
+ const TOOL_REACTION_HEARTBEAT_INTERVAL_MS = 60_000;
29
+ const DYNAMIC_REACTION_MIN_SWITCH_INTERVAL_MS = 5_000;
30
+ const OPTIMISTIC_RUN_ID_CAPTURE_WINDOW_MS = 5_000;
31
+ const TOOL_HEARTBEAT_REACTION = "⏳";
32
+
33
+ export function createDynamicAckReactionController(params: DynamicAckReactionControllerParams) {
34
+ let dynamicReactionStartedAt = 0;
35
+ let lastDynamicReactionAt = 0;
36
+ let currentAckReaction = params.initialReaction;
37
+ let ackReactionAttached = params.initialAttached;
38
+ let ackReactionAttachedAt = params.initialAttachedAt;
39
+ let progressHeartbeatInFlight = false;
40
+ let progressHeartbeatTimer: NodeJS.Timeout | undefined;
41
+ let dynamicReactionUpdatePromise: Promise<void> = Promise.resolve();
42
+ let lastDynamicReactionSwitchAt = 0;
43
+ let disposed = false;
44
+ const createdAt = Date.now();
45
+ const isCorrelatedEvent = createDynamicAckReactionCorrelator({
46
+ sessionKey: params.sessionKey,
47
+ enabled: params.enabled,
48
+ createdAt,
49
+ optimisticCaptureWindowMs: OPTIMISTIC_RUN_ID_CAPTURE_WINDOW_MS,
50
+ log: params.log,
51
+ });
52
+
53
+ const updateDynamicAckReaction = async (nextReaction: string) => {
54
+ const normalizedReaction = typeof nextReaction === "string" ? nextReaction.trim() : "";
55
+ if (disposed || !normalizedReaction || !params.enabled || !ackReactionAttached) {
56
+ params.log?.debug?.(
57
+ `[DingTalk] Dynamic ack reaction update skipped reaction=${normalizedReaction || "-"} ` +
58
+ `enabled=${params.enabled} ackReactionAttached=${ackReactionAttached} disposed=${disposed}`,
59
+ );
60
+ return;
61
+ }
62
+ if (normalizedReaction === currentAckReaction) {
63
+ params.log?.debug?.(
64
+ `[DingTalk] Dynamic ack reaction update skipped because reaction is unchanged: ${normalizedReaction}`,
65
+ );
66
+ if (dynamicReactionStartedAt === 0) {
67
+ dynamicReactionStartedAt = Date.now();
68
+ }
69
+ lastDynamicReactionAt = Date.now();
70
+ return;
71
+ }
72
+ if (
73
+ lastDynamicReactionSwitchAt > 0
74
+ && Date.now() - lastDynamicReactionSwitchAt < DYNAMIC_REACTION_MIN_SWITCH_INTERVAL_MS
75
+ ) {
76
+ params.log?.debug?.(
77
+ `[DingTalk] Dynamic ack reaction update throttled previous=${currentAckReaction} next=${normalizedReaction} ` +
78
+ `minIntervalMs=${DYNAMIC_REACTION_MIN_SWITCH_INTERVAL_MS}`,
79
+ );
80
+ return;
81
+ }
82
+
83
+ const previousReaction = currentAckReaction;
84
+ ackReactionAttached = false;
85
+ await recallNativeAckReactionWithRetry(
86
+ params.dingtalkConfig,
87
+ {
88
+ msgId: params.msgId,
89
+ conversationId: params.conversationId,
90
+ reactionName: previousReaction,
91
+ },
92
+ params.log,
93
+ );
94
+
95
+ const attached = await attachNativeAckReaction(
96
+ params.dingtalkConfig,
97
+ {
98
+ msgId: params.msgId,
99
+ conversationId: params.conversationId,
100
+ reactionName: normalizedReaction,
101
+ },
102
+ params.log,
103
+ );
104
+ if (!attached) {
105
+ if (disposed) {
106
+ return;
107
+ }
108
+ params.log?.debug?.(
109
+ `[DingTalk] Dynamic ack reaction attach did not succeed for reaction=${normalizedReaction}; restoring previous reaction=${previousReaction}`,
110
+ );
111
+ const restored = await attachNativeAckReaction(
112
+ params.dingtalkConfig,
113
+ {
114
+ msgId: params.msgId,
115
+ conversationId: params.conversationId,
116
+ reactionName: previousReaction,
117
+ },
118
+ params.log,
119
+ );
120
+ ackReactionAttached = restored;
121
+ if (restored) {
122
+ currentAckReaction = previousReaction;
123
+ ackReactionAttachedAt = Date.now();
124
+ if (dynamicReactionStartedAt === 0) {
125
+ dynamicReactionStartedAt = ackReactionAttachedAt;
126
+ }
127
+ lastDynamicReactionAt = ackReactionAttachedAt;
128
+ }
129
+ return;
130
+ }
131
+
132
+ params.log?.debug?.(`[DingTalk] Dynamic ack reaction switched to ${normalizedReaction}`);
133
+ ackReactionAttached = true;
134
+ currentAckReaction = normalizedReaction;
135
+ ackReactionAttachedAt = Date.now();
136
+ lastDynamicReactionSwitchAt = ackReactionAttachedAt;
137
+ if (dynamicReactionStartedAt === 0) {
138
+ dynamicReactionStartedAt = ackReactionAttachedAt;
139
+ }
140
+ lastDynamicReactionAt = ackReactionAttachedAt;
141
+ };
142
+
143
+ const queueDynamicAckReactionUpdate = (nextReaction: string) => {
144
+ if (disposed) {
145
+ return dynamicReactionUpdatePromise;
146
+ }
147
+ params.log?.debug?.(
148
+ `[DingTalk] Queue dynamic ack reaction update ${currentAckReaction || "-"} -> ${nextReaction || "-"}`,
149
+ );
150
+ dynamicReactionUpdatePromise = dynamicReactionUpdatePromise
151
+ .then(() => updateDynamicAckReaction(nextReaction))
152
+ .catch((err: unknown) => {
153
+ params.log?.warn?.(`[DingTalk] Dynamic ack reaction update failed: ${getErrorMessage(err)}`);
154
+ });
155
+ return dynamicReactionUpdatePromise;
156
+ };
157
+
158
+ const handleAgentEvent = async (event: unknown) => {
159
+ const agentEvent = event as RuntimeAgentEvent | undefined;
160
+ if (!params.enabled || disposed) {
161
+ return;
162
+ }
163
+ params.log?.debug?.(`[DingTalk] Dynamic reaction observed agent event ${describeEvent(agentEvent)}`);
164
+ if (agentEvent?.stream === "lifecycle" && agentEvent.data?.phase === "start") {
165
+ void isCorrelatedEvent(agentEvent);
166
+ return;
167
+ }
168
+ if (agentEvent?.stream !== "tool" || agentEvent.data?.phase !== "start") {
169
+ return;
170
+ }
171
+ if (!isCorrelatedEvent(agentEvent)) {
172
+ params.log?.debug?.(
173
+ `[DingTalk] Dynamic reaction ignored uncorrelated tool event ${describeEvent(agentEvent)}`,
174
+ );
175
+ return;
176
+ }
177
+ const toolCallId = typeof agentEvent.data?.toolCallId === "string" ? agentEvent.data.toolCallId : "-";
178
+ params.log?.debug?.(
179
+ `[DingTalk] Tool event received for dynamic ack reaction: name=${agentEvent.data?.name || "-"} toolCallId=${toolCallId}`,
180
+ );
181
+ await queueDynamicAckReactionUpdate(
182
+ resolveToolProgressReaction(agentEvent.data?.name, agentEvent.data?.args),
183
+ );
184
+ };
185
+
186
+ const unsubscribeAgentEvents = params.enabled && params.runtimeEvents?.onAgentEvent
187
+ ? params.runtimeEvents.onAgentEvent((event: unknown) => {
188
+ void handleAgentEvent(event).catch((err: unknown) => {
189
+ params.log?.warn?.(`[DingTalk] Dynamic ack reaction event handling failed: ${getErrorMessage(err)}`);
190
+ });
191
+ })
192
+ : () => {};
193
+
194
+ if (params.enabled && !params.runtimeEvents?.onAgentEvent) {
195
+ params.log?.debug?.("[DingTalk] onAgentEvent not available, dynamic reaction tracking disabled");
196
+ }
197
+
198
+ if (params.enabled) {
199
+ progressHeartbeatTimer = setInterval(() => {
200
+ if (
201
+ disposed
202
+ || !ackReactionAttached
203
+ || progressHeartbeatInFlight
204
+ || dynamicReactionStartedAt === 0
205
+ || lastDynamicReactionAt === 0
206
+ ) {
207
+ return;
208
+ }
209
+ if (Date.now() - lastDynamicReactionAt < TOOL_REACTION_SILENCE_MS) {
210
+ return;
211
+ }
212
+ params.log?.debug?.(
213
+ `[DingTalk] Dynamic ack reaction heartbeat triggered currentReaction=${currentAckReaction} ` +
214
+ `lastDynamicReactionAt=${lastDynamicReactionAt}`,
215
+ );
216
+ progressHeartbeatInFlight = true;
217
+ void queueDynamicAckReactionUpdate(TOOL_HEARTBEAT_REACTION).finally(() => {
218
+ progressHeartbeatInFlight = false;
219
+ });
220
+ }, TOOL_REACTION_HEARTBEAT_INTERVAL_MS);
221
+ }
222
+
223
+ const stop = (): void => {
224
+ if (disposed) {
225
+ return;
226
+ }
227
+ disposed = true;
228
+ unsubscribeAgentEvents();
229
+ if (progressHeartbeatTimer) {
230
+ clearInterval(progressHeartbeatTimer);
231
+ progressHeartbeatTimer = undefined;
232
+ }
233
+ };
234
+
235
+ return {
236
+ async awaitDrain(): Promise<void> {
237
+ await dynamicReactionUpdatePromise.catch(() => undefined);
238
+ },
239
+ async dispose(minVisibleMs: number): Promise<void> {
240
+ stop();
241
+ await this.awaitDrain();
242
+ if (!ackReactionAttached) {
243
+ params.onReactionDisposed?.();
244
+ return;
245
+ }
246
+ try {
247
+ const shouldRespectMinVisible = params.enabled && dynamicReactionStartedAt > 0;
248
+ const elapsedMs = ackReactionAttachedAt > 0 ? Date.now() - ackReactionAttachedAt : 0;
249
+ const remainingVisibleMs = shouldRespectMinVisible ? minVisibleMs - elapsedMs : 0;
250
+ if (remainingVisibleMs > 0) {
251
+ await new Promise(resolve => setTimeout(resolve, remainingVisibleMs));
252
+ }
253
+ await recallNativeAckReactionWithRetry(
254
+ params.dingtalkConfig,
255
+ {
256
+ msgId: params.msgId,
257
+ conversationId: params.conversationId,
258
+ reactionName: currentAckReaction,
259
+ },
260
+ params.log,
261
+ );
262
+ } catch (err: unknown) {
263
+ params.log?.warn?.(`[DingTalk] Dynamic ack reaction dispose recall failed: ${getErrorMessage(err)}`);
264
+ } finally {
265
+ ackReactionAttached = false;
266
+ params.onReactionDisposed?.();
267
+ }
268
+ },
269
+ stop,
270
+ };
271
+ }
@@ -0,0 +1,123 @@
1
+ import { getErrorMessage } from "../utils";
2
+
3
+ export type DynamicAckReactionLogger = {
4
+ debug?: (msg: string) => void;
5
+ info?: (msg: string) => void;
6
+ warn?: (msg: string) => void;
7
+ };
8
+
9
+ export type RuntimeAgentEvent = {
10
+ stream?: string;
11
+ runId?: string;
12
+ sessionKey?: string;
13
+ data?: {
14
+ phase?: string;
15
+ name?: string;
16
+ args?: unknown;
17
+ runId?: string;
18
+ sessionKey?: string;
19
+ toolCallId?: string;
20
+ meta?: {
21
+ runId?: string;
22
+ sessionKey?: string;
23
+ } | null;
24
+ };
25
+ };
26
+
27
+ export type RuntimeEventsSurface = {
28
+ onAgentEvent?: (listener: (event: unknown) => void) => (() => void);
29
+ };
30
+
31
+ function firstTrimmedString(...values: unknown[]): string | undefined {
32
+ for (const value of values) {
33
+ if (typeof value === "string" && value.trim()) {
34
+ return value.trim();
35
+ }
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ export function getEventRunId(event: RuntimeAgentEvent | undefined): string | undefined {
41
+ return firstTrimmedString(event?.runId, event?.data?.runId, event?.data?.meta?.runId);
42
+ }
43
+
44
+ export function getEventSessionKey(event: RuntimeAgentEvent | undefined): string | undefined {
45
+ return firstTrimmedString(event?.sessionKey, event?.data?.sessionKey, event?.data?.meta?.sessionKey);
46
+ }
47
+
48
+ export function describeEvent(event: RuntimeAgentEvent | undefined): string {
49
+ const stream = firstTrimmedString(event?.stream) || "-";
50
+ const phase = firstTrimmedString(event?.data?.phase) || "-";
51
+ const toolName = firstTrimmedString(event?.data?.name) || "-";
52
+ const toolCallId = firstTrimmedString(event?.data?.toolCallId) || "-";
53
+ return `stream=${stream} phase=${phase} runId=${getEventRunId(event) || "-"} ` +
54
+ `sessionKey=${getEventSessionKey(event) || "-"} toolCallId=${toolCallId} toolName=${toolName}`;
55
+ }
56
+
57
+ export function createDynamicAckReactionCorrelator(params: {
58
+ sessionKey: string;
59
+ enabled: boolean;
60
+ createdAt: number;
61
+ optimisticCaptureWindowMs: number;
62
+ log?: DynamicAckReactionLogger;
63
+ }) {
64
+ let activeRunId: string | undefined;
65
+ let correlationUnavailableLogged = false;
66
+ let optimisticCaptureCount = 0;
67
+
68
+ return (event: RuntimeAgentEvent | undefined): boolean => {
69
+ const eventRunId = getEventRunId(event);
70
+ const eventSessionKey = getEventSessionKey(event);
71
+ const eventStream = firstTrimmedString(event?.stream) || "";
72
+ const eventPhase = firstTrimmedString(event?.data?.phase) || "";
73
+
74
+ if (activeRunId) {
75
+ const matched = eventRunId === activeRunId;
76
+ params.log?.debug?.(
77
+ `[DingTalk] Dynamic reaction correlation by runId matched=${matched} activeRunId=${activeRunId} ` +
78
+ `eventRunId=${eventRunId || "-"} eventSessionKey=${eventSessionKey || "-"}`,
79
+ );
80
+ return matched;
81
+ }
82
+
83
+ if (eventSessionKey === params.sessionKey) {
84
+ if (eventRunId) {
85
+ activeRunId = eventRunId;
86
+ params.log?.debug?.(
87
+ `[DingTalk] Dynamic reaction captured active runId=${activeRunId} from sessionKey=${params.sessionKey}`,
88
+ );
89
+ } else {
90
+ params.log?.debug?.(
91
+ `[DingTalk] Dynamic reaction correlated by sessionKey=${params.sessionKey} without runId`,
92
+ );
93
+ }
94
+ return true;
95
+ }
96
+
97
+ if (
98
+ optimisticCaptureCount === 0
99
+ && eventStream === "lifecycle"
100
+ && eventPhase === "start"
101
+ && eventRunId
102
+ && !eventSessionKey
103
+ && Date.now() - params.createdAt <= params.optimisticCaptureWindowMs
104
+ ) {
105
+ optimisticCaptureCount += 1;
106
+ activeRunId = eventRunId;
107
+ params.log?.debug?.(
108
+ `[DingTalk] Dynamic reaction optimistically captured active runId=${activeRunId} ` +
109
+ `from first lifecycle event without sessionKey windowMs=${params.optimisticCaptureWindowMs}`,
110
+ );
111
+ return true;
112
+ }
113
+
114
+ if (!correlationUnavailableLogged && params.enabled) {
115
+ correlationUnavailableLogged = true;
116
+ params.log?.debug?.(
117
+ `[DingTalk] Dynamic reaction ignored uncorrelated agent events; ` +
118
+ `reason=${getErrorMessage(eventRunId || eventSessionKey || "waiting for sessionKey/runId match")}`,
119
+ );
120
+ }
121
+ return false;
122
+ };
123
+ }
@@ -0,0 +1,59 @@
1
+ function readToolArgString(args: unknown, keys: string[]): string | undefined {
2
+ if (!args || typeof args !== "object") {
3
+ return undefined;
4
+ }
5
+ const record = args as Record<string, unknown>;
6
+ for (const key of keys) {
7
+ const value = record[key];
8
+ if (typeof value === "string" && value.trim()) {
9
+ return value.trim();
10
+ }
11
+ }
12
+ return undefined;
13
+ }
14
+
15
+ /**
16
+ * Tool names here come from the runtime `onAgentEvent` tool-start payload.
17
+ * The runtime does not expose a typed enum on this event surface yet,
18
+ * so this mapping is intentionally best-effort and keeps a safe default.
19
+ */
20
+ export function resolveToolProgressReaction(toolName: unknown, args: unknown): string {
21
+ const normalizedToolName = typeof toolName === "string" ? toolName.trim().toLowerCase() : "";
22
+ switch (normalizedToolName) {
23
+ case "bash":
24
+ case "exec":
25
+ case "process": {
26
+ const command = readToolArgString(args, ["command", "cmd"]);
27
+ if (!command) {
28
+ return "🛠️";
29
+ }
30
+ if (/\bbrew\s+install\s+/i.test(command) || /\b(?:pnpm|npm|yarn)\s+(?:add|install)\s+/i.test(command)) {
31
+ return "📦";
32
+ }
33
+ if (/\bwhich\s+/i.test(command)) {
34
+ return "🔍";
35
+ }
36
+ return "🛠️";
37
+ }
38
+ case "read":
39
+ case "view":
40
+ return "📂";
41
+ case "write":
42
+ case "edit":
43
+ case "patch":
44
+ return "✍️";
45
+ case "web_search":
46
+ case "search":
47
+ case "browser.search":
48
+ case "browser_search":
49
+ return "🌐";
50
+ case "fetch":
51
+ case "open":
52
+ case "open_url":
53
+ case "browser.open":
54
+ case "browser_open":
55
+ return "🔗";
56
+ default:
57
+ return "🛠️";
58
+ }
59
+ }
@@ -19,11 +19,24 @@ const KEYWORDS = {
19
19
  const POLITE_EXCLUSIONS = ["别客气", "别介意", "别见怪", "别担心", "别着急"] as const;
20
20
 
21
21
  const EMOJIS: EmojiMap = {
22
- "夸奖": ["(๑•̀ㅂ•́)و✧", "(ノ≧∀≦)ノ", "٩(๑>◡<๑)۶", "(★▽★)", "(⌒▽⌒)☆", "(*≧ω≦)", "(ง •_•)ง", "ヾ(≧▽≦*)o"],
22
+ // DingTalk `emotion/reply` does not accept every visually valid kaomoji.
23
+ // Keep only the strings that survived repeated direct API retests against
24
+ // the live endpoint using a real message target. Removed as unstable/bad:
25
+ // `٩(๑>◡<๑)۶`.
26
+ "夸奖": ["(๑•̀ㅂ•́)و✧", "(ノ≧∀≦)ノ", "(★▽★)", "(⌒▽⌒)☆", "(*≧ω≦)", "(ง •_•)ง", "ヾ(≧▽≦*)o"],
23
27
  "责怪": ["(╬ Ò﹏Ó)", "(╯°□°)╯", "(▼皿▼#)", "(。•́︿•̀。)", "(╥﹏╥)", "ヽ(`Д´)ノ", "(#><)", "(;′⌒`)"],
24
- "命令": ["(¬_¬)", "(`ε´)", "(#`Д´)", "(●`∀´●)", "┌(┌ *`д´)┐", "(`д´)", "(•̀へ •́ ╮ )", "( ̄ω ̄;)"],
25
- "叙事": ["(。・ω・。)", "( ̄▽ ̄)", "(´• ω •`)", "(・・?)", "(。_。)", "( ̄ω ̄)", "(´▽`)", "(=_=)"],
26
- "请求": ["(っ´∀`)っ", "(๑•̀ω•́๑)✧", "(づ。◕‿‿◕。)づ", "(p≧w≦q)", "(♡˙︶˙♡)", "( ⁄•⁄ω⁄•⁄ ⁄)", "(´;ω;`)", "(人•ᴗ•✿)"],
28
+ // Keep command kaomoji limited to strings that passed local
29
+ // DingTalk emotion/reply verification. Removed as unstable/bad after
30
+ // repeated direct API retests: `┌(┌ *`д´)┐`, `(•̀へ •́ )`.
31
+ "命令": ["(¬_¬)", "(`ε´)", "(#`Д´)", "(●`∀´●)", "(`д´)", "( ̄ω ̄;)"],
32
+ // Narrative kaomoji must also stay within the subset accepted by
33
+ // DingTalk emotion/reply. Removed as unstable/bad after repeated direct
34
+ // API retests: `(´• ω •`)`.
35
+ "叙事": ["(。・ω・。)", "( ̄▽ ̄)", "(・・?)", "(。_。)", "( ̄ω ̄)", "(´▽`)", "(=_=)"],
36
+ // Request kaomoji must exclude strings that stayed unstable across
37
+ // repeated direct API retests. Removed as unstable/bad:
38
+ // `(づ。◕‿‿◕。)づ`, `(⁄ ⁄•⁄ω⁄•⁄ ⁄)`.
39
+ "请求": ["(っ´∀`)っ", "(๑•̀ω•́๑)✧", "(p≧w≦q)", "(♡˙︶˙♡)", "(´;ω;`)", "(人•ᴗ•✿)"],
27
40
  "未知": ["(•̀_•́)", "(;一_一)", "(???)"],
28
41
  };
29
42
 
@@ -1,13 +1,17 @@
1
1
  import axios from "axios";
2
2
  import { getAccessToken } from "./auth";
3
3
  import type { DingTalkConfig } from "./types";
4
- import { formatDingTalkErrorPayloadLog, getProxyBypassOption } from "./utils";
4
+ import { formatDingTalkErrorPayloadLog, getErrorMessage, getProxyBypassOption } from "./utils";
5
5
 
6
6
  // DingTalk currently exposes a dedicated native "thinking" reaction flow rather than
7
7
  // a generic arbitrary-emoji reaction API for this plugin path.
8
8
  const DINGTALK_NATIVE_ACK_REACTION = "🤔思考中";
9
9
  const THINKING_EMOTION_ID = "2659900";
10
10
  const THINKING_EMOTION_BACKGROUND_ID = "im_bg_1";
11
+ // DingTalk `emotion/reply` occasionally races with just-arrived inbound
12
+ // messages or returns transient 5xx responses. Keep the first attempt
13
+ // immediate, then retry twice with short backoff windows before giving up.
14
+ const THINKING_REACTION_ATTACH_DELAYS_MS = [0, 400, 1200] as const;
11
15
  const THINKING_REACTION_RECALL_DELAYS_MS = [0, 1500, 5000] as const;
12
16
 
13
17
  type AckReactionLogger = {
@@ -23,6 +27,14 @@ type AckReactionTarget = {
23
27
  reactionName?: string;
24
28
  };
25
29
 
30
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
31
+ return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
32
+ }
33
+
34
+ function formatAckReactionTarget(data: AckReactionTarget): string {
35
+ return `msgId=${data.msgId || "-"} conversationId=${data.conversationId || "-"} reactionName=${data.reactionName || DINGTALK_NATIVE_ACK_REACTION}`;
36
+ }
37
+
26
38
  function resolveAckReactionPayload(config: DingTalkConfig, data: AckReactionTarget): {
27
39
  robotCode: string;
28
40
  reactionName: string;
@@ -44,10 +56,10 @@ async function callEmotionApi(
44
56
  errorLogPrefix: string,
45
57
  errorPayloadKey: "inbound.ackReactionAttach" | "inbound.ackReactionRecall",
46
58
  log?: AckReactionLogger,
47
- ): Promise<boolean> {
59
+ ): Promise<{ ok: boolean; error?: unknown }> {
48
60
  const payload = resolveAckReactionPayload(config, data);
49
61
  if (!payload) {
50
- return false;
62
+ return { ok: false };
51
63
  }
52
64
 
53
65
  try {
@@ -77,14 +89,29 @@ async function callEmotionApi(
77
89
  },
78
90
  );
79
91
  log?.info?.(successLog);
80
- return true;
81
- } catch (err: any) {
82
- log?.warn?.(`${errorLogPrefix}: ${err.message}`);
83
- if (err?.response?.data !== undefined) {
84
- log?.warn?.(formatDingTalkErrorPayloadLog(errorPayloadKey, err.response.data));
92
+ return { ok: true };
93
+ } catch (err: unknown) {
94
+ const response = asRecord(asRecord(err)?.response);
95
+ log?.warn?.(`${errorLogPrefix}: ${getErrorMessage(err)}`);
96
+ if (response?.data !== undefined) {
97
+ log?.warn?.(formatDingTalkErrorPayloadLog(errorPayloadKey, response.data));
85
98
  }
86
- return false;
99
+ return { ok: false, error: err };
100
+ }
101
+ }
102
+
103
+ function isRetryableEmotionApiError(err: unknown): boolean {
104
+ const response = asRecord(asRecord(err)?.response);
105
+ const data = asRecord(response?.data);
106
+ const status = Number(response?.status ?? 0);
107
+ const errorCode = String(data?.code || "").trim().toLowerCase();
108
+ if (!response) {
109
+ return true;
110
+ }
111
+ if (status >= 500) {
112
+ return true;
87
113
  }
114
+ return errorCode === "system.err";
88
115
  }
89
116
 
90
117
  export async function attachNativeAckReaction(
@@ -92,15 +119,34 @@ export async function attachNativeAckReaction(
92
119
  data: AckReactionTarget,
93
120
  log?: AckReactionLogger,
94
121
  ): Promise<boolean> {
95
- return callEmotionApi(
96
- config,
97
- data,
98
- "reply",
99
- "[DingTalk] Native ack reaction attach succeeded",
100
- "[DingTalk] Native ack reaction attach failed",
101
- "inbound.ackReactionAttach",
102
- log,
103
- );
122
+ for (let index = 0; index < THINKING_REACTION_ATTACH_DELAYS_MS.length; index += 1) {
123
+ const delayMs = THINKING_REACTION_ATTACH_DELAYS_MS[index];
124
+ if (delayMs > 0) {
125
+ await new Promise(resolve => setTimeout(resolve, delayMs));
126
+ }
127
+ const attempt = index + 1;
128
+ const attemptLabel = `${attempt}/${THINKING_REACTION_ATTACH_DELAYS_MS.length}`;
129
+ const result = await callEmotionApi(
130
+ config,
131
+ data,
132
+ "reply",
133
+ `[DingTalk] Native ack reaction attach succeeded (${formatAckReactionTarget(data)} attempt=${attemptLabel})`,
134
+ `[DingTalk] Native ack reaction attach failed (${formatAckReactionTarget(data)} attempt=${attemptLabel})`,
135
+ "inbound.ackReactionAttach",
136
+ log,
137
+ );
138
+ if (result.ok) {
139
+ return true;
140
+ }
141
+ const shouldRetry = isRetryableEmotionApiError(result.error);
142
+ if (!shouldRetry || attempt === THINKING_REACTION_ATTACH_DELAYS_MS.length) {
143
+ break;
144
+ }
145
+ log?.debug?.(
146
+ `[DingTalk] Retrying native ack reaction attach (${formatAckReactionTarget(data)} nextAttempt=${attempt + 1}/${THINKING_REACTION_ATTACH_DELAYS_MS.length})`,
147
+ );
148
+ }
149
+ return false;
104
150
  }
105
151
 
106
152
  async function recallNativeAckReaction(
@@ -108,7 +154,7 @@ async function recallNativeAckReaction(
108
154
  data: AckReactionTarget,
109
155
  log?: AckReactionLogger,
110
156
  ): Promise<boolean> {
111
- return callEmotionApi(
157
+ const result = await callEmotionApi(
112
158
  config,
113
159
  data,
114
160
  "recall",
@@ -117,6 +163,7 @@ async function recallNativeAckReaction(
117
163
  "inbound.ackReactionRecall",
118
164
  log,
119
165
  );
166
+ return result.ok;
120
167
  }
121
168
 
122
169
  export async function recallNativeAckReactionWithRetry(
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import type { AttachmentTextSource } from "./types";
3
4
 
4
5
  const MAX_EXTRACTED_TEXT_CHARS = 6000;
5
6
  const MAX_ATTACHMENT_EXTRACT_BYTES = 2 * 1024 * 1024;
@@ -13,7 +14,7 @@ export interface AttachmentTextExtractionInput {
13
14
  export interface AttachmentTextExtractionResult {
14
15
  text: string;
15
16
  truncated: boolean;
16
- sourceType: "text" | "html" | "pdf" | "docx";
17
+ sourceType: AttachmentTextSource;
17
18
  }
18
19
 
19
20
  async function isFileTooLarge(filePath: string): Promise<boolean> {