Build a Telegram Task Assistant

Build a Python Telegram bot that manages tasks with commands and inline buttons.

Introduction

30 Second Summary

Task lists often end up somewhere you forget to check. A conversation you already use every day can keep the next job within reach.

In this project, you will build a personal task assistant for Telegram with Python. You will turn chat commands into a separate task list for each user.

What You'll Build

When your bot is running, Telegram shows each new task with a Done button that removes it from your open list when tapped.

By the end of this project, you'll have:

  • A guided welcome that explains the available commands whenever you send /start.
  • A personal task list where /add Prepare model evaluation stores a task that /list displays only for the same Telegram user.
  • Interactive Done buttons that complete selected tasks while leaving the remaining tasks open.
  • Secret Mission: Add a Delete button beside Done so users can remove a task without completing it.

Are there any prerequisites?

You need a Windows PC with Python 3.10+ plus a Telegram account.

Step 1 covers Visual Studio Code plus BotFather plus the isolated environment your bot needs.

Before We Start

This is your moment to commit to building a local personal task assistant with Telegram. Defining who will use it gives every chatbot concept a visible purpose.

Set Up Your Windows Bot Workspace

Your bot now has a clear purpose. It needs a local workspace before it can receive an update from Telegram.

In this step, you will prepare Visual Studio Code as the project workspace. You will create the bot identity before installing its library in an isolated Python environment.

In this step, get ready to:
  • Install Visual Studio Code and open the telegram-task-bot workspace.
  • Register a private bot with BotFather.
  • Build a virtual environment with the python-telegram-bot library at version 22.8.
Install Visual Studio Code and create the workspace

A workspace gives every project file one predictable home. Visual Studio Code uses the selected folder as that workspace.

  • Visit the official Visual Studio Code download page.
  • Download the Windows User setup.
  • Run the downloaded installer.
  • Keep the default choices on each installer screen.
  • Complete the installation.

Why Use the User Setup?

The Windows User setup installs Visual Studio Code without administrator permissions. It also makes the editor available from your Windows command line.

Your editor is ready. The next actions give this project its own workspace.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.

You should see the Visual Studio Code opening screen.

  • Click File in the top menu.
  • Select Open Folder.
  • Navigate to your Desktop in the folder picker.
  • Create a folder named telegram-task-bot.
  • Select the telegram-task-bot folder.
  • Confirm the folder selection to open it as your workspace.

You should see telegram-task-bot at the top of the Explorer sidebar. That confirms Visual Studio Code is working inside the correct folder.

Workspace Not Opening?

Check that you selected the folder itself before confirming the folder picker. If Visual Studio Code still opens without the folder, help me open my telegram-task-bot folder as a Visual Studio Code workspace.

Create your Telegram bot identity

BotFather registers a bot account with Telegram. The registration result includes an authentication token that allows your future Python program to control that bot.

Keep the Token Private

The bot token grants control over your bot. Treat it like a password.

Store the token outside telegram-task-bot. This keeps the credential out of your source code.

  • Open Telegram on the device where you use your account.
  • Sign in to your Telegram account if prompted.
  • Search for @BotFather.
  • Select the BotFather conversation.
  • Send /newbot.

BotFather starts the registration flow. You will receive prompts for the bot's name.

  • Enter a display name for your task assistant when prompted.
  • Enter an available username when prompted.
  • Copy the token that BotFather returns.
  • Store the token in a private password manager entry or another secure location outside the project folder.

Your bot identity is registered. Its private token is ready for the hidden prompt you will build in the next step.

Build the isolated Python environment

Windows PowerShell runs the setup commands inside your selected workspace. The virtual environment keeps version 22.8 separate from packages used by other Python projects.

  • Click Terminal in the Visual Studio Code top menu.
  • Select New Terminal.
  • Check that the terminal type is PowerShell.
  • Confirm that the prompt path ends inside telegram-task-bot.

The terminal now runs commands against your project folder.

  • Create the project-local virtual environment by running this command:
python -m venv .venv

What Does This Command Do?

  • The venv module creates an isolated package environment for this project.
  • The .venv value gives the environment folder a conventional name.
  • Inspect the Explorer sidebar to confirm that the .venv folder now exists.

Do Not See the Environment Folder?

Confirm that the terminal prompt points to telegram-task-bot. If the folder still does not appear, help me troubleshoot Python virtual environment creation on Windows.

  • Activate the new environment by running this command:
