refactor: enforce BasedPyright strict type checking (#5158)
This commit is contained in:
@@ -8,8 +8,9 @@ import time
|
||||
import unicodedata
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Awaitable, Callable, Literal, TypeAlias, TypeVar, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
@@ -17,9 +18,12 @@ from telegram import (
|
||||
BotCommand,
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
Message,
|
||||
MessageEntity,
|
||||
ReactionTypeEmoji,
|
||||
ReplyParameters,
|
||||
Update,
|
||||
User,
|
||||
)
|
||||
from telegram.error import BadRequest, NetworkError, TimedOut
|
||||
from telegram.ext import Application, CallbackQueryHandler, ContextTypes, MessageHandler, filters
|
||||
@@ -43,6 +47,12 @@ TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
|
||||
TELEGRAM_HTML_MAX_LEN = 4096
|
||||
TELEGRAM_REPLY_CONTEXT_MAX_LEN = TELEGRAM_MAX_MESSAGE_LEN # Max length for reply context in user message
|
||||
|
||||
# python-telegram-bot exposes a six-parameter Application generic. Nanobot
|
||||
# doesn't customize its context/data/job-queue types, so keep that SDK boundary
|
||||
# explicit rather than allowing unspecialized generics to spread Unknown.
|
||||
TelegramApplication: TypeAlias = Application[Any, Any, Any, Any, Any, Any]
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def _split_telegram_markdown(content: str, max_len: int) -> list[str]:
|
||||
"""Split raw Telegram Markdown without leaving fenced code blocks unbalanced."""
|
||||
@@ -218,7 +228,7 @@ def _markdown_to_telegram_html(text: str) -> str:
|
||||
|
||||
# 1. Extract and protect code blocks (preserve content from other processing)
|
||||
code_blocks: list[str] = []
|
||||
def save_code_block(m: re.Match) -> str:
|
||||
def save_code_block(m: re.Match[str]) -> str:
|
||||
code_blocks.append(m.group(1))
|
||||
return f"\x00CB{len(code_blocks) - 1}\x00"
|
||||
|
||||
@@ -247,7 +257,7 @@ def _markdown_to_telegram_html(text: str) -> str:
|
||||
|
||||
# 2. Extract and protect inline code
|
||||
inline_codes: list[str] = []
|
||||
def save_inline_code(m: re.Match) -> str:
|
||||
def save_inline_code(m: re.Match[str]) -> str:
|
||||
inline_codes.append(m.group(1))
|
||||
return f"\x00IC{len(inline_codes) - 1}\x00"
|
||||
|
||||
@@ -350,7 +360,7 @@ class _QueuedTelegramUpdate:
|
||||
|
||||
kind: Literal["command", "message"]
|
||||
update: Update
|
||||
context: Any
|
||||
context: ContextTypes.DEFAULT_TYPE
|
||||
sort_key: tuple[int, int]
|
||||
|
||||
|
||||
@@ -421,7 +431,7 @@ class TelegramChannel(BaseChannel):
|
||||
display_name = "Telegram"
|
||||
|
||||
# Commands registered with Telegram's command menu
|
||||
BOT_COMMANDS = [
|
||||
BOT_COMMANDS: list[BotCommand] = [
|
||||
BotCommand("start", "Start the bot"),
|
||||
BotCommand("new", "Start a new conversation"),
|
||||
BotCommand("stop", "Stop the current task"),
|
||||
@@ -455,19 +465,24 @@ class TelegramChannel(BaseChannel):
|
||||
config = TelegramConfig.model_validate(config)
|
||||
super().__init__(config, bus)
|
||||
self.config: TelegramConfig = config
|
||||
self._app: Application | None = None
|
||||
self._app: TelegramApplication | None = None
|
||||
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
|
||||
self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task
|
||||
self._media_group_buffers: dict[str, dict] = {}
|
||||
self._media_group_tasks: dict[str, asyncio.Task] = {}
|
||||
self._typing_tasks: dict[str, asyncio.Task[None]] = {} # chat_id -> typing loop task
|
||||
self._media_group_buffers: dict[str, dict[str, Any]] = {}
|
||||
self._media_group_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._message_threads: dict[tuple[str, int], int] = {}
|
||||
self._bot_user_id: int | None = None
|
||||
self._bot_username: str | None = None
|
||||
self._stream_bufs: dict[str, _StreamBuf] = {} # chat_id -> streaming state
|
||||
self._inbound_buffers: dict[str, list[_QueuedTelegramUpdate]] = {}
|
||||
self._inbound_workers: dict[str, asyncio.Task] = {}
|
||||
self._inbound_workers: dict[str, asyncio.Task[None]] = {}
|
||||
self._rich_send_disabled: bool = False # Latch off if Bot API < 10.1
|
||||
|
||||
def _require_app(self) -> TelegramApplication:
|
||||
if self._app is None:
|
||||
raise RuntimeError("Telegram application is not started")
|
||||
return self._app
|
||||
|
||||
def is_allowed(self, sender_id: str) -> bool:
|
||||
"""Preserve Telegram's legacy id|username allowlist matching."""
|
||||
if super().is_allowed(sender_id):
|
||||
@@ -595,7 +610,7 @@ class TelegramChannel(BaseChannel):
|
||||
if self.config.mode == "webhook":
|
||||
# ``url_path`` is the local HTTP route. ``webhook_url`` is the
|
||||
# public HTTPS URL Telegram calls; reverse proxies may rewrite it.
|
||||
await self._app.updater.start_webhook(
|
||||
await cast(Any, self._app.updater).start_webhook(
|
||||
listen=self.config.webhook_listen_host,
|
||||
port=self.config.webhook_listen_port,
|
||||
url_path=self.config.webhook_path.lstrip("/"),
|
||||
@@ -607,7 +622,7 @@ class TelegramChannel(BaseChannel):
|
||||
)
|
||||
else:
|
||||
# Start polling (this runs until stopped)
|
||||
await self._app.updater.start_polling(
|
||||
await cast(Any, self._app.updater).start_polling(
|
||||
allowed_updates=allowed_updates,
|
||||
drop_pending_updates=False, # Process pending messages on startup
|
||||
error_callback=self._on_polling_error,
|
||||
@@ -637,7 +652,7 @@ class TelegramChannel(BaseChannel):
|
||||
|
||||
if self._app:
|
||||
self.logger.info("Stopping bot...")
|
||||
await self._app.updater.stop()
|
||||
await cast(Any, self._app.updater).stop()
|
||||
await self._app.stop()
|
||||
await self._app.shutdown()
|
||||
self._app = None
|
||||
@@ -674,9 +689,9 @@ class TelegramChannel(BaseChannel):
|
||||
self,
|
||||
chat_id: int,
|
||||
content: str,
|
||||
reply_params=None,
|
||||
thread_kwargs: dict | None = None,
|
||||
reply_markup=None,
|
||||
reply_params: ReplyParameters | dict[str, int | bool] | None = None,
|
||||
thread_kwargs: dict[str, int] | None = None,
|
||||
reply_markup: InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""Attempt sendRichMessage (Bot API 10.1). Returns True on success."""
|
||||
if not self._app:
|
||||
@@ -692,13 +707,17 @@ class TelegramChannel(BaseChannel):
|
||||
# sendRichMessage uses reply_parameters (object), not reply_to_message_id.
|
||||
if hasattr(reply_params, "message_id"):
|
||||
payload["reply_parameters"] = {
|
||||
"message_id": reply_params.message_id,
|
||||
"message_id": cast(ReplyParameters, reply_params).message_id,
|
||||
"allow_sending_without_reply": True,
|
||||
}
|
||||
else:
|
||||
payload["reply_parameters"] = reply_params
|
||||
if thread_kwargs:
|
||||
payload.update({k: v for k, v in thread_kwargs.items() if v is not None})
|
||||
payload.update({
|
||||
k: v
|
||||
for k, v in thread_kwargs.items()
|
||||
if v is not None # pyright: ignore[reportUnnecessaryComparison]
|
||||
})
|
||||
if reply_markup is not None:
|
||||
payload["reply_markup"] = reply_markup
|
||||
|
||||
@@ -749,7 +768,7 @@ class TelegramChannel(BaseChannel):
|
||||
message_thread_id = msg.metadata.get("message_thread_id")
|
||||
if message_thread_id is None and reply_to_message_id is not None:
|
||||
message_thread_id = self._message_threads.get((msg.chat_id, reply_to_message_id))
|
||||
thread_kwargs = {}
|
||||
thread_kwargs: dict[str, int] = {}
|
||||
if message_thread_id is not None:
|
||||
thread_kwargs["message_thread_id"] = message_thread_id
|
||||
|
||||
@@ -820,7 +839,7 @@ class TelegramChannel(BaseChannel):
|
||||
# Send text content
|
||||
if msg.content and msg.content != "[empty message]":
|
||||
render_as_blockquote = bool(progress_event and progress_event.tool_hint)
|
||||
buttons = getattr(msg, "buttons", None) or []
|
||||
buttons = cast(list[list[str]], getattr(msg, "buttons", None) or [])
|
||||
reply_markup = self._build_keyboard(buttons) if buttons else None
|
||||
text = msg.content
|
||||
# Fallback: no native keyboard → splice labels into the message so the choices survive.
|
||||
@@ -850,7 +869,12 @@ class TelegramChannel(BaseChannel):
|
||||
reply_markup=reply_markup if is_last else None,
|
||||
)
|
||||
|
||||
async def _call_with_retry(self, fn, *args, **kwargs):
|
||||
async def _call_with_retry(
|
||||
self,
|
||||
fn: Callable[..., Awaitable[_T]],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> _T:
|
||||
"""Call an async Telegram API function with retry on pool/network timeout and RetryAfter."""
|
||||
from telegram.error import RetryAfter
|
||||
|
||||
@@ -869,27 +893,34 @@ class TelegramChannel(BaseChannel):
|
||||
except RetryAfter as e:
|
||||
if attempt == _SEND_MAX_RETRIES:
|
||||
raise
|
||||
delay = float(e.retry_after)
|
||||
retry_after = e.retry_after
|
||||
delay = (
|
||||
retry_after.total_seconds()
|
||||
if isinstance(retry_after, timedelta)
|
||||
else float(retry_after)
|
||||
)
|
||||
self.logger.warning(
|
||||
"Flood Control (attempt {}/{}), retrying in {:.1f}s",
|
||||
attempt, _SEND_MAX_RETRIES, delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
raise RuntimeError("Telegram retry loop exited unexpectedly")
|
||||
|
||||
async def _send_text(
|
||||
self,
|
||||
chat_id: int,
|
||||
text: str,
|
||||
reply_params=None,
|
||||
thread_kwargs: dict | None = None,
|
||||
reply_params: ReplyParameters | None = None,
|
||||
thread_kwargs: dict[str, int] | None = None,
|
||||
render_as_blockquote: bool = False,
|
||||
reply_markup=None,
|
||||
reply_markup: InlineKeyboardMarkup | None = None,
|
||||
) -> None:
|
||||
"""Send a plain text message with HTML fallback."""
|
||||
app = self._require_app()
|
||||
try:
|
||||
html = _tool_hint_to_telegram_blockquote(text) if render_as_blockquote else _markdown_to_telegram_html(text)
|
||||
await self._call_with_retry(
|
||||
self._app.bot.send_message,
|
||||
app.bot.send_message,
|
||||
chat_id=chat_id, text=html, parse_mode="HTML",
|
||||
reply_parameters=reply_params,
|
||||
reply_markup=reply_markup,
|
||||
@@ -899,7 +930,7 @@ class TelegramChannel(BaseChannel):
|
||||
self.logger.warning("HTML parse failed, falling back to plain text: {}", e)
|
||||
try:
|
||||
await self._call_with_retry(
|
||||
self._app.bot.send_message,
|
||||
app.bot.send_message,
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_parameters=reply_params,
|
||||
@@ -945,7 +976,7 @@ class TelegramChannel(BaseChannel):
|
||||
if reply_to_message_id := meta.get("message_id"):
|
||||
with suppress(ValueError):
|
||||
await self._remove_reaction(chat_id, int(reply_to_message_id))
|
||||
thread_kwargs = {}
|
||||
thread_kwargs: dict[str, int] = {}
|
||||
if message_thread_id := meta.get("message_thread_id"):
|
||||
thread_kwargs["message_thread_id"] = message_thread_id
|
||||
raw_text = buf.text
|
||||
@@ -1032,16 +1063,16 @@ class TelegramChannel(BaseChannel):
|
||||
return
|
||||
|
||||
now = time.monotonic()
|
||||
thread_kwargs = {}
|
||||
stream_thread_kwargs: dict[str, int] = {}
|
||||
if message_thread_id := meta.get("message_thread_id"):
|
||||
thread_kwargs["message_thread_id"] = message_thread_id
|
||||
stream_thread_kwargs["message_thread_id"] = message_thread_id
|
||||
if buf.message_id is None:
|
||||
preview = _strip_md_block(buf.text)
|
||||
try:
|
||||
sent = await self._call_with_retry(
|
||||
self._app.bot.send_message,
|
||||
chat_id=int_chat_id, text=preview,
|
||||
**thread_kwargs,
|
||||
**stream_thread_kwargs,
|
||||
)
|
||||
buf.message_id = sent.message_id
|
||||
buf.last_edit = now
|
||||
@@ -1050,7 +1081,7 @@ class TelegramChannel(BaseChannel):
|
||||
raise # Let ChannelManager handle retry
|
||||
elif (now - buf.last_edit) >= self.config.stream_edit_interval:
|
||||
if len(buf.text) > TELEGRAM_MAX_MESSAGE_LEN:
|
||||
await self._flush_stream_overflow(int_chat_id, buf, thread_kwargs)
|
||||
await self._flush_stream_overflow(int_chat_id, buf, stream_thread_kwargs)
|
||||
buf.last_edit = now
|
||||
return
|
||||
preview = _strip_md_block(buf.text)
|
||||
@@ -1072,7 +1103,7 @@ class TelegramChannel(BaseChannel):
|
||||
self,
|
||||
chat_id: int,
|
||||
buf: "_StreamBuf",
|
||||
thread_kwargs: dict,
|
||||
thread_kwargs: dict[str, int],
|
||||
) -> None:
|
||||
"""Split an oversized stream buffer mid-flight.
|
||||
|
||||
@@ -1083,10 +1114,11 @@ class TelegramChannel(BaseChannel):
|
||||
chunks = _split_telegram_markdown_html_chunks(buf.text, TELEGRAM_HTML_MAX_LEN)
|
||||
if len(chunks) <= 1:
|
||||
return
|
||||
app = self._require_app()
|
||||
first_markdown, first_html = chunks[0]
|
||||
try:
|
||||
await self._call_with_retry(
|
||||
self._app.bot.edit_message_text,
|
||||
app.bot.edit_message_text,
|
||||
chat_id=chat_id, message_id=buf.message_id,
|
||||
text=first_html,
|
||||
parse_mode="HTML",
|
||||
@@ -1098,7 +1130,7 @@ class TelegramChannel(BaseChannel):
|
||||
)
|
||||
try:
|
||||
await self._call_with_retry(
|
||||
self._app.bot.edit_message_text,
|
||||
app.bot.edit_message_text,
|
||||
chat_id=chat_id, message_id=buf.message_id,
|
||||
text=first_markdown,
|
||||
)
|
||||
@@ -1113,7 +1145,7 @@ class TelegramChannel(BaseChannel):
|
||||
async def send_chunk(markdown: str, html: str) -> Any:
|
||||
try:
|
||||
return await self._call_with_retry(
|
||||
self._app.bot.send_message,
|
||||
app.bot.send_message,
|
||||
chat_id=chat_id, text=html, parse_mode="HTML", **thread_kwargs,
|
||||
)
|
||||
except BadRequest as e:
|
||||
@@ -1121,7 +1153,7 @@ class TelegramChannel(BaseChannel):
|
||||
"Stream overflow HTML send failed, falling back to plain text: {}", e
|
||||
)
|
||||
return await self._call_with_retry(
|
||||
self._app.bot.send_message,
|
||||
app.bot.send_message,
|
||||
chat_id=chat_id, text=markdown, **thread_kwargs,
|
||||
)
|
||||
|
||||
@@ -1160,12 +1192,14 @@ class TelegramChannel(BaseChannel):
|
||||
await update.message.reply_text(build_help_text())
|
||||
|
||||
@staticmethod
|
||||
def _sender_id(user) -> str:
|
||||
def _sender_id(user: User) -> str:
|
||||
"""Build sender_id with username for allowlist matching."""
|
||||
sid = str(user.id)
|
||||
return f"{sid}|{user.username}" if user.username else sid
|
||||
|
||||
async def _send_pairing_code_if_private(self, sender_id: str, message, user) -> None:
|
||||
async def _send_pairing_code_if_private(
|
||||
self, sender_id: str, message: Message, user: User
|
||||
) -> None:
|
||||
if message.chat.type != "private":
|
||||
return
|
||||
await self._handle_message(
|
||||
@@ -1177,7 +1211,7 @@ class TelegramChannel(BaseChannel):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _derive_topic_session_key(message) -> str | None:
|
||||
def _derive_topic_session_key(message: Message) -> str | None:
|
||||
"""Derive topic-scoped session key for Telegram chats with threads."""
|
||||
message_thread_id = getattr(message, "message_thread_id", None)
|
||||
if message_thread_id is None:
|
||||
@@ -1185,7 +1219,7 @@ class TelegramChannel(BaseChannel):
|
||||
return f"telegram:{message.chat_id}:topic:{message_thread_id}"
|
||||
|
||||
@staticmethod
|
||||
def _build_message_metadata(message, user) -> dict:
|
||||
def _build_message_metadata(message: Message, user: User) -> dict[str, Any]:
|
||||
"""Build common Telegram inbound metadata payload."""
|
||||
reply_to = getattr(message, "reply_to_message", None)
|
||||
return {
|
||||
@@ -1199,7 +1233,7 @@ class TelegramChannel(BaseChannel):
|
||||
"reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None,
|
||||
}
|
||||
|
||||
async def _extract_reply_context(self, message) -> str | None:
|
||||
async def _extract_reply_context(self, message: Message) -> str | None:
|
||||
"""Extract text from the message being replied to, if any."""
|
||||
reply = getattr(message, "reply_to_message", None)
|
||||
if not reply:
|
||||
@@ -1224,7 +1258,7 @@ class TelegramChannel(BaseChannel):
|
||||
return f"[Reply to: {text}]"
|
||||
|
||||
async def _download_message_media(
|
||||
self, msg, *, add_failure_content: bool = False
|
||||
self, msg: Message, *, add_failure_content: bool = False
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Download media from a message (current or reply). Returns (media_paths, content_parts)."""
|
||||
media_file = None
|
||||
@@ -1255,7 +1289,7 @@ class TelegramChannel(BaseChannel):
|
||||
try:
|
||||
file = await self._app.bot.get_file(media_file.file_id)
|
||||
ext = self._get_extension(
|
||||
media_type,
|
||||
cast(str, media_type),
|
||||
getattr(media_file, "mime_type", None),
|
||||
getattr(media_file, "file_name", None),
|
||||
)
|
||||
@@ -1291,7 +1325,7 @@ class TelegramChannel(BaseChannel):
|
||||
@staticmethod
|
||||
def _has_mention_entity(
|
||||
text: str,
|
||||
entities,
|
||||
entities: list[MessageEntity] | None,
|
||||
bot_username: str,
|
||||
bot_id: int | None,
|
||||
) -> bool:
|
||||
@@ -1314,7 +1348,7 @@ class TelegramChannel(BaseChannel):
|
||||
return True
|
||||
return handle in text.lower()
|
||||
|
||||
async def _is_group_message_for_bot(self, message) -> bool:
|
||||
async def _is_group_message_for_bot(self, message: Message) -> bool:
|
||||
"""Allow group messages when policy is open, @mentioned, or replying to the bot."""
|
||||
if message.chat.type == "private" or self.config.group_policy == "open":
|
||||
return True
|
||||
@@ -1341,7 +1375,7 @@ class TelegramChannel(BaseChannel):
|
||||
reply_user = getattr(getattr(message, "reply_to_message", None), "from_user", None)
|
||||
return bool(bot_id and reply_user and reply_user.id == bot_id)
|
||||
|
||||
def _remember_thread_context(self, message) -> None:
|
||||
def _remember_thread_context(self, message: Message) -> None:
|
||||
"""Cache Telegram thread context by chat/message id for follow-up replies."""
|
||||
message_thread_id = getattr(message, "message_thread_id", None)
|
||||
if message_thread_id is None:
|
||||
@@ -1352,7 +1386,7 @@ class TelegramChannel(BaseChannel):
|
||||
self._message_threads.pop(next(iter(self._message_threads)))
|
||||
|
||||
@staticmethod
|
||||
def _queue_key_for_message(message) -> str:
|
||||
def _queue_key_for_message(message: Message) -> str:
|
||||
"""Return the final nanobot session key used for ordered Telegram ingress."""
|
||||
return TelegramChannel._derive_topic_session_key(message) or f"telegram:{message.chat_id}"
|
||||
|
||||
@@ -1373,6 +1407,8 @@ class TelegramChannel(BaseChannel):
|
||||
) -> None:
|
||||
"""Stage a Telegram update behind a short per-session reorder window."""
|
||||
message = update.message
|
||||
if message is None:
|
||||
return
|
||||
key = self._queue_key_for_message(message)
|
||||
self._inbound_buffers.setdefault(key, []).append(
|
||||
_QueuedTelegramUpdate(
|
||||
@@ -1432,6 +1468,8 @@ class TelegramChannel(BaseChannel):
|
||||
"""Process a queued slash command."""
|
||||
message = update.message
|
||||
user = update.effective_user
|
||||
if message is None or user is None:
|
||||
return
|
||||
sender_id = self._sender_id(user)
|
||||
if not self.is_allowed(sender_id):
|
||||
await self._send_pairing_code_if_private(sender_id, message, user)
|
||||
@@ -1469,6 +1507,8 @@ class TelegramChannel(BaseChannel):
|
||||
|
||||
message = update.message
|
||||
user = update.effective_user
|
||||
if message is None or user is None:
|
||||
return
|
||||
chat_id = message.chat_id
|
||||
sender_id = self._sender_id(user)
|
||||
if not self.is_allowed(sender_id):
|
||||
@@ -1483,8 +1523,8 @@ class TelegramChannel(BaseChannel):
|
||||
return
|
||||
|
||||
# Build content from text and/or media
|
||||
content_parts = []
|
||||
media_paths = []
|
||||
content_parts: list[str] = []
|
||||
media_paths: list[str] = []
|
||||
|
||||
# Text content
|
||||
if message.text:
|
||||
@@ -1625,8 +1665,10 @@ class TelegramChannel(BaseChannel):
|
||||
self.logger.debug("Typing indicator stopped for {}: {}", chat_id, e)
|
||||
|
||||
@staticmethod
|
||||
def _format_telegram_error(exc: Exception) -> str:
|
||||
def _format_telegram_error(exc: Exception | None) -> str:
|
||||
"""Return a short, readable error summary for logs."""
|
||||
if exc is None:
|
||||
return "None"
|
||||
text = str(exc).strip()
|
||||
if text:
|
||||
return text
|
||||
@@ -1682,7 +1724,7 @@ class TelegramChannel(BaseChannel):
|
||||
|
||||
return ""
|
||||
|
||||
def _build_keyboard(self, buttons: list) -> InlineKeyboardMarkup | None:
|
||||
def _build_keyboard(self, buttons: list[list[str]]) -> InlineKeyboardMarkup | None:
|
||||
"""Build inline keyboard markup if inline_keyboards is enabled."""
|
||||
if not buttons or not self.config.inline_keyboards:
|
||||
return None
|
||||
@@ -1711,7 +1753,8 @@ class TelegramChannel(BaseChannel):
|
||||
return
|
||||
query = update.callback_query
|
||||
user = update.effective_user
|
||||
chat_id = query.message.chat_id if query.message else None
|
||||
query_message = query.message
|
||||
chat_id = query_message.chat.id if query_message else None
|
||||
sender_id = self._sender_id(user)
|
||||
if not chat_id:
|
||||
self.logger.warning("Callback query without chat_id")
|
||||
@@ -1720,9 +1763,9 @@ class TelegramChannel(BaseChannel):
|
||||
return
|
||||
button_label = query.data or ""
|
||||
await query.answer()
|
||||
if query.message:
|
||||
if isinstance(query_message, Message):
|
||||
with suppress(Exception):
|
||||
await query.message.edit_reply_markup(reply_markup=None)
|
||||
await query_message.edit_reply_markup(reply_markup=None)
|
||||
self.logger.debug("Inline button tap from {}: {}", sender_id, button_label)
|
||||
self._start_typing(str(chat_id))
|
||||
await self._handle_message(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -1911,6 +1912,36 @@ async def test_on_message_location_with_text() -> None:
|
||||
# Tests for retry amplification fix (issue #3050)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_with_retry_accepts_timedelta_retry_after(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from telegram.error import RetryAfter
|
||||
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
|
||||
MessageBus(),
|
||||
)
|
||||
attempts = 0
|
||||
|
||||
async def retry_once() -> str:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
raise RetryAfter(timedelta(seconds=1.5))
|
||||
return "ok"
|
||||
|
||||
sleep = AsyncMock()
|
||||
monkeypatch.setenv("PTB_TIMEDELTA", "1")
|
||||
monkeypatch.setattr(
|
||||
"nanobot.channels.telegram.runtime.asyncio.sleep",
|
||||
sleep,
|
||||
)
|
||||
|
||||
assert await channel._call_with_retry(retry_once) == "ok"
|
||||
sleep.assert_awaited_once_with(1.5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_text_does_not_fallback_on_network_timeout() -> None:
|
||||
"""TimedOut should propagate immediately, NOT trigger plain-text fallback.
|
||||
@@ -2318,7 +2349,7 @@ async def test_callback_query_ignores_unauthorized_user_before_side_effects() ->
|
||||
data="Yes",
|
||||
answer=AsyncMock(),
|
||||
message=SimpleNamespace(
|
||||
chat_id=123,
|
||||
chat=SimpleNamespace(id=123),
|
||||
edit_reply_markup=AsyncMock(),
|
||||
),
|
||||
)
|
||||
@@ -2332,3 +2363,35 @@ async def test_callback_query_ignores_unauthorized_user_before_side_effects() ->
|
||||
query.answer.assert_not_awaited()
|
||||
query.message.edit_reply_markup.assert_not_awaited()
|
||||
channel._handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_query_handles_inaccessible_message() -> None:
|
||||
from telegram import Chat, InaccessibleMessage
|
||||
|
||||
channel = TelegramChannel(
|
||||
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], inline_keyboards=True),
|
||||
MessageBus(),
|
||||
)
|
||||
channel._handle_message = AsyncMock()
|
||||
channel._start_typing = lambda _chat_id: None
|
||||
|
||||
query = SimpleNamespace(
|
||||
id="cb_inaccessible",
|
||||
data="Yes",
|
||||
answer=AsyncMock(),
|
||||
message=InaccessibleMessage(
|
||||
chat=Chat(id=123, type="private"),
|
||||
message_id=456,
|
||||
),
|
||||
)
|
||||
update = SimpleNamespace(
|
||||
callback_query=query,
|
||||
effective_user=SimpleNamespace(id=12345, username="alice", first_name="Alice"),
|
||||
)
|
||||
|
||||
await channel._on_callback_query(update, None)
|
||||
|
||||
query.answer.assert_awaited_once()
|
||||
channel._handle_message.assert_awaited_once()
|
||||
assert channel._handle_message.await_args.kwargs["chat_id"] == "123"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Telegram setup validation owned by the channel package."""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
@@ -39,7 +39,7 @@ def _get_me(token: str, proxy: str | None) -> dict[str, Any]:
|
||||
response = client.get(f"https://api.telegram.org/bot{token}/getMe")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
return cast(dict[str, Any], data) if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
|
||||
|
||||
Reference in New Issue
Block a user