from __future__ import annotations

import asyncio
import collections
import json
import logging
import os
import socket
import time
from contextlib import suppress
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, Sequence

if TYPE_CHECKING:
    from .progress_stream import TurnProgressTracker
    from .state import TelegramTopicRecord

from ...agents.opencode.supervisor import OpenCodeSupervisor
from ...core.config import load_hub_config, load_repo_config
from ...core.filebox_retention import (
    prune_filebox_root,
    resolve_filebox_retention_policy,
)
from ...core.flows.models import FlowRunRecord
from ...core.flows.pause_dispatch import format_pause_reason, latest_dispatch_seq
from ...core.hub import HubSupervisor
from ...core.locks import FileLock, FileLockBusy
from ...core.logging_utils import log_event
from ...core.pma_notification_store import PmaNotificationStore
from ...core.request_context import reset_conversation_id, set_conversation_id
from ...core.runtime_services import RuntimeServices
from ...core.state import now_iso
from ...core.state_roots import resolve_global_state_root
from ...core.text_delta_coalescer import TextDeltaCoalescer
from ...core.utils import build_opencode_supervisor
from ...flows.ticket_flow.runtime_helpers import build_ticket_flow_runtime_resources
from ...housekeeping import HousekeepingConfig, run_housekeeping_for_roots
from ...integrations.app_server.threads import (
    AppServerThreadRegistry,
    default_app_server_threads_path,
)
from ...manifest import load_manifest
from ...tickets.replies import dispatch_reply, ensure_reply_dirs, resolve_reply_paths
from ...voice import VoiceConfig, VoiceService
from ..app_server.event_buffer import AppServerEventBuffer
from ..app_server.supervisor import WorkspaceAppServerSupervisor
from ..chat.channel_directory import ChannelDirectoryStore
from ..chat.collaboration_policy import (
    CollaborationEvaluationContext,
    CollaborationEvaluationResult,
    build_telegram_collaboration_policy,
    evaluate_collaboration_policy,
)
from ..chat.service import ChatBotServiceCore
from ..chat.turn_policy import PlainTextTurnContext
from ..chat.update_notifier import ChatUpdateStatusNotifier
from .adapter import (
    InlineButton,
    TelegramBotClient,
    TelegramCallbackQuery,
    TelegramDocument,
    TelegramMessage,
    TelegramPhotoSize,
    TelegramUpdate,
    TelegramUpdatePoller,
    build_inline_keyboard,
    encode_cancel_callback,
)
from .chat_adapter import TelegramChatAdapter
from .chat_state_store import TelegramChatStateStore
from .chat_transport import TelegramChatTransport
from .commands_registry import build_command_payloads, diff_command_lists
from .config import (
    AppServerUnavailableError,
    TelegramBotConfig,
    TelegramBotLockError,
    TelegramMediaCandidate,
)
from .config import TelegramBotConfigError as TelegramBotConfigError  # re-export
from .constants import (
    DEFAULT_INTERRUPT_TIMEOUT_SECONDS,
    QUEUED_PLACEHOLDER_TEXT,
    TurnKey,
)
from .dispatch import dispatch_update
from .handlers import callbacks as callback_handlers
from .handlers import messages as message_handlers
from .handlers.approvals import TelegramApprovalHandlers
from .handlers.commands import build_command_specs
from .handlers.commands_runtime import TelegramCommandHandlers
from .handlers.messages import _CoalescedBuffer
from .handlers.questions import TelegramQuestionHandlers
from .handlers.selections import TelegramSelectionHandlers
from .helpers import (
    ModelOption,
    _lock_payload_summary,
    _read_lock_payload,
    _split_topic_key,
    _telegram_lock_path,
    _with_conversation_id,
)
from .notifications import TelegramNotificationHandlers
from .outbox import TelegramOutboxManager
from .runtime import TelegramWorkspaceAndTurnMixin
from .state import (
    OutboxRecord,
    TelegramStateStore,
    TopicRouter,
    parse_topic_key,
    topic_key,
)
from .ticket_flow_bridge import (
    TelegramTicketFlowBridge,
)
from .transport import TelegramMessageTransport
from .types import (
    CompactState,
    ModelPickerState,
    PendingApproval,
    PendingQuestion,
    ReviewCommitSelectionState,
    SelectionState,
    TurnContext,
)
from .voice import TelegramVoiceManager

TICKET_FLOW_WATCH_INTERVAL_SECONDS = 20
TYPING_HEARTBEAT_INTERVAL_SECONDS = 4.0


def _build_opencode_supervisor(
    config: TelegramBotConfig,
    *,
    logger: logging.Logger,
) -> Optional[OpenCodeSupervisor]:
    opencode_command = config.opencode_command or None
    opencode_binary = config.agent_binaries.get("opencode")

    supervisor = build_opencode_supervisor(
        opencode_command=opencode_command,
        opencode_binary=opencode_binary,
        workspace_root=config.root,
        logger=logger,
        request_timeout=None,
        max_handles=config.opencode_max_handles,
        idle_ttl_seconds=config.opencode_idle_ttl_seconds,
        base_env=None,
        subagent_models=None,
    )

    if supervisor is None:
        log_event(
            logger,
            logging.INFO,
            "telegram.opencode.unavailable",
            reason="command_missing",
        )
        return None

    return supervisor


def _next_reply_seq_sync(reply_history_dir: Any) -> int:
    from pathlib import Path

    path = Path(reply_history_dir)
    if not path.exists() or not path.is_dir():
        return 1
    existing: list[int] = []
    _SEQ_RE = __import__("re").compile(r"^[0-9]{4}$")
    for child in path.iterdir():
        try:
            if not child.is_dir():
                continue
            if not _SEQ_RE.fullmatch(child.name):
                continue
            existing.append(int(child.name))
        except OSError:
            continue
    return (max(existing) + 1) if existing else 1


