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