@gramforge/transport-fetch
v0.1.0
Published
A zero-dependency raw Telegram Bot API adapter over the **global `fetch`** (no `node:*` on the hot path — JSR-publishable). It consumes `@gramforge/core` message values and `@gramforge/render`'s `toEntities` output, sends them over `fetch`, and returns a
Downloads
33
Readme
@gramforge/transport-fetch
A zero-dependency raw Telegram Bot API adapter over the global fetch (no
node:* on the hot path — JSR-publishable). It consumes @gramforge/core
message values and @gramforge/render's toEntities output, sends them over
fetch, and returns a typed DeliveryResult<T> for every outcome instead
of throwing.
- Methods:
sendMessage,editMessageText,sendMediaGroup,answerCallbackQuery,answerWebAppQuery. - Every wire response — success and error — is
zodsafeParsed before it reaches you;res.json()is never trusted blindly. parse_modeis always NONE: formatting travels as entities, produced byrender'stoEntities, never as HTML/MarkdownV2.- 429s are typed and inert:
rateLimitedcarries a brandedretryAfterSeconds; nothing here ever sleeps or auto-retries. LiveMessage: a create-once / edit-many live-progress wrapper with debounce + last-write-wins coalescing and typed swallowing of Telegram's "message is not modified" reply.
Install
pnpm add @gramforge/transport-fetchDeliveryResult<T>
Every method returns Promise<DeliveryResult<T>>. Match it exhaustively:
import { match } from "ts-pattern";
import { createBotClient, type DeliveryResult } from "@gramforge/transport-fetch";
function report<T>(r: DeliveryResult<T>): string {
return match(r)
.with({ kind: "ok" }, () => "sent")
.with({ kind: "badRequest" }, (e) => `bad request: ${e.description}`)
.with({ kind: "unauthorized" }, (e) => `unauthorized: ${e.description}`)
.with({ kind: "rateLimited" }, (e) => `retry after ${e.retryAfterSeconds}s`)
.with({ kind: "serverError" }, (e) => `server ${e.status}: ${e.description}`)
.with({ kind: "networkError" }, (e) => `network: ${e.message}`)
.exhaustive();
}| kind | HTTP | payload |
| -------------- | -------- | ------------------------------ |
| ok | 200 | value: T |
| badRequest | 400 (+4xx) | description |
| unauthorized | 401 | description |
| rateLimited | 429 | retryAfterSeconds: RetryAfter|
| serverError | 5xx / bad body | status, description |
| networkError | fetch rejected / non-JSON | message |
Sending a message
import { createMessageText, text, bold, toChatId } from "@gramforge/core";
import { createBotClient } from "@gramforge/transport-fetch";
const client = createBotClient({ token: process.env.BOT_TOKEN! });
// `fetch` and `baseUrl` are injectable: { token, fetch?, baseUrl? }.
const chat = toChatId(123456);
const body = createMessageText([text("Build "), bold([text("passed")])]);
if (chat.kind === "ok" && body.kind === "ok") {
const result = await client.sendMessage({ chatId: chat.value, message: body.value });
// result: DeliveryResult<TelegramMessage>
}editMessageText takes an EditTarget DU — { kind: "chatMessage", chatId,
messageId } or { kind: "inlineMessage", inlineMessageId }. An inline edit
returns the wire literal true, surfaced as { kind: "inlineEdited" }.
Live progress (LiveMessage)
update() is synchronous and cheap; bursts collapse to at most one edit per
minEditIntervalMs (last-write-wins). A 429 is propagated to the caller and
used as a floor for the next flush — never retried silently. Call finish()
from a finally to write a final summary immediately.
const live = client.liveMessage({ chatId: chat.value, minEditIntervalMs: 1500 });
try {
for (const step of steps) {
live.update(`… ${step.label}`); // coalesced, never blocks
await step.run();
}
} finally {
await live.finish("Done ✅");
}The coalescing state machine is exported as createLiveMessage(port, options)
over a LiveMessagePort seam, so @gramforge/transport-grammy reuses the
identical behavior with a grammY-backed port.
Notes
- Runtime deps:
zodandts-patternonly; usesfetch/AbortController— nonode:http. sendMediaGroupaccepts a compile-time 2-to-10-item album tuple;albumSizeErroris the runtime backstop for cast/JSON-decoded values.answerWebAppQuerycurrently ships thearticleinline-result subset (a renderedMessageText); otherInlineQueryResultvariants are added non-breakingly when the mini-app lands.
