@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.
- package/Dockerfile +27 -0
- package/README.md +77 -50
- package/config.example.yml +265 -0
- package/dist/active-hours.d.ts +23 -0
- package/dist/active-hours.d.ts.map +1 -0
- package/dist/active-hours.js +68 -0
- package/dist/active-hours.js.map +1 -0
- package/dist/agent.d.ts +84 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +383 -0
- package/dist/agent.js.map +1 -0
- package/dist/channels/markdown/ir.d.ts +79 -0
- package/dist/channels/markdown/ir.d.ts.map +1 -0
- package/dist/channels/markdown/ir.js +824 -0
- package/dist/channels/markdown/ir.js.map +1 -0
- package/dist/channels/markdown/render.d.ts +35 -0
- package/dist/channels/markdown/render.d.ts.map +1 -0
- package/dist/channels/markdown/render.js +178 -0
- package/dist/channels/markdown/render.js.map +1 -0
- package/dist/channels/telegram-network-errors.d.ts +27 -0
- package/dist/channels/telegram-network-errors.d.ts.map +1 -0
- package/dist/channels/telegram-network-errors.js +156 -0
- package/dist/channels/telegram-network-errors.js.map +1 -0
- package/dist/channels/telegram.d.ts +76 -0
- package/dist/channels/telegram.d.ts.map +1 -0
- package/dist/channels/telegram.js +814 -0
- package/dist/channels/telegram.js.map +1 -0
- package/dist/channels/types.d.ts +59 -0
- package/dist/channels/types.d.ts.map +1 -0
- package/dist/channels/types.js +9 -0
- package/dist/channels/types.js.map +1 -0
- package/dist/channels/whatsapp.d.ts +45 -0
- package/dist/channels/whatsapp.d.ts.map +1 -0
- package/dist/channels/whatsapp.js +310 -0
- package/dist/channels/whatsapp.js.map +1 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +635 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands.d.ts +35 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +113 -0
- package/dist/commands.js.map +1 -0
- package/dist/compaction/history.d.ts +17 -0
- package/dist/compaction/history.d.ts.map +1 -0
- package/dist/compaction/history.js +35 -0
- package/dist/compaction/history.js.map +1 -0
- package/dist/compaction/index.d.ts +3 -0
- package/dist/compaction/index.d.ts.map +1 -0
- package/dist/compaction/index.js +3 -0
- package/dist/compaction/index.js.map +1 -0
- package/dist/compaction/proactive.d.ts +25 -0
- package/dist/compaction/proactive.d.ts.map +1 -0
- package/dist/compaction/proactive.js +87 -0
- package/dist/compaction/proactive.js.map +1 -0
- package/dist/compaction/transcript-repair.d.ts +55 -0
- package/dist/compaction/transcript-repair.d.ts.map +1 -0
- package/dist/compaction/transcript-repair.js +215 -0
- package/dist/compaction/transcript-repair.js.map +1 -0
- package/dist/config.d.ts +128 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +317 -0
- package/dist/config.js.map +1 -0
- package/dist/crash-recovery.d.ts +23 -0
- package/dist/crash-recovery.d.ts.map +1 -0
- package/dist/crash-recovery.js +96 -0
- package/dist/crash-recovery.js.map +1 -0
- package/dist/defaults/extensions/EXTENSIONS.md +158 -0
- package/dist/defaults/extensions/documents-hedgedoc.ts +153 -0
- package/dist/history.d.ts +31 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +49 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +673 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +39 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +143 -0
- package/dist/logger.js.map +1 -0
- package/dist/memory/conversation-search.d.ts +15 -0
- package/dist/memory/conversation-search.d.ts.map +1 -0
- package/dist/memory/conversation-search.js +60 -0
- package/dist/memory/conversation-search.js.map +1 -0
- package/dist/memory/core-memory.d.ts +28 -0
- package/dist/memory/core-memory.d.ts.map +1 -0
- package/dist/memory/core-memory.js +102 -0
- package/dist/memory/core-memory.js.map +1 -0
- package/dist/memory/embeddings.d.ts +44 -0
- package/dist/memory/embeddings.d.ts.map +1 -0
- package/dist/memory/embeddings.js +139 -0
- package/dist/memory/embeddings.js.map +1 -0
- package/dist/memory/search.d.ts +49 -0
- package/dist/memory/search.d.ts.map +1 -0
- package/dist/memory/search.js +97 -0
- package/dist/memory/search.js.map +1 -0
- package/dist/memory/store.d.ts +32 -0
- package/dist/memory/store.d.ts.map +1 -0
- package/dist/memory/store.js +205 -0
- package/dist/memory/store.js.map +1 -0
- package/dist/message-queue.d.ts +73 -0
- package/dist/message-queue.d.ts.map +1 -0
- package/dist/message-queue.js +188 -0
- package/dist/message-queue.js.map +1 -0
- package/dist/model-infra.d.ts +64 -0
- package/dist/model-infra.d.ts.map +1 -0
- package/dist/model-infra.js +202 -0
- package/dist/model-infra.js.map +1 -0
- package/dist/projection/format.d.ts +10 -0
- package/dist/projection/format.d.ts.map +1 -0
- package/dist/projection/format.js +30 -0
- package/dist/projection/format.js.map +1 -0
- package/dist/projection/index.d.ts +11 -0
- package/dist/projection/index.d.ts.map +1 -0
- package/dist/projection/index.js +9 -0
- package/dist/projection/index.js.map +1 -0
- package/dist/projection/reflection.d.ts +94 -0
- package/dist/projection/reflection.d.ts.map +1 -0
- package/dist/projection/reflection.js +334 -0
- package/dist/projection/reflection.js.map +1 -0
- package/dist/projection/store.d.ts +144 -0
- package/dist/projection/store.d.ts.map +1 -0
- package/dist/projection/store.js +519 -0
- package/dist/projection/store.js.map +1 -0
- package/dist/projection/tools.d.ts +11 -0
- package/dist/projection/tools.d.ts.map +1 -0
- package/dist/projection/tools.js +237 -0
- package/dist/projection/tools.js.map +1 -0
- package/dist/scheduler.d.ts +36 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +286 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/system-prompt.d.ts +41 -0
- package/dist/system-prompt.d.ts.map +1 -0
- package/dist/system-prompt.js +162 -0
- package/dist/system-prompt.js.map +1 -0
- package/dist/time.d.ts +52 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +138 -0
- package/dist/time.js.map +1 -0
- package/dist/tools/archival-memory-tool.d.ts +8 -0
- package/dist/tools/archival-memory-tool.d.ts.map +1 -0
- package/dist/tools/archival-memory-tool.js +68 -0
- package/dist/tools/archival-memory-tool.js.map +1 -0
- package/dist/tools/conversation-search-tool.d.ts +6 -0
- package/dist/tools/conversation-search-tool.d.ts.map +1 -0
- package/dist/tools/conversation-search-tool.js +28 -0
- package/dist/tools/conversation-search-tool.js.map +1 -0
- package/dist/tools/core-memory-tool.d.ts +7 -0
- package/dist/tools/core-memory-tool.d.ts.map +1 -0
- package/dist/tools/core-memory-tool.js +59 -0
- package/dist/tools/core-memory-tool.js.map +1 -0
- package/dist/tools/fetch-url.d.ts +15 -0
- package/dist/tools/fetch-url.d.ts.map +1 -0
- package/dist/tools/fetch-url.js +76 -0
- package/dist/tools/fetch-url.js.map +1 -0
- package/dist/tools/files.d.ts +10 -0
- package/dist/tools/files.d.ts.map +1 -0
- package/dist/tools/files.js +127 -0
- package/dist/tools/files.js.map +1 -0
- package/dist/tools/index.d.ts +17 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +118 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/result.d.ts +21 -0
- package/dist/tools/result.d.ts.map +1 -0
- package/dist/tools/result.js +36 -0
- package/dist/tools/result.js.map +1 -0
- package/dist/tools/skill-install.d.ts +17 -0
- package/dist/tools/skill-install.d.ts.map +1 -0
- package/dist/tools/skill-install.js +148 -0
- package/dist/tools/skill-install.js.map +1 -0
- package/dist/tools/web-search.d.ts +42 -0
- package/dist/tools/web-search.d.ts.map +1 -0
- package/dist/tools/web-search.js +237 -0
- package/dist/tools/web-search.js.map +1 -0
- package/dist/trust/guardrail.d.ts +60 -0
- package/dist/trust/guardrail.d.ts.map +1 -0
- package/dist/trust/guardrail.js +171 -0
- package/dist/trust/guardrail.js.map +1 -0
- package/dist/trust/index.d.ts +12 -0
- package/dist/trust/index.d.ts.map +1 -0
- package/dist/trust/index.js +12 -0
- package/dist/trust/index.js.map +1 -0
- package/dist/trust/store.d.ts +118 -0
- package/dist/trust/store.d.ts.map +1 -0
- package/dist/trust/store.js +209 -0
- package/dist/trust/store.js.map +1 -0
- package/dist/trust/wrapper.d.ts +36 -0
- package/dist/trust/wrapper.d.ts.map +1 -0
- package/dist/trust/wrapper.js +142 -0
- package/dist/trust/wrapper.js.map +1 -0
- package/dist/usage.d.ts +53 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +124 -0
- package/dist/usage.js.map +1 -0
- package/dist/util/math.d.ts +9 -0
- package/dist/util/math.d.ts.map +1 -0
- package/dist/util/math.js +22 -0
- package/dist/util/math.js.map +1 -0
- package/dist/util/ssrf.d.ts +21 -0
- package/dist/util/ssrf.d.ts.map +1 -0
- package/dist/util/ssrf.js +77 -0
- package/dist/util/ssrf.js.map +1 -0
- package/dist/workers/index.d.ts +8 -0
- package/dist/workers/index.d.ts.map +1 -0
- package/dist/workers/index.js +7 -0
- package/dist/workers/index.js.map +1 -0
- package/dist/workers/registry.d.ts +53 -0
- package/dist/workers/registry.d.ts.map +1 -0
- package/dist/workers/registry.js +38 -0
- package/dist/workers/registry.js.map +1 -0
- package/dist/workers/scoped-tools.d.ts +21 -0
- package/dist/workers/scoped-tools.d.ts.map +1 -0
- package/dist/workers/scoped-tools.js +111 -0
- package/dist/workers/scoped-tools.js.map +1 -0
- package/dist/workers/spawn.d.ts +62 -0
- package/dist/workers/spawn.d.ts.map +1 -0
- package/dist/workers/spawn.js +314 -0
- package/dist/workers/spawn.js.map +1 -0
- package/dist/workers/tools.d.ts +26 -0
- package/dist/workers/tools.d.ts.map +1 -0
- package/dist/workers/tools.js +380 -0
- package/dist/workers/tools.js.map +1 -0
- package/docker-compose.yml +72 -0
- package/package.json +16 -1
- 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, "<")
|
|
29
|
+
.replace(/>/g, ">");
|
|
30
|
+
}
|
|
31
|
+
function escapeHtmlAttr(text) {
|
|
32
|
+
return escapeHtml(text).replace(/"/g, """);
|
|
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
|