@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,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram webhook registration — the TELEGRAM-domain step both `--tunnel` (dev, tunnel.ts) and
|
|
3
|
+
* `deploy … --run` (the host runners' post-deploy step) invoke. What "registering telegram" means lives
|
|
4
|
+
* here, beside the channel it serves; it reads the same .env tokens the channel uses.
|
|
5
|
+
*/
|
|
6
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
7
|
+
import { log } from "../../log.js";
|
|
8
|
+
import { callApi } from "./telegram-api.js";
|
|
9
|
+
/**
|
|
10
|
+
* Register `<baseUrl>/telegram` as the bot's webhook (with the .env secret). Waits for the server to be
|
|
11
|
+
* REACHABLE first — polling `<baseUrl>/health` — because Telegram VERIFIES the URL when you set it, and a
|
|
12
|
+
* fresh deploy's container (healthcheck + routing) or a fresh tunnel's DNS is not routable for some
|
|
13
|
+
* seconds after the deploy/tunnel command returns. Tracking real readiness (not a fixed timer) is what
|
|
14
|
+
* fixes the race that made the first real deploy need a manual `setWebhook`. Missing tokens print the
|
|
15
|
+
* manual instruction instead of failing. `opts` (timeouts) exist for tests; production uses the defaults.
|
|
16
|
+
*/
|
|
17
|
+
export async function registerTelegramWebhook(baseUrl, opts = {}) {
|
|
18
|
+
const botToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
19
|
+
const secret = process.env.TELEGRAM_SECRET_TOKEN;
|
|
20
|
+
const webhookUrl = `${baseUrl}/telegram`;
|
|
21
|
+
if (!botToken || !secret) {
|
|
22
|
+
log.info(`[fastagent] telegram: set TELEGRAM_BOT_TOKEN + TELEGRAM_SECRET_TOKEN in .env, then re-run to auto-register. Webhook URL: ${webhookUrl}`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// Align registration with the server actually serving. Don't setWebhook against a URL Telegram can't
|
|
26
|
+
// yet reach — it would fail, and a fixed retry window guesses the readiness delay (the deploy race).
|
|
27
|
+
log.info(`[fastagent] telegram: waiting for ${baseUrl} to be reachable before registering the webhook…`);
|
|
28
|
+
const ready = await waitForHealth(`${baseUrl}/health`, opts.readyTimeoutMs ?? 120_000, opts.readyIntervalMs ?? 3_000);
|
|
29
|
+
if (!ready) {
|
|
30
|
+
log.warn(`[fastagent] telegram: ${baseUrl}/health did not come up in time — the app may still be starting. ` +
|
|
31
|
+
`Register the webhook manually once it's up: curl "https://api.telegram.org/bot<token>/setWebhook" -d url=${webhookUrl} -d secret_token=<secret>`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Reachable → register. A short retry backstops Telegram's resolver lagging /health by a moment; only
|
|
35
|
+
// network-transient errors retry (a permanent "bad webhook" config error is reported, not retried).
|
|
36
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
37
|
+
if (attempt > 0)
|
|
38
|
+
await sleep(opts.retryMs ?? 2000);
|
|
39
|
+
try {
|
|
40
|
+
await callApi("https://api.telegram.org", botToken, "setWebhook", { url: webhookUrl, secret_token: secret });
|
|
41
|
+
log.info(`[fastagent] telegram: webhook registered → ${webhookUrl}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
const error = String(e);
|
|
46
|
+
if (!/resolve host|getaddrinfo|ENOTFOUND|fetch failed|ECONNRESET|timeout/i.test(error)) {
|
|
47
|
+
log.error(`[fastagent] telegram: setWebhook failed (${error}). Register manually with url=${webhookUrl}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
log.warn(`[fastagent] telegram: setWebhook still failing after retries. Register manually with url=${webhookUrl}`);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Poll `healthUrl` until it responds 200, or the timeout elapses. Any error (not routable yet, DNS not
|
|
56
|
+
* settled, connection refused) is a "keep waiting", not a failure — that IS the readiness signal. Each
|
|
57
|
+
* probe has its own short timeout so one slow attempt can't eat the whole budget.
|
|
58
|
+
*/
|
|
59
|
+
async function waitForHealth(healthUrl, timeoutMs, intervalMs) {
|
|
60
|
+
const deadline = Date.now() + timeoutMs;
|
|
61
|
+
for (;;) {
|
|
62
|
+
try {
|
|
63
|
+
if ((await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })).ok)
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* not routable yet — keep polling until the deadline */
|
|
68
|
+
}
|
|
69
|
+
if (Date.now() >= deadline)
|
|
70
|
+
return false;
|
|
71
|
+
await sleep(intervalMs);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { telegramChannel } from "@fastagent-sh/fastagent/telegram";
|
|
2
|
+
|
|
3
|
+
// A channel = a third-party ADAPTER (telegramChannel: verify + run + reply) configured with YOUR policy.
|
|
4
|
+
// fastagent discovers this file under channels/, mounts POST /telegram, and pipes the agent + state
|
|
5
|
+
// home to the adapter — this file holds only policy. Setup:
|
|
6
|
+
// 1. @BotFather → /newbot → put the bot token in TELEGRAM_BOT_TOKEN
|
|
7
|
+
// 2. pick a random TELEGRAM_SECRET_TOKEN (verifies that inbound updates really come from Telegram)
|
|
8
|
+
// 3. register the webhook once, pointing Telegram at POST /telegram with that secret:
|
|
9
|
+
// curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
|
|
10
|
+
// -d url=https://your.host/telegram -d secret_token=$TELEGRAM_SECRET_TOKEN
|
|
11
|
+
export default telegramChannel({
|
|
12
|
+
secretToken: process.env.TELEGRAM_SECRET_TOKEN ?? "", // missing → fails at startup (would accept forged updates)
|
|
13
|
+
botToken: process.env.TELEGRAM_BOT_TOKEN ?? "", // used to send the agent's reply back to the chat
|
|
14
|
+
// Dev/personal bot: surface raw errors to the chat so you (and your AI agent) can act on them. The
|
|
15
|
+
// chat is customer-facing by default — for a public bot, drop this or return a neutral string;
|
|
16
|
+
// full details always go to the server log regardless.
|
|
17
|
+
onError: (failed) => `⚠️ ${failed.details}`,
|
|
18
|
+
// The channel owns transport + format (HTML) + attachments (photo→vision, file→disk) + streaming.
|
|
19
|
+
// `route` (POLICY) is OPTIONAL — omitted, it uses defaultTelegramRoute: private chats always answer,
|
|
20
|
+
// groups only on a reply to THIS bot or an @mention of it. Override to customise, reusing the
|
|
21
|
+
// export — but pass your bot's identity: group summon needs it (the omitted default gets it from
|
|
22
|
+
// the channel; a bare `defaultTelegramRoute(u)` answers only private chats):
|
|
23
|
+
// route: (u) => defaultTelegramRoute(u, { botUsername: "my_bot" }) && { session: `user:${u.message?.from?.id}` },
|
|
24
|
+
// route: (u) => defaultTelegramRoute(u, { botUsername: "my_bot" }) && { text: `${telegramEnvelope(u.message!)}\n[extra]` },
|
|
25
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { defineTool, z } from "@fastagent-sh/fastagent";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Send a message or a local file back to a Telegram chat. In a CHAT turn the channel delivers the
|
|
6
|
+
// reply itself — this tool is for files, and for turns NO channel is carrying: a scheduled turn
|
|
7
|
+
// (schedules/<name>.ts) or a self-scheduled wake-up, whose plain reply is not delivered anywhere.
|
|
8
|
+
// The chatId comes from the [telegram: chat …] context line in a chat turn; a scheduled turn has no
|
|
9
|
+
// such line, so the schedule's prompt must name the target chat id. tools/ is auto-discovered.
|
|
10
|
+
|
|
11
|
+
// Telegram caps a message at 4096 chars. Splitting is the TOOL's job, not the model's (counting chars
|
|
12
|
+
// is exactly what an LLM can't do — the channel encodes the same constraint in telegram-api.ts): chunk
|
|
13
|
+
// at the last newline under the cap, hard-cut a single overlong line. Plain-text mirror of the channel
|
|
14
|
+
// policy (this template cannot import core internals).
|
|
15
|
+
const MAX_TEXT = 4096;
|
|
16
|
+
function splitPlain(text: string): string[] {
|
|
17
|
+
const parts: string[] = [];
|
|
18
|
+
let rest = text;
|
|
19
|
+
while (rest.length > MAX_TEXT) {
|
|
20
|
+
let cut = rest.lastIndexOf("\n", MAX_TEXT);
|
|
21
|
+
if (cut <= 0) cut = MAX_TEXT;
|
|
22
|
+
parts.push(rest.slice(0, cut));
|
|
23
|
+
rest = rest.slice(cut).replace(/^\n/, "");
|
|
24
|
+
}
|
|
25
|
+
if (rest.length > 0 || parts.length === 0) parts.push(rest);
|
|
26
|
+
return parts;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Standalone copy of the channel transport's discipline: an upload timeout so a wedged connection
|
|
30
|
+
// can't hang the tool call (and the turn), named errors, and success gated on the body's own ok.
|
|
31
|
+
// Deliberately NO 429 retry — a tool error goes back to the agent, which can decide to retry;
|
|
32
|
+
// fail-fast beats a silently sleeping tool.
|
|
33
|
+
async function callBotApi(token: string, method: string, form: FormData): Promise<void> {
|
|
34
|
+
let res: Response;
|
|
35
|
+
let raw: string;
|
|
36
|
+
try {
|
|
37
|
+
res = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
body: form,
|
|
40
|
+
signal: AbortSignal.timeout(120_000),
|
|
41
|
+
});
|
|
42
|
+
raw = await res.text();
|
|
43
|
+
} catch (e) {
|
|
44
|
+
throw new Error(`telegram ${method}: ${String(e)}`, { cause: e });
|
|
45
|
+
}
|
|
46
|
+
let data: { ok?: boolean; description?: string };
|
|
47
|
+
try {
|
|
48
|
+
data = JSON.parse(raw) as { ok?: boolean; description?: string };
|
|
49
|
+
} catch {
|
|
50
|
+
data = {};
|
|
51
|
+
}
|
|
52
|
+
if (!res.ok || !data.ok) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`telegram ${method} failed: ${res.status} ${data.description ?? "Bot API response was not the expected JSON"}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default defineTool({
|
|
60
|
+
description:
|
|
61
|
+
"Send to a Telegram chat: a text message (`text` — long text is split into multiple messages " +
|
|
62
|
+
"automatically), or a local file (`path` — a document, or a photo if it is an image). Exactly one " +
|
|
63
|
+
"of text/path. In a chat turn take chatId from the [telegram: chat …] context line; in a " +
|
|
64
|
+
"scheduled/woken turn (no context line) the chat id must come from your instruction.",
|
|
65
|
+
input: z.object({
|
|
66
|
+
chatId: z.union([z.string(), z.number()]).describe("target chat id"),
|
|
67
|
+
text: z.string().optional().describe("message text to send"),
|
|
68
|
+
path: z.string().optional().describe("absolute path of the local file to send"),
|
|
69
|
+
caption: z.string().optional().describe("file caption (file mode only)"),
|
|
70
|
+
asPhoto: z.boolean().optional().describe("send the file as a photo (inline) instead of a document"),
|
|
71
|
+
messageThreadId: z.number().optional().describe("thread to reply into (from the context line), if any"),
|
|
72
|
+
}),
|
|
73
|
+
async execute({ chatId, text, path, caption, asPhoto, messageThreadId }) {
|
|
74
|
+
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
75
|
+
if (!token) throw new Error("TELEGRAM_BOT_TOKEN is not set");
|
|
76
|
+
if ((text === undefined) === (path === undefined)) {
|
|
77
|
+
throw new Error("pass exactly one of `text` (a message) or `path` (a file)");
|
|
78
|
+
}
|
|
79
|
+
const base = (): FormData => {
|
|
80
|
+
const form = new FormData();
|
|
81
|
+
form.set("chat_id", String(chatId));
|
|
82
|
+
if (messageThreadId !== undefined) form.set("message_thread_id", String(messageThreadId));
|
|
83
|
+
return form;
|
|
84
|
+
};
|
|
85
|
+
if (text !== undefined) {
|
|
86
|
+
// A file-only param alongside text would be silently dropped — same parameter-responsibility
|
|
87
|
+
// class as the XOR above, so it gets the same corrective error, not a silent ignore.
|
|
88
|
+
if (caption !== undefined || asPhoto !== undefined) {
|
|
89
|
+
throw new Error("`caption`/`asPhoto` are file-mode only — with `text`, put everything in the text");
|
|
90
|
+
}
|
|
91
|
+
const chunks = splitPlain(text);
|
|
92
|
+
for (const chunk of chunks) {
|
|
93
|
+
const form = base();
|
|
94
|
+
form.set("text", chunk);
|
|
95
|
+
await callBotApi(token, "sendMessage", form);
|
|
96
|
+
}
|
|
97
|
+
return chunks.length === 1
|
|
98
|
+
? `sent message to chat ${chatId}`
|
|
99
|
+
: `sent ${chunks.length} messages to chat ${chatId} (split at Telegram's length cap)`;
|
|
100
|
+
}
|
|
101
|
+
const form = base();
|
|
102
|
+
if (caption) form.set("caption", caption);
|
|
103
|
+
form.set(asPhoto ? "photo" : "document", new Blob([await readFile(path as string)]), basename(path as string));
|
|
104
|
+
await callBotApi(token, asPhoto ? "sendPhoto" : "sendDocument", form);
|
|
105
|
+
return `sent ${basename(path as string)} to chat ${chatId}`;
|
|
106
|
+
},
|
|
107
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Create the state home and self-ignore it (`.gitignore="*"`): its contents (buffers, downloaded
|
|
2
|
+
* files) can carry chat content and must never be committable. The workspace opener already protects
|
|
3
|
+
* an in-tree state root; this local guard also covers direct embedders. `wx` never clobbers an
|
|
4
|
+
* operator's own file. */
|
|
5
|
+
export declare function ensureStateHome(dir: string): void;
|
|
6
|
+
/** Returns `unknown` on purpose — no generic pretending otherwise: the file is an IO boundary, and the
|
|
7
|
+
* caller owns shape validation (a `<T>` here would be an unchecked cast wearing a type). */
|
|
8
|
+
export declare function loadStateFile(path: string): unknown;
|
|
9
|
+
export declare function saveStateFile(path: string, value: unknown): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable channel state for a SINGLE-PROCESS deployment (the supported production shape — no
|
|
3
|
+
* cross-instance locking; two processes must not share a state dir). Small JSON files, written
|
|
4
|
+
* atomically (tmp + rename), so a crash leaves the previous version on disk, never a torn file.
|
|
5
|
+
* Writes are synchronous: the files are KB-sized and a write that completes BEFORE the webhook 200
|
|
6
|
+
* is what makes the state actually durable (an ACKed update is never redelivered by Telegram).
|
|
7
|
+
*
|
|
8
|
+
* Failure split: a CORRUPT file (bad JSON) degrades visibly — log.warn + start empty — because channel
|
|
9
|
+
* state is recoverable context, not worth refusing to boot over. An unreadable file (permissions, IO)
|
|
10
|
+
* is an ENVIRONMENT error the operator must fix: it throws, and construction fails loudly — booting
|
|
11
|
+
* with silently-empty state would hide real data behind a config mistake.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { log } from "../../log.js";
|
|
16
|
+
/** Create the state home and self-ignore it (`.gitignore="*"`): its contents (buffers, downloaded
|
|
17
|
+
* files) can carry chat content and must never be committable. The workspace opener already protects
|
|
18
|
+
* an in-tree state root; this local guard also covers direct embedders. `wx` never clobbers an
|
|
19
|
+
* operator's own file. */
|
|
20
|
+
export function ensureStateHome(dir) {
|
|
21
|
+
mkdirSync(dir, { recursive: true });
|
|
22
|
+
try {
|
|
23
|
+
writeFileSync(join(dir, ".gitignore"), "*\n", { flag: "wx" });
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
if (e.code !== "EEXIST")
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Returns `unknown` on purpose — no generic pretending otherwise: the file is an IO boundary, and the
|
|
31
|
+
* caller owns shape validation (a `<T>` here would be an unchecked cast wearing a type). */
|
|
32
|
+
export function loadStateFile(path) {
|
|
33
|
+
let raw;
|
|
34
|
+
try {
|
|
35
|
+
raw = readFileSync(path, "utf8");
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
if (e.code === "ENOENT")
|
|
39
|
+
return undefined; // first run — normal
|
|
40
|
+
// Permissions/IO: an environment error — fail the boot loudly rather than run on invisible state.
|
|
41
|
+
throw new Error(`telegram state file ${path} is unreadable — fix permissions/disk and restart: ${String(e)}`, {
|
|
42
|
+
cause: e,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(raw);
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
log.warn(`[telegram] corrupt state file ${path} — starting empty: ${String(e)}`);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function saveStateFile(path, value) {
|
|
54
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
55
|
+
const tmp = `${path}.tmp`;
|
|
56
|
+
writeFileSync(tmp, JSON.stringify(value));
|
|
57
|
+
renameSync(tmp, path);
|
|
58
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ImageRef } from "../../agent.ts";
|
|
2
|
+
/** Telegram's hard text limit per message. */
|
|
3
|
+
export declare const TELEGRAM_MAX_TEXT = 4096;
|
|
4
|
+
/** Where a reply goes: a chat, optionally a thread (Threaded Mode), optionally replying to a message. */
|
|
5
|
+
export interface Target {
|
|
6
|
+
chatId: number | string;
|
|
7
|
+
threadId?: number;
|
|
8
|
+
/** Message to reply to (the summoning message). Set in groups so the answer threads under the asker. */
|
|
9
|
+
replyTo?: number;
|
|
10
|
+
}
|
|
11
|
+
/** A downloaded inbound file: an absolute local path the agent's tools (read/bash) can open. */
|
|
12
|
+
export interface DownloadedFile {
|
|
13
|
+
path: string;
|
|
14
|
+
name: string;
|
|
15
|
+
size: number;
|
|
16
|
+
}
|
|
17
|
+
/** The methods this channel speaks and their result shapes — a hand-written slice of the Bot API
|
|
18
|
+
* schema. Adding a method = adding a row here, not writing a function. (If this table ever needs to
|
|
19
|
+
* grow past roughly ten rows, or entity-based formatting, adopt gramIO instead of growing it.) */
|
|
20
|
+
interface Api {
|
|
21
|
+
sendMessage: {
|
|
22
|
+
message_id?: number;
|
|
23
|
+
};
|
|
24
|
+
editMessageText: unknown;
|
|
25
|
+
deleteMessage: unknown;
|
|
26
|
+
getMe: {
|
|
27
|
+
username?: string;
|
|
28
|
+
can_read_all_group_messages?: boolean;
|
|
29
|
+
};
|
|
30
|
+
getFile: {
|
|
31
|
+
file_path?: string;
|
|
32
|
+
file_size?: number;
|
|
33
|
+
};
|
|
34
|
+
setWebhook: unknown;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The pipeline: one Bot API call, carrying every transport invariant (see the module header). Returns
|
|
38
|
+
* the method's `result` payload; throws {@link TelegramApiError} on any failure.
|
|
39
|
+
*/
|
|
40
|
+
export declare function callApi<M extends keyof Api>(api: string, botToken: string, method: M, params: Record<string, unknown>): Promise<Api[M]>;
|
|
41
|
+
/**
|
|
42
|
+
* Split text into ≤4096-char chunks (Telegram's limit), preferring a newline boundary. When `html`, it is
|
|
43
|
+
* tag-aware: a tag that would SPAN a boundary is CLOSED at the chunk's end and REOPENED (attributes and
|
|
44
|
+
* all) at the next chunk's start, so every chunk is self-contained valid HTML — a long `<pre>` code block
|
|
45
|
+
* stays formatted instead of the first chunk degrading to plain text. It also never cuts THROUGH a tag
|
|
46
|
+
* token (backs the cut up before a `<` it would land inside). For plain text a `<` is literal content, so
|
|
47
|
+
* both behaviours are skipped.
|
|
48
|
+
*/
|
|
49
|
+
export declare function chunkText(text: string, opts?: {
|
|
50
|
+
html?: boolean;
|
|
51
|
+
}): string[];
|
|
52
|
+
/**
|
|
53
|
+
* Send a message: split to Telegram's 4096-char limit, each chunk as HTML by default (`html:false` for a
|
|
54
|
+
* plain live-preview). If Telegram rejects the markup on the FIRST chunk (a model formatting slip), the
|
|
55
|
+
* whole body is re-chunked and resent as PLAIN — re-chunked, not the same bytes, so the tag-balancer's
|
|
56
|
+
* injected boundary tags don't leak as literal text. (A later chunk failing after the first parsed
|
|
57
|
+
* cleanly is rare; it falls back per-chunk, best-effort.) Returns the FIRST chunk's message_id (so the
|
|
58
|
+
* caller can edit it as a live preview). `message_thread_id` is dropped from the JSON when undefined.
|
|
59
|
+
*/
|
|
60
|
+
export declare function sendMessage(api: string, botToken: string, t: Target, body: string, opts?: {
|
|
61
|
+
html?: boolean;
|
|
62
|
+
}): Promise<number | undefined>;
|
|
63
|
+
/**
|
|
64
|
+
* Edit a message in place — the live-preview mechanism (one message, repeatedly updated). Plain by
|
|
65
|
+
* default (a partial preview may contain unbalanced HTML); `html:true` for the final answer, falling
|
|
66
|
+
* back to plain if Telegram rejects the markup. "message is not modified" is NOT an error: the pump may
|
|
67
|
+
* re-render an unchanged view. The message is identified by (chat_id, message_id) — no thread needed.
|
|
68
|
+
*/
|
|
69
|
+
export declare function editMessageText(api: string, botToken: string, t: Target, messageId: number, body: string, opts?: {
|
|
70
|
+
html?: boolean;
|
|
71
|
+
}): Promise<void>;
|
|
72
|
+
/** Fetch the message's images. Throws if any cannot be loaded — the caller surfaces it (no silent drop). */
|
|
73
|
+
export declare function resolveImages(api: string, botToken: string, fileIds: string[] | undefined): Promise<ImageRef[] | undefined>;
|
|
74
|
+
/** Download the message's files into `filesDir` (the channel passes its state home's `files/`).
|
|
75
|
+
* Throws if any cannot be loaded — the caller surfaces it (no silent drop). */
|
|
76
|
+
export declare function resolveFiles(api: string, botToken: string, fileIds: string[] | undefined, chatId: number | string, filesDir: string): Promise<DownloadedFile[] | undefined>;
|
|
77
|
+
export {};
|