.venv\Scripts\Activate.ps1

What Does Activation Change?

Activation directs Python and pip to the packages stored inside .venv. The environment name at the start of the terminal prompt shows that this redirect is active.

Choose the tab that matches what PowerShell shows after the activation command.

✔️ I see the environment name

Your virtual environment is active. Keep using this PowerShell terminal for the remaining setup commands.

ⓧ PowerShell blocked activation

PowerShell can block local activation scripts under its current execution policy. The documented current-user policy allows the environment script to run for your Windows account.

  • Permit local activation scripts by running this command:
  • Choose the affirmative response if PowerShell asks you to confirm the current-user policy change.
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

What Does This Command Change?

The CurrentUser scope applies the policy to your Windows account. The RemoteSigned policy permits the local activation script.

  • Retry activation by running this command:
.venv\Scripts\Activate.ps1

What Should Change?

The virtual environment name should now appear at the start of your PowerShell prompt. That confirms activation succeeded.

Still Blocked?

Confirm that you changed the policy for the current user before retrying activation. If PowerShell still blocks the script, help me troubleshoot my PowerShell activation policy.

The dependency installation can take a minute while pip downloads the package. A short pause in the terminal is normal.

  • Install the pinned Telegram library by running this command:
pip install python-telegram-bot==22.8

Why Pin the Version?

The version pin installs 22.8 inside the active environment. Every later handler uses the API patterns documented for that version.

  • Wait for the installation command to finish.

You should see the installation progress complete without an error.

Package Installation Failed?

Check that the virtual environment name appears in the terminal prompt. Also confirm that your internet connection is active.

If the installation still fails, help me troubleshoot the pinned package installation.

Before you run the final check, what package name and version do you expect PowerShell to report?

  • Verify the installed package by running this command:
python -m pip show python-telegram-bot

What Does This Check Show?

The command displays metadata for the package installed in the active virtual environment. The name and version fields confirm whether the pinned dependency is ready.

✔️ I see version 22.8

The output includes Name: python-telegram-bot and Version: 22.8. Your project dependency is ready.

  • Leave this activated PowerShell terminal open for the next step.

ⓧ I see an older version

The active environment contains a different package version. Reinstall the pinned version before checking the metadata again.

  • Install the pinned version before repeating the package check by running these commands:
pip install python-telegram-bot==22.8
python -m pip show python-telegram-bot

What Do These Commands Do?

The first command installs version 22.8. The second command displays the resulting package metadata.

Version Still Unchanged?

Confirm that the environment name appears at the start of the terminal prompt. If the old version remains, help me correct the package version in my virtual environment.

  • Leave the activated PowerShell terminal open after the metadata reports version 22.8.

ⓧ The package is missing

The package is not installed in the active environment. Install it before repeating the metadata check.

  • Install the pinned package before repeating the package check by running these commands:
pip install python-telegram-bot==22.8
python -m pip show python-telegram-bot

What Do These Commands Do?

The first command adds version 22.8 to the active environment. The second command confirms the installed name and version.

Package Still Missing?

Confirm that the environment name appears in the terminal prompt before installing again. If the package remains unavailable, help me install the package in the correct virtual environment.

  • Leave the activated PowerShell terminal open after the metadata reports version 22.8.

That is the full workspace ready. Your editor is open with an activated environment that contains the pinned Telegram library.

  • Keep Telegram outside the screenshot so your private token is not captured.

Your Windows bot workspace is ready. Next up, you will connect it to Telegram and watch the bot echo its first message.

Make Your Bot Echo Messages

Your Windows workspace is ready. Visual Studio Code is open on your project. Your isolated Python environment already contains python-telegram-bot version 22.8.

The next proof is a complete message loop. A minimal echo response lets you see an update travel from Telegram to Python before the reply returns to your chat.

In this step, get ready to:
  • Create asynchronous handlers for the start command and ordinary messages.
  • Connect the handlers to a locally running bot application.
  • Verify that sending hello produces an echoed reply.
Create the echo handlers

An asynchronous handler receives an update without blocking the rest of the bot. Your first two handlers respond to /start or copy an ordinary message back to its sender.

  • Select the new-file control in the Visual Studio Code Explorer sidebar.
  • Enter bot.py as the file name.
  • Add the imports and echo handlers by pasting this code into bot.py:
import getpass

from telegram import Update
from telegram.ext import (
    Application,
    CommandHandler,
    ContextTypes,
    MessageHandler,
    filters,
)


async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(
        "Welcome to your task assistant. Send me a message and I will echo it."
    )


async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(update.message.text)

What does this code do?

  • The getpass module provides the hidden prompt used later to keep the token out of bot.py.
  • The Update object represents an incoming Telegram update.
  • The start() handler responds to /start with a welcome message.
  • The echo() handler sends ordinary message text back to its sender.
  • The asynchronous function can pause at await while Telegram sends the reply.
  • Save bot.py.
  • Confirm that bot.py appears directly inside telegram-task-bot in the Explorer sidebar.

Good progress. bot.py now contains the two responses your first message loop needs.

Cannot Find bot.py?

  • Confirm that telegram-task-bot still appears as the open workspace.
  • Recreate bot.py with the new-file control if it was saved elsewhere.

Help me diagnose why bot.py is missing from my telegram-task-bot workspace.

Connect the handlers to the application

Handlers describe how the bot responds. An Application routes each incoming update to the matching handler.

This project uses long polling to fetch updates while the program runs on your PC. This keeps the first bot local.

Why Local Polling?

Local polling lets your Python program request new updates without a public web server. It keeps this first message loop focused on handler behavior.

A webhook requires a publicly reachable service. That hosting work belongs outside this local project.

  • Place your cursor on a blank line below await update.message.reply_text(update.message.text).
  • Add the application startup logic by pasting this code:
def main() -> None:
    token = getpass.getpass("Paste your bot token: ").strip()
    if not token:
        raise ValueError("A bot token is required.")

    application = Application.builder().token(token).build()
    application.add_handler(CommandHandler("start", start))
    application.add_handler(
        MessageHandler(filters.TEXT & ~filters.COMMAND, echo)
    )
    application.run_polling()


if __name__ == "__main__":
    main()

How Does the Application Work?

  • The hidden prompt collects the private token each time the script starts.
  • The Application uses that token to connect your program to the bot account.
  • The CommandHandler routes /start to start().
  • The message filter selects ordinary text.
  • Commands stay out of the echo route.
  • The run_polling() call keeps the application listening for updates.
  • The final guard starts main() when Python runs bot.py directly.
  • Save bot.py.
  • Confirm that the final visible line in the editor is main().

Seeing a Code Warning?

  • Align the application setup lines with the token line inside main().
  • Keep if __name__ == "__main__": at the far left of the file.
  • Keep the final main() indented beneath that guard.

Help me compare the indentation in my bot.py file with the expected structure.

✔️ Awesome, I've got everything!

Your bot.py now contains the complete echo bot.

ⓧ I'd like to double check the full code

import getpass

from telegram import Update
from telegram.ext import (
    Application,
    CommandHandler,
    ContextTypes,
    MessageHandler,
    filters,
)


async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(
        "Welcome to your task assistant. Send me a message and I will echo it."
    )


async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(update.message.text)


def main() -> None:
    token = getpass.getpass("Paste your bot token: ").strip()
    if not token:
        raise ValueError("A bot token is required.")

    application = Application.builder().token(token).build()
    application.add_handler(CommandHandler("start", start))
    application.add_handler(
        MessageHandler(filters.TEXT & ~filters.COMMAND, echo)
    )
    application.run_polling()


if __name__ == "__main__":
    main()
Run the message loop

The finished script waits for a private token before it starts polling. Your activated terminal already has the required library available.

Before you run it, predict what the terminal will request before the bot can connect.

  • Start the bot from the activated Windows PowerShell terminal by running this command:
python bot.py

What Happens When This Runs?

Python executes bot.py and pauses at the hidden token prompt. The script starts polling after you submit the token.

The terminal remains occupied while the bot listens for Telegram updates. That running process is the local connection behind each reply.

Entering a bot credential can feel sensitive. The hidden prompt keeps your typed characters off screen.

  • Paste your private BotFather token into the PowerShell prompt.
  • Press Enter to submit the token.
  • Leave the PowerShell process running during the Telegram test.
  • Switch back to Telegram.
  • Find your bot by using the username provided during registration.
  • Select your bot conversation.
  • Send /start.

You should see Welcome to your task assistant. Send me a message and I will echo it..

Before you send the echo test, predict which text the bot will return.

  • Send hello to the bot.

