@xuanmiss-npm/dingtalk 0.1.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/send.ts ADDED
@@ -0,0 +1,429 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { resolveDingtalkAccount } from "./config.js";
3
+ import { getDingtalkClient } from "./client-registry.js";
4
+
5
+ // ============================================================================
6
+ // 类型定义
7
+ // ============================================================================
8
+
9
+ export type SendDingtalkOptions = {
10
+ cfg?: OpenClawConfig;
11
+ accountId?: string;
12
+ mediaUrl?: string;
13
+ atUsers?: string[];
14
+ conversationType?: "single" | "group";
15
+ };
16
+
17
+ export type SendDingtalkResult = {
18
+ ok: boolean;
19
+ messageId: string;
20
+ processQueryKey?: string;
21
+ error?: string;
22
+ };
23
+
24
+ // ============================================================================
25
+ // 辅助工具
26
+ // ============================================================================
27
+
28
+ /**
29
+ * 判断字符串是否包含 Markdown 语法
30
+ */
31
+ export function isMarkdown(text: string): boolean {
32
+ // 钉钉 Markdown 支持的语法检测:标题、加粗、链接、图片、代码、引用、列表
33
+ const markdownRegex = /(^#|[*_]{1,3}|\[.*\]\(.*\)|!\[.*\]\(.*\)|`.*`|^>|^\s*[-*+]\s|^\s*\d+\.\s)/m;
34
+ return markdownRegex.test(text);
35
+ }
36
+
37
+ /**
38
+ * 格式化提及用户
39
+ * 钉钉 Markdown 消息中,提及用户需要在文本中包含 @userId 或 @mobile
40
+ */
41
+ export function formatMentions(text: string, atUsers?: string[]): string {
42
+ // 钉钉 Markdown 换行建议使用 \n\n
43
+ let formattedText = text.replace(/\n/g, "\n\n");
44
+
45
+ if (!atUsers || atUsers.length === 0) {
46
+ return formattedText;
47
+ }
48
+
49
+ let mentionStr = "";
50
+ if (atUsers.includes("all")) {
51
+ mentionStr = " @所有人";
52
+ } else {
53
+ mentionStr = " " + atUsers.map((id) => `@${id}`).join(" ");
54
+ }
55
+
56
+ return formattedText.trim() + "\n\n" + mentionStr;
57
+ }
58
+
59
+ /**
60
+ * 分离手机号和用户ID
61
+ */
62
+ export function parseAtList(atUsers: string[] = []) {
63
+ const mobiles: string[] = [];
64
+ const userIds: string[] = [];
65
+ let isAtAll = false;
66
+
67
+ for (const id of atUsers) {
68
+ if (id === "all") {
69
+ isAtAll = true;
70
+ } else if (/^\d{11}$/.test(id)) {
71
+ mobiles.push(id);
72
+ } else {
73
+ userIds.push(id);
74
+ }
75
+ }
76
+ return { mobiles, userIds, isAtAll };
77
+ }
78
+
79
+ /**
80
+ * 获取钉钉访问令牌
81
+ * 优先从活跃的 DWClient 中获取,否则手动获取
82
+ */
83
+ export async function getDingtalkAccessToken(
84
+ clientId: string,
85
+ clientSecret: string,
86
+ accountId?: string
87
+ ): Promise<string> {
88
+ // 尝试从注册的 Client 中获取 (SDK 自动管理刷新)
89
+ if (accountId) {
90
+ const client = getDingtalkClient(accountId);
91
+ if (client) {
92
+ return await client.getAccessToken();
93
+ }
94
+ }
95
+
96
+ const response = await fetch(`https://api.dingtalk.com/v1.0/oauth2/accessToken`, {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify({
100
+ appKey: clientId,
101
+ appSecret: clientSecret,
102
+ }),
103
+ });
104
+
105
+ if (!response.ok) {
106
+ const error = await response.text();
107
+ throw new Error(`Failed to get access token: ${response.status} - ${error}`);
108
+ }
109
+
110
+ const data = (await response.json()) as {
111
+ accessToken?: string;
112
+ };
113
+
114
+ if (!data.accessToken) {
115
+ throw new Error("No access token in response");
116
+ }
117
+
118
+ return data.accessToken;
119
+ }
120
+
121
+ // ============================================================================
122
+ // 消息发送
123
+ // ============================================================================
124
+
125
+ /**
126
+ * 发送钉钉消息(单聊)- 使用指定的凭证
127
+ */
128
+ export async function sendMessageDingtalkToUserWithCredentials(
129
+ userId: string,
130
+ text: string,
131
+ credentials: { clientId: string; clientSecret: string; robotCode?: string },
132
+ options: SendDingtalkOptions = {},
133
+ ): Promise<SendDingtalkResult> {
134
+ const { clientId, clientSecret, robotCode } = credentials;
135
+
136
+ if (!clientId || !clientSecret) {
137
+ return { ok: false, messageId: "", error: "DingTalk credentials not configured" };
138
+ }
139
+
140
+ try {
141
+ const accessToken = await getDingtalkAccessToken(clientId, clientSecret, options.accountId);
142
+
143
+ const response = await fetch(`https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend`, {
144
+ method: "POST",
145
+ headers: {
146
+ "Content-Type": "application/json",
147
+ "x-acs-dingtalk-access-token": accessToken,
148
+ },
149
+ body: JSON.stringify({
150
+ robotCode: robotCode || clientId,
151
+ userIds: [userId],
152
+ msgKey: "sampleMarkdown",
153
+ msgParam: JSON.stringify({
154
+ title: "DingTalk Message",
155
+ text: formatMentions(text, options.atUsers)
156
+ }),
157
+ }),
158
+ });
159
+ if (!response.ok) {
160
+ const error = await response.text();
161
+ return { ok: false, messageId: "", error: `HTTP ${response.status}: ${error}` };
162
+ }
163
+
164
+ const result = (await response.json()) as { processQueryKey?: string };
165
+ return {
166
+ ok: true,
167
+ messageId: result.processQueryKey || `dingtalk-${Date.now()}`,
168
+ processQueryKey: result.processQueryKey,
169
+ };
170
+ } catch (err) {
171
+ return { ok: false, messageId: "", error: String(err) };
172
+ }
173
+ }
174
+
175
+ /**
176
+ * 发送钉钉消息(群聊)- 使用指定的凭证和 conversationId
177
+ */
178
+ export async function sendMessageDingtalkToGroupWithCredentials(
179
+ conversationId: string,
180
+ text: string,
181
+ credentials: { clientId: string; clientSecret: string; robotCode?: string },
182
+ options: SendDingtalkOptions = {},
183
+ ): Promise<SendDingtalkResult> {
184
+ const { clientId, clientSecret, robotCode } = credentials;
185
+
186
+ if (!clientId || !clientSecret) {
187
+ return { ok: false, messageId: "", error: "DingTalk credentials not configured" };
188
+ }
189
+
190
+ try {
191
+ const accessToken = await getDingtalkAccessToken(clientId, clientSecret, options.accountId);
192
+ const msgParamObj: any = {
193
+ title: "OpenClaw Notification",
194
+ text: formatMentions(text, options.atUsers)
195
+ };
196
+
197
+ const { mobiles, userIds, isAtAll } = parseAtList(options.atUsers);
198
+ const body: any = {
199
+ robotCode: robotCode || clientId,
200
+ openConversationId: conversationId,
201
+ msgKey: "sampleMarkdown",
202
+ msgParam: JSON.stringify(msgParamObj),
203
+ at: {
204
+ atUserIds: userIds,
205
+ atMobiles: mobiles,
206
+ isAtAll,
207
+ },
208
+ };
209
+
210
+ const response = await fetch(`https://api.dingtalk.com/v1.0/robot/groupMessages/send`, {
211
+ method: "POST",
212
+ headers: {
213
+ "Content-Type": "application/json",
214
+ "x-acs-dingtalk-access-token": accessToken,
215
+ },
216
+ body: JSON.stringify(body),
217
+ });
218
+
219
+ if (!response.ok) {
220
+ const error = await response.text();
221
+ return { ok: false, messageId: "", error: `HTTP ${response.status}: ${error}` };
222
+ }
223
+
224
+ const result = (await response.json()) as { processQueryKey?: string };
225
+ return {
226
+ ok: true,
227
+ messageId: result.processQueryKey || `dingtalk-group-${Date.now()}`,
228
+ processQueryKey: result.processQueryKey,
229
+ };
230
+ } catch (err) {
231
+ return { ok: false, messageId: "", error: String(err) };
232
+ }
233
+ }
234
+
235
+ /**
236
+ * 发送钉钉消息(单聊)
237
+ */
238
+ export async function sendMessageDingtalkToUser(
239
+ userId: string,
240
+ text: string,
241
+ options: SendDingtalkOptions = {},
242
+ ): Promise<SendDingtalkResult> {
243
+ if (!options.cfg) {
244
+ return { ok: false, messageId: "", error: "Config not provided" };
245
+ }
246
+
247
+ const account = resolveDingtalkAccount({
248
+ cfg: options.cfg,
249
+ accountId: options.accountId,
250
+ });
251
+
252
+ return sendMessageDingtalkToUserWithCredentials(userId, text, {
253
+ clientId: account.clientId,
254
+ clientSecret: account.clientSecret,
255
+ robotCode: account.robotCode,
256
+ }, options);
257
+ }
258
+
259
+ /**
260
+ * 发送钉钉消息(群聊/SessionWebhook - 通过Webhook回复)
261
+ */
262
+ export async function sendMessageDingtalkToGroup(
263
+ webhookUrl: string,
264
+ text: string,
265
+ options: SendDingtalkOptions = {},
266
+ ): Promise<SendDingtalkResult> {
267
+ if (!webhookUrl) {
268
+ return { ok: false, messageId: "", error: "Webhook URL required" };
269
+ }
270
+
271
+ try {
272
+ const messageBody: Record<string, any> = {
273
+ msgtype: "markdown",
274
+ markdown: {
275
+ title: "DingTalk Message",
276
+ text: formatMentions(text, options.atUsers),
277
+ },
278
+ };
279
+
280
+ const { mobiles, userIds, isAtAll } = parseAtList(options.atUsers);
281
+
282
+ // 添加@配置
283
+ messageBody.at = {
284
+ atUserIds: userIds,
285
+ atMobiles: mobiles,
286
+ isAtAll,
287
+ };
288
+
289
+ const headers: Record<string, string> = {
290
+ "Content-Type": "application/json"
291
+ };
292
+
293
+ // 如果提供了配置,尝试获取 AccessToken (SessionWebhook 需要)
294
+ if (options.cfg) {
295
+ try {
296
+ const account = resolveDingtalkAccount({
297
+ cfg: options.cfg,
298
+ accountId: options.accountId,
299
+ });
300
+ if (account.clientId && account.clientSecret) {
301
+ const accessToken = await getDingtalkAccessToken(account.clientId, account.clientSecret);
302
+ headers["x-acs-dingtalk-access-token"] = accessToken;
303
+ }
304
+ } catch (e) {
305
+ // 忽略错误,可能是普通的自定义机器人 Webhook
306
+ }
307
+ }
308
+
309
+ const response = await fetch(webhookUrl, {
310
+ method: "POST",
311
+ headers,
312
+ body: JSON.stringify(messageBody),
313
+ });
314
+
315
+ const debugBody = JSON.stringify(messageBody, null, 2);
316
+ console.log(`[DingTalk] Sending via Webhook: ${webhookUrl}`);
317
+ console.log(`[DingTalk] Payload: ${debugBody}`);
318
+ console.log(`[DingTalk] Headers: ${JSON.stringify(headers)}`);
319
+
320
+ if (!response.ok) {
321
+ const error = await response.text();
322
+ return { ok: false, messageId: "", error: `HTTP ${response.status}: ${error}` };
323
+ }
324
+
325
+ return { ok: true, messageId: `webhook-${Date.now()}` };
326
+ } catch (err) {
327
+ return { ok: false, messageId: "", error: String(err) };
328
+ }
329
+ }
330
+
331
+ /**
332
+ * 发送钉钉Markdown消息
333
+ */
334
+ export async function sendMarkdownDingtalk(
335
+ userId: string,
336
+ title: string,
337
+ text: string,
338
+ options: SendDingtalkOptions = {},
339
+ ): Promise<SendDingtalkResult> {
340
+ if (!options.cfg) {
341
+ return { ok: false, messageId: "", error: "Config not provided" };
342
+ }
343
+
344
+ const account = resolveDingtalkAccount({
345
+ cfg: options.cfg,
346
+ accountId: options.accountId,
347
+ });
348
+
349
+ if (!account.clientId || !account.clientSecret) {
350
+ return { ok: false, messageId: "", error: "DingTalk credentials not configured" };
351
+ }
352
+
353
+ try {
354
+ const accessToken = await getDingtalkAccessToken(account.clientId, account.clientSecret, options.accountId);
355
+
356
+ const response = await fetch(`https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend`, {
357
+ method: "POST",
358
+ headers: {
359
+ "Content-Type": "application/json",
360
+ "x-acs-dingtalk-access-token": accessToken,
361
+ },
362
+ body: JSON.stringify({
363
+ robotCode: account.robotCode || account.clientId,
364
+ userIds: [userId],
365
+ msgKey: "sampleMarkdown",
366
+ msgParam: JSON.stringify({ title, text }),
367
+ }),
368
+ });
369
+
370
+ if (!response.ok) {
371
+ const error = await response.text();
372
+ return { ok: false, messageId: "", error: `HTTP ${response.status}: ${error}` };
373
+ }
374
+
375
+ const result = (await response.json()) as { processQueryKey?: string };
376
+ return {
377
+ ok: true,
378
+ messageId: result.processQueryKey || `dingtalk-md-${Date.now()}`,
379
+ processQueryKey: result.processQueryKey,
380
+ };
381
+ } catch (err) {
382
+ return { ok: false, messageId: "", error: String(err) };
383
+ }
384
+ }
385
+
386
+ /**
387
+ * 通用消息发送接口
388
+ * 根据目标类型自动选择发送方式
389
+ */
390
+ export async function sendMessageDingtalk(
391
+ to: string,
392
+ text: string,
393
+ options: SendDingtalkOptions = {},
394
+ ): Promise<SendDingtalkResult> {
395
+ // 识别并处理 OpenClaw 统一样式的 Target 格式
396
+ let target = to;
397
+ if (target.startsWith("user:")) {
398
+ target = target.slice(5);
399
+ } else if (target.startsWith("group:")) {
400
+ target = target.slice(6);
401
+ } else if (target.startsWith("channel:")) {
402
+ target = target.slice(8);
403
+ }
404
+
405
+ console.log(`[DingTalk] Dispatching message. RawTo: "${to}", ResolvedTarget: "${target}"`);
406
+ console.log(`[DingTalk] Options: ${JSON.stringify(options)}`);
407
+
408
+ // 判断目标类型
409
+ // 1. Webhook URL (包括流模式的 sessionWebhook)
410
+ // 只要包含 http 字符(不一定是开头,防止带空格或特殊前缀),就视为 Webhook
411
+ if (target.includes("http://") || target.includes("https://")) {
412
+ console.log(`[DingTalk] Target identified as Webhook.`);
413
+ return sendMessageDingtalkToGroup(target, text, options);
414
+ }
415
+
416
+ // 2. ConversationId (通常以 cid 开头)
417
+ if (target.startsWith("cid")) {
418
+ if (!options.cfg) return { ok: false, messageId: "", error: "Config required for group API" };
419
+ const account = resolveDingtalkAccount({ cfg: options.cfg, accountId: options.accountId });
420
+ return sendMessageDingtalkToGroupWithCredentials(target, text, {
421
+ clientId: account.clientId,
422
+ clientSecret: account.clientSecret,
423
+ robotCode: account.robotCode,
424
+ }, options);
425
+ }
426
+
427
+ // 3. 默认视为用户ID (单聊)
428
+ return sendMessageDingtalkToUser(target, text, options);
429
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "outDir": "dist"
6
+ },
7
+ "include": [
8
+ "index.ts",
9
+ "src/**/*"
10
+ ],
11
+ "exclude": [
12
+ "node_modules",
13
+ "dist"
14
+ ]
15
+ }