MatchMakerBot · a guide for whoever continues it

Owning the codebase

A reading path through the code. The classic view and the workshop explain the product and its design; this guide shows where each thing lives, how a request moves through it, which rules hold it together, and how to change it without breaking them.

Every surface stays brief. More depth is one click away: ▸ expandable blocks open in place, cards marked open ↗ open a panel, and any teal mono link opens that file's declarations from the reference atlas. Press / to filter chapters, Esc to close a panel.

0source files
0lines, no tests
0types
0functions
0tables
0migrations
0tests, ≈
Part I · Orientation

01How to read this guide

About three focused sessions, with the repository open beside the page. Paths are relative to /home/pydea/thcpp/Go/MatchMaker/mmbot. Every snippet comes from the file it names, trimmed only where marked ….

①Session 1 · shape & runtime

Chapters 02–10: the layers, boot, one update's journey, concurrency, state, and the Telegram layer's patterns. Open each file they name.

②Session 2 · the product

Chapters 11–26: searching, the engine, the relay, the gates, social, Crush Match Day, moderation, then the money path (read it twice) and the referrals that feed it.

③Session 3 · working on it

Chapters 27–38: config, observability, tests, recipes, invariants, what's next, the atlas, the table and type maps, the kitchen. Then answer chapter 39's questions.

🧭 The fastest way in

Read app.New top to bottom (chapter 04), then Server.dispatch (chapter 05). Those two functions are the map of everything else: the first says what exists, the second says which code answers a user.

02The shape of the thing

One Go binary over Postgres and Redis, in four layers. Click a layer for what lives there.

