@soimy/dingtalk 3.2.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/LICENSE +21 -0
- package/README.md +796 -42
- package/index.ts +62 -0
- package/package.json +4 -2
- package/src/access-control.ts +83 -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 +75 -0
- package/src/ack-reaction-service.ts +182 -0
- package/src/attachment-text-extractor.ts +148 -0
- package/src/card-callback-service.ts +119 -0
- package/src/card-draft-controller.ts +114 -0
- package/src/card-service.ts +666 -26
- package/src/channel.ts +455 -150
- package/src/config-schema.ts +64 -6
- package/src/config.ts +161 -5
- package/src/connection-manager.ts +354 -47
- package/src/dedup.ts +1 -0
- package/src/docs-service.ts +198 -0
- package/src/draft-stream-loop.ts +119 -0
- package/src/feedback-learning-service.ts +643 -0
- package/src/feedback-learning-store.ts +543 -0
- package/src/group-members-store.ts +48 -14
- package/src/inbound-handler.ts +1374 -259
- package/src/learning-command-service.ts +339 -0
- package/src/media-utils.ts +94 -50
- package/src/message-context-store.ts +787 -0
- package/src/message-utils.ts +487 -46
- package/src/messaging/quoted-context.ts +269 -0
- package/src/messaging/quoted-ref.ts +97 -0
- package/src/onboarding.ts +96 -1
- package/src/peer-id-registry.ts +102 -0
- package/src/persistence-store.ts +131 -0
- package/src/quoted-file-service.ts +385 -0
- 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 +267 -45
- package/src/session-command-service.ts +147 -0
- package/src/session-lock.ts +2 -0
- package/src/session-peer-store.ts +77 -0
- package/src/session-routing.ts +33 -0
- 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 +261 -28
- package/src/utils.ts +231 -12
package/index.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import * as pluginSdk from "openclaw/plugin-sdk";
|
|
2
3
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
4
|
import { dingtalkPlugin } from "./src/channel";
|
|
5
|
+
import { getConfig } from "./src/config";
|
|
6
|
+
import { appendToDoc, createDoc, DocCreateAppendError, listDocs, searchDocs } from "./src/docs-service";
|
|
4
7
|
import { setDingTalkRuntime } from "./src/runtime";
|
|
5
8
|
import type { DingtalkPluginModule } from "./src/types";
|
|
6
9
|
|
|
10
|
+
type GatewayMethodContext = Pick<
|
|
11
|
+
Parameters<Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1]>[0],
|
|
12
|
+
"params" | "respond"
|
|
13
|
+
>;
|
|
14
|
+
|
|
7
15
|
const plugin: DingtalkPluginModule = {
|
|
8
16
|
id: "dingtalk",
|
|
9
17
|
name: "DingTalk Channel",
|
|
@@ -12,6 +20,60 @@ const plugin: DingtalkPluginModule = {
|
|
|
12
20
|
register(api: OpenClawPluginApi): void {
|
|
13
21
|
setDingTalkRuntime(api.runtime);
|
|
14
22
|
api.registerChannel({ plugin: dingtalkPlugin });
|
|
23
|
+
api.registerGatewayMethod("dingtalk.docs.create", async ({ respond, params }: GatewayMethodContext) => {
|
|
24
|
+
const accountId = pluginSdk.readStringParam(params, "accountId");
|
|
25
|
+
const spaceId = pluginSdk.readStringParam(params, "spaceId", { required: true });
|
|
26
|
+
const title = pluginSdk.readStringParam(params, "title", { required: true });
|
|
27
|
+
const content = pluginSdk.readStringParam(params, "content", { allowEmpty: true });
|
|
28
|
+
const parentId = pluginSdk.readStringParam(params, "parentId");
|
|
29
|
+
const config = getConfig(api.config, accountId ?? undefined);
|
|
30
|
+
try {
|
|
31
|
+
const doc = await createDoc(
|
|
32
|
+
config,
|
|
33
|
+
spaceId,
|
|
34
|
+
title,
|
|
35
|
+
content ?? undefined,
|
|
36
|
+
api.logger,
|
|
37
|
+
parentId ?? undefined,
|
|
38
|
+
);
|
|
39
|
+
return respond(true, doc);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error instanceof DocCreateAppendError) {
|
|
42
|
+
return respond(true, {
|
|
43
|
+
partialSuccess: true,
|
|
44
|
+
initContentAppended: false,
|
|
45
|
+
docId: error.doc.docId,
|
|
46
|
+
doc: error.doc,
|
|
47
|
+
appendError: error.message,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
api.registerGatewayMethod("dingtalk.docs.append", async ({ respond, params }: GatewayMethodContext) => {
|
|
54
|
+
const accountId = pluginSdk.readStringParam(params, "accountId");
|
|
55
|
+
const docId = pluginSdk.readStringParam(params, "docId", { required: true });
|
|
56
|
+
const content = pluginSdk.readStringParam(params, "content", { required: true, allowEmpty: false });
|
|
57
|
+
const config = getConfig(api.config, accountId ?? undefined);
|
|
58
|
+
const result = await appendToDoc(config, docId, content, api.logger);
|
|
59
|
+
return respond(true, result);
|
|
60
|
+
});
|
|
61
|
+
api.registerGatewayMethod("dingtalk.docs.search", async ({ respond, params }: GatewayMethodContext) => {
|
|
62
|
+
const accountId = pluginSdk.readStringParam(params, "accountId");
|
|
63
|
+
const keyword = pluginSdk.readStringParam(params, "keyword", { required: true });
|
|
64
|
+
const spaceId = pluginSdk.readStringParam(params, "spaceId");
|
|
65
|
+
const config = getConfig(api.config, accountId ?? undefined);
|
|
66
|
+
const docs = await searchDocs(config, keyword, spaceId, api.logger);
|
|
67
|
+
return respond(true, { docs });
|
|
68
|
+
});
|
|
69
|
+
api.registerGatewayMethod("dingtalk.docs.list", async ({ respond, params }: GatewayMethodContext) => {
|
|
70
|
+
const accountId = pluginSdk.readStringParam(params, "accountId");
|
|
71
|
+
const spaceId = pluginSdk.readStringParam(params, "spaceId", { required: true });
|
|
72
|
+
const parentId = pluginSdk.readStringParam(params, "parentId");
|
|
73
|
+
const config = getConfig(api.config, accountId ?? undefined);
|
|
74
|
+
const docs = await listDocs(config, spaceId, parentId, api.logger);
|
|
75
|
+
return respond(true, { docs });
|
|
76
|
+
});
|
|
15
77
|
},
|
|
16
78
|
};
|
|
17
79
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soimy/dingtalk",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "DingTalk (钉钉) channel plugin for OpenClaw",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bot",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
22
|
"index.ts",
|
|
23
|
-
"src
|
|
23
|
+
"src/**/*.ts",
|
|
24
24
|
"openclaw.plugin.json",
|
|
25
25
|
"clawbot.plugin.json"
|
|
26
26
|
],
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
"axios": "^1.6.0",
|
|
45
45
|
"dingtalk-stream": "^2.1.4",
|
|
46
46
|
"form-data": "^4.0.0",
|
|
47
|
+
"mammoth": "^1.12.0",
|
|
48
|
+
"pdf-parse": "^2.4.5",
|
|
47
49
|
"zod": "^4.3.6"
|
|
48
50
|
},
|
|
49
51
|
"devDependencies": {
|
package/src/access-control.ts
CHANGED
|
@@ -53,3 +53,86 @@ export function isSenderGroupAllowed(params: {
|
|
|
53
53
|
}
|
|
54
54
|
return false;
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
export type GroupAccessResult = {
|
|
58
|
+
allowed: boolean;
|
|
59
|
+
reason?: "disabled" | "group_not_allowed" | "sender_not_allowed";
|
|
60
|
+
legacyFallback?: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type GroupAccessParams = {
|
|
64
|
+
groupPolicy: "open" | "allowlist" | "disabled";
|
|
65
|
+
groupId: string;
|
|
66
|
+
senderId: string;
|
|
67
|
+
groups?: Record<string, { groupAllowFrom?: string[] }>;
|
|
68
|
+
groupAllowFrom?: string[];
|
|
69
|
+
allowFrom?: string[]; // legacy fallback
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export function resolveGroupAccess(params: GroupAccessParams): GroupAccessResult {
|
|
73
|
+
const { groupPolicy, groupId, senderId, groups, groupAllowFrom, allowFrom } = params;
|
|
74
|
+
|
|
75
|
+
// Step 1: disabled → block all
|
|
76
|
+
if (groupPolicy === "disabled") {
|
|
77
|
+
return { allowed: false, reason: "disabled" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Step 2: allowlist → group ID check
|
|
81
|
+
let legacyFallback = false;
|
|
82
|
+
if (groupPolicy === "allowlist") {
|
|
83
|
+
const groupConfig = groups?.[groupId];
|
|
84
|
+
const wildcardConfig = groups?.["*"];
|
|
85
|
+
const groupInConfig = groupConfig !== undefined || wildcardConfig !== undefined;
|
|
86
|
+
|
|
87
|
+
if (!groupInConfig) {
|
|
88
|
+
// Legacy fallback: check allowFrom for group ID
|
|
89
|
+
const legacyAllow = normalizeAllowFrom(allowFrom);
|
|
90
|
+
if (isSenderGroupAllowed({ allow: legacyAllow, groupId })) {
|
|
91
|
+
legacyFallback = true;
|
|
92
|
+
// Continue to sender check below
|
|
93
|
+
} else {
|
|
94
|
+
return { allowed: false, reason: "group_not_allowed" };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Step 3: Sender check
|
|
100
|
+
// Priority: per-group > wildcard > top-level
|
|
101
|
+
const perGroupAllowFrom = groups?.[groupId]?.groupAllowFrom;
|
|
102
|
+
const wildcardAllowFrom = groups?.["*"]?.groupAllowFrom;
|
|
103
|
+
const effectiveAllowFrom = perGroupAllowFrom ?? wildcardAllowFrom ?? groupAllowFrom;
|
|
104
|
+
|
|
105
|
+
if (effectiveAllowFrom == null) {
|
|
106
|
+
return { allowed: true, legacyFallback: legacyFallback || undefined };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Empty array = block all senders (fail-closed per official design)
|
|
110
|
+
if (effectiveAllowFrom.length === 0) {
|
|
111
|
+
return { allowed: false, reason: "sender_not_allowed" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const normalized = normalizeAllowFrom(effectiveAllowFrom);
|
|
115
|
+
if (isSenderAllowed({ allow: normalized, senderId })) {
|
|
116
|
+
return { allowed: true, legacyFallback: legacyFallback || undefined };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { allowed: false, reason: "sender_not_allowed" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function isSenderOwner(params: {
|
|
123
|
+
allow: NormalizedAllowFrom;
|
|
124
|
+
senderId?: string;
|
|
125
|
+
rawSenderId?: string;
|
|
126
|
+
}): boolean {
|
|
127
|
+
const { allow, senderId, rawSenderId } = params;
|
|
128
|
+
if (!allow.hasEntries) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
if (rawSenderId && allow.entriesLower.includes(rawSenderId.toLowerCase())) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
@@ -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
|
+
}
|