@livo-build/runtime 0.2.1 → 0.2.2

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.
@@ -0,0 +1,35 @@
1
+ interface MinimalEnv {
2
+ [key: string]: unknown;
3
+ }
4
+ export interface IndexerOptions {
5
+ /** Override the endpoint directly (takes precedence over env). */
6
+ url?: string;
7
+ /** env key holding the GraphQL endpoint. Default `INDEXER_<NAME>_URL`. */
8
+ urlSecret?: string;
9
+ }
10
+ export declare class GraphQLError extends Error {
11
+ readonly errors: unknown[];
12
+ constructor(message: string, errors: unknown[]);
13
+ }
14
+ /** Binding-name form of an indexer name: upper-snake (INDEXER_<THIS>_URL). */
15
+ export declare function indexerEnvKey(name: string): string;
16
+ export declare class Indexer {
17
+ readonly name: string;
18
+ readonly url: string | undefined;
19
+ constructor(env: MinimalEnv | undefined, name: string, options?: IndexerOptions);
20
+ /** True when the endpoint binding for this indexer was injected. */
21
+ get configured(): boolean;
22
+ /**
23
+ * Run a GraphQL query and return `data` (typed via the caller's generic).
24
+ * Throws on transport errors or GraphQL `errors`.
25
+ */
26
+ query<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T>;
27
+ /** The subgraph's `_meta` — current head block + whether indexing has errored. */
28
+ meta(): Promise<{
29
+ block: number | null;
30
+ hasIndexingErrors: boolean;
31
+ }>;
32
+ }
33
+ /** Convenience: `await indexer(env, "hearth").query(gql, vars)`. */
34
+ export declare function indexer(env: MinimalEnv | undefined, name: string, options?: IndexerOptions): Indexer;
35
+ export {};
@@ -0,0 +1,73 @@
1
+ // Indexer — a typed GraphQL client for a project's Goldsky subgraph. A keeper /
2
+ // bot / server reads `INDEXER_<NAME>_URL` (injected at deploy as the subgraph's
3
+ // STABLE `live`-tag alias, NOT a version-pinned URL), so the worker never holds a
4
+ // URL that breaks on the next sync_indexers/reindex. Just:
5
+ //
6
+ // const { keepers } = await indexer(env, "hearth").query(
7
+ // `query($min:Int!){ keepers(where:{count_gte:$min}){ id count } }`, { min: 3 });
8
+ //
9
+ // Mirrors the Queue producer-binding pattern (queue.ts): a deployable gets the
10
+ // binding injected by the platform; this wraps it so there's no fetch/URL/GraphQL
11
+ // boilerplate to hand-roll. Pass an explicit `url` to point it anywhere (local dev).
12
+ export class GraphQLError extends Error {
13
+ errors;
14
+ constructor(message, errors) {
15
+ super(message);
16
+ this.errors = errors;
17
+ this.name = "GraphQLError";
18
+ }
19
+ }
20
+ /** Binding-name form of an indexer name: upper-snake (INDEXER_<THIS>_URL). */
21
+ export function indexerEnvKey(name) {
22
+ return name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
23
+ }
24
+ export class Indexer {
25
+ name;
26
+ url;
27
+ constructor(env, name, options = {}) {
28
+ this.name = name;
29
+ const key = options.urlSecret ?? `INDEXER_${indexerEnvKey(name)}_URL`;
30
+ this.url = options.url ?? env?.[key];
31
+ }
32
+ /** True when the endpoint binding for this indexer was injected. */
33
+ get configured() {
34
+ return Boolean(this.url);
35
+ }
36
+ /**
37
+ * Run a GraphQL query and return `data` (typed via the caller's generic).
38
+ * Throws on transport errors or GraphQL `errors`.
39
+ */
40
+ async query(query, variables) {
41
+ if (!this.url) {
42
+ throw new Error(`indexer "${this.name}" not configured — no INDEXER_${indexerEnvKey(this.name)}_URL binding. ` +
43
+ `Deploy/sync the subgraph (create_indexer / sync_indexers) so the platform injects it, ` +
44
+ `or pass { url } for local dev.`);
45
+ }
46
+ const res = await fetch(this.url, {
47
+ method: "POST",
48
+ headers: { "content-type": "application/json", accept: "application/json" },
49
+ body: JSON.stringify({ query, variables: variables ?? {} }),
50
+ });
51
+ if (!res.ok) {
52
+ throw new Error(`indexer "${this.name}" query failed: HTTP ${res.status}`);
53
+ }
54
+ const json = (await res.json());
55
+ if (json.errors && json.errors.length) {
56
+ const first = json.errors[0];
57
+ throw new GraphQLError(`indexer "${this.name}" GraphQL error: ${first?.message ?? "unknown"}`, json.errors);
58
+ }
59
+ return json.data;
60
+ }
61
+ /** The subgraph's `_meta` — current head block + whether indexing has errored. */
62
+ async meta() {
63
+ const data = await this.query(`{ _meta { block { number } hasIndexingErrors } }`);
64
+ return {
65
+ block: data._meta?.block?.number ?? null,
66
+ hasIndexingErrors: Boolean(data._meta?.hasIndexingErrors),
67
+ };
68
+ }
69
+ }
70
+ /** Convenience: `await indexer(env, "hearth").query(gql, vars)`. */
71
+ export function indexer(env, name, options) {
72
+ return new Indexer(env, name, options);
73
+ }
@@ -0,0 +1,30 @@
1
+ import { type AbiFunction } from "./abi.js";
2
+ import { type Hex } from "./hex.js";
3
+ /** Canonical Multicall3 (same address on mainnet, testnets, L2s, …). */
4
+ export declare const MULTICALL3_ADDRESS: Hex;
5
+ export interface MulticallCall {
6
+ address: Hex;
7
+ abi: AbiFunction[];
8
+ functionName: string;
9
+ args?: unknown[];
10
+ /** Per-call override of the batch allowFailure. */
11
+ allowFailure?: boolean;
12
+ }
13
+ export interface MulticallOptions {
14
+ /** When true (default), a reverting sub-call yields null instead of throwing the batch. */
15
+ allowFailure?: boolean;
16
+ /** Override the Multicall3 address (e.g. a local Anvil predeploy). */
17
+ multicallAddress?: Hex;
18
+ block?: Hex | "latest" | "pending";
19
+ }
20
+ /** Minimal Chain surface multicall needs — just the JSON-RPC passthrough. */
21
+ interface RpcLike {
22
+ rpc<T = unknown>(method: string, params?: unknown[]): Promise<T>;
23
+ }
24
+ /**
25
+ * Encode N reads into one aggregate3 eth_call and decode each result against its
26
+ * own function ABI. Returns one entry per input call (null for a reverted call
27
+ * when allowFailure). Throws on a reverted call when allowFailure is false.
28
+ */
29
+ export declare function multicall(chain: RpcLike, calls: MulticallCall[], opts?: MulticallOptions): Promise<(unknown | null)[]>;
30
+ export {};
@@ -0,0 +1,65 @@
1
+ // Multicall — batch many contract reads into one eth_call via Multicall3's
2
+ // aggregate3. Encodes/decodes with the same abi.ts coder the rest of the runtime
3
+ // uses (no new deps). Multicall3 is deployed at the canonical address on virtually
4
+ // every EVM chain; pass `multicallAddress` for exotic/local chains where it isn't.
5
+ import { decodeFunctionResult, decodeParameters, encodeFunctionData, encodeParameters, findFunction, functionSelector, } from "./abi.js";
6
+ import { bytesToHex, concatBytes, hexToBytes } from "./hex.js";
7
+ /** Canonical Multicall3 (same address on mainnet, testnets, L2s, …). */
8
+ export const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11";
9
+ // aggregate3((address target, bool allowFailure, bytes callData)[])
10
+ // returns ((bool success, bytes returnData)[])
11
+ const AGGREGATE3_INPUTS = [
12
+ {
13
+ type: "tuple[]",
14
+ components: [
15
+ { name: "target", type: "address" },
16
+ { name: "allowFailure", type: "bool" },
17
+ { name: "callData", type: "bytes" },
18
+ ],
19
+ },
20
+ ];
21
+ const AGGREGATE3_OUTPUTS = [
22
+ {
23
+ type: "tuple[]",
24
+ components: [
25
+ { name: "success", type: "bool" },
26
+ { name: "returnData", type: "bytes" },
27
+ ],
28
+ },
29
+ ];
30
+ // Computed once from the canonical signature — single source of truth with abi.ts.
31
+ const AGGREGATE3_SELECTOR = functionSelector("aggregate3((address,bool,bytes)[])");
32
+ /**
33
+ * Encode N reads into one aggregate3 eth_call and decode each result against its
34
+ * own function ABI. Returns one entry per input call (null for a reverted call
35
+ * when allowFailure). Throws on a reverted call when allowFailure is false.
36
+ */
37
+ export async function multicall(chain, calls, opts = {}) {
38
+ if (calls.length === 0)
39
+ return [];
40
+ const batchAllow = opts.allowFailure ?? true;
41
+ const fns = [];
42
+ const tuples = calls.map((c) => {
43
+ const fn = findFunction(c.abi, c.functionName);
44
+ fns.push(fn);
45
+ return {
46
+ target: c.address,
47
+ allowFailure: c.allowFailure ?? batchAllow,
48
+ callData: encodeFunctionData(fn, c.args ?? []),
49
+ };
50
+ });
51
+ const data = bytesToHex(concatBytes(AGGREGATE3_SELECTOR, encodeParameters(AGGREGATE3_INPUTS, [tuples])));
52
+ const result = await chain.rpc("eth_call", [
53
+ { to: opts.multicallAddress ?? MULTICALL3_ADDRESS, data },
54
+ opts.block ?? "latest",
55
+ ]);
56
+ const [rows] = decodeParameters(AGGREGATE3_OUTPUTS, hexToBytes(result));
57
+ return rows.map((row, i) => {
58
+ if (!row.success) {
59
+ if (calls[i].allowFailure ?? batchAllow)
60
+ return null;
61
+ throw new Error(`multicall: ${calls[i].functionName} on ${calls[i].address} reverted`);
62
+ }
63
+ return decodeFunctionResult(fns[i], hexToBytes(row.returnData));
64
+ });
65
+ }
@@ -0,0 +1,27 @@
1
+ interface MinimalEnv {
2
+ [key: string]: unknown;
3
+ }
4
+ /** Binding-name form of a queue name: upper-snake (QUEUE_<THIS>_URL/TOKEN). */
5
+ export declare function queueEnvKey(name: string): string;
6
+ export declare class Queue {
7
+ readonly name: string;
8
+ private readonly url?;
9
+ private readonly token?;
10
+ constructor(env: MinimalEnv | undefined, name: string);
11
+ /** True when the producer bindings for this queue were injected. */
12
+ get configured(): boolean;
13
+ /** Publish one message. */
14
+ send(message: unknown): Promise<{
15
+ ok: boolean;
16
+ queued?: number;
17
+ }>;
18
+ /** Publish many messages in one request. */
19
+ sendBatch(messages: unknown[]): Promise<{
20
+ ok: boolean;
21
+ queued?: number;
22
+ }>;
23
+ private post;
24
+ }
25
+ /** Convenience: `await queue(env, "settler").send(msg)`. */
26
+ export declare function queue(env: MinimalEnv | undefined, name: string): Queue;
27
+ export {};
package/dist/queue.js ADDED
@@ -0,0 +1,49 @@
1
+ // Queue — a producer handle for a Livo per-project queue (docs/QUEUES-SPEC.md). A
2
+ // deployable that declares `produces: [name]` gets QUEUE_<NAME>_URL +
3
+ // QUEUE_<NAME>_TOKEN injected at deploy; this wraps them so a keeper/bot/server just
4
+ // does `queue(env, "name").send(msg)` — no URL or token handling. The consumer then
5
+ // batches the messages to its consume(batch, env).
6
+ /** Binding-name form of a queue name: upper-snake (QUEUE_<THIS>_URL/TOKEN). */
7
+ export function queueEnvKey(name) {
8
+ return name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
9
+ }
10
+ export class Queue {
11
+ name;
12
+ url;
13
+ token;
14
+ constructor(env, name) {
15
+ this.name = name;
16
+ const k = queueEnvKey(name);
17
+ this.url = env?.[`QUEUE_${k}_URL`];
18
+ this.token = env?.[`QUEUE_${k}_TOKEN`];
19
+ }
20
+ /** True when the producer bindings for this queue were injected. */
21
+ get configured() {
22
+ return Boolean(this.url && this.token);
23
+ }
24
+ /** Publish one message. */
25
+ send(message) {
26
+ return this.post(message);
27
+ }
28
+ /** Publish many messages in one request. */
29
+ sendBatch(messages) {
30
+ return this.post(messages);
31
+ }
32
+ async post(body) {
33
+ if (!this.url || !this.token) {
34
+ throw new Error(`queue "${this.name}" not configured — add it to this deployable's \`produces:\` so QUEUE_${queueEnvKey(this.name)}_URL/TOKEN are injected`);
35
+ }
36
+ const res = await fetch(this.url, {
37
+ method: "POST",
38
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
39
+ body: JSON.stringify(body),
40
+ });
41
+ if (!res.ok)
42
+ throw new Error(`queue "${this.name}" send failed: HTTP ${res.status}`);
43
+ return (await res.json());
44
+ }
45
+ }
46
+ /** Convenience: `await queue(env, "settler").send(msg)`. */
47
+ export function queue(env, name) {
48
+ return new Queue(env, name);
49
+ }
@@ -0,0 +1,15 @@
1
+ interface MinimalEnv {
2
+ [key: string]: unknown;
3
+ }
4
+ /**
5
+ * Read a required secret/binding off `env`, throwing a clear error when it's
6
+ * missing or empty. Use at the top of a handler so misconfiguration fails fast:
7
+ *
8
+ * const token = requireSecret(env, "BOT_TOKEN");
9
+ */
10
+ export declare function requireSecret(env: MinimalEnv | undefined, name: string): string;
11
+ /** Read an optional secret/binding, returning `fallback` (default undefined) when absent or empty. */
12
+ export declare function getSecret(env: MinimalEnv | undefined, name: string, fallback?: string): string | undefined;
13
+ /** Assert several secrets are present at once; throws listing ALL missing ones. */
14
+ export declare function requireSecrets<K extends string>(env: MinimalEnv | undefined, names: readonly K[]): Record<K, string>;
15
+ export {};
package/dist/secret.js ADDED
@@ -0,0 +1,40 @@
1
+ // Typed secret access. Project secrets (set_secret) and auto-injected bindings
2
+ // arrive on the Worker `env`. Reading them raw means a missing one is `undefined`
3
+ // and silently disables a feature at runtime (the classic "my Telegram send did
4
+ // nothing" bug). `requireSecret` turns that into a loud, actionable error the
5
+ // moment the code runs, instead of a silent no-op.
6
+ /**
7
+ * Read a required secret/binding off `env`, throwing a clear error when it's
8
+ * missing or empty. Use at the top of a handler so misconfiguration fails fast:
9
+ *
10
+ * const token = requireSecret(env, "BOT_TOKEN");
11
+ */
12
+ export function requireSecret(env, name) {
13
+ const v = env?.[name];
14
+ if (typeof v === "string" && v.length > 0)
15
+ return v;
16
+ throw new Error(`missing secret "${name}" — set it with set_secret ${name}=<value> (or, for an ` +
17
+ `auto-injected binding like KEEPER_PRIVATE_KEY/RPC_URL, check it's wired for this deployable).`);
18
+ }
19
+ /** Read an optional secret/binding, returning `fallback` (default undefined) when absent or empty. */
20
+ export function getSecret(env, name, fallback) {
21
+ const v = env?.[name];
22
+ return typeof v === "string" && v.length > 0 ? v : fallback;
23
+ }
24
+ /** Assert several secrets are present at once; throws listing ALL missing ones. */
25
+ export function requireSecrets(env, names) {
26
+ const out = {};
27
+ const missing = [];
28
+ for (const n of names) {
29
+ const v = env?.[n];
30
+ if (typeof v === "string" && v.length > 0)
31
+ out[n] = v;
32
+ else
33
+ missing.push(n);
34
+ }
35
+ if (missing.length) {
36
+ throw new Error(`missing secret${missing.length > 1 ? "s" : ""} ${missing.map((m) => `"${m}"`).join(", ")} — ` +
37
+ `set with set_secret (e.g. set_secret ${missing[0]}=<value>).`);
38
+ }
39
+ return out;
40
+ }
package/dist/store.d.ts CHANGED
@@ -28,4 +28,15 @@ export declare class Store {
28
28
  getJSON<T>(key: string): Promise<T | null>;
29
29
  /** Upsert a JSON-serialized value. */
30
30
  setJSON<T>(key: string, value: T): Promise<void>;
31
+ /**
32
+ * Idempotency guard. Returns `true` the FIRST time a key is seen (claim it and
33
+ * proceed) and `false` on every later call — so a retried cron tick or a
34
+ * redelivered webhook doesn't double-fire:
35
+ *
36
+ * if (!(await store.once(`block:${n}`))) return; // already processed n
37
+ *
38
+ * Atomic via `INSERT … ON CONFLICT DO NOTHING` + the row-changed count, so two
39
+ * concurrent calls can't both win.
40
+ */
41
+ once(key: string): Promise<boolean>;
31
42
  }
