import asyncio
import base64
import contextlib
import inspect
import io
import os
import random
import tempfile
import time
from dataclasses import dataclass, field
from typing import Any

from telegram import BotCommand
from telegram.constants import ChatAction

from otto.agent import _max_input_tokens
from otto.chat import (
    Chat,
    Renderer,
    _count_tokens,
    build_help_category_view,
    build_help_main_view,
    build_history_page,
    build_new_success,
    build_provider_picker,
    build_setup_completion_message,
    build_subagent_detail_view,
    build_subagents_main_view,
    execute_new_session,
    get_provider_model_buttons,
)
from otto.config import (
    BotAuthConfig,
    BotConfig,
    ChannelConfig,
    Config,
    TelegramConfig,
    UserConfig,
    resolve_user,
    save_config,
)
from otto.errors import OttoAuthError, OttoConfigError, OttoExecutionError, OttoTransientError
from otto.log import get_logger
from otto.subagents import load_subagent_profiles

log = get_logger("otto.telegram")


BOT_COMMANDS = [
    BotCommand("help", "Commands & reference"),
    BotCommand("status", "System status"),
    BotCommand("model", "Show/switch AI model"),
    BotCommand("thinking", "Set reasoning depth"),
    BotCommand("agents", "List delegated agent jobs"),
    BotCommand("new", "New session"),
    BotCommand("clear", "Clear session history"),
    BotCommand("tools", "List available tools"),
    BotCommand("skills", "List installed skills"),
    BotCommand("subagents", "List delegation subagents"),
    BotCommand("hud", "Recreate status HUD"),
    BotCommand("stop", "Cancel current response"),
]


TELEGRAM_MAX_MESSAGE_LEN = 4096
_CHUNK_SEND_DELAY_SECONDS = 0.1
_ERROR_TAIL_CHARS = 2000
_ERROR_TRUNCATED_PREFIX = "... (truncated)\n"
_GENERIC_USER_ERROR = "There was an error with this request. Check otto logs for more information."
_BUSY_CHAT_MESSAGE = "Still working on your previous message. Send /stop to cancel it."
_MESSAGE_DEBOUNCE_SECONDS = 2.0
_HUD_WORKING_THRESHOLD_SECONDS = 8.0
_HUD_WORKING_REFRESH_SECONDS = 5.0
_HUD_JOB_BANNER_SECONDS = 10.0
_HUD_WORKING_LABEL = "Working"
_HUD_PIN_HISTORY_LIMIT = 10
_HUD_THREAD_ROOT_SUFFIX = "root"
_POLLING_ALLOWED_UPDATES = ["message", "callback_query", "message_reaction"]
_POLLING_ERROR_LOG_INTERVAL_SECONDS = 30.0
_POLLING_RECOVERY_MAX_ATTEMPTS = 6
_POLLING_RECOVERY_MAX_DELAY_SECONDS = 60.0
_POLLING_RECOVERY_BASE_DELAY_SECONDS = 2.0
_POLLING_CONFLICT_BASE_DELAY_SECONDS = 10.0


@dataclass(slots=True)
class _PendingTextBatch:
    chat_id_int: int
    chat_id_str: str
    context: Any
    trigger_message: Any
    thread_id: int | None = None
    parts: list[str] = field(default_factory=list)


@dataclass(slots=True)
class _SteeringRunState:
    pending_inputs: list[str] = field(default_factory=list)
    steering_cancelled: bool = False
    stop_requested: bool = False
    # Scenario A/B tracking
    tools_completed: bool = False  # True once all tools have finished
    cached_tool_context: str | None = None  # Human-readable tool summary for Scenario B
    first_tool_message_id: int | None = (
        None  # Renderer's tool message id; deleted on Scenario A replay
    )
    renderer: "TelegramRenderer | None" = None


@dataclass(slots=True)
class _HudState:
    loaded: bool = False
    message_id: int | None = None
    status_text: str | None = None
    state: str = "idle"
    run_active: bool = False
    run_started_at: float | None = None
    last_working_update_at: float = 0.0
    hold_task: asyncio.Task[None] | None = None
    suppressed: bool = False


@dataclass(frozen=True, slots=True)
class _UserFacingError:
    category: str
    user_message: str
    hud_attention: str | None = None


@dataclass(slots=True)
class _PollingHealth:
    state: str = "stopped"
    last_error_type: str | None = None
    last_error_at: float | None = None
    last_success_at: float | None = None
    consecutive_failures: int = 0
    recovery_reason: str | None = None


def _find_split_index(text: str, max_len: int) -> int:
    if max_len <= 0:
        return 1
    window = text[:max_len]
    if not window:
        return 1

    for separator in ("\n\n", "\n"):
        split_index = window.rfind(separator)
        if split_index > 0:
            return split_index + len(separator)

    for separator in (" ", "\t"):
        split_index = window.rfind(separator)
        if split_index > 0:
            return split_index + 1

    return max_len


def _split_text_for_telegram(text: str, max_len: int = TELEGRAM_MAX_MESSAGE_LEN) -> list[str]:
    if not text:
        return []
    chunk_len = max(1, max_len)
    if len(text) <= chunk_len:
        return [text]

    chunks: list[str] = []
    remaining = text
    while remaining:
        if len(remaining) <= chunk_len:
            chunks.append(remaining)
            break
        split_index = _find_split_index(remaining, chunk_len)
        chunks.append(remaining[:split_index])
        remaining = remaining[split_index:]
    return chunks


async def _send_chunked(message: Any, text: str, **kwargs: Any) -> list[Any]:
    """Send text via reply_* methods, splitting content at Telegram's message limit."""
    method_name = kwargs.pop("_send_method", "reply_text")
    send = getattr(message, method_name)
    chunks = _split_text_for_telegram(text, TELEGRAM_MAX_MESSAGE_LEN)
    sent_messages: list[Any] = []
    for index, chunk in enumerate(chunks):
        sent_messages.append(await send(chunk, **kwargs))
        if index + 1 < len(chunks):
            await asyncio.sleep(_CHUNK_SEND_DELAY_SECONDS)
    return sent_messages


def _truncate_error(error: str) -> str:
    if len(error) <= _ERROR_TAIL_CHARS:
        return error
    return f"{_ERROR_TRUNCATED_PREFIX}{error[-_ERROR_TAIL_CHARS:]}"


def _sanitize_telegram_html(text: str) -> str:
    """Strip HTML tags that Telegram does not support.

    Telegram HTML only accepts: <b>, <i>, <u>, <s>, <code>, <pre>, <a>, <blockquote>,
    <tg-spoiler>, <tg-emoji>, <strong>, <em>, <ins>, <del>, <strike>.
    Unsupported tags like <br/>, <p>, <li>, <div>, <h1>-<h6>, <span>, <ul>, <ol>
    cause the entire message to fail parse_mode="HTML", triggering a plain-text
    fallback that shows raw tags to the user.

    Content inside <code> and <pre> blocks is left untouched — HTML tags in code
    are meant to be displayed literally and stripping them corrupts the content.
    """
    import re

    # Split text into code-protected and non-code segments.  We process only
    # the non-code parts so that e.g. ``<code><br/></code>`` is preserved.
    code_block_re = re.compile(
        r"(<code(?:\s[^>]*)?>.*?</code>|<pre(?:\s[^>]*)?>.*?</pre>)",
        re.DOTALL | re.IGNORECASE,
    )
    segments = code_block_re.split(text)
    result_parts: list[str] = []
    for segment in segments:
        if code_block_re.fullmatch(segment):
            result_parts.append(segment)
            continue
        # Replace <br>, <br/>, <br /> with newline.
        segment = re.sub(r"<br\s*/?>", "\n", segment, flags=re.IGNORECASE)
        # Replace block-level openers with double newline.
        segment = re.sub(r"<(?:p|div|h[1-6])(?:\s[^>]*)?>", "\n\n", segment, flags=re.IGNORECASE)
        # Replace <li> with newline + bullet.
        segment = re.sub(r"<li(?:\s[^>]*)?>", "\n• ", segment, flags=re.IGNORECASE)
        # Strip closing block tags and list wrappers.
        segment = re.sub(
            r"</(?:p|div|h[1-6]|li|ul|ol|span|table|tr|td|th|thead|tbody|img|hr)>",
            "",
            segment,
            flags=re.IGNORECASE,
        )
        # Strip opening list/table wrappers and other unsupported void tags.
        segment = re.sub(
            r"<(?:ul|ol|span|table|tr|td|th|thead|tbody|img|hr)(?:\s[^>]*)?>",
            "",
            segment,
            flags=re.IGNORECASE,
        )
        result_parts.append(segment)

    text = "".join(result_parts)
    # Collapse triple+ newlines into double.
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text


def _md_to_telegram_html(text: str) -> str:
    """Convert limited markdown (bold, italic, code) to Telegram HTML."""
    import re

    # Code blocks first (```...```)
    text = re.sub(r"```(\w*)\n(.*?)```", r"<pre>\2</pre>", text, flags=re.DOTALL)
    # Inline code (`...`)
    text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
    # Bold (**...**)
    text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", text)
    # Italic (*...*)
    text = re.sub(r"\*(.+?)\*", r"<i>\1</i>", text)
    return text


def build_telegram_handoff_instructions(
    bot_name: str | None = None,
    *,
    channels: object | None = None,
    telegram_handle: str | None = None,
) -> str:
    """Build Telegram handoff instructions aligned with setup channel choice."""
    return build_setup_completion_message(
        channels or ("telegram", "cli"),
        bot_name=bot_name,
        telegram_handle=telegram_handle,
        output_format="telegram_html",
    )


