Skip to content

State machines

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 = …) in bot/db/db.py. If the record is not in the expected status, nothing changes and the caller gets False. That is the whole concurrency model.
  • Session: idle → linked → consented → template_chosen → photo_taken, or expired from any step before the photo. expired carries either a closed_reason (the kiosk gave up) or a notice (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 a failed job back to queued.
  • Lead: starts as new; a button sets any of in_work, contacted, deal, refused, spam in any order. No button returns a lead to new. Publication to the group and erasure of personal data are tracked by separate timestamp columns, not by the status.

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.

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

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).

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
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.

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_callback rejects new as a target, and the card has no such button.
  • Any-to-any among the five. There is no enforced order: deal can become spam and 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.

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.

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

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.


Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.