@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.
@@ -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
+ }
@@ -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 'openclaw/plugin-sdk';
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?: 'open' | 'pairing' | 'allowlist';
32
- groupPolicy?: 'open' | 'allowlist';
40
+ dmPolicy?: "open" | "pairing" | "allowlist";
41
+ groupPolicy?: "open" | "allowlist";
33
42
  allowFrom?: string[];
34
43
  showThinking?: boolean;
35
44
  debug?: boolean;
36
- messageType?: 'markdown' | 'card';
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?: 'open' | 'pairing' | 'allowlist';
60
- groupPolicy?: 'open' | 'allowlist';
68
+ dmPolicy?: "open" | "pairing" | "allowlist";
69
+ groupPolicy?: "open" | "allowlist";
61
70
  allowFrom?: string[];
62
71
  showThinking?: boolean;
63
72
  debug?: boolean;
64
- messageType?: 'markdown' | 'card';
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?: 'image' | 'voice' | 'video' | 'file';
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?: 'arraybuffer' | 'json' | 'text';
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: 'direct' | 'queued' | 'batch';
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: '1',
405
- INPUTING: '2',
406
- FINISHED: '3',
407
- FAILED: '5',
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 = 'DISCONNECTED',
446
- CONNECTING = 'CONNECTING',
447
- CONNECTED = 'CONNECTED',
448
- DISCONNECTING = 'DISCONNECTING',
449
- FAILED = '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 = 'default';
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) return [];
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(cfg: OpenClawConfig, accountId?: string | null): ResolvedDingTalkAccount {
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 'node:fs';
2
- import * as path from 'node:path';
3
- import * as os from 'node:os';
4
- import type { Logger, RetryOptions } from './types';
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 !== 'object') {
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 = ['token', 'accessToken'];
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.includes(key)) {
24
+ if (sensitiveFields.has(key)) {
25
25
  const val = 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);
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] === 'object' && obj[key] !== null) {
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)) continue;
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>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<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 = statusCode === 401 || statusCode === 429 || (statusCode && statusCode >= 500);
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('Retry exhausted without returning');
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
  }
@@ -1,9 +0,0 @@
1
- {
2
- "id": "dingtalk",
3
- "channels": ["dingtalk"],
4
- "configSchema": {
5
- "type": "object",
6
- "additionalProperties": true,
7
- "properties": {}
8
- }
9
- }
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
- }