owpengram-server/cmd/bots/ptbecho/echo.py
A 7de1766941 feat: sync ephemeral bot demo commands
Sync telesrv 172a86e (feat(bot-demo): echo ephemeral group commands).

Skipped telesrv docs changes per public sync rules.
2026-07-20 16:44:09 +08:00

352 lines
13 KiB
Python

#!/usr/bin/env python3
"""python-telegram-bot echo demo for telesrv Bot API.
This is a normal python-telegram-bot program. The only telesrv-specific part is
the custom base_url/base_file_url pair.
Example:
python cmd/bots/ptbecho/echo.py \
--token "1780243224:..." \
--base-url http://127.0.0.1:8081/bot \
--base-file-url http://127.0.0.1:8081/file/bot
With BotFather privacy enabled, supergroup bots only receive commands, replies
to the bot, mentions, or messages otherwise visible to bots. In a group, send:
/ping hello
For a command registered with ``is_ephemeral=true``, send:
/private@YourBotUsername
The incoming Bot API message has ``message_id=0`` and carries the transient
identifier in ``api_kwargs`` until python-telegram-bot exposes the Bot API 10.2
fields directly. The demo replies through the same ephemeral action window.
The same program can also send proactive messages:
python cmd/bots/ptbecho/echo.py \
--token "1780243224:..." \
--send-only \
--send-chat-id -1000000000002 \
--send-text "hello from python-telegram-bot"
"""
import argparse
import asyncio
import logging
import os
import signal
from typing import Iterable
from telegram import (
Bot,
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
ReplyKeyboardMarkup,
Update,
)
from telegram.ext import (
Application,
ApplicationBuilder,
CallbackQueryHandler,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
)
LOG = logging.getLogger("ptbecho")
def env_int(name: str) -> int | None:
raw = os.getenv(name)
if raw is None or raw == "":
return None
try:
return int(raw)
except ValueError as exc:
raise SystemExit(f"{name} must be an integer, got {raw!r}") from exc
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Echo bot demo using python-telegram-bot against telesrv.")
parser.add_argument("--token", default=os.getenv("TELESRV_BOT_TOKEN"), help="Bot token, defaults to TELESRV_BOT_TOKEN")
parser.add_argument("--base-url", default=os.getenv("TELESRV_BOT_API_BASE_URL", "http://127.0.0.1:8081/bot"))
parser.add_argument("--base-file-url", default=os.getenv("TELESRV_BOT_API_BASE_FILE_URL", "http://127.0.0.1:8081/file/bot"))
parser.add_argument("--prefix", default="echo: ")
parser.add_argument("--ephemeral-prefix", default="ephemeral echo: ")
parser.add_argument("--drop-pending", action="store_true", help="Drop pending updates before polling")
parser.add_argument("--timeout", type=int, default=30, help="getUpdates long-poll timeout seconds")
parser.add_argument(
"--send-chat-id",
type=int,
default=env_int("TELESRV_BOT_DEMO_CHAT_ID"),
help="Chat id for proactive sendMessage, defaults to TELESRV_BOT_DEMO_CHAT_ID",
)
parser.add_argument(
"--send-text",
default=os.getenv("TELESRV_BOT_DEMO_SEND_TEXT", ""),
help="Text for proactive sendMessage, defaults to TELESRV_BOT_DEMO_SEND_TEXT",
)
parser.add_argument("--send-count", type=int, default=1, help="Number of proactive messages to send")
parser.add_argument("--send-interval", type=float, default=1.0, help="Seconds between proactive sends")
parser.add_argument(
"--buttons-chat-id",
type=int,
default=env_int("TELESRV_BOT_DEMO_BUTTONS_CHAT_ID"),
help="Send reply/inline keyboard validation messages to this chat on startup",
)
parser.add_argument("--send-only", action="store_true", help="Send proactive messages and exit without polling")
parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
if not args.token:
parser.error("missing --token or TELESRV_BOT_TOKEN")
if args.send_count < 1:
parser.error("--send-count must be >= 1")
if args.send_interval < 0:
parser.error("--send-interval must be >= 0")
if args.send_text and args.send_chat_id is None:
parser.error("--send-chat-id is required when --send-text or --send-only is used")
if args.send_only and not args.send_text and args.buttons_chat_id is None:
parser.error("--send-only requires --send-text or --buttons-chat-id")
return args
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if update.effective_message is None:
return
await update.effective_message.reply_text("send /ping <text> in a group, or any text in private chat")
async def ping(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await echo(update, context)
def ephemeral_message_id(message: object) -> int | None:
"""Read a Bot API 10.2 field without depending on a PTB release cycle."""
raw = getattr(message, "ephemeral_message_id", None)
if raw is None:
raw = (getattr(message, "api_kwargs", None) or {}).get("ephemeral_message_id")
if isinstance(raw, int) and not isinstance(raw, bool) and raw > 0:
return raw
return None
async def send_echo(message: object, bot: Bot, prefix: str, ephemeral_prefix: str):
text = getattr(message, "text", None) or getattr(message, "caption", None) or ""
if not text:
return None
transient_id = ephemeral_message_id(message)
if transient_id is None:
return await message.reply_text(prefix + text)
sender = getattr(message, "from_user", None)
if sender is None:
LOG.warning("ignored ephemeral message without from_user ephemeral_message_id=%s", transient_id)
return None
return await bot.send_message(
chat_id=message.chat_id,
text=ephemeral_prefix + text,
api_kwargs={
"receiver_user_id": sender.id,
"reply_parameters": {"ephemeral_message_id": transient_id},
},
)
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if update.effective_message is None or update.effective_chat is None:
return
text = update.effective_message.text or update.effective_message.caption or ""
if not text:
return
prefix = context.application.bot_data.get("prefix", "echo: ")
ephemeral_prefix = context.application.bot_data.get("ephemeral_prefix", "ephemeral echo: ")
transient_id = ephemeral_message_id(update.effective_message)
sent = await send_echo(update.effective_message, context.bot, prefix, ephemeral_prefix)
if sent is None:
return
LOG.info(
"echoed update_id=%s chat_id=%s message_id=%s ephemeral_message_id=%s "
"sent_message_id=%s sent_ephemeral_message_id=%s text=%r",
update.update_id,
update.effective_chat.id,
update.effective_message.message_id,
transient_id,
sent.message_id,
ephemeral_message_id(sent),
text,
)
async def buttons(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if update.effective_chat is None:
return
await send_button_messages(context.bot, update.effective_chat.id)
async def callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
if query is None:
return
await query.answer("telesrv inline callback OK")
LOG.info(
"answered callback query_id=%s chat_id=%s message_id=%s data=%r",
query.id,
query.message.chat_id if query.message else None,
query.message.message_id if query.message else None,
query.data,
)
async def send_active_messages(bot: Bot, chat_id: int, text: str, count: int, interval: float) -> None:
for index in range(count):
sent = await bot.send_message(chat_id=chat_id, text=text)
LOG.info(
"sent proactive index=%s/%s chat_id=%s message_id=%s text=%r",
index + 1,
count,
chat_id,
sent.message_id,
text,
)
if index + 1 < count:
await asyncio.sleep(interval)
async def send_button_messages(bot: Bot, chat_id: int) -> None:
reply = await bot.send_message(
chat_id=chat_id,
text="TELESRV_REPLY_KEYBOARD_20260719",
reply_markup=ReplyKeyboardMarkup(
[[
KeyboardButton("Primary", api_kwargs={"style": "primary"}),
KeyboardButton("Success", api_kwargs={"style": "success"}),
KeyboardButton("Danger", api_kwargs={"style": "danger"}),
]],
resize_keyboard=True,
one_time_keyboard=True,
input_field_placeholder="Tap the reply button",
),
)
inline = await bot.send_message(
chat_id=chat_id,
text="TELESRV_INLINE_CALLBACK_20260719",
reply_markup=InlineKeyboardMarkup(
[[
InlineKeyboardButton("Primary", callback_data="telesrv-primary", api_kwargs={"style": "primary"}),
InlineKeyboardButton("Success", callback_data="telesrv-success", api_kwargs={"style": "success"}),
InlineKeyboardButton("Danger", callback_data="telesrv-danger", api_kwargs={"style": "danger"}),
]],
),
)
LOG.info(
"sent keyboard validation chat_id=%s reply_message_id=%s inline_message_id=%s",
chat_id,
reply.message_id,
inline.message_id,
)
async def send_on_startup(app: Application) -> None:
chat_id = app.bot_data.get("send_chat_id")
text = app.bot_data.get("send_text")
if chat_id is not None and text:
await send_active_messages(
app.bot,
chat_id=chat_id,
text=text,
count=int(app.bot_data.get("send_count", 1)),
interval=float(app.bot_data.get("send_interval", 1.0)),
)
buttons_chat_id = app.bot_data.get("buttons_chat_id")
if buttons_chat_id is not None:
await send_button_messages(app.bot, int(buttons_chat_id))
async def post_init(app: Application) -> None:
me = await app.bot.get_me()
LOG.info("listening as @%s (%s), bot_api=%s", me.username or me.id, me.id, app.bot_data["base_url"])
if (app.bot_data.get("send_chat_id") is not None and app.bot_data.get("send_text")) or app.bot_data.get(
"buttons_chat_id"
) is not None:
await send_on_startup(app)
def build_app(args: argparse.Namespace) -> Application:
app = (
ApplicationBuilder()
.token(args.token)
.base_url(args.base_url)
.base_file_url(args.base_file_url)
.post_init(post_init)
.build()
)
app.bot_data["prefix"] = args.prefix
app.bot_data["ephemeral_prefix"] = args.ephemeral_prefix
app.bot_data["base_url"] = args.base_url
app.bot_data["send_chat_id"] = args.send_chat_id
app.bot_data["send_text"] = args.send_text
app.bot_data["send_count"] = args.send_count
app.bot_data["send_interval"] = args.send_interval
app.bot_data["buttons_chat_id"] = args.buttons_chat_id
app.add_handler(CommandHandler("start", start))
app.add_handler(CommandHandler("ping", ping))
app.add_handler(CommandHandler("private", echo))
app.add_handler(CommandHandler("buttons", buttons))
app.add_handler(CallbackQueryHandler(callback))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
return app
async def run_send_only(args: argparse.Namespace) -> None:
bot = Bot(token=args.token, base_url=args.base_url, base_file_url=args.base_file_url)
me = await bot.get_me()
LOG.info("authenticated as @%s (%s), bot_api=%s", me.username or me.id, me.id, args.base_url)
if args.send_chat_id is not None and args.send_text:
await send_active_messages(
bot,
chat_id=args.send_chat_id,
text=args.send_text,
count=args.send_count,
interval=args.send_interval,
)
if args.buttons_chat_id is not None:
await send_button_messages(bot, args.buttons_chat_id)
def stop_signals() -> Iterable[int] | None:
if os.name == "nt":
return None
return (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
def main() -> int:
args = parse_args()
logging.basicConfig(
level=getattr(logging, args.log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
if args.send_only:
asyncio.run(run_send_only(args))
return 0
app = build_app(args)
app.run_polling(
allowed_updates=["message", "edited_message", "callback_query"],
drop_pending_updates=args.drop_pending,
poll_interval=0.0,
timeout=args.timeout,
stop_signals=stop_signals(),
)
return 0
if __name__ == "__main__":
raise SystemExit(main())