cmd/bot · internal/app
Lifecycle and the one composition root, shared with the test harness.
▼ builds and injects
Adapter — internal/telegram
Webhook, ordered intake, dispatch, every screen and flow, the paced send queue. Later: internal/admin, internal/webviews.
▼ calls, through interfaces it declares itself
Domain — internal/domain/*
user · gender · language · settings · chat · match · billing · directory · crush · moderation · wallet (+ chain, evm, svm, ton, price) · payment. Never imports telegram.
▼ reads and writes
Stores — internal/store/pg · internal/store/redis · migrations
31 GORM models and the seed; the mm: keyspace with its Lua scripts; 35 migrations.
Cross-cutting — config · clock · i18n · obs
Usable from any layer, never reaching upward.

Where the lines are

Non-test lines per package. Click a bar to open that package in the atlas.

∅Nil means “not built”

telegram.Deps takes every service as an interface; a nil one switches its surface off. With no master key there is no wallet, no shop and no invites, and the keys answer coming soon. That is how features landed one sub-phase at a time without flags, and how unit tests build only what they test.

≡One wiring, production and tests

Kitchen tests build the bot through the same app.New, swapping only what a test must own through With… options. A passing test exercised the production wiring.

03Vocabulary

The words the code uses, each one a type or a column you'll meet. Click for the precise meaning.

Part II · Runtime

04Boot, run, shut down

What app.New does

Click a step for its detail.

1 · storesPostgresopen → migrate → seed
2 · stateRedis + settingsopen → seed settings
3 · servicesDomainmatcher, access, directory, wallet, payments…
4 · adaptertelegram.ServerDeps → NewServer
5 · jobsScheduler8 periodic jobs

Run and shutdown

▶App.Run

Starts the sender, runs the boot reconcile (chats, then searches, 2 min budget, panics recovered, users told what moved), then Server.Run: SetWebhook with the secret and an explicit allowed_updates, the HTTP listener (/webhook/<secret>, /metrics), and the scheduler.

■App.Shutdown

Stops the scheduler with half the time left (so a running job finishes), then Server.Shutdown: HTTP server first (accepted updates finish), the inbound shards drain, then the send queue drains. Close closes only the stores it opened.

Why the order matters shutdown

Each stage can produce work for the next. A job may enqueue replies, and an update handled during the drain may enqueue replies, so the sender drains last. HTTP stops first so nothing new arrives. Updates Telegram has already been answered for are on a context the signal doesn't cancel, so they complete rather than vanish. A test sends 30 updates just before shutdown and expects every reply.

⚠ Config is strict

config.Load fails the boot on a missing required key or a malformed typed value: flags take only true/false, AGE_MIN_DELTA a whole number ≥ 0, accept levels a known name. Every bad key is named in one error. Nothing silently falls back to a default.

05One update's journey

Follow one Telegram update from the socket to the reply. If this chapter makes sense, you can find where any behaviour lives.

server.go① Webhooksecret checked, handed to inbound before the 200
inbound.go② Ordered intake64 shards by user, one at a time each
server.go③ Dispatchuser, gate, presence, route
surfaces④ Handlerdomain calls, catalog text
sender.go⑤ Replypaced, ordered per chat, retried

The dispatch table

UpdateGoes to
my_chat_member (blocked the bot)relay.release: stop searches, end the chat, tell the partner
inline_query · chosen_inline_resultinline.go
anything from a user with BannedAtrefuseRestricted
callback lang:onboarding
callback dm: · crush: · wal:/sec:wallet: · quota:plans, shp: · people:directs · crushes · wallets · shop · people
callback search:stop: · find: · quota: · search:add|age|profilerelay (status card, Find card)
any other callback (profile:*, sec:*)settings.handleCallback
message while a prompt waitssettings.capture, unless a command or key, which cancels and falls through
/cancel /settings /profile <id> /user_<id> /like /dislike /changeAgeSpecs /changeAgeSearchRangetheir handlers
a home-keyboard labelpressHome
anything else from someone in a chatrelay.handle
the restonboarding
Two quiet rules

A forwarded message is never read as a command or key press: it carries someone else's words. Only private chats are served.

Read the real dispatch, trimmed server.go

06Concurrency model

What runs at the same time, and what keeps it correct.

HTTPa goroutine per request inbound64 shards · 1 goroutine each shard 0 · users … shard 1 ⋮ shard 63 domain + storesRedis user locksPG advisory wallet locksrow locks · compare-and-setLua scripts schedulera goroutine per job QueueSender4 workers · chat id mod 430/s token bucket→ Telegram
🔒Redis per-user lock

Guards a user's search and chat state. Token-safe SETNX with a 10 s TTL, polled every 20 ms.

💰Postgres advisory lock

Every money operation for a user, behind an in-process gate holding at most 6 at once.

▤Row locks in a transaction

FOR UPDATE on the user row where a count and its ceiling must agree.

⇄Compare-and-set & Lua

Rows move only from the state they were read in; multi-key Redis changes are scripts.

What may run at the same time for one user? reasoning aid
SourceSerialised by
Their own updatestheir inbound shard (strict order)
Their partner's updates (relay to them)nothing user-level: the partner's shard. Ordering per chat comes from the sender.
Sweeps pairing themRedis user lock
Settle job touching their orderswallet advisory lock
A moderator's punishmentrouted into their inbound shard
Crush accept by the other sidematch.Connect: both Redis locks, lower id first

07State: Postgres and Redis

Postgres is the record; Redis is the hot state, rebuilt from Postgres at boot when needed.

The 31 tables

Click a group to see each model's fields, read from pg/models.go. How they connect, and which flows write them: the data model map (chapter 34).

Forward only

A new numbered step in migrations.go, each in its own transaction. Never edit an old one; rollbacks are never run.

One visibility rule

pg.VisibleTo: not banned, past onboarding, no block either way. Directory, inline, profiles, lists and the ceilings' counts all use it.

Exact money

shopspring/decimal: USD as numeric(18,6), coin amounts as numeric(78,18). Never a float.

The Redis keyspace (mm:)

FamilyKeysFile
Status & chatsstatus:{u} · pair:{u} = "partner:chat" · chat:{id}chat.go
Search poolpool · pool:gender|age|lang:* · search:{u}:{s} · searches:{u} · searchseq · searchdue · matchtmp:*search.go
Status cardssearchmsg · searchseal · searchresent (each :{u}:{s})searchmsg.go
Presencepresence:{u} (online for 10 min)presence.go
Promptsawait:{u} = "field|messageID"session.go
Albumsalbum:{tg}:{group} · albums:{tg} · albumduealbum.go
Quotasquota:{feature}:{u}:{week|day}quota.go
Cachessetting:{key} · bal:{chain}:{addr}:{coin}:{gen} 45 s · balgen:{u} · price:{id} 60 scache.go
Lockslock:{u} 10 s · lock:fullsweeplock.go
Lose Redis entirely — what happens? recovery

The next boot's reconcile rebuilds what matters from Postgres:

  1. Chats (chat.Sessions.Reconcile): pairs whose chat Postgres ended are unpaired. Open chats are restored (RestoreChat), or closed if one side is paired elsewhere. Stray chatting/matched users are idled.
  2. Searches (match.Reconcile): orphaned pool members are forgotten, searches of chatting users dropped, searchers with no search idled, cards of lost search records sealed.

Users who were moved get a notice with the home menu. Searches themselves are lost; quotas and caches simply start afresh. Notices beyond the send queue's capacity are dropped and counted (CONCERNS C7).

Part III · The Telegram layer

08Five patterns you'll reuse

27 files, one set of habits. Learn these five and every screen reads the same way.

Every inline button carries prefix:verb:args in at most 64 bytes. Prefixes are constants at the top of each file, dispatch routes on the prefix, and the surface parses the rest. Carry ids and cursors, never labels: one Persian letter is two bytes. Every callback is answered (AnswerCallbackQuery), even on error, or the user's spinner keeps turning.

The full registry, generated from the code, is in chapter 09.

A flow needing typed input stores await:{u} = "field|messageID" and sends a prompt whose Cancel button is scoped to that field (profile:cancel:<field>, wal:wx). The next message lands in settings.capture, which routes by the field's prefix:

Rules

A typed command or a pressed key cancels and runs. A tap does not cancel. Cancel clears only its own field. Opening a chat clears both sides' prompts.

By design

A prompt opened while chatting takes the next message (writing a direct to the partner from their card). Refusing that was tried and reverted.

Menus are one message whose text and keyboard are rewritten on each tap. Section is the reusable form: optional photo, caption fields, and a nested inline menu with Back. Paths like sec:profile:0.2 mean root → item 0 → item 2, and a stale path falls back to the root.

Captions are measured in UTF-16 units against Telegram's 1 024; the line crossing the limit is cut with “…”. Wallet, shop and people screens use a lighter view{text richText, rows} rendered by each surface's show. Queued edits use Message.Edit, a func asked at delivery for the id, so an edit queued behind a sealed card sees it's too late.

bundle.T(locale, "ns.key", map[string]any{"N": …}). Keys live in internal/i18n/locales/en.toml and fa.toml, flat "ns.key" = "…" lines, always both. Persian gets i18n.Digits, Jalali dates (i18n.Date) and mirrored keyboards (MirrorRows). A key missing in Persian falls back to English.

Namespaces: start · onboarding · language · gender · profile · prompt · settings · button · home · find · search · chat · mood · error · quota · limit · people · like · dislike · dm · next_online · crush · moderation · ago · dur · distance · feature · tier · network · wallet · payment · shop · guide.

NewServer builds each surface only when its dependency exists, then connects them with funcs. When you add a surface, this is where it plugs in:

09Screens and the callback registry

The home keyboard is the user's map. Each key opens a surface; click one for its file and flow.

🔎 Find

the Find card

🎲 Random

a search with no filter

👤 Profile · ⚙ Settings

the profile Section

👥 People

who's around

💘 Crushes

the list and the next Match Day

💬 Directs

the inbox

👛 Wallet

balances, deposit, withdraw

⭐ Subscribe

the shop

🎁 Invite

the user's invite link

In-chat keys

While chatting, the reply keyboard is Next · Close / Icebreaker · Partner's profile / Block · Report (relay.press). Their labels are matched in the user's locale; a forwarded label never presses.

Callback registry

Every callback-data constant in internal/telegram, read from the code. Filter it:

ValueConstantFileCarries

10Onboarding and the profile

1
First contact. FirstContact creates the row with a fresh PublicID and says this update made it. If that update is /start ref_<public id>, the user is bound to that inviter (chapter 26). No locale yet, so promptLanguage sends the language buttons (lang:en, lang:fa).
2
Language. chooseLanguage stores it, edits the prompt into the welcome and pre-use notice, and sends the reply keyboard in a message of its own, since an edited message can't carry one.
3
Required fields. Gender, age and alias (MissingRequiredFields). A search or a direct without them asks the user to finish the profile.
4
The profile. Edits go through prompts and pickers in settings.go. Each save calls RefreshProfile to re-file a live search, and once gender, age and alias are all set, counts the user toward their inviter's prize.
Input rules worth knowing domain/user
  • Alias (TidyAlias): one line, invisible characters dropped, 1–32 characters including a letter or digit, no contact details.
  • Mood: nine codes (chill, chatty, bored, romantic, vent, nightowl, deep, curious, playful) or typed. A typed mood goes through TidyMood (1–40 characters, no contact details). MoodKey folds case, punctuation, Arabic/Persian letter forms, ZWNJ and repeated letters for matching. A typed label of a mood button, in any language, becomes that button's code (buttonMood).
  • Gender identity: only for a special gender. Also found as male, female, or only my gender. Picking a non-special gender clears it.
  • Speaking languages: toggles capped by the speaking_language ceiling, row-locked against a concurrent clamp. Retired languages are never shown or matched.
  • Location: an attached location. Clearing it under a running nearby search offers to stop that search.
  • Next online: durations from a picker (5 min … more than 2 days, or custom minutes up to 30 days). No clock times, so no timezones.
Part IV · Matching & chat

11Searching and the status card

A search is a background state: the home keys stay, and a status card carries the controls.

✎Refine, don't restart

A Find pick on a running search refines it: same id, same card, same place in the queue.

🔏Sealing exactly once

The card's id and its final text can arrive in either order. A Lua script makes the edit happen once.

✨The animation

Every 10 s, 60 searches a tick, on the low lane. One line per walk of the pool.

🗑A deleted card

Sent again once, so the hunt stays in sight.

12The matching engine

search.gogate.gorank.gopair.gospec: .stuff/MATCHING.md
Startunder the user's lock candidatesindex sets, 500/page gatemutual, hard rankaffinity + wait reserve → Opena chat SetDue / Unduewait for a sweep

Both sides must accept each other: Eligible(s, c) = accepts(s, c.Profile) && accepts(c, s.Profile).

AgeDelta is computed from the wait, never stored, so no sweep rewrites a search and races a refine.

Among the eligible, Weights.score sums signals in [0, 1], each times its weight. A signal only one side can have neither helps nor hurts. Ties go to the longest wait.

SignalWeightFull marks when
Age3same age, falling to 0 at 10 years apart
Language3the candidate speaks every language wanted (or the seeker's own)
Mood2same mood key
Proximity2near, falling to 0 at 100 km
Wait2the candidate has waited 2 minutes or more
  1. pair reads candidates by intersecting index sets, oldest first, 500 a page, going to the next page (up to 20) only while a whole page yields no pair.
  2. Blocks either way and restricted users are filtered out; the gate and the rank pick the best.
  3. reserve tries the candidate's lock once, removes both searches from the pool, then Sessions.Open. If the open fails, both are requeued.
  4. No pair: the search becomes due when its age band next widens, or not at all.
  • On arrival: Start and Refine try at once.
  • Due retries: match.sweep every 5 s pops what is due in mm:searchdue. The cost is O(due), not O(pool).
  • Safety net: match.fullsweep marks every member due on the admin's interval (5 min; 0 = off). It never pairs by itself.

Decided with the maintainer: only these. Nothing polls the whole pool on a timer.

RuleValueWhere
Around-my-age startAGE_MIN_DELTA (5)env
…widens1 year every 30 sDefaultRules
Nearby50 kmDefaultRules
Live searches per user1 (liveSearchCap, a function on purpose)search.go
Candidate page500, up to 20 pagespair.go
Sweep5 sSweepEvery
Metrics the engine keeps metrics.go

mm_searches_started_total{language}, mm_searches_superseded_total, mm_searches_stopped_total (user stops), mm_matches_total, and the histogram mm_time_to_match_seconds{language} with 10 s as a bucket bound, so “matched within 10 s” reads straight off it. The label is the searcher's UI language; per-hour views come from querying the series over time.

13Chats and the relay

Anonymous by construction: the bot re-sends, never forwards, and scrubs everything it can reach.

🧽 Scrubbing

@handles, URLs, t.me links, phone numbers, with formatting kept.

📦 Kinds

Text by sendMessage; media by copyMessage; some refused.

↪ Forwards

Labelled “Forwarded by {alias}”; the origin never shown.

🖼 Albums

Held a second, sent as one group.

⏹ Ending

Close, Next, Block, Report, a blocked bot, a punishment.

🗄 Evidence

Recorded as written, purged after the retention window.

Part V · Product rules

14The two gates

⏱Subscription axis: rate quotas

CheckAndConsume: a subscriber is unlimited; otherwise one Lua draw on mm:quota:{feature}:{u}:{window}, in a window that opens at first use and lasts a week. specific_chat 15, directs 10 (settings). Peek checks without drawing; Return gives a unit back.

📏Unlock axis: capacity ceilings

EffectiveLimit: the highest owned tier's limit_count, where NULL is ∞. Never infer it from a tier's name: a Phase-2 limit tops out at 5. It's enforced where a list grows.

FeatureNormalSemiUnlockedEnforced in
speaking_language2—∞ · $1.99profile picker, Find card
crush_list25 · $1.99∞ · $4.99💘 toggle
friends_list520 · $0.99∞ · $2.99➕ Contact
crush_match_day12 · $2.99∞ · $6.99the Match Day draw
profile_photoseeded inactive (Phase 2)—

Prices are seeded opening values (OI-1), admin-editable, finalized at the release gate; 0 means not for sale. Upgrades pay the difference.

How the user is told telegram/quota.go · shop.upsell
  • Out of allowance: “You've used your N free filtered searches. They come back in 3 days. Random is still unlimited”, with ⭐ See plans. If the allowance is 0, filtered searches are for subscribers.
  • Last free unit: the draw that takes it is followed, once its action happened, by a heads-up with See plans.
  • List full / language past the cap: an alert, then an upsell listing that feature's tiers still for sale, priced for the user, at most every 10 minutes (UpsellEvery).
  • IsSubscribed trusts the cached SubscriptionExpiry only to say no; a yes is confirmed against an active row.

15Social: presence, people, directs

🟢 Presence

Every update; online for 10 min, idle for an hour.

👥 Directory & cards

Who's around, by recency or nearest.

@ Inline mode

Search people by alias from any chat.

❤ Ties

Likes, contacts and crushes on one capped path.

✉ Directs

Pile up until accepted.

⏰ Next online

Set by anyone, seen by subscribers.

16Crush Match Day

1
Claim the day. The job runs every 10 min. It takes an active CrushMatchDate equal to today in UTC whose ran_at is null and sets it in the same transaction as the matches. A second tick or a restart draws nothing, and a missed day is never drawn late.
2
Mutual pairs, oldest earlier crush first, without banned users or blocked pairs.
3
Allot under each side's crush_match_day ceiling. A pair either side has no room for is dropped for both, and neither is told.
4
Announce each match to both, naming /user_<id>, with 💬 Accept & Chat (crush:a:<match>).
5
Accept. Subscribers only (others get See plans and nothing is recorded). The chat opens when both accepted and both are free, through match.Connect under both locks. Otherwise the later accepter waits, and the chat opens when a chat ends (relay.freed).
The draw's SQL and allotment crush/matchday.go

17Moderation and safety

Acting on it

App.Punish (what the admin panel will call) records it, then Server.Punished runs in the user's own inbound shard: a warning is passed on; a ban or suspension stops searches, ends the chat (the partner is told), removes the keyboard and says why.

The gate

Dispatch refuses every update from a user with BannedAt. Pairing never matches one. moderation.lift (1 min) clears BannedAt for ended suspensions and welcomes the user back.

Offender feed

Every masked message writes a CensorHit in the same transaction. Offenders(since, atLeast) sums them for the admin panel's thresholds.

Shield hook

telegram.Shield (nil in the app) is asked before every chat or direct delivery: the seam for minors-safety work. No detector in Phase 1.

Part VI · Money

18The money map and its laws

Read this part twice. One law runs through it: nothing may ever pay twice, and no coin may be lost, under retries, double taps, crashes, lagging nodes and reorgs.

Screens — telegram/{wallet,withdraw,shop,guide,settled,invite}.go
Overview, balances, history, orders, deposit, the withdrawal steps, the shop, refusals, the guide, settlement messages, the invite link and prize notices.
▼
domain/payment · domain/referral
Buy, Quote, Offers, Settle, gas top-ups, Withdraw, drop belief, the wallet lock, read views; invites and the prizes paid into the reward bucket.
▼
domain/wallet
Keys, Balances, PlanSpend, Destination, networks and the coin registry, the Client/Payer/Helper seams.
▼
wallet/chain · evm · svm · ton · price
Shared types and one client per family; CoinGecko prices.
🔑 Idempotency key

Every purchase and withdrawal carries one; the same key returns the same row.

🔒 The wallet lock

Every operation for a user runs under one Postgres advisory lock.

✍ Sign → keep → send

Signed bytes are stored before broadcast; resending sends the same bytes.

⇄ Compare-and-set

Rows move only from the state they were read in.

⏸ Holds

Unsettled amounts are neither spendable nor withdrawable until final.

🤐 Generic refusals

The service's own trouble is “unavailable, Retry”; the cause goes to admins.

19Custody: keys and wallets

On a user's first financial action, wallet.Service.Ensure makes one EVM key (one address for Ethereum, BSC, Polygon and Base), one Solana key, and a separately generated TON key (v4R2, the version stored per wallet). It's race-safe: a unique user, ON CONFLICT DO NOTHING, re-read.

Master key

WALLET_MASTER_KEY: 32 bytes as 64 hex characters. Never change it: sealed keys carry no version, so rotation means re-sealing every row offline.

Opening a key

EVMKey/SVMKey/TONKey refuse a key that doesn't reproduce the stored address (ErrKeyMismatch) and wipe it after use.

Never shown

Sealed columns are tagged json:"-"; no screen or log prints a key. A kitchen test checks no transcript holds one.

20Networks and chain clients

Six networks in three families, each client checking once that its endpoint serves the right network.

evm.Client over go-ethereum for Ethereum, BSC, Polygon, Base.

  • Chain id checked before the first read.
  • Fees: EIP-1559 where the chain has a base fee (cap = 2 × base + tip), legacy otherwise. Gas estimates get a fifth more room for a contract recipient. Base's L1 data fee is counted.
  • Status from receipts and the safe/finalized blocks. Polygon names no safe block, so confirmed reads at finalized (NoSafeBlock).
  • A dropped leg is re-signed under the same nonce, a replacement, so only one can land. A drop is declared only once the nonce is used by another transaction.
  • Destinations: mixed-case addresses must pass the checksum; the zero address and token contracts are refused.

svm.Client over solana-go.

  • Mainnet genesis hash checked before the first read.
  • SOL transfers never leave an account under rent. SPL via TransferChecked from the owner's associated token account, creating the receiver's with CreateIdempotent only when missing.
  • Balances sum every token account of a mint, but a send moves only from the associated one, so holdings outside it are left out of plans.
  • A capped priority fee: the recent minimum for the accounts touched. A floor is an open decision.
  • Status by signature; a leg is dropped once the block height passes its lastValidBlockHeight with no status. The endpoint must keep transaction history.
  • Destinations: program accounts, the system program and the incinerator are refused.

ton.Client over Toncenter (toncenter.go, rate-limited to 0.9 rps keyless, 9 with a key).

  • Mainnet zero state checked first. Addresses under v4R2 (WalletAddress), shown non-bounceable UQ…, every form accepted on input; testnet and masterchain refused.
  • Deploy on first send, costed as gas. Jettons are a separate path through the owner's jetton wallet. A user with jettons but no TON gets its own error.
  • Messages signed with a fixed 3-minute TTL; status read from the Toncenter trace, failed when the wallet aborted or a message bounced.
  • One coin per purchase on TON (OneCoin): a message's transfers can land apart, so mixing coins could half-pay.

PAYMENT_ACCEPT_AT = included | confirmed | finalized (default), with PAYMENT_ACCEPT_AT_<NETWORK> overrides.

Networkincludedconfirmedfinalized
EVMa successful receiptsafe blockfinalized block (≈13–15 min Ethereum, 15–30 Base; seconds on BSC/Polygon)
Solanaprocessedconfirmedfinalized (≈13 s)
TONone level: in a block is final (≈5 s)

Balances are read at the same level, so a deposit a reorg could undo never pays for anything. A send's own pre-checks read the latest state, so a gas top-up counts at once.

The registry (coins): each network's native coin plus USDC and USDT, with USDT alone on TON. Admins may add a token later. price.Service.USD prices a stablecoin at $1 without asking anyone, and the rest by Coin.PriceID on CoinGecko, cached 60 s. A failure rests the provider 30 s, and stablecoins stay priced through an outage.

21Balances, holds and spend planning

Balances.Of(wallet) reads every active coin on every network in parallel (coins in turn), caches each 45 s under a per-user generation that Forget renews after money moves, prices the held ones in one batch, and sums TotalUSD. Complete is false while a network didn't answer or a holding is unpriced.

What a purchase may spend is that balance less holds (unsettled legs and withdrawals), restricted to networks with a treasury. Then PlanSpend splits the price:

The worked example, both branches SPEND_REAL_MONEY_FIRST

$10 on-chain, $10 reward, a $15 plan:

BranchOn-chain leg(s)From rewardLeft
true (default)$10$5$0 on-chain · $5 reward
false$5$10$5 on-chain · $0 reward

A partial leg is rounded up exactly at the coin's own decimals (ceilDiv), never down: a plain division stops at 16 places, short of an 18-decimal coin's.

22Buying: shop to order

1
Shop (shp:v): plans by length and unlocks priced for the user (Offers). Owned or settling tiers appear as lines, not buttons.
2
Confirm: Quote the item, show the price and what pays (on-chain less held, reward), and a Pay button carrying a fresh nonce and the confirmed price.
3
Pay: Buy(user, "shop:<user>:<nonce>", item). A price changed since confirming shows the confirmation again with the new price, and nothing is paid.
4
Result: paid at once (reward only) or “sent, granted once it confirms”; or a refusal in the user's words.
Why sign → keep → send

A crash after keeping leaves stored signed bytes, which the settle job sends unchanged; the same bytes can land only once. A crash before keeping left nothing to undo. A double tap or a Retry reuses the key and gets the same order.

Refusals, as the user reads them shop.refused
Domain errorUser seesButtons
wallet.ErrInsufficientFunds · chain.ErrInsufficientnot enough fundsDeposit · Back to shop
chain.ErrNoGasnot enough for the feeDeposit · Back to shop
wallet.ErrBalanceUnknownbalance couldn't be readRetry · Back to shop
payment.ErrUnavailableservice unavailable (admins alerted)Retry · Back to shop
payment.ErrSettlingan earlier payment is still confirmingRetry · Back to shop
payment.ErrOwnedalready yoursBack to shop
payment.ErrNotForSalenot for saleBack to shop
payment.ErrPriceChangedthe confirmation again, headed “price changed”Pay (new price) · Back
anything elseerror.generic; logged, cause never shownBack to shop

See shop.refused and its refusals table for the exact list.

Granting payment.grant

A subscription stacks from the latest end still ahead, or from now (billing.Subscribe, row-locked; the order keeps the period bought, so editing a package can't change a grant). A tier inserts a user_feature_unlocks row and the ceiling is read live, so it applies at once. An order paid wholly from reward is granted inside Buy; otherwise by the settle job at the accept level.

23Settlement

payment.settle runs every 12 s. It reads a handful of pending rows, one RPC each, and survives restarts: there are no listeners or websockets.

pending reached final hold releasedleg leaves the table failed / droppedsaid only once final re-signed (≤ 3)coins still there order revokedcoins gone or retries out accept level final reorg / revertsigned afresh → pending when every leg of an order has reached: the order is granted (subscription stacked or tier unlocked)
🐢 A lagging node

A drop is believed only after it reads so for 5 minutes.

↩ Revoking

What's taken back, what's given back, what the user hears.

⏰ Stuck

Anything unseen or unfinished for an hour alerts admins.

📣 Report → users

Server.Settled tells each user, in their language.

24Gas and the helping wallets

GAS_PAYMENT_BY_USER=true (default)

The user's own native coin pays fees. If it's short, the helping wallet tops up only what's missing first, at most wallet.gas_top_ups_per_day (5) per network per day, and at most twice more if fees rose meanwhile.

GAS_PAYMENT_BY_USER=false

The platform pays every fee: the helper sends it all before signing. Past the day's cap the purchase is refused with ErrNoGas, rather than quietly taking the fee from the user's coin.

When a helper can't help

A helping wallet that's missing, short, or slow to land aborts the purchase before anything is written: no order, no hold, reward untouched. The user sees only ErrUnavailable (“unavailable”, Retry), and the cause goes to the admins. Each top-up is kept with its sender and nonce/seqno before it's sent, awaited, and re-read by the settle job if it lands late. Top-ups on one network go one at a time.

25Withdrawals and the wallet screens

1
Coin (wal:wc:<chain>:<symbol>) from what's withdrawable.
2
Address: await withdraw_to:…, checked by wallet.Destination. A wrong one is asked again with why.
3
Amount: await withdraw_amount:…. Persian digits accepted; no finer than the coin's decimals.
4
Confirm to the last digit, under a fresh nonce (wal:wk:<nonce>). Typing a new amount re-confirms under a new nonce.
5
Send: Withdraw(user, "withdraw:<user>:<nonce>", …). A late second tap looks the key up (WithdrawalBy) and shows the sent view again.
What Withdraw checks, in order payment/withdraw.go
  1. Same key already used → that withdrawal.
  2. Coin active (ErrNoCoin, which sends the user back to the coin list).
  3. Amount positive and within the coin's decimals (ErrAmount).
  4. Destination: not the wallet's own address, not the zero address, not a token contract, not a Solana program or sink, not TON testnet or masterchain (ErrDestination).
  5. Nothing in flight on that network (ErrSettling): a native withdrawal behind an unmined send could never pay its fee.
  6. At most Withdrawable: the deposit bucket less holds and recently topped-up gas (ErrOverWithdrawable).
  7. Within the admin's USD bounds, if set: a minimum per withdrawal and a maximum per UTC day.
  8. Sign with the user's key (the user pays the fee, no top-up), keep the row, broadcast, forget cached balances.

Withdraw moves on-chain coins only, so the reward bucket, a ledger number, is never withdrawn; referral.withdrawals_enabled (false) is read by nothing yet and waits for Phase 2's treasury payout.

Overview

On-chain (settled, read live), Held by payments on their way, Reward, Total, a warning when a network didn't answer, and the three deposit addresses in monospace to copy (TON as UQ…).

Views

Token balances · History (paged, tap for details with explorer links) · Orders · Referrals (prizes and bonuses, tap for the invitees behind each) · Deposit (per network: coins and address, only-this-network warning) · Withdraw · 📖 Guide · 🔄 Refresh.

Guide

Six pages (wal:g:<topic>): balances, deposit, paying, confirmations, withdrawing, staying safe. Which balance pays first and who pays fees are read live from payment.Service.Rules.

26Referrals: invites and prizes

Invites close the money loop. A user shares a link; newcomers who start the bot from it and set their gender, age and alias earn the inviter dollars in the reward bucket, and a bonus when one of them first subscribes with real money. The bucket buys subscriptions and unlocks but can't be withdrawn at launch. The rules live in domain/referral, the screen in telegram/invite.go, and the record in one table, referrals.

1
Link. 🎁 Invite → invites.open: t.me/<bot>?start=ref_<public id>, set to copy, and a 📤 Share button. The bot's username comes from getMe in Server.Run.
2
Bind. The newcomer's first update is /start ref_…. FirstContact says this update made the user, so dispatch calls Attribute, which keeps one Referral.
3
Finish. After any profile save, settings.refresh checks RequiredFieldsComplete; when all three are set it calls Completed, which writes completed_at once.
4
Pay. In the same transaction, each full group of finished invitees no prize counted yet becomes one prize: a WalletTx{referral, reward} naming its invitees, referral_usd raised, and each invitee pointed at it.
5
Tell. invites.tell sends the inviter invite.prize (or invite.bonus) in their language. Under Wallet → 🎁 Referrals a tap on it shows who earned it (payment.Tx loads the invitees through pg.VisibleTo, in the prize's order; the hidden are only counted).
6
Later. Every minute referral.payout (Payout) pays what waited, and the conversion bonus of each invitee whose first subscription paid with coins is now final; Server.Rewarded tells the inviters.
Who is bound, and who isn't referral.Attribute
  1. Only the update that made the user. A user who sent anything before, even a plain /start, is never bound later. FirstContact returns false to every concurrent loser of the create race, so one update binds.
  2. Only an invite. The payload must be ref_ plus a public id that exists (ErrNotInvite, ErrUnknownInviter). Other prefixes stay free for other start payloads.
  3. Never oneself (ErrSelf). On a true first contact that can't happen, since the public id is fresh; the check keeps the rule in the domain, not in the caller.
  4. The first binding stands. referee_id is unique and the insert is ON CONFLICT DO NOTHING.
  5. A forwarded /start is someone else's words and binds no one.

The three refusals are logged at debug: a stale or mistyped link is not an error worth an alert.

Why the wallet row is held, and held first holdWallet

Two invitees of one inviter can finish at the same moment, on different shards. Each marks its own row finished, then pays. Without a queue both could read the same five waiting invitees and pay them twice. SELECT … FOR UPDATE on the inviter's wallet row makes the second wait; when it reads, the first has committed and its five carry a prize_tx_id, so they are no longer waiting. The wallet is made first (wallet.Service.Ensure) so there is always a row to hold.

Every payout transaction (Completed, the job's payWaiting and a bonus's convert) takes that row before touching any referral row. With the bonus marking its referral first, a bonus and a prize for one inviter deadlocked: each held the row the other wanted (TestABonusAndAPrizeAtOnceBothPay). Buy takes the reward part with a conditional decrement on the same row, so a prize and a purchase never lose each other's change.

The conversion bonus payConversions

An invitee converts with their first subscription paid at least partly with coins, once its order is paid and every live leg is final (none pending, reached, failed or dropped). Waiting for final, not the grant, means the bonus is never paid for an order the settle job could still revoke, so there's nothing to take back. A subscription paid wholly from the reward bucket has no legs and never converts: promo credit can't mint promo credit.

  1. One SQL finds, per referral not yet converted, the earliest such order (DISTINCT ON (r.id) … ORDER BY r.id, o.paid_at), skipping restricted inviters and invitees.
  2. With a bonus on, the inviter's wallet row is held first.
  3. converted_order_id is set by compare-and-set, so the first conversion is used up even while the bonus is 0: a later subscription never pays it.
  4. With a bonus on, it is credited and ledgered as a referral row naming the invitee and the order, and kept as bonus_tx_id.
Slowing farming down referral.limits, restrictions
  • Bindings per hour (30): Attribute holds the inviter's users row and counts the hour's bindings; past the cap a newcomer is bound to no one (ErrTooManyInvites, logged at info).
  • Prizes per day (10, UTC): pay counts today's prizes (bonuses aren't prizes); full groups past it wait for the next day's payout.
  • Restricted users: a banned or suspended invitee isn't counted toward a group, and a restricted inviter is paid nothing; both wait until lifted, then the job pays.
  • No device data reaches a bot, so there's no "same device" check: the completion gate, self-refusal, these caps and the reward bucket not being withdrawable are the guards.
When the rate changes referral.base

The rate is read at payout time from referral.base ({per: 5, usd: 1}). A rate paying nothing (per under 1, or $0) leaves finished invitees waiting; they count once it pays again. Lowering per pays every full group at once on the next finish, oldest finishers first.

🔗 Only the public id travels

The payload names the inviter by the same handle /user_<id> shows, never an internal or Telegram id.

👛 Built with the wallets

A prize pays into a wallet, so without a master key Invite answers coming soon and a link binds no one.

🛒 Spend, not withdraw

PlanSpend spends the reward bucket; Withdraw moves on-chain coins only, so it never leaves the bot. That is the main guard against farming.

Tests: referral_test.go (binding, groups, a rate paying nothing, twelve finishing at once, a bonus and a prize at once, both caps, restrictions, conversions) and the kitchen package test/integration/referral (the screen, binding on first contact only, five invitees earning one prize told once, the bonus only once final, the caps).

Part VII · Working on it

27Configuration and settings

GroupKeys
TelegramBOT_TOKEN · WEBHOOK_SECRET ([A-Za-z0-9_-]{1,256}) · PUBLIC_URL (https) · HTTP_ADDR
StoresDATABASE_URL · REDIS_URL · tests: DATABASE_TEST_URL · REDIS_TEST_URL
CustodyWALLET_MASTER_KEY · TREASURY_EVM_ADDRESS · TREASURY_SVM_ADDRESS · TREASURY_TON_ADDRESS · HELPING_EVM_PRIV · HELPING_SVM_PRIV · HELPING_TON_PRIV
RPCEVM_RPC_ETHEREUM · EVM_RPC_BSC · EVM_RPC_POLYGON · EVM_RPC_BASE · SVM_RPC_SOLANA · TON_RPC_URL · TONCENTER_API_KEY (empty = public endpoints)
PaymentsPAYMENT_ACCEPT_AT (+ _<NETWORK>) · PRICE_PROVIDER (coingecko) · PRICE_API_KEY
KnobsAGE_MIN_DELTA 5 · SPEND_REAL_MONEY_FIRST true · GAS_PAYMENT_BY_USER true

.env.example documents every key. Never print the real .env; read a single key with grep -E '^KEY=' .env.

settings.defaults is the one source for both seeding and the read fallback (cache → row → default). Admins will edit rows from the panel (T10).

KeyDefaultRead by
quota.specific_chat{15, weekly}filtered searches
quota.directs{10, weekly}new directs
directory.recently_online_days1People
referral.base{per 5, $1}referral prizes
referral.conversion_bonus_usd0conversion bonus (0 = off)
referral.limits{bindings_per_hour 30, prizes_per_day 10}invite caps (null = off)
referral.withdrawals_enabledfalsenothing yet (Phase 2's reward payout)
wallet.withdrawal{min, max_per_day} unsetwithdrawals
wallet.gas_top_ups_per_day5gas
channel_gate{off, []}T9
match.full_sweep_interval_minutes5full sweep
chat.message_retention_days3purge

Ceilings and prices per feature and tier, seeded by pg.DefaultFeatureLimits (chapter 14's table). Edit the row; never a literal in code. A price of 0 means not for sale.

28Observability and errors

📈 Metrics

A dependency-free registry (internal/obs) at /metrics. Counters are declared beside the code that bumps them. A duplicate name panics at boot.

mm_updates_received_total · mm_sends_dropped_total · mm_sends_failed_total · mm_searches_started_total{language} · mm_searches_superseded_total · mm_searches_stopped_total · mm_matches_total · mm_time_to_match_seconds{language} · mm_inline_profiles_sent_total

🪵 Logs

log/slog text to stdout. Messages are short verbs (“relay: load partner”) with attributes ("user", id, "err", err). Never a key, never a secret, never relayed text from one user to another.

⚠ Errors

Wrapped with where and what: fmt.Errorf("payment: withdrawal by key: %w", err). Domain refusals are sentinel errors matched with errors.Is in the adapter; anything unrecognised is logged and shown as error.generic.

🚨 Alerts

payment.Alerts (one call, repeated at most every 10 min per key), logged at error level today; the admin panel's linked Telegram accounts will replace it.

29Tests

TierWhereRunWithout the URLs
Unitbeside the codemake testruns
Datastorebeside the codemake test-db (-p 1)skips
Integration (kitchen)test/integration/{foundations,onboarding,chat,social,safety,wallet,referral}, tag integrationmake test-integration (-p 1)fails, on purpose
Stress & loadtest/stress, test/load (T14)make test-stress · make loadfails
-p 1, always

The datastore and integration packages share one Postgres database and Redis DB 15. In parallel they deadlock and fail at random, and a mutation run reads those failures as false kills.

A kitchen test reads like a conversation

👆👆 Double-tap pattern

Hold the fake network mid-signing, tap again, release, then check the final state.

📏 Rules that keep tests honest

Positive control, no sleeps, final-state checks, no fakes of Telegram.

🧰 Helpers per package

Each package's helpers_test.go and its common helpers.

🧬 Mutation rounds

Prove a test would fail: break the code on purpose.

30Recipes

The common changes, step by step.

Add a user-facing string
  1. Add "ns.key" = "…" to both en.toml and fa.toml (Persian wallet text in the formal register).
  2. Render with bundle.T(u.Locale, "ns.key", data); numbers through i18n.Digits.
  3. Assert it in a kitchen test through w.T, in EN and FA.
Add an admin-tunable value
  1. A Key… constant and its entry in settings.defaults.
  2. A typed getter on settings.Service.
  3. Read it where used; it seeds itself on the next boot.
Add a table or a column
  1. The model in pg/models.go.
  2. A new 0034_… step in migrations.go, with a default for existing rows.
  3. Seed data in pg/seed.go, idempotent.
  4. A datastore test.
Add a button or a whole flow
  1. A prefix constant; check the 64-byte budget with its arguments.
  2. Route it in dispatch, or reuse a surface's prefix.
  3. Answer the callback; edit the same message in place.
  4. Typed input: an await field with a prefix routed in settings.capture, and a Cancel scoped to it.
  5. A kitchen test that taps it.
Add a domain service
  1. A package under internal/domain, never importing telegram.
  2. The interface it satisfies, declared in telegram, as a Deps field (nil = off).
  3. Construct it in app.New and pass it in Deps.
  4. Build and connect its surface in NewServer.
Add a scheduled job
  1. Export …Job (name) and …Every (interval) from the package.
  2. Register it in app.New with o.scheduler.Every.
  3. In kitchen tests fire it with w.Tick(name).
Handle a new kind of update

Add it to allowedUpdates in server.go (the foundations test pins the list), route it in dispatch, and check the kitchen sends it (WithAllowedUpdates). T13 will add pre_checkout_query.

Touch money
  1. Under withUserLock, with an idempotency key.
  2. Sign → keep → send; move rows only from the state you read them in.
  3. Hold amounts until final; refuse unsent when anything is missing.
  4. A user sees only generic words for the service's own trouble.
  5. A kitchen test on the fake network (Hold, Move, FailNext, Unreadable, NeedGas) and a mutation round.
Write the commit message

No co-author line. The maintainer commits by hand. No tracked file ever names a doc, a task id, “Phase N” or a commit hash; long reasoning goes to .stuff/phase-1/complications/.

31Invariants and gotchas

🏛 Structure

domain/* never imports telegram. The binary never compiles the kitchen (make check-deps).

🕶 Anonymity

Never forward, never prefix the sender, scrub every text and caption, never show a forward's origin.

💬 Chats

Only Sessions.Open sets chatting; pairs are written and removed atomically.

🔍 Searches

One live search per user, a function rather than a setting. Restricted users are never paired.

📐 Telegram limits

Callback data ≤ 64 bytes; text 4 096 and captions 1 024 in UTF-16 units; alerts 200.

🌐 Two languages

Both catalogs, Persian digits, Jalali dates, mirrored keyboards, English fallback.

💰 Money

Keys, locks, sign-keep-send, compare-and-set, holds, the 5-minute drop belief, no key ever printed.

🎁 Invites

Bound on the update that made the user, never later; an invitee counts toward one prize, once, and converts once; a prize and its ledger row are one transaction; a bonus waits for final.

⌨ Prompts

A command or key cancels; a tap doesn't; Cancel is scoped; a prompt opened mid-chat takes the next message.

📌 Accepted limits — don't “fix” them unasked

Listed in .stuff/phase-1/CONCERNS.md (C1–C7) and the review notes. For example: reached amounts are held twice below finalized, Match Day notices can be lost in a crash, a suspension ending can leave a list over its cap, TON pays one coin per purchase, 1,000 reads as 1, and rollbacks are never run.

32Where it stands; T9

T0T1T2T3T4T5T6T7T8T9 nextT10T11T12T13T14T15RG

Still empty: internal/domain/notify, internal/admin, internal/webviews, web/*, test/stress, test/load.

✅ Built: invites and prizes

Binding on first contact, the Invite screen, the $1-per-5 prize into the reward bucket with its invitees in the ledger, the inviter told (chapter 26).

✅ Built: bonus and limits

The conversion bonus once a first coin-paid subscription is final, bindings per hour, prizes per day, restricted users waiting, and the referral.payout job.

✅ Built: who earned it

Wallet → 🎁 Referrals: a prize's or bonus's details list its invitees by alias, gender, age and /user_<id>, as the viewer may see them.

⏭ Next: the channel gate (T9)

Its shape is an open decision (T9.0: chat ids or public channels, which member statuses pass): ask first. The setting channel_gate is seeded and unread.

After T9: admin panel (T10, which also replaces LogAlerts), web views (T11), deposit indexer (T12), Telegram Stars (T13), stress and load (T14, which also takes the replies outside the send queue and slow calls holding a shard), maintainer additions (T15, which needs kitchen M13 first), then the release gate. The plan is .stuff/phase-1/TASKS.md; the session hand-over is .stuff/phase-1/CONTEXT.md.

Part VIII · Reference

33The reference atlas

Every folder, file, type and function, read straight from the code, with a purpose note per file and package. Browse the tree, or search every declaration at once.

Sources: 115 non-test files. Counts include unexported names. The “tests” figure is the number of Test… functions in the file's sibling _test.go. Kitchen tests live in test/integration and aren't counted per file.

34The data model, as a map

All 31 tables, starting from users and growing outward to everything that hangs off it. Each box shows the fields that matter; click it for all of them, with their tags. Pick a flow to watch the rows a feature writes, step by step.

🧷Ids, not foreign keys

No table declares a foreign key. Every *_id is kept right by the code that writes it, inside one transaction, under the user's lock.

⚡The search is not a table

A live search exists only in Redis. Postgres sees the result: a chats row. Lose Redis, and the boot reconcile (chapter 07) rebuilds the rest.

🪙Balances are never stored

The deposit bucket is read live from the networks. Postgres keeps what moves: orders, legs, withdrawals, and the ledger.

Reading the map in two minutes orientation
  1. The middle column is the user's life: Redis status above, the users row, and below it the orders they place.
  2. To the right, other people: chats and messages on top, ties and directs in the middle, Match Day and moderation on the edge.
  3. Just left of users, what hangs off one person: the languages they speak, their wallet, and who invited whom (referrals).
  4. Further left, what the admin sets (genders, languages, settings, icebreakers) and what a user owns (subscriptions, unlocks, against packages and tiers).
  5. Bottom, the money trail: an order, its on-chain legs, the coin registry, withdrawals, gas top-ups, and the ledger they all write to.

35The type map

The Go types that carry the work, about forty of them, not all 254. Each arrow is a function turning one into the next: hover a box or follow a flow to see its name, and click the name to read it. Boxes marked ⛁ are the shapes the stores keep.

🔁Values, not handles

Services take and return plain values (pg.User, match.Search, wallet.Plan). A handler loads, calls, renders; it never keeps a row between updates.

🧱Kept before it is sent

On the money path, every type before chain.Sent is plain arithmetic. From Sent on, nothing is broadcast until its row is committed.

36Where the rest is written

Everything under .stuff/ is git-ignored; nothing in a tracked file may point at it.

📋 phase-1/TASKS.md

The plan: every task with its Done note, decisions, stop rules, testing tiers. Tick tasks here.

🧭 phase-1/CONTEXT.md

The session hand-over: state, rules, decisions, open items. A new session starts here.

📐 PRD.md · TDD.md

What to build (FR/NFR/AC, open items) and how (sections by area).

🧩 complications/

Long explanations too big for a comment, and the review records with what was left and why.

⚠ CONCERNS.md

C1–C7: refusals and limits shipped on purpose.

🧮 ../MATCHING.md

The matching engine's full spec.

Part IX · Wrapping up: telebot-kitchen

37What telebot-kitchen is

A fake Telegram, not a fake bot. The bot runs unmodified; only the server on the other end of its Bot API calls is replaced by an in-process model of Telegram. Same maintainer, its own repository: github.com/pya-h/telebot-kitchen, pinned at v0.2.4.

your testada.Send · ada.Tap · u.Expectw.WaitFor · w.Settle the kitchen a world of users, chats, messagesscreens, keyboards, filesits own clock · faultscall recorder · settle machinery your bot (unmodified)real handlers, queue,Postgres and Redis updates → ← Bot API calls
⬅ Outbound seam

An HTTP server answering the Bot API methods the bot calls.

➡ Inbound seam

Updates handed to the bot's webhook handler, no socket.

⏳ Waiting, not sleeping

An activity broadcaster that wakes every waiter.

📏 Real Telegram rules

Limits, refusals and quirks modelled as Telegram behaves.

38How mmbot uses it, and running it

1
Shared stores, wiped per test. The first test in a package opens DATABASE_TEST_URL/REDIS_TEST_URL and migrates. Each test truncates every table except the ledger, re-seeds, and flushes Redis DB 15. It refuses to run against the bot's own URLs.
2
A kitchen with the webhook secret, plus the test's own options (WithScrollback, WithWaitTimeout…).
3
The real app: app.New with the stores, the kitchen's URL and clock, a clock.Manual scheduler and fixed prices. A paying world adds the fake network on Base, the fake helper, recorded alerts, a master key and a treasury.
4
Bind and run: DeliverToWebhook(server.WebhookHandler()), then a.Run(ctx). Stopped in production order at cleanup.
MemberUse
kit.Start(t) · kit.StartPaying(t, kit.Paying{RealMoneyFirst, GasByUser})a fresh world
w.DB · w.Redis · w.Appthe stores and the app, for setup and assertions
w.T(locale, key, data)catalog text, so expectations follow the catalogs
w.Tick(job) · w.Clock().Advance(d)run a job · move time
w.StartedUser(id)a user who already pressed Start
w.ExpectMessage(u, id, what, cond)wait for a message edited in place
w.Restart(loseRedis)a deploy, optionally with Redis lost
w.Photo(name)a file Telegram already holds
w.Wallet(u) · w.Deposit(u, sym, amt) · w.ForgetBalances(u) · w.PrivateKeys(u)paying worlds
w.Alerts.Told() · w.Helper.Short(bool) · w.Helper.TopUps()the admin side

kit.Chain implements Client and Payer for Base: balances as the test funds them, every transfer signed as its own transaction, standing where the test moves it.

Users: u.Send, u.SendCommand, u.SendPhoto/…, u.SendAlbum, u.SendPoll, forwards and formatted sends, u.Tap(label or data) (visible screens; WithScrollback to reach older messages), u.Press(key), u.Delete, u.BlockBot/UnblockBot, u.Search(query)/Pick.

Reading: u.Expect(kitchen.TextIs/TextContains/HasButton…), u.ExpectNothingMore(), u.History(), u.Menu().

The world: w.Settle(), w.WaitFor, w.Calls(), w.CallbackAnswers(), w.FailOnce/Fail/FailAfter.

Only files under the integration/stress tags import the kitchen, so the default build never compiles it. It's imported from GitHub at a pinned version, never a replace to a local checkout. Bumping it means reading its tags and USAGE.md, and keeping go-telegram/bot on the version the kitchen is built against (v1.25.0).

The kitchen stop rule

If a test needs something the kitchen lacks, the kitchen differs from real Telegram, or a kitchen bug fails a test: stop that task and tell the maintainer what Telegram does, what the kitchen did, and the evidence. No workaround, no weakened test, no fork or replace, no hand-rolled Bot API fake.

M13 · open

deleteMessages (batches of 100) and Telegram's 48-hour delete rule. Clear chat (T15.4) needs it, so build it before T15.

M14 · open

Scrolling inline results: Search(query) sends no offset. internal/telegram/inline_test.go fakes Telegram for that one check until it lands.

The kitchen's own docs: USAGE.md in its repository (or ~/go/pkg/mod/github.com/pya-h/telebot-kitchen@v0.2.4/), and the kitchen guide in this idoc.

39Check yourself

Answer each with the code open, then reveal. If most come easily, you own most of the codebase.

A user sends three messages quickly while chatting. Why do they arrive in order?
Their updates share one inbound shard (one goroutine), so they're handled in arrival order; the replies to one chat share one sender worker, so they're sent in enqueue order. The library is built with WithNotAsyncHandlers so it doesn't spread them itself.
Where is a callback starting with wal:wc: handled, and why must it be answered even on error?
dispatch routes wal: to wallets.handle, which shows the withdrawal's address prompt for that coin. An unanswered callback leaves the user's button spinning until Telegram times it out.
A user is banned while their search waits. What keeps them unpaired and unserved?
tryMatch checks the seeker is restricted and pairs no one; withoutRestricted filters restricted candidates for others. Their next update is refused at dispatch (BannedAt). The punishment itself runs relay.release in their inbound shard, stopping the search.
Step by step: 🎲 Random to both users seeing “connected”. Which locks?
pressHome → relay.search (no draw for a zero Spec) → match.Start under the user's Redis lock → matchNow → pair → reserve tries the candidate's lock once, removes both from the pool → Sessions.Open (PG transaction + OpenChat Lua) → relay.matched seals the partner's card → announce to both with the chat keys.
Why does reserve not wait for the candidate's lock?
It already holds the seeker's. If the candidate were reserving the seeker at the same moment, both would wait forever. Instead the seeker is marked due now (“contended”) and the sweep retries it.
Pay is tapped twice before the first finishes signing. Why one order? Which test proves it?
Both taps carry the same nonce, so the same key; the second waits on the wallet lock, then byKey finds the first order and returns it. TestAPurchaseIsConfirmedThenPaidOnce holds the fake network mid-signing to prove it.
A leg reads “not found” once on a lagging node. Why isn't it re-signed?
payment.status reports Pending until a drop has read so for 5 minutes. Once truly dropped, resolve re-reads under the lock, re-signs while the coins are there (EVM under the same nonce), up to 3 times, otherwise revokes.
How would you add a new free-tier quota?
A settings key with a Quota default and getter, a billing.Feature, a draw (CheckAndConsume) at the action with Return if it doesn't happen, the out-of-allowance and last-free notices in both catalogs, and a kitchen test on the kitchen clock.
Your migration adds a column. What else before it ships?
A default for existing rows, the model field, the seed if any, a datastore test, and no edit to an older migration. Check the kit's truncation still covers it (it truncates every table) and that nothing reads the column before the migration runs.
You need a test for a flow the kitchen can't model. What do you do?
Stop the task and tell the maintainer, with what Telegram does and what the kitchen lacks (the kitchen stop rule). No fake of the Bot API, no skipped or weakened test.
Where does a referral prize show up, and what must not happen to it?
In Wallet.ReferralUSD, with a WalletTx{referral, reward} whose metadata lists the invitees, rendered by the wallet's Referrals view. It's spendable through PlanSpend and never withdrawn: Withdraw moves on-chain coins only.
Two invitees of one inviter finish at the same moment, on different shards, making ten. Why exactly two prizes?
Each Completed first holds the inviter's wallet row (FOR UPDATE), then marks only its own row and pays. The second waits, then reads the waiting invitees after the first committed: the ones already paid carry a prize_tx_id and are left out. TestInviteesFinishingAtOnceCountOnceEach proves it with twelve.
Why are balances read at the accept level rather than the latest block?
A deposit that a reorg could undo must not pay for anything. A send's own pre-checks read the latest state instead, so a gas top-up counts at once.
Written 2026-10-14 against commit fe1eac1 plus the T8 review (payout lock order); the atlas is generated from the source tree. Keep this page in step at every “Update the idoc” task: rebuild the atlas data and revisit chapters whose files changed.