diff --git a/.env.example b/.env.example index 03f625b5..b2f3a16a 100644 --- a/.env.example +++ b/.env.example @@ -10,10 +10,3 @@ LLM_API_KEY= # HuggingFace token (for gated models like Flux.1-schnell) HF_TOKEN= - -# Telegram notifications for goal completion/failure -# Create a bot via @BotFather, then message it and check -# https://api.telegram.org/bot/getUpdates for your chat ID -TELEGRAM_BOT_TOKEN= -TELEGRAM_CHAT_ID= - diff --git a/README.md b/README.md index 59b2d5c1..088dc158 100644 --- a/README.md +++ b/README.md @@ -369,16 +369,16 @@ Pass these with `-e` when running the container: | `ENABLE_MUSIC_GEN` | No | Set to `1` to enable music generation (requires GPU). | | `ENABLE_DESKTOP` | No | Set to `1` to enable the desktop agent (GUI automation via Xfce). | | `ENABLE_GROUNDING` | No | Set to `1` to enable visual grounding in browser/desktop (requires GPU). | -| `TELEGRAM_BOT_TOKEN` | No | Telegram bot token for goal run notifications. | -| `TELEGRAM_CHAT_ID` | No | Telegram chat ID to receive notifications. | + +Telegram bot setup (both bidirectional chat and goal-run push notifications) +is configured in-app: add a Telegram integration on the Integrations tab, +then wire push notifications to it from Settings → System → Notifications. To pass multiple env vars, add `-e` for each one: ```bash docker run -d --name computron --shm-size=256m --network=host \ -e HF_TOKEN=hf_your_token_here \ - -e TELEGRAM_BOT_TOKEN=your_bot_token \ - -e TELEGRAM_CHAT_ID=your_chat_id \ -v computron_home:/home/computron \ -v computron_state:/var/lib/computron \ ghcr.io/lefoulkrod/computron_9000:latest diff --git a/channels/__init__.py b/channels/__init__.py new file mode 100644 index 00000000..cbd80484 --- /dev/null +++ b/channels/__init__.py @@ -0,0 +1,6 @@ +"""Channels — entry points that receive user input and run agent turns. + +Each subpackage (``telegram``, future ones) hosts one channel implementation. +A channel constructs an ``Agent``, builds a ``Conversation``, and drives the +turn loop via ``sdk.TurnExecutor``. +""" diff --git a/channels/telegram/__init__.py b/channels/telegram/__init__.py new file mode 100644 index 00000000..59ba0092 --- /dev/null +++ b/channels/telegram/__init__.py @@ -0,0 +1,13 @@ +"""Telegram channel — receives messages from Telegram chats and processes +them through the agent pipeline, returning results inline. +""" + +from channels.telegram._formatter import TelegramFormatter +from channels.telegram._channel import TelegramChannel +from channels.telegram._state import ConversationMap + +__all__ = [ + "ConversationMap", + "TelegramChannel", + "TelegramFormatter", +] diff --git a/channels/telegram/_channel.py b/channels/telegram/_channel.py new file mode 100644 index 00000000..0b77f09d --- /dev/null +++ b/channels/telegram/_channel.py @@ -0,0 +1,871 @@ +"""Telegram channel — talks to the telegram broker over RPC. + +The channel no longer holds the bot token or the aiogram client. It pulls +incoming messages from the broker via the ``next_updates`` long-poll verb +and sends outbound text via ``send_message``. All sender allowlist +enforcement lives in the broker; this process trusts whatever the broker +forwards. +""" + +from __future__ import annotations + +import asyncio +import logging +from contextlib import suppress +from pathlib import Path +from typing import Any + +from agents import build_agent, get_agent_profile, list_agent_profiles +from agents.types import Agent +from channels.telegram._formatter import TelegramFormatter +from channels.telegram._profile_map import ProfileMap +from channels.telegram._state import ConversationMap +from conversations import ( + ConversationCache, + DiskTurnPersistence, + list_conversations, + load_conversation_history, +) +from conversations._models import ConversationSummary +from integrations import supervisor_client +from integrations.broker_client import ( + IntegrationAuthFailed, + IntegrationError, + IntegrationNotConnected, + call as broker_call, +) +from sdk import TurnExecutor +from sdk.events import AgentEvent +from sdk.skills import AgentState +from sdk.turn import is_turn_active, request_stop +from tools.memory import forget, memory_prompt_block, remember +from tools.virtual_computer.run_bash_cmd import run_bash_cmd + +logger = logging.getLogger(__name__) + +__all__ = ["TelegramChannel"] + +# Long-poll window the channel asks the broker to hold each call for. +_LONG_POLL_TIMEOUT_MS = 30_000 + +# Exponential backoff bounds for transient broker errors. +_BACKOFF_INITIAL_SECONDS = 1.0 +_BACKOFF_CAP_SECONDS = 30.0 + +# Backoff used when the broker reports a state the user has to fix +# (auth_failed, not_connected). Polling fast doesn't help — wait longer. +_LONG_BACKOFF_SECONDS = 60.0 + +# Telegram's chat-action indicator clears after ~5 seconds. Re-send every 4 +# so the "typing…" header stays visible across a multi-minute turn. +_TYPING_INDICATOR_INTERVAL_SECONDS = 4.0 + +# callback_data prefix encoding profile picks. Keeps the payload format +# self-describing so unrelated callbacks (future verbs) don't collide. +_PROFILE_CALLBACK_PREFIX = "profile:" + +# callback_data prefix for picking a past conversation to resume. +_CONV_CALLBACK_PREFIX = "conv:" + +# How many past conversations to show in a /list reply. Keeps the inline +# keyboard within Telegram's reasonable-rendering range and the chat tidy. +_LIST_MAX_RESULTS = 10 + +# Initial status text shown while we wait for the first event from the agent. +_STATUS_THINKING = "🤔 Thinking..." +_STATUS_WRITING = "✍️ Writing response..." + + +class TelegramChannel: + """Pulls updates from the telegram broker and dispatches agent turns.""" + + def __init__(self, *, app_sock_path: Path) -> None: + self._app_sock = app_sock_path + self._state = ConversationMap() + self._profiles = ProfileMap() + self._formatter = TelegramFormatter() + self._turn_executor = TurnExecutor() + self._persistence = DiskTurnPersistence() + # Same bounded LRU the web/SSE side uses; on_evict is None because + # the channel doesn't own per-conversation Playwright contexts. + self._cache = ConversationCache(is_active=is_turn_active) + self._integration_id: str = "" + self._default_profile_id: str = "computron" + self._pull_task: asyncio.Task[None] | None = None + self._turn_tasks: set[asyncio.Task[None]] = set() + # Fire-and-forget tasks (e.g. title generation on first turn). Held + # so the GC doesn't reap them; cleared via done_callback. + self._background_tasks: set[asyncio.Task[None]] = set() + # Per-chat in-flight turn tracker. Owned by the dispatch loop so the + # "one turn at a time" check is race-free — ContextVar-based + # ``is_turn_active`` doesn't flip True until the task actually runs, + # so two messages in the same broker batch would both pass that + # check and double-spawn. + self._turn_by_chat: dict[int, asyncio.Task[None]] = {} + self._stopping = False + # Signalled by ``notify_integration_added`` so the supervise loop + # wakes the moment the user finishes the wizard. Default-low; the + # supervise loop clears it before each retry. + self._integration_added_event = asyncio.Event() + + def notify_integration_added(self, slug: str) -> None: + """Wake the supervise loop after a relevant integration is added. + + The integrations HTTP route calls this on every successful add. + Filters by slug so adding Gmail or iCloud doesn't trigger any + Telegram-side work. + """ + if slug == "telegram": + self._integration_added_event.set() + + # -- lifecycle ------------------------------------------------------ + + async def start(self) -> None: + """Launch the supervisor task; discovery happens lazily. + + Channels boot once at app start, but integrations can be added at any + time via the wizard. So discovery runs inside a background task that + polls until a Telegram integration appears, then transitions into the + pull loop. ``start()`` itself returns immediately. + """ + self._pull_task = asyncio.create_task( + self._supervise(), name="telegram-supervise", + ) + + async def _supervise(self) -> None: + """Wait for a Telegram integration to exist, probe it, then pull.""" + integration_id = await self._wait_for_integration() + if integration_id is None: + # Stopping was requested before discovery succeeded. + return + self._integration_id = integration_id + + try: + me = await broker_call( + integration_id, "get_me", {}, app_sock_path=self._app_sock, + ) + except IntegrationNotConnected: + logger.warning( + "Telegram integration %r vanished between discovery and probe; " + "channel not starting", + integration_id, + ) + return + except IntegrationAuthFailed as exc: + logger.error( + "Telegram integration %r auth failed; channel not starting: %s", + integration_id, exc, + ) + return + except IntegrationError as exc: + logger.error( + "Telegram broker probe failed for %r; channel not starting: %s", + integration_id, exc, + ) + return + + logger.info( + "telegram channel started integration_id=%s bot=@%s (id=%d)", + integration_id, me.get("username"), me.get("id"), + ) + await self._pull_loop() + + async def _wait_for_integration(self) -> str | None: + """Discover or wait for a Telegram integration. + + No polling: discovery runs once. If nothing's registered, the + supervise task parks on an event that the integrations HTTP route + sets when a Telegram integration is added. Returns the integration + id, or ``None`` if the channel was stopped while waiting. + """ + while not self._stopping: + integration_id = await self._discover_integration() + if integration_id is not None: + return integration_id + logger.info( + "No Telegram integration registered; channel waiting for one " + "to be added via the wizard", + ) + self._integration_added_event.clear() + await self._integration_added_event.wait() + return None + + async def _discover_integration(self) -> str | None: + """Pick the telegram integration the channel should bind to. + + Returns the integration_id, or None if no telegram integration is + registered. With multiple registered, the first is chosen and the + choice is logged. + """ + try: + result = await supervisor_client.call( + "list", {}, app_sock_path=self._app_sock, + ) + except (FileNotFoundError, ConnectionRefusedError, OSError) as exc: + logger.warning( + "Supervisor unreachable; Telegram channel not starting: %s", exc, + ) + return None + + telegrams = [ + i for i in result.get("integrations", []) + if i.get("slug") == "telegram" + ] + if not telegrams: + return None + if len(telegrams) > 1: + ids = sorted(i["id"] for i in telegrams) + logger.warning( + "Multiple Telegram integrations registered (%s); binding to %s. " + "Multi-integration support is a future feature.", + ids, telegrams[0]["id"], + ) + return telegrams[0]["id"] + + async def stop(self) -> None: + """Stop the pull loop and any in-flight turns.""" + self._stopping = True + # Wake the supervise loop if it's parked waiting for an integration — + # cancel alone is enough, but setting the event keeps the code path + # symmetric with the add-side wake. + self._integration_added_event.set() + if self._pull_task is not None: + self._pull_task.cancel() + with suppress(asyncio.CancelledError): + await self._pull_task + for task in list(self._turn_tasks): + task.cancel() + if self._turn_tasks: + await asyncio.gather(*self._turn_tasks, return_exceptions=True) + logger.info("telegram channel stopped") + + # -- pull loop ------------------------------------------------------ + + async def _pull_loop(self) -> None: + """Long-poll the broker for incoming messages and dispatch each.""" + backoff = _BACKOFF_INITIAL_SECONDS + while not self._stopping: + try: + result = await broker_call( + self._integration_id, + "next_updates", + {"timeout_ms": _LONG_POLL_TIMEOUT_MS}, + app_sock_path=self._app_sock, + ) + backoff = _BACKOFF_INITIAL_SECONDS + except asyncio.CancelledError: + raise + except IntegrationAuthFailed as exc: + logger.error( + "telegram broker auth failed (will retry in %.0fs): %s", + _LONG_BACKOFF_SECONDS, exc, + ) + await asyncio.sleep(_LONG_BACKOFF_SECONDS) + continue + except IntegrationNotConnected as exc: + logger.warning( + "telegram broker not connected (will retry in %.0fs): %s", + _LONG_BACKOFF_SECONDS, exc, + ) + await asyncio.sleep(_LONG_BACKOFF_SECONDS) + continue + except IntegrationError as exc: + logger.warning( + "telegram broker call failed (retrying in %.1fs): %s", + backoff, exc, + ) + await asyncio.sleep(backoff) + backoff = min(backoff * 2, _BACKOFF_CAP_SECONDS) + continue + except Exception: + # Defensive: anything else is a bug, but a crashed pull task + # silently stops the bot until app restart. Log with traceback + # and back off rather than die. + logger.exception( + "telegram pull loop: unexpected error (retrying in %.1fs)", + backoff, + ) + await asyncio.sleep(backoff) + backoff = min(backoff * 2, _BACKOFF_CAP_SECONDS) + continue + + for update in result.get("updates", []): + await self._dispatch(update) + + # -- dispatch ------------------------------------------------------- + + async def _dispatch(self, update: dict[str, Any]) -> None: + """Route a single update to the right handler.""" + kind = update.get("type") + if kind == "message": + await self._dispatch_message(update) + elif kind == "callback_query": + await self._dispatch_callback(update) + + async def _dispatch_message(self, update: dict[str, Any]) -> None: + chat_id = update["chat_id"] + text = update.get("text") + attachments = update.get("attachments") or [] + + if update.get("is_command"): + parts = text.split(maxsplit=1) + cmd = parts[0][1:] # strip leading "/" + arg = parts[1] if len(parts) > 1 else "" + if cmd == "new": + await self._handle_new(chat_id) + elif cmd == "stop": + await self._handle_stop(chat_id) + elif cmd == "profile": + await self._handle_profile(chat_id) + elif cmd == "list": + await self._handle_list(chat_id, arg) + elif cmd == "help": + await self._handle_help(chat_id) + else: + await self._send_text(chat_id, f"Unknown command: /{cmd}") + return + + # Text only, attachments only, or both — all flow through one turn. + # Build the augmented user_content here so the agent sees a single + # human-readable description rather than a flag dict. + user_content = _build_user_content(text, attachments) + if not user_content: + # Nothing actionable (would be unusual — broker already filters + # bare messages with neither text nor attachments). + return + + existing = self._turn_by_chat.get(chat_id) + if existing is not None and not existing.done(): + await self._send_text( + chat_id, "⏳ A turn is already running. Use /stop to cancel it.", + ) + return + + task = asyncio.create_task(self._run_turn(chat_id, user_content)) + self._turn_tasks.add(task) + self._turn_by_chat[chat_id] = task + + def _on_done(t: asyncio.Task[None]) -> None: + self._turn_tasks.discard(t) + # Only clear if this is still the current one — a /stop + new + # message could have already swapped a fresh task in. + if self._turn_by_chat.get(chat_id) is t: + self._turn_by_chat.pop(chat_id, None) + + task.add_done_callback(_on_done) + + async def _dispatch_callback(self, update: dict[str, Any]) -> None: + """Handle an inline-keyboard button tap.""" + callback_id = update["callback_id"] + chat_id = update["chat_id"] + data = update["data"] + + if data.startswith(_PROFILE_CALLBACK_PREFIX): + profile_id = data[len(_PROFILE_CALLBACK_PREFIX):] + await self._select_profile(chat_id, profile_id, callback_id) + return + + if data.startswith(_CONV_CALLBACK_PREFIX): + target = data[len(_CONV_CALLBACK_PREFIX):] + await self._resume_conversation(chat_id, target, callback_id) + return + + # Unknown callback — dismiss the loading spinner anyway so the + # Telegram client doesn't sit on it forever. + await self._answer_callback(callback_id) + + # -- command handlers ----------------------------------------------- + + async def _handle_new(self, chat_id: int) -> None: + conv_id = self._state.reset(chat_id) + self._cache.pop(conv_id) + await self._send_text(chat_id, f"🔄 New conversation started ({conv_id})") + logger.info("telegram /new chat_id=%s conv_id=%s", chat_id, conv_id) + + async def _handle_stop(self, chat_id: int) -> None: + conv_id = self._state.conversation_id_for(chat_id) + if conv_id and is_turn_active(conv_id): + request_stop(conv_id) + await self._send_text(chat_id, "⏹ Stop requested") + logger.info("telegram /stop chat_id=%s conv_id=%s", chat_id, conv_id) + else: + await self._send_text(chat_id, "No active turn to stop") + + async def _handle_profile(self, chat_id: int) -> None: + """Show the profile picker as an inline keyboard.""" + profiles = list_agent_profiles() + if not profiles: + await self._send_text(chat_id, "No agent profiles available.") + return + + current = self._profiles.get(chat_id) or self._default_profile_id + # One button per row keeps long profile lists readable on mobile. + buttons = [ + [{ + "text": f"{'✓ ' if p.id == current else ''}{p.name}", + "data": f"{_PROFILE_CALLBACK_PREFIX}{p.id}", + }] + for p in profiles + ] + try: + await broker_call( + self._integration_id, + "send_message", + { + "chat_id": chat_id, + "text": "Pick an agent profile for this chat:", + "buttons": buttons, + }, + app_sock_path=self._app_sock, + ) + except IntegrationError as exc: + logger.warning("telegram /profile failed chat_id=%s: %s", chat_id, exc) + + async def _handle_help(self, chat_id: int) -> None: + lines = [ + "Commands:", + " /new — start a new conversation", + " /stop — stop the current turn", + " /profile — pick an agent profile for this chat", + " /list [query] — list past conversations, optionally filtered", + " /help — this message", + ] + await self._send_text(chat_id, "\n".join(lines)) + + async def _handle_list(self, chat_id: int, query: str) -> None: + """Show past conversations as an inline keyboard. + + Lists every conversation in the store (Telegram, web, anywhere) — + the conversation store is the single source of truth and the user + shouldn't have to remember where they started. Optional ``query`` + is a case-insensitive substring match on title and first message. + """ + query = query.strip() + summaries = list_conversations() + matches = [ + s for s in summaries + if not query or _matches_query(s, query) + ][:_LIST_MAX_RESULTS] + + if not matches: + msg = ( + f"No conversations match '{query}'." + if query + else "No past conversations yet." + ) + await self._send_text(chat_id, msg) + return + + current = self._state.conversation_id_for(chat_id) + buttons = [ + [{ + "text": _row_label(s, is_current=s.conversation_id == current), + "data": f"{_CONV_CALLBACK_PREFIX}{s.conversation_id}", + }] + for s in matches + ] + header = ( + f"Conversations matching '{query}':" + if query + else "Recent conversations:" + ) + try: + await broker_call( + self._integration_id, + "send_message", + {"chat_id": chat_id, "text": header, "buttons": buttons}, + app_sock_path=self._app_sock, + ) + except IntegrationError as exc: + logger.warning("telegram /list failed chat_id=%s: %s", chat_id, exc) + + async def _resume_conversation( + self, chat_id: int, conv_id: str, callback_id: str, + ) -> None: + """Bind this chat to *conv_id* so the next message continues it. + + Callback data is whatever the bot put on the button — only valid + conversation ids are sent — so no per-chat prefix validation is + needed here. The disk check below catches a stale callback whose + conversation was deleted between list-and-tap. + """ + if load_conversation_history(conv_id) is None: + await self._answer_callback(callback_id, text="Conversation not found") + return + + self._state.set(chat_id, conv_id) + # Drop the cached in-memory Conversation so the next turn rehydrates + # the picked one's history from disk. + self._cache.pop(conv_id) + await self._answer_callback(callback_id, text="Resumed") + await self._send_text(chat_id, f"📂 Resumed conversation: {conv_id}") + logger.info( + "telegram resumed conversation chat_id=%s conv_id=%s", + chat_id, conv_id, + ) + + async def _select_profile( + self, chat_id: int, profile_id: str, callback_id: str, + ) -> None: + """Record the user's profile choice and acknowledge.""" + profile = get_agent_profile(profile_id) + if profile is None or not profile.enabled: + await self._answer_callback(callback_id, text="Profile unavailable") + return + self._profiles.set(chat_id, profile_id) + # Tiny toast on the button tap + a normal reply for the chat history. + await self._answer_callback(callback_id, text=f"Profile: {profile.name}") + await self._send_text(chat_id, f"✅ Profile set to {profile.name}") + logger.info( + "telegram profile selected chat_id=%s profile_id=%s", + chat_id, profile_id, + ) + + # -- turn execution ------------------------------------------------- + + async def _run_turn(self, chat_id: int, text: str) -> None: + """Execute one agent turn for the given chat and deliver the reply.""" + conversation_id = self._state.get(chat_id) + conversation, is_new = await self._cache.get(conversation_id) + + profile = self._resolve_profile(chat_id) + if profile is None: + await self._send_text( + chat_id, "⚠️ No agent profile available. Set one with /profile.", + ) + return + + if conversation.agent_state is None: + conversation.agent_state = await AgentState.create( + skill_names=[ + *profile.skills, + *self._persistence.load_skills(conversation.id), + ], + ) + + agent = build_agent(profile, tools=[run_bash_cmd, remember, forget]) + agent.instruction = memory_prompt_block() + agent.instruction + + logger.info( + "telegram turn start chat_id=%s conversation_id=%s is_new=%s profile=%s", + chat_id, conversation_id, is_new, profile.id, + ) + + typing_task = asyncio.create_task( + self._keep_typing(chat_id), name="telegram-typing", + ) + status = _StatusMessage(chat_id, self) + await status.start(_STATUS_THINKING) + collected_text = "" + file_paths: list[str] = [] + wrote_started = False + events: list[AgentEvent] = [] + + try: + async for event in self._turn_executor.execute( + conversation=conversation, + agent=agent, + user_content=text, + profile_name=profile.name, + ): + events.append(event) + payload = event.payload + ptype = payload.type + if ptype == "tool_call" and hasattr(payload, "name"): + await status.set(f"🔧 Calling {payload.name}...") + elif ptype == "agent_started" and hasattr(payload, "agent_name"): + await status.set(f"🚀 Spawning {payload.agent_name}...") + elif ptype == "agent_completed": + await status.set(_STATUS_THINKING) + elif ptype == "content" and hasattr(payload, "content"): + if payload.content: + collected_text += payload.content + if not wrote_started: + wrote_started = True + await status.set(_STATUS_WRITING) + elif ptype == "file_output" and hasattr(payload, "path"): + if payload.path: + file_paths.append(payload.path) + except Exception: + logger.exception( + "telegram turn failed chat_id=%s conversation_id=%s", + chat_id, conversation_id, + ) + await status.clear() + await self._send_text( + chat_id, "⚠️ An error occurred while processing your message.", + ) + return + finally: + typing_task.cancel() + with suppress(asyncio.CancelledError, Exception): + await typing_task + + await status.clear() + + if collected_text.strip(): + await self._send_text(chat_id, collected_text) + + for path in file_paths: + await self._send_document(chat_id, path) + + # Post-turn persistence. Errors logged, never raised — the user + # already got their reply. + try: + self._persistence.save_events(conversation.id, events) + except Exception: + logger.exception( + "Failed to save agent events for '%s'", conversation.id, + ) + if conversation.agent_state.loaded_skill_names: + try: + self._persistence.save_skills( + conversation.id, + conversation.agent_state.loaded_skill_names, + ) + except Exception: + logger.exception( + "Failed to save loaded skills for '%s'", conversation.id, + ) + if is_new: + task = asyncio.create_task( + self._persistence.on_new_conversation(conversation.id, text), + ) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + logger.info( + "telegram turn end chat_id=%s conversation_id=%s text_len=%d files=%d", + chat_id, conversation_id, len(collected_text), len(file_paths), + ) + + def _resolve_profile(self, chat_id: int): + """Return the AgentProfile for *chat_id* — picked, default, or None. + + The profile's own ``model`` field is the source of truth. There's no + Telegram-specific model override anymore; pick a different profile + via ``/profile`` if you want a different model on Telegram. + """ + for candidate in (self._profiles.get(chat_id), self._default_profile_id): + if not candidate: + continue + profile = get_agent_profile(candidate) + if profile is None or not profile.enabled: + continue + return profile + return None + + async def _keep_typing(self, chat_id: int) -> None: + """Re-send the typing indicator on a loop while a turn is running. + + Telegram's chat-action lasts ~5 seconds; loop slightly faster so + the indicator never gaps. Stops when the task is cancelled. + """ + try: + while True: + try: + await broker_call( + self._integration_id, + "send_chat_action", + {"chat_id": chat_id, "action": "typing"}, + app_sock_path=self._app_sock, + ) + except IntegrationError as exc: + # Transient broker hiccup — drop this beat and try again. + logger.debug( + "telegram send_chat_action failed (will retry): %s", exc, + ) + await asyncio.sleep(_TYPING_INDICATOR_INTERVAL_SECONDS) + except asyncio.CancelledError: + raise + + # -- outbound ------------------------------------------------------- + + async def _send_text(self, chat_id: int, text: str) -> None: + """Send *text* to *chat_id*, splitting if necessary. + + Agent output is CommonMark-flavored markdown. We convert to + Telegram MarkdownV2 and tag the send with ``parse_mode`` so the + formatting renders natively instead of showing as literal + asterisks and backticks. + """ + for chunk in self._formatter.to_markdownv2_chunks(text): + try: + await broker_call( + self._integration_id, + "send_message", + { + "chat_id": chat_id, + "text": chunk, + "parse_mode": "MarkdownV2", + }, + app_sock_path=self._app_sock, + ) + except IntegrationError as exc: + logger.warning( + "telegram send_message failed chat_id=%s: %s", chat_id, exc, + ) + return + + async def _send_document(self, chat_id: int, host_path: str) -> None: + """Upload *host_path* as a Telegram document. + + Reads the file via the broker so the channel never touches the bot + token. Errors are logged and the upload is skipped — the rest of + the reply still goes through. + """ + path = Path(host_path) + caption = self._formatter.file_caption(path) + try: + await broker_call( + self._integration_id, + "send_document", + { + "chat_id": chat_id, + "host_path": str(host_path), + "caption": caption, + "filename": path.name, + }, + app_sock_path=self._app_sock, + ) + except IntegrationError as exc: + logger.warning( + "telegram send_document failed chat_id=%s path=%s: %s", + chat_id, host_path, exc, + ) + + async def _answer_callback( + self, callback_id: str, *, text: str | None = None, + ) -> None: + """Dismiss the loading spinner on an inline-keyboard button tap.""" + args: dict[str, Any] = {"callback_id": callback_id} + if text is not None: + args["text"] = text + try: + await broker_call( + self._integration_id, + "answer_callback_query", + args, + app_sock_path=self._app_sock, + ) + except IntegrationError as exc: + logger.debug("telegram answer_callback_query failed: %s", exc) + + +def _matches_query(summary: ConversationSummary, query: str) -> bool: + """Case-insensitive substring match on title and first message.""" + needle = query.lower() + return ( + needle in (summary.title or "").lower() + or needle in (summary.first_message or "").lower() + ) + + +def _row_label(summary: ConversationSummary, *, is_current: bool) -> str: + """Human-readable label for a /list inline-keyboard row.""" + if summary.title: + body = summary.title + elif summary.first_message: + # Some conversations don't get a title (turn errored before + # title-generation fired). Fall back to a clipped first message. + body = f"({summary.first_message[:50]}…)" + else: + body = f"(empty • {summary.conversation_id})" + # Telegram button labels render best within ~64 chars. + body = body[:60] + prefix = "✓ " if is_current else "" + return f"{prefix}{body}" + + +def _build_user_content( + text: str | None, attachments: list[dict[str, Any]], +) -> str: + """Compose the agent-facing user message from text + attachment paths. + + Mirrors the SSE channel's ``_augment_message_with_attachments`` shape so + the agent sees consistent file references regardless of how the message + arrived (web upload vs. Telegram document). + """ + body = (text or "").strip() + if not attachments: + return body + lines: list[str] = [] + for att in attachments: + path = att.get("path") + if not path: + continue + name = att.get("file_name") or path.rsplit("/", 1)[-1] + mime = att.get("mime_type") or "application/octet-stream" + lines.append(f" - {name} ({mime}) -> {path}") + if not lines: + return body + files_block = "\n".join(lines) + if body: + return f"{body}\n\n[Attached files written to virtual computer]\n{files_block}" + return f"[Attached files written to virtual computer]\n{files_block}" + + +class _StatusMessage: + """A live status message that the channel edits in place during a turn. + + Sends one Telegram message at turn start (capturing its message_id), + edits the text on state transitions (tool calls, sub-agent spawns, + response writing), and deletes the message before the final reply. + Falls back to silent no-ops if the initial send or any edit fails so + a broker hiccup never breaks the turn itself. + """ + + def __init__(self, chat_id: int, channel: TelegramChannel) -> None: + self._chat_id = chat_id + self._channel = channel + self._message_id: int | None = None + self._current: str | None = None + + async def start(self, text: str) -> None: + """Post the initial status message and capture its message_id.""" + try: + result = await broker_call( + self._channel._integration_id, + "send_message", + {"chat_id": self._chat_id, "text": text}, + app_sock_path=self._channel._app_sock, + ) + self._message_id = result.get("message_id") + self._current = text + except IntegrationError as exc: + logger.debug("telegram status send_message failed: %s", exc) + + async def set(self, text: str) -> None: + """Edit the status to *text* if it differs from the current value.""" + if self._message_id is None or text == self._current: + return + try: + await broker_call( + self._channel._integration_id, + "edit_message_text", + { + "chat_id": self._chat_id, + "message_id": self._message_id, + "text": text, + }, + app_sock_path=self._channel._app_sock, + ) + self._current = text + except IntegrationError as exc: + logger.debug("telegram status edit_message_text failed: %s", exc) + + async def clear(self) -> None: + """Delete the status message. Safe to call multiple times.""" + if self._message_id is None: + return + message_id = self._message_id + self._message_id = None + try: + await broker_call( + self._channel._integration_id, + "delete_message", + {"chat_id": self._chat_id, "message_id": message_id}, + app_sock_path=self._channel._app_sock, + ) + except IntegrationError as exc: + logger.debug("telegram status delete_message failed: %s", exc) diff --git a/channels/telegram/_formatter.py b/channels/telegram/_formatter.py new file mode 100644 index 00000000..e5f19871 --- /dev/null +++ b/channels/telegram/_formatter.py @@ -0,0 +1,49 @@ +"""Format agent output for Telegram delivery.""" + +from __future__ import annotations + +from pathlib import Path + +import telegramify_markdown + +__all__ = ["TelegramFormatter"] + +# Telegram's per-message limit, in UTF-16 code units. +_TELEGRAM_MSG_LIMIT = 4096 + + +class TelegramFormatter: + """Prepares agent text for Telegram. + + The agent emits CommonMark-flavored markdown (``**bold**``, fenced + code, lists, links). Telegram's MarkdownV2 uses different syntax and + requires escaping of many punctuation characters. We delegate the + conversion and the chunk splitting to ``telegramify_markdown`` so the + result renders natively in the Telegram client instead of showing + literal asterisks and backticks. + """ + + @staticmethod + def to_markdownv2_chunks(text: str) -> list[str]: + """Convert ``text`` to MarkdownV2 and split to fit Telegram's limit. + + Each returned chunk must be sent with ``parse_mode="MarkdownV2"``. + + ``convert`` produces a plain text + entities pair. + ``split_markdownv2`` budgets against the **rendered** MarkdownV2 + size so chunks that grow under escaping (``.`` → ``\\.``) still + fit Telegram's 4096-code-unit limit, and won't cut a formatting + span in half. + """ + plain_text, entities = telegramify_markdown.convert(text) + return telegramify_markdown.split_markdownv2( + plain_text, entities, _TELEGRAM_MSG_LIMIT, + ) + + @staticmethod + def file_caption(path: Path, *, index: int = 0, total: int = 1) -> str: + """Build a short caption for an attached file.""" + name = path.name + if total == 1: + return f"📎 {name}" + return f"📎 {name} ({index + 1}/{total})" diff --git a/channels/telegram/_profile_map.py b/channels/telegram/_profile_map.py new file mode 100644 index 00000000..421add61 --- /dev/null +++ b/channels/telegram/_profile_map.py @@ -0,0 +1,32 @@ +"""Per-chat agent-profile selection. + +A simple ``chat_id -> profile_id`` mapping the channel consults on every +turn. Empty mapping means the channel falls back to its configured default. +Persistence is in-memory only today; a chat that hits a fresh process +restarts at the default until the user picks again. +""" + +from __future__ import annotations + +from typing import MutableMapping + +__all__ = ["ProfileMap"] + + +class ProfileMap: + """Maps Telegram chat IDs to the agent profile ID active for that chat.""" + + def __init__(self) -> None: + self._map: MutableMapping[int, str] = {} + + def get(self, chat_id: int) -> str | None: + """Return the chosen profile ID for ``chat_id`` or ``None``.""" + return self._map.get(chat_id) + + def set(self, chat_id: int, profile_id: str) -> None: + """Record the user's profile choice for this chat.""" + self._map[chat_id] = profile_id + + def clear(self, chat_id: int) -> None: + """Drop the recorded choice so this chat reverts to the default.""" + self._map.pop(chat_id, None) diff --git a/channels/telegram/_state.py b/channels/telegram/_state.py new file mode 100644 index 00000000..5291b4ce --- /dev/null +++ b/channels/telegram/_state.py @@ -0,0 +1,55 @@ +"""Lightweight mapping between Telegram chat IDs and agent conversation IDs.""" + +from __future__ import annotations + +import uuid +from typing import MutableMapping + +__all__ = ["ConversationMap"] + + +class ConversationMap: + """Maps Telegram chat IDs to agent conversation IDs. + + Default convention: ``telegram_{chat_id}``. When a user issues /new, + a fresh UUID-based suffix is appended so the agent starts a new context. + """ + + def __init__(self) -> None: + self._map: MutableMapping[int, str] = {} + + # -- lookup --------------------------------------------------------- + + def get(self, chat_id: int) -> str: + """Return the conversation ID for *chat_id*, creating a default if absent.""" + return self._map.setdefault(chat_id, f"telegram_{chat_id}") + + # -- explicit bind (used by /list to resume a past conversation) ---- + + def set(self, chat_id: int, conversation_id: str) -> None: + """Bind ``chat_id`` to an existing conversation id. + + The next ``get`` returns this value; the channel hydrates the + history from disk on the cache miss. No effect on the conversation + store itself — only on which conversation this chat resolves to. + """ + self._map[chat_id] = conversation_id + + # -- reset (used by /new) ------------------------------------------- + + def reset(self, chat_id: int) -> str: + """Assign a brand-new conversation ID for *chat_id*. + + The new ID is ``telegram_{chat_id}_{uuid4_short}`` so it's unique + but still traceable to the originating chat. + """ + short = uuid.uuid4().hex[:8] + conv_id = f"telegram_{chat_id}_{short}" + self._map[chat_id] = conv_id + return conv_id + + # -- introspection -------------------------------------------------- + + def conversation_id_for(self, chat_id: int) -> str | None: + """Return the current conversation ID without side-effects, or ``None``.""" + return self._map.get(chat_id) \ No newline at end of file diff --git a/conversations/__init__.py b/conversations/__init__.py index 737f8960..f41c36f7 100644 --- a/conversations/__init__.py +++ b/conversations/__init__.py @@ -22,12 +22,16 @@ save_sub_agent_history, save_summary_record, ) +from ._cache import ConversationCache from ._title_generation import ( generate_conversation_title, ) +from ._turn_persistence import DiskTurnPersistence __all__ = [ + "ConversationCache", "ConversationSummary", + "DiskTurnPersistence", "SummaryRecord", "delete_conversation", "generate_conversation_title", diff --git a/conversations/_cache.py b/conversations/_cache.py new file mode 100644 index 00000000..ff1d384a --- /dev/null +++ b/conversations/_cache.py @@ -0,0 +1,167 @@ +"""Bounded LRU of in-memory ``Conversation`` objects. + +Each channel/handler owns one. The on-disk conversation store is +authoritative; the cache exists so consecutive turns within the same +conversation don't re-read ``history.json`` every time, and so the +``PersistenceHook``'s in-place mutations of history land in something +the next turn reuses. + +Eviction is LRU but skips conversations whose turn is currently in +flight — dropping an in-flight ``Conversation`` would leave the running +turn writing to a referent the next caller can't find, producing two +parallel writers when the next chat hydrates a fresh copy from disk. +""" + +from __future__ import annotations + +import logging +from collections import OrderedDict +from collections.abc import Awaitable, Callable + +from sdk.context._history import ConversationHistory +from sdk.turn._conversation import Conversation + +from ._store import load_conversation_history + +logger = logging.getLogger(__name__) + +__all__ = ["ConversationCache"] + + +# Default size — modest, sized for "active conversations in a session" +# rather than "every conversation ever." Disk is authoritative on miss. +_DEFAULT_MAX_SIZE = 25 + + +class ConversationCache: + """Bounded LRU of ``Conversation`` objects with disk-hydrate on miss. + + Args: + max_size: Maximum entries before eviction kicks in. + is_active: Predicate that returns True if the named conversation + has a turn currently running. The eviction loop skips entries + for which this is True. Defaults to "never active" — safe but + allows in-flight turns to be evicted, so callers that run real + turns should always pass ``sdk.turn.is_turn_active``. + on_evict: Optional async callback invoked AFTER each eviction with + the evicted conversation id. Used for channel-specific cleanup + (e.g. releasing per-conversation browser contexts). + """ + + def __init__( + self, + *, + max_size: int = _DEFAULT_MAX_SIZE, + is_active: Callable[[str], bool] | None = None, + on_evict: Callable[[str], Awaitable[None]] | None = None, + ) -> None: + self._max_size = max_size + self._is_active = is_active or (lambda _id: False) + self._on_evict = on_evict + self._entries: OrderedDict[str, Conversation] = OrderedDict() + + async def get(self, conversation_id: str) -> tuple[Conversation, bool]: + """Return ``(conversation, is_new)`` for the given id. + + Cache hit: moves to most-recently-used, returns ``is_new=False``. + Cache miss: hydrates from disk (or creates empty if no history), + inserts, then triggers eviction of the oldest non-active entries + if we're over the cap. ``is_new=True`` only when **no on-disk + history existed** — a genuine first-time use. + """ + if not conversation_id: + msg = "conversation_id is required" + raise ValueError(msg) + if conversation_id in self._entries: + self._entries.move_to_end(conversation_id) + return self._entries[conversation_id], False + persisted = load_conversation_history(conversation_id) + is_new = persisted is None + if is_new: + logger.info("Creating new conversation %s", conversation_id) + self._entries[conversation_id] = Conversation( + id=conversation_id, + history=ConversationHistory(persisted, instance_id=conversation_id), + ) + await self._evict_others(exclude=conversation_id) + return self._entries[conversation_id], is_new + + async def resume(self, conversation_id: str) -> Conversation | None: + """Force-reload an existing conversation from disk. + + Returns ``None`` if no on-disk history is found. Otherwise replaces + any cached entry with a fresh one hydrated from disk, marks it + most-recently-used, and evicts overflow. + + Use this when the caller knows the on-disk state has changed (or + wants to read it fresh) and the cached copy must not be trusted. + """ + messages = load_conversation_history(conversation_id) + if messages is None: + return None + self._entries[conversation_id] = Conversation( + id=conversation_id, + history=ConversationHistory(messages, instance_id=conversation_id), + ) + self._entries.move_to_end(conversation_id) + await self._evict_others(exclude=conversation_id) + return self._entries[conversation_id] + + def pop(self, conversation_id: str) -> None: + """Drop the cached entry if present. + + Used after explicit resets (``/new`` in Telegram, picking a + different conversation) so the next ``get`` rehydrates fresh from + disk instead of returning a stale in-memory copy. + """ + self._entries.pop(conversation_id, None) + + def clear(self) -> None: + """Drop every entry. Used in tests for clean isolation.""" + self._entries.clear() + + def __contains__(self, conversation_id: str) -> bool: + return conversation_id in self._entries + + def __len__(self) -> int: + return len(self._entries) + + def __iter__(self): + return iter(self._entries) + + async def _evict_others(self, *, exclude: str) -> None: + """Drop the oldest non-active entries until we're at or below cap. + + Conversations whose turn is currently in flight are skipped — + popping them would leave the running turn writing to a referent + nobody else can find, and a subsequent chat for the same id would + rehydrate from disk, producing two parallel writers. + + ``exclude`` skips the conversation that triggered this eviction. + The caller hasn't yet entered turn_scope for it, so ``is_active`` + cannot recognize it as protected — without this guard the + just-inserted entry would be evicted by its own insert in the + rare case where every other cached entry is mid-turn. + """ + while len(self._entries) > self._max_size: + for cid in self._entries: + if cid == exclude: + continue + if not self._is_active(cid): + self._entries.pop(cid) + if self._on_evict is not None: + try: + await self._on_evict(cid) + except Exception: # pragma: no cover - defensive + logger.exception( + "ConversationCache on_evict failed for %s", cid, + ) + logger.info( + "Evicted LRU conversation %s from in-memory cache", cid, + ) + break + else: + # Every cached conversation is mid-turn (or is the just- + # inserted caller) — accept temporary overflow rather than + # evict an active one. The next insert will retry. + return diff --git a/conversations/_turn_persistence.py b/conversations/_turn_persistence.py new file mode 100644 index 00000000..62dedbc6 --- /dev/null +++ b/conversations/_turn_persistence.py @@ -0,0 +1,58 @@ +"""TurnPersistence adapter backed by the on-disk conversation store.""" + +from __future__ import annotations + +import logging +from collections.abc import Iterable +from typing import TYPE_CHECKING + +from ._store import ( + load_loaded_skills, + save_agent_events, + save_conversation_title, + save_loaded_skills, +) +from ._title_generation import generate_conversation_title + +if TYPE_CHECKING: + from sdk.events import AgentEvent + +logger = logging.getLogger(__name__) + +__all__ = ["DiskTurnPersistence"] + + +class DiskTurnPersistence: + """Implements the ``sdk.turn.TurnPersistence`` protocol against the on-disk store.""" + + def load_skills(self, conversation_id: str) -> Iterable[str]: + return load_loaded_skills(conversation_id) + + def save_skills(self, conversation_id: str, skills: Iterable[str]) -> None: + save_loaded_skills(conversation_id, frozenset(skills)) + + def save_events( + self, + conversation_id: str, + events: list[AgentEvent], + ) -> None: + save_agent_events(conversation_id, events) + + async def on_new_conversation( + self, + conversation_id: str, + first_message: str, + ) -> None: + try: + title = await generate_conversation_title(first_message) + save_conversation_title(conversation_id, title) + logger.info( + "Generated title for conversation %s: %r", + conversation_id, + title, + ) + except Exception: + logger.exception( + "Failed to generate title for conversation %s", + conversation_id, + ) diff --git a/integrations/broker_client/_call.py b/integrations/broker_client/_call.py index 80d4b4a6..081a4f4d 100644 --- a/integrations/broker_client/_call.py +++ b/integrations/broker_client/_call.py @@ -19,6 +19,7 @@ from __future__ import annotations import asyncio +from contextlib import suppress from pathlib import Path from typing import Any @@ -106,12 +107,20 @@ async def _rpc_one_shot( """Open a UDS, send one frame, read one frame, close. Wraps the framing helpers in ``integrations._rpc`` so the two hops above - aren't repeating the same 8 lines of connection plumbing. + aren't repeating the same 8 lines of connection plumbing. Maps every + connection/IO failure to ``IntegrationError`` so callers don't have to + catch low-level asyncio exceptions to survive a broker dying mid-call + or a socket being unlinked between integration remove/re-add cycles. """ - reader, writer = await asyncio.open_unix_connection(str(socket_path)) try: - await write_frame(writer, frame) + reader, writer = await asyncio.open_unix_connection(str(socket_path)) + except (FileNotFoundError, ConnectionRefusedError, OSError) as exc: + raise IntegrationError( + f"connect to {socket_path} failed: {exc}", + ) from exc + try: try: + await write_frame(writer, frame) return await read_frame(reader) except RpcError as exc: # A malformed response from the broker / supervisor is a protocol @@ -121,6 +130,14 @@ async def _rpc_one_shot( raise IntegrationError( f"malformed response from {socket_path}: {exc.code}: {exc.message}", ) from exc + except (asyncio.IncompleteReadError, ConnectionError, OSError) as exc: + # Broker died (or socket was unlinked) after we connected — common + # during integration remove/re-add or a broker crash + supervisor + # respawn. Let the caller's normal retry-with-backoff path handle it. + raise IntegrationError( + f"connection to {socket_path} dropped: {exc}", + ) from exc finally: writer.close() - await writer.wait_closed() + with suppress(Exception): + await writer.wait_closed() diff --git a/integrations/brokers/telegram_broker/__init__.py b/integrations/brokers/telegram_broker/__init__.py new file mode 100644 index 00000000..482df1c1 --- /dev/null +++ b/integrations/brokers/telegram_broker/__init__.py @@ -0,0 +1,8 @@ +"""Telegram broker — holds the bot token and bridges Telegram's HTTP API +to the broker RPC surface. + +The broker process owns the aiogram ``Bot`` instance, runs a long-polling +loop, enforces the per-integration allowlist of sender user IDs, and +exposes a small set of verbs (``next_updates``, ``send_message``, +``get_me``) over the standard UDS RPC framing. +""" diff --git a/integrations/brokers/telegram_broker/__main__.py b/integrations/brokers/telegram_broker/__main__.py new file mode 100644 index 00000000..2d05dcd9 --- /dev/null +++ b/integrations/brokers/telegram_broker/__main__.py @@ -0,0 +1,168 @@ +"""Telegram broker entry point: ``python -m integrations.brokers.telegram_broker``. + +The supervisor spawns this with credentials and policy in the environment, +reads ``READY\\n`` from stdout as the signal the broker is serving, and later +sends SIGTERM to shut it down. + +Exit codes: + +- 0: clean shutdown. +- 77: the bot token was rejected by Telegram. The supervisor flips the + integration to ``auth_failed`` and does not restart. +- 1: anything else (env-parse failure, network unreachable, allowlist empty, + internal error). The supervisor transitions to ``error`` and restarts on + backoff. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys +from pathlib import Path +from typing import Any + +from aiogram import Bot +from aiogram.exceptions import TelegramUnauthorizedError + +from integrations._env import env_required +from integrations._perms import PROCESS_UMASK, disable_core_dumps +from integrations._rpc import serve_rpc +from integrations.brokers._common._exit_codes import AUTH_FAIL, CLEAN_SHUTDOWN, GENERIC_ERROR +from integrations.brokers._common._ready import print_ready +from integrations.brokers.telegram_broker._updates import UpdatePump +from integrations.brokers.telegram_broker._verbs import VerbDispatcher +from integrations.permissions import permissions_from_env + +logger = logging.getLogger("telegram_broker") + +# Owner-only by default for everything this process creates. Specific call +# sites still set explicit modes (e.g. 0o660 sockets) — see integrations/_perms.py. +os.umask(PROCESS_UMASK) + +# No core dumps — the broker holds the bot token in memory, and a +# kernel-generated core file would write it to disk. +disable_core_dumps() + + +def _parse_allowed_user_ids(raw: str) -> frozenset[int]: + """Parse a comma-separated user ID list. Bad entries logged and skipped.""" + out: set[int] = set() + for part in raw.split(","): + part = part.strip() + if not part: + continue + try: + out.add(int(part)) + except ValueError: + logger.warning("TELEGRAM_ALLOWED_USER_IDS entry is not an integer: %r", part) + return frozenset(out) + + +async def _run() -> int: + integration_id = env_required("INTEGRATION_ID") + socket_path = Path(env_required("BROKER_SOCKET")) + token = env_required("TELEGRAM_BOT_TOKEN") + permissions = permissions_from_env(env_required("PERMISSIONS")) + allowed = _parse_allowed_user_ids(env_required("TELEGRAM_ALLOWED_USER_IDS")) + downloads_dir = Path(env_required("DOWNLOADS_DIR")) + + # Wipe the token from the process environ once captured. Best-effort + # hygiene: narrows in-process exposure (debuggers, traceback locals, + # crash-reporter captures). The kernel's /proc//environ snapshot is + # set at exec time and won't update; that file is mode 0400 and the + # agent runs as a different UID, so it's not a concern regardless. + os.environ.pop("TELEGRAM_BOT_TOKEN", None) + os.environ.pop("TELEGRAM_ALLOWED_USER_IDS", None) + + log = logging.getLogger(f"telegram_broker[{integration_id}]") + + if not allowed: + # Fail closed at boot. A Telegram bot has no Telegram-side ACL, so an + # empty allowlist would let anyone who discovers the bot username drive + # the agent. Refuse to start rather than silently accept everyone. + log.error("TELEGRAM_ALLOWED_USER_IDS is empty; refusing to start") + return GENERIC_ERROR + + bot = Bot(token=token) + + # Identity probe: detects an invalid/revoked token before we start polling. + try: + me = await bot.get_me() + except TelegramUnauthorizedError as exc: + log.error("Telegram rejected the bot token: %s", exc) + await bot.session.close() + return AUTH_FAIL + except OSError as exc: + # Network-level failure — supervisor retries on backoff. + log.error("could not reach Telegram: %s", exc) + await bot.session.close() + return GENERIC_ERROR + + log.info( + "authenticated as @%s (id=%d) allowed_user_ids=%s", + me.username, me.id, sorted(allowed), + ) + + pump = UpdatePump( + bot, allowed, + integration_id=integration_id, + downloads_dir=downloads_dir, + ) + pump_task = asyncio.create_task(pump.run(), name="telegram-update-pump") + + dispatcher = VerbDispatcher(bot, pump, permissions=permissions) + + async def handler(verb: str, args: dict[str, Any]) -> dict[str, Any]: + return await dispatcher.dispatch(verb, args) + + server = await serve_rpc(socket_path, handler) + log.info("listening on %s (permissions=%s)", socket_path, permissions) + + # READY sentinel: the supervisor watches stdout for this exact line and + # flips the integration from ``pending`` to ``active`` on seeing it. + print_ready() + + async with server: + try: + done, _ = await asyncio.wait( + [pump_task, asyncio.create_task(server.serve_forever())], + return_when=asyncio.FIRST_COMPLETED, + ) + except asyncio.CancelledError: + log.info("shutting down") + done = set() + + # If the pump exited cleanly with auth_failed=True, that's the only path + # that should map to a 77 exit; cancellation or server-finished is clean. + if pump.auth_failed: + await bot.session.close() + return AUTH_FAIL + + pump_task.cancel() + try: + await pump_task + except (asyncio.CancelledError, Exception): + pass + + await bot.session.close() + return CLEAN_SHUTDOWN + + +def main() -> None: + """Console entry point — configure logging, run the async body, exit with its return code.""" + logging.basicConfig( + stream=sys.stderr, + level=logging.INFO, + format="[%(name)s] %(asctime)s %(levelname)s %(message)s", + ) + try: + code = asyncio.run(_run()) + except KeyboardInterrupt: + code = CLEAN_SHUTDOWN + sys.exit(code) + + +if __name__ == "__main__": + main() diff --git a/integrations/brokers/telegram_broker/_updates.py b/integrations/brokers/telegram_broker/_updates.py new file mode 100644 index 00000000..d12faedd --- /dev/null +++ b/integrations/brokers/telegram_broker/_updates.py @@ -0,0 +1,306 @@ +"""Long-polling update pump. + +Pulls updates from Telegram in the background and pushes allowed ones onto +an internal queue. Verb handlers drain the queue via ``next_updates``. + +Drops from non-allowlisted senders are silent — replying would confirm the +bot is live and waste outbound rate limits. Drop counts are logged +periodically rather than per-message so a spam burst doesn't spam the log. + +Photo and document attachments are downloaded eagerly before the update is +enqueued — the wire shape carries a local path the channel can read. +""" + +from __future__ import annotations + +import asyncio +import logging +import re +import time +from pathlib import Path +from typing import Any + +from aiogram import Bot +from aiogram.exceptions import TelegramAPIError, TelegramUnauthorizedError + +logger = logging.getLogger(__name__) + +__all__ = ["UpdatePump", "update_to_dict"] + +# How often to surface aggregate drop counts. A spam burst at this interval +# produces at most one log line per chat ID per window. +_DROP_LOG_INTERVAL_SECONDS = 60.0 + +# Long-poll timeout in seconds — how long Telegram holds the request open +# waiting for an update before returning an empty list. 25 is comfortably +# under typical proxy/load-balancer timeouts and matches aiogram's default. +_LONG_POLL_TIMEOUT_SECONDS = 25 + +# Backoff for transient network errors. The first failure waits 1s; each +# subsequent failure doubles up to the cap. Resets on the first success. +_BACKOFF_INITIAL_SECONDS = 1.0 +_BACKOFF_CAP_SECONDS = 30.0 + +# Sanitize document filenames before writing — strip path separators and any +# character that isn't safe in a filename. Length-cap so a hostile sender +# can't blow out the directory listing. +_FILENAME_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+") +_FILENAME_MAX_LEN = 120 + + +def update_to_dict(update: Any) -> dict[str, Any] | None: + """Flatten an aiogram ``Update`` to the wire shape, or ``None`` to skip. + + Forwards text/caption ``message`` updates (including photo/document + attachments) and ``callback_query`` updates. Other update kinds are + dropped. + + Attachments are returned with metadata + a ``file_id`` placeholder; + ``UpdatePump`` downloads the bytes asynchronously before the update is + queued and populates ``path`` on each attachment. + """ + message = getattr(update, "message", None) + if message is not None: + return _message_to_dict(message) + callback = getattr(update, "callback_query", None) + if callback is not None: + return _callback_to_dict(callback) + return None + + +def _message_to_dict(message: Any) -> dict[str, Any] | None: + """Map a ``message`` update to the wire shape. + + A message qualifies for forwarding if it has text, a caption, or at + least one supported attachment. ``text`` is the user-typed content + (either ``message.text`` for plain messages or ``message.caption`` when + a photo/document was sent with explanatory text). + """ + user = message.from_user + if user is None: + return None + text = getattr(message, "text", None) or getattr(message, "caption", None) + attachments = _extract_attachment_meta(message) + if not text and not attachments: + return None + payload: dict[str, Any] = { + "type": "message", + "message_id": message.message_id, + "chat_id": message.chat.id, + "from_user_id": user.id, + "from_username": getattr(user, "username", None), + "text": text, + "is_command": bool(text and text.startswith("/")), + "timestamp": int(message.date.timestamp()) if message.date else int(time.time()), + } + if attachments: + payload["attachments"] = attachments + return payload + + +def _extract_attachment_meta(message: Any) -> list[dict[str, Any]]: + """Return a list of attachment-metadata dicts for this message. + + Photos arrive as multiple sizes; we keep the largest. Documents arrive + as a single object with its own filename and mime type. Other kinds + (voice, video, sticker, animation) are intentionally dropped today. + """ + out: list[dict[str, Any]] = [] + + photos = getattr(message, "photo", None) or [] + if photos: + # Photo sizes are returned smallest-to-largest; pick the last. + biggest = photos[-1] + out.append({ + "kind": "photo", + "file_id": biggest.file_id, + "file_unique_id": getattr(biggest, "file_unique_id", ""), + "file_size": getattr(biggest, "file_size", None), + "file_name": _photo_filename(biggest), + "mime_type": "image/jpeg", + "width": getattr(biggest, "width", None), + "height": getattr(biggest, "height", None), + }) + + document = getattr(message, "document", None) + if document is not None: + out.append({ + "kind": "document", + "file_id": document.file_id, + "file_unique_id": getattr(document, "file_unique_id", ""), + "file_size": getattr(document, "file_size", None), + "file_name": _safe_filename( + getattr(document, "file_name", None) or f"document_{document.file_id}", + ), + "mime_type": getattr(document, "mime_type", None) or "application/octet-stream", + }) + + return out + + +def _photo_filename(photo: Any) -> str: + """Telegram photos have no user-supplied name; derive a stable one.""" + unique = getattr(photo, "file_unique_id", None) or getattr(photo, "file_id", "photo") + return _safe_filename(f"photo_{unique}.jpg") + + +def _safe_filename(name: str) -> str: + """Strip path separators and unsafe characters; cap length.""" + base = Path(name).name # drop any leading path + cleaned = _FILENAME_UNSAFE.sub("_", base).strip("._") or "file" + return cleaned[:_FILENAME_MAX_LEN] + + +def _callback_to_dict(callback: Any) -> dict[str, Any] | None: + """Flatten a callback_query (inline-keyboard button tap) to the wire shape.""" + user = getattr(callback, "from_user", None) + if user is None: + return None + data = getattr(callback, "data", None) + if data is None: + return None + msg = getattr(callback, "message", None) + chat_id = msg.chat.id if (msg is not None and msg.chat is not None) else None + message_id = msg.message_id if msg is not None else None + if chat_id is None or message_id is None: + return None + return { + "type": "callback_query", + "callback_id": callback.id, + "from_user_id": user.id, + "from_username": getattr(user, "username", None), + "data": data, + "chat_id": chat_id, + "message_id": message_id, + "timestamp": int(time.time()), + } + + +class UpdatePump: + """Background long-poller. Allowed updates land on ``self.queue``.""" + + def __init__( + self, + bot: Bot, + allowed_user_ids: frozenset[int], + *, + integration_id: str, + downloads_dir: Path, + ) -> None: + self._bot = bot + self._allowed = allowed_user_ids + self._integration_id = integration_id + self._downloads_dir = downloads_dir + self.queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + + # Aggregate drop tracking — keyed by sender so a single bad actor + # contributes one log line per window even if they send thousands. + self._drop_counts: dict[int, int] = {} + self._drop_window_started_at: float = time.monotonic() + + # Set to True only after the pump observes an Unauthorized response, + # so __main__ can exit AUTH_FAIL instead of restarting in a loop. + self.auth_failed: bool = False + + async def run(self) -> None: + """Pump loop — long-poll, filter, enqueue. Returns on auth failure or cancel.""" + offset: int | None = None + backoff = _BACKOFF_INITIAL_SECONDS + while True: + try: + updates = await self._bot.get_updates( + offset=offset, + timeout=_LONG_POLL_TIMEOUT_SECONDS, + allowed_updates=["message", "callback_query"], + ) + backoff = _BACKOFF_INITIAL_SECONDS + except TelegramUnauthorizedError as exc: + logger.error("Telegram rejected the bot token: %s", exc) + self.auth_failed = True + return + except TelegramAPIError as exc: + logger.warning( + "Telegram API error in long-poll (retrying in %.1fs): %s", + backoff, exc, + ) + await asyncio.sleep(backoff) + backoff = min(backoff * 2, _BACKOFF_CAP_SECONDS) + continue + except asyncio.CancelledError: + raise + except Exception: # pragma: no cover - defensive + logger.exception( + "Unexpected error in long-poll (retrying in %.1fs)", backoff, + ) + await asyncio.sleep(backoff) + backoff = min(backoff * 2, _BACKOFF_CAP_SECONDS) + continue + + for update in updates: + offset = update.update_id + 1 + await self._handle_update(update) + + self._maybe_flush_drop_log() + + async def _handle_update(self, update: Any) -> None: + """Filter, download any attachments, and enqueue a single update.""" + payload = update_to_dict(update) + if payload is None: + return + if payload["from_user_id"] not in self._allowed: + self._drop_counts[payload["from_user_id"]] = ( + self._drop_counts.get(payload["from_user_id"], 0) + 1 + ) + return + if payload.get("attachments"): + await self._download_attachments(payload) + self.queue.put_nowait(payload) + + async def _download_attachments(self, payload: dict[str, Any]) -> None: + """Download each attachment's bytes; populate ``path`` on each. + + Failures drop the attachment from the wire shape rather than the + whole message — the agent still gets the text/caption and any + successful peers. + """ + kept: list[dict[str, Any]] = [] + for att in payload["attachments"]: + try: + local_path = await self._download_one(att) + except Exception: + logger.exception( + "telegram attachment download failed file_id=%s kind=%s", + att.get("file_id"), att.get("kind"), + ) + continue + att["path"] = str(local_path) + att.pop("file_id", None) # drop the internal handle from the wire shape + kept.append(att) + payload["attachments"] = kept + + async def _download_one(self, att: dict[str, Any]) -> Path: + """Pull the file's bytes from Telegram into the downloads dir.""" + file_id = att["file_id"] + file_name = att["file_name"] + tg_file = await self._bot.get_file(file_id) + dest = self._downloads_dir / file_name + # If the dir doesn't exist yet, create it — the supervisor's + # entrypoint normally sets perms but be defensive. + dest.parent.mkdir(parents=True, exist_ok=True) + await self._bot.download(tg_file, destination=dest) + return dest + + def _maybe_flush_drop_log(self) -> None: + """Emit aggregate drop counts once per window.""" + now = time.monotonic() + if now - self._drop_window_started_at < _DROP_LOG_INTERVAL_SECONDS: + return + if self._drop_counts: + for user_id, count in sorted(self._drop_counts.items()): + logger.warning( + "dropped %d unauthorized message(s) from user_id=%d " + "in the last %.0fs", + count, user_id, _DROP_LOG_INTERVAL_SECONDS, + ) + self._drop_counts.clear() + self._drop_window_started_at = now diff --git a/integrations/brokers/telegram_broker/_verbs.py b/integrations/brokers/telegram_broker/_verbs.py new file mode 100644 index 00000000..4a7e712c --- /dev/null +++ b/integrations/brokers/telegram_broker/_verbs.py @@ -0,0 +1,305 @@ +"""Verb dispatcher for the telegram broker. + +Bridges the RPC frame layer to aiogram's typed Bot methods. Same shape as +the email broker's dispatcher: a ``_VERB_REQUIREMENT`` table gates each +verb by ``(Capability, min Access)``, and the handler table is allowed +to omit verbs that haven't been implemented yet — those return BAD_REQUEST. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any + +from aiogram import Bot +from aiogram.exceptions import TelegramAPIError +from aiogram.types import FSInputFile, InlineKeyboardButton, InlineKeyboardMarkup + +from integrations._rpc import RpcError +from integrations.brokers.telegram_broker._updates import UpdatePump +from integrations.permissions import Access, Capability, Permissions + +# Per-verb: (capability, minimum_access_required). +_VERB_REQUIREMENT: dict[str, tuple[Capability, Access]] = { + "get_me": (Capability.TELEGRAM, Access.READ), + "next_updates": (Capability.TELEGRAM, Access.READ), + "send_message": (Capability.TELEGRAM, Access.READ_WRITE), + "send_document": (Capability.TELEGRAM, Access.READ_WRITE), + "send_chat_action": (Capability.TELEGRAM, Access.READ_WRITE), + "answer_callback_query": (Capability.TELEGRAM, Access.READ_WRITE), + "edit_message_text": (Capability.TELEGRAM, Access.READ_WRITE), + "delete_message": (Capability.TELEGRAM, Access.READ_WRITE), +} + + +_Handler = Callable[[dict[str, Any]], Awaitable[dict[str, Any]]] + + +class VerbDispatcher: + """Route one RPC verb call to the right Bot method.""" + + def __init__( + self, + bot: Bot, + pump: UpdatePump, + *, + permissions: Permissions, + ) -> None: + self._bot = bot + self._pump = pump + self._permissions = permissions + self._handlers: dict[str, _Handler] = { + "get_me": self._handle_get_me, + "next_updates": self._handle_next_updates, + "send_message": self._handle_send_message, + "send_document": self._handle_send_document, + "send_chat_action": self._handle_send_chat_action, + "answer_callback_query": self._handle_answer_callback_query, + "edit_message_text": self._handle_edit_message_text, + "delete_message": self._handle_delete_message, + } + + async def dispatch(self, verb: str, args: dict[str, Any]) -> dict[str, Any]: + """Entry point called by the RPC layer for every incoming frame.""" + requirement = _VERB_REQUIREMENT.get(verb) + if requirement is None: + msg = f"unknown verb: {verb}" + raise RpcError("BAD_REQUEST", msg) + + cap, min_access = requirement + granted = self._permissions.get(cap, Access.OFF) + if granted < min_access: + msg = ( + f"verb {verb!r} requires {cap.value}:{min_access.name.lower()}, " + f"but this integration has {cap.value}:{granted.name.lower()}" + ) + raise RpcError("PERMISSION_DENIED", msg) + + handler = self._handlers.get(verb) + if handler is None: + msg = f"verb not implemented: {verb}" + raise RpcError("BAD_REQUEST", msg) + + return await handler(args) + + # --- handlers ----------------------------------------------------------- + + async def _handle_get_me(self, _args: dict[str, Any]) -> dict[str, Any]: + """``get_me`` → ``{id, username, first_name}``.""" + try: + me = await self._bot.get_me() + except TelegramAPIError as exc: + raise RpcError("INTERNAL", str(exc)) from exc + return { + "id": me.id, + "username": me.username, + "first_name": me.first_name, + } + + async def _handle_next_updates(self, args: dict[str, Any]) -> dict[str, Any]: + """``next_updates {timeout_ms}`` → ``{updates: [...]}``. + + Long-polls the internal queue: blocks up to ``timeout_ms`` for the + first update, then drains everything currently buffered before + returning. Empty list on timeout. + """ + timeout_ms = _require_int(args, "timeout_ms", default=25_000) + if timeout_ms < 0: + raise RpcError("BAD_REQUEST", "'timeout_ms' must be >= 0") + timeout_s = timeout_ms / 1000.0 + + updates: list[dict[str, Any]] = [] + try: + first = await asyncio.wait_for(self._pump.queue.get(), timeout=timeout_s) + updates.append(first) + except asyncio.TimeoutError: + return {"updates": []} + + # Drain the rest without blocking so the caller gets a full batch. + while True: + try: + updates.append(self._pump.queue.get_nowait()) + except asyncio.QueueEmpty: + break + return {"updates": updates} + + async def _handle_send_message(self, args: dict[str, Any]) -> dict[str, Any]: + """``send_message {chat_id, text, reply_to_message_id?, buttons?, parse_mode?}`` → ``{message_id}``. + + ``buttons`` is an optional 2-D array of ``{text, data}`` dicts that + becomes an inline keyboard attached to the message. Each tap fires a + callback_query update with ``data`` matching what was sent. + + ``parse_mode`` is an optional Telegram parse-mode string + (``"MarkdownV2"``, ``"HTML"``, ``"Markdown"``). Omit for plain text. + """ + chat_id = _require_int(args, "chat_id") + text = _require_str(args, "text") + reply_to = args.get("reply_to_message_id") + if reply_to is not None and (isinstance(reply_to, bool) or not isinstance(reply_to, int)): + raise RpcError("BAD_REQUEST", "'reply_to_message_id' must be an integer") + parse_mode = args.get("parse_mode") + if parse_mode is not None and not isinstance(parse_mode, str): + raise RpcError("BAD_REQUEST", "'parse_mode' must be a string") + reply_markup = _coerce_inline_keyboard(args.get("buttons")) + try: + msg = await self._bot.send_message( + chat_id=chat_id, + text=text, + reply_to_message_id=reply_to, + reply_markup=reply_markup, + parse_mode=parse_mode, + ) + except TelegramAPIError as exc: + raise RpcError("INTERNAL", str(exc)) from exc + return {"message_id": msg.message_id} + + async def _handle_send_document(self, args: dict[str, Any]) -> dict[str, Any]: + """``send_document {chat_id, host_path, caption?, filename?}`` → ``{message_id}``. + + ``host_path`` is an absolute path the broker can read. The broker + currently shares the main app's filesystem and UID, so any path the + agent emits via ``FileOutputPayload`` is reachable directly. + """ + chat_id = _require_int(args, "chat_id") + host_path = _require_str(args, "host_path") + caption = args.get("caption") + if caption is not None and not isinstance(caption, str): + raise RpcError("BAD_REQUEST", "'caption' must be a string") + filename = args.get("filename") + if filename is not None and not isinstance(filename, str): + raise RpcError("BAD_REQUEST", "'filename' must be a string") + + path = Path(host_path) + if not path.is_file(): + raise RpcError("BAD_REQUEST", f"file not found: {host_path}") + + document = FSInputFile(str(path), filename=filename or path.name) + try: + msg = await self._bot.send_document( + chat_id=chat_id, + document=document, + caption=caption, + ) + except TelegramAPIError as exc: + raise RpcError("INTERNAL", str(exc)) from exc + return {"message_id": msg.message_id} + + async def _handle_send_chat_action(self, args: dict[str, Any]) -> dict[str, Any]: + """``send_chat_action {chat_id, action}`` → ``{}``. + + ``action`` is one of Telegram's chat-action values, e.g. ``"typing"``. + The indicator clears after ~5 seconds — callers that want it to + persist should re-send periodically. + """ + chat_id = _require_int(args, "chat_id") + action = _require_str(args, "action") + try: + await self._bot.send_chat_action(chat_id=chat_id, action=action) + except TelegramAPIError as exc: + raise RpcError("INTERNAL", str(exc)) from exc + return {} + + async def _handle_edit_message_text(self, args: dict[str, Any]) -> dict[str, Any]: + """``edit_message_text {chat_id, message_id, text}`` → ``{}``. + + Edits a previously sent message in place. The channel uses this for a + rolling status indicator that names the current activity rather than + the generic "typing..." chat action. + """ + chat_id = _require_int(args, "chat_id") + message_id = _require_int(args, "message_id") + text = _require_str(args, "text") + try: + await self._bot.edit_message_text( + chat_id=chat_id, message_id=message_id, text=text, + ) + except TelegramAPIError as exc: + raise RpcError("INTERNAL", str(exc)) from exc + return {} + + async def _handle_delete_message(self, args: dict[str, Any]) -> dict[str, Any]: + """``delete_message {chat_id, message_id}`` → ``{}``. + + Removes a previously sent message. Used to clear the rolling status + message once the turn ends so it doesn't clutter the chat history. + """ + chat_id = _require_int(args, "chat_id") + message_id = _require_int(args, "message_id") + try: + await self._bot.delete_message(chat_id=chat_id, message_id=message_id) + except TelegramAPIError as exc: + raise RpcError("INTERNAL", str(exc)) from exc + return {} + + async def _handle_answer_callback_query(self, args: dict[str, Any]) -> dict[str, Any]: + """``answer_callback_query {callback_id, text?}`` → ``{}``. + + Acknowledges a callback_query so Telegram dismisses the button-tap + loading spinner. Optional ``text`` shows a small toast to the user. + """ + callback_id = _require_str(args, "callback_id") + text = args.get("text") + if text is not None and not isinstance(text, str): + raise RpcError("BAD_REQUEST", "'text' must be a string") + try: + await self._bot.answer_callback_query( + callback_query_id=callback_id, + text=text, + ) + except TelegramAPIError as exc: + raise RpcError("INTERNAL", str(exc)) from exc + return {} + + +def _coerce_inline_keyboard(buttons: Any) -> InlineKeyboardMarkup | None: + """Turn a wire-format buttons grid into an aiogram InlineKeyboardMarkup. + + Wire format: ``[[{"text": "Label", "data": "payload"}, ...], ...]``. + Returns ``None`` when ``buttons`` is missing or empty. + """ + if buttons is None: + return None + if not isinstance(buttons, list): + raise RpcError("BAD_REQUEST", "'buttons' must be a 2-D array of {text, data} dicts") + rows: list[list[InlineKeyboardButton]] = [] + for row in buttons: + if not isinstance(row, list): + raise RpcError("BAD_REQUEST", "each 'buttons' row must be a list of dicts") + button_row: list[InlineKeyboardButton] = [] + for cell in row: + if not isinstance(cell, dict): + raise RpcError("BAD_REQUEST", "each button must be a {text, data} dict") + label = cell.get("text") + data = cell.get("data") + if not isinstance(label, str) or not label: + raise RpcError("BAD_REQUEST", "button 'text' must be a non-empty string") + if not isinstance(data, str) or not data: + raise RpcError("BAD_REQUEST", "button 'data' must be a non-empty string") + button_row.append(InlineKeyboardButton(text=label, callback_data=data)) + if button_row: + rows.append(button_row) + if not rows: + return None + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def _require_str(args: dict[str, Any], key: str) -> str: + value = args.get(key) + if not isinstance(value, str) or not value: + raise RpcError("BAD_REQUEST", f"{key!r} required (non-empty string)") + return value + + +def _require_int(args: dict[str, Any], key: str, *, default: int | None = None) -> int: + if default is None: + value = args.get(key) + if isinstance(value, bool) or not isinstance(value, int): + raise RpcError("BAD_REQUEST", f"{key!r} required (integer)") + return value + value = args.get(key, default) + if isinstance(value, bool) or not isinstance(value, int): + raise RpcError("BAD_REQUEST", f"{key!r} must be an integer") + return value diff --git a/integrations/permissions.py b/integrations/permissions.py index 6d0e2ac2..08439064 100644 --- a/integrations/permissions.py +++ b/integrations/permissions.py @@ -17,6 +17,7 @@ class Capability(StrEnum): DRIVE = "drive" CONTACTS = "contacts" LLM_PROXY = "llm_proxy" + TELEGRAM = "telegram" HTTP = "http" diff --git a/integrations/supervisor/_catalog.py b/integrations/supervisor/_catalog.py index 65d8e082..ed1e3914 100644 --- a/integrations/supervisor/_catalog.py +++ b/integrations/supervisor/_catalog.py @@ -224,6 +224,25 @@ def resolve_capabilities(self, auth_blob: dict | None = None) -> dict[Capability ) +_TELEGRAM = CatalogEntry( + slug="telegram", + command=["python", "-m", "integrations.brokers.telegram_broker"], + capabilities={Capability.TELEGRAM: Access.READ_WRITE}, + static_env={}, + env_injection={ + "token": "TELEGRAM_BOT_TOKEN", + "allowed_user_ids": "TELEGRAM_ALLOWED_USER_IDS", + }, + host_paths=( + # Inbound photos/documents land in the shared "downloads" role — + # same place email attachments and browser saves are written. The + # broker writes; the main app process (and the agent's virtual + # computer view) reads from the same path. + HostPathBinding(role="downloads", env_var="DOWNLOADS_DIR", mode="write"), + ), +) + + DEFAULT_CATALOG: dict[str, CatalogEntry] = { "icloud": _ICLOUD, "gmail": _GMAIL, @@ -232,6 +251,7 @@ def resolve_capabilities(self, auth_blob: dict | None = None) -> dict[Capability "llm_openrouter": _LLM_OPENROUTER, "llm_openai_compat": _LLM_OPENAI_COMPAT, "google_workspace": _GOOGLE_WORKSPACE, + "telegram": _TELEGRAM, "http": _HTTP, } diff --git a/migrations/_006_telegram_notifier_settings.py b/migrations/_006_telegram_notifier_settings.py new file mode 100644 index 00000000..2d088cc9 --- /dev/null +++ b/migrations/_006_telegram_notifier_settings.py @@ -0,0 +1,45 @@ +"""Migration 006: Add Telegram notifier settings keys. + +The goal-run push notifier previously read ``TELEGRAM_INTEGRATION_ID`` and +``TELEGRAM_CHAT_ID`` from the environment. Both now live in +``settings.json`` so the user can edit them in the app's Settings → System +page. This migration seeds the keys with empty defaults on existing +installs; users who relied on the env vars will need to re-enter the +values in the UI. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +_SETTINGS_FILE = "settings.json" + + +def migrate(state_dir: Path) -> None: + """Add telegram_notifier_* keys to settings.json if absent.""" + path = state_dir / _SETTINGS_FILE + if not path.exists(): + # No settings file yet — defaults in settings.py will apply on first read. + return + + try: + data = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + logger.warning("Corrupt %s, skipping migration 006", path) + return + + changed = False + if "telegram_notifier_integration_id" not in data: + data["telegram_notifier_integration_id"] = "" + changed = True + if "telegram_notifier_chat_id" not in data: + data["telegram_notifier_chat_id"] = None + changed = True + + if changed: + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + logger.info("Seeded telegram notifier settings in %s", path) diff --git a/migrations/_runner.py b/migrations/_runner.py index 574f6cab..6aeaf616 100644 --- a/migrations/_runner.py +++ b/migrations/_runner.py @@ -12,6 +12,9 @@ from migrations._003_vision_settings import migrate as _003_vision_settings from migrations._004_rename_num_ctx import migrate as _004_rename_num_ctx from migrations._005_multi_provider import migrate as _005_multi_provider +from migrations._006_telegram_notifier_settings import ( + migrate as _006_telegram_notifier_settings, +) logger = logging.getLogger(__name__) @@ -26,6 +29,7 @@ ("003_vision_settings", _003_vision_settings), ("004_rename_num_ctx", _004_rename_num_ctx), ("005_multi_provider", _005_multi_provider), + ("006_telegram_notifier_settings", _006_telegram_notifier_settings), ] diff --git a/plans/resume-telegram.md b/plans/resume-telegram.md new file mode 100644 index 00000000..837571d2 --- /dev/null +++ b/plans/resume-telegram.md @@ -0,0 +1,87 @@ +# Resume — Telegram integration PR + +Where to pick up next session. + +## PR + +- **omnideck-dev/omnideck#4** — `feature/telegram-integration-v2` → `main` +- Built originally on `lefoulkrod/computron_9000#55` (closed/abandoned once + the repo moved to the `omnideck` origin); branch history shows the full + trail of incremental commits. + +## Branch state + +- Synced with `origin/main` via merge `e15355f` (resolved 5 conflicts in + the integrations area where the new HTTP/`call_api` integration overlapped + with our Telegram additions — both kept, no behavior lost). +- 1429 unit tests pass. + +## What's landed on this branch + +1. **Telegram channel** (`channels/telegram/_channel.py`) — pulls updates + from the broker via `next_updates`, dispatches per-chat turns, renders + status messages (`🤔 Thinking…` → tool labels → `✍️ Writing…`), sends + replies as MarkdownV2 (via `telegramify-markdown`), handles inbound + photos + documents, `/list` (search and resume any conversation), + `/profile` (inline-keyboard picker). +2. **Credential-isolated broker** (`integrations/brokers/telegram_broker/`) + — bot token stays in the broker subprocess, never enters the main app. + Verbs: `get_me`, `next_updates`, `send_message`, `send_document`, + `send_chat_action`, `answer_callback_query`, `edit_message_text`, + `delete_message`. +3. **`ConversationCache`** (`conversations/_cache.py`) — bounded-LRU + hydrate/evict, shared between the web/SSE handler and the Telegram + channel; skips eviction of conversations with an in-flight turn. +4. **`TurnExecutor` refactor** (slimmed) — `Conversation` now holds + `agent_state`; the executor stops loading skills / saving events / + firing first-turn hooks. Channels do that wiring inline. Covers the + web handler, Telegram channel, `TaskExecutor`, and `spawn_agent`. + `TurnPersistence` Protocol moved to `sdk/turn/_persistence.py`. + `SystemPromptBuilder` dropped. +5. **Goal-run notifier settings** — env vars + `TELEGRAM_INTEGRATION_ID` / `TELEGRAM_CHAT_ID` retired. Now in + `settings.json` as `telegram_notifier_integration_id` / + `telegram_notifier_chat_id`, editable in **Settings → System → + Notifications**. Migration 006 seeds empty defaults on existing installs. +6. **Three earlier PR-review fixes** (formatter, channel rename, + env→settings) — see commits `a93ab3b`, `d0364ae` and the resolved + threads on the original computron PR for context. + +## Known / deferred follow-ups + +- **No streaming reply for Telegram.** We buffer the full agent reply + and send it in chunks at turn end (via `to_markdownv2_chunks`). + Real-time streaming would need either per-paragraph `send_message` + calls or `edit_message_text` to grow a single message — both have + Telegram rate-limit and chunk-boundary concerns. Not a blocker. +- **Parallel sub-agent browser contention** — when an agent spawns + sub-agents in parallel, browser tool calls still serialize. Needs + per-agent browser contexts. Tracked separately + ([[project_parallel_subagents]]). +- **Title generation on first turn is fire-and-forget** — if the title + model is slow or unhealthy, the user just sees the conversation ID + on `/list` until it lands. Acceptable. +- **No live chat-ID discovery** in the notifier setup — user has to + copy chat.id from `getUpdates` JSON. We discussed a broker-side + "recent chats" registry to power a dropdown; deferred until someone + trips over the manual flow. +- **`telegramify-markdown` corner cases** — if the agent emits + malformed markdown (unbalanced backticks across chunk boundaries, + weird nested formatting), the Telegram API may reject. Today we + log+warn on send failures and the user just sees nothing for that + chunk. Worth watching once real traffic hits. + +## How to pick up next session + +1. `cd ~/repos/computron_9000-telegram && git status` — confirm branch + `feature/telegram-integration-v2`, clean tree. +2. `gh pr view 4 --repo omnideck-dev/omnideck` — check for new review + comments and CI status. +3. If new inline review comments: `gh api repos/omnideck-dev/omnideck/pulls/4/comments` + to fetch, then walk them one at a time (same pattern as the three + from the computron PR). +4. For manual smoke testing: `just dev` brings up the container; add a + Telegram integration via the wizard; talk to the bot; verify + MarkdownV2 rendering + status indicator + `/list` resume work. +5. The dev container is `computron_virtual_computer` under **docker**, + not podman. diff --git a/pyproject.toml b/pyproject.toml index 328a4033..97bd089a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,8 @@ dependencies = [ "google-auth>=2.30", "google-auth-oauthlib>=1.2", "google-api-python-client>=2.130", + "aiogram>=3.7", + "telegramify-markdown>=1.1.5", ] [project.optional-dependencies] @@ -148,6 +150,7 @@ build-backend = "setuptools.build_meta" # Explicitly include our flat-layout top-level packages include = [ "agents*", + "channels*", "config*", "migrations*", "models*", diff --git a/sdk/__init__.py b/sdk/__init__.py index 92c15d79..998393f7 100644 --- a/sdk/__init__.py +++ b/sdk/__init__.py @@ -16,16 +16,28 @@ from .turn import run_turn from .providers import LLMRuntimeStats, llm_runtime_stats +# Imported last so that sdk.context, sdk.hooks, sdk.turn (without _executor) +# and sdk.providers are all fully loaded before the executor module — which +# pulls from all of them — is initialized. ``Conversation`` lives in its +# own minimal module so importers below the SDK layer can grab it without +# pulling in the full executor. +from .turn._conversation import Conversation +from .turn._executor import TurnExecutor +from .turn._persistence import TurnPersistence + __all__ = [ "BudgetGuard", "ContextHook", "ContextManager", + "Conversation", "ConversationHistory", "LLMRuntimeStats", "LoggingHook", "LoopDetector", "PersistenceHook", "StopHook", + "TurnExecutor", + "TurnPersistence", "default_hooks", "llm_runtime_stats", "run_turn", diff --git a/sdk/skills/agent_state.py b/sdk/skills/agent_state.py index c6c1d8fb..466546a7 100644 --- a/sdk/skills/agent_state.py +++ b/sdk/skills/agent_state.py @@ -1,11 +1,13 @@ """Tracks base tools and dynamically loaded skills for an agent scope.""" import logging -from collections.abc import Callable +from collections.abc import Callable, Iterable from contextvars import ContextVar from typing import Any -from ._registry import Skill +from sdk.tools._core import get_core_tools + +from ._registry import Skill, get_skill logger = logging.getLogger(__name__) @@ -26,6 +28,25 @@ def __init__(self, base_tools: list[Callable[..., Any]]) -> None: self._base_tools: list[Callable[..., Any]] = list(base_tools) self._skills: dict[str, Skill] = {} + @classmethod + async def create(cls, *, skill_names: Iterable[str] = ()) -> "AgentState": + """Build an ``AgentState`` with the core tool set and named skills loaded. + + Missing skills are logged and skipped — matches the historical + tolerance baked into the turn loop. Callers that want stricter + semantics (raise on missing, surface to the LLM, etc.) should + pre-validate names via ``get_skill`` and then use the regular + constructor + ``add``. + """ + state = cls(await get_core_tools()) + for name in skill_names: + skill = get_skill(name) + if skill is None: + logger.warning("Skill %r not registered, skipping", name) + continue + state.add(skill) + return state + def add(self, skill: Skill) -> None: """Attach a skill to this state. No-op if already attached.""" if skill.name in self._skills: diff --git a/sdk/tools/_spawn_agent.py b/sdk/tools/_spawn_agent.py index d4c78dae..e61cea0e 100644 --- a/sdk/tools/_spawn_agent.py +++ b/sdk/tools/_spawn_agent.py @@ -8,17 +8,13 @@ from rich.text import Text from agents import AgentProfile, build_agent, get_agent_profile -from sdk.context import ContextManager, ConversationHistory, LLMCompactionStrategy -from sdk.events import ( - AgentEvent, - SpawnRequestedPayload, - agent_span, - publish_event, -) -from sdk.hooks import PersistenceHook, default_hooks +from sdk import Conversation, TurnExecutor +from sdk.context import ConversationHistory +from sdk.events import AgentEvent, SpawnRequestedPayload, publish_event +from sdk.events._models import ContentPayload from sdk.skills import AgentState, get_skill, list_skills from sdk.tools._core import get_core_tools -from sdk.turn import StopRequestedError, get_conversation_id, run_turn +from sdk.turn import StopRequestedError, get_conversation_id logger = logging.getLogger(__name__) @@ -154,7 +150,11 @@ async def spawn_agent( _log_spawn_error(agent_name, msg) return msg - agent_state = AgentState(await get_core_tools()) + # Validate skills up-front so a missing one fails the spawn synchronously + # with a clear message rather than mid-turn with a generic warning. The + # executor itself also tolerates missing skills (logs + skips), but the + # tool's "tell the calling LLM what's wrong" surface beats that. + state = AgentState(await get_core_tools()) for skill_name in agent_profile.skills: skill = get_skill(skill_name) if skill is None: @@ -165,9 +165,9 @@ async def spawn_agent( ) _log_spawn_error(agent_name, msg) return msg - agent_state.add(skill) + state.add(skill) - agent = build_agent(agent_profile, tools=agent_state.tools, name=agent_name) + agent = build_agent(agent_profile, tools=state.tools, name=agent_name) logger.info( "Spawning sub-agent '%s' (profile=%s, max_iter=%d, instruction=%.100s)", @@ -176,69 +176,45 @@ async def spawn_agent( _log_spawn(agent_name, agent_profile, instructions) # Mint a fresh correlation id and announce the spawn before the child's - # context frame opens — the matching AgentStartedPayload carries the - # same id, letting the UI anchor a card to this request and attach the - # child to it. + # context frame opens — the matching AgentStartedPayload carries the same + # id, letting the UI anchor a card to this request and attach the child + # agent to it. correlation_id = _uuid.uuid4().hex publish_event(AgentEvent(payload=SpawnRequestedPayload( type="spawn_requested", correlation_id=correlation_id, ))) - async with agent_span( - agent_name, - instruction=instructions, - agent_state=agent_state, - profile_name=agent_profile.name, - correlation_id=correlation_id, - ): - conv_id = get_conversation_id() or "default" - short_id = _uuid.uuid4().hex[:8] - instance_id = f"{conv_id}/{agent_name}_{short_id}" - history = ConversationHistory( - [ - {"role": "system", "content": agent.instruction}, - {"role": "user", "content": instructions}, - ], - instance_id=instance_id, - ) + parent_conv_id = get_conversation_id() or "default" + short_id = _uuid.uuid4().hex[:8] + instance_id = f"{parent_conv_id}/{agent_name}_{short_id}" + conversation = Conversation( + id=instance_id, + history=ConversationHistory(instance_id=instance_id), + agent_state=state, + ) - ctx_manager = ContextManager( - history=history, - agent_state=agent_state, - context_limit=agent.context_window, - agent_name=agent.name, - compaction_threshold=agent.compaction_threshold, - strategies=[ - LLMCompactionStrategy(threshold=agent.compaction_threshold), - ], - ) - hooks = default_hooks( - agent, - max_iterations=agent.max_iterations, - ctx_manager=ctx_manager, - ) - hooks.append(PersistenceHook( - conversation_id=conv_id, - history=history, + accumulated: list[str] = [] + try: + async for event in TurnExecutor().execute( + conversation=conversation, + agent=agent, + user_content=instructions, + profile_name=agent_profile.name, sub_agent_name=agent_name, sub_agent_id=short_id, - )) - - try: - result_text = await run_turn( - history=history, - agent=agent, - hooks=hooks, - ) - except StopRequestedError: - logger.info("Spawned agent '%s' stopped by user request", agent_name) - raise - except Exception as exc: - _log_spawn_error(agent_name, str(exc)) - logger.exception("Unexpected error in spawned agent '%s'", agent_name) - raise - - result = (result_text or "").strip() + correlation_id=correlation_id, + ): + if isinstance(event.payload, ContentPayload) and event.payload.content: + accumulated.append(event.payload.content) + except StopRequestedError: + logger.info("Spawned agent '%s' stopped by user request", agent_name) + raise + except Exception as exc: + _log_spawn_error(agent_name, str(exc)) + logger.exception("Unexpected error in spawned agent '%s'", agent_name) + raise + + result = "".join(accumulated).strip() _log_spawn_complete(agent_name, result) return result diff --git a/sdk/turn/__init__.py b/sdk/turn/__init__.py index 636abda3..3795d738 100644 --- a/sdk/turn/__init__.py +++ b/sdk/turn/__init__.py @@ -4,6 +4,10 @@ - ``run_turn``: Async function driving the chat/tool loop. - ``turn_scope``: Async context manager for conversation turn lifecycle. - Stop/nudge signaling utilities for user-initiated control. + +The high-level ``TurnExecutor`` and ``Conversation`` are defined in +``sdk.turn._executor`` and re-exported from the top-level ``sdk`` package. +They are not re-exported here to avoid a load-order cycle with ``sdk.context``. """ from ._execution import ToolLoopError, run_turn diff --git a/sdk/turn/_conversation.py b/sdk/turn/_conversation.py new file mode 100644 index 00000000..e81a1e69 --- /dev/null +++ b/sdk/turn/_conversation.py @@ -0,0 +1,43 @@ +"""Per-conversation state owned by callers (channels, handlers, tools). + +Kept in its own module so importers that only need the dataclass don't +pay the cost of pulling in ``TurnExecutor`` + its transitive deps — +that matters for modules below the SDK in the dependency graph (e.g. +the ``conversations`` package's cache) where importing the executor +would form a cycle. + +``AgentState`` is referenced via ``TYPE_CHECKING`` for the same reason: +``sdk.skills._registry`` eagerly pulls in every registered skill's tool +modules, which a bare cache miss has no business loading. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from sdk.context._history import ConversationHistory + +if TYPE_CHECKING: + from sdk.skills import AgentState + +__all__ = ["Conversation"] + + +@dataclass +class Conversation: + """Per-conversation state owned by the caller. + + Attributes: + id: Unique conversation identifier. + history: The conversation history. + agent_state: Loaded skills + base tools that survive across turns. + Set by the caller (channels hydrate on cache miss; one-shot + callers like spawn_agent construct fresh). ``None`` means + "not yet populated"; callers should fill it before the first + turn runs. + """ + + id: str + history: ConversationHistory + agent_state: AgentState | None = None diff --git a/sdk/turn/_executor.py b/sdk/turn/_executor.py new file mode 100644 index 00000000..1e79e53d --- /dev/null +++ b/sdk/turn/_executor.py @@ -0,0 +1,174 @@ +"""Single-turn agent driver. + +Given a fully-wired ``Conversation`` (with ``agent_state`` populated) +and an ``Agent``, drives one turn: opens the turn/agent spans, appends +the user message, composes the system prompt with the skill block, +runs the agent loop, and yields events to the caller. + +Per-conversation lifecycle work — hydrating ``agent_state`` from disk, +saving events, flushing skills, firing first-turn hooks — is the +caller's responsibility, not the executor's. See the channels (web / +telegram) and one-shot callers (TaskExecutor, ``spawn_agent``) for the +two shapes that lifecycle takes. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncGenerator +from contextlib import suppress + +from agents.types import Agent +from sdk.context._manager import ContextManager +from sdk.context._strategy import LLMCompactionStrategy +from sdk.events._context import agent_span +from sdk.events._models import AgentEvent +from sdk.hooks._default import default_hooks +from sdk.hooks._persistence import PersistenceHook +from sdk.turn._conversation import Conversation +from sdk.turn._execution import run_turn +from sdk.turn._turn import StopRequestedError, turn_scope + +logger = logging.getLogger(__name__) + + +class TurnExecutor: + """Drives a single agent turn against a pre-wired ``Conversation``. + + Stateless and safe to share across conversations. + """ + + async def execute( + self, + *, + conversation: Conversation, + agent: Agent, + user_content: str, + profile_name: str | None = None, + sub_agent_name: str | None = None, + sub_agent_id: str | None = None, + correlation_id: str | None = None, + ) -> AsyncGenerator[AgentEvent, None]: + """Run a single turn and yield events. + + Args: + conversation: Per-conversation state. ``conversation.agent_state`` + must be populated before calling — callers hydrate it from + disk on cache miss or build it inline for one-shot turns. + agent: The fully-constructed ``Agent`` to run. + user_content: The user's message, already augmented if needed. + profile_name: Optional metadata threaded through ``agent_span``. + sub_agent_name: When this turn is a sub-agent invocation, the + short uppercase agent name. Forwarded to ``PersistenceHook`` + so the turn writes to the conversation's ``sub_agents/`` + directory instead of overwriting the main history. + sub_agent_id: When this turn is a sub-agent invocation, a short + unique id (UUID hex prefix). Pairs with ``sub_agent_name``. + correlation_id: Optional id linking this turn to a prior + ``SpawnRequestedPayload`` event so the UI can anchor a card + to the request and attach the child agent to it. + + Yields: + AgentEvent: Events emitted by the agent during the turn. + """ + if conversation.agent_state is None: + msg = ( + f"Conversation {conversation.id!r} has no agent_state; " + "caller must populate it before running a turn." + ) + raise ValueError(msg) + + conv_id = conversation.id + agent_state = conversation.agent_state + logger.info( + "Turn started: conv=%s agent=%s message=%.80s", + conv_id, + agent.name, + user_content, + ) + + # Fresh ContextManager per turn — it borrows the live agent_state so + # the token estimate reflects the current tool set, and the strategy + # threshold tracks the agent's compaction setting. + ctx_manager = ContextManager( + history=conversation.history, + agent_state=agent_state, + context_limit=agent.context_window, + agent_name=agent.name, + compaction_threshold=agent.compaction_threshold, + strategies=[ + LLMCompactionStrategy(threshold=agent.compaction_threshold), + ], + ) + + # Bridge published events through a queue so we can yield them + # regardless of how the caller is iterating. + queue: asyncio.Queue[AgentEvent | None] = asyncio.Queue() + + async def _queue_handler(evt: AgentEvent) -> None: + try: + await queue.put(evt) + except Exception: # pragma: no cover - defensive + logger.exception("Failed to enqueue AgentEvent in TurnExecutor") + + async def _producer() -> None: + try: + async with turn_scope( + handler=_queue_handler, + conversation_id=conv_id, + ): + async with agent_span( + agent.name, + instruction=user_content, + agent_state=agent_state, + profile_name=profile_name, + correlation_id=correlation_id, + ): + conversation.history.append( + {"role": "user", "content": user_content}, + ) + + skill_prompt = agent_state.build_skill_prompt() + full_prompt = ( + f"{agent.instruction}\n{skill_prompt}" + if skill_prompt + else agent.instruction + ) + conversation.history.set_system_message(full_prompt) + + hooks = default_hooks( + agent, + max_iterations=agent.max_iterations, + ctx_manager=ctx_manager, + ) + hooks.append( + PersistenceHook( + conversation_id=conv_id, + history=conversation.history, + sub_agent_name=sub_agent_name, + sub_agent_id=sub_agent_id, + ), + ) + + with suppress(StopRequestedError): + await run_turn( + history=conversation.history, + agent=agent, + hooks=hooks, + ) + finally: + await queue.put(None) + + producer_task = asyncio.create_task(_producer()) + try: + while True: + item = await queue.get() + if item is None: + break + yield item + finally: + if not producer_task.done(): + producer_task.cancel() + with suppress(Exception): + await producer_task diff --git a/sdk/turn/_persistence.py b/sdk/turn/_persistence.py new file mode 100644 index 00000000..59905fbd --- /dev/null +++ b/sdk/turn/_persistence.py @@ -0,0 +1,46 @@ +"""Protocol for persistence hooks fired by channels around a turn. + +This is the small interface the channel uses to: hydrate per-conversation +skill state on cache miss, flush it after each turn, persist the agent +event stream, and fire a first-turn hook (typically title generation). + +Channels that don't want persistence simply pass ``None`` instead of an +implementation. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from sdk.events._models import AgentEvent + +__all__ = ["TurnPersistence"] + + +class TurnPersistence(Protocol): + """Optional persistence surface used by channels around a turn.""" + + def load_skills(self, conversation_id: str) -> Iterable[str]: + """Return persisted skill names to restore for this conversation.""" + + def save_skills(self, conversation_id: str, skills: Iterable[str]) -> None: + """Persist the set of currently-loaded skill names.""" + + def save_events( + self, + conversation_id: str, + events: list[AgentEvent], + ) -> None: + """Persist agent lifecycle events captured during the turn.""" + + async def on_new_conversation( + self, + conversation_id: str, + first_message: str, + ) -> None: + """Hook fired once when a conversation runs its first turn. + + Typical use: generate and persist a conversation title. + """ diff --git a/server/_integrations_routes.py b/server/_integrations_routes.py index 6cd94009..73b65cef 100644 --- a/server/_integrations_routes.py +++ b/server/_integrations_routes.py @@ -124,14 +124,17 @@ async def handle_add_integration(request: web.Request) -> web.Response: status=400, ) - # user_suffix is derived from auth_blob.email — clients never set it. - # Keeps integration IDs deterministic and out of the user's mental model. - # LLM integrations are singletons — no suffix, so the ID is just the slug - # and the socket path matches what the provider factory expects. + # Integration ID composition. Three shapes: + # - LLM integrations are singletons — ID is just the slug, no + # user_suffix, no per-capability permissions (the supervisor's add + # verb still requires the dict though). + # - Slugs whose wizard collects an email derive user_suffix from it so + # IDs stay deterministic and out of the user's mental model. + # - Slugs without an email (bot_token, oauth_device) submit + # user_suffix directly from the wizard; the supervisor enforces the + # [a-z0-9_-]{1,48} format. slug = body.get("slug", "") if slug.startswith("llm_"): - # LLM integrations have no per-capability permissions (no email, calendar, - # etc.) but the supervisor's add verb requires the field as a dict. if "permissions" not in body: body["permissions"] = {} elif slug == "http": @@ -145,10 +148,15 @@ async def handle_add_integration(request: web.Request) -> web.Response: "BAD_REQUEST", "A label is required to name this integration.", ) body["user_suffix"] = derived - else: + elif not body.get("user_suffix"): + # No explicit user_suffix (the bot_token / oauth_device wizard flows + # submit one directly) — derive it from the user's email. derived = _derive_suffix_from_email(body.get("auth_blob")) if not derived: - return error_response("BAD_REQUEST", "Email address is required.") + return error_response( + "BAD_REQUEST", + "An email address or user_suffix is required.", + ) body["user_suffix"] = derived try: @@ -180,6 +188,12 @@ async def handle_add_integration(request: web.Request) -> web.Response: result.get("state") or "running", ) + # Wake channels that were waiting on a relevant integration to appear. + # Each channel filters by slug so unrelated adds are no-ops. + telegram_channel = request.app.get("telegram_channel") + if telegram_channel is not None: + telegram_channel.notify_integration_added(slug) + return web.json_response(result, status=201) diff --git a/server/aiohttp_app.py b/server/aiohttp_app.py index 2e4ea4cb..6cc2ffc3 100644 --- a/server/aiohttp_app.py +++ b/server/aiohttp_app.py @@ -39,6 +39,7 @@ from server._setup_routes import register_setup_routes from server._task_routes import register_task_routes from server.message_handler import handle_user_message +from channels.telegram import TelegramChannel from tools.custom_tools.registry import delete_tool, list_tools from tools.desktop._exec import DesktopExecError from tools.desktop._lifecycle import start_desktop @@ -431,6 +432,7 @@ def create_app(*, client_max_size: int = 10 * 1024**2) -> web.Application: # ``app["ready"]`` and then initializes everything that needed to wait. app.on_startup.append(_start_deferred_subsystems) app.on_cleanup.append(_stop_deferred_subsystems) + app.on_cleanup.append(_stop_telegram_bot) return app @@ -552,6 +554,7 @@ async def _deferred() -> None: await app["ready"].wait() logger.info("Ready — starting deferred subsystems") await _init_task_runner(app) + await _init_telegram_bot(app) except asyncio.CancelledError: raise except Exception: @@ -573,7 +576,10 @@ async def _init_task_runner(app: web.Application) -> None: notifier = None if config.goals.notifications.enabled: - notifier = TelegramNotifier(config.goals.notifications) + notifier = TelegramNotifier( + config.goals.notifications, + app_sock_path=Path(config.integrations.app_sock_path), + ) if not notifier.enabled: notifier = None @@ -592,4 +598,26 @@ async def _stop_deferred_subsystems(app: web.Application) -> None: await runner.stop() +async def _init_telegram_bot(app: web.Application) -> None: + """Start the Telegram channel if a telegram integration is registered. + + The channel auto-discovers the integration via the supervisor — no + config flag, no integration_id field. Adding/removing a Telegram + integration in the wizard is the only enable/disable knob. + """ + config = load_config() + channel = TelegramChannel( + app_sock_path=Path(config.integrations.app_sock_path), + ) + await channel.start() + app["telegram_channel"] = channel + + +async def _stop_telegram_bot(app: web.Application) -> None: + """Stop the Telegram channel if present.""" + channel: TelegramChannel | None = app.get("telegram_channel") + if channel: + await channel.stop() + + __all__ = ["create_app"] diff --git a/server/message_handler.py b/server/message_handler.py index c19c773e..5e5b250e 100644 --- a/server/message_handler.py +++ b/server/message_handler.py @@ -2,9 +2,7 @@ import asyncio import logging -from collections import OrderedDict -from collections.abc import AsyncGenerator, Callable, Sequence -from contextlib import suppress +from collections.abc import AsyncGenerator, Sequence from rich.console import Console from rich.panel import Panel @@ -15,38 +13,28 @@ build_agent, get_agent_profile, ) -from agents.types import Agent, Data +from agents.types import Data from conversations import ( - generate_conversation_title, + ConversationCache, + DiskTurnPersistence, load_agent_events, - load_conversation_history, - load_loaded_skills, load_preview_state, - save_agent_events, - save_conversation_title, - save_loaded_skills, ) -from sdk import ( - PersistenceHook, - default_hooks, - run_turn, -) -from sdk.context import ContextManager, ConversationHistory, LLMCompactionStrategy +from sdk import TurnExecutor from sdk.events import ( AgentEvent, ContentPayload, TurnEndPayload, - agent_span, - get_current_dispatcher, ) -from sdk.hooks._agent_event_buffer import AgentEventBufferHook -from sdk.skills import AgentState, get_skill -from sdk.tools._core import get_core_tools -from sdk.turn import is_turn_active, turn_scope -from sdk.turn._turn import StopRequestedError +from sdk.skills import AgentState +from sdk.turn import is_turn_active from tools.browser.core import release_agent_browser -from tools.memory import load_memory +from tools.memory import forget, memory_prompt_block, remember from tools.virtual_computer.receive_file import receive_attachment +from tools.virtual_computer.run_bash_cmd import run_bash_cmd + +# Background tasks held to prevent GC; cleared via done callback. +_background_tasks: set[asyncio.Task] = set() logger = logging.getLogger(__name__) _console = Console(stderr=True) @@ -102,76 +90,19 @@ def _log_turn_start(profile: AgentProfile) -> None: ) -# In-memory conversation cache. LRU-bounded so a long-lived process -# doesn't hold every conversation a user has ever opened. The on-disk -# state is authoritative; an evicted entry is rehydrated from disk on -# next access. -_MAX_CACHED_CONVERSATIONS = 25 -_conversations: OrderedDict[str, ConversationHistory] = OrderedDict() - -# Track background tasks to avoid garbage collection (RUF006) -_background_tasks: set[asyncio.Task] = set() - - -async def _get_conversation(conversation_id: str) -> tuple[ConversationHistory, bool]: - """Return ``(history, is_new)`` for the given ID, creating it if needed. - - ``is_new`` is True only when the conversation has no in-memory entry - AND no on-disk history — a genuine first-time use. On any cache miss - we hydrate from disk so turns survive process restarts: the browser - preserves a conversation id across server bounces (e.g. ``just - restart-app``), and without hydration the next turn would build on an - empty history and the persistence hook would overwrite the saved file. - - Cache hits move the entry to the end of the LRU; cache misses insert - at the end and may evict the least-recently-used entry whose turn is - not currently active. - """ - if not conversation_id: - msg = "conversation_id is required" - raise ValueError(msg) - if conversation_id in _conversations: - _conversations.move_to_end(conversation_id) - return _conversations[conversation_id], False - persisted = load_conversation_history(conversation_id) - is_new = persisted is None - if is_new: - logger.info("Creating new conversation %s", conversation_id) - _conversations[conversation_id] = ConversationHistory(persisted, instance_id=conversation_id) - await _evict_lru_conversation(exclude=conversation_id) - return _conversations[conversation_id], is_new - - -async def _evict_lru_conversation(exclude: str | None = None) -> None: - """Drop the oldest non-active entries until we are at or below the cap. - - Conversations whose turn is currently in flight are skipped — popping - them from the dict would leave the running turn writing to a referent - nobody else can find, and a subsequent chat for the same id would - rehydrate from disk, producing two parallel writers. +# Shared in-memory conversation cache. The on-disk store is authoritative; +# the cache exists so consecutive turns reuse the same Conversation object +# (and the PersistenceHook's in-place history mutations survive across +# turns). Eviction releases per-conversation browser contexts so evicted +# rows don't leak a Playwright instance. +_cache = ConversationCache( + is_active=is_turn_active, + on_evict=lambda cid: release_agent_browser(f"conv:{cid}"), +) - ``exclude`` skips the conversation that triggered this eviction. The - caller has not yet entered ``turn_scope`` for it, so ``is_turn_active`` - cannot recognize it as protected — without this guard the just-inserted - entry would be evicted by its own insert in the rare case where every - other cached entry is mid-turn. - """ - while len(_conversations) > _MAX_CACHED_CONVERSATIONS: - for cid in _conversations: - if cid == exclude: - continue - if not is_turn_active(cid): - _conversations.pop(cid) - await release_agent_browser(f"conv:{cid}") - logger.info( - "Evicted LRU conversation %s from in-memory cache", cid, - ) - break - else: - # Every cached conversation is mid-turn (or is the just-inserted - # caller) — accept temporary overflow rather than evict an - # active one. The next insert will retry. - return +# Shared turn executor — stateless, safe to reuse across conversations. +_turn_executor = TurnExecutor() +_persistence = DiskTurnPersistence() async def resume_conversation(conversation_id: str) -> dict | None: @@ -187,37 +118,16 @@ async def resume_conversation(conversation_id: str) -> dict | None: None if the conversation isn't found. """ - messages = load_conversation_history(conversation_id) - if messages is None: + conversation = await _cache.resume(conversation_id) + if conversation is None: return None - - _conversations[conversation_id] = ConversationHistory(messages, instance_id=conversation_id) - _conversations.move_to_end(conversation_id) - await _evict_lru_conversation(exclude=conversation_id) return { - "messages": messages, + "messages": list(conversation.history.messages), "events": load_agent_events(conversation_id), "preview_state": load_preview_state(conversation_id), } -def _refresh_system_message(history: ConversationHistory, system_prompt: str) -> None: - """Re-inserts the system message at the start of history with up-to-date memory. - - Called before each model invocation so any memories stored during the previous - turn are visible immediately. - """ - instruction = system_prompt - memory = load_memory() - if memory: - lines = "\n".join(f" {k}: {e.value}" for k, e in memory.items()) - sep = "─" * 64 - memory_block = f"\n── Memory (persisted across sessions) ──────────────────────────\n{lines}\n{sep}\n" - instruction = memory_block + instruction - - history.set_system_message(instruction) - - def _augment_message_with_attachments(message: str, data: Sequence[Data]) -> str: """Write attachments to the virtual computer and return an augmented message.""" file_lines = [] @@ -234,145 +144,6 @@ def _augment_message_with_attachments(message: str, data: Sequence[Data]) -> str return f"{message}\n\n[Attached files written to virtual computer]\n{files_block}" -def _build_agent_from_profile(profile: AgentProfile) -> Agent: - """Construct an Agent from an AgentProfile.""" - from tools.memory import forget, remember - from tools.virtual_computer.run_bash_cmd import run_bash_cmd - - return build_agent(profile, tools=[run_bash_cmd, remember, forget]) - - -async def _run_turn( - *, - history: ConversationHistory, - active_agent: Agent, - profile: AgentProfile, - user_content: str, - conversation_id: str, - handler: Callable[[AgentEvent], object], - is_new_conversation: bool = False, -) -> None: - """Execute a single conversation turn: model calls, tool execution, persistence.""" - logger.info( - "Turn started: conv=%s agent=%s message=%.80s", - conversation_id, - active_agent.name, - user_content, - ) - _log_turn_start(profile) - - conv_id = conversation_id - - # Fresh AgentState each turn, restored from persisted skill names. - # Pre-load skills from the profile. - agent_state = AgentState(await get_core_tools() + active_agent.tools) - for skill_name in profile.skills: - skill = get_skill(skill_name) - if skill is None: - logger.warning("Profile skill '%s' not registered; skipping", skill_name) - continue - agent_state.add(skill) - logger.info("Pre-loaded profile skill '%s' for conv=%s", skill_name, conv_id) - for skill_name in load_loaded_skills(conv_id): - if skill_name in agent_state.loaded_skill_names: - continue - skill = get_skill(skill_name) - if skill is None: - logger.warning( - "Persisted skill '%s' for conv=%s was not found in the skills registry; skipping", - skill_name, - conv_id, - ) - continue - agent_state.add(skill) - logger.info("Restored skill '%s' for conv=%s", skill_name, conv_id) - - ctx_manager = ContextManager( - history=history, - agent_state=agent_state, - context_limit=active_agent.context_window, - agent_name=active_agent.name, - compaction_threshold=active_agent.compaction_threshold, - strategies=[ - LLMCompactionStrategy(threshold=active_agent.compaction_threshold), - ], - ) - - async with turn_scope(handler=handler, conversation_id=conversation_id): - # Subscribe event buffer to capture agent lifecycle/preview events - event_buffer = AgentEventBufferHook() - dispatcher = get_current_dispatcher() - if dispatcher: - dispatcher.subscribe(event_buffer.handle_event) - - async with agent_span( - active_agent.name, instruction=user_content, agent_state=agent_state, profile_name=profile.name - ): - history.append({"role": "user", "content": user_content}) - # Build full system prompt: profile prompt + loaded skill prompts - full_prompt = active_agent.instruction - skill_prompt = agent_state.build_skill_prompt() - if skill_prompt: - full_prompt = full_prompt + "\n" + skill_prompt - _refresh_system_message(history, full_prompt) - - hooks = default_hooks( - active_agent, - max_iterations=active_agent.max_iterations, - ctx_manager=ctx_manager, - ) - - hooks.append( - PersistenceHook( - conversation_id=conv_id, - history=history, - ) - ) - - with suppress(StopRequestedError): - await run_turn( - history=history, - agent=active_agent, - hooks=hooks, - ) - - # Persist loaded skills so they survive across turns and restarts - if agent_state.loaded_skill_names: - try: - save_loaded_skills(conv_id, agent_state.loaded_skill_names) - except Exception: - logger.exception("Failed to save loaded skills for '%s'", conv_id) - - # Yield to event loop so call_soon callbacks (sync event handlers) - # have a chance to run before we read the buffer - await asyncio.sleep(0) - - # Save agent events after the turn (outside agent_span so completion is captured) - buffered_events = event_buffer.get_events() - if buffered_events: - try: - save_agent_events(conv_id, buffered_events) - logger.info("Saved %d agent events for conv=%s", len(buffered_events), conv_id) - except Exception: - logger.exception("Failed to save agent events for '%s'", conv_id) - - # Generate a title for new conversations after the first successful turn - if is_new_conversation and conversation_id: - task = asyncio.create_task(_generate_title(conversation_id, user_content)) - _background_tasks.add(task) - task.add_done_callback(_background_tasks.discard) - - -async def _generate_title(conversation_id: str, first_message: str) -> None: - """Generate and save a title for a new conversation.""" - try: - title = await generate_conversation_title(first_message) - save_conversation_title(conversation_id, title) - logger.info("Generated title for conversation %s: %r", conversation_id, title) - except Exception: - logger.exception("Failed to generate title for conversation %s", conversation_id) - - async def handle_user_message( message: str, data: Sequence[Data] | None = None, @@ -394,7 +165,7 @@ async def handle_user_message( if not conversation_id: msg = "conversation_id is required" raise ValueError(msg) - history, is_new_conversation = await _get_conversation(conversation_id) + conversation, is_new_conversation = await _cache.get(conversation_id) user_content = message if data: @@ -412,45 +183,26 @@ async def handle_user_message( msg = "No model configured. Complete the setup wizard to select a model." raise ValueError(msg) - try: - # Bridge published events via a queue so we can stream them to the caller. - queue: asyncio.Queue[AgentEvent | None] = asyncio.Queue() - - async def _queue_handler(evt: AgentEvent) -> None: - try: - await queue.put(evt) - except Exception: # pragma: no cover - defensive logging - logger.exception("Failed to enqueue AgentEvent in message handler") - - active_agent = _build_agent_from_profile(profile) + _log_turn_start(profile) - async def _producer() -> None: - try: - await _run_turn( - history=history, - active_agent=active_agent, - profile=profile, - user_content=user_content, - conversation_id=conversation_id, - handler=_queue_handler, - is_new_conversation=is_new_conversation, - ) - finally: - await queue.put(None) + if conversation.agent_state is None: + conversation.agent_state = await AgentState.create( + skill_names=[*profile.skills, *_persistence.load_skills(conversation.id)], + ) - producer_task = asyncio.create_task(_producer()) - try: - while True: - item = await queue.get() - if item is None: - break - yield item - finally: - if not producer_task.done(): - producer_task.cancel() - with suppress(Exception): - await producer_task + agent = build_agent(profile, tools=[run_bash_cmd, remember, forget]) + agent.instruction = memory_prompt_block() + agent.instruction + events: list[AgentEvent] = [] + try: + async for event in _turn_executor.execute( + conversation=conversation, + agent=agent, + user_content=user_content, + profile_name=profile.name, + ): + events.append(event) + yield event except Exception: logger.exception("Error handling user message") yield AgentEvent( @@ -460,3 +212,25 @@ async def _producer() -> None: ) ) yield AgentEvent(payload=TurnEndPayload(type="turn_end")) + return + + # Post-turn persistence. Errors logged, never raised — the user already + # got their reply. + try: + _persistence.save_events(conversation.id, events) + except Exception: + logger.exception("Failed to save agent events for '%s'", conversation.id) + if conversation.agent_state.loaded_skill_names: + try: + _persistence.save_skills( + conversation.id, + conversation.agent_state.loaded_skill_names, + ) + except Exception: + logger.exception("Failed to save loaded skills for '%s'", conversation.id) + if is_new_conversation: + task = asyncio.create_task( + _persistence.on_new_conversation(conversation.id, user_content), + ) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) diff --git a/server/ui/src/components/SystemSettings.jsx b/server/ui/src/components/SystemSettings.jsx index 03cc345c..c066f8f5 100644 --- a/server/ui/src/components/SystemSettings.jsx +++ b/server/ui/src/components/SystemSettings.jsx @@ -4,6 +4,7 @@ import ModelPicker from './ModelPicker.jsx'; import PackageIcon from './icons/PackageIcon'; import EyeIcon from './icons/EyeIcon'; import CompactionIcon from './icons/CompactionIcon'; +import SendIcon from './icons/SendIcon'; import ToggleSwitch from './ToggleSwitch.jsx'; import ChevronRightIcon from './icons/ChevronRightIcon'; @@ -11,23 +12,28 @@ export default function SystemSettings() { const [providers, setProviders] = useState([]); const [profiles, setProfiles] = useState([]); const [settings, setSettings] = useState({ default_agent: 'computron' }); + const [telegramIntegrations, setTelegramIntegrations] = useState([]); const [loading, setLoading] = useState(true); const [visionAdvancedOpen, setVisionAdvancedOpen] = useState(false); useEffect(() => { async function init() { try { - const [providersRes, settingsRes, profilesRes] = await Promise.all([ + const [providersRes, settingsRes, profilesRes, integrationsRes] = await Promise.all([ fetch('/api/providers'), fetch('/api/settings'), fetch('/api/profiles'), + fetch('/api/integrations'), ]); const providersData = await providersRes.json(); const settingsData = await settingsRes.json(); const profilesData = await profilesRes.json(); + const integrationsData = integrationsRes.ok ? await integrationsRes.json() : { integrations: [] }; setProviders(providersData.providers || []); setSettings(settingsData); setProfiles(profilesData); + const all = integrationsData.integrations || []; + setTelegramIntegrations(all.filter((i) => i.slug === 'telegram')); } catch { // keep defaults on error } finally { @@ -241,6 +247,60 @@ export default function SystemSettings() { /> + + {/* Notifications */} +
Notifications
+ +
+
+
+ +
+
+ Telegram + Push goal completion / failure messages to a Telegram chat via a bot integration. Leave either field blank to disable. +
+
+
+
+ Bot integration + Add a Telegram integration in the Integrations tab first, then pick it here. +
+ +
+
+
+ Chat ID + + Message your bot once, then visit{' '} + https://api.telegram.org/bot<TOKEN>/getUpdates{' '} + and copy chat.id. Group chat IDs are negative. + +
+ { + const raw = e.target.value; + const num = raw === '' ? null : Number(raw); + updateSetting('telegram_notifier_chat_id', num); + }} + /> +
+
); } diff --git a/server/ui/src/components/integrations/add-wizard/AddIntegrationModal.jsx b/server/ui/src/components/integrations/add-wizard/AddIntegrationModal.jsx index 5fc4f524..f28c4761 100644 --- a/server/ui/src/components/integrations/add-wizard/AddIntegrationModal.jsx +++ b/server/ui/src/components/integrations/add-wizard/AddIntegrationModal.jsx @@ -1,9 +1,14 @@ import { useEffect, useState } from 'react'; import styles from './add-wizard.module.css'; -import { slugifyEmail } from './providers.js'; +import { slugifyEmail, slugifyLabel } from './providers.js'; import { ProviderPicker, SuccessScreen } from './SharedSteps.jsx'; import { ExplainerStep, CredentialsStep, VerifyingStep } from './AppPasswordSteps.jsx'; +import { + ExplainerStep as BotExplainerStep, + CredentialsStep as BotCredentialsStep, + VerifyingStep as BotVerifyingStep, +} from './BotTokenSteps.jsx'; import { OauthCapabilitiesStep, OauthGcpSetupStep, OauthRedirectStep } from './OAuthSteps.jsx'; import { ExplainerStep as TokenExplainerStep, @@ -22,10 +27,16 @@ export default function AddIntegrationModal({ onClose, onAdded }) { const [provider, setProvider] = useState(null); const [step, setStep] = useState(1); const [form, setForm] = useState({ - email: '', - password: '', + // Shared label: '', permissions: {}, + // App-password flow + email: '', + password: '', + // Bot-token flow + token: '', + allowedUserIds: '', + instanceName: '', }); const [oauth, setOauth] = useState({ clientId: '', @@ -98,6 +109,59 @@ export default function AddIntegrationModal({ onClose, onAdded }) { } }; + const handleBotTokenSubmit = async () => { + setSubmitting(true); + setError(null); + setStep(3); + const token = form.token.trim().replace(/\s+/g, ''); + const allowedUserIds = form.allowedUserIds.trim(); + const instanceName = form.instanceName.trim() || 'personal'; + const userSuffix = slugifyLabel(instanceName); + if (!userSuffix) { + setError({code: 'BAD_REQUEST', message: 'Instance name produced an empty ID'}); + setSubmitting(false); + setStep(2); + return; + } + const label = form.label.trim() || `${provider.title} · ${instanceName}`; + try { + const resp = await fetch('/api/integrations', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + slug: provider.slug, + user_suffix: userSuffix, + label, + auth_blob: { + token, + allowed_user_ids: allowedUserIds, + }, + permissions: Object.fromEntries( + (provider.capabilities || []).map( + cap => [cap, form.permissions[cap] || 'rw'], + ), + ), + }), + }); + const body = await resp.json().catch(() => ({})); + if (!resp.ok) { + setError({ + code: body?.error?.code || 'ERROR', + message: body?.error?.message || `HTTP ${resp.status}`, + }); + setSubmitting(false); + setStep(2); + return; + } + setResult(body); + setSubmitting(false); + } catch (err) { + setError({code: 'NETWORK', message: err?.message || 'Request failed'}); + setSubmitting(false); + setStep(2); + } + }; + const handleTokenSubmit = async () => { setSubmitting(true); setError(null); @@ -299,7 +363,9 @@ export default function AddIntegrationModal({ onClose, onAdded }) { setResult(null); setStep(1); setForm({ - email: '', password: '', label: '', permissions: {}, + label: '', permissions: {}, + email: '', password: '', + token: '', allowedUserIds: '', instanceName: '', }); setOauth({ clientId: '', clientSecret: '', @@ -311,6 +377,26 @@ export default function AddIntegrationModal({ onClose, onAdded }) { }} onDone={() => { onAdded?.(); }} /> + ) : provider.authFlow === 'bot_token' ? ( + step === 1 ? ( + setProvider(null)} + onNext={() => setStep(2)} + /> + ) : step === 2 ? ( + setStep(1)} + onCancel={onClose} + onSubmit={handleBotTokenSubmit} + /> + ) : ( + + ) ) : provider.authFlow === 'token' ? ( step === 1 ? ( + +
+

Connect {provider.title}

+

+ You'll need a bot token from @BotFather and + {' '}your Telegram user ID from @userinfobot. +

+
+ +
+ + Token never leaves the broker process + + + User-ID allowlist enforced server-side + + + Revocable from @BotFather at any time + +
+
+
+
+ +
+ +
+
+ + ); +} + +export function CredentialsStep({ provider, form, setForm, error, onBack, onCancel, onSubmit }) { + const trimmedToken = form.token.trim(); + const trimmedAllowed = form.allowedUserIds.trim(); + const trimmedName = form.instanceName.trim(); + // user_suffix derives from the instance name. Empty defaults to "personal" + // — that's the most common single-bot case and removes a required field. + const effectiveName = trimmedName || 'personal'; + const userSuffix = slugifyLabel(effectiveName); + const canSubmit = trimmedToken && trimmedAllowed && userSuffix; + return ( + <> + +
+

Paste your bot details

+

+ Create a bot with @BotFather (run /newbot) and copy + its token. Get your user ID from @userinfobot. +

+
+ + + Open @BotFather + + t.me/BotFather + + + + Open @userinfobot (get your user ID) + + t.me/userinfobot + + +
+ + setForm(f => ({ ...f, token: e.target.value }))} + data-testid="wizard-bot-token" + /> + + Pasted verbatim from BotFather — whitespace is trimmed. + +
+ +
+ + setForm(f => ({ ...f, allowedUserIds: e.target.value }))} + data-testid="wizard-allowed-user-ids" + /> + + Comma-separated numeric IDs. Only senders on this list can + drive the bot — everyone else is silently dropped. + +
+ +
+ + setForm(f => ({ ...f, instanceName: e.target.value }))} + data-testid="wizard-instance-name" + /> + + How this Telegram setup is identified. Becomes + telegram_{userSuffix || 'personal'} in the + integration list. Leave blank for "personal". + +
+ +
Permissions
+
+ {(provider.capabilities || []).map(cap => ( +
+ + {CAP_LABELS[cap] || cap} + + +
+ ))} +
+ + + {error && (() => { + const copy = errorCopy(error, provider); + return ( + + ); + })()} +
+
+
+ +
+ + +
+
+ + ); +} + +export function VerifyingStep() { + return ( + <> + +
+

Connecting…

+

This usually takes a few seconds.

+
+
+
+
+ +
+
Securing your credentials
+
done
+
+
+
+ +
+
Authenticating with Telegram
+
+
+
+
+
+
+ +
+ + +
+
+ + ); +} diff --git a/server/ui/src/components/integrations/add-wizard/providers.js b/server/ui/src/components/integrations/add-wizard/providers.js index 9aea6fab..f0d6f733 100644 --- a/server/ui/src/components/integrations/add-wizard/providers.js +++ b/server/ui/src/components/integrations/add-wizard/providers.js @@ -25,6 +25,18 @@ export const PROVIDERS = [ emailPlaceholder: 'you@gmail.com', capabilities: ['email'], }, + { + slug: 'telegram', + authFlow: 'bot_token', + category: 'Messaging', + title: 'Telegram', + description: 'Bidirectional bot + push notifications', + icon: 'bi-telegram', + vendor: 'Telegram', + botFatherUrl: 'https://t.me/BotFather', + userInfoBotUrl: 'https://t.me/userinfobot', + capabilities: ['telegram'], + }, { slug: 'google_workspace', authFlow: 'oauth_device', @@ -84,6 +96,7 @@ export const PROVIDERS = [ export function errorCopy(error, provider) { const vendor = provider?.vendor ?? provider?.title ?? 'this provider'; const isOauth = provider?.authFlow === 'oauth_device'; + const isBotToken = provider?.authFlow === 'bot_token'; const isToken = provider?.authFlow === 'token'; switch (error?.code) { case 'AUTH': @@ -106,6 +119,15 @@ export function errorCopy(error, provider) { + '(Google Auth Platform → Audience → Publish app).', }; } + if (isBotToken) { + return { + title: `${vendor} rejected the bot token`, + description: + 'The token from BotFather may be mistyped or revoked. ' + + 'Open @BotFather, run /mybots, pick your bot, and ' + + '"API Token" to copy a fresh token.', + }; + } return { title: `${vendor} rejected the password`, description: @@ -143,9 +165,12 @@ export function slugifyEmail(email) { return local.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48); } -// Token integrations have no email to derive an ID from, so the label -// becomes the user-suffix. Same sanitizer rules as slugifyEmail. +// Token / bot_token integrations have no email to derive an ID from, so +// the label becomes the user-suffix. Same sanitizer rules as slugifyEmail. export function slugifyLabel(label) { if (!label) return ''; - return label.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48); + return label.toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); } diff --git a/settings.py b/settings.py index b16ed952..a75700d6 100644 --- a/settings.py +++ b/settings.py @@ -52,6 +52,10 @@ }, "title_provider": "", "title_model": "", + # Goal-run push notifications. Empty integration_id or null chat_id + # leaves the notifier disabled. + "telegram_notifier_integration_id": "", + "telegram_notifier_chat_id": None, } # Metadata service IPs that must never be reachable via user-supplied URLs. @@ -89,6 +93,8 @@ class SettingsUpdate(BaseModel): compaction_options: dict[str, Any] | None = None title_provider: str | None = None title_model: str | None = None + telegram_notifier_integration_id: str | None = None + telegram_notifier_chat_id: int | None = None @field_validator("direct_providers") @classmethod diff --git a/tasks/_executor.py b/tasks/_executor.py index bad97aae..4a69f76c 100644 --- a/tasks/_executor.py +++ b/tasks/_executor.py @@ -7,16 +7,13 @@ from agents import build_agent, get_agent_profile from agents.types import Agent -from sdk import PersistenceHook, default_hooks, run_turn -from sdk.context import ContextManager, ConversationHistory, LLMCompactionStrategy -from sdk.events._context import agent_span, get_current_dispatcher -from sdk.events._models import FileOutputPayload +from sdk import Conversation, TurnExecutor +from sdk.context import ConversationHistory +from sdk.events._models import ContentPayload, FileOutputPayload from sdk.skills import AgentState, get_skill from sdk.tools._core import get_core_tools -from sdk.turn import turn_scope if TYPE_CHECKING: - from sdk.events._models import AgentEvent from tasks._models import Goal, Task, TaskResult from tasks._store import TaskStore @@ -28,6 +25,7 @@ class TaskExecutor: def __init__(self, store: TaskStore) -> None: self._store = store + self._executor = TurnExecutor() async def run(self, task_result: TaskResult, task: Task) -> tuple[str, list[str]]: """Execute a task and return (result_text, file_output_paths).""" @@ -44,47 +42,37 @@ async def run(self, task_result: TaskResult, task: Task) -> tuple[str, list[str] conversation_id = f"goals/{run.goal_id}/{run.id}/{task_result.id}" self._store.set_conversation_id(task_result.id, conversation_id) - agent = await self._build_agent(task) + agent, agent_state = await self._build_agent(task) - history = ConversationHistory( - [ - {"role": "system", "content": agent.instruction}, - {"role": "user", "content": instruction}, - ], - instance_id=conversation_id, + conversation = Conversation( + id=conversation_id, + history=ConversationHistory(instance_id=conversation_id), + agent_state=agent_state, ) + accumulated_text: list[str] = [] file_paths: list[str] = [] - def _capture_file_output(event: AgentEvent) -> None: - if isinstance(event.payload, FileOutputPayload) and event.payload.path: - file_paths.append(event.payload.path) - - async with turn_scope(conversation_id=conversation_id): - dispatcher = get_current_dispatcher() - if dispatcher: - dispatcher.subscribe(_capture_file_output) - state = AgentState(await get_core_tools() + (agent.tools or [])) - ctx_manager = ContextManager( - history=history, - agent_state=state, - context_limit=agent.context_window, - agent_name=agent.name, - strategies=[ - LLMCompactionStrategy(threshold=agent.compaction_threshold), - ], - ) - hooks = default_hooks(agent, max_iterations=agent.max_iterations, ctx_manager=ctx_manager) - hooks.append( - PersistenceHook(conversation_id=conversation_id, history=history) - ) - async with agent_span(agent.name, instruction=instruction, agent_state=state): - result = await run_turn(history, agent, hooks=hooks) - - return result or "", file_paths - - async def _build_agent(self, task: Task) -> Agent: - """Construct an Agent from the task's agent profile.""" + async for event in self._executor.execute( + conversation=conversation, + agent=agent, + user_content=instruction, + ): + payload = event.payload + if isinstance(payload, ContentPayload) and payload.content: + accumulated_text.append(payload.content) + elif isinstance(payload, FileOutputPayload) and payload.path: + file_paths.append(payload.path) + + return "".join(accumulated_text), file_paths + + async def _build_agent(self, task: Task) -> tuple[Agent, AgentState]: + """Construct an ``Agent`` and matching ``AgentState`` for the task. + + Strict on missing skills: a profile that references an unregistered + skill fails the task synchronously with a clear message rather than + running with a silently degraded tool set. + """ if not task.agent_profile: msg = f"Task {task.id} has no agent_profile set" raise RuntimeError(msg) @@ -93,15 +81,16 @@ async def _build_agent(self, task: Task) -> Agent: msg = f"Agent profile '{task.agent_profile}' not found for task {task.id}" raise RuntimeError(msg) - agent_state = AgentState(await get_core_tools()) + state = AgentState(await get_core_tools()) for skill_name in profile.skills: skill = get_skill(skill_name) if skill is None: msg = f"Profile '{profile.id}' references unregistered skill '{skill_name}'" raise RuntimeError(msg) - agent_state.add(skill) + state.add(skill) - return build_agent(profile, tools=agent_state.tools, name="TASK_AGENT") + agent = build_agent(profile, tools=state.tools, name="TASK_AGENT") + return agent, state def _build_instruction( self, task_result: TaskResult, task: Task, goal: Goal diff --git a/tasks/_notifier.py b/tasks/_notifier.py index 135028b7..f12e5192 100644 --- a/tasks/_notifier.py +++ b/tasks/_notifier.py @@ -1,44 +1,58 @@ -"""Telegram push notifications for goal run completion/failure.""" +"""Telegram push notifications for goal run completion/failure. + +Sends messages through the telegram broker (same broker the bidirectional +channel uses); no Telegram credentials live in this process. +""" from __future__ import annotations import logging -import os from pathlib import Path from typing import TYPE_CHECKING -import httpx +from integrations.broker_client import IntegrationError, call as broker_call +from settings import load_settings if TYPE_CHECKING: from config import NotificationsConfig logger = logging.getLogger(__name__) +# Matches Telegram's per-message text cap; we truncate locally so the broker +# returns a clean message_id rather than rejecting a 4097-character payload. _TELEGRAM_MSG_LIMIT = 4096 class TelegramNotifier: - """Sends messages and file attachments to Telegram via the Bot API. + """Sends notification messages to Telegram via the broker. - Reads TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID from environment variables. - If either is missing, the notifier disables itself with a warning. - All public methods are fire-and-forget — errors are logged, never raised. + Configuration is read from app settings (``telegram_notifier_integration_id`` + and ``telegram_notifier_chat_id``) on construction. If either is blank, + the notifier disables itself with a warning. All public methods are + fire-and-forget — errors are logged, never raised. """ - def __init__(self, config: NotificationsConfig) -> None: + def __init__( + self, + config: NotificationsConfig, + *, + app_sock_path: Path, + ) -> None: self._config = config - token = os.environ.get("TELEGRAM_BOT_TOKEN", "") - self._chat_id = os.environ.get("TELEGRAM_CHAT_ID", "") - if not token or not self._chat_id: + self._app_sock = app_sock_path + settings = load_settings() + self._integration_id = settings.get("telegram_notifier_integration_id") or "" + chat_id = settings.get("telegram_notifier_chat_id") + self._chat_id: int | None = chat_id if isinstance(chat_id, int) else None + + if not self._integration_id or self._chat_id is None: logger.warning( - "TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID not set — " - "Telegram notifications disabled" + "Telegram notifier integration_id or chat_id not configured — " + "notifications disabled", ) self._disabled = True - self._base_url = "" - return - self._disabled = False - self._base_url = f"https://api.telegram.org/bot{token}" + else: + self._disabled = False @property def enabled(self) -> bool: @@ -54,54 +68,30 @@ async def send( return try: await self._send_message(message) - for path in attachments or []: - await self._send_document(path) except Exception: logger.exception("Failed to send Telegram notification") + return + + for path in attachments or []: + # Document uploads through the broker are pending; surface the + # skip rather than silently dropping the file references. + logger.warning( + "telegram attachment not sent (broker send_document not " + "implemented yet): %s", path, + ) async def _send_message(self, text: str) -> None: if len(text) > _TELEGRAM_MSG_LIMIT: text = text[: _TELEGRAM_MSG_LIMIT - 30] + "\n\n… (truncated)" - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.post( - f"{self._base_url}/sendMessage", - json={ - "chat_id": self._chat_id, - "text": text, - }, - ) - if resp.status_code != 200: - logger.error( - "Telegram sendMessage failed (%d): %s", - resp.status_code, - resp.text, - ) - - async def _send_document(self, path: Path) -> None: - max_bytes = self._config.max_attachment_size_mb * 1024 * 1024 - if not path.is_file(): - logger.warning("Attachment not found, skipping: %s", path) - return - if path.stat().st_size > max_bytes: - logger.warning( - "Attachment too large (%d MB limit), skipping: %s", - self._config.max_attachment_size_mb, - path, + try: + await broker_call( + self._integration_id, + "send_message", + {"chat_id": self._chat_id, "text": text}, + app_sock_path=self._app_sock, ) - return - async with httpx.AsyncClient(timeout=120) as client: - with open(path, "rb") as f: - resp = await client.post( - f"{self._base_url}/sendDocument", - data={"chat_id": self._chat_id}, - files={"document": (path.name, f)}, - ) - if resp.status_code != 200: - logger.error( - "Telegram sendDocument failed (%d): %s", - resp.status_code, - resp.text, - ) + except IntegrationError as exc: + logger.error("Telegram send_message failed: %s", exc) def format_run_completed( @@ -115,8 +105,8 @@ def format_run_completed( ) -> str: """Format a success notification message.""" lines = [ - f"\u2705 Goal completed: {goal_description}", - f"Run #{run_number} \u00b7 {duration} \u00b7 {completed_tasks}/{total_tasks} tasks", + f"✅ Goal completed: {goal_description}", + f"Run #{run_number} · {duration} · {completed_tasks}/{total_tasks} tasks", "", ] if final_output: @@ -140,8 +130,8 @@ def format_run_failed( ) -> str: """Format a failure notification message.""" lines = [ - f"\u274c Goal failed: {goal_description}", - f"Run #{run_number} \u00b7 {duration} \u00b7 {completed_tasks}/{total_tasks} tasks completed", + f"❌ Goal failed: {goal_description}", + f"Run #{run_number} · {duration} · {completed_tasks}/{total_tasks} tasks completed", "", f"Error (task: {failed_task_description}):", error, diff --git a/tests/unit/channels/__init__.py b/tests/unit/channels/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/channels/telegram/__init__.py b/tests/unit/channels/telegram/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/channels/telegram/test_formatter.py b/tests/unit/channels/telegram/test_formatter.py new file mode 100644 index 00000000..97eecdc5 --- /dev/null +++ b/tests/unit/channels/telegram/test_formatter.py @@ -0,0 +1,96 @@ +"""Tests for channels.telegram._formatter.TelegramFormatter.""" + +from pathlib import Path + +import pytest + +from channels.telegram._formatter import TelegramFormatter + + +# --------------------------------------------------------------------------- +# to_markdownv2_chunks() — CommonMark → MarkdownV2 conversion + chunking +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_short_text_returns_single_chunk(): + chunks = TelegramFormatter.to_markdownv2_chunks("hello world") + assert len(chunks) == 1 + + +@pytest.mark.unit +def test_bold_double_asterisk_converted_to_single_asterisk(): + """Agent emits ``**bold**`` (CommonMark); MarkdownV2 uses ``*bold*``.""" + chunks = TelegramFormatter.to_markdownv2_chunks("hello **world**") + body = "".join(chunks) + assert "*world*" in body + assert "**world**" not in body + + +@pytest.mark.unit +def test_italic_underscore_in_output(): + """``*italic*`` in CommonMark becomes ``_italic_`` in MarkdownV2.""" + chunks = TelegramFormatter.to_markdownv2_chunks("an *italic* word") + body = "".join(chunks) + assert "_italic_" in body + + +@pytest.mark.unit +def test_inline_code_preserved(): + chunks = TelegramFormatter.to_markdownv2_chunks("call `do_thing()` now") + body = "".join(chunks) + assert "`do_thing" in body + + +@pytest.mark.unit +def test_fenced_code_block_preserved(): + text = "before\n\n```python\ndef hi(): pass\n```\n\nafter" + chunks = TelegramFormatter.to_markdownv2_chunks(text) + body = "".join(chunks) + assert "```" in body + assert "def hi" in body + + +@pytest.mark.unit +def test_punctuation_escaped_outside_code(): + """MarkdownV2 requires escaping bare ``.`` and ``(`` ``)`` in body text.""" + chunks = TelegramFormatter.to_markdownv2_chunks("hello (world).") + body = "".join(chunks) + assert "\\(" in body + assert "\\)" in body + assert "\\." in body + + +@pytest.mark.unit +def test_long_text_is_split_under_4096_each(): + """Output chunks fit Telegram's 4096-byte message limit.""" + # Build something well past 4096 chars with plenty of structure so the + # splitter has natural boundaries to cut on. + text = ("paragraph with some words here.\n\n" * 300) + chunks = TelegramFormatter.to_markdownv2_chunks(text) + assert len(chunks) >= 2 + for chunk in chunks: + assert len(chunk.encode("utf-16-le")) // 2 <= 4096 + + +# --------------------------------------------------------------------------- +# file_caption() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_single_file_caption(): + caption = TelegramFormatter.file_caption(Path("report.pdf")) + assert caption == "📎 report.pdf" + + +@pytest.mark.unit +def test_multi_file_caption_includes_index(): + caption = TelegramFormatter.file_caption( + Path("/tmp/a.txt"), index=0, total=3, + ) + assert caption == "📎 a.txt (1/3)" + caption = TelegramFormatter.file_caption( + Path("/tmp/b.txt"), index=2, total=3, + ) + assert caption == "📎 b.txt (3/3)" diff --git a/tests/unit/channels/telegram/test_list_helpers.py b/tests/unit/channels/telegram/test_list_helpers.py new file mode 100644 index 00000000..208d1a0e --- /dev/null +++ b/tests/unit/channels/telegram/test_list_helpers.py @@ -0,0 +1,73 @@ +"""Tests for the /list helpers in channels.telegram._channel.""" + +from __future__ import annotations + +import pytest + +from channels.telegram._channel import _matches_query, _row_label +from conversations._models import ConversationSummary + + +def _summary( + *, + conversation_id: str = "telegram_42", + title: str = "", + first_message: str = "", +) -> ConversationSummary: + return ConversationSummary( + conversation_id=conversation_id, + title=title, + first_message=first_message, + started_at="2026-05-25T00:00:00+00:00", + turn_count=1, + ) + + +@pytest.mark.unit +class TestMatchesQuery: + + def test_matches_title_case_insensitive(self): + s = _summary(title="Trip planning") + assert _matches_query(s, "trip") is True + assert _matches_query(s, "TRIP") is True + assert _matches_query(s, "PLAN") is True + + def test_matches_first_message(self): + s = _summary(first_message="What's the weather in Paris?") + assert _matches_query(s, "weather") is True + assert _matches_query(s, "paris") is True + + def test_no_match(self): + s = _summary(title="Trip planning", first_message="Looking at flights") + assert _matches_query(s, "taxes") is False + + def test_empty_fields_do_not_match_arbitrary_query(self): + s = _summary() + assert _matches_query(s, "anything") is False + + +@pytest.mark.unit +class TestRowLabel: + + def test_title_used_when_present(self): + s = _summary(title="Trip planning") + assert _row_label(s, is_current=False) == "Trip planning" + + def test_first_message_fallback_when_no_title(self): + s = _summary(first_message="hello world") + assert _row_label(s, is_current=False) == "(hello world…)" + + def test_empty_fallback(self): + s = _summary(conversation_id="telegram_42_x") + assert _row_label(s, is_current=False) == "(empty • telegram_42_x)" + + def test_current_marker_prepended(self): + s = _summary(title="Now this one") + assert _row_label(s, is_current=True) == "✓ Now this one" + + def test_label_clipped_to_telegram_button_safe_length(self): + s = _summary(title="x" * 200) + assert len(_row_label(s, is_current=False)) <= 64 + s_current = _summary(title="x" * 200) + # current adds ✓ and a space; still keeps body to 60 chars. + assert _row_label(s_current, is_current=True).startswith("✓ ") diff --git a/tests/unit/channels/telegram/test_profile_map.py b/tests/unit/channels/telegram/test_profile_map.py new file mode 100644 index 00000000..44da53b1 --- /dev/null +++ b/tests/unit/channels/telegram/test_profile_map.py @@ -0,0 +1,41 @@ +"""Tests for channels.telegram._profile_map.ProfileMap.""" + +import pytest + +from channels.telegram._profile_map import ProfileMap + + +@pytest.mark.unit +class TestProfileMap: + + def test_unknown_chat_returns_none(self): + pmap = ProfileMap() + assert pmap.get(404) is None + + def test_set_then_get(self): + pmap = ProfileMap() + pmap.set(7, "code_expert") + assert pmap.get(7) == "code_expert" + + def test_set_overwrites_previous_choice(self): + pmap = ProfileMap() + pmap.set(7, "first") + pmap.set(7, "second") + assert pmap.get(7) == "second" + + def test_clear_drops_choice(self): + pmap = ProfileMap() + pmap.set(7, "x") + pmap.clear(7) + assert pmap.get(7) is None + + def test_clear_unknown_chat_is_a_no_op(self): + pmap = ProfileMap() + pmap.clear(404) # should not raise + + def test_independent_chats(self): + pmap = ProfileMap() + pmap.set(1, "a") + pmap.set(2, "b") + assert pmap.get(1) == "a" + assert pmap.get(2) == "b" diff --git a/tests/unit/channels/telegram/test_state.py b/tests/unit/channels/telegram/test_state.py new file mode 100644 index 00000000..7ed22822 --- /dev/null +++ b/tests/unit/channels/telegram/test_state.py @@ -0,0 +1,72 @@ +"""Tests for channels.telegram._state.ConversationMap.""" + +import pytest + +from channels.telegram._state import ConversationMap + + +@pytest.mark.unit +class TestConversationMap: + + def test_get_creates_default_conv_id_on_first_access(self): + cmap = ConversationMap() + cid = cmap.get(12345) + assert cid == "telegram_12345" + + def test_get_is_idempotent_for_same_chat(self): + cmap = ConversationMap() + first = cmap.get(7) + second = cmap.get(7) + assert first == second + + def test_different_chats_get_distinct_ids(self): + cmap = ConversationMap() + a = cmap.get(1) + b = cmap.get(2) + assert a != b + + def test_reset_assigns_new_unique_id_with_chat_id_prefix(self): + cmap = ConversationMap() + original = cmap.get(42) + reset = cmap.reset(42) + + assert reset != original + # Still keyed back to the chat — the prefix preserves traceability. + assert reset.startswith("telegram_42_") + # Subsequent get returns the reset id, not the default. + assert cmap.get(42) == reset + + def test_reset_is_unique_across_resets_on_same_chat(self): + cmap = ConversationMap() + cmap.get(99) + first_reset = cmap.reset(99) + second_reset = cmap.reset(99) + assert first_reset != second_reset + + def test_conversation_id_for_returns_none_when_unknown(self): + cmap = ConversationMap() + assert cmap.conversation_id_for(404) is None + + def test_conversation_id_for_returns_current_id_without_creating(self): + cmap = ConversationMap() + assert cmap.conversation_id_for(8) is None # not created + cmap.get(8) + assert cmap.conversation_id_for(8) == "telegram_8" + + def test_conversation_id_for_reflects_reset(self): + cmap = ConversationMap() + cmap.get(15) + new = cmap.reset(15) + assert cmap.conversation_id_for(15) == new + + def test_set_binds_chat_to_existing_id(self): + cmap = ConversationMap() + cmap.set(42, "telegram_42_abc123") + assert cmap.get(42) == "telegram_42_abc123" + + def test_set_overwrites_any_previous_binding(self): + cmap = ConversationMap() + cmap.get(42) # default id + cmap.reset(42) # uuid id + cmap.set(42, "telegram_42_explicit") # explicit override + assert cmap.conversation_id_for(42) == "telegram_42_explicit" diff --git a/tests/unit/conversations/__init__.py b/tests/unit/conversations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/conversations/test_cache.py b/tests/unit/conversations/test_cache.py new file mode 100644 index 00000000..08a7ea42 --- /dev/null +++ b/tests/unit/conversations/test_cache.py @@ -0,0 +1,232 @@ +"""Unit tests for ``conversations.ConversationCache``.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest + +from conversations import ConversationCache +from conversations._store import save_conversation_history +from sdk import Conversation +from sdk.context import ConversationHistory + + +@pytest.fixture +async def cache() -> AsyncIterator[ConversationCache]: + """A fresh cache, no active-turn awareness, no on-evict side effect.""" + c = ConversationCache() + yield c + c.clear() + + +# --------------------------------------------------------------------------- +# get() — hydration semantics +# --------------------------------------------------------------------------- + + +async def test_get_cold_no_disk_creates_empty_and_marks_new(cache: ConversationCache) -> None: + conv, is_new = await cache.get("brand-new-id") + assert len(conv.history) == 0 + assert conv.history.instance_id == "brand-new-id" + assert is_new is True + + +async def test_get_cold_with_disk_hydrates_and_marks_not_new(cache: ConversationCache) -> None: + save_conversation_history("existing", [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ]) + conv, is_new = await cache.get("existing") + assert len(conv.history) == 2 + loaded = conv.history.messages + assert loaded[0]["content"] == "hello" + assert loaded[1]["content"] == "hi" + assert is_new is False + + +async def test_warm_cache_wins_over_disk(cache: ConversationCache) -> None: + """An in-memory entry wins over whatever is on disk and is_new=False.""" + cached = Conversation( + id="cid", + history=ConversationHistory( + [{"role": "user", "content": "from-memory"}], + instance_id="cid", + ), + ) + cache._entries["cid"] = cached + save_conversation_history("cid", [{"role": "user", "content": "from-disk"}]) + + conv, is_new = await cache.get("cid") + assert conv is cached + assert conv.history.messages[0]["content"] == "from-memory" + assert is_new is False + + +async def test_subsequent_call_returns_same_instance(cache: ConversationCache) -> None: + first, first_new = await cache.get("same-id") + second, second_new = await cache.get("same-id") + assert first is second + assert first_new is True + assert second_new is False + + +async def test_empty_id_raises(cache: ConversationCache) -> None: + with pytest.raises(ValueError, match="conversation_id is required"): + await cache.get("") + + +async def test_corrupted_history_falls_back_to_empty( + cache: ConversationCache, tmp_path: Path, +) -> None: + from conversations._store import _get_conversations_dir + + cid = "corrupted" + conv_dir = _get_conversations_dir() / cid + conv_dir.mkdir(parents=True) + (conv_dir / "history.json").write_text("{not valid json", encoding="utf-8") + + conv, is_new = await cache.get(cid) + assert len(conv.history) == 0 + assert is_new is True + + +# --------------------------------------------------------------------------- +# LRU + eviction +# --------------------------------------------------------------------------- + + +async def test_lru_evicts_oldest_when_cap_exceeded() -> None: + cache = ConversationCache(max_size=3) + for cid in ("a", "b", "c"): + await cache.get(cid) + assert list(cache) == ["a", "b", "c"] + + await cache.get("d") + assert "a" not in cache + assert list(cache) == ["b", "c", "d"] + + +async def test_lru_access_promotes_to_most_recently_used() -> None: + cache = ConversationCache(max_size=3) + for cid in ("a", "b", "c"): + await cache.get(cid) + + # Touch 'a' — should become most-recently-used. + await cache.get("a") + assert list(cache) == ["b", "c", "a"] + + # Inserting a fourth should now evict 'b', not 'a'. + await cache.get("d") + assert "b" not in cache + assert "a" in cache + + +async def test_lru_skips_active_turn() -> None: + active = {"a"} + cache = ConversationCache(max_size=2, is_active=lambda cid: cid in active) + + await cache.get("a") + await cache.get("b") + assert list(cache) == ["a", "b"] + + # Inserting 'c' would normally evict 'a' (oldest). Active-skip jumps + # over 'a' and evicts 'b' instead. + await cache.get("c") + assert "a" in cache + assert "b" not in cache + assert "c" in cache + + +async def test_lru_overflow_when_all_active() -> None: + cache = ConversationCache(max_size=2, is_active=lambda _cid: True) + await cache.get("a") + await cache.get("b") + await cache.get("c") + assert len(cache) == 3 + assert set(cache) == {"a", "b", "c"} + + +async def test_lru_does_not_evict_just_inserted_when_others_active() -> None: + """Just-inserted survives even when every existing entry is mid-turn.""" + pinned = {"a", "b"} + cache = ConversationCache(max_size=2, is_active=lambda cid: cid in pinned) + await cache.get("a") + await cache.get("b") + await cache.get("c") + assert "c" in cache + assert set(cache) == {"a", "b", "c"} + + +# --------------------------------------------------------------------------- +# on_evict callback +# --------------------------------------------------------------------------- + + +async def test_on_evict_fires_with_evicted_id() -> None: + evicted: list[str] = [] + + async def _on_evict(cid: str) -> None: + evicted.append(cid) + + cache = ConversationCache(max_size=2, on_evict=_on_evict) + await cache.get("a") + await cache.get("b") + await cache.get("c") + assert evicted == ["a"] + + +# --------------------------------------------------------------------------- +# resume() — force-reload +# --------------------------------------------------------------------------- + + +async def test_resume_returns_none_for_missing(cache: ConversationCache) -> None: + assert await cache.resume("nope") is None + + +async def test_resume_hydrates_and_marks_most_recently_used() -> None: + cache = ConversationCache(max_size=3) + await cache.get("a") + save_conversation_history( + "from-disk", [{"role": "user", "content": "hi"}], + ) + conv = await cache.resume("from-disk") + assert conv is not None + # 'from-disk' is now most-recently-used. + assert list(cache)[-1] == "from-disk" + + +async def test_resume_overwrites_cached_entry(cache: ConversationCache) -> None: + """resume reloads from disk even if a cached entry exists for the id.""" + save_conversation_history( + "twice", [{"role": "user", "content": "first version"}], + ) + first = await cache.resume("twice") + assert first is not None + assert first.history.messages[0]["content"] == "first version" + + save_conversation_history( + "twice", [{"role": "user", "content": "second version"}], + ) + second = await cache.resume("twice") + assert second is not None + assert second is not first + assert second.history.messages[0]["content"] == "second version" + + +# --------------------------------------------------------------------------- +# pop() +# --------------------------------------------------------------------------- + + +async def test_pop_drops_entry(cache: ConversationCache) -> None: + await cache.get("a") + assert "a" in cache + cache.pop("a") + assert "a" not in cache + + +def test_pop_unknown_id_is_a_no_op(cache: ConversationCache) -> None: + cache.pop("never-existed") # should not raise diff --git a/tests/unit/integrations/brokers/telegram_broker/__init__.py b/tests/unit/integrations/brokers/telegram_broker/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/integrations/brokers/telegram_broker/test_main.py b/tests/unit/integrations/brokers/telegram_broker/test_main.py new file mode 100644 index 00000000..126990e9 --- /dev/null +++ b/tests/unit/integrations/brokers/telegram_broker/test_main.py @@ -0,0 +1,42 @@ +"""Tests for integrations.brokers.telegram_broker.__main__ helpers.""" + +import pytest + +from integrations.brokers.telegram_broker.__main__ import _parse_allowed_user_ids + + +@pytest.mark.unit +class TestParseAllowedUserIds: + + def test_empty_string_yields_empty_set(self): + assert _parse_allowed_user_ids("") == frozenset() + + def test_whitespace_only_yields_empty_set(self): + assert _parse_allowed_user_ids(" , ,") == frozenset() + + def test_single_id(self): + assert _parse_allowed_user_ids("12345") == frozenset({12345}) + + def test_comma_separated_ids(self): + assert _parse_allowed_user_ids("1,2,3") == frozenset({1, 2, 3}) + + def test_strips_whitespace_around_entries(self): + assert _parse_allowed_user_ids(" 1 , 2,3 ") == frozenset({1, 2, 3}) + + def test_skips_blank_entries(self): + assert _parse_allowed_user_ids("1,,2,,,3") == frozenset({1, 2, 3}) + + def test_skips_non_integer_entries(self, caplog): + out = _parse_allowed_user_ids("1,bogus,2,also-bad,3") + assert out == frozenset({1, 2, 3}) + # Confirm we logged each bad entry (presence, not specific text). + assert any("bogus" in rec.getMessage() for rec in caplog.records) + assert any("also-bad" in rec.getMessage() for rec in caplog.records) + + def test_deduplicates(self): + assert _parse_allowed_user_ids("7,7,7,42") == frozenset({7, 42}) + + def test_negative_ids_accepted(self): + # Group chat IDs are negative in Telegram; we don't filter them out + # at the parser level — that's policy belonging higher up. + assert _parse_allowed_user_ids("-100,42") == frozenset({-100, 42}) diff --git a/tests/unit/integrations/brokers/telegram_broker/test_updates.py b/tests/unit/integrations/brokers/telegram_broker/test_updates.py new file mode 100644 index 00000000..9f2e308f --- /dev/null +++ b/tests/unit/integrations/brokers/telegram_broker/test_updates.py @@ -0,0 +1,281 @@ +"""Tests for integrations.brokers.telegram_broker._updates.""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from integrations.brokers.telegram_broker._updates import ( + UpdatePump, + update_to_dict, +) + + +# --------------------------------------------------------------------------- +# Fake update objects — shape matches the aiogram attributes we read. +# --------------------------------------------------------------------------- + + +@dataclass +class _FakeUser: + id: int + username: str | None = None + + +@dataclass +class _FakeChat: + id: int + + +@dataclass +class _FakeMessage: + message_id: int + chat: _FakeChat + from_user: _FakeUser | None + text: str | None + date: datetime | None = field(default_factory=lambda: datetime(2026, 5, 22, tzinfo=timezone.utc)) + + +@dataclass +class _FakeCallbackQuery: + id: str + from_user: _FakeUser | None + data: str | None + message: _FakeMessage | None + + +@dataclass +class _FakeUpdate: + update_id: int + message: _FakeMessage | None = None + callback_query: _FakeCallbackQuery | None = None + + +# --------------------------------------------------------------------------- +# update_to_dict +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestUpdateToDict: + + def test_text_message_maps_to_wire_dict(self): + msg = _FakeMessage( + message_id=10, + chat=_FakeChat(id=42), + from_user=_FakeUser(id=99, username="alice"), + text="hello", + ) + out = update_to_dict(_FakeUpdate(update_id=1, message=msg)) + assert out is not None + assert out["type"] == "message" + assert out["message_id"] == 10 + assert out["chat_id"] == 42 + assert out["from_user_id"] == 99 + assert out["from_username"] == "alice" + assert out["text"] == "hello" + assert out["is_command"] is False + assert isinstance(out["timestamp"], int) + + def test_slash_text_marked_as_command(self): + msg = _FakeMessage( + message_id=1, chat=_FakeChat(id=1), + from_user=_FakeUser(id=1), text="/new", + ) + out = update_to_dict(_FakeUpdate(update_id=1, message=msg)) + assert out is not None + assert out["is_command"] is True + + def test_no_message_returns_none(self): + # Update without a message field (e.g. a callback_query in real Telegram). + out = update_to_dict(_FakeUpdate(update_id=1, message=None)) + assert out is None + + def test_message_without_text_returns_none(self): + # Photo/document/sticker — no .text, dropped at this layer. + msg = _FakeMessage( + message_id=1, chat=_FakeChat(id=1), + from_user=_FakeUser(id=1), text=None, + ) + assert update_to_dict(_FakeUpdate(update_id=1, message=msg)) is None + + def test_message_without_from_user_returns_none(self): + # Service messages (channel posts, etc.) can lack from_user. + msg = _FakeMessage( + message_id=1, chat=_FakeChat(id=1), + from_user=None, text="hi", + ) + assert update_to_dict(_FakeUpdate(update_id=1, message=msg)) is None + + def test_falls_back_to_now_when_no_date(self): + msg = _FakeMessage( + message_id=1, chat=_FakeChat(id=1), + from_user=_FakeUser(id=1), text="hi", date=None, + ) + before = int(time.time()) + out = update_to_dict(_FakeUpdate(update_id=1, message=msg)) + after = int(time.time()) + assert out is not None + assert before <= out["timestamp"] <= after + + def test_callback_query_maps_to_wire_dict(self): + carrier_msg = _FakeMessage( + message_id=200, chat=_FakeChat(id=42), + from_user=_FakeUser(id=99), text="Pick:", + ) + cb = _FakeCallbackQuery( + id="cbq-1", + from_user=_FakeUser(id=99, username="alice"), + data="profile:foo", + message=carrier_msg, + ) + out = update_to_dict(_FakeUpdate(update_id=1, callback_query=cb)) + assert out is not None + assert out["type"] == "callback_query" + assert out["callback_id"] == "cbq-1" + assert out["from_user_id"] == 99 + assert out["from_username"] == "alice" + assert out["data"] == "profile:foo" + assert out["chat_id"] == 42 + assert out["message_id"] == 200 + + def test_callback_without_from_user_is_dropped(self): + carrier = _FakeMessage( + message_id=1, chat=_FakeChat(id=1), + from_user=_FakeUser(id=1), text="x", + ) + cb = _FakeCallbackQuery(id="x", from_user=None, data="d", message=carrier) + assert update_to_dict(_FakeUpdate(update_id=1, callback_query=cb)) is None + + def test_callback_without_data_is_dropped(self): + carrier = _FakeMessage( + message_id=1, chat=_FakeChat(id=1), + from_user=_FakeUser(id=1), text="x", + ) + cb = _FakeCallbackQuery( + id="x", from_user=_FakeUser(id=1), data=None, message=carrier, + ) + assert update_to_dict(_FakeUpdate(update_id=1, callback_query=cb)) is None + + def test_callback_without_carrier_message_is_dropped(self): + cb = _FakeCallbackQuery( + id="x", from_user=_FakeUser(id=1), data="d", message=None, + ) + assert update_to_dict(_FakeUpdate(update_id=1, callback_query=cb)) is None + + +# --------------------------------------------------------------------------- +# UpdatePump._handle_update — the security filter +# --------------------------------------------------------------------------- + + +def _make_pump(allowed_ids: frozenset[int], *, downloads_dir=None) -> UpdatePump: + """UpdatePump with a stubbed Bot — _handle_update doesn't use the bot.""" + return UpdatePump( + bot=MagicMock(), + allowed_user_ids=allowed_ids, + integration_id="telegram_test", + downloads_dir=downloads_dir or __import__("pathlib").Path("/tmp"), + ) + + +def _msg_update(*, user_id: int, text: str = "hi", update_id: int = 1) -> _FakeUpdate: + return _FakeUpdate( + update_id=update_id, + message=_FakeMessage( + message_id=update_id, + chat=_FakeChat(id=user_id), # DM: chat.id == user.id + from_user=_FakeUser(id=user_id), + text=text, + ), + ) + + +@pytest.mark.unit +class TestUpdatePumpFilter: + + async def test_allowed_user_is_enqueued(self): + pump = _make_pump(frozenset({100})) + await pump._handle_update(_msg_update(user_id=100, text="hello")) + assert pump.queue.qsize() == 1 + out = pump.queue.get_nowait() + assert out["from_user_id"] == 100 + assert out["text"] == "hello" + + async def test_disallowed_user_is_dropped(self): + pump = _make_pump(frozenset({100})) + await pump._handle_update(_msg_update(user_id=999, text="spam")) + assert pump.queue.qsize() == 0 + # Drop accounted for. + assert pump._drop_counts == {999: 1} + + async def test_disallowed_drops_accumulate_per_user(self): + pump = _make_pump(frozenset({100})) + for i in range(3): + await pump._handle_update( + _msg_update(user_id=999, text=f"spam{i}", update_id=i), + ) + for i in range(2): + await pump._handle_update( + _msg_update(user_id=888, text=f"x{i}", update_id=100 + i), + ) + assert pump.queue.qsize() == 0 + assert pump._drop_counts == {999: 3, 888: 2} + + async def test_skippable_update_is_neither_queued_nor_counted(self): + # No-message update — update_to_dict returns None and the filter + # path is bypassed entirely. Drops counter must not bump. + pump = _make_pump(frozenset({100})) + await pump._handle_update(_FakeUpdate(update_id=1, message=None)) + assert pump.queue.qsize() == 0 + assert pump._drop_counts == {} + + async def test_empty_allowlist_drops_everyone(self): + pump = _make_pump(frozenset()) + await pump._handle_update(_msg_update(user_id=1)) + await pump._handle_update(_msg_update(user_id=2)) + assert pump.queue.qsize() == 0 + assert sum(pump._drop_counts.values()) == 2 + + +@pytest.mark.unit +class TestUpdatePumpDropLog: + + async def test_drop_log_does_not_flush_within_window(self, caplog): + pump = _make_pump(frozenset({100})) + await pump._handle_update(_msg_update(user_id=999)) + # Calling flush immediately (window not elapsed) should not log + # and should leave the counter intact. + pump._maybe_flush_drop_log() + assert pump._drop_counts == {999: 1} + + async def test_drop_log_flushes_and_resets_after_window(self, caplog, monkeypatch): + pump = _make_pump(frozenset({100})) + await pump._handle_update(_msg_update(user_id=999)) + await pump._handle_update(_msg_update(user_id=888)) + + # Pretend the window started 9999 seconds ago so the flush fires. + pump._drop_window_started_at = time.monotonic() - 9999.0 + + with caplog.at_level("WARNING"): + pump._maybe_flush_drop_log() + + # Drop counters cleared, a log line emitted per offender. + assert pump._drop_counts == {} + messages = " ".join(rec.getMessage() for rec in caplog.records) + assert "user_id=999" in messages + assert "user_id=888" in messages + + def test_drop_log_no_op_when_nothing_to_flush(self, caplog): + pump = _make_pump(frozenset({100})) + pump._drop_window_started_at = time.monotonic() - 9999.0 + with caplog.at_level("WARNING"): + pump._maybe_flush_drop_log() + # Window timer should still reset even if there was nothing to log. + assert pump._drop_counts == {} diff --git a/tests/unit/integrations/brokers/telegram_broker/test_verbs.py b/tests/unit/integrations/brokers/telegram_broker/test_verbs.py new file mode 100644 index 00000000..3cb30505 --- /dev/null +++ b/tests/unit/integrations/brokers/telegram_broker/test_verbs.py @@ -0,0 +1,425 @@ +"""Tests for integrations.brokers.telegram_broker._verbs.VerbDispatcher.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from integrations._rpc import RpcError +from integrations.brokers.telegram_broker._updates import UpdatePump +from integrations.brokers.telegram_broker._verbs import VerbDispatcher +from integrations.permissions import Access, Capability + + +_READ_WRITE = {Capability.TELEGRAM: Access.READ_WRITE} +_READ_ONLY = {Capability.TELEGRAM: Access.READ} +_NONE = {Capability.TELEGRAM: Access.OFF} + + +def _make_pump() -> UpdatePump: + import pathlib + return UpdatePump( + bot=MagicMock(), + allowed_user_ids=frozenset({1}), + integration_id="telegram_test", + downloads_dir=pathlib.Path("/tmp"), + ) + + +def _make_dispatcher(*, permissions=_READ_WRITE, bot=None, pump=None) -> VerbDispatcher: + return VerbDispatcher( + bot=bot or MagicMock(), + pump=pump or _make_pump(), + permissions=permissions, + ) + + +# --------------------------------------------------------------------------- +# Verb gating +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestDispatchGating: + + async def test_unknown_verb_raises_bad_request(self): + dispatcher = _make_dispatcher() + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch("nope", {}) + assert exc.value.code == "BAD_REQUEST" + + async def test_insufficient_access_raises_permission_denied(self): + dispatcher = _make_dispatcher(permissions=_READ_ONLY) + # send_message requires READ_WRITE. + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch("send_message", {"chat_id": 1, "text": "x"}) + assert exc.value.code == "PERMISSION_DENIED" + + async def test_off_capability_blocks_read_verbs_too(self): + dispatcher = _make_dispatcher(permissions=_NONE) + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch("get_me", {}) + assert exc.value.code == "PERMISSION_DENIED" + + async def test_write_verb_blocked_by_permission_gate(self): + # The permission gate must fire before any handler logic runs. + dispatcher = _make_dispatcher(permissions=_READ_ONLY) + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch( + "send_document", {"chat_id": 1, "host_path": "/tmp/whatever"}, + ) + assert exc.value.code == "PERMISSION_DENIED" + + +# --------------------------------------------------------------------------- +# get_me +# --------------------------------------------------------------------------- + + +@dataclass +class _FakeMe: + id: int + username: str | None + first_name: str + + +@pytest.mark.unit +class TestGetMe: + + async def test_returns_identity_fields(self): + bot = MagicMock() + bot.get_me = AsyncMock(return_value=_FakeMe(id=42, username="testbot", first_name="Test")) + dispatcher = _make_dispatcher(bot=bot) + out = await dispatcher.dispatch("get_me", {}) + assert out == {"id": 42, "username": "testbot", "first_name": "Test"} + + +# --------------------------------------------------------------------------- +# next_updates +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestNextUpdates: + + async def test_returns_empty_on_timeout(self): + pump = _make_pump() + dispatcher = _make_dispatcher(pump=pump) + out = await dispatcher.dispatch("next_updates", {"timeout_ms": 50}) + assert out == {"updates": []} + + async def test_drains_buffered_batch(self): + pump = _make_pump() + pump.queue.put_nowait({"type": "message", "text": "a", "message_id": 1}) + pump.queue.put_nowait({"type": "message", "text": "b", "message_id": 2}) + pump.queue.put_nowait({"type": "message", "text": "c", "message_id": 3}) + dispatcher = _make_dispatcher(pump=pump) + out = await dispatcher.dispatch("next_updates", {"timeout_ms": 50}) + assert [u["text"] for u in out["updates"]] == ["a", "b", "c"] + assert pump.queue.empty() + + async def test_blocks_until_an_update_arrives(self): + """The handler waits up to timeout_ms for the first update.""" + pump = _make_pump() + dispatcher = _make_dispatcher(pump=pump) + + async def _deliver_later() -> None: + await asyncio.sleep(0.02) + pump.queue.put_nowait({"type": "message", "text": "late", "message_id": 1}) + + deliver_task = asyncio.create_task(_deliver_later()) + out = await dispatcher.dispatch("next_updates", {"timeout_ms": 1000}) + await deliver_task + assert len(out["updates"]) == 1 + assert out["updates"][0]["text"] == "late" + + async def test_rejects_negative_timeout(self): + dispatcher = _make_dispatcher() + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch("next_updates", {"timeout_ms": -1}) + assert exc.value.code == "BAD_REQUEST" + + async def test_default_timeout_used_when_missing(self): + pump = _make_pump() + pump.queue.put_nowait({"type": "message", "text": "x", "message_id": 1}) + dispatcher = _make_dispatcher(pump=pump) + out = await dispatcher.dispatch("next_updates", {}) + # With an item ready the handler returns immediately regardless of + # default timeout — assert the item came through. + assert len(out["updates"]) == 1 + + +# --------------------------------------------------------------------------- +# send_message +# --------------------------------------------------------------------------- + + +@dataclass +class _FakeSentMessage: + message_id: int + + +@pytest.mark.unit +class TestSendMessage: + + async def test_sends_and_returns_message_id(self): + bot = MagicMock() + bot.send_message = AsyncMock(return_value=_FakeSentMessage(message_id=777)) + dispatcher = _make_dispatcher(bot=bot) + out = await dispatcher.dispatch( + "send_message", {"chat_id": 42, "text": "hi"}, + ) + assert out == {"message_id": 777} + bot.send_message.assert_awaited_once_with( + chat_id=42, + text="hi", + reply_to_message_id=None, + reply_markup=None, + parse_mode=None, + ) + + async def test_passes_parse_mode_when_provided(self): + bot = MagicMock() + bot.send_message = AsyncMock(return_value=_FakeSentMessage(message_id=1)) + dispatcher = _make_dispatcher(bot=bot) + await dispatcher.dispatch( + "send_message", + {"chat_id": 1, "text": "hi", "parse_mode": "MarkdownV2"}, + ) + assert bot.send_message.await_args.kwargs["parse_mode"] == "MarkdownV2" + + async def test_rejects_non_string_parse_mode(self): + dispatcher = _make_dispatcher() + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch( + "send_message", + {"chat_id": 1, "text": "hi", "parse_mode": 42}, + ) + assert exc.value.code == "BAD_REQUEST" + + async def test_passes_reply_to_message_id_when_provided(self): + bot = MagicMock() + bot.send_message = AsyncMock(return_value=_FakeSentMessage(message_id=1)) + dispatcher = _make_dispatcher(bot=bot) + await dispatcher.dispatch( + "send_message", + {"chat_id": 1, "text": "hi", "reply_to_message_id": 5}, + ) + assert bot.send_message.await_args.kwargs["reply_to_message_id"] == 5 + + @pytest.mark.parametrize("bad_chat_id", [None, "42", 1.5, True, False]) + async def test_rejects_non_integer_chat_id(self, bad_chat_id): + dispatcher = _make_dispatcher() + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch("send_message", {"chat_id": bad_chat_id, "text": "hi"}) + assert exc.value.code == "BAD_REQUEST" + + @pytest.mark.parametrize("bad_text", [None, "", 0, []]) + async def test_rejects_empty_or_non_string_text(self, bad_text): + dispatcher = _make_dispatcher() + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch("send_message", {"chat_id": 1, "text": bad_text}) + assert exc.value.code == "BAD_REQUEST" + + async def test_rejects_non_integer_reply_to(self): + dispatcher = _make_dispatcher() + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch( + "send_message", + {"chat_id": 1, "text": "hi", "reply_to_message_id": "5"}, + ) + assert exc.value.code == "BAD_REQUEST" + + async def test_aiogram_api_error_becomes_internal_rpc_error(self): + from aiogram.exceptions import TelegramBadRequest + + bot = MagicMock() + bot.send_message = AsyncMock(side_effect=TelegramBadRequest( + method=MagicMock(), message="chat not found", + )) + dispatcher = _make_dispatcher(bot=bot) + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch("send_message", {"chat_id": 1, "text": "hi"}) + assert exc.value.code == "INTERNAL" + + async def test_passes_inline_keyboard_when_buttons_provided(self): + from aiogram.types import InlineKeyboardMarkup + + bot = MagicMock() + bot.send_message = AsyncMock(return_value=_FakeSentMessage(message_id=1)) + dispatcher = _make_dispatcher(bot=bot) + await dispatcher.dispatch( + "send_message", + { + "chat_id": 1, + "text": "Pick:", + "buttons": [ + [{"text": "A", "data": "a"}, {"text": "B", "data": "b"}], + [{"text": "C", "data": "c"}], + ], + }, + ) + markup = bot.send_message.await_args.kwargs["reply_markup"] + assert isinstance(markup, InlineKeyboardMarkup) + # Two rows; first has two buttons, second has one. + assert [len(row) for row in markup.inline_keyboard] == [2, 1] + assert markup.inline_keyboard[0][0].text == "A" + assert markup.inline_keyboard[0][0].callback_data == "a" + assert markup.inline_keyboard[1][0].callback_data == "c" + + @pytest.mark.parametrize("bad_buttons", [ + "not a list", + [{"not": "a list"}], # row isn't a list + [[{"text": "a"}]], # missing data + [[{"data": "x"}]], # missing text + [[{"text": "", "data": "x"}]], # empty text + [[{"text": "a", "data": ""}]], # empty data + ]) + async def test_rejects_malformed_buttons(self, bad_buttons): + dispatcher = _make_dispatcher() + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch( + "send_message", {"chat_id": 1, "text": "x", "buttons": bad_buttons}, + ) + assert exc.value.code == "BAD_REQUEST" + + +# --------------------------------------------------------------------------- +# send_chat_action +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSendChatAction: + + async def test_calls_bot_send_chat_action(self): + bot = MagicMock() + bot.send_chat_action = AsyncMock() + dispatcher = _make_dispatcher(bot=bot) + out = await dispatcher.dispatch( + "send_chat_action", {"chat_id": 7, "action": "typing"}, + ) + assert out == {} + bot.send_chat_action.assert_awaited_once_with(chat_id=7, action="typing") + + async def test_requires_action(self): + dispatcher = _make_dispatcher() + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch("send_chat_action", {"chat_id": 7}) + assert exc.value.code == "BAD_REQUEST" + + +# --------------------------------------------------------------------------- +# answer_callback_query +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestAnswerCallbackQuery: + + async def test_calls_bot_answer(self): + bot = MagicMock() + bot.answer_callback_query = AsyncMock() + dispatcher = _make_dispatcher(bot=bot) + await dispatcher.dispatch( + "answer_callback_query", + {"callback_id": "cbq-1", "text": "Picked!"}, + ) + bot.answer_callback_query.assert_awaited_once_with( + callback_query_id="cbq-1", text="Picked!", + ) + + async def test_text_optional(self): + bot = MagicMock() + bot.answer_callback_query = AsyncMock() + dispatcher = _make_dispatcher(bot=bot) + await dispatcher.dispatch( + "answer_callback_query", {"callback_id": "cbq-1"}, + ) + bot.answer_callback_query.assert_awaited_once_with( + callback_query_id="cbq-1", text=None, + ) + + +# --------------------------------------------------------------------------- +# send_document +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestSendDocument: + + async def test_sends_existing_file(self, tmp_path): + f = tmp_path / "report.pdf" + f.write_bytes(b"hello") + + bot = MagicMock() + bot.send_document = AsyncMock(return_value=_FakeSentMessage(message_id=99)) + dispatcher = _make_dispatcher(bot=bot) + + out = await dispatcher.dispatch( + "send_document", + {"chat_id": 1, "host_path": str(f), "caption": "📎 report.pdf"}, + ) + assert out == {"message_id": 99} + kwargs = bot.send_document.await_args.kwargs + assert kwargs["chat_id"] == 1 + assert kwargs["caption"] == "📎 report.pdf" + # The document arg is an FSInputFile pointing at the host path. + assert kwargs["document"].path == str(f) + + async def test_missing_file_rejected(self): + dispatcher = _make_dispatcher() + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch( + "send_document", {"chat_id": 1, "host_path": "/no/such/file.bin"}, + ) + assert exc.value.code == "BAD_REQUEST" + + +# --------------------------------------------------------------------------- +# edit_message_text + delete_message — live status message support +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestEditMessageText: + + async def test_calls_bot_edit(self): + bot = MagicMock() + bot.edit_message_text = AsyncMock() + dispatcher = _make_dispatcher(bot=bot) + out = await dispatcher.dispatch( + "edit_message_text", + {"chat_id": 7, "message_id": 100, "text": "🔧 Calling search..."}, + ) + assert out == {} + bot.edit_message_text.assert_awaited_once_with( + chat_id=7, message_id=100, text="🔧 Calling search...", + ) + + @pytest.mark.parametrize("missing", ["chat_id", "message_id", "text"]) + async def test_required_args(self, missing): + args = {"chat_id": 1, "message_id": 2, "text": "x"} + args.pop(missing) + dispatcher = _make_dispatcher() + with pytest.raises(RpcError) as exc: + await dispatcher.dispatch("edit_message_text", args) + assert exc.value.code == "BAD_REQUEST" + + +@pytest.mark.unit +class TestDeleteMessage: + + async def test_calls_bot_delete(self): + bot = MagicMock() + bot.delete_message = AsyncMock() + dispatcher = _make_dispatcher(bot=bot) + out = await dispatcher.dispatch( + "delete_message", {"chat_id": 7, "message_id": 100}, + ) + assert out == {} + bot.delete_message.assert_awaited_once_with(chat_id=7, message_id=100) diff --git a/tests/unit/migrations/test_006_telegram_notifier_settings.py b/tests/unit/migrations/test_006_telegram_notifier_settings.py new file mode 100644 index 00000000..e7474f6a --- /dev/null +++ b/tests/unit/migrations/test_006_telegram_notifier_settings.py @@ -0,0 +1,61 @@ +"""Tests for migration 006: Telegram notifier keys seeded into settings.json.""" + +import json + +import pytest + +from migrations._006_telegram_notifier_settings import migrate + + +@pytest.fixture() +def state_dir(tmp_path): + """State directory root.""" + return tmp_path + + +@pytest.mark.unit +def test_no_settings_file_is_noop(state_dir): + """Install without a settings.json does nothing — defaults apply on read.""" + migrate(state_dir) + assert not (state_dir / "settings.json").exists() + + +@pytest.mark.unit +def test_seeds_missing_fields(state_dir): + """A pre-existing settings.json without notifier fields gets them filled in.""" + path = state_dir / "settings.json" + path.write_text(json.dumps({"setup_complete": True})) + + migrate(state_dir) + + data = json.loads(path.read_text()) + assert data["setup_complete"] is True + assert data["telegram_notifier_integration_id"] == "" + assert data["telegram_notifier_chat_id"] is None + + +@pytest.mark.unit +def test_preserves_existing_values(state_dir): + """User-set notifier values are not overwritten.""" + path = state_dir / "settings.json" + path.write_text(json.dumps({ + "telegram_notifier_integration_id": "telegram_personal", + "telegram_notifier_chat_id": 42, + })) + + migrate(state_dir) + + data = json.loads(path.read_text()) + assert data["telegram_notifier_integration_id"] == "telegram_personal" + assert data["telegram_notifier_chat_id"] == 42 + + +@pytest.mark.unit +def test_corrupt_settings_file_is_skipped(state_dir): + """A corrupt settings.json doesn't raise; it's left alone.""" + path = state_dir / "settings.json" + path.write_text("{not-json") + + migrate(state_dir) + + assert path.read_text() == "{not-json" diff --git a/tests/unit/sdk/events/test_message_handler_bridge.py b/tests/unit/sdk/events/test_message_handler_bridge.py index 56134a22..bf5e18bb 100644 --- a/tests/unit/sdk/events/test_message_handler_bridge.py +++ b/tests/unit/sdk/events/test_message_handler_bridge.py @@ -50,6 +50,7 @@ async def _fake_tool_loop(**_: Any) -> str | None: return "done" import server.message_handler as mh + import sdk.turn._executor as executor_mod mock_profile = AgentProfile( id="computron", @@ -60,7 +61,7 @@ async def _fake_tool_loop(**_: Any) -> str | None: skills=[], ) monkeypatch.setattr(mh, "get_agent_profile", lambda _pid: mock_profile) - monkeypatch.setattr(mh, "run_turn", _fake_tool_loop) + monkeypatch.setattr(executor_mod, "run_turn", _fake_tool_loop) seen: list[AgentEvent] = [] async for ev in handle_user_message( "hi", data=None, profile_id="computron", conversation_id="test-conv", diff --git a/tests/unit/sdk/skills/test_agent_state_create.py b/tests/unit/sdk/skills/test_agent_state_create.py new file mode 100644 index 00000000..e87e6bb2 --- /dev/null +++ b/tests/unit/sdk/skills/test_agent_state_create.py @@ -0,0 +1,84 @@ +"""Tests for AgentState.create() — the async factory used by all callsites.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from sdk.skills._registry import Skill, _SKILL_REGISTRY, register_skill +from sdk.skills.agent_state import AgentState + +_MOD = "sdk.skills.agent_state" + + +def _make_tool(name: str): + async def tool() -> str: + return name + tool.__name__ = name + return tool + + +def _make_skill(name: str, tool_names: list[str]) -> Skill: + return Skill( + name=name, + description=f"desc_{name}", + prompt=f"{name}-prompt", + tools=[_make_tool(t) for t in tool_names], + ) + + +@pytest.fixture(autouse=True) +def _clean_registry(): + saved = dict(_SKILL_REGISTRY) + yield + _SKILL_REGISTRY.clear() + _SKILL_REGISTRY.update(saved) + + +@pytest.mark.unit +async def test_create_pulls_core_tools_and_starts_empty_without_skills(): + core = [_make_tool("core_a"), _make_tool("core_b")] + with patch(f"{_MOD}.get_core_tools", new=AsyncMock(return_value=core)): + state = await AgentState.create() + + assert state.loaded_skill_names == frozenset() + names = [getattr(t, "__name__", None) for t in state.tools] + assert names == ["core_a", "core_b"] + + +@pytest.mark.unit +async def test_create_loads_named_skills_from_registry(): + register_skill(_make_skill("alpha", ["a_tool"])) + register_skill(_make_skill("beta", ["b_tool"])) + + with patch(f"{_MOD}.get_core_tools", new=AsyncMock(return_value=[])): + state = await AgentState.create(skill_names=["alpha", "beta"]) + + assert state.loaded_skill_names == frozenset({"alpha", "beta"}) + names = {getattr(t, "__name__", None) for t in state.tools} + assert names == {"a_tool", "b_tool"} + + +@pytest.mark.unit +async def test_create_logs_and_skips_unknown_skills(caplog): + register_skill(_make_skill("known", ["t"])) + + with ( + patch(f"{_MOD}.get_core_tools", new=AsyncMock(return_value=[])), + caplog.at_level("WARNING"), + ): + state = await AgentState.create(skill_names=["known", "ghost"]) + + assert state.loaded_skill_names == frozenset({"known"}) + assert any("ghost" in rec.getMessage() for rec in caplog.records) + + +@pytest.mark.unit +async def test_create_deduplicates_repeated_skill_names(): + register_skill(_make_skill("alpha", ["a_tool"])) + + with patch(f"{_MOD}.get_core_tools", new=AsyncMock(return_value=[])): + state = await AgentState.create(skill_names=["alpha", "alpha", "alpha"]) + + assert state.loaded_skill_names == frozenset({"alpha"}) diff --git a/tests/unit/sdk/turn/test_executor.py b/tests/unit/sdk/turn/test_executor.py new file mode 100644 index 00000000..ec9a55b5 --- /dev/null +++ b/tests/unit/sdk/turn/test_executor.py @@ -0,0 +1,149 @@ +"""Tests for sdk.turn._executor.TurnExecutor — pre-wired turn driver.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from agents.types import Agent +from sdk import Conversation, TurnExecutor +from sdk.context import ConversationHistory +from sdk.skills import AgentState +from sdk.skills._registry import Skill + +_MOD = "sdk.turn._executor" + + +def _make_agent(**overrides: Any) -> Agent: + defaults = { + "name": "test-agent", + "description": "test", + "instruction": "BASE INSTRUCTION", + "provider": "ollama", + "model": "test-model", + "options": {}, + "tools": [], + "think": False, + "context_window": 0, + "compaction_threshold": 0.75, + "max_iterations": 0, + } + defaults.update(overrides) + return Agent(**defaults) + + +def _make_conversation( + conv_id: str = "c1", *, agent_state: AgentState | None = None, +) -> Conversation: + return Conversation( + id=conv_id, + history=ConversationHistory(instance_id=conv_id), + agent_state=agent_state if agent_state is not None else AgentState([]), + ) + + +def _fake_skill(name: str) -> Skill: + return Skill(name=name, description=f"{name} skill", prompt=f"{name}-prompt", tools=[]) + + +async def _drain(executor_call): + """Iterate the async-generator to completion and collect events.""" + return [evt async for evt in executor_call] + + +# --------------------------------------------------------------------------- +# agent_state contract — must be populated before execute() +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +async def test_missing_agent_state_raises(): + """The executor refuses to run when the caller forgot to populate it.""" + conv = Conversation( + id="c1", + history=ConversationHistory(instance_id="c1"), + ) + agent = _make_agent() + + with pytest.raises(ValueError, match="agent_state"): + await _drain(TurnExecutor().execute( + conversation=conv, agent=agent, user_content="hi", + )) + + +# --------------------------------------------------------------------------- +# History writes — user message append + system prompt placement +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +async def test_user_message_appended_to_history(): + conv = _make_conversation() + agent = _make_agent() + + with patch(f"{_MOD}.run_turn", new_callable=AsyncMock): + await _drain(TurnExecutor().execute( + conversation=conv, agent=agent, user_content="hello world", + )) + + user_msgs = [m for m in conv.history.messages if m["role"] == "user"] + assert len(user_msgs) == 1 + assert user_msgs[0]["content"] == "hello world" + + +@pytest.mark.unit +async def test_system_message_is_agent_instruction_when_no_skills_loaded(): + conv = _make_conversation() + agent = _make_agent(instruction="MY INSTRUCTION") + + with patch(f"{_MOD}.run_turn", new_callable=AsyncMock): + await _drain(TurnExecutor().execute( + conversation=conv, agent=agent, user_content="hi", + )) + + system = conv.history.messages[0] + assert system["role"] == "system" + assert system["content"] == "MY INSTRUCTION" + + +@pytest.mark.unit +async def test_system_message_includes_skill_prompt_when_skills_loaded(): + """When agent_state has skills, their prompts append to the agent's instruction.""" + state = AgentState([]) + state.add(_fake_skill("alpha")) + state.add(_fake_skill("beta")) + conv = _make_conversation(agent_state=state) + agent = _make_agent(instruction="BASE") + + with patch(f"{_MOD}.run_turn", new_callable=AsyncMock): + await _drain(TurnExecutor().execute( + conversation=conv, agent=agent, user_content="hi", + )) + + system = conv.history.messages[0]["content"] + assert system.startswith("BASE") + assert "alpha-prompt" in system + assert "beta-prompt" in system + + +@pytest.mark.unit +async def test_executor_does_not_load_skills_or_call_persistence(): + """Verify the executor stays out of the persistence/skill-loading business + (those moved to callers in the refactor).""" + state = AgentState([]) + conv = _make_conversation(agent_state=state) + agent = _make_agent() + + with ( + patch(f"{_MOD}.run_turn", new_callable=AsyncMock), + # If the executor reached for the registry we'd see it here. + patch("sdk.skills._registry.get_skill") as get_skill_mock, + ): + await _drain(TurnExecutor().execute( + conversation=conv, agent=agent, user_content="hi", + )) + + assert get_skill_mock.call_count == 0 + assert state.loaded_skill_names == frozenset() diff --git a/tests/unit/server/test_message_handler.py b/tests/unit/server/test_message_handler.py index 5399afb3..03711d04 100644 --- a/tests/unit/server/test_message_handler.py +++ b/tests/unit/server/test_message_handler.py @@ -1,24 +1,32 @@ -"""Unit tests for ``server.message_handler`` cache + persistence behavior.""" +"""Unit tests for ``server.message_handler`` — the SSE handler glue. + +Cache behavior is covered in ``tests/unit/conversations/test_cache.py``. +What's here is the higher-level wiring this module is actually responsible +for — composing ``resume_conversation``'s payload from history + events + +preview state. +""" from __future__ import annotations from collections.abc import AsyncIterator -from pathlib import Path from unittest.mock import AsyncMock, patch import pytest -from conversations._store import save_conversation_history -from sdk.context import ConversationHistory +from conversations._store import ( + save_agent_events, + save_conversation_history, + save_preview_state, +) from server import message_handler as mh @pytest.fixture(autouse=True) -async def _clear_in_memory_conversations() -> AsyncIterator[None]: - """Reset the module-global conversation cache between tests.""" - mh._conversations.clear() +async def _clear_cache() -> AsyncIterator[None]: + """Reset the module-global cache between tests.""" + mh._cache.clear() yield - mh._conversations.clear() + mh._cache.clear() @pytest.fixture(autouse=True) @@ -28,162 +36,32 @@ def _stub_browser_release(): yield -async def test_get_conversation_cold_cache_no_disk_creates_empty_and_marks_new() -> None: - """No in-memory entry, no on-disk history -> empty + is_new=True.""" - conv, is_new = await mh._get_conversation("brand-new-id") - assert len(conv) == 0 - assert conv.instance_id == "brand-new-id" - assert is_new is True - - -async def test_get_conversation_cold_cache_with_disk_hydrates_and_marks_not_new() -> None: - """No in-memory entry, on-disk history present -> hydrated + is_new=False.""" - save_conversation_history("existing", [ - {"role": "user", "content": "hello"}, - {"role": "assistant", "content": "hi"}, - ]) - - conv, is_new = await mh._get_conversation("existing") - - assert len(conv) == 2 - loaded = conv.messages - assert loaded[0]["content"] == "hello" - assert loaded[1]["content"] == "hi" - assert is_new is False - - -async def test_get_conversation_warm_cache_does_not_reread_disk() -> None: - """An in-memory entry wins over whatever is on disk and is_new=False.""" - cached = ConversationHistory( - [{"role": "user", "content": "from-memory"}], - instance_id="cid", - ) - mh._conversations["cid"] = cached - save_conversation_history("cid", [{"role": "user", "content": "from-disk"}]) - - conv, is_new = await mh._get_conversation("cid") - - assert conv is cached - assert conv.messages[0]["content"] == "from-memory" - assert is_new is False - - -async def test_get_conversation_subsequent_call_returns_same_instance() -> None: - """Two calls for the same id return the same ConversationHistory object.""" - first, first_new = await mh._get_conversation("same-id") - second, second_new = await mh._get_conversation("same-id") - assert first is second - assert first_new is True - assert second_new is False - - -async def test_get_conversation_empty_id_raises() -> None: - """Empty string is rejected.""" - with pytest.raises(ValueError, match="conversation_id is required"): - await mh._get_conversation("") - - -async def test_get_conversation_corrupted_history_falls_back_to_empty(tmp_path: Path) -> None: - """A malformed history.json is treated as no on-disk history.""" - from conversations._store import _get_conversations_dir - - cid = "corrupted" - conv_dir = _get_conversations_dir() / cid - conv_dir.mkdir(parents=True) - (conv_dir / "history.json").write_text("{not valid json", encoding="utf-8") - - conv, is_new = await mh._get_conversation(cid) - - assert len(conv) == 0 - assert is_new is True - - -async def test_lru_evicts_oldest_when_cap_exceeded(monkeypatch: pytest.MonkeyPatch) -> None: - """Inserting beyond the cap evicts the least-recently-used entry.""" - monkeypatch.setattr(mh, "_MAX_CACHED_CONVERSATIONS", 3) +async def test_resume_returns_none_for_missing() -> None: + assert await mh.resume_conversation("does-not-exist") is None - await mh._get_conversation("a") - await mh._get_conversation("b") - await mh._get_conversation("c") - assert list(mh._conversations) == ["a", "b", "c"] - await mh._get_conversation("d") +async def test_resume_returns_messages_events_and_preview_state() -> None: + """resume_conversation reads history, events, and preview state from disk.""" + cid = "resume-test" + save_conversation_history(cid, [{"role": "user", "content": "hi"}]) + save_agent_events(cid, [{"payload": {"type": "content", "content": "x"}}]) + save_preview_state(cid, {"open": ["a.txt"]}) - assert "a" not in mh._conversations - assert list(mh._conversations) == ["b", "c", "d"] - - -async def test_lru_access_promotes_to_most_recently_used( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A cache hit moves the entry to the end so it survives the next eviction.""" - monkeypatch.setattr(mh, "_MAX_CACHED_CONVERSATIONS", 3) - - await mh._get_conversation("a") - await mh._get_conversation("b") - await mh._get_conversation("c") - - # Touch 'a' — should become most-recently-used. - await mh._get_conversation("a") - assert list(mh._conversations) == ["b", "c", "a"] - - # Inserting a fourth should now evict 'b', not 'a'. - await mh._get_conversation("d") - assert "b" not in mh._conversations - assert "a" in mh._conversations - - -async def test_lru_skips_active_turn(monkeypatch: pytest.MonkeyPatch) -> None: - """Conversations whose turn is in flight are not evicted.""" - monkeypatch.setattr(mh, "_MAX_CACHED_CONVERSATIONS", 2) - monkeypatch.setattr(mh, "is_turn_active", lambda cid: cid == "a") - - await mh._get_conversation("a") - await mh._get_conversation("b") - assert list(mh._conversations) == ["a", "b"] - - # Inserting 'c' would normally evict 'a' (oldest). Pinning skips - # over 'a' and evicts 'b' instead. - await mh._get_conversation("c") - assert "a" in mh._conversations - assert "b" not in mh._conversations - assert "c" in mh._conversations - - -async def test_lru_overflow_when_all_active(monkeypatch: pytest.MonkeyPatch) -> None: - """When every cached conv is mid-turn, the cache temporarily overflows.""" - monkeypatch.setattr(mh, "_MAX_CACHED_CONVERSATIONS", 2) - monkeypatch.setattr(mh, "is_turn_active", lambda _cid: True) - - await mh._get_conversation("a") - await mh._get_conversation("b") - await mh._get_conversation("c") - - assert len(mh._conversations) == 3 - assert set(mh._conversations) == {"a", "b", "c"} - - -async def test_lru_does_not_evict_just_inserted_when_others_active( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Just-inserted conv survives even when every existing entry is mid-turn.""" - monkeypatch.setattr(mh, "_MAX_CACHED_CONVERSATIONS", 2) - monkeypatch.setattr(mh, "is_turn_active", lambda cid: cid in {"a", "b"}) - - await mh._get_conversation("a") - await mh._get_conversation("b") - await mh._get_conversation("c") - - assert "c" in mh._conversations - assert set(mh._conversations) == {"a", "b", "c"} + result = await mh.resume_conversation(cid) + assert result is not None + assert result["messages"] == [{"role": "user", "content": "hi"}] + # events list and preview_state dict shapes are determined by the store; + # asserting they're truthy is enough — the store has its own tests. + assert result["events"] + assert result["preview_state"] -async def test_resume_conversation_marks_most_recently_used() -> None: - """resume_conversation places the resumed entry at the LRU tail.""" - await mh._get_conversation("a") - save_conversation_history("from-disk", [{"role": "user", "content": "hi"}]) - result = await mh.resume_conversation("from-disk") +async def test_resume_installs_into_cache() -> None: + """After resume, the conversation is in the cache (so the next turn + reuses the same Conversation object).""" + cid = "resume-then-turn" + save_conversation_history(cid, [{"role": "user", "content": "hi"}]) - assert result is not None - assert list(mh._conversations)[-1] == "from-disk" + await mh.resume_conversation(cid) + assert cid in mh._cache diff --git a/tests/unit/server/test_settings.py b/tests/unit/server/test_settings.py index 613299f2..86077bea 100644 --- a/tests/unit/server/test_settings.py +++ b/tests/unit/server/test_settings.py @@ -156,3 +156,32 @@ def test_exclude_unset_omits_defaults(self): assert "vision_model" in dumped assert "setup_complete" not in dumped assert "direct_providers" not in dumped + + +@pytest.mark.unit +def test_telegram_notifier_settings_accepted(): + """The notifier integration_id and chat_id fields round-trip through the model.""" + u = SettingsUpdate( + telegram_notifier_integration_id="telegram_personal", + telegram_notifier_chat_id=42, + ) + assert u.telegram_notifier_integration_id == "telegram_personal" + assert u.telegram_notifier_chat_id == 42 + + +@pytest.mark.unit +def test_telegram_notifier_settings_nullable(): + """Both fields accept null (the explicit "disabled" value).""" + u = SettingsUpdate( + telegram_notifier_integration_id=None, + telegram_notifier_chat_id=None, + ) + assert u.telegram_notifier_integration_id is None + assert u.telegram_notifier_chat_id is None + + +@pytest.mark.unit +def test_telegram_notifier_chat_id_accepts_negative(): + """Group chats have negative IDs; the field must accept them.""" + u = SettingsUpdate(telegram_notifier_chat_id=-1001234567890) + assert u.telegram_notifier_chat_id == -1001234567890 diff --git a/tests/unit/tasks/test_executor.py b/tests/unit/tasks/test_executor.py index 803e5479..65b77f9d 100644 --- a/tests/unit/tasks/test_executor.py +++ b/tests/unit/tasks/test_executor.py @@ -81,8 +81,11 @@ async def _stub_core_tools() -> list[Any]: ) executor = TaskExecutor(store=None) # type: ignore[arg-type] - agent = await executor._build_agent(task) + agent, state = await executor._build_agent(task) assert agent.name == "TASK_AGENT" + # state holds the core tools (none registered in this stub) — primarily + # asserting we got both pieces back, not their contents. + assert state is not None @pytest.mark.unit diff --git a/tests/unit/tasks/test_notifier.py b/tests/unit/tasks/test_notifier.py index 5acf52c2..9c49af22 100644 --- a/tests/unit/tasks/test_notifier.py +++ b/tests/unit/tasks/test_notifier.py @@ -1,11 +1,11 @@ -"""Tests for tasks._notifier — Telegram push notifications.""" +"""Tests for tasks._notifier — Telegram push notifications via the broker.""" -import os from pathlib import Path from unittest.mock import AsyncMock, patch import pytest +from integrations.broker_client import IntegrationError from tasks._notifier import ( TelegramNotifier, format_run_completed, @@ -20,156 +20,186 @@ def _make_config(**overrides): return NotificationsConfig(**overrides) +_ENABLED_SETTINGS = { + "telegram_notifier_integration_id": "telegram_personal", + "telegram_notifier_chat_id": 42, +} + +_APP_SOCK = Path("/tmp/test_app.sock") + + +def _patch_settings(settings: dict): + """Patch ``load_settings`` inside the notifier module.""" + return patch("tasks._notifier.load_settings", return_value=settings) + + @pytest.mark.unit -class TestTelegramNotifier: - """Test TelegramNotifier init and send behavior.""" - - def test_enables_when_env_vars_present(self): - """Notifier is enabled when both env vars are set.""" - with patch.dict(os.environ, {"TELEGRAM_BOT_TOKEN": "tok", "TELEGRAM_CHAT_ID": "123"}): - notifier = TelegramNotifier(_make_config()) - assert notifier.enabled - - async def test_send_noop_when_disabled(self): - """Sending on a disabled notifier is a silent no-op.""" - with patch.dict(os.environ, {}, clear=True): - notifier = TelegramNotifier(_make_config()) - # Should not raise - await notifier.send("hello") - - async def test_send_calls_telegram_api(self): - """Sends a message via the Telegram Bot API.""" - with patch.dict(os.environ, {"TELEGRAM_BOT_TOKEN": "tok", "TELEGRAM_CHAT_ID": "42"}): - notifier = TelegramNotifier(_make_config()) - - mock_response = AsyncMock() - mock_response.status_code = 200 - - with patch("tasks._notifier.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client_cls.return_value = mock_client - - await notifier.send("test message") - - mock_client.post.assert_called_once() - call_args = mock_client.post.call_args - assert "/sendMessage" in call_args[0][0] - assert call_args[1]["json"]["chat_id"] == "42" - assert call_args[1]["json"]["text"] == "test message" - - async def test_send_document(self, tmp_path): - """Sends a file attachment via sendDocument.""" - test_file = tmp_path / "report.pdf" - test_file.write_bytes(b"fake pdf content") - - with patch.dict(os.environ, {"TELEGRAM_BOT_TOKEN": "tok", "TELEGRAM_CHAT_ID": "42"}): - notifier = TelegramNotifier(_make_config()) - - mock_response = AsyncMock() - mock_response.status_code = 200 - - with patch("tasks._notifier.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client_cls.return_value = mock_client - - await notifier.send("msg", attachments=[test_file]) - - assert mock_client.post.call_count == 2 - send_doc_call = mock_client.post.call_args_list[1] - assert "/sendDocument" in send_doc_call[0][0] - - async def test_skips_large_files(self, tmp_path): - """Files exceeding max_attachment_size_mb are skipped.""" - test_file = tmp_path / "huge.bin" - test_file.write_bytes(b"x" * 100) - - with patch.dict(os.environ, {"TELEGRAM_BOT_TOKEN": "tok", "TELEGRAM_CHAT_ID": "42"}): - # Set max to 0 MB so the 100-byte file exceeds it - notifier = TelegramNotifier(_make_config(max_attachment_size_mb=0)) - - mock_response = AsyncMock() - mock_response.status_code = 200 - - with patch("tasks._notifier.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.post.return_value = mock_response - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client_cls.return_value = mock_client - - await notifier.send("msg", attachments=[test_file]) - - # Only sendMessage, no sendDocument - assert mock_client.post.call_count == 1 - - async def test_send_does_not_raise_on_error(self): - """Errors in send are logged, never raised.""" - with patch.dict(os.environ, {"TELEGRAM_BOT_TOKEN": "tok", "TELEGRAM_CHAT_ID": "42"}): - notifier = TelegramNotifier(_make_config()) - - with patch("tasks._notifier.httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.post.side_effect = ConnectionError("offline") - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=False) - mock_client_cls.return_value = mock_client - - # Should not raise - await notifier.send("test") +def test_enables_when_settings_present(): + """Notifier is enabled when both settings are populated.""" + with _patch_settings(_ENABLED_SETTINGS): + notifier = TelegramNotifier(_make_config(), app_sock_path=_APP_SOCK) + assert notifier.enabled @pytest.mark.unit -class TestMessageFormatting: - """Test notification message formatting.""" - - def test_format_run_completed(self): - """Success message includes goal name, stats, output, and file count.""" - msg = format_run_completed( - goal_description="Find Pop-Tarts prices", - run_number=2, - duration="47s", - total_tasks=3, - completed_tasks=3, - final_output="Walmart: $3.48", - file_count=1, - ) - assert "Find Pop-Tarts prices" in msg - assert "Run #2" in msg - assert "3/3" in msg - assert "Walmart: $3.48" in msg - assert "1 file attached" in msg - - def test_format_run_completed_no_files(self): - """Success message omits file line when no files.""" - msg = format_run_completed( - goal_description="Test", - run_number=1, - duration="5s", - total_tasks=1, - completed_tasks=1, - final_output="done", - file_count=0, - ) - assert "file" not in msg - - def test_format_run_failed(self): - """Failure message includes error details.""" - msg = format_run_failed( - goal_description="Scrape data", - run_number=1, - duration="12s", - total_tasks=3, - completed_tasks=1, - failed_task_description="Fetch page", - error="ConnectionError: timeout", +def test_disabled_when_settings_blank(): + """Notifier disables itself when integration_id is blank or chat_id is None.""" + with _patch_settings({}): + notifier = TelegramNotifier(_make_config(), app_sock_path=_APP_SOCK) + assert not notifier.enabled + + +@pytest.mark.unit +def test_disabled_when_only_integration_id_set(): + with _patch_settings( + {"telegram_notifier_integration_id": "telegram_personal", "telegram_notifier_chat_id": None}, + ): + notifier = TelegramNotifier(_make_config(), app_sock_path=_APP_SOCK) + assert not notifier.enabled + + +@pytest.mark.unit +def test_disabled_when_only_chat_id_set(): + with _patch_settings( + {"telegram_notifier_integration_id": "", "telegram_notifier_chat_id": 42}, + ): + notifier = TelegramNotifier(_make_config(), app_sock_path=_APP_SOCK) + assert not notifier.enabled + + +@pytest.mark.unit +def test_disabled_when_chat_id_not_int(): + """A non-integer chat_id (e.g. stray string) disables the notifier rather than crashing.""" + with _patch_settings( + {"telegram_notifier_integration_id": "telegram_personal", "telegram_notifier_chat_id": "42"}, + ): + notifier = TelegramNotifier(_make_config(), app_sock_path=_APP_SOCK) + assert not notifier.enabled + + +@pytest.mark.unit +async def test_send_noop_when_disabled(): + """Sending on a disabled notifier is a silent no-op (no broker call).""" + with _patch_settings({}): + notifier = TelegramNotifier(_make_config(), app_sock_path=_APP_SOCK) + with patch("tasks._notifier.broker_call", new_callable=AsyncMock) as mock_call: + await notifier.send("hello") + mock_call.assert_not_called() + + +@pytest.mark.unit +async def test_send_calls_broker(): + """Sends a message via broker_client.call('send_message', ...).""" + with _patch_settings(_ENABLED_SETTINGS): + notifier = TelegramNotifier(_make_config(), app_sock_path=_APP_SOCK) + with patch("tasks._notifier.broker_call", new_callable=AsyncMock) as mock_call: + mock_call.return_value = {"message_id": 1} + + await notifier.send("test message") + + mock_call.assert_awaited_once_with( + "telegram_personal", + "send_message", + {"chat_id": 42, "text": "test message"}, + app_sock_path=_APP_SOCK, ) - assert "Scrape data" in msg - assert "1/3" in msg - assert "Fetch page" in msg - assert "ConnectionError" in msg + + +@pytest.mark.unit +async def test_send_truncates_long_messages(): + """Messages over the Telegram limit are truncated before sending.""" + with _patch_settings(_ENABLED_SETTINGS): + notifier = TelegramNotifier(_make_config(), app_sock_path=_APP_SOCK) + with patch("tasks._notifier.broker_call", new_callable=AsyncMock) as mock_call: + mock_call.return_value = {"message_id": 1} + + await notifier.send("x" * 5000) + + args = mock_call.await_args.args + sent_text = args[2]["text"] + assert len(sent_text) <= 4096 + assert sent_text.endswith("… (truncated)") + + +@pytest.mark.unit +async def test_send_skips_attachments_for_now(tmp_path): + """Attachments are logged-and-skipped until broker send_document lands.""" + test_file = tmp_path / "report.pdf" + test_file.write_bytes(b"fake pdf content") + + with _patch_settings(_ENABLED_SETTINGS): + notifier = TelegramNotifier(_make_config(), app_sock_path=_APP_SOCK) + with patch("tasks._notifier.broker_call", new_callable=AsyncMock) as mock_call: + mock_call.return_value = {"message_id": 1} + + await notifier.send("msg", attachments=[test_file]) + + # Only the text send_message call; no document call. + assert mock_call.await_count == 1 + assert mock_call.await_args.args[1] == "send_message" + + +@pytest.mark.unit +async def test_send_does_not_raise_on_broker_error(): + """Errors from the broker are logged, never raised.""" + with _patch_settings(_ENABLED_SETTINGS): + notifier = TelegramNotifier(_make_config(), app_sock_path=_APP_SOCK) + with patch( + "tasks._notifier.broker_call", + new_callable=AsyncMock, + side_effect=IntegrationError("broker offline"), + ): + # Should not raise + await notifier.send("test") + + +@pytest.mark.unit +def test_format_run_completed(): + """Success message includes goal name, stats, output, and file count.""" + msg = format_run_completed( + goal_description="Find Pop-Tarts prices", + run_number=2, + duration="47s", + total_tasks=3, + completed_tasks=3, + final_output="Walmart: $3.48", + file_count=1, + ) + assert "Find Pop-Tarts prices" in msg + assert "Run #2" in msg + assert "3/3" in msg + assert "Walmart: $3.48" in msg + assert "1 file attached" in msg + + +@pytest.mark.unit +def test_format_run_completed_no_files(): + """Success message omits file line when no files.""" + msg = format_run_completed( + goal_description="Test", + run_number=1, + duration="5s", + total_tasks=1, + completed_tasks=1, + final_output="done", + file_count=0, + ) + assert "file" not in msg + + +@pytest.mark.unit +def test_format_run_failed(): + """Failure message includes error details.""" + msg = format_run_failed( + goal_description="Scrape data", + run_number=1, + duration="12s", + total_tasks=3, + completed_tasks=1, + failed_task_description="Fetch page", + error="ConnectionError: timeout", + ) + assert "Scrape data" in msg + assert "1/3" in msg + assert "Fetch page" in msg + assert "ConnectionError" in msg diff --git a/tools/memory/__init__.py b/tools/memory/__init__.py index 4c3847f2..cd240309 100644 --- a/tools/memory/__init__.py +++ b/tools/memory/__init__.py @@ -1,5 +1,12 @@ """Persistent key-value memory tools for COMPUTRON.""" -from .memory import MemoryEntry, forget, load_memory, remember, set_key_hidden +from .memory import MemoryEntry, forget, load_memory, memory_prompt_block, remember, set_key_hidden -__all__ = ["MemoryEntry", "forget", "load_memory", "remember", "set_key_hidden"] +__all__ = [ + "MemoryEntry", + "forget", + "load_memory", + "memory_prompt_block", + "remember", + "set_key_hidden", +] diff --git a/tools/memory/memory.py b/tools/memory/memory.py index 883c1c93..1da19de0 100644 --- a/tools/memory/memory.py +++ b/tools/memory/memory.py @@ -58,6 +58,23 @@ def load_memory() -> dict[str, MemoryEntry]: return _load_raw() +def memory_prompt_block() -> str: + """Return a formatted memory block to prepend to a system prompt. + + Empty string when no memories are stored — callers can concatenate + unconditionally. + """ + memory = _load_raw() + if not memory: + return "" + lines = "\n".join(f" {k}: {e.value}" for k, e in memory.items()) + sep = "─" * 64 + return ( + f"\n── Memory (persisted across sessions) " + f"──────────────────────────────────────────\n{lines}\n{sep}\n" + ) + + def set_key_hidden(key: str, hidden: bool) -> None: """Mark a memory key as hidden or visible in the UI.""" data = _load_raw() diff --git a/uv.lock b/uv.lock index f7469fdd..d24172d3 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,32 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiogram" +version = "3.28.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "certifi" }, + { name = "magic-filter" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/3c/72e62c25a7fffedd7dd869e193797b0ed5207041ca90de16bf56fd99e41a/aiogram-3.28.2.tar.gz", hash = "sha256:140913a569516811c4ce576a292491024c8d8a739c47150d64a05aa4adc35310", size = 1855159, upload-time = "2026-05-10T14:20:40.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7b/8bc61691b1fc9d1653e12697436b2742dd1408f14c479d15f46d1ab34be1/aiogram-3.28.2-py3-none-any.whl", hash = "sha256:8a90cdf75a64ba629468f73b6e999662943fff39db213bb406afe114a3e8c0f2", size = 751693, upload-time = "2026-05-10T14:20:38.265Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -306,6 +332,7 @@ name = "computron-9000" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiogram" }, { name = "aiohttp" }, { name = "anthropic" }, { name = "beautifulsoup4" }, @@ -324,6 +351,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "pyyaml" }, { name = "rich" }, + { name = "telegramify-markdown" }, { name = "trafilatura" }, ] @@ -344,6 +372,7 @@ test = [ [package.metadata] requires-dist = [ + { name = "aiogram", specifier = ">=3.7" }, { name = "aiohttp" }, { name = "anthropic", specifier = ">=0.52.0" }, { name = "beautifulsoup4" }, @@ -369,6 +398,7 @@ requires-dist = [ { name = "pyyaml" }, { name = "rich", specifier = ">=14.3.3" }, { name = "ruff", marker = "extra == 'dev'" }, + { name = "telegramify-markdown", specifier = ">=1.1.5" }, { name = "trafilatura", specifier = ">=2.0" }, { name = "types-cachetools", marker = "extra == 'dev'" }, { name = "types-pyyaml", marker = "extra == 'dev'" }, @@ -1008,6 +1038,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/76/7ffc1d3005cf7749123bc47cb3ea343cd97b0ac2211bab40f57283577d0e/lxml_html_clean-0.4.4-py3-none-any.whl", hash = "sha256:ce2ef506614ecb85ee1c5fe0a2aa45b06a19514ec7949e9c8f34f06925cfabcb", size = 14565, upload-time = "2026-02-27T09:35:51.86Z" }, ] +[[package]] +name = "magic-filter" +version = "1.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/08/da7c2cc7398cc0376e8da599d6330a437c01d3eace2f2365f300e0f3f758/magic_filter-1.0.12.tar.gz", hash = "sha256:4751d0b579a5045d1dc250625c4c508c18c3def5ea6afaf3957cb4530d03f7f9", size = 11071, upload-time = "2023-10-01T12:33:19.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/75/f620449f0056eff0ec7c1b1e088f71068eb4e47a46eb54f6c065c6ad7675/magic_filter-1.0.12-py3-none-any.whl", hash = "sha256:e5929e544f310c2b1f154318db8c5cdf544dd658efa998172acd2e4ba0f6c6a6", size = 11335, upload-time = "2023-10-01T12:33:17.711Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1429,6 +1468,142 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pyromark" +version = "0.9.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/a3/b46d6253b3a07d1116080864ecf00fc6d3456b1978d4b5b7df3c0022903e/pyromark-0.9.11.tar.gz", hash = "sha256:cef5de337efc14544a3bdc27fd67a1e84096209c8ba28153202bf1f471b0dcec", size = 9090, upload-time = "2026-05-24T22:19:28.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/77/ebe5e19c60b2e7e052fcc448cccea4bb5a021c1e8b9be23960f8786b9677/pyromark-0.9.11-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3a5eadf6a793fc183922fe493dfa1f9d0e6e35811772c6e02aa4957aca9d63c3", size = 347123, upload-time = "2026-05-24T22:20:56.739Z" }, + { url = "https://files.pythonhosted.org/packages/54/cf/d273c92a469a492b3196f4dae02f79bc94a5df7fac8536d764d7d14a9b35/pyromark-0.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:afff4b52de0b67a94a1dd9b7a542bf40f850c3df8f3ec6b3a7f5d11c579635e5", size = 329835, upload-time = "2026-05-24T22:21:05.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d2/607f710b88c160b7f7a2ce8b2b74de469fa6a83553705ed058715295866e/pyromark-0.9.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef868d75cb5e850454f97068e37079ea4c080407712da520ca5a72e266e9d19", size = 363344, upload-time = "2026-05-24T22:20:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ac/7f1376063aeac39f67ef0c6f6f205ae4c1d848aaf24d6a4a5529b66e4833/pyromark-0.9.11-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da8615e35307173f19f3ff50106b888fe4199dfd1a6ff32607c37e120dd09460", size = 361739, upload-time = "2026-05-24T22:19:31.3Z" }, + { url = "https://files.pythonhosted.org/packages/50/15/7e6bea7c074c1b132696ff4619f5c7e5cb5302b04f2ead136665eb1809da/pyromark-0.9.11-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4417e5a8a30900d0608cee8c5290f307aa495e46c64f9ce335241ed9fc52f26d", size = 387403, upload-time = "2026-05-24T22:18:59.412Z" }, + { url = "https://files.pythonhosted.org/packages/89/69/b20628ecc8c72a825d3b99db0446d7ba07b40d81982b52cc35a87f2a1325/pyromark-0.9.11-cp312-cp312-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5208ec52e2daf927c2e6b6e35d8949a95d450acc2b9e5fcc3c0e277db3681ab7", size = 418091, upload-time = "2026-05-24T22:21:12.607Z" }, + { url = "https://files.pythonhosted.org/packages/31/10/c104d243e66a6bbb3565de6a0229e8c7177ce6103184747f72ecc497f9f6/pyromark-0.9.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0be355ed4884a29e7acb0c2518cfc7a18d23822eaca6d8622bfeb99bf365e4f8", size = 410132, upload-time = "2026-05-24T22:19:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/31/c9/50c2be699fadcd601ac3c103ae61d7382af6ddd18e6922983762a0f02600/pyromark-0.9.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72b7ea10a1a9a6eee49cd3de65b61fc35dc0a793c1c271854a4ffaa0480d7d50", size = 448261, upload-time = "2026-05-24T22:19:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1c/663791a32f81865326f39189bc0bf7670cc6e2118b46974bcfbcd53400ea/pyromark-0.9.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1af981c168121025fb7e63647a1b2aa323c60a401eff8ffc6e26977bf73a5d3", size = 371474, upload-time = "2026-05-24T22:18:50.184Z" }, + { url = "https://files.pythonhosted.org/packages/e1/17/454c9b7cba68f28df9223ee08b07272af2278f26ad921794f258249fe9bb/pyromark-0.9.11-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fe5a0b48eaa195164a0cb8af9a49c49863414e8934c9066e8e75db08fef1cad6", size = 363938, upload-time = "2026-05-24T22:22:44.927Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e0/17e8174e0adc819abbc9af0ceb1bb56087a90c3c576faec7d3880b729cc1/pyromark-0.9.11-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:f6c03533786ae03e937f127512347a290e1f949c6377abe77678d6363776f327", size = 361669, upload-time = "2026-05-24T22:20:03.002Z" }, + { url = "https://files.pythonhosted.org/packages/58/7d/dc8cacc95710c0b277ba793b16d95920ab2357f14617e5d8db3de0618f2c/pyromark-0.9.11-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:73b7227e98b4cb923fea4c84078f16b455e4b498d4a6d3e3771763e77d6e7cf3", size = 387706, upload-time = "2026-05-24T22:21:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/ac/6b/af991f52f6ff1f645034986074a7664af4631d87500558d1b3acb6df8d40/pyromark-0.9.11-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:d784577d5ba257ea9ae035b23566aa816b9659593638c1dd971ef5711a57ab02", size = 410263, upload-time = "2026-05-24T22:19:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/5b/16/cf13492e02da17301e500e1c9765ded899a52192f91e47c5f1029ad019d1/pyromark-0.9.11-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:c2085dcd93283c9a36b9151eafeeeab06c277b29f651f2c0630214437e2f1025", size = 448472, upload-time = "2026-05-24T22:22:35.296Z" }, + { url = "https://files.pythonhosted.org/packages/c2/78/f1d4c67f2f10bbc20ee1326bf3e8cb3aaf3bcf7a333bc9aaf2c9d8632d5c/pyromark-0.9.11-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5707945f3fb9b86a9de942c151d5e1c60739487b7568d8095bba8a661fe855f8", size = 371901, upload-time = "2026-05-24T22:19:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/08/bb/d454b0643c7c32c7a38c792e60f5b1809daaeb886ef08211c766f0a16e3b/pyromark-0.9.11-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:12ed041049380da29edfc6fa32952de367155782994be897040df62f5d4aefb8", size = 372228, upload-time = "2026-05-24T22:22:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/00/02/cba0e603f82bd57c50281497a5e53bcc283d5d88542f0f46ca7bcb635de8/pyromark-0.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a09cb7fd3fed58efa6796d9c4142f5fb4def4b8f3d5871ad7fcb986af43efa8", size = 540654, upload-time = "2026-05-24T22:22:50.136Z" }, + { url = "https://files.pythonhosted.org/packages/56/b1/a848e7d7d09d278b6025ae9d090e9e7467de3495a35263e895ae1100d5ca/pyromark-0.9.11-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d167b897625b7efc0d432a992fca1a4a128064bf07f4eaa3986c79ab8f4ad230", size = 638331, upload-time = "2026-05-24T22:22:51.767Z" }, + { url = "https://files.pythonhosted.org/packages/16/f8/1145df9fa6d990c80dfb977aa94e23a6e6e4894d8e3439678867852e652a/pyromark-0.9.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:009fc49414e147c886881860f008125448b92fbc52f90aa44d6ac987801b6711", size = 605152, upload-time = "2026-05-24T22:19:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/af/e7/bb57d1e5fac16b3e81588eb550dec206b5947de9903420feb1dfebd3960b/pyromark-0.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:12945db6c9bf6fc5f742646e05d683a93ba5056f2b1fe6c1b2e15e640af8fbb6", size = 540741, upload-time = "2026-05-24T22:21:55.035Z" }, + { url = "https://files.pythonhosted.org/packages/3c/56/ccbfe1cd8fefb8c7d5fdbda1651cc8c2e913f8de4cb337c5e6dc983a0608/pyromark-0.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f8837be092c667ecb8b0fef94d4f279325b2ad9ab63d3b07c48c56687d70100c", size = 546256, upload-time = "2026-05-24T22:22:28.385Z" }, + { url = "https://files.pythonhosted.org/packages/16/76/0a73a3a9d8aabddb69fc09400249ee72b8a3c85c736dc00cf79e6682cb85/pyromark-0.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ada5b2190e6eda27c8cd0fdb8c07ab5cad9408680b17b78c53fbc1a5f845d645", size = 590983, upload-time = "2026-05-24T22:19:06.791Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/efc4baf1fb44575264263bf226863e09f0cf51b54a0da439576aa42f9959/pyromark-0.9.11-cp312-cp312-win32.whl", hash = "sha256:698f383677087972cf88622b54627857b48cb910f0ab508857676f56ed157782", size = 261116, upload-time = "2026-05-24T22:20:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/57/9f/8378d1c7f5097fa876747d3c594a0f23f4d8a684b07305494dff7416f111/pyromark-0.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:d309fe00ff29869fcc79c74d46ebe1f073bbdcedbeb116189d637673f201b08e", size = 273258, upload-time = "2026-05-24T22:19:36.515Z" }, + { url = "https://files.pythonhosted.org/packages/0c/48/c24eb9ea06fbb651afac1a98ab39e9b071774cb8530f0a545ad196d89841/pyromark-0.9.11-cp312-cp312-win_arm64.whl", hash = "sha256:47d61a74d3d7b914b9ed33f5bfc0b48a53d4e30e044e4f4ba00875d934c5ea06", size = 258979, upload-time = "2026-05-24T22:22:08.195Z" }, + { url = "https://files.pythonhosted.org/packages/b8/28/8e01ddcdd0ea0e7f1ffa4ee3d9972b64832a9c57e7c2c135f8469026e73a/pyromark-0.9.11-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a18d62fe13bfe9fa0b2e9bc60ab4bc233f949b6af87488b57f205cd89ac79d75", size = 347157, upload-time = "2026-05-24T22:22:17.871Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/fdda575c727947cb172dfb9997df4d40846daf931dc578b8c51dd985b754/pyromark-0.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a4e47c0f70c3193f73833e4a95243f85f19d5c6ad72fba4a5e966353c245fcd2", size = 329873, upload-time = "2026-05-24T22:21:39.137Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/4910abd9406d24ec95529de2e45532ccacb7465288da63195d32549b316d/pyromark-0.9.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0105620485a0345787828e9692567fef3721d43120c5c36f5f618c5fdb333924", size = 363306, upload-time = "2026-05-24T22:23:04.508Z" }, + { url = "https://files.pythonhosted.org/packages/87/88/1da6a8696bf127afbe5977a68dcb9d390cd43d5aa7b76239d074daadfad7/pyromark-0.9.11-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c8fa803f6a08e510eb1506a498a977ae52a6936e34f462422de6810f30b38ead", size = 361365, upload-time = "2026-05-24T22:20:13.596Z" }, + { url = "https://files.pythonhosted.org/packages/53/2d/fb4390089637523eac70068a64622688675496fa760b7473477426ca09f7/pyromark-0.9.11-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7654ad0c23babae77f28af1de7efa9788bbbca2c29299921d8620549c2643ef", size = 387168, upload-time = "2026-05-24T22:22:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/d65013e444497a170c280d7119b63382cbf978bad96cfb01c9fb4d0faee1/pyromark-0.9.11-cp313-cp313-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69fb8cf5fa37a47a4456db2a04b40ef9cb7ad244d2ca1892270e6ae28a882e9e", size = 418452, upload-time = "2026-05-24T22:20:09.664Z" }, + { url = "https://files.pythonhosted.org/packages/77/47/ddc019e2133c42507cbd9d3cc8bc884921f1c455bd8c5d5d75c4b162806e/pyromark-0.9.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0479d32569ed1162012353bbd92eeed925b4da9c591cde3c3aa981895e8951d", size = 409822, upload-time = "2026-05-24T22:18:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/93b326323b002fad5c305983a239206490a9e4e415d0101ae8a3f76a4435/pyromark-0.9.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eae992b292c128d470780accdbc79327cc2fab7664621ec038941ee256b6b35a", size = 448069, upload-time = "2026-05-24T22:20:37.046Z" }, + { url = "https://files.pythonhosted.org/packages/81/03/84e9c90e802f7000369fc16e9a2b4e3c987c55dcde5c46abe679dd574941/pyromark-0.9.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38e8f90d6b1c36bc50c2de52dc9d717ec344474354b8c24812f3a32fcde35faf", size = 371499, upload-time = "2026-05-24T22:21:27.335Z" }, + { url = "https://files.pythonhosted.org/packages/eb/21/258c057a25f5f91bd3bd6edd9e9da1c809aa143842a07e1b286abe53a4d0/pyromark-0.9.11-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:cc065c1416d66672a6650e30e83e3aace55be981093f566a2bd12ec84d8f7279", size = 363946, upload-time = "2026-05-24T22:21:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6f/c0fd6da5e483e408e3c7613f421bff98873187f2a942d7dcce212cae89cc/pyromark-0.9.11-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:d1e44ab25a164b4cd49e907f8340ee8999f02c4f577d7fe21c962b0a41295916", size = 361279, upload-time = "2026-05-24T22:21:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/39/45/6d17775ce47b514f82c70a71709871e69dc95fc786412ef0b344c966772f/pyromark-0.9.11-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:e918c8cd291f295db0eead9f909c104016974eb53a5fa334d1c8f69794df6bc3", size = 387458, upload-time = "2026-05-24T22:22:11.295Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/c33729958990379b966106581e69a3e91c4607cd11fd3197156ff14a5096/pyromark-0.9.11-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:1bd71670031b0ee82200ea5bfed9b80d48dee6f21b2263031cd69c0136ebe6b7", size = 410032, upload-time = "2026-05-24T22:18:55.803Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ce/b531fda481d4ee6555ac2469a81ee4ea9026acfc39c2eed066deaa403eeb/pyromark-0.9.11-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:25d4beeb4650d67518116d4c42719015ba523a303a2a8d93421154479af8e4e9", size = 448268, upload-time = "2026-05-24T22:22:16.394Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/fbffc00186361a468e7b88e251e2fe1ae853059d5d3d95fe6e0479bcc607/pyromark-0.9.11-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:d135ce3e269be82a01866c69ff08a46a42d91a4db4f81057ba5828759f7c41b8", size = 371933, upload-time = "2026-05-24T22:20:55.517Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0906e6a18e98e6afe3d529c6c7074818f65e67ee230507e2f025512ded38/pyromark-0.9.11-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7c9eb49d9ad54b112c9e7402a158fcb66ac6d9d51eea3378ad2487841fb4b4cb", size = 372193, upload-time = "2026-05-24T22:22:41.918Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/98271148db4bead714b55a8dc149d0b1c2d8e10945c8115a7d15ab139237/pyromark-0.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6abe525ac95fcc6e5fa7430b171d81aa315f3201fbe635c06a2a2037642bc30f", size = 540649, upload-time = "2026-05-24T22:21:20.838Z" }, + { url = "https://files.pythonhosted.org/packages/9e/89/d29848725bd74bd6b86c1392e8b8b471fb4393e8b3946a2c6a4a2b364f5d/pyromark-0.9.11-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6780144b731ddf5f4306e40d422c2e4c16d0223cbacce1d95b9f10bc2fe844c1", size = 638026, upload-time = "2026-05-24T22:20:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/f3/20/77afe176a717a88b00b10a9a88b1d49872f0922631bf1aca55dc590c4481/pyromark-0.9.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:427cf7a4c79ea2bf00fcb1ff264ec57c1f2909c601ece4664a74e96b3c79dcf6", size = 604908, upload-time = "2026-05-24T22:22:04.804Z" }, + { url = "https://files.pythonhosted.org/packages/82/bd/13e1976e2604f49dd31b68c140eee8b5b537df4d65111e6347932f016b12/pyromark-0.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46ae4c84fd5c7a9c662b4b66897924f11e28ff0c51b0248d025d5ea9cec3f3fa", size = 540573, upload-time = "2026-05-24T22:18:57.137Z" }, + { url = "https://files.pythonhosted.org/packages/41/19/7f9323efa7c69f4982f7132d3b76376457fb9b89099824e6b860fe1aefb6/pyromark-0.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e9f3ca3d61a9af62b83012f3e0f04e8e768d71b61c1cff26c16b13e054d15778", size = 546207, upload-time = "2026-05-24T22:22:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/2286dc71cf3a12e8bbaa2311387042b5fc479c4afbf67ac7bdc50f7f3f91/pyromark-0.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d3c29e28bad3594acd1892c2919236060acda0e1dd9f77eeff9765d90b9b496", size = 591015, upload-time = "2026-05-24T22:21:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/12/98/e35366fe410e289e56e133bfa7170d73af7a0a5ea77c2bebf8893fe14a73/pyromark-0.9.11-cp313-cp313-win32.whl", hash = "sha256:7ee0dc79569d35aa22d3d9492525aa47ab6fc0c283ffed449320f3f75f3fb5de", size = 260868, upload-time = "2026-05-24T22:20:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/23/f3/bf3b72ab66f7b2445182b7c33a50a05f27624052d9febdb6e083343c857a/pyromark-0.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:da3080c84b4688c8a755bb4e42a6ce15f04a9eb22b5b173121ae4fcd2cb7e0c8", size = 273423, upload-time = "2026-05-24T22:21:16.561Z" }, + { url = "https://files.pythonhosted.org/packages/df/cc/1744b5b51148846886a8939b60409f810490b4ac140e2f2dbd4043f56225/pyromark-0.9.11-cp313-cp313-win_arm64.whl", hash = "sha256:8267a2a233669709d399232e4bb6139779cba9000f8c50845bdee6f220f28be2", size = 258978, upload-time = "2026-05-24T22:19:32.78Z" }, + { url = "https://files.pythonhosted.org/packages/5e/05/022efc4937b3ba579c86d954a396ae11b28efcad2fa30209a35676af91d2/pyromark-0.9.11-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:87703294cf6d13a2e89fe9d88ee0ec4deae5ba5a0b25dacd6a5dafff32055325", size = 345771, upload-time = "2026-05-24T22:19:22.497Z" }, + { url = "https://files.pythonhosted.org/packages/0a/37/a2753f8ce59bf64f856ffd76e5bcfe7f0a214c2796703a7bd279796d9fe2/pyromark-0.9.11-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b9edca0c67d67f8a9b6ca1a792088d53f7179d5005ae6977f7185ea6524f5640", size = 328661, upload-time = "2026-05-24T22:21:30.353Z" }, + { url = "https://files.pythonhosted.org/packages/7f/cd/9db61f94e09c5abdb376e781d4c81e01f8b14dd15d0852c2712aabb8aa8e/pyromark-0.9.11-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a83b9633de7ee66512b9d1c986c1fa58373caa177cc49e9f821dc6d4d8fc5d3", size = 361530, upload-time = "2026-05-24T22:21:08.054Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/d534f50f05ecee8a636e30ac21e7e1fb21fa44d2f286880b673539c7d2f5/pyromark-0.9.11-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd85aa34a004e550ba0aac32de202e6dff65130e4c84b0f87568f6634e7910db", size = 359161, upload-time = "2026-05-24T22:19:38.709Z" }, + { url = "https://files.pythonhosted.org/packages/43/ef/8418c98a9a86c830ef8bb37450bb6eb9690967570e2ac4707766ff82000f/pyromark-0.9.11-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f60124c8ed7ed48c7cc9fa7d1291f3af454d0c88c6a85e948710587c2f7a3723", size = 385356, upload-time = "2026-05-24T22:22:38.996Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2d/a04415b1989a91c8164cd8c7d0ad2dc24db6b0954bd79ba31f2b62cddfab/pyromark-0.9.11-cp313-cp313t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1a45d6de18f952880043cba718865963e2679e4fec4ac360666a0d6ae9fe9107", size = 416452, upload-time = "2026-05-24T22:19:56.632Z" }, + { url = "https://files.pythonhosted.org/packages/29/43/a1cda96871a9d7f15a09c5de5e75233cc6f389d5bf7c639080717f9250f6/pyromark-0.9.11-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:372c89600ec913f332f716944c66c3b15fe37dbfb7323ac1ee8b561a40393a35", size = 407084, upload-time = "2026-05-24T22:22:53.315Z" }, + { url = "https://files.pythonhosted.org/packages/23/36/49e04c5bfa8d0a8f09b028aaf93f127598c0dd357bc0a022e33bf259bffc/pyromark-0.9.11-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d14851cfacbbce1ee49ecfa923ea76183dd7f2dd1310b3d2d622e0e335606d8c", size = 445900, upload-time = "2026-05-24T22:21:42.219Z" }, + { url = "https://files.pythonhosted.org/packages/75/d4/92f71c717e4b2a404f0cb1d74462c9d06faf4c2cab3e4cb7eb38a3468bf0/pyromark-0.9.11-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28a7896b4a5a5eda6f180b19af1e8aa43f6b66337660c306d46ca70cb999a073", size = 369863, upload-time = "2026-05-24T22:19:03.927Z" }, + { url = "https://files.pythonhosted.org/packages/53/ad/17105282b2dc324df9b36d2af124dc2b8f9079fd00cd0b240aef3c6d0b18/pyromark-0.9.11-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:332e21e098edbddf2c82bc9e3e25acd315b95d5a894374539e97b18b548c4685", size = 362208, upload-time = "2026-05-24T22:20:43.158Z" }, + { url = "https://files.pythonhosted.org/packages/97/a8/f1d706f3887dde8413bae2751a1b1e18c49d0f75b6d7a17d577248c00afb/pyromark-0.9.11-cp313-cp313t-manylinux_2_28_armv7l.whl", hash = "sha256:189862413cd50ec06e0639150df070b50fcb7cf9bcac743561bdf9479895a88e", size = 359096, upload-time = "2026-05-24T22:20:24.962Z" }, + { url = "https://files.pythonhosted.org/packages/77/77/1db1054e42148a79de81a969b2001690a9e8f0fa3f40c724bc6138d6e09c/pyromark-0.9.11-cp313-cp313t-manylinux_2_28_i686.whl", hash = "sha256:d7c4d5fbb586cc73c6e63041ac74d42f49165d09c2c60e1f36e0ac3b1f8d76d8", size = 385698, upload-time = "2026-05-24T22:22:13.037Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/2821d6fb8d9577eb8d540132d28d96a1c3fea347388ea5b9090ebef40c52/pyromark-0.9.11-cp313-cp313t-manylinux_2_28_ppc64le.whl", hash = "sha256:ca4d75f7c63e7c0c686f1cdf0946eea83a6e2b11a22530fccc5eb50e9915717a", size = 407290, upload-time = "2026-05-24T22:21:19.506Z" }, + { url = "https://files.pythonhosted.org/packages/5d/69/8acdfcd48229b11919dbd8e6b4d8e5735a5c415e043e61af695c3a238b1f/pyromark-0.9.11-cp313-cp313t-manylinux_2_28_s390x.whl", hash = "sha256:affcddd172b8a636cc7d5049f2e179a4e338fff419bcb7167980bf1a1f44d46a", size = 446109, upload-time = "2026-05-24T22:19:02.891Z" }, + { url = "https://files.pythonhosted.org/packages/03/08/e87518ddc80759bf7d88d1ff0dc2d8a61994304eb3187d61302fa4746a81/pyromark-0.9.11-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:b8e5f2bdefa1ddff59886ff6f729811deb367e9305bc25abd033139984bebea6", size = 370151, upload-time = "2026-05-24T22:20:48.608Z" }, + { url = "https://files.pythonhosted.org/packages/2a/76/f1f6a9983e8f43cc0fb0ad1dee66dd0ea608df59d2e57ce063e9c46077f1/pyromark-0.9.11-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:f577f9311f43175f04e6bf0756ca67cfafb9215c70d572608c8fab1e5c1f0836", size = 370623, upload-time = "2026-05-24T22:22:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/72/f5/8f78d68e70b042e07413bd06399eb85e169fd7226148f91a51083b05f1df/pyromark-0.9.11-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c9d9cbf126f33a0eb53d3780ee5f5ad76015fe359e3b4500b801bda37bd0f919", size = 539216, upload-time = "2026-05-24T22:20:34.078Z" }, + { url = "https://files.pythonhosted.org/packages/19/82/7739f8c579f36556679b21766a533b29fe6e9c8f25d17ee6940335274714/pyromark-0.9.11-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:8989b7d49568fad39df65698170323ba4bf973ed51a251c0791bb1fde80283b8", size = 636005, upload-time = "2026-05-24T22:19:15.359Z" }, + { url = "https://files.pythonhosted.org/packages/12/30/b58a7671a886446fc2d626be753c3fb26fd2dabadee8f63378f37d62792f/pyromark-0.9.11-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:830028b3d644f7e18c84b8af8aafc7468dc9092585787cdc0b340fc7a40f1306", size = 603104, upload-time = "2026-05-24T22:21:40.475Z" }, + { url = "https://files.pythonhosted.org/packages/92/fc/1801bb5e0045db924b3b0587fe4319640e72319eaf8b803acc58c32d896a/pyromark-0.9.11-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:75dc519a7b5d6182a58d5c8b35e2592fda70c9cbbadcbfa1506a60903026b8c6", size = 538467, upload-time = "2026-05-24T22:20:47.078Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b6/4ed750bfefe1c657bd97b7433b2c333703a55c9db3be9b033e0dc03bf50e/pyromark-0.9.11-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:72752e772de34db218aad1d03653c917292565c2788aa77d22e4ba53339bb372", size = 544209, upload-time = "2026-05-24T22:19:00.585Z" }, + { url = "https://files.pythonhosted.org/packages/22/6c/9d201a7657553049ddf17662f120367252b5bc05a20effdfc955e1f9ec82/pyromark-0.9.11-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8965493591b2c1c7100bfe7f3d9d76ac847c6e363121cb78b3aca90de5922769", size = 589394, upload-time = "2026-05-24T22:21:45.517Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a1/a2509165d9f8439dad26b73f6b35b4041a6d45b033b62ab66e6579ad2e08/pyromark-0.9.11-cp313-cp313t-win32.whl", hash = "sha256:84504c69141ac64385e2ad74b3774dc46124cc3574ed838793afe764e8afa738", size = 260541, upload-time = "2026-05-24T22:18:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/ec/35/32dc5ef5a7e77b9fbfd2991d01090b6b2b7d91c7a558a9550e3fd18ddf25/pyromark-0.9.11-cp313-cp313t-win_amd64.whl", hash = "sha256:a64a74e5e3d6d69dbc65ff38404cce961ccdfebdc59f195e70c809ad4e2f207b", size = 273244, upload-time = "2026-05-24T22:21:25.741Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ae/c360c783f6717cc1397027c908dcee6346cc1612efe1d8cdffb8defdb535/pyromark-0.9.11-cp313-cp313t-win_arm64.whl", hash = "sha256:2badd596491959bf9db3677a2d2214554f8834cf4375e37025567c7d676b2f0a", size = 257893, upload-time = "2026-05-24T22:22:59.515Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a4/e6e0995fa79d3fb05a71f1aa2c587e988247c04e58c3d905c309c05b6b0a/pyromark-0.9.11-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4ab000c6507b3b7e5c44f86d6ac9087f9fe2037bf76c18ea94fe9b0fc00bfeb1", size = 347141, upload-time = "2026-05-24T22:22:58.002Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/35229efeb45136581b5d2e03220cfa969b87d32df520d2318ebad84a6c67/pyromark-0.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:edacbabfced967c2752e826af36b0f335163c212154f682de40c25dfb9634e10", size = 329689, upload-time = "2026-05-24T22:21:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/9d/05/fe95ecf4a2e29654e93ce44ae48a63efee08ac667cd570094dad6a26de9b/pyromark-0.9.11-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2044ccd2ceff6b3bdd1b2049359e80f85d50e10bca74cbf6e0ec15702e88fbb0", size = 362984, upload-time = "2026-05-24T22:23:06.392Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/eea6a4a86468242990175e5c42e5870cd5b809e85f5ce79d5cb3f8db841b/pyromark-0.9.11-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b58b29fd60ff4e96f902cb77a3a1d23845770174f502c3287f76c7ee7f7d088", size = 361240, upload-time = "2026-05-24T22:19:30.21Z" }, + { url = "https://files.pythonhosted.org/packages/df/1b/13906bcdbd05f10746ac0e87a6c6391728b2a4c3bda8995c8d8a1b7b021a/pyromark-0.9.11-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:77212a23f21bd4ac2ba935fa7a81e237e90c3157f738bfb37884e939809d570a", size = 387018, upload-time = "2026-05-24T22:19:12.272Z" }, + { url = "https://files.pythonhosted.org/packages/91/07/c2c3ecc3a387c9e880b9a47db6cc68021fd4c2456c6eef4cc6132b49fdaf/pyromark-0.9.11-cp314-cp314-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e04fba581cfc1304a65447e85990ed0bd3c27d3ee55de93397f6fc3d01f9ab57", size = 417669, upload-time = "2026-05-24T22:20:58.161Z" }, + { url = "https://files.pythonhosted.org/packages/72/45/5c6bc54688c77bc22f5e9918601a13007b5dbef45cc89b5e582657d25368/pyromark-0.9.11-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6143a312a783144979c69a6ec180f4d148fc41d063a7d2cd938cc7edfb5aac0d", size = 409519, upload-time = "2026-05-24T22:19:58.001Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b2/8b572ee152c0d6b461132e4c408cdc7833fb1ab6f18721622ccbecf2f357/pyromark-0.9.11-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c751e1a269007ccefe177316b08dcfc09efc7debc549450d98a9563878c7b8c", size = 448060, upload-time = "2026-05-24T22:20:28.137Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/ed6eaccf2c692da8987f5efa70c4d70b1750fba2102ab2a6b83e107ea80d/pyromark-0.9.11-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d6b38603efb313ded143a3836082c0a2cdf3b92dc314795ed43d72c0d818304", size = 371338, upload-time = "2026-05-24T22:18:38.359Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/91dc78be20a481f727da6887f3477a4b5f7388a768966a2f3524870d5bc8/pyromark-0.9.11-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:faf026af5c90c73f18d05ab267b073aba8a145c954af5894fbb42d7287d742ff", size = 363598, upload-time = "2026-05-24T22:19:44.354Z" }, + { url = "https://files.pythonhosted.org/packages/e5/a9/963f92bd732c1fb889896730d6ae54ea02ed95bec9207f48741467330902/pyromark-0.9.11-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:83564a870f4a081e90e13deb5ab483e2c48130d3ef7e34afda2d74db8111ce02", size = 361231, upload-time = "2026-05-24T22:19:25.219Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cf/3a3ab3915314062762730baf13a6085fef69df55b8bef98c93b1af22bb5b/pyromark-0.9.11-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:337f0e57aeeda6993ed4d2695437ca6c2821a60df794b0de89fa22950f7d5aa5", size = 387300, upload-time = "2026-05-24T22:22:26.876Z" }, + { url = "https://files.pythonhosted.org/packages/99/dc/4c497732a5c9f5e7abff266b3ac9b85d50513ec6c685ad3d6323b283ff93/pyromark-0.9.11-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:9739c4ac35926bec56abd112f208845e94cd440f757a3dacfb11dbd29ec9cf1e", size = 409636, upload-time = "2026-05-24T22:19:17.832Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a6/05a9b913f3393082d90a5db1e928c6790a374b98c1c7fede32aa25f577ba/pyromark-0.9.11-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:ff024a8d19d792d1728c9bc1f03f697fb4dbe14ae02854b23d74909c42c134d1", size = 447742, upload-time = "2026-05-24T22:19:23.755Z" }, + { url = "https://files.pythonhosted.org/packages/1a/43/011b930f0c5d0288aba1b9de6a947f2e71e2bdfc90b56ae70d6fdd81c572/pyromark-0.9.11-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:2d88478728353f95582a594f734b6a2c85ba31c3dc6af7f9122055ed8d3e88b5", size = 371594, upload-time = "2026-05-24T22:21:37.504Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/049d0a3c74cd1c436824ced55d136c339496337ae5a68614d31f1c177042/pyromark-0.9.11-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:26d52ebce02df169d2de1cf17b8db07eb4f2c5a543c651f6e15caf8ae6ecd783", size = 372069, upload-time = "2026-05-24T22:21:22.936Z" }, + { url = "https://files.pythonhosted.org/packages/21/f6/d7197ea8aaf9bee0d1ef88c9e4bd48bb59560fde767f0003b0adcfc5014c/pyromark-0.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb789d2d1f13d0efabf9ae2ef85d2fb483440653f54260198477a32a66a0fae0", size = 540529, upload-time = "2026-05-24T22:19:33.992Z" }, + { url = "https://files.pythonhosted.org/packages/b2/47/b403cf8f75aa5a854352ae1b8eb98923ac4089f8d1fcb77771921e72512a/pyromark-0.9.11-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0837914b0c335ba2ea7285c4a44535506bc68b0dc08ebc54bfe1a9b016b7fbde", size = 637913, upload-time = "2026-05-24T22:19:41.757Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/6ba2aa1364c0b8c46999dfbec8648df22043dbb416500fa95ab3b410abdd/pyromark-0.9.11-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:3923bfa9cfdde43b78c3c7abdedd353b17c245cc4a7de9147e451cfe093c21c7", size = 604815, upload-time = "2026-05-24T22:20:32.717Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b2/0b66636e267a5a469854fcb99b868c8d5f822893919d6bab5b0e9beb626e/pyromark-0.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:49ef7e711e0b62259878b38e7dd2eb6823cce1da237728e9c2117dfef667df93", size = 540311, upload-time = "2026-05-24T22:19:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/cc/86/a04b45c16a009e3027be2c2d107ed1d09015637718860430d08633448630/pyromark-0.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:634cb7a2cd473d93e1d244d7527f3e215b2c1670fd664d1532e29b3d043f98b0", size = 546082, upload-time = "2026-05-24T22:18:36.206Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6e/64a8ce53f9abd4f8c7ef1c892efe52dd32440b149b6d51c68761b3773cf4/pyromark-0.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cad96a7b37a386bbcbac01d350cf6d55b78f69a4dbff812d895a6e584fe35e1f", size = 590821, upload-time = "2026-05-24T22:22:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/79/4c/d8d2267317a8edfd29a25395ca11f0204ec59fb3ffbd75599c7fa7ef1507/pyromark-0.9.11-cp314-cp314-win32.whl", hash = "sha256:fcc29565e1d251d588d7f91dae83cb32770d758f81212a680ceea555aaef3a99", size = 260937, upload-time = "2026-05-24T22:19:16.802Z" }, + { url = "https://files.pythonhosted.org/packages/2a/97/0b5082e24276909df1d70299ad3c1b6353bbfcfae3c4887303ca0248fb20/pyromark-0.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:0c13f278d03b0f4b321fd78fef25d3b72e5d19bed8e3464c71318265f0d2f04d", size = 273222, upload-time = "2026-05-24T22:18:48.08Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1a/20f3178384dfdc0d126f9d91cc05b7970f83867f5eb78f8a77a06188d282/pyromark-0.9.11-cp314-cp314-win_arm64.whl", hash = "sha256:0b90ab42108f479d6de4508e65edab4b1c904217ed77919eacb2bbe0817fa3fb", size = 258753, upload-time = "2026-05-24T22:21:53.429Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a1/aeff3c7ba70aeff34c6b28bf012575b33490c42bac99f2b69f5a4f9fde5b/pyromark-0.9.11-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:6d21c4c7cd823a39d79d6140b4f2b23e1d3fac53e202d729781e349bb7ec85cf", size = 345678, upload-time = "2026-05-24T22:19:35.354Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b4/c570e14e75ad752f1575065657fd9c300b1c2ae0fd6c93d2a38ab153fa1a/pyromark-0.9.11-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:15e989f24275834873bc6968b77dd214497fcc4d6f4f562e2703469b453ed99f", size = 328498, upload-time = "2026-05-24T22:19:45.533Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ea/5f07fc67f4f0af5a6c0ec2f2f2557bc426ef55f509c00a1cc0c2ff5ed241/pyromark-0.9.11-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:581dd4a29754a7fa007f3ea67142fbe59e7a2f86f0d4402ce8bc6af66f82cad0", size = 361320, upload-time = "2026-05-24T22:20:59.751Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/ba6de85c6e2f952e83624ca162a8a51740175750dc02eb17b0305285d271/pyromark-0.9.11-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e036669d6990258331a7bc303cf5dc5c001783f15d53b769a34ea7ba7ff5d1dc", size = 359148, upload-time = "2026-05-24T22:22:40.48Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8c/39ad5a4fad0a46f4b0fbb76c33703ea38a903858b36c528c14c9d7512e46/pyromark-0.9.11-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:439181484e0eb660b68962fcd5868adc92db32ce1503948546ac9e96fc26b287", size = 385059, upload-time = "2026-05-24T22:19:40.149Z" }, + { url = "https://files.pythonhosted.org/packages/45/e2/65cc812aece6cc4880f9909ae71eb2e14f655eb116418d82b97c5d60ebd0/pyromark-0.9.11-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:051f9af79107b024488a423c0ac1563eb55f946700dd10ffe72db1a6e2bc675b", size = 416203, upload-time = "2026-05-24T22:20:35.711Z" }, + { url = "https://files.pythonhosted.org/packages/1e/3b/cd470a8c9188c8159729c9ce7de1c47dabbf35ae5cbfcd77fc9fe2b27e1f/pyromark-0.9.11-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ea9568d17b1ef94d1e951d1e8cb351614ea078dd8bb46bca5d5ab6541266583", size = 407225, upload-time = "2026-05-24T22:18:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/5d/1f/35b911f1b66401da26ed46b8b4fce99b04ee1382532a7188f30b3b2ed44b/pyromark-0.9.11-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d9745842b06509c4f425fc31cbb97a80c1754135f396672cd142a84c216a1668", size = 445917, upload-time = "2026-05-24T22:20:44.409Z" }, + { url = "https://files.pythonhosted.org/packages/34/da/0e9d205b6fc4947c8280c89d9816dd68918a877096d583570682e1290676/pyromark-0.9.11-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6efca2fb6022011b501454b8a09ac280db94dbd840fe0f83bd4d5688dc044944", size = 369625, upload-time = "2026-05-24T22:18:49.027Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d4/f4fd5c9db786345eb1d0a84798fac4601e34dbff21f4852bd522a192407a/pyromark-0.9.11-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fe125b76e53994adc8bb14a59f582a912adee2d7f3614875729dd1e0fe23187b", size = 362002, upload-time = "2026-05-24T22:21:02.274Z" }, + { url = "https://files.pythonhosted.org/packages/28/11/cf271b215d98531a8c24ecce7baae9003d3b5a726ef828b72790921fbc89/pyromark-0.9.11-cp314-cp314t-manylinux_2_28_armv7l.whl", hash = "sha256:48adb71a94530f29cb0f0de2b9aa6ca039d81345bec77d6059066352609bcdb1", size = 359172, upload-time = "2026-05-24T22:21:18.123Z" }, + { url = "https://files.pythonhosted.org/packages/4a/57/a5705bdd1e3687df643aba383a4971d2aca240080d997d746eecaed9b382/pyromark-0.9.11-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:0ed5ca997d906eb954235c61aef3b95ec5193cf54916fcda16a46e76814b9aed", size = 385505, upload-time = "2026-05-24T22:20:12.488Z" }, + { url = "https://files.pythonhosted.org/packages/48/23/0aa20e8c9e974dd93057ec3d4283e945ed181fd917cbafe2f685c475d290/pyromark-0.9.11-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:174b20eab1bef372735c8eb6398f66eec93b5e7e0316a0952730785b83750011", size = 407332, upload-time = "2026-05-24T22:19:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/b6/94/ed1dedaae23143173dc29ea1349409568b929a133c8474a9b1e195b5f5f8/pyromark-0.9.11-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:9e7af952342e7394bee512cea64aebc8cd8e1e3ac989cddbc47128e3e411e8b5", size = 445708, upload-time = "2026-05-24T22:21:01.024Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f6/2e146609876e90eac22e75d0391b23e30eb6ddc64f5463edf5422e1429eb/pyromark-0.9.11-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:63589190815986e5c6f5bf66aa336bcaa9ee5e90796dff66b480001ca2eda12b", size = 369988, upload-time = "2026-05-24T22:20:14.844Z" }, + { url = "https://files.pythonhosted.org/packages/34/3c/c53ebcd8d0bb3e605155ce90474a34b8384ca6abcb0d711ddf25e4852806/pyromark-0.9.11-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b9ac6dc765604a47b7268e78910344980cc2fd0a137c02cf217e4e1072fc355d", size = 370582, upload-time = "2026-05-24T22:21:47.277Z" }, + { url = "https://files.pythonhosted.org/packages/6a/72/313075d5fc4a78a59ec5259ed75ba3274a7c11786ab3080d801b9ab8bad4/pyromark-0.9.11-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f1459db160281d6e6cf1ac234207bcc781c46ea4de6c1dc2c467023944950d13", size = 539038, upload-time = "2026-05-24T22:19:50.302Z" }, + { url = "https://files.pythonhosted.org/packages/77/4b/0674c8bff3cc038eb0b33da13ae42129310da078410ec78fa6608ca0b9f6/pyromark-0.9.11-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:156cb4db0911fd6037768e7d31b3571f2309955c9fa29a193d24ce4f26b0e4ed", size = 635924, upload-time = "2026-05-24T22:21:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1b/03271177d632ef73132110215261886869a8cfdc1028fd520a78029596aa/pyromark-0.9.11-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:599fd85d8be2ab6d602b36b3f52b92a959738344e6c1ce569457aa3dae96e8f5", size = 603053, upload-time = "2026-05-24T22:22:00.338Z" }, + { url = "https://files.pythonhosted.org/packages/f0/62/b10c4aa0903c49cc6b227df3bc18322dfeb4f6d6594e02986284e98a2069/pyromark-0.9.11-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a9797732d5bb2555474f0fbfdb6e29eb5431159f2abbfd8a52b2ed5755ab7206", size = 538467, upload-time = "2026-05-24T22:21:50.464Z" }, + { url = "https://files.pythonhosted.org/packages/c5/7f/d04c700b7b0b24cc54c6692222f0fe29efed6fc92c655a7c026847923218/pyromark-0.9.11-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:18102dc1d634f2094f5b8723f51049edf7e6c335110640bdc745c7a2126b21ac", size = 544009, upload-time = "2026-05-24T22:20:04.458Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8e/d0abe1f991286b86e1c4074207675913a7c36fb7bcf9d56fb60711ab92b1/pyromark-0.9.11-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5d17ca83164862fc478f26b0ae643628f4a30d6971dcd0663dc13a73fa5c7151", size = 589315, upload-time = "2026-05-24T22:20:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/b7/58/1f5a33d9854a4614ac555de4d3c12294005f12e0d2621f240a8310e2e390/pyromark-0.9.11-cp314-cp314t-win32.whl", hash = "sha256:134de0516db2ea8bb570a608383c4be443f48ec2a185b58935f053369971d94d", size = 260520, upload-time = "2026-05-24T22:23:08.187Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/59f011062c40670a98136180e6def19cb3a3f4e283ac3936520e8c091ff7/pyromark-0.9.11-cp314-cp314t-win_amd64.whl", hash = "sha256:d710e0577de792280df4b5fc05a90cc615b2226980a2db3ba0f3b428d78c09c1", size = 273172, upload-time = "2026-05-24T22:21:09.624Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/da0a369bfb2910bf905b5a3687c7f4d7c4bd263a26b1d1d1055e67a7ad2b/pyromark-0.9.11-cp314-cp314t-win_arm64.whl", hash = "sha256:3c93e10dc4dc751e363494c75bf98ff3fbfdf2c5753bf49c04cc2339e512258f", size = 257990, upload-time = "2026-05-24T22:22:30.145Z" }, +] + [[package]] name = "pytest" version = "7.4.4" @@ -1853,6 +2028,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" }, ] +[[package]] +name = "telegramify-markdown" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyromark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/db/198bda5ba14b83b714fe5b632992684fac8fa2c07bbc7b14508e8f67e9a9/telegramify_markdown-1.1.5.tar.gz", hash = "sha256:56da17648849f86a351eb712eef4461f8c11aecc7fd27a293f5d9a0a6333b22d", size = 61782, upload-time = "2026-05-10T08:49:40.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/3e/5a68e10227008a820bad5e5b75cc71cbde03db1c4093d6026279463ccb65/telegramify_markdown-1.1.5-py3-none-any.whl", hash = "sha256:9742d56993be3db2cf9c2a61cbb257f8b34b6013a9ff27af5a569509df6673c0", size = 43329, upload-time = "2026-05-10T08:49:38.847Z" }, +] + [[package]] name = "text-unidecode" version = "1.3"