butler/gateway/message_handler.py
Rama main · réplica de lectura del repositorio original.
"""Butler Gateway Message Handler.
Processes incoming messages from any platform (WeChat, Telegram, etc.)
through Butler's orchestration layer. Provides the bridge between
external gateways and Butler's AgentLoop.
Information flow:
User -> Platform Adapter -> Butler Handler -> AgentLoop -> Report Pipeline -> User
R1-6 (audit ``docs/reviews/project-deep-audit-2026-06-r1to8.md`` §R1-6):
The two god methods ``handle_message`` and ``_handle_message_locked``
have been decomposed into composable phase functions living in
:mod:`butler.gateway.message_pipelines` (pre-session phases) and
:mod:`butler.gateway.locked_phases` (in-session phases). This module
keeps the class definition, the per-session AgentLoop bookkeeping,
and a thin orchestrator that wires the phases together.
"""
from __future__ import annotations
import logging
import time as _time
from typing import Any, Optional, cast
from butler.core.agent_loop.loop import AgentLoop
from butler.core.loop_types import LoopResult
from butler.gateway.message_handler_ops import (
interrupt_session_loop_safe,
run_prequeue_interrupt_safe,
)
from butler.gateway.message_pipelines import (
_phase_resolve_session_key,
_phase_transform_inbound_text,
)
from butler.orchestrator import ButlerOrchestrator
from butler.gateway.delegate_push_dedup import gateway_inbound_guard
from butler.gateway.handler_commands import (
format_health_summary,
format_loop_response,
handle_slash_command,
)
from butler.gateway.inbound_drain import drain_queued_inbound
from butler.gateway.inbound_pipeline import build_default_inbound_pipeline
from butler.gateway.locked_turn_orchestrator import run_locked_message_turn
from butler.resilience.message_queue import message_queue_enabled
from butler.gateway.session_loop_factory import create_gateway_loop
from butler.gateway.turn_post_pipeline import run_turn_post_inbound_pipeline
from butler.utilities.env_parse import env_truthy
from butler.session.keys import chat_id_from_session_key, normalize_session_key
from butler.session.lifecycle import trigger_session_end
from butler.gateway.session_registry import GatewaySessionRegistry
logger = logging.getLogger(__name__)
class ButlerMessageHandler:
"""Handles messages from any platform through Butler's pipeline.
The handler maintains per-session AgentLoop instances and routes
messages through Butler's orchestration layer. After the R1-6
split, the two big methods (``handle_message`` and
``_handle_message_locked``) are thin orchestrators that wire
the phase functions in :mod:`message_pipelines` and
:mod:`locked_phases` together.
"""
def __init__(
self,
channel: str = "gateway",
orchestrator: Optional[ButlerOrchestrator] = None,
):
self.channel = channel
self._orchestrator = orchestrator or ButlerOrchestrator(user_id="owner", channel=channel)
self._session_registry = GatewaySessionRegistry(
self._create_loop_for_session,
finalize=self._finalize_session,
on_session_removed=_on_gateway_session_removed,
max_sessions=_env_int("BUTLER_GATEWAY_MAX_SESSIONS", 128),
idle_ttl_seconds=_env_float("BUTLER_GATEWAY_SESSION_IDLE_TTL_SECONDS", 7200),
)
self._sessions: dict[str, AgentLoop] = self._session_registry.sessions
self._health_by_session: dict[str, dict[str, Any]] = self._session_registry.health_by_session
self._inbound_pipeline = build_default_inbound_pipeline()
def _create_loop_for_session(self, session_key: str) -> AgentLoop:
return create_gateway_loop(self, session_key)
def _finalize_session(self, loop: AgentLoop) -> None:
trigger_session_end(self._orchestrator, loop, reason="finalize")
def _get_or_create_loop(self, session_key: str) -> AgentLoop:
self._session_registry.evict_idle()
return self._session_registry.get_or_create(session_key)
def resolve_session_key(
self,
*,
platform: str = "unknown",
external_id: str | None = None,
session_key: str | None = None,
) -> str:
"""Resolve ``platform:chat_id:project`` from external id and per-chat project."""
pm = self._orchestrator.project_manager
cid = str(external_id or "").strip()
if not cid and session_key:
cid = chat_id_from_session_key(session_key)
project = pm.get_project_name_for_chat(platform=platform, chat_id=cid or "default")
return cast(
str,
normalize_session_key(
platform=platform,
external_id=external_id,
session_key=session_key,
project=project,
),
)
def _should_queue_inbound(self, session_key: str, text: str) -> bool:
if not message_queue_enabled():
return False
stripped = (text or "").strip()
if not stripped or stripped.startswith("/"):
return False
return cast(bool, self._session_registry.is_session_active(session_key))
def _queue_push_via_bridge(self) -> bool:
from butler.defaults.env_defaults import GATEWAY_QUEUE_PUSH_VIA_BRIDGE_DEFAULT
return cast(bool, env_truthy("BUTLER_GATEWAY_QUEUE_PUSH_VIA_BRIDGE", default=GATEWAY_QUEUE_PUSH_VIA_BRIDGE_DEFAULT))
def _interrupt_session_loop(self, session_key: str) -> None:
interrupt_session_loop_safe(self._sessions, session_key)
def _format_prequeue_interrupt_reply(self, session_key: str) -> str:
"""Sprint 16 TST-10-5 第八批: 抽自 handle_message pre-dispatch hook (原 line 422-424).
中断 in-flight session loop, 返 "已请求停止" 提示. 防御性 try/except 包裹 interrupt 调用,
即使 interrupt 抛异常也返消息 (logger.debug), 不应阻断 caller.
"""
run_prequeue_interrupt_safe(
lambda: self._interrupt_session_loop(session_key),
log_debug=logger.debug,
)
return "已请求停止当前会话任务(含进行中的委派)。"
def _drain_queued_inbound(
self,
session_key: str,
*,
platform: str,
external_id: str | None,
primary_reply: str = "",
) -> str:
return cast(
str,
drain_queued_inbound(
self,
session_key,
platform=platform,
external_id=external_id,
primary_reply=primary_reply,
),
)
def _recover_registry_if_stale(self) -> None:
"""Clear a stuck ``reset_all`` flag that would block ``enter_session`` forever."""
reg = self._session_registry
with reg._lock:
if (
reg._resetting_all
and not reg._active_sessions
and reg._pending_session_entries == 0
):
logger.warning("Recovering stale gateway reset_all flag")
reg._resetting_all = False
reg._reset_condition.notify_all()
def handle_message(
self,
text: str,
*,
session_key: str | None = None,
platform: str = "unknown",
external_id: str | None = None,
) -> str:
"""Process an incoming message and return the response.
R1-6: this method is now a thin orchestrator that wires the
pre-session phase functions from
:mod:`butler.gateway.message_pipelines` together. The body is
reduced from ~334 non-blank lines to a linear flow of phase
calls; each phase encapsulates one inbound guard, transform,
or admission step.
"""
_t0 = _time.monotonic()
self._recover_registry_if_stale()
logger.info(
"Gateway handle_message start platform=%s external_id=%s preview=%r",
platform,
external_id,
(text or "")[:80],
)
session_key = _phase_resolve_session_key(
self, platform=platform, external_id=external_id, session_key=session_key,
)
text = _phase_transform_inbound_text(
text, platform=platform, external_id=external_id, session_key=session_key,
)
if not text.strip():
return ""
chat_id = str(external_id or "").strip()
with gateway_inbound_guard(chat_id):
return cast(
str,
run_turn_post_inbound_pipeline(
self,
text,
session_key=session_key,
platform=platform,
external_id=external_id,
t0=_t0,
),
)
def _handle_message_after_pipeline(
self,
text: str,
*,
session_key: str | None,
platform: str,
external_id: str | None,
_t0: float,
) -> str:
"""Backward-compatible alias; prefer :func:`turn_post_pipeline.run_turn_post_inbound_pipeline`."""
return cast(
str,
run_turn_post_inbound_pipeline(
self,
text,
session_key=session_key,
platform=platform,
external_id=external_id,
t0=_t0,
),
)
def _handle_message_locked(
self,
text: str,
*,
session_key: str = "default",
platform: str = "unknown",
external_id: str | None = None,
) -> str:
"""In-session pipeline orchestrator (ENG-3 → locked_turn_orchestrator)."""
return cast(
str,
run_locked_message_turn(
self,
text,
session_key=session_key,
platform=platform,
external_id=external_id,
),
)
def last_health_summary(self, session_key: str = "default") -> dict[str, Any]:
"""Return the latest best-effort runtime diagnostics for a session."""
return cast(dict[str, Any], self._session_registry.get_health(session_key))
def _handle_command(
self,
text: str,
*,
session_key: str = "default",
platform: str = "unknown",
external_id: str | None = None,
) -> Optional[str]:
return cast(
str | None,
handle_slash_command(
self,
text,
session_key=session_key,
platform=platform,
external_id=external_id,
),
)
def _format_health_summary(self, session_key: str = "default") -> str:
return cast(str, format_health_summary(self, session_key))
def _format_response(self, result: LoopResult, platform: str) -> str:
return cast(str, format_loop_response(result, platform))
from butler.gateway.handler_helpers import ( # noqa: F401, E402
_normalize_switch_request,
_normalize_status_request,
_normalize_new_session_request,
_normalize_detail_request,
_normalize_memo_request,
_normalize_contacts_request,
_normalize_expense_request,
_normalize_habits_request,
_gateway_run_callbacks,
_is_prequeue_interrupt_command,
_env_int,
_env_float,
_is_sessionless_command,
apply_auto_continue_rewrite,
_tool_audit_summary,
_reset_tool_audit_events,
_maybe_welcome_prefix,
_build_project_overview,
_inject_previous_session_summary,
_on_gateway_session_removed,
_WELCOMED_SESSIONS,
_WELCOME_TEXT,
)