@soimy/dingtalk 2.7.0 → 3.0.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 +99 -3
- package/index.ts +9 -8
- package/package.json +32 -28
- package/src/access-control.ts +55 -0
- package/src/auth.ts +47 -0
- package/src/card-service.ts +338 -0
- package/src/channel.ts +145 -1590
- package/src/config-schema.ts +7 -12
- package/src/config.ts +79 -0
- package/src/connection-manager.ts +69 -32
- package/src/dedup.ts +66 -0
- package/src/group-members-store.ts +45 -0
- package/src/inbound-handler.ts +453 -0
- package/src/logger-context.ts +17 -0
- package/src/media-utils.ts +24 -20
- package/src/message-utils.ts +156 -0
- package/src/onboarding.ts +70 -67
- package/src/peer-id-registry.ts +6 -2
- package/src/runtime.ts +2 -2
- package/src/send-service.ts +281 -0
- package/src/signature.ts +15 -0
- package/src/types.ts +45 -30
- package/src/utils.ts +29 -16
- package/clawbot.plugin.json +0 -9
- package/src/AGENTS.md +0 -63
- package/src/openclaw-channel-dingtalk.code-workspace +0 -17
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import { getAccessToken } from "./auth";
|
|
4
|
+
import {
|
|
5
|
+
deleteActiveCardByTarget,
|
|
6
|
+
getActiveCardIdByTarget,
|
|
7
|
+
getCardById,
|
|
8
|
+
isCardInTerminalState,
|
|
9
|
+
streamAICard,
|
|
10
|
+
} from "./card-service";
|
|
11
|
+
import { stripTargetPrefix } from "./config";
|
|
12
|
+
import { getLogger } from "./logger-context";
|
|
13
|
+
import { uploadMedia as uploadMediaUtil } from "./media-utils";
|
|
14
|
+
import { detectMarkdownAndExtractTitle } from "./message-utils";
|
|
15
|
+
import { resolveOriginalPeerId } from "./peer-id-registry";
|
|
16
|
+
import type {
|
|
17
|
+
AxiosResponse,
|
|
18
|
+
DingTalkConfig,
|
|
19
|
+
Logger,
|
|
20
|
+
ProactiveMessagePayload,
|
|
21
|
+
SendMessageOptions,
|
|
22
|
+
SessionWebhookResponse,
|
|
23
|
+
} from "./types";
|
|
24
|
+
import { AICardStatus } from "./types";
|
|
25
|
+
|
|
26
|
+
export { detectMediaTypeFromExtension } from "./media-utils";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Wrapper to upload media with shared getAccessToken binding.
|
|
30
|
+
*/
|
|
31
|
+
export async function uploadMedia(
|
|
32
|
+
config: DingTalkConfig,
|
|
33
|
+
mediaPath: string,
|
|
34
|
+
mediaType: "image" | "voice" | "video" | "file",
|
|
35
|
+
log?: Logger,
|
|
36
|
+
): Promise<string | null> {
|
|
37
|
+
return uploadMediaUtil(config, mediaPath, mediaType, getAccessToken, log);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function sendProactiveTextOrMarkdown(
|
|
41
|
+
config: DingTalkConfig,
|
|
42
|
+
target: string,
|
|
43
|
+
text: string,
|
|
44
|
+
options: SendMessageOptions = {},
|
|
45
|
+
): Promise<AxiosResponse> {
|
|
46
|
+
const token = await getAccessToken(config, options.log);
|
|
47
|
+
const log = options.log || getLogger();
|
|
48
|
+
|
|
49
|
+
// Support group:/user: prefix and restore original case-sensitive conversationId.
|
|
50
|
+
const { targetId, isExplicitUser } = stripTargetPrefix(target);
|
|
51
|
+
const resolvedTarget = resolveOriginalPeerId(targetId);
|
|
52
|
+
const isGroup = !isExplicitUser && resolvedTarget.startsWith("cid");
|
|
53
|
+
|
|
54
|
+
const url = isGroup
|
|
55
|
+
? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
|
|
56
|
+
: "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
|
|
57
|
+
|
|
58
|
+
const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, "OpenClaw 提醒");
|
|
59
|
+
|
|
60
|
+
log?.debug?.(
|
|
61
|
+
`[DingTalk] Sending proactive message to ${isGroup ? "group" : "user"} ${resolvedTarget} with title "${title}"`,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// DingTalk proactive API uses message templates (sampleMarkdown / sampleText).
|
|
65
|
+
const msgKey = useMarkdown ? "sampleMarkdown" : "sampleText";
|
|
66
|
+
const msgParam = useMarkdown
|
|
67
|
+
? JSON.stringify({ title, text })
|
|
68
|
+
: JSON.stringify({ content: text });
|
|
69
|
+
|
|
70
|
+
const payload: ProactiveMessagePayload = {
|
|
71
|
+
robotCode: config.robotCode || config.clientId,
|
|
72
|
+
msgKey,
|
|
73
|
+
msgParam,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (isGroup) {
|
|
77
|
+
payload.openConversationId = resolvedTarget;
|
|
78
|
+
} else {
|
|
79
|
+
payload.userIds = [resolvedTarget];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const result = await axios({
|
|
83
|
+
url,
|
|
84
|
+
method: "POST",
|
|
85
|
+
data: payload,
|
|
86
|
+
headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
|
|
87
|
+
});
|
|
88
|
+
return result.data;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function sendProactiveMedia(
|
|
92
|
+
config: DingTalkConfig,
|
|
93
|
+
target: string,
|
|
94
|
+
mediaPath: string,
|
|
95
|
+
mediaType: "image" | "voice" | "video" | "file",
|
|
96
|
+
options: SendMessageOptions & { accountId?: string } = {},
|
|
97
|
+
): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string }> {
|
|
98
|
+
const log = options.log || getLogger();
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// Upload first, then send by media_id.
|
|
102
|
+
const mediaId = await uploadMedia(config, mediaPath, mediaType, log);
|
|
103
|
+
if (!mediaId) {
|
|
104
|
+
return { ok: false, error: "Failed to upload media" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const token = await getAccessToken(config, log);
|
|
108
|
+
const { targetId, isExplicitUser } = stripTargetPrefix(target);
|
|
109
|
+
const resolvedTarget = resolveOriginalPeerId(targetId);
|
|
110
|
+
const isGroup = !isExplicitUser && resolvedTarget.startsWith("cid");
|
|
111
|
+
|
|
112
|
+
const dingtalkApi = "https://api.dingtalk.com";
|
|
113
|
+
const url = isGroup
|
|
114
|
+
? `${dingtalkApi}/v1.0/robot/groupMessages/send`
|
|
115
|
+
: `${dingtalkApi}/v1.0/robot/oToMessages/batchSend`;
|
|
116
|
+
|
|
117
|
+
// Build DingTalk template payload by media type.
|
|
118
|
+
let msgKey: string;
|
|
119
|
+
let msgParam: string;
|
|
120
|
+
|
|
121
|
+
if (mediaType === "image") {
|
|
122
|
+
msgKey = "sampleImageMsg";
|
|
123
|
+
msgParam = JSON.stringify({ photoURL: mediaId });
|
|
124
|
+
} else if (mediaType === "voice") {
|
|
125
|
+
msgKey = "sampleAudio";
|
|
126
|
+
msgParam = JSON.stringify({ mediaId, duration: "0" });
|
|
127
|
+
} else {
|
|
128
|
+
// sampleVideo requires picMediaId; fallback to sampleFile for broader compatibility.
|
|
129
|
+
const filename = path.basename(mediaPath);
|
|
130
|
+
const defaultExt = mediaType === "video" ? "mp4" : "file";
|
|
131
|
+
const ext = path.extname(mediaPath).slice(1) || defaultExt;
|
|
132
|
+
msgKey = "sampleFile";
|
|
133
|
+
msgParam = JSON.stringify({ mediaId, fileName: filename, fileType: ext });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const payload: ProactiveMessagePayload = {
|
|
137
|
+
robotCode: config.robotCode || config.clientId,
|
|
138
|
+
msgKey,
|
|
139
|
+
msgParam,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (isGroup) {
|
|
143
|
+
payload.openConversationId = resolvedTarget;
|
|
144
|
+
} else {
|
|
145
|
+
payload.userIds = [resolvedTarget];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
log?.debug?.(
|
|
149
|
+
`[DingTalk] Sending proactive ${mediaType} message to ${isGroup ? "group" : "user"} ${resolvedTarget}`,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const result = await axios({
|
|
153
|
+
url,
|
|
154
|
+
method: "POST",
|
|
155
|
+
data: payload,
|
|
156
|
+
headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const messageId = result.data?.processQueryKey || result.data?.messageId;
|
|
160
|
+
return { ok: true, data: result.data, messageId };
|
|
161
|
+
} catch (err: any) {
|
|
162
|
+
log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
|
|
163
|
+
if (axios.isAxiosError(err) && err.response) {
|
|
164
|
+
log?.error?.(`[DingTalk] Response: ${JSON.stringify(err.response.data)}`);
|
|
165
|
+
}
|
|
166
|
+
return { ok: false, error: err.message };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function sendBySession(
|
|
171
|
+
config: DingTalkConfig,
|
|
172
|
+
sessionWebhook: string,
|
|
173
|
+
text: string,
|
|
174
|
+
options: SendMessageOptions = {},
|
|
175
|
+
): Promise<AxiosResponse> {
|
|
176
|
+
const token = await getAccessToken(config, options.log);
|
|
177
|
+
const log = options.log || getLogger();
|
|
178
|
+
|
|
179
|
+
// Session webhook supports native media messages; prefer that when media info is available.
|
|
180
|
+
if (options.mediaPath && options.mediaType) {
|
|
181
|
+
const mediaId = await uploadMedia(config, options.mediaPath, options.mediaType, log);
|
|
182
|
+
if (mediaId) {
|
|
183
|
+
let body: any;
|
|
184
|
+
|
|
185
|
+
if (options.mediaType === "image") {
|
|
186
|
+
body = { msgtype: "image", image: { media_id: mediaId } };
|
|
187
|
+
} else if (options.mediaType === "voice") {
|
|
188
|
+
body = { msgtype: "voice", voice: { media_id: mediaId } };
|
|
189
|
+
} else if (options.mediaType === "video") {
|
|
190
|
+
body = { msgtype: "video", video: { media_id: mediaId } };
|
|
191
|
+
} else if (options.mediaType === "file") {
|
|
192
|
+
body = { msgtype: "file", file: { media_id: mediaId } };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (body) {
|
|
196
|
+
const result = await axios({
|
|
197
|
+
url: sessionWebhook,
|
|
198
|
+
method: "POST",
|
|
199
|
+
data: body,
|
|
200
|
+
headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
|
|
201
|
+
});
|
|
202
|
+
return result.data;
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
log?.warn?.("[DingTalk] Media upload failed, falling back to text description");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Fallback to text/markdown reply payload.
|
|
210
|
+
const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, "Clawdbot 消息");
|
|
211
|
+
|
|
212
|
+
let body: SessionWebhookResponse;
|
|
213
|
+
if (useMarkdown) {
|
|
214
|
+
let finalText = text;
|
|
215
|
+
if (options.atUserId) {
|
|
216
|
+
finalText = `${finalText} @${options.atUserId}`;
|
|
217
|
+
}
|
|
218
|
+
body = { msgtype: "markdown", markdown: { title, text: finalText } };
|
|
219
|
+
} else {
|
|
220
|
+
body = { msgtype: "text", text: { content: text } };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (options.atUserId) {
|
|
224
|
+
body.at = { atUserIds: [options.atUserId], isAtAll: false };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const result = await axios({
|
|
228
|
+
url: sessionWebhook,
|
|
229
|
+
method: "POST",
|
|
230
|
+
data: body,
|
|
231
|
+
headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
|
|
232
|
+
});
|
|
233
|
+
return result.data;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function sendMessage(
|
|
237
|
+
config: DingTalkConfig,
|
|
238
|
+
conversationId: string,
|
|
239
|
+
text: string,
|
|
240
|
+
options: SendMessageOptions & { sessionWebhook?: string; accountId?: string } = {},
|
|
241
|
+
): Promise<{ ok: boolean; error?: string; data?: AxiosResponse }> {
|
|
242
|
+
try {
|
|
243
|
+
const messageType = config.messageType || "markdown";
|
|
244
|
+
const log = options.log || getLogger();
|
|
245
|
+
|
|
246
|
+
// Card mode: stream into active card if exists; otherwise fallback to markdown/session send.
|
|
247
|
+
if (messageType === "card" && options.accountId) {
|
|
248
|
+
const targetKey = `${options.accountId}:${conversationId}`;
|
|
249
|
+
const activeCardId = getActiveCardIdByTarget(targetKey);
|
|
250
|
+
if (activeCardId) {
|
|
251
|
+
const activeCard = getCardById(activeCardId);
|
|
252
|
+
if (activeCard && !isCardInTerminalState(activeCard.state)) {
|
|
253
|
+
try {
|
|
254
|
+
await streamAICard(activeCard, text, false, log);
|
|
255
|
+
return { ok: true };
|
|
256
|
+
} catch (err: any) {
|
|
257
|
+
// Mark failed and continue to markdown fallback to avoid message loss.
|
|
258
|
+
log?.warn?.(
|
|
259
|
+
`[DingTalk] AI Card streaming failed, fallback to markdown: ${err.message}`,
|
|
260
|
+
);
|
|
261
|
+
activeCard.state = AICardStatus.FAILED;
|
|
262
|
+
activeCard.lastUpdated = Date.now();
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
deleteActiveCardByTarget(targetKey);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (options.sessionWebhook) {
|
|
271
|
+
await sendBySession(config, options.sessionWebhook, text, options);
|
|
272
|
+
return { ok: true };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const result = await sendProactiveTextOrMarkdown(config, conversationId, text, options);
|
|
276
|
+
return { ok: true, data: result };
|
|
277
|
+
} catch (err: any) {
|
|
278
|
+
options.log?.error?.(`[DingTalk] Send message failed: ${err.message}`);
|
|
279
|
+
return { ok: false, error: err.message };
|
|
280
|
+
}
|
|
281
|
+
}
|
package/src/signature.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate DingTalk custom-bot style signature.
|
|
5
|
+
* Sign payload format: `${timestamp}\n${secret}`
|
|
6
|
+
*/
|
|
7
|
+
export function generateDingTalkSignature(timestamp: string | number, secret: string): string {
|
|
8
|
+
if (!secret) {
|
|
9
|
+
throw new Error("secret is required for DingTalk signature generation");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const timestampText = String(timestamp);
|
|
13
|
+
const payload = `${timestampText}\n${secret}`;
|
|
14
|
+
return createHmac("sha256", secret).update(payload).digest("base64");
|
|
15
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -11,11 +11,20 @@
|
|
|
11
11
|
|
|
12
12
|
import type {
|
|
13
13
|
OpenClawConfig,
|
|
14
|
+
OpenClawPluginApi,
|
|
14
15
|
ChannelLogSink as SDKChannelLogSink,
|
|
15
16
|
ChannelAccountSnapshot as SDKChannelAccountSnapshot,
|
|
16
17
|
ChannelGatewayContext as SDKChannelGatewayContext,
|
|
17
18
|
ChannelPlugin as SDKChannelPlugin,
|
|
18
|
-
} from
|
|
19
|
+
} from "openclaw/plugin-sdk";
|
|
20
|
+
|
|
21
|
+
export interface DingtalkPluginModule {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
configSchema?: unknown;
|
|
26
|
+
register?: (api: OpenClawPluginApi) => void | Promise<void>;
|
|
27
|
+
}
|
|
19
28
|
|
|
20
29
|
/**
|
|
21
30
|
* DingTalk channel configuration (extends base OpenClaw config)
|
|
@@ -28,12 +37,12 @@ export interface DingTalkConfig extends OpenClawConfig {
|
|
|
28
37
|
agentId?: string;
|
|
29
38
|
name?: string;
|
|
30
39
|
enabled?: boolean;
|
|
31
|
-
dmPolicy?:
|
|
32
|
-
groupPolicy?:
|
|
40
|
+
dmPolicy?: "open" | "pairing" | "allowlist";
|
|
41
|
+
groupPolicy?: "open" | "allowlist";
|
|
33
42
|
allowFrom?: string[];
|
|
34
43
|
showThinking?: boolean;
|
|
35
44
|
debug?: boolean;
|
|
36
|
-
messageType?:
|
|
45
|
+
messageType?: "markdown" | "card";
|
|
37
46
|
cardTemplateId?: string;
|
|
38
47
|
cardTemplateKey?: string;
|
|
39
48
|
groups?: Record<string, { systemPrompt?: string }>;
|
|
@@ -56,12 +65,12 @@ export interface DingTalkChannelConfig {
|
|
|
56
65
|
corpId?: string;
|
|
57
66
|
agentId?: string;
|
|
58
67
|
name?: string;
|
|
59
|
-
dmPolicy?:
|
|
60
|
-
groupPolicy?:
|
|
68
|
+
dmPolicy?: "open" | "pairing" | "allowlist";
|
|
69
|
+
groupPolicy?: "open" | "allowlist";
|
|
61
70
|
allowFrom?: string[];
|
|
62
71
|
showThinking?: boolean;
|
|
63
72
|
debug?: boolean;
|
|
64
|
-
messageType?:
|
|
73
|
+
messageType?: "markdown" | "card";
|
|
65
74
|
cardTemplateId?: string;
|
|
66
75
|
cardTemplateKey?: string;
|
|
67
76
|
groups?: Record<string, { systemPrompt?: string }>;
|
|
@@ -123,8 +132,9 @@ export interface DingTalkInboundMessage {
|
|
|
123
132
|
createAt: number;
|
|
124
133
|
text?: {
|
|
125
134
|
content: string;
|
|
126
|
-
isReplyMsg?: boolean;
|
|
127
|
-
repliedMsg?: {
|
|
135
|
+
isReplyMsg?: boolean; // 是否是回复消息
|
|
136
|
+
repliedMsg?: {
|
|
137
|
+
// 被回复的消息
|
|
128
138
|
content?: {
|
|
129
139
|
text?: string;
|
|
130
140
|
richText?: Array<{
|
|
@@ -147,13 +157,13 @@ export interface DingTalkInboundMessage {
|
|
|
147
157
|
atName?: string;
|
|
148
158
|
downloadCode?: string; // For picture type in richText
|
|
149
159
|
}>;
|
|
150
|
-
quoteContent?: string;
|
|
160
|
+
quoteContent?: string; // 替代引用格式
|
|
151
161
|
};
|
|
152
162
|
// Legacy 引用格式
|
|
153
163
|
quoteMessage?: {
|
|
154
164
|
msgId?: string;
|
|
155
165
|
msgtype?: string;
|
|
156
|
-
text?: { content: string
|
|
166
|
+
text?: { content: string };
|
|
157
167
|
senderNick?: string;
|
|
158
168
|
senderId?: string;
|
|
159
169
|
};
|
|
@@ -190,7 +200,7 @@ export interface SendMessageOptions {
|
|
|
190
200
|
mediaPath?: string;
|
|
191
201
|
filePath?: string;
|
|
192
202
|
mediaUrl?: string;
|
|
193
|
-
mediaType?:
|
|
203
|
+
mediaType?: "image" | "voice" | "video" | "file";
|
|
194
204
|
}
|
|
195
205
|
|
|
196
206
|
/**
|
|
@@ -262,7 +272,7 @@ export interface AxiosRequestConfig {
|
|
|
262
272
|
method?: string;
|
|
263
273
|
data?: any;
|
|
264
274
|
headers?: Record<string, string>;
|
|
265
|
-
responseType?:
|
|
275
|
+
responseType?: "arraybuffer" | "json" | "text";
|
|
266
276
|
}
|
|
267
277
|
|
|
268
278
|
/**
|
|
@@ -391,7 +401,7 @@ export interface SendMediaParams {
|
|
|
391
401
|
* DingTalk outbound handler configuration
|
|
392
402
|
*/
|
|
393
403
|
export interface DingTalkOutboundHandler {
|
|
394
|
-
deliveryMode:
|
|
404
|
+
deliveryMode: "direct" | "queued" | "batch";
|
|
395
405
|
resolveTarget: (params: ResolveTargetParams) => TargetResolutionResult;
|
|
396
406
|
sendText: (params: SendTextParams) => Promise<{ ok: boolean; data?: any; error?: any }>;
|
|
397
407
|
sendMedia?: (params: SendMediaParams) => Promise<{ ok: boolean; data?: any; error?: any }>;
|
|
@@ -401,10 +411,10 @@ export interface DingTalkOutboundHandler {
|
|
|
401
411
|
* AI Card status constants
|
|
402
412
|
*/
|
|
403
413
|
export const AICardStatus = {
|
|
404
|
-
PROCESSING:
|
|
405
|
-
INPUTING:
|
|
406
|
-
FINISHED:
|
|
407
|
-
FAILED:
|
|
414
|
+
PROCESSING: "1",
|
|
415
|
+
INPUTING: "2",
|
|
416
|
+
FINISHED: "3",
|
|
417
|
+
FAILED: "5",
|
|
408
418
|
} as const;
|
|
409
419
|
|
|
410
420
|
/**
|
|
@@ -442,11 +452,11 @@ export interface AICardStreamingRequest {
|
|
|
442
452
|
* Connection state enum for lifecycle management
|
|
443
453
|
*/
|
|
444
454
|
export enum ConnectionState {
|
|
445
|
-
DISCONNECTED =
|
|
446
|
-
CONNECTING =
|
|
447
|
-
CONNECTED =
|
|
448
|
-
DISCONNECTING =
|
|
449
|
-
FAILED =
|
|
455
|
+
DISCONNECTED = "DISCONNECTED",
|
|
456
|
+
CONNECTING = "CONNECTING",
|
|
457
|
+
CONNECTED = "CONNECTED",
|
|
458
|
+
DISCONNECTING = "DISCONNECTING",
|
|
459
|
+
FAILED = "FAILED",
|
|
450
460
|
}
|
|
451
461
|
|
|
452
462
|
/**
|
|
@@ -473,14 +483,16 @@ export interface ConnectionAttemptResult {
|
|
|
473
483
|
|
|
474
484
|
// ============ Onboarding Helper Functions ============
|
|
475
485
|
|
|
476
|
-
const DEFAULT_ACCOUNT_ID =
|
|
486
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
477
487
|
|
|
478
488
|
/**
|
|
479
489
|
* List all DingTalk account IDs from config
|
|
480
490
|
*/
|
|
481
491
|
export function listDingTalkAccountIds(cfg: OpenClawConfig): string[] {
|
|
482
492
|
const dingtalk = cfg.channels?.dingtalk as DingTalkChannelConfig | undefined;
|
|
483
|
-
if (!dingtalk)
|
|
493
|
+
if (!dingtalk) {
|
|
494
|
+
return [];
|
|
495
|
+
}
|
|
484
496
|
|
|
485
497
|
const accountIds: string[] = [];
|
|
486
498
|
|
|
@@ -508,15 +520,18 @@ export interface ResolvedDingTalkAccount extends DingTalkConfig {
|
|
|
508
520
|
/**
|
|
509
521
|
* Resolve a specific DingTalk account configuration
|
|
510
522
|
*/
|
|
511
|
-
export function resolveDingTalkAccount(
|
|
523
|
+
export function resolveDingTalkAccount(
|
|
524
|
+
cfg: OpenClawConfig,
|
|
525
|
+
accountId?: string | null,
|
|
526
|
+
): ResolvedDingTalkAccount {
|
|
512
527
|
const id = accountId || DEFAULT_ACCOUNT_ID;
|
|
513
528
|
const dingtalk = cfg.channels?.dingtalk as DingTalkChannelConfig | undefined;
|
|
514
529
|
|
|
515
530
|
// If default account, return top-level config
|
|
516
531
|
if (id === DEFAULT_ACCOUNT_ID) {
|
|
517
532
|
const config: DingTalkConfig = {
|
|
518
|
-
clientId: dingtalk?.clientId ??
|
|
519
|
-
clientSecret: dingtalk?.clientSecret ??
|
|
533
|
+
clientId: dingtalk?.clientId ?? "",
|
|
534
|
+
clientSecret: dingtalk?.clientSecret ?? "",
|
|
520
535
|
robotCode: dingtalk?.robotCode,
|
|
521
536
|
corpId: dingtalk?.corpId,
|
|
522
537
|
agentId: dingtalk?.agentId,
|
|
@@ -556,8 +571,8 @@ export function resolveDingTalkAccount(cfg: OpenClawConfig, accountId?: string |
|
|
|
556
571
|
|
|
557
572
|
// Account doesn't exist, return empty config
|
|
558
573
|
return {
|
|
559
|
-
clientId:
|
|
560
|
-
clientSecret:
|
|
574
|
+
clientId: "",
|
|
575
|
+
clientSecret: "",
|
|
561
576
|
accountId: id,
|
|
562
577
|
configured: false,
|
|
563
578
|
};
|
package/src/utils.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import * as fs from
|
|
2
|
-
import * as
|
|
3
|
-
import * as
|
|
4
|
-
import type { Logger, RetryOptions } from
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { Logger, RetryOptions } from "./types";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Mask sensitive fields in data for safe logging
|
|
@@ -12,23 +12,23 @@ export function maskSensitiveData(data: unknown): any {
|
|
|
12
12
|
return data;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
if (typeof data !==
|
|
15
|
+
if (typeof data !== "object") {
|
|
16
16
|
return data as string | number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const masked = JSON.parse(JSON.stringify(data)) as Record<string, any>;
|
|
20
|
-
const sensitiveFields = [
|
|
20
|
+
const sensitiveFields = new Set(["token", "accessToken"]);
|
|
21
21
|
|
|
22
22
|
function maskObj(obj: any): void {
|
|
23
23
|
for (const key in obj) {
|
|
24
|
-
if (sensitiveFields.
|
|
24
|
+
if (sensitiveFields.has(key)) {
|
|
25
25
|
const val = obj[key];
|
|
26
|
-
if (typeof val ===
|
|
27
|
-
obj[key] = val.slice(0, 3) +
|
|
28
|
-
} else if (typeof val ===
|
|
29
|
-
obj[key] =
|
|
26
|
+
if (typeof val === "string" && val.length > 6) {
|
|
27
|
+
obj[key] = val.slice(0, 3) + "*".repeat(val.length - 6) + val.slice(-3);
|
|
28
|
+
} else if (typeof val === "string") {
|
|
29
|
+
obj[key] = "*".repeat(val.length);
|
|
30
30
|
}
|
|
31
|
-
} else if (typeof obj[key] ===
|
|
31
|
+
} else if (typeof obj[key] === "object" && obj[key] !== null) {
|
|
32
32
|
maskObj(obj[key]);
|
|
33
33
|
}
|
|
34
34
|
}
|
|
@@ -53,7 +53,9 @@ export function cleanupOrphanedTempFiles(log?: Logger): number {
|
|
|
53
53
|
const maxAge = 24 * 60 * 60 * 1000;
|
|
54
54
|
|
|
55
55
|
for (const file of files) {
|
|
56
|
-
if (!dingtalkPattern.test(file))
|
|
56
|
+
if (!dingtalkPattern.test(file)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
57
59
|
|
|
58
60
|
const filePath = path.join(tempDir, file);
|
|
59
61
|
try {
|
|
@@ -82,7 +84,10 @@ export function cleanupOrphanedTempFiles(log?: Logger): number {
|
|
|
82
84
|
* Retry logic for API calls with exponential backoff
|
|
83
85
|
* Handles transient failures like 401 token expiry
|
|
84
86
|
*/
|
|
85
|
-
export async function retryWithBackoff<T>(
|
|
87
|
+
export async function retryWithBackoff<T>(
|
|
88
|
+
fn: () => Promise<T>,
|
|
89
|
+
options: RetryOptions = {},
|
|
90
|
+
): Promise<T> {
|
|
86
91
|
const { maxRetries = 3, baseDelayMs = 100, log } = options;
|
|
87
92
|
|
|
88
93
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
@@ -90,7 +95,8 @@ export async function retryWithBackoff<T>(fn: () => Promise<T>, options: RetryOp
|
|
|
90
95
|
return await fn();
|
|
91
96
|
} catch (err: any) {
|
|
92
97
|
const statusCode = err.response?.status;
|
|
93
|
-
const isRetryable =
|
|
98
|
+
const isRetryable =
|
|
99
|
+
statusCode === 401 || statusCode === 429 || (statusCode && statusCode >= 500);
|
|
94
100
|
|
|
95
101
|
if (!isRetryable || attempt === maxRetries) {
|
|
96
102
|
throw err;
|
|
@@ -102,5 +108,12 @@ export async function retryWithBackoff<T>(fn: () => Promise<T>, options: RetryOp
|
|
|
102
108
|
}
|
|
103
109
|
}
|
|
104
110
|
|
|
105
|
-
throw new Error(
|
|
111
|
+
throw new Error("Retry exhausted without returning");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get current timestamp in ISO-compatible epoch milliseconds for status tracking.
|
|
116
|
+
*/
|
|
117
|
+
export function getCurrentTimestamp(): number {
|
|
118
|
+
return Date.now();
|
|
106
119
|
}
|
package/clawbot.plugin.json
DELETED
package/src/AGENTS.md
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
# SOURCE DIRECTORY
|
|
2
|
-
|
|
3
|
-
**Parent:** `./AGENTS.md`
|
|
4
|
-
|
|
5
|
-
## OVERVIEW
|
|
6
|
-
|
|
7
|
-
All DingTalk plugin implementation logic.
|
|
8
|
-
|
|
9
|
-
## STRUCTURE
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
src/
|
|
13
|
-
├── channel.ts # Main plugin definition, API calls, message handling, AI Card
|
|
14
|
-
├── types.ts # Type definitions (30+ interfaces, AI Card types)
|
|
15
|
-
├── runtime.ts # Runtime getter/setter pattern
|
|
16
|
-
└── config-schema.ts # Zod validation for configuration
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## WHERE TO LOOK
|
|
20
|
-
|
|
21
|
-
| Task | Location | Notes |
|
|
22
|
-
| ------------------------- | ---------------------- | -------------------------------------------- |
|
|
23
|
-
| Channel plugin definition | `channel.ts:862` | `dingtalkPlugin` export |
|
|
24
|
-
| AI Card operations | `channel.ts:374-600` | createAICard, streamAICard, finishAICard |
|
|
25
|
-
| Message sending | `channel.ts:520-700` | sendMessage, sendBySession |
|
|
26
|
-
| Token management | `channel.ts:156-177` | getAccessToken with cache |
|
|
27
|
-
| Message processing | `channel.ts:643-859` | handleDingTalkMessage, extractMessageContent |
|
|
28
|
-
| Type exports | `types.ts` | All interfaces/constants |
|
|
29
|
-
| Public API exports | `channel.ts:1068-1076` | sendBySession, createAICard, etc. |
|
|
30
|
-
|
|
31
|
-
## CONVENTIONS
|
|
32
|
-
|
|
33
|
-
Same as root. No src-specific deviations.
|
|
34
|
-
|
|
35
|
-
## ANTI-PATTERNS
|
|
36
|
-
|
|
37
|
-
**Prohibited:**
|
|
38
|
-
|
|
39
|
-
- Mutating module-level state outside of initialized functions
|
|
40
|
-
- Creating multiple AI Card instances for same conversationId (use cached)
|
|
41
|
-
- Calling DingTalk APIs without access token
|
|
42
|
-
- Suppressing errors in async handlers
|
|
43
|
-
|
|
44
|
-
## UNIQUE STYLES
|
|
45
|
-
|
|
46
|
-
**AI Card State Machine:**
|
|
47
|
-
|
|
48
|
-
- States: PROCESSING → INPUTING → FINISHED/FAILED
|
|
49
|
-
- Cached in `Map<string, AICardInstance>` with TTL cleanup
|
|
50
|
-
- Terminal states (FINISHED/FAILED) cleaned after 1 hour
|
|
51
|
-
|
|
52
|
-
**Access Token Caching:**
|
|
53
|
-
|
|
54
|
-
- Module-level variables: `accessToken`, `accessTokenExpiry`
|
|
55
|
-
- Refresh 60s before expiry
|
|
56
|
-
- Retry logic for 401/429/5xx errors
|
|
57
|
-
|
|
58
|
-
**Message Type Handling:**
|
|
59
|
-
|
|
60
|
-
- `text`: Plain text messages
|
|
61
|
-
- `richText`: Extract text + @mentions
|
|
62
|
-
- `picture/audio/video/file`: Download to `/tmp/dingtalk_*`
|
|
63
|
-
- Auto-detect Markdown syntax for auto-formatting
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"folders": [
|
|
3
|
-
{
|
|
4
|
-
"path": ".."
|
|
5
|
-
},
|
|
6
|
-
{
|
|
7
|
-
"path": "../../.."
|
|
8
|
-
}
|
|
9
|
-
],
|
|
10
|
-
"settings": {
|
|
11
|
-
"chat.tools.terminal.autoApprove": {
|
|
12
|
-
"npm --prefix /Users/sym/Repo/openclaw/extensions/openclaw-channel-dingtalk": true,
|
|
13
|
-
"pnpm": true,
|
|
14
|
-
"/usr/bin/git": true
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
}
|