Bot development
Build a bot
Create a bot with @BotFather, receive and answer messages, then add buttons, commands, photos and groups โ a tutorial to follow from start to finish.
By the end of this tutorial you'll have a bot that replies to messages, shows buttons, handles a command menu and photos, and works in groups as well. All you need is the Pabal app, a computer to run the bot program on, and either Python 3.10 or later or Node.js 18 or later.
How bots work
A Pabal bot is an account controlled by a program. Messages sent to the bot pile up in the bot's message box; the bot program asks the server "anything new?" (getUpdates), picks them up, and sends its replies (sendMessage). The bot program runs outside the server, on your own computer.
About this diagram
- Three lanes: on the left is the person using the app, in the middle the Pabal server, and on the right your bot program. The vertical dotted lines show time flowing downwards.
- Gray arrows are requests the bot makes first, and blue arrows are the messages that travel as a result. The bot program only sends requests; the server never contacts the bot first (unless you use a webhook).
- The wait in โ (long polling) is the key: with
timeout=30, when there's nothing new the server doesn't answer with an empty result right away but holds the request for up to 30 seconds, and returns it (โข) the moment a message arrives (โก). That makes the bot quick to respond while keeping the number of requests low. - The offset in โฅ means "received": send the last
update_idyou handled plus 1, and everything up to it is removed from the server. If you don't raise the offset, you keep getting the same updates. - โค follows the same path as a message sent by a person: the bot's reply is pushed to all of the person's devices and shows up in their chat list too.
1. Create a bot with @BotFather
You create a bot by chatting with @BotFather in the Pabal app. BotFather is a bot built into the Pabal server.
- Type
BotFatherinto the app's search box and open BotFather. Click Start and you'll get a list of commands. - Send
/newbot. - Send the bot's name. This is the name shown in chat lists, so non-Latin characters such as Korean are fine too.
- Send the bot's username. It is 5โ32 characters of letters, digits and underscores, starts with a letter, and must end in
bot. - When the reply with the token arrives, you're done. Copy the token and keep it.
BotFather currently replies in Korean. In the conversation above it first asks for the bot's name (the name shown in chat lists), then asks you to choose the bot's username, and finally confirms that the new bot @hello_test_bot has been created, hands you its bot token (100003:โฆ) and reminds you to keep the token as safe as a password.
| BotFather command | What it does |
|---|---|
/newbot | Create a new bot (name โ username โ token) |
/mybots | List the bots you've created |
/token | Show a bot's token again |
/revoke | Issue a new token โ the old token stops working immediately, and connections made with it are cut off |
/setcommands | Set the command menu (one command - description per line) |
/deletebot | Delete a bot โ confirm by sending ๋ค, ์ญ์ ํฉ๋๋ค ("Yes, delete it"). The username is freed so it can be used again |
/cancel | Cancel the operation in progress |
If you add the username, as in /token @hello_test_bot, BotFather skips the "Which bot?" step.
2. Handling the token
A token has the form <bot ID>:<secret>. The number at the front is the bot's user ID, and the rest is the secret. A token alone gives full control of the bot, so treat it like a password.
- Don't write it into your code; keep it in an environment variable (
BOT_TOKEN) or a secret store. Never push it to a public repository. - If it leaks, send
/revoketo BotFather. The old token is rejected immediately (401 Unauthorized), and any bot sessions connected over MTProto with the old token are cut off too. - The token is part of the address (URL), so make sure your bot program doesn't write request URLs to its logs. The Pabal server doesn't log Bot API addresses either.
3. Your first request โ getMe
Every request goes to https://pabal.me/bot<token>/<method>. Let's check that the token is right with getMe.
export BOT_TOKEN='100003:AbCdEfโฆ'
curl -s "https://pabal.me/bot$BOT_TOKEN/getMe"# pip install requests
import os
import requests
r = requests.get(f"https://pabal.me/bot{os.environ['BOT_TOKEN']}/getMe", timeout=10)
print(r.json())// Node.js 18 or later โ fetch is built in
const res = await fetch(`https://pabal.me/bot${process.env.BOT_TOKEN}/getMe`);
console.log(await res.json());If it works, you get this back. Every response is JSON containing ok and result (on success) or error_code and description (on failure).
{
"ok": true,
"result": {
"id": 100003,
"is_bot": true,
"first_name": "Hello Bot",
"username": "hello_test_bot",
"can_join_groups": true,
"can_read_all_group_messages": true,
"supports_inline_queries": false,
"can_connect_to_business": false,
"has_main_web_app": false
}
}
If the token is wrong, you get HTTP 401 with {"ok": false, "error_code": 401, "description": "Unauthorized"}.
4. Receiving messages โ getUpdates
Open the bot in the app and click Start or send it anything, then fetch the new updates.
curl -s "https://pabal.me/bot$BOT_TOKEN/getUpdates?timeout=30"
{
"ok": true,
"result": [
{
"update_id": 1,
"message": {
"message_id": 1,
"from": { "id": 100001, "is_bot": false, "first_name": "Hana" },
"chat": { "id": 100001, "first_name": "Hana", "type": "private" },
"date": 1789805661,
"text": "/start",
"entities": [ { "type": "bot_command", "offset": 0, "length": 6 } ]
}
}
]
}
- update_id: a number that grows by 1 with each update. Once you've handled an update, pass
offset=update_id+1in your next request and everything up to it is removed as "received". - timeout: how many seconds to wait when there's nothing new (0โ50). With 0 you get an empty list right away. We recommend 25โ30.
- chat.id: where to send your reply. In a one-to-one chat it's the person's ID (positive); in a group it's negative.
- There are three kinds of updates you can receive:
message(a new message),edited_message(an edited message) andcallback_query(a button press).
Updates that haven't been fetched pile up in server memory, up to the most recent 1,000 per bot. When the server restarts, anything not yet fetched is lost (the messages themselves stay in the chat). Don't leave your bot switched off for long.
5. Sending a reply โ sendMessage
curl -s "https://pabal.me/bot$BOT_TOKEN/sendMessage" \
-H 'Content-Type: application/json' \
-d '{"chat_id": 100001, "text": "Hello!"}'requests.post(f"https://pabal.me/bot{os.environ['BOT_TOKEN']}/sendMessage",
json={"chat_id": 100001, "text": "Hello!"}, timeout=10)await fetch(`https://pabal.me/bot${process.env.BOT_TOKEN}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: 100001, text: 'Hello!' }),
});You can send parameters however is most convenient: as a JSON body, as a form (application/x-www-form-urlencoded), as multipart/form-data when uploading files, or as a query string after the address. The result is the message that was sent (Message).
A bot can only message a person after that person has sent the bot at least one message. Otherwise you get 403 Forbidden: bot can't initiate conversation with a user. It's the same rule as on Telegram.
6. A complete echo bot
Receive and send in a loop, and you have a bot. Here is the full code, without any library.
# echo.py โ pip install requests
# Run: BOT_TOKEN='100003:โฆ' python3 echo.py
import os
import requests
API = f"https://pabal.me/bot{os.environ['BOT_TOKEN']}"
def call(method, **params):
r = requests.post(f"{API}/{method}", json=params, timeout=60)
data = r.json()
if not data["ok"]:
raise RuntimeError(f"{method}: {data['description']}")
return data["result"]
offset = 0
print("The bot is running. Press Ctrl+C to stop")
while True:
for update in call("getUpdates", offset=offset, timeout=30):
offset = update["update_id"] + 1 # mark as received
message = update.get("message")
if message and "text" in message:
call("sendMessage", chat_id=message["chat"]["id"], text=message["text"])// echo.mjs โ Node.js 18 or later, no libraries
// Run: BOT_TOKEN='100003:โฆ' node echo.mjs
const API = `https://pabal.me/bot${process.env.BOT_TOKEN}`;
async function call(method, params = {}) {
const res = await fetch(`${API}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await res.json();
if (!data.ok) throw new Error(`${method}: ${data.description}`);
return data.result;
}
let offset = 0;
console.log('The bot is running. Press Ctrl+C to stop');
for (;;) {
const updates = await call('getUpdates', { offset, timeout: 30 });
for (const update of updates) {
offset = update.update_id + 1; // mark as received
const message = update.message;
if (message?.text) {
await call('sendMessage', { chat_id: message.chat.id, text: message.text });
}
}
}7. Building with a library
Telegram bot libraries have a setting for changing the server address. With that one setting they run on Pabal as they are. When moving a bot built for Telegram, change just this, and get a new token from Pabal's BotFather.
| Library | Setting to change | Version tested |
|---|---|---|
| python-telegram-bot | .base_url("https://pabal.me/bot"), .base_file_url("https://pabal.me/file/bot") | 22.8 |
| aiogram | AiohttpSession(api=TelegramAPIServer.from_base("https://pabal.me")) | 3.31 |
| No library (HTTP) | The start of the address, https://api.telegram.org โ https://pabal.me | โ |
# hello_bot.py โ pip install python-telegram-bot
# Run: BOT_TOKEN='100003:โฆ' python3 hello_bot.py
import os
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import (Application, CallbackQueryHandler, CommandHandler, ContextTypes,
MessageHandler, filters)
SERVER = "https://pabal.me"
def buttons():
return InlineKeyboardMarkup([[InlineKeyboardButton("๐ Like", callback_data="like"),
InlineKeyboardButton("๐ข Count up", callback_data="count")]])
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text("Hello! Try pressing a button.", reply_markup=buttons())
async def button(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
if query.data == "like":
await query.answer("Thanks!") # text that pops up briefly for whoever pressed
else:
n = context.chat_data.get("n", 0) + 1
context.chat_data["n"] = n
await query.answer() # answer first,
await query.edit_message_text(f"Count: {n}", reply_markup=buttons()) # then edit the message
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(f"You said '{update.message.text}'.")
def main():
app = (Application.builder().token(os.environ["BOT_TOKEN"])
.base_url(f"{SERVER}/bot") # Pabal instead of api.telegram.org
.base_file_url(f"{SERVER}/file/bot")
.build())
app.add_handler(CommandHandler("start", start))
app.add_handler(CallbackQueryHandler(button))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
print("The bot is running. Press Ctrl+C to stop")
app.run_polling()
if __name__ == "__main__":
main()# echo_aiogram.py โ pip install aiogram
# Run: BOT_TOKEN='100003:โฆ' python3 echo_aiogram.py
import asyncio
import os
from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.filters import CommandStart
dp = Dispatcher()
@dp.message(CommandStart())
async def start(message):
await message.answer("Hello! Send me anything.")
@dp.message()
async def echo(message):
if message.text:
await message.answer(message.text)
async def main():
session = AiohttpSession(api=TelegramAPIServer.from_base("https://pabal.me")) # Pabal
bot = Bot(os.environ["BOT_TOKEN"], session=session)
print("The bot is running. Press Ctrl+C to stop")
await dp.start_polling(bot)
asyncio.run(main())8. Buttons and callbacks
To attach inline buttons to a message, pass inline_keyboard (an array of rows, where each row is an array of buttons) in reply_markup. There are two kinds of buttons: callback_data buttons, which notify the bot when pressed, and url buttons, which open a link.
curl -s "https://pabal.me/bot$BOT_TOKEN/sendMessage" -H 'Content-Type: application/json' -d '{
"chat_id": 100001,
"text": "Please choose",
"reply_markup": {
"inline_keyboard": [
[ {"text": "๐ Like", "callback_data": "like"}, {"text": "๐ Not really", "callback_data": "dislike"} ],
[ {"text": "Open the Pabal docs", "url": "https://pabal.me/docs/"} ]
]
}
}'
When someone presses a callback_data button, the bot receives a callback_query update.
{
"update_id": 7,
"callback_query": {
"id": "5812039457730125441",
"from": { "id": 100001, "is_bot": false, "first_name": "Hana" },
"message": { "message_id": 4, "chat": { "id": 100001, "type": "private", "first_name": "Hana" }, "text": "Please choose", โฆ },
"chat_instance": "8413962145072395171",
"data": "like"
}
}
The bot must answer with answerCallbackQuery within 10 seconds. Meanwhile, the app shows a clock on the button and waits.
# Text that pops up briefly on screen (with show_alert: true, a dialog box)
curl -s "https://pabal.me/bot$BOT_TOKEN/answerCallbackQuery" -H 'Content-Type: application/json' \
-d '{"callback_query_id": "5812039457730125441", "text": "Thanks!"}'
# Change the text and buttons of the message that was pressed
curl -s "https://pabal.me/bot$BOT_TOKEN/editMessageText" -H 'Content-Type: application/json' \
-d '{"chat_id": 100001, "message_id": 4, "text": "You pressed Like ๐"}'
callback_datais 1โ64 bytes. A message can have up to 100 buttons.- If you don't answer a callback, the app stops waiting after 10 seconds. If the bot is off, the server ends it right away.
- Only the first answer counts. Some libraries send an empty answer first when they edit a message, so send the answer with text first.
- Editing a message to the same text and the same buttons gives
400 Bad Request: message is not modified(same as Telegram).
Keyboard below the input field
Pass keyboard and a panel of buttons appears in place of the input field; pressing one sends its text as a message. Hide it with remove_keyboard, and turn on reply mode with force_reply.
{
"chat_id": 100001,
"text": "Which one?",
"reply_markup": {
"keyboard": [ [ {"text": "Yes"}, {"text": "No"} ], [ {"text": "Send my location", "request_location": true} ] ],
"resize_keyboard": true,
"one_time_keyboard": true
}
}
9. Command menu
This is the list that appears when you type / in the chat or press the Menu button. Set it from code, or with BotFather's /setcommands.
curl -s "https://pabal.me/bot$BOT_TOKEN/setMyCommands" -H 'Content-Type: application/json' -d '{
"commands": [
{"command": "start", "description": "Get started"},
{"command": "help", "description": "Help"}
]
}'
Commands are 1โ32 characters of lowercase letters, digits and underscores, descriptions are 1โ256 characters, and you can have up to 100 commands. If you pass language_code, a separate list is stored for that language, but the app currently shows only the default list set without a language code. Commands a person sends, such as /start, arrive marked as bot_command in the message's entities.
10. Sending and receiving photos
Sending
Upload the file as multipart/form-data, or reuse the file_id of a photo you received earlier. Photos can be up to 10 MB, and captions (caption) up to 1,024 characters. Sending by URL isn't supported yet.
curl -s "https://pabal.me/bot$BOT_TOKEN/sendPhoto" \
-F chat_id=100001 -F caption='Photo of the day' -F photo=@sunset.jpgwith open("sunset.jpg", "rb") as f:
requests.post(f"{API}/sendPhoto", data={"chat_id": 100001, "caption": "Photo of the day"},
files={"photo": f}, timeout=60)Receiving
A photo someone sends arrives in the message's photo (a list by size; Pabal has just the original). Get its path with getFile and download it.
# 1) file_id โ file_path
curl -s "https://pabal.me/bot$BOT_TOKEN/getFile?file_id=AQAAAAAAAAB7โฆ"
# {"ok":true,"result":{"file_id":"AQAAโฆ","file_unique_id":"AQAAโฆ","file_size":48213,"file_path":"photos/AQAAโฆ.jpg"}}
# 2) Download โ note the /file/ in the address
curl -s -o photo.jpg "https://pabal.me/file/bot$BOT_TOKEN/photos/AQAAโฆ.jpg"
11. In groups
- When you create a group in the app, or under group info โ Add Members, search for the bot's username and add it.
- A bot in a group receives every message in the group (the same as Telegram's "privacy mode off").
chat.typeis"group"andchat.idis negative. - Call
sendMessagewith thatchat.idand the message goes to the group. Buttons, photos and edits work exactly as in one-to-one chats. - Once the bot leaves the group, sending to that group gives
403 Forbidden: bot is not a member of the group chat.
12. Switching to a webhook
If your bot runs on a server with a public HTTPS address, instead of asking with getUpdates you can have the Pabal server send new updates to that address.
curl -s "https://pabal.me/bot$BOT_TOKEN/setWebhook" -H 'Content-Type: application/json' \
-d '{"url": "https://bot.example.com/pabal-webhook", "secret_token": "long-random-string"}'
Setup, verification, retries and replying in the response are all covered in the Webhooks page.
Bots over MTProto (Telethon)
Bots can also connect over MTProto, just like the app. That's handy if you already use tools built for user accounts (such as Telethon). It's the same bot account, so you can mix it with HTTP.
# pip install telethon==1.42.0 โ use 1.42 (explained below)
# Server public key: download https://pabal.me/docs/server-key.pem into the same folder
import asyncio
import os
from telethon import TelegramClient, events
from telethon.crypto import rsa
from telethon.sessions import StringSession
rsa.add_key(open("server-key.pem").read(), old=False) # the Pabal server's public key
client = TelegramClient(StringSession(), api_id=1, api_hash="0" * 32)
client.session.set_dc(2, "122.34.175.215", 8443)
@client.on(events.NewMessage(incoming=True))
async def echo(event):
await event.reply(event.raw_text)
async def main():
await client.start(bot_token=os.environ["BOT_TOKEN"]) # auth.importBotAuthorization
print("The bot is running. Press Ctrl+C to stop")
await client.run_until_disconnected()
asyncio.run(main())
- Use Telethon 1.42. Pabal speaks layer 216, and newer versions of Telethon try to read responses with a higher layer, so they fail as early as sign-in (
TypeNotFoundError). - Pabal doesn't check
api_idandapi_hash, so any values will do. - MTProto bots get new messages pushed to them by the server in real time (no need for getUpdates or a webhook). For callback answers, call
event.answer("โฆ")beforeevent.edit(โฆ).
Rules and limits
| Item | Value |
|---|---|
| Message length / photo caption | 4,096 characters / 1,024 characters |
| Photo size (sendPhoto upload) | 10 MB |
| Request body size | 12 MB |
getUpdates timeout ยท limit | 0โ50 seconds ยท 1โ100 updates |
| Keeping unfetched updates | The most recent 1,000 per bot, in server memory (lost on restart) |
| Wait for a callback answer | 10 seconds |
callback_data ยท number of buttons | 1โ64 bytes ยท 100 per message |
| Commands | 1โ32 characters of lowercase letters, digits and underscores, descriptions of 1โ256 characters, up to 100 commands |
| Starting a conversation | Not allowed โ the person has to message the bot first |
Troubleshooting
| Symptom | Cause and fix |
|---|---|
401 Unauthorized | The token is wrong or was changed with /revoke. Send /token to BotFather to check it. |
404 Not Found: method not found | Pabal doesn't support this method yet. Check the list of methods. |
403 Forbidden: bot can't initiate conversation with a user | That person hasn't messaged the bot yet. Ask them to open the bot in the app and click Start. |
409 Conflict: can't use getUpdates method while webhook is active | A webhook is set. Call deleteWebhook, or receive through the webhook. |
| Pressing a button does nothing | The bot is off, or it didn't call answerCallbackQuery. |
| The same message keeps coming back | You didn't raise the offset. Pass the handled update_id + 1 in the next request. |
| Bold and link formatting doesn't work | parse_mode isn't supported yet, so text is sent as is. Commands, @mentions, URLs and #tags are made clickable automatically. |
| The bot is silent in a group | The bot isn't a member of the group. Add it under group info โ Add Members. |