@largezhou/ddingtalk 1.3.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/src/channel.ts ADDED
@@ -0,0 +1,512 @@
1
+ import {
2
+ buildChannelConfigSchema,
3
+ DEFAULT_ACCOUNT_ID,
4
+ formatPairingApproveHint,
5
+ loadWebMedia,
6
+ missingTargetError,
7
+ type ChannelPlugin,
8
+ type ChannelStatusIssue,
9
+ type ChannelAccountSnapshot,
10
+ type OpenClawConfig,
11
+ } from "openclaw/plugin-sdk";
12
+ import path from "path";
13
+ import { getDingTalkRuntime } from "./runtime.js";
14
+ import {
15
+ listDingTalkAccountIds,
16
+ normalizeAccountId,
17
+ resolveDefaultDingTalkAccountId,
18
+ resolveDingTalkAccount,
19
+ } from "./accounts.js";
20
+ import { DingTalkConfigSchema, type DingTalkConfig, type ResolvedDingTalkAccount, type DingTalkGroupConfig } from "./types.js";
21
+ import { sendTextMessage, sendImageMessage, sendFileMessage, uploadMedia, probeDingTalkBot, inferMediaType, isGroupTarget } from "./client.js";
22
+ import { logger } from "./logger.js";
23
+ import { monitorDingTalkProvider } from "./monitor.js";
24
+ import { dingtalkOnboardingAdapter } from "./onboarding.js";
25
+ import { PLUGIN_ID } from "./constants.js";
26
+
27
+ // ======================= Target Normalization =======================
28
+
29
+ /**
30
+ * 标准化钉钉发送目标
31
+ * 支持格式:
32
+ * - 原始用户 ID
33
+ * - ddingtalk:user:<userId> → <userId>
34
+ * - ddingtalk:chat:<groupId> → chat:<groupId>(保留 chat: 前缀用于群聊路由)
35
+ * - ddingtalk:<id>
36
+ * - chat:<groupId>(直接群聊格式)
37
+ * - user:<userId>
38
+ */
39
+ function normalizeDingTalkTarget(target: string): string | undefined {
40
+ const trimmed = target.trim();
41
+ if (!trimmed) {
42
+ return undefined;
43
+ }
44
+
45
+ // 处理 ddingtalk:chat:<groupId> → chat:<groupId>
46
+ const chatPrefixPattern = new RegExp(`^${PLUGIN_ID}:chat:`, "i");
47
+ if (chatPrefixPattern.test(trimmed)) {
48
+ const groupId = trimmed.replace(chatPrefixPattern, "");
49
+ return groupId ? `chat:${groupId}` : undefined;
50
+ }
51
+
52
+ // 处理 chat:<groupId>(直接保留)
53
+ if (trimmed.startsWith("chat:")) {
54
+ return trimmed.slice(5) ? trimmed : undefined;
55
+ }
56
+
57
+ // 去除 ddingtalk:user: 或 ddingtalk: 前缀
58
+ const prefixPattern = new RegExp(`^${PLUGIN_ID}:(?:user:)?`, "i");
59
+ const withoutPrefix = trimmed.replace(prefixPattern, "");
60
+
61
+ // 去除 user: 前缀
62
+ const userId = withoutPrefix.replace(/^user:/, "");
63
+
64
+ if (!userId) {
65
+ return undefined;
66
+ }
67
+
68
+ // 验证格式:钉钉 ID 一般是字母数字组合
69
+ if (/^[a-zA-Z0-9_$+-]+$/i.test(userId)) {
70
+ return userId;
71
+ }
72
+
73
+ return undefined;
74
+ }
75
+
76
+ // DingTalk channel metadata
77
+ const meta = {
78
+ id: PLUGIN_ID,
79
+ label: "DingTalk",
80
+ selectionLabel: "DingTalk (钉钉 Stream)",
81
+ detailLabel: "钉钉机器人",
82
+ docsPath: `/channels/${PLUGIN_ID}`,
83
+ docsLabel: PLUGIN_ID,
84
+ blurb: "DingTalk enterprise robot with Stream mode for Chinese market.",
85
+ systemImage: "message.fill",
86
+ aliases: ["dingding", "钉钉"],
87
+ };
88
+
89
+ export const dingtalkPlugin: ChannelPlugin<ResolvedDingTalkAccount> = {
90
+ id: PLUGIN_ID,
91
+ meta,
92
+ onboarding: dingtalkOnboardingAdapter,
93
+ capabilities: {
94
+ chatTypes: ["direct", "group"],
95
+ reactions: false,
96
+ threads: false,
97
+ media: true,
98
+ nativeCommands: false,
99
+ blockStreaming: true, // 钉钉不支持流式消息
100
+ },
101
+ commands: {
102
+ enforceOwnerForCommands: true,
103
+ },
104
+ groups: {
105
+ resolveToolPolicy: ({ cfg, groupId }) => {
106
+ if (!groupId) return undefined;
107
+ const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
108
+ const groups = dingtalkConfig.groups;
109
+ if (!groups) return undefined;
110
+ const key = Object.keys(groups).find(
111
+ (k) => k === groupId || k.toLowerCase() === groupId.toLowerCase()
112
+ );
113
+ return key ? groups[key]?.tools : undefined;
114
+ },
115
+ },
116
+ reload: { configPrefixes: [`channels.${PLUGIN_ID}`] },
117
+ configSchema: buildChannelConfigSchema(DingTalkConfigSchema),
118
+ config: {
119
+ listAccountIds: (cfg) => listDingTalkAccountIds(cfg),
120
+ resolveAccount: (cfg, _accountId) => resolveDingTalkAccount({ cfg }),
121
+ defaultAccountId: (_cfg) => resolveDefaultDingTalkAccountId(_cfg),
122
+ setAccountEnabled: ({ cfg, enabled }) => {
123
+ const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
124
+ return {
125
+ ...cfg,
126
+ channels: {
127
+ ...cfg.channels,
128
+ [PLUGIN_ID]: {
129
+ ...dingtalkConfig,
130
+ enabled,
131
+ },
132
+ },
133
+ };
134
+ },
135
+ deleteAccount: ({ cfg }) => {
136
+ const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
137
+ const { clientId, clientSecret, ...rest } = dingtalkConfig;
138
+ return {
139
+ ...cfg,
140
+ channels: {
141
+ ...cfg.channels,
142
+ [PLUGIN_ID]: rest,
143
+ },
144
+ };
145
+ },
146
+ isConfigured: (account) => Boolean(account.clientId?.trim() && account.clientSecret?.trim()),
147
+ describeAccount: (account) => ({
148
+ accountId: account.accountId,
149
+ name: account.name,
150
+ enabled: account.enabled,
151
+ configured: Boolean(account.clientId?.trim() && account.clientSecret?.trim()),
152
+ tokenSource: account.tokenSource,
153
+ }),
154
+ resolveAllowFrom: ({ cfg }) =>
155
+ resolveDingTalkAccount({ cfg }).allowFrom.map((entry) => String(entry)),
156
+ formatAllowFrom: ({ allowFrom }) =>
157
+ allowFrom
158
+ .map((entry) => String(entry).trim())
159
+ .filter(Boolean)
160
+ .map((entry) => entry.replace(new RegExp(`^${PLUGIN_ID}:(?:user:)?`, "i"), "")),
161
+ },
162
+ security: {
163
+ resolveDmPolicy: ({ cfg }) => {
164
+ const account = resolveDingTalkAccount({ cfg });
165
+ return {
166
+ policy: "allowlist",
167
+ allowFrom: account.allowFrom,
168
+ policyPath: `channels.${PLUGIN_ID}.allowFrom`,
169
+ allowFromPath: `channels.${PLUGIN_ID}.`,
170
+ approveHint: formatPairingApproveHint(PLUGIN_ID),
171
+ normalizeEntry: (raw) => raw.replace(new RegExp(`^${PLUGIN_ID}:(?:user:)?`, "i"), ""),
172
+ };
173
+ },
174
+ },
175
+ messaging: {
176
+ normalizeTarget: (target) => {
177
+ const trimmed = target.trim();
178
+ if (!trimmed) {
179
+ return undefined;
180
+ }
181
+ return normalizeDingTalkTarget(trimmed);
182
+ },
183
+ targetResolver: {
184
+ looksLikeId: (id) => {
185
+ const trimmed = id?.trim();
186
+ if (!trimmed) {
187
+ return false;
188
+ }
189
+ // 钉钉用户 ID 或群聊 ID
190
+ const prefixPattern = new RegExp(`^${PLUGIN_ID}:`, "i");
191
+ return /^[a-zA-Z0-9_-]+$/i.test(trimmed)
192
+ || prefixPattern.test(trimmed)
193
+ || trimmed.startsWith("chat:")
194
+ || trimmed.startsWith("user:");
195
+ },
196
+ hint: "<userId> or chat:<openConversationId>",
197
+ },
198
+ },
199
+
200
+ setup: {
201
+ resolveAccountId: () => normalizeAccountId(),
202
+ applyAccountName: ({ cfg, name }) => {
203
+ const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
204
+ return {
205
+ ...cfg,
206
+ channels: {
207
+ ...cfg.channels,
208
+ [PLUGIN_ID]: {
209
+ ...dingtalkConfig,
210
+ name,
211
+ },
212
+ },
213
+ };
214
+ },
215
+ validateInput: ({ input }) => {
216
+ const typedInput = input as {
217
+ clientId?: string;
218
+ clientSecret?: string;
219
+ };
220
+ if (!typedInput.clientId) {
221
+ return "DingTalk requires clientId.";
222
+ }
223
+ if (!typedInput.clientSecret) {
224
+ return "DingTalk requires clientSecret.";
225
+ }
226
+ return null;
227
+ },
228
+ applyAccountConfig: ({ cfg, input }) => {
229
+ const typedInput = input as {
230
+ name?: string;
231
+ clientId?: string;
232
+ clientSecret?: string;
233
+ };
234
+ const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
235
+
236
+ return {
237
+ ...cfg,
238
+ channels: {
239
+ ...cfg.channels,
240
+ [PLUGIN_ID]: {
241
+ ...dingtalkConfig,
242
+ enabled: true,
243
+ ...(typedInput.name ? { name: typedInput.name } : {}),
244
+ ...(typedInput.clientId ? { clientId: typedInput.clientId } : {}),
245
+ ...(typedInput.clientSecret ? { clientSecret: typedInput.clientSecret } : {}),
246
+ },
247
+ },
248
+ };
249
+ },
250
+ },
251
+ outbound: {
252
+ deliveryMode: "direct",
253
+ chunker: (text, limit) => getDingTalkRuntime().channel.text.chunkMarkdownText(text, limit),
254
+ textChunkLimit: 4000, // 钉钉文本消息长度限制
255
+ /**
256
+ * 解析发送目标
257
+ * 支持以下格式:
258
+ * - 用户 ID:直接是用户的 staffId
259
+ * - 带前缀格式:ddingtalk:user:<userId>
260
+ * - 群聊格式:chat:<openConversationId> 或 ddingtalk:chat:<openConversationId>
261
+ */
262
+ resolveTarget: ({ to, allowFrom, mode }) => {
263
+ const trimmed = to?.trim() ?? "";
264
+
265
+ // 如果目标是群聊格式,直接使用(群聊回复时 To 已经是 chat:xxx 格式)
266
+ if (trimmed.startsWith("chat:") || trimmed.startsWith(`${PLUGIN_ID}:chat:`)) {
267
+ const normalized = normalizeDingTalkTarget(trimmed);
268
+ if (normalized) {
269
+ return { ok: true, to: normalized };
270
+ }
271
+ }
272
+
273
+ const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
274
+ const hasWildcard = allowListRaw.includes("*");
275
+ const allowList = allowListRaw
276
+ .filter((entry) => entry !== "*")
277
+ .map((entry) => normalizeDingTalkTarget(entry))
278
+ .filter((entry): entry is string => Boolean(entry));
279
+
280
+ // 有指定目标
281
+ if (trimmed) {
282
+ const normalizedTo = normalizeDingTalkTarget(trimmed);
283
+
284
+ if (!normalizedTo) {
285
+ if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) {
286
+ return { ok: true, to: allowList[0] };
287
+ }
288
+ return {
289
+ ok: false,
290
+ error: missingTargetError(
291
+ "DingTalk",
292
+ `<userId>, chat:<groupId> 或 channels.${PLUGIN_ID}.allowFrom[0]`,
293
+ ),
294
+ };
295
+ }
296
+
297
+ if (mode === "explicit") {
298
+ return { ok: true, to: normalizedTo };
299
+ }
300
+
301
+ if (mode === "implicit" || mode === "heartbeat") {
302
+ if (hasWildcard || allowList.length === 0) {
303
+ return { ok: true, to: normalizedTo };
304
+ }
305
+ if (allowList.includes(normalizedTo)) {
306
+ return { ok: true, to: normalizedTo };
307
+ }
308
+ return { ok: true, to: allowList[0] };
309
+ }
310
+
311
+ return { ok: true, to: normalizedTo };
312
+ }
313
+
314
+ // 没有指定目标
315
+ if (allowList.length > 0) {
316
+ return { ok: true, to: allowList[0] };
317
+ }
318
+
319
+ return {
320
+ ok: false,
321
+ error: missingTargetError(
322
+ "DingTalk",
323
+ `<userId>, chat:<groupId> 或 channels.${PLUGIN_ID}.allowFrom[0]`,
324
+ ),
325
+ };
326
+ },
327
+ sendText: async ({ to, text, cfg }) => {
328
+ const account = resolveDingTalkAccount({ cfg });
329
+ const result = await sendTextMessage(to, text, { account });
330
+ return { channel: PLUGIN_ID, ...result };
331
+ },
332
+ sendMedia: async ({ to, text, mediaUrl, cfg }) => {
333
+ // 没有媒体 URL,提前返回
334
+ if (!mediaUrl) {
335
+ logger.warn("[sendMedia] 没有 mediaUrl,跳过");
336
+ return { channel: PLUGIN_ID, messageId: "", chatId: to };
337
+ }
338
+
339
+ const account = resolveDingTalkAccount({ cfg });
340
+
341
+ try {
342
+ logger.log(`准备发送媒体: ${mediaUrl}`);
343
+
344
+ // 使用 OpenClaw 的 loadWebMedia 加载媒体(支持 URL、本地路径、file://、~ 等)
345
+ const media = await loadWebMedia(mediaUrl);
346
+ const mimeType = media.contentType ?? "application/octet-stream";
347
+ const mediaType = inferMediaType(mimeType);
348
+
349
+ logger.log(`加载媒体成功 | type: ${mediaType} | mimeType: ${mimeType} | size: ${(media.buffer.length / 1024).toFixed(2)} KB`);
350
+
351
+ // 上传到钉钉
352
+ const fileName = media.fileName || path.basename(mediaUrl) || `file_${Date.now()}`;
353
+ const uploadResult = await uploadMedia(media.buffer, fileName, account, {
354
+ mimeType,
355
+ type: mediaType,
356
+ });
357
+
358
+ // 统一使用文件发送(语音/视频因格式限制和参数要求,也降级为文件)
359
+ const ext = path.extname(fileName).slice(1) || "file";
360
+ let sendResult: { messageId: string; chatId: string };
361
+
362
+ if (mediaType === "image") {
363
+ // 图片使用 photoURL
364
+ sendResult = await sendImageMessage(to, uploadResult.url, { account });
365
+ } else {
366
+ // 语音、视频、文件统一使用文件发送
367
+ sendResult = await sendFileMessage(to, uploadResult.mediaId, fileName, ext, { account });
368
+ }
369
+
370
+ logger.log(`发送${mediaType}消息成功(${mediaType !== "image" ? "文件形式" : "图片形式"})`);
371
+
372
+ // 如果有文本,再发送文本消息
373
+ if (text?.trim()) {
374
+ await sendTextMessage(to, text, { account });
375
+ }
376
+
377
+ return { channel: PLUGIN_ID, ...sendResult };
378
+ } catch (err) {
379
+ logger.error("发送媒体失败:", err);
380
+ // 降级:发送文本消息附带链接
381
+ const fallbackText = text ? `${text}\n\n📎 附件: ${mediaUrl}` : `📎 附件: ${mediaUrl}`;
382
+ const result = await sendTextMessage(to, fallbackText, { account });
383
+ return { channel: PLUGIN_ID, ...result };
384
+ }
385
+ },
386
+ },
387
+ status: {
388
+ defaultRuntime: {
389
+ accountId: DEFAULT_ACCOUNT_ID,
390
+ running: false,
391
+ lastStartAt: null,
392
+ lastStopAt: null,
393
+ lastError: null,
394
+ },
395
+ collectStatusIssues: (accounts: ChannelAccountSnapshot[]) => {
396
+ const issues: ChannelStatusIssue[] = [];
397
+ for (const account of accounts) {
398
+ const accountId = account.accountId ?? DEFAULT_ACCOUNT_ID;
399
+ // Check if configured flag is false
400
+ if (!account.configured) {
401
+ issues.push({
402
+ channel: PLUGIN_ID,
403
+ accountId,
404
+ kind: "config",
405
+ message: "DingTalk credentials (clientId/clientSecret) not configured",
406
+ });
407
+ }
408
+ }
409
+ return issues;
410
+ },
411
+ buildChannelSummary: ({ snapshot }) => ({
412
+ configured: snapshot.configured ?? false,
413
+ tokenSource: snapshot.tokenSource ?? "none",
414
+ running: snapshot.running ?? false,
415
+ mode: snapshot.mode ?? null,
416
+ lastStartAt: snapshot.lastStartAt ?? null,
417
+ lastStopAt: snapshot.lastStopAt ?? null,
418
+ lastError: snapshot.lastError ?? null,
419
+ probe: snapshot.probe,
420
+ lastProbeAt: snapshot.lastProbeAt ?? null,
421
+ }),
422
+ probeAccount: async ({ account, timeoutMs }) => probeDingTalkBot(account, timeoutMs),
423
+ buildAccountSnapshot: ({ account, runtime, probe }) => {
424
+ const configured = Boolean(account.clientId?.trim() && account.clientSecret?.trim());
425
+ return {
426
+ accountId: account.accountId,
427
+ name: account.name,
428
+ enabled: account.enabled,
429
+ configured,
430
+ tokenSource: account.tokenSource,
431
+ running: runtime?.running ?? false,
432
+ lastStartAt: runtime?.lastStartAt ?? null,
433
+ lastStopAt: runtime?.lastStopAt ?? null,
434
+ lastError: runtime?.lastError ?? null,
435
+ mode: "stream",
436
+ probe,
437
+ lastInboundAt: runtime?.lastInboundAt ?? null,
438
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
439
+ };
440
+ },
441
+ },
442
+ gateway: {
443
+ startAccount: async (ctx) => {
444
+ const account = ctx.account;
445
+ const clientId = account.clientId.trim();
446
+ const clientSecret = account.clientSecret.trim();
447
+
448
+ let botLabel = "";
449
+ try {
450
+ const probe = await probeDingTalkBot(account, 2500);
451
+ const displayName = probe.ok ? probe.bot?.name?.trim() : null;
452
+ if (displayName) {
453
+ botLabel = ` (${displayName})`;
454
+ }
455
+ } catch (err) {
456
+ if (getDingTalkRuntime().logging.shouldLogVerbose()) {
457
+ ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
458
+ }
459
+ }
460
+
461
+ ctx.log?.info(`[${account.accountId}] starting DingTalk provider${botLabel}`);
462
+
463
+ return monitorDingTalkProvider({
464
+ clientId,
465
+ clientSecret,
466
+ accountId: account.accountId,
467
+ config: ctx.cfg,
468
+ runtime: ctx.runtime,
469
+ abortSignal: ctx.abortSignal,
470
+ });
471
+ },
472
+ logoutAccount: async ({ cfg }) => {
473
+ const nextCfg = { ...cfg } as OpenClawConfig;
474
+ const dingtalkConfig = (cfg.channels?.[PLUGIN_ID] ?? {}) as DingTalkConfig;
475
+ const nextDingTalk = { ...dingtalkConfig };
476
+ let cleared = false;
477
+ let changed = false;
478
+
479
+ if (
480
+ nextDingTalk.clientId ||
481
+ nextDingTalk.clientSecret
482
+ ) {
483
+ delete nextDingTalk.clientId;
484
+ delete nextDingTalk.clientSecret;
485
+ cleared = true;
486
+ changed = true;
487
+ }
488
+
489
+ if (changed) {
490
+ if (Object.keys(nextDingTalk).length > 0) {
491
+ nextCfg.channels = { ...nextCfg.channels, [PLUGIN_ID]: nextDingTalk };
492
+ } else {
493
+ const nextChannels = { ...nextCfg.channels };
494
+ delete (nextChannels as Record<string, unknown>)[PLUGIN_ID];
495
+ if (Object.keys(nextChannels).length > 0) {
496
+ nextCfg.channels = nextChannels;
497
+ } else {
498
+ delete nextCfg.channels;
499
+ }
500
+ }
501
+ await getDingTalkRuntime().config.writeConfigFile(nextCfg);
502
+ }
503
+
504
+ const resolved = resolveDingTalkAccount({
505
+ cfg: changed ? nextCfg : cfg,
506
+ });
507
+ const loggedOut = resolved.tokenSource === "none";
508
+
509
+ return { cleared, envToken: false, loggedOut };
510
+ },
511
+ },
512
+ };