@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.
- package/README.md +141 -12
- package/package.json +2 -2
- package/src/access-control.ts +65 -0
- package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
- package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
- package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
- package/src/ack-reaction-classifier.ts +17 -4
- package/src/ack-reaction-service.ts +66 -19
- package/src/attachment-text-extractor.ts +2 -1
- package/src/card-service.ts +145 -257
- package/src/channel.ts +60 -28
- package/src/config-schema.ts +28 -6
- package/src/config.ts +29 -5
- package/src/inbound-handler.ts +694 -520
- package/src/message-context-store.ts +787 -0
- package/src/message-utils.ts +221 -42
- package/src/messaging/quoted-context.ts +269 -0
- package/src/messaging/quoted-ref.ts +97 -0
- package/src/onboarding.ts +62 -5
- package/src/reply-strategy-card.ts +225 -0
- package/src/reply-strategy-markdown.ts +55 -0
- package/src/reply-strategy-with-reaction.ts +190 -0
- package/src/reply-strategy.ts +72 -0
- package/src/send-service.ts +140 -52
- package/src/targeting/agent-name-matcher.ts +148 -0
- package/src/targeting/agent-routing.ts +181 -0
- package/src/targeting/target-directory-adapter.ts +151 -0
- package/src/targeting/target-directory-store.ts +396 -0
- package/src/targeting/target-input.ts +62 -0
- package/src/types.ts +114 -9
- package/src/quote-journal.ts +0 -242
- package/src/quoted-msg-cache.ts +0 -226
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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:
|
|
17
|
+
sourceType: AttachmentTextSource;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
async function isFileTooLarge(filePath: string): Promise<boolean> {
|