봇 개발
봇 만들기
@BotFather 로 봇을 만들고, 메시지를 받고 답하고, 버튼·명령어·사진·그룹까지 — 처음부터 끝까지 따라 하는 튜토리얼.
이 튜토리얼을 끝내면 메시지에 답하고, 버튼을 달고, 명령어 메뉴와 사진을 다루고, 그룹에서도 일하는 봇을 갖게 됩니다. 필요한 것은 파발 앱과 봇 프로그램을 돌릴 컴퓨터, 그리고 Python 3.10 이상이나 Node.js 18 이상 가운데 하나입니다.
봇은 어떻게 동작하나
파발의 봇은 프로그램이 조종하는 계정입니다. 봇에게 보낸 메시지는 봇의 메시지함에 쌓이고, 봇 프로그램은 서버에 "새로 온 것 있나요?"(getUpdates) 하고 물어 가져간 뒤, 답을 보냅니다(sendMessage). 봇 프로그램은 서버 밖, 여러분의 컴퓨터에서 돕니다.
다이어그램 설명
- 세 줄기: 왼쪽은 앱을 쓰는 사람, 가운데는 파발 서버, 오른쪽은 여러분의 봇 프로그램입니다. 세로 점선은 시간이 흐르는 방향입니다.
- 회색 화살표는 봇이 먼저 하는 요청, 파란 화살표는 그 결과로 오가는 메시지입니다. 봇 프로그램은 요청만 보내고, 서버가 봇에게 먼저 연락하지 않습니다(웹훅을 쓰지 않는 한).
- ①의 기다림(롱 폴링)이 핵심입니다:
timeout=30을 주면 새 소식이 없을 때 서버가 바로 빈 답을 주지 않고 최대 30초 쥐고 있다가, 메시지가 오는 순간(②) 돌려줍니다(③). 그래서 반응이 빠르고 요청 수는 적습니다. - ⑥의 offset 이 "받았음" 표시입니다: 마지막으로 처리한
update_id에 1을 더해 보내면 그 이하는 서버에서 지워집니다. offset 을 올리지 않으면 같은 업데이트를 계속 받습니다. - ⑤는 사람이 보낸 메시지와 같은 길입니다: 봇의 답은 앱의 모든 기기에 푸시되고 대화 목록에도 올라갑니다.
1. @BotFather 로 봇 만들기
봇은 파발 앱 안의 @BotFather 와 대화해서 만듭니다. BotFather 는 파발 서버에 들어 있는 봇입니다.
- 앱 검색창에
BotFather를 넣고 BotFather 를 엽니다. 시작을 누르면 명령 목록이 옵니다. /newbot을 보냅니다.- 봇의 이름을 보냅니다. 대화 목록에 보이는 이름이라 한글도 됩니다.
- 봇의 사용자명을 보냅니다. 영문자로 시작하는 영문자·숫자·밑줄 5~32자이고, 반드시
bot으로 끝나야 합니다. - 토큰이 담긴 답이 오면 끝입니다. 토큰을 복사해 두세요.
| BotFather 명령 | 하는 일 |
|---|---|
/newbot | 새 봇 만들기 (이름 → 사용자명 → 토큰) |
/mybots | 내가 만든 봇 목록 |
/token | 봇 토큰 다시 보기 |
/revoke | 토큰 재발급 — 이전 토큰은 즉시 무효, 이전 토큰으로 붙어 있던 연결도 끊김 |
/setcommands | 명령어 메뉴 설정 (명령어 - 설명 을 한 줄에 하나) |
/deletebot | 봇 삭제 — 네, 삭제합니다 로 확인. 사용자명은 다시 쓸 수 있게 풀림 |
/cancel | 진행 중인 작업 취소 |
/token @hello_test_bot 처럼 사용자명을 붙여 보내면 "어느 봇인가요?" 단계를 건너뜁니다.
2. 토큰 다루기
토큰은 <봇 ID>:<비밀> 꼴입니다. 앞의 숫자가 봇의 사용자 ID 이고, 뒤가 비밀입니다. 토큰 하나로 봇을 완전히 조종할 수 있으므로 비밀번호처럼 다루세요.
- 코드에 적지 말고 환경 변수(
BOT_TOKEN)나 비밀 저장소에 두세요. 공개 저장소에 올리지 마세요. - 새어 나갔다면 BotFather 에
/revoke. 이전 토큰은 즉시 거절(401 Unauthorized)되고, 이전 토큰으로 MTProto 에 붙어 있던 봇 세션도 끊깁니다. - 토큰은 주소(URL) 안에 들어가므로, 봇 프로그램이 요청 주소를 로그에 남기지 않게 하세요. 파발 서버도 Bot API 주소를 로그에 남기지 않습니다.
3. 첫 요청 — getMe
모든 요청의 주소는 https://pabal.me/bot<토큰>/<메서드> 입니다. 토큰이 맞는지 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 이상 — fetch 가 들어 있음
const res = await fetch(`https://pabal.me/bot${process.env.BOT_TOKEN}/getMe`);
console.log(await res.json());성공하면 이렇게 옵니다. 모든 응답은 ok 와 result(성공) 또는 error_code·description(실패)을 담은 JSON 입니다.
{
"ok": true,
"result": {
"id": 100003,
"is_bot": true,
"first_name": "안녕 봇",
"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
}
}
토큰이 틀리면 HTTP 401 과 함께 {"ok": false, "error_code": 401, "description": "Unauthorized"} 가 옵니다.
4. 메시지 받기 — getUpdates
앱에서 봇을 열고 시작을 누르거나 아무 말이나 보낸 뒤, 새 소식을 가져옵니다.
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": "하나" },
"chat": { "id": 100001, "first_name": "하나", "type": "private" },
"date": 1789805661,
"text": "/start",
"entities": [ { "type": "bot_command", "offset": 0, "length": 6 } ]
}
}
]
}
- update_id: 업데이트마다 1씩 커지는 번호. 처리한 뒤 다음 요청에
offset=update_id+1을 주면 그 이하가 "받았음"으로 지워집니다. - timeout: 새 소식이 없을 때 기다릴 초(0~50). 0 이면 바로 빈 목록을 돌려줍니다. 25~30 을 권합니다.
- chat.id: 답장을 보낼 곳. 1:1 대화면 사람의 ID(양수), 그룹이면 음수입니다.
- 받을 수 있는 업데이트는
message(새 메시지),edited_message(고친 메시지),callback_query(버튼 누름) 세 가지입니다.
가져가지 않은 업데이트는 봇마다 최근 1,000개까지 서버 메모리에 쌓입니다. 서버를 다시 켜면 가져가지 않은 것은 사라집니다(메시지 자체는 대화에 남습니다). 봇을 오래 꺼 두지 마세요.
5. 답장 보내기 — sendMessage
curl -s "https://pabal.me/bot$BOT_TOKEN/sendMessage" \
-H 'Content-Type: application/json' \
-d '{"chat_id": 100001, "text": "안녕하세요!"}'requests.post(f"https://pabal.me/bot{os.environ['BOT_TOKEN']}/sendMessage",
json={"chat_id": 100001, "text": "안녕하세요!"}, 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: '안녕하세요!' }),
});파라미터는 JSON 본문, 폼(application/x-www-form-urlencoded), 파일을 올릴 때의 multipart/form-data, 주소 뒤의 쿼리 문자열 중 편한 것으로 보내면 됩니다. 결과는 보낸 메시지(Message)입니다.
사람이 봇에게 한 번이라도 메시지를 보낸 뒤에만 그 사람에게 보낼 수 있습니다. 아니면 403 Forbidden: bot can't initiate conversation with a user 입니다. 텔레그램과 같은 규칙입니다.
6. 따라 말하는 봇 완성
받기와 보내기를 반복하면 봇이 됩니다. 라이브러리 없이 쓴 전체 코드입니다.
# echo.py — pip install requests
# 실행: 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("봇이 켜졌습니다. 멈추려면 Ctrl+C")
while True:
for update in call("getUpdates", offset=offset, timeout=30):
offset = update["update_id"] + 1 # 받았음 표시
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 이상, 라이브러리 없이
// 실행: 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('봇이 켜졌습니다. 멈추려면 Ctrl+C');
for (;;) {
const updates = await call('getUpdates', { offset, timeout: 30 });
for (const update of updates) {
offset = update.update_id + 1; // 받았음 표시
const message = update.message;
if (message?.text) {
await call('sendMessage', { chat_id: message.chat.id, text: message.text });
}
}
}7. 라이브러리로 만들기
텔레그램용 봇 라이브러리는 서버 주소를 바꾸는 설정이 있습니다. 그 설정 하나로 파발에서 그대로 돕니다. 텔레그램용으로 만든 봇을 옮길 때도 이것만 바꾸고, 토큰은 파발의 BotFather 에게서 새로 받으면 됩니다.
| 라이브러리 | 바꿀 설정 | 확인한 판 |
|---|---|---|
| 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 |
| 라이브러리 없이 (HTTP) | 주소 앞부분 https://api.telegram.org → https://pabal.me | — |
# hello_bot.py — pip install python-telegram-bot
# 실행: 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("👍 좋아요", callback_data="like"),
InlineKeyboardButton("🔢 숫자 올리기", callback_data="count")]])
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text("안녕하세요! 버튼을 눌러 보세요.", reply_markup=buttons())
async def button(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
if query.data == "like":
await query.answer("고마워요!") # 누른 사람 화면에 잠깐 뜨는 문구
else:
n = context.chat_data.get("n", 0) + 1
context.chat_data["n"] = n
await query.answer() # 먼저 답하고
await query.edit_message_text(f"숫자: {n}", reply_markup=buttons()) # 메시지를 고친다
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(f"'{update.message.text}' 라고 하셨네요.")
def main():
app = (Application.builder().token(os.environ["BOT_TOKEN"])
.base_url(f"{SERVER}/bot") # 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("봇이 켜졌습니다. 멈추려면 Ctrl+C")
app.run_polling()
if __name__ == "__main__":
main()# echo_aiogram.py — pip install aiogram
# 실행: 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("안녕하세요! 아무 말이나 보내 보세요.")
@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")) # 파발
bot = Bot(os.environ["BOT_TOKEN"], session=session)
print("봇이 켜졌습니다. 멈추려면 Ctrl+C")
await dp.start_polling(bot)
asyncio.run(main())8. 버튼과 콜백
메시지에 인라인 버튼을 달려면 reply_markup 에 inline_keyboard(줄의 배열, 줄은 버튼의 배열)를 줍니다. 버튼은 누르면 봇에게 알리는 callback_data 버튼과, 링크를 여는 url 버튼 두 가지입니다.
curl -s "https://pabal.me/bot$BOT_TOKEN/sendMessage" -H 'Content-Type: application/json' -d '{
"chat_id": 100001,
"text": "골라 주세요",
"reply_markup": {
"inline_keyboard": [
[ {"text": "👍 좋아요", "callback_data": "like"}, {"text": "👎 별로", "callback_data": "dislike"} ],
[ {"text": "파발 문서 열기", "url": "https://pabal.me/docs/"} ]
]
}
}'
사람이 callback_data 버튼을 누르면 봇은 callback_query 업데이트를 받습니다.
{
"update_id": 7,
"callback_query": {
"id": "5812039457730125441",
"from": { "id": 100001, "is_bot": false, "first_name": "하나" },
"message": { "message_id": 4, "chat": { "id": 100001, "type": "private", "first_name": "하나" }, "text": "골라 주세요", … },
"chat_instance": "8413962145072395171",
"data": "like"
}
}
봇은 10초 안에 answerCallbackQuery 로 답해야 합니다. 앱은 그동안 버튼에 시계 표시를 띄우고 기다립니다.
# 화면 위에 잠깐 뜨는 문구 (show_alert: true 면 확인 창)
curl -s "https://pabal.me/bot$BOT_TOKEN/answerCallbackQuery" -H 'Content-Type: application/json' \
-d '{"callback_query_id": "5812039457730125441", "text": "고마워요!"}'
# 누른 메시지의 글과 버튼 바꾸기
curl -s "https://pabal.me/bot$BOT_TOKEN/editMessageText" -H 'Content-Type: application/json' \
-d '{"chat_id": 100001, "message_id": 4, "text": "좋아요를 눌렀어요 👍"}'
callback_data는 1~64바이트입니다. 버튼은 한 메시지에 최대 100개입니다.- 콜백에 답하지 않으면 앱은 10초 뒤 기다리기를 멈춥니다. 봇이 꺼져 있으면 서버가 바로 끝냅니다.
- 답은 처음 한 번만 유효합니다. 라이브러리가 메시지를 고치면서 빈 답을 먼저 보내는 경우가 있으니, 문구가 있는 답을 먼저 보내세요.
- 같은 글·같은 버튼으로 고치면
400 Bad Request: message is not modified입니다(텔레그램과 같음).
입력창 아래 키보드
keyboard 를 주면 입력창 대신 버튼 판이 뜨고, 누르면 그 글자가 메시지로 보내집니다. remove_keyboard 로 숨기고, force_reply 로 답장 모드를 켭니다.
{
"chat_id": 100001,
"text": "어느 쪽인가요?",
"reply_markup": {
"keyboard": [ [ {"text": "예"}, {"text": "아니요"} ], [ {"text": "내 위치 보내기", "request_location": true} ] ],
"resize_keyboard": true,
"one_time_keyboard": true
}
}
9. 명령어 메뉴
대화창에서 / 를 누르거나 메뉴 버튼을 누르면 뜨는 목록입니다. 코드로 정하거나 BotFather 의 /setcommands 로 정합니다.
curl -s "https://pabal.me/bot$BOT_TOKEN/setMyCommands" -H 'Content-Type: application/json' -d '{
"commands": [
{"command": "start", "description": "시작하기"},
{"command": "help", "description": "도움말"}
]
}'
명령어는 영소문자·숫자·밑줄 1~32자, 설명은 1~256자, 최대 100개입니다. language_code 를 주면 언어별 목록을 따로 저장하지만, 지금 앱에는 언어 코드 없이 정한 기본 목록만 보입니다. 사람이 보낸 /start 같은 명령은 메시지의 entities 에 bot_command 로 표시되어 옵니다.
10. 사진 주고받기
보내기
파일을 multipart/form-data 로 올리거나, 전에 받은 사진의 file_id 를 다시 씁니다. 사진은 10MB 까지, 설명(caption)은 1,024자까지입니다. URL 로 보내기는 아직 지원하지 않습니다.
curl -s "https://pabal.me/bot$BOT_TOKEN/sendPhoto" \
-F chat_id=100001 -F caption='오늘의 사진' -F photo=@sunset.jpgwith open("sunset.jpg", "rb") as f:
requests.post(f"{API}/sendPhoto", data={"chat_id": 100001, "caption": "오늘의 사진"},
files={"photo": f}, timeout=60)받기
사람이 보낸 사진은 메시지의 photo(크기별 목록, 파발은 원본 하나)로 옵니다. getFile 로 경로를 얻어 내려받습니다.
# 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) 내려받기 — 주소에 /file/ 이 들어갑니다
curl -s -o photo.jpg "https://pabal.me/file/bot$BOT_TOKEN/photos/AQAA….jpg"
11. 그룹에서
- 앱에서 그룹을 만들 때, 또는 그룹 정보 → 멤버 추가에서 봇의 사용자명을 검색해 넣습니다.
- 그룹에 들어간 봇은 그룹의 모든 메시지를 받습니다(텔레그램의 "프라이버시 모드 꺼짐"과 같음).
chat.type은"group",chat.id는 음수입니다. - 그
chat.id로sendMessage하면 그룹에 보내집니다. 버튼·사진·수정도 1:1 과 똑같습니다. - 봇이 그룹에서 빠지면 그 그룹으로 보내기는
403 Forbidden: bot is not a member of the group chat입니다.
12. 웹훅으로 바꾸기
봇이 공개된 HTTPS 주소를 가진 서버에서 돈다면, getUpdates 로 묻는 대신 파발 서버가 새 소식을 그 주소로 보내게 할 수 있습니다.
curl -s "https://pabal.me/bot$BOT_TOKEN/setWebhook" -H 'Content-Type: application/json' \
-d '{"url": "https://bot.example.com/pabal-webhook", "secret_token": "긴-무작위-문자열"}'
설정·검증·재시도·응답으로 답하기까지는 웹훅 문서에 있습니다.
MTProto 로 붙는 봇 (Telethon)
봇은 앱과 같은 MTProto 로도 붙을 수 있습니다. 사람 계정용 도구(Telethon 등)를 이미 쓰고 있다면 편합니다. 같은 봇 계정이므로 HTTP 와 섞어 써도 됩니다.
# pip install telethon==1.42.0 ← 1.42 를 쓰세요 (아래 설명)
# 서버 공개키: https://pabal.me/docs/server-key.pem 을 받아 같은 폴더에
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) # 파발 서버의 공개키
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("봇이 켜졌습니다. 멈추려면 Ctrl+C")
await client.run_until_disconnected()
asyncio.run(main())
- Telethon 1.42 를 쓰세요. 파발은 레이어 216 을 말하는데, 더 새 Telethon 은 더 높은 레이어로 응답을 읽으려 해서 로그인부터 실패합니다(
TypeNotFoundError). api_id·api_hash는 파발에서 확인하지 않으므로 아무 값이나 됩니다.- MTProto 봇에는 서버가 새 메시지를 실시간으로 밀어 줍니다(getUpdates·웹훅이 필요 없음). 콜백 답은
event.answer("…")를event.edit(…)보다 먼저 부르세요.
규칙과 한도
| 항목 | 값 |
|---|---|
| 메시지 글자 수 / 사진 설명 | 4,096자 / 1,024자 |
| 사진 크기 (sendPhoto 업로드) | 10MB |
| 요청 본문 크기 | 12MB |
getUpdates timeout · limit | 0~50초 · 1~100개 |
| 가져가지 않은 업데이트 보관 | 봇마다 최근 1,000개, 서버 메모리 (재시작 시 사라짐) |
| 콜백 답 기다림 | 10초 |
callback_data · 버튼 수 | 1~64바이트 · 메시지당 100개 |
| 명령어 | 영소문자·숫자·밑줄 1~32자, 설명 1~256자, 최대 100개 |
| 먼저 말 걸기 | 안 됨 — 사람이 먼저 봇에게 보내야 함 |
문제 해결
| 증상 | 원인과 해결 |
|---|---|
401 Unauthorized | 토큰이 틀렸거나 /revoke 로 바뀌었습니다. BotFather 에 /token 을 보내 확인하세요. |
404 Not Found: method not found | 파발이 아직 지원하지 않는 메서드입니다. 메서드 목록을 확인하세요. |
403 Forbidden: bot can't initiate conversation with a user | 그 사람이 아직 봇에게 말을 건 적이 없습니다. 앱에서 봇을 열고 시작을 누르게 하세요. |
409 Conflict: can't use getUpdates method while webhook is active | 웹훅이 설정돼 있습니다. deleteWebhook 을 부르거나 웹훅으로 받으세요. |
| 버튼을 눌러도 반응이 없다 | 봇이 꺼져 있거나 answerCallbackQuery 를 부르지 않았습니다. |
| 같은 메시지를 계속 받는다 | offset 을 올리지 않았습니다. 처리한 update_id + 1 을 다음 요청에 주세요. |
| 굵게·링크 서식이 안 먹는다 | parse_mode 는 아직 지원하지 않아 글자 그대로 보냅니다. 명령·@멘션·URL·#태그 는 자동으로 눌리게 표시됩니다. |
| 그룹에서 봇이 조용하다 | 봇이 그룹 멤버가 아닙니다. 그룹 정보 → 멤버 추가로 넣으세요. |