@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.
- package/README.md +141 -12
- package/index.ts +71 -66
- package/package.json +6 -5
- 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 +106 -47
- package/src/config-schema.ts +28 -6
- package/src/config.ts +30 -6
- package/src/connection-manager.ts +16 -5
- package/src/inbound-handler.ts +694 -520
- package/src/media-utils.ts +99 -36
- 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 +381 -269
- 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/runtime.ts +5 -7
- package/src/send-service.ts +164 -62
- package/src/targeting/agent-name-matcher.ts +148 -0
- package/src/targeting/agent-routing.ts +181 -0
- package/src/targeting/target-directory-adapter.ts +152 -0
- package/src/targeting/target-directory-store.ts +396 -0
- package/src/targeting/target-input.ts +62 -0
- package/src/types.ts +124 -21
- package/src/quote-journal.ts +0 -242
- 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
|
-
|
|
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> {
|