github-actions[bot]
Automated sync from GitHub
71e71e8
Raw
History Blame Contribute Delete
14.7 kB
"""
This is where the rubber meets the road: real Telethon events get converted
into the compat Update/Context objects the rest of the codebase (unchanged)
expects, mirroring how python-telegram-bot's Application dispatched updates.
main.py constructs one Application and calls .run(...) on it - see that file
for the top-level wiring.
"""
import asyncio
import logging
import signal
import traceback
from telethon import TelegramClient, events
from ._bot import Bot
from ._message import Message, CallbackQuery, InlineQuery, Update
from ._types import TelegramUser
from ._jobqueue import JobQueue
from .error import BadRequest
logger = logging.getLogger(__name__)
class Context:
"""Mirrors telegram.ext.CallbackContext / ContextTypes.DEFAULT_TYPE."""
def __init__(self, bot, bot_data, user_data, application, job=None):
self.bot = bot
self.bot_data = bot_data
self.user_data = user_data
self.application = application
self.job = job
@property
def job_queue(self):
return self.application.job_queue
class ContextTypes:
"""Mirrors telegram.ext.ContextTypes."""
DEFAULT_TYPE = Context
# Alias, mirrors telegram.ext.CallbackContext being importable directly too.
CallbackContext = Context
def _is_chatid_command(text: str) -> bool:
if not text:
return False
first_word = text.split()[0] if text.split() else ""
command = first_word.split("@")[0]
return command.lower() == "/chatid"
class Application:
"""Mirrors telegram.ext.Application. Owns the single TelegramClient for
the process, plus the in-memory bot_data/user_data stores PTB would
otherwise manage (this project uses no persistence backend, so plain
dicts that live for the process lifetime are an exact match)."""
def __init__(
self,
api_id: int,
api_hash: str,
bot_token: str,
session: str = "bot_session",
timezone=None,
connect_timeout: int = 20,
connection_retries: int = 5,
):
self._bot_token = bot_token
self._client = TelegramClient(
session,
api_id,
api_hash,
timeout=connect_timeout,
connection_retries=connection_retries,
# We do our own RetryAfter-based retry/backoff (matching the
# original code's explicit `except RetryAfter` handling in e.g.
# group_service.py's pin-retry logic and broadcast_service.py),
# so we don't want Telethon silently sleeping through flood
# waits before that code ever sees them.
flood_sleep_threshold=0,
)
self.bot_data = {}
self._user_data_store = {}
self.bot = Bot(self._client)
self.job_queue = JobQueue(self, timezone=timezone)
# Python's event loop only holds a *weak* reference to tasks created
# via asyncio.ensure_future/create_task - anything not referenced
# elsewhere can be garbage collected mid-execution, before it's done
# (see https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task,
# worse from Python 3.12 on). Every call site in this codebase does
# `context.application.create_task(...)` and discards the return
# value, which is exactly the pattern that's vulnerable - especially
# for anything that awaits a long asyncio.sleep() (game hint loops
# sleeping 30-60s between messages), since a longer suspension means
# a much bigger window for this to happen. This set holds a strong
# reference to every task until it finishes, matching the standard
# asyncio idiom (and what PTB's own Application.create_task does
# internally).
self._background_tasks = set()
self._regular_handler = None
self._callback_handler = None
self._chatid_handler = None
def new_context(self, update: Update = None, job=None) -> Context:
user_id = None
if update is not None and update.effective_user is not None:
user_id = update.effective_user.id
user_data = self._user_data_store.setdefault(user_id, {}) if user_id is not None else {}
return Context(bot=self.bot, bot_data=self.bot_data, user_data=user_data, application=self, job=job)
def create_task(self, coro, name: str = None):
async def _wrapped():
try:
await coro
except BadRequest as e:
message = str(e).lower()
if "cannot start conversations" in message or "invalid peer" in message:
# Same condition notification_service.py's
# send_notification_execute already treats as expected and
# non-fatal (see MIGRATION_NOTES.md) - the recipient
# hasn't been "encountered" by this bot session in any
# capacity Telethon can resolve a peer from, which real
# Bot API/PTB never had as a failure mode at all. This
# codebase has dozens of other places that also fire a
# message to a specific user as a background task (game
# results, location-change DMs, etc.) without their own
# try/except for this - rather than adding one to each
# individually, this generic wrapper (which every
# create_task() call already funnels through) is the one
# place that can catch all of them consistently.
logger.info(f"Background task {name or '<unnamed>'}: recipient unreachable, skipping ({e})")
else:
logger.exception(f"Unhandled exception in background task {name or '<unnamed>'}")
await self.bot.report_error(
f"Unhandled exception in background task {name or '<unnamed>'}:\n\n{traceback.format_exc()}"
)
except Exception:
logger.exception(f"Unhandled exception in background task {name or '<unnamed>'}")
await self.bot.report_error(
f"Unhandled exception in background task {name or '<unnamed>'}:\n\n{traceback.format_exc()}"
)
task = asyncio.ensure_future(_wrapped())
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return task
# -- wiring ---------------------------------------------------------
def set_handlers(self, regular_handler, callback_handler, chatid_handler):
self._regular_handler = regular_handler
self._callback_handler = callback_handler
self._chatid_handler = chatid_handler
def _register_event_handlers(self):
client = self._client
@client.on(events.NewMessage())
async def _on_new_message(event):
if event.out:
# A bot never receives its own sends back as an "update" to
# react to via Bot API's getUpdates/webhook; skip them here
# too so we don't reprocess our own game messages.
return
try:
message = await Message.from_telethon(event.message, client, self.bot)
except Exception:
logger.exception("tg_compat: failed to build Message from NewMessage event")
await self.bot.report_error(f"tg_compat: failed to build Message from NewMessage event:\n\n{traceback.format_exc()}")
return
update = Update(message=message, _bot=self.bot)
context = self.new_context(update=update)
if _is_chatid_command(message.text):
await self._chatid_handler(update, context)
return
await self._regular_handler(update, context)
@client.on(events.ChatAction())
async def _on_chat_action(event):
if event.action_message is None:
return
try:
message = await Message.from_chat_action(event, client, self.bot)
except Exception:
logger.exception("tg_compat: failed to build Message from ChatAction event")
await self.bot.report_error(f"tg_compat: failed to build Message from ChatAction event:\n\n{traceback.format_exc()}")
return
update = Update(message=message, _bot=self.bot)
context = self.new_context(update=update)
await self._regular_handler(update, context)
@client.on(events.CallbackQuery())
async def _on_callback_query(event):
try:
data = event.data.decode("utf-8") if isinstance(event.data, (bytes, bytearray)) else event.data
except Exception:
data = None
tl_message = None
try:
tl_message = await event.get_message()
except Exception:
logger.warning("tg_compat: could not fetch message for callback query", exc_info=True)
message = None
if tl_message is not None:
try:
# Deliberately using from_telethon's default (fetch_reply=True)
# here, not the fetch_reply=False the "we just sent this"
# methods in _bot.py use: manage_message.py re-validates
# command.only_in_reply (and allow_self_reply) against
# update.effective_message.reply_to_message on *every*
# update for that command - including the callback query
# from pressing a Yes/No confirmation button, whose
# "effective_message" is the confirmation message, not the
# original command. In a group chat, that confirmation was
# itself sent as a reply to the original .gift/.loan/etc.
# command (full_message_send's quote_if_group default) -
# so with reply_to_message actually populated, the
# original reply chain is still there to validate against
# on confirm; with fetch_reply=False it was always None
# regardless of the real reply chain, so command.only_in_reply
# rejected every confirmation for every such command with
# "This command can only be used in a reply to a message",
# even though the command *was* used correctly.
message = await Message.from_telethon(tl_message, client, self.bot)
except Exception:
logger.exception("tg_compat: failed to build Message for callback query")
await self.bot.report_error(f"tg_compat: failed to build Message for callback query:\n\n{traceback.format_exc()}")
sender = await event.get_sender()
from_user = TelegramUser.from_entity(sender, client)
callback_query = CallbackQuery(
id=str(event.query.query_id),
data=data,
message=message,
from_user=from_user,
_client=client,
)
update = Update(callback_query=callback_query, _bot=self.bot)
context = self.new_context(update=update)
await self._callback_handler(update, context)
@client.on(events.InlineQuery())
async def _on_inline_query(event):
sender = await event.get_sender()
from_user = TelegramUser.from_entity(sender, client)
inline_query = InlineQuery(id=str(event.id), query=event.text, from_user=from_user, _event=event)
update = Update(inline_query=inline_query, _bot=self.bot)
context = self.new_context(update=update)
await self._regular_handler(update, context)
async def run(self, post_init=None, drop_pending_updates: bool = True):
await self._client.start(bot_token=self._bot_token)
me = await self._client.get_me()
self.bot.id = me.id
self.bot.username = me.username
self._register_event_handlers()
if post_init is not None:
await post_init(self)
if not drop_pending_updates:
await self._client.catch_up()
logger.info(f"Bot connected as @{me.username} (id={me.id})")
# python-telegram-bot's own run_polling()/run_webhook() install signal
# handlers - by default for SIGINT, SIGTERM, and SIGABRT on non-Windows
# platforms - that stop the Application cleanly (log a message and
# return) rather than letting the interpreter's default
# SIGINT-to-KeyboardInterrupt behavior run its course. Without an
# equivalent here, any of those three signals - from Ctrl+C, or from
# whatever is supervising this process (a plain `systemctl stop` on a
# VPS, a container runtime stopping/restarting the container, a
# process manager, etc. - the mechanism is the same regardless of
# where this is hosted) - instead hit asyncio.run()'s own default
# handling: cancel the running task, wait for the CancelledError to
# propagate up through run_until_disconnected() and this coroutine,
# then re-raise the original KeyboardInterrupt - which is exactly the
# "CancelledError, then during handling of that, KeyboardInterrupt"
# traceback this produced.
#
# Handling the signal directly avoids that path entirely: disconnect()
# resolves client.disconnected on its own, so run_until_disconnected()
# below returns normally - no cancellation, no traceback.
loop = asyncio.get_running_loop()
def _request_shutdown(sig_name: str):
logger.info(f"Received {sig_name}, shutting down...")
self.create_task(self._client.disconnect(), name="shutdown-disconnect")
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGABRT):
try:
loop.add_signal_handler(sig, _request_shutdown, sig.name)
except NotImplementedError:
# add_signal_handler is POSIX-only (e.g. unavailable on the
# default Windows event loop, which is the same limitation
# PTB itself documents for its own stop_signals) - falls back
# to the interpreter's default KeyboardInterrupt behavior
# there, same as before this change.
pass
await self._client.run_until_disconnected()
logger.info("Bot stopped.")