@livo-build/runtime 0.2.1 → 0.2.3
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/README.md +148 -8
- package/dist/chain.d.ts +62 -5
- package/dist/chain.js +126 -21
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.js +15 -1
- package/dist/index.d.ts +15 -3
- package/dist/index.js +16 -1
- package/dist/indexer.d.ts +35 -0
- package/dist/indexer.js +73 -0
- package/dist/multicall.d.ts +30 -0
- package/dist/multicall.js +65 -0
- package/dist/queue.d.ts +27 -0
- package/dist/queue.js +49 -0
- package/dist/secret.d.ts +15 -0
- package/dist/secret.js +40 -0
- package/dist/store.d.ts +11 -0
- package/dist/store.js +24 -0
- package/dist/telegram.d.ts +63 -0
- package/dist/telegram.js +115 -0
- package/dist/wallet.d.ts +77 -0
- package/dist/wallet.js +122 -0
- package/dist/watch.d.ts +44 -0
- package/dist/watch.js +46 -0
- package/package.json +1 -1
|
@@ -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 {};
|
package/dist/indexer.js
ADDED
|
@@ -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
|
+
}
|
package/dist/queue.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/secret.d.ts
ADDED
|
@@ -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 {};
|
package/dist/telegram.js
ADDED
|
@@ -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
|
+
}
|
package/dist/wallet.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Chain } from "./chain.js";
|
|
2
|
+
import { type AbiFunction } from "./abi.js";
|
|
3
|
+
import type { Hex } from "./hex.js";
|
|
4
|
+
import type { Receipt } from "./chain.js";
|
|
5
|
+
interface MinimalEnv {
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
export interface WalletOptions {
|
|
9
|
+
/** Platform wallet API base, e.g. https://<site>/wallet. Default env WALLET_API_URL. */
|
|
10
|
+
apiUrl?: string;
|
|
11
|
+
/** Bearer scoping calls to this bot. Default env WALLET_API_TOKEN. */
|
|
12
|
+
apiToken?: string;
|
|
13
|
+
}
|
|
14
|
+
/** One wallet a user owns. `imported` = brought their own key (vs. HD-generated). */
|
|
15
|
+
export interface WalletAccount {
|
|
16
|
+
label: string;
|
|
17
|
+
address: Hex;
|
|
18
|
+
active: boolean;
|
|
19
|
+
imported: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface WalletSendOptions {
|
|
22
|
+
address: Hex;
|
|
23
|
+
abi: AbiFunction[];
|
|
24
|
+
functionName: string;
|
|
25
|
+
args?: unknown[];
|
|
26
|
+
/** Native value to send with the call, in wei. */
|
|
27
|
+
value?: bigint;
|
|
28
|
+
}
|
|
29
|
+
/** A handle bound to a single end-user (e.g. one Telegram account). */
|
|
30
|
+
export declare class UserWallet {
|
|
31
|
+
private readonly wallet;
|
|
32
|
+
/** Platform-namespaced user id, e.g. "tg:12345". */
|
|
33
|
+
readonly userKey: string;
|
|
34
|
+
constructor(wallet: Wallet,
|
|
35
|
+
/** Platform-namespaced user id, e.g. "tg:12345". */
|
|
36
|
+
userKey: string);
|
|
37
|
+
/** Every wallet the user owns (auto-creates their default on first call). */
|
|
38
|
+
list(): Promise<WalletAccount[]>;
|
|
39
|
+
/** The user's active wallet (the one `send` signs with), auto-created if none. */
|
|
40
|
+
account(): Promise<WalletAccount>;
|
|
41
|
+
/** Shorthand for the active wallet's address. */
|
|
42
|
+
address(): Promise<Hex>;
|
|
43
|
+
/** Generate a new HD wallet (from the user's own seed) and make it active. */
|
|
44
|
+
create(label?: string): Promise<WalletAccount>;
|
|
45
|
+
/** Import an external private key and make it active. */
|
|
46
|
+
import(privateKey: string, label?: string): Promise<WalletAccount>;
|
|
47
|
+
/** Switch the active wallet by label. */
|
|
48
|
+
use(label: string): Promise<WalletAccount>;
|
|
49
|
+
/** Stop tracking a wallet by label (active reassigns to another if needed). */
|
|
50
|
+
remove(label: string): Promise<void>;
|
|
51
|
+
/** Native balance (wei) of the user's active wallet. Reads via RPC, no key. */
|
|
52
|
+
balance(): Promise<bigint>;
|
|
53
|
+
/**
|
|
54
|
+
* Sign + broadcast a state-changing call with the user's ACTIVE wallet. The
|
|
55
|
+
* calldata is encoded here; the platform signs server-side and returns the hash.
|
|
56
|
+
*/
|
|
57
|
+
send(opts: WalletSendOptions): Promise<Hex>;
|
|
58
|
+
}
|
|
59
|
+
export declare class Wallet {
|
|
60
|
+
private readonly apiUrl?;
|
|
61
|
+
private readonly apiToken?;
|
|
62
|
+
private readonly env?;
|
|
63
|
+
private _chain?;
|
|
64
|
+
constructor(env: MinimalEnv | undefined, options?: WalletOptions);
|
|
65
|
+
/** A handle for one end-user. Pass any stable id; it's namespaced as "tg:<id>". */
|
|
66
|
+
user(id: string | number, platform?: string): UserWallet;
|
|
67
|
+
/** Lazily build a read-only Chain (for balances/receipts) from env RPC_URL. */
|
|
68
|
+
chain(): Chain;
|
|
69
|
+
/** Wait for a tx broadcast by `send` to mine. Reads via RPC, no key. */
|
|
70
|
+
waitForReceipt(hash: Hex, opts?: {
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
pollMs?: number;
|
|
73
|
+
}): Promise<Receipt>;
|
|
74
|
+
/** Internal: POST to a wallet API sub-path with the bot bearer; throws on error. */
|
|
75
|
+
post(path: "accounts" | "sign", body: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
76
|
+
}
|
|
77
|
+
export {};
|