A Telegram shop bot for selling V2Ray subscriptions, deployed as a single Cloudflare Worker.
NarnixVPN is built on the Narnix framework — the Worker-side bot toolkit this project shares with the framework's other bots. The bot itself runs in one Worker: the storefront, the wallet, the 3X-UI provisioning client, the support desk, the administration surface and the cron jobs. The AI support assistant module also runs in that Worker, relaying user messages to a separate AI backend that runs in Docker over signed callbacks.
| Area | Behaviour |
|---|---|
| Storefront | Category → package → inbound cart, live pricing, mandatory inbounds, per-package extras |
| Checkout | Wallet balance or card-to-card top-up, discount coupons, one-time free trial |
| Delivery | Config created on the 3X-UI panel; subscription URL and QR code delivered in chat |
| Accounts | Balance, deposits with approval, transaction history, renewals that re-price and reset traffic |
| Support | Tickets relayed into the owner's private forum topics, with a delivery ledger and backlog recovery |
| AI assistant | A temporary forum topic per user, relayed to a separate agent backend over signed callbacks |
| Administration | Admins, panels, inbounds, categories, packages, coupons, broadcast, user lookup, manual balance adjust |
| Automation | Cron-triggered broadcast drain, expiry/quota warnings, panel reachability |
| Languages | Persian and English, with compile-time-checked translation keys |
The bot is assembled from 12 pluggable modules. A module contributes a grammY composer and, optionally, conversation flows (wizards); the registry mounts them in this order:
| Module | Responsibility |
|---|---|
start |
Main menu, /start, referral links, user bootstrap |
free_test |
One-time free trial subscription |
language |
Language switch and per-user locale |
wallet |
Balance, card-to-card deposits and approvals, transaction history |
assistant |
AI support assistant: temporary topic, message relay, signed callback dispatch |
ticket |
Owner-only support tickets over dual forum topics |
user |
Subscription list, config delivery, renewals |
coupon |
Discount codes with usage tracking |
shop |
Package purchase flow, pricing, checkout, provisioning |
category |
Category CRUD and the package wizard |
packages |
Package catalogue and inbound pricing |
admin |
Panels, inbounds, admins, lookup, broadcast, manual balance adjust |
- Module registry.
src/core/module.tsdefines theBotModulecontract;src/core/registry.tsregisters the modules and mounts them throughattachToBot(). The registry refuses duplicate module ids and duplicate conversation ids — the latter becausectx.conversation.enter(id)resolves through one flat map, so a collision would silently shadow a wizard. - Deliberate middleware order (
src/core/bot.ts): session → env + i18n binder → channel lock → conversations plugin → module composers. An active wizard must consume updates before anymessage:texthandler can steal them. - Wizards on conversations v2.
src/core/wizard.tsis a small toolkit over@grammyjs/conversations(ask,askText,askInt,askNumber,askChoice,askPhoto,confirm,/cancelhandling, Persian/Arabic digit normalisation). Replay state lives in D1, not KV (src/core/conversationStorage.ts): KV is eventually consistent, and a stale replay log is corruption rather than a missed step. - HTML in one place.
src/core/parseMode.tsinjectsparse_mode: HTMLon the formatted Bot API methods. Because the conversations plugin builds a freshApiper replay, the transformer is re-installed per conversation (src/core/conversations.ts). - Pluggable payment gateways.
src/modules/wallet/gateways/holdsBasePaymentGateway, aCardToCardGatewayand a registry that self-heals from the database on read (register/get/ensureRegistered/syncWithDB) — the documented Workers pattern, since a Worker has no safe startup hook. - Typed i18n derived from the locale file.
src/types/i18n.tsderives every dotted leaf path ofsrc/locales/fa.json, so translation keys are checked at compile time and there is no generated file to drift.check.i18n.tsaudits parity, unused keys and inline Persian. - Money primitives.
src/utils/balance.tsowns the only two ways a balance may move. Both return a boolean instead of throwing (a refund runs inside acatchblock that is already explaining a failure) and both reject amounts that would corrupt the row arithmetically. - 3X-UI client.
src/utils/v2ray.tscreates, updates and removes clients, reads traffic and builds the subscription URL;src/utils/subDelivery.tsandsrc/utils/configTag.tshandle delivery and config naming. - Background jobs (
src/jobs/): a keyset-cursor broadcast drain with a lease, a panel reachability probe with a permanent-failure classifier, and an expiry/quota warnings pass. Two Cron Triggers are declared; the dispatcher matchesevent.cronas a literal string, which is why the trigger stays declared even while the warnings pass is switched off. - AI assistant (
src/modules/assistant/): a temporary forum topic per conversation, message relay into the backend, and signed callback dispatch onPOST /ai/callback. The module is registered before the ticket relay on purpose — both claim threaded private-chat messages, and the ticket relay's unlinked-topic branch would otherwise answer messages in AI topics.
| Area | Choice |
|---|---|
| Runtime | Cloudflare Workers (compatibility_date 2026-06-14) |
| Language | TypeScript, strict, noEmit + skipLibCheck |
| Bot framework | grammY with @grammyjs/conversations v2 |
| Storage | Cloudflare D1 (binding DB, database narnix-db) and KV namespace SESSION_KV (24 h session TTL) |
| Tooling | pnpm workspace, wrangler, tsx, vitest |
| Panel | 3X-UI over its HTTP API |
src/core/ bot assembly, module contract, registry, wizard toolkit, conversations,
conversation storage, D1 model layer, env assertions, error handler
src/modules/ start, free_test, language, wallet, assistant, ticket, user, coupon,
shop, category, packages, admin
src/jobs/ broadcast drain, warnings, reachability, cron dispatcher
src/middlewares/ channel lock
src/utils/ 3X-UI client, subscription delivery, balance, i18n, KV sessions, dates,
randomness, formatting, config tags
src/locales/ fa.json, en.json
src/types/ ambient Env bindings, context, i18n, database, coupon types
src/scripts/ setWebhook.ts
migrations/ 0001_baseline … 0010_ai_chats
Requirements: Node.js with pnpm, a Cloudflare account (Workers, D1, KV) and a bot token from @BotFather.
pnpm install
cp .dev.vars.example .dev.vars # then fill in the values
pnpm db:migrate # apply the migrations to the local D1 database
pnpm dev # wrangler devpnpm poll runs the same bot over long polling instead of a webhook, through
getPlatformProxy() against the local D1/KV bindings — useful when Telegram cannot reach your
machine. Set LOCAL_PROXY to a SOCKS proxy if the Telegram API is blocked on your network.
Local development reads .dev.vars (gitignored); production reads Worker secrets.
src/core/env.ts logs the names of any required variable that is missing at the top of every
invocation, so a forgotten wrangler secret put cannot fail silently.
| Variable | Required | Purpose |
|---|---|---|
BOT_TOKEN |
yes | Telegram bot token |
OWNER |
yes | Owner user id — a super-admin regardless of the admins table |
ADMIN_GROUP_ID |
no | Supergroup for admin notifications and support-ticket topics |
CHANNEL_LOCK, CHANNEL_LOCK_LINK |
no | Mandatory-join channel and the link shown on the "join first" screen |
QR_SERVICE_URL |
no | Self-hosted QR renderer (falls back to a public one) |
AI_BACKEND_URL, AI_CALLBACK_SECRET |
no | Assistant backend and the HMAC key shared with it |
LOCAL_PROXY |
no | SOCKS proxy, read only by src/poll.ts |
WEBHOOK_URL, SECRET_TOKEN |
no | Used by src/scripts/setWebhook.ts to register the webhook |
wrangler secret put BOT_TOKEN
wrangler secret put OWNER
wrangler secret put ADMIN_GROUP_ID # optional
wrangler secret put CHANNEL_LOCK # optional
wrangler secret put CHANNEL_LOCK_LINK # optional
wrangler secret put QR_SERVICE_URL # optional
wrangler secret put AI_BACKEND_URL # optional
wrangler secret put AI_CALLBACK_SECRET # optional
wrangler secret put UPDATE_SECRET # the webhook secret the Worker verifies
pnpm deploy # wrangler deploy
pnpm webhook # register the webhook with Telegram
pnpm db:migrate:remote # apply the migrations to the remote D1 database| Script | What it runs |
|---|---|
pnpm dev |
wrangler dev |
pnpm poll / pnpm poll:watch |
long polling through getPlatformProxy() (nodemon in watch mode) |
pnpm test |
vitest run |
pnpm typecheck |
tsc --noEmit |
pnpm check:i18n |
locale parity, unused-key and inline-Persian audit |
pnpm smoke:wizards |
wizard smoke harness |
pnpm deploy / pnpm webhook |
deploy the Worker / register the webhook |
pnpm db:migrate, pnpm db:migrate:remote, pnpm db:migrations |
apply and list D1 migrations |
| Expression | Job |
|---|---|
* * * * * |
Broadcast drain — one cursor batch per invocation |
*/15 * * * * |
Expiry and quota warnings |
Both are declared in wrangler.jsonc and dispatched in src/jobs/index.ts by matching the
expression string. The warnings pass ships disabled (WARNINGS_ENABLED = false in that file —
nothing was removed, flipping the boolean is the whole change) and its trigger is deliberately left
in place, so that re-enabling it cannot silently fall through to the broadcast drain.
The AI assistant module is the Workers-side half of a separate support-agent service that runs in Docker. A user's messages are relayed into a temporary topic of their own, and the backend answers through:
POST /ai/callback
x-narnix-signature: t=<unix>,v1=<hex>
The signature is HMAC-SHA256 over the timestamp and body with AI_CALLBACK_SECRET, and requests
outside a five-minute window are rejected as replays. The Worker handles these actions: answer,
typing, create_ticket, close_chat, reset_chat, send_qr, send_config, draft and
stage. With AI_BACKEND_URL / AI_CALLBACK_SECRET unset the feature is simply off.
- The repository contains no CI configuration; verification is the
pnpmscripts and the two root harnesses, run locally. smoke.wizards.tshas two wallet checks that fail against the current harness data; the drift is in the harness, not in the module.- Webhook registration reads
SECRET_TOKENwhile webhook verification readsUPDATE_SECRET(src/index.ts). Set both to the same value, or registration will succeed and updates will be rejected. pnpm poll:watchexpectsnodemonto be available; it is not a declared dependency.
NarnixVPN is released under the GNU Affero General Public License v3 (AGPL-3.0-only), the
copyleft license designed for software that runs on servers: anyone operating a modified version
must offer its full source code to the users interacting with it. The complete license text is in
LICENSE.
The Narnix framework and the separate AI assistant backend are independent projects and carry their own licensing.