@bryti/agent 0.0.1 → 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.
Files changed (228) hide show
  1. package/Dockerfile +27 -0
  2. package/README.md +77 -50
  3. package/config.example.yml +265 -0
  4. package/dist/active-hours.d.ts +23 -0
  5. package/dist/active-hours.d.ts.map +1 -0
  6. package/dist/active-hours.js +68 -0
  7. package/dist/active-hours.js.map +1 -0
  8. package/dist/agent.d.ts +84 -0
  9. package/dist/agent.d.ts.map +1 -0
  10. package/dist/agent.js +383 -0
  11. package/dist/agent.js.map +1 -0
  12. package/dist/channels/markdown/ir.d.ts +79 -0
  13. package/dist/channels/markdown/ir.d.ts.map +1 -0
  14. package/dist/channels/markdown/ir.js +824 -0
  15. package/dist/channels/markdown/ir.js.map +1 -0
  16. package/dist/channels/markdown/render.d.ts +35 -0
  17. package/dist/channels/markdown/render.d.ts.map +1 -0
  18. package/dist/channels/markdown/render.js +178 -0
  19. package/dist/channels/markdown/render.js.map +1 -0
  20. package/dist/channels/telegram-network-errors.d.ts +27 -0
  21. package/dist/channels/telegram-network-errors.d.ts.map +1 -0
  22. package/dist/channels/telegram-network-errors.js +156 -0
  23. package/dist/channels/telegram-network-errors.js.map +1 -0
  24. package/dist/channels/telegram.d.ts +76 -0
  25. package/dist/channels/telegram.d.ts.map +1 -0
  26. package/dist/channels/telegram.js +814 -0
  27. package/dist/channels/telegram.js.map +1 -0
  28. package/dist/channels/types.d.ts +59 -0
  29. package/dist/channels/types.d.ts.map +1 -0
  30. package/dist/channels/types.js +9 -0
  31. package/dist/channels/types.js.map +1 -0
  32. package/dist/channels/whatsapp.d.ts +45 -0
  33. package/dist/channels/whatsapp.d.ts.map +1 -0
  34. package/dist/channels/whatsapp.js +310 -0
  35. package/dist/channels/whatsapp.js.map +1 -0
  36. package/dist/cli.d.ts +13 -0
  37. package/dist/cli.d.ts.map +1 -0
  38. package/dist/cli.js +635 -0
  39. package/dist/cli.js.map +1 -0
  40. package/dist/commands.d.ts +35 -0
  41. package/dist/commands.d.ts.map +1 -0
  42. package/dist/commands.js +113 -0
  43. package/dist/commands.js.map +1 -0
  44. package/dist/compaction/history.d.ts +17 -0
  45. package/dist/compaction/history.d.ts.map +1 -0
  46. package/dist/compaction/history.js +35 -0
  47. package/dist/compaction/history.js.map +1 -0
  48. package/dist/compaction/index.d.ts +3 -0
  49. package/dist/compaction/index.d.ts.map +1 -0
  50. package/dist/compaction/index.js +3 -0
  51. package/dist/compaction/index.js.map +1 -0
  52. package/dist/compaction/proactive.d.ts +25 -0
  53. package/dist/compaction/proactive.d.ts.map +1 -0
  54. package/dist/compaction/proactive.js +87 -0
  55. package/dist/compaction/proactive.js.map +1 -0
  56. package/dist/compaction/transcript-repair.d.ts +55 -0
  57. package/dist/compaction/transcript-repair.d.ts.map +1 -0
  58. package/dist/compaction/transcript-repair.js +215 -0
  59. package/dist/compaction/transcript-repair.js.map +1 -0
  60. package/dist/config.d.ts +128 -0
  61. package/dist/config.d.ts.map +1 -0
  62. package/dist/config.js +317 -0
  63. package/dist/config.js.map +1 -0
  64. package/dist/crash-recovery.d.ts +23 -0
  65. package/dist/crash-recovery.d.ts.map +1 -0
  66. package/dist/crash-recovery.js +96 -0
  67. package/dist/crash-recovery.js.map +1 -0
  68. package/dist/defaults/extensions/EXTENSIONS.md +158 -0
  69. package/dist/defaults/extensions/documents-hedgedoc.ts +153 -0
  70. package/dist/history.d.ts +31 -0
  71. package/dist/history.d.ts.map +1 -0
  72. package/dist/history.js +49 -0
  73. package/dist/history.js.map +1 -0
  74. package/dist/index.d.ts +19 -0
  75. package/dist/index.d.ts.map +1 -0
  76. package/dist/index.js +673 -0
  77. package/dist/index.js.map +1 -0
  78. package/dist/logger.d.ts +39 -0
  79. package/dist/logger.d.ts.map +1 -0
  80. package/dist/logger.js +143 -0
  81. package/dist/logger.js.map +1 -0
  82. package/dist/memory/conversation-search.d.ts +15 -0
  83. package/dist/memory/conversation-search.d.ts.map +1 -0
  84. package/dist/memory/conversation-search.js +60 -0
  85. package/dist/memory/conversation-search.js.map +1 -0
  86. package/dist/memory/core-memory.d.ts +28 -0
  87. package/dist/memory/core-memory.d.ts.map +1 -0
  88. package/dist/memory/core-memory.js +102 -0
  89. package/dist/memory/core-memory.js.map +1 -0
  90. package/dist/memory/embeddings.d.ts +44 -0
  91. package/dist/memory/embeddings.d.ts.map +1 -0
  92. package/dist/memory/embeddings.js +139 -0
  93. package/dist/memory/embeddings.js.map +1 -0
  94. package/dist/memory/search.d.ts +49 -0
  95. package/dist/memory/search.d.ts.map +1 -0
  96. package/dist/memory/search.js +97 -0
  97. package/dist/memory/search.js.map +1 -0
  98. package/dist/memory/store.d.ts +32 -0
  99. package/dist/memory/store.d.ts.map +1 -0
  100. package/dist/memory/store.js +205 -0
  101. package/dist/memory/store.js.map +1 -0
  102. package/dist/message-queue.d.ts +73 -0
  103. package/dist/message-queue.d.ts.map +1 -0
  104. package/dist/message-queue.js +188 -0
  105. package/dist/message-queue.js.map +1 -0
  106. package/dist/model-infra.d.ts +64 -0
  107. package/dist/model-infra.d.ts.map +1 -0
  108. package/dist/model-infra.js +202 -0
  109. package/dist/model-infra.js.map +1 -0
  110. package/dist/projection/format.d.ts +10 -0
  111. package/dist/projection/format.d.ts.map +1 -0
  112. package/dist/projection/format.js +30 -0
  113. package/dist/projection/format.js.map +1 -0
  114. package/dist/projection/index.d.ts +11 -0
  115. package/dist/projection/index.d.ts.map +1 -0
  116. package/dist/projection/index.js +9 -0
  117. package/dist/projection/index.js.map +1 -0
  118. package/dist/projection/reflection.d.ts +94 -0
  119. package/dist/projection/reflection.d.ts.map +1 -0
  120. package/dist/projection/reflection.js +334 -0
  121. package/dist/projection/reflection.js.map +1 -0
  122. package/dist/projection/store.d.ts +144 -0
  123. package/dist/projection/store.d.ts.map +1 -0
  124. package/dist/projection/store.js +519 -0
  125. package/dist/projection/store.js.map +1 -0
  126. package/dist/projection/tools.d.ts +11 -0
  127. package/dist/projection/tools.d.ts.map +1 -0
  128. package/dist/projection/tools.js +237 -0
  129. package/dist/projection/tools.js.map +1 -0
  130. package/dist/scheduler.d.ts +36 -0
  131. package/dist/scheduler.d.ts.map +1 -0
  132. package/dist/scheduler.js +286 -0
  133. package/dist/scheduler.js.map +1 -0
  134. package/dist/system-prompt.d.ts +41 -0
  135. package/dist/system-prompt.d.ts.map +1 -0
  136. package/dist/system-prompt.js +162 -0
  137. package/dist/system-prompt.js.map +1 -0
  138. package/dist/time.d.ts +52 -0
  139. package/dist/time.d.ts.map +1 -0
  140. package/dist/time.js +138 -0
  141. package/dist/time.js.map +1 -0
  142. package/dist/tools/archival-memory-tool.d.ts +8 -0
  143. package/dist/tools/archival-memory-tool.d.ts.map +1 -0
  144. package/dist/tools/archival-memory-tool.js +68 -0
  145. package/dist/tools/archival-memory-tool.js.map +1 -0
  146. package/dist/tools/conversation-search-tool.d.ts +6 -0
  147. package/dist/tools/conversation-search-tool.d.ts.map +1 -0
  148. package/dist/tools/conversation-search-tool.js +28 -0
  149. package/dist/tools/conversation-search-tool.js.map +1 -0
  150. package/dist/tools/core-memory-tool.d.ts +7 -0
  151. package/dist/tools/core-memory-tool.d.ts.map +1 -0
  152. package/dist/tools/core-memory-tool.js +59 -0
  153. package/dist/tools/core-memory-tool.js.map +1 -0
  154. package/dist/tools/fetch-url.d.ts +15 -0
  155. package/dist/tools/fetch-url.d.ts.map +1 -0
  156. package/dist/tools/fetch-url.js +76 -0
  157. package/dist/tools/fetch-url.js.map +1 -0
  158. package/dist/tools/files.d.ts +10 -0
  159. package/dist/tools/files.d.ts.map +1 -0
  160. package/dist/tools/files.js +127 -0
  161. package/dist/tools/files.js.map +1 -0
  162. package/dist/tools/index.d.ts +17 -0
  163. package/dist/tools/index.d.ts.map +1 -0
  164. package/dist/tools/index.js +118 -0
  165. package/dist/tools/index.js.map +1 -0
  166. package/dist/tools/result.d.ts +21 -0
  167. package/dist/tools/result.d.ts.map +1 -0
  168. package/dist/tools/result.js +36 -0
  169. package/dist/tools/result.js.map +1 -0
  170. package/dist/tools/skill-install.d.ts +17 -0
  171. package/dist/tools/skill-install.d.ts.map +1 -0
  172. package/dist/tools/skill-install.js +148 -0
  173. package/dist/tools/skill-install.js.map +1 -0
  174. package/dist/tools/web-search.d.ts +42 -0
  175. package/dist/tools/web-search.d.ts.map +1 -0
  176. package/dist/tools/web-search.js +237 -0
  177. package/dist/tools/web-search.js.map +1 -0
  178. package/dist/trust/guardrail.d.ts +60 -0
  179. package/dist/trust/guardrail.d.ts.map +1 -0
  180. package/dist/trust/guardrail.js +171 -0
  181. package/dist/trust/guardrail.js.map +1 -0
  182. package/dist/trust/index.d.ts +12 -0
  183. package/dist/trust/index.d.ts.map +1 -0
  184. package/dist/trust/index.js +12 -0
  185. package/dist/trust/index.js.map +1 -0
  186. package/dist/trust/store.d.ts +118 -0
  187. package/dist/trust/store.d.ts.map +1 -0
  188. package/dist/trust/store.js +209 -0
  189. package/dist/trust/store.js.map +1 -0
  190. package/dist/trust/wrapper.d.ts +36 -0
  191. package/dist/trust/wrapper.d.ts.map +1 -0
  192. package/dist/trust/wrapper.js +142 -0
  193. package/dist/trust/wrapper.js.map +1 -0
  194. package/dist/usage.d.ts +53 -0
  195. package/dist/usage.d.ts.map +1 -0
  196. package/dist/usage.js +124 -0
  197. package/dist/usage.js.map +1 -0
  198. package/dist/util/math.d.ts +9 -0
  199. package/dist/util/math.d.ts.map +1 -0
  200. package/dist/util/math.js +22 -0
  201. package/dist/util/math.js.map +1 -0
  202. package/dist/util/ssrf.d.ts +21 -0
  203. package/dist/util/ssrf.d.ts.map +1 -0
  204. package/dist/util/ssrf.js +77 -0
  205. package/dist/util/ssrf.js.map +1 -0
  206. package/dist/workers/index.d.ts +8 -0
  207. package/dist/workers/index.d.ts.map +1 -0
  208. package/dist/workers/index.js +7 -0
  209. package/dist/workers/index.js.map +1 -0
  210. package/dist/workers/registry.d.ts +53 -0
  211. package/dist/workers/registry.d.ts.map +1 -0
  212. package/dist/workers/registry.js +38 -0
  213. package/dist/workers/registry.js.map +1 -0
  214. package/dist/workers/scoped-tools.d.ts +21 -0
  215. package/dist/workers/scoped-tools.d.ts.map +1 -0
  216. package/dist/workers/scoped-tools.js +111 -0
  217. package/dist/workers/scoped-tools.js.map +1 -0
  218. package/dist/workers/spawn.d.ts +62 -0
  219. package/dist/workers/spawn.d.ts.map +1 -0
  220. package/dist/workers/spawn.js +314 -0
  221. package/dist/workers/spawn.js.map +1 -0
  222. package/dist/workers/tools.d.ts +26 -0
  223. package/dist/workers/tools.d.ts.map +1 -0
  224. package/dist/workers/tools.js +380 -0
  225. package/dist/workers/tools.js.map +1 -0
  226. package/docker-compose.yml +72 -0
  227. package/package.json +16 -1
  228. package/run.sh +27 -0
