Skip to content

Component: sticker-bot

A developer who touches bot handlers or background loops, and an operator who needs to understand what a restart of sticker-bot does to guests who are in the middle of the flow.

  • sticker-bot is one Python process (bot/main.py) running up to five asyncio tasks side by side: polling, worker, sweeper, cleanup and — when the leads module is enabled — leads.
  • If any of these tasks ends or crashes, the whole process shuts down in order and systemd restarts it. That is why every loop catches its own errors.
  • Exactly one instance may run: an OS-level file lock (data/main.lock) refuses a second start. Two instances would mean two workers with possibly different configuration and a Telegram polling conflict.
  • Guest handlers work only in private chats; lead handlers work only in the leads group. A maintenance middleware in front of all routers answers guests while generation is paused.
  • A restart is safe: jobs in processing go back to the queue, jobs in delivering resume their check, deadlines are computed from timestamps in the database.
flowchart TB
    tg["Telegram Bot API"]
    ai["AI providers"]

    subgraph proc["sticker-bot process - main.py"]
        lock["instance_lock.py - one process only"]
        serve["main.serve and shutdown - task supervisor"]

        subgraph polling["Task: polling"]
            mw["Maintenance middleware"]
            r1["Router: maintenance - /pause, /resume"]
            r2["Router: start - /start"]
            r3["Router: consent - consent button"]
            r4["Router: admin - /stats, /admin_stats"]
            r5["Router: leads - buttons and /stickers commands"]
            r6["Router: leads_fallback - orphan buttons"]
        end

        worker["Task: worker - N slots, worker.py"]
        deliver["delivery.py - one verify task per delivering job"]
        sweeper["Task: sweeper - deadlines every 20 s"]
        cleanup["Task: cleanup - TTL every hour"]
        leads["Task: leads - outbox every 2 s, housekeeping every 60 s"]
        alerts["services/alerts.py - admin alerts with cooldown"]
    end

    db[("SQLite app.db")]
    files[("sessions and debug_sheets")]

    tg -- "updates via getUpdates" --> mw
    mw --> r1 --> r2 --> r3 --> r4 --> r5 --> r6
    serve --> worker
    serve --> sweeper
    serve --> cleanup
    serve --> leads
    worker --> ai
    worker --> deliver
    sweeper --> deliver
    worker --> db
    deliver --> db
    sweeper --> db
    leads --> db
    cleanup --> files
    cleanup --> db
    worker --> files
    worker --> alerts
    sweeper --> alerts
    leads --> alerts
    alerts --> tg
    deliver --> tg
    leads --> tg

main() in bot/main.py:

  1. config.validate("bot") — errors stop the process with exit code 1. In APP_ENV=prod a missing ADMIN_CHAT_ID is an error, because nobody would see failed jobs.
  2. instance_lock.acquire(data/main.lock) — fails with STARTUP отменён (“start-up cancelled”) if another main.py holds the lock.
  3. Install signal handlers for SIGINT and SIGTERM (the process handles signals itself; aiogram’s own handling is switched off).
  4. Log STARTUP lines: render mode and where it was read from, likeness mode, consistency mode, sheet layout.
  5. db.init() — schema migration, WAL, CREATE TABLE IF NOT EXISTS.
  6. build_dispatcher(db).
  7. If leads are enabled: check that the bot is a member/admin of the leads group and set the group command menu. Both only log on failure.
  8. serve(...) — start all tasks and wait for a stop signal or for any task to end.

Inside the worker task, before the slots start, two database calls repair what a crash left behind — see Restart behaviour.

build_dispatcher registers things in this exact order. Order matters because aiogram offers an update to routers one after another until a handler matches.

# Element Scope filter Handles Source
0 MaintenanceMiddleware (outer, on messages and callback queries) Private chats only; group events pass through untouched; admin commands other than /start pass through While maintenance is active: answers Сейчас идёт техобслуживание, загляни чуть позже 🛠 (“Maintenance in progress, come back a bit later”) and stops the update bot/handlers/maintenance.py
1 Router maintenance Private chat and user in ADMIN_USER_IDS /pause, /resume bot/handlers/maintenance.py
2 Router start Private chat /start <session_token> (deep link): save profile → pack-limit check → link session → consent message with the ✅ Согласен (“I agree”) button. Bare /start: a hint to scan the QR bot/handlers/start.py
3 Router consent Callback from a private chat, data consent:<token> Records consent (users.consented_at), then moves the session to consented bot/handlers/consent.py
4 Router admin Private chat and user in ADMIN_USER_IDS /admin_stats, /stats. For everyone else the handler does not match — the bot stays silent bot/handlers/admin.py
5 Router leads — only when LEADS_ENABLED=1 and LEADS_CHAT_ID is set Chat equals LEADS_CHAT_ID; commands additionally require LEADS_ADMIN_IDS Callbacks stickers_lead:<id>:<status> and stickers_pick:<action>:<id>; commands /stickers_stats, /stickers_new, /stickers_lead, /stickers_export, /stickers_forget bot/handlers/leads.py
6 Router leads_fallback — always Any chat Lead buttons nobody handled (module off, card from another chat): answers with a pop-up so the button does not spin forever bot/handlers/leads.py

Why the scope filters: the leads group contains other bots and other people. Guest handlers must stay silent there, and /stats in a group would be visible to every member. Why the middleware lets groups through: lead handling does not depend on generation, so status buttons keep working while generation is paused.

There is no outfit selection in the bot — outfits are chosen on the tablet.

