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.
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 ….
Chapters 02–10: the layers, boot, one update's journey, concurrency, state, and the Telegram layer's patterns. Open each file they name.
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.
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.
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/appinternal/telegraminternal/admin, internal/webviews.internal/domain/*telegram.internal/store/pg · internal/store/redis · migrationsmm: keyspace with its Lua scripts; 35 migrations.config · clock · i18n · obsmain is 64 lines: it calls run(), which owns the signal context and the deferred
Close. Everything else is app.New: about 130 lines that read as an inventory of the
system: stores, services, the server, the jobs. The App keeps what it must shut down (scheduler, server,
closers) and exposes a few seams for tests and the future admin panel: Server(), Bundle(),
Punish().
Options (WithDB, WithRedis, WithClock, WithScheduler,
WithBotOptions, WithChains, WithPayers, WithHelpers,
WithPrices, WithAlerts) let a test own a piece without a second wiring path.
Files: cmd/bot/main.go · internal/app/app.go
27 files, about 8 000 lines: the biggest package by far. It splits into four kinds of file:
- Plumbing:
server.go(Deps, NewServer, webhook, dispatch),inbound.go,sender.go,section.go. - Surfaces, each a small struct with a
handlefor its callbacks:settings,people,directs,crushes,wallets,shop,onboarding. - The relay, a struct shared by search, Find, chat keys, albums, animation and reconcile:
relay.go,search.go,find.go,chatkeys.go,album.go,animate.go,quota.go,reconcile.go. - Notices:
restrict.go,settled.go.
The package declares the interfaces it consumes (Matcher, ChatStore, Wallet,
Shop, …) next to the code that uses them. The domain packages satisfy them without knowing they exist.
Each package is a service struct built over *gorm.DB, *redis.Store and a
clock.Clock, plus pure functions where state isn't needed (match.Gate,
wallet.PlanSpend, user.TidyMood). Errors are sentinel values
(match.ErrChatting, payment.ErrPriceChanged, …) wrapped with context;
the adapter maps them to catalog keys.
The heaviest are payment (≈ 2 200 lines), wallet/* (≈ 2 800 lines) and
match (≈ 1 250 lines).
pg holds the models (one file), Open/Close, the idempotent seed and
VisibleTo. redis has one file per key family, most wrapping a Lua script so a
multi-key change is atomic. migrations is a single ordered list of gormigrate steps.
See chapter 07 for the tables, the keyspace and the locks.
- config: typed
.env, strict. - clock:
ClockplusScheduler. Tests get the kitchen's clock and aManualscheduler. - i18n: catalogs, digits, Jalali, RTL.
- obs: metrics. Package-level counters are declared beside the code that bumps them.
Where the lines are
Non-test lines per package. Click a bar to open that package in the atlas.
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.
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.
04Boot, run, shut down
What app.New does
Click a step for its detail.
pg.Open(cfg.DatabaseURL), unless WithDB passed one (the caller keeps ownership). Then
migrations.Run applies every missing step, each in its own transaction, and
pg.Seed runs its seeders: genders, speaking languages, icebreakers, feature limits with prices, coins.
Every seeder is a FirstOrCreate-style upsert, safe on every boot. A deleted default comes back;
deactivating is the admin's path.
If New fails, the deferred handler calls a.Close(), so nothing it opened leaks.
redis.Open (or WithRedis), then settings.New(db, rdb).EnsureSeeded writes a row for every key in
settings.defaults that doesn't have one. Reads go cache → row → default, so a missing row never
breaks a read. Then i18n.New() loads the embedded catalogs.
Chain clients are built for every network but dial nothing until first read, so boot never waits on an RPC. A missing helping wallet is a warning; a malformed treasury fails the boot.
telegram.Deps receives every service. NewServer builds the
bot.Bot with WithNotAsyncHandlers (the server spreads updates itself), the
QueueSender, the inbound shards, and each surface whose dependency is present. Then it
connects surfaces to each other with funcs (chapter 08, pattern 5).
| Job | Every | Does |
|---|---|---|
relay.albums | 1 s | sends albums whose last part came a second ago |
match.sweep | 5 s | retries searches that are due (mm:searchdue) |
search.animate | 10 s | steps status cards, 60 searches a tick |
payment.settle | 12 s | advances legs, withdrawals and top-ups; grants and revokes (payments only) |
match.fullsweep | 1 min tick | safety net on the admin's interval (5 min; 0 = off) |
moderation.lift | 1 min | lifts suspensions that have ended |
referral.payout | 1 min | pays referral prizes that waited (a day's cap, a restriction lifted) and conversion bonuses once final (payments only) |
crush.matchday | 10 min | draws today's Match Day once |
chat.purge | 1 h | deletes messages past retention, except in reported chats |
In tests the scheduler is clock.Manual: a job runs only on w.Tick(name). Moving the clock alone runs nothing.
Run and shutdown
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.
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.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.receive serves POST /webhook/<secret>. It checks the
X-Telegram-Bot-Api-Secret-Token header (401 on a wrong one), decodes the update, and pushes it into
inbound before answering, on a context that the shutdown signal doesn't cancel. An accepted
update can never be dropped by a deploy. The HTTP server has read, write and idle timeouts.
64 shards × a 256-task buffer, one goroutine each. A user always lands on the same shard, so their updates run in arrival order. A panic in a task is recovered, so the shard keeps serving everyone else on it.
in.do also runs work that isn't an update in a user's queue: album flushes, and a moderator's
punishment (Server.Punished), so it lands behind what the user already sent.
A slow call holds its shard: a purchase waiting up to 15 s on an RPC delays every user hashed to that shard. Left for T14 (load work).
Server.dispatch: identify (private chats only), then by kind:
my_chat_member→botStandingChanged(user blocked the bot → release).inline_query/chosen_inline_result→ inline.go (works for people who never started the bot).FirstContact: the user row is created on first contact. When this update made it, a/start ref_…binds the user to their inviter.BannedAtset →refuseRestricted, stop.seen: presence.- Callback → by prefix (table below). Message → capture, commands, home keys, relay, onboarding.
Each surface follows the same rhythm: parse the callback or text, call domain services, map domain errors to catalog keys, build text and keyboard in the user's locale, then send, edit, or both. Callbacks are always answered, even on error.
- Order: messages to one chat share a worker (chat id mod 4), so they arrive in enqueue order.
- Pace: one token bucket at 30/s for all workers.
- Retry: a 429 waits
retry_afterand tries again, up to 3 attempts. ThenMessage.Failedruns andmm_sends_failed_totalcounts it. - Backpressure: a full queue drops the message (logged,
mm_sends_dropped_total) instead of blocking a handler. - Edits:
Message.Editis asked at delivery for the target id, and may say “too late” (!ok). - Low lane:
EnqueueLow(animation frames) is served only when the chat's queue is empty and a token is free. It's never retried, and dropped when late.
Many screens (settings, sections, shop, wallet, people, find) call SendMessage/EditMessageText directly. That means no shared pacing or 429 retry for them. Kept for T14.
The dispatch table
| Update | Goes to |
|---|---|
my_chat_member (blocked the bot) | relay.release: stop searches, end the chat, tell the partner |
inline_query · chosen_inline_result | inline.go |
anything from a user with BannedAt | refuseRestricted |
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|profile | relay (status card, Find card) |
any other callback (profile:*, sec:*) | settings.handleCallback |
| message while a prompt waits | settings.capture, unless a command or key, which cancels and falls through |
/cancel /settings /profile <id> /user_<id> /like /dislike /changeAgeSpecs /changeAgeSearchRange | their handlers |
| a home-keyboard label | pressHome |
| anything else from someone in a chat | relay.handle |
| the rest | onboarding |
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.
Guards a user's search and chat state. Token-safe SETNX with a 10 s TTL, polled every 20 ms.
Every money operation for a user, behind an in-process gate holding at most 6 at once.
FOR UPDATE on the user row where a count and its ceiling must agree.
Rows move only from the state they were read in; multi-key Redis changes are scripts.
Taken by match.Start, Refine, Stop and Connect (both users, lower id first),
and by the retry of a due search. Unlock is a Lua compare of the token, so an expired holder can't release someone
else's lock.
Deadlock rule: pairing holds the seeker's lock and makes a single Lock attempt on the candidate's
inside reserve. If it's taken, the seeker is retried on the next sweep (“contended”) instead of waiting.
HoldFor is a different lock that only expires; the full sweep uses it to run once per interval across processes.
withUserLock(ctx, s, userID, fn) takes pg_advisory_lock(space "mmpw", userID) on a dedicated
connection. Waiting for an advisory lock pins a pooled connection, so 20 waiters would drain the pool and hang the bot.
To prevent that, callers first queue in an in-process gate per key, and at most 6 user locks are held at once
(heldUserLocks). A failed unlock discards its connection rather than return it locked.
Under it: Buy, Withdraw, re-signing a leg, revoking, top-up sends.
billing.Subscribelocks the user row so purchases at once stack one after another.billing.Clampandlanguage.SetForUserlock it, so a toggle can't undo a revocation's clamp.- Contact/crush adds read count and ceiling through the same transaction (
CeilingIn(tx, …)). Reading them on a second connection let 20 taps at once deadlock the pool. Directory.Acceptlocks the direct row so a backlog is delivered once.
Settlement and withdrawal rows are updated WHERE status IN (states read)
(setLegs), so a late read never moves a final leg back or credits twice.
Match Day claims its date with ran_at set only where still null. Redis multi-key changes
(OpenChat, CloseChat, TakeSealedCard, quota draws, presence) are Lua scripts, atomic by construction.
What may run at the same time for one user? reasoning aid
| Source | Serialised by |
|---|---|
| Their own updates | their 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 them | Redis user lock |
| Settle job touching their orders | wallet advisory lock |
| A moderator's punishment | routed into their inbound shard |
| Crush accept by the other side | match.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).
A new numbered step in migrations.go, each in its own transaction. Never edit an old one; rollbacks are never run.
pg.VisibleTo: not banned, past onboarding, no block either way. Directory, inline, profiles, lists and the ceilings' counts all use it.
shopspring/decimal: USD as numeric(18,6), coin amounts as numeric(78,18). Never a float.
The Redis keyspace (mm:)
| Family | Keys | File |
|---|---|---|
| Status & chats | status:{u} · pair:{u} = "partner:chat" · chat:{id} | chat.go |
| Search pool | pool · pool:gender|age|lang:* · search:{u}:{s} · searches:{u} · searchseq · searchdue · matchtmp:* | search.go |
| Status cards | searchmsg · searchseal · searchresent (each :{u}:{s}) | searchmsg.go |
| Presence | presence:{u} (online for 10 min) | presence.go |
| Prompts | await:{u} = "field|messageID" | session.go |
| Albums | album:{tg}:{group} · albums:{tg} · albumdue | album.go |
| Quotas | quota:{feature}:{u}:{week|day} | quota.go |
| Caches | setting:{key} · bal:{chain}:{addr}:{coin}:{gen} 45 s · balgen:{u} · price:{id} 60 s | cache.go |
| Locks | lock:{u} 10 s · lock:fullsweep | lock.go |
Lose Redis entirely — what happens? recovery
The next boot's reconcile rebuilds what matters from Postgres:
- Chats (chat.Sessions.Reconcile): pairs whose chat Postgres ended are unpaired. Open chats are restored (
RestoreChat), or closed if one side is paired elsewhere. Straychatting/matchedusers are idled. - 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).
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:
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.
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.
the Find card
a search with no filter
the profile Section
who's around
the list and the next Match Day
the inbox
balances, deposit, withdraw
the shop
the user's invite link
relay.openFind sends the inline Find card (find:*): Random, gender, age (around, exact, range), nearby, mood (buttons or type your own), languages (toggle, then Go). Picked languages live in the callback data itself. A pick calls refineOrStart (chapter 11).
relay.search(ctx, u, match.Spec{}, nil): a zero Spec. It never draws a quota unit.
Both open settings.open: the profile Section with photo and caption (alias, gender, age, mood, identity, languages, location, next online, public ID). Menu: Alias · Gender · Age · Language · More ▸ (Photo, Remove photo, Also found as, Mood, Location, Speaking languages, When I'm back). Every setter calls matcher.RefreshProfile so a running search sees the change.
people.open: the directory, by recency or nearest, paged with a cursor in the callback. Rows open public cards whose buttons are Like, Contact, Crush, Message and Next online. My contacts switches to the capped contacts list.
people.openCrushes: your crushes against the cap, a note that the list is secret, and the next Match Day (Jalali for fa).
directs.openInbox: conversations, pending requests first; a thread view keeps the newest lines that fit 4 096 units.
wallets.open: makes the wallet on first open, then shows the overview (chapter 25). Only built with a master key; otherwise coming soon.
invites.open: the user's link t.me/<bot>?start=ref_<public id> in monospace to copy, how a prize is earned at the current rate (and the conversion bonus, when on), how many joined and finished, how close the next prize is, what prizes paid so far, and a 📤 Share button (t.me/share/url). Built with the wallets, since prizes pay into one; otherwise coming soon (chapter 26).
shop.open: plans by length, unlocks priced for the user, owned and settling ones as lines (chapter 22). Every “See plans” button lands here too, in a message of its own.
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:
| Value | Constant | File | Carries |
|---|
10Onboarding and the profile
/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).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).MoodKeyfolds 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_languageceiling, 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.
11Searching and the status card
A search is a background state: the home keys stay, and a status card carries the controls.
A Find pick on a running search refines it: same id, same card, same place in the queue.
The card's id and its final text can arrive in either order. A Lua script makes the edit happen once.
Every 10 s, 60 searches a tick, on the low lane. One line per walk of the pool.
Sent again once, so the hunt stays in sight.
Refining a Random or mood-only search into a filtered one draws a unit; refining an already filtered one is free.
match.Refine keeps the id and enqueue time and re-files the pool entry. Picking “around my age” sets
AroundSince, so the band starts narrow from that moment.
TakeSealedCard returns the id and final text only when both exist, and deletes them in the same step.
Whichever of the two calls comes second does the edit; the other finds nothing. Final lines: matched, stopped,
replaced. The filter chips stay as a trace.
search.animate pages mm:pool 60 at a time with a wrapping cursor. The line
(searchLines) advances once per walk of the whole pool, so each card steps one line per visit however big the
pool is. Frames go on the low lane and re-read the live search at delivery; if a refine changed the filters (and drew the
card itself) the frame is skipped.
An edit answered “message to edit not found” means the user deleted the card. ForgetDeletedCard (Lua)
forgets it only if it's still that id, and marks it resent; it returns true only the first time. The card is then
sent again with the current search.
12The matching engine
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.
| Signal | Weight | Full marks when |
|---|---|---|
| Age | 3 | same age, falling to 0 at 10 years apart |
| Language | 3 | the candidate speaks every language wanted (or the seeker's own) |
| Mood | 2 | same mood key |
| Proximity | 2 | near, falling to 0 at 100 km |
| Wait | 2 | the candidate has waited 2 minutes or more |
pairreads 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.- Blocks either way and restricted users are filtered out; the gate and the rank pick the best.
reservetries the candidate's lock once, removes both searches from the pool, thenSessions.Open. If the open fails, both are requeued.- No pair: the search becomes due when its age band next widens, or not at all.
- On arrival:
StartandRefinetry at once. - Due retries:
match.sweepevery 5 s pops what is due inmm:searchdue. The cost is O(due), not O(pool). - Safety net:
match.fullsweepmarks 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.
| Rule | Value | Where |
|---|---|---|
| Around-my-age start | AGE_MIN_DELTA (5) | env |
| …widens | 1 year every 30 s | DefaultRules |
| Nearby | 50 km | DefaultRules |
| Live searches per user | 1 (liveSearchCap, a function on purpose) | search.go |
| Candidate page | 500, up to 20 pages | pair.go |
| Sweep | 5 s | SweepEvery |
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.
@handles, URLs, t.me links, phone numbers, with formatting kept.
Text by sendMessage; media by copyMessage; some refused.
Labelled “Forwarded by {alias}”; the origin never shown.
Held a second, sent as one group.
Close, Next, Block, Report, a blocked bot, a punishment.
Recorded as written, purged after the retention window.
chat.Scrub masks @handles after any character, URLs, t.me links and phone
numbers split by spaces, dots or slashes, replacing each with chat.Mask. scrubFormatted then keeps only
formatting entities (bold, italic, underline, strike, spoiler, code, pre, blockquotes), shifted around the masks in UTF-16
units. It drops text_link, text_mention, custom emoji and auto-detected entities, so a hidden URL never
reaches the partner. The masked count is kept on the row and in the CensorHit ledger.
A copy carries some fields untouched: a document's file name, an audio's title, a venue's address, a sticker's pack.
| Kind | How |
|---|---|
| text | sendMessage with scrubbed text and kept entities |
| photo, video, animation, audio, document, voice, sticker, video note, venue, location, dice | copyMessage with the scrubbed caption and an empty inline keyboard |
| contact, poll, game, checklist, uncopyable kinds | refused with a notice to the sender (C1, C2, C4) |
| service messages (pins, timers) | ignored |
Never forwardMessage: it would show the sender. Never a sender prefix either.
A message with ForwardOrigin gets an italic “Forwarded by {alias}” in the partner's language: as the first line
when it fits (4 096 for text, 1 024 for captions), otherwise as a message just before. The origin, whether a user, hidden user,
chat or channel, is never shown, and a channel post's URL buttons are dropped by the empty keyboard. Covered by
test/integration/chat/forward_test.go.
Parts of an album arrive as separate updates. Each is held in mm:album:{tg}:{group} until a second after the latest part,
or until the sender's next message. The relay.albums job then sends them with one sendMediaGroup, run through the
sender's own inbound shard so nothing overtakes it. A part arriving after the flush goes alone (C3).
- Close / Next: relay.end closes atomically (
CloseChatdeletes a side only while it still belongs to that chat). The partner hears onlychat.left, never why. Next then starts a new search. - Block: a
Blockrow; the two are never paired again, and see nothing of each other in the directory. - Report: files a
Reportand flags the chat in one transaction; the flag keeps its messages from the purge. - Bot blocked (
my_chat_memberkicked) or punished: relay.release stops searches and ends the chat; the partner is told.
End notices name the other side as /user_<id>; after a block, the blocked one reads “your partner”. A chat ending may free a crush match waiting on it (relay.freed).
Each message is stored as pg.Message before it is sent, original text included. A failed record still relays (C6).
chat.purge deletes messages older than chat.message_retention_days (3) in batches, skipping reported chats.
Users can't clear messages themselves: retention replaced that feature.
14The two gates
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.
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.
| Feature | Normal | Semi | Unlocked | Enforced in |
|---|---|---|---|---|
speaking_language | 2 | — | ∞ · $1.99 | profile picker, Find card |
crush_list | 2 | 5 · $1.99 | ∞ · $4.99 | 💘 toggle |
friends_list | 5 | 20 · $0.99 | ∞ · $2.99 | ➕ Contact |
crush_match_day | 1 | 2 · $2.99 | ∞ · $6.99 | the Match Day draw |
profile_photo | seeded 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). IsSubscribedtrusts the cachedSubscriptionExpiryonly to say no; a yes is confirmed against an active row.
16Crush Match Day
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.crush_match_day ceiling. A pair either side has no room for is dropped for both, and neither is told./user_<id>, with 💬 Accept & Chat (crush:a:<match>).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
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.
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.
Every masked message writes a CensorHit in the same transaction. Offenders(since, atLeast) sums them for the admin panel's thresholds.
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.
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.
telegram/{wallet,withdraw,shop,guide,settled,invite}.godomain/payment · domain/referraldomain/walletwallet/chain · evm · svm · ton · priceCovered in chapters 22, 25 and 26. Screens never decide money; they call the service with an idempotency key the confirmation carries, and map errors to the user's words.
One Service with the stores, the wallet service, balances, a Payer per network, the subscriber (billing.Access), a clock, Config (spend order, treasuries, fee reserve, gas policy, helpers, settings) and the admin Alerts. domain/referral stands beside it: the only other writer of the reward bucket, crediting prizes (chapter 26). Files: payment.go, settle.go, gas.go, withdraw.go, drops.go, lock.go, shop.go, view.go, rules.go.
Client is the read side (native and token balances). Tests replace all three with kit.Chain / kit.Helper.
Sent keeps everything needed to read a transaction again or resend it unchanged: sender, hash, raw signed bytes, EVM nonce
or TON seqno, Solana last valid block height, TON expiry. ErrInsufficient and ErrNoGas are shared across families.
Every RPC call times out at 15 s (RequestTimeout), and Endpoint keeps API keys out of error text.
Every purchase and withdrawal carries one; the same key returns the same row.
Every operation for a user runs under one Postgres advisory lock.
Signed bytes are stored before broadcast; resending sends the same bytes.
Rows move only from the state they were read in.
Unsettled amounts are neither spendable nor withdrawable until final.
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.
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.
EVMKey/SVMKey/TONKey refuse a key that doesn't reproduce the stored address (ErrKeyMismatch) and wipe it after use.
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/finalizedblocks. 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
TransferCheckedfrom the owner's associated token account, creating the receiver's withCreateIdempotentonly 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
lastValidBlockHeightwith 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-bounceableUQ…, 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.
| Network | included | confirmed | finalized |
|---|---|---|---|
| EVM | a successful receipt | safe block | finalized block (≈13–15 min Ethereum, 15–30 Base; seconds on BSC/Polygon) |
| Solana | processed | confirmed | finalized (≈13 s) |
| TON | one 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:
| Branch | On-chain leg(s) | From reward | Left |
|---|---|---|---|
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
shp:v): plans by length and unlocks priced for the user (Offers). Owned or settling tiers appear as lines, not buttons.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.Buy(user, "shop:<user>:<nonce>", item). A price changed since confirming shows the confirmation again with the new price, and nothing is paid.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 error | User sees | Buttons |
|---|---|---|
wallet.ErrInsufficientFunds · chain.ErrInsufficient | not enough funds | Deposit · Back to shop |
chain.ErrNoGas | not enough for the fee | Deposit · Back to shop |
wallet.ErrBalanceUnknown | balance couldn't be read | Retry · Back to shop |
payment.ErrUnavailable | service unavailable (admins alerted) | Retry · Back to shop |
payment.ErrSettling | an earlier payment is still confirming | Retry · Back to shop |
payment.ErrOwned | already yours | Back to shop |
payment.ErrNotForSale | not for sale | Back to shop |
payment.ErrPriceChanged | the confirmation again, headed “price changed” | Pay (new price) · Back |
| anything else | error.generic; logged, cause never shown | Back 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.
A drop is believed only after it reads so for 5 minutes.
What's taken back, what's given back, what the user hears.
Anything unseen or unfinished for an hour alerts admins.
Server.Settled tells each user, in their language.
A pending read in between starts the wait again; a restart forgets it, which only waits longer. Before re-signing,
resolve re-reads the legs under the wallet lock (stillUnlanded), so a run that already signed a fresh one isn't mistaken for “coins gone”.
- Subscription: removed; later ones stacked behind it move up so no gap opens; the cached expiry is refreshed.
- Tier: the unlock row removed and the feature's picks clamped to the old ceiling, keeping the earliest added (
billing.Clamp). A Match Day already drawn stays. Dropped languages re-file the live search. - Money: the reward part comes back; legs that did reach the treasury are credited as reward (store credit), once.
- User: a message naming what was revoked and which picks were dropped, explorer links as proof, and a Shop button in a message of its own.
- Admins: alerted.
What the purchase allowed meanwhile (a chat opened, directs sent) is not undone.
StuckAfter = 1 h. Legs pending, reached-but-not-final or unreadable for an hour; a withdrawal not final; a gas top-up that never landed.
Each is reported once in the Report and alerted, repeated at most every 10 min (AlertEvery). Until the admin panel links admins'
Telegram accounts, alerts go to the log (LogAlerts).
Settler(a.server.Settled) hands each run's Report to the adapter: granted orders (“Payment confirmed”), revocations,
payments that arrived after a revocation (credited), withdrawals arrived or not sent. Each user hears in their own locale; revocations re-file
live searches whose languages were dropped (shop.refile).
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=falseThe 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.
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
wal:wc:<chain>:<symbol>) from what's withdrawable.withdraw_to:…, checked by wallet.Destination. A wrong one is asked again with why.withdraw_amount:…. Persian digits accepted; no finer than the coin's decimals.wal:wk:<nonce>). Typing a new amount re-confirms under a new nonce.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
- Same key already used → that withdrawal.
- Coin active (
ErrNoCoin, which sends the user back to the coin list). - Amount positive and within the coin's decimals (
ErrAmount). 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).- Nothing in flight on that network (
ErrSettling): a native withdrawal behind an unmined send could never pay its fee. - At most
Withdrawable: the deposit bucket less holds and recently topped-up gas (ErrOverWithdrawable). - Within the admin's USD bounds, if set: a minimum per withdrawal and a maximum per UTC day.
- 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.
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…).
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.
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.
t.me/<bot>?start=ref_<public id>, set to copy, and a 📤 Share button. The bot's username comes from getMe in Server.Run./start ref_…. FirstContact says this update made the user, so dispatch calls Attribute, which keeps one Referral.settings.refresh checks RequiredFieldsComplete; when all three are set it calls Completed, which writes completed_at once.WalletTx{referral, reward} naming its invitees, referral_usd raised, and each invitee pointed at it.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).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
- Only the update that made the user. A user who sent anything before, even a plain
/start, is never bound later.FirstContactreturnsfalseto every concurrent loser of the create race, so one update binds. - Only an invite. The payload must be
ref_plus a public id that exists (ErrNotInvite,ErrUnknownInviter). Other prefixes stay free for other start payloads. - 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. - The first binding stands.
referee_idis unique and the insert isON CONFLICT DO NOTHING. - A forwarded
/startis 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.
- 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. - With a bonus on, the inviter's wallet row is held first.
converted_order_idis set by compare-and-set, so the first conversion is used up even while the bonus is 0: a later subscription never pays it.- With a bonus on, it is credited and ledgered as a
referralrow naming the invitee and the order, and kept asbonus_tx_id.
Slowing farming down referral.limits, restrictions
- Bindings per hour (30):
Attributeholds the inviter'susersrow 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):
paycounts 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.
The payload names the inviter by the same handle /user_<id> shows, never an internal or Telegram id.
A prize pays into a wallet, so without a master key Invite answers coming soon and a link binds no one.
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).
27Configuration and settings
| Group | Keys |
|---|---|
| Telegram | BOT_TOKEN · WEBHOOK_SECRET ([A-Za-z0-9_-]{1,256}) · PUBLIC_URL (https) · HTTP_ADDR |
| Stores | DATABASE_URL · REDIS_URL · tests: DATABASE_TEST_URL · REDIS_TEST_URL |
| Custody | WALLET_MASTER_KEY · TREASURY_EVM_ADDRESS · TREASURY_SVM_ADDRESS · TREASURY_TON_ADDRESS · HELPING_EVM_PRIV · HELPING_SVM_PRIV · HELPING_TON_PRIV |
| RPC | EVM_RPC_ETHEREUM · EVM_RPC_BSC · EVM_RPC_POLYGON · EVM_RPC_BASE · SVM_RPC_SOLANA · TON_RPC_URL · TONCENTER_API_KEY (empty = public endpoints) |
| Payments | PAYMENT_ACCEPT_AT (+ _<NETWORK>) · PRICE_PROVIDER (coingecko) · PRICE_API_KEY |
| Knobs | AGE_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).
| Key | Default | Read by |
|---|---|---|
quota.specific_chat | {15, weekly} | filtered searches |
quota.directs | {10, weekly} | new directs |
directory.recently_online_days | 1 | People |
referral.base | {per 5, $1} | referral prizes |
referral.conversion_bonus_usd | 0 | conversion bonus (0 = off) |
referral.limits | {bindings_per_hour 30, prizes_per_day 10} | invite caps (null = off) |
referral.withdrawals_enabled | false | nothing yet (Phase 2's reward payout) |
wallet.withdrawal | {min, max_per_day} unset | withdrawals |
wallet.gas_top_ups_per_day | 5 | gas |
channel_gate | {off, []} | T9 |
match.full_sweep_interval_minutes | 5 | full sweep |
chat.message_retention_days | 3 | purge |
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
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
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.
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.
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
| Tier | Where | Run | Without the URLs |
|---|---|---|---|
| Unit | beside the code | make test | runs |
| Datastore | beside the code | make test-db (-p 1) | skips |
| Integration (kitchen) | test/integration/{foundations,onboarding,chat,social,safety,wallet,referral}, tag integration | make test-integration (-p 1) | fails, on purpose |
| Stress & load | test/stress, test/load (T14) | make test-stress · make load | fails |
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
Hold the fake network mid-signing, tap again, release, then check the final state.
Positive control, no sleeps, final-state checks, no fakes of Telegram.
Each package's helpers_test.go and its common helpers.
Prove a test would fail: break the code on purpose.
WaitFor returns the first moment its condition holds, which can be a passing state. That's why the test calls
Settle and checks again.
- Positive control before absence: prove the same user got a reply before asserting
ExpectNothingMore. - Never
time.Sleep: useExpect,WaitFor,Settle, the kitchen clock andw.Tick. - Final state after
Settlefor “only once” and “still X” claims. - No hand-rolled Bot API fakes. httptest fakes of external services (RPCs, CoinGecko) are fine. One exception waits on kitchen gap M14 (
internal/telegram/inline_test.go). - A kitchen gap or divergence → stop and tell the maintainer, never work around it.
- EN and FA where text is asserted; render expected text with
w.T, never hard-coded. - Tests are named as sentences, with a one-line comment stating the behaviour.
member(t, w, id, locale, alias…) creates a profile-complete user directly and returns the started kitchen user.
paired/connected open a chat. Others include openFind, expectCard, expectSealed,
statusOf, openWallet, openShop, shows, orders, reward,
rewarded, current(u, id), neverShown (no key in any transcript), answered and scrape (reads /metrics).
For invites: arrive (a newcomer's first /start, with or without a payload, then the language), setGenderAndAge
and setAlias (finishing the profile through the real section) and inviterOf.
Look in the package's helpers_test.go or the first test file.
After tests pass, a throwaway /tmp/mutN.py swaps one snippet at a time (asserting it matches exactly once), runs the relevant tier with -p 1,
prints KILLED or SURVIVED, and restores the file in finally. A survivor is a missing test, or provably equivalent or log-only (said so in the Done note).
After an interrupted session, grep for leftovers: && false, if false, error(nil).
30Recipes
The common changes, step by step.
Add a user-facing string
- Add
"ns.key" = "…"to bothen.tomlandfa.toml(Persian wallet text in the formal register). - Render with
bundle.T(u.Locale, "ns.key", data); numbers throughi18n.Digits. - Assert it in a kitchen test through
w.T, in EN and FA.
Add an admin-tunable value
- A
Key…constant and its entry insettings.defaults. - A typed getter on
settings.Service. - Read it where used; it seeds itself on the next boot.
Add a table or a column
- The model in
pg/models.go. - A new
0034_…step inmigrations.go, with a default for existing rows. - Seed data in
pg/seed.go, idempotent. - A datastore test.
Add a button or a whole flow
- A prefix constant; check the 64-byte budget with its arguments.
- Route it in
dispatch, or reuse a surface's prefix. - Answer the callback; edit the same message in place.
- Typed input: an
awaitfield with a prefix routed insettings.capture, and a Cancel scoped to it. - A kitchen test that taps it.
Add a domain service
- A package under
internal/domain, never importingtelegram. - The interface it satisfies, declared in
telegram, as aDepsfield (nil = off). - Construct it in
app.Newand pass it inDeps. - Build and connect its surface in
NewServer.
Add a scheduled job
- Export
…Job(name) and…Every(interval) from the package. - Register it in
app.Newwitho.scheduler.Every. - 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
- Under
withUserLock, with an idempotency key. - Sign → keep → send; move rows only from the state you read them in.
- Hold amounts until final; refuse unsent when anything is missing.
- A user sees only generic words for the service's own trouble.
- 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
domain/* never imports telegram. The binary never compiles the kitchen (make check-deps).
Never forward, never prefix the sender, scrub every text and caption, never show a forward's origin.
Only Sessions.Open sets chatting; pairs are written and removed atomically.
One live search per user, a function rather than a setting. Restricted users are never paired.
Callback data ≤ 64 bytes; text 4 096 and captions 1 024 in UTF-16 units; alerts 200.
Both catalogs, Persian digits, Jalali dates, mirrored keyboards, English fallback.
Keys, locks, sign-keep-send, compare-and-set, holds, the 5-minute drop belief, no key ever printed.
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.
A command or key cancels; a tap doesn't; Cancel is scoped; a prompt opened mid-chat takes the next message.
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
Still empty: internal/domain/notify, internal/admin, internal/webviews, web/*,
test/stress, test/load.
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).
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.
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.
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.
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.
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.
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.
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
- The middle column is the user's life: Redis status above, the
usersrow, and below it theordersthey place. - To the right, other people: chats and messages on top, ties and directs in the middle, Match Day and moderation on the edge.
- Just left of
users, what hangs off one person: the languages they speak, their wallet, and who invited whom (referrals). - Further left, what the admin sets (genders, languages, settings, icebreakers) and what a user owns (subscriptions, unlocks, against packages and tiers).
- 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.
Services take and return plain values (pg.User, match.Search, wallet.Plan). A handler loads, calls, renders; it never keeps a row between updates.
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.
The plan: every task with its Done note, decisions, stop rules, testing tiers. Tick tasks here.
The session hand-over: state, rules, decisions, open items. A new session starts here.
What to build (FR/NFR/AC, open items) and how (sections by area).
Long explanations too big for a comment, and the review records with what was left and why.
C1–C7: refusals and limits shipped on purpose.
The matching engine's full spec.
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.
An HTTP server answering the Bot API methods the bot calls.
Updates handed to the bot's webhook handler, no socket.
An activity broadcaster that wakes every waiter.
Limits, refusals and quirks modelled as Telegram behaves.
kitchen.New(t) stands up an in-process HTTP server; the bot is pointed at it with bot.WithServerURL(k.APIURL()) and
k.Token() (any other token gets 401 on getMe). Each method call updates the model of the chats, is recorded
(w.Calls()), and returns a valid response. Among the methods answered: sendMessage, edit*, copyMessage, forwardMessage, deleteMessage,
answerCallbackQuery, answerInlineQuery, sendInvoice/answerPreCheckoutQuery, the chat-member reads and writes, setWebhook/getWebhookInfo.
A method it doesn't answer is a test error naming it.
k.DeliverToWebhook(handler) binds the bot's webhook handler. Updates are posted through it with httptest.NewRequest,
with no socket, carrying the registered secret header, filtered to the bot's allowed_updates. You never hand-craft an update:
ada.Tap("Next") finds the button on ada's screen and sends the callback query Telegram would. k.Redeliver() hands the last
update again, as Telegram does when unsure.
The bot must Run() after binding, so setWebhook registers the secret and update kinds exactly as in production.
Without it the library drops every update and negative assertions pass for nothing.
The library hands updates to the bot asynchronously, so nothing has happened when Send returns. The kitchen notes every API call
and every user action on an activity channel. WaitFor(what, cond) rechecks cond at each note and fails with
what on timeout. Settle() waits until the conversation goes quiet. Expect… wait on their own;
ExpectNothingMore settles first, since absence is only knowable once the bot stops. The timeout defaults to 2 s
(WithWaitTimeout).
- Text 4 096, captions 1 024, callback data 64 bytes, alerts 200: over-limit calls fail as Telegram fails them.
- A bot can't write first to a user who never started it (403):
w.StartedUser/kitchen.Started(). - Invented
file_ids are refused:k.Uploadorw.Photo. - Faults on demand:
Fail/FailOnce/FailAfterwithTooManyRequests,Blocked,ServerError,Timeout,Refuse(code, description). - Forwards from users, hidden users, chats and channels; formatted text and captions (v0.2.4).
38How mmbot uses it, and running it
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.WithScrollback, WithWaitTimeout…).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.DeliverToWebhook(server.WebhookHandler()), then a.Run(ctx). Stopped in production order at cleanup.| Member | Use |
|---|---|
kit.Start(t) · kit.StartPaying(t, kit.Paying{RealMoneyFirst, GasByUser}) | a fresh world |
w.DB · w.Redis · w.App | the 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).
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.
deleteMessages (batches of 100) and Telegram's 48-hour delete rule. Clear chat (T15.4) needs it, so build it before T15.
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?
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?
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?
Pay is tapped twice before the first finishes signing. Why one order? Which test proves it?
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?
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?
You need a test for a flow the kitchen can't model. What do you do?
Where does a referral prize show up, and what must not happen to it?
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?
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?
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.
15Social: presence, people, directs
Every update; online for 10 min, idle for an hour.
Who's around, by recency or nearest.
Search people by alias from any chat.
Likes, contacts and crushes on one capped path.
Pile up until accepted.
Set by anyone, seen by subscribers.
ActivityOf: online under 10 minutes, idle under an hour, away after.Directory.Page(viewer, index, order): users seen within
directory.recently_online_days(at least the online window), visible to the viewer, most recent first or nearest.Profile(viewer, publicID)opens a card however long ago its owner was around. The card is a Section: photo, alias, gender, age, mood, languages, distance, last seen, ID; buttons Like · Contact · Crush · Message · Next online./profile <id>and/user_<id>open it from anywhere.inline_query→Directory.Search: aliases containing the text (case-insensitive, % and _ literal), most recently seen, 20 per answer withnext_offset, personal, cached 30 s. Anyone may ask, even someone who never started the bot. A pick counts onmm_inline_profiles_sent_total.The kitchen can't page inline results yet, so
inline_test.gostill fakes Telegram for that one check.Likes are a free toggle. Contacts and crushes share
toggleHeld: inside one transaction it reads the count of visible held rows and the ceiling (CeilingIn(tx, …)), refuses with*ListFullpast it, and inserts. Dropping is always allowed. A target hidden since (block, ban) frees its place. Crushes are secret unless mutual.Peekthedirectsallowance, then prompt (await direct:<id>).DirectMessage, stored as written and scrubbed on the way out; the recipient sees a request card.“When I'm back” stores
users.next_online_at(+next_online_beyondfor “more than 2 days”). Your own profile shows it. On someone else's card, ⏰ Next online answers a subscriber with “back in about 45 min”, and everyone else with See plans.