@@ -0,0 +1,814 @@
1
+ /**
2
+ * Telegram bridge using grammy.
3
+ *
4
+ * ChannelBridge for Telegram DMs. Long polling for now, webhook later.
5
+ *
6
+ * All outgoing messages use HTML parse mode. LLM markdown output is converted
7
+ * via a proper markdown IR (not regex) before sending. HTML is far simpler
8
+ * than MarkdownV2, which requires escaping 18 characters and breaks constantly
9
+ * on LLM output.
10
+ */
11
+ // ---------------------------------------------------------------------------
12
+ // Imports
13
+ // ---------------------------------------------------------------------------
14
+ import crypto from "node:crypto";
15
+ import { Bot, InlineKeyboard } from "grammy";
16
+ import { markdownToIR, chunkMarkdownIR } from "./markdown/ir.js";
17
+ import { renderMarkdownWithMarkers } from "./markdown/render.js";
18
+ import { isRecoverableTelegramNetworkError, isRetryableGetFileError, isFileTooBigError, } from "./telegram-network-errors.js";
19
+ // ---------------------------------------------------------------------------
20
+ // HTML escape helpers
21
+ // ---------------------------------------------------------------------------
22
+ /**
23
+ * Escape the three HTML special characters that Telegram HTML mode requires.
24
+ */
25
+ function escapeHtml(text) {
26
+ return text
27
+ .replace(/&/g, "&")
28
+ .replace(/</g, "&lt;")
29
+ .replace(/>/g, "&gt;");
30
+ }
31
+ function escapeHtmlAttr(text) {
32
+ return escapeHtml(text).replace(/"/g, "&quot;");
33
+ }
34
+ function buildTelegramLink(link, _text) {
35
+ const href = link.href.trim();
36
+ if (!href || link.start === link.end)
37
+ return null;
38
+ return {
39
+ start: link.start,
40
+ end: link.end,
41
+ open: `<a href="${escapeHtmlAttr(href)}">`,
42
+ close: "</a>",
43
+ };
44
+ }
45
+ // ---------------------------------------------------------------------------
46
+ // Markdown conversion pipeline
47
+ //
48
+ // The pipeline is two-step: markdown → IR (intermediate representation) → HTML.
49
+ // The IR is a structured token list that tracks span boundaries precisely.
50
+ // This matters for chunking: splitting after IR parsing means we can find safe
51
+ // break points between tokens rather than inside them. Cutting a raw markdown
52
+ // string mid-fence or mid-span would produce broken HTML on the far side.
53
+ // ---------------------------------------------------------------------------
54
+ /** Telegram's maximum message length in characters. */
55
+ const MAX_MESSAGE_LENGTH = 4096;
56
+ const TELEGRAM_RENDER_OPTIONS = {
57
+ styleMarkers: {
58
+ bold: { open: "<b>", close: "</b>" },
59
+ italic: { open: "<i>", close: "</i>" },
60
+ strikethrough: { open: "<s>", close: "</s>" },
61
+ code: { open: "<code>", close: "</code>" },
62
+ code_block: { open: "<pre><code>", close: "</code></pre>" },
63
+ },
64
+ escapeText: escapeHtml,
65
+ buildLink: buildTelegramLink,
66
+ };
67
+ const TELEGRAM_IR_OPTIONS = {
68
+ linkify: true,
69
+ headingStyle: "bold",
70
+ blockquotePrefix: "",
71
+ tableMode: "bullets",
72
+ };
73
+ /**
74
+ * Convert LLM markdown to Telegram HTML via the markdown IR.
75
+ * Handles bold, italic, strikethrough, code, links, headings (as bold),
76
+ * and tables (as bullet lists, since Telegram has no table support).
77
+ */
78
+ export function markdownToHtml(text) {
79
+ const ir = markdownToIR(text ?? "", TELEGRAM_IR_OPTIONS);
80
+ return renderMarkdownWithMarkers(ir, TELEGRAM_RENDER_OPTIONS);
81
+ }
82
+ /**
83
+ * Parse markdown into an IR, split at semantic boundaries, and render each
84
+ * chunk to Telegram HTML. Splitting after IR parsing means code blocks,
85
+ * bold spans, and links are never cut in half.
86
+ */
87
+ export function markdownToTelegramChunks(text, maxLength = MAX_MESSAGE_LENGTH) {
88
+ if (!text)
89
+ return [];
90
+ const ir = markdownToIR(text, TELEGRAM_IR_OPTIONS);
91
+ const irChunks = chunkMarkdownIR(ir, maxLength);
92
+ return irChunks.map((chunk) => renderMarkdownWithMarkers(chunk, TELEGRAM_RENDER_OPTIONS));
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Message chunking
96
+ // ---------------------------------------------------------------------------
97
+ /** Maximum retry attempts for send/edit operations. */
98
+ const MAX_SEND_RETRIES = 3;
99
+ /** Base delay for retry backoff in milliseconds. */
100
+ const RETRY_BASE_DELAY_MS = 1000;
101
+ /**
102
+ * How long to wait for more photos in the same album before flushing.
103
+ * Telegram sends album photos as separate updates within ~100-400 ms;
104
+ * 600 ms gives headroom without noticeable latency.
105
+ */
106
+ const MEDIA_GROUP_FLUSH_MS = 600;
107
+ /**
108
+ * Split text into chunks that fit Telegram's message limit.
109
+ * Prefers paragraph boundaries, then newlines, then sentences, then hard cut.
110
+ */
111
+ export function chunkMessage(text, maxLength = MAX_MESSAGE_LENGTH) {
112
+ if (text.length <= maxLength) {
113
+ return [text];
114
+ }
115
+ const chunks = [];
116
+ let remaining = text;
117
+ while (remaining.length > 0) {
118
+ if (remaining.length <= maxLength) {
119
+ chunks.push(remaining);
120
+ break;
121
+ }
122
+ // Find best split point within the limit
123
+ let splitAt = -1;
124
+ // Try double newline (paragraph boundary)
125
+ const lastPara = remaining.lastIndexOf("\n\n", maxLength);
126
+ if (lastPara > maxLength * 0.3) {
127
+ splitAt = lastPara;
128
+ }
129
+ // Try single newline
130
+ if (splitAt === -1) {
131
+ const lastNl = remaining.lastIndexOf("\n", maxLength);
132
+ if (lastNl > maxLength * 0.3) {
133
+ splitAt = lastNl;
134
+ }
135
+ }
136
+ // Try sentence boundary (. ! ?)
137
+ if (splitAt === -1) {
138
+ const slice = remaining.slice(0, maxLength);
139
+ const sentenceMatch = slice.match(/.*[.!?]\s/s);
140
+ if (sentenceMatch && sentenceMatch[0].length > maxLength * 0.3) {
141
+ splitAt = sentenceMatch[0].length;
142
+ }
143
+ }
144
+ // Hard cut as last resort
145
+ if (splitAt === -1) {
146
+ splitAt = maxLength;
147
+ }
148
+ chunks.push(remaining.slice(0, splitAt).trimEnd());
149
+ remaining = remaining.slice(splitAt).trimStart();
150
+ }
151
+ return chunks;
152
+ }
153
+ // ---------------------------------------------------------------------------
154
+ // Retry helpers
155
+ //
156
+ // Retry lives here rather than in the caller because the decision of what is
157
+ // retryable is Telegram-specific: 429 rate-limits carry a retry_after field,
158
+ // 5xx errors warrant exponential backoff, and network failures (ECONNRESET,
159
+ // fetch errors, etc.) need to be classified by a Telegram-aware heuristic.
160
+ // Pushing this into the bridge keeps all callers simple and ensures consistent
161
+ // behavior across sendMessage, editMessage, and sendApprovalRequest.
162
+ // ---------------------------------------------------------------------------
163
+ /**
164
+ * Retry a Telegram API call with exponential backoff.
165
+ * Retries on 429 rate limits (honours retry_after when present), 5xx server
166
+ * errors, and recoverable network errors. Permanent API errors are re-thrown
167
+ * immediately without consuming retry budget.
168
+ */
169
+ async function withRetry(fn, maxRetries = MAX_SEND_RETRIES, baseDelay = RETRY_BASE_DELAY_MS) {
170
+ let lastError;
171
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
172
+ try {
173
+ return await fn();
174
+ }
175
+ catch (error) {
176
+ lastError = error;
177
+ if (attempt === maxRetries)
178
+ break;
179
+ const err = error;
180
+ const code = err.error_code;
181
+ // Telegram API rate limit: use retry_after if provided
182
+ if (code === 429) {
183
+ const retryAfter = err.parameters?.retry_after;
184
+ const delayMs = retryAfter ? retryAfter * 1000 : baseDelay * 2 ** attempt;
185
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
186
+ continue;
187
+ }
188
+ // Telegram server errors (5xx): exponential backoff
189
+ if (code && code >= 500 && code < 600) {
190
+ await new Promise((resolve) => setTimeout(resolve, baseDelay * 2 ** attempt));
191
+ continue;
192
+ }
193
+ // Recoverable network errors (ECONNRESET, timeouts, fetch failures, etc.)
194
+ // TODO: the classifier is heuristic (string matching on error codes/messages);
195
+ // a proper connection state machine tracking polling vs. send contexts would
196
+ // give cleaner semantics and fewer false positives.
197
+ if (isRecoverableTelegramNetworkError(error, { context: "send" })) {
198
+ await new Promise((resolve) => setTimeout(resolve, baseDelay * 2 ** attempt));
199
+ continue;
200
+ }
201
+ // Permanent error — don't retry
202
+ throw error;
203
+ }
204
+ }
205
+ throw lastError;
206
+ }
207
+ /**
208
+ * Retry a getFile call with exponential backoff.
209
+ * Skips retry for permanent "file is too big" errors.
210
+ */
211
+ async function retryGetFile(fn, maxRetries = 3, baseDelay = 1000) {
212
+ let lastError;
213
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
214
+ try {
215
+ return await fn();
216
+ }
217
+ catch (error) {
218
+ lastError = error;
219
+ if (attempt === maxRetries)
220
+ break;
221
+ if (!isRetryableGetFileError(error))
222
+ throw error;
223
+ const delayMs = baseDelay * 2 ** attempt;
224
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
225
+ }
226
+ }
227
+ throw lastError;
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // TelegramBridge
231
+ // ---------------------------------------------------------------------------
232
+ /**
233
+ * Telegram bridge implementation.
234
+ */
235
+ export class TelegramBridge {
236
+ botToken;
237
+ name = "telegram";
238
+ platform = "telegram";
239
+ bot = null;
240
+ handler = null;
241
+ typingIntervals = new Map();
242
+ allowedUsers;
243
+ /** Pending approval requests: approvalKey → resolve function */
244
+ pendingApprovals = new Map();
245
+ /** Media group buffer: media_group_id → accumulated entry */
246
+ mediaGroupBuffer = new Map();
247
+ constructor(botToken, allowedUsers = []) {
248
+ this.botToken = botToken;
249
+ this.allowedUsers = allowedUsers;
250
+ }
251
+ // -------------------------------------------------------------------------
252
+ // Polling lifecycle
253
+ //
254
+ // bot.start() is grammy's long-poll loop. It blocks until bot.stop() is
255
+ // called, so we fire it in the background and attach a .catch() to handle
256
+ // errors. Recoverable network errors (dropped connections, DNS hiccups) are
257
+ // logged as warnings rather than crashing the process; long-polling is
258
+ // inherently fragile over unreliable connections and grammy will restart the
259
+ // loop automatically. Only unexpected errors are promoted to console.error.
260
+ // -------------------------------------------------------------------------
261
+ async start() {
262
+ this.bot = new Bot(this.botToken);
263
+ // Handle /start command
264
+ this.bot.command("start", async (ctx) => {
265
+ if (!this.isAllowed(ctx)) {
266
+ await ctx.reply("Sorry, you're not authorized to use this bot.");
267
+ return;
268
+ }
269
+ await ctx.reply("Welcome to Bryti! I'm your personal AI assistant.\n\n" +
270
+ "Commands:\n" +
271
+ "/start - Show this message\n" +
272
+ "/clear - Clear conversation history\n" +
273
+ "/memory - Show your persistent memory\n" +
274
+ "/help - Show available commands");
275
+ });
276
+ // Handle /help command
277
+ this.bot.command("help", async (ctx) => {
278
+ if (!this.isAllowed(ctx)) {
279
+ await ctx.reply("Sorry, you're not authorized to use this bot.");
280
+ return;
281
+ }
282
+ await ctx.reply("I can help you with:\n" +
283
+ "- Web search and information lookup\n" +
284
+ "- Reading and writing files\n" +
285
+ "- Remembering important information\n\n" +
286
+ "Just send me a message and I'll help you!");
287
+ });
288
+ // Handle /clear command - handled by the message handler
289
+ this.bot.command("clear", async (ctx) => {
290
+ if (!this.isAllowed(ctx)) {
291
+ await ctx.reply("Sorry, you're not authorized to use this bot.");
292
+ return;
293
+ }
294
+ // Signal to handler that this is a clear command
295
+ if (this.handler && ctx.message) {
296
+ const msg = {
297
+ channelId: String(ctx.chat.id),
298
+ userId: String(ctx.from?.id),
299
+ text: "/clear",
300
+ platform: "telegram",
301
+ raw: ctx.message,
302
+ };
303
+ await this.handler(msg);
304
+ await ctx.reply("Conversation history cleared.");
305
+ }
306
+ });
307
+ // Handle /memory command
308
+ this.bot.command("memory", async (ctx) => {
309
+ if (!this.isAllowed(ctx)) {
310
+ await ctx.reply("Sorry, you're not authorized to use this bot.");
311
+ return;
312
+ }
313
+ // Signal to handler that this is a memory command
314
+ if (this.handler && ctx.message) {
315
+ const msg = {
316
+ channelId: String(ctx.chat.id),
317
+ userId: String(ctx.from?.id),
318
+ text: "/memory",
319
+ platform: "telegram",
320
+ raw: ctx.message,
321
+ };
322
+ await this.handler(msg);
323
+ }
324
+ });
325
+ // Handle text messages
326
+ this.bot.on("message:text", async (ctx) => {
327
+ if (!this.isAllowed(ctx)) {
328
+ await ctx.reply("Sorry, you're not authorized to use this bot.");
329
+ return;
330
+ }
331
+ const text = ctx.message.text;
332
+ if (!text || text.startsWith("/")) {
333
+ return; // Skip commands (handled above)
334
+ }
335
+ if (this.handler) {
336
+ const msg = {
337
+ channelId: String(ctx.chat.id),
338
+ userId: String(ctx.from?.id),
339
+ text,
340
+ platform: "telegram",
341
+ raw: ctx.message,
342
+ };
343
+ await this.handler(msg);
344
+ }
345
+ });
346
+ // Handle photo messages — with media group (album) buffering.
347
+ // Telegram sends each photo in an album as a separate update sharing the
348
+ // same media_group_id. We collect them all within MEDIA_GROUP_FLUSH_MS
349
+ // and dispatch a single message containing all images.
350
+ this.bot.on("message:photo", async (ctx) => {
351
+ if (!this.isAllowed(ctx)) {
352
+ await ctx.reply("Sorry, you're not authorized to use this bot.");
353
+ return;
354
+ }
355
+ if (!this.handler)
356
+ return;
357
+ const image = await this.downloadPhoto(ctx);
358
+ if (!image) {
359
+ // Only reply if it's not part of an album (avoid spamming for partial failures)
360
+ if (!ctx.message.media_group_id) {
361
+ await ctx.reply("Sorry, I couldn't download that photo.");
362
+ }
363
+ return;
364
+ }
365
+ const caption = ctx.message.caption?.trim() ?? "";
366
+ const channelId = String(ctx.chat.id);
367
+ const userId = String(ctx.from?.id);
368
+ const mediaGroupId = ctx.message.media_group_id;
369
+ if (mediaGroupId) {
370
+ // Album: accumulate images and reset the flush timer
371
+ const existing = this.mediaGroupBuffer.get(mediaGroupId);
372
+ if (existing) {
373
+ clearTimeout(existing.timer);
374
+ existing.images.push(...image);
375
+ if (caption && !existing.caption)
376
+ existing.caption = caption;
377
+ existing.timer = setTimeout(() => this.flushMediaGroup(mediaGroupId), MEDIA_GROUP_FLUSH_MS);
378
+ }
379
+ else {
380
+ const entry = {
381
+ images: [...image],
382
+ caption,
383
+ channelId,
384
+ userId,
385
+ raw: ctx.message,
386
+ timer: setTimeout(() => this.flushMediaGroup(mediaGroupId), MEDIA_GROUP_FLUSH_MS),
387
+ };
388
+ this.mediaGroupBuffer.set(mediaGroupId, entry);
389
+ }
390
+ return;
391
+ }
392
+ // Single photo (no album)
393
+ const text = caption || "The user sent this image.";
394
+ const msg = {
395
+ channelId,
396
+ userId,
397
+ text,
398
+ platform: "telegram",
399
+ raw: ctx.message,
400
+ images: image,
401
+ };
402
+ await this.handler(msg);
403
+ });
404
+ // Handle document messages that are images (sent as files instead of photos)
405
+ this.bot.on("message:document", async (ctx) => {
406
+ if (!this.isAllowed(ctx)) {
407
+ await ctx.reply("Sorry, you're not authorized to use this bot.");
408
+ return;
409
+ }
410
+ const doc = ctx.message.document;
411
+ const mimeType = doc.mime_type ?? "";
412
+ if (!mimeType.startsWith("image/")) {
413
+ await ctx.reply("Sorry, I can only handle text messages and images for now.");
414
+ return;
415
+ }
416
+ if (!this.handler)
417
+ return;
418
+ const images = await this.downloadDocument(ctx, mimeType);
419
+ if (!images) {
420
+ await ctx.reply("Sorry, I couldn't download that image.");
421
+ return;
422
+ }
423
+ const text = ctx.message.caption?.trim() || "The user sent this image.";
424
+ const msg = {
425
+ channelId: String(ctx.chat.id),
426
+ userId: String(ctx.from?.id),
427
+ text,
428
+ platform: "telegram",
429
+ raw: ctx.message,
430
+ images,
431
+ };
432
+ await this.handler(msg);
433
+ });
434
+ // Handle non-text messages
435
+ this.bot.on("message", async (ctx) => {
436
+ if (!this.isAllowed(ctx)) {
437
+ await ctx.reply("Sorry, you're not authorized to use this bot.");
438
+ return;
439
+ }
440
+ await ctx.reply("Sorry, I can only handle text messages and images for now.");
441
+ });
442
+ // Handle inline keyboard callbacks for approval requests.
443
+ // Callback data format: "approval:<key>:<result>"
444
+ this.bot.on("callback_query:data", async (ctx) => {
445
+ const data = ctx.callbackQuery.data;
446
+ if (!data.startsWith("a:")) {
447
+ await ctx.answerCallbackQuery();
448
+ return;
449
+ }
450
+ // Parse: "a:<shortKey>:<result>" where result is allow|always|deny
451
+ const parts = data.split(":");
452
+ if (parts.length !== 3) {
453
+ await ctx.answerCallbackQuery();
454
+ return;
455
+ }
456
+ const key = parts[1];
457
+ const resultStr = parts[2] === "always" ? "allow_always" : parts[2];
458
+ const resolve = this.pendingApprovals.get(key);
459
+ if (resolve) {
460
+ this.pendingApprovals.delete(key);
461
+ resolve(resultStr);
462
+ // Edit the message to remove the buttons and show the result
463
+ const label = resultStr === "allow" ? "✓ Allowed once"
464
+ : resultStr === "allow_always" ? "✓ Always allowed"
465
+ : "✗ Denied";
466
+ try {
467
+ await ctx.editMessageReplyMarkup({ reply_markup: undefined });
468
+ await ctx.editMessageText((ctx.callbackQuery.message?.text ?? "") + `\n\n<i>${label}</i>`, { parse_mode: "HTML" });
469
+ }
470
+ catch {
471
+ // Message may have been deleted or too old — ignore
472
+ }
473
+ }
474
+ await ctx.answerCallbackQuery();
475
+ });
476
+ // Initialize bot (fetches bot info) then start polling in background
477
+ await this.bot.init();
478
+ // bot.start() blocks until stopped; run it in background.
479
+ // Explicitly declare the update types we handle so Telegram doesn't send
480
+ // types we haven't subscribed to (e.g. channel_post, message_reaction).
481
+ this.bot.start({
482
+ allowed_updates: ["message", "callback_query"],
483
+ }).catch((err) => {
484
+ if (isRecoverableTelegramNetworkError(err, { context: "polling" })) {
485
+ console.warn("Telegram polling stopped (network error):", err.message);
486
+ }
487
+ else {
488
+ console.error("Telegram polling error:", err);
489
+ }
490
+ });
491
+ console.log("Telegram bridge started (polling mode)");
492
+ }
493
+ async stop() {
494
+ // Stop all typing intervals
495
+ for (const interval of this.typingIntervals.values()) {
496
+ clearInterval(interval);
497
+ }
498
+ this.typingIntervals.clear();
499
+ // Cancel any pending media group flush timers
500
+ for (const entry of this.mediaGroupBuffer.values()) {
501
+ clearTimeout(entry.timer);
502
+ }
503
+ this.mediaGroupBuffer.clear();
504
+ if (this.bot) {
505
+ await this.bot.stop();
506
+ this.bot = null;
507
+ }
508
+ console.log("Telegram bridge stopped");
509
+ }
510
+ // -------------------------------------------------------------------------
511
+ // Message sending with retry
512
+ // -------------------------------------------------------------------------
513
+ async sendMessage(channelId, text, opts) {
514
+ if (!this.bot) {
515
+ throw new Error("Bot not started");
516
+ }
517
+ const chatId = parseInt(channelId, 10);
518
+ const bot = this.bot;
519
+ // Stop typing indicator for this chat
520
+ this.stopTyping(channelId);
521
+ // Always use HTML parse mode. For markdown input, parse into an IR first
522
+ // then chunk at semantic boundaries (never mid-fence or mid-tag), then
523
+ // render each chunk. For pre-formatted HTML, chunk the raw string.
524
+ const chunks = opts?.parseMode === "html"
525
+ ? chunkMessage(text)
526
+ : markdownToTelegramChunks(text);
527
+ let lastMessageId = "";
528
+ for (const chunk of chunks) {
529
+ try {
530
+ const message = await withRetry(() => bot.api.sendMessage(chatId, chunk, { parse_mode: "HTML" }));
531
+ lastMessageId = String(message.message_id);
532
+ }
533
+ catch (error) {
534
+ // If HTML parsing fails, fall back to plain text (strip tags)
535
+ const err = error;
536
+ if (err.error_code === 400 && err.description?.includes("can't parse entities")) {
537
+ console.warn("HTML parse failed, falling back to plain text:", err.description);
538
+ const plain = chunk.replace(/<[^>]+>/g, "");
539
+ const message = await withRetry(() => bot.api.sendMessage(chatId, plain));
540
+ lastMessageId = String(message.message_id);
541
+ }
542
+ else {
543
+ throw error;
544
+ }
545
+ }
546
+ }
547
+ return lastMessageId;
548
+ }
549
+ async editMessage(channelId, messageId, text) {
550
+ if (!this.bot) {
551
+ throw new Error("Bot not started");
552
+ }
553
+ const chatId = parseInt(channelId, 10);
554
+ const msgId = parseInt(messageId, 10);
555
+ const bot = this.bot;
556
+ try {
557
+ await withRetry(() => bot.api.editMessageText(chatId, msgId, markdownToHtml(text), {
558
+ parse_mode: "HTML",
559
+ }));
560
+ }
561
+ catch (error) {
562
+ // Ignore "message is not modified" errors
563
+ const err = error;
564
+ if (!err.description?.includes("message is not modified")) {
565
+ throw error;
566
+ }
567
+ }
568
+ }
569
+ async sendTyping(channelId) {
570
+ if (!this.bot) {
571
+ throw new Error("Bot not started");
572
+ }
573
+ // If already typing, don't start another interval
574
+ if (this.typingIntervals.has(channelId)) {
575
+ return;
576
+ }
577
+ const chatId = parseInt(channelId, 10);
578
+ // Send initial typing action
579
+ try {
580
+ await this.bot.api.sendChatAction(chatId, "typing");
581
+ }
582
+ catch {
583
+ // Ignore errors
584
+ }
585
+ // Keep sending typing indicator every 5 seconds
586
+ const interval = setInterval(async () => {
587
+ try {
588
+ if (this.bot) {
589
+ await this.bot.api.sendChatAction(chatId, "typing");
590
+ }
591
+ }
592
+ catch {
593
+ // Stop on error
594
+ this.stopTyping(channelId);
595
+ }
596
+ }, 5000);
597
+ this.typingIntervals.set(channelId, interval);
598
+ }
599
+ onMessage(handler) {
600
+ this.handler = handler;
601
+ }
602
+ // -------------------------------------------------------------------------
603
+ // Approval request handling
604
+ //
605
+ // Approval requests are sent as messages with an InlineKeyboard. Each button
606
+ // carries callback data in the format "a:<shortKey>:<result>", where:
607
+ // - "a:" is a fixed prefix that distinguishes approval callbacks from any
608
+ // other inline keyboard callbacks the bot may receive in the future.
609
+ // - shortKey is a 12-character hex prefix of SHA-256(approvalKey). Telegram
610
+ // limits callback_data to 64 bytes; the full approvalKey (a UUID) would
611
+ // fit but this keeps room for the prefix and result suffix.
612
+ // - result is "allow", "always", or "deny".
613
+ //
614
+ // When a button is pressed the callback_query handler looks up shortKey in
615
+ // pendingApprovals, resolves the Promise with the matching ApprovalResult,
616
+ // removes the entry, and edits the message to remove the buttons (so the
617
+ // user can't press them twice).
618
+ // -------------------------------------------------------------------------
619
+ async sendApprovalRequest(channelId, prompt, approvalKey, timeoutMs = 5 * 60 * 1000) {
620
+ if (!this.bot)
621
+ throw new Error("Bot not started");
622
+ // Telegram limits callback_query data to 64 bytes. Use a short hash
623
+ // as the callback key and map it back to the full approvalKey internally.
624
+ const shortKey = crypto.createHash("sha256").update(approvalKey).digest("hex").slice(0, 12);
625
+ const keyboard = new InlineKeyboard()
626
+ .text("✓ Allow once", `a:${shortKey}:allow`)
627
+ .text("✓ Always allow", `a:${shortKey}:always`)
628
+ .row()
629
+ .text("✗ Deny", `a:${shortKey}:deny`);
630
+ await withRetry(() => this.bot.api.sendMessage(parseInt(channelId, 10), prompt, {
631
+ parse_mode: "HTML",
632
+ reply_markup: keyboard,
633
+ }));
634
+ return new Promise((resolve) => {
635
+ this.pendingApprovals.set(shortKey, resolve);
636
+ // Auto-deny on timeout and notify the user
637
+ setTimeout(async () => {
638
+ if (this.pendingApprovals.has(shortKey)) {
639
+ this.pendingApprovals.delete(shortKey);
640
+ resolve("deny");
641
+ try {
642
+ await withRetry(() => this.bot.api.sendMessage(parseInt(channelId, 10), "⏱ Permission request expired (auto-denied)."));
643
+ }
644
+ catch {
645
+ // Best-effort notification
646
+ }
647
+ }
648
+ }, timeoutMs);
649
+ });
650
+ }
651
+ // -------------------------------------------------------------------------
652
+ // Image downloading
653
+ //
654
+ // Telegram distinguishes two image types:
655
+ // - Photos: sent through Telegram's compression pipeline, always JPEG,
656
+ // delivered as an array of pre-scaled sizes (largest last).
657
+ // - Documents: sent as raw files with the original MIME type preserved.
658
+ // Used when the sender ticks "send as file" or when the client detects
659
+ // the image would degrade too much from compression.
660
+ //
661
+ // Both paths call getFile() to obtain a temporary file_path, then fetch the
662
+ // binary over HTTPS from api.telegram.org/file/bot<token>/<file_path>.
663
+ // Telegram's limit is 20 MB per file; getFile() throws with a specific error
664
+ // message for oversized files. isFileTooBigError() catches this before we
665
+ // waste a download attempt. Above the limit Telegram silently truncates the
666
+ // stored file, so the check is load-bearing, not just a nice-to-have.
667
+ // -------------------------------------------------------------------------
668
+ /**
669
+ * Flush a buffered media group (album) as a single message with all images.
670
+ */
671
+ async flushMediaGroup(mediaGroupId) {
672
+ const entry = this.mediaGroupBuffer.get(mediaGroupId);
673
+ if (!entry)
674
+ return;
675
+ this.mediaGroupBuffer.delete(mediaGroupId);
676
+ if (!this.handler || entry.images.length === 0)
677
+ return;
678
+ const text = entry.caption || "The user sent this image.";
679
+ const msg = {
680
+ channelId: entry.channelId,
681
+ userId: entry.userId,
682
+ text,
683
+ platform: "telegram",
684
+ raw: entry.raw,
685
+ images: entry.images,
686
+ };
687
+ console.log(`[telegram] Flushing media group ${mediaGroupId}: ${entry.images.length} image(s)`);
688
+ try {
689
+ await this.handler(msg);
690
+ }
691
+ catch (err) {
692
+ console.error("[telegram] Media group handler error:", err.message);
693
+ }
694
+ }
695
+ /**
696
+ * Download the largest available photo from a photo message.
697
+ * Returns a single-element array on success, null on failure.
698
+ */
699
+ async downloadPhoto(ctx) {
700
+ if (!this.bot)
701
+ return null;
702
+ // Telegram sends photos as an array of sizes; last entry is largest
703
+ const sizes = ctx.message.photo;
704
+ const largest = sizes[sizes.length - 1];
705
+ if (!largest)
706
+ return null;
707
+ let filePath;
708
+ try {
709
+ const file = await retryGetFile(() => this.bot.api.getFile(largest.file_id));
710
+ if (!file.file_path)
711
+ return null;
712
+ filePath = file.file_path;
713
+ }
714
+ catch (err) {
715
+ if (isFileTooBigError(err)) {
716
+ console.warn(`[telegram] Photo too large to download (>20 MB), skipping`);
717
+ }
718
+ else {
719
+ console.error("[telegram] getFile failed for photo:", err.message);
720
+ }
721
+ return null;
722
+ }
723
+ try {
724
+ const url = `https://api.telegram.org/file/bot${this.botToken}/${filePath}`;
725
+ const response = await fetch(url);
726
+ if (!response.ok) {
727
+ console.error(`[telegram] Photo download failed: HTTP ${response.status}`);
728
+ return null;
729
+ }
730
+ const buffer = await response.arrayBuffer();
731
+ const data = Buffer.from(buffer).toString("base64");
732
+ // Use Content-Type from response; Telegram serves JPEG for compressed photos,
733
+ // but PNG/WebP for images sent uncompressed. Treat application/octet-stream
734
+ // as unknown (Telegram sometimes returns it for valid images).
735
+ const rawMime = response.headers.get("content-type")?.split(";")[0].trim();
736
+ const mimeType = (!rawMime || rawMime === "application/octet-stream") ? "image/jpeg" : rawMime;
737
+ console.log(`[telegram] Downloaded photo: ${buffer.byteLength} bytes ` +
738
+ `(${largest.width}x${largest.height}), mime=${mimeType}`);
739
+ return [{ data, mimeType }];
740
+ }
741
+ catch (err) {
742
+ console.error("[telegram] Photo fetch failed:", err.message);
743
+ return null;
744
+ }
745
+ }
746
+ /**
747
+ * Download an image document (sent as a file rather than a compressed photo).
748
+ * Returns a single-element array on success, null on failure.
749
+ */
750
+ async downloadDocument(ctx, mimeType) {
751
+ if (!this.bot)
752
+ return null;
753
+ const doc = ctx.message.document;
754
+ let filePath;
755
+ try {
756
+ const file = await retryGetFile(() => this.bot.api.getFile(doc.file_id));
757
+ if (!file.file_path)
758
+ return null;
759
+ filePath = file.file_path;
760
+ }
761
+ catch (err) {
762
+ if (isFileTooBigError(err)) {
763
+ console.warn(`[telegram] Image document too large to download (>20 MB), skipping`);
764
+ }
765
+ else {
766
+ console.error("[telegram] getFile failed for document:", err.message);
767
+ }
768
+ return null;
769
+ }
770
+ try {
771
+ const url = `https://api.telegram.org/file/bot${this.botToken}/${filePath}`;
772
+ const response = await fetch(url);
773
+ if (!response.ok) {
774
+ console.error(`[telegram] Document download failed: HTTP ${response.status}`);
775
+ return null;
776
+ }
777
+ const buffer = await response.arrayBuffer();
778
+ const data = Buffer.from(buffer).toString("base64");
779
+ // Trust the declared MIME type for documents; fall back to response header
780
+ const resolvedMime = mimeType || response.headers.get("content-type")?.split(";")[0].trim() || "image/jpeg";
781
+ console.log(`[telegram] Downloaded image document: ${buffer.byteLength} bytes, mime=${resolvedMime}`);
782
+ return [{ data, mimeType: resolvedMime }];
783
+ }
784
+ catch (err) {
785
+ console.error("[telegram] Document fetch failed:", err.message);
786
+ return null;
787
+ }
788
+ }
789
+ /**
790
+ * Check if user is allowed to use the bot.
791
+ * When allowed_users is empty, nobody is allowed (deny by default).
792
+ */
793
+ isAllowed(ctx) {
794
+ const userId = ctx.from?.id;
795
+ if (!userId) {
796
+ return false;
797
+ }
798
+ if (this.allowedUsers.length === 0) {
799
+ return false;
800
+ }
801
+ return this.allowedUsers.includes(userId);
802
+ }
803
+ /**
804
+ * Stop typing indicator for a channel.
805
+ */
806
+ stopTyping(channelId) {
807
+ const interval = this.typingIntervals.get(channelId);
808
+ if (interval) {
809
+ clearInterval(interval);
810
+ this.typingIntervals.delete(channelId);
811
+ }
812
+ }
813
+ }
814
+ //# sourceMappingURL=telegram.js.map