package/dist/store.js CHANGED
@@ -57,4 +57,28 @@ export class Store {
57
57
  async setJSON(key, value) {
58
58
  await this.set(key, JSON.stringify(value));
59
59
  }
60
+ /**
61
+ * Idempotency guard. Returns `true` the FIRST time a key is seen (claim it and
62
+ * proceed) and `false` on every later call — so a retried cron tick or a
63
+ * redelivered webhook doesn't double-fire:
64
+ *
65
+ * if (!(await store.once(`block:${n}`))) return; // already processed n
66
+ *
67
+ * Atomic via `INSERT … ON CONFLICT DO NOTHING` + the row-changed count, so two
68
+ * concurrent calls can't both win.
69
+ */
70
+ async once(key) {
71
+ await this.init();
72
+ const res = (await this.db
73
+ .prepare(`INSERT INTO ${this.table} (k, v, updated_at) VALUES (?, '1', ?)
74
+ ON CONFLICT(k) DO NOTHING`)
75
+ .bind(key, Date.now())
76
+ .run());
77
+ // D1 reports affected rows on `meta.changes` (or `changes`); 1 ⇒ we inserted.
78
+ const changes = res?.meta?.changes ?? res?.changes;
79
+ if (typeof changes === "number")
80
+ return changes > 0;
81
+ // Driver didn't surface a count — fall back to a read (non-atomic, best effort).
82
+ return (await this.get(key)) === "1";
83
+ }
60
84
  }