Task Loop What one iteration does Source
polling aiogram long polling Receives updates and dispatches them through the table above bot/main.py
worker WORKER_SLOTS parallel slots (default 2); each slot loops forever If maintenance is active, wait 1 s. Otherwise claim_next_job(); if a job came back, run process_one_job; if not, wait 1 s bot/worker.py
sweeper every 20 s (1) fail active jobs older than 15 min without a sent link; (2) send the one-time 5-minute delay notice; (3) restart the verify task for any delivering job that has none bot/delivery.py
cleanup every 3600 s Delete files older than the TTL in sessions/ and debug_sheets/; clear jobs.appearance_json for finished jobs and for active jobs older than 15 min bot/main.py, bot/services/storage.py
leads every 2 s; housekeeping every 60 s Publish unpublished leads to the group; notify about repeat contacts; once a minute: reminders, daily digest, retention erasure bot/leads_bot.py, bot/services/leads_schedule.py

A slot does the heavy work inline: appearance description, sheets, cutting, checks, export, sticker set creation. The details are on the generation pipeline page. Two things matter at this level:

  • After the sticker set is created the job moves to delivering and the slot is free at once. Verifying the set and sending the link runs as a separate asyncio task (delivery.spawn), one per job.
  • Every AI call is written to api_usage immediately, not at the end of the job, so a killed process does not lose cost records and a retry sees what earlier attempts already spent.

verify_and_deliver polls every 2 s for the first 90 s after the set was created, then every 10 s. A set is ready when the Bot API returns exactly 12 stickers and the public page t.me/addstickers/<name> confirms the set twice in a row. Only then mark_link_sent flips delivering → done and the link goes out. The flip is a conditional update, so the link and the “we will help you manually” message can never both be sent for one job.

Step Frequency Rule
publish_due 2 s Send a card for each lead with published_at empty whose next_publish_at has come. On failure: retry after 5 s, 15 s, 60 s, 300 s, then every 900 s; alert after 5 failures (at most one alert per 30 min). On Telegram flood control: wait as long as Telegram asks and stop this pass
notify_repeats 2 s For a repeat contact on a published lead: rewrite the card and reply to it with a short ping. Gives up after 5 failures
send_reminders 60 s One ping per lead that stays new for more than 2 hours — only Monday–Friday, 09:00–21:00 Tashkent time (UTC+5)
send_digest 60 s Once a day at 10:00 Tashkent time (catch-up until 12:00). The sent day is stored in settings.stickers_digest_last, so a restart does not duplicate it
erase_expired 60 s Anonymise spam leads 30 days and all other leads 365 days after their last status change

bot/instance_lock.py takes an exclusive, non-blocking lock on DATA_DIR/main.lock (fcntl.flock on Linux, msvcrt.locking on Windows) and writes the PID to main.pid. The operating system releases the lock when the process exits, including on a crash or kill. The systemd unit is the second guard: one unit, one process.

The reason recorded in the module: two processes on one queue each render with their own configuration loaded at start, so an old process could pick up a job with settings that were already changed in .env.

Triggered by SIGINT / SIGTERM (systemd sends SIGINT, see KillSignal in the unit) or by any task ending.

sequenceDiagram
    participant S as systemd
    participant M as main.shutdown
    participant P as polling
    participant W as worker slots
    participant L as sweeper, cleanup, leads
    participant D as delivery verify tasks

    S->>M: SIGINT
    M->>P: stop polling, wait up to 5 s
    Note over W: stop flag is set, no new job is claimed
    M->>W: wait up to 15 s for running jobs
    M->>W: cancel what is still running
    W-->>M: job returned to queued, attempt not counted
    M->>L: cancel
    M->>D: cancel all, jobs stay in delivering
    M->>M: close bot session, exit
Step Constant Why
1. Stop polling POLLING_STOP_TIMEOUT_SEC = 5 No new guests are accepted
2. Let running jobs finish SHUTDOWN_GRACE_SEC = 15 A job close to the end still delivers
3. Cancel the rest — process_one_job catches the cancellation and puts the job back to queued with the marker [requeued on shutdown]. The first two such returns per job do not count as an attempt (SHUTDOWN_FREE_REQUEUES = 2); later ones do, so restarts in a loop cannot repeat paid generation forever
4. Cancel sweeper, cleanup, leads — Their state is in the database
5. Cancel verify tasks — The job stays in delivering; the sweeper resumes it after restart
6. Close the bot session last — Finishing jobs still need to message the guest and the admin chat
7. Exit EXECUTOR_EXIT_WAIT_SEC = 3, then os._exit The thread pool is waited for 3 s at most. AI requests run in daemon threads and do not hold the exit

TimeoutStopSec=30 in the unit is the sum of these with a margin; beyond it systemd sends SIGKILL.

Exit code is 0 for a signal-initiated stop and 1 when a task crashed.

State found at start What happens Code
Job in processing whose next attempt would reach MAX_ATTEMPTS (2) failed; the guest gets the failure message, the admin chat gets an alert. This stops a job that kills the process (for example by running out of memory) from looping fail_stale_over_limit in db.py, called from run_worker
Any other job in processing Back to queued, attempt counted, marker [requeued after restart] requeue_stale_processing
Job in delivering The sweeper notices there is no verify task and starts one sweep_once in delivery.py
Job older than 15 min with no link failed on the first sweep — and it does not also get the 5-minute notice, because the 15-minute pass runs first sweep_once
Unpublished leads Published by the next outbox pass publish_due

bot/services/alerts.py sends a message to ADMIN_CHAT_ID, prefixed with ⚠️ (or ℹ️ for informational notices) and the EVENT_NAME. It never raises. An alert with a key is sent at most once per cooldown (600 s by default); suppressed repeats are counted and mentioned in the next message. Without ADMIN_CHAT_ID alerts only reach the log.


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