class TelegramRenderer:
    """Renders chat events as Telegram messages.

    Response text is sent after completion (no streaming).
    Tool lifecycle is tracked internally for steering and HUD updates, but
    no per-tool status messages are emitted.
    """

    output_format = "telegram_html"

    def __init__(
        self,
        bot: Any,
        chat_id: int,
        max_len: int = TELEGRAM_MAX_MESSAGE_LEN,
        *,
        message_thread_id: int | None = None,
        on_model_change: Any | None = None,
        on_session_reset: Any | None = None,
    ):
        self._bot = bot
        self._chat_id = chat_id
        self._max_len = max_len
        self._message_thread_id = message_thread_id
        self._buffer: list[str] = []
        self._tools_used: list[str] = []
        self._tool_names_used: list[str] = []
        self._tool_message_id: int | None = None
        self._last_tool_flush: float = 0.0
        self._active_tool_calls = 0
        self._had_tool_calls: bool = False  # True once any tool has started
        self._on_model_change = on_model_change
        self._on_session_reset = on_session_reset

    def on_text(self, chunk: str) -> None:
        self._buffer.append(chunk)

    def on_tool_start(self, name: str, args: dict) -> None:
        self._tool_names_used.append(name)
        self._tools_used.append(self._format_tool_label(name, args))
        self._active_tool_calls += 1
        self._had_tool_calls = True
        self._buffer.clear()  # discard intermediate thinking text

    def on_tool_end(self, name: str, result: str) -> None:
        _ = name, result
        if self._active_tool_calls > 0:
            self._active_tool_calls -= 1

    def has_active_tool_call(self) -> bool:
        return self._active_tool_calls > 0

    def had_tool_calls(self) -> bool:
        """True once at least one tool has started (regardless of completion)."""
        return self._had_tool_calls

    def all_tools_idle(self) -> bool:
        """True when tools were used AND none are currently active (post-tool, pre/mid-inference)."""
        return self._had_tool_calls and self._active_tool_calls == 0

    def tool_call_count(self) -> int:
        return len(self._tool_names_used)

    def on_model_change(self, model_name: str) -> None:
        if callable(self._on_model_change):
            self._on_model_change(model_name)

    def on_session_reset(self) -> None:
        if callable(self._on_session_reset):
            self._on_session_reset()

    def reset_for_steering_restart(self) -> None:
        self._buffer.clear()

    async def send_text(self, text: str) -> None:
        await self._send(_md_to_telegram_html(text))

    def _send_target_kwargs(self) -> dict[str, Any]:
        kwargs: dict[str, Any] = {"chat_id": self._chat_id}
        if self._message_thread_id is not None:
            kwargs["message_thread_id"] = self._message_thread_id
        return kwargs

    async def send_with_buttons(self, text: str, buttons: list[list[tuple[str, str]]]) -> None:
        from telegram import InlineKeyboardButton, InlineKeyboardMarkup

        keyboard = [
            [InlineKeyboardButton(label, callback_data=data) for label, data in row]
            for row in buttons
        ]
        markup = InlineKeyboardMarkup(keyboard)
        html_text = _md_to_telegram_html(text)
        target_kwargs = self._send_target_kwargs()
        try:
            await _call_with_backoff(
                lambda: self._bot.send_message(
                    text=html_text,
                    parse_mode="HTML",
                    reply_markup=markup,
                    **target_kwargs,
                )
            )
        except Exception as exc:
            if _is_retry_after(exc):
                return
            await _call_with_backoff(
                lambda: self._bot.send_message(
                    text=html_text,
                    reply_markup=markup,
                    **target_kwargs,
                )
            )

    async def send_file(self, path: str, caption: str | None = None) -> None:
        from pathlib import Path as _Path

        file_path = _Path(path)
        try:
            with file_path.open("rb") as f:
                await self._bot.send_document(
                    document=f,
                    filename=file_path.name,
                    caption=caption,
                    **self._send_target_kwargs(),
                )
        except Exception as exc:
            await self._send(f"Failed to send file: {exc}")

    async def show_error(self, error: str) -> None:
        await self._send(f"Error: {_truncate_error(error)}")

    async def flush(self) -> None:
        """Send complete response as one message."""
        text = "".join(self._buffer)
        self._buffer.clear()
        self._tools_used.clear()
        self._tool_names_used.clear()
        if not text:
            return
        await self._send(text)

    async def tick(self) -> None:
        """Periodic renderer maintenance hook."""
        _ = None

    async def _send(self, text: str, *, reply_to_message_id: int | None = None) -> Any:
        text = _sanitize_telegram_html(text)
        chunks = self._split_text(text, self._max_len)
        if not chunks:
            return None

        sent_message: Any = None
        target_kwargs = self._send_target_kwargs()
        for index, chunk in enumerate(chunks):
            send_kwargs: dict[str, Any] = dict(target_kwargs)
            if index == 0 and reply_to_message_id is not None:
                send_kwargs["reply_to_message_id"] = reply_to_message_id
            try:
                sent_message = await _call_with_backoff(
                    lambda: self._bot.send_message(
                        text=chunk,
                        parse_mode="HTML",
                        **send_kwargs,
                    )
                )
            except Exception as exc:
                if _is_retry_after(exc):
                    return None
                sent_message = await _call_with_backoff(
                    lambda: self._bot.send_message(
                        text=chunk,
                        **send_kwargs,
                    )
                )
            if index + 1 < len(chunks):
                await asyncio.sleep(_CHUNK_SEND_DELAY_SECONDS)
        return sent_message

    async def _update_tool_message(self) -> None:
        # HUD owns tool-progress visibility.
        _ = None

    async def _finalize_tools(self) -> None:
        # HUD owns completion visibility.
        self._tools_used.clear()
        self._tool_names_used.clear()

    @staticmethod
    def _split_text(text: str, max_len: int) -> list[str]:
        return _split_text_for_telegram(text, max_len)

    @staticmethod
    def _format_tool_label(name: str, args: dict) -> str:
        label_max = 60
        clean = name.removeprefix("mcp__")

        extractors: dict[str, str] = {
            "bash": "command",
            "read_file": "path",
            "edit_file": "path",
            "write_file": "path",
            "web_search": "query",
            "memory_search": "query",
            "memory_store": "key",
            "fetch": "url",
            "browse": "url",
            "view_image": "path",
        }

        short_names: dict[str, str] = {
            "read_file": "read",
            "write_file": "write",
            "edit_file": "edit",
            "web_search": "search",
            "memory_search": "mem search",
            "memory_store": "mem store",
            "view_image": "image",
        }

        arg_key = extractors.get(clean)
        if arg_key:
            value = args.get(arg_key, "")
            if value:
                label = short_names.get(clean, clean)
                full = f"{label}: {value}"
                if len(full) > label_max:
                    return full[: label_max - 1] + "…"
                return full

        return clean.replace("_", " ").title()

    @staticmethod
    def _group_tool_summary(names: list[str]) -> str:
        short: dict[str, str] = {
            "read_file": "read",
            "write_file": "write",
            "edit_file": "edit",
            "web_search": "search",
            "memory_search": "mem search",
            "memory_store": "mem store",
            "view_image": "image",
        }
        counts: dict[str, int] = {}
        order: list[str] = []
        for raw in names:
            clean = raw.removeprefix("mcp__")
            label = short.get(clean, clean.replace("_", " "))
            if label not in counts:
                counts[label] = 0
                order.append(label)
            counts[label] += 1
        parts = []
        for label in order:
            c = counts[label]
            parts.append(f"{label} ×{c}")
        return ", ".join(parts)

    async def _safe_edit_tool_message(self, text: str) -> None:
        if self._tool_message_id is None:
            return
        try:
            await _call_with_backoff(
                lambda: self._bot.edit_message_text(
                    message_id=self._tool_message_id,
                    text=text,
                    parse_mode="HTML",
                    **self._send_target_kwargs(),
                )
            )
        except Exception as exc:
            if _is_retry_after(exc):
                return
            try:
                await _call_with_backoff(
                    lambda: self._bot.edit_message_text(
                        message_id=self._tool_message_id,
                        text=text,
                        **self._send_target_kwargs(),
                    )
                )
            except Exception as exc:
                log.debug("edit fallback failed", error=str(exc))


class CallbackTelegramRenderer(TelegramRenderer):
    """Renderer that edits the originating callback message in place."""

    def __init__(
        self,
        bot: Any,
        chat_id: int,
        query: Any,
        max_len: int = TELEGRAM_MAX_MESSAGE_LEN,
        *,
        on_model_change: Any | None = None,
        on_session_reset: Any | None = None,
    ):
        super().__init__(
            bot,
            chat_id,
            max_len,
            on_model_change=on_model_change,
            on_session_reset=on_session_reset,
        )
        self._query = query

    @staticmethod
    def _is_noop_edit_error(exc: BaseException) -> bool:
        return "message is not modified" in str(exc).lower()

    async def _edit_primary_message(self, text: str, *, markup: Any | None = None) -> None:
        try:
            await _call_with_backoff(
                lambda: self._query.edit_message_text(
                    text=text,
                    parse_mode="HTML",
                    reply_markup=markup,
                )
            )
            return
        except Exception as exc:
            if _is_retry_after(exc) or self._is_noop_edit_error(exc):
                return

        try:
            await _call_with_backoff(
                lambda: self._query.edit_message_text(text=text, reply_markup=markup)
            )
        except Exception as exc:
            if _is_retry_after(exc) or self._is_noop_edit_error(exc):
                return

    async def _send(self, text: str, *, reply_to_message_id: int | None = None) -> Any:
        _ = reply_to_message_id
        text = _sanitize_telegram_html(text)
        chunks = self._split_text(text, self._max_len)
        if not chunks:
            return None

        await self._edit_primary_message(chunks[0])
        sent_message: Any = getattr(self._query, "message", None)
        for index, chunk in enumerate(chunks[1:]):
            try:
                sent_message = await _call_with_backoff(
                    lambda: self._bot.send_message(
                        text=chunk,
                        parse_mode="HTML",
                        **self._send_target_kwargs(),
                    )
                )
            except Exception as exc:
                if _is_retry_after(exc):
                    return None
                sent_message = await _call_with_backoff(
                    lambda: self._bot.send_message(
                        text=chunk,
                        **self._send_target_kwargs(),
                    )
                )
            if index + 1 < len(chunks) - 1:
                await asyncio.sleep(_CHUNK_SEND_DELAY_SECONDS)
        return sent_message

    async def send_with_buttons(self, text: str, buttons: list[list[tuple[str, str]]]) -> None:
        from telegram import InlineKeyboardButton, InlineKeyboardMarkup

        keyboard = [
            [InlineKeyboardButton(label, callback_data=data) for label, data in row]
            for row in buttons
        ]
        markup = InlineKeyboardMarkup(keyboard)
        html_text = _md_to_telegram_html(text)
        chunks = self._split_text(html_text, self._max_len)
        if not chunks:
            return

        await self._edit_primary_message(chunks[0], markup=markup)
        for index, chunk in enumerate(chunks[1:]):
            try:
                await _call_with_backoff(
                    lambda: self._bot.send_message(
                        text=chunk,
                        parse_mode="HTML",
                        **self._send_target_kwargs(),
                    )
                )
            except Exception as exc:
                if _is_retry_after(exc):
                    return
                await _call_with_backoff(
                    lambda: self._bot.send_message(
                        text=chunk,
                        **self._send_target_kwargs(),
                    )
                )
            if index + 1 < len(chunks) - 1:
                await asyncio.sleep(_CHUNK_SEND_DELAY_SECONDS)


def _is_retry_after(exc: BaseException) -> bool:
    return "RetryAfter" in type(exc).__name__ or "Flood control" in str(exc)


def _is_transient_telegram_error(exc: BaseException) -> bool:
    if _is_retry_after(exc):
        return False
    tokens = f"{type(exc).__name__} {exc}".lower()
    markers = (
        "timedout",
        "timed out",
        "networkerror",
        "connecterror",
        "readtimeout",
        "writetimeout",
        "connect timeout",
        "connection",
        "temporarily unavailable",
    )
    return any(marker in tokens for marker in markers)


async def _call_with_backoff(operation, *, attempts: int = 3, base_delay: float = 0.3) -> Any:
    delay = max(0.05, base_delay)
    for attempt in range(1, attempts + 1):
        try:
            return await operation()
        except Exception as exc:
            if not _is_transient_telegram_error(exc) or attempt >= attempts:
                raise
            jitter = random.uniform(0.0, min(delay * 0.25, 0.5))
            await asyncio.sleep(delay + jitter)
            delay = min(delay * 2, 2.0)
    return None


