@0xmaxma/claude-gateway 1.0.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.
Files changed (192) hide show
  1. package/README.md +791 -0
  2. package/config.template.json +87 -0
  3. package/dist/agent/context-isolation.d.ts +28 -0
  4. package/dist/agent/context-isolation.d.ts.map +1 -0
  5. package/dist/agent/context-isolation.js +80 -0
  6. package/dist/agent/context-isolation.js.map +1 -0
  7. package/dist/agent/runner.d.ts +211 -0
  8. package/dist/agent/runner.d.ts.map +1 -0
  9. package/dist/agent/runner.js +1802 -0
  10. package/dist/agent/runner.js.map +1 -0
  11. package/dist/agent/workspace-loader.d.ts +31 -0
  12. package/dist/agent/workspace-loader.d.ts.map +1 -0
  13. package/dist/agent/workspace-loader.js +207 -0
  14. package/dist/agent/workspace-loader.js.map +1 -0
  15. package/dist/api/auth.d.ts +25 -0
  16. package/dist/api/auth.d.ts.map +1 -0
  17. package/dist/api/auth.js +77 -0
  18. package/dist/api/auth.js.map +1 -0
  19. package/dist/api/cron-router.d.ts +22 -0
  20. package/dist/api/cron-router.d.ts.map +1 -0
  21. package/dist/api/cron-router.js +197 -0
  22. package/dist/api/cron-router.js.map +1 -0
  23. package/dist/api/gateway-router.d.ts +52 -0
  24. package/dist/api/gateway-router.d.ts.map +1 -0
  25. package/dist/api/gateway-router.js +215 -0
  26. package/dist/api/gateway-router.js.map +1 -0
  27. package/dist/api/router.d.ts +5 -0
  28. package/dist/api/router.d.ts.map +1 -0
  29. package/dist/api/router.js +1098 -0
  30. package/dist/api/router.js.map +1 -0
  31. package/dist/api/skills-router.d.ts +5 -0
  32. package/dist/api/skills-router.d.ts.map +1 -0
  33. package/dist/api/skills-router.js +424 -0
  34. package/dist/api/skills-router.js.map +1 -0
  35. package/dist/api/workspace-router.d.ts +4 -0
  36. package/dist/api/workspace-router.d.ts.map +1 -0
  37. package/dist/api/workspace-router.js +149 -0
  38. package/dist/api/workspace-router.js.map +1 -0
  39. package/dist/config/loader.d.ts +16 -0
  40. package/dist/config/loader.d.ts.map +1 -0
  41. package/dist/config/loader.js +235 -0
  42. package/dist/config/loader.js.map +1 -0
  43. package/dist/config/migrator.d.ts +70 -0
  44. package/dist/config/migrator.d.ts.map +1 -0
  45. package/dist/config/migrator.js +343 -0
  46. package/dist/config/migrator.js.map +1 -0
  47. package/dist/config/watcher.d.ts +30 -0
  48. package/dist/config/watcher.d.ts.map +1 -0
  49. package/dist/config/watcher.js +170 -0
  50. package/dist/config/watcher.js.map +1 -0
  51. package/dist/cron/manager.d.ts +48 -0
  52. package/dist/cron/manager.d.ts.map +1 -0
  53. package/dist/cron/manager.js +580 -0
  54. package/dist/cron/manager.js.map +1 -0
  55. package/dist/cron/scheduler.d.ts +50 -0
  56. package/dist/cron/scheduler.d.ts.map +1 -0
  57. package/dist/cron/scheduler.js +231 -0
  58. package/dist/cron/scheduler.js.map +1 -0
  59. package/dist/discord/receiver.d.ts +17 -0
  60. package/dist/discord/receiver.d.ts.map +1 -0
  61. package/dist/discord/receiver.js +108 -0
  62. package/dist/discord/receiver.js.map +1 -0
  63. package/dist/heartbeat/history.d.ts +24 -0
  64. package/dist/heartbeat/history.d.ts.map +1 -0
  65. package/dist/heartbeat/history.js +50 -0
  66. package/dist/heartbeat/history.js.map +1 -0
  67. package/dist/heartbeat/parser.d.ts +26 -0
  68. package/dist/heartbeat/parser.d.ts.map +1 -0
  69. package/dist/heartbeat/parser.js +283 -0
  70. package/dist/heartbeat/parser.js.map +1 -0
  71. package/dist/history/cleanup.d.ts +21 -0
  72. package/dist/history/cleanup.d.ts.map +1 -0
  73. package/dist/history/cleanup.js +171 -0
  74. package/dist/history/cleanup.js.map +1 -0
  75. package/dist/history/db.d.ts +27 -0
  76. package/dist/history/db.d.ts.map +1 -0
  77. package/dist/history/db.js +323 -0
  78. package/dist/history/db.js.map +1 -0
  79. package/dist/history/media-store.d.ts +27 -0
  80. package/dist/history/media-store.d.ts.map +1 -0
  81. package/dist/history/media-store.js +158 -0
  82. package/dist/history/media-store.js.map +1 -0
  83. package/dist/history/types.d.ts +62 -0
  84. package/dist/history/types.d.ts.map +1 -0
  85. package/dist/history/types.js +3 -0
  86. package/dist/history/types.js.map +1 -0
  87. package/dist/index.d.ts +3 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +483 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/logger.d.ts +3 -0
  92. package/dist/logger.d.ts.map +1 -0
  93. package/dist/logger.js +81 -0
  94. package/dist/logger.js.map +1 -0
  95. package/dist/memory/manager.d.ts +45 -0
  96. package/dist/memory/manager.d.ts.map +1 -0
  97. package/dist/memory/manager.js +178 -0
  98. package/dist/memory/manager.js.map +1 -0
  99. package/dist/session/compactor.d.ts +22 -0
  100. package/dist/session/compactor.d.ts.map +1 -0
  101. package/dist/session/compactor.js +151 -0
  102. package/dist/session/compactor.js.map +1 -0
  103. package/dist/session/process.d.ts +76 -0
  104. package/dist/session/process.d.ts.map +1 -0
  105. package/dist/session/process.js +729 -0
  106. package/dist/session/process.js.map +1 -0
  107. package/dist/session/store.d.ts +102 -0
  108. package/dist/session/store.d.ts.map +1 -0
  109. package/dist/session/store.js +533 -0
  110. package/dist/session/store.js.map +1 -0
  111. package/dist/skills/index.d.ts +9 -0
  112. package/dist/skills/index.d.ts.map +1 -0
  113. package/dist/skills/index.js +15 -0
  114. package/dist/skills/index.js.map +1 -0
  115. package/dist/skills/invoker.d.ts +16 -0
  116. package/dist/skills/invoker.d.ts.map +1 -0
  117. package/dist/skills/invoker.js +36 -0
  118. package/dist/skills/invoker.js.map +1 -0
  119. package/dist/skills/loader.d.ts +23 -0
  120. package/dist/skills/loader.d.ts.map +1 -0
  121. package/dist/skills/loader.js +183 -0
  122. package/dist/skills/loader.js.map +1 -0
  123. package/dist/skills/parser.d.ts +55 -0
  124. package/dist/skills/parser.d.ts.map +1 -0
  125. package/dist/skills/parser.js +178 -0
  126. package/dist/skills/parser.js.map +1 -0
  127. package/dist/skills/sync.d.ts +10 -0
  128. package/dist/skills/sync.d.ts.map +1 -0
  129. package/dist/skills/sync.js +130 -0
  130. package/dist/skills/sync.js.map +1 -0
  131. package/dist/skills/watcher.d.ts +16 -0
  132. package/dist/skills/watcher.d.ts.map +1 -0
  133. package/dist/skills/watcher.js +25 -0
  134. package/dist/skills/watcher.js.map +1 -0
  135. package/dist/telegram/markdown.d.ts +25 -0
  136. package/dist/telegram/markdown.d.ts.map +1 -0
  137. package/dist/telegram/markdown.js +237 -0
  138. package/dist/telegram/markdown.js.map +1 -0
  139. package/dist/telegram/receiver.d.ts +17 -0
  140. package/dist/telegram/receiver.d.ts.map +1 -0
  141. package/dist/telegram/receiver.js +104 -0
  142. package/dist/telegram/receiver.js.map +1 -0
  143. package/dist/types.d.ts +224 -0
  144. package/dist/types.d.ts.map +1 -0
  145. package/dist/types.js +3 -0
  146. package/dist/types.js.map +1 -0
  147. package/dist/ui/web-ui.d.ts +6 -0
  148. package/dist/ui/web-ui.d.ts.map +1 -0
  149. package/dist/ui/web-ui.js +227 -0
  150. package/dist/ui/web-ui.js.map +1 -0
  151. package/dist/watch/factory.d.ts +26 -0
  152. package/dist/watch/factory.d.ts.map +1 -0
  153. package/dist/watch/factory.js +43 -0
  154. package/dist/watch/factory.js.map +1 -0
  155. package/dist/webhook/manager.d.ts +34 -0
  156. package/dist/webhook/manager.d.ts.map +1 -0
  157. package/dist/webhook/manager.js +120 -0
  158. package/dist/webhook/manager.js.map +1 -0
  159. package/mcp/bun.lock +264 -0
  160. package/mcp/channel-manager.ts +115 -0
  161. package/mcp/package.json +15 -0
  162. package/mcp/router.ts +69 -0
  163. package/mcp/server.ts +129 -0
  164. package/mcp/tools/agent/handlers.ts +578 -0
  165. package/mcp/tools/agent/module.ts +163 -0
  166. package/mcp/tools/browser/module.ts +321 -0
  167. package/mcp/tools/browser/skills/open-browser/SKILL.md +89 -0
  168. package/mcp/tools/cron/client.ts +65 -0
  169. package/mcp/tools/cron/module.ts +158 -0
  170. package/mcp/tools/cron/skills/cron/SKILL.md +46 -0
  171. package/mcp/tools/discord/access.ts +197 -0
  172. package/mcp/tools/discord/client.ts +31 -0
  173. package/mcp/tools/discord/commands.ts +48 -0
  174. package/mcp/tools/discord/inbound.ts +53 -0
  175. package/mcp/tools/discord/module.ts +442 -0
  176. package/mcp/tools/discord/outbound.ts +90 -0
  177. package/mcp/tools/discord/receiver-server.ts +58 -0
  178. package/mcp/tools/discord/skills/access/SKILL.md +86 -0
  179. package/mcp/tools/discord/skills/configure/SKILL.md +53 -0
  180. package/mcp/tools/discord/threading.ts +37 -0
  181. package/mcp/tools/discord/types.ts +106 -0
  182. package/mcp/tools/skills/handlers.ts +196 -0
  183. package/mcp/tools/skills/module.ts +163 -0
  184. package/mcp/tools/telegram/module.ts +454 -0
  185. package/mcp/tools/telegram/pure.ts +224 -0
  186. package/mcp/tools/telegram/receiver-server.ts +1656 -0
  187. package/mcp/tools/telegram/skills/access/SKILL.md +169 -0
  188. package/mcp/tools/telegram/skills/configure/SKILL.md +118 -0
  189. package/mcp/tools/telegram/typing.ts +327 -0
  190. package/mcp/types.ts +128 -0
  191. package/package.json +62 -0
  192. package/resource/claude_gateway.svg +112 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Telegram channel module — implements ChannelModule interface.
