botmsg 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2617 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import {
7
+ CallToolRequestSchema,
8
+ ListToolsRequestSchema
9
+ } from "@modelcontextprotocol/sdk/types.js";
10
+
11
+ // src/config/loader.ts
12
+ import { readFileSync, existsSync } from "fs";
13
+ import { extname } from "path";
14
+ import YAML from "yaml";
15
+
16
+ // src/config/schema.ts
17
+ import { z } from "zod";
18
+ var PlatformEnum = z.enum([
19
+ "dingtalk",
20
+ "wecom",
21
+ "feishu",
22
+ "lark",
23
+ "slack",
24
+ "discord",
25
+ "telegram"
26
+ ]);
27
+ var DingTalkBotSchema = z.object({
28
+ platform: z.literal("dingtalk"),
29
+ webhook: z.string().url(),
30
+ secret: z.string().optional(),
31
+ security: z.enum(["keyword", "ip", "sign"]).default("keyword")
32
+ });
33
+ var WeComBotSchema = z.object({
34
+ platform: z.literal("wecom"),
35
+ webhook: z.string().url()
36
+ });
37
+ var FeishuBotSchema = z.object({
38
+ platform: z.literal("feishu"),
39
+ webhook: z.string().url(),
40
+ secret: z.string().optional()
41
+ });
42
+ var LarkBotSchema = z.object({
43
+ platform: z.literal("lark"),
44
+ webhook: z.string().url(),
45
+ secret: z.string().optional()
46
+ });
47
+ var SlackBotSchema = z.object({
48
+ platform: z.literal("slack"),
49
+ webhook: z.string().url()
50
+ });
51
+ var DiscordBotSchema = z.object({
52
+ platform: z.literal("discord"),
53
+ webhook: z.string().url(),
54
+ username: z.string().optional(),
55
+ avatar_url: z.string().url().optional()
56
+ });
57
+ var TelegramBotSchema = z.object({
58
+ platform: z.literal("telegram"),
59
+ token: z.string(),
60
+ chat_id: z.string(),
61
+ parse_mode: z.enum(["MarkdownV2", "HTML", ""]).default("")
62
+ });
63
+ var BotConfigSchema = z.discriminatedUnion("platform", [
64
+ DingTalkBotSchema,
65
+ WeComBotSchema,
66
+ FeishuBotSchema,
67
+ LarkBotSchema,
68
+ SlackBotSchema,
69
+ DiscordBotSchema,
70
+ TelegramBotSchema
71
+ ]);
72
+ var SettingsSchema = z.object({
73
+ log_level: z.enum(["debug", "info", "warn", "error"]).default("info"),
74
+ audit_log: z.string().optional(),
75
+ max_retries: z.number().int().min(0).max(10).default(3),
76
+ dry_run: z.boolean().default(false)
77
+ });
78
+ var FullConfigSchema = z.object({
79
+ bots: z.record(z.string(), BotConfigSchema),
80
+ settings: SettingsSchema.default({})
81
+ });
82
+ var MessageTypeEnum = z.enum([
83
+ "text",
84
+ "markdown",
85
+ "link",
86
+ "image",
87
+ "card",
88
+ "news",
89
+ "file"
90
+ ]);
91
+ var AtSchema = z.object({
92
+ users: z.array(z.string()).optional(),
93
+ mobiles: z.array(z.string()).optional(),
94
+ all: z.boolean().default(false)
95
+ });
96
+ var LinkContentSchema = z.object({
97
+ title: z.string(),
98
+ text: z.string(),
99
+ url: z.string().url(),
100
+ pic_url: z.string().url().optional()
101
+ });
102
+ var CardButtonSchema = z.object({
103
+ title: z.string(),
104
+ url: z.string().url()
105
+ });
106
+ var CardContentSchema = z.object({
107
+ title: z.string(),
108
+ text: z.string(),
109
+ buttons: z.array(CardButtonSchema).optional(),
110
+ single_button_text: z.string().optional(),
111
+ single_button_url: z.string().url().optional()
112
+ });
113
+ var NewsItemSchema = z.object({
114
+ title: z.string(),
115
+ description: z.string().optional(),
116
+ url: z.string().url(),
117
+ pic_url: z.string().url().optional()
118
+ });
119
+
120
+ // src/errors.ts
121
+ var BotMsgError = class extends Error {
122
+ constructor(message, code, platform, details) {
123
+ super(message);
124
+ this.code = code;
125
+ this.platform = platform;
126
+ this.details = details;
127
+ this.name = "BotMsgError";
128
+ }
129
+ code;
130
+ platform;
131
+ details;
132
+ };
133
+ var ConfigError = class extends BotMsgError {
134
+ constructor(message) {
135
+ super(message, "CONFIG_ERROR");
136
+ this.name = "ConfigError";
137
+ }
138
+ };
139
+ var PlatformError = class extends BotMsgError {
140
+ constructor(message, platform, details) {
141
+ super(message, "PLATFORM_ERROR", platform, details);
142
+ this.name = "PlatformError";
143
+ }
144
+ };
145
+ var BotNotFoundError = class extends BotMsgError {
146
+ constructor(botName) {
147
+ super(`Bot "${botName}" not found in configuration`, "BOT_NOT_FOUND");
148
+ this.name = "BotNotFoundError";
149
+ }
150
+ };
151
+
152
+ // src/logger.ts
153
+ var LOG_LEVELS = {
154
+ debug: 0,
155
+ info: 1,
156
+ warn: 2,
157
+ error: 3
158
+ };
159
+ var Logger = class {
160
+ level;
161
+ constructor(level = "info") {
162
+ this.level = LOG_LEVELS[level];
163
+ }
164
+ setLevel(level) {
165
+ this.level = LOG_LEVELS[level];
166
+ }
167
+ log(level, message, ctx) {
168
+ if (LOG_LEVELS[level] < this.level) return;
169
+ const entry = {
170
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
171
+ level: level.toUpperCase(),
172
+ message,
173
+ ...ctx
174
+ };
175
+ console.error(JSON.stringify(entry));
176
+ }
177
+ debug(message, ctx) {
178
+ this.log("debug", message, ctx);
179
+ }
180
+ info(message, ctx) {
181
+ this.log("info", message, ctx);
182
+ }
183
+ warn(message, ctx) {
184
+ this.log("warn", message, ctx);
185
+ }
186
+ error(message, ctx) {
187
+ this.log("error", message, ctx);
188
+ }
189
+ };
190
+ var logger = new Logger(
191
+ process.env.BOTMSG_LOG_LEVEL || "info"
192
+ );
193
+
194
+ // src/config/loader.ts
195
+ var PLATFORM_NAMES = [
196
+ "dingtalk",
197
+ "wecom",
198
+ "feishu",
199
+ "lark",
200
+ "slack",
201
+ "discord",
202
+ "telegram"
203
+ ];
204
+ function parseEnvBots() {
205
+ const bots = {};
206
+ for (const [key, value] of Object.entries(process.env)) {
207
+ if (!key.startsWith("BOTMSG_") || !value) continue;
208
+ const parts = key.slice("BOTMSG_".length).split("_");
209
+ if (parts.length < 3) continue;
210
+ const platform = parts[0].toLowerCase();
211
+ if (!PLATFORM_NAMES.includes(platform)) continue;
212
+ if (["LOG", "AUDIT", "CONFIG", "DRY", "MAX"].includes(parts[0])) continue;
213
+ const instance = parts[1].toLowerCase();
214
+ const field = parts.slice(2).join("_").toLowerCase();
215
+ const botName = `${platform}-${instance}`;
216
+ if (!bots[botName]) bots[botName] = {};
217
+ bots[botName].platform = platform;
218
+ bots[botName][field] = value;
219
+ }
220
+ const result = {};
221
+ for (const [name, raw] of Object.entries(bots)) {
222
+ try {
223
+ const parsed = BotConfigSchema.parse(raw);
224
+ result[name] = parsed;
225
+ } catch (err) {
226
+ logger.warn(`Skipping invalid bot config for "${name}"`, {
227
+ error: err instanceof Error ? err.message : String(err),
228
+ raw
229
+ });
230
+ }
231
+ }
232
+ return result;
233
+ }
234
+ function parseEnvSettings() {
235
+ const settings = {};
236
+ if (process.env.BOTMSG_LOG_LEVEL) {
237
+ settings.log_level = process.env.BOTMSG_LOG_LEVEL;
238
+ }
239
+ if (process.env.BOTMSG_AUDIT_LOG) {
240
+ settings.audit_log = process.env.BOTMSG_AUDIT_LOG;
241
+ }
242
+ if (process.env.BOTMSG_MAX_RETRIES) {
243
+ settings.max_retries = parseInt(process.env.BOTMSG_MAX_RETRIES, 10);
244
+ }
245
+ if (process.env.BOTMSG_DRY_RUN) {
246
+ settings.dry_run = process.env.BOTMSG_DRY_RUN === "true";
247
+ }
248
+ return settings;
249
+ }
250
+ function loadConfigFile(filePath) {
251
+ if (!existsSync(filePath)) {
252
+ throw new ConfigError(`Config file not found: ${filePath}`);
253
+ }
254
+ const content = readFileSync(filePath, "utf-8");
255
+ const ext = extname(filePath).toLowerCase();
256
+ let raw;
257
+ if (ext === ".yaml" || ext === ".yml") {
258
+ raw = YAML.parse(content);
259
+ } else if (ext === ".json") {
260
+ raw = JSON.parse(content);
261
+ } else {
262
+ throw new ConfigError(`Unsupported config file format: ${ext}`);
263
+ }
264
+ return FullConfigSchema.parse(raw);
265
+ }
266
+ function loadConfig() {
267
+ const configPath = process.env.BOTMSG_CONFIG;
268
+ if (configPath) {
269
+ try {
270
+ const fileConfig = loadConfigFile(configPath);
271
+ logger.info(`Loaded config from file: ${configPath}`, {
272
+ botCount: Object.keys(fileConfig.bots).length
273
+ });
274
+ const envBots = parseEnvBots();
275
+ const mergedBots = { ...envBots, ...fileConfig.bots };
276
+ const envSettings = parseEnvSettings();
277
+ const mergedSettings = SettingsSchema.parse({
278
+ ...envSettings,
279
+ ...fileConfig.settings
280
+ });
281
+ return { bots: mergedBots, settings: mergedSettings };
282
+ } catch (err) {
283
+ if (err instanceof ConfigError) throw err;
284
+ logger.warn(`Failed to parse config file, falling back to env vars`, {
285
+ error: err instanceof Error ? err.message : String(err)
286
+ });
287
+ }
288
+ }
289
+ const bots = parseEnvBots();
290
+ const settings = SettingsSchema.parse(parseEnvSettings());
291
+ logger.info(`Loaded config from environment variables`, {
292
+ botCount: Object.keys(bots).length
293
+ });
294
+ return { bots, settings };
295
+ }
296
+
297
+ // src/platforms/dingtalk.ts
298
+ import { createHmac } from "crypto";
299
+
300
+ // src/platforms/base.ts
301
+ var PlatformAdapter = class {
302
+ config;
303
+ botName;
304
+ constructor(botName, config) {
305
+ this.botName = botName;
306
+ this.config = config;
307
+ }
308
+ /**
309
+ * Execute HTTP POST with error handling
310
+ */
311
+ async httpPost(url, body, headers) {
312
+ const startTime = Date.now();
313
+ try {
314
+ const response = await fetch(url, {
315
+ method: "POST",
316
+ headers: {
317
+ "Content-Type": "application/json",
318
+ ...headers
319
+ },
320
+ body: JSON.stringify(body)
321
+ });
322
+ const text = await response.text();
323
+ let data;
324
+ try {
325
+ data = JSON.parse(text);
326
+ } catch {
327
+ data = text;
328
+ }
329
+ return { status: response.status, data };
330
+ } catch (err) {
331
+ const latencyMs = Date.now() - startTime;
332
+ throw new PlatformError(
333
+ `HTTP request failed: ${err instanceof Error ? err.message : String(err)}`,
334
+ this.platformName,
335
+ { latencyMs }
336
+ );
337
+ }
338
+ }
339
+ /**
340
+ * Generate a unique message ID for audit tracking
341
+ */
342
+ generateMessageId() {
343
+ const ts = Date.now().toString(36);
344
+ const rand = Math.random().toString(36).slice(2, 8);
345
+ return `msg_${ts}_${rand}`;
346
+ }
347
+ /**
348
+ * Wrap send with timing and result normalization
349
+ */
350
+ async withTiming(sendFn) {
351
+ const startTime = Date.now();
352
+ try {
353
+ const result = await sendFn();
354
+ return {
355
+ ...result,
356
+ platform: this.platformName,
357
+ bot: this.botName,
358
+ latencyMs: Date.now() - startTime
359
+ };
360
+ } catch (err) {
361
+ return {
362
+ success: false,
363
+ platform: this.platformName,
364
+ bot: this.botName,
365
+ latencyMs: Date.now() - startTime,
366
+ error: err instanceof Error ? err.message : String(err)
367
+ };
368
+ }
369
+ }
370
+ };
371
+
372
+ // src/platforms/dingtalk.ts
373
+ var DingTalkAdapter = class extends PlatformAdapter {
374
+ get platformName() {
375
+ return "dingtalk";
376
+ }
377
+ get cfg() {
378
+ return this.config;
379
+ }
380
+ /**
381
+ * Build signed webhook URL if HMAC-SHA256 security is enabled
382
+ */
383
+ getSignedUrl() {
384
+ const { webhook, secret, security } = this.cfg;
385
+ if (security === "sign" && secret) {
386
+ const timestamp = Date.now();
387
+ const stringToSign = `${timestamp}
388
+ ${secret}`;
389
+ const hmac = createHmac("sha256", secret).update(stringToSign).digest("base64");
390
+ const sign = encodeURIComponent(hmac);
391
+ return `${webhook}&timestamp=${timestamp}&sign=${sign}`;
392
+ }
393
+ return webhook;
394
+ }
395
+ buildPayload(options) {
396
+ const at = this.buildAt(options.at);
397
+ switch (options.type) {
398
+ case "text":
399
+ return {
400
+ msgtype: "text",
401
+ text: { content: options.content },
402
+ ...Object.keys(at).length > 0 ? { at } : {}
403
+ };
404
+ case "markdown":
405
+ return {
406
+ msgtype: "markdown",
407
+ markdown: {
408
+ title: options.title || "Message",
409
+ text: options.content
410
+ },
411
+ ...Object.keys(at).length > 0 ? { at } : {}
412
+ };
413
+ case "link":
414
+ return {
415
+ msgtype: "link",
416
+ link: {
417
+ title: options.title || "Link",
418
+ text: options.content,
419
+ messageUrl: options.linkUrl || "",
420
+ picUrl: options.picUrl || ""
421
+ }
422
+ };
423
+ case "card": {
424
+ const hasButtons = options.buttons && options.buttons.length > 0;
425
+ if (hasButtons) {
426
+ return {
427
+ msgtype: "actionCard",
428
+ actionCard: {
429
+ title: options.title || "Card",
430
+ text: options.content,
431
+ btnOrientation: "0",
432
+ btns: options.buttons.map((btn) => ({
433
+ title: btn.title,
434
+ actionURL: btn.url
435
+ }))
436
+ }
437
+ };
438
+ }
439
+ return {
440
+ msgtype: "actionCard",
441
+ actionCard: {
442
+ title: options.title || "Card",
443
+ text: options.content,
444
+ singleTitle: options.singleButtonText || "View",
445
+ singleURL: options.singleButtonUrl || ""
446
+ }
447
+ };
448
+ }
449
+ case "news":
450
+ return {
451
+ msgtype: "feedCard",
452
+ feedCard: {
453
+ links: (options.newsItems || []).map((item) => ({
454
+ title: item.title,
455
+ messageURL: item.url,
456
+ picURL: item.picUrl || ""
457
+ }))
458
+ }
459
+ };
460
+ case "image":
461
+ return {
462
+ msgtype: "markdown",
463
+ markdown: {
464
+ title: options.title || "Image",
465
+ text: `![image](${options.imageUrl || options.content})`
466
+ }
467
+ };
468
+ case "file":
469
+ return {
470
+ msgtype: "link",
471
+ link: {
472
+ title: options.title || "File",
473
+ text: options.content || "A file was shared",
474
+ messageUrl: options.linkUrl || "",
475
+ picUrl: ""
476
+ }
477
+ };
478
+ default:
479
+ return {
480
+ msgtype: "text",
481
+ text: { content: options.content }
482
+ };
483
+ }
484
+ }
485
+ async send(options) {
486
+ return this.withTiming(async () => {
487
+ const url = this.getSignedUrl();
488
+ const payload = this.buildPayload(options);
489
+ const { status, data } = await this.httpPost(url, payload);
490
+ const result = data;
491
+ if (status === 200 && result.errcode === 0) {
492
+ return { success: true, messageId: this.generateMessageId(), raw: result };
493
+ }
494
+ return {
495
+ success: false,
496
+ error: `DingTalk error [${result.errcode}]: ${result.errmsg}`,
497
+ raw: result
498
+ };
499
+ });
500
+ }
501
+ async sendFile(filePath, _caption) {
502
+ return {
503
+ success: false,
504
+ platform: "dingtalk",
505
+ bot: this.botName,
506
+ latencyMs: 0,
507
+ error: "DingTalk webhook does not support file uploads. Use link type instead."
508
+ };
509
+ }
510
+ buildAt(at) {
511
+ if (!at) return {};
512
+ const result = {};
513
+ if (at.all) result.isAtAll = true;
514
+ if (at.mobiles && at.mobiles.length > 0) result.atMobiles = at.mobiles;
515
+ if (at.users && at.users.length > 0) result.atUserIds = at.users;
516
+ return result;
517
+ }
518
+ };
519
+
520
+ // src/platforms/wecom.ts
521
+ import { readFileSync as readFileSync2 } from "fs";
522
+ import { createHash } from "crypto";
523
+ var WeComAdapter = class extends PlatformAdapter {
524
+ get platformName() {
525
+ return "wecom";
526
+ }
527
+ get cfg() {
528
+ return this.config;
529
+ }
530
+ /**
531
+ * Extract the webhook key from URL for file upload
532
+ */
533
+ getWebhookKey() {
534
+ const url = new URL(this.cfg.webhook);
535
+ return url.searchParams.get("key") || "";
536
+ }
537
+ /**
538
+ * Upload file to get media_id (for file messages)
539
+ */
540
+ async uploadFile(filePath) {
541
+ const key = this.getWebhookKey();
542
+ const uploadUrl = `https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key=${key}&type=file`;
543
+ const fileBuffer = readFileSync2(filePath);
544
+ const fileName = filePath.split(/[/\\]/).pop() || "file";
545
+ const formData = new FormData();
546
+ formData.append("media", new Blob([fileBuffer]), fileName);
547
+ const response = await fetch(uploadUrl, {
548
+ method: "POST",
549
+ body: formData
550
+ });
551
+ const data = await response.json();
552
+ if (data.errcode !== 0) {
553
+ throw new PlatformError(
554
+ `WeCom file upload failed: [${data.errcode}] ${data.errmsg}`,
555
+ "wecom"
556
+ );
557
+ }
558
+ return data.media_id;
559
+ }
560
+ buildPayload(options) {
561
+ switch (options.type) {
562
+ case "text":
563
+ return {
564
+ msgtype: "text",
565
+ text: {
566
+ content: options.content,
567
+ ...this.buildMention(options.at)
568
+ }
569
+ };
570
+ case "markdown":
571
+ return {
572
+ msgtype: "markdown",
573
+ markdown: {
574
+ content: options.content
575
+ }
576
+ };
577
+ case "image": {
578
+ if (options.imageBase64) {
579
+ const md5 = createHash("md5").update(Buffer.from(options.imageBase64, "base64")).digest("hex");
580
+ return {
581
+ msgtype: "image",
582
+ image: {
583
+ base64: options.imageBase64,
584
+ md5
585
+ }
586
+ };
587
+ }
588
+ return {
589
+ msgtype: "news",
590
+ news: {
591
+ articles: [
592
+ {
593
+ title: options.title || "Image",
594
+ description: options.content || "",
595
+ url: options.imageUrl || options.content,
596
+ picurl: options.imageUrl || options.content
597
+ }
598
+ ]
599
+ }
600
+ };
601
+ }
602
+ case "link":
603
+ return {
604
+ msgtype: "news",
605
+ news: {
606
+ articles: [
607
+ {
608
+ title: options.title || "Link",
609
+ description: options.content,
610
+ url: options.linkUrl || "",
611
+ picurl: options.picUrl || ""
612
+ }
613
+ ]
614
+ }
615
+ };
616
+ case "news":
617
+ return {
618
+ msgtype: "news",
619
+ news: {
620
+ articles: (options.newsItems || []).map((item) => ({
621
+ title: item.title,
622
+ description: item.description || "",
623
+ url: item.url,
624
+ picurl: item.picUrl || ""
625
+ }))
626
+ }
627
+ };
628
+ case "file":
629
+ return {
630
+ msgtype: "file",
631
+ file: {
632
+ media_id: options.fileMediaId || ""
633
+ }
634
+ };
635
+ case "card":
636
+ return {
637
+ msgtype: "template_card",
638
+ template_card: {
639
+ card_type: "text_notice",
640
+ main_title: {
641
+ title: options.title || "Card"
642
+ },
643
+ emphasis_content: {
644
+ title: options.content.split("\n")[0] || "",
645
+ desc: options.content
646
+ },
647
+ jump_list: (options.buttons || []).map((btn) => ({
648
+ type: 1,
649
+ url: btn.url,
650
+ title: btn.title
651
+ })),
652
+ card_action: options.singleButtonUrl ? { type: 1, url: options.singleButtonUrl } : options.buttons?.[0] ? { type: 1, url: options.buttons[0].url } : void 0
653
+ }
654
+ };
655
+ default:
656
+ return {
657
+ msgtype: "text",
658
+ text: { content: options.content }
659
+ };
660
+ }
661
+ }
662
+ async send(options) {
663
+ return this.withTiming(async () => {
664
+ const payload = this.buildPayload(options);
665
+ const { status, data } = await this.httpPost(this.cfg.webhook, payload);
666
+ const result = data;
667
+ if (status === 200 && result.errcode === 0) {
668
+ return { success: true, messageId: this.generateMessageId(), raw: result };
669
+ }
670
+ return {
671
+ success: false,
672
+ error: `WeCom error [${result.errcode}]: ${result.errmsg}`,
673
+ raw: result
674
+ };
675
+ });
676
+ }
677
+ async sendFile(filePath, _caption) {
678
+ return this.withTiming(async () => {
679
+ try {
680
+ const mediaId = await this.uploadFile(filePath);
681
+ const payload = {
682
+ msgtype: "file",
683
+ file: { media_id: mediaId }
684
+ };
685
+ const { status, data } = await this.httpPost(this.cfg.webhook, payload);
686
+ const result = data;
687
+ if (status === 200 && result.errcode === 0) {
688
+ return { success: true, messageId: this.generateMessageId(), raw: result };
689
+ }
690
+ return {
691
+ success: false,
692
+ error: `WeCom send file failed: [${result.errcode}] ${result.errmsg}`,
693
+ raw: result
694
+ };
695
+ } catch (err) {
696
+ return {
697
+ success: false,
698
+ error: err instanceof Error ? err.message : String(err)
699
+ };
700
+ }
701
+ });
702
+ }
703
+ buildMention(at) {
704
+ if (!at) return {};
705
+ const result = {};
706
+ if (at.users && at.users.length > 0) {
707
+ result.mentioned_list = at.all ? ["@all"] : at.users;
708
+ } else if (at.all) {
709
+ result.mentioned_list = ["@all"];
710
+ }
711
+ if (at.mobiles && at.mobiles.length > 0) {
712
+ result.mentioned_mobile_list = at.mobiles;
713
+ }
714
+ return result;
715
+ }
716
+ };
717
+
718
+ // src/platforms/feishu.ts
719
+ import { createHmac as createHmac2 } from "crypto";
720
+ var FeishuAdapter = class extends PlatformAdapter {
721
+ baseUrl = "https://open.feishu.cn";
722
+ get platformName() {
723
+ return "feishu";
724
+ }
725
+ get cfg() {
726
+ return this.config;
727
+ }
728
+ /**
729
+ * Generate HMAC-SHA256 signature for Feishu
730
+ * Note: Feishu uses string_to_sign as KEY and empty string as DATA
731
+ */
732
+ generateSign() {
733
+ const { secret } = this.cfg;
734
+ if (!secret) return null;
735
+ const timestamp = Math.floor(Date.now() / 1e3).toString();
736
+ const stringToSign = `${timestamp}
737
+ ${secret}`;
738
+ const sign = createHmac2("sha256", stringToSign).update("").digest("base64");
739
+ return { timestamp, sign };
740
+ }
741
+ /**
742
+ * Add signature fields to payload if security is enabled
743
+ */
744
+ addSignature(payload) {
745
+ const signData = this.generateSign();
746
+ if (signData) {
747
+ payload.timestamp = signData.timestamp;
748
+ payload.sign = signData.sign;
749
+ }
750
+ return payload;
751
+ }
752
+ buildPayload(options) {
753
+ let payload;
754
+ switch (options.type) {
755
+ case "text": {
756
+ let textContent = options.content;
757
+ if (options.at) {
758
+ if (options.at.all) {
759
+ textContent += '\n<at user_id="all">\u6240\u6709\u4EBA</at>';
760
+ }
761
+ if (options.at.users) {
762
+ for (const userId of options.at.users) {
763
+ textContent += `
764
+ <at user_id="${userId}">${userId}</at>`;
765
+ }
766
+ }
767
+ }
768
+ payload = {
769
+ msg_type: "text",
770
+ content: { text: textContent }
771
+ };
772
+ break;
773
+ }
774
+ case "markdown": {
775
+ payload = {
776
+ msg_type: "interactive",
777
+ card: {
778
+ schema: "2.0",
779
+ header: {
780
+ title: {
781
+ tag: "plain_text",
782
+ content: options.title || "Message"
783
+ },
784
+ template: "blue"
785
+ },
786
+ body: {
787
+ elements: [
788
+ {
789
+ tag: "markdown",
790
+ content: options.content
791
+ }
792
+ ]
793
+ }
794
+ }
795
+ };
796
+ break;
797
+ }
798
+ case "link":
799
+ payload = {
800
+ msg_type: "post",
801
+ content: {
802
+ post: {
803
+ zh_cn: {
804
+ title: options.title || "Link",
805
+ content: [
806
+ [
807
+ { tag: "text", text: options.content + "\n" },
808
+ {
809
+ tag: "a",
810
+ text: options.linkUrl || "Open Link",
811
+ href: options.linkUrl || ""
812
+ }
813
+ ]
814
+ ]
815
+ }
816
+ }
817
+ }
818
+ };
819
+ break;
820
+ case "image":
821
+ payload = {
822
+ msg_type: "image",
823
+ content: {
824
+ image_key: options.content
825
+ // For Feishu, content should be image_key
826
+ }
827
+ };
828
+ break;
829
+ case "card": {
830
+ const elements = [
831
+ { tag: "markdown", content: options.content }
832
+ ];
833
+ if (options.buttons && options.buttons.length > 0) {
834
+ elements.push({
835
+ tag: "action",
836
+ actions: options.buttons.map((btn) => ({
837
+ tag: "button",
838
+ text: { tag: "plain_text", content: btn.title },
839
+ type: "primary",
840
+ behaviors: [
841
+ { type: "open_url", default_url: btn.url }
842
+ ]
843
+ }))
844
+ });
845
+ }
846
+ payload = {
847
+ msg_type: "interactive",
848
+ card: {
849
+ schema: "2.0",
850
+ header: {
851
+ title: {
852
+ tag: "plain_text",
853
+ content: options.title || "Card"
854
+ },
855
+ template: "blue"
856
+ },
857
+ body: { elements }
858
+ }
859
+ };
860
+ break;
861
+ }
862
+ case "news": {
863
+ const contentRows = (options.newsItems || []).map((item) => [
864
+ { tag: "a", text: item.title, href: item.url }
865
+ ]);
866
+ payload = {
867
+ msg_type: "post",
868
+ content: {
869
+ post: {
870
+ zh_cn: {
871
+ title: options.title || "News",
872
+ content: contentRows
873
+ }
874
+ }
875
+ }
876
+ };
877
+ break;
878
+ }
879
+ case "file":
880
+ payload = {
881
+ msg_type: "text",
882
+ content: {
883
+ text: options.content || `[File: ${options.filePath || "unknown"}]`
884
+ }
885
+ };
886
+ break;
887
+ default:
888
+ payload = {
889
+ msg_type: "text",
890
+ content: { text: options.content }
891
+ };
892
+ }
893
+ return this.addSignature(payload);
894
+ }
895
+ async send(options) {
896
+ return this.withTiming(async () => {
897
+ const payload = this.buildPayload(options);
898
+ const { status, data } = await this.httpPost(this.cfg.webhook, payload);
899
+ const result = data;
900
+ if (status === 200 && result.code === 0) {
901
+ return { success: true, messageId: this.generateMessageId(), raw: result };
902
+ }
903
+ return {
904
+ success: false,
905
+ error: `Feishu error [${result.code}]: ${result.msg}`,
906
+ raw: result
907
+ };
908
+ });
909
+ }
910
+ async sendFile(filePath, _caption) {
911
+ return {
912
+ success: false,
913
+ platform: "feishu",
914
+ bot: this.botName,
915
+ latencyMs: 0,
916
+ error: "Feishu webhook does not support file uploads. Use image_key or share_chat instead."
917
+ };
918
+ }
919
+ };
920
+
921
+ // src/platforms/lark.ts
922
+ var LarkAdapter = class extends FeishuAdapter {
923
+ baseUrl = "https://open.larksuite.com";
924
+ get platformName() {
925
+ return "lark";
926
+ }
927
+ };
928
+
929
+ // src/platforms/slack.ts
930
+ var SlackAdapter = class extends PlatformAdapter {
931
+ get platformName() {
932
+ return "slack";
933
+ }
934
+ get cfg() {
935
+ return this.config;
936
+ }
937
+ buildPayload(options) {
938
+ switch (options.type) {
939
+ case "text":
940
+ return { text: options.content };
941
+ case "markdown": {
942
+ const blocks = [];
943
+ if (options.title) {
944
+ blocks.push({
945
+ type: "header",
946
+ text: { type: "plain_text", text: options.title }
947
+ });
948
+ }
949
+ blocks.push({
950
+ type: "section",
951
+ text: { type: "mrkdwn", text: options.content }
952
+ });
953
+ return {
954
+ text: options.title || options.content.slice(0, 100),
955
+ blocks
956
+ };
957
+ }
958
+ case "link": {
959
+ const blocks = [
960
+ {
961
+ type: "section",
962
+ text: {
963
+ type: "mrkdwn",
964
+ text: `*${options.title || "Link"}*
965
+ ${options.content}`
966
+ },
967
+ accessory: options.picUrl ? {
968
+ type: "image",
969
+ image_url: options.picUrl,
970
+ alt_text: options.title || "image"
971
+ } : void 0
972
+ }
973
+ ];
974
+ if (options.linkUrl) {
975
+ blocks.push({
976
+ type: "actions",
977
+ elements: [
978
+ {
979
+ type: "button",
980
+ text: { type: "plain_text", text: "Open Link" },
981
+ url: options.linkUrl
982
+ }
983
+ ]
984
+ });
985
+ }
986
+ return {
987
+ text: `${options.title || "Link"}: ${options.linkUrl || ""}`,
988
+ blocks
989
+ };
990
+ }
991
+ case "image": {
992
+ const imageUrl = options.imageUrl || options.content;
993
+ return {
994
+ text: options.title || "Image",
995
+ blocks: [
996
+ {
997
+ type: "image",
998
+ image_url: imageUrl,
999
+ alt_text: options.title || "image"
1000
+ }
1001
+ ]
1002
+ };
1003
+ }
1004
+ case "card": {
1005
+ const blocks = [];
1006
+ if (options.title) {
1007
+ blocks.push({
1008
+ type: "header",
1009
+ text: { type: "plain_text", text: options.title }
1010
+ });
1011
+ }
1012
+ blocks.push({
1013
+ type: "section",
1014
+ text: { type: "mrkdwn", text: options.content }
1015
+ });
1016
+ if (options.buttons && options.buttons.length > 0) {
1017
+ blocks.push({
1018
+ type: "actions",
1019
+ elements: options.buttons.map((btn) => ({
1020
+ type: "button",
1021
+ text: { type: "plain_text", text: btn.title },
1022
+ url: btn.url
1023
+ }))
1024
+ });
1025
+ }
1026
+ return {
1027
+ text: options.title || options.content.slice(0, 100),
1028
+ blocks
1029
+ };
1030
+ }
1031
+ case "news": {
1032
+ const blocks = [];
1033
+ if (options.title) {
1034
+ blocks.push({
1035
+ type: "header",
1036
+ text: { type: "plain_text", text: options.title }
1037
+ });
1038
+ }
1039
+ for (const item of options.newsItems || []) {
1040
+ blocks.push({
1041
+ type: "section",
1042
+ text: {
1043
+ type: "mrkdwn",
1044
+ text: `*<${item.url}|${item.title}>*
1045
+ ${item.description || ""}`
1046
+ },
1047
+ accessory: item.picUrl ? {
1048
+ type: "image",
1049
+ image_url: item.picUrl,
1050
+ alt_text: item.title
1051
+ } : void 0
1052
+ });
1053
+ blocks.push({ type: "divider" });
1054
+ }
1055
+ return {
1056
+ text: options.title || "News",
1057
+ blocks
1058
+ };
1059
+ }
1060
+ case "file":
1061
+ return {
1062
+ text: options.content || `[File: ${options.filePath || "unknown"}]`
1063
+ };
1064
+ default:
1065
+ return { text: options.content };
1066
+ }
1067
+ }
1068
+ async send(options) {
1069
+ return this.withTiming(async () => {
1070
+ const payload = this.buildPayload(options);
1071
+ const { status, data } = await this.httpPost(this.cfg.webhook, payload);
1072
+ if (status === 200) {
1073
+ return { success: true, messageId: this.generateMessageId(), raw: data };
1074
+ }
1075
+ return {
1076
+ success: false,
1077
+ error: `Slack error [${status}]: ${typeof data === "string" ? data : JSON.stringify(data)}`,
1078
+ raw: data
1079
+ };
1080
+ });
1081
+ }
1082
+ async sendFile(filePath, _caption) {
1083
+ return {
1084
+ success: false,
1085
+ platform: "slack",
1086
+ bot: this.botName,
1087
+ latencyMs: 0,
1088
+ error: "Slack incoming webhooks do not support file uploads."
1089
+ };
1090
+ }
1091
+ };
1092
+
1093
+ // src/platforms/discord.ts
1094
+ import { readFileSync as readFileSync3 } from "fs";
1095
+ import { basename } from "path";
1096
+ var DiscordAdapter = class extends PlatformAdapter {
1097
+ get platformName() {
1098
+ return "discord";
1099
+ }
1100
+ get cfg() {
1101
+ return this.config;
1102
+ }
1103
+ /**
1104
+ * Build common fields for all payloads
1105
+ */
1106
+ getBasePayload() {
1107
+ const base = {};
1108
+ if (this.cfg.username) base.username = this.cfg.username;
1109
+ if (this.cfg.avatar_url) base.avatar_url = this.cfg.avatar_url;
1110
+ return base;
1111
+ }
1112
+ buildPayload(options) {
1113
+ const base = this.getBasePayload();
1114
+ switch (options.type) {
1115
+ case "text":
1116
+ return { ...base, content: options.content };
1117
+ case "markdown":
1118
+ case "link": {
1119
+ const embed = {
1120
+ description: options.content,
1121
+ color: 3447003
1122
+ // Blue
1123
+ };
1124
+ if (options.title) embed.title = options.title;
1125
+ if (options.linkUrl) embed.url = options.linkUrl;
1126
+ if (options.picUrl) embed.thumbnail = { url: options.picUrl };
1127
+ embed.timestamp = (/* @__PURE__ */ new Date()).toISOString();
1128
+ return { ...base, embeds: [embed] };
1129
+ }
1130
+ case "image": {
1131
+ const imageUrl = options.imageUrl || options.content;
1132
+ return {
1133
+ ...base,
1134
+ embeds: [
1135
+ {
1136
+ title: options.title || "Image",
1137
+ image: { url: imageUrl },
1138
+ color: 3447003
1139
+ }
1140
+ ]
1141
+ };
1142
+ }
1143
+ case "card": {
1144
+ const embed = {
1145
+ title: options.title || "Card",
1146
+ description: options.content,
1147
+ color: 3447003,
1148
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1149
+ };
1150
+ if (options.buttons && options.buttons.length > 0) {
1151
+ return {
1152
+ ...base,
1153
+ embeds: [embed],
1154
+ components: [
1155
+ {
1156
+ type: 1,
1157
+ components: options.buttons.map((btn) => ({
1158
+ type: 2,
1159
+ style: 5,
1160
+ label: btn.title,
1161
+ url: btn.url
1162
+ }))
1163
+ }
1164
+ ]
1165
+ };
1166
+ }
1167
+ return { ...base, embeds: [embed] };
1168
+ }
1169
+ case "news": {
1170
+ const embeds = (options.newsItems || []).map((item) => ({
1171
+ title: item.title,
1172
+ description: item.description || "",
1173
+ url: item.url,
1174
+ thumbnail: item.picUrl ? { url: item.picUrl } : void 0,
1175
+ color: 3447003
1176
+ }));
1177
+ return { ...base, embeds: embeds.slice(0, 10) };
1178
+ }
1179
+ case "file":
1180
+ return {
1181
+ ...base,
1182
+ content: options.content || `[File: ${options.filePath || "unknown"}]`
1183
+ };
1184
+ default:
1185
+ return { ...base, content: options.content };
1186
+ }
1187
+ }
1188
+ async send(options) {
1189
+ return this.withTiming(async () => {
1190
+ const payload = this.buildPayload(options);
1191
+ const { status, data } = await this.httpPost(this.cfg.webhook, payload);
1192
+ if (status === 204 || status === 200) {
1193
+ return { success: true, messageId: this.generateMessageId(), raw: data };
1194
+ }
1195
+ const result = data;
1196
+ return {
1197
+ success: false,
1198
+ error: `Discord error [${result.code || status}]: ${result.message || JSON.stringify(data)}`,
1199
+ raw: result
1200
+ };
1201
+ });
1202
+ }
1203
+ async sendFile(filePath, caption) {
1204
+ return this.withTiming(async () => {
1205
+ try {
1206
+ const fileBuffer = readFileSync3(filePath);
1207
+ const fileName = basename(filePath);
1208
+ const formData = new FormData();
1209
+ const payloadJson = {
1210
+ ...this.getBasePayload(),
1211
+ content: caption || ""
1212
+ };
1213
+ formData.append("payload_json", JSON.stringify(payloadJson));
1214
+ formData.append("files[0]", new Blob([fileBuffer]), fileName);
1215
+ const response = await fetch(this.cfg.webhook, {
1216
+ method: "POST",
1217
+ body: formData
1218
+ });
1219
+ if (response.status === 204 || response.status === 200) {
1220
+ return {
1221
+ success: true,
1222
+ messageId: this.generateMessageId(),
1223
+ raw: await response.text()
1224
+ };
1225
+ }
1226
+ const errorData = await response.json().catch(() => response.statusText);
1227
+ return {
1228
+ success: false,
1229
+ error: `Discord file upload failed [${response.status}]: ${JSON.stringify(errorData)}`
1230
+ };
1231
+ } catch (err) {
1232
+ return {
1233
+ success: false,
1234
+ error: err instanceof Error ? err.message : String(err)
1235
+ };
1236
+ }
1237
+ });
1238
+ }
1239
+ };
1240
+
1241
+ // src/platforms/telegram.ts
1242
+ import { readFileSync as readFileSync4 } from "fs";
1243
+ import { basename as basename2 } from "path";
1244
+ var TelegramAdapter = class extends PlatformAdapter {
1245
+ get platformName() {
1246
+ return "telegram";
1247
+ }
1248
+ get cfg() {
1249
+ return this.config;
1250
+ }
1251
+ get apiBase() {
1252
+ return `https://api.telegram.org/bot${this.cfg.token}`;
1253
+ }
1254
+ /**
1255
+ * Escape special characters for MarkdownV2
1256
+ */
1257
+ escapeMarkdownV2(text) {
1258
+ if (this.cfg.parse_mode !== "MarkdownV2") return text;
1259
+ return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
1260
+ }
1261
+ buildPayload(options) {
1262
+ const basePayload = {
1263
+ chat_id: this.cfg.chat_id
1264
+ };
1265
+ if (this.cfg.parse_mode) {
1266
+ basePayload.parse_mode = this.cfg.parse_mode;
1267
+ }
1268
+ switch (options.type) {
1269
+ case "text":
1270
+ return {
1271
+ ...basePayload,
1272
+ text: options.content
1273
+ };
1274
+ case "markdown":
1275
+ return {
1276
+ ...basePayload,
1277
+ parse_mode: this.cfg.parse_mode || "MarkdownV2",
1278
+ text: options.content
1279
+ };
1280
+ case "link": {
1281
+ const text = options.title ? `[${options.title}](${options.linkUrl})
1282
+ ${options.content}` : `${options.content}
1283
+ ${options.linkUrl}`;
1284
+ return {
1285
+ ...basePayload,
1286
+ parse_mode: "MarkdownV2",
1287
+ text: this.escapeMarkdownV2(options.content) + (options.linkUrl ? `
1288
+ [Open Link](${options.linkUrl})` : "")
1289
+ };
1290
+ }
1291
+ case "image":
1292
+ return {
1293
+ chat_id: this.cfg.chat_id,
1294
+ photo: options.imageUrl || options.content,
1295
+ caption: options.title || "",
1296
+ ...this.cfg.parse_mode ? { parse_mode: this.cfg.parse_mode } : {}
1297
+ };
1298
+ case "card": {
1299
+ const inlineKeyboard = (options.buttons || []).map((btn) => [
1300
+ { text: btn.title, url: btn.url }
1301
+ ]);
1302
+ const payload = {
1303
+ ...basePayload,
1304
+ text: options.title ? `*${options.title}*
1305
+ ${options.content}` : options.content
1306
+ };
1307
+ if (inlineKeyboard.length > 0) {
1308
+ payload.reply_markup = { inline_keyboard: inlineKeyboard };
1309
+ }
1310
+ return payload;
1311
+ }
1312
+ case "news": {
1313
+ const lines = (options.newsItems || []).map(
1314
+ (item, i) => `${i + 1}. [${item.title}](${item.url})${item.description ? `
1315
+ ${item.description}` : ""}`
1316
+ );
1317
+ return {
1318
+ ...basePayload,
1319
+ parse_mode: "MarkdownV2",
1320
+ text: (options.title ? `*${this.escapeMarkdownV2(options.title)}*
1321
+ ` : "") + this.escapeMarkdownV2(lines.join("\n"))
1322
+ };
1323
+ }
1324
+ case "file":
1325
+ return {
1326
+ chat_id: this.cfg.chat_id,
1327
+ document: options.content || options.linkUrl || "",
1328
+ caption: options.title || ""
1329
+ };
1330
+ default:
1331
+ return { ...basePayload, text: options.content };
1332
+ }
1333
+ }
1334
+ /**
1335
+ * Determine the correct Telegram API method based on message type
1336
+ */
1337
+ getApiMethod(type) {
1338
+ switch (type) {
1339
+ case "image":
1340
+ return "sendPhoto";
1341
+ case "file":
1342
+ return "sendDocument";
1343
+ default:
1344
+ return "sendMessage";
1345
+ }
1346
+ }
1347
+ async send(options) {
1348
+ return this.withTiming(async () => {
1349
+ const method = this.getApiMethod(options.type);
1350
+ const url = `${this.apiBase}/${method}`;
1351
+ const payload = this.buildPayload(options);
1352
+ const { status, data } = await this.httpPost(url, payload);
1353
+ const result = data;
1354
+ if (status === 200 && result.ok === true) {
1355
+ const messageData = result.result;
1356
+ return {
1357
+ success: true,
1358
+ messageId: messageData?.message_id ? String(messageData.message_id) : this.generateMessageId(),
1359
+ raw: result
1360
+ };
1361
+ }
1362
+ return {
1363
+ success: false,
1364
+ error: `Telegram error [${result.error_code}]: ${result.description}`,
1365
+ raw: result
1366
+ };
1367
+ });
1368
+ }
1369
+ async sendFile(filePath, caption) {
1370
+ return this.withTiming(async () => {
1371
+ try {
1372
+ const url = `${this.apiBase}/sendDocument`;
1373
+ const fileBuffer = readFileSync4(filePath);
1374
+ const fileName = basename2(filePath);
1375
+ const formData = new FormData();
1376
+ formData.append("chat_id", this.cfg.chat_id);
1377
+ formData.append("document", new Blob([fileBuffer]), fileName);
1378
+ if (caption) formData.append("caption", caption);
1379
+ if (this.cfg.parse_mode) formData.append("parse_mode", this.cfg.parse_mode);
1380
+ const response = await fetch(url, {
1381
+ method: "POST",
1382
+ body: formData
1383
+ });
1384
+ const data = await response.json();
1385
+ if (response.status === 200 && data.ok === true) {
1386
+ return {
1387
+ success: true,
1388
+ messageId: String(data.result?.message_id || ""),
1389
+ raw: data
1390
+ };
1391
+ }
1392
+ return {
1393
+ success: false,
1394
+ error: `Telegram file upload failed [${data.error_code}]: ${data.description}`,
1395
+ raw: data
1396
+ };
1397
+ } catch (err) {
1398
+ return {
1399
+ success: false,
1400
+ error: err instanceof Error ? err.message : String(err)
1401
+ };
1402
+ }
1403
+ });
1404
+ }
1405
+ };
1406
+
1407
+ // src/platforms/registry.ts
1408
+ var PlatformRegistry = class {
1409
+ bots = /* @__PURE__ */ new Map();
1410
+ /**
1411
+ * Initialize registry from full config
1412
+ */
1413
+ loadConfig(config) {
1414
+ for (const [name, botConfig] of Object.entries(config.bots)) {
1415
+ this.register(name, botConfig);
1416
+ }
1417
+ logger.info(`Registry loaded: ${this.bots.size} bot(s) registered`);
1418
+ }
1419
+ /**
1420
+ * Register a single bot
1421
+ */
1422
+ register(name, config) {
1423
+ const adapter = this.createAdapter(name, config);
1424
+ this.bots.set(name, adapter);
1425
+ logger.debug(`Registered bot: ${name} (${config.platform})`);
1426
+ }
1427
+ /**
1428
+ * Get a bot adapter by name
1429
+ */
1430
+ get(name) {
1431
+ const adapter = this.bots.get(name);
1432
+ if (!adapter) throw new BotNotFoundError(name);
1433
+ return adapter;
1434
+ }
1435
+ /**
1436
+ * Check if a bot exists
1437
+ */
1438
+ has(name) {
1439
+ return this.bots.has(name);
1440
+ }
1441
+ /**
1442
+ * List all registered bots
1443
+ */
1444
+ list() {
1445
+ return Array.from(this.bots.entries()).map(([name, adapter]) => ({
1446
+ name,
1447
+ platform: adapter.platformName,
1448
+ enabled: true
1449
+ }));
1450
+ }
1451
+ /**
1452
+ * Get all adapter instances
1453
+ */
1454
+ getAll() {
1455
+ return Array.from(this.bots.values());
1456
+ }
1457
+ /**
1458
+ * Get adapters by bot names
1459
+ */
1460
+ getMany(names) {
1461
+ return names.map((name) => this.get(name));
1462
+ }
1463
+ createAdapter(name, config) {
1464
+ switch (config.platform) {
1465
+ case "dingtalk":
1466
+ return new DingTalkAdapter(name, config);
1467
+ case "wecom":
1468
+ return new WeComAdapter(name, config);
1469
+ case "feishu":
1470
+ return new FeishuAdapter(name, config);
1471
+ case "lark":
1472
+ return new LarkAdapter(name, config);
1473
+ case "slack":
1474
+ return new SlackAdapter(name, config);
1475
+ case "discord":
1476
+ return new DiscordAdapter(name, config);
1477
+ case "telegram":
1478
+ return new TelegramAdapter(name, config);
1479
+ default:
1480
+ throw new Error(`Unsupported platform: ${config.platform}`);
1481
+ }
1482
+ }
1483
+ };
1484
+
1485
+ // src/queue/rate-limiter.ts
1486
+ import Bottleneck from "bottleneck";
1487
+ var PLATFORM_LIMITS = {
1488
+ dingtalk: {
1489
+ reservoir: 18,
1490
+ // Leave margin below 20/min
1491
+ reservoirRefreshAmount: 18,
1492
+ reservoirRefreshInterval: 6e4,
1493
+ // per minute
1494
+ maxConcurrent: 3,
1495
+ minTime: 3e3,
1496
+ // ~20/min spacing
1497
+ highWater: 50,
1498
+ strategy: Bottleneck.strategy.LEAK
1499
+ },
1500
+ wecom: {
1501
+ reservoir: 18,
1502
+ reservoirRefreshAmount: 18,
1503
+ reservoirRefreshInterval: 6e4,
1504
+ maxConcurrent: 3,
1505
+ minTime: 3e3,
1506
+ highWater: 50,
1507
+ strategy: Bottleneck.strategy.LEAK
1508
+ },
1509
+ feishu: {
1510
+ reservoir: 90,
1511
+ // Leave margin below 100/min
1512
+ reservoirRefreshAmount: 90,
1513
+ reservoirRefreshInterval: 6e4,
1514
+ maxConcurrent: 4,
1515
+ minTime: 250,
1516
+ // ~5/sec = 200ms spacing + margin
1517
+ highWater: 200,
1518
+ strategy: Bottleneck.strategy.LEAK
1519
+ },
1520
+ lark: {
1521
+ reservoir: 90,
1522
+ reservoirRefreshAmount: 90,
1523
+ reservoirRefreshInterval: 6e4,
1524
+ maxConcurrent: 4,
1525
+ minTime: 250,
1526
+ highWater: 200,
1527
+ strategy: Bottleneck.strategy.LEAK
1528
+ },
1529
+ slack: {
1530
+ reservoir: 55,
1531
+ // ~1/sec with margin
1532
+ reservoirRefreshAmount: 55,
1533
+ reservoirRefreshInterval: 6e4,
1534
+ maxConcurrent: 1,
1535
+ minTime: 1100,
1536
+ // 1.1s between messages
1537
+ highWater: 100,
1538
+ strategy: Bottleneck.strategy.LEAK
1539
+ },
1540
+ discord: {
1541
+ reservoir: 4,
1542
+ // Leave margin below 5/2sec
1543
+ reservoirRefreshAmount: 4,
1544
+ reservoirRefreshInterval: 2e3,
1545
+ maxConcurrent: 2,
1546
+ minTime: 500,
1547
+ highWater: 50,
1548
+ strategy: Bottleneck.strategy.LEAK
1549
+ },
1550
+ telegram: {
1551
+ reservoir: 25,
1552
+ // Leave margin below 30/sec
1553
+ reservoirRefreshAmount: 25,
1554
+ reservoirRefreshInterval: 1e3,
1555
+ maxConcurrent: 5,
1556
+ minTime: 40,
1557
+ // ~25/sec
1558
+ highWater: 100,
1559
+ strategy: Bottleneck.strategy.LEAK
1560
+ }
1561
+ };
1562
+ var RETRYABLE_PATTERNS = [
1563
+ "429",
1564
+ "500",
1565
+ "502",
1566
+ "503",
1567
+ "504",
1568
+ "ECONNRESET",
1569
+ "ETIMEDOUT",
1570
+ "ENOTFOUND",
1571
+ "RATE_LIMIT",
1572
+ "Too Many Requests"
1573
+ ];
1574
+ var RateLimiterManager = class {
1575
+ limiters = /* @__PURE__ */ new Map();
1576
+ maxRetries;
1577
+ retryMultiplier;
1578
+ maxRetryDelayMs;
1579
+ constructor(options = {}) {
1580
+ this.maxRetries = options.maxRetries ?? 3;
1581
+ this.retryMultiplier = options.retryMultiplier ?? 2;
1582
+ this.maxRetryDelayMs = options.maxRetryDelayMs ?? 3e4;
1583
+ }
1584
+ /**
1585
+ * Get or create a Bottleneck limiter for a specific bot
1586
+ */
1587
+ getLimiter(botName, platform) {
1588
+ if (this.limiters.has(botName)) {
1589
+ return this.limiters.get(botName);
1590
+ }
1591
+ const platformConfig = PLATFORM_LIMITS[platform] || PLATFORM_LIMITS.telegram;
1592
+ const limiter = new Bottleneck({
1593
+ ...platformConfig,
1594
+ maxExecutionTime: 3e4
1595
+ // 30s timeout per request
1596
+ });
1597
+ limiter.on("failed", async (error, jobInfo) => {
1598
+ const errorMessage = error instanceof Error ? error.message : String(error);
1599
+ const shouldRetry = RETRYABLE_PATTERNS.some(
1600
+ (pattern) => errorMessage.includes(pattern)
1601
+ );
1602
+ if (shouldRetry && jobInfo.retryCount < this.maxRetries) {
1603
+ const delay = Math.min(
1604
+ Math.pow(this.retryMultiplier, jobInfo.retryCount) * 1e3,
1605
+ this.maxRetryDelayMs
1606
+ );
1607
+ logger.warn(`Retry #${jobInfo.retryCount + 1} for ${botName} after ${delay}ms`, {
1608
+ error: errorMessage
1609
+ });
1610
+ return delay;
1611
+ }
1612
+ logger.error(`Max retries exceeded for ${botName}`, {
1613
+ attempts: jobInfo.retryCount + 1,
1614
+ error: errorMessage
1615
+ });
1616
+ });
1617
+ this.limiters.set(botName, limiter);
1618
+ logger.debug(`Created rate limiter for bot: ${botName} (${platform})`);
1619
+ return limiter;
1620
+ }
1621
+ /**
1622
+ * Schedule a task through a bot's rate limiter
1623
+ */
1624
+ async schedule(botName, platform, fn) {
1625
+ const limiter = this.getLimiter(botName, platform);
1626
+ return limiter.schedule(fn);
1627
+ }
1628
+ /**
1629
+ * Get queue stats for a specific bot
1630
+ */
1631
+ getStats(botName) {
1632
+ const limiter = this.limiters.get(botName);
1633
+ if (!limiter) return null;
1634
+ const counts = limiter.counts();
1635
+ return {
1636
+ queued: counts.EXECUTING + counts.QUEUED,
1637
+ running: counts.RUNNING
1638
+ };
1639
+ }
1640
+ /**
1641
+ * Disconnect all limiters (graceful shutdown)
1642
+ */
1643
+ async disconnect() {
1644
+ const promises = Array.from(this.limiters.values()).map(
1645
+ (limiter) => limiter.disconnect()
1646
+ );
1647
+ await Promise.all(promises);
1648
+ this.limiters.clear();
1649
+ logger.info("All rate limiters disconnected");
1650
+ }
1651
+ };
1652
+
1653
+ // src/audit/logger.ts
1654
+ import { appendFileSync, existsSync as existsSync2, mkdirSync } from "fs";
1655
+ import { dirname } from "path";
1656
+ var AuditLogger = class {
1657
+ entries = [];
1658
+ maxSize;
1659
+ filePath;
1660
+ constructor(options = {}) {
1661
+ this.maxSize = options.maxSize ?? 1e3;
1662
+ this.filePath = options.filePath;
1663
+ if (this.filePath) {
1664
+ const dir = dirname(this.filePath);
1665
+ if (!existsSync2(dir)) {
1666
+ mkdirSync(dir, { recursive: true });
1667
+ }
1668
+ }
1669
+ }
1670
+ /**
1671
+ * Record a message send event
1672
+ */
1673
+ log(entry) {
1674
+ this.entries.push(entry);
1675
+ if (this.entries.length > this.maxSize) {
1676
+ this.entries.shift();
1677
+ }
1678
+ if (this.filePath) {
1679
+ try {
1680
+ appendFileSync(this.filePath, JSON.stringify(entry) + "\n", "utf-8");
1681
+ } catch (err) {
1682
+ logger.warn("Failed to write audit log", {
1683
+ error: err instanceof Error ? err.message : String(err)
1684
+ });
1685
+ }
1686
+ }
1687
+ logger.debug("Audit log entry", {
1688
+ id: entry.id,
1689
+ bot: entry.bot,
1690
+ platform: entry.platform,
1691
+ status: entry.status
1692
+ });
1693
+ }
1694
+ /**
1695
+ * Query audit entries with filters
1696
+ */
1697
+ query(filters = {}) {
1698
+ let results = [...this.entries];
1699
+ if (filters.messageId) {
1700
+ results = results.filter((e) => e.messageId === filters.messageId);
1701
+ }
1702
+ if (filters.bot) {
1703
+ results = results.filter((e) => e.bot === filters.bot);
1704
+ }
1705
+ if (filters.platform) {
1706
+ results = results.filter((e) => e.platform === filters.platform);
1707
+ }
1708
+ if (filters.status) {
1709
+ results = results.filter((e) => e.status === filters.status);
1710
+ }
1711
+ if (filters.since) {
1712
+ const sinceTime = new Date(filters.since).getTime();
1713
+ results = results.filter(
1714
+ (e) => new Date(e.timestamp).getTime() >= sinceTime
1715
+ );
1716
+ }
1717
+ if (filters.until) {
1718
+ const untilTime = new Date(filters.until).getTime();
1719
+ results = results.filter(
1720
+ (e) => new Date(e.timestamp).getTime() <= untilTime
1721
+ );
1722
+ }
1723
+ const limit = filters.limit ?? 50;
1724
+ return results.slice(-limit);
1725
+ }
1726
+ /**
1727
+ * Get summary statistics
1728
+ */
1729
+ stats() {
1730
+ const byPlatform = {};
1731
+ const byBot = {};
1732
+ let success = 0;
1733
+ let failed = 0;
1734
+ for (const entry of this.entries) {
1735
+ if (entry.status === "success") success++;
1736
+ else if (entry.status === "failed") failed++;
1737
+ byPlatform[entry.platform] = (byPlatform[entry.platform] || 0) + 1;
1738
+ byBot[entry.bot] = (byBot[entry.bot] || 0) + 1;
1739
+ }
1740
+ return {
1741
+ total: this.entries.length,
1742
+ success,
1743
+ failed,
1744
+ byPlatform,
1745
+ byBot
1746
+ };
1747
+ }
1748
+ /**
1749
+ * Find entry by audit ID
1750
+ */
1751
+ findById(id) {
1752
+ return this.entries.find((e) => e.id === id);
1753
+ }
1754
+ /**
1755
+ * Find entry by message ID
1756
+ */
1757
+ findByMessageId(messageId) {
1758
+ return this.entries.find((e) => e.messageId === messageId);
1759
+ }
1760
+ /**
1761
+ * Clear all in-memory entries
1762
+ */
1763
+ clear() {
1764
+ this.entries = [];
1765
+ }
1766
+ };
1767
+ function hashContent(content, maxLen = 32) {
1768
+ if (content.length <= maxLen) return content;
1769
+ return content.slice(0, maxLen) + "...";
1770
+ }
1771
+
1772
+ // src/templates/engine.ts
1773
+ import Handlebars from "handlebars";
1774
+ var TemplateEngine = class {
1775
+ compiled = /* @__PURE__ */ new Map();
1776
+ hb;
1777
+ constructor() {
1778
+ this.hb = Handlebars.create();
1779
+ this.registerHelpers();
1780
+ }
1781
+ registerHelpers() {
1782
+ this.hb.registerHelper("date", (dateStr, format) => {
1783
+ const d = new Date(dateStr);
1784
+ if (isNaN(d.getTime())) return dateStr;
1785
+ if (format === "iso") return d.toISOString();
1786
+ if (format === "locale") return d.toLocaleString("zh-CN");
1787
+ const pad = (n) => n.toString().padStart(2, "0");
1788
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
1789
+ });
1790
+ this.hb.registerHelper(
1791
+ "uppercase",
1792
+ (str) => typeof str === "string" ? str.toUpperCase() : str
1793
+ );
1794
+ this.hb.registerHelper(
1795
+ "lowercase",
1796
+ (str) => typeof str === "string" ? str.toLowerCase() : str
1797
+ );
1798
+ this.hb.registerHelper(
1799
+ "capitalize",
1800
+ (str) => typeof str === "string" ? str.charAt(0).toUpperCase() + str.slice(1) : str
1801
+ );
1802
+ this.hb.registerHelper(
1803
+ "truncate",
1804
+ (str, maxLen, suffix) => {
1805
+ if (typeof str !== "string") return str;
1806
+ const limit = typeof maxLen === "number" ? maxLen : 100;
1807
+ const end = typeof suffix === "string" ? suffix : "...";
1808
+ return str.length > limit ? str.slice(0, limit) + end : str;
1809
+ }
1810
+ );
1811
+ this.hb.registerHelper(
1812
+ "if_eq",
1813
+ function(a, b, options) {
1814
+ return a === b ? options.fn(this) : options.inverse(this);
1815
+ }
1816
+ );
1817
+ this.hb.registerHelper("json", (obj, indent) => {
1818
+ const spaces = typeof indent === "number" ? indent : 2;
1819
+ return JSON.stringify(obj, null, spaces);
1820
+ });
1821
+ this.hb.registerHelper("now", (format) => {
1822
+ const now = /* @__PURE__ */ new Date();
1823
+ if (format === "iso") return now.toISOString();
1824
+ if (format === "timestamp") return Math.floor(now.getTime() / 1e3).toString();
1825
+ const pad = (n) => n.toString().padStart(2, "0");
1826
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
1827
+ });
1828
+ this.hb.registerHelper(
1829
+ "if_gt",
1830
+ function(a, b, options) {
1831
+ return a > b ? options.fn(this) : options.inverse(this);
1832
+ }
1833
+ );
1834
+ this.hb.registerHelper("join", (arr, separator) => {
1835
+ if (!Array.isArray(arr)) return String(arr || "");
1836
+ const sep = typeof separator === "string" ? separator : ", ";
1837
+ return arr.join(sep);
1838
+ });
1839
+ }
1840
+ /**
1841
+ * Register a partial template for reuse
1842
+ */
1843
+ registerPartial(name, template) {
1844
+ this.hb.registerPartial(name, template);
1845
+ }
1846
+ /**
1847
+ * Compile and cache a template string
1848
+ */
1849
+ compile(templateStr, name) {
1850
+ if (name && this.compiled.has(name)) {
1851
+ return this.compiled.get(name);
1852
+ }
1853
+ const compiled = this.hb.compile(templateStr, { noEscape: true });
1854
+ if (name) this.compiled.set(name, compiled);
1855
+ return compiled;
1856
+ }
1857
+ /**
1858
+ * Render a template string with variables
1859
+ */
1860
+ render(templateStr, variables = {}) {
1861
+ const template = this.compile(templateStr);
1862
+ return template(variables);
1863
+ }
1864
+ /**
1865
+ * Check if a string contains template expressions
1866
+ */
1867
+ hasExpressions(templateStr) {
1868
+ return /\{\{[^}]+\}\}/.test(templateStr);
1869
+ }
1870
+ /**
1871
+ * Extract variable names from a template string
1872
+ */
1873
+ extractVariables(templateStr) {
1874
+ const matches = templateStr.match(/\{\{([^}]+)\}\}/g);
1875
+ if (!matches) return [];
1876
+ return [...new Set(matches.map((m) => m.replace(/\{\{|\}\}/g, "").trim()))];
1877
+ }
1878
+ /**
1879
+ * Clear the compiled template cache
1880
+ */
1881
+ clearCache() {
1882
+ this.compiled.clear();
1883
+ }
1884
+ };
1885
+ var templateEngine = new TemplateEngine();
1886
+
1887
+ // src/tools/send-message.ts
1888
+ function createSendMessageHandler(registry, rateLimiter, auditLogger, dryRun) {
1889
+ return async (args) => {
1890
+ const adapter = registry.get(args.bot);
1891
+ let content = args.content;
1892
+ if (args.variables && templateEngine.hasExpressions(content)) {
1893
+ content = templateEngine.render(content, args.variables);
1894
+ }
1895
+ let title = args.title;
1896
+ if (title && args.variables && templateEngine.hasExpressions(title)) {
1897
+ title = templateEngine.render(title, args.variables);
1898
+ }
1899
+ const sendOptions = {
1900
+ type: args.type,
1901
+ content,
1902
+ title,
1903
+ at: args.at,
1904
+ linkUrl: args.link_url,
1905
+ picUrl: args.pic_url,
1906
+ buttons: args.buttons,
1907
+ singleButtonText: args.single_button_text,
1908
+ singleButtonUrl: args.single_button_url,
1909
+ newsItems: args.news_items,
1910
+ imageUrl: args.image_url,
1911
+ imageBase64: args.image_base64
1912
+ };
1913
+ if (dryRun) {
1914
+ const payload = adapter.buildPayload(sendOptions);
1915
+ const auditId2 = `dry_${Date.now().toString(36)}`;
1916
+ auditLogger.log({
1917
+ id: auditId2,
1918
+ bot: args.bot,
1919
+ platform: adapter.platformName,
1920
+ type: args.type,
1921
+ contentHash: hashContent(content),
1922
+ status: "success",
1923
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1924
+ latencyMs: 0
1925
+ });
1926
+ return {
1927
+ dry_run: true,
1928
+ bot: args.bot,
1929
+ platform: adapter.platformName,
1930
+ payload,
1931
+ rendered_content: content,
1932
+ audit_id: auditId2
1933
+ };
1934
+ }
1935
+ const platform = adapter.platformName;
1936
+ const result = await rateLimiter.schedule(
1937
+ args.bot,
1938
+ platform,
1939
+ () => adapter.send(sendOptions)
1940
+ );
1941
+ const auditId = result.messageId || `msg_${Date.now().toString(36)}`;
1942
+ auditLogger.log({
1943
+ id: auditId,
1944
+ messageId: result.messageId,
1945
+ bot: args.bot,
1946
+ platform: result.platform,
1947
+ type: args.type,
1948
+ contentHash: hashContent(content),
1949
+ status: result.success ? "success" : "failed",
1950
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1951
+ latencyMs: result.latencyMs,
1952
+ error: result.error
1953
+ });
1954
+ return {
1955
+ success: result.success,
1956
+ bot: args.bot,
1957
+ platform: result.platform,
1958
+ message_id: result.messageId,
1959
+ latency_ms: result.latencyMs,
1960
+ error: result.error,
1961
+ audit_id: auditId
1962
+ };
1963
+ };
1964
+ }
1965
+
1966
+ // src/tools/send-file.ts
1967
+ function createSendFileHandler(registry, rateLimiter, auditLogger, dryRun) {
1968
+ return async (args) => {
1969
+ const adapter = registry.get(args.bot);
1970
+ if (dryRun) {
1971
+ const auditId2 = `dry_${Date.now().toString(36)}`;
1972
+ auditLogger.log({
1973
+ id: auditId2,
1974
+ bot: args.bot,
1975
+ platform: adapter.platformName,
1976
+ type: "file",
1977
+ contentHash: hashContent(args.file_path),
1978
+ status: "success",
1979
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1980
+ latencyMs: 0
1981
+ });
1982
+ return {
1983
+ dry_run: true,
1984
+ bot: args.bot,
1985
+ platform: adapter.platformName,
1986
+ file_path: args.file_path,
1987
+ audit_id: auditId2
1988
+ };
1989
+ }
1990
+ const platform = adapter.platformName;
1991
+ const result = await rateLimiter.schedule(
1992
+ args.bot,
1993
+ platform,
1994
+ () => adapter.sendFile(args.file_path, args.caption)
1995
+ );
1996
+ const auditId = result.messageId || `msg_${Date.now().toString(36)}`;
1997
+ auditLogger.log({
1998
+ id: auditId,
1999
+ messageId: result.messageId,
2000
+ bot: args.bot,
2001
+ platform: result.platform,
2002
+ type: "file",
2003
+ contentHash: hashContent(args.file_path),
2004
+ status: result.success ? "success" : "failed",
2005
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2006
+ latencyMs: result.latencyMs,
2007
+ error: result.error
2008
+ });
2009
+ return {
2010
+ success: result.success,
2011
+ bot: args.bot,
2012
+ platform: result.platform,
2013
+ message_id: result.messageId,
2014
+ latency_ms: result.latencyMs,
2015
+ error: result.error,
2016
+ audit_id: auditId
2017
+ };
2018
+ };
2019
+ }
2020
+
2021
+ // src/tools/send-broadcast.ts
2022
+ function createSendBroadcastHandler(registry, rateLimiter, auditLogger, dryRun) {
2023
+ return async (args) => {
2024
+ let content = args.content;
2025
+ if (args.variables && templateEngine.hasExpressions(content)) {
2026
+ content = templateEngine.render(content, args.variables);
2027
+ }
2028
+ let title = args.title;
2029
+ if (title && args.variables && templateEngine.hasExpressions(title)) {
2030
+ title = templateEngine.render(title, args.variables);
2031
+ }
2032
+ const sendOptions = {
2033
+ type: args.type,
2034
+ content,
2035
+ title,
2036
+ at: args.at,
2037
+ linkUrl: args.link_url,
2038
+ picUrl: args.pic_url,
2039
+ buttons: args.buttons,
2040
+ newsItems: args.news_items,
2041
+ imageUrl: args.image_url
2042
+ };
2043
+ const results = await Promise.allSettled(
2044
+ args.bots.map(async (botName) => {
2045
+ const adapter = registry.get(botName);
2046
+ const platform = adapter.platformName;
2047
+ if (dryRun) {
2048
+ return {
2049
+ bot: botName,
2050
+ platform: adapter.platformName,
2051
+ dry_run: true,
2052
+ payload: adapter.buildPayload(sendOptions)
2053
+ };
2054
+ }
2055
+ const result = await rateLimiter.schedule(
2056
+ botName,
2057
+ platform,
2058
+ () => adapter.send(sendOptions)
2059
+ );
2060
+ const auditId = result.messageId || `msg_${Date.now().toString(36)}`;
2061
+ auditLogger.log({
2062
+ id: auditId,
2063
+ messageId: result.messageId,
2064
+ bot: botName,
2065
+ platform: result.platform,
2066
+ type: args.type,
2067
+ contentHash: hashContent(content),
2068
+ status: result.success ? "success" : "failed",
2069
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2070
+ latencyMs: result.latencyMs,
2071
+ error: result.error
2072
+ });
2073
+ return {
2074
+ bot: botName,
2075
+ success: result.success,
2076
+ platform: result.platform,
2077
+ message_id: result.messageId,
2078
+ latency_ms: result.latencyMs,
2079
+ error: result.error,
2080
+ audit_id: auditId
2081
+ };
2082
+ })
2083
+ );
2084
+ const outcomes = results.map((r, i) => {
2085
+ if (r.status === "fulfilled") return r.value;
2086
+ return {
2087
+ bot: args.bots[i],
2088
+ success: false,
2089
+ error: r.reason instanceof Error ? r.reason.message : String(r.reason)
2090
+ };
2091
+ });
2092
+ const successCount = outcomes.filter((o) => o.success || o.dry_run).length;
2093
+ const failCount = outcomes.length - successCount;
2094
+ return {
2095
+ total: outcomes.length,
2096
+ success: successCount,
2097
+ failed: failCount,
2098
+ results: outcomes
2099
+ };
2100
+ };
2101
+ }
2102
+
2103
+ // src/tools/send-batch.ts
2104
+ function createSendBatchHandler(registry, rateLimiter, auditLogger, dryRun) {
2105
+ return async (args) => {
2106
+ const adapter = registry.get(args.bot);
2107
+ let content = args.content;
2108
+ if (args.variables && templateEngine.hasExpressions(content)) {
2109
+ content = templateEngine.render(content, args.variables);
2110
+ }
2111
+ const sendOptions = {
2112
+ type: args.type,
2113
+ content,
2114
+ title: args.title,
2115
+ at: args.at,
2116
+ linkUrl: args.link_url,
2117
+ picUrl: args.pic_url,
2118
+ buttons: args.buttons,
2119
+ newsItems: args.news_items,
2120
+ imageUrl: args.image_url
2121
+ };
2122
+ const results = await Promise.allSettled(
2123
+ args.targets.map(async (target) => {
2124
+ if (dryRun) {
2125
+ return {
2126
+ target,
2127
+ dry_run: true,
2128
+ platform: adapter.platformName
2129
+ };
2130
+ }
2131
+ const platform = adapter.platformName;
2132
+ const result = await rateLimiter.schedule(
2133
+ args.bot,
2134
+ platform,
2135
+ () => adapter.send(sendOptions)
2136
+ );
2137
+ const auditId = result.messageId || `msg_${Date.now().toString(36)}`;
2138
+ auditLogger.log({
2139
+ id: auditId,
2140
+ messageId: result.messageId,
2141
+ bot: args.bot,
2142
+ platform: result.platform,
2143
+ type: args.type,
2144
+ contentHash: hashContent(content),
2145
+ status: result.success ? "success" : "failed",
2146
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2147
+ latencyMs: result.latencyMs,
2148
+ target,
2149
+ error: result.error
2150
+ });
2151
+ return {
2152
+ target,
2153
+ success: result.success,
2154
+ message_id: result.messageId,
2155
+ latency_ms: result.latencyMs,
2156
+ error: result.error,
2157
+ audit_id: auditId
2158
+ };
2159
+ })
2160
+ );
2161
+ const outcomes = results.map((r, i) => {
2162
+ if (r.status === "fulfilled") return r.value;
2163
+ return {
2164
+ target: args.targets[i],
2165
+ success: false,
2166
+ error: r.reason instanceof Error ? r.reason.message : String(r.reason)
2167
+ };
2168
+ });
2169
+ const successCount = outcomes.filter((o) => o.success || o.dry_run).length;
2170
+ return {
2171
+ bot: args.bot,
2172
+ total: outcomes.length,
2173
+ success: successCount,
2174
+ failed: outcomes.length - successCount,
2175
+ results: outcomes
2176
+ };
2177
+ };
2178
+ }
2179
+
2180
+ // src/tools/list-bots.ts
2181
+ function createListBotsHandler(registry, rateLimiter) {
2182
+ return async () => {
2183
+ const bots = registry.list();
2184
+ const detailed = bots.map((bot) => {
2185
+ const queueStats = rateLimiter.getStats(bot.name);
2186
+ return {
2187
+ name: bot.name,
2188
+ platform: bot.platform,
2189
+ enabled: bot.enabled,
2190
+ queue: queueStats || { queued: 0, running: 0 }
2191
+ };
2192
+ });
2193
+ return {
2194
+ total: detailed.length,
2195
+ bots: detailed,
2196
+ platforms: [...new Set(detailed.map((b) => b.platform))]
2197
+ };
2198
+ };
2199
+ }
2200
+
2201
+ // src/tools/message-status.ts
2202
+ function createMessageStatusHandler(auditLogger) {
2203
+ return async (args) => {
2204
+ if (args.audit_id) {
2205
+ const entry = auditLogger.findById(args.audit_id);
2206
+ if (!entry) {
2207
+ return { found: false, message: `Audit entry "${args.audit_id}" not found` };
2208
+ }
2209
+ return { found: true, entry };
2210
+ }
2211
+ if (args.message_id) {
2212
+ const entry = auditLogger.findByMessageId(args.message_id);
2213
+ if (!entry) {
2214
+ return { found: false, message: `Message "${args.message_id}" not found in audit log` };
2215
+ }
2216
+ return { found: true, entry };
2217
+ }
2218
+ const entries = auditLogger.query({
2219
+ bot: args.bot,
2220
+ platform: args.platform,
2221
+ status: args.status,
2222
+ since: args.since,
2223
+ limit: args.limit ?? 20
2224
+ });
2225
+ const stats = auditLogger.stats();
2226
+ return {
2227
+ total_found: entries.length,
2228
+ entries,
2229
+ stats: {
2230
+ total_tracked: stats.total,
2231
+ success: stats.success,
2232
+ failed: stats.failed,
2233
+ by_platform: stats.byPlatform
2234
+ }
2235
+ };
2236
+ };
2237
+ }
2238
+
2239
+ // src/tools/render-template.ts
2240
+ function createRenderTemplateHandler() {
2241
+ return async (args) => {
2242
+ const hasVars = templateEngine.hasExpressions(args.template);
2243
+ const variables = templateEngine.extractVariables(args.template);
2244
+ const rendered = templateEngine.render(args.template, args.variables || {});
2245
+ return {
2246
+ template: args.template,
2247
+ variables_detected: variables,
2248
+ variables_provided: Object.keys(args.variables || {}),
2249
+ rendered,
2250
+ has_expressions: hasVars
2251
+ };
2252
+ };
2253
+ }
2254
+
2255
+ // src/tools/index.ts
2256
+ var toolDefinitions = [
2257
+ {
2258
+ name: "send_message",
2259
+ description: "Send a message to a configured bot. Supports text, markdown, link, image, card, news, and file types. Content can include Handlebars template variables like {{name}}.",
2260
+ inputSchema: {
2261
+ type: "object",
2262
+ properties: {
2263
+ bot: {
2264
+ type: "string",
2265
+ description: "Bot instance name (as configured, e.g. 'dingtalk-ops', 'telegram-bot')"
2266
+ },
2267
+ type: {
2268
+ type: "string",
2269
+ enum: ["text", "markdown", "link", "image", "card", "news", "file"],
2270
+ description: "Message type"
2271
+ },
2272
+ content: {
2273
+ type: "string",
2274
+ description: "Message content. Supports Handlebars templates: {{variable_name}}"
2275
+ },
2276
+ title: {
2277
+ type: "string",
2278
+ description: "Message title (for markdown, link, card types)"
2279
+ },
2280
+ variables: {
2281
+ type: "object",
2282
+ additionalProperties: { type: "string" },
2283
+ description: "Template variables as key-value pairs"
2284
+ },
2285
+ at: {
2286
+ type: "object",
2287
+ properties: {
2288
+ users: { type: "array", items: { type: "string" }, description: "User IDs to mention" },
2289
+ mobiles: { type: "array", items: { type: "string" }, description: "Phone numbers to mention" },
2290
+ all: { type: "boolean", description: "Mention everyone" }
2291
+ },
2292
+ description: "@mention configuration"
2293
+ },
2294
+ link_url: { type: "string", description: "URL for link type messages" },
2295
+ pic_url: { type: "string", description: "Picture/thumbnail URL" },
2296
+ buttons: {
2297
+ type: "array",
2298
+ items: {
2299
+ type: "object",
2300
+ properties: {
2301
+ title: { type: "string" },
2302
+ url: { type: "string" }
2303
+ },
2304
+ required: ["title", "url"]
2305
+ },
2306
+ description: "Buttons for card type messages"
2307
+ },
2308
+ single_button_text: { type: "string", description: "Single button text (for actionCard)" },
2309
+ single_button_url: { type: "string", description: "Single button URL" },
2310
+ news_items: {
2311
+ type: "array",
2312
+ items: {
2313
+ type: "object",
2314
+ properties: {
2315
+ title: { type: "string" },
2316
+ description: { type: "string" },
2317
+ url: { type: "string" },
2318
+ pic_url: { type: "string" }
2319
+ },
2320
+ required: ["title", "url"]
2321
+ },
2322
+ description: "News/feed items for news type messages"
2323
+ },
2324
+ image_url: { type: "string", description: "Image URL for image type messages" },
2325
+ image_base64: { type: "string", description: "Base64-encoded image data" }
2326
+ },
2327
+ required: ["bot", "type", "content"]
2328
+ }
2329
+ },
2330
+ {
2331
+ name: "send_file",
2332
+ description: "Send a file or image through a configured bot. Supported platforms: WeCom (files via media_id), Discord (multipart upload), Telegram (sendDocument).",
2333
+ inputSchema: {
2334
+ type: "object",
2335
+ properties: {
2336
+ bot: { type: "string", description: "Bot instance name" },
2337
+ file_path: { type: "string", description: "Absolute path to the file to send" },
2338
+ caption: { type: "string", description: "Optional caption for the file" }
2339
+ },
2340
+ required: ["bot", "file_path"]
2341
+ }
2342
+ },
2343
+ {
2344
+ name: "send_broadcast",
2345
+ description: "Send the same message to multiple bots simultaneously (cross-platform broadcast). Useful for sending alerts to DingTalk + WeCom + Slack at the same time.",
2346
+ inputSchema: {
2347
+ type: "object",
2348
+ properties: {
2349
+ bots: {
2350
+ type: "array",
2351
+ items: { type: "string" },
2352
+ description: "List of bot instance names to broadcast to"
2353
+ },
2354
+ type: {
2355
+ type: "string",
2356
+ enum: ["text", "markdown", "link", "image", "card", "news", "file"],
2357
+ description: "Message type"
2358
+ },
2359
+ content: { type: "string", description: "Message content (supports Handlebars templates)" },
2360
+ title: { type: "string", description: "Message title" },
2361
+ variables: {
2362
+ type: "object",
2363
+ additionalProperties: { type: "string" },
2364
+ description: "Template variables"
2365
+ },
2366
+ at: {
2367
+ type: "object",
2368
+ properties: {
2369
+ users: { type: "array", items: { type: "string" } },
2370
+ mobiles: { type: "array", items: { type: "string" } },
2371
+ all: { type: "boolean" }
2372
+ }
2373
+ },
2374
+ link_url: { type: "string" },
2375
+ pic_url: { type: "string" },
2376
+ buttons: {
2377
+ type: "array",
2378
+ items: {
2379
+ type: "object",
2380
+ properties: { title: { type: "string" }, url: { type: "string" } },
2381
+ required: ["title", "url"]
2382
+ }
2383
+ },
2384
+ news_items: {
2385
+ type: "array",
2386
+ items: {
2387
+ type: "object",
2388
+ properties: {
2389
+ title: { type: "string" },
2390
+ description: { type: "string" },
2391
+ url: { type: "string" },
2392
+ pic_url: { type: "string" }
2393
+ },
2394
+ required: ["title", "url"]
2395
+ }
2396
+ },
2397
+ image_url: { type: "string" }
2398
+ },
2399
+ required: ["bots", "type", "content"]
2400
+ }
2401
+ },
2402
+ {
2403
+ name: "send_batch",
2404
+ description: "Send the same message to multiple targets through the same bot. Useful for Telegram (multiple chat_ids) or broadcasting within one platform.",
2405
+ inputSchema: {
2406
+ type: "object",
2407
+ properties: {
2408
+ bot: { type: "string", description: "Bot instance name" },
2409
+ targets: {
2410
+ type: "array",
2411
+ items: { type: "string" },
2412
+ description: "List of target IDs (e.g. Telegram chat_ids)"
2413
+ },
2414
+ type: {
2415
+ type: "string",
2416
+ enum: ["text", "markdown", "link", "image", "card", "news", "file"],
2417
+ description: "Message type"
2418
+ },
2419
+ content: { type: "string", description: "Message content" },
2420
+ title: { type: "string" },
2421
+ variables: {
2422
+ type: "object",
2423
+ additionalProperties: { type: "string" }
2424
+ },
2425
+ at: {
2426
+ type: "object",
2427
+ properties: {
2428
+ users: { type: "array", items: { type: "string" } },
2429
+ mobiles: { type: "array", items: { type: "string" } },
2430
+ all: { type: "boolean" }
2431
+ }
2432
+ },
2433
+ link_url: { type: "string" },
2434
+ pic_url: { type: "string" },
2435
+ buttons: {
2436
+ type: "array",
2437
+ items: {
2438
+ type: "object",
2439
+ properties: { title: { type: "string" }, url: { type: "string" } },
2440
+ required: ["title", "url"]
2441
+ }
2442
+ },
2443
+ news_items: {
2444
+ type: "array",
2445
+ items: {
2446
+ type: "object",
2447
+ properties: {
2448
+ title: { type: "string" },
2449
+ description: { type: "string" },
2450
+ url: { type: "string" },
2451
+ pic_url: { type: "string" }
2452
+ },
2453
+ required: ["title", "url"]
2454
+ }
2455
+ },
2456
+ image_url: { type: "string" }
2457
+ },
2458
+ required: ["bot", "targets", "type", "content"]
2459
+ }
2460
+ },
2461
+ {
2462
+ name: "list_bots",
2463
+ description: "List all configured bot instances with their platform, status, and queue information.",
2464
+ inputSchema: {
2465
+ type: "object",
2466
+ properties: {}
2467
+ }
2468
+ },
2469
+ {
2470
+ name: "message_status",
2471
+ description: "Query the audit log for message delivery status. Look up by message_id, audit_id, or filter by bot/platform/status/time range.",
2472
+ inputSchema: {
2473
+ type: "object",
2474
+ properties: {
2475
+ message_id: { type: "string", description: "Message ID to look up" },
2476
+ audit_id: { type: "string", description: "Audit log entry ID" },
2477
+ bot: { type: "string", description: "Filter by bot name" },
2478
+ platform: { type: "string", description: "Filter by platform" },
2479
+ status: { type: "string", enum: ["success", "failed"], description: "Filter by status" },
2480
+ since: { type: "string", description: "ISO timestamp to filter entries after" },
2481
+ limit: { type: "number", description: "Maximum entries to return (default: 20)" }
2482
+ }
2483
+ }
2484
+ },
2485
+ {
2486
+ name: "render_template",
2487
+ description: "Preview a Handlebars template rendering without sending. Shows detected variables and rendered output.",
2488
+ inputSchema: {
2489
+ type: "object",
2490
+ properties: {
2491
+ template: {
2492
+ type: "string",
2493
+ description: "Handlebars template string (e.g. 'Hello {{name}}, status: {{status}}')"
2494
+ },
2495
+ variables: {
2496
+ type: "object",
2497
+ additionalProperties: { type: "string" },
2498
+ description: "Variables to substitute"
2499
+ }
2500
+ },
2501
+ required: ["template"]
2502
+ }
2503
+ }
2504
+ ];
2505
+ function createToolHandlers(registry, rateLimiter, auditLogger, dryRun) {
2506
+ return {
2507
+ send_message: createSendMessageHandler(registry, rateLimiter, auditLogger, dryRun),
2508
+ send_file: createSendFileHandler(registry, rateLimiter, auditLogger, dryRun),
2509
+ send_broadcast: createSendBroadcastHandler(registry, rateLimiter, auditLogger, dryRun),
2510
+ send_batch: createSendBatchHandler(registry, rateLimiter, auditLogger, dryRun),
2511
+ list_bots: createListBotsHandler(registry, rateLimiter),
2512
+ message_status: createMessageStatusHandler(auditLogger),
2513
+ render_template: createRenderTemplateHandler()
2514
+ };
2515
+ }
2516
+
2517
+ // src/index.ts
2518
+ var VERSION = "0.1.0";
2519
+ async function main() {
2520
+ logger.info("botmsg starting...", { version: VERSION });
2521
+ const config = loadConfig();
2522
+ logger.setLevel(config.settings.log_level);
2523
+ if (Object.keys(config.bots).length === 0) {
2524
+ logger.warn("No bots configured. Use env vars or a config file. See README for details.");
2525
+ }
2526
+ const registry = new PlatformRegistry();
2527
+ registry.loadConfig(config);
2528
+ const rateLimiter = new RateLimiterManager({
2529
+ maxRetries: config.settings.max_retries
2530
+ });
2531
+ const auditLogger = new AuditLogger({
2532
+ filePath: config.settings.audit_log
2533
+ });
2534
+ const dryRun = config.settings.dry_run;
2535
+ if (dryRun) {
2536
+ logger.info("DRY RUN mode enabled - no messages will be sent");
2537
+ }
2538
+ const handlers = createToolHandlers(registry, rateLimiter, auditLogger, dryRun);
2539
+ const server = new Server(
2540
+ { name: "botmsg", version: VERSION },
2541
+ { capabilities: { tools: {} } }
2542
+ );
2543
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2544
+ tools: toolDefinitions.map((td) => ({
2545
+ name: td.name,
2546
+ description: td.description,
2547
+ inputSchema: td.inputSchema
2548
+ }))
2549
+ }));
2550
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2551
+ const { name, arguments: args } = request.params;
2552
+ const startTime = Date.now();
2553
+ try {
2554
+ logger.debug(`Tool call: ${name}`, { args: JSON.stringify(args).slice(0, 200) });
2555
+ const handler = handlers[name];
2556
+ if (!handler) {
2557
+ return {
2558
+ content: [{ type: "text", text: `Error: Unknown tool "${name}"` }],
2559
+ isError: true
2560
+ };
2561
+ }
2562
+ const result = await handler(args || {});
2563
+ const latencyMs = Date.now() - startTime;
2564
+ logger.info(`Tool ${name} completed`, { latencyMs });
2565
+ return {
2566
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
2567
+ };
2568
+ } catch (error) {
2569
+ const latencyMs = Date.now() - startTime;
2570
+ let message;
2571
+ if (error instanceof BotMsgError) {
2572
+ message = `[${error.code}] ${error.message}`;
2573
+ if (error.platform) message = `[${error.platform}] ${message}`;
2574
+ } else if (error instanceof Error) {
2575
+ message = error.message;
2576
+ } else {
2577
+ message = "An unexpected error occurred";
2578
+ }
2579
+ logger.error(`Tool ${name} failed`, { latencyMs, error: message });
2580
+ return {
2581
+ content: [{ type: "text", text: `Error: ${message}` }],
2582
+ isError: true
2583
+ };
2584
+ }
2585
+ });
2586
+ const transport = new StdioServerTransport();
2587
+ await server.connect(transport);
2588
+ logger.info("botmsg MCP server running on stdio", {
2589
+ bots: Object.keys(config.bots).length,
2590
+ dryRun
2591
+ });
2592
+ const shutdown = async (signal) => {
2593
+ logger.info(`Received ${signal}, shutting down...`);
2594
+ await rateLimiter.disconnect();
2595
+ await server.close();
2596
+ process.exit(0);
2597
+ };
2598
+ process.on("SIGINT", () => shutdown("SIGINT"));
2599
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
2600
+ process.on("uncaughtException", (error) => {
2601
+ logger.error("Uncaught exception", { error: error.message, stack: error.stack });
2602
+ process.exit(1);
2603
+ });
2604
+ process.on("unhandledRejection", (reason) => {
2605
+ logger.error("Unhandled rejection", {
2606
+ reason: reason instanceof Error ? reason.message : String(reason)
2607
+ });
2608
+ process.exit(1);
2609
+ });
2610
+ }
2611
+ main().catch((error) => {
2612
+ logger.error("Fatal error during startup", {
2613
+ error: error instanceof Error ? error.message : String(error)
2614
+ });
2615
+ process.exit(1);
2616
+ });
2617
+ //# sourceMappingURL=index.js.map