Playbook: a new product in the line
Who is this for
Section titled “Who is this for”A developer who is launching the next product of the Exority Studio line and has to give it what AI Sticker Pack already has: a landing page with a request form, a server that stores every request, and status cards in the team’s shared Telegram group. You have never spoken to the author of the existing code.
- The reference chain is: landing form → server action →
POST /leadswith a shared secret → SQLite row → a bot loop publishes a card with status buttons into the requests group → commands with a product prefix. - Several products’ bots live in one Telegram group. Everything visible there carries a product prefix: commands (
/stickers_stats), callback data (stickers_lead:…), the request hashtag (#stickers_42). A new product takes its own prefix, for exampledj_orraffle_. - In the product repository the prefix is not one setting. It is hard-coded in about twenty places across five files. The table in “What to reuse and what to parametrise” lists every one.
- Two ways to build it: copy the leads module into the new product’s backend (works today, about ten files) or extract a shared multi-product leads service (a proposal; no such code exists).
- DJ Controller already has a landing page, but its form posts straight to Telegram with different fields. It needs a schema decision before it can use the leads server.
- Roll out the server first, then the landing page. Removing the landing page’s secret reverts to direct sending.
The reference architecture
Section titled “The reference architecture”flowchart LR
V["Visitor"] --> F["Landing form"]
F --> A["Server action on Vercel"]
A -- "POST /leads + secret header" --> API["Leads API process"]
API --> DB[("SQLite: leads")]
DB --> O["Outbox loop in the bot process"]
O -- "card with status buttons" --> G["Shared requests group"]
G -- "button presses and prefixed commands" --> O
A -. "fallback: sendMessage" .-> G
The pieces, with the files that implement them for AI Sticker Pack:
| Piece | Where | File |
|---|---|---|
| Form, honeypot, minimum fill time, per-IP limit | landing | src/app/actions/submitRequest.ts |
| Routing: leads server, fallback, console | landing | src/lib/deliverRequest.ts |
POST /leads: secret, validation, limits, dedup, insert |
product, API process | bot/leads_api.py |
| Pure logic: validation, card text, keyboard, stats, CSV | product | bot/services/leads.py |
| Outbox loop, reminders, digest, retention | product, bot process | bot/leads_bot.py |
| When to remind, when to send the digest, when to erase | product | bot/services/leads_schedule.py |
| Status buttons and group commands | product, bot process | bot/handlers/leads.py |
Tables leads, lead_status_history, lead_repeats |
product | bot/db/models.py |
Settings LEADS_* and their validation |
product | bot/config.py |
| The original specification (Russian) | product | docs/LEADS_TZ.md |
The flow itself is described step by step on Lead submission and Lead status.
Why it is built this way
Section titled “Why it is built this way”| Decision | What it is | Why |
|---|---|---|
| Outbox | The API only inserts a row and answers. A loop in the bot process picks up rows with published_at IS NULL every 2 s and sends the card. Retries after 5 s, 15 s, 1 min, 5 min, then every 15 min; an admin alert after 5 failures |
A request survives a crash or restart of the bot and is published when the bot comes back. A background task inside the API would be lost on restart. One process is the only sender to Telegram |
Idempotent request_id |
The form creates a UUID when it opens and sends it with every attempt. request_id is UNIQUE; a repeat returns 200 with the existing number |
A retry after a dropped connection must not create a second request |
| Shared secret | The landing server sends a header; the API compares it with hmac.compare_digest. Wrong secret: 401, and after 5 failures in 10 minutes from one caller, 429. No secret configured: 404 |
The endpoint is public on the internet but only the landing server may write. A disabled endpoint does not reveal that it exists |
| HMAC IP hash | The landing passes the visitor’s IP; the server stores only HMAC-SHA256(salt, ip), truncated to 32 hex characters |
The server sees Vercel’s address, not the visitor’s, so the landing has to pass it. Rate limiting needs a stable key per visitor, and storing a raw IP is avoidable personal data. Without the salt the hash cannot be reversed by enumeration |
| Fallback channel | On timeout (5 s), network error, 5xx, 401 or 404, the landing sends the request straight to the group with sendMessage, with a first line starting ⚠️ Запасной канал (“fallback channel”) and the request_id |
The visitor’s request is never lost because the server is down. Such a request is not in the database; an admin handles it by hand |
No fallback on 429 and 422 |
429 returns “too many requests”; 422 returns an error and is logged |
A fallback on 429 would be a way around the server’s limit. 422 means the landing and the server disagree on the rules: a bug to fix, not to paper over |
| Dedup of repeats | The same Telegram handle (case-insensitive) or the same phone within 24 hours of an existing request that is neither spam nor erased is recorded in lead_repeats. The original card is rewritten with a “repeat” block and a short reply pings the group |
People submit twice. One card per person per day keeps the group readable and the statistics honest |
| Prefix isolation | Commands, callback data and the hashtag all start with the product’s prefix | Several products’ bots are members of one group. A bare /stats would be answered by every bot that can read the group, and a hashtag without a prefix would mix products in search |
| Kill switch | LEADS_ENABLED=0 removes the router and the loop and makes /leads answer 404 |
Rollback without a deploy |
| Guest flow isolation | The product’s guest-facing routers are filtered to private chats; the leads router is filtered to the one group and is included last | Once a product bot joins a group, its /start must not launch the guest flow there |
Limits currently configured by default: 5 requests per visitor per 10 minutes and 60 requests per hour overall (LEADS_IP_LIMIT, LEADS_IP_WINDOW_SEC, LEADS_HOURLY_LIMIT in bot/config.py). A request body over 16 KB is refused with 413.
What to reuse and what to parametrise
Section titled “What to reuse and what to parametrise”Reuse verbatim
Section titled “Reuse verbatim”These parts contain no product name and can be copied unchanged.
| Part | Notes |
|---|---|
| The three tables and their indexes | Names are generic: leads, lead_status_history, lead_repeats |
| Outbox loop, back-off schedule, alert thresholds | run_outbox, publish_due, notify_repeats |
SlidingLimit, secret_ok, ip_hash, clean_ip, parse_utm |
Pure functions |
| Status set and button layout | new, in_work, contacted, deal, refused, spam |
| Reminder and digest timing | Weekdays 9:00–21:00 for reminders, 10:00 daily digest with catch-up until 12:00, in the Tashkent time zone |
| Retention | Spam erased after 30 days, other requests after 365 days from the last status change |
| Test-request convention | A request with тест or test in the name or comment can be deleted completely |
| Environment variable names | LEADS_ENABLED, LEADS_SECRET, LEADS_IP_SALT, LEADS_CHAT_ID, LEADS_ADMIN_IDS, LEADS_CARD_PII, the three limits. They are per process, so they do not collide between products |
| Log markers | LEAD_NEW, LEAD_PUBLISHED, LEAD_STATUS, LEADS_CMD and the rest are not prefixed; each product logs to its own service unit |
Every place the stickers prefix or the product name is hard-coded
Section titled “Every place the stickers prefix or the product name is hard-coded”Found by searching the leads module of the product repository at the verified commit. Line numbers drift; search for the quoted text.
| # | What | File | Current value | For DJ Controller | For Raffle |
|---|---|---|---|---|---|
| 1 | Status-button callback prefix | bot/services/leads.py, CALLBACK_PREFIX |
stickers_lead |
dj_lead |
raffle_lead |
| 2 | Picker-button callback prefix | bot/services/leads.py, PICK_PREFIX |
stickers_pick |
dj_pick |
raffle_pick |
| 3 | Request hashtag | bot/services/leads.py, HASHTAG |
#stickers_ |
#dj_ |
#raffle_ |
| 4 | Command menu (names and descriptions with examples) | bot/services/leads.py, COMMANDS |
stickers_stats, stickers_new, stickers_lead, stickers_export, stickers_forget |
dj_stats … |
raffle_stats … |
| 5 | Command handlers — literal strings, not derived from COMMANDS |
bot/handlers/leads.py, five Command("stickers_…") decorators |
same five names | dj_… |
raffle_… |
| 6 | Card title | bot/services/leads.py, card_text |
Заявка #stickers_<n> · AI Sticker Pack |
· DJ Controller |
· Raffle |
| 7 | Statistics title | bot/services/leads.py, stats_text |
📊 Заявки AI Sticker Pack — … |
product name | product name |
| 8 | Product metric in statistics | bot/services/leads.py, stats_text; db.packs_stats |
a line counting generated sticker packs | replace with the product’s own metric or drop | same |
| 9 | Digest title and product metric | bot/services/leads_schedule.py, digest_text; db.packs_between |
☀️ Стикеры за сутки (…), Паков сгенерировано: N |
rename; replace or drop the metric | same |
| 10 | Digest “already sent” settings key | bot/leads_bot.py, DIGEST_SETTING |
stickers_digest_last |
dj_digest_last |
raffle_digest_last |
| 11 | CSV file name | bot/handlers/leads.py, cmd_export |
stickers_leads_<date>.csv |
dj_leads_<date>.csv |
raffle_leads_<date>.csv |
| 12 | CSV caption | bot/handlers/leads.py, cmd_export |
Заявки AI Sticker Pack: N шт. |
product name | product name |
| 13 | Number parser strips the hashtag prefix | bot/handlers/leads.py, _number_str |
removeprefix("stickers_") |
dj_ |
raffle_ |
| 14 | Hints inside bot answers | bot/handlers/leads.py: answers that mention /stickers_lead, /stickers_forget, /stickers_export; FALLBACK_TEXT |
six strings | rewrite each | rewrite each |
| 15 | Hint inside an admin alert | bot/leads_bot.py, publish_due |
…видна в /stickers_new… |
/dj_new |
/raffle_new |
| 16 | Default bot username in the export hint | bot/handlers/leads.py, cmd_export |
a literal fallback used when BOT_USERNAME is unset |
the product’s <bot_username> |
same |
| 17 | Secret header name | bot/leads_api.py, SECRET_HEADER |
X-Stickers-Leads-Secret |
X-Dj-Leads-Secret |
X-Raffle-Leads-Secret |
| 18 | Secret header name, landing side | landing src/lib/deliverRequest.ts, LEADS_HEADER |
X-Stickers-Leads-Secret |
must equal row 17 | must equal row 17 |
| 19 | Direct and fallback message title | landing src/lib/requestMessage.ts |
AI Sticker Pack — заявка с лендинга |
product name | product name |
| 20 | Browser storage keys | landing src/lib/requestSent.ts, src/lib/theme.ts |
ai-sticker-pack.request-sent, ai-sticker-pack.theme |
dj-controller.… (already so in dj-landing) |
raffle.… |
| 21 | Accepted locales | bot/services/leads.py, LOCALES |
ru, uz, en; anything else is stored as en |
DJ’s landing has en and ru only — no change needed |
match the landing |
| 22 | Alert de-duplication keys | bot/leads_api.py, bot/leads_bot.py |
leads_hourly_limit, leads_publish |
unchanged in a separate backend; must be prefixed in a shared process | same |
| 23 | Tests | tests/test_leads_*.py |
the prefix appears in seven of the eight files — all except test_leads_api.py |
replace when porting | replace when porting |
Rows 1–4 are constants. Rows 5–16 are strings scattered through handlers and texts: changing the constants alone leaves the bot answering to the old command names.
Recommended first step, whichever option you choose: in the copy, introduce one constant PREFIX = "dj" and derive rows 1–5, 10, 11 and 13–15 from it, and one constant PRODUCT_NAME for rows 6, 7, 9 and 12. Then the next product is a two-line change.
Telegram limits to keep in mind when choosing a prefix: a command name is at most 32 characters of lowercase Latin letters, digits and underscores; callback data is at most 64 bytes. raffle_lead:<id>:contacted fits comfortably.
Two implementation options
Section titled “Two implementation options”Option A — copy the leads module into the new product’s backend
Section titled “Option A — copy the leads module into the new product’s backend”Each product runs its own leads API and its own outbox loop, with its own database and its own bot.
What the module needs from its host, which you must provide in the new backend:
| Dependency | Used for |
|---|---|
A settings object with the LEADS_* fields and start-up validation |
all files |
An async database layer with about 25 lead_*, leads_* and repeat_* methods |
API, loop, handlers |
| A schema migration step that creates the three tables | start-up |
A key-value settings table (get_setting, set_setting) |
the digest’s “already sent today” mark |
| An admin alert function with a cooldown key | flood and publish-failure alerts |
A FastAPI app to mount the router on, and a reverse proxy that forwards /leads |
API |
An aiogram dispatcher in polling mode whose allowed_updates include messages and callback queries |
handlers |
| A supervisor that runs the outbox loop beside polling and never lets it die | bot process |
Option B — a shared multi-product leads service
Section titled “Option B — a shared multi-product leads service”One service owns POST /leads for all products, one database with a product column, and one registry that maps a product to its prefix, display name, secret, group and admins. Sketch:
leads.product TEXT NOT NULL; the request number stays per product so hashtags remain#dj_1,#dj_2.- The secret identifies the product, or the body carries
productand each product has its own secret. - Commands, callback prefixes and titles are generated from the registry.
- Either one shared bot posts every product’s cards, or the service holds one bot token per product and runs one polling loop per token.
- Product metrics in statistics and the digest (generated packs, played tracks, tickets) come from a small per-product plug-in or are dropped.
Trade-offs
Section titled “Trade-offs”| Criterion | A: copy per product | B: shared service |
|---|---|---|
| Exists today | yes, as the AI Sticker Pack implementation | no |
| Effort for the second product | port about ten files and eight test files; rename per the table above | design, build and migrate before any product benefits |
| Effort for the fifth product | the same again, plus four copies to keep in sync | one registry entry |
| A bug fix | applied once per copy | applied once |
| Failure isolation | one product’s outage does not touch the others | one outage stops requests for every product (the landing fallback still delivers to the group) |
| Stack requirement | the new backend must be Python with aiogram, FastAPI and SQLite, or the module is rewritten | none on the products |
| Cross-product view | none; statistics are per product | one export, one dashboard |
| Personal data | spread over several databases and backups | in one place: one retention job, one erase command, one bigger target |
| Product-specific metrics in the digest | trivial, same process and database | needs a plug-in or an extra call |
| Secrets | independent per product | one service holds all of them |
Guidance. For the second product, choose A and do the constant extraction above while copying. Revisit B when a third product arrives or when the copies start to diverge. Whether the DJ Controller and Raffle backends are Python at all was not examined for this page; if one is not, option A becomes a rewrite and B becomes more attractive.
Landing side
Section titled “Landing side”Steps to wire a landing form
Section titled “Steps to wire a landing form”Starting from a landing page built on the shared template (see the last section):
-
Decide the fields. The leads server validates
name,telegramandphonestrictly, and its repeat detection keys on Telegram handle and phone. Either adopt those three required fields plus optionaldate,guests,comment, or change server validation, the card, the CSV columns and repeat detection for your product. -
Copy four files from
ai-stickers-landing/src/lib/:deliverRequest.ts,requestSchema.ts,requestMessage.ts,rateLimit.ts. AlsorequestFields.ts(field list without the validator, so the form does not loadzodon the first screen),submitState.tsandrequestSent.ts. -
Copy the server action
src/app/actions/submitRequest.ts. -
Rename
LEADS_HEADER, the message title and the storage key (rows 18–20 above). -
Form component. Add hidden inputs
locale,utm,startedAtandrequestId, and the honeypot fieldcompany. GeneraterequestIdwithcrypto.randomUUID()when the form mounts, not on submit, so a retry reuses it. Load the schema withawait importon submit. -
Dictionaries. Add labels, placeholders, error codes (
nameRequired,tooLong,telegramInvalid,phoneInvalid), the consent line under the button and the “sent” screen texts in every language. -
Environment. Add to
.env.exampleand to Vercel:Variable Value LEADS_ENDPOINTthe product’s https://<server>/leads, Production onlyLEADS_SECRETthe same value as on the server TELEGRAM_BOT_TOKENthe product bot’s token, for the fallback TELEGRAM_CHAT_ID<LEADS_CHAT_ID>NEXT_PUBLIC_SITE_URLthe landing’s public address None of the first four may be prefixed
NEXT_PUBLIC_. -
Tests. Port
deliverRequest.test.ts(ten cases: success, duplicate, timeout fallback, network fallback,429,422, both channels failing, no leads variables, no variables at all, secret never in client code),requestSchema.test.tsandrequestSent.test.ts. Add them to thetestscript inpackage.json. -
Keep both sides’ validation identical. The server’s test file reuses the landing’s examples on purpose. A rule changed on one side only produces
422for real visitors.
What differs in dj-landing today
Section titled “What differs in dj-landing today”Verified in dj-landing/src/app/actions/submitRequest.ts: the action formats a message and calls Telegram’s sendMessage directly. There is no leads server call.
| Aspect | dj-landing |
ai-stickers-landing |
To do for DJ |
|---|---|---|---|
| Delivery | inline in the action: sendMessage, or console.info without variables |
deliverRequest.ts: leads server, then fallback, then console |
copy deliverRequest.ts, call it from the action |
| Request storage | none | SQLite on the leads server | stand up the server side first |
| Fields | name, role, contact, date, comment |
name, telegram, phone, date, guests, comment |
decide: adopt Telegram + phone, or extend the server |
role |
required, one of dj, venue, organizer, other |
does not exist | add a column and a card line, or fold it into comment |
| Contact | one free-text field, 3–120 characters | Telegram handle normalised to @name; phone normalised to +digits |
the server rejects a free-text contact with 422 |
guests |
does not exist | optional, up to 40 characters | simply omit; the server treats it as optional |
date length |
up to 40 | up to 60 | no conflict |
| Validator | full zod, imported statically |
zod/mini, imported on submit |
switch when copying requestSchema.ts |
| Schema helpers | fieldErrors inside requestSchema.ts |
split into requestFields.ts |
copy the split |
| Message | built inline; first line Заявка с лендинга (“request from the landing”) — no product name |
requestMessage.ts; title names the product; the handle is a link entity |
copy and set the product name: in a shared group an unnamed message cannot be attributed |
| Link previews | disable_web_page_preview: true |
link_preview_options: { is_disabled: true } |
comes with the copy |
request_id |
none | UUID from the form, validated as v4, regenerated server-side if missing | add the hidden input |
| Visitor IP to the server | not sent | client_ip from x-forwarded-for |
comes with the copy |
| Timeout on outgoing calls | none | 5 s to the leads server, 10 s to Telegram | comes with the copy |
| Honeypot, 3 s minimum fill, 5 per 10 min per IP | present, identical | present | nothing |
| “Sent” flag | dj-controller.request-sent, never expires |
ai-sticker-pack.request-sent, expires after 24 h |
optional; copy requestSent.ts and keep the DJ key |
| Environment | TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, NEXT_PUBLIC_SITE_URL |
those plus LEADS_ENDPOINT, LEADS_SECRET |
add two variables |
| Tests | dictionaries, schema | dictionaries, packs, schema, delivery, “sent” flag, scene steps | port delivery and “sent” tests |
| Locales | en, ru; default en |
ru, uz, en; default ru |
the server already accepts both |
For Raffle there is no landing page yet. Start it from ai-stickers-landing, not from dj-landing: it is the newer of the two and already contains the delivery code, the watchdog and the performance work.
Telegram group setup
Section titled “Telegram group setup”One group receives the requests of every product.
- Create or choose the product’s bot. Its token goes to the backend and, for the fallback, to the landing’s Vercel variables.
- Add the bot to the requests group and make it an administrator. At start-up the reference implementation checks the bot’s membership (
check_group) and logs a warning if it is only a member or not in the group at all. - Get the group id and set
LEADS_CHAT_ID=<LEADS_CHAT_ID>on the server. A supergroup id is negative. - Set
LEADS_ADMIN_IDSto the Telegram user ids of the people who may press buttons and call commands. These people should also be administrators of the group in Telegram, see the next point. - Command menu. At start-up the bot registers its commands with the scope “administrators of this chat” (
BotCommandScopeChatAdministrators). Only group administrators see the menu. A person inLEADS_ADMIN_IDSwho is not a group administrator can still type the commands; the bot checks permissions againstLEADS_ADMIN_IDS, not against group roles. - Start a private chat with the bot once, for every admin who will use the export command. The CSV goes to the caller’s private chat, and a bot cannot write first.
Privacy mode and what it implies
Section titled “Privacy mode and what it implies”| Bot’s situation in the group | What it receives |
|---|---|
| Member, privacy mode on (Telegram’s default) | only commands addressed to it (/dj_stats@<bot_username>), replies to its own messages, and button presses on its own messages |
| Administrator, or privacy mode off | every message in the group |
Consequences:
- A product bot that is only a member does not react to a command typed without
@<bot_username>. Making it an administrator fixes that, and is also what the menu scope needs. - Every bot that is an administrator can read every card posted by every other product, including names, handles and phone numbers. So can every human member. Decide whether that is acceptable before adding a product.
LEADS_CARD_PII=0publishes cards without contacts; the contacts then stay in the database only. - Because administrator bots receive all messages, an unprefixed command such as
/statswould reach all of them. That is the reason for the prefix. - A button press is delivered only to the bot that sent the message. With one bot per product, callback prefixes are a second line of defence; with a shared bot they are the only routing.
One bot per product or one shared bot
Section titled “One bot per product or one shared bot”| One bot per product | One shared bot | |
|---|---|---|
| Fits option | A (and B with several tokens) | B only |
| Polling | each backend polls its own token | one process must poll: Telegram allows a single consumer of updates per bot token |
| Card author in the group | the product’s own bot, so the source is obvious | one bot; only the title and hashtag tell products apart |
| Guest-facing behaviour | the product bot also serves its guests in private chats; group handling must be isolated from that | the shared bot can be dedicated to the team |
| Fallback from the landing | uses the same product bot’s token | every landing holds the shared token |
| Menu | each bot shows its own five commands to group admins | one menu with 5 × N commands |
| Blast radius of a leaked token | one product | all products |
The reference implementation uses one bot per product, and that bot is also the product’s guest bot. Note one effect: a bot does not receive its own messages, so fallback messages sent with the same token never enter the database. They stay as plain messages for manual handling.
Rollout order and rollback
Section titled “Rollout order and rollback”-
Server, in a test group. Deploy the backend with the leads module. Set
LEADS_SECRET(at least 24 characters),LEADS_IP_SALT,LEADS_ADMIN_IDS, andLEADS_CHAT_IDpointing at a test group. In production mode the reference implementation refuses to start with a shorter secret or an empty salt when the secret is set.Terminal window python -c "import secrets; print(secrets.token_urlsafe(32))" -
Check the logs for the lines that say the command menu was set and which chat the loop publishes to.
-
Send a test request with
curl, marked as a test:Terminal window curl -sS -X POST "https://<server>/leads" \-H "Content-Type: application/json" \-H "X-Dj-Leads-Secret: <LEADS_SECRET>" \-d '{"request_id":"<fresh UUID v4>","name":"ТЕСТ Guest Name","telegram":"@example_user","phone":"+<digits>","comment":"ТЕСТ","locale":"ru"}'Expect
201and{"ok": true, "lead": <n>, "duplicate": false}, then a card in the test group within a few seconds. Send the same body again and expect200with"duplicate": trueand no second card. -
Exercise the card. Press each status button, call each command, and call the export.
-
Delete the test request completely:
/dj_forget <n> тест. This works only because the name or comment contains the test marker. Confirm the statistics no longer count it. -
Switch
LEADS_CHAT_IDto the real group and restart. Nothing reaches the group yet, because no landing calls the endpoint. -
Landing. Set
LEADS_ENDPOINTandLEADS_SECRETin Vercel for Production and redeploy. -
Send one test request through the real form, with
ТЕСТin the name. Check the card, then delete it as in step 5. -
Break it on purpose once. Point a Preview deployment’s
LEADS_ENDPOINTat an address that does not answer and submit. A message with the fallback warning line must appear in the group and the visitor must see success.
Rollback
Section titled “Rollback”| Level | Action | Effect |
|---|---|---|
| Landing only | remove LEADS_SECRET (or LEADS_ENDPOINT) in Vercel and redeploy |
the form sends straight to Telegram, as before the integration. Requests are no longer stored |
| Server, module off | set LEADS_ENABLED=0 and restart both processes |
/leads answers 404; the landing treats 404 as “fall back” and sends straight to the group. Buttons on old cards answer with a notice instead of spinning |
| Server, full | deploy the previous backend version | as above; the three tables stay in the database and are harmless |
Do the landing rollback first if both are needed: it stops new writes immediately.
Final checklist
Section titled “Final checklist”Server side:
- Prefix chosen; it is unique among the bots in the group; command names fit 32 characters.
- Every row of the hard-coded table changed; a search for the old prefix in the new module returns nothing.
- Product name set in the card title, statistics title, digest title and CSV caption.
- Product metric in statistics and digest replaced or removed.
- Three tables created by the migration on a copy of the production database.
LEADS_SECRET(24 characters or more) andLEADS_IP_SALTgenerated and stored only in the server’s environment file and in Vercel.- Guest-facing routers of the product bot filtered to private chats.
- Leads router included last; the fallback router for orphaned buttons included after it.
- Outbox loop runs under the bot’s supervisor and survives an exception in one iteration.
- Reverse proxy forwards
/leadsto the API process.
Telegram:
- Bot is an administrator of the requests group.
LEADS_CHAT_IDandLEADS_ADMIN_IDSset; every admin is a group administrator and has started a private chat with the bot.- Team has agreed that cards with contacts are visible to all group members and administrator bots, or
LEADS_CARD_PII=0is set.
Landing:
- Field set decided and identical in the landing schema and the server validation.
deliverRequest.ts,requestSchema.ts,requestMessage.ts,rateLimit.tsand helpers copied; header name, message title and storage key renamed.- Form sends
requestId,locale,utm,startedAtand the honeypot. - Consent line under the submit button present in every language.
- No delivery variable has a
NEXT_PUBLIC_prefix; the test that checks this is ported and passes. .env.exampleand the README list the new variables.
Rollout:
- Verified in a test group before the real one.
- Test request sent by
curl, duplicate confirmed, card exercised, test request deleted. - Test request sent through the real form and deleted.
- Fallback verified once with an unreachable endpoint.
- Rollback steps written into the product’s deploy notes.
- Documentation for the new product updated in the same change.
Definition of done
Section titled “Definition of done”The work is done when all of the following are true.
- Tests ported and green. On the server, the eight files
tests/test_leads_api.py,_bot.py,_commands.py,_housekeeping.py,_pick.py,_repeats.py,_schedule.pyand_wiring.py, with the new prefix. On the landing,deliverRequest.test.ts,requestSchema.test.tsandrequestSent.test.ts. No test makes a network call or talks to Telegram. - The product’s existing tests are still green, including a test that the guest
/startis silent in the group and works in a private chat. - A test request has made the full trip: submitted through the production form with the test marker, stored with a number, published as a card, moved through at least two statuses by an admin, visible in
/<prefix>_statsand in the CSV, then deleted completely with/<prefix>_forget <n> тестand gone from the statistics. - A duplicate has been refused: the same
request_idtwice gives one card. - A repeat has been merged: a second request with the same phone within 24 hours updates the first card instead of creating a new one.
- The fallback has been seen once in the group, with its warning line.
- Both rollbacks have been tried on a Preview deployment or a staging server.
- No personal data in logs: a search of the service logs for the test name, handle and phone finds nothing.
- An operator who was not involved can find the runbook for missing cards and follow it. Use Leads missing or in fallback as the model.
Wider landing template reuse
Section titled “Wider landing template reuse”dj-landing was the first landing page of the line and ai-stickers-landing was built on its structure. A third landing page should start from ai-stickers-landing.
Shared structure
Section titled “Shared structure”| Part | Files | State in dj-landing |
State in ai-stickers-landing |
|---|---|---|---|
| Stack | package.json |
Next.js 16, React 19, Tailwind v4, zod, plus the motion library |
the same without motion; all movement is CSS |
| Locale routing | src/proxy.ts, src/i18n/config.ts |
en, ru; default en; cookie NEXT_LOCALE |
ru, uz, en; default ru; a “published” flag per locale |
| Dictionaries | src/i18n/dictionaries/*.ts, src/i18n/index.ts |
en.ts defines the type |
the same pattern |
| Dictionary test | src/i18n/dictionaries.test.ts |
same keys, nothing empty, forbidden promises, one form of address | adds digits, apostrophes, dash and routing rules |
| Motion vocabulary | src/lib/motion.ts, tokens in src/app/globals.css |
--dur-fast 180, --dur-base 400, --dur-slow 650, --dur-scene 900, --dur-flip 260; two easing curves; useMotionGate |
identical values, plus one spring and --dur-pack |
| Scroll reveal | src/lib/reveal.ts |
inline gate and data-rv |
identical contract |
| Hero scene machine | components/hero/scene/steps.ts, useSceneMachine.ts |
one sequence, one driver; rests after three cycles | the same machine with an opening, a pause and a jump |
| Section shell and common sections | components/sections/shell.tsx, Faq, HowItWorks, Setup, RequestForm |
present | present |
| Site chrome | components/site/SiteHeader, SiteFooter, LocaleSwitch |
present | present, plus a theme toggle and a brand mark |
| Form protection | src/lib/rateLimit.ts, honeypot, minimum fill time |
present | the same limiter: 5 per IP per 10 minutes, in memory |
| SEO files | src/app/robots.ts, sitemap.ts, opengraph-image.tsx per locale |
present | present |
| Quality scripts | scripts/shots.mjs, audit.mjs, scroll-film.mjs; SHOTS_BASE; widths 360, 768, 1280, 1920 |
present, without a watchdog | present, with a watchdog, plus lighthouse, js-weight, contrast |
| Repository rules | CLAUDE.md, docs/TZ.md, docs/design-plan.md |
present | present |
| Hosting | Vercel, Node 22, no Docker | same | same |
Product-specific parts
Section titled “Product-specific parts”| Part | dj-landing |
ai-stickers-landing |
For a new product |
|---|---|---|---|
| Palette | near-black with a lime primary and a cyan live colour | graphite with an amber primary and a coral accent; a light theme | choose a new fixed palette; keep the token names |
| Fonts | Unbounded, Onest, JetBrains Mono | Unbounded, Onest, IBM Plex Mono (it has the Uzbek ʻ) |
keep Unbounded and Onest; pick the mono by glyph coverage |
| Hero scene content | DJ window, room screen, waveform, cover art | tablet kiosk, phone with Telegram, sticker pack | draw the product’s own story; keep “one step, one device, one action” |
| Mock-up sections | DJ control, room showcase, Serato integration, venues | showcase with kiosk, sticker ribbon, group chat, consent card | product-specific |
| Content data | src/content/tracks.ts, generated covers (scripts/covers.ts) |
src/content/packs.ts, sticker pipeline (demo-packs, sticker-gap, assets) |
product-specific |
| Form fields | name, role, contact, date, comment | name, Telegram, phone, date, guests, comment | see “Landing side” |
| Delivery | direct to Telegram | leads server with fallback | leads server with fallback |
| Promise lists | what the DJ product may and may not claim | what the sticker product may and may not claim | write the lists first; the dictionary test encodes them |
| Audit checks | its own audit.mjs, written for its own scene (not examined in detail for this page) |
the checks listed on Landing quality tooling, many tied to its own class names | copy the script, keep the generic groups, rewrite the scene groups |
Starting a third landing page
Section titled “Starting a third landing page”- Copy
ai-stickers-landingwithoutpublic/stickers,public/outfits,src/content/packs.ts, the three asset scripts and the hero scene’s content components. - Replace the palette tokens and run
npm run contrastuntil every pair passes. - Write
CLAUDE.mdfor the new product first: palette, fonts, what may and may not be promised, tone per language. Then encode the promise lists indictionaries.test.ts. - Rename storage keys, the message title and the secret header.
- Keep
scripts/lib/watchdog.mjs,shots.mjs,lighthouse.mjs,js-weight.mjsandcontrast.mjsas they are. Inaudit.mjskeep the groups for languages, themes, structure, header geometry, headline lines, overflow, palette, hover, form, layout shift and reduced motion; rewrite the groups that name hero-scene classes. - Build the hero scene on
steps.tsanduseSceneMachine.ts. Follow Animation rules. - Wire the form as described above, after the server side is live.
Where to go next
Section titled “Where to go next”- Lead submission flow — the request path in detail.
- Lead status flow — buttons, history and card edits.
- Reminders and digest — the scheduled messages you inherit.
- Bot commands — the reference list of the
stickers_commands you are renaming. - API reference — the
POST /leadscontract. - Privacy — what personal data the leads module stores and for how long.
- Leads missing or in fallback — the runbook to adapt for the new product.
- Multi-tenant scaling — the related question of several clients on one installation.
- Landing quality tooling — the scripts a new landing page inherits.
Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5 (playbook example: dj-landing@558457c).