@fastagent-sh/fastagent 0.12.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/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/agent.d.ts +72 -0
- package/dist/agent.js +14 -0
- package/dist/channels/body.d.ts +9 -0
- package/dist/channels/body.js +29 -0
- package/dist/channels/github/github.d.ts +31 -0
- package/dist/channels/github/github.js +80 -0
- package/dist/channels/github/scaffold/channel.ts +25 -0
- package/dist/channels/http.d.ts +13 -0
- package/dist/channels/http.js +161 -0
- package/dist/channels/respond.d.ts +5 -0
- package/dist/channels/respond.js +3 -0
- package/dist/channels/telegram/context-buffer.d.ts +59 -0
- package/dist/channels/telegram/context-buffer.js +139 -0
- package/dist/channels/telegram/invoke-turn.d.ts +53 -0
- package/dist/channels/telegram/invoke-turn.js +127 -0
- package/dist/channels/telegram/parse.d.ts +160 -0
- package/dist/channels/telegram/parse.js +153 -0
- package/dist/channels/telegram/preview.d.ts +22 -0
- package/dist/channels/telegram/preview.js +266 -0
- package/dist/channels/telegram/register-webhook.d.ts +13 -0
- package/dist/channels/telegram/register-webhook.js +73 -0
- package/dist/channels/telegram/scaffold/channel.ts +25 -0
- package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
- package/dist/channels/telegram/state.d.ts +9 -0
- package/dist/channels/telegram/state.js +58 -0
- package/dist/channels/telegram/telegram-api.d.ts +77 -0
- package/dist/channels/telegram/telegram-api.js +345 -0
- package/dist/channels/telegram/telegram.d.ts +35 -0
- package/dist/channels/telegram/telegram.js +352 -0
- package/dist/channels/telegram/turn-queue.d.ts +21 -0
- package/dist/channels/telegram/turn-queue.js +47 -0
- package/dist/channels/telegram/turn-store.d.ts +38 -0
- package/dist/channels/telegram/turn-store.js +150 -0
- package/dist/cli-auth.d.ts +24 -0
- package/dist/cli-auth.js +30 -0
- package/dist/cli-models.d.ts +11 -0
- package/dist/cli-models.js +20 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1288 -0
- package/dist/collect.d.ts +20 -0
- package/dist/collect.js +27 -0
- package/dist/core.d.ts +10 -0
- package/dist/core.js +8 -0
- package/dist/deploy/container.d.ts +56 -0
- package/dist/deploy/container.js +169 -0
- package/dist/deploy/fly/plan.d.ts +65 -0
- package/dist/deploy/fly/plan.js +142 -0
- package/dist/deploy/fly/run.d.ts +51 -0
- package/dist/deploy/fly/run.js +89 -0
- package/dist/deploy/preflight.d.ts +59 -0
- package/dist/deploy/preflight.js +191 -0
- package/dist/deploy/railway/plan.d.ts +48 -0
- package/dist/deploy/railway/plan.js +91 -0
- package/dist/deploy/railway/run.d.ts +81 -0
- package/dist/deploy/railway/run.js +176 -0
- package/dist/deploy/runner.d.ts +18 -0
- package/dist/deploy/runner.js +25 -0
- package/dist/deploy/secrets.d.ts +49 -0
- package/dist/deploy/secrets.js +88 -0
- package/dist/dev-supervisor.d.ts +14 -0
- package/dist/dev-supervisor.js +158 -0
- package/dist/engines/pi/auth.d.ts +17 -0
- package/dist/engines/pi/auth.js +118 -0
- package/dist/engines/pi/channel.d.ts +31 -0
- package/dist/engines/pi/channel.js +113 -0
- package/dist/engines/pi/chat.d.ts +19 -0
- package/dist/engines/pi/chat.js +200 -0
- package/dist/engines/pi/config.d.ts +110 -0
- package/dist/engines/pi/config.js +285 -0
- package/dist/engines/pi/create.d.ts +127 -0
- package/dist/engines/pi/create.js +222 -0
- package/dist/engines/pi/definition.d.ts +72 -0
- package/dist/engines/pi/definition.js +129 -0
- package/dist/engines/pi/harness.d.ts +50 -0
- package/dist/engines/pi/harness.js +41 -0
- package/dist/engines/pi/invoke.d.ts +28 -0
- package/dist/engines/pi/invoke.js +298 -0
- package/dist/engines/pi/login.d.ts +51 -0
- package/dist/engines/pi/login.js +125 -0
- package/dist/engines/pi/models.d.ts +41 -0
- package/dist/engines/pi/models.js +76 -0
- package/dist/engines/pi/report.d.ts +13 -0
- package/dist/engines/pi/report.js +21 -0
- package/dist/engines/pi/sessions.d.ts +15 -0
- package/dist/engines/pi/sessions.js +130 -0
- package/dist/engines/pi/tool-context.d.ts +17 -0
- package/dist/engines/pi/tool-context.js +13 -0
- package/dist/engines/pi/tool.d.ts +45 -0
- package/dist/engines/pi/tool.js +85 -0
- package/dist/engines/pi/wake-tool.d.ts +20 -0
- package/dist/engines/pi/wake-tool.js +110 -0
- package/dist/engines/pi/workspace.d.ts +53 -0
- package/dist/engines/pi/workspace.js +70 -0
- package/dist/env.d.ts +24 -0
- package/dist/env.js +57 -0
- package/dist/github.d.ts +3 -0
- package/dist/github.js +2 -0
- package/dist/host/node.d.ts +41 -0
- package/dist/host/node.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/invoke-stream.d.ts +10 -0
- package/dist/invoke-stream.js +26 -0
- package/dist/loader.d.ts +38 -0
- package/dist/loader.js +69 -0
- package/dist/log.d.ts +26 -0
- package/dist/log.js +51 -0
- package/dist/observe.d.ts +12 -0
- package/dist/observe.js +44 -0
- package/dist/pi.d.ts +16 -0
- package/dist/pi.js +12 -0
- package/dist/proxy.d.ts +9 -0
- package/dist/proxy.js +23 -0
- package/dist/runtime.d.ts +26 -0
- package/dist/runtime.js +29 -0
- package/dist/scaffold/add-channel.d.ts +46 -0
- package/dist/scaffold/add-channel.js +227 -0
- package/dist/scaffold/init.d.ts +54 -0
- package/dist/scaffold/init.js +249 -0
- package/dist/scaffold/templates/env.example +24 -0
- package/dist/scaffold/templates/fastagent.config.mjs +19 -0
- package/dist/scaffold/templates/gitignore +8 -0
- package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
- package/dist/scaffold/templates/gitignore.kit +2 -0
- package/dist/scaffold/templates/persona.md +10 -0
- package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
- package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
- package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
- package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
- package/dist/scaffold/templates.d.ts +17 -0
- package/dist/scaffold/templates.js +56 -0
- package/dist/scaffold/vendor-skill.d.ts +24 -0
- package/dist/scaffold/vendor-skill.js +157 -0
- package/dist/schedule/audit.d.ts +18 -0
- package/dist/schedule/audit.js +57 -0
- package/dist/schedule/cron.d.ts +7 -0
- package/dist/schedule/cron.js +33 -0
- package/dist/schedule/discover.d.ts +17 -0
- package/dist/schedule/discover.js +64 -0
- package/dist/schedule/schedule.d.ts +35 -0
- package/dist/schedule/schedule.js +23 -0
- package/dist/schedule/scheduler.d.ts +36 -0
- package/dist/schedule/scheduler.js +253 -0
- package/dist/schedule/state.d.ts +14 -0
- package/dist/schedule/state.js +56 -0
- package/dist/schedule/wakeups.d.ts +82 -0
- package/dist/schedule/wakeups.js +196 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +2 -0
- package/dist/tunnel.d.ts +31 -0
- package/dist/tunnel.js +139 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +13 -0
- package/dist/workspace.d.ts +9 -0
- package/dist/workspace.js +45 -0
- package/package.json +97 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Bot API transport. ONE pipeline (`callApi`) carries every JSON call — per-method code does
|
|
3
|
+
* not exist, so a transport rule can never be missing from one call site. The transport invariants
|
|
4
|
+
* live here and nowhere else:
|
|
5
|
+
*
|
|
6
|
+
* 1. Every call has a per-attempt timeout (API_TIMEOUT_MS; file bytes DOWNLOAD_TIMEOUT_MS) — a wedged
|
|
7
|
+
* connection cannot hang a turn, its session queue, or webhook registration.
|
|
8
|
+
* 2. Only a 429 is retried (bounded attempts, honouring `retry_after` up to FLOOD_WAIT_MAX_S per
|
|
9
|
+
* wait); a longer flood ban or exhausted retries fail visibly. No other failure class is retried:
|
|
10
|
+
* the request may have been processed, and a retried sendMessage would double-deliver.
|
|
11
|
+
* 3. Success requires the body's own `ok:true` — an intermediary's HTTP 200 is not a sent message.
|
|
12
|
+
* 4. Every failure is a {@link TelegramApiError} naming the method: self-description is a property of
|
|
13
|
+
* the error type, not per-call-site string assembly.
|
|
14
|
+
*
|
|
15
|
+
* On top of the pipeline sits the channel's POLICY: the 4096-char HTML-aware split, the HTML→plain
|
|
16
|
+
* parse fallback, and the getFile→download dance. (telegram.ts orchestrates; no agent, no rendering.)
|
|
17
|
+
*/
|
|
18
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
19
|
+
import { basename, join } from "node:path";
|
|
20
|
+
/** Telegram's hard text limit per message. */
|
|
21
|
+
export const TELEGRAM_MAX_TEXT = 4096;
|
|
22
|
+
const PARSE_ERROR = /can't parse|entit|unsupported|unclosed|tag/i;
|
|
23
|
+
/** Per-attempt timeout for a JSON Bot API call — small JSON round-trips, so 30s is generous. */
|
|
24
|
+
const API_TIMEOUT_MS = 30_000;
|
|
25
|
+
/** Timeout for downloading file bytes (up to the 20 MB cap) — sized for a slow link, not a JSON call. */
|
|
26
|
+
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
27
|
+
/** Longest flood wait (seconds) honoured PER ATTEMPT before failing visibly instead. Telegram's
|
|
28
|
+
* `retry_after` can reach minutes–hours on a flood ban; sleeping that long would silently park the
|
|
29
|
+
* turn and every queued turn behind it. Aggregate is bounded by RETRIES: ~3× this cap worst-case. */
|
|
30
|
+
const FLOOD_WAIT_MAX_S = 30;
|
|
31
|
+
/** How many 429s one call absorbs before giving up. */
|
|
32
|
+
const RETRIES = 3;
|
|
33
|
+
/** Download sanity cap (Telegram's own getFile limit); a larger file/image is rejected visibly. The
|
|
34
|
+
* engine resizes images to the model's needs, so this is a transport guard, not the model size limit. */
|
|
35
|
+
const MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024;
|
|
36
|
+
/** A named Bot API failure. `status` 0 = the transport itself failed (network error / timeout) before
|
|
37
|
+
* any HTTP status existed; otherwise the HTTP status with Telegram's own description (or a note that
|
|
38
|
+
* the body carried no usable answer). Module-private: no EXTERNAL caller matches on the type; it
|
|
39
|
+
* stays a typed class (not a plain Error) because this module's own retry logic — sendMessage's
|
|
40
|
+
* parse-mode fallback, editMessageText's "not modified" swallow — does `instanceof` + reads
|
|
41
|
+
* `.description`. */
|
|
42
|
+
class TelegramApiError extends Error {
|
|
43
|
+
method;
|
|
44
|
+
status;
|
|
45
|
+
description;
|
|
46
|
+
// No constructor parameter properties: the CLI runs source under Node's strip-only TS mode, which
|
|
47
|
+
// rejects them (ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX).
|
|
48
|
+
constructor(method, status, description, options) {
|
|
49
|
+
super(status === 0
|
|
50
|
+
? `telegram ${method}: ${description}`
|
|
51
|
+
: `telegram ${method} failed: ${status} ${description}`.trim(), options);
|
|
52
|
+
this.name = "TelegramApiError";
|
|
53
|
+
this.method = method;
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.description = description;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Sleep on the GLOBAL timer (not `node:timers/promises`) so tests can drive it with fake timers. */
|
|
59
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
60
|
+
/**
|
|
61
|
+
* The pipeline: one Bot API call, carrying every transport invariant (see the module header). Returns
|
|
62
|
+
* the method's `result` payload; throws {@link TelegramApiError} on any failure.
|
|
63
|
+
*/
|
|
64
|
+
export async function callApi(api, botToken, method, params) {
|
|
65
|
+
for (let attempt = 0;; attempt++) {
|
|
66
|
+
let res;
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
res = await fetch(`${api}/bot${botToken}/${method}`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "content-type": "application/json" },
|
|
72
|
+
body: JSON.stringify(params),
|
|
73
|
+
signal: AbortSignal.timeout(API_TIMEOUT_MS),
|
|
74
|
+
});
|
|
75
|
+
raw = await res.text(); // the body read shares the timeout — a mid-body stall is a transport failure too
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
throw new TelegramApiError(method, 0, String(e), { cause: e });
|
|
79
|
+
}
|
|
80
|
+
let data;
|
|
81
|
+
try {
|
|
82
|
+
data = JSON.parse(raw);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
data = {}; // only the parse is forgiven — the ok-gate below turns it into a named failure
|
|
86
|
+
}
|
|
87
|
+
if (res.ok && data.ok === true)
|
|
88
|
+
return data.result;
|
|
89
|
+
if (res.status === 429 && attempt < RETRIES) {
|
|
90
|
+
const floodWait = data.parameters?.retry_after ?? attempt + 1; // no retry_after → short linear backoff
|
|
91
|
+
if (floodWait <= FLOOD_WAIT_MAX_S) {
|
|
92
|
+
await wait((floodWait + 1) * 1000);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
throw new TelegramApiError(method, 429, `${data.description ?? ""} (retry_after ${floodWait}s exceeds the ${FLOOD_WAIT_MAX_S}s flood-wait cap)`.trim());
|
|
96
|
+
}
|
|
97
|
+
const exhausted = res.status === 429 ? ` (gave up after ${attempt} retries)` : "";
|
|
98
|
+
throw new TelegramApiError(method, res.status, `${data.description ?? "Bot API response was not the expected JSON"}${exhausted}`.trim());
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Tags left open at the end of `html`, innermost last, each as its full opening string (attributes and
|
|
102
|
+
* all) so a reopen reproduces `<a href=…>` / `<code class=…>` exactly. A close pops the nearest matching
|
|
103
|
+
* open. Telegram's HTML is a shallow flat subset, so this simple stack is enough. */
|
|
104
|
+
function unclosedTags(html) {
|
|
105
|
+
const stack = [];
|
|
106
|
+
for (const m of html.matchAll(/<(\/?)([a-z][a-z0-9-]*)(?:\s[^>]*)?>/gi)) {
|
|
107
|
+
const name = m[2]?.toLowerCase();
|
|
108
|
+
if (name === undefined)
|
|
109
|
+
continue;
|
|
110
|
+
if (m[1] === "/") {
|
|
111
|
+
const i = stack.map((t) => t.name).lastIndexOf(name);
|
|
112
|
+
if (i !== -1)
|
|
113
|
+
stack.splice(i, 1);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
stack.push({ name, open: m[0] });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return stack;
|
|
120
|
+
}
|
|
121
|
+
/** Reserve for the `</…>` closers appended at a chunk boundary — Telegram nesting is shallow, so this
|
|
122
|
+
* covers the worst realistic stack. A pathological deeper nest would push the chunk past 4096 and fail
|
|
123
|
+
* visibly on send (Telegram's "message is too long", not a silent truncation); real agent output does
|
|
124
|
+
* not reach it. */
|
|
125
|
+
const TAG_CLOSE_BUDGET = 64;
|
|
126
|
+
/**
|
|
127
|
+
* Split text into ≤4096-char chunks (Telegram's limit), preferring a newline boundary. When `html`, it is
|
|
128
|
+
* tag-aware: a tag that would SPAN a boundary is CLOSED at the chunk's end and REOPENED (attributes and
|
|
129
|
+
* all) at the next chunk's start, so every chunk is self-contained valid HTML — a long `<pre>` code block
|
|
130
|
+
* stays formatted instead of the first chunk degrading to plain text. It also never cuts THROUGH a tag
|
|
131
|
+
* token (backs the cut up before a `<` it would land inside). For plain text a `<` is literal content, so
|
|
132
|
+
* both behaviours are skipped.
|
|
133
|
+
*/
|
|
134
|
+
export function chunkText(text, opts = {}) {
|
|
135
|
+
if (text.length <= TELEGRAM_MAX_TEXT)
|
|
136
|
+
return [text];
|
|
137
|
+
const chunks = [];
|
|
138
|
+
let open = []; // tags carried across the current boundary (html only)
|
|
139
|
+
let rest = text;
|
|
140
|
+
while (rest.length > 0) {
|
|
141
|
+
const prefix = open.map((t) => t.open).join(""); // reopen carried tags at the chunk head
|
|
142
|
+
if (prefix.length + rest.length <= TELEGRAM_MAX_TEXT) {
|
|
143
|
+
chunks.push(prefix + rest); // the tail closes the carried tags itself
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
// Room for content, reserving the reopen prefix + (html) the closers appended below. ≥1 guarantees
|
|
147
|
+
// progress even in a pathological deep nest.
|
|
148
|
+
const room = Math.max(1, TELEGRAM_MAX_TEXT - prefix.length - (opts.html ? TAG_CLOSE_BUDGET : 0));
|
|
149
|
+
let cut = rest.lastIndexOf("\n", room);
|
|
150
|
+
if (cut <= 0)
|
|
151
|
+
cut = room; // no newline in range → hard cut at the limit
|
|
152
|
+
if (opts.html) {
|
|
153
|
+
// Don't cut through a tag token: if the last `<` before the cut has no `>` after it, back up to it
|
|
154
|
+
// (lt > 0 keeps at least one content char, so the loop still progresses).
|
|
155
|
+
const lt = rest.lastIndexOf("<", cut - 1);
|
|
156
|
+
if (lt > 0 && lt > rest.lastIndexOf(">", cut - 1))
|
|
157
|
+
cut = lt;
|
|
158
|
+
// Likewise don't split an entity token (`&`): entities are short, so a `&` within ~12 chars of
|
|
159
|
+
// the cut with no `;` yet means the cut is inside one — back up to that `&`. Only when the `&` is
|
|
160
|
+
// CONTENT, not inside a tag token (a raw `&` in an attribute is legal and common — any href with
|
|
161
|
+
// query params); backing up to one of those would cut through the tag this very rule's sibling
|
|
162
|
+
// protects.
|
|
163
|
+
const amp = rest.lastIndexOf("&", cut - 1);
|
|
164
|
+
if (amp > 0 &&
|
|
165
|
+
cut - amp < 12 &&
|
|
166
|
+
amp > rest.lastIndexOf(";", cut - 1) &&
|
|
167
|
+
rest.lastIndexOf("<", amp) <= rest.lastIndexOf(">", amp))
|
|
168
|
+
cut = amp;
|
|
169
|
+
}
|
|
170
|
+
let chunk = rest.slice(0, cut);
|
|
171
|
+
let advance = cut;
|
|
172
|
+
if (opts.html) {
|
|
173
|
+
// A boundary newline is CONTENT in html (it may sit inside a <pre>) — keep it at this chunk's end
|
|
174
|
+
// (before the closers) rather than dropping it, so rejoined <pre> code is lossless.
|
|
175
|
+
if (rest[cut] === "\n") {
|
|
176
|
+
chunk += "\n";
|
|
177
|
+
advance = cut + 1;
|
|
178
|
+
}
|
|
179
|
+
open = unclosedTags(prefix + chunk);
|
|
180
|
+
chunk += open
|
|
181
|
+
.map((t) => `</${t.name}>`)
|
|
182
|
+
.reverse()
|
|
183
|
+
.join("");
|
|
184
|
+
}
|
|
185
|
+
chunks.push(prefix + chunk);
|
|
186
|
+
// Plain text: drop the boundary newline (the message split itself separates the parts). Html: keep
|
|
187
|
+
// everything (the newline was already folded into the chunk above).
|
|
188
|
+
rest = opts.html ? rest.slice(advance) : rest.slice(cut).replace(/^\n/, "");
|
|
189
|
+
}
|
|
190
|
+
return chunks;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Send a message: split to Telegram's 4096-char limit, each chunk as HTML by default (`html:false` for a
|
|
194
|
+
* plain live-preview). If Telegram rejects the markup on the FIRST chunk (a model formatting slip), the
|
|
195
|
+
* whole body is re-chunked and resent as PLAIN — re-chunked, not the same bytes, so the tag-balancer's
|
|
196
|
+
* injected boundary tags don't leak as literal text. (A later chunk failing after the first parsed
|
|
197
|
+
* cleanly is rare; it falls back per-chunk, best-effort.) Returns the FIRST chunk's message_id (so the
|
|
198
|
+
* caller can edit it as a live preview). `message_thread_id` is dropped from the JSON when undefined.
|
|
199
|
+
*/
|
|
200
|
+
export async function sendMessage(api, botToken, t, body, opts = {}) {
|
|
201
|
+
let mode = opts.html ?? true;
|
|
202
|
+
let chunks = chunkText(body, { html: mode });
|
|
203
|
+
let firstId;
|
|
204
|
+
let first = true;
|
|
205
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
206
|
+
const chunk = chunks[i];
|
|
207
|
+
if (chunk === undefined)
|
|
208
|
+
continue;
|
|
209
|
+
const base = { chat_id: t.chatId, message_thread_id: t.threadId, text: chunk };
|
|
210
|
+
// Reply to the summoning message on the FIRST chunk only — threads the answer under the asker in a
|
|
211
|
+
// group; allow_sending_without_reply so a since-deleted original still delivers. Continuation
|
|
212
|
+
// chunks post plainly right after (N reply-quotes would be noise).
|
|
213
|
+
if (first && t.replyTo !== undefined) {
|
|
214
|
+
base.reply_parameters = { message_id: t.replyTo, allow_sending_without_reply: true };
|
|
215
|
+
}
|
|
216
|
+
let result;
|
|
217
|
+
try {
|
|
218
|
+
result = await callApi(api, botToken, "sendMessage", mode ? { ...base, parse_mode: "HTML" } : base);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
if (!(mode && e instanceof TelegramApiError && PARSE_ERROR.test(e.description)))
|
|
222
|
+
throw e;
|
|
223
|
+
if (first) {
|
|
224
|
+
// Nothing sent yet — re-chunk the whole body as plain (no injected boundary tags leak) and restart.
|
|
225
|
+
mode = false;
|
|
226
|
+
chunks = chunkText(body, { html: false });
|
|
227
|
+
i = -1;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
// A later chunk failed after the first parsed cleanly (rare) — best-effort plain resend of this chunk.
|
|
231
|
+
result = await callApi(api, botToken, "sendMessage", base);
|
|
232
|
+
}
|
|
233
|
+
if (first)
|
|
234
|
+
firstId = result.message_id;
|
|
235
|
+
first = false;
|
|
236
|
+
}
|
|
237
|
+
return firstId;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Edit a message in place — the live-preview mechanism (one message, repeatedly updated). Plain by
|
|
241
|
+
* default (a partial preview may contain unbalanced HTML); `html:true` for the final answer, falling
|
|
242
|
+
* back to plain if Telegram rejects the markup. "message is not modified" is NOT an error: the pump may
|
|
243
|
+
* re-render an unchanged view. The message is identified by (chat_id, message_id) — no thread needed.
|
|
244
|
+
*/
|
|
245
|
+
export async function editMessageText(api, botToken, t, messageId, body, opts = {}) {
|
|
246
|
+
const base = {
|
|
247
|
+
chat_id: t.chatId,
|
|
248
|
+
message_id: messageId,
|
|
249
|
+
text: body.slice(0, TELEGRAM_MAX_TEXT),
|
|
250
|
+
};
|
|
251
|
+
const notModified = (e) => e instanceof TelegramApiError && /message is not modified/i.test(e.description);
|
|
252
|
+
try {
|
|
253
|
+
await callApi(api, botToken, "editMessageText", opts.html ? { ...base, parse_mode: "HTML" } : base);
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
if (notModified(e))
|
|
257
|
+
return;
|
|
258
|
+
if (!(opts.html && e instanceof TelegramApiError && PARSE_ERROR.test(e.description)))
|
|
259
|
+
throw e;
|
|
260
|
+
try {
|
|
261
|
+
await callApi(api, botToken, "editMessageText", base); // plain fallback for rejected markup
|
|
262
|
+
}
|
|
263
|
+
catch (e2) {
|
|
264
|
+
if (!notModified(e2))
|
|
265
|
+
throw e2;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function mimeFromPath(path) {
|
|
270
|
+
const ext = path.toLowerCase().split(".").pop();
|
|
271
|
+
return ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : ext === "gif" ? "image/gif" : "image/jpeg";
|
|
272
|
+
}
|
|
273
|
+
/** getFile → download the bytes, enforcing the 20 MB transport cap at both the metadata and byte stage.
|
|
274
|
+
* The shared core of fetching an image (→ vision) and a file (→ disk). Throws on failure / oversize. */
|
|
275
|
+
async function getFileBytes(api, botToken, fileId) {
|
|
276
|
+
const meta = await callApi(api, botToken, "getFile", { file_id: fileId });
|
|
277
|
+
const remotePath = meta.file_path;
|
|
278
|
+
if (!remotePath)
|
|
279
|
+
throw new Error(`telegram getFile: no file_path in response for ${fileId}`);
|
|
280
|
+
if ((meta.file_size ?? 0) > MAX_DOWNLOAD_BYTES)
|
|
281
|
+
throw new Error("file is too large (max 20 MB)");
|
|
282
|
+
// The byte download is the one non-JSON call (a GET of the file endpoint), so it cannot ride the
|
|
283
|
+
// pipeline — same timeout + naming discipline, applied here once.
|
|
284
|
+
let res;
|
|
285
|
+
let buf;
|
|
286
|
+
let errBody;
|
|
287
|
+
try {
|
|
288
|
+
res = await fetch(`${api}/file/bot${botToken}/${remotePath}`, {
|
|
289
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
290
|
+
});
|
|
291
|
+
buf = res.ok ? await res.arrayBuffer() : undefined;
|
|
292
|
+
errBody = res.ok ? undefined : await res.text(); // the error body self-describes (expired path etc.)
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
throw new TelegramApiError("file download", 0, String(e), { cause: e });
|
|
296
|
+
}
|
|
297
|
+
if (!res.ok || buf === undefined) {
|
|
298
|
+
let description;
|
|
299
|
+
try {
|
|
300
|
+
description = JSON.parse(errBody ?? "").description;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
/* non-JSON error body — fall through to the generic description */
|
|
304
|
+
}
|
|
305
|
+
throw new TelegramApiError("file download", res.status, description ?? "Bot API response was not the expected JSON");
|
|
306
|
+
}
|
|
307
|
+
const bytes = Buffer.from(buf);
|
|
308
|
+
if (bytes.byteLength > MAX_DOWNLOAD_BYTES)
|
|
309
|
+
throw new Error("file is too large (max 20 MB)");
|
|
310
|
+
return { bytes, remotePath };
|
|
311
|
+
}
|
|
312
|
+
/** Resolve a Telegram file_id to an ImageRef (getFile → download → base64). Throws on failure / oversize. */
|
|
313
|
+
async function fetchTelegramImage(api, botToken, fileId) {
|
|
314
|
+
const { bytes, remotePath } = await getFileBytes(api, botToken, fileId);
|
|
315
|
+
return { mimeType: mimeFromPath(remotePath), data: bytes.toString("base64") };
|
|
316
|
+
}
|
|
317
|
+
/** Download a Telegram file_id to <filesDir>/<chat>/<name>. Throws on failure / oversize. */
|
|
318
|
+
async function downloadTelegramFile(api, botToken, fileId, chatId, filesDir) {
|
|
319
|
+
const { bytes, remotePath } = await getFileBytes(api, botToken, fileId);
|
|
320
|
+
const name = basename(remotePath);
|
|
321
|
+
const dir = join(filesDir, String(chatId));
|
|
322
|
+
await mkdir(dir, { recursive: true });
|
|
323
|
+
const dest = join(dir, name);
|
|
324
|
+
await writeFile(dest, bytes);
|
|
325
|
+
return { path: dest, name, size: bytes.byteLength };
|
|
326
|
+
}
|
|
327
|
+
/** Fetch the message's images. Throws if any cannot be loaded — the caller surfaces it (no silent drop). */
|
|
328
|
+
export async function resolveImages(api, botToken, fileIds) {
|
|
329
|
+
if (!fileIds || fileIds.length === 0)
|
|
330
|
+
return undefined;
|
|
331
|
+
const images = [];
|
|
332
|
+
for (const id of fileIds)
|
|
333
|
+
images.push(await fetchTelegramImage(api, botToken, id));
|
|
334
|
+
return images;
|
|
335
|
+
}
|
|
336
|
+
/** Download the message's files into `filesDir` (the channel passes its state home's `files/`).
|
|
337
|
+
* Throws if any cannot be loaded — the caller surfaces it (no silent drop). */
|
|
338
|
+
export async function resolveFiles(api, botToken, fileIds, chatId, filesDir) {
|
|
339
|
+
if (!fileIds || fileIds.length === 0)
|
|
340
|
+
return undefined;
|
|
341
|
+
const files = [];
|
|
342
|
+
for (const id of fileIds)
|
|
343
|
+
files.push(await downloadTelegramFile(api, botToken, id, chatId, filesDir));
|
|
344
|
+
return files;
|
|
345
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ChannelModule } from "../../host/node.ts";
|
|
2
|
+
import { type TelegramMessage, type TelegramRoute, type TelegramUpdate, defaultTelegramRoute, telegramEnvelope } from "./parse.ts";
|
|
3
|
+
import { type TelegramFailure } from "./preview.ts";
|
|
4
|
+
export { defaultTelegramRoute, telegramEnvelope };
|
|
5
|
+
export type { TelegramFailure, TelegramMessage, TelegramRoute, TelegramUpdate };
|
|
6
|
+
export interface TelegramChannelOptions {
|
|
7
|
+
/** Webhook secret token (the `secret_token` you set via setWebhook); verifies inbound updates. */
|
|
8
|
+
secretToken: string;
|
|
9
|
+
/** Bot token — used to send the agent's reply via the Bot API. */
|
|
10
|
+
botToken: string;
|
|
11
|
+
/** Policy: whether/where to answer an update (return null to ignore). Defaults to {@link defaultTelegramRoute}. */
|
|
12
|
+
route?: (update: TelegramUpdate) => TelegramRoute | null;
|
|
13
|
+
/**
|
|
14
|
+
* Customer-facing failure text for the chat (the dev-facing full `details` always go to the operator
|
|
15
|
+
* log). Return a string to send it, or undefined/"" to stay silent. Default: a neutral message keyed
|
|
16
|
+
* on `retryable`. A developer's own bot can surface the raw details, e.g. `(f) => `⚠️ ${f.details}``.
|
|
17
|
+
*/
|
|
18
|
+
onError?: (failed: TelegramFailure) => string | undefined;
|
|
19
|
+
/** Bot @username for group @mention summon by the default route (else resolved via getMe). */
|
|
20
|
+
botUsername?: string;
|
|
21
|
+
/** Bot API base, for tests. Defaults to the public Telegram endpoint. */
|
|
22
|
+
apiBaseUrl?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build a Telegram bot channel: policy options in, a {@link ChannelModule} out. The framework (or an
|
|
26
|
+
* embedder) mounts it with the context — `telegramChannel(opts)` in `channels/telegram.ts` is the whole
|
|
27
|
+
* glue; `agent` and the state root arrive via ctx, never through user code. Mounts `POST /telegram`
|
|
28
|
+
* (the path `--tunnel` webhook registration expects). The adapter owns that route key; to serve the
|
|
29
|
+
* SAME instance at a different path (e.g. behind a rewriting proxy), re-key the returned module:
|
|
30
|
+
* `(ctx) => ({ "POST /bot": telegramChannel(opts)(ctx)["POST /telegram"]! })`.
|
|
31
|
+
* This re-routes ONE instance — it is not a way to run two telegram bots in one workspace: the state
|
|
32
|
+
* home is derived from the channel kind (`<stateRoot>/channels/telegram`), so a second instance would
|
|
33
|
+
* share the first's turn-store/context-buffer. One telegram instance per workspace (single-process).
|
|
34
|
+
*/
|
|
35
|
+
export declare function telegramChannel({ secretToken, botToken, route, onError, botUsername, apiBaseUrl, }: TelegramChannelOptions): ChannelModule;
|