Skip to content

Reminders, digest and scheduled anonymisation

Team members who receive the pings and the morning digest, and developers who change the schedule.

  • One loop in the bot process publishes cards every 2 seconds and runs housekeeping once a minute.
  • Reminder: a lead that stays new for more than 2 hours gets one ping, as a reply to its card. Pings go out only Monday to Friday, 09:00–21:00 Tashkent time. There is never a second ping.
  • Digest: every day at 10:00 Tashkent time, if there is anything to report. If the bot was down at 10:00 it catches up until 12:00; after that the day is skipped.
  • Anonymisation: spam leads lose their personal data 30 days after the last status change; all other leads after 365 days.
  • Each of the three steps is isolated: an error in one is logged and the others still run.
sequenceDiagram
    autonumber
    participant L as Leads loop in the bot
    participant D as SQLite
    participant T as Leads group
    loop every 2 s until the bot stops
        L->>D: publish due cards, notify due repeats
        opt 60 s since the last housekeeping
            L->>D: new leads older than 2 h without a ping
            opt weekday 09 to 21 Tashkent
                L->>T: Reminder as a reply to the card
                L->>D: reminded_at
            end
            L->>D: last digest day from settings
            opt hour is 10 or 11 Tashkent and no digest today
                L->>T: Digest, only if a number is not zero
                L->>D: store today as last digest day
            end
            L->>D: leads past their retention period
            L->>T: Rewrite each expired card to data erased
        end
    end

run_outbox in leads_bot.py owns the loop. HOUSEKEEP_SEC is 60. The first housekeeping pass runs right after the bot starts. Time rules are pure functions in services/leads_schedule.py, tested with frozen time in tests/test_leads_schedule.py and tests/test_leads_housekeeping.py.

All times use a fixed UTC+5 offset (TASHKENT in services/leads.py); there is no daylight-saving change.

Rule Value Code
Lead is eligible status new, not anonymised, card published, reminded_at empty, created at least 2 hours ago db.leads_for_reminder, REMIND_AFTER
Allowed time Monday–Friday, hour from 9 up to but not including 21, Tashkent is_work_time, WORK_DAYS, WORK_HOURS
Text ⏰ #stickers_<id> без статуса уже <N> ч — кто возьмёт? (“no status for N h — who takes it?”) reminder_text
Where Reply to the card (allow_sending_without_reply), or a plain message if the card is gone send_reminders
How many One per lead, ever db.lead_reminded

Steps:

  1. send_reminders returns at once outside working hours. Nothing is queued; eligibility is recomputed each minute.
  2. A lead whose 2 hours ran out at night is pinged at 09:00. One from Friday evening is pinged on Monday at 09:00.
  3. For each eligible lead the bot sends the ping and then sets reminded_at.
  4. reminded_at is set even if sending failed. The failure is logged as LEAD_REMIND #<id> не отправлен (“not sent”) and the ping is not retried. This is deliberate: one failed ping must not repeat every minute.
  5. The hours in the text are whole hours since created_at, so a lead from Friday evening shows a large number on Monday.

Changing a lead’s status before the ping is due removes it from the eligible set.

Rule Value Code
Send window Tashkent hour is 10 or 11 (DIGEST_AT 10, DIGEST_LATE_UNTIL 12) digest_day
Once per day The last sent day is stored in settings under stickers_digest_last send_digest
Period covered 10:00 yesterday to 10:00 today, Tashkent digest_window
Days Every day, weekends included —
Empty digest Not sent, but the day is still marked as done digest_text returns None

Content:

☀️ Стикеры за сутки (<dd.mm 10:00> — <dd.mm 10:00>)
Новых заявок: <n>
Без статуса: <n> — #stickers_<id>, #stickers_<id> и ещё <k>
Паков сгенерировано: <n>

(“Stickers in the last 24 h / New leads / Without a status / Packs generated”.)

  • New leads are counted by created_at inside the window.
  • Without a status is the current number of new, non-anonymised leads, whatever their age. Up to 10 hashtags are listed, oldest first.
  • Packs generated counts jobs by pack_created_at inside the window, excluding jobs of users listed in ADMIN_USER_IDS (their test packs).

Failure behaviour: if sending the digest raises an error, the day is not marked, the error is logged as LEADS: ошибка шага «дайджест» (“step error: digest”), and the step runs again a minute later. Retries stop at 12:00. A bot that starts at 11:30 still sends the digest; one that starts at 12:05 does not.

Lead status Personal data is removed after Clock starts at
spam 30 days (SPAM_KEEP) Last status change (status_at), or creation if the status never changed
Any status 365 days (LEAD_KEEP) Same

erase_expired selects the leads with db.leads_to_erase and calls erase_lead for each:

  1. db.lead_erase sets name, telegram, phone, comment and ip_hash to NULL and stamps erased_at. The same personal fields are cleared in lead_repeats.
  2. Status, language, UTM values, event date, guest count, timestamps and the status history stay, so statistics do not change.
  3. The card in the group is rewritten to 🗑 Заявка #stickers_<id> — данные удалены (<date>) (“data erased”). If the card cannot be edited the failure is only logged; the database is already clean.
  4. Log line: LEAD_ERASED #<id> по сроку (статус <status>) (“by retention period”).

A status change restarts the clock, because the rule uses the last change. Erasure on request is described in Data erasure.

  • housekeeping wraps each step (пинги, дайджест, сроки хранения — pings, digest, retention) in its own try. An exception is logged and the next step runs.
  • run_outbox wraps the whole pass. The loop must never end: in main.serve the end of any task stops the whole bot, including the guest flow.
  • The loop does not run when the module is disabled (LEADS_ENABLED=0 or no LEADS_CHAT_ID).

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