@songsid/agend 0.0.1

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 (232) hide show
  1. package/README.md +210 -0
  2. package/README.zh-TW.md +134 -0
  3. package/dist/access-path.d.ts +10 -0
  4. package/dist/access-path.js +32 -0
  5. package/dist/access-path.js.map +1 -0
  6. package/dist/adapter-world.d.ts +25 -0
  7. package/dist/adapter-world.js +41 -0
  8. package/dist/adapter-world.js.map +1 -0
  9. package/dist/agent-cli-instructions.md +50 -0
  10. package/dist/agent-cli.d.ts +2 -0
  11. package/dist/agent-cli.js +200 -0
  12. package/dist/agent-cli.js.map +1 -0
  13. package/dist/agent-endpoint.d.ts +25 -0
  14. package/dist/agent-endpoint.js +162 -0
  15. package/dist/agent-endpoint.js.map +1 -0
  16. package/dist/backend/antigravity.d.ts +17 -0
  17. package/dist/backend/antigravity.js +98 -0
  18. package/dist/backend/antigravity.js.map +1 -0
  19. package/dist/backend/claude-code.d.ts +23 -0
  20. package/dist/backend/claude-code.js +171 -0
  21. package/dist/backend/claude-code.js.map +1 -0
  22. package/dist/backend/codex.d.ts +18 -0
  23. package/dist/backend/codex.js +160 -0
  24. package/dist/backend/codex.js.map +1 -0
  25. package/dist/backend/factory.d.ts +2 -0
  26. package/dist/backend/factory.js +28 -0
  27. package/dist/backend/factory.js.map +1 -0
  28. package/dist/backend/gemini-cli.d.ts +17 -0
  29. package/dist/backend/gemini-cli.js +163 -0
  30. package/dist/backend/gemini-cli.js.map +1 -0
  31. package/dist/backend/index.d.ts +7 -0
  32. package/dist/backend/index.js +7 -0
  33. package/dist/backend/index.js.map +1 -0
  34. package/dist/backend/kiro.d.ts +17 -0
  35. package/dist/backend/kiro.js +147 -0
  36. package/dist/backend/kiro.js.map +1 -0
  37. package/dist/backend/marker-utils.d.ts +13 -0
  38. package/dist/backend/marker-utils.js +64 -0
  39. package/dist/backend/marker-utils.js.map +1 -0
  40. package/dist/backend/mock.d.ts +25 -0
  41. package/dist/backend/mock.js +85 -0
  42. package/dist/backend/mock.js.map +1 -0
  43. package/dist/backend/opencode.d.ts +16 -0
  44. package/dist/backend/opencode.js +136 -0
  45. package/dist/backend/opencode.js.map +1 -0
  46. package/dist/backend/types.d.ts +86 -0
  47. package/dist/backend/types.js +33 -0
  48. package/dist/backend/types.js.map +1 -0
  49. package/dist/channel/access-manager.d.ts +18 -0
  50. package/dist/channel/access-manager.js +153 -0
  51. package/dist/channel/access-manager.js.map +1 -0
  52. package/dist/channel/adapters/telegram.d.ts +63 -0
  53. package/dist/channel/adapters/telegram.js +646 -0
  54. package/dist/channel/adapters/telegram.js.map +1 -0
  55. package/dist/channel/attachment-handler.d.ts +15 -0
  56. package/dist/channel/attachment-handler.js +88 -0
  57. package/dist/channel/attachment-handler.js.map +1 -0
  58. package/dist/channel/factory.d.ts +12 -0
  59. package/dist/channel/factory.js +67 -0
  60. package/dist/channel/factory.js.map +1 -0
  61. package/dist/channel/ipc-bridge.d.ts +26 -0
  62. package/dist/channel/ipc-bridge.js +220 -0
  63. package/dist/channel/ipc-bridge.js.map +1 -0
  64. package/dist/channel/mcp-server.d.ts +10 -0
  65. package/dist/channel/mcp-server.js +288 -0
  66. package/dist/channel/mcp-server.js.map +1 -0
  67. package/dist/channel/mcp-tools.d.ts +17 -0
  68. package/dist/channel/mcp-tools.js +110 -0
  69. package/dist/channel/mcp-tools.js.map +1 -0
  70. package/dist/channel/message-bus.d.ts +17 -0
  71. package/dist/channel/message-bus.js +86 -0
  72. package/dist/channel/message-bus.js.map +1 -0
  73. package/dist/channel/message-queue.d.ts +39 -0
  74. package/dist/channel/message-queue.js +253 -0
  75. package/dist/channel/message-queue.js.map +1 -0
  76. package/dist/channel/tool-router.d.ts +6 -0
  77. package/dist/channel/tool-router.js +75 -0
  78. package/dist/channel/tool-router.js.map +1 -0
  79. package/dist/channel/tool-tracker.d.ts +13 -0
  80. package/dist/channel/tool-tracker.js +58 -0
  81. package/dist/channel/tool-tracker.js.map +1 -0
  82. package/dist/channel/types.d.ts +118 -0
  83. package/dist/channel/types.js +2 -0
  84. package/dist/channel/types.js.map +1 -0
  85. package/dist/chat-export.d.ts +4 -0
  86. package/dist/chat-export.js +91 -0
  87. package/dist/chat-export.js.map +1 -0
  88. package/dist/classic-channel-manager.d.ts +59 -0
  89. package/dist/classic-channel-manager.js +193 -0
  90. package/dist/classic-channel-manager.js.map +1 -0
  91. package/dist/cli.d.ts +2 -0
  92. package/dist/cli.js +1833 -0
  93. package/dist/cli.js.map +1 -0
  94. package/dist/config.d.ts +9 -0
  95. package/dist/config.js +118 -0
  96. package/dist/config.js.map +1 -0
  97. package/dist/context-guardian.d.ts +26 -0
  98. package/dist/context-guardian.js +73 -0
  99. package/dist/context-guardian.js.map +1 -0
  100. package/dist/cost-guard.d.ts +36 -0
  101. package/dist/cost-guard.js +147 -0
  102. package/dist/cost-guard.js.map +1 -0
  103. package/dist/daemon-entry.d.ts +1 -0
  104. package/dist/daemon-entry.js +29 -0
  105. package/dist/daemon-entry.js.map +1 -0
  106. package/dist/daemon.d.ts +152 -0
  107. package/dist/daemon.js +1714 -0
  108. package/dist/daemon.js.map +1 -0
  109. package/dist/daily-summary.d.ts +13 -0
  110. package/dist/daily-summary.js +55 -0
  111. package/dist/daily-summary.js.map +1 -0
  112. package/dist/event-log.d.ts +36 -0
  113. package/dist/event-log.js +100 -0
  114. package/dist/event-log.js.map +1 -0
  115. package/dist/export-import.d.ts +2 -0
  116. package/dist/export-import.js +162 -0
  117. package/dist/export-import.js.map +1 -0
  118. package/dist/fleet-context.d.ts +61 -0
  119. package/dist/fleet-context.js +4 -0
  120. package/dist/fleet-context.js.map +1 -0
  121. package/dist/fleet-dashboard-html.d.ts +6 -0
  122. package/dist/fleet-dashboard-html.js +443 -0
  123. package/dist/fleet-dashboard-html.js.map +1 -0
  124. package/dist/fleet-health-server.d.ts +35 -0
  125. package/dist/fleet-health-server.js +290 -0
  126. package/dist/fleet-health-server.js.map +1 -0
  127. package/dist/fleet-instructions.d.ts +5 -0
  128. package/dist/fleet-instructions.js +161 -0
  129. package/dist/fleet-instructions.js.map +1 -0
  130. package/dist/fleet-manager.d.ts +212 -0
  131. package/dist/fleet-manager.js +3655 -0
  132. package/dist/fleet-manager.js.map +1 -0
  133. package/dist/fleet-rpc-handlers.d.ts +42 -0
  134. package/dist/fleet-rpc-handlers.js +356 -0
  135. package/dist/fleet-rpc-handlers.js.map +1 -0
  136. package/dist/fleet-system-prompt.d.ts +11 -0
  137. package/dist/fleet-system-prompt.js +61 -0
  138. package/dist/fleet-system-prompt.js.map +1 -0
  139. package/dist/general-knowledge/skills.md +177 -0
  140. package/dist/hang-detector.d.ts +16 -0
  141. package/dist/hang-detector.js +53 -0
  142. package/dist/hang-detector.js.map +1 -0
  143. package/dist/index.d.ts +8 -0
  144. package/dist/index.js +6 -0
  145. package/dist/index.js.map +1 -0
  146. package/dist/instance-lifecycle.d.ts +90 -0
  147. package/dist/instance-lifecycle.js +592 -0
  148. package/dist/instance-lifecycle.js.map +1 -0
  149. package/dist/instructions.d.ts +15 -0
  150. package/dist/instructions.js +90 -0
  151. package/dist/instructions.js.map +1 -0
  152. package/dist/logger.d.ts +7 -0
  153. package/dist/logger.js +84 -0
  154. package/dist/logger.js.map +1 -0
  155. package/dist/outbound-handlers.d.ts +51 -0
  156. package/dist/outbound-handlers.js +739 -0
  157. package/dist/outbound-handlers.js.map +1 -0
  158. package/dist/outbound-schemas.d.ts +238 -0
  159. package/dist/outbound-schemas.js +248 -0
  160. package/dist/outbound-schemas.js.map +1 -0
  161. package/dist/paths.d.ts +10 -0
  162. package/dist/paths.js +42 -0
  163. package/dist/paths.js.map +1 -0
  164. package/dist/plugin/agend/.claude-plugin/plugin.json +5 -0
  165. package/dist/quickstart.d.ts +1 -0
  166. package/dist/quickstart.js +595 -0
  167. package/dist/quickstart.js.map +1 -0
  168. package/dist/routing-engine.d.ts +22 -0
  169. package/dist/routing-engine.js +44 -0
  170. package/dist/routing-engine.js.map +1 -0
  171. package/dist/safe-async.d.ts +6 -0
  172. package/dist/safe-async.js +20 -0
  173. package/dist/safe-async.js.map +1 -0
  174. package/dist/scheduler/db.d.ts +37 -0
  175. package/dist/scheduler/db.js +360 -0
  176. package/dist/scheduler/db.js.map +1 -0
  177. package/dist/scheduler/db.test.d.ts +1 -0
  178. package/dist/scheduler/db.test.js +92 -0
  179. package/dist/scheduler/db.test.js.map +1 -0
  180. package/dist/scheduler/index.d.ts +4 -0
  181. package/dist/scheduler/index.js +4 -0
  182. package/dist/scheduler/index.js.map +1 -0
  183. package/dist/scheduler/scheduler.d.ts +44 -0
  184. package/dist/scheduler/scheduler.js +197 -0
  185. package/dist/scheduler/scheduler.js.map +1 -0
  186. package/dist/scheduler/scheduler.test.d.ts +1 -0
  187. package/dist/scheduler/scheduler.test.js +119 -0
  188. package/dist/scheduler/scheduler.test.js.map +1 -0
  189. package/dist/scheduler/types.d.ts +107 -0
  190. package/dist/scheduler/types.js +7 -0
  191. package/dist/scheduler/types.js.map +1 -0
  192. package/dist/service-installer.d.ts +17 -0
  193. package/dist/service-installer.js +182 -0
  194. package/dist/service-installer.js.map +1 -0
  195. package/dist/setup-wizard.d.ts +48 -0
  196. package/dist/setup-wizard.js +701 -0
  197. package/dist/setup-wizard.js.map +1 -0
  198. package/dist/statusline-watcher.d.ts +34 -0
  199. package/dist/statusline-watcher.js +73 -0
  200. package/dist/statusline-watcher.js.map +1 -0
  201. package/dist/stt.d.ts +10 -0
  202. package/dist/stt.js +33 -0
  203. package/dist/stt.js.map +1 -0
  204. package/dist/tmux-control.d.ts +52 -0
  205. package/dist/tmux-control.js +207 -0
  206. package/dist/tmux-control.js.map +1 -0
  207. package/dist/tmux-manager.d.ts +44 -0
  208. package/dist/tmux-manager.js +218 -0
  209. package/dist/tmux-manager.js.map +1 -0
  210. package/dist/topic-archiver.d.ts +40 -0
  211. package/dist/topic-archiver.js +103 -0
  212. package/dist/topic-archiver.js.map +1 -0
  213. package/dist/topic-commands.d.ts +28 -0
  214. package/dist/topic-commands.js +359 -0
  215. package/dist/topic-commands.js.map +1 -0
  216. package/dist/transcript-monitor.d.ts +23 -0
  217. package/dist/transcript-monitor.js +164 -0
  218. package/dist/transcript-monitor.js.map +1 -0
  219. package/dist/types.d.ts +211 -0
  220. package/dist/types.js +2 -0
  221. package/dist/types.js.map +1 -0
  222. package/dist/ui/dashboard.html +719 -0
  223. package/dist/web-api.d.ts +101 -0
  224. package/dist/web-api.js +648 -0
  225. package/dist/web-api.js.map +1 -0
  226. package/dist/webhook-emitter.d.ts +15 -0
  227. package/dist/webhook-emitter.js +41 -0
  228. package/dist/webhook-emitter.js.map +1 -0
  229. package/dist/workflow-templates/default.md +35 -0
  230. package/package.json +76 -0
  231. package/templates/launchd.plist.ejs +31 -0
  232. package/templates/systemd.service.ejs +16 -0
