Telegram bot for discipline in Python: aiogram 3, APScheduler and deployment on VDS

I decided to write this not for promotion, but for constructive feedback, to continue working on the project, as I am currently deciding what to do next and what it could grow into.

Let me warn you right away: I made this with AI, so if that triggers someone, feel free to skip the article.
Yes, another bot, but the topic is close to me and I wanted to create something of my own.

What we have as is - a pet project about how I built and launched a Telegram bot from scratch that reminds you of the focus of the day, tracks completions, gives achievements, gently motivates, works across time zones, and runs on a VDS under systemd.

Goal: one focus per day without unnecessary services

I wanted to solve my personal problem with discipline: not to get lost in routine and not to neglect small but important tasks like charging, pet projects, or studying. Without a new app, registrations, and task managers — everything is inside Telegram.

This gave birth to a simple requirement for the product:

  • one active focus per user;

  • two touches a day: remind in the morning, ask in the evening how the day went;

  • light gaming shell: streaks, achievements, goal level;

  • minimum friction: start by command, everything else through the bot.

The result is the bot @focuscompanion_bot, which I currently use myself and am also refining based on feedback.

Functionality from the user's perspective

Work scenario:

  • At startup, the bot conducts onboarding: name, time of morning and evening notifications, time zone, domain (work/personal/health), and the formulation of the focus.

  • Every morning, a message arrives with a reminder about the focus.

  • In the evening, the bot asks to mark the result for the goal:
    ✅ done, 🌓 partially, ❌ not done.

  • By /week, it shows a snapshot of the goal: for short-term goals — a "weekly" view with a 7-day strip, for long-term ones — aggregated statistics without a 7-day tie.

  • By /streak, it shows the current and best series.

  • /achievements — a list of achievements obtained across all goals.

  • /time, /focus, /settings, /feedback — manage schedule, goal, minimal mode (just got a hint for this) and feedback, so users can immediately report what to fix.

I keep the project intentionally simple, but with a proper structure.

Stack:

  • Python 3.12

  • aiogram 3 (asynchronous Telegram framework)

  • SQLite as storage (for now)

  • APScheduler for periodic tasks

  • systemd service for launching on VDS Timeweb

Project files:

  • bot.py — main logic, command handlers, FSM onboarding and settings, APScheduler scheduler.

  • db.py + models.sql — layer for working with SQLite and database schema.

  • config.py — reading .env, bot token ultimately stored in variables later, dictionaries for achievements and thresholds.

  • discipline.db — the database itself.

  • requirements.txt — dependencies.

  • images/ — including welcome screen / startup image.

The bot is fully on async/await: aiogram 3, FSM out of the box, asynchronous APScheduler.

Read also:

Data Model

Minimal set of entities:

  • users:

    • id, tg_id

    • name

    • morning_time, checkin_time (strings HH:MM)

    • start_date

    • timezone (string like Europe/Moscow)

    • service fields: last_morning_sent, last_checkin_reminder_sent, minimal_mode.

focuses:

  • id

  • user_id

  • title (goal formulation)

  • domain (work/personal/health)

  • is_active, started_at, ended_at

  • best_streak (best streak for this goal).

checkins:

  • id

  • user_id, focus_id

  • date (YYYY‑MM‑DD)

  • status (done, partial, fail).

achievements:

  • id

  • user_id, focus_id

  • level (achievement level number)

  • days (length of the streak at the time of achievement)

  • created_at.

This set allows:

  • to quickly calculate the current and best streak for the active focus;

  • to build simple weekly and long-term statistics;

  • to grant achievements for reaching thresholds ACHIEVEMENT_THRESHOLDS

Onboarding and FSM

Onboarding is done via FSM aiogram: each state is responsible for one step and stores intermediate data.

Step chain:

  1. /start → check if the user is in the database.

  2. If new:

    • name;

    • morning time (morning_time);

    • evening time (checkin_time);

    • time zone (selection from a fixed list of Russian time zones);

    • domain (work/personal/health);

    • formulation of the focus.

In the FSM data, all fields are collected, and at the end:

  • a record is created in focuses (active focus);

  • the user fields in users are updated;

  • the user is brought to the main scenario with check-in buttons.

Time Zones and Schedule

One of the most non-trivial parts for the pet project is time zones and schedule.

Approach:

  • During onboarding, the user manually selects a time zone from a list of pre-set options (for example, Europe/Moscow, Asia/Yekaterinburg, etc.).

  • A string with the name of the time zone is saved in the database in users.timezone.

  • APScheduler runs in UTC and executes two jobs once a minute:

    Read also:
    • send_morning_focus

    • send_daily_checkins.​​

