@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,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> {