3
+ * Wraps the existing Telegram bot logic with prefixed tool names.
4
+ */
5
+
6
+ import * as path from 'path';
7
+ import * as fs from 'fs';
8
+ import * as os from 'os';
9
+ import type {
10
+ ChannelModule,
11
+ ChannelCapabilities,
12
+ ChannelAccountSnapshot,
13
+ McpToolDefinition,
14
+ McpToolResult,
15
+ ToolVisibility,
16
+ InboundMessageHandler,
17
+ ChannelId,
18
+ } from '../../types';
19
+
20
+ const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
21
+ const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;
22
+ const MAX_CHUNK_LIMIT = 4096;
23
+
24
+ export class TelegramModule implements ChannelModule {
25
+ id = 'telegram' as ChannelId;
26
+ toolVisibility: ToolVisibility = 'current-channel';
27
+ skillsDir = path.join(__dirname, 'skills');
28
+
29
+ capabilities: ChannelCapabilities = {
30
+ typingIndicator: true,
31
+ reactions: true,
32
+ editMessage: true,
33
+ fileAttachment: true,
34
+ threadReply: true,
35
+ maxMessageLength: 4096,
36
+ markupFormat: 'html',
37
+ };
38
+
39
+ private bot: any = null;
40
+ private botUsername = '';
41
+ private stateDir: string;
42
+ private inboxDir: string;
43
+ private typingDir: string;
44
+ private running = false;
45
+ private lastMessageAt?: number;
46
+ private lastError?: string;
47
+ private _hasMarkdown?: (text: string) => boolean;
48
+ private _toTelegramHtml?: (text: string) => string;
49
+ private _InputFile?: any;
50
+ private _typingManager?: any;
51
+
52
+ constructor() {
53
+ this.stateDir = process.env.TELEGRAM_STATE_DIR
54
+ ?? path.join(os.homedir(), '.claude', 'channels', 'telegram');
55
+ this.inboxDir = path.join(this.stateDir, 'inbox');
56
+ this.typingDir = path.join(this.stateDir, 'typing');
57
+ }
58
+
59
+ isEnabled(): boolean {
60
+ return Boolean(this.getToken());
61
+ }
62
+
63
+ private getToken(): string | undefined {
64
+ if (process.env.TELEGRAM_BOT_TOKEN) return process.env.TELEGRAM_BOT_TOKEN;
65
+ // Standalone fallback: read from .env file
66
+ const envFile = path.join(this.stateDir, '.env');
67
+ try {
68
+ for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
69
+ const m = line.match(/^TELEGRAM_BOT_TOKEN=(.*)$/);
70
+ if (m) return m[1];
71
+ }
72
+ } catch {}
73
+ return undefined;
74
+ }
75
+
76
+ getTools(): McpToolDefinition[] {
77
+ return [
78
+ {
79
+ name: 'telegram_reply',
80
+ description:
81
+ 'Reply on Telegram. Pass chat_id from the inbound message. Optionally pass reply_to (message_id) for threading, and files (absolute paths) to attach images or documents.',
82
+ inputSchema: {
83
+ type: 'object',
84
+ properties: {
85
+ chat_id: { type: 'string' },
86
+ text: { type: 'string' },
87
+ reply_to: {
88
+ type: 'string',
89
+ description: 'Message ID to thread under. Use message_id from the inbound <channel> block.',
90
+ },
91
+ files: {
92
+ type: 'array',
93
+ items: { type: 'string' },
94
+ description: 'Absolute file paths to attach. Images send as photos (inline preview); other types as documents. Max 50MB each.',
95
+ },
96
+ format: {
97
+ type: 'string',
98
+ enum: ['text', 'html'],
99
+ description: "Rendering mode. 'html' enables Telegram formatting (bold, italic, code, links). Caller must escape special chars per HTML rules (&amp; &lt; &gt;). Default: 'text' (plain, no escaping needed).",
100
+ },
101
+ },
102
+ required: ['chat_id', 'text'],
103
+ },
104
+ },
105
+ {
106
+ name: 'telegram_react',
107
+ description: 'Add an emoji reaction to a Telegram message. Telegram only accepts a fixed whitelist (👍 👎 ❤ 🔥 👀 🎉 etc) — non-whitelisted emoji will be rejected.',
108
+ inputSchema: {
109
+ type: 'object',
110
+ properties: {
111
+ chat_id: { type: 'string' },
112
+ message_id: { type: 'string' },
113
+ emoji: { type: 'string' },
114
+ },
115
+ required: ['chat_id', 'message_id', 'emoji'],
116
+ },
117
+ },
118
+ {
119
+ name: 'telegram_download_attachment',
120
+ description: 'Download a file attachment from a Telegram message to the local inbox. Use when the inbound <channel> meta shows attachment_file_id. Returns the local file path ready to Read. Telegram caps bot downloads at 20MB.',
121
+ inputSchema: {
122
+ type: 'object',
123
+ properties: {
124
+ file_id: { type: 'string', description: 'The attachment_file_id from inbound meta' },
125
+ },
126
+ required: ['file_id'],
127
+ },
128
+ },
129
+ {
130
+ name: 'telegram_edit_message',
131
+ description: "Edit a message the bot previously sent. Useful for interim progress updates. Edits don't trigger push notifications — send a new reply when a long task completes so the user's device pings.",
132
+ inputSchema: {
133
+ type: 'object',
134
+ properties: {
135
+ chat_id: { type: 'string' },
136
+ message_id: { type: 'string' },
137
+ text: { type: 'string' },
138
+ format: {
139
+ type: 'string',
140
+ enum: ['text', 'html'],
141
+ description: "Rendering mode. 'html' enables Telegram formatting (bold, italic, code, links). Caller must escape special chars per HTML rules (&amp; &lt; &gt;). Default: 'text' (plain, no escaping needed).",
142
+ },
143
+ },
144
+ required: ['chat_id', 'message_id', 'text'],
145
+ },
146
+ },
147
+ ];
148
+ }
149
+
150
+ async handleTool(name: string, args: Record<string, unknown>): Promise<McpToolResult> {
151
+ if (!this.bot) {
152
+ return { content: [{ type: 'text', text: 'Telegram bot not initialized' }], isError: true };
153
+ }
154
+
155
+ try {
156
+ switch (name) {
157
+ case 'telegram_reply':
158
+ return await this.handleReply(args);
159
+ case 'telegram_react':
160
+ return await this.handleReact(args);
161
+ case 'telegram_download_attachment':
162
+ return await this.handleDownload(args);
163
+ case 'telegram_edit_message':
164
+ return await this.handleEditMessage(args);
165
+ default:
166
+ return { content: [{ type: 'text', text: `unknown tool: ${name}` }], isError: true };
167
+ }
168
+ } catch (err) {
169
+ const msg = err instanceof Error ? err.message : String(err);
170
+ return { content: [{ type: 'text', text: `${name} failed: ${msg}` }], isError: true };
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Initialize bot and tool helpers. Called by server.ts before MCP connect
176
+ * to ensure tools work immediately. Does NOT start polling or block.
177
+ */
178
+ async initBot(): Promise<void> {
179
+ const token = this.getToken();
180
+ if (!token) throw new Error('TELEGRAM_BOT_TOKEN not configured');
181
+
182
+ // Dynamic import grammy (only available in bun runtime)
183
+ // @ts-ignore — grammy is installed in plugin's node_modules, not root
184
+ const { Bot, InputFile } = await import('grammy');
185
+ this.bot = new Bot(token, {
186
+ client: { apiRoot: process.env.TELEGRAM_API_ROOT ?? 'https://api.telegram.org' },
187
+ });
188
+
189
+ // Import pure functions and typing manager
190
+ const { hasMarkdown, toTelegramHtml } = await import('./pure');
191
+ const { createWorkingStateManager } = await import('./typing');
192
+
193
+ const typingManager = createWorkingStateManager(
194
+ this.typingDir,
195
+ {
196
+ sendChatAction: (chatId: string, action: string) => this.bot.api.sendChatAction(chatId, action),
197
+ sendMessage: (chatId: string, text: string, opts?: any) => this.bot.api.sendMessage(chatId, text, opts),
198
+ editMessageText: (chatId: string, msgId: number, text: string) => this.bot.api.editMessageText(chatId, msgId, text),
199
+ deleteMessage: (chatId: string, msgId: number) => this.bot.api.deleteMessage(chatId, msgId),
200
+ setMessageReaction: (chatId: string, msgId: number, emoji: string) =>
201
+ this.bot.api.setMessageReaction(chatId, msgId, [
202
+ { type: 'emoji', emoji },
203
+ ]),
204
+ },
205
+ { ...fs },
206
+ );
207
+
208
+ this._hasMarkdown = hasMarkdown;
209
+ this._toTelegramHtml = toTelegramHtml;
210
+ this._InputFile = InputFile;
211
+ this._typingManager = typingManager;
212
+
213
+ this.running = true;
214
+ }
215
+
216
+ async start(handler: InboundMessageHandler, signal: AbortSignal): Promise<void> {
217
+ if (!this.bot) {
218
+ await this.initBot();
219
+ }
220
+
221
+ const SEND_ONLY = process.env.TELEGRAM_SEND_ONLY === 'true';
222
+
223
+ // Handle abort signal
224
+ signal.addEventListener('abort', () => {
225
+ this.running = false;
226
+ void Promise.resolve(this.bot.stop()).catch(() => {});
227
+ });
228
+
229
+ // @ts-ignore — GrammyError needed for polling
230
+ const { GrammyError } = await import('grammy');
231
+
232
+ if (SEND_ONLY) {
233
+ // In SEND_ONLY mode, just keep alive — no polling
234
+ await new Promise<void>((resolve) => {
235
+ signal.addEventListener('abort', () => resolve());
236
+ });
237
+ } else {
238
+ await this.startPolling(GrammyError);
239
+ }
240
+ }
241
+
242
+ getSnapshot(): ChannelAccountSnapshot {
243
+ return {
244
+ accountId: this.id,
245
+ running: this.running,
246
+ configured: this.isEnabled(),
247
+ lastMessageAt: this.lastMessageAt,
248
+ lastError: this.lastError,
249
+ };
250
+ }
251
+
252
+ // --- Private helpers ---
253
+
254
+ private readAccessFile(): any {
255
+ const accessFile = path.join(this.stateDir, 'access.json');
256
+ try {
257
+ const raw = fs.readFileSync(accessFile, 'utf8');
258
+ const parsed = JSON.parse(raw);
259
+ return {
260
+ dmPolicy: parsed.dmPolicy ?? 'pairing',
261
+ allowFrom: parsed.allowFrom ?? [],
262
+ groups: parsed.groups ?? {},
263
+ pending: parsed.pending ?? {},
264
+ mentionPatterns: parsed.mentionPatterns,
265
+ ackReaction: parsed.ackReaction,
266
+ replyToMode: parsed.replyToMode,
267
+ textChunkLimit: parsed.textChunkLimit,
268
+ chunkMode: parsed.chunkMode,
269
+ };
270
+ } catch (err) {
271
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
272
+ return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {} };
273
+ }
274
+ return { dmPolicy: 'pairing', allowFrom: [], groups: {}, pending: {} };
275
+ }
276
+ }
277
+
278
+ private assertAllowedChat(chat_id: string): void {
279
+ const access = this.readAccessFile();
280
+ if (access.allowFrom.includes(chat_id)) return;
281
+ if (chat_id in access.groups) return;
282
+ throw new Error(`chat ${chat_id} is not allowlisted`);
283
+ }
284
+
285
+ private assertSendable(f: string): void {
286
+ let real: string, stateReal: string;
287
+ try {
288
+ real = fs.realpathSync(f);
289
+ stateReal = fs.realpathSync(this.stateDir);
290
+ } catch { return; }
291
+ const inbox = path.join(stateReal, 'inbox');
292
+ if (real.startsWith(stateReal + path.sep) && !real.startsWith(inbox + path.sep)) {
293
+ throw new Error(`refusing to send channel state: ${f}`);
294
+ }
295
+ }
296
+
297
+ private chunk(text: string, limit: number, mode: 'length' | 'newline'): string[] {
298
+ if (text.length <= limit) return [text];
299
+ const out: string[] = [];
300
+ let rest = text;
301
+ while (rest.length > limit) {
302
+ let cut = limit;
303
+ if (mode === 'newline') {
304
+ const para = rest.lastIndexOf('\n\n', limit);
305
+ const line = rest.lastIndexOf('\n', limit);
306
+ const space = rest.lastIndexOf(' ', limit);
307
+ cut = para > limit / 2 ? para : line > limit / 2 ? line : space > 0 ? space : limit;
308
+ }
309
+ out.push(rest.slice(0, cut));
310
+ rest = rest.slice(cut).replace(/^\n+/, '');
311
+ }
312
+ if (rest) out.push(rest);
313
+ return out;
314
+ }
315
+
316
+ private async handleReply(args: Record<string, unknown>): Promise<McpToolResult> {
317
+ const chat_id = args.chat_id as string;
318
+ const text = args.text as string;
319
+ const reply_to = args.reply_to != null ? Number(args.reply_to) : undefined;
320
+ const files = (args.files as string[] | undefined) ?? [];
321
+ const explicitFormat = args.format as string | undefined;
322
+
323
+ const hasMarkdown = this._hasMarkdown!;
324
+ const toTelegramHtml = this._toTelegramHtml!;
325
+ const InputFile = this._InputFile;
326
+
327
+ const useHtml = explicitFormat === 'html' || (!explicitFormat && hasMarkdown(text));
328
+ const sendText = useHtml && !explicitFormat ? toTelegramHtml(text) : text;
329
+ const parseMode = useHtml ? 'HTML' as const : undefined;
330
+
331
+ this.assertAllowedChat(chat_id);
332
+
333
+ for (const f of files) {
334
+ this.assertSendable(f);
335
+ const st = fs.statSync(f);
336
+ if (st.size > MAX_ATTACHMENT_BYTES) {
337
+ throw new Error(`file too large: ${f} (${(st.size / 1024 / 1024).toFixed(1)}MB, max 50MB)`);
338
+ }
339
+ }
340
+
341
+ const access = this.readAccessFile();
342
+ const limit = Math.max(1, Math.min(access.textChunkLimit ?? MAX_CHUNK_LIMIT, MAX_CHUNK_LIMIT));
343
+ const mode = access.chunkMode ?? 'length';
344
+ const replyMode = access.replyToMode ?? 'first';
345
+ const chunks = this.chunk(sendText, limit, mode);
346
+ const sentIds: number[] = [];
347
+
348
+ try {
349
+ for (let i = 0; i < chunks.length; i++) {
350
+ const shouldReplyTo =
351
+ reply_to != null &&
352
+ replyMode !== 'off' &&
353
+ (replyMode === 'all' || i === 0);
354
+ const sent = await this.bot.api.sendMessage(chat_id, chunks[i], {
355
+ ...(shouldReplyTo ? { reply_parameters: { message_id: reply_to } } : {}),
356
+ ...(parseMode ? { parse_mode: parseMode } : {}),
357
+ });
358
+ sentIds.push(sent.message_id);
359
+ }
360
+ } catch (err) {
361
+ const msg = err instanceof Error ? err.message : String(err);
362
+ throw new Error(`reply failed after ${sentIds.length} of ${chunks.length} chunk(s) sent: ${msg}`);
363
+ }
364
+
365
+ for (const f of files) {
366
+ const ext = path.extname(f).toLowerCase();
367
+ const input = new InputFile(f);
368
+ const opts = reply_to != null && replyMode !== 'off'
369
+ ? { reply_parameters: { message_id: reply_to } }
370
+ : undefined;
371
+ if (PHOTO_EXTS.has(ext)) {
372
+ const sent = await this.bot.api.sendPhoto(chat_id, input, opts);
373
+ sentIds.push(sent.message_id);
374
+ } else {
375
+ const sent = await this.bot.api.sendDocument(chat_id, input, opts);
376
+ sentIds.push(sent.message_id);
377
+ }
378
+ }
379
+
380
+ // Write .replied marker
381
+ try {
382
+ fs.mkdirSync(this.typingDir, { recursive: true });
383
+ fs.writeFileSync(path.join(this.typingDir, `${chat_id}.replied`), sendText);
384
+ } catch {}
385
+
386
+ const result = sentIds.length === 1
387
+ ? `sent (id: ${sentIds[0]})`
388
+ : `sent ${sentIds.length} parts (ids: ${sentIds.join(', ')})`;
389
+
390
+ return { content: [{ type: 'text', text: result }] };
391
+ }
392
+
393
+ private async handleReact(args: Record<string, unknown>): Promise<McpToolResult> {
394
+ this.assertAllowedChat(args.chat_id as string);
395
+ await this.bot.api.setMessageReaction(args.chat_id as string, Number(args.message_id), [
396
+ { type: 'emoji', emoji: args.emoji },
397
+ ]);
398
+ return { content: [{ type: 'text', text: 'reacted' }] };
399
+ }
400
+
401
+ private async handleDownload(args: Record<string, unknown>): Promise<McpToolResult> {
402
+ const file_id = args.file_id as string;
403
+ const token = this.getToken()!;
404
+ const file = await this.bot.api.getFile(file_id);
405
+ if (!file.file_path) throw new Error('Telegram returned no file_path — file may have expired');
406
+ const apiRoot = process.env.TELEGRAM_API_ROOT ?? 'https://api.telegram.org';
407
+ const url = `${apiRoot}/file/bot${token}/${file.file_path}`;
408
+ const res = await fetch(url);
409
+ if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
410
+ const buf = Buffer.from(await res.arrayBuffer());
411
+ const rawExt = file.file_path.includes('.') ? file.file_path.split('.').pop()! : 'bin';
412
+ const ext = rawExt.replace(/[^a-zA-Z0-9]/g, '') || 'bin';
413
+ const uniqueId = (file.file_unique_id ?? '').replace(/[^a-zA-Z0-9_-]/g, '') || 'dl';
414
+ const dlPath = path.join(this.inboxDir, `${Date.now()}-${uniqueId}.${ext}`);
415
+ fs.mkdirSync(this.inboxDir, { recursive: true });
416
+ fs.writeFileSync(dlPath, buf);
417
+ return { content: [{ type: 'text', text: dlPath }] };
418
+ }
419
+
420
+ private async handleEditMessage(args: Record<string, unknown>): Promise<McpToolResult> {
421
+ this.assertAllowedChat(args.chat_id as string);
422
+ const editFormat = (args.format as string | undefined) ?? 'text';
423
+ const editParseMode = editFormat === 'html' ? 'HTML' as const : undefined;
424
+ const edited = await this.bot.api.editMessageText(
425
+ args.chat_id as string,
426
+ Number(args.message_id),
427
+ args.text as string,
428
+ ...(editParseMode ? [{ parse_mode: editParseMode }] : []),
429
+ );
430
+ const id = typeof edited === 'object' ? edited.message_id : args.message_id;
431
+ return { content: [{ type: 'text', text: `edited (id: ${id})` }] };
432
+ }
433
+
434
+ private async startPolling(GrammyError: any): Promise<void> {
435
+ for (let attempt = 1; ; attempt++) {
436
+ try {
437
+ await this.bot.start({
438
+ onStart: (info: any) => {
439
+ this.botUsername = info.username;
440
+ },
441
+ });
442
+ return;
443
+ } catch (err: any) {
444
+ if (err instanceof GrammyError && err.error_code === 409) {
445
+ const delay = Math.min(1000 * attempt, 15000);
446
+ await new Promise(r => setTimeout(r, delay));
447
+ continue;
448
+ }
449
+ if (err instanceof Error && err.message === 'Aborted delay') return;
450
+ throw err;
451
+ }
452
+ }
453
+ }
454
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Pure functions extracted from server.ts for unit testing.
3
+ * These functions have no Grammy/MCP dependencies.
4
+ */
5
+
6
+ export type PendingEntry = {
7
+ senderId: string
8
+ chatId: string
9
+ createdAt: number
10
+ expiresAt: number
11
+ replies: number
12
+ }
13
+
14
+ export type GroupPolicy = {
15
+ requireMention: boolean
16
+ allowFrom: string[]
17
+ }
18
+
19
+ export type Access = {
20
+ dmPolicy: 'pairing' | 'allowlist' | 'disabled'
21
+ allowFrom: string[]
22
+ groups: Record<string, GroupPolicy>
23
+ pending: Record<string, PendingEntry>
24
+ mentionPatterns?: string[]
25
+ ackReaction?: string
26
+ replyToMode?: 'off' | 'first' | 'all'
27
+ textChunkLimit?: number
28
+ chunkMode?: 'length' | 'newline'
29
+ }
30
+
31
+ export function defaultAccess(): Access {
32
+ return {
33
+ dmPolicy: 'pairing',
34
+ allowFrom: [],
35
+ groups: {},
36
+ pending: {},
37
+ }
38
+ }
39
+
40
+ export const MAX_CHUNK_LIMIT = 4096
41
+
42
+ export function pruneExpired(a: Access, now?: number): boolean {
43
+ const ts = now ?? Date.now()
44
+ let changed = false
45
+ for (const [code, p] of Object.entries(a.pending)) {
46
+ if (p.expiresAt < ts) {
47
+ delete a.pending[code]
48
+ changed = true
49
+ }
50
+ }
51
+ return changed
52
+ }
53
+
54
+ export function chunk(text: string, limit: number, mode: 'length' | 'newline'): string[] {
55
+ if (text.length <= limit) return [text]
56
+ const out: string[] = []
57
+ let rest = text
58
+ while (rest.length > limit) {
59
+ let cut = limit
60
+ if (mode === 'newline') {
61
+ const para = rest.lastIndexOf('\n\n', limit)
62
+ const line = rest.lastIndexOf('\n', limit)
63
+ const space = rest.lastIndexOf(' ', limit)
64
+ cut = para > limit / 2 ? para : line > limit / 2 ? line : space > 0 ? space : limit
65
+ }
66
+ out.push(rest.slice(0, cut))
67
+ rest = rest.slice(cut).replace(/^\n+/, '')
68
+ }
69
+ if (rest) out.push(rest)
70
+ return out
71
+ }
72
+
73
+ import {
74
+ readFileSync,
75
+ writeFileSync,
76
+ mkdirSync,
77
+ renameSync,
78
+ } from 'fs'
79
+ import { join } from 'path'
80
+
81
+ export function readAccessFile(accessFile: string): Access {
82
+ try {
83
+ const raw = readFileSync(accessFile, 'utf8')
84
+ const parsed = JSON.parse(raw) as Partial<Access>
85
+ return {
86
+ dmPolicy: parsed.dmPolicy ?? 'pairing',
87
+ allowFrom: parsed.allowFrom ?? [],
88
+ groups: parsed.groups ?? {},
89
+ pending: parsed.pending ?? {},
90
+ mentionPatterns: parsed.mentionPatterns,
91
+ ackReaction: parsed.ackReaction,
92
+ replyToMode: parsed.replyToMode,
93
+ textChunkLimit: parsed.textChunkLimit,
94
+ chunkMode: parsed.chunkMode,
95
+ }
96
+ } catch (err) {
97
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess()
98
+ try {
99
+ renameSync(accessFile, `${accessFile}.corrupt-${Date.now()}`)
100
+ } catch {}
101
+ return defaultAccess()
102
+ }
103
+ }
104
+
105
+ export function saveAccess(stateDir: string, a: Access): void {
106
+ const accessFile = join(stateDir, 'access.json')
107
+ mkdirSync(stateDir, { recursive: true })
108
+ const tmp = accessFile + '.tmp'
109
+ writeFileSync(tmp, JSON.stringify(a, null, 2) + '\n', { mode: 0o600 })
110
+ renameSync(tmp, accessFile)
111
+ }
112
+
113
+ export type GateInput = {
114
+ fromId?: string
115
+ chatType?: string
116
+ chatId?: string
117
+ botUsername?: string
118
+ replyToUsername?: string
119
+ messageText?: string
120
+ messageEntities?: Array<{ type: string; offset: number; length: number; user?: { is_bot: boolean; username?: string } }>
121
+ captionEntities?: Array<{ type: string; offset: number; length: number; user?: { is_bot: boolean; username?: string } }>
122
+ }
123
+
124
+ export type GateResult =
125
+ | { action: 'deliver'; access: Access }
126
+ | { action: 'drop' }
127
+ | { action: 'pair'; code: string; isResend: boolean }
128
+
129
+ /**
130
+ * Pure gate logic (for testing without Grammy Context).
131
+ * Caller must provide readAccess and saveAccess functions,
132
+ * plus a code generator.
133
+ */
134
+ export function gateLogic(
135
+ input: GateInput,
136
+ loadAccess: () => Access,
137
+ saveAccessFn: (a: Access) => void,
138
+ generateCode: () => string,
139
+ now?: number,
140
+ ): GateResult {
141
+ const access = loadAccess()
142
+ const pruned = pruneExpired(access, now)
143
+ if (pruned) saveAccessFn(access)
144
+
145
+ if (access.dmPolicy === 'disabled') return { action: 'drop' }
146
+
147
+ if (!input.fromId) return { action: 'drop' }
148
+ const senderId = input.fromId
149
+ const chatType = input.chatType
150
+
151
+ if (chatType === 'private') {
152
+ if (access.allowFrom.includes(senderId)) return { action: 'deliver', access }
153
+ if (access.dmPolicy === 'allowlist') return { action: 'drop' }
154
+
155
+ // pairing mode
156
+ for (const [code, p] of Object.entries(access.pending)) {
157
+ if (p.senderId === senderId) {
158
+ if ((p.replies ?? 1) >= 2) return { action: 'drop' }
159
+ p.replies = (p.replies ?? 1) + 1
160
+ saveAccessFn(access)
161
+ return { action: 'pair', code, isResend: true }
162
+ }
163
+ }
164
+ if (Object.keys(access.pending).length >= 3) return { action: 'drop' }
165
+
166
+ const code = generateCode()
167
+ const ts = now ?? Date.now()
168
+ access.pending[code] = {
169
+ senderId,
170
+ chatId: input.chatId ?? senderId,
171
+ createdAt: ts,
172
+ expiresAt: ts + 60 * 60 * 1000,
173
+ replies: 1,
174
+ }
175
+ saveAccessFn(access)
176
+ return { action: 'pair', code, isResend: false }
177
+ }
178
+
179
+ if (chatType === 'group' || chatType === 'supergroup') {
180
+ const groupId = input.chatId ?? ''
181
+ const policy = access.groups[groupId]
182
+ if (!policy) return { action: 'drop' }
183
+ const groupAllowFrom = policy.allowFrom ?? []
184
+ const requireMention = policy.requireMention ?? true
185
+ if (groupAllowFrom.length > 0 && !groupAllowFrom.includes(senderId)) {
186
+ return { action: 'drop' }
187
+ }
188
+ if (requireMention && !isMentionedPure(input, access.mentionPatterns)) {
189
+ return { action: 'drop' }
190
+ }
191
+ return { action: 'deliver', access }
192
+ }
193
+
194
+ return { action: 'drop' }
195
+ }
196
+
197
+ export { hasMarkdown, toTelegramHtml } from '../../../src/telegram/markdown'
198
+
199
+ export function isMentionedPure(input: GateInput, extraPatterns?: string[]): boolean {
200
+ const entities = input.messageEntities ?? input.captionEntities ?? []
201
+ const text = input.messageText ?? ''
202
+ const botUsername = input.botUsername ?? ''
203
+
204
+ for (const e of entities) {
205
+ if (e.type === 'mention') {
206
+ const mentioned = text.slice(e.offset, e.offset + e.length)
207
+ if (mentioned.toLowerCase() === `@${botUsername}`.toLowerCase()) return true
208
+ }
209
+ if (e.type === 'text_mention' && e.user?.is_bot && e.user.username === botUsername) {
210
+ return true
211
+ }
212
+ }
213
+
214
+ if (input.replyToUsername === botUsername) return true
215
+
216
+ for (const pat of extraPatterns ?? []) {
217
+ try {
218
+ if (new RegExp(pat, 'i').test(text)) return true
219
+ } catch {
220
+ // Invalid regex — skip
221
+ }
222
+ }
223
+ return false
224
+ }