@@ -0,0 +1,63 @@
1
+ interface MinimalEnv {
2
+ [key: string]: unknown;
3
+ }
4
+ export interface TelegramOptions {
5
+ /** env key for the bot token. Default "BOT_TOKEN". */
6
+ tokenSecret?: string;
7
+ /** env key for the webhook secret token. Default "WEBHOOK_SECRET". */
8
+ webhookSecretKey?: string;
9
+ /** Override the token directly. */
10
+ token?: string;
11
+ /** Override the webhook secret directly. */
12
+ webhookSecret?: string;
13
+ }
14
+ /** A parsed inbound message — the shape a reply handler actually wants. */
15
+ export interface BotMessage {
16
+ /** Trimmed message text. */
17
+ text: string;
18
+ /** Chat to reply into. */
19
+ chatId: number | string;
20
+ /** Sender attribution: "@username" or the numeric id as a string. */
21
+ handle: string;
22
+ /** Raw Telegram `from` object (id, username, first_name, …). */
23
+ from: Record<string, unknown> | undefined;
24
+ /** Raw Telegram message object. */
25
+ raw: Record<string, unknown>;
26
+ /** True when this came from Livo's test_bot harness (not real Telegram). */
27
+ isTest: boolean;
28
+ }
29
+ export interface SendMessageOptions {
30
+ parseMode?: "Markdown" | "MarkdownV2" | "HTML" | null;
31
+ replyToMessageId?: number;
32
+ disablePreview?: boolean;
33
+ }
34
+ export declare class Telegram {
35
+ private readonly token?;
36
+ private readonly webhookSecret?;
37
+ constructor(env: MinimalEnv | undefined, options?: TelegramOptions);
38
+ /** True when a bot token is configured (sends will actually reach Telegram). */
39
+ get configured(): boolean;
40
+ /**
41
+ * Verify a request really came from Telegram (or Livo's test_bot) by matching
42
+ * the secret-token header against WEBHOOK_SECRET. Returns true when no secret is
43
+ * configured (nothing to check). Use it if you parse the update yourself.
44
+ */
45
+ verify(req: Request): boolean;
46
+ /** Parse a raw Telegram update into a BotMessage, or null if it has no text message. */
47
+ parse(update: Record<string, unknown>): BotMessage | null;
48
+ /**
49
+ * Full webhook handler: verifies the secret, parses the update, runs `reply`,
50
+ * and either returns the computed reply to test_bot (__livo_test) or sends it to
51
+ * Telegram. `reply` returns a string to send, or null/undefined to stay silent.
52
+ * Returns the Worker Response — `return new Telegram(env).handleUpdate(req, fn)`.
53
+ */
54
+ handleUpdate(req: Request, reply: (msg: BotMessage) => string | null | undefined | Promise<string | null | undefined>, opts?: SendMessageOptions): Promise<Response>;
55
+ /** Send a message to a chat. No-op-safe: throws if no token is configured. */
56
+ sendMessage(chatId: number | string, text: string, opts?: SendMessageOptions): Promise<void>;
57
+ /** Register the slash-command menu (setMyCommands) — autocompletes in chat. */
58
+ setMyCommands(commands: Array<{
59
+ command: string;
60
+ description: string;
61
+ }>): Promise<void>;
62
+ }
63
+ export {};
@@ -0,0 +1,115 @@
1
+ // Telegram — collapses the webhook plumbing every Livo bot re-implements by hand:
2
+ // the secret-token gate, the `__livo_test` short-circuit (so test_bot can read a
3
+ // reply without hitting Telegram), update parsing, and the sendMessage call. A bot
4
+ // becomes just its reply logic:
5
+ //
6
+ // import { Telegram } from "@livo-build/runtime";
7
+ // export default {
8
+ // fetch(req, env) {
9
+ // return new Telegram(env).handleUpdate(req, (msg) =>
10
+ // msg.text === "/start" ? "👋 hi" : "you said " + msg.text);
11
+ // },
12
+ // };
13
+ //
14
+ // The deploy injects env.BOT_TOKEN (the resolved Telegram token) and
15
+ // env.WEBHOOK_SECRET (authenticates Telegram's calls) — same bindings the
16
+ // hand-rolled templates read.
17
+ const API = "https://api.telegram.org/bot";
18
+ export class Telegram {
19
+ token;
20
+ webhookSecret;
21
+ constructor(env, options = {}) {
22
+ this.token = options.token ?? env?.[options.tokenSecret ?? "BOT_TOKEN"];
23
+ this.webhookSecret =
24
+ options.webhookSecret ?? env?.[options.webhookSecretKey ?? "WEBHOOK_SECRET"];
25
+ }
26
+ /** True when a bot token is configured (sends will actually reach Telegram). */
27
+ get configured() {
28
+ return Boolean(this.token);
29
+ }
30
+ /**
31
+ * Verify a request really came from Telegram (or Livo's test_bot) by matching
32
+ * the secret-token header against WEBHOOK_SECRET. Returns true when no secret is
33
+ * configured (nothing to check). Use it if you parse the update yourself.
34
+ */
35
+ verify(req) {
36
+ if (!this.webhookSecret)
37
+ return true;
38
+ return req.headers.get("x-telegram-bot-api-secret-token") === this.webhookSecret;
39
+ }
40
+ /** Parse a raw Telegram update into a BotMessage, or null if it has no text message. */
41
+ parse(update) {
42
+ const msg = (update.message ?? update.edited_message);
43
+ const text = msg && typeof msg.text === "string" ? msg.text : undefined;
44
+ if (!msg || text === undefined)
45
+ return null;
46
+ const from = msg.from;
47
+ const username = from && typeof from.username === "string" ? from.username : undefined;
48
+ const handle = username ? `@${username}` : from?.id != null ? String(from.id) : "anon";
49
+ const chat = msg.chat;
50
+ return {
51
+ text: text.trim(),
52
+ chatId: chat?.id ?? 0,
53
+ handle,
54
+ from,
55
+ raw: msg,
56
+ isTest: update.__livo_test === true,
57
+ };
58
+ }
59
+ /**
60
+ * Full webhook handler: verifies the secret, parses the update, runs `reply`,
61
+ * and either returns the computed reply to test_bot (__livo_test) or sends it to
62
+ * Telegram. `reply` returns a string to send, or null/undefined to stay silent.
63
+ * Returns the Worker Response — `return new Telegram(env).handleUpdate(req, fn)`.
64
+ */
65
+ async handleUpdate(req, reply, opts = {}) {
66
+ if (req.method !== "POST")
67
+ return new Response("ok");
68
+ if (!this.verify(req))
69
+ return new Response("forbidden", { status: 403 });
70
+ const update = (await req.json().catch(() => ({})));
71
+ const msg = this.parse(update);
72
+ if (!msg)
73
+ return new Response("ok");
74
+ const out = (await reply(msg)) ?? null;
75
+ if (msg.isTest) {
76
+ return new Response(JSON.stringify({ reply: out }), {
77
+ headers: { "content-type": "application/json" },
78
+ });
79
+ }
80
+ if (out != null && this.configured) {
81
+ await this.sendMessage(msg.chatId, out, opts);
82
+ }
83
+ return new Response("ok");
84
+ }
85
+ /** Send a message to a chat. No-op-safe: throws if no token is configured. */
86
+ async sendMessage(chatId, text, opts = {}) {
87
+ if (!this.token) {
88
+ throw new Error("Telegram: no bot token — set it with set_secret BOT_TOKEN=<token> (the deploy injects it as env.BOT_TOKEN).");
89
+ }
90
+ const body = { chat_id: chatId, text };
91
+ if (opts.parseMode !== null)
92
+ body.parse_mode = opts.parseMode ?? "Markdown";
93
+ if (opts.replyToMessageId)
94
+ body.reply_to_message_id = opts.replyToMessageId;
95
+ if (opts.disablePreview)
96
+ body.disable_web_page_preview = true;
97
+ const res = await fetch(`${API}${this.token}/sendMessage`, {
98
+ method: "POST",
99
+ headers: { "content-type": "application/json" },
100
+ body: JSON.stringify(body),
101
+ });
102
+ if (!res.ok)
103
+ throw new Error(`Telegram sendMessage failed: HTTP ${res.status}`);
104
+ }
105
+ /** Register the slash-command menu (setMyCommands) — autocompletes in chat. */
106
+ async setMyCommands(commands) {
107
+ if (!this.token)
108
+ throw new Error("Telegram: no bot token (set_secret BOT_TOKEN=<token>).");
109
+ await fetch(`${API}${this.token}/setMyCommands`, {
110
+ method: "POST",
111
+ headers: { "content-type": "application/json" },
112
+ body: JSON.stringify({ commands }),
113
+ });
114
+ }
115
+ }
@@ -0,0 +1,44 @@
1
+ import type { Chain } from "./chain.js";
2
+ import type { Store } from "./store.js";
3
+ import type { AbiEvent, DecodedLog } from "./events.js";
4
+ import type { Hex } from "./hex.js";
5
+ export interface WatchLogsOptions {
6
+ /** Event to decode — an AbiEvent or a human-readable signature. */
7
+ event: AbiEvent | string;
8
+ /** Contract address(es) to filter (omit to match any). */
9
+ address?: Hex | Hex[];
10
+ /** Indexed-arg filter, positional or by name (see Chain.getLogs). */
11
+ args?: Record<string, unknown> | unknown[];
12
+ /** Store key holding this watcher's cursor. Namespace it per watcher. */
13
+ cursorKey: string;
14
+ /** First block to scan when no cursor exists yet. Default: current head (new logs only). */
15
+ fromBlock?: bigint;
16
+ /** Stay this many blocks behind head for reorg safety. Default 0. */
17
+ confirmations?: bigint;
18
+ /** Max blocks per eth_getLogs window (RPC range cap). Default 2000n. */
19
+ chunkSize?: bigint;
20
+ /** Invoked per non-empty chunk with that chunk's decoded logs. Must be idempotent. */
21
+ onLogs: (logs: DecodedLog[], range: {
22
+ fromBlock: bigint;
23
+ toBlock: bigint;
24
+ }) => Promise<void> | void;
25
+ }
26
+ export interface WatchLogsResult {
27
+ /** First block scanned this run. */
28
+ fromBlock: bigint;
29
+ /** New cursor — the last block scanned (head - confirmations). */
30
+ toBlock: bigint;
31
+ /** Total logs delivered to onLogs this run. */
32
+ logCount: number;
33
+ /** Number of eth_getLogs windows scanned. */
34
+ chunks: number;
35
+ }
36
+ /**
37
+ * Catch up on event logs since the last run. Reads the cursor (or `fromBlock`),
38
+ * scans `[cursor, head - confirmations]` in `chunkSize` windows, calls `onLogs`
39
+ * per non-empty chunk, and persists the cursor AFTER each chunk — so a Worker
40
+ * killed mid-catch-up resumes from the last completed window. Delivery is
41
+ * at-least-once: if `onLogs` throws, the cursor is not advanced past that chunk
42
+ * and the next tick retries it, so `onLogs` must be idempotent.
43
+ */
44
+ export declare function watchLogs(chain: Chain, store: Store, opts: WatchLogsOptions): Promise<WatchLogsResult>;
package/dist/watch.js ADDED
@@ -0,0 +1,46 @@
1
+ // watchLogs — resumable event catch-up for keepers. A Worker has no long-lived
2
+ // process, so this is NOT a websocket subscription: each scheduled tick reads new
3
+ // logs since a Store-persisted block cursor, up to the (confirmed) head, in
4
+ // chunked eth_getLogs windows. Layered as a free function over Chain + Store so
5
+ // Chain (Layer 1, may hold keys) never imports Store (Layer 3).
6
+ /**
7
+ * Catch up on event logs since the last run. Reads the cursor (or `fromBlock`),
8
+ * scans `[cursor, head - confirmations]` in `chunkSize` windows, calls `onLogs`
9
+ * per non-empty chunk, and persists the cursor AFTER each chunk — so a Worker
10
+ * killed mid-catch-up resumes from the last completed window. Delivery is
11
+ * at-least-once: if `onLogs` throws, the cursor is not advanced past that chunk
12
+ * and the next tick retries it, so `onLogs` must be idempotent.
13
+ */
14
+ export async function watchLogs(chain, store, opts) {
15
+ const confirmations = opts.confirmations ?? 0n;
16
+ const chunkSize = opts.chunkSize ?? 2000n;
17
+ const head = await chain.getBlockNumber();
18
+ const safeHead = head - confirmations;
19
+ const stored = await store.getJSON(opts.cursorKey);
20
+ const from = stored ? BigInt(stored.next) : (opts.fromBlock ?? safeHead);
21
+ if (from > safeHead) {
22
+ return { fromBlock: from, toBlock: from - 1n, logCount: 0, chunks: 0 };
23
+ }
24
+ let cur = from;
25
+ let logCount = 0;
26
+ let chunks = 0;
27
+ while (cur <= safeHead) {
28
+ const to = cur + chunkSize - 1n < safeHead ? cur + chunkSize - 1n : safeHead;
29
+ const logs = await chain.getLogs({
30
+ event: opts.event,
31
+ address: opts.address,
32
+ args: opts.args,
33
+ fromBlock: cur,
34
+ toBlock: to,
35
+ });
36
+ chunks++;
37
+ if (logs.length) {
38
+ await opts.onLogs(logs, { fromBlock: cur, toBlock: to });
39
+ logCount += logs.length;
40
+ }
41
+ // Advance + persist only after a successful onLogs, so a throw retries this window.
42
+ await store.setJSON(opts.cursorKey, { next: (to + 1n).toString() });
43
+ cur = to + 1n;
44
+ }
45
+ return { fromBlock: from, toBlock: safeHead, logCount, chunks };
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livo-build/runtime",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Livo runtime — chain signing/reads, D1 state, and logging for keepers, servers, and bots.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",