moltbot-dingtalk-stream 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,373 +1,642 @@
1
- import { DWClient, DWClientDownStream } from 'dingtalk-stream';
2
- import axios from 'axios';
1
+ import { DWClient } from "dingtalk-stream";
2
+ import { getDingTalkRuntime, type ClawdbotCoreRuntime } from "./runtime.js";
3
+ import {
4
+ CHANNEL_ID,
5
+ DEFAULT_ACCOUNT_ID,
6
+ DingTalkConfigSchema,
7
+ listDingTalkAccountIds,
8
+ resolveDingTalkAccount,
9
+ resolveDefaultDingTalkAccountId,
10
+ normalizeAccountId,
11
+ setAccountEnabledInConfig,
12
+ deleteAccountFromConfig,
13
+ applyAccountNameToConfig,
14
+ type ClawdbotConfig,
15
+ type ResolvedDingTalkAccount,
16
+ } from "./schema.js";
17
+
18
+ // ============================================================================
19
+ // Plugin API Types
20
+ // ============================================================================
3
21
 
4
- // Define interfaces
5
22
  interface ClawdbotPluginApi {
6
23
  config: ClawdbotConfig;
7
- logger: any;
8
- runtime: any;
9
- postMessage(params: any): Promise<void>;
10
- registerChannel(opts: { plugin: any }): void;
11
- registerService(service: any): void;
24
+ logger: Console;
25
+ runtime: ClawdbotCoreRuntime;
26
+ registerChannel(opts: { plugin: ChannelPlugin }): void;
27
+ registerService?(service: unknown): void;
12
28
  }
13
29
 
