@adriangalilea/utils
v2.24.1
Published
TypeScript utilities - logger, currency, formatter, GramIO bot plugins, and more
Downloads
8,991
Maintainers
Readme
ts-utils
TypeScript utilities - logger, currency, offensive programming, file operations, environment management, and more.
Installation
pnpm add @adriangalilea/utilsUsage
Logger
Next.js-style logger with colored output and Unicode symbols:
import { wait, error, warn, ready, info, success, event, trace, createLogger } from '@adriangalilea/utils'
// Basic logging
wait('Loading...')
error('Something went wrong')
warn('This is a warning')
ready('Server is ready')
info('Information message')
success('Operation successful')
event('Event occurred')
trace('Trace message')
// Warn once (won't repeat same message)
warnOnce('This warning appears only once')
// Timer functionality
time('operation')
// ... do something
timeEnd('operation') // outputs: operation: 123ms
// Create prefixed logger
const apiLogger = createLogger('API')
apiLogger.info('Request received') // [API] Request receivedCurrency
Currency utilities with comprehensive crypto support (500+ symbols):
import { currency, isCrypto, isStablecoin, isFiat, getSymbol, getOptimalDecimals } from '@adriangalilea/utils'
// Check currency types
isCrypto('BTC') // true
isCrypto('XBT') // true (alternative for BTC)
isCrypto('WBTC') // true (wrapped tokens detected)
isStablecoin('USDT') // true
isFiat('USD') // true
// Get currency symbols
getSymbol('BTC') // '₿'
getSymbol('ETH') // 'Ξ'
getSymbol('USD') // '$'
// Get optimal decimal places based on value
getOptimalDecimals(0.00001234, 'BTC') // 10
getOptimalDecimals(1234.56, 'USD') // 2
getOptimalDecimals(0.123, 'ETH') // 6
// Percentage calculations
currency.percentageOf(25, 100) // 25
currency.percentageChange(100, 150) // 50
currency.percentageDiff(100, 150) // 40
// Basis points
currency.basisPointsToPercent(100) // 1
currency.percentToBasisPoints(1) // 100
currency.formatBasisPoints(50) // "50 bps"Format
Pure number formatting — named exports, tree-shakeable, zero currency baggage (money formatting lives in the currency module, which owns the symbol/decimals knowledge and its crypto-symbol dataset):
import { compact, percentage, withCommas } from '@adriangalilea/utils/format'
withCommas(1234567) // "1,234,567"
withCommas(1234.567, 2) // "1,234.57"
compact(1234567) // "1.2M"
compact(1234) // "1.2K"
percentage(12.3456) // "12.3%"
percentage(0.05) // "0.05%"
percentage(123.456) // "123%"
// Money — from currency, not format:
import { usd, btc, money } from '@adriangalilea/utils/currency'
usd(1234.56) // "$1234.56"
btc(0.00001234) // "0.00001234 ₿"
money(100, 'EUR') // "€100.00"URL (url)
URLs, from user-typed text to cache identity. The whole funnel — is there a
URL? which resource? whose content? what identity? — answered once, on one
object. Each concern rides its state of the art instead of hand-rolls:
detection is linkifyjs (scanner-based, TLD-aware);
parsing is the WHATWG URL API, never regexes; the knowledge of WHICH params
are tracking is vendored from
@protontech/tidy-url
(Proton's maintained fork of DrKain/tidy-url, MIT; refresh with
pnpm update-url-rules) plus a small tested overlay; per-site content
identity is an adapter registry (YouTube built in). Global rules only for
unambiguous trackers (utm_*, fbclid, gclid, …); ambiguous names stay per-host —
si is junk on YouTube/Spotify but ref on GitHub names a branch and
survives.
import { urlsIn, urlOf, cleanUrl, urlKey, hostOf, hostMatches,
youtubeVideoId, youtubeUrl, youtubeThumbnailUrl } from '@adriangalilea/utils/url'
// Every http(s) URL in free text, in order, fully resolved:
urlsIn('watch youtu.be/dQw4w9WgXcQ?si=junk and https://a.com/x?utm_source=t')
// [{ raw: 'youtu.be/dQw4w9WgXcQ?si=junk', start: 6, end: 34, hadScheme: false,
// href: 'https://youtu.be/dQw4w9WgXcQ', // cleaned — fetch/share this
// host: 'youtu.be', site: 'youtube', id: 'dQw4w9WgXcQ',
// key: 'youtube:dQw4w9WgXcQ' }, // THE cache/dedupe identity
// { …, href: 'https://a.com/x', site: null, id: null, key: 'a.com/x' }]
// One pasted token → one resolved Url (or null). Punctuation, brackets,
// emails-are-not-URLs: the scanner's problem, not yours.
urlOf('<https://example.com/a?utm_source=x>,')?.href // 'https://example.com/a'
urlOf('example.com/a', { requireScheme: true }) // null (bare domains opt-out)
// `key` is the point: every spelling of one resource collides.
// Recognized sites collapse to their native content id —
urlKey('https://music.youtube.com/watch?v=dQw4w9WgXcQ&si=x') // 'youtube:dQw4w9WgXcQ'
urlKey('https://www.youtube.com/shorts/dQw4w9WgXcQ') // 'youtube:dQw4w9WgXcQ'
// — everything else canonicalizes generically:
urlKey('https://www.theverge.com/2026/story/?utm_source=x#c') // 'theverge.com/2026/story'
// The SAME resource minus tracking — keeps scheme, www., order, fragment.
cleanUrl('https://www.youtube.com/watch?v=abc&t=120s&si=junk')
// 'https://www.youtube.com/watch?v=abc&t=120s'
// YouTube identity from ANY spelling (watch, youtu.be, shorts, live, embed,
// music., nocookie, redirect wrappers, bare 11-char id):
youtubeVideoId('https://www.youtube.com/attribution_link?u=%2Fwatch%3Fv%3DdQw4w9WgXcQ')
// 'dQw4w9WgXcQ'
youtubeUrl('dQw4w9WgXcQ') // 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
youtubeThumbnailUrl('dQw4w9WgXcQ') // 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg'
hostOf('https://www.theverge.com/a') // 'theverge.com'
hostMatches('music.youtube.com', 'youtube.com') // true (RFC 6265 domain-match)
hostMatches('notyoutube.com', 'youtube.com') // false
// App-specific junk rides along per call
urlKey(url, { strip: ['session_id'] })Unparseable or non-http(s) input passes through unchanged — these are hygiene functions over user-pasted text, not validators.
CLI presentation (cli)
Terminal output: aligned tables, key/value blocks, trees, and a semantic color
palette. Distinct from format (universal value formatting) — this is
terminal-scoped. Colors come from the logger and auto-disable on non-TTY /
NO_COLOR, and alignment is ANSI-aware (padding uses visible width), so a
table() renders colored in a terminal and as plain aligned text in a pipe, log
file, or a bot's monospace block.
import { table, kv, tree, indent, clip, ui } from '@adriangalilea/utils/cli'
// Semantic palette — use these, not raw colors, so intent stays consistent
ui.head('Name') ui.accent('id') ui.muted('note')
ui.ok('done') ui.warn('!') ui.bad('err') ui.ref('#abc123')
// Aligned columns (auto width; per-column align; optional bolded header).
// Cells may be pre-colored — widths use visible length so they still line up.
table(
[['Ada', ui.accent('[email protected]'), ui.ref('42')],
['Bo', ui.accent('[email protected]'), ui.ref('7')]],
{ head: ['name', 'email', 'msgs'], align: ['l', 'l', 'r'] },
)
// Key/value block (aligned keys) — for a detail view
kv([['name', 'Ada'], ['email', '[email protected]']], { indent: 2 })
// Nesting
tree('Ada', ['email: [email protected]', 'phone: +1…']) // labeled node + children
indent(block, 4) // indent every line
clip('a very long value', 10) // "a very lo…" (ANSI-aware — styled input stays styled)Run the demo: FORCE_COLOR=1 pnpm tsx tests/cli-demo.ts.
Live output (cli) — pinned region, spinner, progress
A pinned, self-repainting region at the bottom of the terminal for progress UIs. You own the state; the region is a render: () => string repainted on a timer — a frame is just a string, so table() / kv() / ui and the widgets (spin(), bar(), elapsed()) compose inside it unchanged.
import { live, spinner, spin, bar, elapsed, table, ui } from '@adriangalilea/utils/cli'
// The one-liner: animated while fn runs, persists "✓ label 1.2s" when done
await spinner('connecting to imap.gmail.com', () => adapter.connect())
// The general region: declarative multi-line progress
const region = live(() => table(accounts.map(a => [
a.done === a.total ? ui.ok('✓') : spin(),
a.alias,
bar(a.done, a.total, 18),
ui.muted(`${a.rate}/s`),
])))
// ...mutate your state; it repaints ~12.5fps (region.refresh() for instant)
region.done() // final frame persists into scrollback (or .clear() to remove)Patterns:
// Relabel a spinner mid-flight — fn receives a setter
await spinner('connecting 0/4', async (set) => {
for (const [i, a] of accounts.entries()) { await a.connect(); set(`connecting ${i + 1}/4`) }
})
// Streaming table — rows appear as they arrive, columns re-align retroactively
const rows: string[][] = []
const region = live(() => table(rows, { head: ['day', 'author', 'title'] }))
for await (const e of feed) { rows.push(renderRow(e)); region.refresh() }
region.done() // full aligned table persists (a pipe gets exactly this, once)
// State-dependent bar color — style hook on the filled part
bar(done, total, 18, ui.warn) // amber: throttled/coolingWhat makes it hold up:
- Logging never tears the UI, with no API to learn. While a region is active,
console.log/warn/error— and therefore the logger — are rerouted to print above the region (erase → write → repaint). Keep logging from anywhere, including third-party code. Boundary: onlyconsole.*is patched — a library writing raw toprocess.stdout.writebypasses the routing and can tear. - Non-TTY degrades to sane output. In a pipe / CI / log file nothing animates:
done()prints the final frame once,spinner()prints just its✓ label 1.2sline. Same calling code. Opt-inheartbeat: msprints plain snapshots so long CI runs aren't silent. - Renders to stderr by default — stdout stays clean for
--jsonand pipes. - Crash-safe cursor: hidden while painting, restored on done/clear, process exit, and signals — politely (if the app has its own SIGINT handler for graceful shutdown, it stays in charge).
- Flicker-free: synchronized-update escapes (
?2026) make repaints atomic on modern terminals; lines are ANSI-aware clipped to the terminal width so the erase math never breaks. - Measurement is grapheme-aware: a ZWJ emoji / flag counts as one visible unit in
width()/clip()/table()alignment. East-Asian double-width (CJK) is a known TODO — those columns can drift a cell. - One region at a time, by design (
assert): two pinned regions can't share the bottom of one screen — compose into a singlerender(). Aspinner()started under an active region is legitimate composition and degrades gracefully: its final line prints above the region.
Run the demo: pnpm tsx tests/live-demo.ts (and pipe it through | cat to see the non-TTY degradation).
Offensive Programming
Fail loud, fail fast. Zero dependencies, works in Node, Deno, Bun, and browsers.
Two kinds of errors, kept separate: Panic (bugs in us — crash the process) and SourcedError (boundary failures — handle per-source).
import { assert, panic, assertNever, must, unwrap, Panic, SourcedError, isSourcedError } from '@adriangalilea/utils'
// Assert invariants — narrows types via `asserts condition`
assert(port > 0 && port < 65536, 'invalid port:', port)
// Impossible state
switch (state) {
case 'ready': handleReady(); break
default: panic('impossible state:', state)
}
// Exhaustiveness check — TS compile error if you miss a case
type Event = { kind: 'click' } | { kind: 'hover' } | { kind: 'scroll' }
function handle(e: Event) {
switch (e.kind) {
case 'click': return handleClick()
case 'hover': return handleHover()
// forgot 'scroll' → TS error: Argument of type '{ kind: "scroll" }' not assignable to 'never'
default: return assertNever(e)
}
}
// Add a new variant to Event → every assertNever site lights up at compile time.
// Unwrap operations that shouldn't fail (sync + async)
const data = must(() => JSON.parse(staticJsonString))
const file = must(() => readFileSync(path))
const resp = await must(() => fetch(url))
// Unwrap nullable values — T | null | undefined → T in one expression
const user = unwrap(db.findUser(id), 'user not found:', id)
const el = unwrap(document.getElementById('app'))Typed boundary errors — SourcedError
Every external system call should wear its source. When it fails, carry forensics:
import { SourcedError, isSourcedError, Panic } from '@adriangalilea/utils'
try {
return await stripe.charges.create({ customer, amount })
} catch (e) {
throw new SourcedError({
source: 'stripe',
operation: 'charge_customer',
message: e instanceof Error ? e.message : String(e),
status: (e as any)?.statusCode,
cause: e,
context: { customer, amount },
})
}
// At catch boundaries — keep Panics and SourcedErrors separate:
try { await doWork() }
catch (e) {
if (e instanceof Panic) throw e // bug in us — crash
if (isSourcedError(e, 'stripe') && e.status === 402) {
// TS knows e.source === 'stripe' here (generic narrows)
return { error: 'card declined' }
}
if (isSourcedError(e)) {
logger.error(`[${e.source}:${e.operation}]`, e.toJSON()) // structured forensics
throw e
}
throw e // unknown — re-throw
}Every SourcedError carries source, operation, status, context, and the original exception via cause. Call .toJSON() for serialization across process boundaries.
Features
- Logger: Next.js-style colored console output with symbols
- Currency:
- 13,750+ crypto symbols from CoinGecko (auto-updatable)
- Alternative ticker support (XBT→BTC, wrapped tokens, etc.)
- Optimal decimal calculations
- Percentage and basis point utilities
- Fiat and stablecoin detection
- Format: Number and currency formatting with compact notation
- CLI: ANSI-aware tables/kv/trees + semantic palette, and live output — pinned self-repainting region, spinner, progress bar, with logs flowing above and clean non-TTY degradation
- Offensive Programming: assert, panic, assertNever, must, unwrap (throw
Panic) + SourcedError for typed boundary failures - File Operations: Read, write with automatic path resolution
- Directory Operations: Create, list, walk directories
- KEV: Redis-style environment variable management with monorepo support
- XDG: XDG Base Directory paths — reads env vars set by xdg-dirs, falls back to spec defaults
- Unseen: Persistent dedup filter — "what's new since last time?" for cron/monitoring workflows
- Project Discovery: Find project/monorepo roots, detect JS/TS projects
- LLM caller (
llm): multi-provider failover on top of the Vercel AI SDK — priority ordering, per-key circuit breaker (KV-backed), reset events for live previews, usage accounting with actual billed cost (OpenRouter), tool calling. Worker-safe. - Bot plugins (GramIO):
kit(graceful shutdown + admin context),access-control(gate + approve/deny menu, backed by sessions),llm(draft-streamed repliesstreamChatReply+ per-thread conversation historyctx.llm),payments(Telegram Stars: VIP tiers + credits + perks + waiver + refund flow, ToS-compliant, Spanish-autónomo-aware),coalesce,language,menu
XDG Base Directories
XDG paths that respect env vars from xdg-dirs with spec-compliant fallbacks:
import { xdg, dir } from '@adriangalilea/utils'
xdg.state('notify') // ~/.local/state/notify
xdg.state('notify', 'watchers.json') // ~/.local/state/notify/watchers.json
xdg.config('myapp') // ~/.config/myapp
xdg.data('myapp') // ~/.local/share/myapp
xdg.cache('myapp') // ~/.cache/myapp
xdg.runtime('myapp') // $XDG_RUNTIME_DIR/myapp
// Ensure the directory exists before writing
dir.create(xdg.state('notify'))Unseen
"What's new since last time?" — filters an array of objects to only the ones you haven't seen before. Remembers across runs.
import { unseen } from '@adriangalilea/utils'
const messages = await fetchMessages()
const newMessages = await unseen('messages', messages, 'id')1st run:
messages = [{ id: '1', from: 'alice', text: 'hi' }]
newMessages = [{ id: '1', from: 'alice', text: 'hi' }]2nd run, no new message:
newMessages = []3rd run, bob replied:
messages = [{ id: '1', ... }, { id: '2', from: 'bob', text: 'hey' }]
newMessages = [{ id: '2', from: 'bob', text: 'hey' }]Saves state to: $XDG_STATE_HOME/unseen/{name}.json
Polyglot strings (say)
A typed multi-language string is just an object literal { en, es, … } — the keys are the source of truth, the TS compiler enforces completeness, there's no JSON file / extraction tool / registry.
import { say, type Polyglot } from '@adriangalilea/utils/say'
say({ en: 'Hello', es: 'Hola' }, 'es') // → 'Hola'
say({ en: 'Hello', es: 'Hola' }, 'fr') // TS error: '"fr"' not in '"en" | "es"'
// parametric — closures, no wrapper:
const greeting = (name: string) => ({ en: `Hi ${name}`, es: `Hola ${name}` })
say(greeting('Adrian'), 'es') // → 'Hola Adrian'
// type your own adapter:
const notify = (msg: Polyglot<'en' | 'es'>, lang: 'en' | 'es') =>
transport.send(say(msg, lang))In a bot, bot/language adds ctx.lang + ctx.say. Both resolve stored explicit pick → Telegram client hint → configured default, at READ time — the hint is never persisted, and session.language is written only by an explicit pick (the plugin's menuItem action):
ctx.say({ en: 'Continue', es: 'Continuar' }) // → string
await ctx.say.send({ en: 'Hi', es: 'Hola' }) // → ctx.send(resolved)
await ctx.say.edit({ en: 'Done', es: 'Listo' }) // → ctx.editText (callback only)
await ctx.say.answer({ en: 'OK', es: 'OK' }) // → ctx.answer (callback only)Telegram HTML (tg-html)
Transform arbitrary (LLM-emitted) HTML into Telegram-compatible parse_mode=HTML with opinionated, consistent spacing. Telegram's HTML subset has no headings, lists, or block layout — send it <h1>/<ul> and the API rejects the whole message. transform() accepts the HTML a model naturally writes and renders the structure typographically: h1 → bold+underline + blank line, h2…h6 → bold + blank line, ul/li → • bullets, blocks get blank lines, blockquotes keep their internal line breaks, allowed tags pass through with attributes filtered, unknown tags drop their markup (content kept), and stray </> escape instead of vanishing. Zero dependencies, no DOM, Worker-safe. Successor of the standalone tghtml package (jsr, archived).
import { transform } from '@adriangalilea/utils/tg-html'
transform('<h1>Title</h1><ul><li>Point one</li><li>Point two</li></ul>')
// '<b><u>Title</u></b>\n\n• Point one\n• Point two'Telegram Markdown (tg-md)
The MARKDOWN twin of tg-html, for the markdown LLMs actually emit (grown in xtldr's bot, moved here verbatim). One opinionated dialect — _ is NEVER emphasis so snake_case survives, every tag balanced by construction so Telegram can't reject a render, pure whole-string functions so full-frame draft repaints (bot/draft) degrade malformed mid-stream markdown to literal text — rendered to every shape a bot sends:
import { markdownToRichHtml, markdownToTelegramHtml, toPlainText, tidyRichMarkdown } from '@adriangalilea/utils/tg-md'
markdownToRichHtml(md, coverUrl) // rich-message HTML (Bot API 10.1): real <h1>/<ul>, cover <img> spliced after the title
markdownToTelegramHtml(md) // strict parse_mode=HTML subset: bold-underlined title, breathing headings, • bullets, ###### footer stays quiet
toPlainText(md) // last-ditch fallback; quotes keep their > marker
tidyRichMarkdown(md) // strip the stray ```fence``` models wrap replies inZero dependencies, Worker-safe. Typography (heading air, bullet glyph, title underline) IS the opinion; it gets options when a second consumer needs a different look, not before.
Telegram bot plugins (GramIO)
Plugins for personal Telegram bots built on GramIO. Each plugin lives at its own subpath; peer deps (gramio, @gramio/storage, @gramio/session, @gramio/format, marked) are all optional — install only what you import.
pnpm add @adriangalilea/utils gramio @gramio/storage @gramio/sessionOne bot file, ideation → production (bot/create)
createBot is the composer: it constructs the storage + session pair ONCE and threads it into every feature, so "must be the SAME instance you passed to session()" is unrepresentable instead of a doc warning. The same file runs in every stage — storage and transport are environment decisions, never code shape:
| stage | run | session | transport |
|---|---|---|---|
| ideation | BOT_TOKEN=… tsx bot.ts | memory (ephemeral, announced) | long-poll |
| experiment | BOT_PERSIST=./bot.sqlite tsx bot.ts | sqlite | long-poll |
| prod · your own hardware | systemd/launchd unit running the same command | sqlite / BOT_PERSIST=redis://… | long-poll (a legitimate prod mode: dials out, no inbound port/TLS) |
| prod · Cloudflare Worker | wrangler deploy (D1 binding DB) | D1 (bot/storage-d1) | webhook (bot/worker) |
| prod · Node/Bun server | webhook behind your HTTP server | sqlite/redis | fetch-shaped handler |
import { createBot } from '@adriangalilea/utils/bot/create'
const app = createBot({
admins: 190202471,
language: { supported: ['en', 'es'] as const, default: 'en' },
menu: {
adminContact: '@you',
header: async (ctx) => `⚙️ hi ${ctx.from?.firstName}`, // async — read your db here
items: [/* … */],
personalData: { onForget: async (ctx, userId) => {/* wipe YOUR tables */} },
},
handlers: (bot) => bot.command('start', (ctx) => ctx.say({ en: 'hi', es: 'hola' })),
})
export default app // Worker: webhook + /setup + /pause + deploy DMs
if (app.isMain(import.meta)) app.poll() // Node: `tsx bot.ts` long-pollsCloudflare is one adapter, not the architecture: bot/worker and bot/storage-d1 are the workerd cap (~each 100 lines, both optional); bot/kit's gracefulStart is the Node twin (signals + start/stop DMs) and stays the one deliberately Node-only corner. The worker-safe tripwire (pnpm test:worker-safe) guarantees the core never grows a Node dependency.
Design rules the bot plugins hold to (each learned the hard way)
- Resolution is read-time. Language resolves stored pick → live Telegram hint → default on every surface (
ctx.lang,ctx.say, menu chrome, picker highlight). Inferred values are NEVER persisted — a user who switches their client language moves with it until they pick. - Only explicit user signals are stored. The session holds picks, consent, state the user created — never derived values, never render caches. If a sync signature ever tempts you to cache a rendered string into the session, the signature is the bug (menu resolvers are async for exactly this reason).
- Derives cover every event the user can speak through — message, callback, AND inline. A feature that skips inline forces consumers to fork shadow helpers that read the session directly; those forks then read as design.
- Forget actually forgets.
personalData.onForgetruns inside the same try as the session delete: your message logs/metrics/credit rows get wiped too, or the user is told it failed — never a partial erasure reading as success. - The composer owns instance wiring. Features still compose manually (below) when you need full control, but every "same instance" contract has one home.
Threaded Mode
Telegram's Threaded Mode for private chats (BotFather → Bot Settings → Threaded Mode) auto-threads out of the box: @gramio/contexts ≥0.9 (what gramio ≥0.12 resolves) forwards message_thread_id on every ctx.send / ctx.sendDocument / ctx.reply — including from callback handlers, which carry threadId too. No fork, no override.
Runs on Cloudflare Workers / bun / anywhere. Every bot subpath below is import-safe off Node — no filesystem access, no node:* modules, no import-time side effects anywhere in its graph — except bot/kit, which deliberately owns the Node-only pieces (process signal handling in gracefulStart, kev-backed env reading in adminContext). pnpm test:worker-safe walks every graph and screams on regression.
| Subpath | What it does |
|---|---|
| @adriangalilea/utils/bot/ctx | Structural ctx types (BotMessageCtx, BotCallbackCtx, …) and the narrow<T>(ctx) cast helper. Pure typing — no runtime state. |
| @adriangalilea/utils/bot/keys | The bot-id key namespace, a persisted contract: botId(ctx), botStorageKey(ctx, userId) → bot-<id>:<userId>, botSubKey(ctx, sub) → bot-<id>:<sub>. Every storage key the library writes derives from here, and those keys live in YOUR Redis/D1 rows — the shape never changes. Pure functions of ctx.bot.info.id (no env/fs), which is what keeps the whole bot surface Worker-safe. |
| @adriangalilea/utils/bot/kit | The Node-only corner. gracefulStart(bot, opts?) — SIGINT/SIGTERM → bot.stop() → exit; force-kills if shutdown hangs. DMs the admin @<bot> started. / @<bot> shutting down. by default when KEV.TELEGRAM_ADMIN_ID is set (graceful only — crashes don't trigger onStop); pass notifyAdmin: false to disable or notifyAdmin: 12345 for an explicit chat id.adminContext({ adminId? }) — reads TELEGRAM_ADMIN_ID from kev (with optional hardcoded fallback), decorates ctx.adminId + ctx.isAdmin.Also re-exports botSession / prefixStorage from bot/session so Node consumers keep one import. |
| @adriangalilea/utils/bot/session | botSession(opts) — drop-in replacement for @gramio/session's session() that auto-namespaces every key as bot-<id>:<senderId> using ctx.bot.info.id (populated by getMe() at startup). Use this instead of session() — full stop. Multiple bots sharing one Redis/D1 stay isolated by construction; every plugin in this package derives the same prefix internally via botStorageKey(ctx, userId) / botSubKey(ctx, sub) (from bot/keys). No regex, no manual prefix argument, no way to forget.prefixStorage(storage, prefix) — escape hatch for adding a top-level prefix on top of the bot-id namespace; almost never needed. Worker-safe. |
| @adriangalilea/utils/bot/notify | Best-effort admin DMs, worker-safe (you pass the ids; no env, no process). notifyAdmins(bot, adminIds, text, extra?) — DM each admin, failures logged and swallowed (a notification must never take the bot down). alertAdminError(bot, adminIds, label, error, throttle?) — truncated 🚨 label\nName: message, rate-limited through a caller-owned alertThrottle(ms?) so a failure storm sends one alert per window. gracefulStart's start/stop DMs are built on this. |
| @adriangalilea/utils/bot/profile | syncBotProfile(bot, { name?, description?, about?, photo?, commands?, expects?, adminIds? }) — the bot's Telegram-facing identity as CODE, never BotFather: localized name / description / About / public command list, reconciled idempotently on every boot (get → compare → set per field per language; unchanged values cost one read, so cold-start firing is rate-limit-free). expects: { inline: true } declares the BotFather-only capabilities the code assumes — a mismatch (inline mode off on an inline-dependent bot) DMs the admins, since the API can only detect it, not fix it. Never throws; failures log and the bot keeps running. |
| @adriangalilea/utils/bot/access-control | Personal-bot ACL — gates non-admin/non-default users; admin gets DM with [✅ Aprobar][❌ Denegar] on first attempt; /access opens a persistent menu (revoke / reapprove / list pending). Backed by @gramio/session per-user + a small index. Native alternative: BotFather → Bot Settings → Access → "Restrict bot usage" — flat allow-list at Telegram. Use this plugin when you want in-bot approval flow instead of a BotFather round-trip; both can coexist. |
| @adriangalilea/utils/bot/allow-list | Static allow-list by id and/or @username — stateless, no session/storage. allowList({ ids?, usernames? }) is a plugin that decorates ctx.allowed (boolean); gate in your handlers (if (!ctx.allowed) return). makeAllowList(...) is the pure framework-free predicate. The light counterpart to access-control (which adds an approve/deny flow + revocable store, needing session+storage). Username caveat: a @username is optional and mutable, and the Bot API can't resolve username→id ahead of time — prefer ids when known, usernames is the pragmatic fallback. |
| @adriangalilea/utils/bot/groups | Group-chat identity as plain functions: chatIdOf(ctx) (the chat id on any ctx flavour), isGroupChat(ctx) / isPrivateChat(ctx), and isGroupAdmin(ctx, { chatId?, userId? }?) — the getChatMember creator/administrator check behind every admin-gated group setting (auto-summary toggles, "dismiss" buttons on group welcomes), chat/user defaulted from the ctx. Every reader resolves both gramio spellings — message ctxs carry chat, callback ctxs (inline-button taps) only chatId/message.chat — so the same gate works in handlers and taps; read chat ids via chatIdOf, never ctx.chat?.id. Takes real gramio ctxs and MenuCtx with no cast; a miswired ctx panics, an API rejection fails closed (false). Composition over policy: bot-owner override stays at your call site (ctx.isAdmin \|\| await isGroupAdmin(ctx)). |
| @adriangalilea/utils/bot/language (picker surface) | The language-picker vocabulary and surfaces, so bots don't fork label lists: flagFor / autonym / languageLabel ("🇪🇸 Español"), languagePickerItem({ label, codes, isActive, pick }) (the 2-up primary-highlighted MenuItem factory — storage and policy live in your closures), and addLanguageRows(kb, { codes, pack, active?, activeStyle? }) (the raw-InlineKeyboard twin for onboarding / group-welcome keyboards; activeStyle: "success" when primary already marks something else on the keyboard). |
| @adriangalilea/utils/bot/user | userLabel(u) — the conditional "[name] [@username] [id]" line every bot re-rolls for admin DMs and logs: Ada Lovelace (@ada · 42) / Ada (42) / @ada (42) / id 42, missing pieces drop instead of padding. Reads both gramio spellings (firstName / first_name); plain text by design. |
| @adriangalilea/utils/bot/urls | Telegram-message URL + entity parsing, on what the platform ALREADY parsed (entities carry exact UTF-16 spans — never startsWith("/") or a mention regex). urlsInMessage(msg) → the url module's Url[]: the visible text scanned by urlsIn PLUS text_link entities — hyperlinked words whose URL never appears in the text, invisible to any text scanner (what forwarded newsletters carry); a text_link's raw is its anchor words and its span covers them, so span-cutting treats linked words as the link. isCommandMessage(msg) (a bot_command entity at offset 0), commandToken(msg) (the leading command without any @botname suffix, /summary@my_bot → /summary; null for ordinary text), commandAddressee(msg) (the bot a command is explicitly addressed to, lowercased, null when bare — the group addressing gate: a bare command may belong to any bot in the room, one addressed elsewhere never concerns yours), cutEntities(text, entities, shouldCut) (exact span surgery; the predicate sees each visible slice), messageTextAndEntities(msg) (text or caption with ITS entity set). Framework-agnostic by shape: gramio's MessageEntity class and the raw Bot API object both fit. |
| @adriangalilea/utils/bot/announce | The house announcement (news) template for bot mass messages, first-class: an Announcement = banner (# 📰 <bot> News), ordered sections (canonical section.info/features/fixes with 💬/✨/🐛, free-form allowed), italic closer, · signature (middle dot by design, never an em dash). renderAnnouncement(a) → the Telegram-markdown subset; renderAnnouncementBodies({en, es, …}) → per-language bodies for a polyglot broadcast engine ("en" is the required fallback anchor). Pure, zero deps. |
| @adriangalilea/utils/bot/updates | The pending-update queue of a webhook-less bot token, treated as the destroy-on-read resource it is (getUpdates with an offset CONFIRMS — destroys — everything below it). updateQueue({ token, fetch?, apiRoot? }) → peek({ limit? }) (webhook state + pending count + the queue head, verbatim; sends NO offset so nothing is ever confirmed — repeatable, zero risk), count(), webhookInfo(), and drain({ onBatch, limit?, timeoutS?, quietPolls? }) — the one-chance-safe sweep: each raw batch is handed to onBatch (persist it somewhere durable) and AWAITED before the offset advances, because the next call is the confirmation; a crash re-delivers the unconfirmed tail on re-run, and the drain ends only after several consecutive silent long-polls, never on the first empty response. A 409 (webhook registered / competing poller) is a typed SourcedError, never a silent retry. Plain fetch, injectable for tests; no framework. |
| @adriangalilea/utils/bot/inline-feedback | Behavioral tripwire for BotFather's inline feedback probability (gates chosen_inline_result; defaults 0%, silently resets on bot transfer and inline-mode toggling, invisible to getMe): served-but-never-chosen crosses a threshold → throttled admin DM naming the exact BotFather switch. Storage injected as three ops; never throws into the answer path. |
| @adriangalilea/utils/bot/coalesce | Joins client-split inbound messages back into one. When a user pastes >4096 chars, Telegram clients fragment it into separate message updates with no marker. Middleware detects the burst and emits one combined event. |
| @adriangalilea/utils/llm | Multi-provider LLM caller on the Vercel AI SDK — the policy layer the SDK doesn't ship. createLlm({ providers, health? }) → .stream(req) (typed events: delta / reasoning / reset / tool-call / end) and .complete(req). Failover: priority-ordered across OpenAI-compatible, Anthropic-compatible, and OpenRouter endpoints; multiple keys per provider (health tracked per key fingerprint); per-key circuit breaker with exponential cool-down persisted in any KV-shaped HealthStore; an attempt that dies after emitting yields reset (discard, next candidate regenerates); empty completions count as failures. Accounting: tokens summed across billed attempts; costUsd is the ACTUAL charge (OpenRouter usage accounting via providerMetadata, or a cost field on the raw usage frame) — never a price-table estimate. Knobs: per-model temperature, maxTokens caps, disableThinking mapped to each dialect. Tools: AI SDK tool() + toolChoice pass through (re-exported). Worker-safe; peers: ai, @ai-sdk/openai-compatible, @ai-sdk/anthropic, @openrouter/ai-sdk-provider. |
| @adriangalilea/utils/bot/llm | The Telegram side of an LLM chatbot; the model side is @adriangalilea/utils/llm. Output: streamChatReply(ctx, events, opts?) consumes an AsyncIterable<LlmStreamEvent> and paints it with Telegram's native message-draft streaming (sendMessageDraft full-frame repaints, throttled ~1/s, keepalive under the ~30s draft TTL), then persists the finished markdown via ctx.send, entity-split across 4096 by @gramio/split. Reasoning models get a thinking phase — streams into the ephemeral draft and evaporates (reasoning: 'preview', default), persists as an expandable blockquote ('message'), or never renders at all ('hidden'). Upstream reset (provider failover) repaints the draft from scratch. Drafts are private-chat-only (+ BotFather forum-topic mode); elsewhere the preview phase is skipped and only the final send happens. Returns { content, reasoning, toolCalls, usage, messages }. The painter underneath is @adriangalilea/utils/bot/draft (createDraftPreview(ctx, { render, throttleMs? })): dependency-light (no markdown machinery), for producers that push deltas or own their per-frame rendering (plain or rich-message frames) and persist path, with streamForensics (render-regression + reset warnings) built in and exported standalone. History: llmHistory({...}) returns .plugin (decorates ctx.llm with .add() / .get() / .clear() / .all() / .clearAll(), per-(user, thread) OpenAI ChatMessage shape, persisted in the shared session record so the menu's 🗑 Forget wipes it automatically) AND .menuItem (drop-in "🗑 Delete this thread" for botMenu — wipes the LLM history AND calls deleteForumTopic so the Telegram thread + all its messages disappear from the chat; falls back to history-only clear when no threadId is present). |
| @adriangalilea/utils/bot/metrics | Self-serve product measurement, the flags pattern applied to counting: defineMetrics(spec, { write, funnels? }) — declare a metric once (counter / timing, perUser: true for uniques·repeat·retention·funnels), bump()/record() at the call site (fire-and-forget, never throws), describe() is the schema panels render generically so a new metric reaches every panel with zero panel edits. Storage = one injected atomic day-bucket upsert (aggregate row + per-user row when user given); per-user rows are personal data — wipe on forget, prune by age. Money stays in ledgers. |
| @adriangalilea/utils/bot/menu | botMenu({ command, description, items, privacy?, personalData?, adminContact }) — /settings command + InlineKeyboard router. Root view always renders a 🛡️ Privacy & data submenu button that wraps the privacy policy link plus (if personalData: { storage }) 🗑 Forget + 📥 Export buttons. Items take keepRow (render on the same row as the next item — e.g. a two-per-row language picker) and rootExtra (render at the bottom of the root menu, below Privacy & data). label / header / style / visible resolvers may be async (read your db at render time — never cache render strings in the session); parseMode: 'HTML' renders the header formatted (you own escaping); personalData.onForget(ctx, userId) wipes YOUR tables inside the same try as the session delete, so Forget either forgets everything or reports failure. toggleMenuItem({ id, read, write, label: { off, on }, toast? }) — convenience factory for boolean-toggle items with dynamic label + optional toast, storage-agnostic via read/write closures. |
| @adriangalilea/utils/bot/payments | botPayments({ session, storage, paysupport, paysupportHint?, legal, waiver, vip?, credits?, perks? }) — Telegram Stars monetization in one drop-in plugin. Three axes, all optional: vip (positional tier ladder — single rung in v1 is just vip: [{...}], ladder is vip: [{...}, {...}]; ids are vip.1, vip.2, …), credits (consumable balance + top-up packs credits.1, credits.2, …), perks (orthogonal one-shot unlocks perks.<key>). Surface: ctx.payments.atLeast('vip') / atLeast('vip.2') (typed rank check), ctx.payments.tier() / .tier.level() / .tier.label(), ctx.payments.credits.{balance, consume, tryConsume} (throws InsufficientCredits), ctx.payments.has(perkId), await ctx.payments.require('vip', { feature? }) (gate that sends a localized upgrade prompt deep-linked to /settings → 💎 VIP), await ctx.payments.invoice(productKey) (threads Art. 103(m) TRLGDCU consent inline before sendInvoice). Owns: waiver consent flow (versioned text → forces re-consent on bump, snapshotted on every charge for audit), /paysupport slash command (Telegram ToS §6.5; paysupportHint overrides the where-to-manage-charges line when your menu isn't /settings), idempotent successful_payment fulfillment via pay:idempotency:{chargeId} sentinel, lazy subscription expiry (no cron needed), tier upgrade auto-cancel of the lower rung's renewal, and admin-DM refund approval (mirror of accessControl's [✅ Aprobar][❌ Denegar] pattern). Returns: { plugin, menuItem, payouts, onFulfilled } — menuItem is the drop-in 💎 VIP entry for botMenu; payouts.{record, list, export, exportForUsers} is the Fragment payout ledger (you receive TON, log the EUR conversion, export time-windowed CSV/JSON for your gestor); onFulfilled(productKey \| '*', handler) / onRefunded(...) register fire-and-forget hooks (purchase applied / admin-approved refund — a revenue ledger writes on one, reverses on the other). Stars-only by design — Telegram ToS §6.2 forbids third-party payment providers for digital goods. Crypto Pay deferred (MiCA risk); Stripe-outside-Telegram is a future v2 channel. Full compliance memo (Spanish-autónomo seller-of-record analysis, Verifactu vs Crea y Crece, MiCA, Art. 103(m) waiver text, GDPR retention) in src/bot/payments/CLAUDE.md. |
| @adriangalilea/utils/bot/create | createBot<S>({ token?, storage?, initial?, admins?, language?, menu?, access?, payments?, handlers?, worker? }) — the composer (see "One bot file, ideation → production" above). Returns { build, session, poll, isMain, fetch }: poll() long-polls, export default app is a complete Worker, app.session(ctx) / the handlers callback's session(ctx) is the TYPED accessor for your S fields. Owns storage+session wiring; resolves storage per environment (D1 binding → bot/storage-d1; BOT_PERSIST path → sqlite, redis:// → redis, lazily-imported optional peers; else announced-ephemeral memory) — or pass storage: (env) => Storage when the choice is env-dependent (e.g. a D1 binding not named DB). Boot NARRATES the composition (session: memory …, features: language(en,es) · menu(/settings)) so every implicit decision is visible where you're looking. Runnable demo: pnpm demo:bot. |
| @adriangalilea/utils/bot/worker | botWorkerFetch(resolve) — the Cloudflare Worker cap: secret-checked webhook (ack fast, work + storage flush ride ctx.waitUntil, errors DM admins throttled), POST /setup (webhook registration with handler-derived allowed_updates + a commit-narrating 🚀 deploy DM from the request body), POST /deploy-started (the 🛳 "what is shipping" DM — curl it from your deploy script on the still-live version, body {sha, author, message, etaSeconds?}), operator-authed /pause /resume /webhook-status (+ statusExtra), and a routes escape hatch tried before the built-ins. Structural bot type — no workers-types dependency. |
| @adriangalilea/utils/bot/flags | defineFlags(spec, { read, write? }) — feature flags declared ONCE in code, resolved LIVE from the bot's operator-config record (D1 json row, Redis hash — storage-agnostic, Worker-safe, zero deps). Each flag: { kind: 'bool'\|'number'\|'string', label, help?, default }, where default is a scalar or a tier map { free, vip?, 'vip.N'? } resolved off ctx.payments.tier() — "free users get this limit, premium users get that model" is one declaration, zero branching at the call site (ladder walks down: exact rung → lower rungs → vip → free). Read sites: await flags.<key>(ctx) — stored override wins over the code default, per-ctx reads coalesce into one config fetch. flags.describe() is the JSON schema admin panels render generically, so a new flag appears in every panel with zero panel edits; flags.set(ctx, key, value) writes a kind-checked live override (null clears it — RFC 7386 null-delete, matching SQLite json_patch); flags.overrides(ctx) lists what's overridden. Corrupt stored values scream (SourcedError); bad specs panic at construction. |
| @adriangalilea/utils/bot/storage-d1 | d1Storage({ db, table? }) — @gramio/storage adapter over a D1 session table (schema in the module doc). flush() matters: the session plugin writes un-awaited and workerd freezes the isolate the instant fetch() returns — hand flush to bot/worker (automatic via createBot) so writes survive. |
Standard wiring:
import { Bot } from 'gramio'
import { redisStorage } from '@gramio/storage-redis'
import { adminContext, gracefulStart } from '@adriangalilea/utils/bot/kit'
import { botSession } from '@adriangalilea/utils/bot/session'
import { accessControl } from '@adriangalilea/utils/bot/access-control'
import { createLlm } from '@adriangalilea/utils/llm'
import { streamChatReply, llmHistory, toModelMessages } from '@adriangalilea/utils/bot/llm'
// Raw redis is fine — bot-id namespacing happens inside botSession +
// every plugin via ctx.bot.info.id. Multiple bots sharing this Redis
// stay isolated by construction. No manual prefix to remember.
const storage = redisStorage()
const userSession = botSession({ storage, key: 'session', initial: () => ({}) })
const chat = llmHistory({ session: userSession, maxTurns: 20, retentionDays: 7 })
// Any OpenAI-compatible endpoint: vllm-mlx, mlx-lm, llama.cpp, Together, Groq, OpenAI, …
// More entries = priority failover; a KV-shaped `health` store adds the circuit breaker.
const llm = createLlm({
providers: [{ id: 'local', type: 'openai', baseUrl: process.env.LLM_URL!, apiKey: 'none', defaultModel: process.env.LLM_MODEL! }],
})
const bot = new Bot(process.env.BOT_TOKEN!)
.extend(adminContext({ adminId: 190202471 })) // KEV.TELEGRAM_ADMIN_ID overrides
.extend(userSession)
.extend(accessControl({ session: userSession, storage, defaults: [] }))
.extend(chat.plugin)
.on('message', async (ctx) => {
if (!ctx.access.allowed) return
ctx.llm.add({ role: 'user', content: ctx.text ?? '' })
// Draft-streamed reply: thinking phase previews + final entity-split send.
const { content } = await streamChatReply(ctx, llm.stream({
instructions: 'You are helpful.',
messages: toModelMessages(ctx.llm.get()),
}))
ctx.llm.add({ role: 'assistant', content })
})
await gracefulStart(bot)Inside handlers, ctx.access is a typed discriminated union — { allowed: true, source: 'admin' | 'default' | 'store', record? } or { allowed: false, reason }. ctx.adminId and ctx.isAdmin are available on every event from adminContext.
For tests/demos without a second Telegram account, simulateAccessRequest(bot, storage, adminId, fakeUser, msg) injects a synthetic pending request so admin can exercise the approve/deny flow.
Menu items — coloured buttons, refresh, toast-return, confirm
MenuItem supports four cooperating fields for richer UX. Each is opt-in:
import { botMenu, toggleMenuItem } from '@adriangalilea/utils/bot/menu'
const menu = botMenu({
command: 'settings',
description: 'Open settings',
adminContact: '@yourhandle',
personalData: { storage },
items: [
lang.menuItem, // ← submenu, selected lang renders as a blue (primary) button
chat.menuItem, // ← red (danger) button with built-in "⚠️ Sure?" confirm step
// Boolean toggle — dynamic label + automatic colour + auto-refresh + toast.
toggleMenuItem({
id: 'thinking',
read: (ctx) => (ctx.session as { thinking?: boolean }).thinking ?? false,
write: (ctx, v) => { (ctx.session as { thinking?: boolean }).thinking = v },
label: {
off: { en: '💭 Thinking: OFF', es: '💭 Razonamiento: OFF' },
on: { en: '💭 Thinking: ON', es: '💭 Razonamiento: ON' },
},
toast: {
on: { en: 'Thinking on.', es: 'Razonamiento activado.' },
off: { en: 'Thinking off.', es: 'Razonamiento desactivado.' },
},
}),
// Custom destructive action with an explicit confirm step. The
// action only runs after the user taps Confirm in the overlay.
{
id: 'reset',
label: { en: '💥 Reset everything', es: '💥 Resetear todo' },
style: 'danger',
confirm: {
prompt: {
en: '⚠️ Reset ALL your data?\n\nThis is irreversible.',
es: '⚠️ ¿Resetear TODOS tus datos?\n\nNo se puede deshacer.',
},
},
action: (ctx) => {
ctx.session.somethingHeavy = undefined
// Return the toast string — the menu plugin owns the single
// answerCallbackQuery for the tap. Calling ctx.answer here
// would be a double-answer and would break refresh.
return { en: '✅ Reset.', es: '✅ Reseteado.' }
},
},
],
})Field summary:
style: 'primary' | 'success' | 'danger'(or(ctx) => …for state-dependent colouring) maps to Telegram's native InlineKeyboardButton.style. Usestyleinstead of emoji markers (●/○) for active-selection signalling — same UX, native rendering.refresh: truere-renders the menu in place afteractionruns, so dynamiclabel/styleresolvers reflect mutated state without the user re-opening/settings.toggleMenuItemenables this by default.actionreturnsvoid | string | Polyglot<string>; the menu plugin sends a singleanswerCallbackQuerywith that text. Never callctx.answer(...)from inside an action — Telegram rejects the second answer, the action throws, andrefreshnever runs.confirm: { prompt }adds a one-step confirmation overlay before the action runs. Cancel returns to root. Use this for destructive actions instead ofctx.answer({ show_alert: true })— Telegram's alert UI doesn't compose with refresh / toast.
Live state inside resolvers: label / style / header / visible resolvers fire AFTER the action mutated the session, and they may be async — read your database (or ctx.session.<field>) directly at render time; never cache render strings into the session to satisfy a signature. ctx.lang from bot/language is a snapshot at event start and goes stale within the same callback; ctx.say(...) IS live and safe to use anywhere.
See src/bot/CLAUDE.md for storage layout, design decisions, and gotchas.
Release
Bump version in package.json, push to main. CI handles everything:
- Type-check, lint, build
- Publish to npm via OIDC trusted publishing (no tokens — GitHub Actions proves identity directly to npm)
- Create git tag
vX.Y.Z - Generate changelog via git-cliff and create GitHub release
License
MIT
