Component: sticker-bot
Who is this for
Section titled “Who is this for”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-botis 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
processinggo back to the queue, jobs indeliveringresume their check, deadlines are computed from timestamps in the database.
Component diagram
Section titled “Component diagram”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
Start-up sequence
Section titled “Start-up sequence”main() in bot/main.py:
config.validate("bot")— errors stop the process with exit code 1. InAPP_ENV=proda missingADMIN_CHAT_IDis an error, because nobody would see failed jobs.instance_lock.acquire(data/main.lock)— fails withSTARTUP отменён(“start-up cancelled”) if anothermain.pyholds the lock.- Install signal handlers for
SIGINTandSIGTERM(the process handles signals itself; aiogram’s own handling is switched off). - Log
STARTUPlines: render mode and where it was read from, likeness mode, consistency mode, sheet layout. db.init()— schema migration, WAL,CREATE TABLE IF NOT EXISTS.build_dispatcher(db).- 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.
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.
Dispatcher: middleware and router order
Section titled “Dispatcher: middleware and router order”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.
The five tasks
Section titled “The five tasks”| 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 |
Worker slot and the job it runs
Section titled “Worker slot and the job it runs”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
deliveringand 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_usageimmediately, 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.
Delivery: verify, then send exactly once
Section titled “Delivery: verify, then send exactly once”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.
Leads loop
Section titled “Leads loop”| 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 |
Instance lock
Section titled “Instance lock”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.
Shutdown sequence
Section titled “Shutdown sequence”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.
Restart behaviour
Section titled “Restart behaviour”| 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 |
Alerts
Section titled “Alerts”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.
Where to go next
Section titled “Where to go next”- Component: generation pipeline — what a worker slot does with a job.
- State machines — every status change named on this page, with code locations.
- Bot commands — the commands as a user sees them.
- Reliability — retries and exactly-once guarantees in one place.
- Runbook: bot silent — when the bot stops answering.
Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.