diff --git a/src/blacki/telegram/album_buffer.py b/src/blacki/telegram/album_buffer.py new file mode 100644 index 0000000..e129c9a --- /dev/null +++ b/src/blacki/telegram/album_buffer.py @@ -0,0 +1,164 @@ +"""Buffering for Telegram media-group (photo album) messages. + +Telegram delivers a multi-photo album as several separate messages that +share a ``media_group_id``, arriving in quick succession rather than as one +message. This buffers those messages, waits for a short debounce window (or +a max-wait cap, in case delivery stalls) for the rest of the album to +arrive, then hands the complete album to a caller-supplied flush callback. +""" + +import asyncio +import contextlib +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast + +from .types import ChatType, Message + +logger = logging.getLogger(__name__) + +_ALBUM_DEBOUNCE_SECONDS = 0.5 +_ALBUM_MAX_WAIT_SECONDS = 2.0 + + +@dataclass(slots=True) +class _BufferedAlbum: + """In-memory buffer for a Telegram media group (photo album).""" + + chat_id: int + message_thread_id: int | None + media_group_id: str + chat_type: ChatType | None + messages: list[Message] + debounce_handle: asyncio.TimerHandle | None = None + max_wait_task: asyncio.Task[None] | None = None + future: asyncio.Future[None] | None = None + processed: bool = False + created_seq: int = 0 + + +class AlbumBuffer: + """Buffers album messages and flushes complete albums via a callback. + + Owns no turn-processing or session logic: ``on_flush`` is called + synchronously with the completed ``_BufferedAlbum`` once its debounce + window elapses (or the max-wait cap is hit), and the caller decides how + to process it. + """ + + def __init__(self, on_flush: Callable[[_BufferedAlbum], None]) -> None: + self._on_flush = on_flush + self._buffers: dict[tuple[int, int | None, str], _BufferedAlbum] = {} + self._background_tasks: set[asyncio.Task[None]] = set() + + def get_active( + self, chat_id: int, message_thread_id: int | None + ) -> list[_BufferedAlbum]: + """Return active (not yet flushed) album buffers for a conversation.""" + return [ + album + for (cid, tid, _), album in self._buffers.items() + if cid == chat_id and tid == message_thread_id and not album.processed + ] + + async def add_message(self, message: Message, seq: int) -> None: + """Buffer an incoming album message; wait until its album is flushed.""" + chat_id = message.chat.id + message_thread_id = message.message_thread_id + media_group_id = cast(str, message.media_group_id) + key = (chat_id, message_thread_id, media_group_id) + + album = self._buffers.get(key) + if album is None: + loop = asyncio.get_running_loop() + future: asyncio.Future[None] = loop.create_future() + album = _BufferedAlbum( + chat_id=chat_id, + message_thread_id=message_thread_id, + media_group_id=media_group_id, + chat_type=message.chat.type, + messages=[message], + future=future, + created_seq=seq, + ) + self._buffers[key] = album + + max_wait_task = asyncio.create_task(self._max_wait(album)) + self._background_tasks.add(max_wait_task) + max_wait_task.add_done_callback(self._background_tasks.discard) + album.max_wait_task = max_wait_task + else: + album.messages.append(message) + if album.debounce_handle is not None: + album.debounce_handle.cancel() + + loop = asyncio.get_running_loop() + album.debounce_handle = loop.call_later( + _ALBUM_DEBOUNCE_SECONDS, + self._on_debounce_expired, + album, + ) + + try: + if album.future is not None: + await asyncio.shield(album.future) + except asyncio.CancelledError: + raise + + async def _max_wait(self, album: _BufferedAlbum) -> None: + """Flush the album after the maximum wait timeout.""" + try: + await asyncio.sleep(_ALBUM_MAX_WAIT_SECONDS) + self._flush(album) + except asyncio.CancelledError: + pass + + def _on_debounce_expired(self, album: _BufferedAlbum) -> None: + """Handle debounce timer expiration by flushing the album.""" + self._flush(album) + + def _flush(self, album: _BufferedAlbum) -> None: + """Mark an album processed, remove it from the buffer, and flush it.""" + if album.processed: + return + album.processed = True + + key = (album.chat_id, album.message_thread_id, album.media_group_id) + self._buffers.pop(key, None) + + if album.debounce_handle is not None: + album.debounce_handle.cancel() + album.debounce_handle = None + if album.max_wait_task is not None: + album.max_wait_task.cancel() + album.max_wait_task = None + + self._on_flush(album) + + def cleanup(self, album: _BufferedAlbum) -> None: + """Cancel timers and tasks and release a buffered album.""" + key = (album.chat_id, album.message_thread_id, album.media_group_id) + self._buffers.pop(key, None) + + if album.debounce_handle is not None: + album.debounce_handle.cancel() + album.debounce_handle = None + + if album.max_wait_task is not None: + album.max_wait_task.cancel() + album.max_wait_task = None + + if album.future is not None and not album.future.done(): + album.future.cancel() + + async def shutdown(self) -> None: + """Cancel and release all currently buffered albums and their tasks.""" + for album in list(self._buffers.values()): + self.cleanup(album) + + for task in list(self._background_tasks): + task.cancel() + if self._background_tasks: + with contextlib.suppress(asyncio.CancelledError): + await asyncio.gather(*self._background_tasks, return_exceptions=True) diff --git a/src/blacki/telegram/bot.py b/src/blacki/telegram/bot.py index c74edfc..27410bc 100644 --- a/src/blacki/telegram/bot.py +++ b/src/blacki/telegram/bot.py @@ -3,13 +3,12 @@ import asyncio import contextlib import logging -import os import re -from collections.abc import Sequence +from collections.abc import Coroutine, Sequence from contextvars import ContextVar from dataclasses import dataclass from pathlib import Path -from typing import cast +from typing import Any, cast from google.genai import types @@ -23,22 +22,17 @@ ) from blacki.inference import ( InferenceProfile, - ReasoningConfig, - ReasoningEffort, inference_profile_from_environment, load_inference_profile, - update_inference_profile, -) -from blacki.model_capabilities import ( - ModelCapabilities, - OpenRouterModelCapabilitiesResolver, ) from blacki.reminders.storage import Reminder from blacki.utils.preferences import get_preferences_storage from . import TelegramConfig +from .album_buffer import AlbumBuffer, _BufferedAlbum from .api import TelegramApiClient, TelegramApiError from .formatting import escape_markdown_plain, format_for_telegram +from .settings_menu import SettingsMenu from .streaming import split_long_message from .types import ( BotCommand, @@ -63,24 +57,6 @@ _DEFAULT_IMAGE_PROMPT = "Describe this image." _MAX_ALBUM_PHOTOS = 10 _MAX_ALBUM_BYTES = 20 * 1024 * 1024 -_ALBUM_DEBOUNCE_SECONDS = 0.5 -_ALBUM_MAX_WAIT_SECONDS = 2.0 - - -@dataclass(slots=True) -class _BufferedAlbum: - """In-memory buffer for a Telegram media group (photo album).""" - - chat_id: int - message_thread_id: int | None - media_group_id: str - chat_type: ChatType | None - messages: list[Message] - debounce_handle: asyncio.TimerHandle | None = None - max_wait_task: asyncio.Task[None] | None = None - future: asyncio.Future[None] | None = None - processed: bool = False - created_seq: int = 0 def _format_health_sync_result(result: SyncResult) -> str: @@ -101,39 +77,6 @@ def _format_health_sync_result(result: SyncResult) -> str: return "Google Health could not be refreshed right now. Please try again later." -MODEL_CHOICES = { - "m1": ("openrouter/openai/gpt-oss-120b", "GPT-OSS 120B"), - "m2": ("openrouter/x-ai/grok-4.3", "Grok 4.3"), - "m3": ("google/gemini-flash-latest", "Gemini Flash"), - "m4": ("openrouter/deepseek/deepseek-v4-pro", "DeepSeek v4 Pro"), - "m5": ("openrouter/deepseek/deepseek-v4-flash", "DeepSeek v4 Flash"), - "m6": ("google/gemini-pro-latest", "Gemini Pro"), - "m7": ("moonshotai/kimi-latest", "Kimi Latest"), - "m8": ("openrouter/minimax/minimax-m2.7", "MiniMax m2.7"), - "m9": ("openrouter/nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"), - "m10": ("openrouter/z-ai/glm-5", "GLM 5"), - "m11": ("openrouter/openai/gpt-5.6-luna", "GPT-5.6 Luna"), - "m_default": ("default", "System Default"), -} - -_SETTINGS_MODEL_PREFIX = "s:m:" -_SETTINGS_REASONING_PREFIX = "s:r:" -_SETTINGS_THINKING = "s:t" -_SETTINGS_BACK = "s:b" -_SETTINGS_RESET = "s:x" -_INHERIT_REASONING = "inherit" -_REASONING_LABELS = { - "inherit": "Default", - "none": "Off", - "minimal": "Minimal", - "low": "Low", - "medium": "Medium", - "high": "High", - "xhigh": "XHigh", - "max": "Max", -} - - @dataclass(slots=True, frozen=True) class TelegramSessionIdentity: """Stable Telegram identifiers used to resolve ADK sessions.""" @@ -161,13 +104,16 @@ def __init__( self._polling_task: asyncio.Task[None] | None = None self._conversation_tasks: dict[str, asyncio.Task[None]] = {} self._conversation_task_seqs: dict[str, int] = {} - self._album_buffers: dict[tuple[int, int | None, str], _BufferedAlbum] = {} + self._album_buffer = AlbumBuffer(on_flush=self._on_album_flushed) self._update_counter: int = 0 self._background_tasks: set[asyncio.Task[None]] = set() self._chat_type_context: ContextVar[ChatType | None] = ContextVar( "telegram_chat_type", default=None ) - self._capabilities_resolver: OpenRouterModelCapabilitiesResolver | None = None + self._settings_menu = SettingsMenu( + api_provider=lambda: self.api, + load_profile=self._load_chat_profile, + ) @property def api(self) -> TelegramApiClient: @@ -203,8 +149,7 @@ async def stop(self) -> None: with contextlib.suppress(asyncio.CancelledError): await self._polling_task - for album in list(self._album_buffers.values()): - self._cleanup_album_buffer(album) + await self._album_buffer.shutdown() for task in list(self._background_tasks): task.cancel() @@ -214,10 +159,7 @@ async def stop(self) -> None: await self.runtime.close() - if self._capabilities_resolver is not None: - with contextlib.suppress(Exception): - await self._capabilities_resolver.aclose() - self._capabilities_resolver = None + await self._settings_menu.aclose() if self._api is not None: await self._api.close() @@ -323,32 +265,6 @@ async def _polling_loop(self) -> None: return await asyncio.sleep(min(5 * consecutive_errors, 60)) - def _cleanup_album_buffer(self, album: _BufferedAlbum) -> None: - """Cancel and remove timers and tasks for a buffered album.""" - key = (album.chat_id, album.message_thread_id, album.media_group_id) - self._album_buffers.pop(key, None) - - if album.debounce_handle is not None: - album.debounce_handle.cancel() - album.debounce_handle = None - - if album.max_wait_task is not None: - album.max_wait_task.cancel() - album.max_wait_task = None - - if album.future is not None and not album.future.done(): - album.future.cancel() - - def _get_active_album_buffers( - self, chat_id: int, message_thread_id: int | None - ) -> list[_BufferedAlbum]: - """Return active album buffers for the given conversation.""" - return [ - album - for (cid, tid, _), album in self._album_buffers.items() - if cid == chat_id and tid == message_thread_id and not album.processed - ] - async def _safe_handle_update(self, update: Update) -> None: """Handle update concurrently and allow cancellation.""" if update.callback_query: @@ -364,7 +280,8 @@ async def _safe_handle_update(self, update: Update) -> None: # Check if this update is part of a photo album if update.message.photo and update.message.media_group_id is not None: - await self._buffer_album_message(update.message) + self._update_counter += 1 + await self._album_buffer.add_message(update.message, self._update_counter) return chat_id = update.message.chat.id @@ -377,7 +294,7 @@ async def _safe_handle_update(self, update: Update) -> None: self._update_counter += 1 current_seq = self._update_counter - for active_album in self._get_active_album_buffers(chat_id, message_thread_id): + for active_album in self._album_buffer.get_active(chat_id, message_thread_id): if active_album.future is not None: try: await asyncio.shield(active_album.future) @@ -388,13 +305,25 @@ async def _safe_handle_update(self, update: Update) -> None: except Exception as exc: logger.debug("Album buffer wait suppressed error: %s", exc) + await self._run_sequenced_turn( + conversation_key, current_seq, self._handle_update(update) + ) + + async def _run_sequenced_turn( + self, + conversation_key: str, + seq: int, + turn: Coroutine[Any, Any, None], + ) -> None: + """Run a turn, cancelling and waiting out any superseded turn first.""" existing_task = self._conversation_tasks.get(conversation_key) existing_seq = self._conversation_task_seqs.get(conversation_key, 0) - if ( + supersedes_existing = ( existing_task is not None and not existing_task.done() - and current_seq >= existing_seq - ): + and seq >= existing_seq + ) + if supersedes_existing and existing_task is not None: logger.info( "Cancelling in-flight turn for conversation %s", conversation_key ) @@ -403,18 +332,14 @@ async def _safe_handle_update(self, update: Update) -> None: current_task = asyncio.current_task() if current_task is not None: self._conversation_tasks[conversation_key] = current_task - self._conversation_task_seqs[conversation_key] = current_seq + self._conversation_task_seqs[conversation_key] = seq try: # Wait for the superseded task to fully clean up before starting - if ( - existing_task is not None - and not existing_task.done() - and current_seq >= existing_seq - ): + if supersedes_existing and existing_task is not None: await asyncio.wait([existing_task]) - await self._handle_update(update) + await turn except asyncio.CancelledError: logger.info("Message turn superseded for conversation %s", conversation_key) raise @@ -423,129 +348,25 @@ async def _safe_handle_update(self, update: Update) -> None: self._conversation_tasks.pop(conversation_key, None) self._conversation_task_seqs.pop(conversation_key, None) - async def _buffer_album_message(self, message: Message) -> None: - """Buffer an incoming album message with debounce and max-wait.""" - chat_id = message.chat.id - message_thread_id = message.message_thread_id - media_group_id = cast(str, message.media_group_id) - key = (chat_id, message_thread_id, media_group_id) - - self._update_counter += 1 - current_seq = self._update_counter - - album = self._album_buffers.get(key) - if album is None: - loop = asyncio.get_running_loop() - future: asyncio.Future[None] = loop.create_future() - album = _BufferedAlbum( - chat_id=chat_id, - message_thread_id=message_thread_id, - media_group_id=media_group_id, - chat_type=message.chat.type, - messages=[message], - future=future, - created_seq=current_seq, - ) - self._album_buffers[key] = album - - # Schedule max wait timer - max_wait_task = asyncio.create_task(self._album_max_wait(album)) - self._background_tasks.add(max_wait_task) - max_wait_task.add_done_callback(self._background_tasks.discard) - album.max_wait_task = max_wait_task - else: - album.messages.append(message) - if album.debounce_handle is not None: - album.debounce_handle.cancel() - - # Set or reset debounce timer - loop = asyncio.get_running_loop() - album.debounce_handle = loop.call_later( - _ALBUM_DEBOUNCE_SECONDS, - self._on_album_debounce_expired, - album, - ) - - try: - if album.future is not None: - await asyncio.shield(album.future) - except asyncio.CancelledError: - raise - - async def _album_max_wait(self, album: _BufferedAlbum) -> None: - """Flush album buffer after maximum wait timeout.""" - try: - await asyncio.sleep(_ALBUM_MAX_WAIT_SECONDS) - self._flush_album(album) - except asyncio.CancelledError: - pass - - def _on_album_debounce_expired(self, album: _BufferedAlbum) -> None: - """Handle debounce timer expiration by flushing the album.""" - self._flush_album(album) - - def _flush_album(self, album: _BufferedAlbum) -> None: - """Flush buffered album messages into a conversation turn.""" - if album.processed: - return - album.processed = True - - key = (album.chat_id, album.message_thread_id, album.media_group_id) - self._album_buffers.pop(key, None) - - if album.debounce_handle is not None: - album.debounce_handle.cancel() - album.debounce_handle = None - if album.max_wait_task is not None: - album.max_wait_task.cancel() - album.max_wait_task = None - + def _on_album_flushed(self, album: _BufferedAlbum) -> None: + """Schedule processing of a completed album as a background task.""" task = asyncio.create_task(self._process_flushed_album(album)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) async def _process_flushed_album(self, album: _BufferedAlbum) -> None: """Enqueue flushed album as a conversation turn, respecting cancellation.""" - chat_id = album.chat_id - message_thread_id = album.message_thread_id conversation_key = self._build_conversation_key( - chat_id=str(chat_id), - message_thread_id=message_thread_id, + chat_id=str(album.chat_id), + message_thread_id=album.message_thread_id, ) - - existing_task = self._conversation_tasks.get(conversation_key) - existing_seq = self._conversation_task_seqs.get(conversation_key, 0) - if ( - existing_task is not None - and not existing_task.done() - and album.created_seq >= existing_seq - ): - logger.info( - "Cancelling in-flight turn for conversation %s", conversation_key - ) - existing_task.cancel() - - current_task = asyncio.current_task() - if current_task is not None: - self._conversation_tasks[conversation_key] = current_task - self._conversation_task_seqs[conversation_key] = album.created_seq - try: - if ( - existing_task is not None - and not existing_task.done() - and album.created_seq >= existing_seq - ): - await asyncio.wait([existing_task]) - - await self._handle_album_turn(album) - except asyncio.CancelledError: - logger.info("Message turn superseded for conversation %s", conversation_key) - raise + await self._run_sequenced_turn( + conversation_key, + album.created_seq, + self._handle_album_turn(album), + ) finally: - if self._conversation_tasks.get(conversation_key) is current_task: - self._conversation_tasks.pop(conversation_key, None) - self._conversation_task_seqs.pop(conversation_key, None) if album.future is not None and not album.future.done(): album.future.set_result(None) @@ -623,18 +444,7 @@ async def _handle_album_turn(self, album: _BufferedAlbum) -> None: total_downloaded_bytes = 0 for file_id, _ in photo_items: - file_info = await self.api.get_file(file_id) - file_path_api = file_info.get("file_path") - if not file_path_api: - raise ValueError("Telegram did not return a photo file path") - - image_bytes = await self.api.download_file(file_path_api) - if not image_bytes: - raise ValueError("Telegram returned an empty photo") - if len(image_bytes) > _MAX_NATIVE_IMAGE_BYTES: - raise ValueError("Telegram photo exceeds the 10 MB limit") - if not image_bytes.startswith(_JPEG_MAGIC): - raise ValueError("Telegram photo is not a JPEG image") + image_bytes = await self._download_and_validate_photo(file_id) total_downloaded_bytes += len(image_bytes) if total_downloaded_bytes > _MAX_ALBUM_BYTES: @@ -650,18 +460,13 @@ async def _handle_album_turn(self, album: _BufferedAlbum) -> None: types.Part.from_bytes(data=img_bytes, mime_type="image/jpeg") ) - profile = await self._load_chat_profile(chat_id) - final_response = await self._run_user_turn_with_retry( + await self._run_turn_and_send_response( session_identity=session_identity, - message_text=prompt, - state=state, - user_parts=parts, - inference_profile=profile, - ) - await self._send_final_response( chat_id=chat_id, message_thread_id=message_thread_id, - response_text=final_response, + state=state, + message_text=prompt, + user_parts=parts, ) except Exception: logger.exception("Failed to handle Telegram photo album") @@ -775,18 +580,7 @@ async def _handle_photo_upload( action="typing", message_thread_id=message_thread_id, ) - file_info = await self.api.get_file(file_id) - file_path_api = file_info.get("file_path") - if not file_path_api: - raise ValueError("Telegram did not return a photo file path") - - image_bytes = await self.api.download_file(file_path_api) - if not image_bytes: - raise ValueError("Telegram returned an empty photo") - if len(image_bytes) > _MAX_NATIVE_IMAGE_BYTES: - raise ValueError("Telegram photo exceeds the 10 MB limit") - if not image_bytes.startswith(_JPEG_MAGIC): - raise ValueError("Telegram photo is not a JPEG image") + image_bytes = await self._download_and_validate_photo(file_id) prompt = ( caption.strip() @@ -797,18 +591,13 @@ async def _handle_photo_upload( types.Part.from_text(text=prompt), types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg"), ) - profile = await self._load_chat_profile(chat_id) - final_response = await self._run_user_turn_with_retry( + await self._run_turn_and_send_response( session_identity=session_identity, - message_text=prompt, - state=state, - user_parts=user_parts, - inference_profile=profile, - ) - await self._send_final_response( chat_id=chat_id, message_thread_id=message_thread_id, - response_text=final_response, + state=state, + message_text=prompt, + user_parts=user_parts, ) except Exception: logger.exception("Failed to handle Telegram photo") @@ -832,6 +621,22 @@ async def _send_photo_error( message_thread_id=message_thread_id, ) + async def _download_and_validate_photo(self, file_id: str) -> bytes: + """Download a Telegram photo and validate its size and format.""" + file_info = await self.api.get_file(file_id) + file_path_api = file_info.get("file_path") + if not file_path_api: + raise ValueError("Telegram did not return a photo file path") + + image_bytes = await self.api.download_file(file_path_api) + if not image_bytes: + raise ValueError("Telegram returned an empty photo") + if len(image_bytes) > _MAX_NATIVE_IMAGE_BYTES: + raise ValueError("Telegram photo exceeds the 10 MB limit") + if not image_bytes.startswith(_JPEG_MAGIC): + raise ValueError("Telegram photo is not a JPEG image") + return image_bytes + async def _handle_command(self, message: Message, command: str) -> None: """Handle a command message.""" chat_id = message.chat.id @@ -843,9 +648,13 @@ async def _handle_command(self, message: Message, command: str) -> None: elif command == "/reset": await self._handle_reset(chat_id, message.message_thread_id) elif command == "/model": - await self._send_model_menu(chat_id, message.message_thread_id) + await self._settings_menu.send_model_menu( + chat_id, message.message_thread_id + ) elif command == "/thinking": - await self._send_thinking_menu(chat_id, message.message_thread_id) + await self._settings_menu.send_thinking_menu( + chat_id, message.message_thread_id + ) elif command == "/connect_health": await self._connect_health(message) elif command == "/health_summary": @@ -1045,290 +854,13 @@ async def notify_health_connection( protect_content=True, ) - async def _send_model_menu( - self, chat_id: int, message_thread_id: int | None - ) -> None: - """Send the compact model-and-thinking settings panel.""" - profile = await self._load_chat_profile(chat_id) - text, reply_markup = self._build_model_menu(profile) - try: - await self.api.send_message( - chat_id=chat_id, - text=text, - parse_mode=ParseMode.MARKDOWN_V2, - message_thread_id=message_thread_id, - reply_markup=reply_markup, - ) - except Exception: - logger.exception("Failed to send model menu") - - async def _send_thinking_menu( - self, chat_id: int, message_thread_id: int | None - ) -> None: - """Send the reasoning-effort menu for the effective model.""" - profile = await self._load_chat_profile(chat_id) - text, reply_markup = await self._build_thinking_menu(profile) - try: - await self.api.send_message( - chat_id=chat_id, - text=text, - parse_mode=ParseMode.MARKDOWN_V2, - message_thread_id=message_thread_id, - reply_markup=reply_markup, - ) - except Exception: - logger.exception("Failed to send thinking menu") - - def _build_model_menu( - self, profile: InferenceProfile - ) -> tuple[str, InlineKeyboardMarkup]: - """Build the model menu without performing network I/O.""" - effective_model = self._effective_model(profile) - current_display_name = self._model_display_name(effective_model) - current_thinking = self._reasoning_display(profile) - - buttons: list[list[InlineKeyboardButton]] = [] - row: list[InlineKeyboardButton] = [] - for key, (_, display_name) in MODEL_CHOICES.items(): - row.append( - InlineKeyboardButton( - text=display_name, - callback_data=f"{_SETTINGS_MODEL_PREFIX}{key}", - ) - ) - if len(row) == 2: - buttons.append(row) - row = [] - if row: - buttons.append(row) - - buttons.append( - [ - InlineKeyboardButton( - text=f"🧠 Thinking: {current_thinking}", - callback_data=_SETTINGS_THINKING, - ) - ] - ) - buttons.append( - [ - InlineKeyboardButton( - text="↩️ Reset settings", callback_data=_SETTINGS_RESET - ) - ] - ) - - text = format_for_telegram( - "⚙️ **Inference settings**\n\n" - f"Model: **{current_display_name}**\n" - f"Thinking: **{current_thinking}**\n\n" - "Choose a model or adjust Thinking. Changes apply to the next turn." - ) - return text, InlineKeyboardMarkup(inline_keyboard=buttons) - - async def _build_thinking_menu( - self, profile: InferenceProfile - ) -> tuple[str, InlineKeyboardMarkup]: - """Build a capability-aware reasoning menu.""" - effective_model = self._effective_model(profile) - capability = await self._resolve_capabilities(effective_model) - options = self._reasoning_options(capability) - current = self._reasoning_display(profile) - - buttons: list[list[InlineKeyboardButton]] = [] - row: list[InlineKeyboardButton] = [] - for value, label in options: - row.append( - InlineKeyboardButton( - text=f"{label}{' ✓' if label == current else ''}", - callback_data=f"{_SETTINGS_REASONING_PREFIX}{value}", - ) - ) - if len(row) == 2: - buttons.append(row) - row = [] - if row: - buttons.append(row) - buttons.append( - [ - InlineKeyboardButton( - text="⬅️ Back to settings", callback_data=_SETTINGS_BACK - ) - ] - ) - - if capability is None or capability.reasoning is None: - note = ( - "Thinking controls are not published for this model. " - "Only the provider default is available." - ) - elif not capability.reasoning.supports_effort: - note = "This model does not expose effort controls." - else: - note = "Only options supported by the selected model are shown." - - text = format_for_telegram( - f"🧠 **Thinking for {self._model_display_name(effective_model)}**\n\n" - f"Current: **{current}**\n" - f"{note}" - ) - return text, InlineKeyboardMarkup(inline_keyboard=buttons) - async def _handle_callback_query(self, query: CallbackQuery) -> None: """Handle incoming callback query.""" data = query.data or "" if data.startswith("health:"): await self._handle_health_callback(query) return - action, value = self._parse_settings_callback(data) - if action is None: - await self.api.answer_callback_query(query.id, text="Unknown action") - return - - if action == "model" and (value is None or value not in MODEL_CHOICES): - await self.api.answer_callback_query(query.id, text="Unknown model") - return - if action == "reasoning" and value not in { - _INHERIT_REASONING, - *tuple(_REASONING_LABELS), - }: - await self.api.answer_callback_query( - query.id, text="Unknown thinking option" - ) - return - - if query.message is None: - await self.api.answer_callback_query(query.id, text="Settings expired") - return - - chat_id = query.message.chat.id - await self.api.answer_callback_query(query.id, text="Updating settings…") - - try: - storage = get_preferences_storage() - if action == "model": - model_id, _ = MODEL_CHOICES[cast(str, value)] - await update_inference_profile( - storage, - str(chat_id), - { - "model": None if model_id == "default" else model_id, - "reasoning": None, - }, - ) - await self._edit_model_menu(query, chat_id) - return - - if action == "reasoning": - profile = await self._load_chat_profile(chat_id) - capability = await self._resolve_capabilities( - self._effective_model(profile) - ) - supported = { - option for option, _ in self._reasoning_options(capability) - } - if value not in supported: - await self._edit_error( - query, - chat_id, - "That thinking option is not available for this model.", - ) - return - reasoning = self._reasoning_config(value) - await update_inference_profile( - storage, - str(chat_id), - {"reasoning": reasoning}, - base_profile=profile, - ) - await self._edit_model_menu(query, chat_id) - return - - if action == "reset": - await update_inference_profile( - storage, - str(chat_id), - {"model": None, "reasoning": None}, - ) - await self._edit_model_menu(query, chat_id) - return - - if action == "thinking": - profile = await self._load_chat_profile(chat_id) - text, markup = await self._build_thinking_menu(profile) - await self.api.edit_message_text( - chat_id=chat_id, - message_id=query.message.message_id, - text=text, - parse_mode=ParseMode.MARKDOWN_V2, - reply_markup=markup, - ) - return - - # All other parsed actions return above, so the only remaining - # valid action is Back. - await self._edit_model_menu(query, chat_id) - except Exception: - logger.exception("Failed to update Telegram inference settings") - await self._edit_error( - query, chat_id, "Could not save settings. Please try again." - ) - - @staticmethod - def _parse_settings_callback(data: str) -> tuple[str | None, str | None]: - """Parse current and legacy callback payloads.""" - if data.startswith("mod:"): - return "model", data.removeprefix("mod:") - if data.startswith(_SETTINGS_MODEL_PREFIX): - return "model", data.removeprefix(_SETTINGS_MODEL_PREFIX) - if data.startswith(_SETTINGS_REASONING_PREFIX): - return "reasoning", data.removeprefix(_SETTINGS_REASONING_PREFIX) - if data == _SETTINGS_THINKING: - return "thinking", None - if data == _SETTINGS_BACK: - return "back", None - if data == _SETTINGS_RESET: - return "reset", None - return None, None - - async def _edit_model_menu(self, query: CallbackQuery, chat_id: int) -> None: - """Render the settings panel into an existing callback message.""" - if query.message is None: - return - profile = await self._load_chat_profile(chat_id) - text, markup = self._build_model_menu(profile) - await self.api.edit_message_text( - chat_id=chat_id, - message_id=query.message.message_id, - text=text, - parse_mode=ParseMode.MARKDOWN_V2, - reply_markup=markup, - ) - - async def _edit_error( - self, query: CallbackQuery, chat_id: int, message: str - ) -> None: - """Show a recoverable settings error while retaining a back action.""" - if query.message is None: - return - try: - await self.api.edit_message_text( - chat_id=chat_id, - message_id=query.message.message_id, - text=format_for_telegram(f"⚠️ {message}"), - parse_mode=ParseMode.MARKDOWN_V2, - reply_markup=InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="⬅️ Back to settings", callback_data=_SETTINGS_BACK - ) - ] - ] - ), - ) - except Exception: - logger.exception("Failed to render Telegram settings error") + await self._settings_menu.handle_callback(query) async def _load_chat_profile(self, chat_id: int | str) -> InferenceProfile: """Load a profile snapshot, retaining the process fallback on errors.""" @@ -1345,89 +877,6 @@ async def _load_chat_profile(self, chat_id: int | str) -> InferenceProfile: else inference_profile_from_environment() ) - async def _resolve_capabilities( - self, model_id: str | None - ) -> ModelCapabilities | None: - """Resolve OpenRouter reasoning metadata without blocking turns.""" - if not model_id or model_id == "default": - return None - try: - if self._capabilities_resolver is None: - self._capabilities_resolver = OpenRouterModelCapabilitiesResolver() - return await self._capabilities_resolver.resolve( - model_id, - openrouter_routed=bool(os.getenv("OPENROUTER_API_KEY")), - ) - except Exception: - logger.exception("Failed to resolve model capabilities for %s", model_id) - return None - - @staticmethod - def _effective_model(profile: InferenceProfile) -> str: - """Resolve the profile model, then the process-wide model setting.""" - return profile.model or os.getenv("ROOT_AGENT_MODEL") or "default" - - @staticmethod - def _model_display_name(model_id: str) -> str: - """Return a friendly label while preserving unknown model IDs.""" - for configured_id, display_name in MODEL_CHOICES.values(): - if configured_id == model_id: - return display_name - if model_id == "default": - return "System Default" - return model_id.rsplit("/", 1)[-1] - - @staticmethod - def _effort_value(value: object) -> str | None: - """Normalize enum or string effort values for Telegram labels.""" - raw = getattr(value, "value", value) - return raw.strip().lower() if isinstance(raw, str) and raw.strip() else None - - def _reasoning_display(self, profile: InferenceProfile) -> str: - """Render the profile's current reasoning setting.""" - reasoning = profile.reasoning - if reasoning is None: - return _REASONING_LABELS[_INHERIT_REASONING] - value = self._effort_value(reasoning.effort) - if value is None: - return _REASONING_LABELS[_INHERIT_REASONING] - return _REASONING_LABELS.get(value, value.title()) - - def _reasoning_options( - self, capability: ModelCapabilities | None - ) -> list[tuple[str, str]]: - """Return default plus only the effort values the model supports.""" - options: list[tuple[str, str]] = [ - (_INHERIT_REASONING, _REASONING_LABELS[_INHERIT_REASONING]) - ] - reasoning = getattr(capability, "reasoning", None) - if reasoning is None or not reasoning.supports_effort: - return options - - supported = reasoning.supported_efforts - if supported is None: - supported = tuple(_REASONING_LABELS) - for effort in supported: - value = self._effort_value(effort) - if value is None or value == _INHERIT_REASONING: - continue - if value == "none" and reasoning.mandatory: - continue - label = _REASONING_LABELS.get(value, value.title()) - options.append((value, label)) - return options - - @staticmethod - def _reasoning_config(value: str) -> ReasoningConfig | None: - """Convert a Telegram value into the typed profile update.""" - if value == _INHERIT_REASONING: - return None - try: - effort = ReasoningEffort(value) - except ValueError: - return None - return ReasoningConfig(effort=effort) - async def _send_start_message(self, chat_id: int) -> None: """Send the start/welcome message.""" health_commands = "" @@ -1607,17 +1056,12 @@ async def _handle_file_upload( message_thread_id=message_thread_id, ) - final_response = await self._run_user_turn_with_retry( + await self._run_turn_and_send_response( session_identity=session_identity, - message_text=user_message, - state=state, - inference_profile=await self._load_chat_profile(chat_id), - ) - - await self._send_final_response( chat_id=chat_id, message_thread_id=message_thread_id, - response_text=final_response, + state=state, + message_text=user_message, ) except Exception: @@ -1696,6 +1140,31 @@ async def _run_user_turn_with_retry( retry_count, ) + async def _run_turn_and_send_response( + self, + *, + session_identity: TelegramSessionIdentity, + chat_id: int, + message_thread_id: int | None, + state: dict[str, str], + message_text: str, + user_parts: Sequence[types.Part] | None = None, + ) -> None: + """Load the chat profile, run a turn with retry, and send the response.""" + profile = await self._load_chat_profile(chat_id) + final_response = await self._run_user_turn_with_retry( + session_identity=session_identity, + message_text=message_text, + state=state, + user_parts=user_parts, + inference_profile=profile, + ) + await self._send_final_response( + chat_id=chat_id, + message_thread_id=message_thread_id, + response_text=final_response, + ) + async def _handle_message( self, chat_id: int, @@ -1724,17 +1193,12 @@ async def _handle_message( conversation_key=session_identity.conversation_key, chat_type=chat_type or self._chat_type_context.get(), ) - profile = await self._load_chat_profile(chat_id) - final_response = await self._run_user_turn_with_retry( + await self._run_turn_and_send_response( session_identity=session_identity, - message_text=user_message, - state=state, - inference_profile=profile, - ) - await self._send_final_response( chat_id=chat_id, message_thread_id=message_thread_id, - response_text=final_response, + state=state, + message_text=user_message, ) logger.info("Sent ADK response to chat %s", chat_id) @@ -1792,17 +1256,12 @@ async def handle_scheduled_reminder(self, reminder: Reminder) -> None: message_thread_id=message_thread_id, conversation_key=session_identity.conversation_key, ) - profile = await self._load_chat_profile(chat_id_str) - final_response = await self._run_user_turn_with_retry( + await self._run_turn_and_send_response( session_identity=session_identity, - message_text=f"[Scheduled Event] {reminder.message}", - state=state, - inference_profile=profile, - ) - await self._send_final_response( chat_id=chat_id, message_thread_id=message_thread_id, - response_text=final_response, + state=state, + message_text=f"[Scheduled Event] {reminder.message}", ) except Exception: logger.exception( diff --git a/src/blacki/telegram/settings_menu.py b/src/blacki/telegram/settings_menu.py new file mode 100644 index 0000000..d7ea1ae --- /dev/null +++ b/src/blacki/telegram/settings_menu.py @@ -0,0 +1,449 @@ +"""Inline-keyboard settings UI for choosing model and reasoning effort.""" + +import contextlib +import logging +import os +from collections.abc import Awaitable, Callable, Sequence +from typing import cast + +from blacki.inference import ( + InferenceProfile, + ReasoningConfig, + ReasoningEffort, + update_inference_profile, +) +from blacki.model_capabilities import ( + ModelCapabilities, + OpenRouterModelCapabilitiesResolver, +) +from blacki.utils.preferences import get_preferences_storage + +from .api import TelegramApiClient +from .formatting import format_for_telegram +from .types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, ParseMode + +logger = logging.getLogger(__name__) + +MODEL_CHOICES = { + "m1": ("openrouter/openai/gpt-oss-120b", "GPT-OSS 120B"), + "m2": ("openrouter/x-ai/grok-4.3", "Grok 4.3"), + "m3": ("google/gemini-flash-latest", "Gemini Flash"), + "m4": ("openrouter/deepseek/deepseek-v4-pro", "DeepSeek v4 Pro"), + "m5": ("openrouter/deepseek/deepseek-v4-flash", "DeepSeek v4 Flash"), + "m6": ("google/gemini-pro-latest", "Gemini Pro"), + "m7": ("moonshotai/kimi-latest", "Kimi Latest"), + "m8": ("openrouter/minimax/minimax-m2.7", "MiniMax m2.7"), + "m9": ("openrouter/nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"), + "m10": ("openrouter/z-ai/glm-5", "GLM 5"), + "m11": ("openrouter/openai/gpt-5.6-luna", "GPT-5.6 Luna"), + "m_default": ("default", "System Default"), +} + +_SETTINGS_MODEL_PREFIX = "s:m:" +_SETTINGS_REASONING_PREFIX = "s:r:" +_SETTINGS_THINKING = "s:t" +_SETTINGS_BACK = "s:b" +_SETTINGS_RESET = "s:x" +_INHERIT_REASONING = "inherit" +_REASONING_LABELS = { + "inherit": "Default", + "none": "Off", + "minimal": "Minimal", + "low": "Low", + "medium": "Medium", + "high": "High", + "xhigh": "XHigh", + "max": "Max", +} + +ProfileLoader = Callable[[int | str], Awaitable[InferenceProfile]] + + +class SettingsMenu: + """Inline-keyboard settings UI for choosing model and reasoning effort. + + Owns no Telegram transport or session state of its own: it renders and + reacts to the `/model` and `/thinking` settings panels via an API client + obtained from ``api_provider`` and a chat's inference profile obtained + from ``load_profile``. + """ + + def __init__( + self, + api_provider: Callable[[], TelegramApiClient], + load_profile: ProfileLoader, + ) -> None: + self._api_provider = api_provider + self._load_profile = load_profile + self._capabilities_resolver: OpenRouterModelCapabilitiesResolver | None = None + + @property + def _api(self) -> TelegramApiClient: + return self._api_provider() + + async def aclose(self) -> None: + """Release the cached model-capabilities resolver, if any.""" + if self._capabilities_resolver is not None: + with contextlib.suppress(Exception): + await self._capabilities_resolver.aclose() + self._capabilities_resolver = None + + async def send_model_menu( + self, chat_id: int, message_thread_id: int | None + ) -> None: + """Send the compact model-and-thinking settings panel.""" + profile = await self._load_profile(chat_id) + text, reply_markup = self._build_model_menu(profile) + try: + await self._api.send_message( + chat_id=chat_id, + text=text, + parse_mode=ParseMode.MARKDOWN_V2, + message_thread_id=message_thread_id, + reply_markup=reply_markup, + ) + except Exception: + logger.exception("Failed to send model menu") + + async def send_thinking_menu( + self, chat_id: int, message_thread_id: int | None + ) -> None: + """Send the reasoning-effort menu for the effective model.""" + profile = await self._load_profile(chat_id) + text, reply_markup = await self._build_thinking_menu(profile) + try: + await self._api.send_message( + chat_id=chat_id, + text=text, + parse_mode=ParseMode.MARKDOWN_V2, + message_thread_id=message_thread_id, + reply_markup=reply_markup, + ) + except Exception: + logger.exception("Failed to send thinking menu") + + @staticmethod + def _chunk_buttons( + buttons: Sequence[InlineKeyboardButton], per_row: int = 2 + ) -> list[list[InlineKeyboardButton]]: + """Group flat buttons into keyboard rows of a fixed width.""" + return [list(buttons[i : i + per_row]) for i in range(0, len(buttons), per_row)] + + def _build_model_menu( + self, profile: InferenceProfile + ) -> tuple[str, InlineKeyboardMarkup]: + """Build the model menu without performing network I/O.""" + effective_model = self._effective_model(profile) + current_display_name = self._model_display_name(effective_model) + current_thinking = self._reasoning_display(profile) + + model_buttons = [ + InlineKeyboardButton( + text=display_name, + callback_data=f"{_SETTINGS_MODEL_PREFIX}{key}", + ) + for key, (_, display_name) in MODEL_CHOICES.items() + ] + buttons = self._chunk_buttons(model_buttons) + + buttons.append( + [ + InlineKeyboardButton( + text=f"🧠 Thinking: {current_thinking}", + callback_data=_SETTINGS_THINKING, + ) + ] + ) + buttons.append( + [ + InlineKeyboardButton( + text="↩️ Reset settings", callback_data=_SETTINGS_RESET + ) + ] + ) + + text = format_for_telegram( + "⚙️ **Inference settings**\n\n" + f"Model: **{current_display_name}**\n" + f"Thinking: **{current_thinking}**\n\n" + "Choose a model or adjust Thinking. Changes apply to the next turn." + ) + return text, InlineKeyboardMarkup(inline_keyboard=buttons) + + async def _build_thinking_menu( + self, profile: InferenceProfile + ) -> tuple[str, InlineKeyboardMarkup]: + """Build a capability-aware reasoning menu.""" + effective_model = self._effective_model(profile) + capability = await self._resolve_capabilities(effective_model) + options = self._reasoning_options(capability) + current = self._reasoning_display(profile) + + reasoning_buttons = [ + InlineKeyboardButton( + text=f"{label}{' ✓' if label == current else ''}", + callback_data=f"{_SETTINGS_REASONING_PREFIX}{value}", + ) + for value, label in options + ] + buttons = self._chunk_buttons(reasoning_buttons) + buttons.append( + [ + InlineKeyboardButton( + text="⬅️ Back to settings", callback_data=_SETTINGS_BACK + ) + ] + ) + + if capability is None or capability.reasoning is None: + note = ( + "Thinking controls are not published for this model. " + "Only the provider default is available." + ) + elif not capability.reasoning.supports_effort: + note = "This model does not expose effort controls." + else: + note = "Only options supported by the selected model are shown." + + text = format_for_telegram( + f"🧠 **Thinking for {self._model_display_name(effective_model)}**\n\n" + f"Current: **{current}**\n" + f"{note}" + ) + return text, InlineKeyboardMarkup(inline_keyboard=buttons) + + async def handle_callback(self, query: CallbackQuery) -> None: + """Handle a settings callback query (model, thinking, back, or reset).""" + data = query.data or "" + action, value = self._parse_settings_callback(data) + if action is None: + await self._api.answer_callback_query(query.id, text="Unknown action") + return + + if action == "model" and (value is None or value not in MODEL_CHOICES): + await self._api.answer_callback_query(query.id, text="Unknown model") + return + if action == "reasoning" and value not in { + _INHERIT_REASONING, + *tuple(_REASONING_LABELS), + }: + await self._api.answer_callback_query( + query.id, text="Unknown thinking option" + ) + return + + if query.message is None: + await self._api.answer_callback_query(query.id, text="Settings expired") + return + + chat_id = query.message.chat.id + await self._api.answer_callback_query(query.id, text="Updating settings…") + + try: + storage = get_preferences_storage() + if action == "model": + model_id, _ = MODEL_CHOICES[cast(str, value)] + await update_inference_profile( + storage, + str(chat_id), + { + "model": None if model_id == "default" else model_id, + "reasoning": None, + }, + ) + await self._edit_model_menu(query, chat_id) + return + + if action == "reasoning": + profile = await self._load_profile(chat_id) + capability = await self._resolve_capabilities( + self._effective_model(profile) + ) + supported = { + option for option, _ in self._reasoning_options(capability) + } + if value not in supported: + await self._edit_error( + query, + chat_id, + "That thinking option is not available for this model.", + ) + return + reasoning = self._reasoning_config(value) + await update_inference_profile( + storage, + str(chat_id), + {"reasoning": reasoning}, + base_profile=profile, + ) + await self._edit_model_menu(query, chat_id) + return + + if action == "reset": + await update_inference_profile( + storage, + str(chat_id), + {"model": None, "reasoning": None}, + ) + await self._edit_model_menu(query, chat_id) + return + + if action == "thinking": + profile = await self._load_profile(chat_id) + text, markup = await self._build_thinking_menu(profile) + await self._api.edit_message_text( + chat_id=chat_id, + message_id=query.message.message_id, + text=text, + parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=markup, + ) + return + + # All other parsed actions return above, so the only remaining + # valid action is Back. + await self._edit_model_menu(query, chat_id) + except Exception: + logger.exception("Failed to update Telegram inference settings") + await self._edit_error( + query, chat_id, "Could not save settings. Please try again." + ) + + @staticmethod + def _parse_settings_callback(data: str) -> tuple[str | None, str | None]: + """Parse current and legacy callback payloads.""" + if data.startswith("mod:"): + return "model", data.removeprefix("mod:") + if data.startswith(_SETTINGS_MODEL_PREFIX): + return "model", data.removeprefix(_SETTINGS_MODEL_PREFIX) + if data.startswith(_SETTINGS_REASONING_PREFIX): + return "reasoning", data.removeprefix(_SETTINGS_REASONING_PREFIX) + if data == _SETTINGS_THINKING: + return "thinking", None + if data == _SETTINGS_BACK: + return "back", None + if data == _SETTINGS_RESET: + return "reset", None + return None, None + + async def _edit_model_menu(self, query: CallbackQuery, chat_id: int) -> None: + """Render the settings panel into an existing callback message.""" + if query.message is None: + return + profile = await self._load_profile(chat_id) + text, markup = self._build_model_menu(profile) + await self._api.edit_message_text( + chat_id=chat_id, + message_id=query.message.message_id, + text=text, + parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=markup, + ) + + async def _edit_error( + self, query: CallbackQuery, chat_id: int, message: str + ) -> None: + """Show a recoverable settings error while retaining a back action.""" + if query.message is None: + return + try: + await self._api.edit_message_text( + chat_id=chat_id, + message_id=query.message.message_id, + text=format_for_telegram(f"⚠️ {message}"), + parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="⬅️ Back to settings", callback_data=_SETTINGS_BACK + ) + ] + ] + ), + ) + except Exception: + logger.exception("Failed to render Telegram settings error") + + async def _resolve_capabilities( + self, model_id: str | None + ) -> ModelCapabilities | None: + """Resolve OpenRouter reasoning metadata without blocking turns.""" + if not model_id or model_id == "default": + return None + try: + if self._capabilities_resolver is None: + self._capabilities_resolver = OpenRouterModelCapabilitiesResolver() + return await self._capabilities_resolver.resolve( + model_id, + openrouter_routed=bool(os.getenv("OPENROUTER_API_KEY")), + ) + except Exception: + logger.exception("Failed to resolve model capabilities for %s", model_id) + return None + + @staticmethod + def _effective_model(profile: InferenceProfile) -> str: + """Resolve the profile model, then the process-wide model setting.""" + return profile.model or os.getenv("ROOT_AGENT_MODEL") or "default" + + @staticmethod + def _model_display_name(model_id: str) -> str: + """Return a friendly label while preserving unknown model IDs.""" + for configured_id, display_name in MODEL_CHOICES.values(): + if configured_id == model_id: + return display_name + if model_id == "default": + return "System Default" + return model_id.rsplit("/", 1)[-1] + + @staticmethod + def _effort_value(value: object) -> str | None: + """Normalize enum or string effort values for Telegram labels.""" + raw = getattr(value, "value", value) + return raw.strip().lower() if isinstance(raw, str) and raw.strip() else None + + def _reasoning_display(self, profile: InferenceProfile) -> str: + """Render the profile's current reasoning setting.""" + reasoning = profile.reasoning + if reasoning is None: + return _REASONING_LABELS[_INHERIT_REASONING] + value = self._effort_value(reasoning.effort) + if value is None: + return _REASONING_LABELS[_INHERIT_REASONING] + return _REASONING_LABELS.get(value, value.title()) + + def _reasoning_options( + self, capability: ModelCapabilities | None + ) -> list[tuple[str, str]]: + """Return default plus only the effort values the model supports.""" + options: list[tuple[str, str]] = [ + (_INHERIT_REASONING, _REASONING_LABELS[_INHERIT_REASONING]) + ] + reasoning = getattr(capability, "reasoning", None) + if reasoning is None or not reasoning.supports_effort: + return options + + supported = reasoning.supported_efforts + if supported is None: + supported = tuple(_REASONING_LABELS) + for effort in supported: + value = self._effort_value(effort) + if value is None or value == _INHERIT_REASONING: + continue + if value == "none" and reasoning.mandatory: + continue + label = _REASONING_LABELS.get(value, value.title()) + options.append((value, label)) + return options + + @staticmethod + def _reasoning_config(value: str) -> ReasoningConfig | None: + """Convert a Telegram value into the typed profile update.""" + if value == _INHERIT_REASONING: + return None + try: + effort = ReasoningEffort(value) + except ValueError: + return None + return ReasoningConfig(effort=effort) diff --git a/tests/test_telegram_album_buffer.py b/tests/test_telegram_album_buffer.py new file mode 100644 index 0000000..269563e --- /dev/null +++ b/tests/test_telegram_album_buffer.py @@ -0,0 +1,144 @@ +# mypy: ignore-errors +"""Unit tests for the standalone Telegram album-buffering mechanics. + +Full end-to-end album flows (photos arriving via polling, debounce/flush +timing, turn processing) are covered as TelegramBot integration tests in +test_telegram_bot.py. This file only covers AlbumBuffer's own buffering +state machine in isolation. +""" + +import asyncio +from unittest.mock import MagicMock, patch + +import pytest + +from blacki.telegram.album_buffer import AlbumBuffer, _BufferedAlbum +from blacki.telegram.types import ChatType, Message + + +@pytest.fixture +def on_flush() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def buffer(on_flush: MagicMock) -> AlbumBuffer: + return AlbumBuffer(on_flush=on_flush) + + +@pytest.mark.asyncio +async def test_cleanup_album_buffer_branches(buffer: AlbumBuffer) -> None: + """Test cleanup() when handles are None or future is done/None.""" + loop = asyncio.get_running_loop() + done_future: asyncio.Future[None] = loop.create_future() + done_future.set_result(None) + + album1 = _BufferedAlbum( + chat_id=123, + message_thread_id=None, + media_group_id="clean-1", + chat_type=ChatType.PRIVATE, + messages=[], + debounce_handle=None, + max_wait_task=None, + future=done_future, + ) + buffer._buffers[(123, None, "clean-1")] = album1 + buffer.cleanup(album1) + assert (123, None, "clean-1") not in buffer._buffers + + album2 = _BufferedAlbum( + chat_id=123, + message_thread_id=None, + media_group_id="clean-2", + chat_type=ChatType.PRIVATE, + messages=[], + debounce_handle=None, + max_wait_task=None, + future=None, + ) + buffer._buffers[(123, None, "clean-2")] = album2 + buffer.cleanup(album2) + assert (123, None, "clean-2") not in buffer._buffers + + +@pytest.mark.asyncio +async def test_buffer_album_message_branches(buffer: AlbumBuffer) -> None: + """Test add_message() when debounce_handle or future is None.""" + msg = Message.model_validate( + { + "message_id": 1, + "date": "2024-01-01T00:00:00Z", + "chat": {"id": 123, "type": "private"}, + "media_group_id": "branch-buf", + "photo": [ + { + "file_id": "p1", + "file_unique_id": "u1", + "width": 10, + "height": 10, + } + ], + } + ) + + album = _BufferedAlbum( + chat_id=123, + message_thread_id=None, + media_group_id="branch-buf", + chat_type=ChatType.PRIVATE, + messages=[], + debounce_handle=None, + future=None, + ) + buffer._buffers[(123, None, "branch-buf")] = album + + await buffer.add_message(msg, 1) + assert len(album.messages) == 1 + assert album.debounce_handle is not None + album.debounce_handle.cancel() + + +@pytest.mark.asyncio +async def test_album_max_wait_triggers_flush(buffer: AlbumBuffer) -> None: + """Test _max_wait completing its sleep and triggering flush.""" + album = _BufferedAlbum( + chat_id=123, + message_thread_id=None, + media_group_id="max-wait-flush", + chat_type=ChatType.PRIVATE, + messages=[], + ) + buffer._buffers[(123, None, "max-wait-flush")] = album + + with patch("blacki.telegram.album_buffer._ALBUM_MAX_WAIT_SECONDS", 0.01): + await buffer._max_wait(album) + + assert album.processed is True + assert (123, None, "max-wait-flush") not in buffer._buffers + + +@pytest.mark.asyncio +async def test_flush_album_branches(buffer: AlbumBuffer) -> None: + """Test _flush with an already-processed album and None handles.""" + album_processed = _BufferedAlbum( + chat_id=123, + message_thread_id=None, + media_group_id="flushed-already", + chat_type=ChatType.PRIVATE, + messages=[], + processed=True, + ) + buffer._flush(album_processed) + + album_none_handles = _BufferedAlbum( + chat_id=123, + message_thread_id=None, + media_group_id="none-handles", + chat_type=ChatType.PRIVATE, + messages=[], + debounce_handle=None, + max_wait_task=None, + ) + buffer._flush(album_none_handles) + assert album_none_handles.processed is True diff --git a/tests/test_telegram_bot.py b/tests/test_telegram_bot.py index 7e5cd86..72d73d0 100644 --- a/tests/test_telegram_bot.py +++ b/tests/test_telegram_bot.py @@ -22,11 +22,11 @@ from blacki.inference import InferenceProfile from blacki.reminders.storage import Reminder from blacki.telegram import TelegramConfig +from blacki.telegram.album_buffer import _BufferedAlbum from blacki.telegram.api import TelegramApiClient, TelegramApiError from blacki.telegram.bot import ( TelegramBot, TelegramSessionIdentity, - _BufferedAlbum, create_telegram_bot, ) from blacki.telegram.formatting import ( @@ -3936,13 +3936,13 @@ async def test_album_media_group_id_parsed_and_isolated( # Let the tasks register into album buffers await asyncio.sleep(0.05) - assert (100, None, "group-A") in bot._album_buffers - assert (100, 5, "group-A") in bot._album_buffers - assert (200, None, "group-A") in bot._album_buffers + assert (100, None, "group-A") in bot._album_buffer._buffers + assert (100, 5, "group-A") in bot._album_buffer._buffers + assert (200, None, "group-A") in bot._album_buffer._buffers # Flush all albums - for album in list(bot._album_buffers.values()): - bot._flush_album(album) + for album in list(bot._album_buffer._buffers.values()): + bot._album_buffer._flush(album) await asyncio.gather(task1, task2, task3) @@ -4040,7 +4040,7 @@ async def test_album_single_polling_response( await asyncio.sleep(0.05) # Verify 3 messages buffered - album = bot._album_buffers.get((123, None, "album-1")) + album = bot._album_buffer._buffers.get((123, None, "album-1")) assert album is not None assert len(album.messages) == 3 @@ -4183,11 +4183,11 @@ async def test_album_max_wait_timeout( ) await asyncio.sleep(0.05) - album = bot._album_buffers.get((123, None, "album-timeout")) + album = bot._album_buffer._buffers.get((123, None, "album-timeout")) assert album is not None # Simulate max wait triggering directly - bot._flush_album(album) + bot._album_buffer._flush(album) await task assert len(runtime_recorder.run_user_turn_calls) == 1 @@ -4236,9 +4236,9 @@ async def test_album_exceeds_max_photos_limit( ] await asyncio.sleep(0.05) - album = bot._album_buffers.get((123, None, "album-too-many")) + album = bot._album_buffer._buffers.get((123, None, "album-too-many")) assert album is not None - bot._flush_album(album) + bot._album_buffer._flush(album) await asyncio.gather(*tasks) assert len(runtime_recorder.run_user_turn_calls) == 0 @@ -4290,9 +4290,9 @@ async def test_album_exceeds_aggregate_reported_bytes( ] await asyncio.sleep(0.05) - album = bot._album_buffers.get((123, None, "album-heavy")) + album = bot._album_buffer._buffers.get((123, None, "album-heavy")) assert album is not None - bot._flush_album(album) + bot._album_buffer._flush(album) await asyncio.gather(*tasks) assert len(runtime_recorder.run_user_turn_calls) == 0 @@ -4350,9 +4350,9 @@ async def test_album_exceeds_aggregate_downloaded_bytes( ] await asyncio.sleep(0.05) - album = bot._album_buffers.get((123, None, "album-download-overflow")) + album = bot._album_buffer._buffers.get((123, None, "album-download-overflow")) assert album is not None - bot._flush_album(album) + bot._album_buffer._flush(album) await asyncio.gather(*tasks) assert len(runtime_recorder.run_user_turn_calls) == 0 @@ -4477,7 +4477,7 @@ async def test_album_failure_cleans_buffer_and_privacy_logs( assert private_caption not in caplog.text assert "secret-image-content" not in caplog.text - assert len(bot._album_buffers) == 0 + assert len(bot._album_buffer._buffers) == 0 @pytest.mark.asyncio async def test_bot_stop_cleans_all_album_buffers( @@ -4515,9 +4515,9 @@ async def test_bot_stop_cleans_all_album_buffers( ) await asyncio.sleep(0.05) - assert len(bot._album_buffers) == 1 + assert len(bot._album_buffer._buffers) == 1 await bot.stop() - assert len(bot._album_buffers) == 0 + assert len(bot._album_buffer._buffers) == 0 with pytest.raises(asyncio.CancelledError): await task @@ -4760,7 +4760,7 @@ async def test_album_followed_by_text_during_debounce_preserves_order( ) # Yield briefly so album message is registered into buffer but not yet flushed await asyncio.sleep(0.05) - assert (123, None, "album-p1") in bot._album_buffers + assert (123, None, "album-p1") in bot._album_buffer._buffers # Send text message during debounce window text_task = asyncio.create_task( @@ -4784,144 +4784,6 @@ async def test_album_followed_by_text_during_debounce_preserves_order( == "Text during debounce" ) - @pytest.mark.asyncio - async def test_cleanup_album_buffer_branches( - self, - telegram_config: TelegramConfig, - runtime_recorder: RecordingRuntime, - ) -> None: - """Test _cleanup_album_buffer when handles are None or future is done/None.""" - bot = TelegramBot(telegram_config, cast(AdkRuntime, runtime_recorder)) - loop = asyncio.get_running_loop() - done_future: asyncio.Future[None] = loop.create_future() - done_future.set_result(None) - - album1 = _BufferedAlbum( - chat_id=123, - message_thread_id=None, - media_group_id="clean-1", - chat_type=ChatType.PRIVATE, - messages=[], - debounce_handle=None, - max_wait_task=None, - future=done_future, - ) - bot._album_buffers[(123, None, "clean-1")] = album1 - bot._cleanup_album_buffer(album1) - assert (123, None, "clean-1") not in bot._album_buffers - - album2 = _BufferedAlbum( - chat_id=123, - message_thread_id=None, - media_group_id="clean-2", - chat_type=ChatType.PRIVATE, - messages=[], - debounce_handle=None, - max_wait_task=None, - future=None, - ) - bot._album_buffers[(123, None, "clean-2")] = album2 - bot._cleanup_album_buffer(album2) - assert (123, None, "clean-2") not in bot._album_buffers - - @pytest.mark.asyncio - async def test_buffer_album_message_branches( - self, - telegram_config: TelegramConfig, - runtime_recorder: RecordingRuntime, - ) -> None: - """Test _buffer_album_message when debounce_handle or future is None.""" - bot = TelegramBot(telegram_config, cast(AdkRuntime, runtime_recorder)) - msg = Message.model_validate( - { - "message_id": 1, - "date": "2024-01-01T00:00:00Z", - "chat": {"id": 123, "type": "private"}, - "media_group_id": "branch-buf", - "photo": [ - { - "file_id": "p1", - "file_unique_id": "u1", - "width": 10, - "height": 10, - } - ], - } - ) - - album = _BufferedAlbum( - chat_id=123, - message_thread_id=None, - media_group_id="branch-buf", - chat_type=ChatType.PRIVATE, - messages=[], - debounce_handle=None, - future=None, - ) - bot._album_buffers[(123, None, "branch-buf")] = album - - await bot._buffer_album_message(msg) - assert len(album.messages) == 1 - assert album.debounce_handle is not None - album.debounce_handle.cancel() - - @pytest.mark.asyncio - async def test_album_max_wait_triggers_flush( - self, - telegram_config: TelegramConfig, - runtime_recorder: RecordingRuntime, - ) -> None: - """Test _album_max_wait completing its sleep and triggering flush.""" - bot = TelegramBot(telegram_config, cast(AdkRuntime, runtime_recorder)) - mock_api = create_autospec(TelegramApiClient, instance=True) - mock_api.send_message = AsyncMock() - bot._api = mock_api - - album = _BufferedAlbum( - chat_id=123, - message_thread_id=None, - media_group_id="max-wait-flush", - chat_type=ChatType.PRIVATE, - messages=[], - ) - bot._album_buffers[(123, None, "max-wait-flush")] = album - - with patch("blacki.telegram.bot._ALBUM_MAX_WAIT_SECONDS", 0.01): - await bot._album_max_wait(album) - - assert album.processed is True - assert (123, None, "max-wait-flush") not in bot._album_buffers - - @pytest.mark.asyncio - async def test_flush_album_branches( - self, - telegram_config: TelegramConfig, - runtime_recorder: RecordingRuntime, - ) -> None: - """Test _flush_album with already processed album and None handles.""" - bot = TelegramBot(telegram_config, cast(AdkRuntime, runtime_recorder)) - album_processed = _BufferedAlbum( - chat_id=123, - message_thread_id=None, - media_group_id="flushed-already", - chat_type=ChatType.PRIVATE, - messages=[], - processed=True, - ) - bot._flush_album(album_processed) - - album_none_handles = _BufferedAlbum( - chat_id=123, - message_thread_id=None, - media_group_id="none-handles", - chat_type=ChatType.PRIVATE, - messages=[], - debounce_handle=None, - max_wait_task=None, - ) - bot._flush_album(album_none_handles) - assert album_none_handles.processed is True - @pytest.mark.asyncio async def test_process_flushed_album_cancels_older_task( self, @@ -5077,7 +4939,7 @@ async def test_safe_handle_update_active_album_future_branches( messages=[], future=None, ) - bot._album_buffers[(123, None, "no-fut")] = album_no_future + bot._album_buffer._buffers[(123, None, "no-fut")] = album_no_future text_msg = Message.model_validate( { @@ -5104,7 +4966,7 @@ async def test_safe_handle_update_active_album_future_branches( messages=[], future=err_future, ) - bot._album_buffers[(123, None, "err-fut")] = album_err_future + bot._album_buffer._buffers[(123, None, "err-fut")] = album_err_future await bot._safe_handle_update( Update.model_validate({"update_id": 2, "message": text_msg.model_dump()}) @@ -5121,7 +4983,7 @@ async def test_safe_handle_update_active_album_future_branches( messages=[], future=cancel_future, ) - bot._album_buffers[(123, None, "cancel-fut")] = album_cancelling + bot._album_buffer._buffers[(123, None, "cancel-fut")] = album_cancelling task = asyncio.create_task( bot._safe_handle_update( @@ -5134,7 +4996,7 @@ async def test_safe_handle_update_active_album_future_branches( task.cancel() with pytest.raises(asyncio.CancelledError): await task - bot._album_buffers.pop((123, None, "cancel-fut"), None) + bot._album_buffer._buffers.pop((123, None, "cancel-fut"), None) # Case 4: active album future cancelled while current_task is NOT cancelling cancelled_fut: asyncio.Future[None] = loop.create_future() @@ -5147,7 +5009,9 @@ async def test_safe_handle_update_active_album_future_branches( messages=[], future=cancelled_fut, ) - bot._album_buffers[(123, None, "pre-cancelled-fut")] = album_pre_cancelled + bot._album_buffer._buffers[(123, None, "pre-cancelled-fut")] = ( + album_pre_cancelled + ) await bot._safe_handle_update( Update.model_validate({"update_id": 4, "message": text_msg.model_dump()}) diff --git a/tests/test_telegram_bot_model_override.py b/tests/test_telegram_bot_model_override.py index 0891b28..617193f 100644 --- a/tests/test_telegram_bot_model_override.py +++ b/tests/test_telegram_bot_model_override.py @@ -1,37 +1,22 @@ # mypy: ignore-errors -"""Tests for Telegram bot model override and callback queries.""" +"""Tests for TelegramBot's delegation to the settings menu and profile loading. + +Settings-menu behavior itself (model/thinking panels, callback handling, +reasoning options, etc.) is tested directly against SettingsMenu in +test_telegram_settings_menu.py. This file only covers the thin TelegramBot +glue: command dispatch, callback routing, and the shared profile loader. +""" -import asyncio -import os -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, create_autospec, patch -import aiosqlite import pytest from blacki.adk_runtime import AdkRuntime -from blacki.inference import ( - INFERENCE_PROFILE_PREFERENCE_KEY, - LEGACY_MODEL_PREFERENCE_KEY, - InferenceProfile, - ReasoningConfig, - ReasoningEffort, - update_inference_profile, -) +from blacki.inference import InferenceProfile, ReasoningConfig, ReasoningEffort from blacki.telegram import TelegramConfig from blacki.telegram.api import TelegramApiClient from blacki.telegram.bot import TelegramBot -from blacki.telegram.types import ( - CallbackQuery, - Chat, - InlineKeyboardButton, - InlineKeyboardMarkup, - Message, - ParseMode, - Update, - User, -) -from blacki.utils.preferences import SqlitePreferencesStorage +from blacki.telegram.types import CallbackQuery, Chat, Message, Update, User @pytest.fixture @@ -56,32 +41,6 @@ def bot(telegram_config: TelegramConfig, mock_runtime: MagicMock) -> TelegramBot return bot_inst -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -async def test_send_model_menu_success(mock_get_prefs, bot: TelegramBot) -> None: - mock_storage = AsyncMock() - mock_storage.get.return_value = "openrouter/deepseek/deepseek-v4-pro" - mock_get_prefs.return_value = mock_storage - - await bot._send_model_menu(chat_id=123, message_thread_id=None) - bot._api.send_message.assert_called_once() - kwargs = bot._api.send_message.call_args.kwargs - assert kwargs["chat_id"] == 123 - assert "reply_markup" in kwargs - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -async def test_send_model_menu_exception(mock_get_prefs, bot: TelegramBot) -> None: - mock_storage = AsyncMock() - mock_storage.get.return_value = None - mock_get_prefs.return_value = mock_storage - - bot._api.send_message.side_effect = Exception("failed") - # Should not raise - await bot._send_model_menu(chat_id=123, message_thread_id=None) - - @pytest.mark.asyncio async def test_safe_handle_update_callback_query(bot: TelegramBot) -> None: user = User(id=1, is_bot=False, first_name="Test") @@ -94,66 +53,27 @@ async def test_safe_handle_update_callback_query(bot: TelegramBot) -> None: @pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -@patch("blacki.telegram.bot.MODEL_CHOICES", {"m1": ("m1", "M1"), "m2": ("m2", "M2")}) -async def test_send_model_menu_even_choices(mock_get_prefs, bot: TelegramBot) -> None: - mock_storage = AsyncMock() - mock_storage.get.return_value = "m1" - mock_get_prefs.return_value = mock_storage - - await bot._send_model_menu(chat_id=123, message_thread_id=None) - bot._api.send_message.assert_called_once() - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -@patch("blacki.telegram.bot.MODEL_CHOICES", {"m1": ("m1", "M1")}) -async def test_send_model_menu_unknown_current_model( - mock_get_prefs, bot: TelegramBot -) -> None: - mock_storage = AsyncMock() - - async def get_preference(_chat_id, key, default=None): - if key == INFERENCE_PROFILE_PREFERENCE_KEY: - return {"model": "unknown_model_id"} - return default - - mock_storage.get.side_effect = get_preference - mock_get_prefs.return_value = mock_storage - - with patch.dict(os.environ, {"ROOT_AGENT_MODEL": "default"}): - await bot._send_model_menu(chat_id=123, message_thread_id=None) - bot._api.send_message.assert_called_once() - kwargs = bot._api.send_message.call_args.kwargs - assert "unknown\\_model\\_id" in kwargs["text"] - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -async def test_handle_callback_query_no_message( - mock_get_prefs, bot: TelegramBot -) -> None: - mock_storage = AsyncMock() - mock_get_prefs.return_value = mock_storage - user = User(id=1, is_bot=False, first_name="Test") - # Message is None - cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=None - ) +async def test_handle_command_model(bot: TelegramBot) -> None: + chat = Chat(id=123, type="private") + msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - await bot._handle_callback_query(cq) - bot._api.answer_callback_query.assert_called_once() - bot._api.edit_message_text.assert_not_called() + with patch.object( + bot._settings_menu, "send_model_menu", AsyncMock() + ) as mock_send_menu: + await bot._handle_command(msg, "/model") + mock_send_menu.assert_called_once_with(123, None) @pytest.mark.asyncio -async def test_handle_command_model(bot: TelegramBot) -> None: +async def test_handle_command_thinking(bot: TelegramBot) -> None: chat = Chat(id=123, type="private") msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - with patch.object(bot, "_send_model_menu", AsyncMock()) as mock_send_menu: - await bot._handle_command(msg, "/model") - mock_send_menu.assert_called_once_with(123, None) + with patch.object( + bot._settings_menu, "send_thinking_menu", AsyncMock() + ) as mock_send_menu: + await bot._handle_command(msg, "/thinking") + mock_send_menu.assert_awaited_once_with(123, None) @pytest.mark.asyncio @@ -174,645 +94,25 @@ async def test_build_session_state_no_pref(bot: TelegramBot) -> None: @pytest.mark.asyncio -async def test_handle_callback_query_invalid_data(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - cq = CallbackQuery(id="cq1", from_user=user, chat_instance="inst", data="invalid") - await bot._handle_callback_query(cq) - bot._api.answer_callback_query.assert_called_once_with("cq1", text="Unknown action") - - -@pytest.mark.asyncio -async def test_handle_callback_query_unknown_model(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="mod:unknown" - ) - await bot._handle_callback_query(cq) - bot._api.answer_callback_query.assert_called_once_with("cq1", text="Unknown model") - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -async def test_handle_callback_query_valid_model( - mock_get_prefs, bot: TelegramBot -) -> None: - mock_storage = AsyncMock() - mock_get_prefs.return_value = mock_storage - user = User(id=1, is_bot=False, first_name="Test") - chat = Chat(id=123, type="private") - msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=msg - ) - - with patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as mock_update: - await bot._handle_callback_query(cq) - mock_update.assert_awaited_once_with( - mock_storage, - "123", - {"model": "openrouter/openai/gpt-oss-120b", "reasoning": None}, - ) - bot._api.answer_callback_query.assert_called_once() - bot._api.edit_message_text.assert_called_once() - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -async def test_handle_callback_query_default_model( - mock_get_prefs, bot: TelegramBot -) -> None: - mock_storage = AsyncMock() - mock_get_prefs.return_value = mock_storage - user = User(id=1, is_bot=False, first_name="Test") - chat = Chat(id=123, type="private") - msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - cq = CallbackQuery( - id="cq1", - from_user=user, - chat_instance="inst", - data="mod:m_default", - message=msg, - ) - - with patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as mock_update: - await bot._handle_callback_query(cq) - mock_update.assert_awaited_once_with( - mock_storage, - "123", - {"model": None, "reasoning": None}, - ) - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -async def test_handle_callback_query_edit_msg_exception( - mock_get_prefs, bot: TelegramBot -) -> None: - mock_storage = AsyncMock() - mock_get_prefs.return_value = mock_storage - user = User(id=1, is_bot=False, first_name="Test") - chat = Chat(id=123, type="private") - msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=msg - ) - - bot._api.edit_message_text.side_effect = Exception("fail") - with patch("blacki.telegram.bot.update_inference_profile", new=AsyncMock()): - await bot._handle_callback_query(cq) - bot._api.answer_callback_query.assert_called_once() - - -@pytest.mark.asyncio -async def test_handle_command_thinking(bot: TelegramBot) -> None: - chat = Chat(id=123, type="private") - msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - - with patch.object(bot, "_send_thinking_menu", AsyncMock()) as mock_send_menu: - await bot._handle_command(msg, "/thinking") - mock_send_menu.assert_awaited_once_with(123, None) - - -def test_settings_callback_data_is_within_telegram_limit(bot: TelegramBot) -> None: - text, markup = bot._build_model_menu(InferenceProfile()) - assert text - callback_data = [ - button.callback_data - for row in markup.inline_keyboard - for button in row - if button.callback_data is not None - ] - assert callback_data - assert all(len(value.encode("utf-8")) <= 64 for value in callback_data) - - -@pytest.mark.asyncio -async def test_message_less_settings_mutation_does_not_write(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="s:m:m1", message=None - ) - - with patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as update: - await bot._handle_callback_query(cq) - - update.assert_not_awaited() - bot._api.answer_callback_query.assert_called_once_with( - "cq1", text="Settings expired" - ) - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -@patch( - "blacki.telegram.bot.load_inference_profile", - new_callable=AsyncMock, - return_value=InferenceProfile( - model="openrouter/openai/gpt-oss-120b", - reasoning=ReasoningConfig(effort=ReasoningEffort.HIGH), - ), -) -async def test_reasoning_callback_preserves_model( - mock_load, mock_get_prefs, bot: TelegramBot -) -> None: - mock_storage = AsyncMock() - mock_get_prefs.return_value = mock_storage +async def test_handle_callback_query_routes_health_data(bot: TelegramBot) -> None: user = User(id=1, is_bot=False, first_name="Test") - chat = Chat(id=123, type="private") - msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="s:r:max", message=msg - ) - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=("low", "high", "max"), - mandatory=False, - ) + id="cq1", from_user=user, chat_instance="inst", data="health:cancel" ) - with ( - patch.object(bot, "_resolve_capabilities", AsyncMock(return_value=capability)), - patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as update, - ): + with patch.object(bot, "_handle_health_callback", AsyncMock()) as mock_health: await bot._handle_callback_query(cq) - - update.assert_awaited_once_with( - mock_storage, - "123", - {"reasoning": ReasoningConfig(effort=ReasoningEffort.MAX)}, - base_profile=InferenceProfile( - model="openrouter/openai/gpt-oss-120b", - reasoning=ReasoningConfig(effort=ReasoningEffort.HIGH), - ), - ) - - -async def _initialized_preferences_storage() -> SqlitePreferencesStorage: - connection = await aiosqlite.connect(":memory:", isolation_level=None) - connection.row_factory = aiosqlite.Row - storage = SqlitePreferencesStorage(connection, asyncio.Lock()) - await storage.initialize() - return storage - - -def _reasoning_callback_query() -> CallbackQuery: - return CallbackQuery( - id="cq1", - from_user=User(id=1, is_bot=False, first_name="Test"), - chat_instance="inst", - data="s:r:max", - message=Message( - message_id=42, - date="2024-01-01T00:00:00Z", - chat=Chat(id=123, type="private"), - ), - ) - - -def _max_reasoning_capability() -> SimpleNamespace: - return SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=("max",), - mandatory=False, - ) - ) - - -@pytest.mark.asyncio -async def test_reasoning_callback_migrates_legacy_model(bot: TelegramBot) -> None: - storage = await _initialized_preferences_storage() - await storage.set("123", LEGACY_MODEL_PREFERENCE_KEY, "legacy-model") - - try: - with ( - patch("blacki.telegram.bot.get_preferences_storage", return_value=storage), - patch.object( - bot, - "_resolve_capabilities", - AsyncMock(return_value=_max_reasoning_capability()), - ), - ): - await bot._handle_callback_query(_reasoning_callback_query()) - - assert await storage.get("123", INFERENCE_PROFILE_PREFERENCE_KEY) == { - "model": "legacy-model", - "reasoning": {"effort": "max"}, - } - finally: - await storage.close() - await storage.conn.close() - - -@pytest.mark.asyncio -async def test_stale_reasoning_callback_preserves_new_model( - bot: TelegramBot, -) -> None: - storage = await _initialized_preferences_storage() - await storage.set("123", LEGACY_MODEL_PREFERENCE_KEY, "legacy-model") - - async def select_new_model_during_capability_lookup( - model_id: str, - ) -> SimpleNamespace: - assert model_id == "legacy-model" - await update_inference_profile( - storage, - "123", - {"model": "new-model", "reasoning": None}, - ) - return _max_reasoning_capability() - - try: - with ( - patch("blacki.telegram.bot.get_preferences_storage", return_value=storage), - patch.object( - bot, - "_resolve_capabilities", - side_effect=select_new_model_during_capability_lookup, - ), - ): - await bot._handle_callback_query(_reasoning_callback_query()) - - assert await storage.get("123", INFERENCE_PROFILE_PREFERENCE_KEY) == { - "model": "new-model", - "reasoning": None, - } - assert ( - "Could not save settings" - in bot._api.edit_message_text.await_args.kwargs["text"] - ) - finally: - await storage.close() - await storage.conn.close() - - -@pytest.mark.asyncio -async def test_reasoning_menu_hides_off_for_mandatory_model(bot: TelegramBot) -> None: - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=("none", "high", "max"), - mandatory=True, - ) - ) - with patch.object(bot, "_resolve_capabilities", AsyncMock(return_value=capability)): - _, markup = await bot._build_thinking_menu(InferenceProfile()) - - labels = [button.text for row in markup.inline_keyboard for button in row] - assert not any(label.startswith("Off") for label in labels) - assert any(label.startswith("High") for label in labels) - - -@pytest.mark.asyncio -async def test_thinking_menu_falls_back_when_capability_client_fails( - bot: TelegramBot, -) -> None: - with ( - patch.object( - bot, - "_load_chat_profile", - AsyncMock( - return_value=InferenceProfile(model="openrouter/openai/gpt-5.6-luna") - ), - ), - patch( - "blacki.telegram.bot.OpenRouterModelCapabilitiesResolver", - side_effect=RuntimeError("capability client unavailable"), - ), - ): - await bot._send_thinking_menu(chat_id=123, message_thread_id=None) - - bot._api.send_message.assert_awaited_once() - markup = bot._api.send_message.call_args.kwargs["reply_markup"] - callback_data = [ - button.callback_data - for row in markup.inline_keyboard - for button in row - if button.callback_data is not None - ] - assert callback_data == ["s:r:inherit", "s:b"] + mock_health.assert_awaited_once_with(cq) @pytest.mark.asyncio -async def test_stale_reasoning_callback_does_not_write(bot: TelegramBot) -> None: +async def test_handle_callback_query_routes_settings_data(bot: TelegramBot) -> None: user = User(id=1, is_bot=False, first_name="Test") - chat = Chat(id=123, type="private") - msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="s:r:not-real", message=msg - ) + cq = CallbackQuery(id="cq1", from_user=user, chat_instance="inst", data="s:b") - with patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as update: + with patch.object(bot._settings_menu, "handle_callback", AsyncMock()) as mock_menu: await bot._handle_callback_query(cq) - - update.assert_not_awaited() - bot._api.answer_callback_query.assert_called_once_with( - "cq1", text="Unknown thinking option" - ) - - -@pytest.mark.asyncio -async def test_stop_closes_capability_resolver(bot: TelegramBot) -> None: - resolver = AsyncMock() - bot._capabilities_resolver = resolver - bot.runtime.close = AsyncMock() - bot._api.close = AsyncMock() - - await bot.stop() - - resolver.aclose.assert_awaited_once() - assert bot._capabilities_resolver is None - - -@pytest.mark.asyncio -async def test_stop_suppresses_capability_resolver_close_error( - bot: TelegramBot, -) -> None: - resolver = AsyncMock() - resolver.aclose.side_effect = RuntimeError("close failed") - bot._capabilities_resolver = resolver - bot.runtime.close = AsyncMock() - bot._api.close = AsyncMock() - - await bot.stop() - - resolver.aclose.assert_awaited_once() - assert bot._capabilities_resolver is None - - -@pytest.mark.asyncio -async def test_send_thinking_menu_handles_send_error(bot: TelegramBot) -> None: - bot._api.send_message.side_effect = RuntimeError("send failed") - - await bot._send_thinking_menu(chat_id=123, message_thread_id=None) - - bot._api.send_message.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_thinking_menu_even_options_has_no_partial_row(bot: TelegramBot) -> None: - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=("max",), - mandatory=False, - ) - ) - with patch.object(bot, "_resolve_capabilities", AsyncMock(return_value=capability)): - _, markup = await bot._build_thinking_menu(InferenceProfile()) - - assert [button.callback_data for button in markup.inline_keyboard[0]] == [ - "s:r:inherit", - "s:r:max", - ] - assert markup.inline_keyboard[-1][0].callback_data == "s:b" - - -@pytest.mark.asyncio -async def test_thinking_menu_notes_when_effort_is_unsupported( - bot: TelegramBot, -) -> None: - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=False, - supported_efforts=(), - mandatory=False, - ) - ) - with patch.object(bot, "_resolve_capabilities", AsyncMock(return_value=capability)): - text, markup = await bot._build_thinking_menu(InferenceProfile()) - - assert "does not expose effort controls" in text - assert [ - button.callback_data for row in markup.inline_keyboard for button in row - ] == [ - "s:r:inherit", - "s:b", - ] - - -@pytest.mark.asyncio -async def test_callback_model_none_value_is_rejected_without_write( - bot: TelegramBot, -) -> None: - user = User(id=1, is_bot=False, first_name="Test") - message = Message( - message_id=42, - date="2024-01-01T00:00:00Z", - chat=Chat(id=123, type="private"), - ) - query = CallbackQuery( - id="cq1", - from_user=user, - chat_instance="inst", - data="s:m:m1", - message=message, - ) - - with ( - patch.object(bot, "_parse_settings_callback", return_value=("model", None)), - patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as update, - ): - await bot._handle_callback_query(query) - - update.assert_not_awaited() - assert bot._api.answer_callback_query.await_args_list[-1].kwargs["text"] == ( - "Unknown model" - ) - - -@pytest.mark.asyncio -async def test_callback_rejects_effort_not_supported_by_model(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - message = Message( - message_id=42, - date="2024-01-01T00:00:00Z", - chat=Chat(id=123, type="private"), - ) - query = CallbackQuery( - id="cq1", - from_user=user, - chat_instance="inst", - data="s:r:max", - message=message, - ) - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=("low",), - mandatory=False, - ) - ) - mock_storage = AsyncMock() - - with ( - patch("blacki.telegram.bot.get_preferences_storage", return_value=mock_storage), - patch.object(bot, "_resolve_capabilities", AsyncMock(return_value=capability)), - patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as update, - ): - await bot._handle_callback_query(query) - - update.assert_not_awaited() - assert bot._api.edit_message_text.await_count == 1 - - -@pytest.mark.asyncio -async def test_reset_callback_updates_both_profile_fields(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - message = Message( - message_id=42, - date="2024-01-01T00:00:00Z", - chat=Chat(id=123, type="private"), - ) - query = CallbackQuery( - id="cq1", - from_user=user, - chat_instance="inst", - data="s:x", - message=message, - ) - - mock_storage = AsyncMock() - with ( - patch("blacki.telegram.bot.get_preferences_storage", return_value=mock_storage), - patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as update, - ): - await bot._handle_callback_query(query) - - update.assert_awaited_once_with( - mock_storage, - "123", - {"model": None, "reasoning": None}, - ) - - -@pytest.mark.asyncio -async def test_thinking_callback_edits_capability_menu(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - message = Message( - message_id=42, - date="2024-01-01T00:00:00Z", - chat=Chat(id=123, type="private"), - ) - query = CallbackQuery( - id="cq1", - from_user=user, - chat_instance="inst", - data="s:t", - message=message, - ) - markup = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text="Default", callback_data="s:r:inherit")] - ] - ) - with ( - patch("blacki.telegram.bot.get_preferences_storage", return_value=AsyncMock()), - patch.object( - bot, - "_build_thinking_menu", - AsyncMock(return_value=("thinking", markup)), - ), - ): - await bot._handle_callback_query(query) - - bot._api.edit_message_text.assert_awaited_once_with( - chat_id=123, - message_id=42, - text="thinking", - parse_mode=ParseMode.MARKDOWN_V2, - reply_markup=markup, - ) - - -@pytest.mark.asyncio -async def test_back_callback_returns_to_model_menu(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - message = Message( - message_id=42, - date="2024-01-01T00:00:00Z", - chat=Chat(id=123, type="private"), - ) - query = CallbackQuery( - id="cq1", - from_user=user, - chat_instance="inst", - data="s:b", - message=message, - ) - - with ( - patch("blacki.telegram.bot.get_preferences_storage", return_value=AsyncMock()), - patch.object(bot, "_edit_model_menu", AsyncMock()) as edit_menu, - ): - await bot._handle_callback_query(query) - - edit_menu.assert_awaited_once_with(query, 123) - - -def test_settings_callback_parser_handles_navigation_actions() -> None: - assert TelegramBot._parse_settings_callback("s:t") == ("thinking", None) - assert TelegramBot._parse_settings_callback("s:b") == ("back", None) - assert TelegramBot._parse_settings_callback("s:x") == ("reset", None) - - -@pytest.mark.asyncio -async def test_edit_model_menu_ignores_message_less_callback(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - query = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="s:b", message=None - ) - - await bot._edit_model_menu(query, 123) - - bot._api.edit_message_text.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_edit_error_ignores_message_less_callback(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - query = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="s:b", message=None - ) - - await bot._edit_error(query, 123, "failed") - - bot._api.edit_message_text.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_resolve_capabilities_skips_missing_models(bot: TelegramBot) -> None: - assert await bot._resolve_capabilities(None) is None - assert await bot._resolve_capabilities("default") is None - - -@pytest.mark.asyncio -async def test_resolve_capabilities_uses_cached_resolver(bot: TelegramBot) -> None: - resolver = AsyncMock() - resolver.resolve.return_value = None - bot._capabilities_resolver = resolver - - assert await bot._resolve_capabilities("openrouter/openai/gpt-5.6-luna") is None - - resolver.resolve.assert_awaited_once() - assert resolver.resolve.await_args.args == ("openrouter/openai/gpt-5.6-luna",) + mock_menu.assert_awaited_once_with(cq) @pytest.mark.asyncio @@ -852,57 +152,3 @@ async def test_load_chat_profile_uses_environment_after_invalid_result( assert profile == InferenceProfile( reasoning=ReasoningConfig(effort=ReasoningEffort.MAX) ) - - -def test_model_display_name_handles_unknown_future_model(bot: TelegramBot) -> None: - assert bot._model_display_name("openrouter/acme/future-model") == "future-model" - - -def test_model_display_name_handles_system_default(bot: TelegramBot) -> None: - with patch("blacki.telegram.bot.MODEL_CHOICES", {}): - assert bot._model_display_name("default") == "System Default" - - -def test_reasoning_display_inherits_when_only_token_budget_is_set( - bot: TelegramBot, -) -> None: - profile = InferenceProfile(reasoning=ReasoningConfig(max_tokens=256)) - - assert bot._reasoning_display(profile) == "Default" - - -def test_reasoning_options_include_gateway_values_when_unspecified() -> None: - bot = TelegramBot.__new__(TelegramBot) - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=None, - mandatory=False, - ) - ) - - options = bot._reasoning_options(capability) - - assert ("max", "Max") in options - assert ("none", "Off") in options - - -def test_reasoning_options_skip_empty_and_inherit_values() -> None: - bot = TelegramBot.__new__(TelegramBot) - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=(None, "inherit", "max"), - mandatory=False, - ) - ) - - assert bot._reasoning_options(capability) == [ - ("inherit", "Default"), - ("max", "Max"), - ] - - -def test_reasoning_config_handles_inherit_and_invalid_values() -> None: - assert TelegramBot._reasoning_config("inherit") is None - assert TelegramBot._reasoning_config("not-an-effort") is None diff --git a/tests/test_telegram_settings_menu.py b/tests/test_telegram_settings_menu.py new file mode 100644 index 0000000..4e9f94c --- /dev/null +++ b/tests/test_telegram_settings_menu.py @@ -0,0 +1,856 @@ +# mypy: ignore-errors +"""Tests for the standalone Telegram settings-menu UI (model/thinking panels).""" + +import asyncio +import os +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch + +import aiosqlite +import pytest + +from blacki.inference import ( + INFERENCE_PROFILE_PREFERENCE_KEY, + LEGACY_MODEL_PREFERENCE_KEY, + InferenceProfile, + ReasoningConfig, + ReasoningEffort, + update_inference_profile, +) +from blacki.telegram.api import TelegramApiClient +from blacki.telegram.settings_menu import SettingsMenu +from blacki.telegram.types import ( + CallbackQuery, + Chat, + InlineKeyboardButton, + InlineKeyboardMarkup, + Message, + ParseMode, + User, +) +from blacki.utils.preferences import SqlitePreferencesStorage + + +@pytest.fixture +def mock_api() -> MagicMock: + return create_autospec(TelegramApiClient, instance=True) + + +@pytest.fixture +def load_profile() -> AsyncMock: + return AsyncMock(return_value=InferenceProfile()) + + +@pytest.fixture +def menu(mock_api: MagicMock, load_profile: AsyncMock) -> SettingsMenu: + return SettingsMenu(api_provider=lambda: mock_api, load_profile=load_profile) + + +@pytest.mark.asyncio +@patch("blacki.telegram.settings_menu.get_preferences_storage") +async def test_send_model_menu_success( + mock_get_prefs, menu: SettingsMenu, mock_api: MagicMock +) -> None: + mock_storage = AsyncMock() + mock_storage.get.return_value = "openrouter/deepseek/deepseek-v4-pro" + mock_get_prefs.return_value = mock_storage + + await menu.send_model_menu(chat_id=123, message_thread_id=None) + mock_api.send_message.assert_called_once() + kwargs = mock_api.send_message.call_args.kwargs + assert kwargs["chat_id"] == 123 + assert "reply_markup" in kwargs + + +@pytest.mark.asyncio +async def test_send_model_menu_exception( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + mock_api.send_message.side_effect = Exception("failed") + # Should not raise + await menu.send_model_menu(chat_id=123, message_thread_id=None) + + +@pytest.mark.asyncio +@patch( + "blacki.telegram.settings_menu.MODEL_CHOICES", + {"m1": ("m1", "M1"), "m2": ("m2", "M2")}, +) +async def test_send_model_menu_even_choices( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + await menu.send_model_menu(chat_id=123, message_thread_id=None) + mock_api.send_message.assert_called_once() + + +@pytest.mark.asyncio +@patch("blacki.telegram.settings_menu.MODEL_CHOICES", {"m1": ("m1", "M1")}) +async def test_send_model_menu_unknown_current_model( + menu: SettingsMenu, mock_api: MagicMock, load_profile: AsyncMock +) -> None: + load_profile.return_value = InferenceProfile(model="unknown_model_id") + + with patch.dict(os.environ, {"ROOT_AGENT_MODEL": "default"}): + await menu.send_model_menu(chat_id=123, message_thread_id=None) + mock_api.send_message.assert_called_once() + kwargs = mock_api.send_message.call_args.kwargs + assert "unknown\\_model\\_id" in kwargs["text"] + + +@pytest.mark.asyncio +async def test_handle_callback_query_no_message( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + # Message is None + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=None + ) + + await menu.handle_callback(cq) + mock_api.answer_callback_query.assert_called_once() + mock_api.edit_message_text.assert_not_called() + + +@pytest.mark.asyncio +async def test_handle_callback_query_invalid_data( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + cq = CallbackQuery(id="cq1", from_user=user, chat_instance="inst", data="invalid") + await menu.handle_callback(cq) + mock_api.answer_callback_query.assert_called_once_with("cq1", text="Unknown action") + + +@pytest.mark.asyncio +async def test_handle_callback_query_unknown_model( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="mod:unknown" + ) + await menu.handle_callback(cq) + mock_api.answer_callback_query.assert_called_once_with("cq1", text="Unknown model") + + +@pytest.mark.asyncio +@patch("blacki.telegram.settings_menu.get_preferences_storage") +async def test_handle_callback_query_valid_model( + mock_get_prefs, menu: SettingsMenu, mock_api: MagicMock +) -> None: + mock_storage = AsyncMock() + mock_get_prefs.return_value = mock_storage + user = User(id=1, is_bot=False, first_name="Test") + chat = Chat(id=123, type="private") + msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=msg + ) + + with patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as mock_update: + await menu.handle_callback(cq) + mock_update.assert_awaited_once_with( + mock_storage, + "123", + {"model": "openrouter/openai/gpt-oss-120b", "reasoning": None}, + ) + mock_api.answer_callback_query.assert_called_once() + mock_api.edit_message_text.assert_called_once() + + +@pytest.mark.asyncio +@patch("blacki.telegram.settings_menu.get_preferences_storage") +async def test_handle_callback_query_default_model( + mock_get_prefs, menu: SettingsMenu, mock_api: MagicMock +) -> None: + mock_storage = AsyncMock() + mock_get_prefs.return_value = mock_storage + user = User(id=1, is_bot=False, first_name="Test") + chat = Chat(id=123, type="private") + msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) + cq = CallbackQuery( + id="cq1", + from_user=user, + chat_instance="inst", + data="mod:m_default", + message=msg, + ) + + with patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as mock_update: + await menu.handle_callback(cq) + mock_update.assert_awaited_once_with( + mock_storage, + "123", + {"model": None, "reasoning": None}, + ) + + +@pytest.mark.asyncio +@patch("blacki.telegram.settings_menu.get_preferences_storage") +async def test_handle_callback_query_edit_msg_exception( + mock_get_prefs, menu: SettingsMenu, mock_api: MagicMock +) -> None: + mock_storage = AsyncMock() + mock_get_prefs.return_value = mock_storage + user = User(id=1, is_bot=False, first_name="Test") + chat = Chat(id=123, type="private") + msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=msg + ) + + mock_api.edit_message_text.side_effect = Exception("fail") + with patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ): + await menu.handle_callback(cq) + mock_api.answer_callback_query.assert_called_once() + + +def test_settings_callback_data_is_within_telegram_limit(menu: SettingsMenu) -> None: + text, markup = menu._build_model_menu(InferenceProfile()) + assert text + callback_data = [ + button.callback_data + for row in markup.inline_keyboard + for button in row + if button.callback_data is not None + ] + assert callback_data + assert all(len(value.encode("utf-8")) <= 64 for value in callback_data) + + +@pytest.mark.asyncio +async def test_message_less_settings_mutation_does_not_write( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="s:m:m1", message=None + ) + + with patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as update: + await menu.handle_callback(cq) + + update.assert_not_awaited() + mock_api.answer_callback_query.assert_called_once_with( + "cq1", text="Settings expired" + ) + + +@pytest.mark.asyncio +@patch("blacki.telegram.settings_menu.get_preferences_storage") +@patch( + "blacki.telegram.settings_menu.OpenRouterModelCapabilitiesResolver", +) +async def test_reasoning_callback_preserves_model( + mock_resolver_cls, + mock_get_prefs, + menu: SettingsMenu, + mock_api: MagicMock, + load_profile, +) -> None: + load_profile.return_value = InferenceProfile( + model="openrouter/openai/gpt-oss-120b", + reasoning=ReasoningConfig(effort=ReasoningEffort.HIGH), + ) + mock_storage = AsyncMock() + mock_get_prefs.return_value = mock_storage + user = User(id=1, is_bot=False, first_name="Test") + chat = Chat(id=123, type="private") + msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="s:r:max", message=msg + ) + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=("low", "high", "max"), + mandatory=False, + ) + ) + + with ( + patch.object(menu, "_resolve_capabilities", AsyncMock(return_value=capability)), + patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as update, + ): + await menu.handle_callback(cq) + + update.assert_awaited_once_with( + mock_storage, + "123", + {"reasoning": ReasoningConfig(effort=ReasoningEffort.MAX)}, + base_profile=InferenceProfile( + model="openrouter/openai/gpt-oss-120b", + reasoning=ReasoningConfig(effort=ReasoningEffort.HIGH), + ), + ) + + +async def _initialized_preferences_storage() -> SqlitePreferencesStorage: + connection = await aiosqlite.connect(":memory:", isolation_level=None) + connection.row_factory = aiosqlite.Row + storage = SqlitePreferencesStorage(connection, asyncio.Lock()) + await storage.initialize() + return storage + + +def _reasoning_callback_query() -> CallbackQuery: + return CallbackQuery( + id="cq1", + from_user=User(id=1, is_bot=False, first_name="Test"), + chat_instance="inst", + data="s:r:max", + message=Message( + message_id=42, + date="2024-01-01T00:00:00Z", + chat=Chat(id=123, type="private"), + ), + ) + + +def _max_reasoning_capability() -> SimpleNamespace: + return SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=("max",), + mandatory=False, + ) + ) + + +@pytest.mark.asyncio +async def test_reasoning_callback_migrates_legacy_model( + mock_api: MagicMock, +) -> None: + storage = await _initialized_preferences_storage() + await storage.set("123", LEGACY_MODEL_PREFERENCE_KEY, "legacy-model") + + from blacki.inference import load_inference_profile + + async def load_profile(chat_id: int | str) -> InferenceProfile: + return await load_inference_profile(storage, str(chat_id)) + + menu = SettingsMenu(api_provider=lambda: mock_api, load_profile=load_profile) + + try: + with ( + patch( + "blacki.telegram.settings_menu.get_preferences_storage", + return_value=storage, + ), + patch.object( + menu, + "_resolve_capabilities", + AsyncMock(return_value=_max_reasoning_capability()), + ), + ): + await menu.handle_callback(_reasoning_callback_query()) + + assert await storage.get("123", INFERENCE_PROFILE_PREFERENCE_KEY) == { + "model": "legacy-model", + "reasoning": {"effort": "max"}, + } + finally: + await storage.close() + await storage.conn.close() + + +@pytest.mark.asyncio +async def test_stale_reasoning_callback_preserves_new_model( + mock_api: MagicMock, +) -> None: + storage = await _initialized_preferences_storage() + await storage.set("123", LEGACY_MODEL_PREFERENCE_KEY, "legacy-model") + + from blacki.inference import load_inference_profile + + async def load_profile(chat_id: int | str) -> InferenceProfile: + return await load_inference_profile(storage, str(chat_id)) + + menu = SettingsMenu(api_provider=lambda: mock_api, load_profile=load_profile) + + async def select_new_model_during_capability_lookup( + model_id: str, + ) -> SimpleNamespace: + assert model_id == "legacy-model" + await update_inference_profile( + storage, + "123", + {"model": "new-model", "reasoning": None}, + ) + return _max_reasoning_capability() + + try: + with ( + patch( + "blacki.telegram.settings_menu.get_preferences_storage", + return_value=storage, + ), + patch.object( + menu, + "_resolve_capabilities", + side_effect=select_new_model_during_capability_lookup, + ), + ): + await menu.handle_callback(_reasoning_callback_query()) + + assert await storage.get("123", INFERENCE_PROFILE_PREFERENCE_KEY) == { + "model": "new-model", + "reasoning": None, + } + assert ( + "Could not save settings" + in mock_api.edit_message_text.await_args.kwargs["text"] + ) + finally: + await storage.close() + await storage.conn.close() + + +@pytest.mark.asyncio +async def test_reasoning_menu_hides_off_for_mandatory_model(menu: SettingsMenu) -> None: + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=("none", "high", "max"), + mandatory=True, + ) + ) + with patch.object( + menu, "_resolve_capabilities", AsyncMock(return_value=capability) + ): + _, markup = await menu._build_thinking_menu(InferenceProfile()) + + labels = [button.text for row in markup.inline_keyboard for button in row] + assert not any(label.startswith("Off") for label in labels) + assert any(label.startswith("High") for label in labels) + + +@pytest.mark.asyncio +async def test_thinking_menu_falls_back_when_capability_client_fails( + menu: SettingsMenu, mock_api: MagicMock, load_profile: AsyncMock +) -> None: + load_profile.return_value = InferenceProfile(model="openrouter/openai/gpt-5.6-luna") + + with patch( + "blacki.telegram.settings_menu.OpenRouterModelCapabilitiesResolver", + side_effect=RuntimeError("capability client unavailable"), + ): + await menu.send_thinking_menu(chat_id=123, message_thread_id=None) + + mock_api.send_message.assert_awaited_once() + markup = mock_api.send_message.call_args.kwargs["reply_markup"] + callback_data = [ + button.callback_data + for row in markup.inline_keyboard + for button in row + if button.callback_data is not None + ] + assert callback_data == ["s:r:inherit", "s:b"] + + +@pytest.mark.asyncio +async def test_stale_reasoning_callback_does_not_write( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + chat = Chat(id=123, type="private") + msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="s:r:not-real", message=msg + ) + + with patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as update: + await menu.handle_callback(cq) + + update.assert_not_awaited() + mock_api.answer_callback_query.assert_called_once_with( + "cq1", text="Unknown thinking option" + ) + + +@pytest.mark.asyncio +async def test_aclose_closes_capability_resolver(menu: SettingsMenu) -> None: + resolver = AsyncMock() + menu._capabilities_resolver = resolver + + await menu.aclose() + + resolver.aclose.assert_awaited_once() + assert menu._capabilities_resolver is None + + +@pytest.mark.asyncio +async def test_aclose_suppresses_capability_resolver_close_error( + menu: SettingsMenu, +) -> None: + resolver = AsyncMock() + resolver.aclose.side_effect = RuntimeError("close failed") + menu._capabilities_resolver = resolver + + await menu.aclose() + + resolver.aclose.assert_awaited_once() + assert menu._capabilities_resolver is None + + +@pytest.mark.asyncio +async def test_send_thinking_menu_handles_send_error( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + mock_api.send_message.side_effect = RuntimeError("send failed") + + await menu.send_thinking_menu(chat_id=123, message_thread_id=None) + + mock_api.send_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_thinking_menu_even_options_has_no_partial_row( + menu: SettingsMenu, +) -> None: + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=("max",), + mandatory=False, + ) + ) + with patch.object( + menu, "_resolve_capabilities", AsyncMock(return_value=capability) + ): + _, markup = await menu._build_thinking_menu(InferenceProfile()) + + assert [button.callback_data for button in markup.inline_keyboard[0]] == [ + "s:r:inherit", + "s:r:max", + ] + assert markup.inline_keyboard[-1][0].callback_data == "s:b" + + +@pytest.mark.asyncio +async def test_thinking_menu_notes_when_effort_is_unsupported( + menu: SettingsMenu, +) -> None: + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=False, + supported_efforts=(), + mandatory=False, + ) + ) + with patch.object( + menu, "_resolve_capabilities", AsyncMock(return_value=capability) + ): + text, markup = await menu._build_thinking_menu(InferenceProfile()) + + assert "does not expose effort controls" in text + assert [ + button.callback_data for row in markup.inline_keyboard for button in row + ] == [ + "s:r:inherit", + "s:b", + ] + + +@pytest.mark.asyncio +async def test_callback_model_none_value_is_rejected_without_write( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + message = Message( + message_id=42, + date="2024-01-01T00:00:00Z", + chat=Chat(id=123, type="private"), + ) + query = CallbackQuery( + id="cq1", + from_user=user, + chat_instance="inst", + data="s:m:m1", + message=message, + ) + + with ( + patch.object(menu, "_parse_settings_callback", return_value=("model", None)), + patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as update, + ): + await menu.handle_callback(query) + + update.assert_not_awaited() + assert mock_api.answer_callback_query.await_args_list[-1].kwargs["text"] == ( + "Unknown model" + ) + + +@pytest.mark.asyncio +async def test_callback_rejects_effort_not_supported_by_model( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + message = Message( + message_id=42, + date="2024-01-01T00:00:00Z", + chat=Chat(id=123, type="private"), + ) + query = CallbackQuery( + id="cq1", + from_user=user, + chat_instance="inst", + data="s:r:max", + message=message, + ) + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=("low",), + mandatory=False, + ) + ) + mock_storage = AsyncMock() + + with ( + patch( + "blacki.telegram.settings_menu.get_preferences_storage", + return_value=mock_storage, + ), + patch.object(menu, "_resolve_capabilities", AsyncMock(return_value=capability)), + patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as update, + ): + await menu.handle_callback(query) + + update.assert_not_awaited() + assert mock_api.edit_message_text.await_count == 1 + + +@pytest.mark.asyncio +async def test_reset_callback_updates_both_profile_fields( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + message = Message( + message_id=42, + date="2024-01-01T00:00:00Z", + chat=Chat(id=123, type="private"), + ) + query = CallbackQuery( + id="cq1", + from_user=user, + chat_instance="inst", + data="s:x", + message=message, + ) + + mock_storage = AsyncMock() + with ( + patch( + "blacki.telegram.settings_menu.get_preferences_storage", + return_value=mock_storage, + ), + patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as update, + ): + await menu.handle_callback(query) + + update.assert_awaited_once_with( + mock_storage, + "123", + {"model": None, "reasoning": None}, + ) + + +@pytest.mark.asyncio +async def test_thinking_callback_edits_capability_menu( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + message = Message( + message_id=42, + date="2024-01-01T00:00:00Z", + chat=Chat(id=123, type="private"), + ) + query = CallbackQuery( + id="cq1", + from_user=user, + chat_instance="inst", + data="s:t", + message=message, + ) + markup = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Default", callback_data="s:r:inherit")] + ] + ) + with ( + patch( + "blacki.telegram.settings_menu.get_preferences_storage", + return_value=AsyncMock(), + ), + patch.object( + menu, + "_build_thinking_menu", + AsyncMock(return_value=("thinking", markup)), + ), + ): + await menu.handle_callback(query) + + mock_api.edit_message_text.assert_awaited_once_with( + chat_id=123, + message_id=42, + text="thinking", + parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=markup, + ) + + +@pytest.mark.asyncio +async def test_back_callback_returns_to_model_menu( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + message = Message( + message_id=42, + date="2024-01-01T00:00:00Z", + chat=Chat(id=123, type="private"), + ) + query = CallbackQuery( + id="cq1", + from_user=user, + chat_instance="inst", + data="s:b", + message=message, + ) + + with ( + patch( + "blacki.telegram.settings_menu.get_preferences_storage", + return_value=AsyncMock(), + ), + patch.object(menu, "_edit_model_menu", AsyncMock()) as edit_menu, + ): + await menu.handle_callback(query) + + edit_menu.assert_awaited_once_with(query, 123) + + +def test_settings_callback_parser_handles_navigation_actions() -> None: + assert SettingsMenu._parse_settings_callback("s:t") == ("thinking", None) + assert SettingsMenu._parse_settings_callback("s:b") == ("back", None) + assert SettingsMenu._parse_settings_callback("s:x") == ("reset", None) + + +@pytest.mark.asyncio +async def test_edit_model_menu_ignores_message_less_callback( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + query = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="s:b", message=None + ) + + await menu._edit_model_menu(query, 123) + + mock_api.edit_message_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_edit_error_ignores_message_less_callback( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + query = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="s:b", message=None + ) + + await menu._edit_error(query, 123, "failed") + + mock_api.edit_message_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolve_capabilities_skips_missing_models(menu: SettingsMenu) -> None: + assert await menu._resolve_capabilities(None) is None + assert await menu._resolve_capabilities("default") is None + + +@pytest.mark.asyncio +async def test_resolve_capabilities_uses_cached_resolver(menu: SettingsMenu) -> None: + resolver = AsyncMock() + resolver.resolve.return_value = None + menu._capabilities_resolver = resolver + + assert await menu._resolve_capabilities("openrouter/openai/gpt-5.6-luna") is None + + resolver.resolve.assert_awaited_once() + assert resolver.resolve.await_args.args == ("openrouter/openai/gpt-5.6-luna",) + + +def test_model_display_name_handles_unknown_future_model(menu: SettingsMenu) -> None: + assert menu._model_display_name("openrouter/acme/future-model") == "future-model" + + +def test_model_display_name_handles_system_default(menu: SettingsMenu) -> None: + with patch("blacki.telegram.settings_menu.MODEL_CHOICES", {}): + assert menu._model_display_name("default") == "System Default" + + +def test_reasoning_display_inherits_when_only_token_budget_is_set( + menu: SettingsMenu, +) -> None: + profile = InferenceProfile(reasoning=ReasoningConfig(max_tokens=256)) + + assert menu._reasoning_display(profile) == "Default" + + +def test_reasoning_options_include_gateway_values_when_unspecified() -> None: + menu = SettingsMenu.__new__(SettingsMenu) + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=None, + mandatory=False, + ) + ) + + options = menu._reasoning_options(capability) + + assert ("max", "Max") in options + assert ("none", "Off") in options + + +def test_reasoning_options_skip_empty_and_inherit_values() -> None: + menu = SettingsMenu.__new__(SettingsMenu) + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=(None, "inherit", "max"), + mandatory=False, + ) + ) + + assert menu._reasoning_options(capability) == [ + ("inherit", "Default"), + ("max", "Max"), + ] + + +def test_reasoning_config_handles_inherit_and_invalid_values() -> None: + assert SettingsMenu._reasoning_config("inherit") is None + assert SettingsMenu._reasoning_config("not-an-effort") is None