How to build an AI Telegram bot with Python and an API
Build a personal text bot: send it a Telegram message, let Python forward the question to VOID API, and receive the model's reply. This example keeps the last three question-and-answer pairs, supports /reset, and limits model requests to 20 per UTC day. Start by checking the API key with a separate script. This helps identify model connection problems before setting up Telegram. The project files and results from real VOID API calls are included below. The measured example uses Russian prompts and bot messages. The code and recorded outputs include those original strings; МАЯК means “beacon.” You can translate the message strings and system instruction for your own bot, then measure usage with those changes.
Prepare Python and two credentials
Install Python 3.11 or later. The bot runs while its Python process is active and can reach the Telegram API and https://void-api.tech/v1. Your own computer is enough for the first run.
Send /newbot to the official @BotFather, choose a display name, and select an available username ending in bot. The token you receive connects your program to Telegram.
Create a separate API key in your VOID account and check your balance. This tutorial uses claude-haiku-4.5 with Chat Completions. The BotFather token and VOID API key serve different purposes; put each in its corresponding .env field.
Install the dependencies
Create a project folder and save the dependencies in requirements.txt. The Linux and macOS commands appear below the file contents.
On Windows, create the environment with py -m venv .venv and activate it in PowerShell with .\.venv\Scripts\Activate.ps1. Then install the dependencies with the same command: python -m pip install -r requirements.txt.
requirements.txt
python-telegram-bot==22.8
openai==3.37.0
python-dotenv==1.2.4
Terminal: Linux and macOS
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txtSave the settings in .env
Create .env alongside the Python files. Replace the values in angle brackets with your credentials. Leave TELEGRAM_ALLOWED_USER_ID at 0 for now: in this state, nobody can send a model request through the bot.
After the first launch, /start will display your numeric Telegram user ID. Put it in .env and restart the program. An @username does not work in this field.
If you use Git, add the exclusions below to .gitignore. They keep credentials, the virtual environment, and the local quota database out of the repository.
.env: replace the credential placeholders
TELEGRAM_BOT_TOKEN=<BotFather token>
VOID_API_KEY=<VOID API key>
VOID_MODEL=claude-haiku-4.5
TELEGRAM_ALLOWED_USER_ID=0
.gitignore
.env
.venv/
__pycache__/
quota.sqlite3*
Check VOID API before starting the bot
Save check_api.py and run python check_api.py in the activated environment. The script makes one paid request to the selected model and prints its answer and token counts. This check does not need Telegram.
On October 23, 2026, the original Russian prompt returned МАЯК, with 33 input tokens and 7 output tokens. Counts may differ if you change the model or prompt.
If this check fails, inspect VOID_API_KEY, VOID_MODEL, and your balance first. Telegram configuration cannot resolve a model API failure.
check_api.py (original measured prompt)
import os
from pathlib import Path
from dotenv import load_dotenv
from openai import APIError, OpenAI
load_dotenv(Path(__file__).resolve().parent / ".env")
with OpenAI(
api_key=os.environ["VOID_API_KEY"],
base_url="https://void-api.tech/v1",
timeout=60,
max_retries=0,
) as client:
try:
response = client.chat.completions.create(
model=os.environ["VOID_MODEL"],
messages=[{"role": "user", "content": "Ответь только словом МАЯК."}],
max_tokens=32,
)
except APIError as error:
print(f"Ошибка API: {type(error).__name__}, HTTP {getattr(error, 'status_code', None)}")
raise SystemExit(1) from None
if not response.choices or not response.choices[0].message.content:
raise SystemExit("API ответил, но текст отсутствует. Проверьте модель и лимит ответа.")
print(response.choices[0].message.content)
if response.usage:
print(f"input={response.usage.prompt_tokens}, output={response.usage.completion_tokens}")
Run the check
python check_api.pyOutput from the verified run
МАЯК
input=33, output=7Save the code in bot.py
This bot handles private text messages from its owner. It does not reply in groups or process images and voice messages.
The daily counter is stored in quota.sqlite3 beside the program and survives restarts. Conversation history lives in process memory. The /reset command clears that history while preserving the daily counter.
bot.py (tested example with Russian messages)
import logging
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
from openai import APIError, AsyncOpenAI
from telegram import Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters
ROOT = Path(__file__).resolve().parent
load_dotenv(ROOT / ".env")
OWNER_ID = int(os.getenv("TELEGRAM_ALLOWED_USER_ID", "0"))
MODEL = os.environ["VOID_MODEL"]
DAILY_REQUESTS = 20
HISTORY_TURNS = 3
MAX_INPUT_CHARS = 3000
MAX_OUTPUT_TOKENS = 600
logging.basicConfig(level=logging.WARNING)
client = AsyncOpenAI(
api_key=os.environ["VOID_API_KEY"],
base_url="https://void-api.tech/v1",
timeout=60.0,
max_retries=0,
)
db = sqlite3.connect(ROOT / "quota.sqlite3")
db.execute("CREATE TABLE IF NOT EXISTS quota (day TEXT PRIMARY KEY, used INTEGER NOT NULL)")
db.commit()
def reserve_request():
day = datetime.now(timezone.utc).date().isoformat()
with db:
cursor = db.execute(
"INSERT INTO quota(day, used) VALUES (?, 1) "
"ON CONFLICT(day) DO UPDATE SET used = used + 1 WHERE used < ?",
(day, DAILY_REQUESTS),
)
return cursor.rowcount == 1
def keep_history(history, turns):
if turns < 0:
raise ValueError("turns must be non-negative")
if turns == 0:
return []
return history[-2 * turns:]
async def start(update, context):
await update.message.reply_text(
f"Ваш Telegram ID: {update.effective_user.id}.\n"
"Укажите его в TELEGRAM_ALLOWED_USER_ID и перезапустите бота.\n"
"Команда /reset очищает историю диалога."
)
async def reset(update, context):
if update.effective_user.id != OWNER_ID:
return
context.user_data.clear()
await update.message.reply_text("История очищена. Дневной счётчик сохранён.")
async def answer(update, context):
if update.effective_user.id != OWNER_ID:
await update.message.reply_text("Это личный бот. Доступ к модели закрыт.")
return
text = update.message.text.strip()
if not text or len(text) > MAX_INPUT_CHARS:
await update.message.reply_text(f"Отправьте от 1 до {MAX_INPUT_CHARS} символов.")
return
if not reserve_request():
await update.message.reply_text("Дневной лимит исчерпан. Обновление в 00:00 UTC.")
return
history = context.user_data.get("history", [])
user_message = {"role": "user", "content": text}
messages = [
{"role": "system", "content": "Отвечай по-русски, кратко и по существу."},
*history,
user_message,
]
try:
response = await client.chat.completions.create(
model=MODEL,
messages=messages,
max_tokens=MAX_OUTPUT_TOKENS,
)
except APIError as error:
logging.warning(
"Void API: %s, HTTP %s", type(error).__name__, getattr(error, "status_code", None)
)
await update.message.reply_text(
"Не удалось получить ответ. Проверьте ключ, баланс и модель в VOID. "
"Попытка учтена в дневном лимите."
)
return
choice = response.choices[0] if response.choices else None
reply = (choice.message.content or "").strip() if choice else ""
if not reply:
await update.message.reply_text("Модель не вернула текст. Попытка учтена в лимите.")
return
context.user_data["history"] = keep_history(
history + [user_message, {"role": "assistant", "content": reply}],
HISTORY_TURNS,
)
for offset in range(0, len(reply), 2000):
await update.message.reply_text(reply[offset : offset + 2000])
if choice.finish_reason == "length":
await update.message.reply_text("Ответ остановлен по лимиту токенов.")
if response.usage:
print(
f"model={response.model} input={response.usage.prompt_tokens} "
f"output={response.usage.completion_tokens}",
flush=True,
)
async def on_error(update, context):
logging.warning("Ошибка обработчика: %s", type(context.error).__name__)
async def shutdown(application):
await client.close()
db.close()
def build_app():
app = (
Application.builder()
.token(os.environ["TELEGRAM_BOT_TOKEN"])
.concurrent_updates(False)
.post_shutdown(shutdown)
.build()
)
private = filters.ChatType.PRIVATE
app.add_handler(CommandHandler("start", start, filters=private))
app.add_handler(CommandHandler("reset", reset, filters=private))
app.add_handler(MessageHandler(private & filters.TEXT & ~filters.COMMAND, answer))
app.add_error_handler(on_error)
return app
if __name__ == "__main__":
build_app().run_polling(allowed_updates=[Update.MESSAGE], drop_pending_updates=True)
Grant access and check conversation memory
Run python bot.py, open your bot in Telegram, and send /start. Copy the displayed ID into TELEGRAM_ALLOWED_USER_ID. Stop the process with Ctrl+C, then start it again.
Send a message asking the bot to remember the code word МАЯК and reply with that word only. Then ask which code word you told it to remember, again requesting a one-word answer. On the second request, the program includes the previous question and reply. The recorded Russian messages appear in the verification section below.
Send /reset and ask a new question. The program will no longer include the old history. A model can still guess a word by chance, so check the outgoing messages or use a new, unrelated topic rather than relying on one matching answer.
Run one instance with this bot token. The program receives messages through long polling, so this example does not need a public webhook. The drop_pending_updates=True setting skips messages queued while the process was stopped.
Start the bot
python bot.pyAdjust memory and usage limits
HISTORY_TURNS sets the number of previous question-and-answer pairs. At 3, each new request includes up to three such pairs. To disable memory, set it to 0 and restart the bot. The keep_history function handles zero separately because the slice history[-0:] returns the entire list.
MAX_INPUT_CHARS limits the length of a new message in characters. MAX_OUTPUT_TOKENS limits the model's output budget. Characters and tokens are different units, so adding these values does not give a request price.
DAILY_REQUESTS limits model request attempts per UTC day. The program counts an attempt before sending it, including calls that fail. After a network error, it may be unclear whether the provider processed the request. Automatic SDK retries are disabled.
A request limit does not enforce an exact spending amount. Check input tokens, output tokens, and billed costs in your account. When changing models, check the price and support for Chat Completions and max_tokens.
Settings at the top of bot.py
DAILY_REQUESTS = 20
HISTORY_TURNS = 3
MAX_INPUT_CHARS = 3000
MAX_OUTPUT_TOKENS = 600What we verified with real API calls
On October 23, 2026, we passed two code-word messages to the bot's handler. It called claude-haiku-4.5 through VOID and received the replies shown below. Token counts came from usage; costs came from VOID's request history. The transcript preserves the original Russian messages.
In this check, incoming Telegram events and reply_text delivery were replaced with local mocks. It verifies the handler, history forwarding, and real VOID responses. Delivery through Telegram's servers was outside the test.
Separate local checks covered rejection of unauthorized users, input-length limits, history trimming and disabling, /reset, API error handling, splitting long replies, and persistence of the daily quota. These checks evaluate program behavior rather than model quality.
Two real VOID calls from the handler
Message (original Russian): Запомни кодовое слово МАЯК. Ответь только этим словом.
Model reply: МАЯК
Input: 73 tokens. Output: 7 tokens.
Billed request cost: $0.00005701
Message (original Russian): Какое кодовое слово я попросил запомнить? Ответь одним словом.
Model reply: МАЯК
Input: 110 tokens. Output: 7 tokens.
Billed request cost: $0.00007810If the bot does not reply
If /start fails, check that the process is running, the BotFather token is correct, and the computer can reach Telegram's API. An update-retrieval conflict may mean another instance is using the same token.
If /start works but model access is denied, check the numeric TELEGRAM_ALLOWED_USER_ID and restart the program after editing .env.
If check_api.py fails, read the printed error class and HTTP status. Check the VOID key, model ID, and balance. Use an API key, not your account password.
If the daily quota is exhausted, check the number of requests. The counter renews at 00:00 UTC. Deleting quota.sqlite3 resets the limit, so preserve this file when moving the bot to a server.
If a reply ends with a token-limit notice, ask for a shorter response format or increase MAX_OUTPUT_TOKENS while monitoring costs.
API access and references
Create a VOID key and check the model's current price before running the example. Prices and model availability may change after the date of this test.