class TelegramBotService(
    TelegramWorkspaceAndTurnMixin,
    TelegramMessageTransport,
    TelegramNotificationHandlers,
    TelegramApprovalHandlers,
    TelegramQuestionHandlers,
    TelegramSelectionHandlers,
    TelegramCommandHandlers,
):
    TICKET_FLOW_WATCH_INTERVAL_SECONDS = TICKET_FLOW_WATCH_INTERVAL_SECONDS

    def __init__(
        self,
        config: TelegramBotConfig,
        *,
        logger: Optional[logging.Logger] = None,
        hub_root: Optional[Path] = None,
        manifest_path: Optional[Path] = None,
        voice_config: Optional[VoiceConfig] = None,
        voice_service: Optional[VoiceService] = None,
        housekeeping_config: Optional[HousekeepingConfig] = None,
        update_repo_url: Optional[str] = None,
        update_repo_ref: Optional[str] = None,
        update_skip_checks: bool = False,
        update_backend: str = "auto",
        update_linux_service_names: Optional[dict[str, str]] = None,
        app_server_auto_restart: Optional[bool] = None,
    ) -> None:
        self._config = config
        self._logger = logger or logging.getLogger(__name__)
        self._hub_root = hub_root
        self._manifest_path = manifest_path
        self._hub_supervisor = None
        self._hub_thread_registry = None
        self._hub_config_path: Optional[Path] = None
        config_root = hub_root or self._config.root
        generated_hub_config = config_root / ".codex-autorunner" / "config.yml"
        if generated_hub_config.exists():
            self._hub_config_path = generated_hub_config
        else:
            root_hub_config = config_root / "codex-autorunner.yml"
            if root_hub_config.exists():
                self._hub_config_path = root_hub_config
        if self._hub_root:
            try:
                from ..github.polling import build_hub_scm_poll_processor

                self._hub_supervisor = HubSupervisor.from_path(
                    self._hub_root,
                    scm_poll_processor=build_hub_scm_poll_processor(
                        hub_root=self._hub_root,
                        raw_config=load_hub_config(self._hub_root).raw,
                    ),
                )
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.pma.hub_supervisor.unavailable",
                    hub_root=str(self._hub_root),
                    exc=exc,
                )
            try:
                self._hub_thread_registry = AppServerThreadRegistry(
                    default_app_server_threads_path(self._hub_root)
                )
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.pma.thread_registry.unavailable",
                    hub_root=str(self._hub_root),
                    exc=exc,
                )
        self._update_repo_url = update_repo_url
        self._update_repo_ref = update_repo_ref
        self._update_skip_checks = update_skip_checks
        self._update_backend = update_backend
        self._update_linux_service_names = update_linux_service_names or {}
        self._app_server_auto_restart = app_server_auto_restart
        self._collaboration_policy = (
            config.collaboration_policy
            or build_telegram_collaboration_policy(
                allowed_chat_ids=config.allowed_chat_ids,
                allowed_user_ids=config.allowed_user_ids,
                require_topics=config.require_topics,
                trigger_mode=config.trigger_mode,
            )
        )
        self._allowlist = config.allowlist()
        self._store = TelegramStateStore(
            config.state_file, default_approval_mode=config.defaults.approval_mode
        )
        self._router = TopicRouter(self._store)
        self._app_server_state_root = resolve_global_state_root() / "workspaces"
        self.app_server_events = AppServerEventBuffer()
        self._app_server_supervisor = WorkspaceAppServerSupervisor(
            config.app_server_command,
            state_root=self._app_server_state_root,
            env_builder=self._build_workspace_env,
            approval_handler=self._handle_approval_request,
            notification_handler=self._handle_buffered_app_server_notification,
            logger=self._logger,
            auto_restart=self._app_server_auto_restart,
            max_handles=config.app_server_max_handles,
            idle_ttl_seconds=config.app_server_idle_ttl_seconds,
        )
        self.app_server_supervisor = self._app_server_supervisor
        self._opencode_supervisor = _build_opencode_supervisor(
            config,
            logger=self._logger,
        )
        self.opencode_supervisor = self._opencode_supervisor
        self._runtime_services = RuntimeServices(
            app_server_supervisor=self._app_server_supervisor,
            opencode_supervisor=self._opencode_supervisor,
            flow_runtime_builder=build_ticket_flow_runtime_resources,
        )
        poll_timeout = float(config.poll_timeout_seconds)
        request_timeout = config.poll_request_timeout_seconds
        if request_timeout is None:
            # Keep HTTP timeout above long-poll timeout to avoid ReadTimeout churn.
            request_timeout = max(poll_timeout + 5.0, 10.0)
        self._bot = TelegramBotClient(
            config.bot_token or "",
            logger=self._logger,
            timeout_seconds=float(request_timeout),
        )
        self._poller = TelegramUpdatePoller(
            self._bot, allowed_updates=config.poll_allowed_updates
        )
        self._chat_adapter = TelegramChatAdapter(
            self._bot,
            poller=self._poller,
        )
        self._chat_transport = TelegramChatTransport(self)
        self._chat_state_store = TelegramChatStateStore(self._store)
        channel_directory_root = self._hub_root or self._config.root
        self._channel_directory_store = ChannelDirectoryStore(channel_directory_root)
        self._chat_core = ChatBotServiceCore(
            owner=self,
            runtime_services=self._runtime_services,
            state_store=self._chat_state_store,
            adapter=self._chat_adapter,
            transport=self._chat_transport,
        )
        self._model_options: dict[str, ModelPickerState] = {}
        self._model_pending: dict[str, ModelOption] = {}
        self._model_catalog_cache: dict[str, tuple[Any, float]] = {}
        self._agent_options: dict[str, SelectionState] = {}
        self._agent_profile_options: dict[str, SelectionState] = {}
        self._voice_config = voice_config
        self._voice_service = voice_service
        self._housekeeping_config = housekeeping_config
        if self._voice_service is None and voice_config is not None:
            try:
                self._voice_service = VoiceService(voice_config, logger=self._logger)
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.voice.init_failed",
                    exc=exc,
                )
        self._turn_semaphore: Optional[asyncio.Semaphore] = None
        self._turn_contexts: dict[TurnKey, TurnContext] = {}
        self._reasoning_buffers: dict[str, TextDeltaCoalescer] = {}
        self._turn_preview_text: dict[TurnKey, str] = {}
        self._turn_preview_updated_at: dict[TurnKey, float] = {}
        self._turn_progress_trackers: dict[TurnKey, "TurnProgressTracker"] = {}
        self._turn_progress_rendered: dict[TurnKey, str] = {}
        self._turn_progress_final_rendered: dict[TurnKey, str] = {}
        self._turn_progress_final_summary: dict[TurnKey, str] = {}
        self._turn_progress_updated_at: dict[TurnKey, float] = {}
        self._turn_progress_backoff_until: dict[TurnKey, float] = {}
        self._turn_progress_failure_streaks: dict[TurnKey, int] = {}
        self._turn_progress_suppressed_counts: dict[TurnKey, int] = {}
        self._turn_progress_tasks: dict[TurnKey, asyncio.Task[None]] = {}
        self._turn_progress_heartbeat_tasks: dict[TurnKey, asyncio.Task[None]] = {}
        self._turn_progress_locks: dict[TurnKey, asyncio.Lock] = {}
        self._oversize_warnings: set[TurnKey] = set()
        self._pending_approvals: dict[str, PendingApproval] = {}
        self._pending_questions: dict[str, PendingQuestion] = {}
        self._typing_sessions: dict[tuple[int, Optional[int]], int] = {}
        self._typing_tasks: dict[tuple[int, Optional[int]], asyncio.Task[None]] = {}
        self._typing_lock: Optional[asyncio.Lock] = None
        self._ticket_flow_pause_targets: dict[str, str] = {}
        self._ticket_flow_bridge = TelegramTicketFlowBridge(
            logger=self._logger,
            store=self._store,
            pause_targets=self._ticket_flow_pause_targets,
            send_message_with_outbox=self._send_message_with_outbox,
            send_document=self._send_document,
            pause_config=self._config.pause_dispatch_notifications,
            default_notification_chat_id=self._config.default_notification_chat_id,
            hub_root=hub_root,
            manifest_path=manifest_path,
            config_root=self._config.root,
            runtime_services=self._runtime_services,
        )
        self._resume_options: dict[str, SelectionState] = {}
        self._bind_options: dict[str, SelectionState] = {}
        self._flow_run_options: dict[str, SelectionState] = {}
        self._update_options: dict[str, SelectionState] = {}
        self._update_confirm_options: dict[str, dict[str, Any]] = {}
        self._review_commit_options: dict[str, ReviewCommitSelectionState] = {}
        self._review_commit_subjects: dict[str, dict[str, str]] = {}
        self._pending_review_custom: dict[str, dict[str, Any]] = {}
        self._compact_pending: dict[str, CompactState] = {}
        self._coalesced_buffers: dict[str, _CoalescedBuffer] = {}
        self._coalesce_locks: dict[str, asyncio.Lock] = {}
        self._media_batch_buffers: dict[str, message_handlers._MediaBatchBuffer] = {}
        self._media_batch_locks: dict[str, asyncio.Lock] = {}
        self._outbox_inflight: set[str] = set()
        self._outbox_lock: Optional[asyncio.Lock] = None
        self._queued_placeholder_map: dict[tuple[int, int], int] = {}
        self._queued_placeholder_timestamps: dict[tuple[int, int], float] = {}
        self._bot_username: Optional[str] = None
        self._token_usage_by_thread: "collections.OrderedDict[str, dict[str, Any]]" = (
            collections.OrderedDict()
        )
        self._token_usage_by_turn: "collections.OrderedDict[str, dict[str, Any]]" = (
            collections.OrderedDict()
        )
        self._outbox_task: Optional[asyncio.Task[None]] = None
        self._cache_cleanup_task: Optional[asyncio.Task[None]] = None
        self._ticket_flow_watch_task: Optional[asyncio.Task[None]] = None
        self._terminal_flow_watch_task: Optional[asyncio.Task[None]] = None
        self._cache_timestamps: dict[str, dict[object, float]] = {}
        self._last_update_ids: dict[str, int] = {}
        self._last_update_persisted_at: dict[str, float] = {}
        self._spawned_tasks: set[asyncio.Task[Any]] = set()
        self._update_status_notifier = ChatUpdateStatusNotifier(
            platform="telegram",
            logger=self._logger,
            read_status=self._read_update_status,
            send_notice=self._send_update_status_notice,
            spawn_task=self._spawn_task,
            mark_notified=self._mark_update_notified,
            format_status=self._format_update_status_message,
            running_message="Update still running. Use /update status for the latest state.",
        )
        self._outbox_manager = TelegramOutboxManager(
            self._store,
            send_message=self._send_message,
            edit_message_text=self._edit_message_text,
            delete_message=self._delete_message,
            on_delivered=self._handle_telegram_outbox_delivery,
            logger=self._logger,
        )
        self._voice_manager = TelegramVoiceManager(
            self._config,
            self._store,
            voice_config=self._voice_config,
            voice_service=self._voice_service,
            send_message=self._send_message,
            edit_message_text=self._edit_message_text,
            send_progress_message=self._send_voice_progress_message,
            deliver_transcript=self._deliver_voice_transcript,
            download_file=self._download_telegram_file,
            logger=self._logger,
        )
        self._voice_task: Optional[asyncio.Task[None]] = None
        self._housekeeping_task: Optional[asyncio.Task[None]] = None
        self._command_specs = build_command_specs(self)
        self._instance_lock_path: Optional[Path] = None
        self._instance_lock: Optional[FileLock] = None

    async def _handle_telegram_outbox_delivery(
        self, record: OutboxRecord, delivered_message_id: Optional[int]
    ) -> None:
        if not isinstance(delivered_message_id, int):
            return
        PmaNotificationStore(self._config.root).mark_delivered(
            delivery_record_id=record.record_id,
            delivered_message_id=delivered_message_id,
        )

    async def _housekeeping_roots(self) -> list[Path]:
        roots: set[Path] = set()
        try:
            state = await self._store.load()
            for record in state.topics.values():
                if isinstance(record.workspace_path, str) and record.workspace_path:
                    roots.add(Path(record.workspace_path).expanduser().resolve())
        except Exception as exc:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.housekeeping.state_failed",
                exc=exc,
            )
        if self._hub_root and self._manifest_path and self._manifest_path.exists():
            try:
                manifest = load_manifest(self._manifest_path, self._hub_root)
                for repo in manifest.repos:
                    roots.add((self._hub_root / repo.path).resolve())
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.housekeeping.manifest_failed",
                    exc=exc,
                )
        if self._config.root:
            roots.add(self._config.root.resolve())
        return sorted(roots)

    async def _gather_workspace_roots(self) -> list[Path]:
        roots: set[Path] = set()
        try:
            state = await self._store.load()
            for record in state.topics.values():
                if isinstance(record.workspace_path, str) and record.workspace_path:
                    roots.add(Path(record.workspace_path).expanduser().resolve())
        except Exception as exc:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.prewarm.state_failed",
                exc=exc,
            )
        return sorted(roots)

    async def _prewarm_workspace_clients(self) -> None:
        workspace_roots = await self._gather_workspace_roots()
        if not workspace_roots:
            log_event(
                self._logger,
                logging.INFO,
                "telegram.prewarm.skipped",
                reason="no_workspaces",
            )
            return

        log_event(
            self._logger,
            logging.INFO,
            "telegram.prewarm.started",
            workspace_count=len(workspace_roots),
            workspaces=[str(p) for p in workspace_roots],
        )

        sem = asyncio.Semaphore(3)
        prewarmed_count = 0
        failed_count = 0

        async def prewarm_one(workspace_root: Path) -> None:
            nonlocal prewarmed_count, failed_count
            async with sem:
                try:
                    await self._app_server_supervisor.get_client(workspace_root)
                    prewarmed_count += 1
                    log_event(
                        self._logger,
                        logging.INFO,
                        "telegram.prewarm.client_ready",
                        workspace_root=str(workspace_root),
                    )
                except Exception as exc:
                    failed_count += 1
                    log_event(
                        self._logger,
                        logging.WARNING,
                        "telegram.prewarm.client_failed",
                        workspace_root=str(workspace_root),
                        exc=exc,
                    )

        await asyncio.gather(
            *[prewarm_one(root) for root in workspace_roots],
            return_exceptions=True,
        )

        log_event(
            self._logger,
            logging.INFO,
            "telegram.prewarm.completed",
            workspace_count=len(workspace_roots),
            prewarmed_count=prewarmed_count,
            failed_count=failed_count,
        )

    async def _housekeeping_loop(self) -> None:
        config = self._housekeeping_config
        if config is None or not config.enabled:
            return
        interval = max(config.interval_seconds, 1)
        while True:
            try:
                await self._run_housekeeping_cycle(config)
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.housekeeping.failed",
                    exc=exc,
                )
            await asyncio.sleep(interval)

    async def _run_housekeeping_cycle(self, config: HousekeepingConfig) -> None:
        roots = await self._housekeeping_roots()
        if roots:
            for root in roots:
                try:
                    repo_config = load_repo_config(root, hub_path=self._hub_config_path)
                    summary = await asyncio.to_thread(
                        prune_filebox_root,
                        root,
                        policy=resolve_filebox_retention_policy(repo_config.pma),
                    )
                    if summary.inbox_pruned or summary.outbox_pruned:
                        log_event(
                            self._logger,
                            logging.INFO,
                            "telegram.filebox.cleanup",
                            root=str(root),
                            inbox_pruned=summary.inbox_pruned,
                            outbox_pruned=summary.outbox_pruned,
                            bytes_before=summary.bytes_before,
                            bytes_after=summary.bytes_after,
                        )
                except Exception as exc:
                    log_event(
                        self._logger,
                        logging.WARNING,
                        "telegram.filebox.cleanup_failed",
                        root=str(root),
                        exc=exc,
                    )
            await asyncio.to_thread(
                run_housekeeping_for_roots,
                config,
                roots,
                self._logger,
            )
        await self._app_server_supervisor.prune_idle()
        if self._opencode_supervisor is not None:
            await self._opencode_supervisor.prune_idle()

    def _evaluate_collaboration_message_policy(
        self,
        message: TelegramMessage,
        *,
        text: str,
        is_explicit_command: bool,
    ) -> CollaborationEvaluationResult:
        return evaluate_collaboration_policy(
            self._collaboration_policy,
            CollaborationEvaluationContext(
                actor_id=(
                    str(message.from_user_id)
                    if message.from_user_id is not None
                    else None
                ),
                container_id=str(message.chat_id),
                destination_id=str(message.chat_id),
                subdestination_id=(
                    str(message.thread_id) if message.thread_id is not None else None
                ),
                is_explicit_command=is_explicit_command,
                plain_text=PlainTextTurnContext(
                    text=text,
                    chat_type=message.chat_type,
                    bot_username=self._bot_username,
                    reply_to_is_bot=message.reply_to_is_bot,
                    reply_to_username=message.reply_to_username,
                    reply_to_message_id=(
                        str(message.reply_to_message_id)
                        if message.reply_to_message_id is not None
                        else None
                    ),
                    thread_id=(
                        str(message.thread_id)
                        if message.thread_id is not None
                        else None
                    ),
                ),
            ),
        )

    def _log_collaboration_policy_result(
        self,
        message: TelegramMessage,
        result: CollaborationEvaluationResult,
    ) -> None:
        log_event(
            self._logger,
            logging.INFO,
            "telegram.collaboration_policy.evaluated",
            chat_id=message.chat_id,
            thread_id=message.thread_id,
            message_id=message.message_id,
            user_id=message.from_user_id,
            **result.log_fields(),
        )

    def _ensure_outbox_lock(self) -> asyncio.Lock:
        loop = asyncio.get_running_loop()
        lock = self._outbox_lock
        lock_loop = getattr(lock, "_loop", None) if lock else None
        if (
            lock is None
            or lock_loop is None
            or lock_loop is not loop
            or lock_loop.is_closed()
        ):
            lock = asyncio.Lock()
            self._outbox_lock = lock
        return lock

    async def _mark_outbox_inflight(self, record_id: str) -> bool:
        lock = self._ensure_outbox_lock()
        async with lock:
            if record_id in self._outbox_inflight:
                return False
            self._outbox_inflight.add(record_id)
            return True

    async def _clear_outbox_inflight(self, record_id: str) -> None:
        lock = self._ensure_outbox_lock()
        async with lock:
            self._outbox_inflight.discard(record_id)

    def _acquire_instance_lock(self) -> None:
        token = self._config.bot_token
        if not token:
            raise TelegramBotLockError("missing telegram bot token")
        lock_path = _telegram_lock_path(token)
        payload = {
            "pid": os.getpid(),
            "started_at": now_iso(),
            "host": socket.gethostname(),
            "cwd": os.getcwd(),
            "config_root": str(self._config.root),
        }
        lock_path.parent.mkdir(parents=True, exist_ok=True)
        try:
            lock = FileLock(lock_path)
            lock.acquire(blocking=False)
        except FileLockBusy as exc:
            existing = _read_lock_payload(lock_path)
            log_event(
                self._logger,
                logging.ERROR,
                "telegram.lock.contended",
                lock_path=str(lock_path),
                **_lock_payload_summary(existing),
            )
            raise TelegramBotLockError(
                "Telegram bot already running for this token."
            ) from exc
        lock.write_text(json.dumps(payload) + "\n")
        self._instance_lock_path = lock_path
        self._instance_lock = lock
        log_event(
            self._logger,
            logging.INFO,
            "telegram.lock.acquired",
            lock_path=str(lock_path),
            **_lock_payload_summary(payload),
        )

    def _release_instance_lock(self) -> None:
        lock = self._instance_lock
        lock_path = self._instance_lock_path
        if lock is None or lock_path is None:
            return
        try:
            existing = _read_lock_payload(lock_path)
            if isinstance(existing, dict):
                pid = existing.get("pid")
                if isinstance(pid, int) and pid == os.getpid():
                    lock.write_text("")
        finally:
            lock.release()
        self._instance_lock_path = None
        self._instance_lock = None

    def _ensure_turn_semaphore(self) -> asyncio.Semaphore:
        if self._turn_semaphore is None:
            self._turn_semaphore = asyncio.Semaphore(
                self._config.concurrency.max_parallel_turns
            )
        return self._turn_semaphore

    async def run_polling(self) -> None:
        await self._chat_core.run()

    async def _dispatch_update(self, update: TelegramUpdate) -> None:
        await dispatch_update(self, update)

    async def _prime_bot_identity(self) -> None:
        try:
            payload = await self._bot.get_me()
        except Exception:
            return
        if isinstance(payload, dict):
            username = payload.get("username")
            if isinstance(username, str) and username:
                self._bot_username = username

    async def _register_bot_commands(self) -> None:
        registration = self._config.command_registration
        if not registration.enabled:
            log_event(
                self._logger,
                logging.DEBUG,
                "telegram.commands.disabled",
            )
            return
        desired, invalid = build_command_payloads(self._command_specs)
        if invalid:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.commands.invalid",
                invalid=invalid,
            )
        if not desired:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.commands.empty",
            )
            return
        if len(desired) > 100:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.commands.truncated",
                desired_count=len(desired),
            )
            desired = desired[:100]
        for scope_spec in registration.scopes:
            scope = scope_spec.scope
            language_code = scope_spec.language_code
            try:
                current = await self._bot.get_my_commands(
                    scope=scope,
                    language_code=language_code,
                )
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.commands.get_failed",
                    scope=scope,
                    language_code=language_code,
                    exc=exc,
                )
                continue
            diff = diff_command_lists(desired, current)
            if not diff.needs_update:
                log_event(
                    self._logger,
                    logging.DEBUG,
                    "telegram.commands.up_to_date",
                    scope=scope,
                    language_code=language_code,
                )
                continue
            try:
                updated = await self._bot.set_my_commands(
                    desired,
                    scope=scope,
                    language_code=language_code,
                )
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.commands.set_failed",
                    scope=scope,
                    language_code=language_code,
                    exc=exc,
                )
                continue
            log_event(
                self._logger,
                logging.INFO,
                "telegram.commands.updated",
                scope=scope,
                language_code=language_code,
                updated=updated,
                added=diff.added,
                removed=diff.removed,
                changed=diff.changed,
                order_changed=diff.order_changed,
            )

    async def _prime_poller_offset(self) -> None:
        last_update_id = await self._store.get_last_update_id_global()
        if not isinstance(last_update_id, int) or isinstance(last_update_id, bool):
            return
        offset = last_update_id + 1
        self._poller.set_offset(offset)
        log_event(
            self._logger,
            logging.INFO,
            "telegram.poll.offset.init",
            stored_global_update_id=last_update_id,
            poller_offset=offset,
        )

    async def _record_poll_offset(self, updates: Sequence[TelegramUpdate]) -> None:
        offset = self._poller.offset
        if offset is None:
            return
        last_update_id = offset - 1
        if last_update_id < 0:
            return
        stored = await self._store.update_last_update_id_global(last_update_id)
        max_update_id = max((update.update_id for update in updates), default=None)
        log_event(
            self._logger,
            logging.INFO,
            "telegram.poll.offset.updated",
            incoming_update_id=max_update_id,
            stored_global_update_id=stored,
            poller_offset=offset,
        )

    def _spawn_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
        task: asyncio.Task[Any] = asyncio.create_task(coro)
        self._spawned_tasks.add(task)
        task.add_done_callback(self._log_task_result)
        return task

    def _log_task_result(self, task: asyncio.Future) -> None:
        if isinstance(task, asyncio.Task):
            self._spawned_tasks.discard(task)
        try:
            task.result()
        except asyncio.CancelledError:
            return
        except Exception as exc:
            log_event(self._logger, logging.WARNING, "telegram.task.failed", exc=exc)

    def _touch_cache_timestamp(self, cache_name: str, key: object) -> None:
        cache = self._cache_timestamps.setdefault(cache_name, {})
        cache[key] = time.monotonic()

    def _evict_expired_cache_entries(self, cache_name: str, ttl_seconds: float) -> None:
        cache = self._cache_timestamps.get(cache_name)
        if not cache:
            return
        now = time.monotonic()
        expired: list[object] = []
        for key, updated_at in cache.items():
            if (now - updated_at) > ttl_seconds:
                expired.append(key)
        if not expired:
            return
        for key in expired:
            cache.pop(key, None)
            if cache_name == "reasoning_buffers":
                self._reasoning_buffers.pop(key, None)
            elif cache_name == "turn_preview":
                self._turn_preview_text.pop(key, None)
                self._turn_preview_updated_at.pop(key, None)
            elif cache_name == "progress_trackers":
                self._turn_progress_trackers.pop(key, None)
                self._turn_progress_rendered.pop(key, None)
                self._turn_progress_final_rendered.pop(key, None)
                self._turn_progress_final_summary.pop(key, None)
                self._turn_progress_updated_at.pop(key, None)
                self._turn_progress_backoff_until.pop(key, None)
                self._turn_progress_failure_streaks.pop(key, None)
                self._turn_progress_suppressed_counts.pop(key, None)
                task = self._turn_progress_tasks.pop(key, None)
                if task and not task.done():
                    task.cancel()
                heartbeat_task = self._turn_progress_heartbeat_tasks.pop(key, None)
                if heartbeat_task and not heartbeat_task.done():
                    heartbeat_task.cancel()
            elif cache_name == "oversize_warnings":
                self._oversize_warnings.discard(key)
            elif cache_name == "coalesced_buffers":
                self._coalesced_buffers.pop(key, None)
                self._coalesce_locks.pop(key, None)
            elif cache_name == "media_batch_buffers":
                self._media_batch_buffers.pop(key, None)
                self._media_batch_locks.pop(key, None)
            elif cache_name == "resume_options":
                self._resume_options.pop(key, None)
            elif cache_name == "bind_options":
                self._bind_options.pop(key, None)
            elif cache_name == "flow_run_options":
                self._flow_run_options.pop(key, None)
            elif cache_name == "agent_options":
                self._agent_options.pop(key, None)
            elif cache_name == "update_options":
                self._update_options.pop(key, None)
            elif cache_name == "update_confirm_options":
                self._update_confirm_options.pop(key, None)
            elif cache_name == "review_commit_options":
                self._review_commit_options.pop(key, None)
            elif cache_name == "review_commit_subjects":
                self._review_commit_subjects.pop(key, None)
            elif cache_name == "pending_review_custom":
                self._pending_review_custom.pop(key, None)
            elif cache_name == "compact_pending":
                self._compact_pending.pop(key, None)
            elif cache_name == "agent_profile_options":
                self._agent_profile_options.pop(key, None)
            elif cache_name == "model_options":
                self._model_options.pop(key, None)
            elif cache_name == "model_pending":
                self._model_pending.pop(key, None)
            elif cache_name == "pending_approvals":
                self._pending_approvals.pop(key, None)
            elif cache_name == "pending_questions":
                self._pending_questions.pop(key, None)

    async def _cache_cleanup_loop(self) -> None:
        interval = max(self._config.cache.cleanup_interval_seconds, 1.0)
        while True:
            await asyncio.sleep(interval)
            self._evict_expired_cache_entries(
                "reasoning_buffers", self._config.cache.reasoning_buffer_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "turn_preview", self._config.cache.turn_preview_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "progress_trackers", self._config.cache.progress_stream_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "oversize_warnings", self._config.cache.oversize_warning_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "coalesced_buffers", self._config.cache.coalesce_buffer_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "media_batch_buffers",
                self._config.cache.media_batch_buffer_ttl_seconds,
            )
            self._evict_expired_cache_entries(
                "resume_options", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "bind_options", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "flow_run_options", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "agent_options", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "agent_profile_options",
                self._config.cache.selection_state_ttl_seconds,
            )
            self._evict_expired_cache_entries(
                "update_options", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "update_confirm_options",
                self._config.cache.selection_state_ttl_seconds,
            )
            self._evict_expired_cache_entries(
                "review_commit_options",
                self._config.cache.selection_state_ttl_seconds,
            )
            self._evict_expired_cache_entries(
                "review_commit_subjects",
                self._config.cache.selection_state_ttl_seconds,
            )
            self._evict_expired_cache_entries(
                "pending_review_custom",
                self._config.cache.selection_state_ttl_seconds,
            )
            self._evict_expired_cache_entries(
                "compact_pending", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "model_options", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "model_pending", self._config.cache.model_pending_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "pending_approvals", self._config.cache.pending_approval_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "pending_questions", self._config.cache.pending_question_ttl_seconds
            )
            now = time.monotonic()
            expired_placeholders = []
            for key, timestamp in self._queued_placeholder_timestamps.items():
                if (now - timestamp) > self._config.cache.pending_approval_ttl_seconds:
                    expired_placeholders.append(key)
            for key in expired_placeholders:
                self._queued_placeholder_map.pop(key, None)
                self._queued_placeholder_timestamps.pop(key, None)

    @staticmethod
    def _parse_last_active(record: "TelegramTopicRecord") -> float:
        raw = getattr(record, "last_active_at", None)
        if isinstance(raw, str):
            try:
                return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").timestamp()
            except ValueError:
                return float("-inf")
        return float("-inf")

    def _select_ticket_flow_topic(
        self, entries: list[tuple[str, "TelegramTopicRecord"]]
    ) -> Optional[tuple[str, "TelegramTopicRecord"]]:
        return self._ticket_flow_bridge._select_ticket_flow_topic(entries)

    @staticmethod
    def _set_ticket_dispatch_marker(
        value: Optional[str],
    ) -> "Callable[[TelegramTopicRecord], None]":
        def apply(topic: "TelegramTopicRecord") -> None:
            topic.last_ticket_dispatch_seq = value

        return apply

    async def _ticket_flow_watch_loop(self) -> None:
        await self._ticket_flow_bridge.watch_ticket_flow_pauses(
            TICKET_FLOW_WATCH_INTERVAL_SECONDS
        )

    async def _watch_ticket_flow_pauses(self) -> None:
        await self._ticket_flow_bridge._scan_and_notify_pauses()

    async def _notify_ticket_flow_pause(
        self,
        workspace_root: Path,
        entries: list[tuple[str, "TelegramTopicRecord"]],
    ) -> None:
        await self._ticket_flow_bridge._notify_ticket_flow_pause(
            workspace_root, entries
        )

    def _load_ticket_flow_pause(
        self, workspace_root: Path
    ) -> Optional[tuple[str, str, str, Optional[Path]]]:
        return self._ticket_flow_bridge._load_ticket_flow_pause(workspace_root)

    def _latest_dispatch_seq(self, history_dir: Path) -> Optional[str]:
        return latest_dispatch_seq(history_dir)

    def _format_ticket_flow_pause_reason(self, record: "FlowRunRecord") -> str:
        return format_pause_reason(record)

    def _get_paused_ticket_flow(
        self, workspace_root: Path, preferred_run_id: Optional[str] = None
    ) -> Optional[tuple[str, FlowRunRecord]]:
        return self._ticket_flow_bridge.get_paused_ticket_flow(
            workspace_root, preferred_run_id=preferred_run_id
        )

    async def _write_user_reply_from_telegram(
        self,
        workspace_root: Path,
        run_id: str,
        run_record: FlowRunRecord,
        message: TelegramMessage,
        text: str,
        files: Optional[list[tuple[str, bytes]]] = None,
    ) -> tuple[bool, str]:
        try:
            reply_paths = resolve_reply_paths(
                workspace_root=workspace_root, run_id=run_id
            )
            ensure_reply_dirs(reply_paths)

            cleaned_text = text.strip()
            raw = cleaned_text
            if raw and not raw.endswith("\n"):
                raw += "\n"

            await asyncio.to_thread(
                reply_paths.user_reply_path.write_text, raw, encoding="utf-8"
            )

            if files:
                for filename, data in files:
                    safe_name = Path(filename).name
                    if not safe_name or safe_name in {".", ".."}:
                        safe_name = "attachment"
                    dest = reply_paths.reply_dir / safe_name
                    dest.parent.mkdir(parents=True, exist_ok=True)
                    await asyncio.to_thread(dest.write_bytes, data)

            seq = await asyncio.to_thread(
                lambda: _next_reply_seq_sync(reply_paths.reply_history_dir)
            )
            dispatch, errors = await asyncio.to_thread(
                dispatch_reply, reply_paths, next_seq=seq
            )
            if errors:
                return False, "\n".join(errors)
            if dispatch is None:
                return False, "Failed to archive reply"
            return (
                True,
                f"Reply archived (seq {dispatch.seq}).",
            )
        except Exception as exc:
            self._logger.warning(
                "Failed to write USER_REPLY.md from Telegram",
                exc=exc,
                workspace_root=str(workspace_root),
                run_id=run_id,
            )
            return False, f"Failed to write reply: {exc}"

    async def _interrupt_timeout_check(
        self, key: str, turn_id: str, message_id: int
    ) -> None:
        await asyncio.sleep(DEFAULT_INTERRUPT_TIMEOUT_SECONDS)
        runtime = self._router.runtime_for(key)
        if runtime.current_turn_id != turn_id:
            return
        if runtime.interrupt_message_id != message_id:
            return
        if runtime.interrupt_turn_id != turn_id:
            return
        chat_id, _thread_id = _split_topic_key(key)
        await self._edit_message_text(
            chat_id,
            message_id,
            "Still stopping... (30s). If this is stuck, try /interrupt again.",
        )
        runtime.interrupt_requested = False

    async def _dispatch_interrupt_request(
        self,
        *,
        turn_id: str,
        codex_thread_id: Optional[str],
        runtime: Any,
        chat_id: int,
        thread_id: Optional[int],
    ) -> None:
        key = await self._resolve_topic_key(chat_id, thread_id)
        record = await self._router.get_topic(key)
        turn_ctx = self._resolve_turn_context(turn_id, thread_id=codex_thread_id)
        if turn_ctx is not None and turn_ctx.topic_key and turn_ctx.topic_key != key:
            scoped_record = await self._router.get_topic(turn_ctx.topic_key)
            if scoped_record is not None:
                key = turn_ctx.topic_key
                record = scoped_record
        workspace_path = record.workspace_path if record else None
        if record and getattr(record, "pma_enabled", False):
            # PMA topics are hub-scoped; their stored topic records may not carry a
            # workspace path. Turn execution patches workspace_path to hub_root, but
            # interrupt dispatch must do the same so /interrupt works in PMA mode.
            hub_root = getattr(self, "_hub_root", None)
            if hub_root is not None:
                workspace_path = str(hub_root)
        if record and record.agent == "opencode":
            session_id = record.active_thread_id or codex_thread_id
            if (
                not session_id
                or self._opencode_supervisor is None
                or not isinstance(workspace_path, str)
                or not workspace_path
            ):
                runtime.interrupt_requested = False
                if runtime.interrupt_message_id is not None:
                    await self._edit_message_text(
                        chat_id,
                        runtime.interrupt_message_id,
                        "Interrupt failed (OpenCode unavailable).",
                    )
                    runtime.interrupt_message_id = None
                    runtime.interrupt_turn_id = None
                return
            try:
                client = await self._opencode_supervisor.get_client(
                    Path(workspace_path)
                )
                await client.abort(session_id)
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.interrupt.failed",
                    chat_id=chat_id,
                    thread_id=thread_id,
                    turn_id=turn_id,
                    exc=exc,
                )
                if (
                    runtime.interrupt_message_id is not None
                    and runtime.interrupt_turn_id == turn_id
                ):
                    await self._edit_message_text(
                        chat_id,
                        runtime.interrupt_message_id,
                        "Interrupt failed (OpenCode error).",
                    )
                runtime.interrupt_message_id = None
                runtime.interrupt_turn_id = None
                runtime.interrupt_requested = False
                return
            runtime.interrupt_message_id = None
            runtime.interrupt_turn_id = None
            runtime.interrupt_requested = False
            return
        try:
            client = await self._client_for_workspace(workspace_path)
        except AppServerUnavailableError:
            runtime.interrupt_requested = False
            if runtime.interrupt_message_id is not None:
                await self._edit_message_text(
                    chat_id,
                    runtime.interrupt_message_id,
                    "Interrupt failed (app-server unavailable).",
                )
                runtime.interrupt_message_id = None
                runtime.interrupt_turn_id = None
            return
        if client is None:
            runtime.interrupt_requested = False
            if runtime.interrupt_message_id is not None:
                await self._edit_message_text(
                    chat_id,
                    runtime.interrupt_message_id,
                    "Interrupt failed (app-server error).",
                )
                runtime.interrupt_message_id = None
                runtime.interrupt_turn_id = None
            return
        try:
            await client.turn_interrupt(turn_id, thread_id=codex_thread_id)
        except Exception as exc:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.interrupt.failed",
                chat_id=chat_id,
                thread_id=thread_id,
                turn_id=turn_id,
                exc=exc,
            )
            if (
                runtime.interrupt_message_id is not None
                and runtime.interrupt_turn_id == turn_id
            ):
                await self._edit_message_text(
                    chat_id,
                    runtime.interrupt_message_id,
                    "Interrupt failed (app-server error).",
                )
                runtime.interrupt_message_id = None
                runtime.interrupt_turn_id = None
            runtime.interrupt_requested = False

    async def _handle_message(self, message: TelegramMessage) -> None:
        self._record_channel_directory_seen_from_telegram_message(message)
        await message_handlers.handle_message(self, message)

    def _should_bypass_topic_queue(self, message: TelegramMessage) -> bool:
        return message_handlers.should_bypass_topic_queue(self, message)

    def _record_channel_directory_seen_from_telegram_message(
        self, message: TelegramMessage
    ) -> None:
        thread_id = (
            str(message.thread_id) if isinstance(message.thread_id, int) else None
        )
        chat_label = (
            message.chat_title.strip()
            if isinstance(message.chat_title, str) and message.chat_title.strip()
            else str(message.chat_id)
        )
        topic_label: Optional[str] = None
        if thread_id is None:
            display = chat_label
        else:
            topic_label = (
                message.thread_title.strip()
                if isinstance(message.thread_title, str)
                and message.thread_title.strip()
                else None
            )
            if topic_label is None:
                topic_label = self._existing_topic_label(
                    chat_id=str(message.chat_id),
                    thread_id=thread_id,
                    chat_label=chat_label,
                )
            if topic_label is None:
                topic_label = thread_id
            display = f"{chat_label} / {topic_label}"

        meta: dict[str, Any] = {}
        if isinstance(message.chat_type, str) and message.chat_type.strip():
            meta["chat_type"] = message.chat_type.strip()
        if (
            thread_id is not None
            and isinstance(topic_label, str)
            and topic_label != thread_id
        ):
            meta["topic_title"] = topic_label

        try:
            self._channel_directory_store.record_seen(
                "telegram",
                str(message.chat_id),
                thread_id,
                display,
                meta,
            )
        except Exception as exc:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.channel_directory.record_failed",
                chat_id=message.chat_id,
                thread_id=message.thread_id,
                message_id=message.message_id,
                exc=exc,
            )

    async def _handle_buffered_app_server_notification(
        self, message: dict[str, Any]
    ) -> None:
        await self.app_server_events.handle_notification(message)
        await self._handle_app_server_notification(message)

    def _existing_topic_label(
        self, *, chat_id: str, thread_id: str, chat_label: str
    ) -> Optional[str]:
        try:
            entries = self._channel_directory_store.list_entries(limit=None)
        except Exception:
            return None

        for entry in entries:
            if not isinstance(entry, dict):
                continue
            if entry.get("platform") != "telegram":
                continue
            if str(entry.get("chat_id") or "") != chat_id:
                continue
            if str(entry.get("thread_id") or "") != thread_id:
                continue

            meta = entry.get("meta")
            if isinstance(meta, dict):
                topic_title = meta.get("topic_title")
                if isinstance(topic_title, str):
                    normalized = topic_title.strip()
                    if normalized:
                        return normalized

            display = entry.get("display")
            if isinstance(display, str):
                prefix = f"{chat_label} / "
                if display.startswith(prefix):
                    topic_part = display[len(prefix) :].strip()
                    if topic_part and topic_part != thread_id:
                        return topic_part
                separator_index = display.rfind(" / ")
                if separator_index >= 0:
                    topic_part = display[separator_index + 3 :].strip()
                    if topic_part and topic_part != thread_id:
                        return topic_part
            break
        return None

    async def _handle_edited_message(self, message: TelegramMessage) -> None:
        await message_handlers.handle_edited_message(self, message)

    async def _handle_message_inner(
        self,
        message: TelegramMessage,
        *,
        topic_key: Optional[str] = None,
        placeholder_id: Optional[int] = None,
    ) -> None:
        await message_handlers.handle_message_inner(
            self, message, topic_key=topic_key, placeholder_id=placeholder_id
        )

    def _coalesce_key_for_topic(self, key: str, user_id: Optional[int]) -> str:
        return message_handlers.coalesce_key_for_topic(self, key, user_id)

    async def _coalesce_key(self, message: TelegramMessage) -> str:
        return await message_handlers.coalesce_key(self, message)

    async def _buffer_coalesced_message(
        self,
        message: TelegramMessage,
        text: str,
        *,
        placeholder_id: Optional[int] = None,
    ) -> None:
        await message_handlers.buffer_coalesced_message(
            self, message, text, placeholder_id=placeholder_id
        )

    async def _coalesce_flush_after(self, key: str) -> None:
        window_seconds = self._config.coalesce_window_seconds
        await message_handlers.coalesce_flush_after(self, key, window_seconds)

    async def _flush_coalesced_message(self, message: TelegramMessage) -> None:
        await message_handlers.flush_coalesced_message(self, message)

    async def _flush_coalesced_key(self, key: str) -> None:
        await message_handlers.flush_coalesced_key(self, key)

    def _build_coalesced_message(self, buffer: _CoalescedBuffer) -> TelegramMessage:
        return message_handlers.build_coalesced_message(buffer)

    def _message_has_media(self, message: TelegramMessage) -> bool:
        return message_handlers.message_has_media(message)

    def _select_photo(
        self, photos: Sequence[TelegramPhotoSize]
    ) -> Optional[TelegramPhotoSize]:
        return message_handlers.select_photo(photos)

    def _document_is_image(self, document: TelegramDocument) -> bool:
        return message_handlers.document_is_image(document)

    def _select_image_candidate(
        self, message: TelegramMessage
    ) -> Optional[TelegramMediaCandidate]:
        return message_handlers.select_image_candidate(message)

    def _select_voice_candidate(
        self, message: TelegramMessage
    ) -> Optional[TelegramMediaCandidate]:
        return message_handlers.select_voice_candidate(message)

    async def _handle_media_message(
        self,
        message: TelegramMessage,
        runtime: Any,
        caption_text: str,
        *,
        placeholder_id: Optional[int] = None,
    ) -> None:
        await message_handlers.handle_media_message(
            self,
            message,
            runtime,
            caption_text,
            placeholder_id=placeholder_id,
        )

    def _with_conversation_id(
        self, message: str, *, chat_id: int, thread_id: Optional[int]
    ) -> str:
        return _with_conversation_id(message, chat_id=chat_id, thread_id=thread_id)

    async def _should_process_update(self, key: str, update_id: int) -> bool:
        if not isinstance(update_id, int):
            return True
        if isinstance(update_id, bool):
            return True
        last_id = self._last_update_ids.get(key)
        if last_id is None:
            record = None
            try:
                record = await self._store.get_topic(key)
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.update_id.load.failed",
                    exc=exc,
                    topic_key=key,
                    update_id=update_id,
                )
            last_id = record.last_update_id if record else None
            if isinstance(last_id, int) and not isinstance(last_id, bool):
                self._last_update_ids[key] = last_id
            else:
                last_id = None
        if isinstance(last_id, int) and update_id <= last_id:
            return False
        self._last_update_ids[key] = update_id
        try:
            await self._maybe_persist_update_id(key, update_id)
        except Exception as exc:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.update_id.persist.failed",
                exc=exc,
                topic_key=key,
                update_id=update_id,
            )
        return True

    async def _maybe_persist_update_id(self, key: str, update_id: int) -> None:
        now = time.monotonic()
        last_persisted = self._last_update_persisted_at.get(key, 0.0)
        if (
            now - last_persisted
        ) < self._config.cache.update_id_persist_interval_seconds:
            return

        def apply(record: "TelegramTopicRecord") -> None:
            record.last_update_id = update_id

        await self._store.update_topic(key, apply)
        self._last_update_persisted_at[key] = now

    def _ensure_typing_lock(self) -> asyncio.Lock:
        loop = asyncio.get_running_loop()
        lock = self._typing_lock
        lock_loop = getattr(lock, "_loop", None) if lock else None
        if (
            lock is None
            or lock_loop is None
            or lock_loop is not loop
            or lock_loop.is_closed()
        ):
            lock = asyncio.Lock()
            self._typing_lock = lock
        return lock

    async def _typing_session_active(self, key: tuple[int, Optional[int]]) -> bool:
        lock = self._ensure_typing_lock()
        async with lock:
            return self._typing_sessions.get(key, 0) > 0

    async def _typing_indicator_loop(
        self, chat_id: int, thread_id: Optional[int]
    ) -> None:
        key = (chat_id, thread_id)
        send_chat_action = getattr(self._bot, "send_chat_action", None)
        if not callable(send_chat_action):
            return
        try:
            while True:
                try:
                    await send_chat_action(
                        chat_id,
                        action="typing",
                        message_thread_id=thread_id,
                    )
                except Exception as exc:
                    log_event(
                        self._logger,
                        logging.DEBUG,
                        "telegram.typing.send.failed",
                        chat_id=chat_id,
                        thread_id=thread_id,
                        exc=exc,
                    )
                await asyncio.sleep(TYPING_HEARTBEAT_INTERVAL_SECONDS)
                if not await self._typing_session_active(key):
                    return
        finally:
            lock = self._ensure_typing_lock()
            async with lock:
                task = self._typing_tasks.get(key)
                if task is asyncio.current_task():
                    self._typing_tasks.pop(key, None)

    async def _begin_typing_indicator(
        self, chat_id: int, thread_id: Optional[int]
    ) -> None:
        key = (chat_id, thread_id)
        lock = self._ensure_typing_lock()
        async with lock:
            self._typing_sessions[key] = self._typing_sessions.get(key, 0) + 1
            task = self._typing_tasks.get(key)
            if task is not None and not task.done():
                return
            typing_coro = self._typing_indicator_loop(chat_id, thread_id)
            try:
                self._typing_tasks[key] = self._spawn_task(typing_coro)
            except Exception:
                typing_coro.close()
                count = self._typing_sessions.get(key, 0)
                if count <= 1:
                    self._typing_sessions.pop(key, None)
                else:
                    self._typing_sessions[key] = count - 1
                raise

    async def _end_typing_indicator(
        self, chat_id: int, thread_id: Optional[int]
    ) -> None:
        key = (chat_id, thread_id)
        task_to_cancel: Optional[asyncio.Task[None]] = None
        lock = self._ensure_typing_lock()
        async with lock:
            count = self._typing_sessions.get(key)
            if count is None:
                return
            if count > 1:
                self._typing_sessions[key] = count - 1
                return
            self._typing_sessions.pop(key, None)
            task_to_cancel = self._typing_tasks.pop(key, None)
        if task_to_cancel is not None and not task_to_cancel.done():
            task_to_cancel.cancel()
            with suppress(asyncio.CancelledError):
                await task_to_cancel

    async def _handle_callback(self, callback: TelegramCallbackQuery) -> None:
        await callback_handlers.handle_callback(self, callback)

    def _enqueue_topic_work(
        self,
        key: str,
        work: Any,
        *,
        force_queue: bool = False,
        item_id: Optional[str] = None,
    ) -> Optional[str]:
        runtime = self._router.runtime_for(key)
        wrapped = self._wrap_topic_work(key, work)
        if force_queue or self._config.concurrency.per_topic_queue:
            return runtime.queue.enqueue_detached(wrapped, item_id=item_id)
        self._spawn_task(wrapped())
        return None

    def _queued_placeholder_keyboard(self, source_message_id: int) -> dict[str, Any]:
        source = str(source_message_id)
        return build_inline_keyboard(
            [
                [
                    InlineButton(
                        "Cancel",
                        encode_cancel_callback(f"queue_cancel:{source}"),
                    ),
                    InlineButton(
                        "Interrupt + Send",
                        encode_cancel_callback(f"queue_interrupt_send:{source}"),
                    ),
                ]
            ]
        )

    async def _maybe_send_queued_placeholder(
        self, message: TelegramMessage, *, topic_key: str
    ) -> Optional[int]:
        runtime = self._router.runtime_for(topic_key)
        is_busy = runtime.current_turn_id is not None or runtime.queue.pending() > 0
        if not is_busy:
            return None
        placeholder_id = await self._send_placeholder(
            message.chat_id,
            thread_id=message.thread_id,
            reply_to=message.message_id,
            text=QUEUED_PLACEHOLDER_TEXT,
            reply_markup=self._queued_placeholder_keyboard(message.message_id),
        )
        if placeholder_id is None:
            return None
        self._set_queued_placeholder(
            message.chat_id, message.message_id, placeholder_id
        )
        log_event(
            self._logger,
            logging.INFO,
            "telegram.placeholder.queued",
            topic_key=topic_key,
            chat_id=message.chat_id,
            thread_id=message.thread_id,
            message_id=message.message_id,
            placeholder_id=placeholder_id,
        )
        return placeholder_id

    def _wrap_placeholder_work(
        self,
        *,
        chat_id: int,
        placeholder_id: Optional[int],
        work: Any,
    ) -> Any:
        if placeholder_id is None:
            return work

        async def wrapped() -> Any:
            try:
                return await work()
            finally:
                await self._delete_message(chat_id, placeholder_id)

        return wrapped

    def _claim_queued_placeholder(self, chat_id: int, message_id: int) -> Optional[int]:
        placeholder_id = self._queued_placeholder_map.pop((chat_id, message_id), None)
        self._queued_placeholder_timestamps.pop((chat_id, message_id), None)
        return placeholder_id

    def _get_queued_placeholder(self, chat_id: int, message_id: int) -> Optional[int]:
        return self._queued_placeholder_map.get((chat_id, message_id))

    def _set_queued_placeholder(
        self, chat_id: int, message_id: int, placeholder_id: int
    ) -> None:
        self._queued_placeholder_map[(chat_id, message_id)] = placeholder_id
        self._queued_placeholder_timestamps[(chat_id, message_id)] = time.monotonic()

    def _clear_queued_placeholder(self, chat_id: int, message_id: int) -> None:
        self._queued_placeholder_map.pop((chat_id, message_id), None)
        self._queued_placeholder_timestamps.pop((chat_id, message_id), None)

    def _wrap_topic_work(self, key: str, work: Any) -> Any:
        conversation_id = None
        try:
            chat_id, thread_id, _scope = parse_topic_key(key)
            conversation_id = topic_key(chat_id, thread_id)
        except Exception:
            conversation_id = None

        if not conversation_id:
            return work

        async def wrapped() -> Any:
            token = set_conversation_id(conversation_id)
            try:
                return await work()
            finally:
                reset_conversation_id(token)

        return wrapped