Job operation scheme:

  1. We take all users whose last_morning_sent (or last_checkin_reminder_sent) is not equal to today's date.

  2. For each:

    • convert now_utc to local time using pytz.timezone(user.timezone);

    • compare local time HH:MM with saved morning_time / checkin_time.

  3. If "now" matches the user's time — send a message, mark last_*_sent = today.​​

from datetime import datetime
from pytz import timezone as pytz_timezone

async def send_daily_checkins():
    now_utc = datetime.now(pytz_timezone("UTC"))
    today_str_utc = now_utc.strftime("%Y-%m-%d")

    # Get all users who haven't received the evening message today
    users = await get_users_for_evening(today_str_utc)
    if not users:
        return

    ids_to_mark: list[int] = []

    for user in users:
        tg_id = user["tg_id"]
        user_id = user["id"]
        tz_name = user["timezone"] or "Europe/Moscow"
        user_tz = pytz_timezone(tz_name)

        now_user = now_utc.astimezone(user_tz)
        today_str = now_user.strftime("%Y-%m-%d")

        checkin_time_local = (user["checkin_time"] or "").replace(":", "")
        if not checkin_time_local:
            continue

        current_time_local = now_user.strftime("%H%M")
        if current_time_local != checkin_time_local:
            continue

        status = await get_today_checkin_status(user_id, today_str)

        if status:
            # there is already a check-in — just send a brief summary
            text = get_summary_text(status, user.get("name"))
            await bot.send_message(tg_id, text)
        else:
            # no check-in — send buttons done/partial/fail
            text = "How was your day regarding your focus?"
            await bot.send_message(tg_id, text, reply_markup=checkin_kb)

        ids_to_mark.append(user_id)

    if ids_to_mark:
        await mark_evening_sent(ids_to_mark, today_str_utc)

This solution:

  • does not create separate cron tasks for each;

  • works the same for different regions of Russia;

  • remains simple for debugging.

Statistics and Achievements

A daily check-in is a record in checkins with status done/partial/fail.

Further on top of this:

  • Streak: I count the number of consecutive days with done or partial, starting from the last date with a check-in backward. A partial day does not break the streak.​

  • Weekly statistics:

    • for short goals (≤7 days) I show:

      • a 7-day status stream (emoji);

      • a progress bar of 10 blocks that uses the formula done + partial * 0.5;

      • a textual summary of the week.

for long goals (>7 days) — only aggregates for all days:

  • how many days total for the goal;

  • how many of them are done / partial / fail;

  • without tying to “week” and “7 days”.

The achievement system:

  • there is an array of thresholds ACHIEVEMENT_THRESHOLDS = [3, 7, 14, 30, ...];

  • upon each streak update, the level is calculated get_achievement_level(streak);

  • if a new level is reached and it is higher than the saved one for this focus_id — a record is added to achievements.

Deploy: GitHub → Timeweb VDS → systemd

I have organized development and deployment this way.

Locally:

  • Work in the GitHub repository (private).

  • Cycle: code edits → local bot launch → onboarding and main command checks → git commitgit push.​

On the server (VDS Timeweb):

  • In the project directory:

    • git pull origin main

    • pip install -r requirements.txt (if necessary)

    • schema migrations via models.sql (minimal DDL).

The bot runs as discipline-bot.service under systemd:

  • ExecStart=/root/discipline_bot/venv/bin/python /root/discipline_bot/bot.py

  • restart via systemctl restart discipline-bot.

For convenience, there is a script deploy.sh that does a pull, updates dependencies, and restarts the service, which has greatly simplified commits lately, of which there have been many.​
Previously, I used a specialized "bot hosting," but encountered issues with caching, race conditions, and debugging complexity, so I moved to a regular VDS with a bare terminal — this turned out to be easier and more predictable, and seems more professional.

What's next

The bot is currently live in production, sending notifications daily. From the next steps, I see:​

  • migration from SQLite to PostgreSQL (for more complex analytics and parallel load);

  • detailed statistics by weeks and months (retention, "percentage of ideal weeks," distribution by status);

  • enhancement of the achievement system and difficulty levels;

  • data export and possibly a lightweight web panel for viewing progress from a desktop;

  • full i18n and English localization, so the bot can confidently be taken to Reddit and English-speaking platforms.

Link to the bot: https://t.me/focuscompanion_bot.​

Question to tekkix readers: what would you definitely add to such a "discipline tracker bot" in terms of metrics and UX, or perhaps in the process? And how can it be promoted?

Thank you all in advance! I would appreciate any feedback!

Comments