2023-08-02
这是个好东西- -
基础安装:
pip install python-telegram-bot
如果需要后台任务、限流或 webhook,可以按项目需要安装附加能力:
pip install "python-telegram-bot[job-queue,rate-limiter,webhooks]"
项目目录可以先这样放:
telegram_bot_demo/
bot.py
config.py
handlers.py
keyboards.py
小项目放一个文件也能跑,但一旦命令多起来,拆文件会舒服很多。
先写一个能启动、能响应 /start、能复读普通文本的 bot。
import os
import logging
from telegram import Update
from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes, MessageHandler, filters
logging.basicConfig(
format="%(levelname)s:%(name)s:%(message)s",
level=logging.INFO,
)
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.effective_message.reply_text(
"你好,我是一个用 python-telegram-bot 写的示例 bot。"
)
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = update.effective_message.text
await update.effective_message.reply_text(f"收到:{text}")
def main() -> None:
token = os.environ["TELEGRAM_BOT_TOKEN"]
app = ApplicationBuilder().token(token).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()
运行前把 token 放到环境变量里,不要写进源码:
export TELEGRAM_BOT_TOKEN="你的 token"
python bot.py
这里有几个关键点:
ApplicationBuilder().token(token).build() 创建应用。
CommandHandler 处理 /start 这种命令。
MessageHandler 处理普通消息。
回调函数用 async def,里面用 await 调 Telegram API。
PTB 会按注册顺序匹配 handler。越具体的 handler 越应该放前面,兜底 handler 放后面。
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("help", help_command))
app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text))
比如 filters.TEXT & ~filters.COMMAND 是“普通文本但不是命令”。如果你把一个超宽泛的 handler 放太前,后面的处理函数可能根本轮不到。
Telegram bot 的体验,不应该全靠用户打字。Inline keyboard 能把很多操作变成按钮。
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import CallbackQueryHandler, CommandHandler, ContextTypes
async def menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
keyboard = [
[
InlineKeyboardButton("查看功能", callback_data="menu:features"),
InlineKeyboardButton("获取帮助", callback_data="menu:help"),
],
[
InlineKeyboardButton("项目地址", url="https://github.com/python-telegram-bot/python-telegram-bot")
],
]
await update.effective_message.reply_text(
"请选择一个操作:",
reply_markup=InlineKeyboardMarkup(keyboard),
)
async def on_button(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
await query.answer()
if query.data == "menu:features":
await query.edit_message_text("功能:命令、按钮、会话、后台任务、webhook。")
elif query.data == "menu:help":
await query.edit_message_text("发送 /start 或 /menu 开始体验。")
else:
await query.edit_message_text("这个按钮还没有绑定动作。")
app.add_handler(CommandHandler("menu", menu))
app.add_handler(CallbackQueryHandler(on_button, pattern=r"^menu:"))
按钮回调里建议先 await query.answer(),否则客户端可能一直显示加载状态。callback_data 最好带前缀,比如 menu:、order:、admin:,后续拆 handler 会更清晰。
复杂一点的 bot 经常要分几步收集信息,比如点餐、报名、配置参数。不要靠一堆 if else 猜用户处在哪一步,ConversationHandler 就是用来写状态机的。
下面做一个简单点单流程:
from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update
from telegram.ext import ConversationHandler, CommandHandler, MessageHandler, ContextTypes, filters
CHOOSING, CONFIRMING = range(2)
async def order_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
keyboard = [["咖啡", "甜点"], ["取消"]]
await update.effective_message.reply_text(
"想来点什么?",
reply_markup=ReplyKeyboardMarkup(keyboard, resize_keyboard=True),
)
return CHOOSING
async def choose_item(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
item = update.effective_message.text
if item == "取消":
return await cancel(update, context)
context.user_data["item"] = item
await update.effective_message.reply_text(
f"你选择了:{item}。回复“确认”提交,或回复“取消”。"
)
return CONFIRMING
async def confirm_order(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
text = update.effective_message.text
if text == "取消":
return await cancel(update, context)
if text != "确认":
await update.effective_message.reply_text("请回复“确认”或“取消”。")
return CONFIRMING
item = context.user_data.get("item", "未选择")
await update.effective_message.reply_text(
f"已提交:{item}",
reply_markup=ReplyKeyboardRemove(),
)
return ConversationHandler.END
async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
context.user_data.pop("item", None)
await update.effective_message.reply_text(
"已取消。",
reply_markup=ReplyKeyboardRemove(),
)
return ConversationHandler.END
order_conv = ConversationHandler(
entry_points=[CommandHandler("order", order_start)],
states={
CHOOSING: [MessageHandler(filters.TEXT & ~filters.COMMAND, choose_item)],
CONFIRMING: [MessageHandler(filters.TEXT & ~filters.COMMAND, confirm_order)],
},
fallbacks=[CommandHandler("cancel", cancel)],
)
app.add_handler(order_conv)
context.user_data 很适合存用户会话里的临时数据。它不是业务数据库,重要内容还是应该写到你自己的存储里。
很多 bot 都需要后台任务,比如延迟提醒、定期拉取状态、批量通知。PTB 的 JobQueue 可以挂在 Application 上使用。
async def send_reminder(context: ContextTypes.DEFAULT_TYPE) -> None:
job = context.job
await context.bot.send_message(
chat_id=job.chat_id,
text=f"提醒:{job.data}",
)
async def remind(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if len(context.args) < 2:
await update.effective_message.reply_text("用法:/remind 30 喝水")
return
delay = int(context.args[0])
note = " ".join(context.args[1:])
chat_id = update.effective_chat.id
context.job_queue.run_once(
send_reminder,
when=delay,
chat_id=chat_id,
data=note,
name=f"remind:{chat_id}",
)
await update.effective_message.reply_text(f"已安排提醒:{note}")
app.add_handler(CommandHandler("remind", remind))
这里的重点不是“提醒功能”本身,而是把后台任务也放进 PTB 的上下文里,这样你可以直接使用 context.bot、context.job,代码不会散成两套体系。
bot 长期运行时,网络波动、用户输入异常、第三方接口失败都很常见。统一错误处理可以帮你快速定位问题。
async def on_error(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
logging.exception("Handler failed", exc_info=context.error)
if isinstance(update, Update) and update.effective_message:
await update.effective_message.reply_text(
"这一步处理失败了,我已经记录错误。"
)
app.add_error_handler(on_error)
生产环境里可以把错误发到日志系统或告警通道,但不要把 token、用户隐私、完整请求体随手打到公开日志里。
开发阶段用 polling 很方便;要接入服务化部署,webhook 更适合放在 HTTPS 入口后面。
import os
from telegram import Update
from telegram.ext import ApplicationBuilder
def main() -> None:
token = os.environ["TELEGRAM_BOT_TOKEN"]
public_url = os.environ["PUBLIC_WEBHOOK_URL"]
port = int(os.environ.get("PORT", "8443"))
app = ApplicationBuilder().token(token).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("menu", menu))
app.add_handler(CallbackQueryHandler(on_button, pattern=r"^menu:"))
app.add_error_handler(on_error)
app.run_webhook(
listen="0.0.0.0",
port=port,
url_path=token,
webhook_url=f"{public_url}/{token}",
allowed_updates=Update.ALL_TYPES,
)
if __name__ == "__main__":
main()
url_path 不要用太容易猜的路径。实际部署时,HTTPS 证书、反向代理、容器健康检查和日志采集都要一起考虑。
配置到处 os.environ[...] 会让代码难维护,可以做一个小配置类。
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class BotConfig:
token: str
public_webhook_url: str | None = None
admin_chat_id: int | None = None
def load_config() -> BotConfig:
admin_raw = os.environ.get("ADMIN_CHAT_ID")
admin_chat_id = int(admin_raw) if admin_raw else None
return BotConfig(
token=os.environ["TELEGRAM_BOT_TOKEN"],
public_webhook_url=os.environ.get("PUBLIC_WEBHOOK_URL"),
admin_chat_id=admin_chat_id,
)
后续你要切换 polling、webhook、管理员权限、外部接口地址,都可以从这一个入口拿配置。
def build_app(config: BotConfig):
app = ApplicationBuilder().token(config.token).build()
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("menu", menu))
app.add_handler(CommandHandler("remind", remind))
app.add_handler(order_conv)
app.add_handler(CallbackQueryHandler(on_button, pattern=r"^menu:"))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
app.add_error_handler(on_error)
return app
def main() -> None:
config = load_config()
app = build_app(config)
if config.public_webhook_url:
app.run_webhook(
listen="0.0.0.0",
port=int(os.environ.get("PORT", "8443")),
url_path=config.token,
webhook_url=f"{config.public_webhook_url}/{config.token}",
allowed_updates=Update.ALL_TYPES,
)
else:
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()
这个结构的好处是:handler 负责业务,config 负责配置,main 只负责装配。bot 越写越大时,这种边界会很省心。