@nextclaw/channel-runtime 0.2.3 → 0.2.4

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/dist/index.js ADDED
@@ -0,0 +1,4207 @@
1
+ // src/channels/base.ts
2
+ var BaseChannel = class {
3
+ constructor(config, bus) {
4
+ this.config = config;
5
+ this.bus = bus;
6
+ }
7
+ running = false;
8
+ async handleControlMessage(_msg) {
9
+ return false;
10
+ }
11
+ isAllowed(senderId) {
12
+ const allowList = this.config.allowFrom ?? [];
13
+ if (!allowList.length) {
14
+ return true;
15
+ }
16
+ if (allowList.includes(senderId)) {
17
+ return true;
18
+ }
19
+ if (senderId.includes("|")) {
20
+ return senderId.split("|").some((part) => allowList.includes(part));
21
+ }
22
+ return false;
23
+ }
24
+ async handleMessage(params) {
25
+ if (!this.isAllowed(params.senderId)) {
26
+ return;
27
+ }
28
+ const msg = {
29
+ channel: this.name,
30
+ senderId: params.senderId,
31
+ chatId: params.chatId,
32
+ content: params.content,
33
+ timestamp: /* @__PURE__ */ new Date(),
34
+ attachments: params.attachments ?? [],
35
+ metadata: params.metadata ?? {}
36
+ };
37
+ await this.bus.publishInbound(msg);
38
+ }
39
+ get isRunning() {
40
+ return this.running;
41
+ }
42
+ };
43
+
44
+ // src/config/brand.ts
45
+ import {
46
+ APP_NAME,
47
+ APP_TITLE,
48
+ APP_REPLY_SUBJECT
49
+ } from "@nextclaw/core";
50
+
51
+ // src/channels/dingtalk.ts
52
+ import { DWClient, EventAck, TOPIC_ROBOT } from "dingtalk-stream";
53
+ import { fetch } from "undici";
54
+ var DingTalkChannel = class extends BaseChannel {
55
+ name = "dingtalk";
56
+ client = null;
57
+ accessToken = null;
58
+ tokenExpiry = 0;
59
+ constructor(config, bus) {
60
+ super(config, bus);
61
+ }
62
+ async start() {
63
+ this.running = true;
64
+ if (!this.config.clientId || !this.config.clientSecret) {
65
+ throw new Error("DingTalk clientId/clientSecret not configured");
66
+ }
67
+ this.client = new DWClient({
68
+ clientId: this.config.clientId,
69
+ clientSecret: this.config.clientSecret,
70
+ debug: false
71
+ });
72
+ this.client.registerCallbackListener(TOPIC_ROBOT, async (res) => {
73
+ await this.handleRobotMessage(res);
74
+ });
75
+ this.client.registerAllEventListener(() => ({ status: EventAck.SUCCESS }));
76
+ await this.client.connect();
77
+ }
78
+ async stop() {
79
+ this.running = false;
80
+ if (this.client) {
81
+ this.client.disconnect();
82
+ this.client = null;
83
+ }
84
+ }
85
+ async send(msg) {
86
+ const token = await this.getAccessToken();
87
+ if (!token) {
88
+ return;
89
+ }
90
+ const url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend";
91
+ const payload = {
92
+ robotCode: this.config.clientId,
93
+ userIds: [msg.chatId],
94
+ msgKey: "sampleMarkdown",
95
+ msgParam: JSON.stringify({
96
+ text: msg.content,
97
+ title: `${APP_TITLE} Reply`
98
+ })
99
+ };
100
+ const response = await fetch(url, {
101
+ method: "POST",
102
+ headers: {
103
+ "content-type": "application/json",
104
+ "x-acs-dingtalk-access-token": token
105
+ },
106
+ body: JSON.stringify(payload)
107
+ });
108
+ if (!response.ok) {
109
+ throw new Error(`DingTalk send failed: ${response.status}`);
110
+ }
111
+ }
112
+ async handleRobotMessage(res) {
113
+ if (!res?.data) {
114
+ return;
115
+ }
116
+ let parsed;
117
+ try {
118
+ parsed = JSON.parse(res.data);
119
+ } catch {
120
+ return;
121
+ }
122
+ const text = parsed.text?.content?.trim() ?? "";
123
+ if (!text) {
124
+ this.client?.socketCallBackResponse(res.headers.messageId, { ok: true });
125
+ return;
126
+ }
127
+ const senderId = parsed.senderStaffId || parsed.senderId || "";
128
+ const senderName = parsed.senderNick || "";
129
+ if (!senderId) {
130
+ this.client?.socketCallBackResponse(res.headers.messageId, { ok: true });
131
+ return;
132
+ }
133
+ await this.handleMessage({
134
+ senderId,
135
+ chatId: senderId,
136
+ content: text,
137
+ attachments: [],
138
+ metadata: {
139
+ sender_name: senderName,
140
+ platform: "dingtalk"
141
+ }
142
+ });
143
+ this.client?.socketCallBackResponse(res.headers.messageId, { ok: true });
144
+ }
145
+ async getAccessToken() {
146
+ if (this.accessToken && Date.now() < this.tokenExpiry) {
147
+ return this.accessToken;
148
+ }
149
+ const url = "https://api.dingtalk.com/v1.0/oauth2/accessToken";
150
+ const payload = {
151
+ appKey: this.config.clientId,
152
+ appSecret: this.config.clientSecret
153
+ };
154
+ const response = await fetch(url, {
155
+ method: "POST",
156
+ headers: { "content-type": "application/json" },
157
+ body: JSON.stringify(payload)
158
+ });
159
+ if (!response.ok) {
160
+ return null;
161
+ }
162
+ const data = await response.json();
163
+ const token = data.accessToken;
164
+ const expiresIn = Number(data.expireIn ?? 7200);
165
+ if (!token) {
166
+ return null;
167
+ }
168
+ this.accessToken = token;
169
+ this.tokenExpiry = Date.now() + (expiresIn - 60) * 1e3;
170
+ return token;
171
+ }
172
+ };
173
+
174
+ // src/channels/discord.ts
175
+ import {
176
+ Client,
177
+ GatewayIntentBits,
178
+ Partials,
179
+ MessageFlags,
180
+ REST,
181
+ Routes,
182
+ ApplicationCommandOptionType
183
+ } from "discord.js";
184
+ import { ProxyAgent, fetch as fetch2 } from "undici";
185
+ import { join } from "path";
186
+ import { mkdirSync, writeFileSync } from "fs";
187
+
188
+ // src/utils/helpers.ts
189
+ import { getDataPath } from "@nextclaw/core";
190
+
191
+ // src/channels/typing-controller.ts
192
+ var ChannelTypingController = class {
193
+ heartbeatMs;
194
+ autoStopMs;
195
+ sendTyping;
196
+ tasks = /* @__PURE__ */ new Map();
197
+ constructor(options) {
198
+ this.heartbeatMs = Math.max(1e3, Math.floor(options.heartbeatMs));
199
+ this.autoStopMs = Math.max(this.heartbeatMs, Math.floor(options.autoStopMs));
200
+ this.sendTyping = options.sendTyping;
201
+ }
202
+ start(targetId) {
203
+ this.stop(targetId);
204
+ void this.sendTyping(targetId);
205
+ const heartbeat = setInterval(() => {
206
+ void this.sendTyping(targetId);
207
+ }, this.heartbeatMs);
208
+ const autoStop = setTimeout(() => {
209
+ this.stop(targetId);
210
+ }, this.autoStopMs);
211
+ this.tasks.set(targetId, {
212
+ heartbeat,
213
+ autoStop
214
+ });
215
+ }
216
+ stop(targetId) {
217
+ const task = this.tasks.get(targetId);
218
+ if (!task) {
219
+ return;
220
+ }
221
+ clearInterval(task.heartbeat);
222
+ clearTimeout(task.autoStop);
223
+ this.tasks.delete(targetId);
224
+ }
225
+ stopAll() {
226
+ for (const targetId of this.tasks.keys()) {
227
+ this.stop(targetId);
228
+ }
229
+ }
230
+ };
231
+
232
+ // src/channels/discord.ts
233
+ import { CommandRegistry, isTypingStopControlMessage } from "@nextclaw/core";
234
+ var DEFAULT_MEDIA_MAX_MB = 8;
235
+ var MEDIA_FETCH_TIMEOUT_MS = 15e3;
236
+ var TYPING_HEARTBEAT_MS = 6e3;
237
+ var TYPING_AUTO_STOP_MS = 12e4;
238
+ var DISCORD_TEXT_LIMIT = 2e3;
239
+ var DISCORD_MAX_LINES_PER_MESSAGE = 17;
240
+ var STREAM_EDIT_MIN_INTERVAL_MS = 600;
241
+ var STREAM_MAX_UPDATES_PER_MESSAGE = 40;
242
+ var FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
243
+ var SLASH_GUILD_THRESHOLD = 10;
244
+ var DiscordChannel = class extends BaseChannel {
245
+ constructor(config, bus, sessionManager, coreConfig) {
246
+ super(config, bus);
247
+ this.sessionManager = sessionManager;
248
+ this.coreConfig = coreConfig;
249
+ this.commandRegistry = this.coreConfig ? new CommandRegistry(this.coreConfig, this.sessionManager) : null;
250
+ this.typingController = new ChannelTypingController({
251
+ heartbeatMs: TYPING_HEARTBEAT_MS,
252
+ autoStopMs: TYPING_AUTO_STOP_MS,
253
+ sendTyping: async (channelId) => {
254
+ if (!this.client) {
255
+ return;
256
+ }
257
+ const channel = this.client.channels.cache.get(channelId);
258
+ if (!channel || !channel.isTextBased()) {
259
+ return;
260
+ }
261
+ const textChannel = channel;
262
+ await textChannel.sendTyping();
263
+ }
264
+ });
265
+ }
266
+ name = "discord";
267
+ client = null;
268
+ typingController;
269
+ commandRegistry;
270
+ async start() {
271
+ if (!this.config.token) {
272
+ throw new Error("Discord token not configured");
273
+ }
274
+ this.running = true;
275
+ this.client = new Client({
276
+ intents: this.config.intents ?? GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages | GatewayIntentBits.DirectMessages,
277
+ partials: [Partials.Channel]
278
+ });
279
+ this.client.on("ready", () => {
280
+ console.log("Discord bot connected");
281
+ void this.registerSlashCommands();
282
+ });
283
+ this.client.on("messageCreate", async (message) => {
284
+ await this.handleIncoming(message);
285
+ });
286
+ this.client.on("interactionCreate", async (interaction) => {
287
+ await this.handleInteraction(interaction);
288
+ });
289
+ await this.client.login(this.config.token);
290
+ }
291
+ async stop() {
292
+ this.running = false;
293
+ this.typingController.stopAll();
294
+ if (this.client) {
295
+ await this.client.destroy();
296
+ this.client = null;
297
+ }
298
+ }
299
+ async handleControlMessage(msg) {
300
+ if (!isTypingStopControlMessage(msg)) {
301
+ return false;
302
+ }
303
+ this.stopTyping(msg.chatId);
304
+ return true;
305
+ }
306
+ async send(msg) {
307
+ if (isTypingStopControlMessage(msg)) {
308
+ this.stopTyping(msg.chatId);
309
+ return;
310
+ }
311
+ if (!this.client) {
312
+ return;
313
+ }
314
+ const channel = await this.client.channels.fetch(msg.chatId);
315
+ if (!channel || !channel.isTextBased()) {
316
+ return;
317
+ }
318
+ this.stopTyping(msg.chatId);
319
+ const textChannel = channel;
320
+ const content = msg.content ?? "";
321
+ const textChunkLimit = resolveTextChunkLimit(this.config);
322
+ const chunks = chunkDiscordText(content, {
323
+ maxChars: textChunkLimit,
324
+ maxLines: DISCORD_MAX_LINES_PER_MESSAGE
325
+ });
326
+ if (chunks.length === 0) {
327
+ return;
328
+ }
329
+ const flags = msg.metadata?.silent === true ? MessageFlags.SuppressNotifications : void 0;
330
+ const streamingMode = resolveDiscordStreamingMode(this.config);
331
+ if (streamingMode === "off") {
332
+ await sendDiscordChunks({
333
+ textChannel,
334
+ chunks,
335
+ replyTo: msg.replyTo ?? void 0,
336
+ flags
337
+ });
338
+ return;
339
+ }
340
+ await sendDiscordDraftStreaming({
341
+ textChannel,
342
+ chunks,
343
+ replyTo: msg.replyTo ?? void 0,
344
+ flags,
345
+ draftChunk: resolveDraftChunkConfig(this.config, textChunkLimit),
346
+ streamingMode
347
+ });
348
+ }
349
+ async handleIncoming(message) {
350
+ const selfUserId = this.client?.user?.id;
351
+ if (selfUserId && message.author.id === selfUserId) {
352
+ return;
353
+ }
354
+ if (message.author.bot && !this.config.allowBots) {
355
+ return;
356
+ }
357
+ const senderId = message.author.id;
358
+ const channelId = message.channelId;
359
+ const isGroup = Boolean(message.guildId);
360
+ if (!this.isAllowedByPolicy({ senderId, channelId, isGroup })) {
361
+ return;
362
+ }
363
+ const mentionState = this.resolveMentionState({ message, selfUserId, channelId, isGroup });
364
+ if (mentionState.requireMention && !mentionState.wasMentioned) {
365
+ return;
366
+ }
367
+ const contentParts = [];
368
+ const attachments = [];
369
+ const attachmentIssues = [];
370
+ if (message.content) {
371
+ contentParts.push(message.content);
372
+ }
373
+ if (message.attachments.size) {
374
+ const mediaDir = join(getDataPath(), "media");
375
+ mkdirSync(mediaDir, { recursive: true });
376
+ const maxBytes = Math.max(1, this.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB) * 1024 * 1024;
377
+ const proxy = this.resolveProxyAgent();
378
+ for (const attachment of message.attachments.values()) {
379
+ const resolved = await this.resolveInboundAttachment({
380
+ attachment,
381
+ mediaDir,
382
+ maxBytes,
383
+ proxy
384
+ });
385
+ if (resolved.attachment) {
386
+ attachments.push(resolved.attachment);
387
+ }
388
+ if (resolved.issue) {
389
+ attachmentIssues.push(resolved.issue);
390
+ }
391
+ }
392
+ if (!message.content && attachments.length > 0) {
393
+ contentParts.push(buildAttachmentSummary(attachments));
394
+ }
395
+ }
396
+ const replyTo = message.reference?.messageId ?? null;
397
+ this.startTyping(channelId);
398
+ try {
399
+ await this.handleMessage({
400
+ senderId,
401
+ chatId: channelId,
402
+ content: contentParts.length ? contentParts.join("\n") : "[empty message]",
403
+ attachments,
404
+ metadata: {
405
+ message_id: message.id,
406
+ channel_id: channelId,
407
+ guild_id: message.guildId,
408
+ reply_to: replyTo,
409
+ account_id: this.resolveAccountId(),
410
+ accountId: this.resolveAccountId(),
411
+ is_group: isGroup,
412
+ peer_kind: isGroup ? "channel" : "direct",
413
+ peer_id: isGroup ? channelId : senderId,
414
+ was_mentioned: mentionState.wasMentioned,
415
+ require_mention: mentionState.requireMention,
416
+ ...attachmentIssues.length ? { attachment_issues: attachmentIssues } : {}
417
+ }
418
+ });
419
+ } catch (error) {
420
+ this.stopTyping(channelId);
421
+ throw error;
422
+ }
423
+ }
424
+ async handleInteraction(interaction) {
425
+ if (!interaction.isChatInputCommand()) {
426
+ return;
427
+ }
428
+ await this.handleSlashCommand(interaction);
429
+ }
430
+ async handleSlashCommand(interaction) {
431
+ if (!this.commandRegistry) {
432
+ await this.replyInteraction(interaction, "Slash commands are not available.", true);
433
+ return;
434
+ }
435
+ const channelId = interaction.channelId;
436
+ if (!channelId) {
437
+ await this.replyInteraction(interaction, "Slash commands are not available in this channel.", true);
438
+ return;
439
+ }
440
+ const senderId = interaction.user.id;
441
+ const isGroup = Boolean(interaction.guildId);
442
+ if (!this.isAllowedByPolicy({ senderId, channelId, isGroup })) {
443
+ await this.replyInteraction(interaction, "You are not authorized to use commands here.", true);
444
+ return;
445
+ }
446
+ const args = {};
447
+ for (const option of interaction.options.data) {
448
+ if (typeof option.name === "string" && option.value !== void 0) {
449
+ args[option.name] = option.value;
450
+ }
451
+ }
452
+ try {
453
+ await interaction.deferReply({ ephemeral: true });
454
+ const result = await this.commandRegistry.execute(interaction.commandName, args, {
455
+ channel: this.name,
456
+ chatId: channelId,
457
+ senderId,
458
+ sessionKey: `${this.name}:${channelId}`
459
+ });
460
+ if (result.ephemeral === false) {
461
+ await interaction.editReply({ content: "Command executed." });
462
+ await interaction.followUp({ content: result.content, ephemeral: false });
463
+ return;
464
+ }
465
+ await interaction.editReply({ content: result.content });
466
+ } catch (error) {
467
+ await this.replyInteraction(interaction, "Command failed to execute.", true);
468
+ console.error(`Discord slash command error: ${String(error)}`);
469
+ }
470
+ }
471
+ async replyInteraction(interaction, content, ephemeral) {
472
+ const payload = { content, ephemeral };
473
+ if (interaction.deferred || interaction.replied) {
474
+ await interaction.followUp(payload);
475
+ return;
476
+ }
477
+ await interaction.reply(payload);
478
+ }
479
+ async registerSlashCommands() {
480
+ if (!this.client || !this.commandRegistry) {
481
+ return;
482
+ }
483
+ const appId = this.client.application?.id ?? this.client.user?.id;
484
+ if (!appId) {
485
+ return;
486
+ }
487
+ const commands = this.buildSlashCommandPayloads();
488
+ if (!commands.length) {
489
+ return;
490
+ }
491
+ const rest = new REST({ version: "10" }).setToken(this.config.token);
492
+ let guildIds = [];
493
+ try {
494
+ const guilds = await this.client.guilds.fetch();
495
+ guildIds = [...guilds.keys()];
496
+ } catch (error) {
497
+ console.error(`Failed to fetch Discord guild list: ${String(error)}`);
498
+ }
499
+ try {
500
+ if (guildIds.length > 0 && guildIds.length <= SLASH_GUILD_THRESHOLD) {
501
+ for (const guildId of guildIds) {
502
+ await rest.put(Routes.applicationGuildCommands(appId, guildId), { body: commands });
503
+ }
504
+ console.log(`Discord slash commands registered for ${guildIds.length} guild(s).`);
505
+ } else {
506
+ await rest.put(Routes.applicationCommands(appId), { body: commands });
507
+ console.log("Discord slash commands registered globally.");
508
+ }
509
+ } catch (error) {
510
+ console.error(`Failed to register Discord slash commands: ${String(error)}`);
511
+ }
512
+ }
513
+ buildSlashCommandPayloads() {
514
+ const specs = this.commandRegistry?.listSlashCommands() ?? [];
515
+ return specs.map((spec) => ({
516
+ name: spec.name,
517
+ description: spec.description,
518
+ options: mapCommandOptions(spec.options)
519
+ }));
520
+ }
521
+ resolveProxyAgent() {
522
+ const proxy = this.config.proxy?.trim();
523
+ if (!proxy) {
524
+ return null;
525
+ }
526
+ try {
527
+ return new ProxyAgent(proxy);
528
+ } catch {
529
+ return null;
530
+ }
531
+ }
532
+ resolveAccountId() {
533
+ const accountId = this.config.accountId?.trim();
534
+ return accountId || "default";
535
+ }
536
+ isAllowedByPolicy(params) {
537
+ if (!params.isGroup) {
538
+ if (this.config.dmPolicy === "disabled") {
539
+ return false;
540
+ }
541
+ const allowFrom = this.config.allowFrom ?? [];
542
+ if (this.config.dmPolicy === "allowlist" || this.config.dmPolicy === "pairing") {
543
+ return this.isAllowed(params.senderId);
544
+ }
545
+ if (allowFrom.includes("*")) {
546
+ return true;
547
+ }
548
+ return allowFrom.length === 0 ? true : this.isAllowed(params.senderId);
549
+ }
550
+ if (this.config.groupPolicy === "disabled") {
551
+ return false;
552
+ }
553
+ if (this.config.groupPolicy === "allowlist") {
554
+ const allowFrom = this.config.groupAllowFrom ?? [];
555
+ return allowFrom.includes("*") || allowFrom.includes(params.channelId);
556
+ }
557
+ return true;
558
+ }
559
+ resolveMentionState(params) {
560
+ if (!params.isGroup) {
561
+ return { wasMentioned: false, requireMention: false };
562
+ }
563
+ const groups = this.config.groups ?? {};
564
+ const groupRule = groups[params.channelId] ?? groups["*"];
565
+ const requireMention = groupRule?.requireMention ?? this.config.requireMention ?? false;
566
+ if (!requireMention) {
567
+ return { wasMentioned: false, requireMention: false };
568
+ }
569
+ const patterns = [
570
+ ...this.config.mentionPatterns ?? [],
571
+ ...groupRule?.mentionPatterns ?? []
572
+ ].map((pattern) => pattern.trim()).filter(Boolean);
573
+ const content = params.message.content ?? "";
574
+ const wasMentionedByUserRef = Boolean(params.selfUserId) && params.message.mentions.users.has(params.selfUserId ?? "");
575
+ const wasMentionedByText = Boolean(params.selfUserId) && (content.includes(`<@${params.selfUserId}>`) || content.includes(`<@!${params.selfUserId}>`));
576
+ const wasMentionedByPattern = patterns.some((pattern) => {
577
+ try {
578
+ return new RegExp(pattern, "i").test(content);
579
+ } catch {
580
+ return content.toLowerCase().includes(pattern.toLowerCase());
581
+ }
582
+ });
583
+ return {
584
+ wasMentioned: wasMentionedByUserRef || wasMentionedByText || wasMentionedByPattern,
585
+ requireMention
586
+ };
587
+ }
588
+ async resolveInboundAttachment(params) {
589
+ const { attachment, mediaDir, maxBytes, proxy } = params;
590
+ const id = attachment.id;
591
+ const name = attachment.name ?? "file";
592
+ const url = attachment.url;
593
+ const mimeType = attachment.contentType ?? guessMimeFromName(name) ?? void 0;
594
+ if (!url) {
595
+ return {
596
+ issue: {
597
+ id,
598
+ name,
599
+ code: "invalid_payload",
600
+ message: "attachment URL missing"
601
+ }
602
+ };
603
+ }
604
+ if (attachment.size && attachment.size > maxBytes) {
605
+ return {
606
+ attachment: {
607
+ id,
608
+ name,
609
+ url,
610
+ mimeType,
611
+ size: attachment.size,
612
+ source: "discord",
613
+ status: "remote-only",
614
+ errorCode: "too_large"
615
+ },
616
+ issue: {
617
+ id,
618
+ name,
619
+ url,
620
+ code: "too_large",
621
+ message: `attachment size ${attachment.size} exceeds ${maxBytes}`
622
+ }
623
+ };
624
+ }
625
+ const controller = new AbortController();
626
+ const timeoutId = setTimeout(() => controller.abort(), MEDIA_FETCH_TIMEOUT_MS);
627
+ try {
628
+ const fetchInit = {
629
+ signal: controller.signal,
630
+ ...proxy ? { dispatcher: proxy } : {}
631
+ };
632
+ const res = await fetch2(url, fetchInit);
633
+ if (!res.ok) {
634
+ return {
635
+ attachment: {
636
+ id,
637
+ name,
638
+ url,
639
+ mimeType,
640
+ size: attachment.size,
641
+ source: "discord",
642
+ status: "remote-only",
643
+ errorCode: "http_error"
644
+ },
645
+ issue: {
646
+ id,
647
+ name,
648
+ url,
649
+ code: "http_error",
650
+ message: `HTTP ${res.status}`
651
+ }
652
+ };
653
+ }
654
+ const buffer = Buffer.from(await res.arrayBuffer());
655
+ if (buffer.length > maxBytes) {
656
+ return {
657
+ attachment: {
658
+ id,
659
+ name,
660
+ url,
661
+ mimeType,
662
+ size: buffer.length,
663
+ source: "discord",
664
+ status: "remote-only",
665
+ errorCode: "too_large"
666
+ },
667
+ issue: {
668
+ id,
669
+ name,
670
+ url,
671
+ code: "too_large",
672
+ message: `downloaded payload ${buffer.length} exceeds ${maxBytes}`
673
+ }
674
+ };
675
+ }
676
+ const filename = `${id}_${sanitizeAttachmentName(name)}`;
677
+ const filePath = join(mediaDir, filename);
678
+ writeFileSync(filePath, buffer);
679
+ return {
680
+ attachment: {
681
+ id,
682
+ name,
683
+ path: filePath,
684
+ url,
685
+ mimeType,
686
+ size: buffer.length,
687
+ source: "discord",
688
+ status: "ready"
689
+ }
690
+ };
691
+ } catch (err) {
692
+ return {
693
+ attachment: {
694
+ id,
695
+ name,
696
+ url,
697
+ mimeType,
698
+ size: attachment.size,
699
+ source: "discord",
700
+ status: "remote-only",
701
+ errorCode: "download_failed"
702
+ },
703
+ issue: {
704
+ id,
705
+ name,
706
+ url,
707
+ code: "download_failed",
708
+ message: String(err)
709
+ }
710
+ };
711
+ } finally {
712
+ clearTimeout(timeoutId);
713
+ }
714
+ }
715
+ startTyping(channelId) {
716
+ this.typingController.start(channelId);
717
+ }
718
+ stopTyping(channelId) {
719
+ this.typingController.stop(channelId);
720
+ }
721
+ };
722
+ function mapCommandOptions(options) {
723
+ if (!options || options.length === 0) {
724
+ return void 0;
725
+ }
726
+ return options.map((option) => ({
727
+ name: option.name,
728
+ description: option.description,
729
+ type: mapCommandOptionType(option.type),
730
+ required: option.required ?? false
731
+ }));
732
+ }
733
+ function mapCommandOptionType(type) {
734
+ switch (type) {
735
+ case "boolean":
736
+ return ApplicationCommandOptionType.Boolean;
737
+ case "number":
738
+ return ApplicationCommandOptionType.Number;
739
+ case "string":
740
+ default:
741
+ return ApplicationCommandOptionType.String;
742
+ }
743
+ }
744
+ function sanitizeAttachmentName(name) {
745
+ return name.replace(/[\\/:*?"<>|]/g, "_");
746
+ }
747
+ function guessMimeFromName(name) {
748
+ const lower = name.toLowerCase();
749
+ if (lower.endsWith(".png")) return "image/png";
750
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
751
+ if (lower.endsWith(".gif")) return "image/gif";
752
+ if (lower.endsWith(".webp")) return "image/webp";
753
+ if (lower.endsWith(".bmp")) return "image/bmp";
754
+ if (lower.endsWith(".tif") || lower.endsWith(".tiff")) return "image/tiff";
755
+ return null;
756
+ }
757
+ function isImageAttachment(attachment) {
758
+ if (attachment.mimeType?.startsWith("image/")) {
759
+ return true;
760
+ }
761
+ return Boolean(attachment.name && guessMimeFromName(attachment.name));
762
+ }
763
+ function buildAttachmentSummary(attachments) {
764
+ const count = attachments.length;
765
+ if (count === 0) {
766
+ return "";
767
+ }
768
+ const allImages = attachments.every((entry) => isImageAttachment(entry));
769
+ if (allImages) {
770
+ return `<media:image> (${count} ${count === 1 ? "image" : "images"})`;
771
+ }
772
+ return `<media:document> (${count} ${count === 1 ? "file" : "files"})`;
773
+ }
774
+ function countLines(text) {
775
+ if (!text) {
776
+ return 0;
777
+ }
778
+ return text.split("\n").length;
779
+ }
780
+ function parseFenceLine(line) {
781
+ const match = line.match(FENCE_RE);
782
+ if (!match) {
783
+ return null;
784
+ }
785
+ const indent = match[1] ?? "";
786
+ const marker = match[2] ?? "";
787
+ return {
788
+ indent,
789
+ markerChar: marker[0] ?? "`",
790
+ markerLen: marker.length,
791
+ openLine: line
792
+ };
793
+ }
794
+ function closeFenceLine(openFence) {
795
+ return `${openFence.indent}${openFence.markerChar.repeat(openFence.markerLen)}`;
796
+ }
797
+ function closeFenceIfNeeded(text, openFence) {
798
+ if (!openFence) {
799
+ return text;
800
+ }
801
+ const closeLine = closeFenceLine(openFence);
802
+ if (!text) {
803
+ return closeLine;
804
+ }
805
+ if (!text.endsWith("\n")) {
806
+ return `${text}
807
+ ${closeLine}`;
808
+ }
809
+ return `${text}${closeLine}`;
810
+ }
811
+ function splitLongLine(line, maxChars, opts) {
812
+ const limit = Math.max(1, Math.floor(maxChars));
813
+ if (line.length <= limit) {
814
+ return [line];
815
+ }
816
+ const chunks = [];
817
+ let remaining = line;
818
+ while (remaining.length > limit) {
819
+ if (opts.preserveWhitespace) {
820
+ chunks.push(remaining.slice(0, limit));
821
+ remaining = remaining.slice(limit);
822
+ continue;
823
+ }
824
+ const window = remaining.slice(0, limit);
825
+ let breakIndex = -1;
826
+ for (let index = window.length - 1; index >= 0; index -= 1) {
827
+ if (/\s/.test(window[index])) {
828
+ breakIndex = index;
829
+ break;
830
+ }
831
+ }
832
+ if (breakIndex <= 0) {
833
+ breakIndex = limit;
834
+ }
835
+ chunks.push(remaining.slice(0, breakIndex));
836
+ remaining = remaining.slice(breakIndex);
837
+ }
838
+ if (remaining.length) {
839
+ chunks.push(remaining);
840
+ }
841
+ return chunks;
842
+ }
843
+ function chunkDiscordText(text, opts = {}) {
844
+ const maxChars = Math.max(1, Math.floor(opts.maxChars ?? DISCORD_TEXT_LIMIT));
845
+ const maxLines = Math.max(1, Math.floor(opts.maxLines ?? DISCORD_MAX_LINES_PER_MESSAGE));
846
+ const body = text ?? "";
847
+ if (!body) {
848
+ return [];
849
+ }
850
+ if (body.length <= maxChars && countLines(body) <= maxLines) {
851
+ return [body];
852
+ }
853
+ const lines = body.split("\n");
854
+ const chunks = [];
855
+ let current = "";
856
+ let currentLines = 0;
857
+ let openFence = null;
858
+ const flush = () => {
859
+ if (!current) {
860
+ return;
861
+ }
862
+ const payload = closeFenceIfNeeded(current, openFence);
863
+ if (payload.trim().length) {
864
+ chunks.push(payload);
865
+ }
866
+ current = "";
867
+ currentLines = 0;
868
+ if (openFence) {
869
+ current = openFence.openLine;
870
+ currentLines = 1;
871
+ }
872
+ };
873
+ for (const line of lines) {
874
+ const fenceInfo = parseFenceLine(line);
875
+ const wasInsideFence = openFence !== null;
876
+ let nextOpenFence = openFence;
877
+ if (fenceInfo) {
878
+ if (!openFence) {
879
+ nextOpenFence = fenceInfo;
880
+ } else if (openFence.markerChar === fenceInfo.markerChar && fenceInfo.markerLen >= openFence.markerLen) {
881
+ nextOpenFence = null;
882
+ }
883
+ }
884
+ const reserveChars = nextOpenFence ? closeFenceLine(nextOpenFence).length + 1 : 0;
885
+ const reserveLines = nextOpenFence ? 1 : 0;
886
+ const effectiveMaxChars = maxChars - reserveChars;
887
+ const effectiveMaxLines = maxLines - reserveLines;
888
+ const charLimit = effectiveMaxChars > 0 ? effectiveMaxChars : maxChars;
889
+ const lineLimit = effectiveMaxLines > 0 ? effectiveMaxLines : maxLines;
890
+ const prefixLength = current.length > 0 ? current.length + 1 : 0;
891
+ const segmentLimit = Math.max(1, charLimit - prefixLength);
892
+ const segments = splitLongLine(line, segmentLimit, {
893
+ preserveWhitespace: wasInsideFence
894
+ });
895
+ for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
896
+ const segment = segments[segmentIndex];
897
+ const isContinuation = segmentIndex > 0;
898
+ const delimiter = isContinuation ? "" : current.length > 0 ? "\n" : "";
899
+ const addition = `${delimiter}${segment}`;
900
+ const nextLength = current.length + addition.length;
901
+ const nextLineCount = currentLines + (isContinuation ? 0 : 1);
902
+ const exceedsChars = nextLength > charLimit;
903
+ const exceedsLines = nextLineCount > lineLimit;
904
+ if ((exceedsChars || exceedsLines) && current.length > 0) {
905
+ flush();
906
+ }
907
+ if (current.length > 0) {
908
+ current += addition;
909
+ if (!isContinuation) {
910
+ currentLines += 1;
911
+ }
912
+ } else {
913
+ current = segment;
914
+ currentLines = 1;
915
+ }
916
+ }
917
+ openFence = nextOpenFence;
918
+ }
919
+ if (current.length) {
920
+ const payload = closeFenceIfNeeded(current, openFence);
921
+ if (payload.trim().length) {
922
+ chunks.push(payload);
923
+ }
924
+ }
925
+ return chunks;
926
+ }
927
+ function clampInt(value, min, max) {
928
+ if (Number.isNaN(value)) {
929
+ return min;
930
+ }
931
+ return Math.min(max, Math.max(min, Math.floor(value)));
932
+ }
933
+ function resolveTextChunkLimit(config) {
934
+ const configured = typeof config.textChunkLimit === "number" ? config.textChunkLimit : DISCORD_TEXT_LIMIT;
935
+ return clampInt(configured, 1, DISCORD_TEXT_LIMIT);
936
+ }
937
+ function resolveDiscordStreamingMode(config) {
938
+ const raw = config.streaming;
939
+ if (raw === true) {
940
+ return "partial";
941
+ }
942
+ if (raw === false || raw === void 0 || raw === null) {
943
+ return "off";
944
+ }
945
+ if (raw === "progress") {
946
+ return "partial";
947
+ }
948
+ if (raw === "partial" || raw === "block" || raw === "off") {
949
+ return raw;
950
+ }
951
+ return "off";
952
+ }
953
+ function resolveDraftChunkConfig(config, textChunkLimit) {
954
+ const raw = config.draftChunk ?? {};
955
+ const minChars = clampInt(raw.minChars ?? 200, 1, textChunkLimit);
956
+ const maxChars = clampInt(raw.maxChars ?? 800, minChars, textChunkLimit);
957
+ const breakPreference = raw.breakPreference === "line" || raw.breakPreference === "none" ? raw.breakPreference : "paragraph";
958
+ return {
959
+ minChars,
960
+ maxChars,
961
+ breakPreference
962
+ };
963
+ }
964
+ function findDraftBreakIndex(text, start, end, preference) {
965
+ const slice = text.slice(start, end);
966
+ if (slice.length === 0) {
967
+ return null;
968
+ }
969
+ if (preference === "paragraph") {
970
+ const idx = slice.lastIndexOf("\n\n");
971
+ if (idx >= 0) {
972
+ return start + idx + 2;
973
+ }
974
+ }
975
+ if (preference === "paragraph" || preference === "line") {
976
+ const idx = slice.lastIndexOf("\n");
977
+ if (idx >= 0) {
978
+ return start + idx + 1;
979
+ }
980
+ }
981
+ for (let i = slice.length - 1; i >= 0; i -= 1) {
982
+ if (/\s/.test(slice[i])) {
983
+ return start + i + 1;
984
+ }
985
+ }
986
+ return null;
987
+ }
988
+ function splitDraftChunks(text, config) {
989
+ const chunks = [];
990
+ if (!text) {
991
+ return chunks;
992
+ }
993
+ let cursor = 0;
994
+ const length = text.length;
995
+ while (cursor < length) {
996
+ const remaining = length - cursor;
997
+ if (remaining <= config.maxChars) {
998
+ chunks.push(text.slice(cursor));
999
+ break;
1000
+ }
1001
+ const minEnd = Math.min(length, cursor + config.minChars);
1002
+ const maxEnd = Math.min(length, cursor + config.maxChars);
1003
+ let nextEnd = maxEnd;
1004
+ const breakIndex = findDraftBreakIndex(text, minEnd, maxEnd, config.breakPreference);
1005
+ if (breakIndex !== null && breakIndex > cursor) {
1006
+ nextEnd = breakIndex;
1007
+ }
1008
+ if (nextEnd <= cursor) {
1009
+ nextEnd = maxEnd;
1010
+ }
1011
+ chunks.push(text.slice(cursor, nextEnd));
1012
+ cursor = nextEnd;
1013
+ }
1014
+ return chunks;
1015
+ }
1016
+ async function sleep(ms) {
1017
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
1018
+ }
1019
+ async function sendDiscordChunks(params) {
1020
+ const { textChannel, chunks, replyTo, flags } = params;
1021
+ let first = true;
1022
+ for (const chunk of chunks) {
1023
+ const payload = {
1024
+ content: chunk
1025
+ };
1026
+ if (first && replyTo) {
1027
+ payload.reply = { messageReference: replyTo };
1028
+ }
1029
+ if (flags !== void 0) {
1030
+ payload.flags = flags;
1031
+ }
1032
+ await textChannel.send(payload);
1033
+ first = false;
1034
+ }
1035
+ }
1036
+ async function sendDiscordDraftStreaming(params) {
1037
+ const { textChannel, chunks, replyTo, flags, draftChunk, streamingMode } = params;
1038
+ let first = true;
1039
+ const effectiveDraftChunk = streamingMode === "block" ? draftChunk : {
1040
+ ...draftChunk,
1041
+ minChars: Math.max(1, Math.floor(draftChunk.minChars / 2)),
1042
+ maxChars: Math.max(draftChunk.minChars, Math.floor(draftChunk.maxChars / 2))
1043
+ };
1044
+ for (const chunk of chunks) {
1045
+ const draftChunks = splitDraftChunks(chunk, effectiveDraftChunk);
1046
+ if (draftChunks.length === 0) {
1047
+ continue;
1048
+ }
1049
+ if (draftChunks.length > STREAM_MAX_UPDATES_PER_MESSAGE) {
1050
+ await sendDiscordChunks({
1051
+ textChannel,
1052
+ chunks: [chunk],
1053
+ replyTo: first ? replyTo : void 0,
1054
+ flags
1055
+ });
1056
+ first = false;
1057
+ continue;
1058
+ }
1059
+ let draftMessage = null;
1060
+ let current = "";
1061
+ let lastEditAt = 0;
1062
+ for (const draftPart of draftChunks) {
1063
+ current += draftPart;
1064
+ if (!draftMessage) {
1065
+ const payload = {
1066
+ content: current
1067
+ };
1068
+ if (first && replyTo) {
1069
+ payload.reply = { messageReference: replyTo };
1070
+ }
1071
+ if (flags !== void 0) {
1072
+ payload.flags = flags;
1073
+ }
1074
+ draftMessage = await textChannel.send(
1075
+ payload
1076
+ );
1077
+ first = false;
1078
+ lastEditAt = Date.now();
1079
+ continue;
1080
+ }
1081
+ const waitMs = Math.max(0, lastEditAt + STREAM_EDIT_MIN_INTERVAL_MS - Date.now());
1082
+ if (waitMs > 0) {
1083
+ await sleep(waitMs);
1084
+ }
1085
+ try {
1086
+ await draftMessage.edit({ content: current });
1087
+ } catch {
1088
+ await sendDiscordChunks({
1089
+ textChannel,
1090
+ chunks: [chunk],
1091
+ replyTo: void 0,
1092
+ flags
1093
+ });
1094
+ draftMessage = null;
1095
+ break;
1096
+ }
1097
+ lastEditAt = Date.now();
1098
+ }
1099
+ }
1100
+ }
1101
+
1102
+ // src/channels/email.ts
1103
+ import { ImapFlow } from "imapflow";
1104
+ import { simpleParser } from "mailparser";
1105
+ import nodemailer from "nodemailer";
1106
+ var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1107
+ var EmailChannel = class extends BaseChannel {
1108
+ name = "email";
1109
+ lastSubjectByChat = /* @__PURE__ */ new Map();
1110
+ lastMessageIdByChat = /* @__PURE__ */ new Map();
1111
+ processedUids = /* @__PURE__ */ new Set();
1112
+ maxProcessedUids = 1e5;
1113
+ constructor(config, bus) {
1114
+ super(config, bus);
1115
+ }
1116
+ async start() {
1117
+ if (!this.config.consentGranted) {
1118
+ return;
1119
+ }
1120
+ if (!this.validateConfig()) {
1121
+ return;
1122
+ }
1123
+ this.running = true;
1124
+ const pollSeconds = Math.max(5, Number(this.config.pollIntervalSeconds ?? 30));
1125
+ while (this.running) {
1126
+ try {
1127
+ const items = await this.fetchNewMessages();
1128
+ for (const item of items) {
1129
+ if (item.subject) {
1130
+ this.lastSubjectByChat.set(item.sender, item.subject);
1131
+ }
1132
+ if (item.messageId) {
1133
+ this.lastMessageIdByChat.set(item.sender, item.messageId);
1134
+ }
1135
+ await this.handleMessage({
1136
+ senderId: item.sender,
1137
+ chatId: item.sender,
1138
+ content: item.content,
1139
+ attachments: [],
1140
+ metadata: item.metadata ?? {}
1141
+ });
1142
+ }
1143
+ } catch {
1144
+ }
1145
+ await sleep2(pollSeconds * 1e3);
1146
+ }
1147
+ }
1148
+ async stop() {
1149
+ this.running = false;
1150
+ }
1151
+ async send(msg) {
1152
+ if (!this.config.consentGranted) {
1153
+ return;
1154
+ }
1155
+ const forceSend = Boolean((msg.metadata ?? {}).force_send);
1156
+ if (!this.config.autoReplyEnabled && !forceSend) {
1157
+ return;
1158
+ }
1159
+ if (!this.config.smtpHost) {
1160
+ return;
1161
+ }
1162
+ const toAddr = msg.chatId.trim();
1163
+ if (!toAddr) {
1164
+ return;
1165
+ }
1166
+ const baseSubject = this.lastSubjectByChat.get(toAddr) ?? APP_REPLY_SUBJECT;
1167
+ const subject = msg.metadata?.subject?.trim() || this.replySubject(baseSubject);
1168
+ const transporter = nodemailer.createTransport({
1169
+ host: this.config.smtpHost,
1170
+ port: this.config.smtpPort,
1171
+ secure: this.config.smtpUseSsl,
1172
+ auth: {
1173
+ user: this.config.smtpUsername,
1174
+ pass: this.config.smtpPassword
1175
+ },
1176
+ tls: this.config.smtpUseTls ? { rejectUnauthorized: false } : void 0
1177
+ });
1178
+ await transporter.sendMail({
1179
+ from: this.config.fromAddress || this.config.smtpUsername || this.config.imapUsername,
1180
+ to: toAddr,
1181
+ subject,
1182
+ text: msg.content ?? "",
1183
+ inReplyTo: this.lastMessageIdByChat.get(toAddr) ?? void 0,
1184
+ references: this.lastMessageIdByChat.get(toAddr) ?? void 0
1185
+ });
1186
+ }
1187
+ validateConfig() {
1188
+ const missing = [];
1189
+ if (!this.config.imapHost) missing.push("imapHost");
1190
+ if (!this.config.imapUsername) missing.push("imapUsername");
1191
+ if (!this.config.imapPassword) missing.push("imapPassword");
1192
+ if (!this.config.smtpHost) missing.push("smtpHost");
1193
+ if (!this.config.smtpUsername) missing.push("smtpUsername");
1194
+ if (!this.config.smtpPassword) missing.push("smtpPassword");
1195
+ return missing.length === 0;
1196
+ }
1197
+ replySubject(subject) {
1198
+ const prefix = this.config.subjectPrefix || "Re: ";
1199
+ return subject.startsWith(prefix) ? subject : `${prefix}${subject}`;
1200
+ }
1201
+ async fetchNewMessages() {
1202
+ const client = new ImapFlow({
1203
+ host: this.config.imapHost,
1204
+ port: this.config.imapPort,
1205
+ secure: this.config.imapUseSsl,
1206
+ auth: {
1207
+ user: this.config.imapUsername,
1208
+ pass: this.config.imapPassword
1209
+ }
1210
+ });
1211
+ await client.connect();
1212
+ const lock = await client.getMailboxLock(this.config.imapMailbox || "INBOX");
1213
+ const items = [];
1214
+ try {
1215
+ const uids = await client.search({ seen: false });
1216
+ if (!Array.isArray(uids)) {
1217
+ return items;
1218
+ }
1219
+ for (const uid of uids) {
1220
+ const key = String(uid);
1221
+ if (this.processedUids.has(key)) {
1222
+ continue;
1223
+ }
1224
+ const message = await client.fetchOne(uid, { uid: true, source: true, envelope: true });
1225
+ if (!message || !message.source) {
1226
+ continue;
1227
+ }
1228
+ const parsed = await simpleParser(message.source);
1229
+ const sender = parsed.from?.value?.[0]?.address ?? "";
1230
+ if (!sender) {
1231
+ continue;
1232
+ }
1233
+ if (!this.isAllowed(sender)) {
1234
+ continue;
1235
+ }
1236
+ const rawContent = parsed.text ?? parsed.html ?? "";
1237
+ const content = typeof rawContent === "string" ? rawContent : "";
1238
+ const subject = parsed.subject ?? "";
1239
+ const messageId = parsed.messageId ?? "";
1240
+ items.push({
1241
+ sender,
1242
+ subject,
1243
+ content: content.slice(0, this.config.maxBodyChars),
1244
+ messageId,
1245
+ metadata: { subject }
1246
+ });
1247
+ if (this.config.markSeen) {
1248
+ await client.messageFlagsAdd(uid, ["\\Seen"]);
1249
+ }
1250
+ this.processedUids.add(key);
1251
+ if (this.processedUids.size > this.maxProcessedUids) {
1252
+ const iterator = this.processedUids.values();
1253
+ const oldest = iterator.next().value;
1254
+ if (oldest) {
1255
+ this.processedUids.delete(oldest);
1256
+ }
1257
+ }
1258
+ }
1259
+ } finally {
1260
+ lock.release();
1261
+ await client.logout();
1262
+ }
1263
+ return items;
1264
+ }
1265
+ };
1266
+
1267
+ // src/channels/feishu.ts
1268
+ import * as Lark from "@larksuiteoapi/node-sdk";
1269
+ var MSG_TYPE_MAP = {
1270
+ image: "[image]",
1271
+ audio: "[audio]",
1272
+ file: "[file]",
1273
+ sticker: "[sticker]"
1274
+ };
1275
+ var TABLE_RE = /((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)/gm;
1276
+ function isRecord(value) {
1277
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1278
+ }
1279
+ var FeishuChannel = class extends BaseChannel {
1280
+ name = "feishu";
1281
+ client = null;
1282
+ wsClient = null;
1283
+ processedMessageIds = [];
1284
+ processedSet = /* @__PURE__ */ new Set();
1285
+ constructor(config, bus) {
1286
+ super(config, bus);
1287
+ }
1288
+ async start() {
1289
+ if (!this.config.appId || !this.config.appSecret) {
1290
+ throw new Error("Feishu appId/appSecret not configured");
1291
+ }
1292
+ this.running = true;
1293
+ this.client = new Lark.Client({ appId: this.config.appId, appSecret: this.config.appSecret });
1294
+ const dispatcher = new Lark.EventDispatcher({
1295
+ encryptKey: this.config.encryptKey || void 0,
1296
+ verificationToken: this.config.verificationToken || void 0
1297
+ }).register({
1298
+ "im.message.receive_v1": async (data) => {
1299
+ await this.handleIncoming(data);
1300
+ }
1301
+ });
1302
+ this.wsClient = new Lark.WSClient({
1303
+ appId: this.config.appId,
1304
+ appSecret: this.config.appSecret,
1305
+ loggerLevel: Lark.LoggerLevel.info
1306
+ });
1307
+ this.wsClient.start({ eventDispatcher: dispatcher });
1308
+ }
1309
+ async stop() {
1310
+ this.running = false;
1311
+ if (this.wsClient) {
1312
+ this.wsClient.close();
1313
+ this.wsClient = null;
1314
+ }
1315
+ }
1316
+ async send(msg) {
1317
+ if (!this.client) {
1318
+ return;
1319
+ }
1320
+ const receiveIdType = msg.chatId.startsWith("oc_") ? "chat_id" : "open_id";
1321
+ const elements = buildCardElements(msg.content ?? "");
1322
+ const card = {
1323
+ config: { wide_screen_mode: true },
1324
+ elements
1325
+ };
1326
+ const content = JSON.stringify(card);
1327
+ await this.client.im.message.create({
1328
+ params: { receive_id_type: receiveIdType },
1329
+ data: {
1330
+ receive_id: msg.chatId,
1331
+ msg_type: "interactive",
1332
+ content
1333
+ }
1334
+ });
1335
+ }
1336
+ async handleIncoming(data) {
1337
+ const root = isRecord(data.event) ? data.event : data;
1338
+ const message = root.message ?? data.message ?? {};
1339
+ const sender = root.sender ?? message.sender ?? data.sender ?? {};
1340
+ const senderIdObj = sender.sender_id ?? {};
1341
+ const senderOpenId = senderIdObj.open_id || sender.open_id || "";
1342
+ const senderUserId = senderIdObj.user_id || sender.user_id || "";
1343
+ const senderUnionId = senderIdObj.union_id || sender.union_id || "";
1344
+ const senderId = senderOpenId || senderUserId || senderUnionId || "";
1345
+ const senderType = sender.sender_type ?? sender.senderType;
1346
+ if (senderType === "bot") {
1347
+ return;
1348
+ }
1349
+ const chatId = message.chat_id ?? "";
1350
+ const chatType = message.chat_type ?? "";
1351
+ const isGroup = chatType === "group";
1352
+ const msgType = message.msg_type ?? message.message_type ?? "";
1353
+ const messageId = message.message_id ?? "";
1354
+ if (!senderId || !chatId) {
1355
+ return;
1356
+ }
1357
+ if (!this.isAllowed(String(senderId))) {
1358
+ return;
1359
+ }
1360
+ if (messageId && this.isDuplicate(messageId)) {
1361
+ return;
1362
+ }
1363
+ if (messageId) {
1364
+ await this.addReaction(messageId, "THUMBSUP");
1365
+ }
1366
+ let content = "";
1367
+ if (message.content) {
1368
+ try {
1369
+ const parsed = JSON.parse(String(message.content));
1370
+ content = String(parsed.text ?? parsed.content ?? "");
1371
+ } catch {
1372
+ content = String(message.content);
1373
+ }
1374
+ }
1375
+ if (!content && MSG_TYPE_MAP[msgType]) {
1376
+ content = MSG_TYPE_MAP[msgType];
1377
+ }
1378
+ if (!content) {
1379
+ return;
1380
+ }
1381
+ await this.handleMessage({
1382
+ senderId: String(senderId),
1383
+ // Always route by Feishu chat_id so DM/group sessions are stable.
1384
+ chatId,
1385
+ content,
1386
+ attachments: [],
1387
+ metadata: {
1388
+ message_id: messageId,
1389
+ chat_id: chatId,
1390
+ chat_type: chatType,
1391
+ msg_type: msgType,
1392
+ is_group: isGroup,
1393
+ peer_kind: isGroup ? "group" : "direct",
1394
+ peer_id: chatId,
1395
+ sender_open_id: senderOpenId || void 0,
1396
+ sender_user_id: senderUserId || void 0,
1397
+ sender_union_id: senderUnionId || void 0
1398
+ }
1399
+ });
1400
+ }
1401
+ isDuplicate(messageId) {
1402
+ if (this.processedSet.has(messageId)) {
1403
+ return true;
1404
+ }
1405
+ this.processedSet.add(messageId);
1406
+ this.processedMessageIds.push(messageId);
1407
+ if (this.processedMessageIds.length > 1e3) {
1408
+ const removed = this.processedMessageIds.splice(0, 500);
1409
+ for (const id of removed) {
1410
+ this.processedSet.delete(id);
1411
+ }
1412
+ }
1413
+ return false;
1414
+ }
1415
+ async addReaction(messageId, emojiType) {
1416
+ if (!this.client) {
1417
+ return;
1418
+ }
1419
+ try {
1420
+ await this.client.im.messageReaction.create({
1421
+ path: { message_id: messageId },
1422
+ data: { reaction_type: { emoji_type: emojiType } }
1423
+ });
1424
+ } catch {
1425
+ }
1426
+ }
1427
+ };
1428
+ function buildCardElements(content) {
1429
+ const elements = [];
1430
+ let lastEnd = 0;
1431
+ for (const match of content.matchAll(TABLE_RE)) {
1432
+ const start = match.index ?? 0;
1433
+ const tableText = match[1] ?? "";
1434
+ const before = content.slice(lastEnd, start).trim();
1435
+ if (before) {
1436
+ elements.push({ tag: "markdown", content: before });
1437
+ }
1438
+ elements.push(parseMdTable(tableText) ?? { tag: "markdown", content: tableText });
1439
+ lastEnd = start + tableText.length;
1440
+ }
1441
+ const remaining = content.slice(lastEnd).trim();
1442
+ if (remaining) {
1443
+ elements.push({ tag: "markdown", content: remaining });
1444
+ }
1445
+ if (!elements.length) {
1446
+ elements.push({ tag: "markdown", content });
1447
+ }
1448
+ return elements;
1449
+ }
1450
+ function parseMdTable(tableText) {
1451
+ const lines = tableText.trim().split("\n").map((line) => line.trim()).filter(Boolean);
1452
+ if (lines.length < 3) {
1453
+ return null;
1454
+ }
1455
+ const split = (line) => line.replace(/^\|+|\|+$/g, "").split("|").map((item) => item.trim());
1456
+ const headers = split(lines[0]);
1457
+ const rows = lines.slice(2).map(split);
1458
+ const columns = headers.map((header, index) => ({
1459
+ tag: "column",
1460
+ name: `c${index}`,
1461
+ display_name: header,
1462
+ width: "auto"
1463
+ }));
1464
+ const tableRows = rows.map((row) => {
1465
+ const values = {};
1466
+ headers.forEach((_, index) => {
1467
+ values[`c${index}`] = row[index] ?? "";
1468
+ });
1469
+ return values;
1470
+ });
1471
+ return {
1472
+ tag: "table",
1473
+ page_size: rows.length + 1,
1474
+ columns,
1475
+ rows: tableRows
1476
+ };
1477
+ }
1478
+
1479
+ // src/channels/mochat.ts
1480
+ import { io } from "socket.io-client";
1481
+ import { fetch as fetch3 } from "undici";
1482
+ import { join as join2 } from "path";
1483
+ import { mkdirSync as mkdirSync2, existsSync, readFileSync, writeFileSync as writeFileSync2 } from "fs";
1484
+ var MAX_SEEN_MESSAGE_IDS = 2e3;
1485
+ var CURSOR_SAVE_DEBOUNCE_MS = 500;
1486
+ var AsyncLock = class {
1487
+ queue = Promise.resolve();
1488
+ async run(task) {
1489
+ const run = this.queue.then(task, task);
1490
+ this.queue = run.then(
1491
+ () => void 0,
1492
+ () => void 0
1493
+ );
1494
+ return run;
1495
+ }
1496
+ };
1497
+ var MochatChannel = class extends BaseChannel {
1498
+ name = "mochat";
1499
+ socket = null;
1500
+ wsConnected = false;
1501
+ wsReady = false;
1502
+ stateDir = join2(getDataPath(), "mochat");
1503
+ cursorPath = join2(this.stateDir, "session_cursors.json");
1504
+ sessionCursor = {};
1505
+ cursorSaveTimer = null;
1506
+ sessionSet = /* @__PURE__ */ new Set();
1507
+ panelSet = /* @__PURE__ */ new Set();
1508
+ autoDiscoverSessions = false;
1509
+ autoDiscoverPanels = false;
1510
+ coldSessions = /* @__PURE__ */ new Set();
1511
+ sessionByConverse = /* @__PURE__ */ new Map();
1512
+ seenSet = /* @__PURE__ */ new Map();
1513
+ seenQueue = /* @__PURE__ */ new Map();
1514
+ delayStates = /* @__PURE__ */ new Map();
1515
+ fallbackMode = false;
1516
+ sessionFallbackTasks = /* @__PURE__ */ new Map();
1517
+ panelFallbackTasks = /* @__PURE__ */ new Map();
1518
+ refreshTimer = null;
1519
+ targetLocks = /* @__PURE__ */ new Map();
1520
+ refreshInFlight = false;
1521
+ constructor(config, bus) {
1522
+ super(config, bus);
1523
+ }
1524
+ async start() {
1525
+ this.running = true;
1526
+ if (!this.config.clawToken) {
1527
+ throw new Error("Mochat clawToken not configured");
1528
+ }
1529
+ mkdirSync2(this.stateDir, { recursive: true });
1530
+ await this.loadSessionCursors();
1531
+ this.seedTargetsFromConfig();
1532
+ await this.refreshTargets(false);
1533
+ const socketReady = await this.startSocketClient();
1534
+ if (!socketReady) {
1535
+ await this.ensureFallbackWorkers();
1536
+ }
1537
+ const intervalMs = Math.max(1e3, this.config.refreshIntervalMs);
1538
+ this.refreshTimer = setInterval(() => {
1539
+ void this.refreshLoopTick();
1540
+ }, intervalMs);
1541
+ }
1542
+ async stop() {
1543
+ this.running = false;
1544
+ if (this.refreshTimer) {
1545
+ clearInterval(this.refreshTimer);
1546
+ this.refreshTimer = null;
1547
+ }
1548
+ await this.stopFallbackWorkers();
1549
+ await this.cancelDelayTimers();
1550
+ if (this.socket) {
1551
+ this.socket.disconnect();
1552
+ this.socket = null;
1553
+ }
1554
+ if (this.cursorSaveTimer) {
1555
+ clearTimeout(this.cursorSaveTimer);
1556
+ this.cursorSaveTimer = null;
1557
+ }
1558
+ await this.saveSessionCursors();
1559
+ this.wsConnected = false;
1560
+ this.wsReady = false;
1561
+ }
1562
+ async send(msg) {
1563
+ if (!this.config.clawToken) {
1564
+ return;
1565
+ }
1566
+ const parts = [];
1567
+ if (msg.content && msg.content.trim()) {
1568
+ parts.push(msg.content.trim());
1569
+ }
1570
+ if (msg.media?.length) {
1571
+ for (const item of msg.media) {
1572
+ if (typeof item === "string" && item.trim()) {
1573
+ parts.push(item.trim());
1574
+ }
1575
+ }
1576
+ }
1577
+ const content = parts.join("\n").trim();
1578
+ if (!content) {
1579
+ return;
1580
+ }
1581
+ const target = resolveMochatTarget(msg.chatId);
1582
+ if (!target.id) {
1583
+ return;
1584
+ }
1585
+ const isPanel = (target.isPanel || this.panelSet.has(target.id)) && !target.id.startsWith("session_");
1586
+ if (isPanel) {
1587
+ await this.apiSend(
1588
+ "/api/claw/groups/panels/send",
1589
+ "panelId",
1590
+ target.id,
1591
+ content,
1592
+ msg.replyTo,
1593
+ readGroupId(msg.metadata ?? {})
1594
+ );
1595
+ return;
1596
+ }
1597
+ await this.apiSend("/api/claw/sessions/send", "sessionId", target.id, content, msg.replyTo);
1598
+ }
1599
+ seedTargetsFromConfig() {
1600
+ const [sessions, autoSessions] = normalizeIdList(this.config.sessions);
1601
+ const [panels, autoPanels] = normalizeIdList(this.config.panels);
1602
+ this.autoDiscoverSessions = autoSessions;
1603
+ this.autoDiscoverPanels = autoPanels;
1604
+ sessions.forEach((sid) => {
1605
+ this.sessionSet.add(sid);
1606
+ if (!(sid in this.sessionCursor)) {
1607
+ this.coldSessions.add(sid);
1608
+ }
1609
+ });
1610
+ panels.forEach((pid) => {
1611
+ this.panelSet.add(pid);
1612
+ });
1613
+ }
1614
+ async startSocketClient() {
1615
+ let parser = void 0;
1616
+ if (!this.config.socketDisableMsgpack) {
1617
+ try {
1618
+ const mod = await import("socket.io-msgpack-parser");
1619
+ parser = mod.default ?? mod;
1620
+ } catch {
1621
+ parser = void 0;
1622
+ }
1623
+ }
1624
+ const socketUrl = (this.config.socketUrl || this.config.baseUrl).trim().replace(/\/$/, "");
1625
+ const socketPath = (this.config.socketPath || "/socket.io").trim();
1626
+ const reconnectionDelay = Math.max(100, this.config.socketReconnectDelayMs);
1627
+ const reconnectionDelayMax = Math.max(100, this.config.socketMaxReconnectDelayMs);
1628
+ const timeout = Math.max(1e3, this.config.socketConnectTimeoutMs);
1629
+ const reconnectionAttempts = this.config.maxRetryAttempts > 0 ? this.config.maxRetryAttempts : Number.MAX_SAFE_INTEGER;
1630
+ const socket = io(socketUrl, {
1631
+ path: socketPath.startsWith("/") ? socketPath : `/${socketPath}`,
1632
+ transports: ["websocket"],
1633
+ auth: { token: this.config.clawToken },
1634
+ reconnection: true,
1635
+ reconnectionAttempts,
1636
+ reconnectionDelay,
1637
+ reconnectionDelayMax,
1638
+ timeout,
1639
+ parser
1640
+ });
1641
+ socket.on("connect", async () => {
1642
+ this.wsConnected = true;
1643
+ this.wsReady = false;
1644
+ const subscribed = await this.subscribeAll();
1645
+ this.wsReady = subscribed;
1646
+ if (subscribed) {
1647
+ await this.stopFallbackWorkers();
1648
+ } else {
1649
+ await this.ensureFallbackWorkers();
1650
+ }
1651
+ });
1652
+ socket.on("disconnect", async () => {
1653
+ if (!this.running) {
1654
+ return;
1655
+ }
1656
+ this.wsConnected = false;
1657
+ this.wsReady = false;
1658
+ await this.ensureFallbackWorkers();
1659
+ });
1660
+ socket.on("connect_error", () => {
1661
+ this.wsConnected = false;
1662
+ this.wsReady = false;
1663
+ });
1664
+ socket.on("claw.session.events", async (payload) => {
1665
+ await this.handleWatchPayload(payload, "session");
1666
+ });
1667
+ socket.on("claw.panel.events", async (payload) => {
1668
+ await this.handleWatchPayload(payload, "panel");
1669
+ });
1670
+ const notifyHandler = (eventName) => async (payload) => {
1671
+ if (eventName === "notify:chat.inbox.append") {
1672
+ await this.handleNotifyInboxAppend(payload);
1673
+ return;
1674
+ }
1675
+ if (eventName.startsWith("notify:chat.message.")) {
1676
+ await this.handleNotifyChatMessage(payload);
1677
+ }
1678
+ };
1679
+ [
1680
+ "notify:chat.inbox.append",
1681
+ "notify:chat.message.add",
1682
+ "notify:chat.message.update",
1683
+ "notify:chat.message.recall",
1684
+ "notify:chat.message.delete"
1685
+ ].forEach((eventName) => {
1686
+ socket.on(eventName, notifyHandler(eventName));
1687
+ });
1688
+ this.socket = socket;
1689
+ return new Promise((resolve) => {
1690
+ const timer = setTimeout(() => resolve(false), timeout);
1691
+ socket.once("connect", () => {
1692
+ clearTimeout(timer);
1693
+ resolve(true);
1694
+ });
1695
+ socket.once("connect_error", () => {
1696
+ clearTimeout(timer);
1697
+ resolve(false);
1698
+ });
1699
+ });
1700
+ }
1701
+ async subscribeAll() {
1702
+ const sessions = Array.from(this.sessionSet).sort();
1703
+ const panels = Array.from(this.panelSet).sort();
1704
+ let ok = await this.subscribeSessions(sessions);
1705
+ ok = await this.subscribePanels(panels) && ok;
1706
+ if (this.autoDiscoverSessions || this.autoDiscoverPanels) {
1707
+ await this.refreshTargets(true);
1708
+ }
1709
+ return ok;
1710
+ }
1711
+ async subscribeSessions(sessionIds) {
1712
+ if (!sessionIds.length) {
1713
+ return true;
1714
+ }
1715
+ for (const sid of sessionIds) {
1716
+ if (!(sid in this.sessionCursor)) {
1717
+ this.coldSessions.add(sid);
1718
+ }
1719
+ }
1720
+ const ack = await this.socketCall("com.claw.im.subscribeSessions", {
1721
+ sessionIds,
1722
+ cursors: this.sessionCursor,
1723
+ limit: this.config.watchLimit
1724
+ });
1725
+ if (!ack.result) {
1726
+ return false;
1727
+ }
1728
+ const data = ack.data;
1729
+ let items = [];
1730
+ if (Array.isArray(data)) {
1731
+ items = data.filter((item) => typeof item === "object" && item !== null);
1732
+ } else if (data && typeof data === "object") {
1733
+ const sessions = data.sessions;
1734
+ if (Array.isArray(sessions)) {
1735
+ items = sessions.filter((item) => typeof item === "object" && item !== null);
1736
+ } else if (data.sessionId) {
1737
+ items = [data];
1738
+ }
1739
+ }
1740
+ for (const payload of items) {
1741
+ await this.handleWatchPayload(payload, "session");
1742
+ }
1743
+ return true;
1744
+ }
1745
+ async subscribePanels(panelIds) {
1746
+ if (!this.autoDiscoverPanels && !panelIds.length) {
1747
+ return true;
1748
+ }
1749
+ const ack = await this.socketCall("com.claw.im.subscribePanels", { panelIds });
1750
+ if (!ack.result) {
1751
+ return false;
1752
+ }
1753
+ return true;
1754
+ }
1755
+ async socketCall(eventName, payload) {
1756
+ if (!this.socket) {
1757
+ return { result: false, message: "socket not connected" };
1758
+ }
1759
+ return new Promise((resolve) => {
1760
+ this.socket?.timeout(1e4).emit(eventName, payload, (err, response) => {
1761
+ if (err) {
1762
+ resolve({ result: false, message: String(err) });
1763
+ return;
1764
+ }
1765
+ if (response && typeof response === "object") {
1766
+ resolve(response);
1767
+ return;
1768
+ }
1769
+ resolve({ result: true, data: response });
1770
+ });
1771
+ });
1772
+ }
1773
+ async refreshLoopTick() {
1774
+ if (!this.running || this.refreshInFlight) {
1775
+ return;
1776
+ }
1777
+ this.refreshInFlight = true;
1778
+ try {
1779
+ await this.refreshTargets(this.wsReady);
1780
+ if (this.fallbackMode) {
1781
+ await this.ensureFallbackWorkers();
1782
+ }
1783
+ } finally {
1784
+ this.refreshInFlight = false;
1785
+ }
1786
+ }
1787
+ async refreshTargets(subscribeNew) {
1788
+ if (this.autoDiscoverSessions) {
1789
+ await this.refreshSessionsDirectory(subscribeNew);
1790
+ }
1791
+ if (this.autoDiscoverPanels) {
1792
+ await this.refreshPanels(subscribeNew);
1793
+ }
1794
+ }
1795
+ async refreshSessionsDirectory(subscribeNew) {
1796
+ let response;
1797
+ try {
1798
+ response = await this.postJson("/api/claw/sessions/list", {});
1799
+ } catch {
1800
+ return;
1801
+ }
1802
+ const sessions = response.sessions;
1803
+ if (!Array.isArray(sessions)) {
1804
+ return;
1805
+ }
1806
+ const newIds = [];
1807
+ for (const session of sessions) {
1808
+ const sid = strField(session, "sessionId");
1809
+ if (!sid) {
1810
+ continue;
1811
+ }
1812
+ if (!this.sessionSet.has(sid)) {
1813
+ this.sessionSet.add(sid);
1814
+ newIds.push(sid);
1815
+ if (!(sid in this.sessionCursor)) {
1816
+ this.coldSessions.add(sid);
1817
+ }
1818
+ }
1819
+ const converseId = strField(session, "converseId");
1820
+ if (converseId) {
1821
+ this.sessionByConverse.set(converseId, sid);
1822
+ }
1823
+ }
1824
+ if (!newIds.length) {
1825
+ return;
1826
+ }
1827
+ if (this.wsReady && subscribeNew) {
1828
+ await this.subscribeSessions(newIds);
1829
+ }
1830
+ if (this.fallbackMode) {
1831
+ await this.ensureFallbackWorkers();
1832
+ }
1833
+ }
1834
+ async refreshPanels(subscribeNew) {
1835
+ let response;
1836
+ try {
1837
+ response = await this.postJson("/api/claw/groups/get", {});
1838
+ } catch {
1839
+ return;
1840
+ }
1841
+ const panels = response.panels;
1842
+ if (!Array.isArray(panels)) {
1843
+ return;
1844
+ }
1845
+ const newIds = [];
1846
+ for (const panel of panels) {
1847
+ const panelType = panel.type;
1848
+ if (typeof panelType === "number" && panelType !== 0) {
1849
+ continue;
1850
+ }
1851
+ const pid = strField(panel, "id", "_id");
1852
+ if (pid && !this.panelSet.has(pid)) {
1853
+ this.panelSet.add(pid);
1854
+ newIds.push(pid);
1855
+ }
1856
+ }
1857
+ if (!newIds.length) {
1858
+ return;
1859
+ }
1860
+ if (this.wsReady && subscribeNew) {
1861
+ await this.subscribePanels(newIds);
1862
+ }
1863
+ if (this.fallbackMode) {
1864
+ await this.ensureFallbackWorkers();
1865
+ }
1866
+ }
1867
+ async ensureFallbackWorkers() {
1868
+ if (!this.running) {
1869
+ return;
1870
+ }
1871
+ this.fallbackMode = true;
1872
+ for (const sid of this.sessionSet) {
1873
+ if (this.sessionFallbackTasks.has(sid)) {
1874
+ continue;
1875
+ }
1876
+ const task = this.sessionWatchWorker(sid).finally(() => {
1877
+ if (this.sessionFallbackTasks.get(sid) === task) {
1878
+ this.sessionFallbackTasks.delete(sid);
1879
+ }
1880
+ });
1881
+ this.sessionFallbackTasks.set(sid, task);
1882
+ }
1883
+ for (const pid of this.panelSet) {
1884
+ if (this.panelFallbackTasks.has(pid)) {
1885
+ continue;
1886
+ }
1887
+ const task = this.panelPollWorker(pid).finally(() => {
1888
+ if (this.panelFallbackTasks.get(pid) === task) {
1889
+ this.panelFallbackTasks.delete(pid);
1890
+ }
1891
+ });
1892
+ this.panelFallbackTasks.set(pid, task);
1893
+ }
1894
+ }
1895
+ async stopFallbackWorkers() {
1896
+ this.fallbackMode = false;
1897
+ const tasks = [...this.sessionFallbackTasks.values(), ...this.panelFallbackTasks.values()];
1898
+ this.sessionFallbackTasks.clear();
1899
+ this.panelFallbackTasks.clear();
1900
+ await Promise.allSettled(tasks);
1901
+ }
1902
+ async sessionWatchWorker(sessionId) {
1903
+ while (this.running && this.fallbackMode) {
1904
+ try {
1905
+ const payload = await this.postJson("/api/claw/sessions/watch", {
1906
+ sessionId,
1907
+ cursor: this.sessionCursor[sessionId] ?? 0,
1908
+ timeoutMs: this.config.watchTimeoutMs,
1909
+ limit: this.config.watchLimit
1910
+ });
1911
+ await this.handleWatchPayload(payload, "session");
1912
+ } catch {
1913
+ await sleep3(Math.max(100, this.config.retryDelayMs));
1914
+ }
1915
+ }
1916
+ }
1917
+ async panelPollWorker(panelId) {
1918
+ const sleepMs = Math.max(1e3, this.config.refreshIntervalMs);
1919
+ while (this.running && this.fallbackMode) {
1920
+ try {
1921
+ const payload = await this.postJson("/api/claw/groups/panels/messages", {
1922
+ panelId,
1923
+ limit: Math.min(100, Math.max(1, this.config.watchLimit))
1924
+ });
1925
+ const messages = payload.messages;
1926
+ if (Array.isArray(messages)) {
1927
+ for (const msg of [...messages].reverse()) {
1928
+ const event = makeSyntheticEvent({
1929
+ messageId: String(msg.messageId ?? ""),
1930
+ author: String(msg.author ?? ""),
1931
+ content: msg.content,
1932
+ meta: msg.meta,
1933
+ groupId: String(payload.groupId ?? ""),
1934
+ converseId: panelId,
1935
+ timestamp: msg.createdAt,
1936
+ authorInfo: msg.authorInfo
1937
+ });
1938
+ await this.processInboundEvent(panelId, event, "panel");
1939
+ }
1940
+ }
1941
+ } catch {
1942
+ await sleep3(sleepMs);
1943
+ }
1944
+ await sleep3(sleepMs);
1945
+ }
1946
+ }
1947
+ async handleWatchPayload(payload, targetKind) {
1948
+ if (!payload || typeof payload !== "object") {
1949
+ return;
1950
+ }
1951
+ const targetId = strField(payload, "sessionId");
1952
+ if (!targetId) {
1953
+ return;
1954
+ }
1955
+ const lockKey = `${targetKind}:${targetId}`;
1956
+ const lock = this.targetLocks.get(lockKey) ?? new AsyncLock();
1957
+ this.targetLocks.set(lockKey, lock);
1958
+ await lock.run(async () => {
1959
+ const previousCursor = this.sessionCursor[targetId] ?? 0;
1960
+ const cursor = payload.cursor;
1961
+ if (targetKind === "session" && typeof cursor === "number" && cursor >= 0) {
1962
+ this.markSessionCursor(targetId, cursor);
1963
+ }
1964
+ const rawEvents = payload.events;
1965
+ if (!Array.isArray(rawEvents)) {
1966
+ return;
1967
+ }
1968
+ if (targetKind === "session" && this.coldSessions.has(targetId)) {
1969
+ this.coldSessions.delete(targetId);
1970
+ return;
1971
+ }
1972
+ for (const event of rawEvents) {
1973
+ const seq = event.seq;
1974
+ if (targetKind === "session" && typeof seq === "number" && seq > (this.sessionCursor[targetId] ?? previousCursor)) {
1975
+ this.markSessionCursor(targetId, seq);
1976
+ }
1977
+ if (event.type === "message.add") {
1978
+ await this.processInboundEvent(targetId, event, targetKind);
1979
+ }
1980
+ }
1981
+ });
1982
+ }
1983
+ async processInboundEvent(targetId, event, targetKind) {
1984
+ const payload = event.payload;
1985
+ if (!payload) {
1986
+ return;
1987
+ }
1988
+ const author = strField(payload, "author");
1989
+ if (!author || this.config.agentUserId && author === this.config.agentUserId) {
1990
+ return;
1991
+ }
1992
+ if (!this.isAllowed(author)) {
1993
+ return;
1994
+ }
1995
+ const messageId = strField(payload, "messageId");
1996
+ const seenKey = `${targetKind}:${targetId}`;
1997
+ if (messageId && this.rememberMessageId(seenKey, messageId)) {
1998
+ return;
1999
+ }
2000
+ const rawBody = normalizeMochatContent(payload.content) || "[empty message]";
2001
+ const authorInfo = safeDict(payload.authorInfo);
2002
+ const senderName = strField(authorInfo, "nickname", "email");
2003
+ const senderUsername = strField(authorInfo, "agentId");
2004
+ const groupId = strField(payload, "groupId");
2005
+ const isGroup = Boolean(groupId);
2006
+ const wasMentioned = resolveWasMentioned(payload, this.config.agentUserId);
2007
+ const requireMention = targetKind === "panel" && isGroup && resolveRequireMention(this.config, targetId, groupId);
2008
+ const useDelay = targetKind === "panel" && this.config.replyDelayMode === "non-mention";
2009
+ if (requireMention && !wasMentioned && !useDelay) {
2010
+ return;
2011
+ }
2012
+ const entry = {
2013
+ rawBody,
2014
+ author,
2015
+ senderName,
2016
+ senderUsername,
2017
+ timestamp: parseTimestamp(event.timestamp),
2018
+ messageId,
2019
+ groupId
2020
+ };
2021
+ if (useDelay) {
2022
+ const delayKey = seenKey;
2023
+ if (wasMentioned) {
2024
+ await this.flushDelayedEntries(delayKey, targetId, targetKind, true, entry);
2025
+ } else {
2026
+ await this.enqueueDelayedEntry(delayKey, targetId, targetKind, entry);
2027
+ }
2028
+ return;
2029
+ }
2030
+ await this.dispatchEntries(targetId, targetKind, [entry], wasMentioned);
2031
+ }
2032
+ rememberMessageId(key, messageId) {
2033
+ const seenSet = this.seenSet.get(key) ?? /* @__PURE__ */ new Set();
2034
+ const seenQueue = this.seenQueue.get(key) ?? [];
2035
+ if (seenSet.has(messageId)) {
2036
+ return true;
2037
+ }
2038
+ seenSet.add(messageId);
2039
+ seenQueue.push(messageId);
2040
+ while (seenQueue.length > MAX_SEEN_MESSAGE_IDS) {
2041
+ const removed = seenQueue.shift();
2042
+ if (removed) {
2043
+ seenSet.delete(removed);
2044
+ }
2045
+ }
2046
+ this.seenSet.set(key, seenSet);
2047
+ this.seenQueue.set(key, seenQueue);
2048
+ return false;
2049
+ }
2050
+ async enqueueDelayedEntry(key, targetId, targetKind, entry) {
2051
+ const state = this.delayStates.get(key) ?? { entries: [], timer: null, lock: new AsyncLock() };
2052
+ this.delayStates.set(key, state);
2053
+ await state.lock.run(async () => {
2054
+ state.entries.push(entry);
2055
+ if (state.timer) {
2056
+ clearTimeout(state.timer);
2057
+ }
2058
+ state.timer = setTimeout(() => {
2059
+ void this.flushDelayedEntries(key, targetId, targetKind, false, null);
2060
+ }, Math.max(0, this.config.replyDelayMs));
2061
+ });
2062
+ }
2063
+ async flushDelayedEntries(key, targetId, targetKind, mentioned, entry) {
2064
+ const state = this.delayStates.get(key) ?? { entries: [], timer: null, lock: new AsyncLock() };
2065
+ this.delayStates.set(key, state);
2066
+ let entries = [];
2067
+ await state.lock.run(async () => {
2068
+ if (entry) {
2069
+ state.entries.push(entry);
2070
+ }
2071
+ if (state.timer) {
2072
+ clearTimeout(state.timer);
2073
+ state.timer = null;
2074
+ }
2075
+ entries = [...state.entries];
2076
+ state.entries = [];
2077
+ });
2078
+ if (entries.length) {
2079
+ await this.dispatchEntries(targetId, targetKind, entries, mentioned);
2080
+ }
2081
+ }
2082
+ async dispatchEntries(targetId, targetKind, entries, wasMentioned) {
2083
+ const last = entries[entries.length - 1];
2084
+ const isGroup = Boolean(last.groupId);
2085
+ const body = buildBufferedBody(entries, isGroup) || "[empty message]";
2086
+ await this.handleMessage({
2087
+ senderId: last.author,
2088
+ chatId: targetId,
2089
+ content: body,
2090
+ attachments: [],
2091
+ metadata: {
2092
+ message_id: last.messageId,
2093
+ timestamp: last.timestamp,
2094
+ is_group: isGroup,
2095
+ group_id: last.groupId,
2096
+ sender_name: last.senderName,
2097
+ sender_username: last.senderUsername,
2098
+ target_kind: targetKind,
2099
+ was_mentioned: wasMentioned,
2100
+ buffered_count: entries.length
2101
+ }
2102
+ });
2103
+ }
2104
+ async cancelDelayTimers() {
2105
+ for (const state of this.delayStates.values()) {
2106
+ if (state.timer) {
2107
+ clearTimeout(state.timer);
2108
+ }
2109
+ }
2110
+ this.delayStates.clear();
2111
+ }
2112
+ async handleNotifyChatMessage(payload) {
2113
+ if (!payload || typeof payload !== "object") {
2114
+ return;
2115
+ }
2116
+ const data = payload;
2117
+ const groupId = strField(data, "groupId");
2118
+ const panelId = strField(data, "converseId", "panelId");
2119
+ if (!groupId || !panelId) {
2120
+ return;
2121
+ }
2122
+ if (this.panelSet.size && !this.panelSet.has(panelId)) {
2123
+ return;
2124
+ }
2125
+ const event = makeSyntheticEvent({
2126
+ messageId: String(data._id ?? data.messageId ?? ""),
2127
+ author: String(data.author ?? ""),
2128
+ content: data.content,
2129
+ meta: data.meta,
2130
+ groupId,
2131
+ converseId: panelId,
2132
+ timestamp: data.createdAt,
2133
+ authorInfo: data.authorInfo
2134
+ });
2135
+ await this.processInboundEvent(panelId, event, "panel");
2136
+ }
2137
+ async handleNotifyInboxAppend(payload) {
2138
+ if (!payload || typeof payload !== "object") {
2139
+ return;
2140
+ }
2141
+ const data = payload;
2142
+ if (data.type !== "message") {
2143
+ return;
2144
+ }
2145
+ const detail = data.payload;
2146
+ if (!detail || typeof detail !== "object") {
2147
+ return;
2148
+ }
2149
+ if (strField(detail, "groupId")) {
2150
+ return;
2151
+ }
2152
+ const converseId = strField(detail, "converseId");
2153
+ if (!converseId) {
2154
+ return;
2155
+ }
2156
+ let sessionId = this.sessionByConverse.get(converseId);
2157
+ if (!sessionId) {
2158
+ await this.refreshSessionsDirectory(this.wsReady);
2159
+ sessionId = this.sessionByConverse.get(converseId);
2160
+ }
2161
+ if (!sessionId) {
2162
+ return;
2163
+ }
2164
+ const event = makeSyntheticEvent({
2165
+ messageId: String(detail.messageId ?? data._id ?? ""),
2166
+ author: String(detail.messageAuthor ?? ""),
2167
+ content: String(detail.messagePlainContent ?? detail.messageSnippet ?? ""),
2168
+ meta: { source: "notify:chat.inbox.append", converseId },
2169
+ groupId: "",
2170
+ converseId,
2171
+ timestamp: data.createdAt
2172
+ });
2173
+ await this.processInboundEvent(sessionId, event, "session");
2174
+ }
2175
+ markSessionCursor(sessionId, cursor) {
2176
+ if (cursor < 0) {
2177
+ return;
2178
+ }
2179
+ const current = this.sessionCursor[sessionId] ?? 0;
2180
+ if (cursor < current) {
2181
+ return;
2182
+ }
2183
+ this.sessionCursor[sessionId] = cursor;
2184
+ if (!this.cursorSaveTimer) {
2185
+ this.cursorSaveTimer = setTimeout(() => {
2186
+ this.cursorSaveTimer = null;
2187
+ void this.saveSessionCursors();
2188
+ }, CURSOR_SAVE_DEBOUNCE_MS);
2189
+ }
2190
+ }
2191
+ async loadSessionCursors() {
2192
+ if (!existsSync(this.cursorPath)) {
2193
+ return;
2194
+ }
2195
+ try {
2196
+ const raw = readFileSync(this.cursorPath, "utf-8");
2197
+ const data = JSON.parse(raw);
2198
+ const cursors = data.cursors;
2199
+ if (cursors && typeof cursors === "object") {
2200
+ for (const [sid, value] of Object.entries(cursors)) {
2201
+ if (typeof value === "number" && value >= 0) {
2202
+ this.sessionCursor[sid] = value;
2203
+ }
2204
+ }
2205
+ }
2206
+ } catch {
2207
+ return;
2208
+ }
2209
+ }
2210
+ async saveSessionCursors() {
2211
+ try {
2212
+ mkdirSync2(this.stateDir, { recursive: true });
2213
+ const payload = {
2214
+ schemaVersion: 1,
2215
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2216
+ cursors: this.sessionCursor
2217
+ };
2218
+ writeFileSync2(this.cursorPath, JSON.stringify(payload, null, 2) + "\n");
2219
+ } catch {
2220
+ return;
2221
+ }
2222
+ }
2223
+ async postJson(path, payload) {
2224
+ const url = `${this.config.baseUrl.trim().replace(/\/$/, "")}${path}`;
2225
+ const response = await fetch3(url, {
2226
+ method: "POST",
2227
+ headers: {
2228
+ "content-type": "application/json",
2229
+ "X-Claw-Token": this.config.clawToken
2230
+ },
2231
+ body: JSON.stringify(payload)
2232
+ });
2233
+ if (!response.ok) {
2234
+ throw new Error(`Mochat HTTP ${response.status}`);
2235
+ }
2236
+ let parsed;
2237
+ try {
2238
+ parsed = await response.json();
2239
+ } catch {
2240
+ parsed = await response.text();
2241
+ }
2242
+ if (parsed && typeof parsed === "object" && parsed.code !== void 0) {
2243
+ const data = parsed;
2244
+ if (typeof data.code === "number" && data.code !== 200) {
2245
+ throw new Error(String(data.message ?? data.name ?? "request failed"));
2246
+ }
2247
+ if (data.data && typeof data.data === "object") {
2248
+ return data.data;
2249
+ }
2250
+ return {};
2251
+ }
2252
+ if (parsed && typeof parsed === "object") {
2253
+ return parsed;
2254
+ }
2255
+ return {};
2256
+ }
2257
+ async apiSend(path, idKey, idValue, content, replyTo, groupId) {
2258
+ const body = { [idKey]: idValue, content };
2259
+ if (replyTo) {
2260
+ body.replyTo = replyTo;
2261
+ }
2262
+ if (groupId) {
2263
+ body.groupId = groupId;
2264
+ }
2265
+ await this.postJson(path, body);
2266
+ }
2267
+ };
2268
+ function normalizeIdList(values) {
2269
+ const cleaned = values.map((value) => String(value).trim()).filter(Boolean);
2270
+ const unique = Array.from(new Set(cleaned.filter((value) => value !== "*"))).sort();
2271
+ return [unique, cleaned.includes("*")];
2272
+ }
2273
+ function safeDict(value) {
2274
+ return value && typeof value === "object" ? value : {};
2275
+ }
2276
+ function strField(src, ...keys) {
2277
+ for (const key of keys) {
2278
+ const value = src[key];
2279
+ if (typeof value === "string" && value.trim()) {
2280
+ return value.trim();
2281
+ }
2282
+ }
2283
+ return "";
2284
+ }
2285
+ function makeSyntheticEvent(params) {
2286
+ const payload = {
2287
+ messageId: params.messageId,
2288
+ author: params.author,
2289
+ content: params.content,
2290
+ meta: safeDict(params.meta),
2291
+ groupId: params.groupId,
2292
+ converseId: params.converseId
2293
+ };
2294
+ if (params.authorInfo) {
2295
+ payload.authorInfo = safeDict(params.authorInfo);
2296
+ }
2297
+ return {
2298
+ type: "message.add",
2299
+ timestamp: params.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
2300
+ payload
2301
+ };
2302
+ }
2303
+ function normalizeMochatContent(content) {
2304
+ if (typeof content === "string") {
2305
+ return content.trim();
2306
+ }
2307
+ if (content === null || content === void 0) {
2308
+ return "";
2309
+ }
2310
+ try {
2311
+ return JSON.stringify(content);
2312
+ } catch {
2313
+ return String(content);
2314
+ }
2315
+ }
2316
+ function resolveMochatTarget(raw) {
2317
+ const trimmed = (raw || "").trim();
2318
+ if (!trimmed) {
2319
+ return { id: "", isPanel: false };
2320
+ }
2321
+ const lowered = trimmed.toLowerCase();
2322
+ let cleaned = trimmed;
2323
+ let forcedPanel = false;
2324
+ for (const prefix of ["mochat:", "group:", "channel:", "panel:"]) {
2325
+ if (lowered.startsWith(prefix)) {
2326
+ cleaned = trimmed.slice(prefix.length).trim();
2327
+ forcedPanel = prefix !== "mochat:";
2328
+ break;
2329
+ }
2330
+ }
2331
+ if (!cleaned) {
2332
+ return { id: "", isPanel: false };
2333
+ }
2334
+ return { id: cleaned, isPanel: forcedPanel || !cleaned.startsWith("session_") };
2335
+ }
2336
+ function extractMentionIds(value) {
2337
+ if (!Array.isArray(value)) {
2338
+ return [];
2339
+ }
2340
+ const ids = [];
2341
+ for (const item of value) {
2342
+ if (typeof item === "string" && item.trim()) {
2343
+ ids.push(item.trim());
2344
+ } else if (item && typeof item === "object") {
2345
+ const obj = item;
2346
+ for (const key of ["id", "userId", "_id"]) {
2347
+ const candidate = obj[key];
2348
+ if (typeof candidate === "string" && candidate.trim()) {
2349
+ ids.push(candidate.trim());
2350
+ break;
2351
+ }
2352
+ }
2353
+ }
2354
+ }
2355
+ return ids;
2356
+ }
2357
+ function resolveWasMentioned(payload, agentUserId) {
2358
+ const meta = payload.meta;
2359
+ if (meta) {
2360
+ if (meta.mentioned === true || meta.wasMentioned === true) {
2361
+ return true;
2362
+ }
2363
+ for (const field of ["mentions", "mentionIds", "mentionedUserIds", "mentionedUsers"]) {
2364
+ if (agentUserId && extractMentionIds(meta[field]).includes(agentUserId)) {
2365
+ return true;
2366
+ }
2367
+ }
2368
+ }
2369
+ if (!agentUserId) {
2370
+ return false;
2371
+ }
2372
+ const content = payload.content;
2373
+ if (typeof content !== "string" || !content) {
2374
+ return false;
2375
+ }
2376
+ return content.includes(`<@${agentUserId}>`) || content.includes(`@${agentUserId}`);
2377
+ }
2378
+ function resolveRequireMention(config, sessionId, groupId) {
2379
+ const groups = config.groups ?? {};
2380
+ for (const key of [groupId, sessionId, "*"]) {
2381
+ if (key && groups[key]) {
2382
+ return Boolean(groups[key].requireMention);
2383
+ }
2384
+ }
2385
+ return Boolean(config.mention.requireInGroups);
2386
+ }
2387
+ function buildBufferedBody(entries, isGroup) {
2388
+ if (!entries.length) {
2389
+ return "";
2390
+ }
2391
+ if (entries.length === 1) {
2392
+ return entries[0].rawBody;
2393
+ }
2394
+ const lines = [];
2395
+ for (const entry of entries) {
2396
+ if (!entry.rawBody) {
2397
+ continue;
2398
+ }
2399
+ if (isGroup) {
2400
+ const label = entry.senderName.trim() || entry.senderUsername.trim() || entry.author;
2401
+ if (label) {
2402
+ lines.push(`${label}: ${entry.rawBody}`);
2403
+ continue;
2404
+ }
2405
+ }
2406
+ lines.push(entry.rawBody);
2407
+ }
2408
+ return lines.join("\n").trim();
2409
+ }
2410
+ function parseTimestamp(value) {
2411
+ if (typeof value !== "string" || !value.trim()) {
2412
+ return null;
2413
+ }
2414
+ const parsed = Date.parse(value);
2415
+ return Number.isNaN(parsed) ? null : parsed;
2416
+ }
2417
+ function readGroupId(metadata) {
2418
+ const value = metadata.group_id ?? metadata.groupId;
2419
+ if (typeof value === "string" && value.trim()) {
2420
+ return value.trim();
2421
+ }
2422
+ return null;
2423
+ }
2424
+ function sleep3(ms) {
2425
+ return new Promise((resolve) => setTimeout(resolve, ms));
2426
+ }
2427
+
2428
+ // src/channels/qq.ts
2429
+ import {
2430
+ Bot,
2431
+ ReceiverMode,
2432
+ SessionEvents
2433
+ } from "qq-official-bot";
2434
+ var QQChannel = class extends BaseChannel {
2435
+ name = "qq";
2436
+ bot = null;
2437
+ processedIds = [];
2438
+ processedSet = /* @__PURE__ */ new Set();
2439
+ senderNameCache = /* @__PURE__ */ new Map();
2440
+ reconnectTimer = null;
2441
+ connectTask = null;
2442
+ reconnectAttempt = 0;
2443
+ reconnectBaseMs = 1e3;
2444
+ reconnectMaxMs = 6e4;
2445
+ constructor(config, bus) {
2446
+ super(config, bus);
2447
+ }
2448
+ async start() {
2449
+ if (!this.config.appId || !this.config.secret) {
2450
+ this.running = false;
2451
+ throw new Error("QQ appId/appSecret not configured");
2452
+ }
2453
+ this.running = true;
2454
+ this.reconnectAttempt = 0;
2455
+ this.clearReconnectTimer();
2456
+ this.tryConnect("startup");
2457
+ }
2458
+ async stop() {
2459
+ this.running = false;
2460
+ this.clearReconnectTimer();
2461
+ this.reconnectAttempt = 0;
2462
+ await this.teardownBot();
2463
+ if (this.connectTask) {
2464
+ await this.connectTask;
2465
+ }
2466
+ }
2467
+ async send(msg) {
2468
+ if (!this.bot) {
2469
+ return;
2470
+ }
2471
+ const qqMeta = msg.metadata?.qq ?? {};
2472
+ const messageType = qqMeta.messageType ?? "private";
2473
+ const metadataMessageId = msg.metadata?.message_id ?? null;
2474
+ const sourceId = msg.replyTo ?? metadataMessageId ?? void 0;
2475
+ const source = sourceId ? { id: sourceId } : void 0;
2476
+ const rawContent = msg.content ?? "";
2477
+ const payload = rawContent;
2478
+ try {
2479
+ await this.sendByMessageType({ messageType, qqMeta, msg, payload, source });
2480
+ } catch (error) {
2481
+ if (!this.isDisallowedUrlParamError(error)) {
2482
+ throw error;
2483
+ }
2484
+ const safeText = this.toQqSafeText(rawContent, error);
2485
+ await this.sendByMessageType({ messageType, qqMeta, msg, payload: safeText, source });
2486
+ }
2487
+ }
2488
+ async sendByMessageType(params) {
2489
+ const { messageType, qqMeta, msg, payload, source } = params;
2490
+ if (messageType === "group") {
2491
+ const groupId = qqMeta.groupId ?? msg.chatId;
2492
+ await this.sendWithTokenRetry(() => this.bot?.sendGroupMessage(groupId, payload, source));
2493
+ return;
2494
+ }
2495
+ if (messageType === "direct") {
2496
+ const guildId = qqMeta.guildId ?? msg.chatId;
2497
+ await this.sendWithTokenRetry(() => this.bot?.sendDirectMessage(guildId, payload, source));
2498
+ return;
2499
+ }
2500
+ if (messageType === "guild") {
2501
+ const channelId = qqMeta.channelId ?? msg.chatId;
2502
+ await this.sendWithTokenRetry(() => this.bot?.sendGuildMessage(channelId, payload, source));
2503
+ return;
2504
+ }
2505
+ const userId = qqMeta.userId ?? msg.chatId;
2506
+ await this.sendWithTokenRetry(() => this.bot?.sendPrivateMessage(userId, payload, source));
2507
+ }
2508
+ async handleIncoming(event) {
2509
+ const messageId = event.message_id || event.id || "";
2510
+ if (messageId && this.isDuplicate(messageId)) {
2511
+ return;
2512
+ }
2513
+ if (event.user_id === event.self_id) {
2514
+ return;
2515
+ }
2516
+ const rawEvent = event;
2517
+ const senderId = event.user_id || rawEvent.sender?.member_openid || rawEvent.sender?.user_openid || rawEvent.sender?.user_id || "";
2518
+ if (!senderId) {
2519
+ return;
2520
+ }
2521
+ const content = event.raw_message?.trim() ?? "";
2522
+ const normalizedContent = content || "[empty message]";
2523
+ const eventSenderName = this.resolveSenderName(rawEvent);
2524
+ if (eventSenderName) {
2525
+ this.senderNameCache.set(senderId, eventSenderName);
2526
+ }
2527
+ const declaredName = this.extractDeclaredName(normalizedContent);
2528
+ if (declaredName) {
2529
+ this.senderNameCache.set(senderId, declaredName);
2530
+ }
2531
+ const senderName = declaredName ?? eventSenderName ?? this.senderNameCache.get(senderId) ?? null;
2532
+ let chatId = senderId;
2533
+ let messageType = "private";
2534
+ const qqMeta = {};
2535
+ if (event.message_type === "group") {
2536
+ messageType = "group";
2537
+ const groupId = event.group_id || rawEvent.group_openid || "";
2538
+ chatId = groupId;
2539
+ qqMeta.groupId = groupId;
2540
+ qqMeta.userId = senderId;
2541
+ if (senderName) {
2542
+ qqMeta.userName = senderName;
2543
+ }
2544
+ } else if (event.message_type === "guild") {
2545
+ messageType = "guild";
2546
+ chatId = event.channel_id ?? "";
2547
+ qqMeta.guildId = event.guild_id;
2548
+ qqMeta.channelId = event.channel_id;
2549
+ qqMeta.userId = senderId;
2550
+ if (senderName) {
2551
+ qqMeta.userName = senderName;
2552
+ }
2553
+ } else if (event.sub_type === "direct") {
2554
+ messageType = "direct";
2555
+ chatId = event.guild_id ?? "";
2556
+ qqMeta.guildId = event.guild_id;
2557
+ qqMeta.userId = senderId;
2558
+ if (senderName) {
2559
+ qqMeta.userName = senderName;
2560
+ }
2561
+ } else {
2562
+ qqMeta.userId = senderId;
2563
+ if (senderName) {
2564
+ qqMeta.userName = senderName;
2565
+ }
2566
+ }
2567
+ qqMeta.messageType = messageType;
2568
+ const safeContent = this.decorateSpeakerPrefix({
2569
+ content: normalizedContent,
2570
+ messageType,
2571
+ senderId,
2572
+ senderName
2573
+ });
2574
+ if (!chatId) {
2575
+ return;
2576
+ }
2577
+ if (!this.isAllowed(senderId)) {
2578
+ return;
2579
+ }
2580
+ await this.handleMessage({
2581
+ senderId,
2582
+ chatId,
2583
+ content: safeContent,
2584
+ attachments: [],
2585
+ metadata: {
2586
+ message_id: messageId,
2587
+ qq: qqMeta
2588
+ }
2589
+ });
2590
+ }
2591
+ resolveSenderName(rawEvent) {
2592
+ const candidates = [
2593
+ rawEvent.sender?.card,
2594
+ rawEvent.sender?.nickname,
2595
+ rawEvent.sender?.nick,
2596
+ rawEvent.sender?.username,
2597
+ rawEvent.sender?.user_name
2598
+ ];
2599
+ for (const value of candidates) {
2600
+ if (typeof value !== "string") {
2601
+ continue;
2602
+ }
2603
+ const normalized = value.trim();
2604
+ if (normalized) {
2605
+ return normalized;
2606
+ }
2607
+ }
2608
+ return null;
2609
+ }
2610
+ decorateSpeakerPrefix(params) {
2611
+ const userId = this.sanitizeSpeakerToken(params.senderId);
2612
+ if (!userId) {
2613
+ return params.content;
2614
+ }
2615
+ const name = this.sanitizeSpeakerToken(params.senderName ?? "");
2616
+ const speakerFields = [`user_id=${userId}`];
2617
+ if (name) {
2618
+ speakerFields.push(`name=${name}`);
2619
+ }
2620
+ return `[speaker:${speakerFields.join(";")}] ${params.content}`;
2621
+ }
2622
+ sanitizeSpeakerToken(value) {
2623
+ return value.replace(/[\r\n;\]]/g, " ").trim();
2624
+ }
2625
+ extractDeclaredName(content) {
2626
+ const trimmed = content.trim();
2627
+ const patterns = [
2628
+ /^我的昵称是\s*([^\s,。!?!?,]{1,24})$/u,
2629
+ /^我叫\s*([^\s,。!?!?,]{1,24})$/u,
2630
+ /^叫我\s*([^\s,。!?!?,]{1,24})$/u
2631
+ ];
2632
+ for (const pattern of patterns) {
2633
+ const match = trimmed.match(pattern);
2634
+ if (!match) {
2635
+ continue;
2636
+ }
2637
+ const candidate = this.sanitizeSpeakerToken(match[1] ?? "");
2638
+ if (candidate) {
2639
+ return candidate;
2640
+ }
2641
+ }
2642
+ return null;
2643
+ }
2644
+ isDuplicate(messageId) {
2645
+ if (this.processedSet.has(messageId)) {
2646
+ return true;
2647
+ }
2648
+ this.processedSet.add(messageId);
2649
+ this.processedIds.push(messageId);
2650
+ if (this.processedIds.length > 1e3) {
2651
+ const removed = this.processedIds.splice(0, 500);
2652
+ for (const id of removed) {
2653
+ this.processedSet.delete(id);
2654
+ }
2655
+ }
2656
+ return false;
2657
+ }
2658
+ async sendWithTokenRetry(send) {
2659
+ try {
2660
+ await send();
2661
+ } catch (error) {
2662
+ if (!this.isTokenExpiredError(error) || !this.bot) {
2663
+ throw error;
2664
+ }
2665
+ await this.bot.sessionManager.getAccessToken();
2666
+ await send();
2667
+ }
2668
+ }
2669
+ isTokenExpiredError(error) {
2670
+ const message = error instanceof Error ? error.message : String(error);
2671
+ return message.includes("code(11244)") || message.toLowerCase().includes("token not exist or expire");
2672
+ }
2673
+ isDisallowedUrlParamError(error) {
2674
+ const message = error instanceof Error ? error.message : String(error);
2675
+ return message.includes("code(40034028)") || message.includes("\u8BF7\u6C42\u53C2\u6570\u4E0D\u5141\u8BB8\u5305\u542Burl");
2676
+ }
2677
+ toQqSafeText(content, error) {
2678
+ let safe = content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1").replace(/https?:\/\/\S+/gi, "[link]").replace(/www\.\S+/gi, "[link]").replace(/\b[a-z0-9._/-]+\.md\b/gi, "[file]");
2679
+ const blocked = this.extractBlockedUrlToken(error);
2680
+ if (blocked) {
2681
+ safe = safe.replaceAll(blocked, "[link]");
2682
+ }
2683
+ return safe;
2684
+ }
2685
+ extractBlockedUrlToken(error) {
2686
+ const message = error instanceof Error ? error.message : String(error);
2687
+ const match = message.match(/包含url\s+([^\s]+)/);
2688
+ if (!match) {
2689
+ return null;
2690
+ }
2691
+ const token = match[1].trim();
2692
+ return token.length > 0 ? token : null;
2693
+ }
2694
+ tryConnect(trigger) {
2695
+ if (!this.running || this.bot || this.connectTask) {
2696
+ return;
2697
+ }
2698
+ this.connectTask = this.connect(trigger).finally(() => {
2699
+ this.connectTask = null;
2700
+ });
2701
+ }
2702
+ async connect(trigger) {
2703
+ let candidate = null;
2704
+ try {
2705
+ candidate = this.createBot();
2706
+ await candidate.start();
2707
+ if (!this.running) {
2708
+ await this.safeStopBot(candidate);
2709
+ return;
2710
+ }
2711
+ this.bot = candidate;
2712
+ this.reconnectAttempt = 0;
2713
+ console.log("QQ bot connected");
2714
+ } catch (error) {
2715
+ if (candidate) {
2716
+ await this.safeStopBot(candidate);
2717
+ }
2718
+ if (!this.running) {
2719
+ return;
2720
+ }
2721
+ this.reconnectAttempt += 1;
2722
+ const delayMs = this.getBackoffDelayMs(this.reconnectAttempt);
2723
+ console.error(
2724
+ `[qq] start failed (${trigger}, attempt ${this.reconnectAttempt}), retry in ${delayMs}ms: ${this.formatError(error)}`
2725
+ );
2726
+ this.scheduleReconnect(delayMs, `${trigger}-retry`);
2727
+ }
2728
+ }
2729
+ createBot() {
2730
+ const bot = new Bot({
2731
+ appid: this.config.appId,
2732
+ secret: this.config.secret,
2733
+ mode: ReceiverMode.WEBSOCKET,
2734
+ intents: ["C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE"],
2735
+ removeAt: true,
2736
+ logLevel: "info"
2737
+ });
2738
+ bot.on("message.private", async (event) => {
2739
+ await this.handleIncoming(event);
2740
+ });
2741
+ bot.on("message.group", async (event) => {
2742
+ await this.handleIncoming(event);
2743
+ });
2744
+ bot.sessionManager.on(SessionEvents.DEAD, () => {
2745
+ void this.handleSessionDead(bot);
2746
+ });
2747
+ return bot;
2748
+ }
2749
+ async handleSessionDead(bot) {
2750
+ if (!this.running || this.bot !== bot) {
2751
+ return;
2752
+ }
2753
+ this.bot = null;
2754
+ await this.safeStopBot(bot);
2755
+ this.reconnectAttempt += 1;
2756
+ const delayMs = this.getBackoffDelayMs(this.reconnectAttempt);
2757
+ console.error(`[qq] session dead, reconnect in ${delayMs}ms`);
2758
+ this.scheduleReconnect(delayMs, "session-dead");
2759
+ }
2760
+ scheduleReconnect(delayMs, trigger) {
2761
+ if (!this.running) {
2762
+ return;
2763
+ }
2764
+ this.clearReconnectTimer();
2765
+ this.reconnectTimer = setTimeout(() => {
2766
+ this.reconnectTimer = null;
2767
+ this.tryConnect(trigger);
2768
+ }, delayMs);
2769
+ }
2770
+ clearReconnectTimer() {
2771
+ if (!this.reconnectTimer) {
2772
+ return;
2773
+ }
2774
+ clearTimeout(this.reconnectTimer);
2775
+ this.reconnectTimer = null;
2776
+ }
2777
+ async teardownBot() {
2778
+ if (!this.bot) {
2779
+ return;
2780
+ }
2781
+ const bot = this.bot;
2782
+ this.bot = null;
2783
+ await this.safeStopBot(bot);
2784
+ }
2785
+ async safeStopBot(bot) {
2786
+ bot.removeAllListeners("message.private");
2787
+ bot.removeAllListeners("message.group");
2788
+ bot.sessionManager.removeAllListeners(SessionEvents.DEAD);
2789
+ try {
2790
+ await bot.stop();
2791
+ } catch {
2792
+ }
2793
+ }
2794
+ getBackoffDelayMs(attempt) {
2795
+ const jitter = Math.floor(Math.random() * 500);
2796
+ const exp = Math.min(this.reconnectMaxMs, this.reconnectBaseMs * 2 ** Math.max(0, attempt - 1));
2797
+ return Math.min(this.reconnectMaxMs, exp + jitter);
2798
+ }
2799
+ formatError(error) {
2800
+ if (error instanceof Error) {
2801
+ return error.stack ?? error.message;
2802
+ }
2803
+ return String(error);
2804
+ }
2805
+ };
2806
+
2807
+ // src/channels/slack.ts
2808
+ import { WebClient } from "@slack/web-api";
2809
+ import { SocketModeClient } from "@slack/socket-mode";
2810
+ var SlackChannel = class extends BaseChannel {
2811
+ name = "slack";
2812
+ webClient = null;
2813
+ socketClient = null;
2814
+ botUserId = null;
2815
+ botId = null;
2816
+ constructor(config, bus) {
2817
+ super(config, bus);
2818
+ }
2819
+ async start() {
2820
+ if (!this.config.botToken || !this.config.appToken) {
2821
+ throw new Error("Slack bot/app token not configured");
2822
+ }
2823
+ if (this.config.mode !== "socket") {
2824
+ throw new Error(`Unsupported Slack mode: ${this.config.mode}`);
2825
+ }
2826
+ this.running = true;
2827
+ this.webClient = new WebClient(this.config.botToken);
2828
+ this.socketClient = new SocketModeClient({
2829
+ appToken: this.config.appToken
2830
+ });
2831
+ this.socketClient.on("events_api", async ({ body, ack }) => {
2832
+ await ack();
2833
+ await this.handleEvent(body?.event);
2834
+ });
2835
+ try {
2836
+ const auth = await this.webClient.auth.test();
2837
+ this.botUserId = auth.user_id ?? null;
2838
+ this.botId = auth.bot_id ?? null;
2839
+ } catch {
2840
+ this.botUserId = null;
2841
+ this.botId = null;
2842
+ }
2843
+ await this.socketClient.start();
2844
+ }
2845
+ async stop() {
2846
+ this.running = false;
2847
+ if (this.socketClient) {
2848
+ await this.socketClient.disconnect();
2849
+ this.socketClient = null;
2850
+ }
2851
+ this.botUserId = null;
2852
+ this.botId = null;
2853
+ }
2854
+ async send(msg) {
2855
+ if (!this.webClient) {
2856
+ return;
2857
+ }
2858
+ const slackMeta = msg.metadata?.slack ?? {};
2859
+ const threadTs = slackMeta.thread_ts;
2860
+ const channelType = slackMeta.channel_type;
2861
+ const useThread = Boolean(threadTs && channelType !== "im");
2862
+ await this.webClient.chat.postMessage({
2863
+ channel: msg.chatId,
2864
+ text: msg.content ?? "",
2865
+ thread_ts: useThread ? threadTs : void 0
2866
+ });
2867
+ }
2868
+ async handleEvent(event) {
2869
+ if (!event) {
2870
+ return;
2871
+ }
2872
+ const eventType = event.type;
2873
+ if (eventType !== "message" && eventType !== "app_mention") {
2874
+ return;
2875
+ }
2876
+ const subtype = event.subtype;
2877
+ const botId = event.bot_id;
2878
+ const isBotMessage = subtype === "bot_message" || Boolean(botId);
2879
+ if (subtype && subtype !== "bot_message") {
2880
+ return;
2881
+ }
2882
+ if (isBotMessage && !this.config.allowBots) {
2883
+ return;
2884
+ }
2885
+ const senderId = event.user ?? (isBotMessage ? botId : void 0);
2886
+ const chatId = event.channel;
2887
+ const channelType = event.channel_type ?? "";
2888
+ const text = event.text ?? "";
2889
+ if (!senderId || !chatId) {
2890
+ return;
2891
+ }
2892
+ if (this.botUserId && event.user === this.botUserId) {
2893
+ return;
2894
+ }
2895
+ if (this.botId && botId && botId === this.botId) {
2896
+ return;
2897
+ }
2898
+ if (eventType === "message" && !isBotMessage && this.botUserId && text.includes(`<@${this.botUserId}>`)) {
2899
+ return;
2900
+ }
2901
+ if (!this.isAllowedInSlack(senderId, chatId, channelType)) {
2902
+ return;
2903
+ }
2904
+ if (channelType !== "im" && !this.shouldRespondInChannel(eventType, text, chatId)) {
2905
+ return;
2906
+ }
2907
+ const cleanText = this.stripBotMention(text);
2908
+ const threadTs = event.thread_ts ?? event.ts;
2909
+ try {
2910
+ if (this.webClient && event.ts) {
2911
+ await this.webClient.reactions.add({
2912
+ channel: chatId,
2913
+ name: "eyes",
2914
+ timestamp: event.ts
2915
+ });
2916
+ }
2917
+ } catch {
2918
+ }
2919
+ await this.handleMessage({
2920
+ senderId,
2921
+ chatId,
2922
+ content: cleanText,
2923
+ attachments: [],
2924
+ metadata: {
2925
+ slack: {
2926
+ event,
2927
+ thread_ts: threadTs,
2928
+ channel_type: channelType
2929
+ }
2930
+ }
2931
+ });
2932
+ }
2933
+ isAllowedInSlack(senderId, chatId, channelType) {
2934
+ if (channelType === "im") {
2935
+ if (!this.config.dm.enabled) {
2936
+ return false;
2937
+ }
2938
+ if (this.config.dm.policy === "allowlist") {
2939
+ return this.config.dm.allowFrom.includes(senderId);
2940
+ }
2941
+ return true;
2942
+ }
2943
+ if (this.config.groupPolicy === "allowlist") {
2944
+ return this.config.groupAllowFrom.includes(chatId);
2945
+ }
2946
+ return true;
2947
+ }
2948
+ shouldRespondInChannel(eventType, text, chatId) {
2949
+ if (this.config.groupPolicy === "open") {
2950
+ return true;
2951
+ }
2952
+ if (this.config.groupPolicy === "mention") {
2953
+ if (eventType === "app_mention") {
2954
+ return true;
2955
+ }
2956
+ return this.botUserId ? text.includes(`<@${this.botUserId}>`) : false;
2957
+ }
2958
+ if (this.config.groupPolicy === "allowlist") {
2959
+ return this.config.groupAllowFrom.includes(chatId);
2960
+ }
2961
+ return false;
2962
+ }
2963
+ stripBotMention(text) {
2964
+ if (!text || !this.botUserId) {
2965
+ return text;
2966
+ }
2967
+ const pattern = new RegExp(`<@${this.botUserId}>\\s*`, "g");
2968
+ return text.replace(pattern, "").trim();
2969
+ }
2970
+ };
2971
+
2972
+ // src/channels/telegram.ts
2973
+ import TelegramBot from "node-telegram-bot-api";
2974
+
2975
+ // src/providers/transcription.ts
2976
+ import { createReadStream, existsSync as existsSync2 } from "fs";
2977
+ import { basename } from "path";
2978
+ import { FormData, fetch as fetch4 } from "undici";
2979
+ var GroqTranscriptionProvider = class {
2980
+ apiKey;
2981
+ apiUrl = "https://api.groq.com/openai/v1/audio/transcriptions";
2982
+ constructor(apiKey) {
2983
+ this.apiKey = apiKey ?? process.env.GROQ_API_KEY ?? null;
2984
+ }
2985
+ async transcribe(filePath) {
2986
+ if (!this.apiKey) {
2987
+ return "";
2988
+ }
2989
+ if (!existsSync2(filePath)) {
2990
+ return "";
2991
+ }
2992
+ const form = new FormData();
2993
+ form.append("file", createReadStream(filePath), basename(filePath));
2994
+ form.append("model", "whisper-large-v3");
2995
+ const response = await fetch4(this.apiUrl, {
2996
+ method: "POST",
2997
+ headers: {
2998
+ Authorization: `Bearer ${this.apiKey}`
2999
+ },
3000
+ body: form
3001
+ });
3002
+ if (!response.ok) {
3003
+ return "";
3004
+ }
3005
+ const data = await response.json();
3006
+ return data.text ?? "";
3007
+ }
3008
+ };
3009
+
3010
+ // src/channels/telegram.ts
3011
+ import { join as join3 } from "path";
3012
+ import { mkdirSync as mkdirSync3 } from "fs";
3013
+ import {
3014
+ isAssistantStreamResetControlMessage,
3015
+ isTypingStopControlMessage as isTypingStopControlMessage2,
3016
+ readAssistantStreamDelta
3017
+ } from "@nextclaw/core";
3018
+ var TYPING_HEARTBEAT_MS2 = 6e3;
3019
+ var TYPING_AUTO_STOP_MS2 = 12e4;
3020
+ var TELEGRAM_TEXT_LIMIT = 4096;
3021
+ var STREAM_PREVIEW_MIN_CHARS = 30;
3022
+ var STREAM_PREVIEW_PARTIAL_MIN_INTERVAL_MS = 700;
3023
+ var STREAM_PREVIEW_BLOCK_MIN_INTERVAL_MS = 1200;
3024
+ var STREAM_PREVIEW_BLOCK_MIN_GROWTH = 120;
3025
+ var BOT_COMMANDS = [
3026
+ { command: "start", description: "Start the bot" },
3027
+ { command: "reset", description: "Reset conversation history" },
3028
+ { command: "help", description: "Show available commands" }
3029
+ ];
3030
+ var TelegramStreamPreviewController = class {
3031
+ constructor(params) {
3032
+ this.params = params;
3033
+ }
3034
+ states = /* @__PURE__ */ new Map();
3035
+ async handleReset(msg) {
3036
+ const chatId = String(msg.chatId);
3037
+ this.dispose(chatId);
3038
+ if (this.params.resolveMode() === "off") {
3039
+ return;
3040
+ }
3041
+ const replyToMessageId = readReplyToMessageId(msg.metadata);
3042
+ this.states.set(chatId, {
3043
+ chatId,
3044
+ rawText: "",
3045
+ lastRenderedText: "",
3046
+ messageId: void 0,
3047
+ replyToMessageId,
3048
+ silent: msg.metadata?.silent === true,
3049
+ lastSentAt: 0,
3050
+ lastEmittedChars: 0,
3051
+ inFlight: false,
3052
+ pending: false,
3053
+ timer: null
3054
+ });
3055
+ }
3056
+ async handleDelta(msg, delta) {
3057
+ if (!delta) {
3058
+ return;
3059
+ }
3060
+ if (this.params.resolveMode() === "off") {
3061
+ return;
3062
+ }
3063
+ const chatId = String(msg.chatId);
3064
+ const state = this.ensureState(chatId);
3065
+ state.rawText += delta;
3066
+ this.scheduleFlush(state);
3067
+ }
3068
+ async finalizeWithFinalMessage(msg) {
3069
+ if (this.params.resolveMode() === "off") {
3070
+ return false;
3071
+ }
3072
+ const chatId = String(msg.chatId);
3073
+ const state = this.states.get(chatId);
3074
+ if (!state) {
3075
+ return false;
3076
+ }
3077
+ state.rawText = msg.content ?? "";
3078
+ const replyToMessageId = msg.replyTo ? Number(msg.replyTo) : state.replyToMessageId;
3079
+ state.silent = msg.metadata?.silent === true || state.silent;
3080
+ if (!state.rawText.trim()) {
3081
+ this.dispose(chatId);
3082
+ return false;
3083
+ }
3084
+ const handled = await this.flushNow(state, {
3085
+ force: true,
3086
+ allowInitialBelowThreshold: true,
3087
+ replyToMessageId,
3088
+ silent: state.silent
3089
+ });
3090
+ this.dispose(chatId);
3091
+ return handled;
3092
+ }
3093
+ stopAll() {
3094
+ for (const chatId of this.states.keys()) {
3095
+ this.dispose(chatId);
3096
+ }
3097
+ }
3098
+ ensureState(chatId) {
3099
+ const existing = this.states.get(chatId);
3100
+ if (existing) {
3101
+ return existing;
3102
+ }
3103
+ const created = {
3104
+ chatId,
3105
+ rawText: "",
3106
+ lastRenderedText: "",
3107
+ messageId: void 0,
3108
+ replyToMessageId: void 0,
3109
+ silent: false,
3110
+ lastSentAt: 0,
3111
+ lastEmittedChars: 0,
3112
+ inFlight: false,
3113
+ pending: false,
3114
+ timer: null
3115
+ };
3116
+ this.states.set(chatId, created);
3117
+ return created;
3118
+ }
3119
+ scheduleFlush(state) {
3120
+ if (state.timer) {
3121
+ return;
3122
+ }
3123
+ const minInterval = this.params.resolveMode() === "block" ? STREAM_PREVIEW_BLOCK_MIN_INTERVAL_MS : STREAM_PREVIEW_PARTIAL_MIN_INTERVAL_MS;
3124
+ const delay = Math.max(0, minInterval - (Date.now() - state.lastSentAt));
3125
+ state.timer = setTimeout(() => {
3126
+ state.timer = null;
3127
+ void this.flushScheduled(state);
3128
+ }, delay);
3129
+ }
3130
+ async flushScheduled(state) {
3131
+ const current = this.states.get(state.chatId);
3132
+ if (current !== state) {
3133
+ return;
3134
+ }
3135
+ if (state.inFlight) {
3136
+ state.pending = true;
3137
+ return;
3138
+ }
3139
+ state.inFlight = true;
3140
+ try {
3141
+ await this.flushNow(state, {
3142
+ force: false,
3143
+ allowInitialBelowThreshold: false,
3144
+ replyToMessageId: state.replyToMessageId,
3145
+ silent: state.silent
3146
+ });
3147
+ } finally {
3148
+ state.inFlight = false;
3149
+ if (state.pending) {
3150
+ state.pending = false;
3151
+ this.scheduleFlush(state);
3152
+ }
3153
+ }
3154
+ }
3155
+ async flushNow(state, opts) {
3156
+ const bot = this.params.getBot();
3157
+ if (!bot) {
3158
+ return false;
3159
+ }
3160
+ const plainText = state.rawText.trimEnd();
3161
+ if (!plainText) {
3162
+ return false;
3163
+ }
3164
+ const mode = this.params.resolveMode();
3165
+ if (mode === "block" && !opts.force && plainText.length - state.lastEmittedChars < STREAM_PREVIEW_BLOCK_MIN_GROWTH) {
3166
+ return typeof state.messageId === "number";
3167
+ }
3168
+ if (typeof state.messageId !== "number" && !opts.allowInitialBelowThreshold && plainText.length < STREAM_PREVIEW_MIN_CHARS) {
3169
+ return false;
3170
+ }
3171
+ const renderedText = markdownToTelegramHtml(plainText).trimEnd();
3172
+ if (!renderedText) {
3173
+ return false;
3174
+ }
3175
+ const limitedRenderedText = renderedText.slice(0, TELEGRAM_TEXT_LIMIT);
3176
+ if (limitedRenderedText === state.lastRenderedText) {
3177
+ return typeof state.messageId === "number";
3178
+ }
3179
+ if (typeof state.messageId === "number") {
3180
+ try {
3181
+ await bot.editMessageText(limitedRenderedText, {
3182
+ chat_id: Number(state.chatId),
3183
+ message_id: state.messageId,
3184
+ parse_mode: "HTML"
3185
+ });
3186
+ } catch {
3187
+ try {
3188
+ await bot.editMessageText(plainText.slice(0, TELEGRAM_TEXT_LIMIT), {
3189
+ chat_id: Number(state.chatId),
3190
+ message_id: state.messageId
3191
+ });
3192
+ } catch {
3193
+ return false;
3194
+ }
3195
+ }
3196
+ state.lastRenderedText = limitedRenderedText;
3197
+ state.lastSentAt = Date.now();
3198
+ state.lastEmittedChars = plainText.length;
3199
+ return true;
3200
+ }
3201
+ const sendOptions = {
3202
+ parse_mode: "HTML",
3203
+ ...opts.replyToMessageId ? { reply_to_message_id: opts.replyToMessageId } : {},
3204
+ ...opts.silent ? { disable_notification: true } : {}
3205
+ };
3206
+ try {
3207
+ const sent = await bot.sendMessage(Number(state.chatId), limitedRenderedText, sendOptions);
3208
+ if (typeof sent.message_id === "number") {
3209
+ state.messageId = sent.message_id;
3210
+ }
3211
+ } catch {
3212
+ const sent = await bot.sendMessage(Number(state.chatId), plainText.slice(0, TELEGRAM_TEXT_LIMIT), {
3213
+ ...opts.replyToMessageId ? { reply_to_message_id: opts.replyToMessageId } : {},
3214
+ ...opts.silent ? { disable_notification: true } : {}
3215
+ });
3216
+ if (typeof sent.message_id === "number") {
3217
+ state.messageId = sent.message_id;
3218
+ }
3219
+ }
3220
+ state.lastRenderedText = limitedRenderedText;
3221
+ state.lastSentAt = Date.now();
3222
+ state.lastEmittedChars = plainText.length;
3223
+ return true;
3224
+ }
3225
+ dispose(chatId) {
3226
+ const state = this.states.get(chatId);
3227
+ if (!state) {
3228
+ return;
3229
+ }
3230
+ if (state.timer) {
3231
+ clearTimeout(state.timer);
3232
+ state.timer = null;
3233
+ }
3234
+ this.states.delete(chatId);
3235
+ }
3236
+ };
3237
+ var TelegramChannel = class extends BaseChannel {
3238
+ constructor(config, bus, groqApiKey, sessionManager) {
3239
+ super(config, bus);
3240
+ this.sessionManager = sessionManager;
3241
+ this.transcriber = new GroqTranscriptionProvider(groqApiKey ?? null);
3242
+ this.typingController = new ChannelTypingController({
3243
+ heartbeatMs: TYPING_HEARTBEAT_MS2,
3244
+ autoStopMs: TYPING_AUTO_STOP_MS2,
3245
+ sendTyping: async (chatId) => {
3246
+ await this.bot?.sendChatAction(Number(chatId), "typing");
3247
+ }
3248
+ });
3249
+ this.streamPreview = new TelegramStreamPreviewController({
3250
+ resolveMode: () => resolveTelegramStreamingMode(this.config),
3251
+ getBot: () => this.bot
3252
+ });
3253
+ }
3254
+ name = "telegram";
3255
+ bot = null;
3256
+ botUserId = null;
3257
+ botUsername = null;
3258
+ typingController;
3259
+ streamPreview;
3260
+ transcriber;
3261
+ async start() {
3262
+ if (!this.config.token) {
3263
+ throw new Error("Telegram bot token not configured");
3264
+ }
3265
+ this.running = true;
3266
+ const options = { polling: true };
3267
+ if (this.config.proxy) {
3268
+ options.request = { proxy: this.config.proxy };
3269
+ }
3270
+ this.bot = new TelegramBot(this.config.token, options);
3271
+ try {
3272
+ const me = await this.bot.getMe();
3273
+ this.botUserId = me.id;
3274
+ this.botUsername = me.username ?? null;
3275
+ } catch {
3276
+ this.botUserId = null;
3277
+ this.botUsername = null;
3278
+ }
3279
+ this.bot.onText(/^\/start$/, async (msg) => {
3280
+ await this.bot?.sendMessage(
3281
+ msg.chat.id,
3282
+ `\u{1F44B} Hi ${msg.from?.first_name ?? ""}! I'm ${APP_NAME}.
3283
+
3284
+ Send me a message and I'll respond!
3285
+ Type /help to see available commands.`
3286
+ );
3287
+ });
3288
+ this.bot.onText(/^\/help$/, async (msg) => {
3289
+ const helpText = `\u{1F916} <b>${APP_NAME} commands</b>
3290
+
3291
+ /start \u2014 Start the bot
3292
+ /reset \u2014 Reset conversation history
3293
+ /help \u2014 Show this help message
3294
+
3295
+ Just send me a text message to chat!`;
3296
+ await this.bot?.sendMessage(msg.chat.id, helpText, { parse_mode: "HTML" });
3297
+ });
3298
+ this.bot.onText(/^\/reset$/, async (msg) => {
3299
+ const chatId = String(msg.chat.id);
3300
+ if (!this.sessionManager) {
3301
+ await this.bot?.sendMessage(msg.chat.id, "\u26A0\uFE0F Session management is not available.");
3302
+ return;
3303
+ }
3304
+ const accountId = this.resolveAccountId();
3305
+ const candidates = this.sessionManager.listSessions().filter((entry) => {
3306
+ const metadata = entry.metadata ?? {};
3307
+ const lastChannel = typeof metadata.last_channel === "string" ? metadata.last_channel : "";
3308
+ const lastTo = typeof metadata.last_to === "string" ? metadata.last_to : "";
3309
+ const lastAccountId = typeof metadata.last_account_id === "string" ? metadata.last_account_id : typeof metadata.last_accountId === "string" ? metadata.last_accountId : "default";
3310
+ return lastChannel === this.name && lastTo === chatId && lastAccountId === accountId;
3311
+ }).map((entry) => String(entry.key ?? "")).filter(Boolean);
3312
+ let totalCleared = 0;
3313
+ for (const key of candidates) {
3314
+ const session = this.sessionManager.getIfExists(key);
3315
+ if (!session) {
3316
+ continue;
3317
+ }
3318
+ totalCleared += session.messages.length;
3319
+ this.sessionManager.clear(session);
3320
+ this.sessionManager.save(session);
3321
+ }
3322
+ if (candidates.length === 0) {
3323
+ const legacySession = this.sessionManager.getOrCreate(`${this.name}:${chatId}`);
3324
+ totalCleared = legacySession.messages.length;
3325
+ this.sessionManager.clear(legacySession);
3326
+ this.sessionManager.save(legacySession);
3327
+ }
3328
+ await this.bot?.sendMessage(msg.chat.id, `\u{1F504} Conversation history cleared (${totalCleared} messages).`);
3329
+ });
3330
+ this.bot.on("message", async (msg) => {
3331
+ if (!msg.text && !msg.caption && !msg.photo && !msg.voice && !msg.audio && !msg.document) {
3332
+ return;
3333
+ }
3334
+ if (msg.text?.startsWith("/")) {
3335
+ return;
3336
+ }
3337
+ await this.handleIncoming(msg);
3338
+ });
3339
+ this.bot.on("channel_post", async (msg) => {
3340
+ if (!msg.text && !msg.caption && !msg.photo && !msg.voice && !msg.audio && !msg.document) {
3341
+ return;
3342
+ }
3343
+ if (msg.text?.startsWith("/")) {
3344
+ return;
3345
+ }
3346
+ await this.handleIncoming(msg);
3347
+ });
3348
+ await this.bot.setMyCommands(BOT_COMMANDS);
3349
+ }
3350
+ async stop() {
3351
+ this.running = false;
3352
+ this.typingController.stopAll();
3353
+ this.streamPreview.stopAll();
3354
+ if (this.bot) {
3355
+ await this.bot.stopPolling();
3356
+ this.bot = null;
3357
+ }
3358
+ }
3359
+ async handleControlMessage(msg) {
3360
+ if (isTypingStopControlMessage2(msg)) {
3361
+ this.stopTyping(msg.chatId);
3362
+ return true;
3363
+ }
3364
+ if (isAssistantStreamResetControlMessage(msg)) {
3365
+ await this.streamPreview.handleReset(msg);
3366
+ return true;
3367
+ }
3368
+ const delta = readAssistantStreamDelta(msg);
3369
+ if (delta !== null) {
3370
+ await this.streamPreview.handleDelta(msg, delta);
3371
+ return true;
3372
+ }
3373
+ return false;
3374
+ }
3375
+ async send(msg) {
3376
+ if (isTypingStopControlMessage2(msg)) {
3377
+ this.stopTyping(msg.chatId);
3378
+ return;
3379
+ }
3380
+ if (!this.bot) {
3381
+ return;
3382
+ }
3383
+ this.stopTyping(msg.chatId);
3384
+ if (await this.streamPreview.finalizeWithFinalMessage(msg)) {
3385
+ return;
3386
+ }
3387
+ const htmlContent = markdownToTelegramHtml(msg.content ?? "");
3388
+ const silent = msg.metadata?.silent === true;
3389
+ const replyTo = msg.replyTo ? Number(msg.replyTo) : void 0;
3390
+ const options = {
3391
+ parse_mode: "HTML",
3392
+ ...replyTo ? { reply_to_message_id: replyTo } : {},
3393
+ ...silent ? { disable_notification: true } : {}
3394
+ };
3395
+ try {
3396
+ await this.bot.sendMessage(Number(msg.chatId), htmlContent, options);
3397
+ } catch {
3398
+ await this.bot.sendMessage(Number(msg.chatId), msg.content ?? "", {
3399
+ ...replyTo ? { reply_to_message_id: replyTo } : {},
3400
+ ...silent ? { disable_notification: true } : {}
3401
+ });
3402
+ }
3403
+ }
3404
+ async handleIncoming(message) {
3405
+ if (!this.bot) {
3406
+ return;
3407
+ }
3408
+ const sender = resolveSender(message);
3409
+ if (!sender) {
3410
+ return;
3411
+ }
3412
+ const chatId = String(message.chat.id);
3413
+ const isGroup = message.chat.type !== "private";
3414
+ if (!this.isAllowedByPolicy({ senderId: String(sender.id), chatId, isGroup })) {
3415
+ return;
3416
+ }
3417
+ const mentionState = this.resolveMentionState({ message, chatId, isGroup });
3418
+ if (mentionState.requireMention && !mentionState.wasMentioned) {
3419
+ return;
3420
+ }
3421
+ let senderId = String(sender.id);
3422
+ if (sender.username) {
3423
+ senderId = `${senderId}|${sender.username}`;
3424
+ }
3425
+ const contentParts = [];
3426
+ const attachments = [];
3427
+ if (message.text) {
3428
+ contentParts.push(message.text);
3429
+ }
3430
+ if (message.caption) {
3431
+ contentParts.push(message.caption);
3432
+ }
3433
+ const { fileId, mediaType, mimeType } = resolveMedia(message);
3434
+ if (fileId && mediaType) {
3435
+ const mediaDir = join3(getDataPath(), "media");
3436
+ mkdirSync3(mediaDir, { recursive: true });
3437
+ const extension = getExtension(mediaType, mimeType);
3438
+ const downloaded = await this.bot.downloadFile(fileId, mediaDir);
3439
+ const finalPath = extension && !downloaded.endsWith(extension) ? `${downloaded}${extension}` : downloaded;
3440
+ attachments.push({
3441
+ id: fileId,
3442
+ name: finalPath.split("/").pop(),
3443
+ path: finalPath,
3444
+ mimeType: mimeType ?? inferMediaMimeType(mediaType),
3445
+ source: "telegram",
3446
+ status: "ready"
3447
+ });
3448
+ if (mediaType === "voice" || mediaType === "audio") {
3449
+ const transcription = await this.transcriber.transcribe(finalPath);
3450
+ if (transcription) {
3451
+ contentParts.push(`[transcription: ${transcription}]`);
3452
+ } else {
3453
+ contentParts.push(`[${mediaType}: ${finalPath}]`);
3454
+ }
3455
+ } else {
3456
+ contentParts.push(`[${mediaType}: ${finalPath}]`);
3457
+ }
3458
+ }
3459
+ await this.maybeAddAckReaction({
3460
+ message,
3461
+ chatId,
3462
+ isGroup,
3463
+ mentionState
3464
+ });
3465
+ const content = contentParts.length ? contentParts.join("\n") : "[empty message]";
3466
+ this.startTyping(chatId);
3467
+ try {
3468
+ await this.dispatchToBus(senderId, chatId, content, attachments, {
3469
+ message_id: message.message_id,
3470
+ user_id: sender.id,
3471
+ username: sender.username,
3472
+ first_name: sender.firstName,
3473
+ sender_type: sender.type,
3474
+ is_bot: sender.isBot,
3475
+ is_group: isGroup,
3476
+ account_id: this.resolveAccountId(),
3477
+ accountId: this.resolveAccountId(),
3478
+ peer_kind: isGroup ? "group" : "direct",
3479
+ peer_id: isGroup ? chatId : String(sender.id),
3480
+ was_mentioned: mentionState.wasMentioned,
3481
+ require_mention: mentionState.requireMention
3482
+ });
3483
+ } catch (error) {
3484
+ this.stopTyping(chatId);
3485
+ throw error;
3486
+ }
3487
+ }
3488
+ async dispatchToBus(senderId, chatId, content, attachments, metadata) {
3489
+ await this.handleMessage({ senderId, chatId, content, attachments, metadata });
3490
+ }
3491
+ startTyping(chatId) {
3492
+ this.typingController.start(chatId);
3493
+ }
3494
+ stopTyping(chatId) {
3495
+ this.typingController.stop(chatId);
3496
+ }
3497
+ resolveAccountId() {
3498
+ const accountId = this.config.accountId?.trim();
3499
+ return accountId || "default";
3500
+ }
3501
+ async maybeAddAckReaction(params) {
3502
+ if (!this.bot) {
3503
+ return;
3504
+ }
3505
+ if (typeof params.message.message_id !== "number") {
3506
+ return;
3507
+ }
3508
+ const emoji = (this.config.ackReaction ?? "\u{1F440}").trim();
3509
+ if (!emoji) {
3510
+ return;
3511
+ }
3512
+ const shouldAck = shouldSendAckReaction({
3513
+ scope: this.config.ackReactionScope,
3514
+ isDirect: !params.isGroup,
3515
+ isGroup: params.isGroup,
3516
+ requireMention: params.mentionState.requireMention,
3517
+ wasMentioned: params.mentionState.wasMentioned
3518
+ });
3519
+ if (!shouldAck) {
3520
+ return;
3521
+ }
3522
+ const reaction = [{ type: "emoji", emoji }];
3523
+ try {
3524
+ await this.bot.setMessageReaction(Number(params.chatId), params.message.message_id, {
3525
+ reaction
3526
+ });
3527
+ } catch {
3528
+ }
3529
+ }
3530
+ isAllowedByPolicy(params) {
3531
+ if (!params.isGroup) {
3532
+ if (this.config.dmPolicy === "disabled") {
3533
+ return false;
3534
+ }
3535
+ const allowFrom = this.config.allowFrom ?? [];
3536
+ if (this.config.dmPolicy === "allowlist" || this.config.dmPolicy === "pairing") {
3537
+ return this.isAllowed(params.senderId);
3538
+ }
3539
+ if (allowFrom.includes("*")) {
3540
+ return true;
3541
+ }
3542
+ return allowFrom.length === 0 ? true : this.isAllowed(params.senderId);
3543
+ }
3544
+ if (this.config.groupPolicy === "disabled") {
3545
+ return false;
3546
+ }
3547
+ if (this.config.groupPolicy === "allowlist") {
3548
+ const allowFrom = this.config.groupAllowFrom ?? [];
3549
+ return allowFrom.includes("*") || allowFrom.includes(params.chatId);
3550
+ }
3551
+ return true;
3552
+ }
3553
+ resolveMentionState(params) {
3554
+ if (!params.isGroup) {
3555
+ return { wasMentioned: false, requireMention: false };
3556
+ }
3557
+ const groups = this.config.groups ?? {};
3558
+ const groupRule = groups[params.chatId] ?? groups["*"];
3559
+ const requireMention = groupRule?.requireMention ?? this.config.requireMention ?? false;
3560
+ if (!requireMention) {
3561
+ return { wasMentioned: false, requireMention: false };
3562
+ }
3563
+ const content = `${params.message.text ?? ""}
3564
+ ${params.message.caption ?? ""}`.trim();
3565
+ const patterns = [
3566
+ ...this.config.mentionPatterns ?? [],
3567
+ ...groupRule?.mentionPatterns ?? []
3568
+ ].map((pattern) => pattern.trim()).filter(Boolean);
3569
+ const usernameMentioned = this.botUsername ? content.includes(`@${this.botUsername}`) : false;
3570
+ const replyToBot = Boolean(this.botUserId) && Boolean(params.message.reply_to_message?.from) && params.message.reply_to_message?.from?.id === this.botUserId;
3571
+ const patternMentioned = patterns.some((pattern) => {
3572
+ try {
3573
+ return new RegExp(pattern, "i").test(content);
3574
+ } catch {
3575
+ return content.toLowerCase().includes(pattern.toLowerCase());
3576
+ }
3577
+ });
3578
+ return {
3579
+ wasMentioned: usernameMentioned || replyToBot || patternMentioned,
3580
+ requireMention
3581
+ };
3582
+ }
3583
+ };
3584
+ function resolveSender(message) {
3585
+ if (message.from) {
3586
+ return {
3587
+ id: message.from.id,
3588
+ username: message.from.username,
3589
+ firstName: message.from.first_name,
3590
+ isBot: Boolean(message.from.is_bot),
3591
+ type: "user"
3592
+ };
3593
+ }
3594
+ if (message.sender_chat) {
3595
+ return {
3596
+ id: message.sender_chat.id,
3597
+ username: message.sender_chat.username,
3598
+ firstName: message.sender_chat.title,
3599
+ isBot: true,
3600
+ type: "sender_chat"
3601
+ };
3602
+ }
3603
+ return null;
3604
+ }
3605
+ function resolveMedia(message) {
3606
+ if (message.photo?.length) {
3607
+ const photo = message.photo[message.photo.length - 1];
3608
+ return { fileId: photo.file_id, mediaType: "image", mimeType: "image/jpeg" };
3609
+ }
3610
+ if (message.voice) {
3611
+ return { fileId: message.voice.file_id, mediaType: "voice", mimeType: message.voice.mime_type };
3612
+ }
3613
+ if (message.audio) {
3614
+ return { fileId: message.audio.file_id, mediaType: "audio", mimeType: message.audio.mime_type };
3615
+ }
3616
+ if (message.document) {
3617
+ return { fileId: message.document.file_id, mediaType: "file", mimeType: message.document.mime_type };
3618
+ }
3619
+ return {};
3620
+ }
3621
+ function getExtension(mediaType, mimeType) {
3622
+ const map = {
3623
+ "image/jpeg": ".jpg",
3624
+ "image/png": ".png",
3625
+ "image/gif": ".gif",
3626
+ "audio/ogg": ".ogg",
3627
+ "audio/mpeg": ".mp3",
3628
+ "audio/mp4": ".m4a"
3629
+ };
3630
+ if (mimeType && map[mimeType]) {
3631
+ return map[mimeType];
3632
+ }
3633
+ const fallback = {
3634
+ image: ".jpg",
3635
+ voice: ".ogg",
3636
+ audio: ".mp3",
3637
+ file: ""
3638
+ };
3639
+ return fallback[mediaType] ?? "";
3640
+ }
3641
+ function inferMediaMimeType(mediaType) {
3642
+ if (!mediaType) {
3643
+ return void 0;
3644
+ }
3645
+ if (mediaType === "image") {
3646
+ return "image/jpeg";
3647
+ }
3648
+ if (mediaType === "voice") {
3649
+ return "audio/ogg";
3650
+ }
3651
+ if (mediaType === "audio") {
3652
+ return "audio/mpeg";
3653
+ }
3654
+ return void 0;
3655
+ }
3656
+ function shouldSendAckReaction(params) {
3657
+ const scope = params.scope ?? "all";
3658
+ if (scope === "off") {
3659
+ return false;
3660
+ }
3661
+ if (scope === "all") {
3662
+ return true;
3663
+ }
3664
+ if (scope === "direct") {
3665
+ return params.isDirect;
3666
+ }
3667
+ if (scope === "group-all") {
3668
+ return params.isGroup;
3669
+ }
3670
+ if (scope === "group-mentions") {
3671
+ return params.isGroup && params.requireMention && params.wasMentioned;
3672
+ }
3673
+ return false;
3674
+ }
3675
+ function readReplyToMessageId(metadata) {
3676
+ const raw = metadata.message_id;
3677
+ if (typeof raw === "number" && Number.isFinite(raw)) {
3678
+ return Math.trunc(raw);
3679
+ }
3680
+ if (typeof raw === "string") {
3681
+ const parsed = Number(raw);
3682
+ if (Number.isFinite(parsed)) {
3683
+ return Math.trunc(parsed);
3684
+ }
3685
+ }
3686
+ return void 0;
3687
+ }
3688
+ function resolveTelegramStreamingMode(config) {
3689
+ const raw = config.streaming;
3690
+ if (raw === true) {
3691
+ return "partial";
3692
+ }
3693
+ if (raw === false || raw === void 0 || raw === null) {
3694
+ return "off";
3695
+ }
3696
+ if (raw === "progress") {
3697
+ return "partial";
3698
+ }
3699
+ if (raw === "partial" || raw === "block" || raw === "off") {
3700
+ return raw;
3701
+ }
3702
+ return "off";
3703
+ }
3704
+ function markdownToTelegramHtml(text) {
3705
+ if (!text) {
3706
+ return "";
3707
+ }
3708
+ const codeBlocks = [];
3709
+ text = text.replace(/```[\w]*\n?([\s\S]*?)```/g, (_m, code) => {
3710
+ codeBlocks.push(code);
3711
+ return `\0CB${codeBlocks.length - 1}\0`;
3712
+ });
3713
+ const inlineCodes = [];
3714
+ text = text.replace(/`([^`]+)`/g, (_m, code) => {
3715
+ inlineCodes.push(code);
3716
+ return `\0IC${inlineCodes.length - 1}\0`;
3717
+ });
3718
+ text = text.replace(/^#{1,6}\s+(.+)$/gm, "$1");
3719
+ text = text.replace(/^>\s*(.*)$/gm, "$1");
3720
+ text = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3721
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
3722
+ text = text.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
3723
+ text = text.replace(/__(.+?)__/g, "<b>$1</b>");
3724
+ text = text.replace(/(?<![a-zA-Z0-9])_([^_]+)_(?![a-zA-Z0-9])/g, "<i>$1</i>");
3725
+ text = text.replace(/~~(.+?)~~/g, "<s>$1</s>");
3726
+ text = text.replace(/^[-*]\s+/gm, "\u2022 ");
3727
+ inlineCodes.forEach((code, i) => {
3728
+ const escaped = code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3729
+ text = text.replace(`\0IC${i}\0`, `<code>${escaped}</code>`);
3730
+ });
3731
+ codeBlocks.forEach((code, i) => {
3732
+ const escaped = code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3733
+ text = text.replace(`\0CB${i}\0`, `<pre><code>${escaped}</code></pre>`);
3734
+ });
3735
+ return text;
3736
+ }
3737
+
3738
+ // src/channels/wecom.ts
3739
+ import { createHash } from "crypto";
3740
+ import { createServer } from "http";
3741
+ import { fetch as fetch5 } from "undici";
3742
+ var MSG_TYPE_FALLBACK = {
3743
+ image: "[image]",
3744
+ voice: "[voice]",
3745
+ video: "[video]",
3746
+ file: "[file]",
3747
+ location: "[location]",
3748
+ event: "[event]"
3749
+ };
3750
+ var TOKEN_EXPIRY_BUFFER_MS = 6e4;
3751
+ var WeComChannel = class extends BaseChannel {
3752
+ name = "wecom";
3753
+ server = null;
3754
+ accessToken = null;
3755
+ tokenExpiry = 0;
3756
+ processedIds = [];
3757
+ processedSet = /* @__PURE__ */ new Set();
3758
+ constructor(config, bus) {
3759
+ super(config, bus);
3760
+ }
3761
+ async start() {
3762
+ if (!this.config.corpId || !this.config.agentId || !this.config.secret || !this.config.token) {
3763
+ throw new Error("WeCom corpId/agentId/secret/token not configured");
3764
+ }
3765
+ this.running = true;
3766
+ await new Promise((resolve, reject) => {
3767
+ this.server = createServer((req, res) => {
3768
+ void this.handleCallbackRequest(req, res);
3769
+ });
3770
+ this.server.once("error", reject);
3771
+ this.server.listen(this.config.callbackPort, () => {
3772
+ this.server?.off("error", reject);
3773
+ resolve();
3774
+ });
3775
+ });
3776
+ }
3777
+ async stop() {
3778
+ this.running = false;
3779
+ if (!this.server) {
3780
+ return;
3781
+ }
3782
+ await new Promise((resolve) => {
3783
+ this.server?.close(() => resolve());
3784
+ });
3785
+ this.server = null;
3786
+ }
3787
+ async send(msg) {
3788
+ const receiver = msg.chatId?.trim();
3789
+ if (!receiver) {
3790
+ return;
3791
+ }
3792
+ const content = normalizeOutboundContent(msg);
3793
+ if (!content) {
3794
+ return;
3795
+ }
3796
+ const accessToken = await this.getAccessToken();
3797
+ const sendUrl = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(accessToken)}`;
3798
+ const agentIdNumber = Number(this.config.agentId);
3799
+ const payload = {
3800
+ touser: receiver,
3801
+ msgtype: "text",
3802
+ agentid: Number.isFinite(agentIdNumber) ? agentIdNumber : this.config.agentId,
3803
+ text: {
3804
+ content
3805
+ },
3806
+ safe: 0
3807
+ };
3808
+ const response = await fetch5(sendUrl, {
3809
+ method: "POST",
3810
+ headers: {
3811
+ "content-type": "application/json"
3812
+ },
3813
+ body: JSON.stringify(payload)
3814
+ });
3815
+ if (!response.ok) {
3816
+ throw new Error(`WeCom send failed: HTTP ${response.status}`);
3817
+ }
3818
+ const body = await response.json();
3819
+ const errcode = Number(body.errcode ?? -1);
3820
+ if (!Number.isFinite(errcode) || errcode !== 0) {
3821
+ const errmsg = typeof body.errmsg === "string" ? body.errmsg : "unknown error";
3822
+ throw new Error(`WeCom send failed: ${errcode} ${errmsg}`);
3823
+ }
3824
+ }
3825
+ async handleCallbackRequest(req, res) {
3826
+ const callbackPath = normalizeCallbackPath(this.config.callbackPath);
3827
+ const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
3828
+ if (requestUrl.pathname !== callbackPath) {
3829
+ res.statusCode = 404;
3830
+ res.end("not found");
3831
+ return;
3832
+ }
3833
+ const method = (req.method ?? "GET").toUpperCase();
3834
+ if (method === "GET") {
3835
+ this.handleVerificationRequest(requestUrl, res);
3836
+ return;
3837
+ }
3838
+ if (method === "POST") {
3839
+ await this.handleInboundMessageRequest(req, requestUrl, res);
3840
+ return;
3841
+ }
3842
+ res.statusCode = 405;
3843
+ res.end("method not allowed");
3844
+ }
3845
+ handleVerificationRequest(requestUrl, res) {
3846
+ const timestamp = requestUrl.searchParams.get("timestamp") ?? "";
3847
+ const nonce = requestUrl.searchParams.get("nonce") ?? "";
3848
+ const echostr = requestUrl.searchParams.get("echostr") ?? "";
3849
+ const signature = requestUrl.searchParams.get("msg_signature") ?? requestUrl.searchParams.get("signature") ?? "";
3850
+ if (!timestamp || !nonce || !echostr || !signature) {
3851
+ res.statusCode = 400;
3852
+ res.end("invalid verification payload");
3853
+ return;
3854
+ }
3855
+ if (!this.verifySignature(timestamp, nonce, signature)) {
3856
+ res.statusCode = 401;
3857
+ res.end("signature mismatch");
3858
+ return;
3859
+ }
3860
+ res.statusCode = 200;
3861
+ res.setHeader("content-type", "text/plain; charset=utf-8");
3862
+ res.end(echostr);
3863
+ }
3864
+ async handleInboundMessageRequest(req, requestUrl, res) {
3865
+ const timestamp = requestUrl.searchParams.get("timestamp") ?? "";
3866
+ const nonce = requestUrl.searchParams.get("nonce") ?? "";
3867
+ const signature = requestUrl.searchParams.get("msg_signature") ?? requestUrl.searchParams.get("signature") ?? "";
3868
+ if (!timestamp || !nonce || !signature || !this.verifySignature(timestamp, nonce, signature)) {
3869
+ res.statusCode = 401;
3870
+ res.end("signature mismatch");
3871
+ return;
3872
+ }
3873
+ const rawBody = await readBody(req);
3874
+ if (!rawBody.trim()) {
3875
+ respondSuccess(res);
3876
+ return;
3877
+ }
3878
+ if (extractXmlField(rawBody, "Encrypt")) {
3879
+ respondSuccess(res);
3880
+ return;
3881
+ }
3882
+ const senderId = extractXmlField(rawBody, "FromUserName");
3883
+ if (!senderId || !this.isAllowed(senderId)) {
3884
+ respondSuccess(res);
3885
+ return;
3886
+ }
3887
+ const msgType = extractXmlField(rawBody, "MsgType") || "text";
3888
+ const msgId = extractXmlField(rawBody, "MsgId") || buildSyntheticMessageId(rawBody, senderId, msgType);
3889
+ if (this.isDuplicate(msgId)) {
3890
+ respondSuccess(res);
3891
+ return;
3892
+ }
3893
+ const content = extractXmlField(rawBody, "Content")?.trim() || MSG_TYPE_FALLBACK[msgType.toLowerCase()] || "[unsupported message]";
3894
+ await this.handleMessage({
3895
+ senderId,
3896
+ chatId: senderId,
3897
+ content,
3898
+ attachments: [],
3899
+ metadata: {
3900
+ message_id: msgId,
3901
+ wecom: {
3902
+ msgType,
3903
+ toUserName: extractXmlField(rawBody, "ToUserName"),
3904
+ agentId: extractXmlField(rawBody, "AgentID"),
3905
+ createTime: extractXmlField(rawBody, "CreateTime")
3906
+ }
3907
+ }
3908
+ });
3909
+ respondSuccess(res);
3910
+ }
3911
+ verifySignature(timestamp, nonce, signature) {
3912
+ const expected = createHash("sha1").update([this.config.token, timestamp, nonce].sort().join("")).digest("hex");
3913
+ return expected === signature;
3914
+ }
3915
+ async getAccessToken() {
3916
+ if (this.accessToken && Date.now() < this.tokenExpiry) {
3917
+ return this.accessToken;
3918
+ }
3919
+ const tokenUrl = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${encodeURIComponent(this.config.corpId)}&corpsecret=${encodeURIComponent(this.config.secret)}`;
3920
+ const response = await fetch5(tokenUrl, { method: "GET" });
3921
+ if (!response.ok) {
3922
+ throw new Error(`WeCom gettoken failed: HTTP ${response.status}`);
3923
+ }
3924
+ const body = await response.json();
3925
+ const errcode = Number(body.errcode ?? -1);
3926
+ if (!Number.isFinite(errcode) || errcode !== 0) {
3927
+ const errmsg = typeof body.errmsg === "string" ? body.errmsg : "unknown error";
3928
+ throw new Error(`WeCom gettoken failed: ${errcode} ${errmsg}`);
3929
+ }
3930
+ const accessToken = typeof body.access_token === "string" ? body.access_token : "";
3931
+ const expiresIn = Number(body.expires_in ?? 7200);
3932
+ if (!accessToken) {
3933
+ throw new Error("WeCom gettoken failed: missing access_token");
3934
+ }
3935
+ this.accessToken = accessToken;
3936
+ this.tokenExpiry = Date.now() + Math.max(0, expiresIn * 1e3 - TOKEN_EXPIRY_BUFFER_MS);
3937
+ return accessToken;
3938
+ }
3939
+ isDuplicate(messageId) {
3940
+ if (this.processedSet.has(messageId)) {
3941
+ return true;
3942
+ }
3943
+ this.processedSet.add(messageId);
3944
+ this.processedIds.push(messageId);
3945
+ if (this.processedIds.length > 1e3) {
3946
+ const removed = this.processedIds.splice(0, 500);
3947
+ for (const id of removed) {
3948
+ this.processedSet.delete(id);
3949
+ }
3950
+ }
3951
+ return false;
3952
+ }
3953
+ };
3954
+ function normalizeCallbackPath(path) {
3955
+ if (!path) {
3956
+ return "/wecom/callback";
3957
+ }
3958
+ return path.startsWith("/") ? path : `/${path}`;
3959
+ }
3960
+ function respondSuccess(res) {
3961
+ res.statusCode = 200;
3962
+ res.setHeader("content-type", "text/plain; charset=utf-8");
3963
+ res.end("success");
3964
+ }
3965
+ function buildSyntheticMessageId(rawBody, senderId, msgType) {
3966
+ return createHash("sha1").update(`${senderId}:${msgType}:${rawBody}`).digest("hex");
3967
+ }
3968
+ function extractXmlField(xml, field) {
3969
+ const cdataPattern = new RegExp(`<${field}><!\\[CDATA\\[(.*?)\\]\\]><\\/${field}>`, "s");
3970
+ const cdataMatch = xml.match(cdataPattern);
3971
+ if (cdataMatch?.[1]) {
3972
+ return cdataMatch[1].trim();
3973
+ }
3974
+ const textPattern = new RegExp(`<${field}>(.*?)<\\/${field}>`, "s");
3975
+ const textMatch = xml.match(textPattern);
3976
+ return textMatch?.[1]?.trim() ?? "";
3977
+ }
3978
+ async function readBody(req) {
3979
+ const chunks = [];
3980
+ return await new Promise((resolve, reject) => {
3981
+ req.on("data", (chunk) => {
3982
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
3983
+ });
3984
+ req.on("error", reject);
3985
+ req.on("end", () => {
3986
+ resolve(Buffer.concat(chunks).toString("utf8"));
3987
+ });
3988
+ });
3989
+ }
3990
+ function normalizeOutboundContent(msg) {
3991
+ const segments = [];
3992
+ if (typeof msg.content === "string" && msg.content.trim()) {
3993
+ segments.push(msg.content.trim());
3994
+ }
3995
+ if (Array.isArray(msg.media)) {
3996
+ for (const item of msg.media) {
3997
+ if (typeof item === "string" && item.trim()) {
3998
+ segments.push(item.trim());
3999
+ }
4000
+ }
4001
+ }
4002
+ return segments.join("\n").trim();
4003
+ }
4004
+
4005
+ // src/channels/whatsapp.ts
4006
+ import WebSocket from "ws";
4007
+ var WhatsAppChannel = class extends BaseChannel {
4008
+ name = "whatsapp";
4009
+ ws = null;
4010
+ connected = false;
4011
+ constructor(config, bus) {
4012
+ super(config, bus);
4013
+ }
4014
+ async start() {
4015
+ this.running = true;
4016
+ const bridgeUrl = this.config.bridgeUrl;
4017
+ while (this.running) {
4018
+ try {
4019
+ await new Promise((resolve, reject) => {
4020
+ const ws = new WebSocket(bridgeUrl);
4021
+ this.ws = ws;
4022
+ ws.on("open", () => {
4023
+ this.connected = true;
4024
+ });
4025
+ ws.on("message", (data) => {
4026
+ const payload = data.toString();
4027
+ void this.handleBridgeMessage(payload);
4028
+ });
4029
+ ws.on("close", () => {
4030
+ this.connected = false;
4031
+ this.ws = null;
4032
+ resolve();
4033
+ });
4034
+ ws.on("error", (_err) => {
4035
+ this.connected = false;
4036
+ this.ws = null;
4037
+ reject(_err);
4038
+ });
4039
+ });
4040
+ } catch {
4041
+ if (!this.running) {
4042
+ break;
4043
+ }
4044
+ await sleep4(5e3);
4045
+ }
4046
+ }
4047
+ }
4048
+ async stop() {
4049
+ this.running = false;
4050
+ this.connected = false;
4051
+ if (this.ws) {
4052
+ this.ws.close();
4053
+ this.ws = null;
4054
+ }
4055
+ }
4056
+ async send(msg) {
4057
+ if (!this.ws || !this.connected) {
4058
+ return;
4059
+ }
4060
+ const payload = {
4061
+ type: "send",
4062
+ to: msg.chatId,
4063
+ text: msg.content
4064
+ };
4065
+ this.ws.send(JSON.stringify(payload));
4066
+ }
4067
+ async handleBridgeMessage(raw) {
4068
+ let data;
4069
+ try {
4070
+ data = JSON.parse(raw);
4071
+ } catch {
4072
+ return;
4073
+ }
4074
+ const msgType = data.type;
4075
+ if (msgType === "message") {
4076
+ const pn = data.pn ?? "";
4077
+ const sender = data.sender ?? "";
4078
+ let content = data.content ?? "";
4079
+ const userId = pn || sender;
4080
+ const senderId = userId.includes("@") ? userId.split("@")[0] : userId;
4081
+ if (content === "[Voice Message]") {
4082
+ content = "[Voice Message: Transcription not available for WhatsApp yet]";
4083
+ }
4084
+ await this.handleMessage({
4085
+ senderId,
4086
+ chatId: sender || userId,
4087
+ content,
4088
+ attachments: [],
4089
+ metadata: {
4090
+ message_id: data.id,
4091
+ timestamp: data.timestamp,
4092
+ is_group: Boolean(data.isGroup)
4093
+ }
4094
+ });
4095
+ return;
4096
+ }
4097
+ if (msgType === "status") {
4098
+ const status = data.status;
4099
+ if (status === "connected") {
4100
+ this.connected = true;
4101
+ } else if (status === "disconnected") {
4102
+ this.connected = false;
4103
+ }
4104
+ return;
4105
+ }
4106
+ if (msgType === "qr") {
4107
+ return;
4108
+ }
4109
+ if (msgType === "error") {
4110
+ return;
4111
+ }
4112
+ }
4113
+ };
4114
+ function sleep4(ms) {
4115
+ return new Promise((resolve) => setTimeout(resolve, ms));
4116
+ }
4117
+
4118
+ // src/index.ts
4119
+ var BUILTIN_CHANNEL_RUNTIMES = {
4120
+ telegram: {
4121
+ id: "telegram",
4122
+ isEnabled: (config) => config.channels.telegram.enabled,
4123
+ createChannel: (context) => {
4124
+ const providers = context.config.providers;
4125
+ const groqApiKey = providers.groq?.apiKey;
4126
+ return new TelegramChannel(
4127
+ context.config.channels.telegram,
4128
+ context.bus,
4129
+ groqApiKey,
4130
+ context.sessionManager
4131
+ );
4132
+ }
4133
+ },
4134
+ whatsapp: {
4135
+ id: "whatsapp",
4136
+ isEnabled: (config) => config.channels.whatsapp.enabled,
4137
+ createChannel: (context) => new WhatsAppChannel(context.config.channels.whatsapp, context.bus)
4138
+ },
4139
+ discord: {
4140
+ id: "discord",
4141
+ isEnabled: (config) => config.channels.discord.enabled,
4142
+ createChannel: (context) => new DiscordChannel(context.config.channels.discord, context.bus, context.sessionManager, context.config)
4143
+ },
4144
+ feishu: {
4145
+ id: "feishu",
4146
+ isEnabled: (config) => config.channels.feishu.enabled,
4147
+ createChannel: (context) => new FeishuChannel(context.config.channels.feishu, context.bus)
4148
+ },
4149
+ mochat: {
4150
+ id: "mochat",
4151
+ isEnabled: (config) => config.channels.mochat.enabled,
4152
+ createChannel: (context) => new MochatChannel(context.config.channels.mochat, context.bus)
4153
+ },
4154
+ dingtalk: {
4155
+ id: "dingtalk",
4156
+ isEnabled: (config) => config.channels.dingtalk.enabled,
4157
+ createChannel: (context) => new DingTalkChannel(context.config.channels.dingtalk, context.bus)
4158
+ },
4159
+ wecom: {
4160
+ id: "wecom",
4161
+ isEnabled: (config) => config.channels.wecom.enabled,
4162
+ createChannel: (context) => new WeComChannel(context.config.channels.wecom, context.bus)
4163
+ },
4164
+ email: {
4165
+ id: "email",
4166
+ isEnabled: (config) => config.channels.email.enabled,
4167
+ createChannel: (context) => new EmailChannel(context.config.channels.email, context.bus)
4168
+ },
4169
+ slack: {
4170
+ id: "slack",
4171
+ isEnabled: (config) => config.channels.slack.enabled,
4172
+ createChannel: (context) => new SlackChannel(context.config.channels.slack, context.bus)
4173
+ },
4174
+ qq: {
4175
+ id: "qq",
4176
+ isEnabled: (config) => config.channels.qq.enabled,
4177
+ createChannel: (context) => new QQChannel(context.config.channels.qq, context.bus)
4178
+ }
4179
+ };
4180
+ var BUILTIN_CHANNEL_PLUGIN_IDS = Object.keys(BUILTIN_CHANNEL_RUNTIMES);
4181
+ function listBuiltinChannelRuntimes() {
4182
+ return BUILTIN_CHANNEL_PLUGIN_IDS.map(
4183
+ (channelId) => resolveBuiltinChannelRuntime(channelId)
4184
+ );
4185
+ }
4186
+ function resolveBuiltinChannelRuntime(channelId) {
4187
+ const runtime = BUILTIN_CHANNEL_RUNTIMES[channelId];
4188
+ if (!runtime) {
4189
+ throw new Error(`builtin channel runtime not found: ${channelId}`);
4190
+ }
4191
+ return runtime;
4192
+ }
4193
+ export {
4194
+ BUILTIN_CHANNEL_PLUGIN_IDS,
4195
+ DingTalkChannel,
4196
+ DiscordChannel,
4197
+ EmailChannel,
4198
+ FeishuChannel,
4199
+ MochatChannel,
4200
+ QQChannel,
4201
+ SlackChannel,
4202
+ TelegramChannel,
4203
+ WeComChannel,
4204
+ WhatsAppChannel,
4205
+ listBuiltinChannelRuntimes,
4206
+ resolveBuiltinChannelRuntime
4207
+ };