@adriangalilea/utils
v0.19.0
Published
TypeScript utilities - logger, currency, formatter, GramIO bot plugins, and more
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
Number and currency formatting utilities:
import { format } from '@adriangalilea/utils/format'
// Number formatting
format.number(1234.567, 2) // "1234.57"
format.withCommas(1234567) // "1,234,567"
format.withCommas(1234.567, 2) // "1,234.57"
// Compact notation
format.compact(1234567) // "1.2M"
format.compact(1234) // "1.2K"
// Currency formatting
format.usd(1234.56) // "$1,234.56"
format.btc(0.00123456) // "0.001235 ₿"
format.eth(1.23456789) // "1.234568 Ξ"
format.auto(100, 'EUR') // "€100.00"
// Percentages
format.percentage(12.3456) // "12.3%"
format.percentage(0.05) // "0.05%"
format.percentage(123.456) // "123%"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
- 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
- Bot plugins (GramIO):
kit(graceful shutdown + admin context),access-control(gate + approve/deny menu, backed by sessions),llm(OpenAI-compat SSE parserstreamChat+ Telegram streaming outputctx.startStream+ 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.say — a callable namespace bound to ctx.lang:
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 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/sessionThreaded Mode — pin the @gramio/contexts fork
Telegram added Threaded Mode for private chats (BotFather → Bot Settings → Threaded Mode). gramio's SendMixin skips auto-threading there, and CallbackQueryContext doesn't expose threadId at all. Fixes PR'd upstream; until merged, pin the fork in your bot project's package.json (pnpm only honors overrides at the workspace root, not transitively):
{
"pnpm": {
"overrides": {
"@gramio/contexts": "github:adriangalilea/contexts#local-build/auto-thread-private-chat-threaded-mode"
}
}
}Then pnpm install. Every ctx.send / ctx.sendDocument / ctx.reply / etc. — including from callback handlers — will auto-forward message_thread_id and stay in the thread the message came from. If you don't use Threaded Mode, skip this.
| Subpath | What it does |
|---|---|
| @adriangalilea/utils/bot/kit | 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.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 stay isolated by construction; every plugin in this package derives the same prefix internally via botStorageKey(ctx, userId) / botSubKey(ctx, sub). 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. |
| @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/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/bot/llm | The full LLM-chatbot pipeline in one module. Input: streamChat(response) parses OpenAI-compatible SSE (OpenAI, vllm, mlx-lm, llama.cpp, Together, Groq, …) into a typed AsyncGenerator<{type: 'content' \| 'reasoning', text}>. Output: ctx.startStream() (low-level: debounced markdown to Telegram, 4000-char split, exposes wasPartial after .end()). ctx.startChatStream(response) (high-level: consumes the stream, renders reasoning as a Telegram expandable_blockquote entity + content as streamed markdown — both go through markdownToFormattable with graceful degradation — returns { content, reasoning }). 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 Redis-only clear when no threadId is present). |
| @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. 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, 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), 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) registers fire-and-forget hooks. 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. |
Standard wiring:
import { Bot } from 'gramio'
import { redisStorage } from '@gramio/storage-redis'
import { adminContext, botSession, gracefulStart } from '@adriangalilea/utils/bot/kit'
import { accessControl } from '@adriangalilea/utils/bot/access-control'
import { llmStream, llmHistory } 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 })
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(llmStream())
.extend(chat.plugin)
.on('message', async (ctx) => {
if (!ctx.access.allowed) return
ctx.llm.add({ role: 'user', content: ctx.text ?? '' })
// Any OpenAI-compatible endpoint: vllm-mlx, mlx-lm, llama.cpp, Together, Groq, OpenAI, …
const response = await fetch(process.env.LLM_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: process.env.LLM_MODEL,
messages: [{ role: 'system', content: 'You are helpful.' }, ...ctx.llm.get()],
stream: true,
}),
})
// High-level: handles reasoning (collapsed blockquote) + content streaming.
const { content } = await ctx.startChatStream(response)
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 resolvers fire AFTER the action mutated the session. Read mutable state from ctx.session.<field> directly. 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
