Skip to content

Guest session flow

Developers and on-site operators who need to know exactly what happens between a guest pressing Start on the tablet and the tablet becoming free for the next guest.

  • The kiosk creates a session only when a guest presses Start (POST /session). The QR code carries a Telegram deep link with the session token.
  • The bot checks the pack limit first, links the session to the Telegram account, and asks for consent. Nothing continues without the consent button.
  • The outfit is chosen on the tablet, not in the bot. The API accepts the choice only when the session is consented.
  • The tablet takes a 3-frame burst, uploads the sharpest frame, and is free again about 5 seconds later. It never waits for generation.
  • Session statuses: idle → linked → consented → template_chosen → photo_taken, plus expired from any state before photo_taken.
  • Maintenance mode answers 503 with code maintenance. An exhausted pack limit answers 409 with code pack_limit. The kiosk shows a friendly screen for both.
sequenceDiagram
    autonumber
    participant G as Guest
    participant K as Kiosk
    participant A as API
    participant D as SQLite
    participant B as Bot
    G->>K: Press Start
    K->>A: POST /session
    A->>D: insert session, status idle
    A-->>K: session_token and deep_link
    K->>A: GET /session/TOKEN/qr.svg
    A-->>K: QR image
    loop every 1 s
        K->>A: GET /session/TOKEN
        A-->>K: status, template_id, notice
    end
    G->>B: Scan QR, bot receives /start TOKEN
    B->>D: save profile, check pack limit
    alt limit exhausted
        B->>D: session expired with notice
        B-->>G: Link to existing pack or in-progress text
        K-->>G: Notice screen for 7 s, then idle
    else limit not reached
        B->>D: idle to linked
        B-->>G: Consent message with button
        G->>B: Tap consent button
        B->>D: users.consented_at, linked to consented
        B-->>G: Go back to the tablet
        K-->>G: Outfit cards, camera warms up
        G->>K: Tap an outfit card
        K->>A: POST /session/TOKEN/template
        A->>D: consented to template_chosen
        K-->>G: Look at the camera, countdown 3-2-1
        K->>A: POST /session/TOKEN/photo
        A->>D: photo_taken plus job queued, one transaction
        A-->>K: job_id, status queued
        A->>B: background task sends Generating text
        K-->>G: Done screen for 5 s, then idle
    end
  1. Kiosk boots. kiosk.js applies texts, loads outfit cards (GET /templates, retried with a 1 s → 10 s backoff), loads idle timeouts (GET /kiosk-config), loads optional footer logos (GET /branding) and asks for camera permission once, then switches the camera off again. The camera stays off until a guest has consented.
  2. Start. startSession() ignores repeated taps (starting flag, 1 s cooldown) and calls POST /session. api.py create_session generates the token with secrets.token_urlsafe(12) and inserts a row with status idle. The response contains session_token and deep_link (https://t.me/<bot_username>?start=<token>).
  3. QR. The kiosk loads GET /session/{token}/qr.svg. The server renders the QR itself (library segno, error correction level M, 4-module quiet zone, Cache-Control: no-store). The QR is shown only when the image has loaded; the 90 s timer starts at that moment. Polling of GET /session/{token} starts, once per second.
  4. Bot /start <token>. The handler in handlers/start.py works only in private chats. In this order it:
    1. saves first name, last name and username (db.save_user_profile);
    2. checks the pack limit (db.pack_quota). If exhausted, it closes the kiosk session with a notice (db.block_session) and replies with the existing pack link (У тебя уже есть набор: … — “You already have a pack”) or Твой набор уже готовится ⏳ … (“Your pack is already being prepared”);
    3. rejects an unknown token (Не нашёл сессию с планшета… — “Could not find the tablet session”);
    4. rejects a session the tablet already closed (Сессия закрыта, нажми «СТАРТ» на планшете. — “Session closed, press START on the tablet”);
    5. links the session (db.link_session_to_user): idle → linked. The same guest may repeat /start; a session already linked to another account is not taken over (Этот QR-код уже устарел… — “This QR code is stale”);
    6. sends the consent message with one inline button, callback data consent:<token>.
  5. Consent. handlers/consent.py writes users.consented_at first, then moves the session linked → consented (only for the same Telegram account). The message is edited to Спасибо! Теперь вернись к планшету… (“Thanks! Now go back to the tablet”).
  6. Outfit on the tablet. On consented the kiosk renders the cards, opens the camera to warm it up and starts the idle timer (idle.js). A tap calls POST /session/{token}/template. choose_template validates the id against the catalogue, repeats the pack-limit check, then runs db.set_session_template (consented | template_chosen → template_chosen).
  7. Photo. takePhotoFlow() shows “look at the camera” for 2 s, counts 3-2-1, grabs 3 frames 150 ms apart, scores sharpness (variance of a Laplacian on a 320 px grey copy) and encodes the best frame as JPEG, quality 0.92.
  8. Upload. POST /session/{token}/photo (multipart field file). upload_photo:
    • reads at most 15 MB (MAX_UPLOAD_BYTES);
    • _normalize_photo: must decode as an image, is rotated by EXIF, converted to RGB, must be at least 256 px on the short side, is downscaled to 2048 px and re-encoded as JPEG quality 92. Re-encoding drops EXIF metadata;
    • saves the file as data/sessions/<token>/selfie_<random>.jpg;
    • calls db.take_photo_and_enqueue, one BEGIN IMMEDIATE transaction: session must be template_chosen, the pack limit is checked again, the session becomes photo_taken and a job row is inserted with status queued. A losing duplicate request deletes its own file.
  9. Kiosk is free. The API answers {"job_id": …, "status": "queued"} and only then sends Генерирую твой персональный набор стикеров… (“Generating your personal sticker pack”) as a background task. The kiosk shows the done screen for 5 s and returns to idle. What happens to the job is described in Generation.

Source: CFG in kiosk.js, idle.js, and GET /kiosk-config.

Setting Value Effect
pollMs 1 s Status polling interval
startCooldownMs 1 s Minimum gap between two session creations
qrTimeoutMs 90 s QR shown without a scan → POST …/expire?reason=qr_timeout, back to idle
linkedTimeoutMs 3 min Guest scanned but did not consent → reset (reason kiosk_reset)
KIOSK_IDLE_SEC 90 s (minimum 5) No touch on the outfit screen → prompt Ты здесь? Нажми, чтобы продолжить (“Are you here?”)
KIOSK_IDLE_PROMPT_SEC 15 s (minimum 3) Prompt countdown; at zero → reason=idle_timeout, back to idle
lookAtCameraMs / countdownFrom 2 s / 3 Before the burst
burstFrames / burstGapMs 3 / 150 ms Burst shape
uploadRetries 3 Upload attempts, pauses 1 s, 2 s, 3 s. A 4xx other than 408/429 is not retried
captureAttempts 2 Full “shoot + upload” attempts per session
doneScreenMs 5 s Done screen
noticeScreenMs 7 s Pack-limit notice screen
errorScreenMs 4 s Error screen
staffHoldMs 3 s Hold the hidden staff zone to reset (reason=staff_reset)

For station checks the kiosk accepts ?qr_timeout_sec=, ?idle_sec= and ?idle_prompt_sec= overrides, and ?camera=always keeps the camera open permanently.

POST /session/{token}/expire?reason=<code> moves a session to expired from idle, linked, consented or template_chosen and stores the code in sessions.closed_reason. An unknown code is stored as kiosk_reset.

Code Sent when
qr_timeout 90 s without a scan
idle_timeout Idle prompt ran out on the outfit screen
cancel Guest pressed Cancel under the QR
staff_reset Staff held the reset zone for 3 s
camera_error Camera could not be opened
kiosk_reset Default: consent timeout, lost session, any other return to idle

An expired session costs the guest nothing: no job exists, so the pack limit is untouched. After the photo the same call is a no-op, because photo_taken is not in the list of expirable statuses.

Situation HTTP answer What the guest sees
Maintenance mode on POST /session, …/template, …/photo 503, {"detail": {"code": "maintenance"}} Home screen with Сейчас идёт техобслуживание, загляни чуть позже (“Maintenance, come back later”)
Pack limit on …/template or …/photo 409, {"detail": {"code": "pack_limit", "state", "notice"}} Notice screen; the bot sends the existing link or the in-progress text
Outfit chosen before consent, or session in another state 409 with the current status Hint to try again; polling moves the kiosk on
Unknown or hidden outfit id 422 Hint to try again
Photo for a session that is not template_chosen (double tap, second tablet) 409 “Session finished” screen, then idle
File over 15 MB 413 Counts as a failed capture attempt
Not an image, or short side under 256 px 422 “Photo did not work”, automatic retake (once)
Unknown session token 404 Kiosk returns to idle
Network error during polling — “No connection” badge; polling continues
Camera cannot be opened — “Camera unavailable” screen for 4 s, then idle with reason=camera_error

Caddy caps request bodies at 20 MB, above the API’s own 15 MB limit, so an oversized upload still reaches the API and gets a clean 413 (deploy/caddy/stickers.caddy).

db._quota in db/db.py counts the guest’s jobs in queued, processing, delivering and done. Failed jobs do not count. The allowance is 1 + users.extra_packs_allowed; ALLOW_MULTIPLE_PACKS=1 removes the limit (tests and demos only). The limit is checked three times: in the bot on /start, in the API on outfit choice, and atomically when the job is created.


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