moltbot-dingtalk-stream 1.0.8 → 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,382 +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
- // From 地址: 用于标识发送者
191
- // - 群聊: channel:group:<groupId>:<senderId>
192
- // - 私聊: channel:<senderId>
193
- const fromAddress = chatType === 'group'
194
- ? `moltbot-dingtalk-stream:group:${convoId}:${senderId}`
195
- : `moltbot-dingtalk-stream:${senderId}`;
196
-
197
- const ctxPayload = {
198
- Body: cleanedText,
199
- RawBody: textContent,
200
- CommandBody: cleanedText,
201
- From: fromAddress,
202
- To: 'bot',
203
- // SessionKey 根据 chatType 设置:
204
- // - 群聊: 使用 group:<conversationId> 格式让所有群成员共享上下文
205
- // - 私聊: 使用 dm:<senderId> 格式让每个用户有独立的会话上下文
206
- SessionKey: chatType === 'group' ? `group:${convoId}` : `dm:${senderId}`,
207
- AccountId: accountId,
208
- ChatType: chatType,
209
- SenderName: message.senderNick,
210
- SenderId: senderId,
211
- Provider: 'moltbot-dingtalk-stream',
212
- Surface: 'moltbot-dingtalk-stream',
213
- MessageSid: message.msgId,
214
- Timestamp: message.createAt,
215
- // 群聊相关元数据
216
- GroupSubject: chatType === 'group' ? (message.conversationId) : undefined,
217
- ConversationLabel: chatType === 'group' ? `钉钉群:${convoId}` : `钉钉私聊:${message.senderNick || senderId}`,
218
- };
219
-
220
- const finalizedCtx = replyModule.finalizeInboundContext(ctxPayload);
221
-
222
- let replyBuffer = "";
223
- let replySent = false;
224
-
225
- const sendToDingTalk = async (text: string) => {
226
- if (!text) return;
227
- if (replySent) {
228
- ctx.log?.info?.(`[${accountId}] Reply already sent, skipping buffer flush.`);
229
- return;
230
- }
231
-
232
- const replyWebhook = sessionWebhooks.get(convoId) || config.webhookUrl;
233
- if (!replyWebhook) {
234
- ctx.log?.error?.(`[${accountId}] No webhook to reply to ${convoId}`);
235
- return;
236
- }
237
-
238
- try {
239
- await axios.post(replyWebhook, {
240
- msgtype: "text",
241
- text: { content: text }
242
- }, { headers: { 'Content-Type': 'application/json' } });
243
- replySent = true;
244
- ctx.log?.info?.(`[${accountId}] Reply sent successfully.`);
245
- } catch (e) {
246
- ctx.log?.error?.(`[${accountId}] Failed to send reply: ${e}`);
247
- }
248
- };
249
-
250
- const dispatcher = {
251
- sendFinalReply: (payload: any) => {
252
- const text = payload.text || payload.content || '';
253
- sendToDingTalk(text).catch(e => ctx.log?.error?.(`[${accountId}] sendToDingTalk failed: ${e}`));
254
- return true;
255
- },
256
- typing: async () => { },
257
- reaction: async () => { },
258
- isSynchronous: () => false,
259
- waitForIdle: async () => { },
260
- sendBlockReply: async (block: any) => {
261
- // Accumulate text from blocks
262
- const text = block.text || block.delta || block.content || '';
263
- if (text) {
264
- replyBuffer += text;
265
- }
266
- },
267
- getQueuedCounts: () => ({ active: 0, queued: 0, final: 0 })
268
- };
269
-
270
- // Internal dispatch
271
- const dispatchPromise = replyModule.dispatchReplyFromConfig({
272
- ctx: finalizedCtx,
273
- cfg: pluginRuntime.config,
274
- dispatcher: dispatcher,
275
- replyOptions: {}
276
- });
464
+ const client = new DWClient({
465
+ clientId: account.clientId,
466
+ clientSecret: account.clientSecret,
467
+ });
277
468
 
278
- // ACK immediately to prevent retries
279
- if (res.headers && res.headers.messageId) {
280
- client.socketCallBackResponse(res.headers.messageId, { status: "SUCCEED" });
281
- }
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;
282
475
 
