diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index 80ae1133..db1be789 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -997,6 +997,7 @@ async def run( channel: str | None = None, model: str | None = None, internal: bool = False, + images: list[dict[str, Any]] | None = None, ) -> str: """Run the agent for a user message and return the final text response. @@ -1004,12 +1005,28 @@ async def run( internal: If True, the user_message is a system-generated trigger (e.g., background task completion) and won't be stored in DB or shown in the UI. + images: Optional list of image dicts with keys ``type``, + ``media_type``, and ``data`` (base64-encoded). """ + # If the session is still running (e.g. /stop cleanup in progress), + # wait briefly instead of failing immediately. if self.sessions.is_running(session_id): - raise RuntimeError(f"Session {session_id} is already running") + for _ in range(10): + await asyncio.sleep(0.3) + if not self.sessions.is_running(session_id): + break + else: + raise RuntimeError(f"Session {session_id} is already running") broadcaster.start_buffering(session_id) async with self._semaphore: + # Clear any stale deferred-stop flag left over from a *previous* + # turn. If /stop arrived while the old turn was still cleaning up + # (mark_not_running hadn't run yet), the flag lingers and would + # immediately kill this brand-new turn. Flags set *during* this + # turn's client init are unaffected — they're created after + # mark_running below. + self.sessions.pop_stop_request(session_id) self.sessions.mark_running(session_id) # Notify all connected clients that this session started running await broadcaster.broadcast("__global__", { @@ -1020,7 +1037,7 @@ async def run( try: return await self._run_inner( session_id, user_message, source, channel, model, - internal=internal, + internal=internal, images=images, ) finally: self.sessions.mark_not_running(session_id) @@ -1040,6 +1057,7 @@ async def _run_inner( channel: str | None, model: str | None, internal: bool = False, + images: list[dict[str, Any]] | None = None, ) -> str: # Ensure session exists in DB await self.sessions.get_or_create(session_id, source=source) @@ -1066,9 +1084,13 @@ async def _run_inner( self._generate_session_title(session_id, user_message), ) - # Store user message in DB + # Store user message in DB (note attached images for display) + db_text = user_message + if images: + suffix = f"\n[{len(images)} image(s) attached]" + db_text = (user_message + suffix) if user_message else suffix.strip() await self.sessions.add_message( - session_id, "user", user_message, channel=channel, + session_id, "user", db_text, channel=channel, ) full_response_text = "" @@ -1096,10 +1118,48 @@ async def _run_inner( session_id, source, model, fork_from=fork_from, ) + # Check for deferred /stop that arrived while we were setting up + if self.sessions.pop_stop_request(session_id): + logger.info("Stop requested before agent turn — aborting session %s", session_id) + return "" + # Send message — the client preserves conversation history internally - await client.query(user_message) + if images: + # Build multi-modal content blocks (text + images) + content_blocks: list[dict[str, Any]] = [] + if user_message: + content_blocks.append({"type": "text", "text": user_message}) + for img in images: + content_blocks.append({ + "type": "image", + "source": { + "type": img["type"], + "media_type": img["media_type"], + "data": img["data"], + }, + }) + + async def _image_prompt(): + yield { + "type": "user", + "message": {"role": "user", "content": content_blocks}, + "parent_tool_use_id": None, + } + + await client.query(_image_prompt()) + else: + await client.query(user_message) async for message in client.receive_response(): + # Early-capture sdk_session_id from first message that + # carries it so it survives /stop cancellation (ResultMessage + # — the normal source — never arrives when the turn is + # interrupted). + if not sdk_session_id: + msg_sid = getattr(message, "session_id", None) + if msg_sid: + sdk_session_id = msg_sid + if isinstance(message, AssistantMessage): # Extract parent_tool_use_id — set when this message # comes from a sub-agent (Task) rather than the main agent @@ -1195,26 +1255,41 @@ async def _run_inner( if full_response_text else "[Stopped by user]" ) - # Merge available tool results - self._merge_tool_results(tool_calls_log, tool_results_map) - await self.sessions.add_message( - session_id, "assistant", partial, - channel=channel, - thinking=thinking_text if thinking_text else None, - tool_calls=tool_calls_log if tool_calls_log else None, - blocks=ordered_blocks if ordered_blocks else None, - ) - await broadcaster.broadcast(session_id, { - "type": "stopped", "session_id": session_id, - }) - # Memorize before discarding client - await self._memorize_session(session_id) - # Keep sdk_session_id for resume — stop is user-initiated + + # --- Critical cleanup first (must succeed for resume) ---------- + # Persist sdk_session_id so the session can be resumed later. + # For new sessions the DB still has NULL because mark_active() + # was called before the SDK emitted any messages. + if sdk_session_id: + await self.db.update_session_fields( + session_id, {"sdk_session_id": sdk_session_id}, + ) await self.sessions.mark_stopped(session_id) unregister_handler(session_id) client = self.sessions.remove_client(session_id) if client: await self._safe_disconnect(client) + + # --- Non-critical: save message, broadcast, memorize ----------- + try: + self._merge_tool_results(tool_calls_log, tool_results_map) + await self.sessions.add_message( + session_id, "assistant", partial, + channel=channel, + thinking=thinking_text if thinking_text else None, + tool_calls=tool_calls_log if tool_calls_log else None, + blocks=ordered_blocks if ordered_blocks else None, + ) + await broadcaster.broadcast(session_id, { + "type": "stopped", "session_id": session_id, + }) + except Exception as cleanup_err: + logger.warning( + "Non-critical stop cleanup failed for %s: %s", + session_id, cleanup_err, + ) + # Memorize in background — don't block the stop path + asyncio.create_task(self._memorize_session(session_id)) return partial except Exception as e: diff --git a/nerve/agent/sessions.py b/nerve/agent/sessions.py index 72b694de..85b7a02b 100644 --- a/nerve/agent/sessions.py +++ b/nerve/agent/sessions.py @@ -60,6 +60,9 @@ def __init__(self, db: Database, sticky_period_minutes: int = 120): # Running task tracking self._running_tasks: dict[str, asyncio.Task] = {} self._running_sessions: set[str] = set() + # Stop-requested flag: set when /stop arrives before the SDK client + # is registered. _run_inner() checks this after creating the client. + self._stop_requested: set[str] = set() # Serialize status transitions self._transition_lock = asyncio.Lock() # Callback for memorizing session before close/archive/delete. @@ -323,33 +326,69 @@ def get_running_ids(self) -> set[str]: """Return the set of currently running session IDs.""" return set(self._running_sessions) + def request_stop(self, session_id: str) -> None: + """Set a deferred stop flag (checked by _run_inner after client init).""" + self._stop_requested.add(session_id) + + def pop_stop_request(self, session_id: str) -> bool: + """Return True and clear if a stop was requested for *session_id*.""" + try: + self._stop_requested.remove(session_id) + return True + except KeyError: + return False + async def stop_session(self, session_id: str) -> bool: - """Stop a running session. + """Stop the current turn of a running session. - Uses SDK client.interrupt() first for clean stop, falls back to - asyncio task cancellation. Returns True if something was stopped. + Sends SDK interrupt first — this gracefully ends the current turn + while keeping the client alive for the next message. Falls back + to asyncio task cancellation (which disconnects the client) only + if the interrupt doesn't complete within a timeout. """ - # Try SDK interrupt first (cleanly stops the current turn) client = self._clients.get(session_id) + interrupted = False if client: try: await client.interrupt() + interrupted = True logger.info("Interrupted SDK client for session %s", session_id) - return True except Exception as e: logger.warning( - "SDK interrupt failed for %s: %s, falling back to cancel", + "SDK interrupt failed for %s: %s", session_id, e, ) - # Fallback: cancel the asyncio task task = self._running_tasks.get(session_id) if task and not task.done(): + if interrupted: + # Give interrupt time to complete the turn gracefully. + # When it works, receive_response() yields a ResultMessage, + # _run_inner exits via the normal path, and the client + # stays alive for the next message. + done, _ = await asyncio.wait({task}, timeout=5.0) + if done: + logger.info( + "Session %s stopped gracefully via interrupt", + session_id, + ) + return True + + # Interrupt failed or timed out — force cancel. + # The CancelledError handler in _run_inner disconnects the + # client since its state is inconsistent. task.cancel() - logger.info("Cancelled task for session %s", session_id) + logger.info("Cancelled task for session %s (interrupt timed out)", session_id) + return True + + # Session is running but client/task not registered yet — set a + # deferred stop flag that _run_inner() will check after client init. + if self.is_running(session_id): + self.request_stop(session_id) + logger.info("Deferred stop requested for session %s", session_id) return True - return False + return client is not None # interrupt was sent even if no task # ------------------------------------------------------------------ # # Fork & resume # diff --git a/nerve/agent/tools.py b/nerve/agent/tools.py index fb088035..222e19b5 100644 --- a/nerve/agent/tools.py +++ b/nerve/agent/tools.py @@ -1623,6 +1623,24 @@ async def _ask_user_impl(args: dict, session_id: str) -> dict: return {"content": [{"type": "text", "text": f"Failed to ask question: {e}"}]} +async def _react_impl(args: dict, session_id: str) -> dict: + """Core implementation for the react tool.""" + if not _engine: + return {"content": [{"type": "text", "text": "Engine not available."}]} + + emoji = args["emoji"] + + try: + success = await _engine.router.set_reaction(session_id, emoji) + if success: + return {"content": [{"type": "text", "text": f"Reaction set: {emoji}"}]} + else: + return {"content": [{"type": "text", "text": "Cannot set reaction: no message context or channel does not support reactions."}]} + except Exception as e: + logger.error("react tool failed: %s", e) + return {"content": [{"type": "text", "text": f"Failed to set reaction: {e}"}]} + + _nerve_asgi_app = None # Cached mini FastAPI app for in-process API calls @@ -1739,6 +1757,20 @@ async def notify(args: dict) -> dict: return await _notify_impl(args, _current_session_id) +@tool( + "react", + "Set an emoji reaction on the user's last message. " + "Use to acknowledge messages, express emotions, or respond non-verbally. " + "Works on channels that support reactions (e.g., Telegram).", + { + "emoji": {"type": "string", "description": "Emoji to react with (e.g., '👍', '❤', '🔥', '😂')"}, + }, +) +async def react_tool(args: dict) -> dict: + """Set a reaction (fallback — uses deprecated global).""" + return await _react_impl(args, _current_session_id) + + @tool( "ask_user", "Ask the user a question via async notification. " @@ -1795,11 +1827,15 @@ def create_nerve_mcp_server(): "priority": {"type": "string", "description": "Priority: 'low', 'normal', 'high', 'urgent'. Default: 'normal'", "default": "normal"}, } +_REACT_SCHEMA = { + "emoji": {"type": "string", "description": "Emoji to react with (e.g., '👍', '❤', '🔥', '😂')"}, +} + def create_session_mcp_server(session_id: str): - """Create an MCP server with session_id bound for notification tools. + """Create an MCP server with session_id bound for session-scoped tools. - Each session gets its own MCP server instance so notify/ask_user + Each session gets its own MCP server instance so notify/ask_user/react tools always reference the correct session — no shared global needed. """ @@ -1825,8 +1861,19 @@ async def session_ask_user(args: dict) -> dict: # session_id captured from enclosing scope — race-free return await _ask_user_impl(args, session_id) + @tool( + "react", + "Set an emoji reaction on the user's last message. " + "Use to acknowledge messages, express emotions, or respond non-verbally. " + "Works on channels that support reactions (e.g., Telegram).", + _REACT_SCHEMA, + ) + async def session_react(args: dict) -> dict: + # session_id captured from enclosing scope — race-free + return await _react_impl(args, session_id) + # Shared tools (don't need session context) + session-scoped tools - shared_tools = [t for t in ALL_TOOLS if t.name not in ("notify", "ask_user")] - all_tools = shared_tools + [session_notify, session_ask_user] + shared_tools = [t for t in ALL_TOOLS if t.name not in ("notify", "ask_user", "react")] + all_tools = shared_tools + [session_notify, session_ask_user, session_react] return create_sdk_mcp_server(name="nerve", version="1.0.0", tools=all_tools) diff --git a/nerve/channels/base.py b/nerve/channels/base.py index 29fef458..3960e851 100644 --- a/nerve/channels/base.py +++ b/nerve/channels/base.py @@ -27,6 +27,7 @@ class ChannelCapability(Flag): MARKDOWN = auto() # Renders markdown natively INTERACTIVE = auto() # Can route interactive tool responses back (AskUserQuestion, etc.) TYPING_INDICATOR = auto() # Can show "typing…" status + REACTIONS = auto() # Can set emoji reactions on messages @dataclass(frozen=True) @@ -141,6 +142,17 @@ async def send_typing(self, target: str) -> None: Only called if channel declares TYPING_INDICATOR capability. """ + # ------------------------------------------------------------------ # + # Optional: reactions support # + # Only called if channel declares ChannelCapability.REACTIONS. # + # ------------------------------------------------------------------ # + + async def set_reaction(self, target: str, message_id: int, emoji: str) -> None: + """Set an emoji reaction on a message. + + Only called if channel declares REACTIONS capability. + """ + # ------------------------------------------------------------------ # # Optional: interactive tool support # # Only called if channel declares ChannelCapability.INTERACTIVE. # diff --git a/nerve/channels/router.py b/nerve/channels/router.py index e66c5fc4..0487ac20 100644 --- a/nerve/channels/router.py +++ b/nerve/channels/router.py @@ -48,6 +48,9 @@ def __init__(self, engine: AgentEngine): self._channels: dict[str, BaseChannel] = {} # Active stream adapters: (channel_name, target) -> StreamAdapter self._adapters: dict[tuple[str, str], StreamAdapter] = {} + # Per-session inbound message context (for reaction support) + # Maps session_id -> {channel_name, target, message_id} + self._message_context: dict[str, dict[str, Any]] = {} # ------------------------------------------------------------------ # # Channel registry # @@ -102,6 +105,15 @@ async def handle_message(self, msg: InboundMessage) -> str: msg.channel_key, source=msg.channel_name, ) + # Store message context for reaction support + msg_id = msg.metadata.get("message_id") if msg.metadata else None + if msg_id is not None: + self._message_context[session_id] = { + "channel_name": msg.channel_name, + "target": msg.sender_id, + "message_id": msg_id, + } + # Show typing indicator if supported if ChannelCapability.TYPING_INDICATOR in channel.capabilities: try: @@ -114,19 +126,59 @@ async def handle_message(self, msg: InboundMessage) -> str: channel, msg.sender_id, session_id, ) - try: - response = await self.engine.run( + # Extract images from metadata (e.g. Telegram photos) + images = msg.metadata.get("images") if msg.metadata else None + + # Wrap in a Task so stop_session() can cancel it (otherwise + # channels that ``await engine.run()`` directly — like Telegram — + # have no cancellable task and /stop only sends an SDK interrupt + # which may hang indefinitely). + task = asyncio.create_task( + self.engine.run( session_id=session_id, user_message=msg.text, source=msg.channel_name, channel=msg.channel_name, + images=images, ) + ) + self.engine.register_task(session_id, task) + try: + response = await task return response + except asyncio.CancelledError: + # /stop cancelled the task — _run_inner already handled + # cleanup (persisted sdk_session_id, marked stopped, etc.). + # Return whatever partial response was captured. + if task.done() and not task.cancelled(): + return task.result() + return "" finally: await self._teardown_streaming( channel.name, msg.sender_id, session_id, ) + # ------------------------------------------------------------------ # + # Reactions # + # ------------------------------------------------------------------ # + + async def set_reaction(self, session_id: str, emoji: str) -> bool: + """Set a reaction on the last inbound message for a session. + + Returns True if the reaction was set, False if no context or + the channel does not support reactions. + """ + ctx = self._message_context.get(session_id) + if not ctx: + return False + + channel = self._channels.get(ctx["channel_name"]) + if not channel or ChannelCapability.REACTIONS not in channel.capabilities: + return False + + await channel.set_reaction(ctx["target"], ctx["message_id"], emoji) + return True + # ------------------------------------------------------------------ # # Interactive tool response routing # # ------------------------------------------------------------------ # diff --git a/nerve/channels/stream_adapter.py b/nerve/channels/stream_adapter.py index 311723f8..cc8b2b77 100644 --- a/nerve/channels/stream_adapter.py +++ b/nerve/channels/stream_adapter.py @@ -44,6 +44,12 @@ def __init__( self._last_edit: float = 0.0 self._edit_lock = asyncio.Lock() + # Tool label grouping (collapse consecutive same-tool calls) + self._last_tool_name: str | None = None + self._tool_run_count: int = 0 + self._tool_label_start: int = 0 + self._tool_label_prefix: str = "\n\n" + # Precompute capability checks self._supports_streaming = ( ChannelCapability.STREAMING in channel.capabilities @@ -72,7 +78,17 @@ async def on_event(self, session_id: str, message: dict[str, Any]) -> None: elif msg_type == "tool_use": tool_name = message.get("tool", "unknown") if self._supports_streaming and self._supports_edit: - self._buffer += f"\n`[tool: {tool_name}]`\n" + if tool_name == self._last_tool_name: + self._tool_run_count += 1 + self._buffer = self._buffer[:self._tool_label_start] + self._buffer += f"{self._tool_label_prefix}`[{tool_name}] x{self._tool_run_count}`\n" + else: + after_tool = self._last_tool_name is not None + self._last_tool_name = tool_name + self._tool_run_count = 1 + self._tool_label_start = len(self._buffer) + self._tool_label_prefix = "" if after_tool else "\n\n" + self._buffer += f"{self._tool_label_prefix}`[{tool_name}]`\n" elif msg_type == "done": await self._handle_done() @@ -92,6 +108,10 @@ async def on_event(self, session_id: str, message: dict[str, Any]) -> None: # ------------------------------------------------------------------ # async def _handle_token(self, content: str) -> None: + if self._last_tool_name is not None: + # Transitioning from tool block to text — add blank line separator + self._buffer += "\n" + self._last_tool_name = None # Reset tool grouping self._buffer += content if not self._supports_streaming or not self._supports_edit: diff --git a/nerve/channels/telegram.py b/nerve/channels/telegram.py index ca327ab3..3a0aa5b5 100644 --- a/nerve/channels/telegram.py +++ b/nerve/channels/telegram.py @@ -7,12 +7,19 @@ from __future__ import annotations +import asyncio +import base64 +import collections +import html as _html import logging +import re +import socket +import time from typing import Any, TYPE_CHECKING from telegram import Update from telegram.constants import ChatAction, ParseMode -from telegram.ext import Application, CallbackQueryHandler, CommandHandler, MessageHandler, filters +from telegram.ext import Application, CallbackQueryHandler, CommandHandler, MessageHandler, MessageReactionHandler, filters from nerve.channels.base import ( BaseChannel, @@ -32,6 +39,112 @@ MAX_MSG_LEN = 4096 # Minimum interval between message edits (seconds) to avoid rate limits EDIT_INTERVAL = 1.5 +# Watchdog: check every 30s, log heartbeat every ~5 min +WATCHDOG_INTERVAL = 30 +WATCHDOG_HEARTBEAT_EVERY = 10 + +# TCP keepalive: prevent NAT/firewall from silently dropping the +# long-poll connection. These values tell the OS to send a keepalive +# probe after 60s idle, retry every 10s, give up after 3 failures. +_TCP_KEEPALIVE_OPTS = ( + (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), + (socket.SOL_TCP, socket.TCP_KEEPIDLE, 60), + (socket.SOL_TCP, socket.TCP_KEEPINTVL, 10), + (socket.SOL_TCP, socket.TCP_KEEPCNT, 3), +) + + +def _md_to_tg_html(text: str) -> str: + """Convert standard Markdown to Telegram-compatible HTML. + + Telegram's legacy ``ParseMode.MARKDOWN`` only supports ``*bold*``, + but LLMs emit ``**bold**`` (standard Markdown). This converts the + common constructs to HTML so we can use ``ParseMode.HTML`` instead, + which is more predictable and doesn't choke on special characters. + + Handles: ``**bold**``, ``*italic*``, `` `code` ``, code fences, + and ``[text](url)``. Unmatched markers pass through as-is. + """ + protected: list[str] = [] + + def _protect(replacement: str) -> str: + idx = len(protected) + protected.append(replacement) + return f"\x00{idx}\x00" + + # -- protect constructs that contain chars we'd otherwise escape -- + + # Code fences: ```lang\n...\n``` + def _fence(m: re.Match) -> str: + return _protect(f"
{_html.escape(m.group(2))}")
+ text = re.sub(r"```(\w*)\n?(.*?)```", _fence, text, flags=re.DOTALL)
+
+ # Inline code: `...`
+ def _code(m: re.Match) -> str:
+ return _protect(f"{_html.escape(m.group(1))}")
+ text = re.sub(r"`([^`]+)`", _code, text)
+
+ # Markdown links: [text](url)
+ def _link(m: re.Match) -> str:
+ label = _html.escape(m.group(1))
+ url = m.group(2)
+ return _protect(f'{label}')
+ text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", _link, text)
+
+ # -- escape remaining HTML entities --
+ text = _html.escape(text, quote=False)
+
+ # -- inline formatting --
+ text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
+ text = re.sub(r"(?\1", text)
+
+ # -- restore protected spans --
+ for i, repl in enumerate(protected):
+ text = text.replace(f"\x00{i}\x00", repl)
+
+ return text
+
+
+def _format_reply_context(message: Any) -> str:
+ """Extract reply-to context and quote from a Telegram message.
+
+ Returns a prefix string like:
+ [Reply to assistant: "original text here"]
+ [Quoted: "selected portion"]
+
+ Returns empty string if the message is not a reply.
+ """
+ reply = getattr(message, "reply_to_message", None)
+ if not reply:
+ return ""
+
+ parts: list[str] = []
+
+ # Determine sender label
+ from_user = getattr(reply, "from_user", None)
+ if from_user and getattr(from_user, "is_bot", False):
+ sender = "assistant"
+ elif from_user:
+ sender = getattr(from_user, "first_name", None) or "user"
+ else:
+ sender = "user"
+
+ # Original message text
+ original = getattr(reply, "text", None) or getattr(reply, "caption", None) or ""
+ if original:
+ display = original if len(original) <= 500 else original[:500] + "…"
+ parts.append(f'[Reply to {sender}: "{display}"]')
+ else:
+ parts.append(f"[Reply to {sender}'s message]")
+
+ # Quote (manually selected text)
+ quote = getattr(message, "quote", None)
+ if quote:
+ quote_text = getattr(quote, "text", None)
+ if quote_text:
+ parts.append(f'[Quoted: "{quote_text}"]')
+
+ return "\n".join(parts)
class TelegramChannel(BaseChannel):
@@ -47,6 +160,17 @@ def __init__(self, config: NerveConfig, router: ChannelRouter):
self._app: Application | None = None
self._allowed_users: set[int] = set(config.telegram.allowed_users)
self._notification_service = None # Set after service is created
+ self._watchdog_task: asyncio.Task | None = None
+ self._stopping = False
+ self._last_update_time: float = 0.0 # monotonic, set on any incoming update
+ # Media group (album) collection: group_id -> list of updates
+ self._media_groups: dict[str, list[Update]] = {}
+ self._media_group_tasks: dict[str, asyncio.Task] = {}
+ # Message cache for reaction context: message_id -> (chat_id, text_snippet)
+ self._message_cache: collections.OrderedDict[int, tuple[int, str]] = (
+ collections.OrderedDict()
+ )
+ self._message_cache_max = 200
def set_notification_service(self, service) -> None:
"""Wire the notification service for callback query handling."""
@@ -62,6 +186,7 @@ def capabilities(self) -> ChannelCapability:
ChannelCapability.SEND_TEXT
| ChannelCapability.MARKDOWN
| ChannelCapability.TYPING_INDICATOR
+ | ChannelCapability.REACTIONS
)
if self.config.telegram.stream_mode == "partial":
caps |= ChannelCapability.STREAMING
@@ -79,38 +204,230 @@ def constraints(self) -> ChannelConstraints:
# Lifecycle #
# ------------------------------------------------------------------ #
+ def _build_application(self) -> Application:
+ """Build a PTB Application with robust connection settings."""
+ builder = (
+ Application.builder()
+ .token(self.config.telegram.bot_token)
+ # Process updates concurrently so /stop can interrupt a running
+ # message handler instead of being queued behind it.
+ .concurrent_updates(True)
+ # TCP keepalive on the polling connection — prevents NAT/firewall
+ # from silently dropping the long-poll connection
+ .get_updates_socket_options(_TCP_KEEPALIVE_OPTS)
+ # Separate connection pool for polling vs sending, so a stuck
+ # outbound request can't starve the polling connection
+ .get_updates_connection_pool_size(1)
+ .get_updates_read_timeout(10)
+ .get_updates_connect_timeout(10)
+ .get_updates_pool_timeout(1.0)
+ )
+ app = builder.build()
+
+ # Register handlers
+ app.add_handler(CommandHandler("start", self._handle_start))
+ app.add_handler(CommandHandler("session", self._handle_session))
+ app.add_handler(CommandHandler("sessions", self._handle_sessions))
+ app.add_handler(CommandHandler("new", self._handle_new_session))
+ app.add_handler(CommandHandler("stop", self._handle_stop))
+ app.add_handler(CommandHandler("reply", self._handle_reply))
+ app.add_handler(CallbackQueryHandler(self._handle_callback_query))
+ app.add_handler(MessageHandler(
+ (filters.TEXT | filters.PHOTO) & ~filters.COMMAND,
+ self._handle_message,
+ ))
+ app.add_handler(MessageReactionHandler(self._handle_reaction))
+ app.add_error_handler(self._handle_error)
+
+ return app
+
async def start(self) -> None:
"""Start the Telegram bot."""
if not self.config.telegram.bot_token:
logger.warning("Telegram bot token not configured")
return
- self._app = (
- Application.builder()
- .token(self.config.telegram.bot_token)
- .build()
- )
-
- # Register handlers
- self._app.add_handler(CommandHandler("start", self._handle_start))
- self._app.add_handler(CommandHandler("session", self._handle_session))
- self._app.add_handler(CommandHandler("sessions", self._handle_sessions))
- self._app.add_handler(CommandHandler("new", self._handle_new_session))
- self._app.add_handler(CommandHandler("reply", self._handle_reply))
- self._app.add_handler(CallbackQueryHandler(self._handle_callback_query))
- self._app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message))
+ self._stopping = False
+ self._app = self._build_application()
await self._app.initialize()
await self._app.start()
- await self._app.updater.start_polling(drop_pending_updates=True)
- logger.info("Telegram bot started polling")
+ await self._app.updater.start_polling(
+ drop_pending_updates=True,
+ # Retry initial connection indefinitely — don't give up on
+ # transient network errors during startup
+ bootstrap_retries=-1,
+ # Explicitly request all update types — the default set excludes
+ # message_reaction, and auto-detection only works via
+ # Application.run_polling(), not Updater.start_polling().
+ allowed_updates=Update.ALL_TYPES,
+ )
+ self._last_update_time = time.monotonic()
+ logger.info("Telegram bot started polling (with TCP keepalive)")
+
+ # Launch watchdog for monitoring + recovery
+ self._watchdog_task = asyncio.create_task(
+ self._run_watchdog(), name="telegram-polling-watchdog",
+ )
async def stop(self) -> None:
+ self._stopping = True
+ if self._watchdog_task and not self._watchdog_task.done():
+ self._watchdog_task.cancel()
+ try:
+ await self._watchdog_task
+ except asyncio.CancelledError:
+ pass
if self._app:
await self._app.updater.stop()
await self._app.stop()
await self._app.shutdown()
+ # ------------------------------------------------------------------ #
+ # Watchdog — monitor both Updater and Application health #
+ # ------------------------------------------------------------------ #
+
+ async def _run_watchdog(self) -> None:
+ """Monitor Telegram bot health and log diagnostics.
+
+ Checks BOTH the Updater (fetches updates from Telegram) AND the
+ Application (processes updates from queue → handlers). Previous
+ versions only checked the Updater, missing cases where the
+ Application's update-processing task crashed silently.
+ """
+ check_count = 0
+ while not self._stopping:
+ try:
+ await asyncio.sleep(WATCHDOG_INTERVAL)
+ except asyncio.CancelledError:
+ break
+
+ if self._app is None or self._stopping:
+ break
+
+ check_count += 1
+ status = self._get_health_status()
+
+ # Periodic heartbeat
+ if check_count % WATCHDOG_HEARTBEAT_EVERY == 0:
+ logger.info(
+ "Telegram watchdog: %s (check #%d, last_update=%s, queue=%d)",
+ status["summary"], check_count,
+ status["last_update_ago"], status["queue_size"],
+ )
+
+ if not status["healthy"]:
+ logger.warning(
+ "Telegram bot unhealthy: %s (queue=%d, last_update=%s) — rebuilding",
+ status["summary"], status["queue_size"],
+ status["last_update_ago"],
+ )
+ try:
+ await self._rebuild()
+ logger.info("Telegram bot rebuilt successfully")
+ except Exception as e:
+ logger.error("Telegram bot rebuild failed: %s", e, exc_info=True)
+
+ def _get_health_status(self) -> dict:
+ """Check health of both Updater and Application."""
+ now = time.monotonic()
+ stale_sec = now - self._last_update_time if self._last_update_time > 0 else 0
+ last_ago = f"{stale_sec:.0f}s ago" if self._last_update_time > 0 else "never"
+
+ result = {
+ "healthy": True,
+ "summary": "ok",
+ "last_update_ago": last_ago,
+ "queue_size": 0,
+ }
+
+ if not self._app:
+ result.update(healthy=False, summary="no Application")
+ return result
+
+ # --- Check update queue ---
+ # If updates pile up in the queue, the Application isn't consuming them
+ try:
+ result["queue_size"] = self._app.update_queue.qsize()
+ except Exception:
+ pass
+
+ # --- Check Updater (the part that fetches from Telegram) ---
+ updater = self._app.updater
+ if not updater or not updater.running:
+ result.update(healthy=False, summary="updater not running")
+ return result
+
+ polling_task: asyncio.Task | None = getattr(
+ updater, "_Updater__polling_task", None,
+ )
+ if polling_task is not None and polling_task.done():
+ exc = None
+ try:
+ exc = polling_task.exception()
+ except (asyncio.CancelledError, asyncio.InvalidStateError):
+ pass
+ result.update(
+ healthy=False,
+ summary=f"polling task dead (exception: {exc})",
+ )
+ return result
+
+ # --- Check Application (the part that processes updates → handlers) ---
+ fetcher_task: asyncio.Task | None = getattr(
+ self._app, "_Application__update_fetcher_task", None,
+ )
+ if fetcher_task is not None and fetcher_task.done():
+ exc = None
+ try:
+ exc = fetcher_task.exception()
+ except (asyncio.CancelledError, asyncio.InvalidStateError):
+ pass
+ result.update(
+ healthy=False,
+ summary=f"update fetcher task dead (exception: {exc})",
+ )
+ return result
+
+ # --- Check for backed-up queue (Application alive but not consuming) ---
+ if result["queue_size"] > 10:
+ result.update(
+ healthy=False,
+ summary=f"update queue backed up ({result['queue_size']} pending)",
+ )
+ return result
+
+ return result
+
+ async def _rebuild(self) -> None:
+ """Tear down and rebuild the entire PTB Application."""
+ old_app = self._app
+ self._app = self._build_application()
+
+ await self._app.initialize()
+ await self._app.start()
+ await self._app.updater.start_polling(
+ drop_pending_updates=False,
+ bootstrap_retries=-1,
+ allowed_updates=Update.ALL_TYPES,
+ )
+ self._last_update_time = time.monotonic()
+
+ # Best-effort cleanup of the old app
+ if old_app:
+ try:
+ await asyncio.wait_for(old_app.updater.stop(), timeout=5)
+ except Exception:
+ pass
+ try:
+ await asyncio.wait_for(old_app.stop(), timeout=5)
+ except Exception:
+ pass
+ try:
+ await asyncio.wait_for(old_app.shutdown(), timeout=5)
+ except Exception:
+ pass
+
# ------------------------------------------------------------------ #
# Outbound: send complete message #
# ------------------------------------------------------------------ #
@@ -124,11 +441,19 @@ async def send(self, message: OutboundMessage) -> None:
# Split long messages
for i in range(0, len(text), MAX_MSG_LEN):
chunk = text[i:i + MAX_MSG_LEN]
- await self._app.bot.send_message(
- chat_id=chat_id,
- text=chunk,
- parse_mode=ParseMode.MARKDOWN,
- )
+ html_chunk = _md_to_tg_html(chunk)
+ try:
+ sent = await self._app.bot.send_message(
+ chat_id=chat_id,
+ text=html_chunk,
+ parse_mode=ParseMode.HTML,
+ )
+ except Exception:
+ sent = await self._app.bot.send_message(
+ chat_id=chat_id,
+ text=chunk,
+ )
+ self._cache_message(sent.message_id, chat_id, chunk)
def format_response(self, text: str) -> str:
"""Truncate for Telegram if needed."""
@@ -153,11 +478,30 @@ async def edit_message(self, target: str, message_id: str, text: str) -> None:
if self._app is None:
return
chat_id = int(target)
- await self._app.bot.edit_message_text(
- chat_id=chat_id,
- message_id=int(message_id),
- text=text,
- )
+ html_text = _md_to_tg_html(text)
+ try:
+ await self._app.bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=int(message_id),
+ text=html_text,
+ parse_mode=ParseMode.HTML,
+ )
+ except Exception as exc:
+ exc_str = str(exc)
+ if "message is not modified" in exc_str.lower():
+ return # Already up-to-date — don't clobber with plain text
+ logger.warning("edit_message HTML failed: %s", exc)
+ # Fallback: send without formatting if HTML parsing fails
+ try:
+ await self._app.bot.edit_message_text(
+ chat_id=chat_id,
+ message_id=int(message_id),
+ text=text,
+ )
+ except Exception:
+ pass
+ # Update cache with the latest text (streaming overwrites placeholder)
+ self._cache_message(int(message_id), int(target), text)
async def send_typing(self, target: str) -> None:
"""Show typing indicator."""
@@ -168,6 +512,35 @@ async def send_typing(self, target: str) -> None:
action=ChatAction.TYPING,
)
+ # ------------------------------------------------------------------ #
+ # Reactions: set emoji on a message #
+ # ------------------------------------------------------------------ #
+
+ async def set_reaction(self, target: str, message_id: int, emoji: str) -> None:
+ """Set an emoji reaction on a Telegram message."""
+ if self._app is None:
+ return
+ await self._app.bot.set_message_reaction(
+ chat_id=int(target),
+ message_id=message_id,
+ reaction=[emoji],
+ )
+
+ # ------------------------------------------------------------------ #
+ # Message cache (for reaction context) #
+ # ------------------------------------------------------------------ #
+
+ def _cache_message(self, message_id: int, chat_id: int, text: str) -> None:
+ """Store a message snippet in the LRU cache for reaction lookups."""
+ snippet = text[:200] if text else ""
+ if not snippet:
+ return
+ self._message_cache[message_id] = (chat_id, snippet)
+ # Move to end (most recent) and evict oldest if over limit
+ self._message_cache.move_to_end(message_id)
+ while len(self._message_cache) > self._message_cache_max:
+ self._message_cache.popitem(last=False)
+
# ------------------------------------------------------------------ #
# Auth #
# ------------------------------------------------------------------ #
@@ -184,8 +557,13 @@ def _is_authorized(self, user_id: int) -> bool:
# Command handlers — delegate to router for session management #
# ------------------------------------------------------------------ #
+ def _touch(self) -> None:
+ """Record that we received an update from Telegram."""
+ self._last_update_time = time.monotonic()
+
async def _handle_start(self, update: Update, context: Any) -> None:
"""Handle /start command."""
+ self._touch()
user_id = update.effective_user.id
if not self._is_authorized(user_id):
logger.warning("Unauthorized /start from user %d", user_id)
@@ -197,6 +575,7 @@ async def _handle_start(self, update: Update, context: Any) -> None:
async def _handle_session(self, update: Update, context: Any) -> None:
"""Handle /session