@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.
- package/README.md +99 -3
- package/index.ts +9 -8
- package/package.json +32 -28
- package/src/access-control.ts +55 -0
- package/src/auth.ts +47 -0
- package/src/card-service.ts +338 -0
- package/src/channel.ts +145 -1590
- package/src/config-schema.ts +7 -12
- package/src/config.ts +79 -0
- package/src/connection-manager.ts +69 -32
- package/src/dedup.ts +66 -0
- package/src/group-members-store.ts +45 -0
- package/src/inbound-handler.ts +453 -0
- package/src/logger-context.ts +17 -0
- package/src/media-utils.ts +24 -20
- package/src/message-utils.ts +156 -0
- package/src/onboarding.ts +70 -67
- package/src/peer-id-registry.ts +6 -2
- package/src/runtime.ts +2 -2
- package/src/send-service.ts +281 -0
- package/src/signature.ts +15 -0
- package/src/types.ts +45 -30
- package/src/utils.ts +29 -16
- package/clawbot.plugin.json +0 -9
- package/src/AGENTS.md +0 -63
- package/src/openclaw-channel-dingtalk.code-workspace +0 -17
package/src/channel.ts
CHANGED
|
@@ -1,1511 +1,70 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
|
|
3
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
4
|
+
import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
|
|
5
|
+
import { getAccessToken } from "./auth";
|
|
6
|
+
import { createAICard, streamAICard, finishAICard } from "./card-service";
|
|
7
|
+
import { getConfig, isConfigured, resolveRelativePath, stripTargetPrefix } from "./config";
|
|
8
|
+
import { DingTalkConfigSchema } from "./config-schema.js";
|
|
9
|
+
import { ConnectionManager } from "./connection-manager";
|
|
10
|
+
import { isMessageProcessed, markMessageProcessed } from "./dedup";
|
|
11
|
+
import { handleDingTalkMessage } from "./inbound-handler";
|
|
12
|
+
import { getLogger } from "./logger-context";
|
|
13
|
+
import { dingtalkOnboardingAdapter } from "./onboarding.js";
|
|
14
|
+
import { resolveOriginalPeerId } from "./peer-id-registry";
|
|
15
|
+
import {
|
|
16
|
+
detectMediaTypeFromExtension,
|
|
17
|
+
sendMessage,
|
|
18
|
+
sendProactiveMedia,
|
|
19
|
+
sendBySession,
|
|
20
|
+
uploadMedia,
|
|
21
|
+
} from "./send-service";
|
|
15
22
|
import type {
|
|
16
|
-
DingTalkConfig,
|
|
17
|
-
TokenInfo,
|
|
18
23
|
DingTalkInboundMessage,
|
|
19
|
-
MessageContent,
|
|
20
|
-
SendMessageOptions,
|
|
21
|
-
MediaFile,
|
|
22
|
-
HandleDingTalkMessageParams,
|
|
23
|
-
ProactiveMessagePayload,
|
|
24
|
-
SessionWebhookResponse,
|
|
25
|
-
AxiosResponse,
|
|
26
|
-
Logger,
|
|
27
|
-
ResolvedAccount,
|
|
28
24
|
GatewayStartContext,
|
|
29
25
|
GatewayStopResult,
|
|
30
|
-
AICardInstance,
|
|
31
|
-
AICardStreamingRequest,
|
|
32
26
|
ConnectionManagerConfig,
|
|
33
27
|
DingTalkChannelPlugin,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
import {
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Get current timestamp in ISO 8601 format for status tracking
|
|
40
|
-
*/
|
|
41
|
-
function getCurrentTimestamp(): number {
|
|
42
|
-
return Date.now();
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Access Token cache - keyed by clientId for multi-account support
|
|
46
|
-
interface TokenCache {
|
|
47
|
-
accessToken: string;
|
|
48
|
-
expiry: number;
|
|
49
|
-
}
|
|
50
|
-
const accessTokenCache = new Map<string, TokenCache>();
|
|
51
|
-
|
|
52
|
-
// Global logger reference for use across module methods
|
|
53
|
-
let currentLogger: Logger | undefined;
|
|
54
|
-
|
|
55
|
-
// AI Card instance cache for streaming updates
|
|
56
|
-
const aiCardInstances = new Map<string, AICardInstance>();
|
|
57
|
-
|
|
58
|
-
// Target to active AI Card instance ID mapping (accountId:conversationId -> cardInstanceId)
|
|
59
|
-
// Used to quickly lookup existing active cards for a target
|
|
60
|
-
const activeCardsByTarget = new Map<string, string>();
|
|
61
|
-
|
|
62
|
-
// Card cache TTL (1 hour)
|
|
63
|
-
const CARD_CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
|
64
|
-
|
|
65
|
-
// DingTalk API base URL
|
|
66
|
-
const DINGTALK_API = 'https://api.dingtalk.com';
|
|
67
|
-
|
|
68
|
-
// Thinking and tool usage message truncate length
|
|
69
|
-
const THINKING_TRUNCATE_LENGTH = 500;
|
|
70
|
-
|
|
71
|
-
// ============ Message Deduplication ============
|
|
72
|
-
// Prevents duplicate message processing when DingTalk retries delivery
|
|
73
|
-
// Uses pure in-memory storage with short TTL and lazy cleanup during processing
|
|
74
|
-
|
|
75
|
-
const processedMessages = new Map<string, number>(); // Map<dedupKey, expiresAt>
|
|
76
|
-
const MESSAGE_DEDUP_TTL = 60000; // 60 seconds
|
|
77
|
-
const MESSAGE_DEDUP_MAX_SIZE = 1000; // Hard cap to prevent memory pressure during bursts
|
|
78
|
-
let messageCounter = 0; // Counter for deterministic cleanup triggering (safe due to Node.js single-threaded event loop)
|
|
79
|
-
|
|
80
|
-
// Check if message was already processed (with lazy cleanup of expired entries)
|
|
81
|
-
function isMessageProcessed(dedupKey: string): boolean {
|
|
82
|
-
const now = Date.now();
|
|
83
|
-
const expiresAt = processedMessages.get(dedupKey);
|
|
84
|
-
|
|
85
|
-
if (expiresAt === undefined) {
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Lazy cleanup: remove expired entry if found
|
|
90
|
-
if (now >= expiresAt) {
|
|
91
|
-
processedMessages.delete(dedupKey);
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return true;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Mark message as processed with bot-scoped key
|
|
99
|
-
function markMessageProcessed(dedupKey: string): void {
|
|
100
|
-
const expiresAt = Date.now() + MESSAGE_DEDUP_TTL;
|
|
101
|
-
processedMessages.set(dedupKey, expiresAt);
|
|
102
|
-
|
|
103
|
-
// Hard cap: if Map exceeds max size, force immediate cleanup
|
|
104
|
-
if (processedMessages.size > MESSAGE_DEDUP_MAX_SIZE) {
|
|
105
|
-
const now = Date.now();
|
|
106
|
-
for (const [key, expiry] of processedMessages.entries()) {
|
|
107
|
-
if (now >= expiry) {
|
|
108
|
-
processedMessages.delete(key);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
// If still over limit after cleanup, clear oldest entries (safety valve)
|
|
112
|
-
// Maps maintain insertion order, so we can delete early entries
|
|
113
|
-
if (processedMessages.size > MESSAGE_DEDUP_MAX_SIZE) {
|
|
114
|
-
const removeCount = processedMessages.size - MESSAGE_DEDUP_MAX_SIZE;
|
|
115
|
-
let removed = 0;
|
|
116
|
-
for (const key of processedMessages.keys()) {
|
|
117
|
-
processedMessages.delete(key);
|
|
118
|
-
if (++removed >= removeCount) break;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return; // Skip regular cleanup since we just did a full sweep
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Lazy cleanup: remove expired entries deterministically every 10 messages
|
|
125
|
-
// With 60s TTL, Map stays small under normal load, but we avoid cleanup on every message for performance
|
|
126
|
-
messageCounter++;
|
|
127
|
-
if (messageCounter >= 10) {
|
|
128
|
-
messageCounter = 0;
|
|
129
|
-
const now = Date.now();
|
|
130
|
-
for (const [key, expiry] of processedMessages.entries()) {
|
|
131
|
-
if (now >= expiry) {
|
|
132
|
-
processedMessages.delete(key);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Authorization helpers
|
|
139
|
-
type NormalizedAllowFrom = {
|
|
140
|
-
entries: string[];
|
|
141
|
-
entriesLower: string[];
|
|
142
|
-
hasWildcard: boolean;
|
|
143
|
-
hasEntries: boolean;
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Normalize allowFrom list to standardized format
|
|
148
|
-
*/
|
|
149
|
-
function normalizeAllowFrom(list?: Array<string>): NormalizedAllowFrom {
|
|
150
|
-
const entries = (list ?? []).map((value) => String(value).trim()).filter(Boolean);
|
|
151
|
-
const hasWildcard = entries.includes('*');
|
|
152
|
-
const normalized = entries
|
|
153
|
-
.filter((value) => value !== '*')
|
|
154
|
-
.map((value) => value.replace(/^(dingtalk|dd|ding):/i, ''));
|
|
155
|
-
const normalizedLower = normalized.map((value) => value.toLowerCase());
|
|
156
|
-
return {
|
|
157
|
-
entries: normalized,
|
|
158
|
-
entriesLower: normalizedLower,
|
|
159
|
-
hasWildcard,
|
|
160
|
-
hasEntries: entries.length > 0,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Check if sender is allowed based on allowFrom list
|
|
166
|
-
*/
|
|
167
|
-
function isSenderAllowed(params: { allow: NormalizedAllowFrom; senderId?: string }): boolean {
|
|
168
|
-
const { allow, senderId } = params;
|
|
169
|
-
if (!allow.hasEntries) return true;
|
|
170
|
-
if (allow.hasWildcard) return true;
|
|
171
|
-
if (senderId && allow.entriesLower.includes(senderId.toLowerCase())) return true;
|
|
172
|
-
return false;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// 群组通道授权
|
|
176
|
-
function isSenderGroupAllowed(params: { allow: NormalizedAllowFrom; groupId?: string }): boolean {
|
|
177
|
-
const { allow, groupId } = params;
|
|
178
|
-
|
|
179
|
-
if (groupId && allow.entriesLower.includes(groupId.toLowerCase())) return true;
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Helper function to check if a card is in a terminal state
|
|
184
|
-
function isCardInTerminalState(state: string): boolean {
|
|
185
|
-
return state === AICardStatus.FINISHED || state === AICardStatus.FAILED;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Clean up old AI card instances from cache
|
|
189
|
-
function cleanupCardCache() {
|
|
190
|
-
const now = Date.now();
|
|
191
|
-
|
|
192
|
-
// Clean up AI card instances that are in FINISHED or FAILED state
|
|
193
|
-
// Active cards (PROCESSING, INPUTING) are not cleaned up even if they exceed TTL
|
|
194
|
-
for (const [cardInstanceId, instance] of aiCardInstances.entries()) {
|
|
195
|
-
if (isCardInTerminalState(instance.state) && now - instance.lastUpdated > CARD_CACHE_TTL) {
|
|
196
|
-
// Remove from aiCardInstances
|
|
197
|
-
aiCardInstances.delete(cardInstanceId);
|
|
198
|
-
|
|
199
|
-
// Remove from activeCardsByTarget mapping (break after first match for efficiency)
|
|
200
|
-
for (const [targetKey, mappedCardId] of activeCardsByTarget.entries()) {
|
|
201
|
-
if (mappedCardId === cardInstanceId) {
|
|
202
|
-
activeCardsByTarget.delete(targetKey);
|
|
203
|
-
break; // Each card should only have one target mapping
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Get the current logger instance
|
|
212
|
-
* Useful for methods that don't receive log as a parameter
|
|
213
|
-
*/
|
|
214
|
-
function getLogger(): Logger | undefined {
|
|
215
|
-
return currentLogger;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Helper function to detect markdown and extract title
|
|
219
|
-
function detectMarkdownAndExtractTitle(
|
|
220
|
-
text: string,
|
|
221
|
-
options: SendMessageOptions,
|
|
222
|
-
defaultTitle: string
|
|
223
|
-
): { useMarkdown: boolean; title: string } {
|
|
224
|
-
const hasMarkdown = /^[#*>-]|[*_`#[\]]/.test(text) || text.includes('\n');
|
|
225
|
-
const useMarkdown = options.useMarkdown !== false && (options.useMarkdown || hasMarkdown);
|
|
226
|
-
|
|
227
|
-
const title =
|
|
228
|
-
options.title ||
|
|
229
|
-
(useMarkdown
|
|
230
|
-
? text
|
|
231
|
-
.split('\n')[0]
|
|
232
|
-
.replace(/^[#*\s\->]+/, '')
|
|
233
|
-
.slice(0, 20) || defaultTitle
|
|
234
|
-
: defaultTitle);
|
|
235
|
-
|
|
236
|
-
return { useMarkdown, title };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// ============ Group Members Persistence ============
|
|
240
|
-
|
|
241
|
-
function groupMembersFilePath(storePath: string, groupId: string): string {
|
|
242
|
-
const dir = path.join(path.dirname(storePath), 'dingtalk-members');
|
|
243
|
-
const safeId = groupId.replace(/\+/g, '-').replace(/\//g, '_');
|
|
244
|
-
return path.join(dir, `${safeId}.json`);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function noteGroupMember(storePath: string, groupId: string, userId: string, name: string): void {
|
|
248
|
-
if (!userId || !name) return;
|
|
249
|
-
const filePath = groupMembersFilePath(storePath, groupId);
|
|
250
|
-
let roster: Record<string, string> = {};
|
|
251
|
-
try {
|
|
252
|
-
roster = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
253
|
-
} catch {}
|
|
254
|
-
if (roster[userId] === name) return;
|
|
255
|
-
roster[userId] = name;
|
|
256
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
257
|
-
fs.writeFileSync(filePath, JSON.stringify(roster, null, 2));
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function formatGroupMembers(storePath: string, groupId: string): string | undefined {
|
|
261
|
-
const filePath = groupMembersFilePath(storePath, groupId);
|
|
262
|
-
let roster: Record<string, string> = {};
|
|
263
|
-
try {
|
|
264
|
-
roster = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
265
|
-
} catch {
|
|
266
|
-
return undefined;
|
|
267
|
-
}
|
|
268
|
-
const entries = Object.entries(roster);
|
|
269
|
-
if (entries.length === 0) return undefined;
|
|
270
|
-
return entries.map(([id, name]) => `${name} (${id})`).join(', ');
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// ============ Group Config Resolution ============
|
|
274
|
-
|
|
275
|
-
function resolveGroupConfig(cfg: DingTalkConfig, groupId: string): { systemPrompt?: string } | undefined {
|
|
276
|
-
const groups = cfg.groups;
|
|
277
|
-
if (!groups) return undefined;
|
|
278
|
-
return groups[groupId] || groups['*'] || undefined;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// ============ Target Prefix Helpers ============
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Strip group: or user: prefix from target ID
|
|
285
|
-
* Returns the raw targetId and whether it was explicitly marked as a user
|
|
286
|
-
*/
|
|
287
|
-
function stripTargetPrefix(target: string): { targetId: string; isExplicitUser: boolean } {
|
|
288
|
-
if (target.startsWith('group:')) {
|
|
289
|
-
return { targetId: target.slice(6), isExplicitUser: false };
|
|
290
|
-
}
|
|
291
|
-
if (target.startsWith('user:')) {
|
|
292
|
-
return { targetId: target.slice(5), isExplicitUser: true };
|
|
293
|
-
}
|
|
294
|
-
return { targetId: target, isExplicitUser: false };
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// ============ Config Helpers ============
|
|
298
|
-
|
|
299
|
-
function getConfig(cfg: OpenClawConfig, accountId?: string): DingTalkConfig {
|
|
300
|
-
const dingtalkCfg = cfg?.channels?.dingtalk as DingTalkConfig | undefined;
|
|
301
|
-
if (!dingtalkCfg) return {} as DingTalkConfig;
|
|
302
|
-
|
|
303
|
-
if (accountId && dingtalkCfg.accounts?.[accountId]) {
|
|
304
|
-
return dingtalkCfg.accounts[accountId];
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
return dingtalkCfg;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function isConfigured(cfg: OpenClawConfig, accountId?: string): boolean {
|
|
311
|
-
const config = getConfig(cfg, accountId);
|
|
312
|
-
return Boolean(config.clientId && config.clientSecret);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const DEFAULT_AGENT_ID = 'main';
|
|
316
|
-
const VALID_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
317
|
-
const INVALID_AGENT_ID_CHARS_RE = /[^a-z0-9_-]+/g;
|
|
318
|
-
const LEADING_DASH_RE = /^-+/;
|
|
319
|
-
const TRAILING_DASH_RE = /-+$/;
|
|
320
|
-
|
|
321
|
-
function normalizeAgentId(value: string | undefined | null): string {
|
|
322
|
-
const trimmed = (value ?? '').trim();
|
|
323
|
-
if (!trimmed) return DEFAULT_AGENT_ID;
|
|
324
|
-
if (VALID_AGENT_ID_RE.test(trimmed)) return trimmed.toLowerCase();
|
|
325
|
-
return (
|
|
326
|
-
trimmed
|
|
327
|
-
.toLowerCase()
|
|
328
|
-
.replace(INVALID_AGENT_ID_CHARS_RE, '-')
|
|
329
|
-
.replace(LEADING_DASH_RE, '')
|
|
330
|
-
.replace(TRAILING_DASH_RE, '')
|
|
331
|
-
.slice(0, 64) || DEFAULT_AGENT_ID
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function resolveUserPath(input: string): string {
|
|
336
|
-
const trimmed = input.trim();
|
|
337
|
-
if (!trimmed) return trimmed;
|
|
338
|
-
if (trimmed.startsWith('~')) {
|
|
339
|
-
const expanded = trimmed.replace(/^~(?=$|[\\/])/, os.homedir());
|
|
340
|
-
return path.resolve(expanded);
|
|
341
|
-
}
|
|
342
|
-
return path.resolve(trimmed);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function resolveDefaultAgentWorkspaceDir(): string {
|
|
346
|
-
const profile = process.env.OPENCLAW_PROFILE?.trim();
|
|
347
|
-
if (profile && profile.toLowerCase() !== 'default') {
|
|
348
|
-
return path.join(os.homedir(), '.openclaw', `workspace-${profile}`);
|
|
349
|
-
}
|
|
350
|
-
return path.join(os.homedir(), '.openclaw', 'workspace');
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
interface AgentConfig {
|
|
354
|
-
id?: string;
|
|
355
|
-
default?: boolean;
|
|
356
|
-
workspace?: string;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function resolveDefaultAgentId(cfg: OpenClawConfig): string {
|
|
360
|
-
const agents: AgentConfig[] = cfg.agents?.list ?? [];
|
|
361
|
-
if (agents.length === 0) return DEFAULT_AGENT_ID;
|
|
362
|
-
const defaults = agents.filter((agent: AgentConfig) => agent?.default);
|
|
363
|
-
const chosen = (defaults[0] ?? agents[0])?.id?.trim();
|
|
364
|
-
return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string): string {
|
|
368
|
-
const id = normalizeAgentId(agentId);
|
|
369
|
-
const agents: AgentConfig[] = cfg.agents?.list ?? [];
|
|
370
|
-
const agent = agents.find((entry: AgentConfig) => normalizeAgentId(entry?.id) === id);
|
|
371
|
-
const configured = agent?.workspace?.trim();
|
|
372
|
-
if (configured) return resolveUserPath(configured);
|
|
373
|
-
const defaultAgentId = resolveDefaultAgentId(cfg);
|
|
374
|
-
if (id === defaultAgentId) {
|
|
375
|
-
const fallback = cfg.agents?.defaults?.workspace?.trim();
|
|
376
|
-
if (fallback) return resolveUserPath(fallback);
|
|
377
|
-
return resolveDefaultAgentWorkspaceDir();
|
|
378
|
-
}
|
|
379
|
-
return path.join(os.homedir(), '.openclaw', `workspace-${id}`);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// Get Access Token with retry logic - uses clientId-based cache for multi-account support
|
|
383
|
-
async function getAccessToken(config: DingTalkConfig, log?: Logger): Promise<string> {
|
|
384
|
-
const cacheKey = config.clientId;
|
|
385
|
-
const now = Date.now();
|
|
386
|
-
const cached = accessTokenCache.get(cacheKey);
|
|
387
|
-
|
|
388
|
-
if (cached && cached.expiry > now + 60000) {
|
|
389
|
-
return cached.accessToken;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
const token = await retryWithBackoff(
|
|
393
|
-
async () => {
|
|
394
|
-
const response = await axios.post<TokenInfo>('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
|
|
395
|
-
appKey: config.clientId,
|
|
396
|
-
appSecret: config.clientSecret,
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
// Store in cache with clientId as key
|
|
400
|
-
accessTokenCache.set(cacheKey, {
|
|
401
|
-
accessToken: response.data.accessToken,
|
|
402
|
-
expiry: now + response.data.expireIn * 1000,
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
return response.data.accessToken;
|
|
406
|
-
},
|
|
407
|
-
{ maxRetries: 3, log }
|
|
408
|
-
);
|
|
409
|
-
|
|
410
|
-
return token;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Wrapper function to upload media via uploadMediaUtil with getAccessToken bound
|
|
414
|
-
async function uploadMedia(
|
|
415
|
-
config: DingTalkConfig,
|
|
416
|
-
mediaPath: string,
|
|
417
|
-
mediaType: 'image' | 'voice' | 'video' | 'file',
|
|
418
|
-
log?: Logger
|
|
419
|
-
): Promise<string | null> {
|
|
420
|
-
return uploadMediaUtil(config, mediaPath, mediaType, getAccessToken, log);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// Send text/markdown proactive message via DingTalk OpenAPI
|
|
424
|
-
async function sendProactiveTextOrMarkdown(
|
|
425
|
-
config: DingTalkConfig,
|
|
426
|
-
target: string,
|
|
427
|
-
text: string,
|
|
428
|
-
options: SendMessageOptions = {}
|
|
429
|
-
): Promise<AxiosResponse> {
|
|
430
|
-
const token = await getAccessToken(config, options.log);
|
|
431
|
-
const log = options.log || getLogger();
|
|
432
|
-
|
|
433
|
-
// Support group: and user: prefixes, and resolve case-sensitive conversationId
|
|
434
|
-
const { targetId, isExplicitUser } = stripTargetPrefix(target);
|
|
435
|
-
const resolvedTarget = resolveOriginalPeerId(targetId);
|
|
436
|
-
const isGroup = !isExplicitUser && resolvedTarget.startsWith('cid');
|
|
437
|
-
|
|
438
|
-
const url = isGroup
|
|
439
|
-
? 'https://api.dingtalk.com/v1.0/robot/groupMessages/send'
|
|
440
|
-
: 'https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend';
|
|
441
|
-
|
|
442
|
-
// Use shared helper function for markdown detection and title extraction
|
|
443
|
-
const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, 'OpenClaw 提醒');
|
|
444
|
-
|
|
445
|
-
log?.debug?.(
|
|
446
|
-
`[DingTalk] Sending proactive message to ${isGroup ? 'group' : 'user'} ${resolvedTarget} with title "${title}"`
|
|
447
|
-
);
|
|
448
|
-
|
|
449
|
-
// Choose msgKey based on whether we're sending markdown or plain text
|
|
450
|
-
// Note: DingTalk's proactive message API uses predefined message templates
|
|
451
|
-
// sampleMarkdown supports markdown formatting, sampleText for plain text
|
|
452
|
-
const msgKey = useMarkdown ? 'sampleMarkdown' : 'sampleText';
|
|
453
|
-
const msgParam = useMarkdown ? JSON.stringify({ title, text }) : JSON.stringify({ content: text });
|
|
454
|
-
|
|
455
|
-
const payload: ProactiveMessagePayload = {
|
|
456
|
-
robotCode: config.robotCode || config.clientId,
|
|
457
|
-
msgKey,
|
|
458
|
-
msgParam,
|
|
459
|
-
};
|
|
460
|
-
|
|
461
|
-
if (isGroup) {
|
|
462
|
-
payload.openConversationId = resolvedTarget;
|
|
463
|
-
} else {
|
|
464
|
-
payload.userIds = [resolvedTarget];
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
const result = await axios({
|
|
468
|
-
url,
|
|
469
|
-
method: 'POST',
|
|
470
|
-
data: payload,
|
|
471
|
-
headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
|
|
472
|
-
});
|
|
473
|
-
return result.data;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Send proactive media message via DingTalk OpenAPI
|
|
477
|
-
async function sendProactiveMedia(
|
|
478
|
-
config: DingTalkConfig,
|
|
479
|
-
target: string,
|
|
480
|
-
mediaPath: string,
|
|
481
|
-
mediaType: 'image' | 'voice' | 'video' | 'file',
|
|
482
|
-
options: SendMessageOptions & { accountId?: string } = {}
|
|
483
|
-
): Promise<{ ok: boolean; error?: string; data?: any; messageId?: string }> {
|
|
484
|
-
const log = options.log || getLogger();
|
|
485
|
-
|
|
486
|
-
try {
|
|
487
|
-
// Upload media first to get media_id
|
|
488
|
-
const mediaId = await uploadMedia(config, mediaPath, mediaType, log);
|
|
489
|
-
if (!mediaId) {
|
|
490
|
-
return { ok: false, error: 'Failed to upload media' };
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
const token = await getAccessToken(config, log);
|
|
494
|
-
const { targetId, isExplicitUser } = stripTargetPrefix(target);
|
|
495
|
-
const resolvedTarget = resolveOriginalPeerId(targetId);
|
|
496
|
-
const isGroup = !isExplicitUser && resolvedTarget.startsWith('cid');
|
|
497
|
-
|
|
498
|
-
const DINGTALK_API = 'https://api.dingtalk.com';
|
|
499
|
-
const url = isGroup
|
|
500
|
-
? `${DINGTALK_API}/v1.0/robot/groupMessages/send`
|
|
501
|
-
: `${DINGTALK_API}/v1.0/robot/oToMessages/batchSend`;
|
|
502
|
-
|
|
503
|
-
// Build payload based on media type
|
|
504
|
-
// For personal messages (oToMessages), use native media message types
|
|
505
|
-
// For group messages (groupMessages), use sampleImageMsg/sampleAudio/etc.
|
|
506
|
-
let msgKey: string;
|
|
507
|
-
let msgParam: string;
|
|
508
|
-
|
|
509
|
-
if (mediaType === 'image') {
|
|
510
|
-
msgKey = 'sampleImageMsg';
|
|
511
|
-
msgParam = JSON.stringify({ photoURL: mediaId });
|
|
512
|
-
} else if (mediaType === 'voice') {
|
|
513
|
-
msgKey = 'sampleAudio';
|
|
514
|
-
msgParam = JSON.stringify({ mediaId, duration: '0' });
|
|
515
|
-
} else {
|
|
516
|
-
// File-like media (including video)
|
|
517
|
-
// Note: sampleVideo requires a cover image (picMediaId) which we don't have,
|
|
518
|
-
// so we fall back to sampleFile for video to avoid .octet-stream issues.
|
|
519
|
-
const filename = path.basename(mediaPath);
|
|
520
|
-
const defaultExt = mediaType === 'video' ? 'mp4' : 'file';
|
|
521
|
-
const ext = path.extname(mediaPath).slice(1) || defaultExt;
|
|
522
|
-
msgKey = 'sampleFile';
|
|
523
|
-
msgParam = JSON.stringify({ mediaId, fileName: filename, fileType: ext });
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
const payload: ProactiveMessagePayload = {
|
|
527
|
-
robotCode: config.robotCode || config.clientId,
|
|
528
|
-
msgKey,
|
|
529
|
-
msgParam,
|
|
530
|
-
};
|
|
531
|
-
|
|
532
|
-
if (isGroup) {
|
|
533
|
-
payload.openConversationId = resolvedTarget;
|
|
534
|
-
} else {
|
|
535
|
-
payload.userIds = [resolvedTarget];
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
log?.debug?.(
|
|
539
|
-
`[DingTalk] Sending proactive ${mediaType} message to ${isGroup ? 'group' : 'user'} ${resolvedTarget}`
|
|
540
|
-
);
|
|
541
|
-
|
|
542
|
-
const result = await axios({
|
|
543
|
-
url,
|
|
544
|
-
method: 'POST',
|
|
545
|
-
data: payload,
|
|
546
|
-
headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
const messageId = result.data?.processQueryKey || result.data?.messageId;
|
|
550
|
-
return { ok: true, data: result.data, messageId };
|
|
551
|
-
} catch (err: any) {
|
|
552
|
-
log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
|
|
553
|
-
if (axios.isAxiosError(err) && err.response) {
|
|
554
|
-
log?.error?.(`[DingTalk] Response: ${JSON.stringify(err.response.data)}`);
|
|
555
|
-
}
|
|
556
|
-
return { ok: false, error: err.message };
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// Download media file to agent workspace (sandbox-compatible)
|
|
561
|
-
// workspacePath: the agent's workspace directory (e.g., /home/node/.openclaw/workspace)
|
|
562
|
-
async function downloadMedia(
|
|
563
|
-
config: DingTalkConfig,
|
|
564
|
-
downloadCode: string,
|
|
565
|
-
workspacePath: string,
|
|
566
|
-
log?: Logger
|
|
567
|
-
): Promise<MediaFile | null> {
|
|
568
|
-
const formatAxiosErrorData = (value: unknown): string | undefined => {
|
|
569
|
-
if (value === null || value === undefined) return undefined;
|
|
570
|
-
if (Buffer.isBuffer(value)) return `<buffer ${value.length} bytes>`;
|
|
571
|
-
if (value instanceof ArrayBuffer) return `<arraybuffer ${value.byteLength} bytes>`;
|
|
572
|
-
if (typeof value === 'string') return value.length > 500 ? `${value.slice(0, 500)}…` : value;
|
|
573
|
-
try {
|
|
574
|
-
return JSON.stringify(maskSensitiveData(value));
|
|
575
|
-
} catch {
|
|
576
|
-
return String(value);
|
|
577
|
-
}
|
|
578
|
-
};
|
|
579
|
-
|
|
580
|
-
if (!downloadCode) {
|
|
581
|
-
log?.error?.('[DingTalk] downloadMedia requires downloadCode to be provided.');
|
|
582
|
-
return null;
|
|
583
|
-
}
|
|
584
|
-
if (!config.robotCode) {
|
|
585
|
-
if (log?.error) {
|
|
586
|
-
log.error('[DingTalk] downloadMedia requires robotCode to be configured.');
|
|
587
|
-
}
|
|
588
|
-
return null;
|
|
589
|
-
}
|
|
590
|
-
try {
|
|
591
|
-
const token = await getAccessToken(config, log);
|
|
592
|
-
const response = await axios.post(
|
|
593
|
-
'https://api.dingtalk.com/v1.0/robot/messageFiles/download',
|
|
594
|
-
{ downloadCode, robotCode: config.robotCode },
|
|
595
|
-
{ headers: { 'x-acs-dingtalk-access-token': token } }
|
|
596
|
-
);
|
|
597
|
-
const payload = response.data as Record<string, any>;
|
|
598
|
-
const downloadUrl = payload?.downloadUrl ?? payload?.data?.downloadUrl;
|
|
599
|
-
if (!downloadUrl) {
|
|
600
|
-
const payloadDetail = formatAxiosErrorData(payload);
|
|
601
|
-
log?.error?.(`[DingTalk] downloadMedia missing downloadUrl. payload=${payloadDetail ?? 'unknown'}`);
|
|
602
|
-
return null;
|
|
603
|
-
}
|
|
604
|
-
const mediaResponse = await axios.get(downloadUrl, { responseType: 'arraybuffer' });
|
|
605
|
-
const contentType = mediaResponse.headers['content-type'] || 'application/octet-stream';
|
|
606
|
-
const buffer = Buffer.from(mediaResponse.data as ArrayBuffer);
|
|
607
|
-
|
|
608
|
-
// Save to agent workspace's media/inbound/ directory (sandbox-compatible)
|
|
609
|
-
// This ensures the file is accessible within the sandbox
|
|
610
|
-
const mediaDir = path.join(workspacePath, 'media', 'inbound');
|
|
611
|
-
fs.mkdirSync(mediaDir, { recursive: true });
|
|
612
|
-
|
|
613
|
-
const ext = contentType.split('/')[1]?.split(';')[0] || 'bin';
|
|
614
|
-
const filename = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
|
615
|
-
const mediaPath = path.join(mediaDir, filename);
|
|
616
|
-
|
|
617
|
-
fs.writeFileSync(mediaPath, buffer);
|
|
618
|
-
log?.debug?.(`[DingTalk] Media saved to workspace: ${mediaPath}`);
|
|
619
|
-
return { path: mediaPath, mimeType: contentType };
|
|
620
|
-
} catch (err: any) {
|
|
621
|
-
if (log?.error) {
|
|
622
|
-
if (axios.isAxiosError(err)) {
|
|
623
|
-
const status = err.response?.status;
|
|
624
|
-
const statusText = err.response?.statusText;
|
|
625
|
-
const dataDetail = formatAxiosErrorData(err.response?.data);
|
|
626
|
-
const code = err.code ? ` code=${err.code}` : '';
|
|
627
|
-
const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ''}` : '';
|
|
628
|
-
log.error(`[DingTalk] Failed to download media:${statusLabel}${code} message=${err.message}`);
|
|
629
|
-
if (dataDetail) {
|
|
630
|
-
log.error(`[DingTalk] downloadMedia response data: ${dataDetail}`);
|
|
631
|
-
}
|
|
632
|
-
} else {
|
|
633
|
-
log.error(`[DingTalk] Failed to download media: ${err.message}`);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
return null;
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
function extractMessageContent(data: DingTalkInboundMessage): MessageContent {
|
|
641
|
-
const msgtype = data.msgtype || 'text';
|
|
642
|
-
|
|
643
|
-
// Helper function to format quoted content from DingTalk's reply message structure
|
|
644
|
-
const formatQuotedContent = (): string => {
|
|
645
|
-
const textField = data.text as any;
|
|
646
|
-
|
|
647
|
-
// Case 1: isReplyMsg=true WITH repliedMsg content (desktop client)
|
|
648
|
-
if (textField?.isReplyMsg && textField?.repliedMsg) {
|
|
649
|
-
const repliedMsg = textField.repliedMsg;
|
|
650
|
-
const content = repliedMsg?.content;
|
|
651
|
-
|
|
652
|
-
// Try plain text format first
|
|
653
|
-
if (content?.text) {
|
|
654
|
-
const quoteText = content.text.trim();
|
|
655
|
-
if (quoteText) {
|
|
656
|
-
return `[引用消息: "${quoteText}"]\n\n`;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// Handle richText format
|
|
661
|
-
if (content?.richText && Array.isArray(content.richText)) {
|
|
662
|
-
const textParts: string[] = [];
|
|
663
|
-
for (const part of content.richText) {
|
|
664
|
-
if (part.msgType === 'text' && part.content) {
|
|
665
|
-
textParts.push(part.content);
|
|
666
|
-
} else if (part.msgType === 'emoji' || part.type === 'emoji') {
|
|
667
|
-
textParts.push(part.content || '[表情]');
|
|
668
|
-
} else if (part.msgType === 'picture' || part.type === 'picture') {
|
|
669
|
-
textParts.push('[图片]');
|
|
670
|
-
} else if (part.msgType === 'at' || part.type === 'at') {
|
|
671
|
-
textParts.push(`@${part.content || part.atName || '某人'}`);
|
|
672
|
-
} else if (part.text) {
|
|
673
|
-
textParts.push(part.text);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
const quoteText = textParts.join('').trim();
|
|
677
|
-
if (quoteText) {
|
|
678
|
-
return `[引用消息: "${quoteText}"]\n\n`;
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// Case 2: isReplyMsg=true WITHOUT repliedMsg (rich media quote, mobile or desktop - only has originalMsgId)
|
|
684
|
-
if (textField?.isReplyMsg && !textField?.repliedMsg && data.originalMsgId) {
|
|
685
|
-
return `[这是一条引用消息,原消息ID: ${data.originalMsgId}]\n\n`;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// Fallback: Check for quoteMessage field (legacy format)
|
|
689
|
-
if (data.quoteMessage) {
|
|
690
|
-
const quoteText = data.quoteMessage.text?.content?.trim() || '';
|
|
691
|
-
if (quoteText) {
|
|
692
|
-
return `[引用消息: "${quoteText}"]\n\n`;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// Fallback: Check for quoteContent in content field
|
|
697
|
-
if (data.content?.quoteContent) {
|
|
698
|
-
return `[引用消息: "${data.content.quoteContent}"]\n\n`;
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
return '';
|
|
702
|
-
};
|
|
703
|
-
|
|
704
|
-
const quotedPrefix = formatQuotedContent();
|
|
705
|
-
|
|
706
|
-
// Logic for different message types
|
|
707
|
-
if (msgtype === 'text') {
|
|
708
|
-
return { text: quotedPrefix + (data.text?.content?.trim() || ''), messageType: 'text' };
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// Improved richText parsing: join all text/at components and extract first picture
|
|
712
|
-
if (msgtype === 'richText') {
|
|
713
|
-
const richTextParts = data.content?.richText || [];
|
|
714
|
-
let text = '';
|
|
715
|
-
let pictureDownloadCode: string | undefined;
|
|
716
|
-
for (const part of richTextParts) {
|
|
717
|
-
// Handle text content: include explicit text type or undefined type field (DingTalk may omit type)
|
|
718
|
-
if (part.text && (part.type === 'text' || part.type === undefined)) text += part.text;
|
|
719
|
-
if (part.type === 'at' && part.atName) text += `@${part.atName} `;
|
|
720
|
-
// Extract first picture's downloadCode from richText
|
|
721
|
-
if (part.type === 'picture' && part.downloadCode && !pictureDownloadCode) {
|
|
722
|
-
pictureDownloadCode = part.downloadCode;
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
return {
|
|
726
|
-
text: quotedPrefix + (text.trim() || (pictureDownloadCode ? '<media:image>' : '[富文本消息]')),
|
|
727
|
-
mediaPath: pictureDownloadCode,
|
|
728
|
-
mediaType: pictureDownloadCode ? 'image' : undefined,
|
|
729
|
-
messageType: 'richText',
|
|
730
|
-
};
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
if (msgtype === 'picture') {
|
|
734
|
-
return { text: '<media:image>', mediaPath: data.content?.downloadCode, mediaType: 'image', messageType: 'picture' };
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
if (msgtype === 'audio') {
|
|
738
|
-
return {
|
|
739
|
-
text: data.content?.recognition || '<media:voice>',
|
|
740
|
-
mediaPath: data.content?.downloadCode,
|
|
741
|
-
mediaType: 'audio',
|
|
742
|
-
messageType: 'audio',
|
|
743
|
-
};
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
if (msgtype === 'video') {
|
|
747
|
-
return { text: '<media:video>', mediaPath: data.content?.downloadCode, mediaType: 'video', messageType: 'video' };
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
if (msgtype === 'file') {
|
|
751
|
-
return {
|
|
752
|
-
text: `<media:file> (${data.content?.fileName || '文件'})`,
|
|
753
|
-
mediaPath: data.content?.downloadCode,
|
|
754
|
-
mediaType: 'file',
|
|
755
|
-
messageType: 'file',
|
|
756
|
-
};
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// Fallback
|
|
760
|
-
return { text: data.text?.content?.trim() || `[${msgtype}消息]`, messageType: msgtype };
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
// Send message via sessionWebhook
|
|
764
|
-
async function sendBySession(
|
|
765
|
-
config: DingTalkConfig,
|
|
766
|
-
sessionWebhook: string,
|
|
767
|
-
text: string,
|
|
768
|
-
options: SendMessageOptions = {}
|
|
769
|
-
): Promise<AxiosResponse> {
|
|
770
|
-
const token = await getAccessToken(config, options.log);
|
|
771
|
-
const log = options.log || getLogger();
|
|
772
|
-
|
|
773
|
-
// If mediaPath is provided, upload media and send as native media message
|
|
774
|
-
if (options.mediaPath && options.mediaType) {
|
|
775
|
-
const mediaId = await uploadMedia(config, options.mediaPath, options.mediaType, log);
|
|
776
|
-
if (mediaId) {
|
|
777
|
-
let body: any;
|
|
778
|
-
|
|
779
|
-
// Construct message body based on media type
|
|
780
|
-
if (options.mediaType === 'image') {
|
|
781
|
-
body = { msgtype: 'image', image: { media_id: mediaId } };
|
|
782
|
-
} else if (options.mediaType === 'voice') {
|
|
783
|
-
body = { msgtype: 'voice', voice: { media_id: mediaId } };
|
|
784
|
-
} else if (options.mediaType === 'video') {
|
|
785
|
-
body = { msgtype: 'video', video: { media_id: mediaId } };
|
|
786
|
-
} else if (options.mediaType === 'file') {
|
|
787
|
-
body = { msgtype: 'file', file: { media_id: mediaId } };
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
if (body) {
|
|
791
|
-
const result = await axios({
|
|
792
|
-
url: sessionWebhook,
|
|
793
|
-
method: 'POST',
|
|
794
|
-
data: body,
|
|
795
|
-
headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
|
|
796
|
-
});
|
|
797
|
-
return result.data;
|
|
798
|
-
}
|
|
799
|
-
} else {
|
|
800
|
-
log?.warn?.('[DingTalk] Media upload failed, falling back to text description');
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
// Use shared helper function for markdown detection and title extraction
|
|
805
|
-
const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, 'Clawdbot 消息');
|
|
806
|
-
|
|
807
|
-
let body: SessionWebhookResponse;
|
|
808
|
-
if (useMarkdown) {
|
|
809
|
-
let finalText = text;
|
|
810
|
-
if (options.atUserId) finalText = `${finalText} @${options.atUserId}`;
|
|
811
|
-
body = { msgtype: 'markdown', markdown: { title, text: finalText } };
|
|
812
|
-
} else {
|
|
813
|
-
body = { msgtype: 'text', text: { content: text } };
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
if (options.atUserId) body.at = { atUserIds: [options.atUserId], isAtAll: false };
|
|
817
|
-
|
|
818
|
-
const result = await axios({
|
|
819
|
-
url: sessionWebhook,
|
|
820
|
-
method: 'POST',
|
|
821
|
-
data: body,
|
|
822
|
-
headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
|
|
823
|
-
});
|
|
824
|
-
return result.data;
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
// ============ AI Card API Functions ============
|
|
828
|
-
|
|
829
|
-
/**
|
|
830
|
-
* Create and deliver an AI Card using the new DingTalk API (createAndDeliver)
|
|
831
|
-
* @param config DingTalk configuration
|
|
832
|
-
* @param conversationId Conversation ID (starts with 'cid' for groups, user ID for DM)
|
|
833
|
-
* @param data Original message data for context
|
|
834
|
-
* @param accountId Account ID for multi-account support
|
|
835
|
-
* @param log Logger instance
|
|
836
|
-
* @returns AI Card instance or null on failure
|
|
837
|
-
*/
|
|
838
|
-
async function createAICard(
|
|
839
|
-
config: DingTalkConfig,
|
|
840
|
-
conversationId: string,
|
|
841
|
-
data: DingTalkInboundMessage,
|
|
842
|
-
accountId: string,
|
|
843
|
-
log?: Logger
|
|
844
|
-
): Promise<AICardInstance | null> {
|
|
845
|
-
try {
|
|
846
|
-
const token = await getAccessToken(config, log);
|
|
847
|
-
// Use crypto.randomUUID() for robust GUID generation instead of Date.now() + random
|
|
848
|
-
const cardInstanceId = `card_${randomUUID()}`;
|
|
849
|
-
|
|
850
|
-
log?.info?.(`[DingTalk][AICard] Creating and delivering card outTrackId=${cardInstanceId}`);
|
|
851
|
-
log?.debug?.(`[DingTalk][AICard] conversationType=${data.conversationType}, conversationId=${conversationId}`);
|
|
852
|
-
|
|
853
|
-
const isGroup = conversationId.startsWith('cid');
|
|
854
|
-
|
|
855
|
-
if (!config.cardTemplateId) {
|
|
856
|
-
throw new Error('DingTalk cardTemplateId is not configured.');
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// Build the createAndDeliver request body
|
|
860
|
-
const createAndDeliverBody = {
|
|
861
|
-
cardTemplateId: config.cardTemplateId,
|
|
862
|
-
outTrackId: cardInstanceId,
|
|
863
|
-
cardData: {
|
|
864
|
-
cardParamMap: {},
|
|
865
|
-
},
|
|
866
|
-
callbackType: 'STREAM',
|
|
867
|
-
imGroupOpenSpaceModel: { supportForward: true },
|
|
868
|
-
imRobotOpenSpaceModel: { supportForward: true },
|
|
869
|
-
openSpaceId: isGroup ? `dtv1.card//IM_GROUP.${conversationId}` : `dtv1.card//IM_ROBOT.${conversationId}`,
|
|
870
|
-
userIdType: 1,
|
|
871
|
-
imGroupOpenDeliverModel: isGroup ? { robotCode: config.robotCode || config.clientId } : undefined,
|
|
872
|
-
imRobotOpenDeliverModel: !isGroup ? { spaceType: 'IM_ROBOT' } : undefined,
|
|
873
|
-
};
|
|
874
|
-
|
|
875
|
-
if (isGroup && !config.robotCode) {
|
|
876
|
-
log?.warn?.(
|
|
877
|
-
'[DingTalk][AICard] robotCode not configured, using clientId as fallback. ' +
|
|
878
|
-
'For best compatibility, set robotCode explicitly in config.'
|
|
879
|
-
);
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
log?.debug?.(
|
|
883
|
-
`[DingTalk][AICard] POST /v1.0/card/instances/createAndDeliver body=${JSON.stringify(createAndDeliverBody)}`
|
|
884
|
-
);
|
|
885
|
-
const resp = await axios.post(`${DINGTALK_API}/v1.0/card/instances/createAndDeliver`, createAndDeliverBody, {
|
|
886
|
-
headers: { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' },
|
|
887
|
-
});
|
|
888
|
-
log?.debug?.(
|
|
889
|
-
`[DingTalk][AICard] CreateAndDeliver response: status=${resp.status} data=${JSON.stringify(resp.data)}`
|
|
890
|
-
);
|
|
891
|
-
|
|
892
|
-
// Cache the AI card instance with config reference for token refresh
|
|
893
|
-
const aiCardInstance: AICardInstance = {
|
|
894
|
-
cardInstanceId,
|
|
895
|
-
accessToken: token,
|
|
896
|
-
conversationId,
|
|
897
|
-
createdAt: Date.now(),
|
|
898
|
-
lastUpdated: Date.now(),
|
|
899
|
-
state: AICardStatus.PROCESSING, // Initial state after creation
|
|
900
|
-
config, // Store config reference for token refresh
|
|
901
|
-
};
|
|
902
|
-
aiCardInstances.set(cardInstanceId, aiCardInstance);
|
|
903
|
-
|
|
904
|
-
// Add mapping from target to active card ID (accountId:conversationId -> cardInstanceId)
|
|
905
|
-
const targetKey = `${accountId}:${conversationId}`;
|
|
906
|
-
activeCardsByTarget.set(targetKey, cardInstanceId);
|
|
907
|
-
log?.debug?.(`[DingTalk][AICard] Registered active card mapping: ${targetKey} -> ${cardInstanceId}`);
|
|
908
|
-
|
|
909
|
-
return aiCardInstance;
|
|
910
|
-
} catch (err: any) {
|
|
911
|
-
log?.error?.(`[DingTalk][AICard] Create failed: ${err.message}`);
|
|
912
|
-
if (err.response) {
|
|
913
|
-
log?.error?.(
|
|
914
|
-
`[DingTalk][AICard] Error response: status=${err.response.status} data=${JSON.stringify(err.response.data)}`
|
|
915
|
-
);
|
|
916
|
-
}
|
|
917
|
-
return null;
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
/**
|
|
922
|
-
* Format thinking/tool content for display in AI Card
|
|
923
|
-
* Truncates to configured length and adds "> " prefix to each line
|
|
924
|
-
*/
|
|
925
|
-
function formatContentForCard(content: string, type: 'thinking' | 'tool'): string {
|
|
926
|
-
if (!content) return '';
|
|
927
|
-
|
|
928
|
-
// truncate to configured length, add ellipsis if truncated
|
|
929
|
-
const truncated = content.slice(0, THINKING_TRUNCATE_LENGTH) + (content.length > THINKING_TRUNCATE_LENGTH ? '…' : '');
|
|
930
|
-
|
|
931
|
-
// split into lines, then escape leading/trailing underscore per line, then prefix with ">"
|
|
932
|
-
const quotedLines = truncated
|
|
933
|
-
.split('\n')
|
|
934
|
-
.map((line) => line.replace(/^_(?=[^ ])/, '*').replace(/(?<=[^ ])_(?=$)/, '*'))
|
|
935
|
-
.map((line) => `> ${line}`)
|
|
936
|
-
.join('\n');
|
|
937
|
-
|
|
938
|
-
const emoji = type === 'thinking' ? '🤔' : '🛠️';
|
|
939
|
-
const label = type === 'thinking' ? '思考中' : '工具执行';
|
|
940
|
-
|
|
941
|
-
return `${emoji} **${label}**\n${quotedLines}`;
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
/**
|
|
945
|
-
* Stream update AI Card content using the new DingTalk API
|
|
946
|
-
* Always use isFull=true to fully replace the Markdown content
|
|
947
|
-
* @param card AI Card instance
|
|
948
|
-
* @param content Content to stream
|
|
949
|
-
* @param finished Whether this is the final update (isFinalize=true)
|
|
950
|
-
* @param log Logger instance
|
|
951
|
-
*/
|
|
952
|
-
async function streamAICard(
|
|
953
|
-
card: AICardInstance,
|
|
954
|
-
content: string,
|
|
955
|
-
finished: boolean = false,
|
|
956
|
-
log?: Logger
|
|
957
|
-
): Promise<void> {
|
|
958
|
-
// Refresh token if it's been more than 1.5 hours since card creation (tokens expire after 2 hours)
|
|
959
|
-
const tokenAge = Date.now() - card.createdAt;
|
|
960
|
-
const TOKEN_REFRESH_THRESHOLD = 90 * 60 * 1000; // 1.5 hours in milliseconds
|
|
961
|
-
|
|
962
|
-
if (tokenAge > TOKEN_REFRESH_THRESHOLD && card.config) {
|
|
963
|
-
log?.debug?.('[DingTalk][AICard] Token age exceeds threshold, refreshing...');
|
|
964
|
-
try {
|
|
965
|
-
card.accessToken = await getAccessToken(card.config, log);
|
|
966
|
-
log?.debug?.('[DingTalk][AICard] Token refreshed successfully');
|
|
967
|
-
} catch (err: any) {
|
|
968
|
-
log?.warn?.(`[DingTalk][AICard] Failed to refresh token: ${err.message}`);
|
|
969
|
-
// Continue with old token, let the API call fail if token is invalid
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
// Call streaming API to update content with full replacement
|
|
974
|
-
const streamBody: AICardStreamingRequest = {
|
|
975
|
-
outTrackId: card.cardInstanceId,
|
|
976
|
-
guid: randomUUID(), // Use crypto.randomUUID() for robust GUID generation
|
|
977
|
-
key: card.config?.cardTemplateKey || 'content',
|
|
978
|
-
content: content,
|
|
979
|
-
isFull: true, // Always full replacement for Markdown content
|
|
980
|
-
isFinalize: finished, // Set to true on final update to close the streaming channel
|
|
981
|
-
isError: false,
|
|
982
|
-
};
|
|
983
|
-
|
|
984
|
-
log?.debug?.(
|
|
985
|
-
`[DingTalk][AICard] PUT /v1.0/card/streaming contentLen=${content.length} isFull=true isFinalize=${finished} guid=${streamBody.guid} payload=${JSON.stringify(streamBody)}`
|
|
986
|
-
);
|
|
987
|
-
|
|
988
|
-
try {
|
|
989
|
-
const streamResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, streamBody, {
|
|
990
|
-
headers: { 'x-acs-dingtalk-access-token': card.accessToken, 'Content-Type': 'application/json' },
|
|
991
|
-
});
|
|
992
|
-
log?.debug?.(
|
|
993
|
-
`[DingTalk][AICard] Streaming response: status=${streamResp.status}, data=${JSON.stringify(streamResp.data)}`
|
|
994
|
-
);
|
|
995
|
-
|
|
996
|
-
// Update last updated time and state
|
|
997
|
-
card.lastUpdated = Date.now();
|
|
998
|
-
if (finished) {
|
|
999
|
-
card.state = AICardStatus.FINISHED;
|
|
1000
|
-
} else if (card.state === AICardStatus.PROCESSING) {
|
|
1001
|
-
card.state = AICardStatus.INPUTING;
|
|
1002
|
-
}
|
|
1003
|
-
} catch (err: any) {
|
|
1004
|
-
// Handle 500 unknownError - likely cardTemplateKey mismatch with card template variables
|
|
1005
|
-
if (err.response?.status === 500 && err.response?.data?.code === 'unknownError') {
|
|
1006
|
-
const usedKey = streamBody.key;
|
|
1007
|
-
const cardTemplateId = card.config?.cardTemplateId || '(unknown)';
|
|
1008
|
-
const errorMsg =
|
|
1009
|
-
`⚠️ **[DingTalk] AI Card 串流更新失败 (500 unknownError)**\n\n` +
|
|
1010
|
-
`这通常是因为 \`cardTemplateKey\` (当前值: \`${usedKey}\`) 与钉钉卡片模板 \`${cardTemplateId}\` 中定义的正文变量名不匹配。\n\n` +
|
|
1011
|
-
`**建议操作**:\n` +
|
|
1012
|
-
`1. 前往钉钉开发者后台检查该模板的“变量管理”\n` +
|
|
1013
|
-
`2. 确保配置中的 \`cardTemplateKey\` 与模板中用于显示内容的字段变量名完全一致\n\n` +
|
|
1014
|
-
`*注意:当前及后续消息将自动转为 Markdown 发送,直到问题修复。*\n` +
|
|
1015
|
-
`*参考文档: https://github.com/soimy/openclaw-channel-dingtalk/blob/main/README.md#3-%E5%BB%BA%E7%AB%8B%E5%8D%A1%E7%89%87%E6%A8%A1%E6%9D%BF%E5%8F%AF%E9%80%89`;
|
|
1016
|
-
|
|
1017
|
-
log?.error?.(
|
|
1018
|
-
`[DingTalk][AICard] Streaming failed with 500 unknownError. Key: ${usedKey}, Template: ${cardTemplateId}. ` +
|
|
1019
|
-
`Verify that "cardTemplateKey" matches the content field variable name in your card template.`
|
|
1020
|
-
);
|
|
1021
|
-
|
|
1022
|
-
card.state = AICardStatus.FAILED;
|
|
1023
|
-
card.lastUpdated = Date.now();
|
|
1024
|
-
|
|
1025
|
-
if (card.config) {
|
|
1026
|
-
// Send notification directly to user via Markdown fallback
|
|
1027
|
-
// We don't pass accountId here to ensure it doesn't try to use AI Card recursion
|
|
1028
|
-
try {
|
|
1029
|
-
await sendMessage(card.config, card.conversationId, errorMsg, { log });
|
|
1030
|
-
} catch (sendErr: any) {
|
|
1031
|
-
log?.warn?.(`[DingTalk][AICard] Failed to send error notification to user: ${sendErr.message}`);
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
throw err;
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
// Handle 401 errors specifically - try to refresh token once
|
|
1039
|
-
if (err.response?.status === 401 && card.config) {
|
|
1040
|
-
log?.warn?.('[DingTalk][AICard] Received 401 error, attempting token refresh and retry...');
|
|
1041
|
-
try {
|
|
1042
|
-
card.accessToken = await getAccessToken(card.config, log);
|
|
1043
|
-
// Retry the streaming request with refreshed token
|
|
1044
|
-
const retryResp = await axios.put(`${DINGTALK_API}/v1.0/card/streaming`, streamBody, {
|
|
1045
|
-
headers: { 'x-acs-dingtalk-access-token': card.accessToken, 'Content-Type': 'application/json' },
|
|
1046
|
-
});
|
|
1047
|
-
log?.debug?.(`[DingTalk][AICard] Retry after token refresh succeeded: status=${retryResp.status}`);
|
|
1048
|
-
// Update state on successful retry
|
|
1049
|
-
card.lastUpdated = Date.now();
|
|
1050
|
-
if (finished) {
|
|
1051
|
-
card.state = AICardStatus.FINISHED;
|
|
1052
|
-
} else if (card.state === AICardStatus.PROCESSING) {
|
|
1053
|
-
card.state = AICardStatus.INPUTING;
|
|
1054
|
-
}
|
|
1055
|
-
return; // Success, exit function
|
|
1056
|
-
} catch (retryErr: any) {
|
|
1057
|
-
log?.error?.(`[DingTalk][AICard] Retry after token refresh failed: ${retryErr.message}`);
|
|
1058
|
-
// Fall through to mark as failed and throw
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
// Ensure card state reflects the failure to prevent retry loops
|
|
1063
|
-
card.state = AICardStatus.FAILED;
|
|
1064
|
-
card.lastUpdated = Date.now();
|
|
1065
|
-
log?.error?.(
|
|
1066
|
-
`[DingTalk][AICard] Streaming update failed: ${err.message}, resp=${JSON.stringify(err.response?.data)}`
|
|
1067
|
-
);
|
|
1068
|
-
throw err;
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
/**
|
|
1073
|
-
* Finalize AI Card: close streaming channel and update to FINISHED state
|
|
1074
|
-
* @param card AI Card instance
|
|
1075
|
-
* @param content Final content
|
|
1076
|
-
* @param log Logger instance
|
|
1077
|
-
*/
|
|
1078
|
-
async function finishAICard(card: AICardInstance, content: string, log?: Logger): Promise<void> {
|
|
1079
|
-
log?.debug?.(`[DingTalk][AICard] Starting finish, final content length=${content.length}`);
|
|
1080
|
-
|
|
1081
|
-
// Send final content with isFull=true and isFinalize=true to close streaming
|
|
1082
|
-
// No separate state update needed - the streaming API handles everything
|
|
1083
|
-
await streamAICard(card, content, true, log);
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
// ============ End of New AI Card API Functions ============
|
|
1087
|
-
|
|
1088
|
-
// Send message with automatic mode selection (card/markdown)
|
|
1089
|
-
// Card mode: if an active AI Card exists for the target, stream updates; otherwise fall back to markdown.
|
|
1090
|
-
async function sendMessage(
|
|
1091
|
-
config: DingTalkConfig,
|
|
1092
|
-
conversationId: string,
|
|
1093
|
-
text: string,
|
|
1094
|
-
options: SendMessageOptions & { sessionWebhook?: string; accountId?: string } = {}
|
|
1095
|
-
): Promise<{ ok: boolean; error?: string; data?: AxiosResponse }> {
|
|
1096
|
-
try {
|
|
1097
|
-
const messageType = config.messageType || 'markdown';
|
|
1098
|
-
const log = options.log || getLogger();
|
|
1099
|
-
|
|
1100
|
-
if (messageType === 'card' && options.accountId) {
|
|
1101
|
-
const targetKey = `${options.accountId}:${conversationId}`;
|
|
1102
|
-
const activeCardId = activeCardsByTarget.get(targetKey);
|
|
1103
|
-
if (activeCardId) {
|
|
1104
|
-
const activeCard = aiCardInstances.get(activeCardId);
|
|
1105
|
-
if (activeCard && !isCardInTerminalState(activeCard.state)) {
|
|
1106
|
-
try {
|
|
1107
|
-
await streamAICard(activeCard, text, false, log);
|
|
1108
|
-
return { ok: true };
|
|
1109
|
-
} catch (err: any) {
|
|
1110
|
-
log?.warn?.(`[DingTalk] AI Card streaming failed, fallback to markdown: ${err.message}`);
|
|
1111
|
-
activeCard.state = AICardStatus.FAILED;
|
|
1112
|
-
activeCard.lastUpdated = Date.now();
|
|
1113
|
-
}
|
|
1114
|
-
} else {
|
|
1115
|
-
activeCardsByTarget.delete(targetKey);
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
// Fallback to markdown mode
|
|
1121
|
-
if (options.sessionWebhook) {
|
|
1122
|
-
await sendBySession(config, options.sessionWebhook, text, options);
|
|
1123
|
-
return { ok: true };
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
const result = await sendProactiveTextOrMarkdown(config, conversationId, text, options);
|
|
1127
|
-
return { ok: true, data: result };
|
|
1128
|
-
} catch (err: any) {
|
|
1129
|
-
options.log?.error?.(`[DingTalk] Send message failed: ${err.message}`);
|
|
1130
|
-
return { ok: false, error: err.message };
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
// Message handler
|
|
1135
|
-
async function handleDingTalkMessage(params: HandleDingTalkMessageParams): Promise<void> {
|
|
1136
|
-
const { cfg, accountId, data, sessionWebhook, log, dingtalkConfig } = params;
|
|
1137
|
-
const rt = getDingTalkRuntime();
|
|
1138
|
-
|
|
1139
|
-
// Save logger reference globally for use by other methods
|
|
1140
|
-
currentLogger = log;
|
|
1141
|
-
|
|
1142
|
-
log?.debug?.('[DingTalk] Full Inbound Data:', JSON.stringify(maskSensitiveData(data)));
|
|
1143
|
-
|
|
1144
|
-
// 0. 清理过期的卡片缓存
|
|
1145
|
-
cleanupCardCache();
|
|
1146
|
-
|
|
1147
|
-
// 1. 过滤机器人自身消息
|
|
1148
|
-
if (data.senderId === data.chatbotUserId || data.senderStaffId === data.chatbotUserId) {
|
|
1149
|
-
log?.debug?.('[DingTalk] Ignoring robot self-message');
|
|
1150
|
-
return;
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
const content = extractMessageContent(data);
|
|
1154
|
-
if (!content.text) return;
|
|
1155
|
-
|
|
1156
|
-
const isDirect = data.conversationType === '1';
|
|
1157
|
-
const senderId = data.senderStaffId || data.senderId;
|
|
1158
|
-
const senderName = data.senderNick || 'Unknown';
|
|
1159
|
-
const groupId = data.conversationId;
|
|
1160
|
-
const groupName = data.conversationTitle || 'Group';
|
|
1161
|
-
|
|
1162
|
-
// Register original peer IDs to preserve case-sensitive conversationId (base64)
|
|
1163
|
-
if (groupId) registerPeerId(groupId);
|
|
1164
|
-
if (senderId) registerPeerId(senderId);
|
|
1165
|
-
|
|
1166
|
-
// 2. Check authorization for direct messages based on dmPolicy
|
|
1167
|
-
let commandAuthorized = true;
|
|
1168
|
-
if (isDirect) {
|
|
1169
|
-
const dmPolicy = dingtalkConfig.dmPolicy || 'open';
|
|
1170
|
-
const allowFrom = dingtalkConfig.allowFrom || [];
|
|
1171
|
-
|
|
1172
|
-
if (dmPolicy === 'allowlist') {
|
|
1173
|
-
const normalizedAllowFrom = normalizeAllowFrom(allowFrom);
|
|
1174
|
-
const isAllowed = isSenderAllowed({ allow: normalizedAllowFrom, senderId });
|
|
1175
|
-
|
|
1176
|
-
if (!isAllowed) {
|
|
1177
|
-
log?.debug?.(`[DingTalk] DM blocked: senderId=${senderId} not in allowlist (dmPolicy=allowlist)`);
|
|
1178
|
-
|
|
1179
|
-
// Notify user with their sender ID so they can request access
|
|
1180
|
-
try {
|
|
1181
|
-
await sendBySession(
|
|
1182
|
-
dingtalkConfig,
|
|
1183
|
-
sessionWebhook,
|
|
1184
|
-
`⛔ 访问受限\n\n您的用户ID:\`${senderId}\`\n\n请联系管理员将此ID添加到允许列表中。`,
|
|
1185
|
-
{ log }
|
|
1186
|
-
);
|
|
1187
|
-
} catch (err: any) {
|
|
1188
|
-
log?.debug?.(`[DingTalk] Failed to send access denied message: ${err.message}`);
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
return;
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
log?.debug?.(`[DingTalk] DM authorized: senderId=${senderId} in allowlist`);
|
|
1195
|
-
} else if (dmPolicy === 'pairing') {
|
|
1196
|
-
// For pairing mode, SDK will handle the authorization
|
|
1197
|
-
// Set commandAuthorized to true to let SDK check pairing status
|
|
1198
|
-
commandAuthorized = true;
|
|
1199
|
-
} else {
|
|
1200
|
-
// 'open' policy - allow all
|
|
1201
|
-
commandAuthorized = true;
|
|
1202
|
-
}
|
|
1203
|
-
} else {
|
|
1204
|
-
// 群组通道授权
|
|
1205
|
-
const groupPolicy = dingtalkConfig.groupPolicy || 'open';
|
|
1206
|
-
const allowFrom = dingtalkConfig.allowFrom || [];
|
|
1207
|
-
|
|
1208
|
-
if (groupPolicy === 'allowlist') {
|
|
1209
|
-
const normalizedAllowFrom = normalizeAllowFrom(allowFrom);
|
|
1210
|
-
const isAllowed = isSenderGroupAllowed({ allow: normalizedAllowFrom, groupId });
|
|
1211
|
-
|
|
1212
|
-
if (!isAllowed) {
|
|
1213
|
-
log?.debug?.(
|
|
1214
|
-
`[DingTalk] Group blocked: conversationId=${groupId} senderId=${senderId} not in allowlist (groupPolicy=allowlist)`
|
|
1215
|
-
);
|
|
1216
|
-
|
|
1217
|
-
try {
|
|
1218
|
-
await sendBySession(
|
|
1219
|
-
dingtalkConfig,
|
|
1220
|
-
sessionWebhook,
|
|
1221
|
-
`⛔ 访问受限\n\n您的群聊ID:\`${groupId}\`\n\n请联系管理员将此ID添加到允许列表中。`,
|
|
1222
|
-
{ log, atUserId: senderId }
|
|
1223
|
-
);
|
|
1224
|
-
} catch (err: any) {
|
|
1225
|
-
log?.debug?.(`[DingTalk] Failed to send group access denied message: ${err.message}`);
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
return;
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
log?.debug?.(`[DingTalk] Group authorized: conversationId=${groupId} senderId=${senderId} in allowlist`);
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
const route = rt.channel.routing.resolveAgentRoute({
|
|
1236
|
-
cfg,
|
|
1237
|
-
channel: 'dingtalk',
|
|
1238
|
-
accountId,
|
|
1239
|
-
// agent权限和openclaw保持一致
|
|
1240
|
-
peer: { kind: isDirect ? 'direct' : 'group', id: isDirect ? senderId : groupId },
|
|
1241
|
-
});
|
|
1242
|
-
|
|
1243
|
-
const storePath = rt.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId });
|
|
1244
|
-
const workspacePath = resolveAgentWorkspaceDir(cfg, route.agentId);
|
|
1245
|
-
|
|
1246
|
-
// Download media to agent workspace (must be after route is resolved for correct workspace)
|
|
1247
|
-
let mediaPath: string | undefined;
|
|
1248
|
-
let mediaType: string | undefined;
|
|
1249
|
-
if (content.mediaPath && dingtalkConfig.robotCode) {
|
|
1250
|
-
const media = await downloadMedia(dingtalkConfig, content.mediaPath, workspacePath, log);
|
|
1251
|
-
if (media) {
|
|
1252
|
-
mediaPath = media.path;
|
|
1253
|
-
mediaType = media.mimeType;
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
1257
|
-
const previousTimestamp = rt.channel.session.readSessionUpdatedAt({ storePath, sessionKey: route.sessionKey });
|
|
1258
|
-
|
|
1259
|
-
// Group-specific: resolve config, track members, format member list
|
|
1260
|
-
const groupConfig = !isDirect ? resolveGroupConfig(dingtalkConfig, groupId) : undefined;
|
|
1261
|
-
// GroupSystemPrompt is injected into the system prompt on every turn (unlike
|
|
1262
|
-
// group intro which only fires on the first turn). Embed DingTalk IDs here so
|
|
1263
|
-
// the AI always has access to conversationId.
|
|
1264
|
-
const groupSystemPrompt = !isDirect
|
|
1265
|
-
? [`DingTalk group context: conversationId=${groupId}`, groupConfig?.systemPrompt?.trim()]
|
|
1266
|
-
.filter(Boolean)
|
|
1267
|
-
.join('\n')
|
|
1268
|
-
: undefined;
|
|
1269
|
-
|
|
1270
|
-
if (!isDirect) {
|
|
1271
|
-
noteGroupMember(storePath, groupId, senderId, senderName);
|
|
1272
|
-
}
|
|
1273
|
-
const groupMembers = !isDirect ? formatGroupMembers(storePath, groupId) : undefined;
|
|
1274
|
-
|
|
1275
|
-
const fromLabel = isDirect ? `${senderName} (${senderId})` : `${groupName} - ${senderName}`;
|
|
1276
|
-
const body = rt.channel.reply.formatInboundEnvelope({
|
|
1277
|
-
channel: 'DingTalk',
|
|
1278
|
-
from: fromLabel,
|
|
1279
|
-
timestamp: data.createAt,
|
|
1280
|
-
body: content.text,
|
|
1281
|
-
chatType: isDirect ? 'direct' : 'group',
|
|
1282
|
-
sender: { name: senderName, id: senderId },
|
|
1283
|
-
previousTimestamp,
|
|
1284
|
-
envelope: envelopeOptions,
|
|
1285
|
-
});
|
|
1286
|
-
|
|
1287
|
-
const to = isDirect ? senderId : groupId;
|
|
1288
|
-
const ctx = rt.channel.reply.finalizeInboundContext({
|
|
1289
|
-
Body: body,
|
|
1290
|
-
RawBody: content.text,
|
|
1291
|
-
CommandBody: content.text,
|
|
1292
|
-
From: to,
|
|
1293
|
-
To: to,
|
|
1294
|
-
SessionKey: route.sessionKey,
|
|
1295
|
-
AccountId: accountId,
|
|
1296
|
-
ChatType: isDirect ? 'direct' : 'group',
|
|
1297
|
-
ConversationLabel: fromLabel,
|
|
1298
|
-
GroupSubject: isDirect ? undefined : groupName,
|
|
1299
|
-
SenderName: senderName,
|
|
1300
|
-
SenderId: senderId,
|
|
1301
|
-
Provider: 'dingtalk',
|
|
1302
|
-
Surface: 'dingtalk',
|
|
1303
|
-
MessageSid: data.msgId,
|
|
1304
|
-
Timestamp: data.createAt,
|
|
1305
|
-
MediaPath: mediaPath,
|
|
1306
|
-
MediaType: mediaType,
|
|
1307
|
-
MediaUrl: mediaPath,
|
|
1308
|
-
GroupMembers: groupMembers,
|
|
1309
|
-
GroupSystemPrompt: groupSystemPrompt,
|
|
1310
|
-
GroupChannel: isDirect ? undefined : route.sessionKey,
|
|
1311
|
-
CommandAuthorized: commandAuthorized,
|
|
1312
|
-
OriginatingChannel: 'dingtalk',
|
|
1313
|
-
OriginatingTo: to,
|
|
1314
|
-
});
|
|
1315
|
-
|
|
1316
|
-
await rt.channel.session.recordInboundSession({
|
|
1317
|
-
storePath,
|
|
1318
|
-
sessionKey: ctx.SessionKey || route.sessionKey,
|
|
1319
|
-
ctx,
|
|
1320
|
-
updateLastRoute: { sessionKey: route.mainSessionKey, channel: 'dingtalk', to, accountId },
|
|
1321
|
-
onRecordError: (err: unknown) => {
|
|
1322
|
-
log?.error?.(`[DingTalk] Failed to record inbound session: ${String(err)}`);
|
|
1323
|
-
},
|
|
1324
|
-
});
|
|
1325
|
-
|
|
1326
|
-
log?.info?.(`[DingTalk] Inbound: from=${senderName} text="${content.text.slice(0, 50)}..."`);
|
|
1327
|
-
|
|
1328
|
-
// Determine if we are in card mode, if so, create or reuse card instance first
|
|
1329
|
-
const useCardMode = dingtalkConfig.messageType === 'card';
|
|
1330
|
-
let currentAICard: AICardInstance | undefined;
|
|
1331
|
-
let lastCardContent = '';
|
|
1332
|
-
|
|
1333
|
-
if (useCardMode) {
|
|
1334
|
-
// Try to reuse an existing active AI card for this target, if available
|
|
1335
|
-
const targetKey = `${accountId}:${to}`;
|
|
1336
|
-
const existingCardId = activeCardsByTarget.get(targetKey);
|
|
1337
|
-
const existingCard = existingCardId ? aiCardInstances.get(existingCardId) : undefined;
|
|
1338
|
-
|
|
1339
|
-
// Only reuse cards that are not in terminal states
|
|
1340
|
-
if (existingCard && !isCardInTerminalState(existingCard.state)) {
|
|
1341
|
-
currentAICard = existingCard;
|
|
1342
|
-
log?.debug?.('[DingTalk] Reusing existing active AI card for this conversation.');
|
|
1343
|
-
} else {
|
|
1344
|
-
// Create a new AI card
|
|
1345
|
-
try {
|
|
1346
|
-
const aiCard = await createAICard(dingtalkConfig, to, data, accountId, log);
|
|
1347
|
-
if (aiCard) {
|
|
1348
|
-
currentAICard = aiCard;
|
|
1349
|
-
} else {
|
|
1350
|
-
log?.warn?.('[DingTalk] Failed to create AI card (returned null), fallback to text/markdown.');
|
|
1351
|
-
}
|
|
1352
|
-
} catch (err: any) {
|
|
1353
|
-
log?.warn?.(`[DingTalk] Failed to create AI card: ${err.message}, fallback to text/markdown.`);
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
// Feedback: Thinking...
|
|
1359
|
-
if (dingtalkConfig.showThinking !== false) {
|
|
1360
|
-
try {
|
|
1361
|
-
const thinkingText = '🤔 思考中,请稍候...';
|
|
1362
|
-
// AI card already has thinking state visually, so we only send thinking message for non-card modes
|
|
1363
|
-
if (useCardMode && currentAICard) {
|
|
1364
|
-
log?.debug?.('[DingTalk] AI Card in thinking state, skipping thinking message send.');
|
|
1365
|
-
} else {
|
|
1366
|
-
lastCardContent = thinkingText;
|
|
1367
|
-
await sendMessage(dingtalkConfig, to, thinkingText, {
|
|
1368
|
-
sessionWebhook,
|
|
1369
|
-
atUserId: !isDirect ? senderId : null,
|
|
1370
|
-
log,
|
|
1371
|
-
accountId,
|
|
1372
|
-
});
|
|
1373
|
-
}
|
|
1374
|
-
} catch (err: any) {
|
|
1375
|
-
log?.debug?.(`[DingTalk] Thinking message failed: ${err.message}`);
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
const { queuedFinal } = await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1380
|
-
ctx,
|
|
1381
|
-
cfg,
|
|
1382
|
-
dispatcherOptions: {
|
|
1383
|
-
responsePrefix: '',
|
|
1384
|
-
deliver: async (payload: any, info?: { kind: string }) => {
|
|
1385
|
-
try {
|
|
1386
|
-
const textToSend = payload.markdown || payload.text;
|
|
1387
|
-
if (!textToSend) return;
|
|
1388
|
-
|
|
1389
|
-
// Handle tool results separately for AI Card streaming
|
|
1390
|
-
//
|
|
1391
|
-
// Note: use /verbose on in conversation to get tool execution info
|
|
1392
|
-
//
|
|
1393
|
-
if (useCardMode && currentAICard && info?.kind === 'tool') {
|
|
1394
|
-
log?.info?.(`[DingTalk] Tool result received, streaming to AI Card: ${textToSend.slice(0, 100)}`);
|
|
1395
|
-
const toolText = formatContentForCard(textToSend, 'tool');
|
|
1396
|
-
if (toolText) {
|
|
1397
|
-
await streamAICard(currentAICard, toolText, false, log);
|
|
1398
|
-
return; // Don't send via sendMessage for tool results in card mode
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
lastCardContent = textToSend;
|
|
1403
|
-
await sendMessage(dingtalkConfig, to, textToSend, {
|
|
1404
|
-
sessionWebhook,
|
|
1405
|
-
atUserId: !isDirect ? senderId : null,
|
|
1406
|
-
log,
|
|
1407
|
-
accountId,
|
|
1408
|
-
});
|
|
1409
|
-
} catch (err: any) {
|
|
1410
|
-
log?.error?.(`[DingTalk] Reply failed: ${err.message}`);
|
|
1411
|
-
throw err;
|
|
1412
|
-
}
|
|
1413
|
-
},
|
|
1414
|
-
},
|
|
1415
|
-
replyOptions: {
|
|
1416
|
-
// Handle reasoning stream updates to update the AI Card content in real-time
|
|
1417
|
-
// Note: use /reasoning stream in conversation to get reasoning stream updates
|
|
1418
|
-
//
|
|
1419
|
-
onReasoningStream: async (payload: any) => {
|
|
1420
|
-
if (!useCardMode || !currentAICard) { return; }
|
|
1421
|
-
const thinkingText = formatContentForCard(payload.text, 'thinking');
|
|
1422
|
-
if (!thinkingText) return;
|
|
1423
|
-
try {
|
|
1424
|
-
await streamAICard(currentAICard, thinkingText, false, log);
|
|
1425
|
-
} catch (err: any) {
|
|
1426
|
-
log?.debug?.(`[DingTalk] Thinking stream update failed: ${err.message}`);
|
|
1427
|
-
}
|
|
1428
|
-
},
|
|
1429
|
-
},
|
|
1430
|
-
});
|
|
1431
|
-
|
|
1432
|
-
// Finalize AI card
|
|
1433
|
-
if (useCardMode && currentAICard) {
|
|
1434
|
-
try {
|
|
1435
|
-
// Helper function to check if a value is a non-empty string
|
|
1436
|
-
const isNonEmptyString = (value: any): boolean => typeof value === 'string' && value.trim().length > 0;
|
|
1437
|
-
|
|
1438
|
-
// Validate that we have actual content before finalization
|
|
1439
|
-
const hasLastCardContent = isNonEmptyString(lastCardContent);
|
|
1440
|
-
const hasQueuedFinalString = isNonEmptyString(queuedFinal);
|
|
1441
|
-
|
|
1442
|
-
if (hasLastCardContent || hasQueuedFinalString) {
|
|
1443
|
-
const finalContent =
|
|
1444
|
-
hasLastCardContent && typeof lastCardContent === 'string'
|
|
1445
|
-
? lastCardContent
|
|
1446
|
-
: typeof queuedFinal === 'string'
|
|
1447
|
-
? queuedFinal
|
|
1448
|
-
: '';
|
|
1449
|
-
await finishAICard(currentAICard, finalContent, log);
|
|
1450
|
-
} else {
|
|
1451
|
-
// No textual content was produced; skip finalization with empty content
|
|
1452
|
-
log?.debug?.('[DingTalk] Skipping AI Card finalization because no textual content was produced.');
|
|
1453
|
-
// Still mark the card as finished to allow cleanup
|
|
1454
|
-
currentAICard.state = AICardStatus.FINISHED;
|
|
1455
|
-
currentAICard.lastUpdated = Date.now();
|
|
1456
|
-
}
|
|
1457
|
-
} catch (err: any) {
|
|
1458
|
-
log?.debug?.(`[DingTalk] AI Card finalization failed: ${err.message}`);
|
|
1459
|
-
// Ensure the AI card transitions to a terminal error state
|
|
1460
|
-
try {
|
|
1461
|
-
if (currentAICard.state !== AICardStatus.FINISHED) {
|
|
1462
|
-
currentAICard.state = AICardStatus.FAILED;
|
|
1463
|
-
currentAICard.lastUpdated = Date.now();
|
|
1464
|
-
}
|
|
1465
|
-
} catch (stateErr: any) {
|
|
1466
|
-
// Log state update failure at debug level to aid production debugging
|
|
1467
|
-
log?.debug?.(`[DingTalk] Failed to update card state to FAILED: ${stateErr.message}`);
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
// Note: Media cleanup is handled by openclaw's media storage mechanism
|
|
1472
|
-
// Files saved via rt.channel.media.saveMediaBuffer are managed automatically
|
|
1473
|
-
}
|
|
28
|
+
ResolvedAccount,
|
|
29
|
+
} from "./types";
|
|
30
|
+
import { ConnectionState } from "./types";
|
|
31
|
+
import { cleanupOrphanedTempFiles, getCurrentTimestamp } from "./utils";
|
|
1474
32
|
|
|
1475
|
-
// DingTalk Channel Definition
|
|
33
|
+
// DingTalk Channel Definition (assembly layer).
|
|
34
|
+
// Heavy logic is delegated to service modules for maintainability.
|
|
1476
35
|
export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
1477
|
-
id:
|
|
36
|
+
id: "dingtalk",
|
|
1478
37
|
meta: {
|
|
1479
|
-
id:
|
|
1480
|
-
label:
|
|
1481
|
-
selectionLabel:
|
|
1482
|
-
docsPath:
|
|
1483
|
-
blurb:
|
|
1484
|
-
aliases: [
|
|
38
|
+
id: "dingtalk",
|
|
39
|
+
label: "DingTalk",
|
|
40
|
+
selectionLabel: "DingTalk (钉钉)",
|
|
41
|
+
docsPath: "/channels/dingtalk",
|
|
42
|
+
blurb: "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
|
|
43
|
+
aliases: ["dd", "ding"],
|
|
1485
44
|
},
|
|
1486
45
|
configSchema: buildChannelConfigSchema(DingTalkConfigSchema),
|
|
1487
46
|
onboarding: dingtalkOnboardingAdapter,
|
|
1488
47
|
capabilities: {
|
|
1489
|
-
chatTypes: [
|
|
48
|
+
chatTypes: ["direct", "group"] as Array<"direct" | "group">,
|
|
1490
49
|
reactions: false,
|
|
1491
50
|
threads: false,
|
|
1492
51
|
media: true,
|
|
1493
52
|
nativeCommands: false,
|
|
1494
53
|
blockStreaming: false,
|
|
1495
54
|
},
|
|
1496
|
-
reload: { configPrefixes: [
|
|
55
|
+
reload: { configPrefixes: ["channels.dingtalk"] },
|
|
1497
56
|
config: {
|
|
1498
57
|
listAccountIds: (cfg: OpenClawConfig): string[] => {
|
|
1499
58
|
const config = getConfig(cfg);
|
|
1500
59
|
return config.accounts && Object.keys(config.accounts).length > 0
|
|
1501
60
|
? Object.keys(config.accounts)
|
|
1502
61
|
: isConfigured(cfg)
|
|
1503
|
-
? [
|
|
62
|
+
? ["default"]
|
|
1504
63
|
: [];
|
|
1505
64
|
},
|
|
1506
65
|
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => {
|
|
1507
66
|
const config = getConfig(cfg);
|
|
1508
|
-
const id = accountId ||
|
|
67
|
+
const id = accountId || "default";
|
|
1509
68
|
const account = config.accounts?.[id];
|
|
1510
69
|
const resolvedConfig = account || config;
|
|
1511
70
|
const configured = Boolean(resolvedConfig.clientId && resolvedConfig.clientSecret);
|
|
@@ -1517,49 +76,53 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1517
76
|
name: resolvedConfig.name || null,
|
|
1518
77
|
};
|
|
1519
78
|
},
|
|
1520
|
-
defaultAccountId: (): string =>
|
|
79
|
+
defaultAccountId: (): string => "default",
|
|
1521
80
|
isConfigured: (account: ResolvedAccount): boolean =>
|
|
1522
81
|
Boolean(account.config?.clientId && account.config?.clientSecret),
|
|
1523
82
|
describeAccount: (account: ResolvedAccount) => ({
|
|
1524
83
|
accountId: account.accountId,
|
|
1525
|
-
name: account.config?.name ||
|
|
84
|
+
name: account.config?.name || "DingTalk",
|
|
1526
85
|
enabled: account.enabled,
|
|
1527
86
|
configured: Boolean(account.config?.clientId),
|
|
1528
87
|
}),
|
|
1529
88
|
},
|
|
1530
89
|
security: {
|
|
1531
90
|
resolveDmPolicy: ({ account }: any) => ({
|
|
1532
|
-
policy: account.config?.dmPolicy ||
|
|
91
|
+
policy: account.config?.dmPolicy || "open",
|
|
1533
92
|
allowFrom: account.config?.allowFrom || [],
|
|
1534
|
-
policyPath:
|
|
1535
|
-
allowFromPath:
|
|
1536
|
-
approveHint:
|
|
1537
|
-
normalizeEntry: (raw: string) => raw.replace(/^(dingtalk|dd|ding):/i,
|
|
93
|
+
policyPath: "channels.dingtalk.dmPolicy",
|
|
94
|
+
allowFromPath: "channels.dingtalk.allowFrom",
|
|
95
|
+
approveHint: "使用 /allow dingtalk:<userId> 批准用户",
|
|
96
|
+
normalizeEntry: (raw: string) => raw.replace(/^(dingtalk|dd|ding):/i, ""),
|
|
1538
97
|
}),
|
|
1539
98
|
},
|
|
1540
99
|
groups: {
|
|
1541
|
-
resolveRequireMention: ({ cfg }: any): boolean => getConfig(cfg).groupPolicy !==
|
|
100
|
+
resolveRequireMention: ({ cfg }: any): boolean => getConfig(cfg).groupPolicy !== "open",
|
|
1542
101
|
resolveGroupIntroHint: ({ groupId, groupChannel }: any): string | undefined => {
|
|
1543
102
|
const parts = [`conversationId=${groupId}`];
|
|
1544
|
-
if (groupChannel)
|
|
1545
|
-
|
|
103
|
+
if (groupChannel) {
|
|
104
|
+
parts.push(`sessionKey=${groupChannel}`);
|
|
105
|
+
}
|
|
106
|
+
return `DingTalk IDs: ${parts.join(", ")}.`;
|
|
1546
107
|
},
|
|
1547
108
|
},
|
|
1548
109
|
messaging: {
|
|
1549
|
-
normalizeTarget: (raw: string) => (raw ? raw.replace(/^(dingtalk|dd|ding):/i,
|
|
1550
|
-
targetResolver: {
|
|
110
|
+
normalizeTarget: (raw: string) => (raw ? raw.replace(/^(dingtalk|dd|ding):/i, "") : undefined),
|
|
111
|
+
targetResolver: {
|
|
112
|
+
looksLikeId: (id: string): boolean => /^[\w+\-/=]+$/.test(id),
|
|
113
|
+
hint: "<conversationId>",
|
|
114
|
+
},
|
|
1551
115
|
},
|
|
1552
116
|
outbound: {
|
|
1553
|
-
deliveryMode:
|
|
117
|
+
deliveryMode: "direct" as const,
|
|
1554
118
|
resolveTarget: ({ to }: any) => {
|
|
1555
119
|
const trimmed = to?.trim();
|
|
1556
120
|
if (!trimmed) {
|
|
1557
121
|
return {
|
|
1558
122
|
ok: false as const,
|
|
1559
|
-
error: new Error(
|
|
123
|
+
error: new Error("DingTalk message requires --to <conversationId>"),
|
|
1560
124
|
};
|
|
1561
125
|
}
|
|
1562
|
-
// Strip group: or user: prefix and resolve original case-sensitive conversationId
|
|
1563
126
|
const { targetId } = stripTargetPrefix(trimmed);
|
|
1564
127
|
const resolved = resolveOriginalPeerId(targetId);
|
|
1565
128
|
return { ok: true as const, to: resolved };
|
|
@@ -1573,15 +136,22 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1573
136
|
const data = result.data as any;
|
|
1574
137
|
const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
|
|
1575
138
|
return {
|
|
1576
|
-
channel:
|
|
139
|
+
channel: "dingtalk",
|
|
1577
140
|
messageId,
|
|
1578
|
-
meta: result.data
|
|
141
|
+
meta: result.data
|
|
142
|
+
? { data: result.data as unknown as Record<string, unknown> }
|
|
143
|
+
: undefined,
|
|
1579
144
|
};
|
|
1580
145
|
}
|
|
1581
|
-
throw new Error(
|
|
146
|
+
throw new Error(
|
|
147
|
+
typeof result.error === "string" ? result.error : JSON.stringify(result.error),
|
|
148
|
+
);
|
|
1582
149
|
} catch (err: any) {
|
|
1583
150
|
throw new Error(
|
|
1584
|
-
typeof err?.response?.data ===
|
|
151
|
+
typeof err?.response?.data === "string"
|
|
152
|
+
? err.response.data
|
|
153
|
+
: err?.message || "sendText failed",
|
|
154
|
+
{ cause: err },
|
|
1585
155
|
);
|
|
1586
156
|
}
|
|
1587
157
|
},
|
|
@@ -1596,13 +166,15 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1596
166
|
log,
|
|
1597
167
|
}: any) => {
|
|
1598
168
|
const config = getConfig(cfg, accountId);
|
|
1599
|
-
if (!config.clientId)
|
|
169
|
+
if (!config.clientId) {
|
|
170
|
+
throw new Error("DingTalk not configured");
|
|
171
|
+
}
|
|
1600
172
|
|
|
1601
|
-
// Support mediaPath
|
|
173
|
+
// Support mediaPath/filePath/mediaUrl aliases for better CLI compatibility.
|
|
1602
174
|
const rawMediaPath = mediaPath || filePath || mediaUrl;
|
|
1603
175
|
|
|
1604
176
|
getLogger()?.debug?.(
|
|
1605
|
-
`[DingTalk] sendMedia called: to=${to}, mediaPath=${mediaPath}, filePath=${filePath}, mediaUrl=${mediaUrl}, rawMediaPath=${rawMediaPath}
|
|
177
|
+
`[DingTalk] sendMedia called: to=${to}, mediaPath=${mediaPath}, filePath=${filePath}, mediaUrl=${mediaUrl}, rawMediaPath=${rawMediaPath}`,
|
|
1606
178
|
);
|
|
1607
179
|
|
|
1608
180
|
if (!rawMediaPath) {
|
|
@@ -1612,41 +184,48 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1612
184
|
mediaPath,
|
|
1613
185
|
filePath,
|
|
1614
186
|
mediaUrl,
|
|
1615
|
-
})}
|
|
187
|
+
})}`,
|
|
1616
188
|
);
|
|
1617
189
|
}
|
|
1618
190
|
|
|
1619
|
-
|
|
1620
|
-
const actualMediaPath = resolveUserPath(rawMediaPath);
|
|
191
|
+
const actualMediaPath = resolveRelativePath(rawMediaPath);
|
|
1621
192
|
|
|
1622
193
|
getLogger()?.debug?.(
|
|
1623
|
-
`[DingTalk] sendMedia resolved path: rawMediaPath=${rawMediaPath}, actualMediaPath=${actualMediaPath}
|
|
194
|
+
`[DingTalk] sendMedia resolved path: rawMediaPath=${rawMediaPath}, actualMediaPath=${actualMediaPath}`,
|
|
1624
195
|
);
|
|
1625
196
|
|
|
1626
197
|
try {
|
|
1627
|
-
// Detect media type from file extension if not provided
|
|
1628
198
|
const mediaType = providedMediaType || detectMediaTypeFromExtension(actualMediaPath);
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
199
|
+
const result = await sendProactiveMedia(config, to, actualMediaPath, mediaType, {
|
|
200
|
+
log,
|
|
201
|
+
accountId,
|
|
202
|
+
});
|
|
1632
203
|
getLogger()?.debug?.(
|
|
1633
|
-
`[DingTalk] sendMedia: ${mediaType} file=${actualMediaPath} result: ${JSON.stringify(result)}
|
|
204
|
+
`[DingTalk] sendMedia: ${mediaType} file=${actualMediaPath} result: ${JSON.stringify(result)}`,
|
|
1634
205
|
);
|
|
1635
206
|
|
|
1636
207
|
if (result.ok) {
|
|
1637
|
-
// Extract messageId from DingTalk response for CLI display
|
|
1638
208
|
const data = result.data;
|
|
1639
|
-
const messageId = String(
|
|
209
|
+
const messageId = String(
|
|
210
|
+
result.messageId || data?.processQueryKey || data?.messageId || randomUUID(),
|
|
211
|
+
);
|
|
1640
212
|
return {
|
|
1641
|
-
channel:
|
|
213
|
+
channel: "dingtalk",
|
|
1642
214
|
messageId,
|
|
1643
|
-
meta: result.data
|
|
215
|
+
meta: result.data
|
|
216
|
+
? { data: result.data as unknown as Record<string, unknown> }
|
|
217
|
+
: undefined,
|
|
1644
218
|
};
|
|
1645
219
|
}
|
|
1646
|
-
throw new Error(
|
|
220
|
+
throw new Error(
|
|
221
|
+
typeof result.error === "string" ? result.error : JSON.stringify(result.error),
|
|
222
|
+
);
|
|
1647
223
|
} catch (err: any) {
|
|
1648
224
|
throw new Error(
|
|
1649
|
-
typeof err?.response?.data ===
|
|
225
|
+
typeof err?.response?.data === "string"
|
|
226
|
+
? err.response.data
|
|
227
|
+
: err?.message || "sendMedia failed",
|
|
228
|
+
{ cause: err },
|
|
1650
229
|
);
|
|
1651
230
|
}
|
|
1652
231
|
},
|
|
@@ -1656,15 +235,13 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1656
235
|
const { account, cfg, abortSignal } = ctx;
|
|
1657
236
|
const config = account.config;
|
|
1658
237
|
if (!config.clientId || !config.clientSecret) {
|
|
1659
|
-
throw new Error(
|
|
238
|
+
throw new Error("DingTalk clientId and clientSecret are required");
|
|
1660
239
|
}
|
|
1661
240
|
|
|
1662
241
|
ctx.log?.info?.(`[${account.accountId}] Initializing DingTalk Stream client...`);
|
|
1663
242
|
|
|
1664
|
-
// Cleanup orphaned temp files from previous sessions
|
|
1665
243
|
cleanupOrphanedTempFiles(ctx.log);
|
|
1666
244
|
|
|
1667
|
-
// Create DWClient with autoReconnect disabled (we'll manage reconnection ourselves)
|
|
1668
245
|
const client = new DWClient({
|
|
1669
246
|
clientId: config.clientId,
|
|
1670
247
|
clientSecret: config.clientSecret,
|
|
@@ -1672,11 +249,9 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1672
249
|
keepAlive: true,
|
|
1673
250
|
});
|
|
1674
251
|
|
|
1675
|
-
// Disable
|
|
1676
|
-
// Access private config to override autoReconnect
|
|
252
|
+
// Disable built-in reconnect so ConnectionManager owns all retry/backoff behavior.
|
|
1677
253
|
(client as any).config.autoReconnect = false;
|
|
1678
254
|
|
|
1679
|
-
// Register message callback listener
|
|
1680
255
|
client.registerCallbackListener(TOPIC_ROBOT, async (res: any) => {
|
|
1681
256
|
const messageId = res.headers?.messageId;
|
|
1682
257
|
try {
|
|
@@ -1685,13 +260,10 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1685
260
|
}
|
|
1686
261
|
const data = JSON.parse(res.data) as DingTalkInboundMessage;
|
|
1687
262
|
|
|
1688
|
-
// Message deduplication
|
|
1689
|
-
// robotKey priority: robotCode (DingTalk's bot identifier) > clientId (app key) > accountId (local ID)
|
|
1690
|
-
// This ensures consistent keys per bot instance (config values remain stable during runtime)
|
|
263
|
+
// Message deduplication key is bot-scoped to avoid cross-account conflicts.
|
|
1691
264
|
const robotKey = config.robotCode || config.clientId || account.accountId;
|
|
1692
265
|
const msgId = data.msgId || messageId;
|
|
1693
266
|
|
|
1694
|
-
// Skip dedup if we don't have a message ID (extremely rare edge case)
|
|
1695
267
|
if (!msgId) {
|
|
1696
268
|
ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
|
|
1697
269
|
} else {
|
|
@@ -1716,20 +288,21 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1716
288
|
}
|
|
1717
289
|
});
|
|
1718
290
|
|
|
1719
|
-
//
|
|
1720
|
-
// The 'stopped' flag guards against multiple termination paths (abort signal, explicit stop)
|
|
1721
|
-
// from executing concurrently and ensures each lifecycle transition updates snapshot only once
|
|
291
|
+
// Guard against duplicate stop paths (abort signal + explicit stop).
|
|
1722
292
|
let stopped = false;
|
|
1723
293
|
|
|
1724
|
-
// Create connection manager configuration
|
|
1725
294
|
const connectionConfig: ConnectionManagerConfig = {
|
|
1726
295
|
maxAttempts: config.maxConnectionAttempts ?? 10,
|
|
1727
296
|
initialDelay: config.initialReconnectDelay ?? 1000,
|
|
1728
297
|
maxDelay: config.maxReconnectDelay ?? 60000,
|
|
1729
298
|
jitter: config.reconnectJitter ?? 0.3,
|
|
1730
299
|
onStateChange: (state: ConnectionState, error?: string) => {
|
|
1731
|
-
if (stopped)
|
|
1732
|
-
|
|
300
|
+
if (stopped) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
ctx.log?.debug?.(
|
|
304
|
+
`[${account.accountId}] Connection state changed to: ${state}${error ? ` (${error})` : ""}`,
|
|
305
|
+
);
|
|
1733
306
|
if (state === ConnectionState.CONNECTED) {
|
|
1734
307
|
ctx.setStatus({
|
|
1735
308
|
...ctx.getStatus(),
|
|
@@ -1750,37 +323,43 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1750
323
|
ctx.log?.debug?.(
|
|
1751
324
|
`[${account.accountId}] Connection config: maxAttempts=${connectionConfig.maxAttempts}, ` +
|
|
1752
325
|
`initialDelay=${connectionConfig.initialDelay}ms, maxDelay=${connectionConfig.maxDelay}ms, ` +
|
|
1753
|
-
`jitter=${connectionConfig.jitter}
|
|
326
|
+
`jitter=${connectionConfig.jitter}`,
|
|
1754
327
|
);
|
|
1755
328
|
|
|
1756
|
-
|
|
1757
|
-
|
|
329
|
+
const connectionManager = new ConnectionManager(
|
|
330
|
+
client,
|
|
331
|
+
account.accountId,
|
|
332
|
+
connectionConfig,
|
|
333
|
+
ctx.log,
|
|
334
|
+
);
|
|
1758
335
|
|
|
1759
|
-
//
|
|
1760
|
-
// This allows the abort signal to cancel in-flight connection attempts
|
|
336
|
+
// Register abort listener before connect() so startup can be cancelled safely.
|
|
1761
337
|
if (abortSignal) {
|
|
1762
|
-
// Check if already aborted before we even start
|
|
1763
338
|
if (abortSignal.aborted) {
|
|
1764
|
-
ctx.log?.warn?.(
|
|
339
|
+
ctx.log?.warn?.(
|
|
340
|
+
`[${account.accountId}] Abort signal already active, skipping connection`,
|
|
341
|
+
);
|
|
1765
342
|
|
|
1766
|
-
// Update snapshot: channel aborted before start
|
|
1767
343
|
ctx.setStatus({
|
|
1768
344
|
...ctx.getStatus(),
|
|
1769
345
|
running: false,
|
|
1770
346
|
lastStopAt: getCurrentTimestamp(),
|
|
1771
|
-
lastError:
|
|
347
|
+
lastError: "Connection aborted before start",
|
|
1772
348
|
});
|
|
1773
349
|
|
|
1774
|
-
throw new Error(
|
|
350
|
+
throw new Error("Connection aborted before start");
|
|
1775
351
|
}
|
|
1776
352
|
|
|
1777
|
-
abortSignal.addEventListener(
|
|
1778
|
-
if (stopped)
|
|
353
|
+
abortSignal.addEventListener("abort", () => {
|
|
354
|
+
if (stopped) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
1779
357
|
stopped = true;
|
|
1780
|
-
ctx.log?.info?.(
|
|
358
|
+
ctx.log?.info?.(
|
|
359
|
+
`[${account.accountId}] Abort signal received, stopping DingTalk Stream client...`,
|
|
360
|
+
);
|
|
1781
361
|
connectionManager.stop();
|
|
1782
362
|
|
|
1783
|
-
// Update snapshot: channel stopped by abort signal
|
|
1784
363
|
ctx.setStatus({
|
|
1785
364
|
...ctx.getStatus(),
|
|
1786
365
|
running: false,
|
|
@@ -1789,13 +368,10 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1789
368
|
});
|
|
1790
369
|
}
|
|
1791
370
|
|
|
1792
|
-
// Connect with robust retry logic
|
|
1793
371
|
try {
|
|
1794
372
|
await connectionManager.connect();
|
|
1795
373
|
|
|
1796
|
-
// Only mark as running if we weren't stopped and the connection is actually established
|
|
1797
374
|
if (!stopped && connectionManager.isConnected()) {
|
|
1798
|
-
// Update snapshot: connection successful, channel is now running
|
|
1799
375
|
ctx.setStatus({
|
|
1800
376
|
...ctx.getStatus(),
|
|
1801
377
|
running: true,
|
|
@@ -1804,38 +380,33 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1804
380
|
});
|
|
1805
381
|
ctx.log?.info?.(`[${account.accountId}] DingTalk Stream client connected successfully`);
|
|
1806
382
|
|
|
1807
|
-
// Keep startAccount alive until the connection manager is explicitly stopped.
|
|
1808
|
-
// The Gateway treats the Promise resolution as "channel finished" and would
|
|
1809
|
-
// trigger auto-restart if we returned here immediately after connecting.
|
|
1810
383
|
await connectionManager.waitForStop();
|
|
1811
384
|
} else {
|
|
1812
|
-
// Startup was cancelled or connection is not established; do not overwrite stopped snapshot.
|
|
1813
385
|
ctx.log?.info?.(
|
|
1814
386
|
`[${account.accountId}] DingTalk Stream client connect() completed but channel is ` +
|
|
1815
|
-
`not running (stopped=${stopped}, connected=${connectionManager.isConnected()})
|
|
387
|
+
`not running (stopped=${stopped}, connected=${connectionManager.isConnected()})`,
|
|
1816
388
|
);
|
|
1817
389
|
}
|
|
1818
390
|
} catch (err: any) {
|
|
1819
391
|
ctx.log?.error?.(`[${account.accountId}] Failed to establish connection: ${err.message}`);
|
|
1820
392
|
|
|
1821
|
-
// Update snapshot: connection failed
|
|
1822
393
|
ctx.setStatus({
|
|
1823
394
|
...ctx.getStatus(),
|
|
1824
395
|
running: false,
|
|
1825
|
-
lastError: err.message ||
|
|
396
|
+
lastError: err.message || "Connection failed",
|
|
1826
397
|
});
|
|
1827
398
|
throw err;
|
|
1828
399
|
}
|
|
1829
400
|
|
|
1830
|
-
// Return stop handler
|
|
1831
401
|
return {
|
|
1832
402
|
stop: () => {
|
|
1833
|
-
if (stopped)
|
|
403
|
+
if (stopped) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
1834
406
|
stopped = true;
|
|
1835
407
|
ctx.log?.info?.(`[${account.accountId}] Stopping DingTalk Stream client...`);
|
|
1836
408
|
connectionManager.stop();
|
|
1837
409
|
|
|
1838
|
-
// Update snapshot: channel stopped
|
|
1839
410
|
ctx.setStatus({
|
|
1840
411
|
...ctx.getStatus(),
|
|
1841
412
|
running: false,
|
|
@@ -1848,16 +419,22 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1848
419
|
},
|
|
1849
420
|
},
|
|
1850
421
|
status: {
|
|
1851
|
-
defaultRuntime: {
|
|
422
|
+
defaultRuntime: {
|
|
423
|
+
accountId: "default",
|
|
424
|
+
running: false,
|
|
425
|
+
lastStartAt: null,
|
|
426
|
+
lastStopAt: null,
|
|
427
|
+
lastError: null,
|
|
428
|
+
},
|
|
1852
429
|
collectStatusIssues: (accounts: any[]) => {
|
|
1853
430
|
return accounts.flatMap((account) => {
|
|
1854
431
|
if (!account.configured) {
|
|
1855
432
|
return [
|
|
1856
433
|
{
|
|
1857
|
-
channel:
|
|
434
|
+
channel: "dingtalk",
|
|
1858
435
|
accountId: account.accountId,
|
|
1859
|
-
kind:
|
|
1860
|
-
message:
|
|
436
|
+
kind: "config" as const,
|
|
437
|
+
message: "Account not configured (missing clientId or clientSecret)",
|
|
1861
438
|
},
|
|
1862
439
|
];
|
|
1863
440
|
}
|
|
@@ -1873,7 +450,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1873
450
|
}),
|
|
1874
451
|
probeAccount: async ({ account, timeoutMs }: any) => {
|
|
1875
452
|
if (!account.configured || !account.config?.clientId || !account.config?.clientSecret) {
|
|
1876
|
-
return { ok: false, error:
|
|
453
|
+
return { ok: false, error: "Not configured" };
|
|
1877
454
|
}
|
|
1878
455
|
try {
|
|
1879
456
|
const controller = new AbortController();
|
|
@@ -1882,7 +459,9 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1882
459
|
await getAccessToken(account.config);
|
|
1883
460
|
return { ok: true, details: { clientId: account.config.clientId } };
|
|
1884
461
|
} finally {
|
|
1885
|
-
if (timeoutId)
|
|
462
|
+
if (timeoutId) {
|
|
463
|
+
clearTimeout(timeoutId);
|
|
464
|
+
}
|
|
1886
465
|
}
|
|
1887
466
|
} catch (error: any) {
|
|
1888
467
|
return { ok: false, error: error.message };
|
|
@@ -1903,30 +482,6 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
1903
482
|
},
|
|
1904
483
|
};
|
|
1905
484
|
|
|
1906
|
-
/**
|
|
1907
|
-
* Public low-level API exports for the DingTalk channel plugin.
|
|
1908
|
-
*
|
|
1909
|
-
* - {@link sendBySession} sends a message to DingTalk using a session/webhook
|
|
1910
|
-
* (e.g. replies within an existing conversation).
|
|
1911
|
-
* - {@link createAICard} creates and delivers an AI Card using the DingTalk API
|
|
1912
|
-
* (returns AICardInstance for streaming updates). Automatically registers the card
|
|
1913
|
-
* in activeCardsByTarget mapping (accountId:conversationId -> cardInstanceId).
|
|
1914
|
-
* - {@link streamAICard} streams content updates to an AI Card
|
|
1915
|
-
* (for real-time streaming message updates).
|
|
1916
|
-
* - {@link finishAICard} finalizes an AI Card and sets state to FINISHED
|
|
1917
|
-
* (closes streaming channel and updates card state).
|
|
1918
|
-
* - {@link sendMessage} sends a message with automatic mode selection
|
|
1919
|
-
* (text/markdown/card based on config).
|
|
1920
|
-
* - {@link uploadMedia} uploads a local media file to DingTalk media server
|
|
1921
|
-
* and returns the media_id for use in messages.
|
|
1922
|
-
* - {@link getAccessToken} retrieves (and caches) the DingTalk access token
|
|
1923
|
-
* for the configured application/runtime.
|
|
1924
|
-
* - {@link getLogger} retrieves the current global logger instance
|
|
1925
|
-
* (set by handleDingTalkMessage during inbound message processing).
|
|
1926
|
-
*
|
|
1927
|
-
* These exports are intended to be used by external integrations that need
|
|
1928
|
-
* direct programmatic access to DingTalk messaging and authentication.
|
|
1929
|
-
*/
|
|
1930
485
|
export {
|
|
1931
486
|
sendBySession,
|
|
1932
487
|
createAICard,
|
|
@@ -1938,4 +493,4 @@ export {
|
|
|
1938
493
|
getAccessToken,
|
|
1939
494
|
getLogger,
|
|
1940
495
|
};
|
|
1941
|
-
export { detectMediaTypeFromExtension } from
|
|
496
|
+
export { detectMediaTypeFromExtension } from "./media-utils";
|