State machines
Who is this for
Section titled “Who is this for”A developer who needs to know whether a status change is legal and where it is made, and an operator who sees a
status in the admin panel, in /stats or in the database and wants to know how the record got there.
- Three records have a status column: sessions (the tablet ↔ bot handshake), jobs (the generation queue) and leads (requests from the landing).
- Every transition is a conditional SQL update (
… WHERE status = …) inbot/db/db.py. If the record is not in the expected status, nothing changes and the caller getsFalse. That is the whole concurrency model. - Session:
idle → linked → consented → template_chosen → photo_taken, orexpiredfrom any step before the photo.expiredcarries either aclosed_reason(the kiosk gave up) or anotice(the pack limit stopped it). - Job:
queued → processing → delivering → done | failed. A job gets 2 attempts; a 15-minute deadline from creation fails it in any active status; an admin can put afailedjob back toqueued. - Lead: starts as
new; a button sets any ofin_work,contacted,deal,refused,spamin any order. No button returns a lead tonew. Publication to the group and erasure of personal data are tracked by separate timestamp columns, not by the status.
Session
Section titled “Session”A session is created by the kiosk and links one tablet interaction to one Telegram user.
stateDiagram-v2
[*] --> idle : Start tapped on the kiosk
idle --> linked : guest opens the bot with the QR token
linked --> linked : same guest sends start again
linked --> consented : guest presses the consent button
consented --> consented : consent pressed again
consented --> linked : same guest sends start again
consented --> template_chosen : outfit tapped on the kiosk
template_chosen --> template_chosen : another outfit tapped
template_chosen --> photo_taken : photo accepted and job queued
idle --> expired : kiosk reset or pack limit
linked --> expired : kiosk reset or pack limit
consented --> expired : kiosk reset or pack limit
template_chosen --> expired : kiosk reset or pack limit
photo_taken --> [*]
expired --> [*]
photo_taken and expired are final. Session rows are never deleted automatically; only a guest reset in the admin
panel removes them.
Session transitions
Section titled “Session transitions”| From | To | Trigger | Performed by | Code |
|---|---|---|---|---|
| — | idle |
Guest taps Start; kiosk calls POST /session. Refused with 503 during maintenance |
sticker-api |
create_session in api.py → Database.create_session |
idle |
linked |
Guest sends /start <token> (opens the QR deep link). Sets telegram_user_id |
sticker-bot |
handlers/start.py → link_session_to_user |
linked, consented |
linked |
The same guest sends /start <token> again. Idempotent for linked; from consented it steps back, so consent must be pressed again. A different guest is refused — a session already taken is not hijacked |
sticker-bot |
link_session_to_user |
linked, consented |
consented |
Guest presses ✅ Согласен (“I agree”). users.consented_at is written before the session moves, because the kiosk opens the outfit screen as soon as it sees consented. Requires the same Telegram user |
sticker-bot |
handlers/consent.py → record_consent, set_session_consented |
consented, template_chosen |
template_chosen |
Guest taps an outfit card; kiosk calls POST /session/{token}/template. Any other status answers 409 — an outfit cannot be chosen without consent |
sticker-api |
choose_template → set_session_template |
template_chosen |
photo_taken |
Kiosk uploads the photo. In one BEGIN IMMEDIATE transaction: status check, pack-limit check, session update, job insert |
sticker-api |
upload_photo → take_photo_and_enqueue |
idle, linked, consented, template_chosen |
expired + closed_reason |
Kiosk calls POST /session/{token}/expire?reason=<code> |
sticker-api |
expire_session in api.py → Database.expire_session |
idle, linked, consented, template_chosen |
expired + notice |
Pack limit reached, detected at /start or at the outfit choice |
sticker-bot or sticker-api |
block_session, called from handlers/start.py and choose_template |
template_chosen |
expired + notice |
Pack limit reached, detected inside the photo transaction (the authoritative check) | sticker-api |
take_photo_and_enqueue |
| any | row deleted | Operator resets the guest in the admin panel | sticker-api |
admin_reset → reset_user |
Why a session is expired
Section titled “Why a session is expired”Two columns explain it, and at most one of them is set:
| Column | Values | Set when | What the guest sees |
|---|---|---|---|
closed_reason |
idle_timeout — idle prompt on the outfit screen ran out; qr_timeout — QR not scanned in 90 s; cancel — Cancel tapped; staff_reset — staff held the hidden reset zone; camera_error — camera could not be opened; kiosk_reset — any other return to the start screen, and the fallback for an unknown code |
The kiosk abandoned the session | A late /start or consent press is answered with Сессия закрыта, нажми «СТАРТ» на планшете. (“Session closed, press START on the tablet”) |
notice |
pack_ready — the guest already has a pack; pack_in_progress — the guest’s pack is still being generated |
The pack limit stopped the session | The kiosk shows a friendly screen; the bot sends the existing pack link or Твой набор уже готовится ⏳… (“Your pack is already being prepared”) |
Closing a session never consumes the guest’s pack allowance: the limit counts jobs, and no job exists before
photo_taken.
The kiosk-side timers that lead to each close reason are listed on the kiosk page.
A job is one generation request. The jobs table is the queue.
stateDiagram-v2
[*] --> queued : photo accepted
queued --> processing : worker slot claims the job
processing --> delivering : sticker set created
delivering --> done : set verified and link sent
processing --> queued : retry, shutdown or restart
processing --> failed : attempts used up or permanent error
queued --> failed : 15 minute deadline
processing --> failed : 15 minute deadline
delivering --> failed : 15 minute deadline
failed --> queued : admin retry
done --> [*]
failed --> [*]
queued, processing and delivering are the active statuses (ACTIVE_JOB_STATUSES). For the pack limit,
everything except failed counts (QUOTA_JOB_STATUSES).
Job transitions
Section titled “Job transitions”| From | To | Trigger | Performed by | Code |
|---|---|---|---|---|
| — | queued |
Photo accepted; row inserted together with the session move to photo_taken |
sticker-api |
take_photo_and_enqueue in db.py |
queued |
processing |
A free worker slot takes the oldest queued job with one UPDATE … RETURNING. Not done while maintenance is active |
sticker-bot worker |
_worker_loop in worker.py → claim_next_job |
processing |
delivering |
createNewStickerSet succeeded. Stores pack_name, pack_created_at, provider_used |
worker | _process_one_job → start_delivery |
delivering |
done |
Readiness check passed. One transaction: status, link_sent_at, clear appearance_json, upsert the pack into users. Only then the link is sent |
delivery verify task | _verify_loop in delivery.py → mark_link_sent |
processing |
queued |
Attempt failed with a retryable error and fewer than 2 attempts are used. attempts + 1, after a 10 s pause |
worker | _process_one_job (MAX_ATTEMPTS = 2, RETRY_DELAY_SEC = 10) |
processing |
failed |
Second failed attempt, or a permanent error on any attempt: missing photo or template (PermanentJobError), job cost ceiling (JobCostExceeded), provider out of funds (BillingExhausted). Clears appearance_json; alert to admins; message to the guest |
worker | _process_one_job |
processing |
queued |
The bot is stopping and the job did not finish within the 15 s grace period. Marker [requeued on shutdown]; the first two such returns do not count as an attempt |
worker | _requeue_on_shutdown |
processing |
queued |
Found in processing at start-up (the process died). attempts + 1, marker [requeued after restart] |
worker start-up | run_worker → requeue_stale_processing |
processing |
failed |
Found in processing at start-up and this would be the last attempt. Stops a job that kills the process from looping |
worker start-up | run_worker → fail_stale_over_limit |
queued, processing, delivering |
failed |
15 minutes since created_at with no link sent. Error text starts with STICKERSET_DELIVERY_FAILED; alert; guest gets the “we will help manually” message |
sweeper, or the verify task itself | fail_undelivered in delivery.py → fail_undelivered_job |
failed |
queued |
Operator presses Retry in the admin panel. Resets attempts, error, pack fields and created_at (so deadlines and the cost ceiling restart). Refused if the selfie file is gone or the pack limit would be exceeded |
sticker-api |
admin_retry in admin_panel.py → retry_failed_job |
| any | row deleted | Operator resets the guest | sticker-api |
reset_user |
Things that are not statuses
Section titled “Things that are not statuses”| Mechanism | Column | Rule |
|---|---|---|
| Delay notice | delay_notice_sent_at |
5 minutes after created_at with no link: the guest gets Дорабатываем детали твоего набора ✨ Скоро пришлю! (“Polishing the details of your pack, sending soon”) exactly once. claim_delay_notice is a conditional update, so two sweeps cannot both send it |
| Link sent | link_sent_at |
Set together with done. Every deadline query requires it to be empty, so a job cannot get both the link and a failure message |
| Deadline during generation | — | After generation, before creating the sticker set, the worker re-reads the job. If the sweeper already failed it, no set is created |
| Render mode | render_mode |
figure, composite or composite_fallback — how the stickers were made, not where the job is |
Why deadlines are computed from created_at and kept in the database: they survive a restart of the bot, and they
measure what the guest experiences — time since the “Generating…” message — regardless of whether the job is
waiting, generating or being verified.
A lead is one request from the landing form.
Status
Section titled “Status”stateDiagram-v2
[*] --> new : POST /leads stored the request
state "in_work, contacted, deal, refused or spam" as handled
new --> handled : admin presses a status button
handled --> handled : admin presses a different button
The box on the right stands for five separate status values; a button press can move a lead from any of them to any other.
| From | To | Trigger | Performed by | Code |
|---|---|---|---|---|
| — | new |
Landing calls POST /leads with a new request_id and no matching recent contact |
sticker-api |
create_lead in leads_api.py → lead_submit |
| any status | in_work, contacted, deal, refused or spam |
A user from LEADS_ADMIN_IDS presses a button on the card in the leads group (callback stickers_lead:<id>:<status>). One transaction updates the lead and appends a row to lead_status_history |
sticker-bot |
on_status in handlers/leads.py → lead_set_status |
| any status | same status | Same button pressed again: nothing is written, the admin sees Уже так (“Already so”) |
sticker-bot |
lead_set_status returns changed = False |
Rules that the diagram cannot show:
- No way back to
new.parse_callbackrejectsnewas a target, and the card has no such button. - Any-to-any among the five. There is no enforced order:
dealcan becomespamand back. - An erased lead is frozen. Its card has no buttons, and a stale button press is refused with
Данные заявки удалены — статус не меняется(“The request’s data is deleted — status does not change”). - Non-admins get
Нет доступа(“No access”). - Side effects of
spam: a spam lead is anonymised after 30 days instead of 365, and it is ignored when detecting repeat contacts.
Button labels and icons are defined in STATUSES in services/leads.py.
Publication, reminder and erasure
Section titled “Publication, reminder and erasure”These run in parallel with the status and are stored as timestamps.
stateDiagram-v2
[*] --> pending : lead row inserted
pending --> pending : send failed, retry with backoff
pending --> published : card posted to the group
published --> published : card re-posted or edited
pending --> erased : personal data erased before publication
published --> erased : forget command or retention period
erased --> [*]
| Aspect | Columns | Transition | Performed by | Code |
|---|---|---|---|---|
| Outbox: pending → published | published_at, chat_id, message_id |
A card is sent to LEADS_CHAT_ID; checked every 2 s |
sticker-bot leads loop |
publish_due in leads_bot.py → lead_published |
| Outbox: failed attempt | publish_attempts, next_publish_at, publish_error |
Next try after 5 s, 15 s, 60 s, 300 s, then every 900 s; Telegram flood control overrides the delay with its own value. Admin alert from the 5th failure | leads loop | publish_due → lead_publish_failed |
| Card moved | chat_id, message_id |
/stickers_lead <id> posts the card again; later pings reply to the new message |
sticker-bot |
show_card → lead_card_moved |
| Reminder | reminded_at |
Once per lead: still new 2 hours after creation, published, not erased; only Monday–Friday 09:00–21:00 Tashkent time |
leads loop, every 60 s | send_reminders → lead_reminded |
| Erasure on request | erased_at; name, telegram, phone, comment, ip_hash set to NULL |
/stickers_forget <id> by a lead admin; the card is rewritten to a “data deleted” stub |
sticker-bot |
forget in handlers/leads.py → erase_lead → lead_erase |
| Erasure by retention | same | spam: 30 days after the last status change; others: 365 days (creation time is used if the status never changed) |
leads loop, every 60 s | erase_expired, cutoffs in leads_schedule.py → leads_to_erase |
| Full deletion | rows removed from leads, lead_status_history, lead_repeats |
/stickers_forget <id> тест — allowed only for a lead whose name or comment contains тест or test |
sticker-bot |
forget(full=True) → lead_delete |
Erasure keeps the row: status, locale, UTM, dates and status history stay, so statistics do not change.
Repeat contacts
Section titled “Repeat contacts”When the same Telegram username (case-insensitive) or phone submits again within 24 hours of a lead that is neither
spam nor erased, no new lead is created. A lead_repeats row stores the time and only the fields that changed.
It has its own small outbox:
| State | Columns | Transition | Code |
|---|---|---|---|
| pending → notified | notified_at |
The lead’s card is rewritten with a “repeat contact” block and a short ping replies to it | notify_repeats → repeat_notified |
| failed attempt | notify_attempts, next_notify_at, notify_error |
Same backoff as publication; after 5 failures the repeat is marked notified with the error text and not retried | notify_repeats → repeat_notify_failed |
| absorbed | notified_at |
A repeat that arrived before the lead was published is already in the first version of the card; it is marked notified when the card is published | lead_published |
Maintenance pause
Section titled “Maintenance pause”Not a per-record status, but it gates two of the machines above.
| State | How it is entered | How it is left | Effect |
|---|---|---|---|
env |
MAINTENANCE_MODE=1 in bot/.env + restart |
Set it to 0 + restart | New sessions, outfit choice and photos are refused (503); workers do not claim jobs; guests get the maintenance text. Jobs already running finish. Queued jobs wait and their deadlines keep running. Lead handling is not affected |
auto |
A provider answered “no funds” during a job; the worker writes the maintenance key to settings |
/resume by a user in ADMIN_USER_IDS |
Same |
admin |
/pause |
/resume |
Same |
Code: services/maintenance.py,
handlers/maintenance.py.
Where to go next
Section titled “Where to go next”- Data model — the columns named on this page.
- Flow: guest session and Flow: pack delivery — the same transitions as sequences.
- Flow: lead status — what a lead admin sees for each transition.
- Reliability — why conditional updates are enough.
- Runbook: pack not delivered — diagnosing a job stuck in an active status.
Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.