14
- interface ClawdbotConfig {
15
- channels?: {
16
- 'moltbot-dingtalk-stream'?: {
17
- accounts?: {
18
- [key: string]: DingTalkAccountConfig;
19
- };
20
- };
21
- [key: string]: any;
22
- };
30
+ interface InboundContext {
31
+ Body: string;
32
+ RawBody: string;
33
+ CommandBody: string;
34
+ From: string;
35
+ To: string;
36
+ SessionKey: string;
37
+ AccountId: string;
38
+ ChatType: "direct" | "group";
39
+ SenderName?: string;
40
+ SenderId: string;
41
+ SenderUsername?: string;
42
+ Provider: string;
43
+ Surface: string;
44
+ MessageSid: string;
45
+ Timestamp: number;
46
+ GroupSubject?: string;
47
+ ConversationLabel?: string;
48
+ OriginatingChannel?: string;
49
+ OriginatingTo?: string;
23
50
  }
24
51
 
25
- interface DingTalkAccountConfig {
26
- enabled?: boolean;
27
- clientId: string;
28
- clientSecret: string;
29
- webhookUrl?: string;
30
- name?: string;
52
+ interface Dispatcher {
53
+ sendFinalReply: (payload: { text?: string; content?: string }) => boolean;
54
+ typing: () => Promise<void>;
55
+ reaction: () => Promise<void>;
56
+ isSynchronous: () => boolean;
57
+ waitForIdle: () => Promise<void>;
58
+ sendBlockReply: (block: { text?: string; delta?: string; content?: string }) => Promise<void>;
59
+ getQueuedCounts: () => { active: number; queued: number; final: number };
31
60
  }
32
61
 
33
- interface ResolvedDingTalkAccount {
34
- accountId: string;
35
- name?: string;
36
- enabled: boolean;
37
- configured: boolean;
38
- config: DingTalkAccountConfig;
62
+ interface DispatchOptions {
63
+ ctx: InboundContext;
64
+ cfg: ClawdbotConfig;
65
+ dispatcher: Dispatcher;
66
+ replyOptions: Record<string, unknown>;
39
67
  }
40
68
 
41
- interface DingTalkRobotMessage {
42
- conversationId: string;
43
- chatbotCorpId: string;
44
- chatbotUserId: string;
45
- msgId: string;
46
- senderNick: string;
47
- isAdmin: boolean;
48
- senderStaffId?: string;
49
- sessionWebhook: string;
50
- sessionWebhookExpiredTime: number;
51
- createAt: number;
52
- senderCorpId?: string;
53
- conversationType: '1' | '2';
54
- senderId: string;
55
- text?: {
56
- content: string;
69
+ interface GatewayContext {
70
+ account: ResolvedDingTalkAccount;
71
+ cfg: ClawdbotConfig;
72
+ runtime: ClawdbotCoreRuntime;
73
+ abortSignal?: AbortSignal;
74
+ log?: {
75
+ info: (msg: string) => void;
76
+ warn: (msg: string) => void;
77
+ error: (msg: string) => void;
78
+ debug?: (msg: string) => void;
57
79
  };
58
- msgtype: string;
80
+ setStatus?: (status: Record<string, unknown>) => void;
81
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
59
82
  }
60
83
 
61
- // Store plugin runtime
62
- let pluginRuntime: ClawdbotPluginApi | null = null;
63
-
64
- // Store session webhooks for reply
65
- const sessionWebhooks: Map<string, string> = new Map();
66
- // Store active clients for each account
67
- const activeClients: Map<string, DWClient> = new Map();
68
-
69
- // Helper functions
70
- function listDingTalkAccountIds(cfg: ClawdbotConfig): string[] {
71
- const accounts = cfg.channels?.['moltbot-dingtalk-stream']?.accounts;
72
- return accounts ? Object.keys(accounts) : [];
84
+ interface ChannelPlugin {
85
+ id: string;
86
+ meta: {
87
+ id: string;
88
+ label: string;
89
+ selectionLabel: string;
90
+ docsPath: string;
91
+ docsLabel: string;
92
+ blurb: string;
93
+ order: number;
94
+ aliases: string[];
95
+ };
96
+ capabilities: {
97
+ chatTypes: readonly string[];
98
+ media?: boolean;
99
+ threads?: boolean;
100
+ };
101
+ reload: { configPrefixes: string[] };
102
+ configSchema: typeof DingTalkConfigSchema;
103
+ config: {
104
+ listAccountIds: (cfg: ClawdbotConfig) => string[];
105
+ resolveAccount: (cfg: ClawdbotConfig, accountId?: string) => ResolvedDingTalkAccount;
106
+ defaultAccountId: (cfg: ClawdbotConfig) => string;
107
+ setAccountEnabled: (opts: { cfg: ClawdbotConfig; accountId: string; enabled: boolean }) => ClawdbotConfig;
108
+ deleteAccount: (opts: { cfg: ClawdbotConfig; accountId: string }) => ClawdbotConfig;
109
+ isConfigured: (account: ResolvedDingTalkAccount) => boolean;
110
+ describeAccount: (account: ResolvedDingTalkAccount) => Record<string, unknown>;
111
+ };
112
+ security?: {
113
+ resolveDmPolicy: (opts: {
114
+ cfg: ClawdbotConfig;
115
+ accountId?: string;
116
+ account: ResolvedDingTalkAccount;
117
+ }) => {
118
+ policy: string;
119
+ allowFrom: string[];
120
+ allowFromPath: string;
121
+ normalizeEntry: (raw: string) => string;
122
+ };
123
+ };
124
+ mentions?: {
125
+ stripPatterns: () => string[];
126
+ };
127
+ groups?: {
128
+ resolveRequireMention: (opts: { cfg: ClawdbotConfig; accountId?: string }) => boolean;
129
+ };
130
+ messaging?: {
131
+ normalizeTarget: (target: string) => string;
132
+ targetResolver?: {
133
+ looksLikeId: (id: string) => boolean;
134
+ hint: string;
135
+ };
136
+ };
137
+ setup?: {
138
+ resolveAccountId: (opts: { accountId?: string }) => string;
139
+ applyAccountName: (opts: { cfg: ClawdbotConfig; accountId: string; name?: string }) => ClawdbotConfig;
140
+ validateInput: (opts: { accountId: string; input: SetupInput }) => string | null;
141
+ applyAccountConfig: (opts: { cfg: ClawdbotConfig; accountId: string; input: SetupInput }) => ClawdbotConfig;
142
+ };
143
+ outbound: {
144
+ deliveryMode: "direct";
145
+ textChunkLimit?: number;
146
+ sendText: (opts: {
147
+ to: string;
148
+ text: string;
149
+ accountId?: string;
150
+ deps?: Record<string, unknown>;
151
+ replyToId?: string;
152
+ }) => Promise<{ channel: string; ok: boolean; error?: string }>;
153
+ sendMedia?: (opts: {
154
+ to: string;
155
+ text: string;
156
+ mediaUrl: string;
157
+ accountId?: string;
158
+ }) => Promise<{ channel: string; ok: boolean; error?: string }>;
159
+ };
160
+ status?: {
161
+ defaultRuntime: {
162
+ accountId: string;
163
+ running: boolean;
164
+ lastStartAt: null;
165
+ lastStopAt: null;
166
+ lastError: null;
167
+ };
168
+ probeAccount: (opts: { account: ResolvedDingTalkAccount; timeoutMs?: number }) => Promise<{
169
+ ok: boolean;
170
+ error?: string;
171
+ bot?: { name?: string };
172
+ }>;
173
+ buildAccountSnapshot: (opts: {
174
+ account: ResolvedDingTalkAccount;
175
+ runtime?: Record<string, unknown>;
176
+ probe?: Record<string, unknown>;
177
+ }) => Record<string, unknown>;
178
+ };
179
+ gateway: {
180
+ startAccount: (ctx: GatewayContext) => Promise<void>;
181
+ };
73
182
  }
74
183
 
75
- function resolveDingTalkAccount(opts: { cfg: ClawdbotConfig; accountId?: string }): ResolvedDingTalkAccount {
76
- const { cfg, accountId = 'default' } = opts;
77
- const account = cfg.channels?.['moltbot-dingtalk-stream']?.accounts?.[accountId];
78
- return {
79
- accountId,
80
- name: account?.name,
81
- enabled: account?.enabled ?? false,
82
- configured: Boolean(account?.clientId && account?.clientSecret),
83
- config: account || { clientId: '', clientSecret: '' }
84
- };
184
+ interface SetupInput {
185
+ name?: string;
186
+ clientId?: string;
187
+ clientSecret?: string;
188
+ useEnv?: boolean;
85
189
  }
86
190
 
191
+ // ============================================================================
192
+ // Channel Meta
193
+ // ============================================================================
194
+
195
+ const meta = {
196
+ id: CHANNEL_ID,
197
+ label: "DingTalk",
198
+ selectionLabel: "DingTalk Bot (Stream)",
199
+ docsPath: "/channels/dingtalk",
200
+ docsLabel: "dingtalk",
201
+ blurb: "DingTalk bot channel plugin (Stream mode)",
202
+ order: 100,
203
+ aliases: ["dt", "ding", "dingtalk"],
204
+ };
205
+
206
+ // ============================================================================
207
+ // Store plugin runtime reference
208
+ // ============================================================================
209
+
210
+ let pluginRuntime: ClawdbotPluginApi | null = null;
211
+
212
+ // ============================================================================
87
213
  // DingTalk Channel Plugin
88
- const dingTalkChannelPlugin = {
89
- id: "moltbot-dingtalk-stream",
90
- meta: {
91
- id: "moltbot-dingtalk-stream",
92
- label: "钉钉",
93
- selectionLabel: "DingTalk Bot (Stream)",
94
- docsPath: "/channels/moltbot-dingtalk-stream",
95
- docsLabel: "dingtalk",
96
- blurb: "钉钉机器人通道插件 (Stream模式)",
97
- order: 100,
98
- aliases: ["dt", "ding"],
99
- },
214
+ // ============================================================================
215
+
216
+ export const dingtalkPlugin: ChannelPlugin = {
217
+ id: CHANNEL_ID,
218
+ meta,
219
+
100
220
  capabilities: {
101
- chatTypes: ["direct", "group"] as const,
221
+ chatTypes: ["direct", "group"],
222
+ media: true,
223
+ threads: false,
224
+ },
225
+
226
+ reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
227
+
228
+ configSchema: DingTalkConfigSchema,
229
+
230
+ // ============================================================================
231
+ // Config Management
232
+ // ============================================================================
233
+ config: {
234
+ listAccountIds: (cfg) => listDingTalkAccountIds(cfg),
235
+ resolveAccount: (cfg, accountId) => resolveDingTalkAccount({ cfg, accountId }),
236
+ defaultAccountId: (cfg) => resolveDefaultDingTalkAccountId(cfg),
237
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
238
+ setAccountEnabledInConfig({ cfg, accountId, enabled }),
239
+ deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfig({ cfg, accountId }),
240
+ isConfigured: (account) => account.configured,
241
+ describeAccount: (account) => ({
242
+ accountId: account.accountId,
243
+ name: account.name,
244
+ enabled: account.enabled,
245
+ configured: account.configured,
246
+ tokenSource: account.tokenSource,
247
+ }),
248
+ },
249
+
250
+ // ============================================================================
251
+ // Security (DM Policy)
252
+ // ============================================================================
253
+ security: {
254
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
255
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
256
+ const channelConfig = cfg.channels?.[CHANNEL_ID];
257
+ const useAccountPath = Boolean(channelConfig?.accounts?.[resolvedAccountId]);
258
+ const allowFromPath = useAccountPath
259
+ ? `channels.${CHANNEL_ID}.accounts.${resolvedAccountId}.dm.`
260
+ : `channels.${CHANNEL_ID}.dm.`;
261
+
262
+ return {
263
+ policy: account.config.dm?.policy ?? "open",
264
+ allowFrom: account.config.dm?.allowFrom ?? [],
265
+ allowFromPath,
266
+ normalizeEntry: (raw) => raw.replace(/^dingtalk:/i, ""),
267
+ };
268
+ },
269
+ },
270
+
271
+ // ============================================================================
272
+ // Mentions
273
+ // ============================================================================
274
+ mentions: {
275
+ stripPatterns: () => ["@\\S+\\s*"],
102
276
  },
103
- reload: { configPrefixes: ["channels.moltbot-dingtalk-stream"] },
104
- configSchema: {
105
- type: "object" as const,
106
- properties: {
107
- channels: {
108
- type: "object" as const,
109
- properties: {
110
- 'moltbot-dingtalk-stream': {
111
- type: "object" as const,
112
- properties: {
113
- accounts: {
114
- type: "object" as const,
115
- additionalProperties: {
116
- type: "object" as const,
117
- properties: {
118
- enabled: { type: "boolean" as const },
119
- clientId: { type: "string" as const },
120
- clientSecret: { type: "string" as const },
121
- webhookUrl: { type: "string" as const },
122
- name: { type: "string" as const },
123
- },
124
- required: ["clientId", "clientSecret"],
125
- },
277
+
278
+ // ============================================================================
279
+ // Groups
280
+ // ============================================================================
281
+ groups: {
282
+ resolveRequireMention: ({ cfg, accountId }) => {
283
+ const account = resolveDingTalkAccount({ cfg, accountId });
284
+ return account.config.requireMention ?? true;
285
+ },
286
+ },
287
+
288
+ // ============================================================================
289
+ // Messaging
290
+ // ============================================================================
291
+ messaging: {
292
+ normalizeTarget: (target) => {
293
+ if (target.startsWith("dingtalk:")) return target;
294
+ if (target.startsWith("group:")) return `dingtalk:${target}`;
295
+ if (target.startsWith("user:")) return `dingtalk:${target}`;
296
+ return `dingtalk:${target}`;
297
+ },
298
+ targetResolver: {
299
+ looksLikeId: (id) => /^[a-zA-Z0-9_-]+$/.test(id),
300
+ hint: "<conversationId|user:ID>",
301
+ },
302
+ },
303
+
304
+ // ============================================================================
305
+ // Setup (Account Configuration)
306
+ // ============================================================================
307
+ setup: {
308
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
309
+
310
+ applyAccountName: ({ cfg, accountId, name }) =>
311
+ applyAccountNameToConfig({ cfg, accountId, name }),
312
+
313
+ validateInput: ({ accountId, input }) => {
314
+ if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
315
+ return "Environment variables can only be used for the default account";
316
+ }
317
+ if (!input.useEnv && (!input.clientId || !input.clientSecret)) {
318
+ return "DingTalk requires clientId and clientSecret";
319
+ }
320
+ return null;
321
+ },
322
+
323
+ applyAccountConfig: ({ cfg, accountId, input }) => {
324
+ const namedConfig = applyAccountNameToConfig({
325
+ cfg,
326
+ accountId,
327
+ name: input.name,
328
+ });
329
+
330
+ if (accountId === DEFAULT_ACCOUNT_ID) {
331
+ return {
332
+ ...namedConfig,
333
+ channels: {
334
+ ...namedConfig.channels,
335
+ [CHANNEL_ID]: {
336
+ ...namedConfig.channels?.[CHANNEL_ID],
337
+ enabled: true,
338
+ ...(input.useEnv ? {} : { clientId: input.clientId, clientSecret: input.clientSecret }),
339
+ },
340
+ },
341
+ };
342
+ }
343
+
344
+ return {
345
+ ...namedConfig,
346
+ channels: {
347
+ ...namedConfig.channels,
348
+ [CHANNEL_ID]: {
349
+ ...namedConfig.channels?.[CHANNEL_ID],
350
+ enabled: true,
351
+ accounts: {
352
+ ...namedConfig.channels?.[CHANNEL_ID]?.accounts,
353
+ [accountId]: {
354
+ ...namedConfig.channels?.[CHANNEL_ID]?.accounts?.[accountId],
355
+ enabled: true,
356
+ clientId: input.clientId,
357
+ clientSecret: input.clientSecret,
126
358
  },
127
359
  },
128
360
  },
129
361
  },
130
- },
362
+ };
131
363
  },
132
364
  },
133
- config: {
134
- listAccountIds: (cfg: ClawdbotConfig) => listDingTalkAccountIds(cfg),
135
- resolveAccount: (cfg: ClawdbotConfig, accountId?: string) => resolveDingTalkAccount({ cfg, accountId }),
136
- defaultAccountId: (_cfg: ClawdbotConfig) => 'default',
137
- isConfigured: (account: ResolvedDingTalkAccount) => account.configured,
138
- describeAccount: (account: ResolvedDingTalkAccount) => ({
365
+
366
+ // ============================================================================
367
+ // Outbound (Send Messages)
368
+ // ============================================================================
369
+ outbound: {
370
+ deliveryMode: "direct",
371
+ textChunkLimit: 2000,
372
+
373
+ sendText: async ({ to, text, accountId }) => {
374
+ const result = await getDingTalkRuntime().channel.dingtalk.sendMessage(to, text, {
375
+ accountId,
376
+ });
377
+ return { channel: CHANNEL_ID, ...result };
378
+ },
379
+
380
+ sendMedia: async ({ to, text, mediaUrl, accountId }) => {
381
+ const result = await getDingTalkRuntime().channel.dingtalk.sendMessage(to, text, {
382
+ accountId,
383
+ mediaUrl,
384
+ });
385
+ return { channel: CHANNEL_ID, ...result };
386
+ },
387
+ },
388
+
389
+ // ============================================================================
390
+ // Status (Probe & Monitoring)
391
+ // ============================================================================
392
+ status: {
393
+ defaultRuntime: {
394
+ accountId: DEFAULT_ACCOUNT_ID,
395
+ running: false,
396
+ lastStartAt: null,
397
+ lastStopAt: null,
398
+ lastError: null,
399
+ },
400
+
401
+ probeAccount: async ({ account, timeoutMs }) => {
402
+ if (!account.clientId || !account.clientSecret) {
403
+ return { ok: false, error: "Missing clientId or clientSecret" };
404
+ }
405
+ return getDingTalkRuntime().channel.dingtalk.probe(
406
+ account.clientId,
407
+ account.clientSecret,
408
+ timeoutMs
409
+ );
410
+ },
411
+
412
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
139
413
  accountId: account.accountId,
140
414
  name: account.name,
141
415
  enabled: account.enabled,
142
416
  configured: account.configured,
417
+ tokenSource: account.tokenSource,
418
+ running: (runtime as Record<string, unknown>)?.running ?? false,
419
+ lastStartAt: (runtime as Record<string, unknown>)?.lastStartAt ?? null,
420
+ lastStopAt: (runtime as Record<string, unknown>)?.lastStopAt ?? null,
421
+ lastError: (runtime as Record<string, unknown>)?.lastError ?? null,
422
+ probe,
143
423
  }),
144
424
  },
425
+
426
+ // ============================================================================
427
+ // Gateway (Start/Stop Bot)
428
+ // ============================================================================
145
429
  gateway: {
146
- startAccount: async (ctx: any) => {
147
- const account: ResolvedDingTalkAccount = ctx.account;
148
- const config = account.config;
430
+ startAccount: async (ctx) => {
431
+ const { account, cfg, abortSignal, log, statusSink } = ctx;
149
432
  const accountId = account.accountId;
433
+ const core = pluginRuntime?.runtime;
150
434
 
151
- if (!config.clientId || !config.clientSecret) {
152
- ctx.log?.warn?.(`[${accountId}] missing clientId or clientSecret`);
435
+ if (!account.clientId || !account.clientSecret) {
436
+ log?.warn?.(`[${accountId}] Missing clientId or clientSecret`);
153
437
  return;
154
438
  }
155
439
 
156
- ctx.log?.info?.(`[${accountId}] starting DingTalk Stream client`);
440
+ if (!core?.channel?.reply) {
441
+ log?.error?.(`[${accountId}] runtime.channel.reply not available`);
442
+ return;
443
+ }
157
444
 
445
+ log?.info?.(`[${accountId}] Starting DingTalk Stream client`);
446
+
447
+ // Probe 检测凭据
158
448
  try {
159
- const client = new DWClient({
160
- clientId: config.clientId,
161
- clientSecret: config.clientSecret,
162
- });
163
-
164
- // Helper to safely handle messages
165
- const handleMessage = async (res: any) => {
166
- try {
167
- const message = JSON.parse(res.data);
168
- const textContent = message.text?.content || "";
169
- const senderId = message.senderId;
170
- const convoId = message.conversationId;
171
- const msgId = message.msgId;
172
- // Store session webhook if provided (DingTalk Stream mode provides this for replies)
173
- if (message.sessionWebhook) {
174
- sessionWebhooks.set(convoId, message.sessionWebhook);
175
- }
449
+ const probe = await getDingTalkRuntime().channel.dingtalk.probe(
450
+ account.clientId,
451
+ account.clientSecret,
452
+ 2500
453
+ );
454
+ if (probe.ok) {
455
+ log?.info?.(`[${accountId}] Credentials verified successfully`);
456
+ ctx.setStatus?.({ accountId, probe });
457
+ } else {
458
+ log?.warn?.(`[${accountId}] Credential verification failed: ${probe.error}`);
459
+ }
460
+ } catch (err) {
461
+ log?.debug?.(`[${accountId}] Probe failed: ${String(err)}`);
462
+ }
176
463
 
177
- // Log reception
178
- ctx.log?.info?.(`[${accountId}] received message from ${message.senderNick || senderId}: ${textContent}`);
179
-
180
- // Filter out empty messages
181
- if (!textContent) return;
182
-
183
- // Simple text cleaning (remove @bot mentions if possible, though DingTalk usually gives clean content or we might need to parse entities)
184
- const cleanedText = textContent.replace(/@\w+\s*/g, '').trim();
185
-
186
- // Forward the message to Clawdbot for processing
187
- if (pluginRuntime?.runtime?.channel?.reply) {
188
- const replyModule = pluginRuntime.runtime.channel.reply;
189
- const chatType = String(message.conversationType) === '2' ? 'group' : 'direct';
190
- const fromAddress = chatType === 'group' ? `dingtalk:group:${convoId}` : `dingtalk:${senderId}`;
191
-
192
- const ctxPayload = {
193
- Body: cleanedText,
194
- RawBody: textContent,
195
- CommandBody: cleanedText,
196
- From: fromAddress,
197
- To: 'bot',
198
- SessionKey: `dingtalk:${convoId}`,
199
- AccountId: accountId,
200
- ChatType: chatType,
201
- SenderName: message.senderNick,
202
- SenderId: senderId,
203
- Provider: 'dingtalk',
204
- Surface: 'dingtalk',
205
- MessageSid: message.msgId,
206
- Timestamp: message.createAt,
207
- // Required for some logic
208
- GroupSubject: chatType === 'group' ? (message.conversationId) : undefined,
209
- };
210
-
211
- const finalizedCtx = replyModule.finalizeInboundContext(ctxPayload);
212
-
213
- let replyBuffer = "";
214
- let replySent = false;
215
-
216
- const sendToDingTalk = async (text: string) => {
217
- if (!text) return;
218
- if (replySent) {
219
- ctx.log?.info?.(`[${accountId}] Reply already sent, skipping buffer flush.`);
220
- return;
221
- }
222
-
223
- const replyWebhook = sessionWebhooks.get(convoId) || config.webhookUrl;
224
- if (!replyWebhook) {
225
- ctx.log?.error?.(`[${accountId}] No webhook to reply to ${convoId}`);
226
- return;
227
- }
228
-
229
- try {
230
- await axios.post(replyWebhook, {
231
- msgtype: "text",
232
- text: { content: text }
233
- }, { headers: { 'Content-Type': 'application/json' } });
234
- replySent = true;
235
- ctx.log?.info?.(`[${accountId}] Reply sent successfully.`);
236
- } catch (e) {
237
- ctx.log?.error?.(`[${accountId}] Failed to send reply: ${e}`);
238
- }
239
- };
240
-
241
- const dispatcher = {
242
- sendFinalReply: (payload: any) => {
243
- const text = payload.text || payload.content || '';
244
- sendToDingTalk(text).catch(e => ctx.log?.error?.(`[${accountId}] sendToDingTalk failed: ${e}`));
245
- return true;
246
- },
247
- typing: async () => { },
248
- reaction: async () => { },
249
- isSynchronous: () => false,
250
- waitForIdle: async () => { },
251
- sendBlockReply: async (block: any) => {
252
- // Accumulate text from blocks
253
- const text = block.text || block.delta || block.content || '';
254
- if (text) {
255
- replyBuffer += text;
256
- }
257
- },
258
- getQueuedCounts: () => ({ active: 0, queued: 0, final: 0 })
259
- };
260
-
261
- // Internal dispatch
262
- const dispatchPromise = replyModule.dispatchReplyFromConfig({
263
- ctx: finalizedCtx,
264
- cfg: pluginRuntime.config,
265
- dispatcher: dispatcher,
266
- replyOptions: {}
267
- });
464
+ const client = new DWClient({
465
+ clientId: account.clientId,
466
+ clientSecret: account.clientSecret,
467
+ });
268
468
 
269
- // ACK immediately to prevent retries
270
- if (res.headers && res.headers.messageId) {
271
- client.socketCallBackResponse(res.headers.messageId, { status: "SUCCEED" });
272
- }
469
+ const handleMessage = async (res: { data: string; headers?: { messageId?: string } }) => {
470
+ try {
471
+ const message = JSON.parse(res.data);
472
+ const textContent = message.text?.content || "";
473
+ const senderId = message.senderId;
474
+ const convoId = message.conversationId;
273
475
 
274
- // Wait for run to finish
275
- await dispatchPromise;
476
+ log?.info?.(`[${accountId}] Received message from ${message.senderNick || senderId}: ${textContent}`);
276
477
 
277
- // If final reply wasn't called but we have buffer (streaming case where agent didn't return final payload?)
278
- if (!replySent && replyBuffer) {
279
- ctx.log?.info?.(`[${accountId}] Sending accumulated buffer from blocks (len=${replyBuffer.length}).`);
280
- await sendToDingTalk(replyBuffer);
281
- }
478
+ statusSink?.({ lastInboundAt: Date.now() });
282
479
 
283
- } else {
284
- ctx.log?.error?.(`[${accountId}] runtime.channel.reply not available`);
480
+ if (!textContent) return;
481
+
482
+ const rawBody = textContent;
483
+ const cleanedText = textContent.replace(/@\S+\s*/g, "").trim();
484
+
485
+ const chatType = String(message.conversationType) === "2" ? "group" : "direct";
486
+
487
+ // Store session webhook with multiple keys for flexible lookup
488
+ if (message.sessionWebhook) {
489
+ getDingTalkRuntime().channel.dingtalk.setSessionWebhook(convoId, message.sessionWebhook);
490
+ if (chatType === "direct" && senderId) {
491
+ getDingTalkRuntime().channel.dingtalk.setSessionWebhook(senderId, message.sessionWebhook);
492
+ getDingTalkRuntime().channel.dingtalk.setSessionWebhook(`dingtalk:user:${senderId}`, message.sessionWebhook);
493
+ }
494
+ if (chatType === "group" && convoId) {
495
+ getDingTalkRuntime().channel.dingtalk.setSessionWebhook(`dingtalk:channel:${convoId}`, message.sessionWebhook);
285
496
  }
286
- } catch (error) {
287
- ctx.log?.error?.(`[${accountId}] error processing message: ${error instanceof Error ? error.message : String(error)}`);
288
- console.error('DingTalk Handler Error:', error);
289
497
  }
290
- };
291
498
 
292
- // Register callback for robot messages
293
- client.registerCallbackListener('/v1.0/im/bot/messages/get', handleMessage);
499
+ const route = core.channel.routing?.resolveAgentRoute?.({
500
+ cfg,
501
+ channel: CHANNEL_ID,
502
+ accountId,
503
+ peer: {
504
+ kind: chatType === "group" ? "group" : "direct",
505
+ id: chatType === "group" ? convoId : senderId,
506
+ },
507
+ }) ?? { agentId: "main", sessionKey: `dingtalk:${convoId}`, accountId };
508
+
509
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions?.(cfg) ?? {};
510
+ const body = core.channel.reply.formatAgentEnvelope?.({
511
+ channel: "DingTalk",
512
+ from: message.senderNick ?? message.senderId,
513
+ timestamp: message.createAt,
514
+ envelope: envelopeOptions,
515
+ body: cleanedText,
516
+ }) ?? cleanedText;
517
+
518
+ const ctxPayload: InboundContext = {
519
+ Body: body,
520
+ RawBody: rawBody,
521
+ CommandBody: cleanedText,
522
+ From: `dingtalk:user:${senderId}`,
523
+ To: chatType === "group" ? `dingtalk:channel:${convoId}` : `dingtalk:user:${senderId}`,
524
+ SessionKey: route.sessionKey,
525
+ AccountId: route.accountId,
526
+ ChatType: chatType,
527
+ ConversationLabel: chatType === "group" ? convoId : undefined,
528
+ SenderName: message.senderNick,
529
+ SenderId: senderId,
530
+ SenderUsername: message.senderNick,
531
+ Provider: "dingtalk",
532
+ Surface: "dingtalk",
533
+ MessageSid: message.msgId,
534
+ Timestamp: message.createAt,
535
+ GroupSubject: chatType === "group" ? convoId : undefined,
536
+ OriginatingChannel: CHANNEL_ID,
537
+ OriginatingTo: chatType === "group" ? `dingtalk:channel:${convoId}` : `dingtalk:user:${senderId}`,
538
+ };
539
+
540
+ const finalizedCtx = core.channel.reply.finalizeInboundContext(ctxPayload);
541
+
542
+ const storePath = core.channel.session?.resolveStorePath?.(
543
+ (cfg as Record<string, unknown>).session,
544
+ { agentId: route.agentId }
545
+ ) ?? "";
546
+
547
+ if (core.channel.session?.recordInboundSession) {
548
+ await core.channel.session.recordInboundSession({
549
+ storePath,
550
+ sessionKey: route.sessionKey,
551
+ ctx: finalizedCtx,
552
+ onRecordError: (err) => {
553
+ log?.error?.(`[${accountId}] Failed to record session: ${String(err)}`);
554
+ },
555
+ });
556
+ }
294
557
 
295
- // Connect to DingTalk Stream
296
- await client.connect();
297
- activeClients.set(accountId, client);
298
- ctx.log?.info?.(`[${accountId}] DingTalk Stream client connected`);
558
+ if (res.headers?.messageId) {
559
+ client.socketCallBackResponse(res.headers.messageId, { status: "SUCCEED" });
560
+ }
299
561
 
300
- // Handle abort signal for cleanup
301
- ctx.abortSignal?.addEventListener('abort', () => {
302
- ctx.log?.info?.(`[${accountId}] stopping DingTalk Stream client`);
303
- client.disconnect();
304
- activeClients.delete(accountId);
305
- });
562
+ const DINGTALK_TEXT_LIMIT = 2000;
306
563
 
307
- } catch (error) {
308
- ctx.log?.error?.(`[${accountId}] failed to start: ${error instanceof Error ? error.message : String(error)}`);
309
- throw error;
310
- }
311
- },
312
- },
313
- outbound: {
314
- deliveryMode: "direct" as const,
315
- sendText: async (opts: { text: string; account: ResolvedDingTalkAccount; target: string; senderId?: string }) => {
316
- const { text, account, target } = opts;
317
- const config = account.config;
564
+ const deliverDingTalkReply = async (payload: { text?: string; content?: string; mediaUrls?: string[] }) => {
565
+ const text = payload.text || payload.content || "";
566
+ if (!text) {
567
+ log?.warn?.(`[${accountId}] Received empty payload`);
568
+ return;
569
+ }
318
570
 
319
- // Try session webhook first (for replies)
320
- const sessionWebhook = sessionWebhooks.get(target);
571
+ log?.info?.(`[${accountId}] Sending reply: ${text.substring(0, 50)}...`);
572
+
573
+ const chunkMode = core.channel.text?.resolveChunkMode?.(cfg, CHANNEL_ID, accountId) ?? "smart";
574
+ const chunks = core.channel.text?.chunkMarkdownTextWithMode?.(text, DINGTALK_TEXT_LIMIT, chunkMode) ?? [text];
575
+
576
+ for (const chunk of chunks.length > 0 ? chunks : [text]) {
577
+ if (!chunk) continue;
578
+ const result = await getDingTalkRuntime().channel.dingtalk.sendMessage(convoId, chunk, {
579
+ accountId,
580
+ });
321
581
 
322
- if (sessionWebhook) {
323
- try {
324
- await axios.post(sessionWebhook, {
325
- msgtype: "text",
326
- text: { content: text }
327
- }, {
328
- headers: { 'Content-Type': 'application/json' }
329
- });
330
- return { ok: true as const };
331
- } catch (error) {
332
- // Fall through to webhookUrl
333
- }
334
- }
582
+ if (result.ok) {
583
+ log?.info?.(`[${accountId}] Reply sent successfully`);
584
+ statusSink?.({ lastOutboundAt: Date.now() });
585
+ } else {
586
+ log?.error?.(`[${accountId}] Failed to send reply: ${result.error}`);
587
+ }
588
+ }
589
+ };
335
590
 
336
- // Fallback to webhookUrl for proactive messages
337
- if (config?.webhookUrl) {
338
- try {
339
- await axios.post(config.webhookUrl, {
340
- msgtype: "text",
341
- text: { content: text }
342
- }, {
343
- headers: { 'Content-Type': 'application/json' }
591
+ log?.info?.(`[${accountId}] Using dispatchReplyWithBufferedBlockDispatcher`);
592
+
593
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
594
+ ctx: finalizedCtx,
595
+ cfg,
596
+ dispatcherOptions: {
597
+ deliver: deliverDingTalkReply,
598
+ onError: (err, info) => {
599
+ log?.error?.(`[${accountId}] DingTalk ${info.kind} reply failed: ${String(err)}`);
600
+ },
601
+ },
344
602
  });
345
- return { ok: true as const };
603
+
604
+ log?.info?.(`[${accountId}] dispatchReplyWithBufferedBlockDispatcher completed`);
346
605
  } catch (error) {
347
- return { ok: false as const, error: error instanceof Error ? error.message : String(error) };
606
+ log?.error?.(
607
+ `[${accountId}] Error processing message: ${error instanceof Error ? error.message : String(error)}`
608
+ );
348
609
  }
349
- }
610
+ };
350
611
 
351
- return { ok: false as const, error: "No webhook available for sending messages" };
352
- }
353
- }
354
- };
612
+ client.registerCallbackListener("/v1.0/im/bot/messages/get", handleMessage);
613
+
614
+ await client.connect();
615
+ getDingTalkRuntime().channel.dingtalk.setClient(accountId, client);
616
+ log?.info?.(`[${accountId}] DingTalk Stream client connected`);
355
617
 
618
+ abortSignal?.addEventListener("abort", () => {
619
+ log?.info?.(`[${accountId}] Stopping DingTalk Stream client`);
620
+ client.disconnect();
621
+ getDingTalkRuntime().channel.dingtalk.removeClient(accountId);
622
+ });
623
+ },
624
+ },
625
+ };
356
626
 
627
+ // ============================================================================
628
+ // Plugin Export
629
+ // ============================================================================
357
630
 
358
- // Plugin object format required by Clawdbot
359
631
  const plugin = {
360
- id: "moltbot-dingtalk-stream",
632
+ id: CHANNEL_ID,
361
633
  name: "DingTalk Channel",
362
- description: "DingTalk channel plugin using Stream mode",
363
- configSchema: {
364
- type: "object" as const,
365
- properties: {}
366
- },
634
+ description: "DingTalk channel plugin (Stream mode)",
635
+
367
636
  register(api: ClawdbotPluginApi) {
368
637
  pluginRuntime = api;
369
- api.registerChannel({ plugin: dingTalkChannelPlugin });
370
- }
638
+ api.registerChannel({ plugin: dingtalkPlugin });
639
+ },
371
640
  };
372
641
 
373
- export default plugin;
642
+ export default plugin;