@@ -0,0 +1,646 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { randomBytes } from "node:crypto";
3
+ import { createReadStream, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
4
+ import { join, extname, basename } from "node:path";
5
+ import { Bot, GrammyError, InputFile } from "grammy";
6
+ import { InlineKeyboard } from "grammy";
7
+ import { MessageQueue } from "../message-queue.js";
8
+ const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"]);
9
+ /** Convert a threadId string to a Telegram message_thread_id number.
10
+ * Returns undefined for null/undefined or for the General topic (id=1),
11
+ * which must not receive message_thread_id in Telegram API calls. */
12
+ function toThreadId(threadId) {
13
+ if (threadId == null || threadId === "1")
14
+ return undefined;
15
+ return Number(threadId);
16
+ }
17
+ /**
18
+ * Whitelist of legitimate Telegram API roots. Misconfiguring `apiRoot`
19
+ * (or having it set by an attacker via a config file write) would leak
20
+ * the bot token — every API call sends the token in the path. Only the
21
+ * official endpoint and loopback (E2E mock servers) are allowed.
22
+ */
23
+ const TELEGRAM_API_HOST_ALLOWLIST = new Set([
24
+ "api.telegram.org",
25
+ "localhost",
26
+ "127.0.0.1",
27
+ "::1",
28
+ ]);
29
+ export function validateTelegramApiRoot(apiRoot) {
30
+ let url;
31
+ try {
32
+ url = new URL(apiRoot);
33
+ }
34
+ catch {
35
+ throw new Error(`Invalid telegram_api_root URL: ${apiRoot}`);
36
+ }
37
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
38
+ throw new Error(`telegram_api_root must use http(s): ${apiRoot}`);
39
+ }
40
+ // http:// only allowed for loopback (mock servers); production must be https.
41
+ // URL.hostname wraps IPv6 in brackets ("[::1]") — strip them for allowlist match.
42
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
43
+ if (!TELEGRAM_API_HOST_ALLOWLIST.has(host)) {
44
+ throw new Error(`telegram_api_root host "${host}" is not in the allowlist ` +
45
+ `(${[...TELEGRAM_API_HOST_ALLOWLIST].join(", ")}). ` +
46
+ `Sending the bot token to an arbitrary host would leak credentials.`);
47
+ }
48
+ if (url.protocol === "http:" && host === "api.telegram.org") {
49
+ throw new Error("telegram_api_root must use https for api.telegram.org");
50
+ }
51
+ }
52
+ export class TelegramAdapter extends EventEmitter {
53
+ type = "telegram";
54
+ topology = "topics";
55
+ id;
56
+ bot;
57
+ accessManager;
58
+ inboxDir;
59
+ apiRoot;
60
+ queue;
61
+ cleanupTimer = null;
62
+ constructor(opts) {
63
+ super();
64
+ this.id = opts.id;
65
+ this.accessManager = opts.accessManager;
66
+ this.inboxDir = opts.inboxDir;
67
+ if (opts.apiRoot)
68
+ validateTelegramApiRoot(opts.apiRoot);
69
+ this.apiRoot = (opts.apiRoot ?? "https://api.telegram.org").replace(/\/+$/, "");
70
+ mkdirSync(this.inboxDir, { recursive: true });
71
+ this.bot = new Bot(opts.botToken, opts.apiRoot ? { client: { apiRoot: opts.apiRoot } } : undefined);
72
+ // Build MessageQueue backed by this bot
73
+ this.queue = new MessageQueue({
74
+ send: async (chatId, threadId, text) => {
75
+ const msg = await this.bot.api.sendMessage(Number(chatId), text, {
76
+ message_thread_id: toThreadId(threadId),
77
+ });
78
+ return { messageId: String(msg.message_id) };
79
+ },
80
+ edit: async (chatId, messageId, text) => {
81
+ await this.bot.api.editMessageText(Number(chatId), Number(messageId), text);
82
+ },
83
+ sendFile: async (chatId, threadId, filePath) => {
84
+ const ext = extname(filePath).toLowerCase();
85
+ const filename = basename(filePath);
86
+ if (IMAGE_EXTENSIONS.has(ext)) {
87
+ const msg = await this.bot.api.sendPhoto(Number(chatId), new InputFile(createReadStream(filePath), filename), { message_thread_id: toThreadId(threadId) });
88
+ return { messageId: String(msg.message_id) };
89
+ }
90
+ else {
91
+ const msg = await this.bot.api.sendDocument(Number(chatId), new InputFile(createReadStream(filePath), filename), { message_thread_id: toThreadId(threadId) });
92
+ return { messageId: String(msg.message_id) };
93
+ }
94
+ },
95
+ });
96
+ this._registerHandlers();
97
+ }
98
+ _registerHandlers() {
99
+ this.bot.on("message", async (ctx) => {
100
+ const msg = ctx.message;
101
+ if (!msg)
102
+ return;
103
+ const userId = msg.from?.id;
104
+ if (userId == null)
105
+ return;
106
+ // Access control
107
+ if (!this.accessManager.isAllowed(userId)) {
108
+ // In pairing mode, allow /pair commands through
109
+ if (msg.text?.startsWith("/pair")) {
110
+ await this._handlePairCommand(ctx);
111
+ return;
112
+ }
113
+ // Allow messages from non-primary chats through for classic mode
114
+ // (classic mode has its own access control in fleet-manager)
115
+ const chatId = String(msg.chat.id);
116
+ if (this.lastChatId && chatId === this.lastChatId)
117
+ return;
118
+ // Non-primary chat: let it through to fleet-manager for classic routing
119
+ }
120
+ // Skip service messages (topic rename, pin, member join/leave, etc.)
121
+ if (this._isServiceMessage(msg))
122
+ return;
123
+ const chatId = String(msg.chat.id);
124
+ const threadId = msg.message_thread_id != null
125
+ ? String(msg.message_thread_id)
126
+ : undefined;
127
+ const messageId = String(msg.message_id);
128
+ const username = msg.from?.username ?? msg.from?.first_name ?? String(userId);
129
+ const text = msg.text ?? msg.caption ?? "";
130
+ // Collect attachments
131
+ const attachments = this._extractAttachments(msg);
132
+ const replyToText = msg.reply_to_message?.text ?? msg.reply_to_message?.caption;
133
+ this.emit("message", {
134
+ source: "telegram",
135
+ adapterId: this.id,
136
+ chatId,
137
+ threadId,
138
+ messageId,
139
+ userId: String(userId),
140
+ username,
141
+ text,
142
+ timestamp: new Date(msg.date * 1000),
143
+ attachments: attachments.length > 0 ? attachments : undefined,
144
+ replyTo: msg.reply_to_message?.message_id != null
145
+ ? String(msg.reply_to_message.message_id)
146
+ : undefined,
147
+ replyToText: replyToText || undefined,
148
+ chatTitle: msg.chat.title || undefined,
149
+ });
150
+ });
151
+ // Handle callback queries from approval inline keyboards and directory browser
152
+ this.bot.on("callback_query:data", async (ctx) => {
153
+ if (!ctx.callbackQuery?.data)
154
+ return;
155
+ await ctx.answerCallbackQuery();
156
+ this.emit("callback_query", {
157
+ callbackData: ctx.callbackQuery.data,
158
+ chatId: String(ctx.callbackQuery.message?.chat.id ?? ""),
159
+ threadId: ctx.callbackQuery.message?.message_thread_id != null
160
+ ? String(ctx.callbackQuery.message.message_thread_id)
161
+ : undefined,
162
+ messageId: String(ctx.callbackQuery.message?.message_id ?? ""),
163
+ });
164
+ });
165
+ // Handle topic closed/deleted events (for auto-unbind)
166
+ this.bot.on("message:forum_topic_closed", (ctx) => {
167
+ const chatId = String(ctx.message?.chat.id ?? "");
168
+ const threadId = ctx.message?.message_thread_id != null
169
+ ? String(ctx.message.message_thread_id)
170
+ : undefined;
171
+ if (threadId) {
172
+ this.emit("topic_closed", { chatId, threadId });
173
+ }
174
+ });
175
+ }
176
+ _extractAttachments(msg) {
177
+ const result = [];
178
+ if (msg.photo && msg.photo.length > 0) {
179
+ const largest = msg.photo[msg.photo.length - 1];
180
+ result.push({ kind: "photo", fileId: largest.file_id, size: largest.file_size });
181
+ }
182
+ if (msg.document) {
183
+ result.push({
184
+ kind: "document",
185
+ fileId: msg.document.file_id,
186
+ mime: msg.document.mime_type,
187
+ size: msg.document.file_size,
188
+ filename: msg.document.file_name,
189
+ });
190
+ }
191
+ if (msg.audio) {
192
+ result.push({
193
+ kind: "audio",
194
+ fileId: msg.audio.file_id,
195
+ mime: msg.audio.mime_type,
196
+ size: msg.audio.file_size,
197
+ });
198
+ }
199
+ if (msg.voice) {
200
+ result.push({
201
+ kind: "voice",
202
+ fileId: msg.voice.file_id,
203
+ mime: msg.voice.mime_type,
204
+ size: msg.voice.file_size,
205
+ });
206
+ }
207
+ if (msg.video) {
208
+ result.push({
209
+ kind: "video",
210
+ fileId: msg.video.file_id,
211
+ mime: msg.video.mime_type,
212
+ size: msg.video.file_size,
213
+ });
214
+ }
215
+ if (msg.sticker) {
216
+ result.push({ kind: "sticker", fileId: msg.sticker.file_id });
217
+ }
218
+ return result;
219
+ }
220
+ /** Returns true if the message is a Telegram service event with no user content. */
221
+ _isServiceMessage(msg) {
222
+ const hasText = !!(msg.text || msg.caption);
223
+ const hasMedia = !!(msg.photo || msg.document || msg.audio || msg.voice || msg.video || msg.sticker || msg.video_note);
224
+ if (hasText || hasMedia)
225
+ return false;
226
+ // If there's no text and no media, it's a service message
227
+ // (e.g. new_chat_title, pinned_message, new_chat_members, left_chat_member,
228
+ // forum_topic_created, forum_topic_edited, etc.)
229
+ return true;
230
+ }
231
+ async _handlePairCommand(ctx) {
232
+ const msg = ctx.message;
233
+ if (!msg || !msg.from)
234
+ return;
235
+ const userId = String(msg.from.id);
236
+ const chatId = String(msg.chat.id);
237
+ try {
238
+ const code = await this.handlePairing(chatId, userId);
239
+ await ctx.reply(`Your pairing code is: \`${code}\`\nShare it with the daemon owner to get access.`, {
240
+ parse_mode: "Markdown",
241
+ });
242
+ }
243
+ catch (err) {
244
+ const message = err instanceof Error ? err.message : String(err);
245
+ await ctx.reply(`Pairing failed: ${message}`);
246
+ }
247
+ }
248
+ // ── ChannelAdapter lifecycle ──────────────────────────────────────────────
249
+ /** Expose bot for fleet manager operations (topic existence checks etc.) */
250
+ getBot() { return this.bot; }
251
+ async start() {
252
+ this.queue.start();
253
+ this._pruneInbox();
254
+ this.cleanupTimer = setInterval(() => this._pruneInbox(), 60 * 60 * 1000);
255
+ // Grammy's default error handler calls bot.stop() on any throw — override
256
+ // to keep polling alive on handler errors
257
+ this.bot.catch((err) => {
258
+ this.emit("handler_error", err.error);
259
+ });
260
+ // 409 Conflict = another getUpdates consumer is active (official plugin zombie,
261
+ // or second Claude Code instance). Retry with backoff, but cap attempts so a
262
+ // permanently-stuck conflict does not loop forever.
263
+ const MAX_409_RETRIES = 30; // ~7 min total at 15s ceiling
264
+ const MAX_RECONNECT_RETRIES = 10; // auto-reconnect on non-409 errors
265
+ void (async () => {
266
+ let reconnects = 0;
267
+ while (true) {
268
+ for (let attempt = 1; attempt <= MAX_409_RETRIES; attempt++) {
269
+ try {
270
+ await this.bot.start({
271
+ drop_pending_updates: attempt === 1 && reconnects === 0,
272
+ onStart: (info) => {
273
+ reconnects = 0; // reset on successful start
274
+ this.emit("started", info.username);
275
+ },
276
+ });
277
+ return; // bot.stop() was called — clean exit
278
+ }
279
+ catch (err) {
280
+ if (err instanceof GrammyError && err.error_code === 409) {
281
+ if (attempt >= MAX_409_RETRIES) {
282
+ this.emit("error", new Error(`Telegram polling: 409 conflict persisted after ${MAX_409_RETRIES} attempts; giving up`));
283
+ return;
284
+ }
285
+ const delay = Math.min(1000 * attempt, 15000);
286
+ this.emit("polling_conflict", { attempt, delay });
287
+ await new Promise(r => setTimeout(r, delay));
288
+ continue;
289
+ }
290
+ if (err instanceof Error && err.message === "Aborted delay")
291
+ return;
292
+ // Auto-reconnect on transient errors (network, timeout, 5xx)
293
+ reconnects++;
294
+ if (reconnects > MAX_RECONNECT_RETRIES) {
295
+ this.emit("error", new Error(`Telegram polling: gave up after ${reconnects} reconnect attempts. Last error: ${err.message}`));
296
+ return;
297
+ }
298
+ console.warn(`[telegram] Polling error (reconnect ${reconnects}/${MAX_RECONNECT_RETRIES}): ${err.message}`);
299
+ await new Promise(r => setTimeout(r, Math.min(5000 * reconnects, 30000)));
300
+ break; // break inner loop to restart bot.start()
301
+ }
302
+ }
303
+ }
304
+ })();
305
+ }
306
+ async stop() {
307
+ if (this.cleanupTimer)
308
+ clearInterval(this.cleanupTimer);
309
+ this.queue.stop();
310
+ await this.bot.stop();
311
+ }
312
+ /** Delete inbox files older than 1 hour */
313
+ _pruneInbox() {
314
+ const maxAge = 60 * 60 * 1000;
315
+ const now = Date.now();
316
+ try {
317
+ for (const name of readdirSync(this.inboxDir)) {
318
+ const filePath = join(this.inboxDir, name);
319
+ try {
320
+ const mtime = statSync(filePath).mtimeMs;
321
+ if (now - mtime > maxAge)
322
+ unlinkSync(filePath);
323
+ }
324
+ catch { /* ignore per-file errors */ }
325
+ }
326
+ }
327
+ catch { /* ignore if dir doesn't exist */ }
328
+ }
329
+ // ── Text / file sending ───────────────────────────────────────────────────
330
+ async sendText(chatId, text, opts) {
331
+ return new Promise((resolve, reject) => {
332
+ // We enqueue and immediately capture the first sent messageId via a one-shot sender
333
+ // For simplicity we use the bot API directly for the first chunk resolution,
334
+ // and delegate subsequent chunks to the queue.
335
+ const threadId = opts?.threadId;
336
+ const chunkLimit = opts?.chunkLimit ?? 4096;
337
+ // Split text manually when caller needs the SentMessage back
338
+ const chunks = [];
339
+ let offset = 0;
340
+ while (offset < text.length) {
341
+ chunks.push(text.slice(offset, offset + chunkLimit));
342
+ offset += chunkLimit;
343
+ }
344
+ if (chunks.length === 0) {
345
+ reject(new Error("Empty text"));
346
+ return;
347
+ }
348
+ // Send first chunk directly to get the messageId; enqueue the rest
349
+ const parseMode = opts?.format === "html" ? "HTML" : undefined;
350
+ this.bot.api
351
+ .sendMessage(Number(chatId), chunks[0], {
352
+ message_thread_id: toThreadId(threadId),
353
+ parse_mode: parseMode,
354
+ })
355
+ .then((msg) => {
356
+ const result = {
357
+ messageId: String(msg.message_id),
358
+ chatId,
359
+ threadId,
360
+ };
361
+ // Enqueue remaining chunks via the queue
362
+ for (let i = 1; i < chunks.length; i++) {
363
+ this.queue.enqueue(chatId, threadId, { type: "content", text: chunks[i] });
364
+ }
365
+ resolve(result);
366
+ })
367
+ .catch(reject);
368
+ });
369
+ }
370
+ async sendFile(chatId, filePath, opts) {
371
+ const threadId = opts?.threadId;
372
+ const ext = extname(filePath).toLowerCase();
373
+ const filename = basename(filePath);
374
+ let messageId;
375
+ if (IMAGE_EXTENSIONS.has(ext)) {
376
+ const msg = await this.bot.api.sendPhoto(Number(chatId), new InputFile(createReadStream(filePath), filename), { message_thread_id: toThreadId(threadId) });
377
+ messageId = String(msg.message_id);
378
+ }
379
+ else {
380
+ const msg = await this.bot.api.sendDocument(Number(chatId), new InputFile(createReadStream(filePath), filename), { message_thread_id: toThreadId(threadId) });
381
+ messageId = String(msg.message_id);
382
+ }
383
+ return { messageId, chatId, threadId };
384
+ }
385
+ async editMessage(chatId, messageId, text) {
386
+ await this.bot.api.editMessageText(Number(chatId), Number(messageId), text);
387
+ }
388
+ async react(chatId, messageId, emoji) {
389
+ await this.bot.api.setMessageReaction(Number(chatId), Number(messageId), [
390
+ { type: "emoji", emoji: emoji },
391
+ ]);
392
+ }
393
+ // ── Approval ─────────────────────────────────────────────────────────────
394
+ async sendApproval(prompt, callback, signal, threadId) {
395
+ const TIMEOUT_SECS = 120;
396
+ const COUNTDOWN_INTERVAL_MS = 30_000;
397
+ const nonce = randomBytes(5).toString("hex");
398
+ const approveData = `approval:approve:${nonce}`;
399
+ const alwaysData = `approval:approve_always:${nonce}`;
400
+ const denyData = `approval:deny:${nonce}`;
401
+ const makeKeyboard = (remainingSecs) => {
402
+ const mm = Math.floor(remainingSecs / 60);
403
+ const ss = remainingSecs % 60;
404
+ const ts = `${mm}:${String(ss).padStart(2, "0")}`;
405
+ return new InlineKeyboard()
406
+ .text(`✅ Allow (${ts})`, approveData)
407
+ .text("✅ Always", alwaysData)
408
+ .text(`❌ Deny`, denyData);
409
+ };
410
+ // Format the permission message
411
+ let text = `⚠️ *Permission Request*\nTool: \`${prompt.tool_name}\``;
412
+ if (prompt.input_preview) {
413
+ const preview = prompt.input_preview.length > 200
414
+ ? prompt.input_preview.slice(0, 200) + "…"
415
+ : prompt.input_preview;
416
+ text += `\n\`\`\`\n${preview}\n\`\`\``;
417
+ }
418
+ else if (prompt.description) {
419
+ text += `\n${prompt.description}`;
420
+ }
421
+ let sentChatId;
422
+ let sentMessageId;
423
+ let countdownTimer;
424
+ const startTime = Date.now();
425
+ const stopCountdown = () => {
426
+ if (countdownTimer) {
427
+ clearInterval(countdownTimer);
428
+ countdownTimer = undefined;
429
+ }
430
+ };
431
+ const cleanup = () => {
432
+ stopCountdown();
433
+ this.off("callback_query", handler);
434
+ };
435
+ const handler = (query) => {
436
+ if (!query.callbackData)
437
+ return;
438
+ const isApprove = query.callbackData === approveData;
439
+ const isAlways = query.callbackData === alwaysData;
440
+ const isDeny = query.callbackData === denyData;
441
+ if (!isApprove && !isAlways && !isDeny)
442
+ return;
443
+ cleanup();
444
+ // Post-decision feedback: update message with decision result
445
+ const editChatId = query.chatId ? Number(query.chatId) : sentChatId;
446
+ const editMsgId = query.messageId ? Number(query.messageId) : sentMessageId;
447
+ if (editChatId && editMsgId) {
448
+ const label = isDeny ? "❌ Denied" : isAlways ? "✅ Always Allowed" : "✅ Allowed";
449
+ this.bot.api.editMessageText(editChatId, editMsgId, `${label}\nTool: \`${prompt.tool_name}\``, { parse_mode: "Markdown" }).catch(() => { });
450
+ }
451
+ callback(isDeny ? "deny" : isAlways ? "approve_always" : "approve");
452
+ };
453
+ this.on("callback_query", handler);
454
+ if (signal) {
455
+ signal.addEventListener("abort", () => {
456
+ // On timeout/abort, update message to show expiry
457
+ stopCountdown();
458
+ if (sentChatId && sentMessageId) {
459
+ this.bot.api.editMessageText(sentChatId, sentMessageId, `⏱ Timed out\nTool: \`${prompt.tool_name}\``, { parse_mode: "Markdown" }).catch(() => { });
460
+ }
461
+ this.off("callback_query", handler);
462
+ });
463
+ }
464
+ const keyboard = makeKeyboard(TIMEOUT_SECS);
465
+ if (threadId) {
466
+ const chatId = this.getChatId();
467
+ if (chatId) {
468
+ const sent = await this.bot.api.sendMessage(Number(chatId), text, {
469
+ message_thread_id: Number(threadId),
470
+ reply_markup: keyboard,
471
+ parse_mode: "Markdown",
472
+ }).catch(() => {
473
+ return this.bot.api.sendMessage(Number(chatId), text, {
474
+ message_thread_id: Number(threadId),
475
+ reply_markup: keyboard,
476
+ });
477
+ });
478
+ sentChatId = sent.chat.id;
479
+ sentMessageId = sent.message_id;
480
+ }
481
+ }
482
+ else {
483
+ this.emit("approval_request", { prompt: text, keyboard, nonce });
484
+ }
485
+ // Start countdown timer — update buttons every 30s with remaining time
486
+ if (sentChatId && sentMessageId) {
487
+ const chatIdForEdit = sentChatId;
488
+ const msgIdForEdit = sentMessageId;
489
+ countdownTimer = setInterval(() => {
490
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
491
+ const remaining = Math.max(0, TIMEOUT_SECS - elapsed);
492
+ if (remaining <= 0) {
493
+ stopCountdown();
494
+ return;
495
+ }
496
+ const updatedKeyboard = makeKeyboard(remaining);
497
+ this.bot.api.editMessageReplyMarkup(chatIdForEdit, msgIdForEdit, {
498
+ reply_markup: updatedKeyboard,
499
+ }).catch(() => { });
500
+ }, COUNTDOWN_INTERVAL_MS);
501
+ }
502
+ return { cancel: cleanup };
503
+ }
504
+ /** Get the last known chatId (group ID for topic mode) */
505
+ lastChatId = null;
506
+ getChatId() { return this.lastChatId; }
507
+ setChatId(chatId) { this.lastChatId = chatId; }
508
+ // ── File download ─────────────────────────────────────────────────────────
509
+ async downloadAttachment(fileId) {
510
+ const file = await this.bot.api.getFile(fileId);
511
+ const filePath = file.file_path;
512
+ if (!filePath) {
513
+ throw new Error(`No file_path returned for fileId: ${fileId}`);
514
+ }
515
+ // Construct the download URL
516
+ const token = this.bot.token;
517
+ const url = `${this.apiRoot}/file/bot${token}/${filePath}`;
518
+ const filename = filePath.split("/").pop() ?? fileId;
519
+ const localPath = join(this.inboxDir, filename);
520
+ // Download using fetch
521
+ const response = await fetch(url);
522
+ if (!response.ok) {
523
+ throw new Error(`Failed to download file: ${response.status} ${response.statusText}`);
524
+ }
525
+ const { createWriteStream } = await import("node:fs");
526
+ const { pipeline } = await import("node:stream/promises");
527
+ const { Readable } = await import("node:stream");
528
+ const dest = createWriteStream(localPath);
529
+ const body = response.body;
530
+ if (!body)
531
+ throw new Error("No response body");
532
+ await pipeline(Readable.fromWeb(body), dest);
533
+ return localPath;
534
+ }
535
+ // ── Intent-oriented methods ──────────────────────────────────────────────
536
+ async promptUser(chatId, text, choices, opts) {
537
+ const keyboard = new InlineKeyboard();
538
+ for (const choice of choices) {
539
+ keyboard.text(choice.label, choice.id).row();
540
+ }
541
+ const threadId = opts?.threadId;
542
+ const msg = await this.bot.api.sendMessage(Number(chatId), text, {
543
+ message_thread_id: toThreadId(threadId),
544
+ reply_markup: keyboard,
545
+ });
546
+ return String(msg.message_id);
547
+ }
548
+ async notifyAlert(chatId, alert, opts) {
549
+ const threadId = opts?.threadId;
550
+ if (alert.choices && alert.choices.length > 0) {
551
+ const keyboard = new InlineKeyboard();
552
+ for (const choice of alert.choices) {
553
+ keyboard.text(choice.label, choice.id);
554
+ }
555
+ const msg = await this.bot.api.sendMessage(Number(chatId), alert.message, {
556
+ message_thread_id: toThreadId(threadId),
557
+ reply_markup: keyboard,
558
+ });
559
+ return { messageId: String(msg.message_id), chatId, threadId };
560
+ }
561
+ return this.sendText(chatId, alert.message, opts);
562
+ }
563
+ async createTopic(name) {
564
+ const chatId = this.getChatId();
565
+ if (!chatId)
566
+ throw new Error("No chat ID set — cannot create topic");
567
+ const res = await this.bot.api.createForumTopic(Number(chatId), name);
568
+ return res.message_thread_id;
569
+ }
570
+ async deleteTopic(topicId) {
571
+ const chatId = this.getChatId();
572
+ if (!chatId)
573
+ return;
574
+ await this.bot.api.raw.deleteForumTopic({ chat_id: Number(chatId), message_thread_id: Number(topicId) });
575
+ }
576
+ async topicExists(topicId) {
577
+ const chatId = this.getChatId();
578
+ if (!chatId)
579
+ return false;
580
+ try {
581
+ const msg = await this.bot.api.sendMessage(Number(chatId), "\u200B", {
582
+ message_thread_id: topicId,
583
+ });
584
+ await this.bot.api.deleteMessage(Number(chatId), msg.message_id).catch(() => { });
585
+ return true;
586
+ }
587
+ catch (err) {
588
+ const errMsg = String(err);
589
+ if (errMsg.includes("thread not found") || errMsg.includes("TOPIC_ID_INVALID")) {
590
+ return false;
591
+ }
592
+ throw err;
593
+ }
594
+ }
595
+ // ── Pairing ───────────────────────────────────────────────────────────────
596
+ async closeForumTopic(threadId) {
597
+ const chatId = this.getChatId();
598
+ if (!chatId)
599
+ return;
600
+ try {
601
+ await this.bot.api.closeForumTopic(Number(chatId), Number(threadId));
602
+ }
603
+ catch {
604
+ // Best-effort — topic may already be closed
605
+ }
606
+ }
607
+ async reopenForumTopic(threadId) {
608
+ const chatId = this.getChatId();
609
+ if (!chatId)
610
+ return;
611
+ try {
612
+ await this.bot.api.reopenForumTopic(Number(chatId), Number(threadId));
613
+ }
614
+ catch {
615
+ // Best-effort — topic may already be open
616
+ }
617
+ }
618
+ async editForumTopic(threadId, opts) {
619
+ const chatId = this.getChatId();
620
+ if (!chatId)
621
+ return;
622
+ try {
623
+ await this.bot.api.editForumTopic(Number(chatId), Number(threadId), {
624
+ name: opts.name,
625
+ icon_custom_emoji_id: opts.iconCustomEmojiId,
626
+ });
627
+ }
628
+ catch {
629
+ // Best-effort — icon change is cosmetic
630
+ }
631
+ }
632
+ async getTopicIconStickers() {
633
+ const stickers = await this.bot.api.getForumTopicIconStickers();
634
+ return stickers
635
+ .filter((s) => s.custom_emoji_id != null)
636
+ .map((s) => ({ customEmojiId: s.custom_emoji_id, emoji: s.emoji ?? "" }));
637
+ }
638
+ async handlePairing(chatId, userId) {
639
+ const code = this.accessManager.generateCode(userId);
640
+ return code;
641
+ }
642
+ async confirmPairing(code, callerUserId) {
643
+ return this.accessManager.confirmCode(code, callerUserId);
644
+ }
645
+ }
646
+ //# sourceMappingURL=telegram.js.map