class TelegramBot:
    def __init__(
        self,
        chat: Chat,
        config: Config,
        bot_config: BotConfig | None = None,
        channel_config: ChannelConfig | None = None,
        # Legacy compat — kept so existing callers that pass positional
        # TelegramConfig still work until daemon.py is fully migrated.
        telegram_config: TelegramConfig | None = None,
        alias: str | None = None,
    ):
        self._chat = chat
        self._config = config

        # New-schema path: BotConfig + ChannelConfig provided directly.
        if bot_config is not None:
            self._bot_config: BotConfig | None = bot_config
            self._channel_config: ChannelConfig | None = channel_config
            self._telegram_config: TelegramConfig | None = None
            self._token = channel_config.token if channel_config else ""
            self._alias = bot_config.name
        else:
            # Legacy path: TelegramConfig bridge.
            self._bot_config = None
            self._channel_config = None
            self._telegram_config = telegram_config or config.telegram
            self._token = self._telegram_config.token if self._telegram_config else ""
            self._alias = alias

        self._app: Any | None = None
        self._running_tasks: dict[str, asyncio.Task] = {}  # chat_id -> agent task
        self._active_chats: set[str] = set()
        self._active_chats_lock = asyncio.Lock()
        self._log = log.bind(bot=self.alias)
        self._pending_startup_notifications: list[tuple[int, str]] = []
        self._startup_notification_tasks: set[asyncio.Task[None]] = set()
        self._pending_text_batches: dict[str, _PendingTextBatch] = {}
        self._debounce_tasks: dict[str, asyncio.Task[None]] = {}
        self._debounce_lock = asyncio.Lock()
        self._steering_state: dict[str, _SteeringRunState] = {}
        self._steering_lock = asyncio.Lock()

        self._hud_state: dict[tuple[int, int | None], _HudState] = {}
        self._chat_context_to_telegram_id: dict[str, int] = {}
        self._job_status_snapshot: dict[str, str] = {}
        self._job_status_loop_task: asyncio.Task[None] | None = None

        self._stopping = False
        self._polling_health = _PollingHealth()
        self._polling_recovery_task: asyncio.Task[None] | None = None
        self._polling_error_last_log_at: dict[str, float] = {}
        self._polling_error_suppressed: dict[str, int] = {}

    def _thread_suffix(self, thread_id: int | None) -> str:
        return str(thread_id) if thread_id is not None else _HUD_THREAD_ROOT_SUFFIX

    def _register_chat_context(
        self,
        chat_id_str: str,
        chat_id_int: int,
        thread_id: int | None = None,
    ) -> None:
        _ = thread_id
        self._chat_context_to_telegram_id[chat_id_str] = chat_id_int

    @staticmethod
    def _hud_memory_key(chat_id_int: int, thread_id: int | None = None) -> str:
        suffix = str(thread_id) if thread_id is not None else _HUD_THREAD_ROOT_SUFFIX
        return f"hud:{chat_id_int}:{suffix}"

    def _hud_memory_get(self, chat_id_int: int, thread_id: int | None = None) -> int | None:
        memory = getattr(self._chat, "_memory", None)
        if memory is None:
            return None
        if type(memory).__module__.startswith("unittest.mock"):
            return None
        if not hasattr(memory, "get"):
            return None
        try:
            raw = memory.get(self._hud_memory_key(chat_id_int))
        except Exception:
            return None
        if raw is None:
            return None
        try:
            return int(str(raw).strip())
        except (TypeError, ValueError):
            return None

    def _hud_memory_set(
        self,
        chat_id_int: int,
        message_id: int | None,
        thread_id: int | None = None,
    ) -> None:
        memory = getattr(self._chat, "_memory", None)
        if memory is None:
            return
        if type(memory).__module__.startswith("unittest.mock"):
            return
        key = self._hud_memory_key(chat_id_int, thread_id)
        if message_id is None:
            delete = getattr(memory, "delete", None)
            if callable(delete):
                try:
                    delete(key)
                except Exception as exc:
                    log.debug("HUD memory delete failed", key=key, error=str(exc))
            return
        set_fn = getattr(memory, "set", None)
        if callable(set_fn):
            try:
                set_fn(key, str(message_id))
                return
            except Exception as exc:
                log.debug("HUD memory set failed", key=key, error=str(exc))
        store_fn = getattr(memory, "store", None)
        if callable(store_fn):
            with contextlib.suppress(Exception):
                store_fn(key, str(message_id))

    @staticmethod
    def _hud_history_key(chat_id_int: int, thread_id: int | None = None) -> str:
        suffix = str(thread_id) if thread_id is not None else _HUD_THREAD_ROOT_SUFFIX
        return f"hud:{chat_id_int}:{suffix}:messages"

    def _hud_history_get(self, chat_id_int: int, thread_id: int | None = None) -> list[int]:
        memory = getattr(self._chat, "_memory", None)
        if memory is None:
            return []
        if type(memory).__module__.startswith("unittest.mock"):
            return []
        if not hasattr(memory, "get"):
            return []
        try:
            raw = memory.get(self._hud_history_key(chat_id_int, thread_id))
        except Exception:
            return []
        if raw is None:
            return []
        ids: list[int] = []
        for token in str(raw).split(","):
            token = token.strip()
            if not token:
                continue
            try:
                ids.append(int(token))
            except (TypeError, ValueError):
                continue
        return ids

    def _hud_history_set(
        self,
        chat_id_int: int,
        ids: list[int],
        thread_id: int | None = None,
    ) -> None:
        memory = getattr(self._chat, "_memory", None)
        if memory is None:
            return
        if type(memory).__module__.startswith("unittest.mock"):
            return
        key = self._hud_history_key(chat_id_int, thread_id)
        values = [str(mid) for mid in ids if isinstance(mid, int)]
        if not values:
            delete = getattr(memory, "delete", None)
            if callable(delete):
                with contextlib.suppress(Exception):
                    delete(key)
            return
        value = ",".join(values)
        set_fn = getattr(memory, "set", None)
        if callable(set_fn):
            with contextlib.suppress(Exception):
                set_fn(key, value)
                return
        store_fn = getattr(memory, "store", None)
        if callable(store_fn):
            with contextlib.suppress(Exception):
                store_fn(key, value)

    def _get_hud_state(self, chat_id_int: int, thread_id: int | None = None) -> _HudState:
        key = (chat_id_int, thread_id)
        state = self._hud_state.get(key)
        if state is None:
            state = _HudState()
            self._hud_state[key] = state
        if not state.loaded:
            state.message_id = self._hud_memory_get(chat_id_int, thread_id)
            state.loaded = True
        return state

    @staticmethod
    def _short_model_name(model: str) -> str:
        value = str(model or "model")
        short = value.split("/", 1)[-1]
        if "-" in short:
            head, tail = short.rsplit("-", 1)
            if tail.isdigit() and len(tail) == 8 and head:
                short = head
        if len(short) > 32:
            return short[:31] + "…"
        return short

    def _context_usage_percent(self, chat_id_str: str, model: str) -> int | None:
        try:
            session_store = getattr(self._chat, "_session_store", None)
            if session_store is None:
                return None
            session = session_store.load(chat_id_str)
            if inspect.iscoroutine(session):
                session.close()
                return None
            if inspect.isawaitable(session):
                return None
            if session is None:
                return None

            messages = getattr(session, "messages", []) or []
            # Count only current request context (tool results included by design).
            used_tokens = int(_count_tokens(messages=messages, model=model) or 0)
            max_input_tokens = int(_max_input_tokens(model) or 0)
            if max_input_tokens <= 0:
                return None

            pct = int(round((used_tokens / max_input_tokens) * 100))
            if pct < 0:
                return 0
            if pct > 999:
                return 999
            return pct
        except Exception:
            return None

    def _build_hud_idle_text(self, chat_id_str: str) -> str:
        model = self._config.agent.model
        try:
            context_key = self._chat._context_key(chat_id_str, bot_id=self.alias)
            overrides = getattr(self._chat, "_model_overrides", {})
            value = overrides.get(context_key, self._config.agent.model)
            if isinstance(value, str):
                model = value
        except Exception as exc:
            self._log.debug("model override lookup failed", error=str(exc))

        context_pct = self._context_usage_percent(chat_id_str, model)
        if context_pct is None:
            return f"{self._short_model_name(model)}"
        return f"{self._short_model_name(model)} · {context_pct}% context"

    def _build_hud_working_text(
        self, chat_id_str: str, model: str, elapsed_seconds: float, tools: list[str]
    ) -> str:
        seconds = max(0, int(elapsed_seconds))
        tool_count = len(tools)
        suffix = "tool" if tool_count == 1 else "tools"
        base = f"{_HUD_WORKING_LABEL} · {seconds}s · {tool_count} {suffix}"
        context_pct = self._context_usage_percent(chat_id_str, model)
        if context_pct is not None:
            base = f"{base} · {context_pct}%"
        if tool_count > 0:
            summary = TelegramRenderer._group_tool_summary(tools)
            if len(summary) > 40:
                summary = summary[:37] + "..."
            return f"{base} [{summary}]"
        return base

    @staticmethod
    def _is_hud_noop_error(exc: BaseException) -> bool:
        return "message is not modified" in str(exc).lower()

    @staticmethod
    def _iter_exception_chain(exc: BaseException) -> list[BaseException]:
        chain: list[BaseException] = []
        seen: set[int] = set()
        current: BaseException | None = exc
        while current is not None:
            marker = id(current)
            if marker in seen:
                break
            seen.add(marker)
            chain.append(current)
            current = current.__cause__ or current.__context__
        return chain

    @classmethod
    def _classify_exception(cls, exc: BaseException) -> _UserFacingError:
        chain = cls._iter_exception_chain(exc)
        class_names = [type(item).__name__.lower() for item in chain]
        messages = [str(item).lower() for item in chain]
        tokens = " | ".join([*class_names, *messages])

        if any(
            type(item).__module__.startswith("telegram.")
            and type(item).__name__ in {"TimedOut", "NetworkError"}
            for item in chain
        ):
            return _UserFacingError(
                category="telegram_transient",
                user_message="Telegram is temporarily unreachable. Please retry in a moment.",
                hud_attention="Telegram unavailable",
            )

        rate_limit_markers = [
            "ratelimiterror",
            "rate limit",
            "too many requests",
            "quota exceeded",
            " 429",
        ]
        if any(marker in tokens for marker in rate_limit_markers):
            return _UserFacingError(
                category="rate_limit",
                user_message="Rate limited by the model provider. Please retry in a moment.",
                hud_attention="Rate limited",
            )

        auth_markers = [
            "authenticationerror",
            "permissiondeniederror",
            "unauthorized",
            "invalid api key",
            "expired token",
            "forbidden",
            "permission denied",
            " 401",
            " 403",
            "auth",
        ]
        if any(marker in tokens for marker in auth_markers):
            return _UserFacingError(
                category="auth",
                user_message=(
                    "Authentication with the model provider failed. Reauthenticate and try again."
                ),
                hud_attention="Auth expired",
            )

        model_markers = [
            "notfounderror",
            "model not found",
            "unknown model",
            "unsupported model",
            "unavailable model",
            "model unavailable",
        ]
        if any(marker in tokens for marker in model_markers):
            return _UserFacingError(
                category="model_unavailable",
                user_message="The selected model is unavailable. Choose a different model and try again.",
                hud_attention="Model unavailable",
            )

        context_markers = [
            "contextwindowexceedederror",
            "maximum context length",
            "context window",
            "token limit",
            "request too large",
        ]
        if any(marker in tokens for marker in context_markers):
            return _UserFacingError(
                category="context_window",
                user_message=(
                    "This request is too large for the model context window. "
                    "Start a new session or shorten the request."
                ),
                hud_attention="Context full",
            )

        bad_request_markers = [
            "badrequesterror",
            "invalid request",
            "invalid tool",
            "malformed",
            "invalid schema",
            "unprocessable",
            " 400",
            " 422",
        ]
        if any(marker in tokens for marker in bad_request_markers):
            return _UserFacingError(
                category="bad_request",
                user_message=(
                    "The request could not be processed by the model provider. "
                    "Please retry or adjust your request."
                ),
            )

        transient_markers = [
            "apiconnectionerror",
            "serviceunavailableerror",
            "internalservererror",
            "timeout",
            "timed out",
            "temporarily unavailable",
            "connection reset",
            "upstream",
            " 500",
            " 502",
            " 503",
            " 504",
        ]
        if any(marker in tokens for marker in transient_markers):
            return _UserFacingError(
                category="provider_transient",
                user_message="The model provider is temporarily unavailable. Please retry shortly.",
                hud_attention="Provider unavailable",
            )

        policy_markers = [
            "contentpolicyviolationerror",
            "content policy",
            "safety",
            "policy violation",
            "prompt blocked",
        ]
        if any(marker in tokens for marker in policy_markers):
            return _UserFacingError(
                category="content_policy",
                user_message="This request was blocked by provider safety policies. Please revise and retry.",
                hud_attention="Blocked by policy",
            )

        if isinstance(exc, OttoAuthError):
            return _UserFacingError(
                category="auth",
                user_message=(
                    "Authentication with the model provider failed. Reauthenticate and try again."
                ),
                hud_attention="Auth expired",
            )

        if isinstance(exc, OttoConfigError):
            return _UserFacingError(
                category="config",
                user_message=("Otto configuration is invalid. Check your settings and retry."),
                hud_attention="Config error",
            )

        if isinstance(exc, OttoTransientError):
            return _UserFacingError(
                category="provider_transient",
                user_message="The model provider is temporarily unavailable. Please retry shortly.",
                hud_attention="Provider unavailable",
            )

        if isinstance(exc, OttoExecutionError):
            return _UserFacingError(category="execution", user_message=_GENERIC_USER_ERROR)

        return _UserFacingError(category="unknown", user_message=_GENERIC_USER_ERROR)

    def _reset_polling_health(self) -> None:
        self._polling_health = _PollingHealth(state="starting")
        self._polling_error_last_log_at.clear()
        self._polling_error_suppressed.clear()

    def _mark_polling_success(self) -> None:
        self._polling_health.state = "running"
        self._polling_health.last_success_at = time.monotonic()
        self._polling_health.last_error_type = None
        self._polling_health.last_error_at = None
        self._polling_health.consecutive_failures = 0
        self._polling_health.recovery_reason = None

    @staticmethod
    def _classify_polling_error(exc: BaseException) -> str:
        tokens = f"{type(exc).__name__} {exc}".lower()
        if "conflict" in tokens and "getupdates" in tokens:
            return "conflict"
        if "timedout" in tokens or "timed out" in tokens:
            return "timeout"
        if "networkerror" in tokens or "connecterror" in tokens or "connection" in tokens:
            return "network"
        return "unknown"

    def _log_polling_error(self, category: str, exc: BaseException) -> None:
        now = time.monotonic()
        last = self._polling_error_last_log_at.get(category, 0.0)
        if last and (now - last) < _POLLING_ERROR_LOG_INTERVAL_SECONDS:
            self._polling_error_suppressed[category] = (
                self._polling_error_suppressed.get(category, 0) + 1
            )
            return

        self._polling_error_last_log_at[category] = now
        suppressed = self._polling_error_suppressed.pop(category, 0)
        payload: dict[str, Any] = {
            "category": category,
            "error_type": type(exc).__name__,
            "error": str(exc),
        }
        if suppressed:
            payload["suppressed"] = suppressed

        if category == "conflict":
            self._log.error("telegram polling conflict detected", **payload)
            return
        if category in {"network", "timeout"}:
            self._log.warning("telegram polling transient error", **payload)
            return
        self._log.warning("telegram polling error", **payload)

    def _on_polling_error(self, exc: BaseException) -> None:
        if self._stopping:
            return

        category = self._classify_polling_error(exc)
        self._polling_health.state = "degraded"
        self._polling_health.last_error_type = category
        self._polling_health.last_error_at = time.monotonic()
        self._polling_health.consecutive_failures += 1
        self._log_polling_error(category, exc)

        if category == "conflict":
            self._schedule_polling_recovery("conflict")
            return
        if category in {"network", "timeout"}:
            self._schedule_polling_recovery("transient")

    def _schedule_polling_recovery(self, reason: str) -> None:
        if self._stopping:
            return
        if self._app is None or self._app.updater is None:
            return

        existing = self._polling_recovery_task
        if existing is not None and not existing.done():
            return

        self._polling_health.recovery_reason = reason
        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:
            return

        task = loop.create_task(
            self._restart_polling_with_backoff(reason),
            name=f"telegram-polling-recovery:{self.alias}",
        )
        self._polling_recovery_task = task
        task.add_done_callback(self._on_polling_recovery_done)

    def _on_polling_recovery_done(self, task: asyncio.Task[None]) -> None:
        if self._polling_recovery_task is task:
            self._polling_recovery_task = None

        if task.cancelled():
            return

        exc = task.exception()
        if exc is not None:
            self._log.exception(
                "telegram polling recovery task failed",
                error=str(exc),
                error_type=type(exc).__name__,
            )

    async def _restart_polling_with_backoff(self, reason: str) -> None:
        if self._app is None or self._app.updater is None:
            return

        delay = (
            _POLLING_CONFLICT_BASE_DELAY_SECONDS
            if reason == "conflict"
            else _POLLING_RECOVERY_BASE_DELAY_SECONDS
        )

        for attempt in range(1, _POLLING_RECOVERY_MAX_ATTEMPTS + 1):
            if self._stopping:
                return

            jitter = random.uniform(0.0, min(delay * 0.2, 2.0))
            await asyncio.sleep(delay + jitter)
            if self._stopping:
                return

            with contextlib.suppress(Exception):
                await self._app.updater.stop()

            try:
                await self._app.updater.start_polling(
                    drop_pending_updates=False,
                    allowed_updates=_POLLING_ALLOWED_UPDATES,
                    error_callback=self._on_polling_error,
                )
            except Exception as exc:
                category = self._classify_polling_error(exc)
                self._polling_health.state = "degraded"
                self._polling_health.last_error_type = category
                self._polling_health.last_error_at = time.monotonic()
                self._polling_health.consecutive_failures += 1
                self._log_polling_error(category, exc)
                delay = min(delay * 2, _POLLING_RECOVERY_MAX_DELAY_SECONDS)
                continue

            self._mark_polling_success()
            self._log.info(
                "telegram polling recovered",
                reason=reason,
                attempt=attempt,
            )
            return

        self._log.error(
            "telegram polling recovery exhausted",
            reason=reason,
            attempts=_POLLING_RECOVERY_MAX_ATTEMPTS,
        )

    @staticmethod
    def _extract_update_thread_id(update: Any) -> int | None:
        if update is None:
            return None

        message = getattr(update, "effective_message", None)
        thread_id = getattr(message, "message_thread_id", None)
        if isinstance(thread_id, int):
            return thread_id

        query = getattr(update, "callback_query", None)
        query_message = getattr(query, "message", None)
        thread_id = getattr(query_message, "message_thread_id", None)
        if isinstance(thread_id, int):
            return thread_id

        return None

    @staticmethod
    def _extract_update_chat_id(update: Any) -> int | None:
        if update is None:
            return None

        chat = getattr(update, "effective_chat", None)
        chat_id = getattr(chat, "id", None)
        if isinstance(chat_id, int):
            return chat_id

        query = getattr(update, "callback_query", None)
        query_message = getattr(query, "message", None)
        chat_id = getattr(query_message, "chat_id", None)
        if isinstance(chat_id, int):
            return chat_id

        return None

    async def _on_application_error(self, update: object, context: Any) -> None:
        exc = getattr(context, "error", None)
        if exc is None:
            exc = RuntimeError("unknown telegram update error")

        classified = self._classify_exception(exc)
        chat_id_int = self._extract_update_chat_id(update)
        thread_id = self._extract_update_thread_id(update)
        user = getattr(update, "effective_user", None) if update is not None else None
        user_id = getattr(user, "id", None)

        self._log.exception(
            "telegram update handler failed",
            category=classified.category,
            chat_id=chat_id_int,
            user_id=user_id,
            error=str(exc),
        )

        if chat_id_int is None:
            return

        chat_id_str = str(chat_id_int)
        if user is not None:
            with contextlib.suppress(Exception):
                chat_id_str = self._resolve_chat_id_str(user, chat_id_int)

        self._register_chat_context(chat_id_str, chat_id_int, thread_id)

        bot = getattr(context, "bot", None)
        if bot is None and self._app is not None:
            bot = self._app.bot
        if bot is None:
            return

        with contextlib.suppress(Exception):
            await self._set_hud_idle(
                chat_id_int=chat_id_int,
                chat_id_str=chat_id_str,
                bot=bot,
                allow_create=False,
                thread_id=thread_id,
            )

        renderer = TelegramRenderer(bot, chat_id_int, message_thread_id=thread_id)
        try:
            await renderer.show_error(classified.user_message)
        except Exception as send_exc:
            self._log.warning(
                "failed to send global telegram error message",
                chat_id=chat_id_int,
                error=str(send_exc),
            )

    def _cancel_hud_hold(self, state: _HudState) -> None:
        task = state.hold_task
        if task is not None:
            task.cancel()
            state.hold_task = None

    def _clear_hud_message_ref(
        self,
        chat_id_int: int,
        state: _HudState,
        thread_id: int | None = None,
    ) -> None:
        state.message_id = None
        state.status_text = None
        self._hud_memory_set(chat_id_int, None, thread_id)

    async def _send_hud_message(
        self,
        chat_id_int: int,
        state: _HudState,
        bot: Any,
        text: str,
        thread_id: int | None = None,
    ) -> bool:
        send_kwargs: dict[str, Any] = {"chat_id": chat_id_int, "text": text}
        if thread_id is not None:
            send_kwargs["message_thread_id"] = thread_id
        try:
            sent = await bot.send_message(**send_kwargs)
        except Exception as exc:
            self._log.warning("hud send failed", chat_id=chat_id_int, error=str(exc))
            return False
        message_id = getattr(sent, "message_id", None)
        if not isinstance(message_id, int):
            return False

        try:
            await bot.pin_chat_message(
                chat_id=chat_id_int,
                message_id=message_id,
                disable_notification=True,
            )
        except Exception as exc:
            self._log.warning("hud pin failed", chat_id=chat_id_int, error=str(exc))

        state.message_id = message_id
        state.status_text = text
        state.suppressed = False
        self._hud_memory_set(chat_id_int, message_id, thread_id)
        return True

    async def _apply_hud_text(
        self,
        *,
        chat_id_int: int,
        state: _HudState,
        bot: Any,
        text: str,
        allow_create: bool,
        manual: bool = False,
        thread_id: int | None = None,
    ) -> bool:
        message_id = state.message_id

        if message_id is not None and state.status_text == text:
            return True

        if message_id is None:
            if not allow_create:
                return False
            if state.suppressed and not manual:
                return False
            return await self._send_hud_message(
                chat_id_int,
                state,
                bot,
                text,
                thread_id=thread_id,
            )

        try:
            edit_kwargs: dict[str, Any] = {
                "chat_id": chat_id_int,
                "message_id": message_id,
                "text": text,
            }
            if thread_id is not None:
                edit_kwargs["message_thread_id"] = thread_id
            await bot.edit_message_text(**edit_kwargs)
        except Exception as exc:
            if self._is_hud_noop_error(exc):
                state.status_text = text
                return True
            self._log.info(
                "hud edit failed; entering silent mode", chat_id=chat_id_int, error=str(exc)
            )
            with contextlib.suppress(Exception):
                await bot.unpin_chat_message(chat_id=chat_id_int, message_id=message_id)
            self._clear_hud_message_ref(chat_id_int, state, thread_id)
            state.suppressed = True
            return False

        state.status_text = text
        return True

    async def _ensure_hud_initialized(
        self,
        *,
        chat_id_int: int,
        chat_id_str: str,
        bot: Any,
        allow_create: bool,
        thread_id: int | None = None,
    ) -> _HudState:
        state = self._get_hud_state(chat_id_int, thread_id)
        idle_text = self._build_hud_idle_text(chat_id_str)

        if state.message_id is not None and state.status_text is None:
            await self._apply_hud_text(
                chat_id_int=chat_id_int,
                state=state,
                bot=bot,
                text=idle_text,
                allow_create=False,
                thread_id=thread_id,
            )
            return state

        if state.message_id is None and allow_create and not state.suppressed:
            await self._apply_hud_text(
                chat_id_int=chat_id_int,
                state=state,
                bot=bot,
                text=idle_text,
                allow_create=True,
                thread_id=thread_id,
            )
        return state

    async def _set_hud_idle(
        self,
        *,
        chat_id_int: int,
        chat_id_str: str,
        bot: Any,
        allow_create: bool = False,
        thread_id: int | None = None,
    ) -> None:
        state = await self._ensure_hud_initialized(
            chat_id_int=chat_id_int,
            chat_id_str=chat_id_str,
            bot=bot,
            allow_create=allow_create,
            thread_id=thread_id,
        )
        self._cancel_hud_hold(state)
        state.state = "idle"
        state.run_active = False
        state.run_started_at = None
        state.last_working_update_at = 0.0
        idle_text = self._build_hud_idle_text(chat_id_str)
        await self._apply_hud_text(
            chat_id_int=chat_id_int,
            state=state,
            bot=bot,
            text=idle_text,
            allow_create=allow_create,
            thread_id=thread_id,
        )

    async def _clear_hud_attention(
        self,
        *,
        chat_id_int: int,
        chat_id_str: str,
        bot: Any,
        thread_id: int | None = None,
    ) -> None:
        state = self._get_hud_state(chat_id_int, thread_id)
        if state.state != "attention" and state.hold_task is None:
            return
        await self._set_hud_idle(
            chat_id_int=chat_id_int,
            chat_id_str=chat_id_str,
            bot=bot,
            allow_create=False,
            thread_id=thread_id,
        )

    def _schedule_hud_revert(
        self,
        *,
        chat_id_int: int,
        chat_id_str: str,
        bot: Any,
        state: _HudState,
        delay_seconds: float,
        thread_id: int | None = None,
    ) -> None:
        async def _revert() -> None:
            try:
                await asyncio.sleep(delay_seconds)
            except asyncio.CancelledError:
                return
            await self._set_hud_idle(
                chat_id_int=chat_id_int,
                chat_id_str=chat_id_str,
                bot=bot,
                allow_create=False,
            )

        task = asyncio.create_task(_revert())
        state.hold_task = task

        def _cleanup(done: asyncio.Task[None]) -> None:
            if state.hold_task is done:
                state.hold_task = None

        task.add_done_callback(_cleanup)

    async def _set_hud_attention(
        self,
        *,
        chat_id_int: int,
        chat_id_str: str,
        bot: Any,
        text: str,
        hold_seconds: float | None = None,
        thread_id: int | None = None,
    ) -> None:
        state = await self._ensure_hud_initialized(
            chat_id_int=chat_id_int,
            chat_id_str=chat_id_str,
            bot=bot,
            allow_create=False,
            thread_id=thread_id,
        )
        self._cancel_hud_hold(state)
        state.state = "attention"
        state.run_active = False
        state.run_started_at = None
        state.last_working_update_at = 0.0
        await self._apply_hud_text(
            chat_id_int=chat_id_int,
            state=state,
            bot=bot,
            text=text,
            allow_create=False,
            thread_id=thread_id,
        )
        if hold_seconds and hold_seconds > 0:
            self._schedule_hud_revert(
                chat_id_int=chat_id_int,
                chat_id_str=chat_id_str,
                bot=bot,
                state=state,
                delay_seconds=hold_seconds,
                thread_id=thread_id,
            )

    async def _recreate_hud(
        self, *, chat_id_int: int, chat_id_str: str, bot: Any, thread_id: int | None = None
    ) -> None:
        state = self._get_hud_state(chat_id_int, thread_id)
        state.suppressed = False
        self._cancel_hud_hold(state)
        old_message_id = state.message_id
        if old_message_id is not None:
            with contextlib.suppress(Exception):
                await bot.unpin_chat_message(chat_id=chat_id_int, message_id=old_message_id)
            with contextlib.suppress(Exception):
                await bot.delete_message(chat_id=chat_id_int, message_id=old_message_id)
        self._clear_hud_message_ref(chat_id_int, state, thread_id)
        state.state = "idle"
        state.run_active = False
        state.run_started_at = None
        state.last_working_update_at = 0.0
        idle_text = self._build_hud_idle_text(chat_id_str)
        await self._apply_hud_text(
            chat_id_int=chat_id_int,
            state=state,
            bot=bot,
            text=idle_text,
            allow_create=True,
            manual=True,
            thread_id=thread_id,
        )

    async def _hud_on_user_activity(
        self, *, chat_id_int: int, chat_id_str: str, bot: Any, thread_id: int | None = None
    ) -> None:
        self._register_chat_context(chat_id_str, chat_id_int, thread_id)
        await self._ensure_hud_initialized(
            chat_id_int=chat_id_int,
            chat_id_str=chat_id_str,
            bot=bot,
            allow_create=True,
            thread_id=thread_id,
        )
        await self._clear_hud_attention(
            chat_id_int=chat_id_int, chat_id_str=chat_id_str, bot=bot, thread_id=thread_id
        )

    async def _hud_on_run_start(
        self, *, chat_id_int: int, chat_id_str: str, bot: Any, thread_id: int | None = None
    ) -> None:
        state = await self._ensure_hud_initialized(
            chat_id_int=chat_id_int,
            chat_id_str=chat_id_str,
            bot=bot,
            allow_create=True,
            thread_id=thread_id,
        )
        state.run_active = True
        state.run_started_at = time.monotonic()
        state.last_working_update_at = 0.0
        if state.state != "attention":
            state.state = "idle"

    async def _hud_on_run_tick(
        self,
        *,
        chat_id_int: int,
        chat_id_str: str,
        bot: Any,
        renderer: TelegramRenderer,
        thread_id: int | None = None,
    ) -> None:
        state = self._get_hud_state(chat_id_int, thread_id)
        if not state.run_active or state.run_started_at is None:
            return

        elapsed = time.monotonic() - state.run_started_at
        if elapsed < _HUD_WORKING_THRESHOLD_SECONDS:
            return

        now = time.monotonic()
        if (
            state.last_working_update_at
            and (now - state.last_working_update_at) < _HUD_WORKING_REFRESH_SECONDS
        ):
            return

        state.state = "working"
        model = self._config.agent.model
        try:
            context_key = self._chat._context_key(chat_id_str, bot_id=self.alias)
            overrides = getattr(self._chat, "_model_overrides", {})
            value = overrides.get(context_key, self._config.agent.model)
            if isinstance(value, str):
                model = value
        except Exception as exc:
            self._log.debug("model override lookup failed", error=str(exc))

        working_text = self._build_hud_working_text(
            chat_id_str,
            model,
            elapsed,
            renderer._tool_names_used,
        )
        updated = await self._apply_hud_text(
            chat_id_int=chat_id_int,
            state=state,
            bot=bot,
            text=working_text,
            allow_create=False,
            thread_id=thread_id,
        )
        if updated:
            state.last_working_update_at = now

    async def _hud_on_run_complete(
        self,
        *,
        chat_id_int: int,
        chat_id_str: str,
        bot: Any,
        attention: str | None = None,
        thread_id: int | None = None,
    ) -> None:
        if attention:
            await self._set_hud_attention(
                chat_id_int=chat_id_int,
                chat_id_str=chat_id_str,
                bot=bot,
                text=attention,
                thread_id=thread_id,
            )
            return

        await self._set_hud_idle(
            chat_id_int=chat_id_int,
            chat_id_str=chat_id_str,
            bot=bot,
            allow_create=False,
            thread_id=thread_id,
        )

    async def _job_status_loop(self) -> None:
        from otto.orchestrator import get_orchestrator

        initialized = False
        while True:
            try:
                await asyncio.sleep(2.0)
                app = self._app
                if app is None:
                    continue

                jobs = await get_orchestrator().list_jobs()
                next_snapshot: dict[str, str] = {}
                notices: list[tuple[str, str, str]] = []

                for job in jobs:
                    job_id = str(job.get("id", ""))
                    status = str(job.get("status", ""))
                    ext = job.get("x-otto", {})
                    chat_context = str(ext.get("chatId", ""))
                    if not job_id or not status or not chat_context:
                        continue

                    next_snapshot[job_id] = status
                    if not initialized:
                        continue

                    previous = self._job_status_snapshot.get(job_id)
                    if previous == status:
                        continue

                    if status == "done":
                        notices.append((chat_context, job_id, "done"))
                    elif status == "failed":
                        notices.append((chat_context, job_id, "failed"))

                self._job_status_snapshot = next_snapshot
                initialized = True

                for chat_context, job_id, status in notices:
                    chat_id_int = self._chat_context_to_telegram_id.get(chat_context)
                    if chat_id_int is None:
                        continue
                    banner = f"Job {job_id} done" if status == "done" else f"Job {job_id} failed"
                    await self._set_hud_attention(
                        chat_id_int=chat_id_int,
                        chat_id_str=chat_context,
                        bot=app.bot,
                        text=banner,
                        hold_seconds=_HUD_JOB_BANNER_SECONDS,
                    )
            except asyncio.CancelledError:
                break
            except Exception as exc:
                self._log.debug("job HUD loop tick failed", error=str(exc))

    def queue_startup_notification(self, chat_id: int, text: str) -> None:
        item = (chat_id, text)
        if item in self._pending_startup_notifications:
            return
        self._pending_startup_notifications.append(item)

    async def _send_startup_notification(self, chat_id: int, text: str) -> None:
        if self._app is None:
            return
        try:
            await self._app.bot.send_message(chat_id=chat_id, text=text)
        except Exception as exc:
            self._log.warning(
                "failed to send startup notification",
                chat_id=chat_id,
                error=str(exc),
            )

    def _dispatch_startup_notifications(self) -> None:
        pending = list(self._pending_startup_notifications)
        self._pending_startup_notifications.clear()
        for chat_id, text in pending:
            task = asyncio.create_task(self._send_startup_notification(chat_id, text))
            self._startup_notification_tasks.add(task)
            task.add_done_callback(self._startup_notification_tasks.discard)

    def _apply_bot_default_model(self, chat_id: str) -> None:
        """Set the per-bot model default for a chat if no override exists yet."""
        bot_model: str | None = None
        if self._bot_config is not None:
            bot_model = self._bot_config.model
        elif self._telegram_config is not None:
            for bot_cfg in self._telegram_config.bots:
                if bot_cfg.alias == self.alias:
                    bot_model = bot_cfg.model
                    break
        if not bot_model:
            return
        context_key = self._chat._context_key(chat_id, bot_id=self.alias)
        if context_key not in self._chat._model_overrides:
            self._chat._model_overrides[context_key] = bot_model

    @property
    def alias(self) -> str:
        return self._alias or "default"

    def _setup_channels(self) -> tuple[str, ...]:
        if self._bot_config is not None:
            return tuple(
                str(channel.type)
                for channel in self._bot_config.channels
                if getattr(channel, "type", None)
            )
        return ("telegram",)

    def build_setup_completion_message(self, *, bot_name: str | None = None) -> str:
        bot_name = bot_name or self.alias
        return build_setup_completion_message(
            self._setup_channels(),
            bot_name=bot_name,
            output_format="telegram_html",
        )

    def _is_telegram_with_cli_enabled(self) -> bool:
        channels = self._setup_channels()
        return "telegram" in channels and "cli" in channels

    async def start(self) -> None:
        """Build application, register handlers, and start polling."""
        from telegram.ext import (
            Application,
            CallbackQueryHandler,
            CommandHandler,
            MessageHandler,
            MessageReactionHandler,
            filters,
        )

        self._log.info("starting bot")
        self._stopping = False
        self._reset_polling_health()
        self._app = Application.builder().token(self._token).build()

        # Run text handlers in background so /stop can be processed while a response is active.
        self._app.add_handler(
            MessageHandler(filters.TEXT & ~filters.COMMAND, self._on_message, block=False)
        )
        self._app.add_handler(MessageHandler(filters.PHOTO, self._on_photo, block=False))
        self._app.add_handler(
            MessageHandler(filters.VOICE | filters.AUDIO, self._on_voice, block=False)
        )
        self._app.add_handler(MessageHandler(filters.Document.ALL, self._on_document, block=False))
        self._app.add_handler(CommandHandler("start", self._on_start))
        self._app.add_handler(MessageHandler(filters.COMMAND, self._on_command))
        self._app.add_handler(CallbackQueryHandler(self._on_callback))
        self._app.add_handler(MessageReactionHandler(self._on_reaction, block=False))

        add_error_handler = getattr(self._app, "add_error_handler", None)
        if callable(add_error_handler):
            add_error_handler(self._on_application_error)

        await self._app.initialize()
        try:
            await self._app.bot.set_my_commands(BOT_COMMANDS)
        except Exception as exc:
            self._log.info("set_my_commands failed", error=str(exc))
        await self._app.start()

        if self._app.updater is not None:
            await self._app.updater.start_polling(
                drop_pending_updates=True,
                allowed_updates=_POLLING_ALLOWED_UPDATES,
                error_callback=self._on_polling_error,
            )

        self._mark_polling_success()
        self._dispatch_startup_notifications()
        if self._job_status_loop_task is None or self._job_status_loop_task.done():
            self._job_status_loop_task = asyncio.create_task(self._job_status_loop())

    async def stop(self) -> None:
        """Gracefully stop polling and shutdown the app."""
        self._stopping = True
        if self._app is None:
            self._polling_health.state = "stopped"
            return

        self._log.info("stopping bot")
        for task in list(self._startup_notification_tasks):
            task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await task
        self._startup_notification_tasks.clear()
        for task in list(self._debounce_tasks.values()):
            task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await task
        self._debounce_tasks.clear()
        self._pending_text_batches.clear()

        if self._job_status_loop_task is not None:
            self._job_status_loop_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._job_status_loop_task
            self._job_status_loop_task = None

        recovery_task = self._polling_recovery_task
        if recovery_task is not None:
            recovery_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await recovery_task
            self._polling_recovery_task = None

        for state in self._hud_state.values():
            self._cancel_hud_hold(state)

        if self._app.updater is not None:
            await self._app.updater.stop()
        await self._app.stop()
        await self._app.shutdown()
        self._polling_health.state = "stopped"

    async def _run_with_renderer(
        self,
        chat_id_int: int,
        chat_id_str: str,
        context: Any,
        handler_coro,
        thread_id: int | None = None,
    ) -> None:
        """Shared lifecycle: claim slot, typing indicator, tick loop, run handler, cleanup.

        Steering scenarios handled here:
          Scenario A — steering arrives while a tool is actively running:
            - Tool is never interrupted; it runs to completion.
            - After the tool finishes the task is cancelled.
            - Full cycle (tool + inference) is replayed with steering injected naturally.
            - The first tool-status message is deleted ONLY after the replay tool call
              succeeds (preserves audit trail on failure).
          Scenario B — steering arrives after all tools have completed (mid-inference):
            - Only the inference step is cancelled; tools are NOT re-run.
            - Cached tool summary is re-injected as already-known context.
            - First tool-status message is never deleted — it ran once, keep it visible.
        """
        if not await self._claim_chat_slot(chat_id_str):
            return

        async with self._steering_lock:
            state = self._steering_state.get(chat_id_str)
            if state is None:
                state = _SteeringRunState()
                self._steering_state[chat_id_str] = state
            state.stop_requested = False
            state.steering_cancelled = False
            state.tools_completed = False
            state.cached_tool_context = None
            state.first_tool_message_id = None
            state.pending_inputs.clear()
            state.renderer = None

        try:
            await self._hud_on_run_start(
                chat_id_int=chat_id_int,
                chat_id_str=chat_id_str,
                bot=context.bot,
                thread_id=thread_id,
            )
            current_handler = handler_coro
            last_hud_attention: str | None = None
            while True:
                last_hud_attention = None
                renderer = TelegramRenderer(context.bot, chat_id_int, message_thread_id=thread_id)
                async with self._steering_lock:
                    state = self._steering_state.get(chat_id_str)
                    if state is None:
                        state = _SteeringRunState()
                        self._steering_state[chat_id_str] = state
                    state.renderer = renderer
                    state.steering_cancelled = False
                    state.tools_completed = False
                    state.cached_tool_context = None
                    state.first_tool_message_id = None

                stop_event = asyncio.Event()
                typing_task = asyncio.create_task(
                    self._keep_typing(chat_id_int, stop_event, thread_id=thread_id)
                )
                tick_task = asyncio.create_task(
                    self._tick_loop(chat_id_int, chat_id_str, renderer, stop_event)
                )

                agent_task = asyncio.create_task(current_handler(renderer))
                self._running_tasks[chat_id_str] = agent_task
                try:
                    await agent_task
                except asyncio.CancelledError:
                    async with self._steering_lock:
                        state = self._steering_state.get(chat_id_str)
                        stop_requested = bool(state and state.stop_requested)
                    if stop_requested:
                        await renderer.send_text("Stopped.")
                except Exception as exc:
                    classified = self._classify_exception(exc)
                    last_hud_attention = classified.hud_attention
                    self._log.exception(
                        "telegram chat run failed",
                        chat_id=chat_id_str,
                        telegram_chat_id=chat_id_int,
                        category=classified.category,
                        error=str(exc),
                    )
                    await renderer.show_error(classified.user_message)
                finally:
                    if self._running_tasks.get(chat_id_str) is agent_task:
                        self._running_tasks.pop(chat_id_str, None)
                    stop_event.set()
                    await typing_task
                    tick_task.cancel()
                    with contextlib.suppress(asyncio.CancelledError):
                        await tick_task

                async with self._steering_lock:
                    state = self._steering_state.get(chat_id_str)
                    if state is None:
                        pending_inputs: list[str] = []
                        stop_requested = False
                        scenario_b = False
                        cached_tool_ctx: str | None = None
                        first_tool_msg_id: int | None = None
                    else:
                        pending_inputs = list(state.pending_inputs)
                        stop_requested = state.stop_requested
                        scenario_b = state.tools_completed
                        cached_tool_ctx = state.cached_tool_context
                        first_tool_msg_id = state.first_tool_message_id
                        state.pending_inputs.clear()
                        state.steering_cancelled = False
                        state.tools_completed = False
                        state.cached_tool_context = None
                        state.first_tool_message_id = None
                        state.renderer = None

                if stop_requested:
                    break

                merged_pending = self._merge_coalesced_parts(pending_inputs)
                if not merged_pending:
                    break

                if scenario_b:
                    # Scenario B: tools already done — replay inference only with cached context.
                    async def _scenario_b_handler(
                        steering_renderer: TelegramRenderer,
                        merged_text: str = merged_pending,
                        tool_ctx: str | None = cached_tool_ctx,
                    ) -> None:
                        # Build a message that injects the cached tool result as known context
                        # plus the steering naturally appended.
                        if tool_ctx:
                            steered_text = (
                                f"[Context from previous tool run: {tool_ctx}]\n\n"
                                f"While working on this, you added: {merged_text}"
                            )
                        else:
                            steered_text = f"While working on this, you added: {merged_text}"
                        await self._chat.handle_message(
                            chat_id=chat_id_str,
                            text=steered_text,
                            renderer=steering_renderer,
                            bot_id=self.alias,
                        )

                    current_handler = _scenario_b_handler
                else:
                    # Scenario A: tool was mid-flight (or no tools) — full replay.
                    _ = first_tool_msg_id

                    async def _scenario_a_handler(
                        steering_renderer: TelegramRenderer,
                        merged_text: str = merged_pending,
                    ) -> None:
                        # Inject steering naturally into the user message.
                        steered_text = f"While working on this, you added: {merged_text}"
                        await self._chat.handle_message(
                            chat_id=chat_id_str,
                            text=steered_text,
                            renderer=steering_renderer,
                            bot_id=self.alias,
                        )

                    current_handler = _scenario_a_handler

            await self._hud_on_run_complete(
                chat_id_int=chat_id_int,
                chat_id_str=chat_id_str,
                bot=context.bot,
                attention=last_hud_attention,
                thread_id=thread_id,
            )
        finally:
            async with self._steering_lock:
                self._steering_state.pop(chat_id_str, None)
            await self._release_chat_slot(chat_id_str)

    def _authorize_or_bootstrap(self, user: Any, *, event: str) -> bool:
        """Authorize user or bootstrap ownership once when allowed."""
        if self._is_authorized(user.id):
            return True
        if not self._try_bootstrap(user):
            self._log.warning(f"unauthorized {event}", user_id=user.id)
            return False
        self._log.info("user bootstrapped as owner", user_id=user.id)
        return True

    async def _run_handler_with_busy_notice(
        self,
        message: Any,
        chat_id_int: int,
        chat_id_str: str,
        context: Any,
        handler_coro,
        *,
        steer_text: str | None = None,
        thread_id: int | None = None,
    ) -> None:
        """Mirror _on_message slot behavior for all message handlers."""
        if not await self._claim_chat_slot(chat_id_str):
            if steer_text is not None and await self._try_steer_active_run(chat_id_str, steer_text):
                return
            await _send_chunked(message, _BUSY_CHAT_MESSAGE)
            return
        # _run_with_renderer handles lifecycle; release so it can claim again.
        await self._release_chat_slot(chat_id_str)
        await self._run_with_renderer(
            chat_id_int,
            chat_id_str,
            context,
            handler_coro,
            thread_id=thread_id,
        )

    async def _try_steer_active_run(self, chat_id: str, text: str) -> bool:
        merged = self._merge_coalesced_parts([text])
        if not merged:
            return True

        task = self._running_tasks.get(chat_id)
        if task is None or task.done():
            return False

        async with self._steering_lock:
            state = self._steering_state.get(chat_id)
            if state is None:
                state = _SteeringRunState()
                self._steering_state[chat_id] = state
            state.pending_inputs.append(merged)
            renderer = state.renderer

            if renderer is not None and renderer.has_active_tool_call():
                # Scenario A: tool is mid-flight — queue the steering input but do NOT cancel.
                # _tick_loop will cancel once the tool completes (at the active→idle transition).
                pass
            elif renderer is not None and renderer.all_tools_idle() and not state.tools_completed:
                # Scenario A transition detected here (e.g. steering arrived between ticks):
                # tool just finished, mark tools_completed and cancel now.
                state.tools_completed = True
                state.cached_tool_context = (
                    renderer._group_tool_summary(renderer._tool_names_used)
                    if renderer._tool_names_used
                    else None
                )
                state.first_tool_message_id = renderer._tool_message_id
                state.steering_cancelled = True
                task.cancel()
            elif renderer is not None and state.tools_completed:
                # Scenario B: tools already done, mid-inference — cancel inference only.
                state.steering_cancelled = True
                task.cancel()
            else:
                # No tools running, no tools completed (pre-tool or no-tool case) — cancel immediately.
                state.steering_cancelled = True
                task.cancel()
        return True

    async def _on_message(self, update: Any, context: Any) -> None:
        """Handle non-command text messages."""
        user = getattr(update, "effective_user", None)
        chat = getattr(update, "effective_chat", None)
        message = getattr(update, "message", None)
        if user is None or chat is None or message is None or message.text is None:
            return

        if not self._authorize_or_bootstrap(user, event="message"):
            return

        bound = self._log.bind(chat_id=chat.id, user_id=user.id)
        bound.info("message received", text=message.text[:80])

        thread_id = getattr(message, "message_thread_id", None)
        if not isinstance(thread_id, int):
            thread_id = None
        self._kickoff_typing_hint(bot=context.bot, chat_id=chat.id, thread_id=thread_id)

        chat_id_str = self._resolve_chat_id_str(user, chat.id)
        self._apply_bot_default_model(chat_id_str)
        await self._hud_on_user_activity(
            chat_id_int=chat.id,
            chat_id_str=chat_id_str,
            bot=context.bot,
            thread_id=thread_id,
        )

        inbound_text = self._format_inbound_user_text(message)
        await self._queue_debounced_text_message(
            message=message,
            chat_id_int=chat.id,
            chat_id_str=chat_id_str,
            context=context,
            text=inbound_text,
        )
        bound.info("message queued")

    def _format_inbound_user_text(self, message: Any) -> str:
        text = message.text or ""
        recall_prefix: str | None = None
        reply_to = getattr(message, "reply_to_message", None)
        if reply_to is not None:
            replied_mid = getattr(reply_to, "message_id", None)
            if replied_mid is not None:
                snippet = getattr(reply_to, "text", None) or getattr(reply_to, "caption", None)
                snippet_str = f"\n> {snippet.strip()[:1000]}" if snippet else ""
                recall_prefix = f"[User replied to message_id={replied_mid}]{snippet_str}"
        if recall_prefix:
            return f"{recall_prefix}\n\n{text}"
        return text

    @staticmethod
    def _merge_coalesced_parts(parts: list[str]) -> str:
        merged_parts = [part for part in parts if part and part.strip()]
        return "\n\n".join(merged_parts)

    def _track_debounce_task(self, chat_id: str, task: asyncio.Task[None]) -> None:
        self._debounce_tasks[chat_id] = task

        def _cleanup(done: asyncio.Task[None]) -> None:
            if self._debounce_tasks.get(chat_id) is done:
                self._debounce_tasks.pop(chat_id, None)

        task.add_done_callback(_cleanup)

    async def _queue_debounced_text_message(
        self,
        *,
        message: Any,
        chat_id_int: int,
        chat_id_str: str,
        context: Any,
        text: str,
    ) -> None:
        async with self._debounce_lock:
            batch = self._pending_text_batches.get(chat_id_str)
            thread_id = getattr(message, "message_thread_id", None)
            if not isinstance(thread_id, int):
                thread_id = None

            if batch is None:
                batch = _PendingTextBatch(
                    chat_id_int=chat_id_int,
                    chat_id_str=chat_id_str,
                    context=context,
                    trigger_message=message,
                    thread_id=thread_id,
                )
                self._pending_text_batches[chat_id_str] = batch
            batch.parts.append(text)
            batch.chat_id_int = chat_id_int
            batch.context = context
            batch.trigger_message = message
            batch.thread_id = thread_id

            prior_task = self._debounce_tasks.get(chat_id_str)
            if prior_task is not None:
                prior_task.cancel()

            task = asyncio.create_task(self._debounce_and_dispatch(chat_id_str))
            self._track_debounce_task(chat_id_str, task)

    async def _debounce_and_dispatch(self, chat_id_str: str) -> None:
        try:
            await asyncio.sleep(_MESSAGE_DEBOUNCE_SECONDS)
        except asyncio.CancelledError:
            return

        async with self._debounce_lock:
            batch = self._pending_text_batches.pop(chat_id_str, None)
            # Release debounce slot now — the window is closed and we're about to
            # dispatch.  If we stay registered, any new message for this chat would
            # cancel *this* task while it's running the handler, breaking steering.
            if self._debounce_tasks.get(chat_id_str) is asyncio.current_task():
                self._debounce_tasks.pop(chat_id_str, None)

        if batch is None:
            return

        merged_text = self._merge_coalesced_parts(batch.parts)
        if not merged_text:
            return

        async def _handler(renderer):
            await self._chat.handle_message(
                chat_id=batch.chat_id_str,
                text=merged_text,
                renderer=renderer,
                bot_id=self.alias,
            )

        await self._run_handler_with_busy_notice(
            batch.trigger_message,
            batch.chat_id_int,
            batch.chat_id_str,
            batch.context,
            _handler,
            steer_text=merged_text,
            thread_id=batch.thread_id,
        )

    async def _on_photo(self, update: Any, context: Any) -> None:
        """Handle photo messages."""
        user = getattr(update, "effective_user", None)
        chat = getattr(update, "effective_chat", None)
        message = getattr(update, "message", None)
        if user is None or chat is None or message is None:
            return
        if not message.photo:
            return

        if not self._authorize_or_bootstrap(user, event="photo"):
            return

        chat_id_str = self._resolve_chat_id_str(user, chat.id)
        self._apply_bot_default_model(chat_id_str)
        await self._hud_on_user_activity(
            chat_id_int=chat.id, chat_id_str=chat_id_str, bot=context.bot
        )

        caption = message.caption or ""

        async def _handler(renderer):
            photo = message.photo[-1]  # highest resolution
            file = await photo.get_file()
            data = await file.download_as_bytearray()
            b64 = base64.b64encode(bytes(data)).decode("ascii")
            await self._chat.handle_message(
                chat_id=chat_id_str,
                text=caption,
                renderer=renderer,
                bot_id=self.alias,
                attachments=[{"type": "image", "data": b64, "mime": "image/jpeg"}],
            )

        await self._run_handler_with_busy_notice(message, chat.id, chat_id_str, context, _handler)

    async def _on_voice(self, update: Any, context: Any) -> None:
        """Handle voice and audio messages."""
        user = getattr(update, "effective_user", None)
        chat = getattr(update, "effective_chat", None)
        message = getattr(update, "message", None)
        if user is None or chat is None or message is None:
            return

        voice = message.voice or message.audio
        if voice is None:
            return

        if not self._authorize_or_bootstrap(user, event="voice"):
            return

        chat_id_str = self._resolve_chat_id_str(user, chat.id)
        self._apply_bot_default_model(chat_id_str)
        await self._hud_on_user_activity(
            chat_id_int=chat.id, chat_id_str=chat_id_str, bot=context.bot
        )

        async def _handler(renderer):
            file = await voice.get_file()
            data = await file.download_as_bytearray()

            tmp_path = None
            try:
                with tempfile.NamedTemporaryFile(delete=False, suffix=".ogg") as tmp:
                    tmp.write(bytes(data))
                    tmp_path = tmp.name

                try:
                    from otto.transcribe import transcribe
                except ImportError:
                    await renderer.send_text(
                        "Voice transcription requires faster-whisper. "
                        "Install with: pip install 'otto-agent[voice]' then restart Otto."
                    )
                    return

                text = await transcribe(tmp_path)
                await self._chat.handle_message(
                    chat_id=chat_id_str,
                    text=text,
                    renderer=renderer,
                    bot_id=self.alias,
                )
            finally:
                if tmp_path:
                    with contextlib.suppress(OSError):
                        os.unlink(tmp_path)

        await self._run_handler_with_busy_notice(message, chat.id, chat_id_str, context, _handler)

    async def _on_document(self, update: Any, context: Any) -> None:
        """Handle document messages."""
        user = getattr(update, "effective_user", None)
        chat = getattr(update, "effective_chat", None)
        message = getattr(update, "message", None)
        if user is None or chat is None or message is None:
            return

        doc = message.document
        if doc is None:
            return

        if not self._authorize_or_bootstrap(user, event="document"):
            return

        chat_id_str = self._resolve_chat_id_str(user, chat.id)
        self._apply_bot_default_model(chat_id_str)
        await self._hud_on_user_activity(
            chat_id_int=chat.id, chat_id_str=chat_id_str, bot=context.bot
        )

        caption = message.caption or ""

        text_extensions = {
            ".txt",
            ".py",
            ".json",
            ".md",
            ".csv",
            ".log",
            ".xml",
            ".yaml",
            ".toml",
            ".sh",
            ".js",
            ".ts",
            ".html",
            ".css",
            ".sql",
            ".rs",
            ".go",
            ".c",
            ".h",
            ".cpp",
            ".java",
            ".rb",
            ".php",
            ".r",
            ".swift",
            ".kt",
        }

        async def _handler(renderer):
            file = await doc.get_file()
            data = await file.download_as_bytearray()
            filename = doc.file_name or "document"

            ext = os.path.splitext(filename)[1].lower()
            if ext in text_extensions:
                extracted = bytes(data).decode("utf-8", errors="replace")
            elif ext == ".pdf":
                try:
                    import pdfplumber

                    pages_text = []
                    with pdfplumber.open(io.BytesIO(bytes(data))) as pdf:
                        for page in pdf.pages:
                            page_text = page.extract_text()
                            if page_text:
                                pages_text.append(page_text)
                    extracted = "\n".join(pages_text)
                except ImportError:
                    extracted = (
                        "PDF processing requires pdfplumber. "
                        f"User sent file: {filename} ({len(data)} bytes)"
                    )
            else:
                extracted = f"User sent file: {filename} ({len(data)} bytes)"

            await self._chat.handle_message(
                chat_id=chat_id_str,
                text=caption,
                renderer=renderer,
                bot_id=self.alias,
                attachments=[{"type": "text", "filename": filename, "content": extracted}],
            )

        await self._run_handler_with_busy_notice(message, chat.id, chat_id_str, context, _handler)

    async def _send_typing_hint(
        self,
        *,
        bot: Any,
        chat_id: int,
        thread_id: int | None = None,
    ) -> None:
        send_kwargs: dict[str, Any] = {"chat_id": chat_id, "action": ChatAction.TYPING}
        if thread_id is not None:
            send_kwargs["message_thread_id"] = thread_id
        try:
            await _call_with_backoff(lambda: bot.send_chat_action(**send_kwargs), attempts=2)
        except Exception as exc:
            self._log.debug("send_chat_action failed", error=str(exc))
            return

    def _kickoff_typing_hint(
        self,
        *,
        bot: Any,
        chat_id: int,
        thread_id: int | None = None,
    ) -> None:
        with contextlib.suppress(Exception):
            asyncio.create_task(
                self._send_typing_hint(bot=bot, chat_id=chat_id, thread_id=thread_id),
                name=f"telegram-typing-hint:{chat_id}",
            )

    async def _keep_typing(
        self,
        chat_id: int,
        stop: asyncio.Event,
        thread_id: int | None = None,
    ) -> None:
        """Send periodic typing indicators while work is in progress."""
        while not stop.is_set():
            try:
                if self._app is None:
                    break
                send_kwargs: dict[str, Any] = {"chat_id": chat_id, "action": ChatAction.TYPING}
                if thread_id is not None:
                    send_kwargs["message_thread_id"] = thread_id
                await self._app.bot.send_chat_action(**send_kwargs)
                await asyncio.wait_for(stop.wait(), timeout=4.0)
            except TimeoutError:
                continue
            except (asyncio.CancelledError, Exception):
                break

    async def _tick_loop(
        self,
        chat_id_int: int,
        chat_id: str,
        renderer: TelegramRenderer,
        stop: asyncio.Event,
    ) -> None:
        """Call renderer.tick() periodically and apply steering at safe boundaries.

        Steering cancel logic:
          - While a tool is actively running: never cancel (Scenario A — let tool finish).
          - When tool transitions active→idle (had tools, now idle, tools_completed not yet set):
            cancel now (Scenario A). Cache tool summary + first_tool_message_id in state.
          - When already idle and tools_completed is True (mid-inference):
            cancel now (Scenario B). Tool message is preserved.
          - When no tools have run yet: cancel immediately (no tools scenario).
        """
        while not stop.is_set():
            try:
                await renderer.tick()
                curr_tool_active = renderer.has_active_tool_call()

                task = self._running_tasks.get(chat_id)
                if task and not task.done():
                    async with self._steering_lock:
                        state = self._steering_state.get(chat_id)
                        has_pending = bool(
                            state and state.pending_inputs and not state.stop_requested
                        )

                        if has_pending and state is not None:
                            if curr_tool_active:
                                # Tool is actively running — queue is already populated,
                                # do NOT cancel. Scenario A: wait for tool to finish.
                                pass
                            elif renderer.all_tools_idle() and not state.tools_completed:
                                # Transition: tool just finished (active→idle).
                                # Scenario A cancel: re-run full cycle with steering.
                                state.tools_completed = True
                                # Cache tool summary and first tool message id for the handler.
                                state.cached_tool_context = (
                                    renderer._group_tool_summary(renderer._tool_names_used)
                                    if renderer._tool_names_used
                                    else None
                                )
                                state.first_tool_message_id = renderer._tool_message_id
                                state.steering_cancelled = True
                                task.cancel()
                            elif state.tools_completed:
                                # Scenario B: tools already marked complete, mid-inference.
                                # first_tool_message_id was already set at transition above.
                                state.steering_cancelled = True
                                task.cancel()
                            else:
                                # No tools have run yet — safe to cancel immediately.
                                state.steering_cancelled = True
                                task.cancel()

                await self._hud_on_run_tick(
                    chat_id_int=chat_id_int,
                    chat_id_str=chat_id,
                    bot=renderer._bot,
                    renderer=renderer,
                )

                await asyncio.wait_for(stop.wait(), timeout=1.0)
            except TimeoutError:
                continue
            except (asyncio.CancelledError, Exception):
                break

    async def _on_reaction(self, update: Any, context: Any) -> None:
        """Log inbound message reactions for later preference reconciliation.

        Pure observation — no automated actions taken. Logs to
        ~/.otto/reaction_log.jsonl with bot_name so signals from all agents
        can be compared during reconciliation.
        """
        from .reaction_log import log_reaction

        reaction_update = getattr(update, "message_reaction", None)
        if reaction_update is None:
            return

        chat = getattr(reaction_update, "chat", None)
        user = getattr(reaction_update, "user", None)
        message_id = getattr(reaction_update, "message_id", None)
        new_reactions = getattr(reaction_update, "new_reaction", []) or []
        old_reactions = getattr(reaction_update, "old_reaction", []) or []

        chat_id = getattr(chat, "id", None) if chat else None
        user_id = getattr(user, "id", None) if user else None
        bot_name = self._alias or (self._bot_config.name if self._bot_config else "otto")

        # Log added reactions
        for r in new_reactions:
            emoji = getattr(r, "emoji", str(r))
            log_reaction(
                bot_name=bot_name,
                chat_id=chat_id,
                message_id=message_id,
                reaction=emoji,
                is_added=True,
                user_id=user_id,
            )

        # Log removed reactions (present in old but not new)
        old_emojis = {getattr(r, "emoji", str(r)) for r in old_reactions}
        new_emojis = {getattr(r, "emoji", str(r)) for r in new_reactions}
        for emoji in old_emojis - new_emojis:
            log_reaction(
                bot_name=bot_name,
                chat_id=chat_id,
                message_id=message_id,
                reaction=emoji,
                is_added=False,
                user_id=user_id,
            )

    async def _on_command(self, update: Any, context: Any) -> None:
        """Handle slash commands via Chat."""
        user = getattr(update, "effective_user", None)
        chat = getattr(update, "effective_chat", None)
        message = getattr(update, "message", None)
        if user is None or chat is None or message is None or message.text is None:
            return

        if not self._is_authorized(user.id):
            return

        parts = message.text.split(None, 1)
        command = parts[0].lstrip("/").split("@", 1)[0].lower()
        args = parts[1] if len(parts) > 1 else ""

        thread_id = getattr(message, "message_thread_id", None)
        if not isinstance(thread_id, int):
            thread_id = None
        self._kickoff_typing_hint(bot=context.bot, chat_id=chat.id, thread_id=thread_id)

        chat_id_str = self._resolve_chat_id_str(user, chat.id)

        self._apply_bot_default_model(chat_id_str)
        self._register_chat_context(chat_id_str, chat.id, thread_id)

        if command == "hud":
            await self._clear_hud_attention(
                chat_id_int=chat.id,
                chat_id_str=chat_id_str,
                bot=context.bot,
                thread_id=thread_id,
            )
            await self._recreate_hud(
                chat_id_int=chat.id,
                chat_id_str=chat_id_str,
                bot=context.bot,
                thread_id=thread_id,
            )
            return

        await self._hud_on_user_activity(
            chat_id_int=chat.id,
            chat_id_str=chat_id_str,
            bot=context.bot,
            thread_id=thread_id,
        )

        # /stop cancels any running agent task for this chat (silent if nothing running)
        if command == "stop":
            task = self._running_tasks.get(chat_id_str)
            if task and not task.done():
                async with self._steering_lock:
                    state = self._steering_state.get(chat_id_str)
                    if state is None:
                        state = _SteeringRunState()
                        self._steering_state[chat_id_str] = state
                    state.stop_requested = True
                    state.pending_inputs.clear()
                    state.steering_cancelled = False
                task.cancel()
            return

        def _queue_idle_refresh() -> None:
            asyncio.create_task(
                self._set_hud_idle(
                    chat_id_int=chat.id,
                    chat_id_str=chat_id_str,
                    bot=context.bot,
                    allow_create=False,
                    thread_id=thread_id,
                )
            )

        renderer = TelegramRenderer(
            context.bot,
            chat.id,
            message_thread_id=thread_id,
            on_model_change=lambda _model: _queue_idle_refresh(),
            on_session_reset=lambda: _queue_idle_refresh(),
        )
        await self._chat.handle_command(
            chat_id=chat_id_str,
            command=command,
            args=args,
            renderer=renderer,
            bot_id=self.alias,
        )

        if command in {"new", "clear", "model", "thinking"}:
            await self._set_hud_idle(
                chat_id_int=chat.id,
                chat_id_str=chat_id_str,
                bot=context.bot,
                allow_create=False,
                thread_id=thread_id,
            )

    async def _on_callback(self, update: Any, context: Any) -> None:
        """Handle inline keyboard button presses."""
        query = getattr(update, "callback_query", None)
        if query is None:
            return

        user = getattr(update, "effective_user", None)
        if user is None or not self._is_authorized(user.id):
            with contextlib.suppress(Exception):
                await _call_with_backoff(lambda: query.answer("Access denied.", show_alert=True))
            return

        data = getattr(query, "data", "") or ""
        if ":" not in data:
            with contextlib.suppress(Exception):
                await _call_with_backoff(lambda: query.answer())
            return

        prefix, action = data.split(":", 1)
        try:
            await _call_with_backoff(lambda: query.answer())
        except Exception as exc:
            if _is_transient_telegram_error(exc):
                self._log.info(
                    "callback acknowledgement failed",
                    chat_id=getattr(query.message, "chat_id", None),
                    error=str(exc),
                )
            elif _is_retry_after(exc):
                return
            else:
                raise

        if query.message is None:
            return
        chat_id = self._resolve_chat_id_str(user, query.message.chat_id)
        telegram_chat_id = query.message.chat_id
        self._register_chat_context(chat_id, telegram_chat_id)
        await self._clear_hud_attention(
            chat_id_int=telegram_chat_id,
            chat_id_str=chat_id,
            bot=context.bot,
        )

        renderer: Renderer = CallbackTelegramRenderer(context.bot, telegram_chat_id, query)

        if prefix == "status":
            await self._handle_status_callback(
                query, chat_id, telegram_chat_id, action, context, renderer
            )
        elif prefix == "model":
            await self._handle_model_callback(
                query, chat_id, telegram_chat_id, action, context, renderer
            )
        elif prefix == "help":
            await self._handle_help_callback(
                query, chat_id, telegram_chat_id, action, context, renderer
            )
        elif prefix == "agents":
            await self._handle_agents_callback(
                query, chat_id, telegram_chat_id, action, context, renderer
            )
        elif prefix == "new":
            await self._handle_new_callback(
                query, chat_id, telegram_chat_id, action, context, renderer
            )
        elif prefix == "history":
            await self._handle_history_callback(
                query, chat_id, telegram_chat_id, action, context, renderer
            )
        elif prefix == "thinking":
            await self._handle_thinking_callback(
                query, chat_id, telegram_chat_id, action, context, renderer
            )
        elif prefix == "subagents":
            await self._handle_subagents_callback(
                query, chat_id, telegram_chat_id, action, context, renderer
            )

    async def _handle_status_callback(
        self,
        query: Any,
        chat_id: str,
        telegram_chat_id: int,
        action: str,
        context: Any,
        renderer: Renderer,
    ) -> None:
        """Handle status button callbacks."""
        if action == "model":
            context_key = self._chat._context_key(chat_id, bot_id=self.alias)
            model = self._chat._model_overrides.get(context_key, self._config.agent.model)
            await renderer.send_text(f"Current model: {model}")
        elif action == "thinking":
            await self._chat.handle_command(
                chat_id=chat_id,
                command="thinking",
                args="",
                renderer=renderer,
                bot_id=self.alias,
            )
        elif action == "new":
            await self._chat.handle_command(
                chat_id=chat_id,
                command="new",
                args="",
                renderer=renderer,
                bot_id=self.alias,
            )
            await self._set_hud_idle(
                chat_id_int=telegram_chat_id,
                chat_id_str=chat_id,
                bot=context.bot,
                allow_create=False,
            )
        elif action == "refresh":
            await self._chat.handle_command(
                chat_id=chat_id,
                command="status",
                args="",
                renderer=renderer,
                bot_id=self.alias,
            )

    async def _handle_new_callback(
        self,
        query: Any,
        chat_id: str,
        telegram_chat_id: int,
        action: str,
        context: Any,
        renderer: Renderer,
    ) -> None:
        """Handle /new confirmation callbacks."""
        _ = query
        if action == "cancel":
            await renderer.send_text("Kept current session.")
            return

        if action != "confirm":
            await renderer.send_text("Unknown action.")
            return

        execute_new_session(self._chat, chat_id)
        text, buttons = build_new_success()
        await renderer.send_with_buttons(text, buttons)
        await self._recreate_hud(
            chat_id_int=telegram_chat_id,
            chat_id_str=chat_id,
            bot=context.bot,
        )

    async def _handle_history_callback(
        self,
        query: Any,
        chat_id: str,
        telegram_chat_id: int,
        action: str,
        context: Any,
        renderer: Renderer,
    ) -> None:
        """Handle /history navigation and resume callbacks."""
        _ = query, telegram_chat_id, context

        if action.startswith("nav:"):
            raw_offset = action.split(":", 1)[1]
            try:
                offset = int(raw_offset)
            except ValueError:
                await renderer.send_text("Invalid history page.")
                return
            text, buttons = build_history_page(self._chat, chat_id, offset)
            await renderer.send_with_buttons(text, buttons)
            return

        if action.startswith("resume:"):
            ts = action.split(":", 1)[1]
            ok = self._chat._session_store.restore(chat_id, ts)
            if not ok:
                await renderer.send_text(f"No archived session found for <code>{ts}</code>.")
                return
            text, buttons = build_history_page(self._chat, chat_id, 0)
            await renderer.send_with_buttons(
                f"Resumed session <code>{ts}</code>.\n\n{text}", buttons
            )
            await self._recreate_hud(
                chat_id_int=telegram_chat_id,
                chat_id_str=chat_id,
                bot=context.bot,
            )
            return

        await renderer.send_text("Unknown history action.")

    async def _handle_model_callback(
        self,
        query: Any,
        chat_id: str,
        telegram_chat_id: int,
        action: str,
        context: Any,
        renderer: Renderer,
    ) -> None:
        """Handle model picker button callbacks."""
        _ = query
        if action == "cancel":
            await renderer.send_text("Model unchanged.")
        elif action == "custom":
            await renderer.send_text(
                "Send the model string as a message.\nExample: <code>codex/gpt-5.3-codex</code>"
            )
        elif action == "back":
            text, buttons = build_provider_picker(
                self._config, auth_storage=self._chat._auth_storage
            )
            context_key = self._chat._context_key(chat_id, bot_id=self.alias)
            current = self._chat._model_overrides.get(context_key, self._config.agent.model)
            text = text.replace(
                f"<code>{self._config.agent.model}</code>", f"<code>{current}</code>"
            )
            await renderer.send_with_buttons(text, buttons)
        elif action.startswith("provider:"):
            provider_key = action.split(":", 1)[1]
            text, buttons = get_provider_model_buttons(
                self._config, provider_key, auth_storage=self._chat._auth_storage
            )
            await renderer.send_with_buttons(text, buttons)
        else:
            await self._chat.handle_command(
                chat_id=chat_id,
                command="model",
                args=action,
                renderer=renderer,
                bot_id=self.alias,
            )
            await self._set_hud_idle(
                chat_id_int=telegram_chat_id,
                chat_id_str=chat_id,
                bot=context.bot,
                allow_create=False,
            )

    async def _handle_thinking_callback(
        self,
        query: Any,
        chat_id: str,
        telegram_chat_id: int,
        action: str,
        context: Any,
        renderer: Renderer,
    ) -> None:
        """Handle thinking mode picker callbacks."""
        _ = query, telegram_chat_id, context
        if action == "cancel":
            await renderer.send_text("Thinking unchanged.")
            return

        await self._chat.handle_command(
            chat_id=chat_id,
            command="thinking",
            args=action,
            renderer=renderer,
            bot_id=self.alias,
        )

    async def _handle_subagents_callback(
        self,
        query: Any,
        chat_id: str,
        telegram_chat_id: int,
        action: str,
        context: Any,
        renderer: Renderer,
    ) -> None:
        _ = query, chat_id, telegram_chat_id, context
        profiles = load_subagent_profiles()

        if action == "cancel" or action == "noop":
            if action == "cancel":
                await renderer.send_text("Closed.")
            return

        if action.startswith("page:"):
            raw = action.split(":", 1)[1]
            try:
                page = max(0, int(raw))
            except ValueError:
                page = 0
            text, buttons = build_subagents_main_view(profiles, page=page)
            await renderer.send_with_buttons(text, buttons)
            return

        if action.startswith("detail:"):
            parts = action.split(":", 2)
            if len(parts) != 3:
                await renderer.send_text("Unknown action.")
                return
            _, name, raw_page = parts
            try:
                back_page = max(0, int(raw_page))
            except ValueError:
                back_page = 0
            view = build_subagent_detail_view(name, profiles, back_page=back_page)
            if view is None:
                await renderer.send_text("Subagent not found.")
                return
            text, buttons = view
            await renderer.send_with_buttons(text, buttons)
            return

        await renderer.send_text("Unknown action.")

    async def _handle_help_callback(
        self,
        query: Any,
        chat_id: str,
        telegram_chat_id: int,
        action: str,
        context: Any,
        renderer: Renderer,
    ) -> None:
        """Handle /help menu category navigation and quick launch actions."""
        _ = query
        if action == "back":
            text, buttons = build_help_main_view()
            await renderer.send_with_buttons(text, buttons)
            return

        if action.startswith("run:"):
            command = action.split(":", 1)[1].strip().lstrip("/")
            if not command:
                await renderer.send_text("Unknown command.")
                return
            await self._chat.handle_command(
                chat_id=chat_id,
                command=command,
                args="",
                renderer=renderer,
                bot_id=self.alias,
            )
            if command in {"new", "clear", "model", "thinking"}:
                await self._set_hud_idle(
                    chat_id_int=telegram_chat_id,
                    chat_id_str=chat_id,
                    bot=context.bot,
                    allow_create=False,
                )
            return

        category_view = build_help_category_view(action)
        if category_view is None:
            await renderer.send_text("Unknown help category.")
            return

        text, buttons = category_view
        await renderer.send_with_buttons(text, buttons)

    async def _handle_agents_callback(
        self,
        query: Any,
        chat_id: str,
        telegram_chat_id: int,
        action: str,
        context: Any,
        renderer: Renderer,
    ) -> None:
        """Handle /agents view toggles from inline buttons."""
        _ = query
        mode = action if action in {"active", "all", "past"} else "active"
        args = "" if mode == "active" else mode
        await self._chat.handle_command(
            chat_id=chat_id,
            command="agents",
            args=args,
            renderer=renderer,
            bot_id=self.alias,
        )

    def _resolve_chat_id_str(self, user: Any, fallback_chat_id: int) -> str:
        users = getattr(self._bot_config, "users", None)
        if not isinstance(users, list):
            users = self._config.users
        user_config = resolve_user(users, telegram_id=getattr(user, "id", None))
        if user_config is not None:
            return user_config.name
        return str(fallback_chat_id)

    async def _on_start(self, update: Any, context: Any) -> None:
        """Handle /start with bootstrap-ready messaging."""
        _ = context
        user = getattr(update, "effective_user", None)
        message = getattr(update, "message", None)
        if user is None or message is None:
            return

        if self._try_bootstrap(user):
            await _send_chunked(message, "Welcome! You are now the owner of this Otto instance.")
            return

        if self._is_authorized(user.id):
            await _send_chunked(message, "Otto is ready.")
            if self._is_telegram_with_cli_enabled():
                await _send_chunked(message, self.build_setup_completion_message())

    def _is_authorized(self, user_id: int) -> bool:
        if self._bot_config is not None:
            auth_fn = getattr(self._chat, "is_authorized_user", None)
            if callable(auth_fn):
                try:
                    resolved = auth_fn(telegram_id=user_id, bot_id=self.alias)
                    if isinstance(resolved, bool):
                        return resolved
                except Exception as exc:
                    self._log.debug("auth callback failed", error=str(exc))

            # Fallback for tests or alternate chat implementations.
            user = resolve_user(self._config.users, telegram_id=user_id)
            if user is None:
                return False
            if user.name == self._bot_config.auth.owner:
                return True
            return user.name in self._bot_config.auth.allowed_users

        # Legacy path.
        if self._telegram_config.owner_id and user_id == self._telegram_config.owner_id:
            return True
        return user_id in self._telegram_config.allowed_users

    def _try_bootstrap(self, user: Any) -> bool:
        """If bootstrap=first_user and no owner set, claim ownership."""
        user_id = getattr(user, "id", None)
        if user_id is None:
            return False

        if self._bot_config is not None:
            return self._try_bootstrap_new(user, user_id)
        return self._try_bootstrap_legacy(user, user_id)

    def _try_bootstrap_new(self, user: Any, user_id: int) -> bool:
        """Bootstrap path for new [[bots]] schema."""
        username = getattr(user, "username", None)
        bootstrap_fn = getattr(self._chat, "try_bootstrap_user", None)
        if callable(bootstrap_fn):
            try:
                resolved = bootstrap_fn(
                    telegram_id=user_id,
                    username_hint=username,
                    bot_id=self.alias,
                )
            except Exception as exc:
                self._log.debug("bootstrap callback failed", error=str(exc))
                resolved = None

            if (
                isinstance(resolved, tuple | list)
                and len(resolved) == 2
                and isinstance(resolved[0], bool)
            ):
                claimed, owner_name = resolved
                if not claimed:
                    return False

                refreshed = next((b for b in self._config.bots if b.name == self.alias), None)
                if refreshed is not None:
                    self._bot_config = refreshed

                self._log.info("bootstrap persisted", owner=owner_name, telegram_id=user_id)
                return True

        if self._bot_config.auth.bootstrap != "first_user":
            return False
        if self._bot_config.auth.owner and self._bot_config.auth.owner.strip():
            return False

        user_name = username if username else f"user_{user_id}"

        existing = resolve_user(self._config.users, telegram_id=user_id)
        if existing is None:
            user_name_base = user_name
            taken = {u.name for u in self._config.users}
            suffix = 2
            while user_name in taken:
                user_name = f"{user_name_base}_{suffix}"
                suffix += 1
            new_user = UserConfig(name=user_name, telegram_id=user_id)
            updated_users = [*self._config.users, new_user]
            object.__setattr__(self._config, "users", updated_users)
        else:
            user_name = existing.name

        old_auth = self._bot_config.auth
        new_allowed = (
            [user_name, *old_auth.allowed_users]
            if user_name not in old_auth.allowed_users
            else list(old_auth.allowed_users)
        )
        new_auth = BotAuthConfig(
            owner=user_name,
            allowed_users=new_allowed,
            bootstrap="disabled",
        )
        new_bot_config = BotConfig(
            name=self._bot_config.name,
            model=self._bot_config.model,
            auth=new_auth,
            workspace=self._bot_config.workspace,
            skills=self._bot_config.skills,
            channels=self._bot_config.channels,
        )
        self._bot_config = new_bot_config

        updated_bots = [
            new_bot_config if b.name == self._bot_config.name else b for b in self._config.bots
        ]
        object.__setattr__(self._config, "bots", updated_bots)

        try:
            save_config(self._config)
            self._log.info("bootstrap persisted", owner=user_name, telegram_id=user_id)
        except Exception as exc:
            self._log.error("failed to persist bootstrap", error=str(exc))
        return True

    def _try_bootstrap_legacy(self, user: Any, user_id: int) -> bool:
        """Bootstrap path for legacy [telegram] schema."""
        tc = self._telegram_config
        if tc is None:
            return False
        if tc.bootstrap != "first_user":
            return False
        # owner_id 0 means unset
        if tc.owner_id and tc.owner_id != 0:
            return False
        # Claim ownership and persist to disk
        new_tc = TelegramConfig(
            token=tc.token,
            owner_id=user_id,
            allowed_users=[user_id, *tc.allowed_users],
            bootstrap="disabled",
            bots=tc.bots,
        )
        self._telegram_config = new_tc

        # If this is the main bot, update the root config
        if tc is self._config.telegram:
            object.__setattr__(self._config, "telegram", new_tc)

        # Update _raw_values so save_config writes the new values (not stale snapshot)
        raw = getattr(self._config, "_raw_values", None)
        if isinstance(raw, dict) and "telegram" in raw:
            if tc is self._config.telegram:
                raw["telegram"]["owner_id"] = user_id
                raw["telegram"]["allowed_users"] = [user_id, *tc.allowed_users]
                raw["telegram"]["bootstrap"] = "disabled"
        try:
            save_config(self._config)
            self._log.info("bootstrap persisted", owner_id=user_id)
        except Exception as exc:
            self._log.error("failed to persist bootstrap", error=str(exc))
        return True

    async def _claim_chat_slot(self, chat_id: str) -> bool:
        """Reserve a chat for one active text message run at a time."""
        async with self._active_chats_lock:
            if chat_id in self._active_chats:
                return False
            self._active_chats.add(chat_id)
            return True

    async def _release_chat_slot(self, chat_id: str) -> None:
        """Release a previously reserved chat slot."""
        async with self._active_chats_lock:
            self._active_chats.discard(chat_id)


__all__ = ["TelegramBot", "TelegramRenderer"]