283
- // Wait for run to finish
284
- await dispatchPromise;
476
+ log?.info?.(`[${accountId}] Received message from ${message.senderNick || senderId}: ${textContent}`);
285
477
 
286
- // If final reply wasn't called but we have buffer (streaming case where agent didn't return final payload?)
287
- if (!replySent && replyBuffer) {
288
- ctx.log?.info?.(`[${accountId}] Sending accumulated buffer from blocks (len=${replyBuffer.length}).`);
289
- await sendToDingTalk(replyBuffer);
290
- }
478
+ statusSink?.({ lastInboundAt: Date.now() });
291
479
 
292
- } else {
293
- 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);
294
496
  }
295
- } catch (error) {
296
- ctx.log?.error?.(`[${accountId}] error processing message: ${error instanceof Error ? error.message : String(error)}`);
297
- console.error('DingTalk Handler Error:', error);
298
497
  }
299
- };
300
498
 
301
- // Register callback for robot messages
302
- 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
+ }
303
557
 
304
- // Connect to DingTalk Stream
305
- await client.connect();
306
- activeClients.set(accountId, client);
307
- ctx.log?.info?.(`[${accountId}] DingTalk Stream client connected`);
558
+ if (res.headers?.messageId) {
559
+ client.socketCallBackResponse(res.headers.messageId, { status: "SUCCEED" });
560
+ }
308
561
 
309
- // Handle abort signal for cleanup
310
- ctx.abortSignal?.addEventListener('abort', () => {
311
- ctx.log?.info?.(`[${accountId}] stopping DingTalk Stream client`);
312
- client.disconnect();
313
- activeClients.delete(accountId);
314
- });
562
+ const DINGTALK_TEXT_LIMIT = 2000;
315
563
 
316
- } catch (error) {
317
- ctx.log?.error?.(`[${accountId}] failed to start: ${error instanceof Error ? error.message : String(error)}`);
318
- throw error;
319
- }
320
- },
321
- },
322
- outbound: {
323
- deliveryMode: "direct" as const,
324
- sendText: async (opts: { text: string; account: ResolvedDingTalkAccount; target: string; senderId?: string }) => {
325
- const { text, account, target } = opts;
326
- 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
+ }
327
570
 
328
- // Try session webhook first (for replies)
329
- 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
+ });
330
581
 
331
- if (sessionWebhook) {
332
- try {
333
- await axios.post(sessionWebhook, {
334
- msgtype: "text",
335
- text: { content: text }
336
- }, {
337
- headers: { 'Content-Type': 'application/json' }
338
- });
339
- return { ok: true as const };
340
- } catch (error) {
341
- // Fall through to webhookUrl
342
- }
343
- }
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
+ };
344
590
 
345
- // Fallback to webhookUrl for proactive messages
346
- if (config?.webhookUrl) {
347
- try {
348
- await axios.post(config.webhookUrl, {
349
- msgtype: "text",
350
- text: { content: text }
351
- }, {
352
- 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
+ },
353
602
  });
354
- return { ok: true as const };
603
+
604
+ log?.info?.(`[${accountId}] dispatchReplyWithBufferedBlockDispatcher completed`);
355
605
  } catch (error) {
356
- 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
+ );
357
609
  }
358
- }
610
+ };
359
611
 
360
- return { ok: false as const, error: "No webhook available for sending messages" };
361
- }
362
- }
363
- };
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`);
364
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
+ };
365
626
 
627
+ // ============================================================================
628
+ // Plugin Export
629
+ // ============================================================================
366
630
 
367
- // Plugin object format required by Clawdbot
368
631
  const plugin = {
369
- id: "moltbot-dingtalk-stream",
632
+ id: CHANNEL_ID,
370
633
  name: "DingTalk Channel",
371
- description: "DingTalk channel plugin using Stream mode",
372
- configSchema: {
373
- type: "object" as const,
374
- properties: {}
375
- },
634
+ description: "DingTalk channel plugin (Stream mode)",
635
+
376
636
  register(api: ClawdbotPluginApi) {
377
637
  pluginRuntime = api;
378
- api.registerChannel({ plugin: dingTalkChannelPlugin });
379
- }
638
+ api.registerChannel({ plugin: dingtalkPlugin });
639
+ },
380
640
  };
381
641
 
382
- export default plugin;
642
+ export default plugin;