@yaoyuanchao/dingtalk 1.2.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/api.ts ADDED
@@ -0,0 +1,382 @@
1
+ import https from "node:https";
2
+ import http from "node:http";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+
7
+ const DINGTALK_API_BASE = "https://api.dingtalk.com/v1.0";
8
+ const DINGTALK_OAPI_BASE = "https://oapi.dingtalk.com";
9
+
10
+ /** Temp directory for downloaded pictures */
11
+ const TEMP_DIR = path.join(os.tmpdir(), "dingtalk-pictures");
12
+
13
+ /** Cache access tokens per clientId */
14
+ const tokenCache = new Map<string, { token: string; expiresAt: number }>();
15
+
16
+ function jsonPost(url: string, body: any, headers?: Record<string, string>): Promise<any> {
17
+ return new Promise((resolve, reject) => {
18
+ const data = JSON.stringify(body);
19
+ const urlObj = new URL(url);
20
+ const mod = urlObj.protocol === "https:" ? https : http;
21
+ const req = mod.request(urlObj, {
22
+ method: "POST",
23
+ headers: {
24
+ "Content-Type": "application/json",
25
+ "Content-Length": Buffer.byteLength(data),
26
+ ...headers,
27
+ },
28
+ timeout: 10000, // 10 second timeout
29
+ family: 4, // Force IPv4 to avoid IPv6 connection issues
30
+ }, (res) => {
31
+ let buf = "";
32
+ res.on("data", (chunk: any) => { buf += chunk; });
33
+ res.on("end", () => {
34
+ try { resolve(JSON.parse(buf)); }
35
+ catch { resolve({ raw: buf }); }
36
+ });
37
+ });
38
+ req.on("error", reject);
39
+ req.on("timeout", () => {
40
+ req.destroy();
41
+ reject(new Error("Request timeout"));
42
+ });
43
+ req.write(data);
44
+ req.end();
45
+ });
46
+ }
47
+
48
+ function httpGetBuffer(url: string, headers?: Record<string, string>): Promise<Buffer> {
49
+ return new Promise((resolve, reject) => {
50
+ const urlObj = new URL(url);
51
+ const mod = urlObj.protocol === "https:" ? https : http;
52
+ const req = mod.request(urlObj, {
53
+ method: "GET",
54
+ headers: headers || {},
55
+ timeout: 30000, // 30 second timeout for file downloads
56
+ family: 4,
57
+ }, (res) => {
58
+ const chunks: Buffer[] = [];
59
+ res.on("data", (chunk: Buffer) => { chunks.push(chunk); });
60
+ res.on("end", () => {
61
+ resolve(Buffer.concat(chunks));
62
+ });
63
+ });
64
+ req.on("error", reject);
65
+ req.on("timeout", () => {
66
+ req.destroy();
67
+ reject(new Error("Download timeout"));
68
+ });
69
+ req.end();
70
+ });
71
+ }
72
+
73
+ export async function getDingTalkAccessToken(clientId: string, clientSecret: string): Promise<string> {
74
+ const cached = tokenCache.get(clientId);
75
+ if (cached && cached.expiresAt > Date.now() + 60_000) {
76
+ return cached.token;
77
+ }
78
+ const res = await jsonPost(`${DINGTALK_API_BASE}/oauth2/accessToken`, {
79
+ appKey: clientId,
80
+ appSecret: clientSecret,
81
+ });
82
+ if (!res.accessToken) {
83
+ throw new Error(`DingTalk token error: ${JSON.stringify(res)}`);
84
+ }
85
+ tokenCache.set(clientId, {
86
+ token: res.accessToken,
87
+ expiresAt: Date.now() + (res.expireIn ?? 7200) * 1000,
88
+ });
89
+ return res.accessToken;
90
+ }
91
+
92
+ /** Send reply via sessionWebhook (preferred, no auth needed) */
93
+ export async function sendViaSessionWebhook(
94
+ sessionWebhook: string,
95
+ text: string,
96
+ ): Promise<{ ok: boolean }> {
97
+ const res = await jsonPost(sessionWebhook, {
98
+ msgtype: "text",
99
+ text: { content: text },
100
+ });
101
+ return { ok: res?.errcode === 0 || !res?.errcode };
102
+ }
103
+
104
+ /** Send markdown via sessionWebhook */
105
+ export async function sendMarkdownViaSessionWebhook(
106
+ sessionWebhook: string,
107
+ title: string,
108
+ text: string,
109
+ ): Promise<{ ok: boolean }> {
110
+ const res = await jsonPost(sessionWebhook, {
111
+ msgtype: "markdown",
112
+ markdown: { title, text },
113
+ });
114
+ return { ok: res?.errcode === 0 || !res?.errcode };
115
+ }
116
+
117
+ /** Send image via sessionWebhook using markdown format */
118
+ export async function sendImageViaSessionWebhook(
119
+ sessionWebhook: string,
120
+ imageUrl: string,
121
+ caption?: string,
122
+ ): Promise<{ ok: boolean }> {
123
+ const title = caption || "图片";
124
+ const text = caption
125
+ ? `${caption}\n\n![image](${imageUrl})`
126
+ : `![image](${imageUrl})`;
127
+
128
+ return sendMarkdownViaSessionWebhook(sessionWebhook, title, text);
129
+ }
130
+
131
+ /** Send message via REST API (proactive/outbound, requires token) */
132
+ export async function sendDingTalkRestMessage(params: {
133
+ clientId: string;
134
+ clientSecret: string;
135
+ robotCode: string;
136
+ userId?: string;
137
+ conversationId?: string;
138
+ text: string;
139
+ }): Promise<{ ok: boolean }> {
140
+ const token = await getDingTalkAccessToken(params.clientId, params.clientSecret);
141
+ const headers = { "x-acs-dingtalk-access-token": token };
142
+
143
+ if (params.userId) {
144
+ const res = await jsonPost(
145
+ `${DINGTALK_API_BASE}/robot/oToMessages/batchSend`,
146
+ {
147
+ robotCode: params.robotCode,
148
+ userIds: [params.userId],
149
+ msgKey: "sampleText",
150
+ msgParam: JSON.stringify({ content: params.text }),
151
+ },
152
+ headers,
153
+ );
154
+ if (res?.errcode && res.errcode !== 0) {
155
+ throw new Error(`DingTalk DM send error: ${JSON.stringify(res)}`);
156
+ }
157
+ return { ok: !!res?.processQueryKey || !res?.code };
158
+ }
159
+
160
+ if (params.conversationId) {
161
+ const res = await jsonPost(
162
+ `${DINGTALK_API_BASE}/robot/groupMessages/send`,
163
+ {
164
+ robotCode: params.robotCode,
165
+ openConversationId: params.conversationId,
166
+ msgKey: "sampleText",
167
+ msgParam: JSON.stringify({ content: params.text }),
168
+ },
169
+ headers,
170
+ );
171
+ if (res?.errcode && res.errcode !== 0) {
172
+ throw new Error(`DingTalk group send error: ${JSON.stringify(res)}`);
173
+ }
174
+ return { ok: !!res?.processQueryKey || !res?.code };
175
+ }
176
+
177
+ throw new Error("Either userId or conversationId required");
178
+ }
179
+
180
+ /** Probe DingTalk connection by getting an access token */
181
+ export async function probeDingTalk(
182
+ clientId: string,
183
+ clientSecret: string,
184
+ ): Promise<{ ok: boolean; error?: string }> {
185
+ try {
186
+ await getDingTalkAccessToken(clientId, clientSecret);
187
+ return { ok: true };
188
+ } catch (err) {
189
+ return { ok: false, error: String(err) };
190
+ }
191
+ }
192
+
193
+ /** User info cache - persisted to local file */
194
+ const USER_CACHE_FILE = path.join(os.homedir(), ".clawdbot", "extensions", "dingtalk", ".cache", "users.json");
195
+ let userCache: Map<string, { name: string; avatar?: string }> | null = null;
196
+
197
+ function loadUserCache(): Map<string, { name: string; avatar?: string }> {
198
+ if (userCache) return userCache;
199
+
200
+ try {
201
+ if (fs.existsSync(USER_CACHE_FILE)) {
202
+ const data = fs.readFileSync(USER_CACHE_FILE, "utf-8");
203
+ const obj = JSON.parse(data);
204
+ userCache = new Map(Object.entries(obj));
205
+ return userCache;
206
+ }
207
+ } catch (err) {
208
+ console.warn("[dingtalk] Failed to load user cache:", err);
209
+ }
210
+
211
+ userCache = new Map();
212
+ return userCache;
213
+ }
214
+
215
+ function saveUserCache(cache: Map<string, { name: string; avatar?: string }>): void {
216
+ try {
217
+ const dir = path.dirname(USER_CACHE_FILE);
218
+ if (!fs.existsSync(dir)) {
219
+ fs.mkdirSync(dir, { recursive: true });
220
+ }
221
+ const obj = Object.fromEntries(cache.entries());
222
+ fs.writeFileSync(USER_CACHE_FILE, JSON.stringify(obj, null, 2), "utf-8");
223
+ } catch (err) {
224
+ console.warn("[dingtalk] Failed to save user cache:", err);
225
+ }
226
+ }
227
+
228
+ /** Get user info by userid (staffId), with persistent cache */
229
+ export async function getUserInfo(
230
+ clientId: string,
231
+ clientSecret: string,
232
+ userid: string,
233
+ ): Promise<{ name: string; avatar?: string } | null> {
234
+ const cache = loadUserCache();
235
+
236
+ // Check cache first
237
+ const cached = cache.get(userid);
238
+ if (cached) return cached;
239
+
240
+ // Call DingTalk API
241
+ try {
242
+ const token = await getDingTalkAccessToken(clientId, clientSecret);
243
+ const res = await jsonPost(
244
+ `${DINGTALK_OAPI_BASE}/topapi/v2/user/get?access_token=${token}`,
245
+ {
246
+ userid,
247
+ language: "zh_CN",
248
+ },
249
+ );
250
+
251
+ if (res.errcode !== 0) {
252
+ console.warn(`[dingtalk] Failed to get user info for ${userid}: ${res.errmsg}`);
253
+ return null;
254
+ }
255
+
256
+ const userInfo = {
257
+ name: res.result?.name || userid,
258
+ avatar: res.result?.avatar,
259
+ };
260
+
261
+ // Save to cache
262
+ cache.set(userid, userInfo);
263
+ saveUserCache(cache);
264
+
265
+ return userInfo;
266
+ } catch (err) {
267
+ console.warn(`[dingtalk] Error getting user info for ${userid}:`, err);
268
+ return null;
269
+ }
270
+ }
271
+
272
+ /** Batch get user info with timeout */
273
+ export async function batchGetUserInfo(
274
+ clientId: string,
275
+ clientSecret: string,
276
+ userids: string[],
277
+ timeoutMs: number = 500,
278
+ ): Promise<Map<string, string>> {
279
+ const result = new Map<string, string>();
280
+
281
+ if (userids.length === 0) return result;
282
+
283
+ const promises = userids.map(async (userid) => {
284
+ const info = await getUserInfo(clientId, clientSecret, userid);
285
+ if (info) {
286
+ result.set(userid, info.name);
287
+ }
288
+ });
289
+
290
+ try {
291
+ await Promise.race([
292
+ Promise.all(promises),
293
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeoutMs)),
294
+ ]);
295
+ } catch (err) {
296
+ // Timeout or error - return partial results
297
+ console.warn(`[dingtalk] Batch user info fetch timeout/error:`, err);
298
+ }
299
+
300
+ return result;
301
+ }
302
+
303
+ /** Download picture from DingTalk and return as base64 or file path */
304
+ export async function downloadPicture(
305
+ clientId: string,
306
+ clientSecret: string,
307
+ robotCode: string,
308
+ downloadCode: string,
309
+ ): Promise<{ base64?: string; filePath?: string; error?: string }> {
310
+ try {
311
+ const token = await getDingTalkAccessToken(clientId, clientSecret);
312
+
313
+ // DingTalk picture download API (v1.0 API, NOT oapi)
314
+ const url = `${DINGTALK_API_BASE}/robot/messageFiles/download`;
315
+ const headers = {
316
+ "x-acs-dingtalk-access-token": token,
317
+ };
318
+ const body = {
319
+ downloadCode,
320
+ robotCode,
321
+ };
322
+
323
+ // Download as buffer
324
+ const response = await jsonPost(url, body, headers);
325
+
326
+ // Check if response contains error
327
+ if (response.errcode && response.errcode !== 0) {
328
+ console.warn(`[dingtalk] Picture download failed: ${response.errmsg}`);
329
+ return { error: response.errmsg || "Download failed" };
330
+ }
331
+
332
+ // If response has a file URL, download it
333
+ if (response.downloadUrl) {
334
+ const imageBuffer = await httpGetBuffer(response.downloadUrl);
335
+
336
+ // Convert to base64
337
+ const base64 = imageBuffer.toString('base64');
338
+
339
+ // Also save to temp file for reference
340
+ if (!fs.existsSync(TEMP_DIR)) {
341
+ fs.mkdirSync(TEMP_DIR, { recursive: true });
342
+ }
343
+
344
+ const timestamp = Date.now();
345
+ const filename = `picture_${timestamp}.jpg`;
346
+ const filePath = path.join(TEMP_DIR, filename);
347
+
348
+ fs.writeFileSync(filePath, imageBuffer);
349
+
350
+ console.log(`[dingtalk] Picture downloaded successfully: ${filePath} (${imageBuffer.length} bytes)`);
351
+
352
+ return { base64, filePath };
353
+ }
354
+
355
+ return { error: "No download URL in response" };
356
+ } catch (err) {
357
+ console.warn(`[dingtalk] Error downloading picture:`, err);
358
+ return { error: String(err) };
359
+ }
360
+ }
361
+
362
+ /** Clean up old picture files (older than 1 hour) */
363
+ export function cleanupOldPictures(): void {
364
+ try {
365
+ if (!fs.existsSync(TEMP_DIR)) return;
366
+
367
+ const files = fs.readdirSync(TEMP_DIR);
368
+ const oneHourAgo = Date.now() - 60 * 60 * 1000;
369
+
370
+ for (const file of files) {
371
+ const filePath = path.join(TEMP_DIR, file);
372
+ const stats = fs.statSync(filePath);
373
+
374
+ if (stats.mtimeMs < oneHourAgo) {
375
+ fs.unlinkSync(filePath);
376
+ console.log(`[dingtalk] Cleaned up old picture: ${filePath}`);
377
+ }
378
+ }
379
+ } catch (err) {
380
+ console.warn(`[dingtalk] Error cleaning up pictures:`, err);
381
+ }
382
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,271 @@
1
+ import { getDingTalkRuntime } from './runtime.js';
2
+ import { resolveDingTalkAccount } from './accounts.js';
3
+ import { startDingTalkMonitor } from './monitor.js';
4
+ import { sendDingTalkRestMessage } from './api.js';
5
+ import { probeDingTalk } from './probe.js';
6
+
7
+ export const dingtalkPlugin = {
8
+ id: 'dingtalk',
9
+
10
+ meta: {
11
+ label: 'DingTalk',
12
+ selectionLabel: 'DingTalk (钉钉)',
13
+ detailLabel: 'DingTalk',
14
+ blurb: 'DingTalk bot via Stream Mode (WebSocket)',
15
+ aliases: ['dingding', 'dd'],
16
+ order: 75,
17
+ },
18
+
19
+ capabilities: {
20
+ chatTypes: ['direct', 'group'],
21
+ media: true, // Supports images via markdown in sessionWebhook replies
22
+ threads: false,
23
+ reactions: false,
24
+ mentions: true,
25
+ },
26
+
27
+ config: {
28
+ schema: {
29
+ type: 'object',
30
+ properties: {
31
+ enabled: {
32
+ type: 'boolean',
33
+ title: 'Enable DingTalk',
34
+ default: true,
35
+ },
36
+ clientId: {
37
+ type: 'string',
38
+ title: 'Client ID (AppKey)',
39
+ description: 'DingTalk application AppKey',
40
+ },
41
+ clientSecret: {
42
+ type: 'string',
43
+ title: 'Client Secret (AppSecret)',
44
+ description: 'DingTalk application AppSecret',
45
+ secret: true,
46
+ },
47
+ robotCode: {
48
+ type: 'string',
49
+ title: 'Robot Code (Optional)',
50
+ description: 'Optional robot code, defaults to Client ID',
51
+ },
52
+ dm: {
53
+ type: 'object',
54
+ title: 'Direct Message Settings',
55
+ properties: {
56
+ enabled: {
57
+ type: 'boolean',
58
+ title: 'Enable DM',
59
+ default: true,
60
+ },
61
+ policy: {
62
+ type: 'string',
63
+ title: 'DM Access Policy',
64
+ enum: ['disabled', 'pairing', 'allowlist', 'open'],
65
+ default: 'pairing',
66
+ description: 'disabled=no DM, pairing=show staffId to add, allowlist=only allowed users, open=everyone',
67
+ },
68
+ allowFrom: {
69
+ type: 'array',
70
+ title: 'Allowed Staff IDs',
71
+ items: { type: 'string' },
72
+ default: [],
73
+ description: 'List of staff IDs allowed to DM the bot',
74
+ },
75
+ },
76
+ },
77
+ groupPolicy: {
78
+ type: 'string',
79
+ title: 'Group Chat Policy',
80
+ enum: ['disabled', 'allowlist', 'open'],
81
+ default: 'allowlist',
82
+ description: 'disabled=no groups, allowlist=specific groups, open=all groups',
83
+ },
84
+ groupAllowlist: {
85
+ type: 'array',
86
+ title: 'Allowed Group IDs',
87
+ items: { type: 'string' },
88
+ default: [],
89
+ description: 'List of conversation IDs for allowed groups (only used when groupPolicy is "allowlist")',
90
+ },
91
+ requireMention: {
92
+ type: 'boolean',
93
+ title: 'Require @ Mention in Groups',
94
+ default: true,
95
+ description: 'If true, bot only responds when @mentioned in group chats',
96
+ },
97
+ messageFormat: {
98
+ type: 'string',
99
+ title: 'Message Format',
100
+ enum: ['text', 'markdown'],
101
+ default: 'text',
102
+ description: 'text=plain text, markdown=DingTalk markdown (limited: no tables, use text for tables)',
103
+ },
104
+ },
105
+ required: ['clientId', 'clientSecret'],
106
+ },
107
+
108
+ listAccountIds(cfg) {
109
+ const channel = cfg?.channels?.dingtalk ?? {};
110
+ if (channel.clientId) return ['default'];
111
+ return [];
112
+ },
113
+
114
+ resolveAccount(cfg, accountId) {
115
+ return resolveDingTalkAccount({ cfg, accountId });
116
+ },
117
+
118
+ defaultAccountId() {
119
+ return 'default';
120
+ },
121
+
122
+ setAccountEnabled({ cfg, accountId, enabled }) {
123
+ const runtime = getDingTalkRuntime();
124
+ runtime.config.set('channels.dingtalk.enabled', enabled);
125
+ },
126
+
127
+ deleteAccount({ cfg, accountId }) {
128
+ const runtime = getDingTalkRuntime();
129
+ runtime.config.delete('channels.dingtalk');
130
+ },
131
+
132
+ isConfigured(account) {
133
+ return !!(account.clientId && account.clientSecret);
134
+ },
135
+
136
+ describeAccount(account) {
137
+ return {
138
+ accountId: account.accountId,
139
+ name: account.name || 'DingTalk Bot',
140
+ enabled: account.enabled,
141
+ configured: !!(account.clientId && account.clientSecret),
142
+ credentialSource: account.credentialSource,
143
+ };
144
+ },
145
+ },
146
+
147
+ security: {
148
+ resolveDmPolicy({ cfg, accountId, account }) {
149
+ const dm = account.config.dm ?? {};
150
+ return {
151
+ policy: dm.policy ?? 'pairing',
152
+ allowFrom: dm.allowFrom ?? [],
153
+ };
154
+ },
155
+ },
156
+
157
+ outbound: {
158
+ deliveryMode: 'buffer',
159
+ textChunkLimit: 2000,
160
+
161
+ async sendText({ to, text, accountId, cfg }) {
162
+ const account = resolveDingTalkAccount({ cfg, accountId });
163
+ const [type, id] = to.split(':');
164
+
165
+ if (type === 'dm') {
166
+ await sendDingTalkRestMessage({
167
+ clientId: account.clientId,
168
+ clientSecret: account.clientSecret,
169
+ robotCode: account.robotCode || account.clientId,
170
+ userId: id,
171
+ text,
172
+ });
173
+ } else if (type === 'group') {
174
+ await sendDingTalkRestMessage({
175
+ clientId: account.clientId,
176
+ clientSecret: account.clientSecret,
177
+ robotCode: account.robotCode || account.clientId,
178
+ conversationId: id,
179
+ text,
180
+ });
181
+ }
182
+
183
+ return { channel: 'dingtalk', ok: true };
184
+ },
185
+
186
+ async sendMedia({ to, text, mediaUrl, accountId, cfg }) {
187
+ // Note: DingTalk REST API (oToMessages/groupMessages) doesn't support markdown or images
188
+ // Images can only be sent via sessionWebhook (when replying to messages)
189
+ // For now, we send the image URL as a text link
190
+
191
+ if (!mediaUrl) {
192
+ throw new Error('mediaUrl is required for sending media on DingTalk');
193
+ }
194
+
195
+ // Check if URL is accessible (basic check)
196
+ if (!mediaUrl.startsWith('http://') && !mediaUrl.startsWith('https://')) {
197
+ throw new Error('DingTalk requires publicly accessible image URLs (http:// or https://)');
198
+ }
199
+
200
+ const account = resolveDingTalkAccount({ cfg, accountId });
201
+ const [type, id] = to.split(':');
202
+
203
+ // Build text message with image URL
204
+ const textMessage = text
205
+ ? `${text}\n\n图片链接: ${mediaUrl}`
206
+ : `图片链接: ${mediaUrl}`;
207
+
208
+ if (type === 'dm') {
209
+ await sendDingTalkRestMessage({
210
+ clientId: account.clientId,
211
+ clientSecret: account.clientSecret,
212
+ robotCode: account.robotCode || account.clientId,
213
+ userId: id,
214
+ text: textMessage,
215
+ });
216
+ } else if (type === 'group') {
217
+ await sendDingTalkRestMessage({
218
+ clientId: account.clientId,
219
+ clientSecret: account.clientSecret,
220
+ robotCode: account.robotCode || account.clientId,
221
+ conversationId: id,
222
+ text: textMessage,
223
+ });
224
+ }
225
+
226
+ return { channel: 'dingtalk', ok: true };
227
+ },
228
+ },
229
+
230
+ gateway: {
231
+ async startAccount({ account, signal, setStatus }) {
232
+ const runtime = getDingTalkRuntime();
233
+ const log = runtime.log?.child?.({ channel: 'dingtalk', account: account.accountId }) ?? runtime.log ?? console;
234
+ const cfg = runtime.config;
235
+
236
+ log.info?.('[dingtalk] Starting Stream connection...');
237
+
238
+ try {
239
+ await startDingTalkMonitor({
240
+ account,
241
+ cfg,
242
+ abortSignal: signal,
243
+ log,
244
+ setStatus,
245
+ });
246
+
247
+ log.info?.('[dingtalk] Stream connection started successfully');
248
+ } catch (err) {
249
+ log.error?.('[dingtalk] Failed to start Stream', err);
250
+ throw err;
251
+ }
252
+ },
253
+ },
254
+
255
+ status: {
256
+ async probeAccount(account) {
257
+ if (!account.configured || !account.clientId || !account.clientSecret) {
258
+ return { ok: false, error: 'Not configured' };
259
+ }
260
+
261
+ return await probeDingTalk(account.clientId, account.clientSecret);
262
+ },
263
+ },
264
+
265
+ onboarding: {
266
+ async run(ctx: any) {
267
+ const { onboardDingTalk } = await import('./onboarding.js');
268
+ return onboardDingTalk(ctx);
269
+ },
270
+ },
271
+ };