You should receive hello as the bot's reply.

That is your first working Telegram-to-Python message loop. A message now reaches your local program before its reply returns to the chat.

Before you send a task-like message, decide whether the bot can recognize it as work to store.

  • Send Prepare model evaluation as an ordinary message.

You will see Prepare model evaluation copied back. Every ordinary message follows the same echo route.

That shortfall is intentional. The bot receives messages reliably, but it has no task intent yet.

No Telegram Reply?

  • Confirm that the PowerShell process is still running.
  • Confirm that you selected the bot username created through BotFather.
  • Restart the script if the token was rejected.
  • Paste the original private token into the new hidden prompt.

Help me diagnose why my locally running Telegram bot does not reply to hello.

Your Telegram-to-Python loop now works. Next, you will replace blind copying with task commands.

Turn Messages Into Task Commands

Your Telegram bot now completes the full message round trip. Sending text reaches your local Python program before the reply returns to the chat.

Echoing every message cannot interpret intent. You will replace that behavior with command handlers plus per-user in-memory state for useful task operations.

In this step, get ready to:
  • Replace the echo behavior with dedicated task commands.
  • Store separate task collections for each Telegram user.
  • Guide ordinary text toward the supported commands.
Define the task commands

A command gives each message an explicit purpose. The bot can use context.args to collect the words that follow /add.

  • Return to the activated PowerShell terminal from earlier.
  • Press Ctrl+C to stop the echo bot.
  • Switch back to bot.py in the Visual Studio Code editor.
  • Replace the existing start() function with this state setup and updated handler:
TASKS: dict[int, dict[int, str]] = {}
NEXT_TASK_ID: dict[int, int] = {}


async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(
        "Welcome to your task assistant.\n\n"
        "Commands:\n"
        "/add <task> - add a task\n"
        "/list - show your tasks"
    )

What Does This Code Do?

  • The TASKS dictionary groups task dictionaries by Telegram user ID.
  • The NEXT_TASK_ID dictionary tracks the next available task number for each user.
  • The updated start() handler tells users which commands the bot supports.
  • Save bot.py.
  • Start the updated bot by running this command:
python bot.py

What Does This Command Do?

Python runs bot.py inside the activated virtual environment. Long polling keeps the process waiting for Telegram updates.

  • Paste your private BotFather token into the hidden prompt.
  • Press Enter.
  • Send /start in the bot chat.

Good progress. Your bot now replies with instructions for /add <task> and /list.

Does the Old Welcome Message Appear?

Stop the bot before checking that you saved bot.py. Confirm that the updated start() function contains the command instructions.

Help me check my updated start handler.

The next handler turns the words after /add into a task. It also assigns a separate increasing task ID inside each user's collection.

  • Press Ctrl+C in the PowerShell terminal.
  • Insert this add_task() function between start() and echo() in bot.py:
