@adongguo/dingtalk 0.1.3
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 +247 -0
- package/clawdbot.plugin.json +9 -0
- package/index.ts +86 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +60 -0
- package/src/accounts.ts +49 -0
- package/src/ai-card.ts +341 -0
- package/src/bot.ts +403 -0
- package/src/channel.ts +220 -0
- package/src/client.ts +49 -0
- package/src/config-schema.ts +119 -0
- package/src/directory.ts +90 -0
- package/src/gateway-stream.ts +159 -0
- package/src/media.ts +608 -0
- package/src/monitor.ts +127 -0
- package/src/onboarding.ts +355 -0
- package/src/outbound.ts +46 -0
- package/src/policy.ts +92 -0
- package/src/probe.ts +41 -0
- package/src/reactions.ts +64 -0
- package/src/reply-dispatcher.ts +167 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +314 -0
- package/src/session.ts +144 -0
- package/src/streaming-handler.ts +298 -0
- package/src/targets.ts +56 -0
- package/src/types.ts +198 -0
- package/src/typing.ts +36 -0
package/src/bot.ts
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import type { DWClient } from "dingtalk-stream";
|
|
2
|
+
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
3
|
+
import {
|
|
4
|
+
buildPendingHistoryContextFromMap,
|
|
5
|
+
recordPendingHistoryEntryIfEnabled,
|
|
6
|
+
clearHistoryEntriesIfEnabled,
|
|
7
|
+
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
8
|
+
type HistoryEntry,
|
|
9
|
+
} from "openclaw/plugin-sdk";
|
|
10
|
+
import type { DingTalkConfig, DingTalkMessageContext, DingTalkMediaInfo, DingTalkIncomingMessage } from "./types.js";
|
|
11
|
+
import { getDingTalkRuntime } from "./runtime.js";
|
|
12
|
+
import {
|
|
13
|
+
resolveDingTalkGroupConfig,
|
|
14
|
+
resolveDingTalkReplyPolicy,
|
|
15
|
+
resolveDingTalkAllowlistMatch,
|
|
16
|
+
isDingTalkGroupAllowed,
|
|
17
|
+
} from "./policy.js";
|
|
18
|
+
import { createDingTalkReplyDispatcher } from "./reply-dispatcher.js";
|
|
19
|
+
import { downloadMediaDingTalk } from "./media.js";
|
|
20
|
+
|
|
21
|
+
function parseMessageContent(message: DingTalkIncomingMessage): string {
|
|
22
|
+
if (message.msgtype === "text" && message.text?.content) {
|
|
23
|
+
return message.text.content.trim();
|
|
24
|
+
}
|
|
25
|
+
if (message.msgtype === "richText" && message.content) {
|
|
26
|
+
// For richText, try to extract text content
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(message.content);
|
|
29
|
+
return extractRichTextContent(parsed);
|
|
30
|
+
} catch {
|
|
31
|
+
return message.content;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// For other message types, return a placeholder
|
|
35
|
+
return `[${message.msgtype}]`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extractRichTextContent(richText: unknown): string {
|
|
39
|
+
if (!richText || typeof richText !== "object") return "";
|
|
40
|
+
const parts: string[] = [];
|
|
41
|
+
|
|
42
|
+
function traverse(node: unknown): void {
|
|
43
|
+
if (!node || typeof node !== "object") return;
|
|
44
|
+
if (Array.isArray(node)) {
|
|
45
|
+
for (const item of node) {
|
|
46
|
+
traverse(item);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const obj = node as Record<string, unknown>;
|
|
51
|
+
if (obj.text && typeof obj.text === "string") {
|
|
52
|
+
parts.push(obj.text);
|
|
53
|
+
}
|
|
54
|
+
if (obj.content) {
|
|
55
|
+
traverse(obj.content);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
traverse(richText);
|
|
60
|
+
return parts.join("").trim() || "[富文本消息]";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function checkBotMentioned(message: DingTalkIncomingMessage): boolean {
|
|
64
|
+
// In DingTalk, if the bot is mentioned, isInAtList will be true
|
|
65
|
+
if (message.isInAtList) return true;
|
|
66
|
+
// Also check atUsers array
|
|
67
|
+
if (message.atUsers && message.atUsers.length > 0) return true;
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stripBotMention(text: string): string {
|
|
72
|
+
// DingTalk mentions are typically @bot_name format
|
|
73
|
+
// The text content usually already has mentions stripped in some cases
|
|
74
|
+
// But let's clean up any remaining @mentions at the start
|
|
75
|
+
return text.replace(/^@\S+\s*/g, "").trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function inferPlaceholder(msgtype: string): string {
|
|
79
|
+
switch (msgtype) {
|
|
80
|
+
case "image":
|
|
81
|
+
case "picture":
|
|
82
|
+
return "<media:image>";
|
|
83
|
+
case "file":
|
|
84
|
+
return "<media:document>";
|
|
85
|
+
case "voice":
|
|
86
|
+
return "<media:audio>";
|
|
87
|
+
case "video":
|
|
88
|
+
return "<media:video>";
|
|
89
|
+
default:
|
|
90
|
+
return "<media:document>";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function resolveDingTalkMediaList(params: {
|
|
95
|
+
cfg: ClawdbotConfig;
|
|
96
|
+
message: DingTalkIncomingMessage;
|
|
97
|
+
maxBytes: number;
|
|
98
|
+
log?: (msg: string) => void;
|
|
99
|
+
client?: DWClient;
|
|
100
|
+
}): Promise<DingTalkMediaInfo[]> {
|
|
101
|
+
const { cfg, message, maxBytes, log, client } = params;
|
|
102
|
+
|
|
103
|
+
// Only process media message types
|
|
104
|
+
const mediaTypes = ["image", "picture", "file", "voice", "video"];
|
|
105
|
+
if (!mediaTypes.includes(message.msgtype)) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// DingTalk requires downloadCode to download media
|
|
110
|
+
if (!message.downloadCode) {
|
|
111
|
+
log?.(`dingtalk: no downloadCode for ${message.msgtype} message`);
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const out: DingTalkMediaInfo[] = [];
|
|
116
|
+
const core = getDingTalkRuntime();
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const result = await downloadMediaDingTalk({
|
|
120
|
+
cfg,
|
|
121
|
+
downloadCode: message.downloadCode,
|
|
122
|
+
robotCode: message.robotCode,
|
|
123
|
+
client,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (!result) {
|
|
127
|
+
log?.(`dingtalk: failed to download ${message.msgtype} media`);
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let contentType = result.contentType;
|
|
132
|
+
if (!contentType) {
|
|
133
|
+
contentType = await core.media.detectMime({ buffer: result.buffer });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
137
|
+
result.buffer,
|
|
138
|
+
contentType,
|
|
139
|
+
"inbound",
|
|
140
|
+
maxBytes,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
out.push({
|
|
144
|
+
path: saved.path,
|
|
145
|
+
contentType: saved.contentType,
|
|
146
|
+
placeholder: inferPlaceholder(message.msgtype),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
log?.(`dingtalk: downloaded ${message.msgtype} media, saved to ${saved.path}`);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
log?.(`dingtalk: failed to download ${message.msgtype} media: ${String(err)}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildDingTalkMediaPayload(
|
|
158
|
+
mediaList: DingTalkMediaInfo[],
|
|
159
|
+
): {
|
|
160
|
+
MediaPath?: string;
|
|
161
|
+
MediaType?: string;
|
|
162
|
+
MediaUrl?: string;
|
|
163
|
+
MediaPaths?: string[];
|
|
164
|
+
MediaUrls?: string[];
|
|
165
|
+
MediaTypes?: string[];
|
|
166
|
+
} {
|
|
167
|
+
const first = mediaList[0];
|
|
168
|
+
const mediaPaths = mediaList.map((media) => media.path);
|
|
169
|
+
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
|
170
|
+
return {
|
|
171
|
+
MediaPath: first?.path,
|
|
172
|
+
MediaType: first?.contentType,
|
|
173
|
+
MediaUrl: first?.path,
|
|
174
|
+
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
175
|
+
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
176
|
+
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function parseDingTalkMessage(message: DingTalkIncomingMessage): DingTalkMessageContext {
|
|
181
|
+
const rawContent = parseMessageContent(message);
|
|
182
|
+
const mentionedBot = checkBotMentioned(message);
|
|
183
|
+
const content = stripBotMention(rawContent);
|
|
184
|
+
const isGroup = message.conversationType === "2";
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
conversationId: message.conversationId,
|
|
188
|
+
messageId: message.msgId,
|
|
189
|
+
senderId: message.senderStaffId || "",
|
|
190
|
+
senderNick: message.senderNick,
|
|
191
|
+
chatType: isGroup ? "group" : "p2p",
|
|
192
|
+
mentionedBot,
|
|
193
|
+
sessionWebhook: message.sessionWebhook,
|
|
194
|
+
sessionWebhookExpiredTime: message.sessionWebhookExpiredTime,
|
|
195
|
+
content,
|
|
196
|
+
contentType: message.msgtype,
|
|
197
|
+
robotCode: message.robotCode,
|
|
198
|
+
chatbotCorpId: message.chatbotCorpId,
|
|
199
|
+
isAdmin: message.isAdmin,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function handleDingTalkMessage(params: {
|
|
204
|
+
cfg: ClawdbotConfig;
|
|
205
|
+
message: DingTalkIncomingMessage;
|
|
206
|
+
runtime?: RuntimeEnv;
|
|
207
|
+
chatHistories?: Map<string, HistoryEntry[]>;
|
|
208
|
+
client?: DWClient;
|
|
209
|
+
}): Promise<void> {
|
|
210
|
+
const { cfg, message, runtime, chatHistories, client } = params;
|
|
211
|
+
const dingtalkCfg = cfg.channels?.dingtalk as DingTalkConfig | undefined;
|
|
212
|
+
const log = runtime?.log ?? console.log;
|
|
213
|
+
const error = runtime?.error ?? console.error;
|
|
214
|
+
|
|
215
|
+
const ctx = parseDingTalkMessage(message);
|
|
216
|
+
const isGroup = ctx.chatType === "group";
|
|
217
|
+
|
|
218
|
+
log(`dingtalk: received message from ${ctx.senderNick} (${ctx.senderId}) in ${ctx.conversationId} (${ctx.chatType})`);
|
|
219
|
+
|
|
220
|
+
const historyLimit = Math.max(
|
|
221
|
+
0,
|
|
222
|
+
dingtalkCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
if (isGroup) {
|
|
226
|
+
const groupPolicy = dingtalkCfg?.groupPolicy ?? "open";
|
|
227
|
+
const groupAllowFrom = dingtalkCfg?.groupAllowFrom ?? [];
|
|
228
|
+
const groupConfig = resolveDingTalkGroupConfig({ cfg: dingtalkCfg, groupId: ctx.conversationId });
|
|
229
|
+
|
|
230
|
+
const senderAllowFrom = groupConfig?.allowFrom ?? groupAllowFrom;
|
|
231
|
+
const allowed = isDingTalkGroupAllowed({
|
|
232
|
+
groupPolicy,
|
|
233
|
+
allowFrom: senderAllowFrom,
|
|
234
|
+
senderId: ctx.senderId,
|
|
235
|
+
senderName: ctx.senderNick,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (!allowed) {
|
|
239
|
+
log(`dingtalk: sender ${ctx.senderId} not in group allowlist`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const { requireMention } = resolveDingTalkReplyPolicy({
|
|
244
|
+
isDirectMessage: false,
|
|
245
|
+
globalConfig: dingtalkCfg,
|
|
246
|
+
groupConfig,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (requireMention && !ctx.mentionedBot) {
|
|
250
|
+
log(`dingtalk: message in group ${ctx.conversationId} did not mention bot, recording to history`);
|
|
251
|
+
if (chatHistories) {
|
|
252
|
+
recordPendingHistoryEntryIfEnabled({
|
|
253
|
+
historyMap: chatHistories,
|
|
254
|
+
historyKey: ctx.conversationId,
|
|
255
|
+
limit: historyLimit,
|
|
256
|
+
entry: {
|
|
257
|
+
sender: ctx.senderNick || ctx.senderId,
|
|
258
|
+
body: ctx.content,
|
|
259
|
+
timestamp: Date.now(),
|
|
260
|
+
messageId: ctx.messageId,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
const dmPolicy = dingtalkCfg?.dmPolicy ?? "pairing";
|
|
268
|
+
const allowFrom = dingtalkCfg?.allowFrom ?? [];
|
|
269
|
+
|
|
270
|
+
if (dmPolicy === "allowlist") {
|
|
271
|
+
const match = resolveDingTalkAllowlistMatch({
|
|
272
|
+
allowFrom,
|
|
273
|
+
senderId: ctx.senderId,
|
|
274
|
+
});
|
|
275
|
+
if (!match.allowed) {
|
|
276
|
+
log(`dingtalk: sender ${ctx.senderId} not in DM allowlist`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const core = getDingTalkRuntime();
|
|
284
|
+
|
|
285
|
+
const dingtalkFrom = isGroup ? `dingtalk:group:${ctx.conversationId}` : `dingtalk:${ctx.senderId}`;
|
|
286
|
+
const dingtalkTo = isGroup ? `chat:${ctx.conversationId}` : `user:${ctx.senderId}`;
|
|
287
|
+
|
|
288
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
289
|
+
cfg,
|
|
290
|
+
channel: "dingtalk",
|
|
291
|
+
peer: {
|
|
292
|
+
kind: isGroup ? "group" : "dm",
|
|
293
|
+
id: isGroup ? ctx.conversationId : ctx.senderId,
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
|
298
|
+
const inboundLabel = isGroup
|
|
299
|
+
? `DingTalk message in group ${ctx.conversationId}`
|
|
300
|
+
: `DingTalk DM from ${ctx.senderNick}`;
|
|
301
|
+
|
|
302
|
+
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
|
303
|
+
sessionKey: route.sessionKey,
|
|
304
|
+
contextKey: `dingtalk:message:${ctx.conversationId}:${ctx.messageId}`,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Resolve media from message
|
|
308
|
+
const mediaMaxBytes = (dingtalkCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
|
|
309
|
+
const mediaList = await resolveDingTalkMediaList({
|
|
310
|
+
cfg,
|
|
311
|
+
message,
|
|
312
|
+
maxBytes: mediaMaxBytes,
|
|
313
|
+
log,
|
|
314
|
+
client,
|
|
315
|
+
});
|
|
316
|
+
const mediaPayload = buildDingTalkMediaPayload(mediaList);
|
|
317
|
+
|
|
318
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
319
|
+
|
|
320
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
321
|
+
channel: "DingTalk",
|
|
322
|
+
from: isGroup ? ctx.conversationId : ctx.senderNick || ctx.senderId,
|
|
323
|
+
timestamp: new Date(),
|
|
324
|
+
envelope: envelopeOptions,
|
|
325
|
+
body: ctx.content,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
let combinedBody = body;
|
|
329
|
+
const historyKey = isGroup ? ctx.conversationId : undefined;
|
|
330
|
+
|
|
331
|
+
if (isGroup && historyKey && chatHistories) {
|
|
332
|
+
combinedBody = buildPendingHistoryContextFromMap({
|
|
333
|
+
historyMap: chatHistories,
|
|
334
|
+
historyKey,
|
|
335
|
+
limit: historyLimit,
|
|
336
|
+
currentMessage: combinedBody,
|
|
337
|
+
formatEntry: (entry) =>
|
|
338
|
+
core.channel.reply.formatAgentEnvelope({
|
|
339
|
+
channel: "DingTalk",
|
|
340
|
+
from: ctx.conversationId,
|
|
341
|
+
timestamp: entry.timestamp,
|
|
342
|
+
body: `${entry.sender}: ${entry.body}`,
|
|
343
|
+
envelope: envelopeOptions,
|
|
344
|
+
}),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
349
|
+
Body: combinedBody,
|
|
350
|
+
RawBody: ctx.content,
|
|
351
|
+
CommandBody: ctx.content,
|
|
352
|
+
From: dingtalkFrom,
|
|
353
|
+
To: dingtalkTo,
|
|
354
|
+
SessionKey: route.sessionKey,
|
|
355
|
+
AccountId: route.accountId,
|
|
356
|
+
ChatType: isGroup ? "group" : "direct",
|
|
357
|
+
GroupSubject: isGroup ? ctx.conversationId : undefined,
|
|
358
|
+
SenderName: ctx.senderNick || ctx.senderId,
|
|
359
|
+
SenderId: ctx.senderId,
|
|
360
|
+
Provider: "dingtalk" as const,
|
|
361
|
+
Surface: "dingtalk" as const,
|
|
362
|
+
MessageSid: ctx.messageId,
|
|
363
|
+
Timestamp: Date.now(),
|
|
364
|
+
WasMentioned: ctx.mentionedBot,
|
|
365
|
+
CommandAuthorized: true,
|
|
366
|
+
OriginatingChannel: "dingtalk" as const,
|
|
367
|
+
OriginatingTo: dingtalkTo,
|
|
368
|
+
...mediaPayload,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const { dispatcher, replyOptions, markDispatchIdle } = createDingTalkReplyDispatcher({
|
|
372
|
+
cfg,
|
|
373
|
+
agentId: route.agentId,
|
|
374
|
+
runtime: runtime as RuntimeEnv,
|
|
375
|
+
conversationId: ctx.conversationId,
|
|
376
|
+
sessionWebhook: ctx.sessionWebhook,
|
|
377
|
+
client,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
log(`dingtalk: dispatching to agent (session=${route.sessionKey})`);
|
|
381
|
+
|
|
382
|
+
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
|
383
|
+
ctx: ctxPayload,
|
|
384
|
+
cfg,
|
|
385
|
+
dispatcher,
|
|
386
|
+
replyOptions,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
markDispatchIdle();
|
|
390
|
+
|
|
391
|
+
if (isGroup && historyKey && chatHistories) {
|
|
392
|
+
clearHistoryEntriesIfEnabled({
|
|
393
|
+
historyMap: chatHistories,
|
|
394
|
+
historyKey,
|
|
395
|
+
limit: historyLimit,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
log(`dingtalk: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
error(`dingtalk: failed to dispatch message: ${String(err)}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { ResolvedDingTalkAccount, DingTalkConfig } from "./types.js";
|
|
4
|
+
import { resolveDingTalkAccount, resolveDingTalkCredentials } from "./accounts.js";
|
|
5
|
+
import { dingtalkOutbound } from "./outbound.js";
|
|
6
|
+
import { probeDingTalk } from "./probe.js";
|
|
7
|
+
import { resolveDingTalkGroupToolPolicy } from "./policy.js";
|
|
8
|
+
import { normalizeDingTalkTarget, looksLikeDingTalkId } from "./targets.js";
|
|
9
|
+
import {
|
|
10
|
+
listDingTalkDirectoryPeers,
|
|
11
|
+
listDingTalkDirectoryGroups,
|
|
12
|
+
listDingTalkDirectoryPeersLive,
|
|
13
|
+
listDingTalkDirectoryGroupsLive,
|
|
14
|
+
} from "./directory.js";
|
|
15
|
+
import { dingtalkOnboardingAdapter } from "./onboarding.js";
|
|
16
|
+
|
|
17
|
+
const meta = {
|
|
18
|
+
id: "dingtalk",
|
|
19
|
+
label: "DingTalk",
|
|
20
|
+
selectionLabel: "DingTalk (钉钉)",
|
|
21
|
+
docsPath: "/channels/dingtalk",
|
|
22
|
+
docsLabel: "dingtalk",
|
|
23
|
+
blurb: "钉钉/DingTalk enterprise messaging.",
|
|
24
|
+
aliases: ["dingding"],
|
|
25
|
+
order: 70,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
|
|
29
|
+
id: "dingtalk",
|
|
30
|
+
meta: {
|
|
31
|
+
...meta,
|
|
32
|
+
},
|
|
33
|
+
pairing: {
|
|
34
|
+
idLabel: "dingtalkUserId",
|
|
35
|
+
normalizeAllowEntry: (entry) => entry.replace(/^(dingtalk|user|staff):/i, ""),
|
|
36
|
+
notifyApproval: async ({ cfg, id }) => {
|
|
37
|
+
// Note: DingTalk pairing approval requires sessionWebhook which is only available
|
|
38
|
+
// during active message handling. This is a limitation of the DingTalk API.
|
|
39
|
+
console.log(`[dingtalk] Pairing approved for user ${id}. Cannot send notification without sessionWebhook.`);
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
capabilities: {
|
|
43
|
+
chatTypes: ["direct", "channel"],
|
|
44
|
+
polls: false,
|
|
45
|
+
threads: false, // DingTalk has limited thread support
|
|
46
|
+
media: true,
|
|
47
|
+
reactions: false, // DingTalk doesn't support reactions via bot API
|
|
48
|
+
edit: false, // DingTalk doesn't support message editing via sessionWebhook
|
|
49
|
+
reply: true,
|
|
50
|
+
},
|
|
51
|
+
agentPrompt: {
|
|
52
|
+
messageToolHints: () => [
|
|
53
|
+
"- DingTalk targeting: messages are sent via sessionWebhook to the current conversation.",
|
|
54
|
+
"- DingTalk supports text, markdown, and ActionCard message types.",
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
groups: {
|
|
58
|
+
resolveToolPolicy: resolveDingTalkGroupToolPolicy,
|
|
59
|
+
},
|
|
60
|
+
reload: { configPrefixes: ["channels.dingtalk"] },
|
|
61
|
+
configSchema: {
|
|
62
|
+
schema: {
|
|
63
|
+
type: "object",
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
properties: {
|
|
66
|
+
enabled: { type: "boolean" },
|
|
67
|
+
appKey: { type: "string" },
|
|
68
|
+
appSecret: { type: "string" },
|
|
69
|
+
robotCode: { type: "string" },
|
|
70
|
+
connectionMode: { type: "string", enum: ["stream", "webhook"] },
|
|
71
|
+
webhookPath: { type: "string" },
|
|
72
|
+
webhookPort: { type: "integer", minimum: 1 },
|
|
73
|
+
dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
|
|
74
|
+
allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
|
|
75
|
+
groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
|
|
76
|
+
groupAllowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
|
|
77
|
+
requireMention: { type: "boolean" },
|
|
78
|
+
historyLimit: { type: "integer", minimum: 0 },
|
|
79
|
+
dmHistoryLimit: { type: "integer", minimum: 0 },
|
|
80
|
+
textChunkLimit: { type: "integer", minimum: 1 },
|
|
81
|
+
chunkMode: { type: "string", enum: ["length", "newline"] },
|
|
82
|
+
mediaMaxMb: { type: "number", minimum: 0 },
|
|
83
|
+
renderMode: { type: "string", enum: ["auto", "raw", "card"] },
|
|
84
|
+
cooldownMs: { type: "integer", minimum: 0 },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
config: {
|
|
89
|
+
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
90
|
+
resolveAccount: (cfg) => resolveDingTalkAccount({ cfg }),
|
|
91
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
92
|
+
setAccountEnabled: ({ cfg, enabled }) => ({
|
|
93
|
+
...cfg,
|
|
94
|
+
channels: {
|
|
95
|
+
...cfg.channels,
|
|
96
|
+
dingtalk: {
|
|
97
|
+
...cfg.channels?.dingtalk,
|
|
98
|
+
enabled,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
deleteAccount: ({ cfg }) => {
|
|
103
|
+
const next = { ...cfg } as ClawdbotConfig;
|
|
104
|
+
const nextChannels = { ...cfg.channels };
|
|
105
|
+
delete (nextChannels as Record<string, unknown>).dingtalk;
|
|
106
|
+
if (Object.keys(nextChannels).length > 0) {
|
|
107
|
+
next.channels = nextChannels;
|
|
108
|
+
} else {
|
|
109
|
+
delete next.channels;
|
|
110
|
+
}
|
|
111
|
+
return next;
|
|
112
|
+
},
|
|
113
|
+
isConfigured: (_account, cfg) =>
|
|
114
|
+
Boolean(resolveDingTalkCredentials(cfg.channels?.dingtalk as DingTalkConfig | undefined)),
|
|
115
|
+
describeAccount: (account) => ({
|
|
116
|
+
accountId: account.accountId,
|
|
117
|
+
enabled: account.enabled,
|
|
118
|
+
configured: account.configured,
|
|
119
|
+
}),
|
|
120
|
+
resolveAllowFrom: ({ cfg }) =>
|
|
121
|
+
(cfg.channels?.dingtalk as DingTalkConfig | undefined)?.allowFrom ?? [],
|
|
122
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
123
|
+
allowFrom
|
|
124
|
+
.map((entry) => String(entry).trim())
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.map((entry) => entry.toLowerCase()),
|
|
127
|
+
},
|
|
128
|
+
security: {
|
|
129
|
+
collectWarnings: ({ cfg }) => {
|
|
130
|
+
const dingtalkCfg = cfg.channels?.dingtalk as DingTalkConfig | undefined;
|
|
131
|
+
const defaultGroupPolicy = (cfg.channels as Record<string, { groupPolicy?: string }> | undefined)?.defaults?.groupPolicy;
|
|
132
|
+
const groupPolicy = dingtalkCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
|
133
|
+
if (groupPolicy !== "open") return [];
|
|
134
|
+
return [
|
|
135
|
+
`- DingTalk groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.dingtalk.groupPolicy="allowlist" + channels.dingtalk.groupAllowFrom to restrict senders.`,
|
|
136
|
+
];
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
setup: {
|
|
140
|
+
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
141
|
+
applyAccountConfig: ({ cfg }) => ({
|
|
142
|
+
...cfg,
|
|
143
|
+
channels: {
|
|
144
|
+
...cfg.channels,
|
|
145
|
+
dingtalk: {
|
|
146
|
+
...cfg.channels?.dingtalk,
|
|
147
|
+
enabled: true,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
},
|
|
152
|
+
onboarding: dingtalkOnboardingAdapter,
|
|
153
|
+
messaging: {
|
|
154
|
+
normalizeTarget: normalizeDingTalkTarget,
|
|
155
|
+
targetResolver: {
|
|
156
|
+
looksLikeId: looksLikeDingTalkId,
|
|
157
|
+
hint: "<conversationId|user:staffId>",
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
directory: {
|
|
161
|
+
self: async () => null,
|
|
162
|
+
listPeers: async ({ cfg, query, limit }) =>
|
|
163
|
+
listDingTalkDirectoryPeers({ cfg, query, limit }),
|
|
164
|
+
listGroups: async ({ cfg, query, limit }) =>
|
|
165
|
+
listDingTalkDirectoryGroups({ cfg, query, limit }),
|
|
166
|
+
listPeersLive: async ({ cfg, query, limit }) =>
|
|
167
|
+
listDingTalkDirectoryPeersLive({ cfg, query, limit }),
|
|
168
|
+
listGroupsLive: async ({ cfg, query, limit }) =>
|
|
169
|
+
listDingTalkDirectoryGroupsLive({ cfg, query, limit }),
|
|
170
|
+
},
|
|
171
|
+
outbound: dingtalkOutbound,
|
|
172
|
+
status: {
|
|
173
|
+
defaultRuntime: {
|
|
174
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
175
|
+
running: false,
|
|
176
|
+
lastStartAt: null,
|
|
177
|
+
lastStopAt: null,
|
|
178
|
+
lastError: null,
|
|
179
|
+
port: null,
|
|
180
|
+
},
|
|
181
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
182
|
+
configured: snapshot.configured ?? false,
|
|
183
|
+
running: snapshot.running ?? false,
|
|
184
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
185
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
186
|
+
lastError: snapshot.lastError ?? null,
|
|
187
|
+
port: snapshot.port ?? null,
|
|
188
|
+
probe: snapshot.probe,
|
|
189
|
+
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
190
|
+
}),
|
|
191
|
+
probeAccount: async ({ cfg }) =>
|
|
192
|
+
await probeDingTalk(cfg.channels?.dingtalk as DingTalkConfig | undefined),
|
|
193
|
+
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
|
194
|
+
accountId: account.accountId,
|
|
195
|
+
enabled: account.enabled,
|
|
196
|
+
configured: account.configured,
|
|
197
|
+
running: runtime?.running ?? false,
|
|
198
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
199
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
200
|
+
lastError: runtime?.lastError ?? null,
|
|
201
|
+
port: runtime?.port ?? null,
|
|
202
|
+
probe,
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
205
|
+
gateway: {
|
|
206
|
+
startAccount: async (ctx) => {
|
|
207
|
+
const { monitorDingTalkProvider } = await import("./monitor.js");
|
|
208
|
+
const dingtalkCfg = ctx.cfg.channels?.dingtalk as DingTalkConfig | undefined;
|
|
209
|
+
const port = dingtalkCfg?.webhookPort ?? null;
|
|
210
|
+
ctx.setStatus({ accountId: ctx.accountId, port });
|
|
211
|
+
ctx.log?.info(`starting dingtalk provider (mode: ${dingtalkCfg?.connectionMode ?? "stream"})`);
|
|
212
|
+
return monitorDingTalkProvider({
|
|
213
|
+
config: ctx.cfg,
|
|
214
|
+
runtime: ctx.runtime,
|
|
215
|
+
abortSignal: ctx.abortSignal,
|
|
216
|
+
accountId: ctx.accountId,
|
|
217
|
+
});
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
};
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { DWClient } from "dingtalk-stream";
|
|
2
|
+
import type { DingTalkConfig } from "./types.js";
|
|
3
|
+
import { resolveDingTalkCredentials } from "./accounts.js";
|
|
4
|
+
|
|
5
|
+
let cachedClient: DWClient | null = null;
|
|
6
|
+
let cachedConfig: { appKey: string; appSecret: string } | null = null;
|
|
7
|
+
|
|
8
|
+
export function createDingTalkClient(cfg: DingTalkConfig): DWClient {
|
|
9
|
+
const creds = resolveDingTalkCredentials(cfg);
|
|
10
|
+
if (!creds) {
|
|
11
|
+
throw new Error("DingTalk credentials not configured (appKey, appSecret required)");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (
|
|
15
|
+
cachedClient &&
|
|
16
|
+
cachedConfig &&
|
|
17
|
+
cachedConfig.appKey === creds.appKey &&
|
|
18
|
+
cachedConfig.appSecret === creds.appSecret
|
|
19
|
+
) {
|
|
20
|
+
return cachedClient;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const client = new DWClient({
|
|
24
|
+
clientId: creds.appKey,
|
|
25
|
+
clientSecret: creds.appSecret,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
cachedClient = client;
|
|
29
|
+
cachedConfig = { appKey: creds.appKey, appSecret: creds.appSecret };
|
|
30
|
+
|
|
31
|
+
return client;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clearClientCache() {
|
|
35
|
+
if (cachedClient) {
|
|
36
|
+
try {
|
|
37
|
+
cachedClient.disconnect();
|
|
38
|
+
} catch {
|
|
39
|
+
// Ignore disconnect errors
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
cachedClient = null;
|
|
43
|
+
cachedConfig = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function getAccessToken(cfg: DingTalkConfig): Promise<string> {
|
|
47
|
+
const client = createDingTalkClient(cfg);
|
|
48
|
+
return await client.getAccessToken();
|
|
49
|
+
}
|