async def add_task(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    task_text = " ".join(context.args).strip()
    if not task_text:
        await update.message.reply_text("Usage: /add <task>")
        return

    user_id = update.effective_user.id
    task_id = NEXT_TASK_ID.get(user_id, 1)
    NEXT_TASK_ID[user_id] = task_id + 1
    TASKS.setdefault(user_id, {})[task_id] = task_text

    await update.message.reply_text(f"Added task {task_id}: {task_text}")

How Does Task Storage Work?

  • The task_text value joins every argument after /add into one task description.
  • The user_id value identifies which Telegram user owns the task.
  • The task_id value increases independently inside that user's collection.
  • The confirmation reply exposes the stored ID plus the task text.
  • Find the existing CommandHandler("start", start) registration inside main().
  • Insert this registration directly below it:
    application.add_handler(CommandHandler("add", add_task))

Why Register the Handler?

The registration routes /add updates to add_task(). The function only runs when that command reaches the application.

  • Save bot.py.
  • Restart the bot by running this command:
python bot.py

What Happens During the Restart?

The restart loads the new handler registration into the running application. The token prompt remains hidden because getpass.getpass() reads it without displaying the value.

  • Paste your private BotFather token into the hidden prompt.
  • Press Enter.
  • Send /add Prepare model evaluation in Telegram.

You will see Added task 1: Prepare model evaluation. The command now produces stored task data instead of an echo.

Does the Add Command Fail?

Check that add_task() appears above main(). Confirm that its handler registration uses the same function name.

Help me debug my add command.

Keep tasks separate by user

Each Telegram user has a unique ID. Using that ID as the outer dictionary key prevents one user's task list from appearing in another user's chat.

  • Press Ctrl+C in the PowerShell terminal.
  • Insert this list_tasks() function between add_task() and echo() in bot.py:
async def list_tasks(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    user_id = update.effective_user.id
    user_tasks = TASKS.get(user_id, {})

    if not user_tasks:
        await update.message.reply_text("You have no open tasks.")
        return

    await update.message.reply_text("Your open tasks:")
    for task_id, task_text in user_tasks.items():
        await update.message.reply_text(f"{task_id}. {task_text}")

How Does Listing Stay Private?

  • The handler reads the current user's ID from the update.
  • The user_tasks value only receives the dictionary stored under that ID.
  • The loop replies once for each task owned by the current user.
  • Find the /add handler registration inside main().
  • Insert this registration directly below it:
    application.add_handler(CommandHandler("list", list_tasks))

Why Add Another Route?

This registration sends /list updates to list_tasks(). The existing /add route continues to handle new tasks.

  • Save bot.py.

Before you restart the bot, do you think the task from the previous process still exists in memory?

  • Restart the bot by running this command:
python bot.py

What Does the Restart Reset?

The new Python process creates empty TASKS and NEXT_TASK_ID dictionaries. In-memory tasks last only until the local process stops.

  • Paste your private BotFather token into the hidden prompt.
  • Press Enter.
  • Send /list in Telegram.

You will see You have no open tasks. The restart cleared the earlier in-memory task.

  • Send /add Prepare model evaluation.
  • Send /list.

Telegram displays Your open tasks: followed by 1. Prepare model evaluation. The current process can retrieve the task from your user-specific collection.

Does the List Stay Empty?

Send the /add command after the latest restart. Confirm that list_tasks() reads from TASKS with the current user_id.

Help me debug my per-user task list.

Replace echo with command guidance

Ordinary text still reaches the temporary echo handler. A guidance handler now points users toward the commands that perform real task operations.

  • Press Ctrl+C in the PowerShell terminal.
  • Replace the existing echo() function with this unknown_text() function:
async def unknown_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(
        "I did not understand that. Use /add <task> or /list."
    )

What Does This Handler Change?

The unknown_text() handler gives ordinary text a useful response. Users can immediately see the two supported task commands.

  • Find the existing MessageHandler block near the bottom of main().
  • Select that block through the existing application.run_polling() line.
  • Replace the selected code with this final routing setup:
    application.add_handler(
        MessageHandler(filters.TEXT & ~filters.COMMAND, unknown_text)
    )
    application.run_polling(allowed_updates=Update.ALL_TYPES)

How Does the Final Routing Work?

  • The text filter selects ordinary text that is not a command.
  • The selected messages now reach unknown_text() instead of the removed echo handler.
  • The polling call accepts every Telegram update type supported by the application.

✔️ Awesome, I've got everything!

  • Save bot.py.

ⓧ I'd like to double check the full code

import getpass

from telegram import Update
from telegram.ext import (
    Application,
    CommandHandler,
    ContextTypes,
    MessageHandler,
    filters,
)

TASKS: dict[int, dict[int, str]] = {}
NEXT_TASK_ID: dict[int, int] = {}


async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(
        "Welcome to your task assistant.\n\n"
        "Commands:\n"
        "/add <task> - add a task\n"
        "/list - show your tasks"
    )


async def add_task(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    task_text = " ".join(context.args).strip()
    if not task_text:
        await update.message.reply_text("Usage: /add <task>")
        return

    user_id = update.effective_user.id
    task_id = NEXT_TASK_ID.get(user_id, 1)
    NEXT_TASK_ID[user_id] = task_id + 1
    TASKS.setdefault(user_id, {})[task_id] = task_text

    await update.message.reply_text(f"Added task {task_id}: {task_text}")


async def list_tasks(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    user_id = update.effective_user.id
    user_tasks = TASKS.get(user_id, {})

    if not user_tasks:
        await update.message.reply_text("You have no open tasks.")
        return

    await update.message.reply_text("Your open tasks:")
    for task_id, task_text in user_tasks.items():
        await update.message.reply_text(f"{task_id}. {task_text}")


async def unknown_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(
        "I did not understand that. Use /add <task> or /list."
    )


def main() -> None:
    token = getpass.getpass("Paste your bot token: ").strip()
    if not token:
        raise ValueError("A bot token is required.")

    application = Application.builder().token(token).build()
    application.add_handler(CommandHandler("start", start))
    application.add_handler(CommandHandler("add", add_task))
    application.add_handler(CommandHandler("list", list_tasks))
    application.add_handler(
        MessageHandler(filters.TEXT & ~filters.COMMAND, unknown_text)
    )
    application.run_polling(allowed_updates=Update.ALL_TYPES)


if __name__ == "__main__":
    main()

Before you run the final version, which messages do you expect to reach command handlers? Which message should receive command guidance?

  • Start the completed command bot by running this command:
python bot.py

What Does This Final Run Prove?

This run loads the complete command routing setup. The checks in Telegram prove that commands and ordinary text now follow different handlers.

  • Paste your private BotFather token into the hidden prompt.
  • Press Enter.
  • Send /start in Telegram.
  • Send /add Prepare model evaluation.
  • Send /list.
  • Send hello as an ordinary message.

After /add, you will see Added task 1: Prepare model evaluation. Sending /list displays the stored task.

Sending hello displays I did not understand that. Use /add <task> or /list..

Does Ordinary Text Still Echo?

Confirm that the MessageHandler registration ends with unknown_text. Search bot.py for any remaining reference to echo.

Help me debug my final message routing.

That is the command layer working. Your bot now stores user-specific tasks while steering unsupported messages toward useful actions.

Your task assistant can now add and list separate tasks for each user. Next up, you will give every listed task an interactive completion button.

Complete Tasks With Inline Buttons

Your command-based Telegram bot can already keep each user's tasks separate. Completing a task still takes more effort than it should because the conversation has no action control.

An inline keyboard puts a Done button beneath each task. A callback query carries the selected task to your Python handler.

In this step, get ready to:
  • Attach a Done button to every listed task.
  • Process each button press with a callback handler.
  • Complete one task while keeping the remaining tasks open.
Attach a Done button to each task

Each button carries its task ID inside callback_data. That connection lets one shared handler identify the task the user selected.

  • Switch back to the activated PowerShell terminal from earlier.
  • Press Ctrl+C to stop the current bot process.

You'll see the PowerShell prompt again. Your in-memory tasks are cleared when the process stops.

  • Switch back to bot.py in Visual Studio Code.
  • Replace the import section at the top of bot.py with the code below:
import getpass

from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import (
    Application,
    CallbackQueryHandler,
    CommandHandler,
    ContextTypes,
    MessageHandler,
    filters,
)

What do these imports provide?

  • InlineKeyboardButton represents the control that appears beneath a task.
  • InlineKeyboardMarkup arranges buttons into rows.
  • CallbackQueryHandler connects button presses to an asynchronous handler.
  • Find the existing list_tasks function in bot.py.
  • Replace the entire function with the code below:
async def list_tasks(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    user_id = update.effective_user.id
    user_tasks = TASKS.get(user_id, {})

    if not user_tasks:
        await update.message.reply_text("You have no open tasks.")
        return

    await update.message.reply_text("Your open tasks:")
    for task_id, task_text in user_tasks.items():
        keyboard = InlineKeyboardMarkup(
            [[InlineKeyboardButton("Done", callback_data=f"done:{task_id}")]]
        )
        await update.message.reply_text(
            f"{task_id}. {task_text}",
            reply_markup=keyboard,
        )

How does the button identify a task?

  • InlineKeyboardMarkup creates one row containing one Done button.
  • callback_data stores done: followed by the task ID. That value identifies the selected task later.
  • reply_markup attaches the keyboard to the individual task message.
  • Save bot.py.

Before you restart the bot, where do you expect the Done control to appear in the conversation?

  • Start the updated bot from the activated PowerShell terminal by running this command:
python bot.py

What does this command do?

Python starts bot.py. The application keeps long polling active while it waits for Telegram updates.

Your private token stays hidden while you paste it. It never appears in the terminal or bot.py.

  • Paste your private BotFather token into the hidden prompt.
  • Send /add Prepare model evaluation to your bot.
  • Send /list to your bot.

You'll see the task in its own message. A Done button sits directly beneath it.

Don't see the Done button?

  • Confirm that list_tasks passes keyboard through the reply_markup argument.
  • Check that both inline keyboard classes appear in the import from telegram.
  • Ask for help checking why the Done button is missing.

That button is your first interactive bot control. Each task now carries its own action inside the conversation.

  • Return to the activated PowerShell terminal from earlier.
  • Press Ctrl+C to stop the bot before adding its callback logic.
Handle Done button callbacks

Telegram sends a button press as a callback query. The handler acknowledges that query before removing the selected task from the correct user's collection.

  • In bot.py, find the unknown_text function.
  • Insert the new complete_task function immediately above it by copying the code below:
async def complete_task(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    query = update.callback_query
    await query.answer()

    user_id = query.from_user.id
    task_id = int(query.data.split(":", maxsplit=1)[1])
    task_text = TASKS.get(user_id, {}).pop(task_id, None)

    if task_text is None:
        await query.edit_message_text("That task is no longer available. Send /list again.")
        return

    await query.edit_message_text(f"Completed: {task_text}")

How does task completion work?

  • query.answer() acknowledges the button press so Telegram can stop showing its progress indicator.
  • query.from_user.id selects the task collection for the user who pressed the button.
  • query.data.split() extracts the task ID from the button's callback data.
  • pop() removes the matching task. The edited message then confirms which task was completed.
  • In main(), find the registration for the list command handler.
  • Insert the callback handler immediately below that registration by copying the code below:
    application.add_handler(
        CallbackQueryHandler(complete_task, pattern=r"^done:\d+$")
    )

How is the callback connected?

CallbackQueryHandler sends matching button presses to complete_task. The pattern accepts callback data that starts with done: and ends with a numeric task ID.

✔️ Awesome, I've got everything!

Your button display and callback flow are now in place.

ⓧ I'd like to double check the full code

import getpass

from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import (
    Application,
    CallbackQueryHandler,
    CommandHandler,
    ContextTypes,
    MessageHandler,
    filters,
)

TASKS: dict[int, dict[int, str]] = {}
NEXT_TASK_ID: dict[int, int] = {}


async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(
        "Welcome to your task assistant.\n\n"
        "Commands:\n"
        "/add <task> - add a task\n"
        "/list - show your tasks"
    )


async def add_task(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    task_text = " ".join(context.args).strip()
    if not task_text:
        await update.message.reply_text("Usage: /add <task>")
        return

    user_id = update.effective_user.id
    task_id = NEXT_TASK_ID.get(user_id, 1)
    NEXT_TASK_ID[user_id] = task_id + 1
    TASKS.setdefault(user_id, {})[task_id] = task_text

    await update.message.reply_text(f"Added task {task_id}: {task_text}")


async def list_tasks(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    user_id = update.effective_user.id
    user_tasks = TASKS.get(user_id, {})

    if not user_tasks:
        await update.message.reply_text("You have no open tasks.")
        return

    await update.message.reply_text("Your open tasks:")
    for task_id, task_text in user_tasks.items():
        keyboard = InlineKeyboardMarkup(
            [[InlineKeyboardButton("Done", callback_data=f"done:{task_id}")]]
        )
        await update.message.reply_text(
            f"{task_id}. {task_text}",
            reply_markup=keyboard,
        )


async def complete_task(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    query = update.callback_query
    await query.answer()

    user_id = query.from_user.id
    task_id = int(query.data.split(":", maxsplit=1)[1])
    task_text = TASKS.get(user_id, {}).pop(task_id, None)

    if task_text is None:
        await query.edit_message_text("That task is no longer available. Send /list again.")
        return

    await query.edit_message_text(f"Completed: {task_text}")


async def unknown_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    await update.message.reply_text(
        "I did not understand that. Use /add <task> or /list."
    )


def main() -> None:
    token = getpass.getpass("Paste your bot token: ").strip()
    if not token:
        raise ValueError("A bot token is required.")

    application = Application.builder().token(token).build()
    application.add_handler(CommandHandler("start", start))
    application.add_handler(CommandHandler("add", add_task))
    application.add_handler(CommandHandler("list", list_tasks))
    application.add_handler(
        CallbackQueryHandler(complete_task, pattern=r"^done:\d+$")
    )
    application.add_handler(
        MessageHandler(filters.TEXT & ~filters.COMMAND, unknown_text)
    )
    application.run_polling(allowed_updates=Update.ALL_TYPES)


if __name__ == "__main__":
    main()

What should you compare?

Use this complete file to check the placement of complete_task and the callback handler registration. Your imports should also match the reference.

  • Save bot.py.
Test the complete task flow

The finished flow should change one selected task message without disturbing the other tasks. A fresh list command should then show only the tasks that remain open.

Before you restart the bot, which task message do you expect Telegram to change after you press its Done button?

  • Start the completed bot from the activated PowerShell terminal by running this command:
python bot.py

What happens after startup?

The application starts long polling and waits for commands or button callbacks. It keeps running until you stop the local process.

  • Paste your private BotFather token into the hidden prompt.
  • Send /add Prepare model evaluation to your bot.
  • Send /add Send status update to your bot.
  • Send /list to your bot.

You'll see two separate task messages. Each task has its own Done button.

  • Tap Done beneath one task.

You'll see that task message change to a completion confirmation beginning with Completed:.

  • Send /list again.

You'll see only the remaining open task. The completed task no longer appears in the list.

Does the button keep loading?

  • Confirm that complete_task calls query.answer() before editing the message.
  • Check that the callback handler appears before the non-command message handler in main().
  • Send /list again if Telegram says that the task is no longer available.
  • Ask for help tracing the callback data into complete_task.
  • Return to the activated PowerShell terminal from earlier.
  • Press Ctrl+C to stop the local bot.

You'll see the PowerShell prompt again. The bot stops responding because its local polling process is offline.

You now have a task assistant that turns each open task into an interactive action. Users can complete a selected task without typing another command.

Secret mission

Add a Delete Action

Your bot currently treats every finished task as completed. Add a Delete button so users can remove a task while preserving the difference between finishing work and discarding it.

Clean Up Your Resources

Clean Up Your Resources

Your Telegram bot runs on your Windows computer. This project creates no paid cloud resources or paid API calls.

Decide whether to keep the project available, stop the local process for later, or remove what you no longer need.

Resources you used:

  • Your bot registration created through BotFather.
  • Your local telegram-task-bot folder, including bot.py and the .venv virtual environment with python-telegram-bot 22.8.
  • Your local PowerShell bot process and its per-user in-memory task data.

Keep everything running

No action needed. Choose this if you plan to keep developing or demonstrating your task assistant.

  • Retain the telegram-task-bot folder with bot.py and .venv.
  • Keep the bot registration in BotFather for future testing.
  • Keep your bot token private and outside bot.py.

Pause - I'll come back to this later

Stop the local bot process to free the terminal while keeping your code and bot registration ready for later.

  • Switch back to the PowerShell terminal in Visual Studio Code.
  • Press Ctrl+C if the bot process is still running.
  • Close Visual Studio Code when PowerShell returns to its prompt.
  • Leave the telegram-task-bot folder in place for your next session.

Once stopped, the bot no longer responds in Telegram. Its in-memory tasks are discarded.

Delete - I don't want to use this again

Removing the local folder permanently erases your code and virtual environment. Your Telegram bot registration remains separate unless you remove it through BotFather.

  • Press Ctrl+C in PowerShell if the local bot process is still running.
  • Copy bot.py somewhere safe if you want to keep your work.
  • Close Visual Studio Code.
  • Use File Explorer to delete the entire telegram-task-bot folder.
  • Confirm the telegram-task-bot folder no longer appears in its previous location.

The bot registration is managed separately through BotFather.

  • Message @BotFather if you also want to remove the test bot.
  • Use BotFather's bot-management options to remove the test bot.
  • Confirm the test bot no longer appears in BotFather's bot-management options.
  • Keep the bot token private even after removing the registration.

Nice Work!

Nice Work!

You made it! Your Telegram task assistant now manages each user's tasks through commands and inline buttons.

Your bot token stays outside the source code. Every supported message reaches an asynchronous handler with a clear job.

You've learned how to:

  • Connect a BotFather-created bot to a local Python application with python-telegram-bot 22.8. Keep the private token outside bot.py.
  • Route /start, /add, /list, and ordinary text through dedicated asynchronous handlers. Return useful guidance for every supported message type.
  • Keep each Telegram user's tasks separate with per-user in-memory state. Use inline keyboards and callback queries to complete only the selected task.
  • Secret Mission: Extended one callback flow with distinct Done and Delete actions. Delete now removes a task without reporting it as completed.

Ready to quiz yourself?