From d2b08c850805bdb3c367996c71cc5ded64b003a8 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Sun, 23 Aug 2026 19:47:06 +0200 Subject: [PATCH 01/18] Add the core Slack channel with access guardrails Implement Socket Mode transport, fail-closed sender and conversation policies, per-thread sessions, streaming, file handling, reactions, slash commands, reconnect supervision, configuration, and daemon lifecycle. Cover the channel with a local Web API/Socket Mode stand-in plus unit and integration tests. --- nerve/channels/slack.py | 1965 ++++++++++++++++++++++++++ nerve/channels/stream_adapter.py | 10 +- nerve/cli.py | 31 + nerve/config.py | 140 ++ nerve/config_reload.py | 5 + nerve/gateway/server.py | 38 +- nerve/migrate.py | 4 +- nerve/templates/config/settings.yaml | 4 +- pyproject.toml | 4 + tests/fake_slack.py | 251 ++++ tests/test_config_resolution.py | 81 ++ tests/test_db.py | 72 + tests/test_slack_channel.py | 1483 +++++++++++++++++++ tests/test_slack_integration.py | 228 +++ uv.lock | 421 ++++++ 15 files changed, 4728 insertions(+), 9 deletions(-) create mode 100644 nerve/channels/slack.py create mode 100644 tests/fake_slack.py create mode 100644 tests/test_slack_channel.py create mode 100644 tests/test_slack_integration.py diff --git a/nerve/channels/slack.py b/nerve/channels/slack.py new file mode 100644 index 00000000..cb3f2e1d --- /dev/null +++ b/nerve/channels/slack.py @@ -0,0 +1,1965 @@ +"""Slack bot channel — receive messages, run agent, respond. + +Uses slack_sdk Socket Mode: the bot opens an outbound WebSocket to Slack, so +no public URL and no inbound firewall hole are needed. That is the same shape +as the Telegram channel's long-polling transport, and it keeps a self-hosted +Nerve reachable from behind NAT. + +Session management is delegated to ChannelRouter. Access control is not: +a Slack workspace carries traffic the operator never meant for the agent, so +every inbound event passes :class:`~nerve.channels.access.AccessPolicy` +before it becomes an InboundMessage. See :mod:`nerve.channels.access`. + +Addressing +---------- +A Slack conversation is a channel id, optionally narrowed to one thread. The +two are packed into a single ``target`` string — ``C0456DEF`` or +``C0456DEF:1699887766.123456`` — because :class:`BaseChannel` gives a channel +one opaque address per destination. ``channel_key`` is ``slack:``, so +with ``reply_in_thread`` on, each thread is its own session and two people can +run separate conversations in one channel. +""" + +from __future__ import annotations + +import asyncio +import base64 +import collections +import logging +import re +import time +from pathlib import Path +from typing import Any, Callable, TYPE_CHECKING + +from nerve.channels.access import AccessPolicy, Identity, needs_name_resolution +from nerve.channels.archives import ( + IMAGE_EXT_TO_MIME, + MAX_TEXT_SIZE, + TEXT_EXTENSIONS, + extract_zip, +) +from nerve.channels.base import ( + BaseChannel, + ChannelCapability, + ChannelConstraints, + InboundMessage, + OutboundMessage, +) +from nerve.config import ( + SLACK_ALL_COMMANDS, + SLACK_DEFAULT_COMMANDS, + NerveConfig, +) + +if TYPE_CHECKING: + from nerve.channels.router import ChannelRouter + +logger = logging.getLogger(__name__) + +# chat.postMessage accepts 40k chars but renders only the first ~4k as a +# single block, so split well below that and let each chunk stand alone. +MAX_MSG_LEN = 3900 +# chat.update is limited to roughly one call per second per channel. +EDIT_INTERVAL = 1.2 +# Watchdog: check every 30s, log heartbeat every ~5 min. +WATCHDOG_INTERVAL = 30 +WATCHDOG_HEARTBEAT_EVERY = 10 +# Bounded caches: event dedupe, message text for reaction context, resolved names. +_DEDUPE_MAX = 500 +_MESSAGE_CACHE_MAX = 200 +_NAME_CACHE_MAX = 500 +_INBOUND_TS_MAX = 500 +_NAME_CACHE_TTL = 600.0 +# Concurrent dispatch tasks. The router serialises per session, so this only +# bounds envelopes not yet routed — including ones headed for a refusal. +_MAX_INFLIGHT = 100 +# Slack renders at most 25 elements in one actions block. +_MAX_ACTION_ELEMENTS = 25 +# Star picker action ids: ``starpick:<1|0>:``. Distinct from the +# session card's ``sessstar:`` toggle, which flips whatever the row holds — +# a picker has to set the state the command asked for. +_STAR_ACTION_PREFIX = "starpick:" + +# Message subtypes that are not a person talking. ``file_share`` and +# ``thread_broadcast`` are absent on purpose: the first is a real message that +# happens to carry an attachment, the second is a thread reply the sender also +# sent to the channel ("also send to #channel"), which is exactly how someone +# continues a running agent thread in the open. +_IGNORED_SUBTYPES = frozenset({ + "bot_message", + "message_changed", + "message_deleted", + "message_replied", + "channel_join", + "channel_leave", + "channel_topic", + "channel_purpose", + "channel_name", + "channel_archive", + "channel_unarchive", +}) + +_MAX_TEXT_SIZE = MAX_TEXT_SIZE # inline text cap +_MAX_DOWNLOAD_SIZE = 20_000_000 # refuse to pull anything larger into a prompt + +_TEXT_EXTENSIONS = TEXT_EXTENSIONS +_IMAGE_EXT_TO_MIME = IMAGE_EXT_TO_MIME + +# Unicode emoji → Slack short name. The agent's set_reaction tool speaks the +# Telegram reaction vocabulary; reactions.add only accepts short names. An +# emoji outside this table is skipped rather than guessed at, so a reaction +# never silently lands as the wrong one. +_EMOJI_TO_SLACK: dict[str, str] = { + "👍": "thumbsup", "👎": "thumbsdown", "❤": "heart", "❤️": "heart", + "🔥": "fire", "🥰": "smiling_face_with_3_hearts", "👏": "clap", + "😁": "grin", "🤔": "thinking_face", "🤯": "exploding_head", + "😱": "scream", "😢": "cry", "🎉": "tada", "🤩": "star-struck", + "🙏": "pray", "👌": "ok_hand", "🥱": "yawning_face", "😍": "heart_eyes", + "🌚": "new_moon_with_face", "💯": "100", "🤣": "rolling_on_the_floor_laughing", + "⚡": "zap", "🏆": "trophy", "💔": "broken_heart", "🤨": "face_with_raised_eyebrow", + "😐": "neutral_face", "🍾": "champagne", "👀": "eyes", "🙈": "see_no_evil", + "😇": "innocent", "🤝": "handshake", "🤗": "hugging_face", "🫡": "saluting_face", + "🆒": "cool", "😎": "sunglasses", "✅": "white_check_mark", "❌": "x", + "⏳": "hourglass_flowing_sand", "🚀": "rocket", "✍": "writing_hand", + "🤡": "clown_face", "💩": "hankey", "😴": "sleeping", "👻": "ghost", +} + + +# ---------------------------------------------------------------------- # +# Pure helpers — module level so they are testable without a transport # +# ---------------------------------------------------------------------- # + + +# Slack object ids: a type letter then uppercase alphanumerics. U/W users, +# B bots, C/G/D/T conversations and teams. Used to decide whether an +# allow/deny pattern can be matched against the id alone, so the shape has to +# be exact — anything looser skips a name lookup a deny list depends on. +_SLACK_ID_RE = re.compile(r"^[UWBCDGT][A-Z0-9]{7,}$") + + +def is_slack_id(pattern: str) -> bool: + """Whether *pattern* is a literal Slack object id rather than a name.""" + return bool(_SLACK_ID_RE.match(pattern)) + + +# A bare & — one that is not already the start of an escape Slack recognises. +_BARE_AMPERSAND_RE = re.compile(r"&(?!(?:amp|lt|gt);)") + + +def _escape_ampersands(text: str) -> str: + """Escape ``&`` without double-escaping one that is already an entity.""" + return _BARE_AMPERSAND_RE.sub("&", text) + + +def format_target(channel_id: str, thread_ts: str | None = None) -> str: + """Pack a conversation address into one opaque target string.""" + return f"{channel_id}:{thread_ts}" if thread_ts else channel_id + + +def parse_target(target: str) -> tuple[str, str | None]: + """Unpack :func:`format_target` into ``(channel_id, thread_ts)``. + + Slack channel ids never contain a colon and a thread ts is always + ``.``, so the split is unambiguous. + """ + channel_id, sep, thread_ts = target.partition(":") + return channel_id, (thread_ts if sep and thread_ts else None) + + +def _md_to_slack(text: str) -> str: + """Convert standard Markdown to Slack mrkdwn. + + Slack's flavour collides with Markdown on the two most common markers: + ``*text*`` is bold rather than italic, and ``_text_`` is the only italic. + Links are ````. Headings and tables do not exist, so headings + become bold lines. + + Code spans and fences are lifted out first and restored last, so the + substitutions never rewrite code — the failure that makes a snippet of + Python containing ``**kwargs`` render as bold. + """ + protected: list[str] = [] + + def _protect(replacement: str) -> str: + idx = len(protected) + protected.append(replacement) + return f"\x00{idx}\x00" + + def _fence(m: re.Match) -> str: + # Slack has no language tag — it would render as the first line of + # the block — and needs the newline after the opening fence kept, + # or the whole block collapses onto one line. + return _protect("```\n" + m.group(2).strip("\n") + "\n```") + text = re.sub(r"```(\w*)\n?(.*?)```", _fence, text, flags=re.DOTALL) + + def _code(m: re.Match) -> str: + return _protect(f"`{m.group(1)}`") + text = re.sub(r"`([^`]+)`", _code, text) + + def _link(m: re.Match) -> str: + # Slack escapes & inside a link too, and rewrites the message if we + # do not. Doing it here keeps what we send byte-identical to what + # Slack stores, so a later edit does not fight the normalisation. + label = _escape_ampersands(m.group(1)) + url = _escape_ampersands(m.group(2)) + return _protect(f"<{url}|{label}>") + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", _link, text) + + # Slack requires these three escaped in message text; everything else is + # literal. Do it before adding markup so the markup itself survives. + text = _escape_ampersands(text).replace("<", "<").replace(">", ">") + + # Headings have no equivalent — render the text as a bold line. Bold is + # staged behind \x01 until the italic pass has run, so a bold marker is + # never re-read as a pair of italic ones. + text = re.sub( + r"^\s{0,3}#{1,6}\s+(.+?)\s*$", + lambda m: f"\x01{m.group(1)}\x01", + text, + flags=re.MULTILINE, + ) + text = re.sub( + r"\*\*(.+?)\*\*", lambda m: f"\x01{m.group(1)}\x01", text, flags=re.DOTALL, + ) + text = re.sub(r"(? str: + """Turn Slack's wire format into something worth putting in a prompt. + + Unwraps ```` and ``<@U123>`` markup, drops the bot's own + mention (the agent does not need to be told it was addressed), and + unescapes the three reserved entities. + """ + if bot_user_id: + text = re.sub(rf"<@{re.escape(bot_user_id)}(\|[^>]*)?>", "", text) + # Entity forms first — each is a <…|…> too, so the generic link rule + # would otherwise claim them and render "#general" as "general (#C1)". + text = re.sub(r"<#C[A-Z0-9]+\|([^>]+)>", r"#\1", text) + text = re.sub(r"<#(C[A-Z0-9]+)>", r"#\1", text) + text = re.sub(r"<@([UW][A-Z0-9]+)\|([^>]+)>", r"@\2", text) + text = re.sub(r"<@([UW][A-Z0-9]+)>", r"@\1", text) + text = re.sub(r"]+)>", r"@\1", text) + text = re.sub(r"]*)?>", r"@\1", text) + text = re.sub(r"<([^|>]+)\|([^>]+)>", r"\2 (\1)", text) + text = re.sub(r"<((?:https?|mailto):[^>]+)>", r"\1", text) + text = text.replace("<", "<").replace(">", ">").replace("&", "&") + return text.strip() + + +def split_message(text: str, limit: int = MAX_MSG_LEN) -> list[str]: + """Split *text* into chunks under *limit*, preferring line boundaries. + + A hard slice mid-line breaks code fences and lists across messages, so + lines are packed greedily and only a single over-long line is cut. + """ + if len(text) <= limit: + return [text] if text else [] + + chunks: list[str] = [] + current = "" + for line in text.split("\n"): + while len(line) > limit: + if current: + chunks.append(current) + current = "" + chunks.append(line[:limit]) + line = line[limit:] + if not current: + current = line + elif len(current) + 1 + len(line) <= limit: + current = f"{current}\n{line}" + else: + chunks.append(current) + current = line + if current: + chunks.append(current) + return chunks + + +def slack_emoji_name(emoji: str) -> str | None: + """Map a unicode emoji (or an already-short name) to a Slack short name.""" + cleaned = emoji.strip().strip(":") + if cleaned and all(c.isalnum() or c in "-_+" for c in cleaned): + return cleaned + return _EMOJI_TO_SLACK.get(emoji.strip()) or _EMOJI_TO_SLACK.get( + emoji.strip().rstrip("️"), + ) + + +# Block Kit rendering ---------------------------------------------------- # + +_SESSIONS_BUTTON_LIMIT = 8 +_SESSION_LABEL_MAX = 70 # Slack button text is capped at 75 chars + + +def _session_label(session: dict, current_id: str | None) -> str: + """Button label for one session: current marked ✓, starred marked ⭐.""" + title = (session.get("title") or "").strip() or session.get("id", "?") + prefix = "✓ " if session.get("id") == current_id else "" + if session.get("starred"): + prefix += "⭐ " + label = f"{prefix}{title}" + if len(label) > _SESSION_LABEL_MAX: + label = label[: _SESSION_LABEL_MAX - 1] + "…" + return label + + +def build_sessions_blocks( + sessions: list[dict], current_id: str | None, +) -> list[dict[str, Any]]: + """Render the ``/nerve sessions`` Block Kit view (pure, sync — testable). + + One tap-to-switch button per session with the id carried in ``value``, + a ⭐ toggle beside it, and a trailing "New session" button. Switching + away leaves the previous session running; its output still reaches the + conversation it was bound to. + """ + blocks: list[dict[str, Any]] = [] + shown = sessions[:_SESSIONS_BUTTON_LIMIT] + + if not shown: + blocks.append({ + "type": "section", + "text": {"type": "mrkdwn", "text": "No sessions yet — start one below."}, + }) + else: + current_title = next( + ( + (s.get("title") or s.get("id")) + for s in shown + if s.get("id") == current_id + ), + None, + ) + header = "*Sessions* — tap to switch." + if current_title: + header += f"\nCurrent: {current_title}" + header += "\n⭐ keeps a session alive (never auto-closed)." + blocks.append({ + "type": "section", "text": {"type": "mrkdwn", "text": header}, + }) + for s in shown: + sid = s.get("id") + if not sid: + continue + blocks.append({ + "type": "actions", + "block_id": f"sess_row:{sid}", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": _session_label(s, current_id), + "emoji": True, + }, + "action_id": f"sess:{sid}", + "value": sid, + }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "⭐" if s.get("starred") else "☆", + "emoji": True, + }, + "action_id": f"sessstar:{sid}", + "value": sid, + }, + ], + }) + + blocks.append({ + "type": "actions", + "block_id": "sess_new", + "elements": [{ + "type": "button", + "text": {"type": "plain_text", "text": "➕ New session", "emoji": True}, + "action_id": "sess:new", + "value": "new", + "style": "primary", + }], + }) + return blocks + + +# One section block holds 3000 chars, and one message holds 50 blocks. The +# section budget leaves room for the option rows below them. +_MAX_SECTION_LEN = 3000 +_MAX_SECTION_BLOCKS = 45 + +# Slack renders a styled button in green or red. Keys are the canonical +# approval ``value`` strings that NotificationService sends. +_APPROVAL_STYLES: dict[str, str] = { + "approve": "primary", "yes": "primary", "allow": "primary", + "decline": "danger", "deny": "danger", "no": "danger", "reject": "danger", +} + + +def build_notification_blocks( + text: str, + notification_id: str, + options: list[tuple[str, str]] | None = None, +) -> list[dict[str, Any]]: + """Render a notification card, with one button per option. + + ``options`` is a list of ``(label, value)``. The value rides in the + button's ``value`` field and the notification id in ``action_id``; + Slack allows 2000 chars for each, so neither needs the truncation + Telegram's 64-byte ``callback_data`` forces. + + 3000 characters is the limit on one section, not on the message, so a + long body is spread over several sections at line boundaries. Only a + body past the whole-message block limit loses anything, and it says so. + """ + chunks = split_message(_md_to_slack(text), _MAX_SECTION_LEN) + if len(chunks) > _MAX_SECTION_BLOCKS: + dropped = sum(len(c) for c in chunks[_MAX_SECTION_BLOCKS - 1:]) + chunks = chunks[: _MAX_SECTION_BLOCKS - 1] + chunks.append( + f"_… {dropped} more characters — open the notification in the " + "web UI to read the rest._", + ) + blocks: list[dict[str, Any]] = [ + {"type": "section", "text": {"type": "mrkdwn", "text": chunk}} + for chunk in chunks + ] + elements = [ + { + "type": "button", + "text": {"type": "plain_text", "text": label[:75], "emoji": True}, + "action_id": f"notif:{notification_id}:{value}"[:255], + "value": value[:2000], + **( + {"style": _APPROVAL_STYLES[value.lower()]} + if value.lower() in _APPROVAL_STYLES + else {} + ), + } + for label, value in (options or []) + ] + # Slack rejects the whole message with invalid_blocks past 25 elements in + # one actions block, so a long option list is spread over several rows. + for start in range(0, len(elements), _MAX_ACTION_ELEMENTS): + chunk = elements[start:start + _MAX_ACTION_ELEMENTS] + blocks.append({ + "type": "actions", + "block_id": f"notif:{notification_id}:{start}", + "elements": chunk, + }) + return blocks + + +class SlackChannel(BaseChannel): + """Slack bot channel over Socket Mode. + + Owns the WebSocket transport, event dispatch, and authorization. + Session management and agent execution belong to the ChannelRouter. + """ + + def __init__( + self, config: Callable[[], NerveConfig], router: ChannelRouter, + ): + self._config = config + self.router = router + self._client: Any = None # AsyncSocketModeClient + self._web: Any = None # AsyncWebClient + self._bot_user_id: str = "" + self._bot_id: str = "" # the app's own bot_id, to spot our own posts + self._notification_service = None # Set after service is created + self._watchdog_task: asyncio.Task | None = None + self._stopping = False + self._last_event_time: float = 0.0 # monotonic, set on any inbound envelope + # Envelopes are dispatched off the ack path; hold a strong reference so + # the loop cannot collect a task mid-flight. + self._inflight: set[asyncio.Task] = set() + # Slack redelivers an event when an ack is slow, and a workspace + # subscribed to both message.channels and app_mention sees the same + # message twice. Both collapse to one run here. + self._seen_events: collections.OrderedDict[str, None] = collections.OrderedDict() + # message ts -> (target, text snippet) for reaction context. + self._message_cache: collections.OrderedDict[str, tuple[str, str]] = ( + collections.OrderedDict() + ) + # target -> ts of the last inbound message, for the read-receipt ack. + # With reply_in_thread on, every thread is a distinct target, so this + # and the name cache are bounded rather than left to grow per thread. + self._last_inbound_ts: collections.OrderedDict[str, str] = ( + collections.OrderedDict() + ) + # Resolved names: id -> (Identity, monotonic deadline). + self._name_cache: collections.OrderedDict[str, tuple[Identity, float]] = ( + collections.OrderedDict() + ) + + def set_notification_service(self, service) -> None: + """Wire the notification service for button presses.""" + self._notification_service = service + + @property + def config(self) -> NerveConfig: + """The live config, resolved per read rather than captured. + + The bot outlives every reload and the guardrail lists decide, on each + event, whether a message reaches the agent. Reading them per use means + a reload that tightens ``deny_users`` takes effect immediately. The + tokens are handed to the transport at connect time, so those still + need a restart — they are listed in ``config_reload`` as such. + """ + return self._config() + + @property + def policy(self) -> AccessPolicy: + """The access policy, rebuilt per read so reloads apply at once.""" + cfg = self.config.slack + return AccessPolicy.from_lists( + allow_users=cfg.allow_users, + deny_users=cfg.deny_users, + allow_channels=cfg.allow_channels, + deny_channels=cfg.deny_channels, + ) + + @property + def enabled_commands(self) -> frozenset[str]: + """The `/nerve` subcommands this workspace may run. + + Resolved per use so narrowing the list takes effect on a reload. + """ + configured = self.config.slack.commands + if configured is None: + return frozenset(SLACK_DEFAULT_COMMANDS) + # "all" is expanded here as well as in config parsing: this is the + # enforcement point, and it is reached by any SlackConfig, including + # one built directly rather than through from_dict. + names = {str(n).strip().lstrip("/").lower() for n in configured} + if names & {"all", "*"}: + return frozenset(SLACK_ALL_COMMANDS) + return frozenset(names & set(SLACK_ALL_COMMANDS)) + + @property + def name(self) -> str: + return "slack" + + @property + def capabilities(self) -> ChannelCapability: + caps = ( + ChannelCapability.SEND_TEXT + | ChannelCapability.MARKDOWN + | ChannelCapability.TYPING_INDICATOR + | ChannelCapability.REACTIONS + | ChannelCapability.SEND_FILES + ) + if self.config.slack.stream_mode == "partial": + caps |= ChannelCapability.STREAMING + return caps + + @property + def constraints(self) -> ChannelConstraints: + return ChannelConstraints( + max_message_length=MAX_MSG_LEN, + min_edit_interval=EDIT_INTERVAL, + supports_message_edit=True, + ) + + # ------------------------------------------------------------------ # + # Lifecycle # + # ------------------------------------------------------------------ # + + async def start(self) -> None: + """Connect the Socket Mode client and start dispatching events.""" + cfg = self.config.slack + if not cfg.bot_token or not cfg.app_token: + logger.warning( + "Slack needs both bot_token (xoxb-…) and app_token (xapp-…) — " + "channel not started", + ) + return + + from slack_sdk.http_retry.builtin_async_handlers import ( + AsyncRateLimitErrorRetryHandler, + ) + from slack_sdk.web.async_client import AsyncWebClient + + self._stopping = False + self._web = AsyncWebClient(token=cfg.bot_token) + # The SDK retries connection errors out of the box but not 429s, and + # Slack rate limits chat.postMessage to roughly one call per second + # per channel. A streamed reply arrives as a burst of edits followed + # by a post, so without this the last message of a long answer is the + # one most likely to be dropped. + self._web.retry_handlers.append( + AsyncRateLimitErrorRetryHandler(max_retry_count=3), + ) + + auth = await self._web.auth_test() + self._bot_user_id = auth.get("user_id", "") + self._bot_id = auth.get("bot_id", "") + logger.info( + "Slack authenticated as %s (%s, %s) in workspace %s", + auth.get("user"), self._bot_user_id, self._bot_id, auth.get("team"), + ) + + self._client = self._build_socket_client() + await self._client.connect() + self._last_event_time = time.monotonic() + logger.info("Slack Socket Mode connected") + + self._announce_auth_state() + + self._watchdog_task = asyncio.create_task( + self._run_watchdog(), name="slack-socket-watchdog", + ) + + def _build_socket_client(self): + """A fresh Socket Mode client wired to this channel's dispatcher.""" + from slack_sdk.socket_mode.aiohttp import SocketModeClient + + client = SocketModeClient( + app_token=self.config.slack.app_token, + web_client=self._web, + # Slack closes and reissues a socket roughly hourly; without this + # the channel goes quiet until the daemon restarts. + auto_reconnect_enabled=True, + ) + client.socket_mode_request_listeners.append(self._on_request) + return client + + def _announce_auth_state(self) -> None: + """Log how access is configured — loudly when it lets nobody in. + + An unconfigured policy refuses every message. That is the safe + default but an invisible one, so say it plainly at startup instead + of leaving the operator to wonder why the bot never answers. + """ + policy = self.policy + if not policy.configured: + logger.warning( + "Slack: no slack.allow_users or slack.allow_channels configured " + "— every message will be refused. Add your Slack member id " + "(Profile → ⋮ → Copy member ID) to slack.allow_users.", + ) + return + logger.info("Slack access policy: %s", policy.describe()) + + async def stop(self) -> None: + self._stopping = True + if self._watchdog_task and not self._watchdog_task.done(): + self._watchdog_task.cancel() + try: + await self._watchdog_task + except asyncio.CancelledError: + pass + # Cancel and then wait: closing the socket out from under a dispatch + # still touching the router or the web client races teardown. + inflight = list(self._inflight) + for task in inflight: + task.cancel() + if inflight: + await asyncio.gather(*inflight, return_exceptions=True) + if self._client: + try: + await self._client.close() + except Exception as e: + logger.warning("Slack socket close raised: %s", e) + + # ------------------------------------------------------------------ # + # Watchdog # + # ------------------------------------------------------------------ # + + async def _run_watchdog(self) -> None: + """Reconnect the socket when Slack's own auto-reconnect gives up.""" + check_count = 0 + while not self._stopping: + try: + await asyncio.sleep(WATCHDOG_INTERVAL) + except asyncio.CancelledError: + break + if self._client is None or self._stopping: + break + + check_count += 1 + # is_connected() is a coroutine: it pings the socket rather than + # reading a flag. + connected = bool(await self._client.is_connected()) + if check_count % WATCHDOG_HEARTBEAT_EVERY == 0: + since = time.monotonic() - self._last_event_time + logger.info( + "Slack watchdog: %s (check #%d, last event %.0fs ago)", + "connected" if connected else "disconnected", check_count, since, + ) + if connected: + continue + + logger.warning("Slack socket is down — rebuilding") + try: + await self._rebuild() + self._last_event_time = time.monotonic() + logger.info("Slack socket reconnected") + except Exception as e: + logger.error("Slack reconnect failed: %s", e, exc_info=True) + + async def _rebuild(self) -> None: + """Replace the socket, closing the old one first. + + Calling ``connect()`` again on a live client leaves the previous + session running: Slack hands each event to exactly one of an app's + open connections, so the orphan silently takes a share of the + traffic and the agent sees only part of its own conversation. The + old client is therefore closed before a new one is built. + + A brief gap is the safe trade. Slack redelivers an unacked envelope, + while a split connection loses events with no sign anything is wrong. + """ + old = self._client + if old is not None: + try: + await asyncio.wait_for(old.close(), timeout=10) + except Exception as e: + logger.warning("Slack: closing the old socket raised: %s", e) + + self._client = self._build_socket_client() + await self._client.connect() + + def _touch(self) -> None: + """Record that an envelope arrived from Slack.""" + self._last_event_time = time.monotonic() + + # ------------------------------------------------------------------ # + # Envelope dispatch # + # ------------------------------------------------------------------ # + + async def _on_request(self, client: Any, req: Any) -> None: + """Ack the envelope, then handle it off the ack path. + + Slack retries anything not acked within three seconds, and an agent + turn takes far longer than that. Acking first and dispatching to a + task is what stops one message becoming three runs. + """ + self._touch() + from slack_sdk.socket_mode.response import SocketModeResponse + + try: + await client.send_socket_mode_response( + SocketModeResponse(envelope_id=req.envelope_id), + ) + except Exception as e: + logger.warning("Slack ack failed for %s: %s", req.envelope_id, e) + + # The envelope is acked, so Slack will not resend it. Dropping past + # the cap is therefore a real loss, but a bounded one: without it a + # burst — including one made entirely of messages the policy will + # refuse — allocates dispatch tasks and Slack lookups without limit. + if len(self._inflight) >= _MAX_INFLIGHT: + logger.warning( + "Slack: %d envelopes already in flight — dropping %s", + len(self._inflight), req.type, + ) + return + + task = asyncio.create_task(self._dispatch(req)) + self._inflight.add(task) + task.add_done_callback(self._inflight.discard) + + async def _dispatch(self, req: Any) -> None: + """Route one Socket Mode envelope to its handler.""" + try: + if req.type == "events_api": + await self._handle_event(req.payload.get("event") or {}) + elif req.type == "interactive": + await self._handle_interactive(req.payload or {}) + elif req.type == "slash_commands": + await self._handle_slash_command(req.payload or {}) + except Exception as e: + logger.error( + "Slack dispatch failed for %s: %s", req.type, e, exc_info=True, + ) + + def _is_duplicate(self, key: str) -> bool: + """True if this event id was already handled (bounded LRU).""" + if key in self._seen_events: + return True + self._seen_events[key] = None + while len(self._seen_events) > _DEDUPE_MAX: + self._seen_events.popitem(last=False) + return False + + # ------------------------------------------------------------------ # + # Authorization # + # ------------------------------------------------------------------ # + + async def _identify_user( + self, user_id: str, resolve: bool, need_email: bool = False, + ) -> Identity: + """Build the Identity for a member, looking up names only if needed. + + ``need_email`` says the deny list names an email address. Slack omits + ``profile.email`` when the token lacks ``users:read.email`` and still + answers 200, so an absent email there is indistinguishable from a + user who has none — either way the candidate set is short of what the + deny list needs, and the identity is marked incomplete. + """ + if not resolve: + return Identity(id=user_id) + cache_key = f"u:{user_id}:{int(need_email)}" + cached = self._name_cache.get(cache_key) + if cached and cached[1] > time.monotonic(): + return cached[0] + try: + info = await self._web.users_info(user=user_id) + user = info.get("user") or {} + profile = user.get("profile") or {} + email = profile.get("email") + names = tuple( + n for n in ( + user.get("name"), + profile.get("display_name"), + profile.get("real_name"), + email, + ) if n + ) + complete = bool(names) and (email is not None or not need_email) + if need_email and email is None: + logger.warning( + "Slack users.info returned no email for %s — the deny list " + "names one, so the user is refused. Grant users:read.email " + "or write the deny rule against the handle or id instead.", + user_id, + ) + identity = Identity(id=user_id, names=names, complete=complete) + except Exception as e: + logger.warning("Slack users.info failed for %s: %s", user_id, e) + identity = Identity(id=user_id, complete=False) + self._remember( + self._name_cache, cache_key, + (identity, time.monotonic() + _NAME_CACHE_TTL), _NAME_CACHE_MAX, + ) + return identity + + async def _identify_conversation( + self, channel_id: str, channel_type: str, resolve: bool, + ) -> Identity: + """Build the Identity for a conversation. + + A direct message has no name, so it is given the synthetic name + ``dm`` — that is how ``allow_channels: ["dm", "eng-*"]`` admits + direct messages alongside a set of channels. + """ + if channel_type == "im": + return Identity(id=channel_id, names=("dm",)) + if not resolve: + return Identity(id=channel_id) + cached = self._name_cache.get(f"c:{channel_id}") + if cached and cached[1] > time.monotonic(): + return cached[0] + try: + info = await self._web.conversations_info(channel=channel_id) + channel = info.get("channel") or {} + if channel.get("is_im"): + identity = Identity(id=channel_id, names=("dm",)) + else: + name = channel.get("name") or "" + identity = Identity( + id=channel_id, + names=(name,) if name else (), + complete=bool(name), + ) + except Exception as e: + logger.warning( + "Slack conversations.info failed for %s: %s", channel_id, e, + ) + identity = Identity(id=channel_id, complete=False) + self._remember( + self._name_cache, f"c:{channel_id}", + (identity, time.monotonic() + _NAME_CACHE_TTL), _NAME_CACHE_MAX, + ) + return identity + + async def _authorize( + self, user_id: str, channel_id: str, channel_type: str, + ) -> bool: + """Run the access policy for one event, logging any refusal.""" + policy = self.policy + if not policy.configured: + logger.warning( + "Slack: refusing %s in %s — no allow list configured", + user_id, channel_id, + ) + return False + + user = await self._identify_user( + user_id, + needs_name_resolution(policy.users, is_id=is_slack_id), + need_email=policy.users.deny_needs(lambda p: "@" in p), + ) + conversation = await self._identify_conversation( + channel_id, + channel_type, + needs_name_resolution(policy.conversations, is_id=is_slack_id), + ) + verdict = policy.check(user, conversation) + if not verdict.allowed: + logger.info("Slack refused a message: %s", verdict.reason) + return verdict.allowed + + # ------------------------------------------------------------------ # + # Events API # + # ------------------------------------------------------------------ # + + async def _handle_event(self, event: dict[str, Any]) -> None: + """Route one Events API event.""" + etype = event.get("type") + if etype in ("message", "app_mention"): + await self._handle_message_event(event) + elif etype == "reaction_added": + await self._handle_reaction_event(event) + + def _is_own_message(self, event: dict[str, Any]) -> bool: + """True only for messages this app itself posted. + + Treating every ``bot_id`` as our own is too broad: Slack stamps one + onto a message a *person* sent through any app or integration — + a workflow, a scheduled send, a client posting with a user token — + while still naming them in ``user``. Ignoring those drops real people + mid-conversation. + + Other bots are turned away by the ``bot_message`` subtype instead, + which is what a message with no human behind it carries. A person + posting through an app is a person, and the access policy judges + them on their own id. + """ + if self._bot_id and event.get("bot_id") == self._bot_id: + return True + return bool(self._bot_user_id and event.get("user") == self._bot_user_id) + + async def _should_answer( + self, event: dict[str, Any], channel_type: str, channel_key: str, + ) -> bool: + """Whether a channel message is addressed to the agent. + + A direct message always is. In a shared channel the bot answers only + when mentioned, or when the message continues a thread it is already + running a session for — otherwise adding the bot to a busy channel + would start an agent turn for every remark in it. + """ + if channel_type == "im": + return True + if event.get("type") == "app_mention": + return True + if self._bot_user_id and f"<@{self._bot_user_id}>" in (event.get("text") or ""): + return True + if event.get("thread_ts"): + return bool(await self.router.get_last_session(channel_key)) + return False + + async def _handle_message_event(self, event: dict[str, Any]) -> None: + """Turn a Slack message into an InboundMessage and hand it to the router.""" + if self._is_own_message(event): + return + subtype = event.get("subtype") + if subtype in _IGNORED_SUBTYPES: + return + + channel_id = event.get("channel") or "" + user_id = event.get("user") or "" + ts = event.get("ts") or "" + if not channel_id or not user_id or not ts: + return + + if self._is_duplicate(f"{channel_id}:{ts}"): + return + + channel_type = event.get("channel_type") or ( + "im" if channel_id.startswith("D") else "channel" + ) + cfg = self.config.slack + thread_ts = event.get("thread_ts") if cfg.reply_in_thread else None + # A first reply in a channel opens a thread on the message itself, so + # the conversation stays out of the channel's main flow. + if cfg.reply_in_thread and not thread_ts and channel_type != "im": + thread_ts = ts + target = format_target(channel_id, thread_ts) + channel_key = f"slack:{target}" + + if not await self._should_answer(event, channel_type, channel_key): + return + if not await self._authorize(user_id, channel_id, channel_type): + return + + text = slack_to_plain(event.get("text") or "", self._bot_user_id) + self._cache_message(ts, target, text) + + images: list[dict[str, str]] = [] + file_context, file_blocks = await self._extract_files(event.get("files") or []) + if file_context: + text = f"{file_context}\n\n{text}" if text else file_context + images.extend(file_blocks) + + if not text and not images: + return + + logger.info( + "Slack message from %s in %s: %s%s", + user_id, target, + (text[:80] + ("..." if len(text) > 80 else "")) if text else "(no text)", + f" [{len(images)} attachment(s)]" if images else "", + ) + + metadata: dict[str, Any] = {"message_id": ts, "slack_user_id": user_id} + if images: + metadata["images"] = images + self._remember(self._last_inbound_ts, target, ts, _INBOUND_TS_MAX) + + msg = InboundMessage( + channel_name="slack", + channel_key=channel_key, + sender_id=target, + text=text, + metadata=metadata, + ) + + try: + await self.router.handle_message(msg) + except Exception as e: + logger.error("Agent error for %s: %s", target, e, exc_info=True) + await self._post(target, f"Error: {e}") + + async def _handle_reaction_event(self, event: dict[str, Any]) -> None: + """Forward an emoji reaction on a cached message as text.""" + if self._is_own_message(event): + return + item = event.get("item") or {} + channel_id = item.get("channel") or "" + ts = item.get("ts") or "" + user_id = event.get("user") or "" + reaction = event.get("reaction") or "" + if not (channel_id and ts and user_id and reaction): + return + if self._is_duplicate(f"reaction:{channel_id}:{ts}:{user_id}:{reaction}"): + return + + cached = self._message_cache.get(ts) + if not cached: + # Only react to reactions on messages from this conversation that + # we still hold context for; anything else has no session to + # attach to and would open one from a stray emoji. + return + target, original_text = cached + + channel_type = "im" if channel_id.startswith("D") else "channel" + if not await self._authorize(user_id, channel_id, channel_type): + return + + text = f'[Reaction: :{reaction}: on message: "{original_text}"]' + logger.info("Slack reaction from %s in %s: :%s:", user_id, target, reaction) + + msg = InboundMessage( + channel_name="slack", + channel_key=f"slack:{target}", + sender_id=target, + text=text, + metadata={}, + ) + try: + await self.router.handle_message(msg) + except Exception as e: + logger.error("Agent error for reaction in %s: %s", target, e, exc_info=True) + + # ------------------------------------------------------------------ # + # Attachments # + # ------------------------------------------------------------------ # + + async def _download_file(self, url: str) -> bytes | None: + """Fetch a private Slack file with the bot token.""" + import httpx + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get( + url, + headers={"Authorization": f"Bearer {self.config.slack.bot_token}"}, + follow_redirects=True, + ) + resp.raise_for_status() + return resp.content + except Exception as e: + logger.warning("Slack file download failed for %s: %s", url, e) + return None + + async def _extract_files( + self, files: list[dict[str, Any]], + ) -> tuple[str, list[dict[str, str]]]: + """Pull message attachments into prompt text and content blocks. + + Returns ``(context_text, blocks)``. Text files are inlined, images and + PDFs become base64 blocks, ZIPs are unpacked one level, and anything + else contributes a metadata line only. + """ + if not files: + return "", [] + + parts: list[str] = [] + blocks: list[dict[str, str]] = [] + + for f in files: + name = f.get("name") or "unnamed" + mime = f.get("mimetype") or "" + size = int(f.get("size") or 0) + ext = f".{name.rsplit('.', 1)[-1].lower()}" if "." in name else "" + size_str = ( + f"{size / 1024:.0f} KB" if size < 1_000_000 + else f"{size / 1_000_000:.1f} MB" + ) + meta = f"[File: {name} ({size_str}, {mime or 'unknown type'})]" + url = f.get("url_private_download") or f.get("url_private") or "" + + if size > _MAX_DOWNLOAD_SIZE or not url: + parts.append(f"{meta}\n(Too large or not downloadable)") + continue + + is_text = ( + mime.startswith("text/") + or ext in _TEXT_EXTENSIONS + or f.get("mode") == "snippet" + ) + if is_text: + if size > _MAX_TEXT_SIZE: + parts.append(f"{meta}\n(Text file too large to inline — {size_str})") + continue + data = await self._download_file(url) + if data is None: + parts.append(meta) + continue + parts.append( + f"{meta}\n```\n{data.decode('utf-8', errors='replace')}\n```", + ) + continue + + if mime in _IMAGE_EXT_TO_MIME.values() or ext in _IMAGE_EXT_TO_MIME: + data = await self._download_file(url) + if data is None: + parts.append(meta) + continue + blocks.append({ + "type": "base64", + "media_type": mime or _IMAGE_EXT_TO_MIME.get(ext, "image/png"), + "data": base64.b64encode(data).decode("utf-8"), + }) + parts.append(meta) + continue + + if mime == "application/pdf" or ext == ".pdf": + data = await self._download_file(url) + if data is None: + parts.append(meta) + continue + blocks.append({ + "type": "base64", + "media_type": "application/pdf", + "data": base64.b64encode(data).decode("utf-8"), + }) + parts.append(meta) + continue + + if ext == ".zip" or mime in ("application/zip", "application/x-zip-compressed"): + data = await self._download_file(url) + if data is None: + parts.append(meta) + continue + zip_blocks, zip_text = extract_zip(data, meta) + blocks.extend(zip_blocks) + parts.append(zip_text) + continue + + parts.append(meta) + + return "\n".join(parts), blocks + + # ------------------------------------------------------------------ # + # Outbound # + # ------------------------------------------------------------------ # + + async def _post( + self, target: str, text: str, blocks: list[dict] | None = None, + ) -> str | None: + """Post one message to a target. Returns its ts. + + Raises on an API failure rather than reporting success. Slack rate + limits ``chat.postMessage`` to roughly one call per second per + channel, so failure here is ordinary, and a caller that cannot see it + will either drop the agent's reply or record an undelivered + notification as delivered. Callers that genuinely want best-effort + catch it themselves. + """ + if self._web is None: + return None + channel_id, thread_ts = parse_target(target) + resp = await self._web.chat_postMessage( + channel=channel_id, + text=text, + blocks=blocks, + thread_ts=thread_ts, + unfurl_links=False, + unfurl_media=False, + ) + return resp.get("ts") + + async def send(self, message: OutboundMessage) -> None: + """Send a complete message, split to fit Slack's render limit. + + Propagates a failure so StreamAdapter can fall back to editing the + streaming placeholder; swallowing it loses the whole turn. + """ + if self._web is None: + return + for chunk in split_message(message.text, MAX_MSG_LEN): + ts = await self._post(message.target, _md_to_slack(chunk)) + if ts: + self._cache_message(ts, message.target, chunk) + + def format_response(self, text: str) -> str: + """Return text unchanged — :meth:`send` splits and converts it.""" + return text + + # ------------------------------------------------------------------ # + # Streaming protocol # + # ------------------------------------------------------------------ # + + async def send_placeholder(self, target: str, session_id: str) -> str | None: + """Post the placeholder that streaming updates will edit in place. + + Returns None if the post fails, which makes StreamAdapter fall back + to sending the finished reply as one message. Raising instead would + abort the turn over a rate-limited placeholder. + """ + try: + return await self._post(target, "⏳") + except Exception as e: + logger.warning( + "Slack placeholder failed for %s (%s) — streaming this turn " + "without one", target, e, + ) + return None + + async def edit_message(self, target: str, message_id: str, text: str) -> None: + """Rewrite a previously sent message with the latest streamed text.""" + if self._web is None: + return + channel_id, _ = parse_target(target) + body = _md_to_slack(text) + if len(body) > MAX_MSG_LEN: + body = body[:MAX_MSG_LEN] + "…" + try: + await self._web.chat_update( + channel=channel_id, ts=message_id, text=body, + ) + except Exception as e: + logger.debug("Slack chat.update failed for %s: %s", target, e) + self._cache_message(message_id, target, text) + + async def delete_message(self, target: str, message_id: str) -> None: + """Remove a message — used to clear the streaming placeholder.""" + if self._web is None: + return + channel_id, _ = parse_target(target) + try: + await self._web.chat_delete(channel=channel_id, ts=message_id) + except Exception as e: + logger.debug("Slack chat.delete failed for %s: %s", target, e) + + async def send_typing(self, target: str) -> None: + """Acknowledge receipt with an 👀 reaction. + + Slack has no typing indicator a bot may raise, and a "thinking…" + message would be one more post to clean up. A reaction on the message + being answered says the same thing and disappears with it. + """ + if self._web is None: + return + ts = self._last_inbound_ts.get(target) + if not ts: + return + channel_id, _ = parse_target(target) + try: + await self._web.reactions_add( + channel=channel_id, timestamp=ts, name="eyes", + ) + except Exception as e: + # already_reacted is the normal case on a follow-up message. + logger.debug("Slack reactions.add (ack) failed: %s", e) + + async def set_reaction(self, target: str, message_id: Any, emoji: str) -> None: + """Set an emoji reaction on a message.""" + if self._web is None: + return + name = slack_emoji_name(emoji) + if not name: + logger.info("Slack has no short name for reaction %r — skipped", emoji) + return + channel_id, _ = parse_target(target) + try: + await self._web.reactions_add( + channel=channel_id, timestamp=str(message_id), name=name, + ) + except Exception as e: + logger.warning("Slack reactions.add failed for %s: %s", target, e) + + async def send_file(self, target: str, file_path: str) -> bool: + """Upload a file into the conversation as an attachment.""" + if self._web is None or not target: + return False + path = Path(file_path) + if not path.is_file(): + return False + channel_id, thread_ts = parse_target(target) + try: + await self._web.files_upload_v2( + channel=channel_id, + file=str(path), + filename=path.name, + thread_ts=thread_ts, + ) + return True + except Exception as e: + logger.warning("Slack files_upload_v2 failed for %s: %s", target, e) + return False + + @staticmethod + def _remember( + cache: collections.OrderedDict, key: str, value: Any, limit: int, + ) -> None: + """Insert into an LRU cache, evicting the oldest entries past *limit*.""" + cache[key] = value + cache.move_to_end(key) + while len(cache) > limit: + cache.popitem(last=False) + + def _cache_message(self, ts: str, target: str, text: str) -> None: + """Store a message snippet in the LRU cache for reaction lookups.""" + snippet = (text or "")[:200] + if not snippet: + return + self._remember( + self._message_cache, ts, (target, snippet), _MESSAGE_CACHE_MAX, + ) + + # ------------------------------------------------------------------ # + # Slash commands — /nerve # + # ------------------------------------------------------------------ # + + async def _handle_slash_command(self, payload: dict[str, Any]) -> None: + """Handle ``/nerve ``. + + One command with subcommands rather than one command per action: + Slack registers commands per workspace, so ``/new`` and ``/stop`` + would collide with every other app installed there. + """ + user_id = payload.get("user_id") or "" + channel_id = payload.get("channel_id") or "" + if not user_id or not channel_id: + return + + channel_type = "im" if channel_id.startswith("D") else "channel" + if not await self._authorize(user_id, channel_id, channel_type): + await self._respond_ephemeral( + channel_id, user_id, "You are not authorized to use this bot.", + ) + return + + args = (payload.get("text") or "").strip().split() + sub = args[0].lower() if args else "help" + rest = args[1:] + target = format_target(channel_id) + channel_key = f"slack:{target}" + + enabled = self.enabled_commands + if sub == "session": + sub = "sessions" + if sub != "help" and sub not in enabled: + known = sub in SLACK_ALL_COMMANDS + await self._respond_ephemeral( + channel_id, user_id, + f"`/nerve {sub}` is turned off for this workspace." + if known + else f"No such command `/nerve {sub}`.", + ) + return + + # `sessions` and `new` bind a session to the command's own key. In a + # threaded channel nothing ever reads that key, so they would answer + # as if they had worked and change nothing. + if sub in ("sessions", "new") and not self._binds_to_channel_key(channel_id): + await self._respond_ephemeral( + channel_id, user_id, self._THREADED_CHANNEL_REFUSAL.format(sub=sub), + ) + return + + if sub == "sessions": + await self._send_sessions_view(channel_id, user_id, channel_key) + elif sub == "new": + await self._cmd_new(channel_id, user_id, channel_key, rest) + elif sub == "stop": + await self._cmd_stop(channel_id, user_id, channel_key) + elif sub in ("star", "unstar"): + await self._cmd_star(channel_id, user_id, channel_key, sub == "star") + elif sub == "doctor": + from nerve.cli import doctor_report + await self._respond_ephemeral( + channel_id, user_id, f"```\n{doctor_report(self.config)}\n```", + ) + elif sub == "restart": + import subprocess + import sys + + await self._respond_ephemeral(channel_id, user_id, "Restarting Nerve…") + logger.info("Restart requested by Slack user %s", user_id) + subprocess.Popen( + [sys.executable, "-m", "nerve", "restart"], + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + elif sub == "reply": + await self._cmd_reply(channel_id, user_id, " ".join(rest)) + else: + await self._respond_ephemeral( + channel_id, user_id, self._help_text(enabled), + ) + + @staticmethod + def _help_text(enabled: frozenset[str]) -> str: + """Help listing only what this workspace can actually run.""" + lines = [ + (name, f"• `/nerve {usage}` — {what}") + for name, usage, what in ( + ("sessions", "sessions", "list and switch sessions"), + ("new", "new [title]", "stop the current session, start a new one"), + ("stop", "stop", "stop a running session in this channel"), + ("star", "star", "keep a session alive"), + ("unstar", "unstar", "let a session auto-close again"), + ("reply", "reply ", "answer the latest pending question"), + ("doctor", "doctor", "health report"), + ("restart", "restart", "restart the daemon"), + ) + if name in enabled + ] + if not lines: + return "No `/nerve` commands are enabled for this workspace." + return "*Nerve commands*\n" + "\n".join(line for _, line in lines) + + _THREADED_CHANNEL_REFUSAL = ( + "`/nerve {sub}` needs a thread to bind the session to, and Slack does " + "not run `/nerve` inside one. Every new mention in this channel " + "already opens its own thread and its own session. Use `/nerve stop` " + "to end one, or set `slack.reply_in_thread: false` to keep a single " + "session per channel." + ) + + def _binds_to_channel_key(self, channel_id: str) -> bool: + """Whether ordinary messages here land on the command's own key. + + A slash command carries no thread reference, so it can only name + ``slack:``. A direct message routes there, and so does a + channel message while ``reply_in_thread`` is off. With it on, a + channel message opens a thread and routes to + ``slack::`` instead, so a session bound at channel level + is never read again: the command reports success and the next message + starts somewhere else. + """ + if not channel_id: + return False + if channel_id.startswith("D"): + return True + return not self.config.slack.reply_in_thread + + async def _cmd_new( + self, channel_id: str, user_id: str, channel_key: str, args: list[str], + ) -> None: + prev = await self.router.get_last_session(channel_key) + if prev: + await self.router.engine.stop_session(prev) + title = " ".join(args) or None + session_id = await self.router.create_session( + channel_key, title=title, source="slack", + ) + await self._respond_ephemeral( + channel_id, user_id, + f"New session `{session_id}`" + (f" — {title}" if title else ""), + ) + + async def _live_sessions_for_channel(self, channel_id: str) -> list[dict[str, Any]]: + """Every live session reachable from this channel, newest first. + + Slack refuses to run a slash command inside a thread — it answers + "/nerve is not supported in threads" — so a command never carries + thread context and cannot simply read the session for its own key. + With per-thread routing that key usually owns nothing while the + threads beside it are busy, which is how ``/nerve stop`` came to + report "No active session" with three turns still running. + + The prefix is re-checked per row because ``slack:C123`` is also a + prefix of ``slack:C1234``. + """ + rows = await self.router.list_conversation_sessions(f"slack:{channel_id}") + matching: list[dict[str, Any]] = [] + for row in rows: + key = row.get("channel_key") or "" + if not key.startswith("slack:"): + continue + row_channel, thread_ts = parse_target(key[len("slack:"):]) + if row_channel != channel_id: + continue + matching.append({**row, "thread_ts": thread_ts}) + return matching + + @staticmethod + def _session_choice_label(row: dict[str, Any]) -> str: + """Button label naming one session, and the thread it belongs to.""" + title = (row.get("title") or "").strip() or row.get("session_id", "?") + where = "in thread" if row.get("thread_ts") else "in channel" + label = f"{title} ({where})" + return label[:74] + "…" if len(label) > 75 else label + + def _session_picker_blocks( + self, + candidates: list[dict[str, Any]], + prompt: str, + action_prefix: str, + style: str | None = None, + ) -> list[dict[str, Any]]: + """One button per live session, so the caller names the target. + + Slack does not allow ``/nerve`` inside a thread, so a command cannot + say which of a channel's threads was meant. Asking is safer than + acting on whichever row the query returned first. + """ + return [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + f"*{len(candidates)} sessions are live in this " + f"channel.* {prompt}" + ), + }, + }, + *[ + { + "type": "actions", + "block_id": f"{action_prefix}row:{row['session_id']}", + "elements": [{ + "type": "button", + "text": { + "type": "plain_text", + "text": self._session_choice_label(row), + "emoji": True, + }, + "action_id": f"{action_prefix}{row['session_id']}", + "value": row["session_id"], + **({"style": style} if style else {}), + }], + } + for row in candidates[:_MAX_ACTION_ELEMENTS] + ], + ] + + async def _cmd_stop( + self, channel_id: str, user_id: str, channel_key: str, + ) -> None: + candidates = await self._live_sessions_for_channel(channel_id) + if not candidates: + await self._respond_ephemeral( + channel_id, user_id, "No active session in this channel.", + ) + return + + if len(candidates) == 1: + await self._stop_and_report(channel_id, user_id, candidates[0]) + return + + await self._respond_ephemeral_blocks( + channel_id, user_id, + text="Which session should I stop?", + blocks=self._session_picker_blocks( + candidates, "Pick the one to stop:", "sessstop:", style="danger", + ), + ) + + async def _stop_and_report( + self, channel_id: str, user_id: str, row: dict[str, Any], + ) -> None: + """Stop one session and say which one, so the answer is checkable.""" + session_id = row["session_id"] + stopped = await self.router.engine.stop_session(session_id) + where = "the thread" if row.get("thread_ts") else "this channel" + await self._respond_ephemeral( + channel_id, user_id, + f"Stopped `{session_id}` in {where}." + if stopped + else f"`{session_id}` was not running.", + ) + + async def _cmd_star( + self, channel_id: str, user_id: str, channel_key: str, starred: bool, + ) -> None: + # Same thread-blindness as stop: resolve across the conversation + # rather than the command's own key, which usually owns nothing. + candidates = await self._live_sessions_for_channel(channel_id) + verb = "star" if starred else "unstar" + if not candidates: + await self._respond_ephemeral( + channel_id, user_id, + f"No active session to {verb} in this channel.", + ) + return + + if len(candidates) == 1: + await self._star_and_report( + channel_id, user_id, candidates[0]["session_id"], starred, + ) + return + + await self._respond_ephemeral_blocks( + channel_id, user_id, + text=f"Which session should I {verb}?", + blocks=self._session_picker_blocks( + candidates, + f"Pick the one to {verb}:", + f"{_STAR_ACTION_PREFIX}{int(starred)}:", + ), + ) + + async def _star_and_report( + self, channel_id: str, user_id: str, session_id: str, starred: bool, + ) -> None: + """Star or unstar one session and name it in the reply.""" + try: + await self.router.set_session_starred(session_id, starred) + except ValueError as e: + await self._respond_ephemeral(channel_id, user_id, str(e)) + return + await self._respond_ephemeral( + channel_id, user_id, + f"⭐ Starred `{session_id}` — it won't auto-close when idle." + if starred + else f"☆ Unstarred `{session_id}` — normal auto-close applies.", + ) + + async def _cmd_reply(self, channel_id: str, user_id: str, answer: str) -> None: + if not answer: + await self._respond_ephemeral( + channel_id, user_id, "Usage: `/nerve reply `", + ) + return + if not self._notification_service: + await self._respond_ephemeral( + channel_id, user_id, "Notification service not available.", + ) + return + pending = await self._notification_service.db.list_notifications( + status="pending", type="question", limit=1, + ) + if not pending: + await self._respond_ephemeral(channel_id, user_id, "No pending questions.") + return + ok = await self._notification_service.handle_answer( + notification_id=pending[0]["id"], answer=answer, answered_by="slack", + ) + await self._respond_ephemeral( + channel_id, user_id, + f"Answer recorded for: {pending[0]['title']}" + if ok + else "Failed to record answer.", + ) + + async def _respond_ephemeral( + self, channel_id: str, user_id: str, text: str, + ) -> None: + """Reply so only the person who ran the command sees it.""" + if self._web is None: + return + try: + await self._web.chat_postEphemeral( + channel=channel_id, user=user_id, text=_md_to_slack(text), + ) + except Exception as e: + logger.warning("Slack chat.postEphemeral failed: %s", e) + + async def _respond_ephemeral_blocks( + self, channel_id: str, user_id: str, text: str, blocks: list[dict], + ) -> None: + """Ephemeral reply carrying Block Kit, for the pickers.""" + if self._web is None: + return + try: + await self._web.chat_postEphemeral( + channel=channel_id, user=user_id, text=text, blocks=blocks, + ) + except Exception as e: + logger.warning("Slack ephemeral blocks failed: %s", e) + + async def _send_sessions_view( + self, channel_id: str, user_id: str, channel_key: str, + ) -> None: + """Post the session switcher, visible only to the requester.""" + if self._web is None: + return + blocks = await self._sessions_blocks_for(channel_key) + try: + await self._web.chat_postEphemeral( + channel=channel_id, user=user_id, + text="Sessions", blocks=blocks, + ) + except Exception as e: + logger.warning("Slack sessions view failed: %s", e) + + async def _sessions_blocks_for(self, channel_key: str) -> list[dict[str, Any]]: + """Build the session switcher for a conversation. + + Only interactive sessions with history are offered as switch targets; + an empty session is nothing to switch to and cron sessions are never + switch targets. + """ + current = await self.router.get_last_session(channel_key) + sessions = await self.router.list_sessions(limit=30) + non_empty: list[dict] = [] + for s in sessions: + if s.get("source") not in ("slack", "telegram", "web"): + continue + if await self.router.count_session_messages(s["id"]) > 0: + non_empty.append(s) + if len(non_empty) >= _SESSIONS_BUTTON_LIMIT: + break + return build_sessions_blocks(non_empty, current) + + # ------------------------------------------------------------------ # + # Interactive — Block Kit button presses # + # ------------------------------------------------------------------ # + + async def _handle_interactive(self, payload: dict[str, Any]) -> None: + """Handle a Block Kit button press.""" + if payload.get("type") != "block_actions": + return + actions = payload.get("actions") or [] + if not actions: + return + action_id = actions[0].get("action_id") or "" + value = actions[0].get("value") or "" + + user_id = (payload.get("user") or {}).get("id") or "" + channel_id = (payload.get("channel") or {}).get("id") or "" + response_url = payload.get("response_url") or "" + if not user_id: + return + + channel_type = "im" if channel_id.startswith("D") else "channel" + if channel_id and not await self._authorize(user_id, channel_id, channel_type): + return + + if action_id.startswith("sessstop:"): + stopped = await self.router.engine.stop_session(value) + await self._replace_via_url( + response_url, + f"Stopped `{value}`." if stopped else f"`{value}` was not running.", + ) + return + + if action_id.startswith(_STAR_ACTION_PREFIX): + await self._handle_star_button(action_id, value, response_url) + return + + if action_id.startswith("sess:") or action_id.startswith("sessstar:"): + await self._handle_session_button( + action_id, value, channel_id, user_id, response_url, + ) + return + + if action_id.startswith("notif:"): + await self._handle_notification_button( + action_id, value, payload, response_url, + ) + + async def _handle_star_button( + self, action_id: str, value: str, response_url: str, + ) -> None: + """Star or unstar the session picked from the `/nerve star` card.""" + parts = action_id.split(":", 2) + if len(parts) < 3: + return + starred = parts[1] == "1" + session_id = value or parts[2] + try: + await self.router.set_session_starred(session_id, starred) + except ValueError: + await self._replace_via_url( + response_url, "That session is no longer available.", + ) + return + await self._replace_via_url( + response_url, + f"⭐ Starred `{session_id}`." + if starred + else f"☆ Unstarred `{session_id}`.", + ) + + async def _handle_session_button( + self, + action_id: str, + value: str, + channel_id: str, + user_id: str, + response_url: str, + ) -> None: + """Switch, create, or star a session from the switcher card.""" + channel_key = f"slack:{format_target(channel_id)}" + + # A card posted before `reply_in_thread` was turned on, or one kept + # open in a threaded channel, would bind the session to a key no + # message ever reads. Starring does not touch the mapping, so it is + # still allowed. + if not action_id.startswith("sessstar:") and not self._binds_to_channel_key( + channel_id, + ): + await self._replace_via_url( + response_url, + self._THREADED_CHANNEL_REFUSAL.format(sub="sessions"), + ) + return + + if action_id.startswith("sessstar:"): + try: + await self.router.toggle_session_starred(value) + except ValueError: + await self._replace_via_url( + response_url, "That session is no longer available.", + ) + return + elif value == "new": + await self.router.create_session(channel_key, source="slack") + else: + try: + await self.router.switch_session(channel_key, value) + except ValueError: + await self._replace_via_url( + response_url, "That session is no longer available.", + ) + return + + blocks = await self._sessions_blocks_for(channel_key) + await self._replace_via_url(response_url, "Sessions", blocks) + + async def _handle_notification_button( + self, + action_id: str, + value: str, + payload: dict[str, Any], + response_url: str, + ) -> None: + """Record an answer to a notification and settle the card.""" + parts = action_id.split(":", 2) + if len(parts) < 3: + return + notification_id = parts[1] + answer = value or parts[2] + + if not self._notification_service: + await self._replace_via_url(response_url, "Service unavailable.") + return + + actor = (payload.get("user") or {}).get("id") or "" + success = await self._notification_service.handle_answer( + notification_id=notification_id, answer=answer, answered_by="slack", + ) + if not success: + await self._replace_via_url( + response_url, "Already answered or expired.", + ) + return + + # The card's section text is the mrkdwn Slack already stores. Running + # it through the Markdown converter a second time turns *bold* into + # _italic_ and escapes the link markup, so it is carried across + # verbatim and only the status line is converted. + original = "\n".join( + (block.get("text") or {}).get("text") or "" + for block in (payload.get("message") or {}).get("blocks") or [] + if block.get("type") == "section" + ).strip("\n") + + status = f"✅ Answered: {_md_to_slack(answer)}" + snoozed_until = await self._get_snoozed_until(notification_id) + if snoozed_until: + status = f"💤 Snoozed until {snoozed_until} — will resurface" + # Written as raw mention markup: the converter would escape it, and + # the card is the only place a reader sees who pressed the button. + if actor: + status += f" (by <@{actor}>)" + await self._replace_via_url( + response_url, + f"{original}\n\n{status}" if original else status, + already_mrkdwn=True, + ) + + async def _get_snoozed_until(self, notification_id: str) -> str | None: + """Human-readable re-delivery time if the row was snoozed. + + A snoozed approval is the only outcome that leaves the row pending + with ``redeliver_at`` set. Cosmetic, so every failure yields None. + """ + try: + notif = await self._notification_service.db.get_notification( + notification_id, + ) + if ( + not notif + or notif.get("status") != "pending" + or not notif.get("redeliver_at") + ): + return None + from datetime import datetime + dt = datetime.fromisoformat(notif["redeliver_at"]) + return dt.astimezone().strftime("%Y-%m-%d %H:%M %Z") + except Exception: + return None + + async def _replace_via_url( + self, + response_url: str, + text: str, + blocks: list[dict] | None = None, + already_mrkdwn: bool = False, + ) -> None: + """Replace the card a button lives on. + + ``response_url`` is the only way to edit an ephemeral message, and it + works for in-channel cards too, so both paths use it. + + ``already_mrkdwn`` says *text* came back off a card Slack rendered, + so it must go out untouched. Converting it again reads the mrkdwn as + Markdown and rewrites the message. + """ + if not response_url: + return + import httpx + + body: dict[str, Any] = { + "replace_original": True, + "text": text if (blocks or already_mrkdwn) else _md_to_slack(text), + } + if blocks: + body["blocks"] = blocks + try: + async with httpx.AsyncClient(timeout=10.0) as client: + await client.post(response_url, json=body) + except Exception as e: + logger.debug("Slack response_url update failed: %s", e) diff --git a/nerve/channels/stream_adapter.py b/nerve/channels/stream_adapter.py index 9a41661a..fc12979f 100644 --- a/nerve/channels/stream_adapter.py +++ b/nerve/channels/stream_adapter.py @@ -170,8 +170,14 @@ async def _handle_done(self) -> None: await self.channel.delete_message(self.target, placeholder_id) except Exception: pass # Duplicate is better than lost response - elif not self._supports_streaming: - # Non-streaming channel: send the accumulated response as one message + elif not self._supports_streaming or ( + self._supports_edit and not self._placeholder_id + ): + # Non-streaming channel, or an edit-in-place channel whose + # placeholder never got created: send the accumulated response as + # one message. Without the second case a channel that reports a + # failed placeholder as None drops the entire reply — it matches + # neither the edit branch above nor a non-streaming channel. text = self._normalize_text(self._buffer) if text: formatted = self.channel.format_response(text) diff --git a/nerve/cli.py b/nerve/cli.py index 97acedb1..5e645ed1 100644 --- a/nerve/cli.py +++ b/nerve/cli.py @@ -1110,6 +1110,37 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s else: lines.append("[--] Telegram disabled") + # Check Slack + if config.slack.enabled: + missing = [ + name for name, value in ( + ("bot_token", config.slack.bot_token), + ("app_token", config.slack.app_token), + ) if not value + ] + if missing: + errors.append( + f"[ERR] Slack enabled but {', '.join(missing)} not set " + "(Socket Mode needs both)" + ) + else: + lines.append(f"[OK] Slack bot token: ...{config.slack.bot_token[-4:]}") + slack = config.slack + if not slack.allow_users and not slack.allow_channels: + warnings.append( + "[WARN] slack.allow_users and slack.allow_channels are both " + "empty — the bot refuses every message. Add your Slack " + "member id to slack.allow_users" + ) + else: + lines.append( + f"[OK] Slack guardrails: {len(slack.allow_users)} allowed " + f"user(s), {len(slack.allow_channels)} allowed channel(s), " + f"{len(slack.deny_users) + len(slack.deny_channels)} deny rule(s)" + ) + else: + lines.append("[--] Slack disabled") + # Check SSL if config.gateway.ssl.enabled: if config.gateway.ssl.cert and config.gateway.ssl.cert.exists(): diff --git a/nerve/config.py b/nerve/config.py index dc192688..77cda6ea 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -999,6 +999,144 @@ def from_dict(cls, d: dict, locked: bool = False) -> TelegramConfig: ) +# Every `/nerve` subcommand, and the set enabled when the operator says +# nothing. Kept here rather than in the channel so config validation can +# name them without importing the transport. +SLACK_ALL_COMMANDS: tuple[str, ...] = ( + "sessions", "new", "stop", "star", "unstar", "reply", "doctor", "restart", +) +# `sessions` and `reply` are absent on purpose. Both reach every session in +# the instance, including web and Telegram ones, and Slack has no ownership +# model yet to narrow them to the caller. In a workspace where several people +# may DM the bot that would let any of them list, attach to, continue, or +# answer someone else's work. List them in `slack.commands` to turn them on. +SLACK_DEFAULT_COMMANDS: tuple[str, ...] = ( + "new", "stop", "star", "unstar", +) + + +def _slack_commands(raw: object) -> list[str] | None: + """Normalize ``slack.commands``. + + ``None`` (absent) keeps the default set; ``[]`` disables the slash + command. ``"all"`` as the sole entry means every known subcommand, so a + trusted workspace does not have to list them. + + An unknown name is dropped with a warning rather than refused: the whole + point of the key is to *narrow* what a workspace can reach, and a typo + that stopped the daemon booting would be a worse failure than a command + that stays off. + """ + if raw is None: + return None + if isinstance(raw, str): + raw = [raw] + if not isinstance(raw, (list, tuple)): + logger.warning( + "slack.commands must be a list — ignoring %r and keeping the " + "default set", raw, + ) + return None + + names = [str(v).strip().lstrip("/").lower() for v in raw if str(v).strip()] + if names == ["all"] or names == ["*"]: + return list(SLACK_ALL_COMMANDS) + + kept, unknown = [], [] + for name in names: + (kept if name in SLACK_ALL_COMMANDS else unknown).append(name) + if unknown: + logger.warning( + "slack.commands has no such subcommand(s): %s — known ones are %s", + ", ".join(sorted(set(unknown))), ", ".join(SLACK_ALL_COMMANDS), + ) + return kept + + +@dataclass +class SlackConfig: + """Slack bot channel — Socket Mode transport plus access guardrails. + + The allow/deny lists are the whole authorization story: Slack has no + pairing step, because a workspace already decides who can reach the bot + at all. Patterns match a Slack id (``U0123ABC``), a handle, a display + name, an email, or a channel name, case-insensitively and with globs + (``eng-*``). See :mod:`nerve.channels.access` for the semantics — deny + wins, a non-empty allow list is a gate, and a policy with no allow + patterns at all refuses everything. + """ + + # Off until the workspace is set up. Slack reaches an installation that + # never asked for it, so an absent or credential-less section resolves to + # disabled and `nerve doctor` stays quiet about a channel nobody uses. + enabled: bool = False + bot_token: str = "" # xoxb-… — Web API calls + app_token: str = "" # xapp-… — Socket Mode connection + allow_users: list[str] = field(default_factory=list) + deny_users: list[str] = field(default_factory=list) + allow_channels: list[str] = field(default_factory=list) + deny_channels: list[str] = field(default_factory=list) + stream_mode: str = "partial" + # Reply inside the thread the message came from, and treat each thread as + # its own session. Off means every message in a channel shares one session + # and replies land at channel level. + reply_in_thread: bool = True + # Which `/nerve` subcommands the workspace may run. None keeps the + # default set, which acts only on this channel's own sessions; an empty + # list turns the slash command off entirely. `doctor` and `restart` are + # operator tools and are opt-in: the first prints host health into a + # shared workspace, and the second lets anyone on the allow list bounce + # the daemon. `sessions` and `reply` are opt-in too, because they reach + # every session in the instance. + # See SLACK_ALL_COMMANDS / SLACK_DEFAULT_COMMANDS. + commands: list[str] | None = None + + @classmethod + @_coerced + def from_dict(cls, d: dict, locked: bool = False) -> SlackConfig: + stream_mode = d.get("stream_mode", "partial") + if stream_mode not in ("partial", "full"): + logger.warning( + "slack.stream_mode %r is not one of ('partial', 'full') — " + "falling back to 'partial'", + stream_mode, + ) + stream_mode = "partial" + bot_token = d.get("bot_token", "") + app_token = d.get("app_token", "") + # Slack is opt-in. An installation that predates the channel has no + # `slack` section at all, and reading that as "on" makes `nerve + # doctor` fail on every one of them over credentials nobody meant to + # set. Without an explicit `enabled`, the section counts as switched + # on only once both Socket Mode tokens are there. + # + # Lockdown still wins, for the reason TelegramConfig.from_dict gives: + # whether a box answers Slack is a per-machine decision written to the + # machine-local config.yaml, and lockdown drops that layer. Shared + # settings carrying a token this box can resolve must not start + # serving a workspace on their own. + # + # An unparseable value falls back to off rather than to the declared + # default, so `enabled: ${SLACK_ON:-nope}` cannot turn the bot on. + enabled = ( + _as_bool(d["enabled"], False, label="SlackConfig.enabled") + if "enabled" in d + else bool(bot_token and app_token) and not locked + ) + return cls( + enabled=enabled, + bot_token=bot_token, + app_token=app_token, + allow_users=d.get("allow_users") or [], + deny_users=d.get("deny_users") or [], + allow_channels=d.get("allow_channels") or [], + deny_channels=d.get("deny_channels") or [], + stream_mode=stream_mode, + reply_in_thread=d.get("reply_in_thread", True), + commands=_slack_commands(d.get("commands")), + ) + + @dataclass class TelegramSyncConfig: enabled: bool = True @@ -2600,6 +2738,7 @@ class NerveConfig: gateway: GatewayConfig = field(default_factory=GatewayConfig) agent: AgentConfig = field(default_factory=AgentConfig) telegram: TelegramConfig = field(default_factory=TelegramConfig) + slack: SlackConfig = field(default_factory=SlackConfig) sync: SyncConfig = field(default_factory=SyncConfig) memory: MemoryConfig = field(default_factory=MemoryConfig) cron: CronConfig = field(default_factory=CronConfig) @@ -2828,6 +2967,7 @@ def _build_from_dict(cls, d: dict) -> NerveConfig: gateway=GatewayConfig.from_dict(d.get("gateway", {})), agent=AgentConfig.from_dict(d.get("agent", {})), telegram=TelegramConfig.from_dict(d.get("telegram", {}), locked=locked), + slack=SlackConfig.from_dict(d.get("slack", {}), locked=locked), sync=SyncConfig.from_dict(d.get("sync", {})), memory=MemoryConfig.from_dict(d.get("memory", {})), cron=CronConfig.from_dict(d.get("cron", {}), workspace=workspace, locked=locked), diff --git a/nerve/config_reload.py b/nerve/config_reload.py index e30b2fd0..1949cdc3 100644 --- a/nerve/config_reload.py +++ b/nerve/config_reload.py @@ -86,6 +86,9 @@ "mcp_endpoint.path", "memory", "proxy", + "slack.app_token", + "slack.bot_token", + "slack.enabled", "sync.codex", "telegram.allowed_users", "telegram.bot_token", @@ -105,6 +108,8 @@ "auth.jwt_secret", "langfuse.public_key", "langfuse.secret_key", + "slack.app_token", + "slack.bot_token", "telegram.bot_token", }) diff --git a/nerve/gateway/server.py b/nerve/gateway/server.py index 3f409ed8..21c09e78 100644 --- a/nerve/gateway/server.py +++ b/nerve/gateway/server.py @@ -344,6 +344,34 @@ async def lifespan(app: FastAPI): await telegram_channel.start() logger.info("Telegram bot started") + # Start Slack bot if enabled + slack_channel = None + if config.slack.enabled and config.slack.bot_token and config.slack.app_token: + from nerve.channels.slack import SlackChannel + # get_config, not the object read above: the channel resolves config per + # use so a reload reaches the reads that happen per event (the + # allow/deny lists). + slack_channel = SlackChannel(get_config, _engine.router) + slack_channel.set_notification_service(notification_service) + try: + await slack_channel.start() + except Exception as e: + # A bad token or a revoked app must not stop the daemon booting — + # every other channel and the web UI still work without Slack. + # Registration happens only after a clean start: a half-built + # channel left in the router still answers get_channel("slack"), + # so notification fanout would keep posting into a dead client and + # recording the result as delivered. + logger.error("Slack bot failed to start: %s", e, exc_info=True) + try: + await slack_channel.stop() + except Exception: + logger.debug("Slack cleanup after failed start raised", exc_info=True) + slack_channel = None + else: + _engine.register_channel(slack_channel) + logger.info("Slack bot started") + # Start cron service global _cron_service cron_task = None @@ -675,12 +703,14 @@ async def _periodic_notify_maintenance(): logger.warning("External-agents sync shutdown raised: %s", e) _external_agents_sync = None - # Shutdown: stop telegram FIRST, before cancelling background tasks. - # Background task cancellation propagates through anyio cancel scopes - # (Starlette runs the lifespan in an anyio context), which can kill - # the telegram polling task before we get a chance to stop it cleanly. + # Shutdown: stop the chat channels FIRST, before cancelling background + # tasks. Background task cancellation propagates through anyio cancel + # scopes (Starlette runs the lifespan in an anyio context), which can kill + # the polling and socket tasks before we get a chance to stop them cleanly. if telegram_channel: await telegram_channel.stop() + if slack_channel: + await slack_channel.stop() if ws_sync_task: # Exit through the loop's own stop path rather than cancelling it where # it stands: a cycle interrupted between the merge and the reload leaves diff --git a/nerve/migrate.py b/nerve/migrate.py index 30e8f8cb..28d4eef0 100644 --- a/nerve/migrate.py +++ b/nerve/migrate.py @@ -127,7 +127,8 @@ r"(?=0.21.0", "pyyaml>=6.0", "python-telegram-bot>=21.0", + "slack-sdk>=3.33.0", + # Socket Mode's asyncio client is the aiohttp one; slack-sdk leaves the + # transport dependency to the caller. + "aiohttp>=3.10", # Floor raised from 0.2.82 for mcp 2.x: 0.2.140 is the first release whose # own constraint is `mcp<3.0.0` (0.2.100 through 0.2.139 all declare # `mcp<2.0.0`). It matters because the SDK builds the in-process MCP server diff --git a/tests/fake_slack.py b/tests/fake_slack.py new file mode 100644 index 00000000..747b912c --- /dev/null +++ b/tests/fake_slack.py @@ -0,0 +1,251 @@ +"""A local stand-in for Slack — the Web API and the Socket Mode gateway. + +Socket Mode is just a WebSocket the bot dials out to, and the URL for it +comes from ``apps.connections.open`` on the Web API. Point a +:class:`slack_sdk.web.async_client.AsyncWebClient` at a different +``base_url`` and both halves are ours, so a Slack integration test needs no +workspace, no tokens, and no network. + +Usage:: + + async with FakeSlack() as slack: + channel = SlackChannel(lambda: cfg, router) + with slack.patch_client(monkeypatch): + await channel.start() + await slack.push_event({"type": "message", ...}) + await slack.wait_for("chat.postMessage") + +What it deliberately does not do: rate limits, pagination, scope +enforcement, or Block Kit validation. It answers the calls this channel +makes, records them, and lets a test push envelopes at the bot. +""" + +from __future__ import annotations + +import asyncio +import json +import uuid +from typing import Any + +from aiohttp import web + + +class FakeSlack: + """An aiohttp app serving the Slack endpoints SlackChannel calls.""" + + def __init__(self, bot_user_id: str = "U0BOT") -> None: + self.bot_user_id = bot_user_id + # Every Web API call, in order: (method_name, parsed_body). + self.calls: list[tuple[str, dict[str, Any]]] = [] + # Envelope ids the bot acked over the socket. + self.acks: list[str] = [] + # Names returned by users.info / conversations.info, set by the test. + self.users: dict[str, dict[str, Any]] = {} + self.conversations: dict[str, dict[str, Any]] = {} + # Methods that should answer with an error instead of ok. + self.errors: dict[str, str] = {} + + self._ws: web.WebSocketResponse | None = None + self._connected = asyncio.Event() + self._runner: web.AppRunner | None = None + self._port = 0 + self._ts = 1_000_000.0 + + # -- lifecycle ------------------------------------------------------ # + + async def __aenter__(self) -> FakeSlack: + await self.start() + return self + + async def __aexit__(self, *exc: object) -> None: + await self.stop() + + async def start(self) -> str: + """Bind to a free port and start serving. Returns the base URL.""" + app = web.Application() + app.router.add_get("/link", self._handle_socket) + # slack_sdk sends read methods (users.info, conversations.info) as GET + # and writes as POST, so both verbs reach the same dispatcher. + app.router.add_post("/api/{method}", self._handle_api) + app.router.add_get("/api/{method}", self._handle_api) + + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, "127.0.0.1", 0) + await site.start() + self._port = site._server.sockets[0].getsockname()[1] + return self.base_url + + async def stop(self) -> None: + if self._ws is not None and not self._ws.closed: + await self._ws.close() + if self._runner is not None: + await self._runner.cleanup() + + @property + def base_url(self) -> str: + return f"http://127.0.0.1:{self._port}" + + def patch_client(self, monkeypatch) -> None: + """Point slack_sdk's AsyncWebClient at this server. + + SlackChannel imports AsyncWebClient inside ``start()``, so replacing + the module attribute is enough — and the Socket Mode client reuses + that same web client to look up its WebSocket URL. + """ + import functools + + import slack_sdk.web.async_client as module + + monkeypatch.setattr( + module, + "AsyncWebClient", + functools.partial(module.AsyncWebClient, base_url=f"{self.base_url}/api/"), + ) + + # -- the socket ----------------------------------------------------- # + + async def _handle_socket(self, request: web.Request) -> web.WebSocketResponse: + ws = web.WebSocketResponse(autoping=True) + await ws.prepare(request) + self._ws = ws + await ws.send_str(json.dumps({"type": "hello", "num_connections": 1})) + self._connected.set() + + async for message in ws: + if message.type is not web.WSMsgType.TEXT: + continue + try: + payload = json.loads(message.data) + except json.JSONDecodeError: + continue + envelope_id = payload.get("envelope_id") + if envelope_id: + self.acks.append(envelope_id) + return ws + + async def wait_connected(self, timeout: float = 5.0) -> None: + """Block until the bot has opened its socket.""" + await asyncio.wait_for(self._connected.wait(), timeout) + + async def push(self, envelope_type: str, payload: dict[str, Any]) -> str: + """Push one Socket Mode envelope at the bot. Returns its envelope id.""" + await self.wait_connected() + assert self._ws is not None + envelope_id = str(uuid.uuid4()) + await self._ws.send_str(json.dumps({ + "type": envelope_type, + "envelope_id": envelope_id, + "payload": payload, + "accepts_response_payload": False, + })) + return envelope_id + + async def push_event(self, event: dict[str, Any]) -> str: + """Push an Events API event (the common case).""" + return await self.push("events_api", {"event": event}) + + # -- the Web API ---------------------------------------------------- # + + async def _handle_api(self, request: web.Request) -> web.Response: + method = request.match_info["method"] + body = await self._parse_body(request) + self.calls.append((method, body)) + + if method in self.errors: + return web.json_response({"ok": False, "error": self.errors[method]}) + + handler = getattr(self, f"_api_{method.replace('.', '_')}", None) + if handler is None: + return web.json_response({"ok": True}) + return web.json_response(handler(body)) + + @staticmethod + async def _parse_body(request: web.Request) -> dict[str, Any]: + """Read a call's arguments from the query string, a form, or JSON.""" + if request.method == "GET": + items: Any = request.query.items() + elif request.content_type == "application/json": + return await request.json() + else: + items = (await request.post()).items() + parsed: dict[str, Any] = {} + for key, value in items: + text = str(value) + if text.startswith(("[", "{")): + try: + parsed[key] = json.loads(text) + continue + except json.JSONDecodeError: + pass + parsed[key] = text + return parsed + + def _next_ts(self) -> str: + self._ts += 1 + return f"{self._ts:.6f}" + + def _api_auth_test(self, body: dict) -> dict: + return { + "ok": True, + "user_id": self.bot_user_id, + "user": "nerve", + "team": "T0FAKE", + } + + def _api_apps_connections_open(self, body: dict) -> dict: + return {"ok": True, "url": f"ws://127.0.0.1:{self._port}/link"} + + def _api_chat_postMessage(self, body: dict) -> dict: + return {"ok": True, "ts": self._next_ts(), "channel": body.get("channel")} + + def _api_chat_postEphemeral(self, body: dict) -> dict: + return {"ok": True, "message_ts": self._next_ts()} + + def _api_chat_update(self, body: dict) -> dict: + return {"ok": True, "ts": body.get("ts"), "channel": body.get("channel")} + + def _api_users_info(self, body: dict) -> dict: + user_id = body.get("user", "") + return { + "ok": True, + "user": self.users.get(user_id, {"id": user_id, "name": user_id, "profile": {}}), + } + + def _api_conversations_info(self, body: dict) -> dict: + channel_id = body.get("channel", "") + return { + "ok": True, + "channel": self.conversations.get( + channel_id, {"id": channel_id, "name": channel_id}, + ), + } + + # -- assertions ----------------------------------------------------- # + + def calls_to(self, method: str) -> list[dict[str, Any]]: + """Every recorded body for one API method, in order.""" + return [body for name, body in self.calls if name == method] + + async def wait_for( + self, method: str, count: int = 1, timeout: float = 5.0, + ) -> list[dict[str, Any]]: + """Wait until *method* has been called *count* times, then return the bodies. + + Events are dispatched off the ack path, so a test that asserts + immediately after pushing would race the handler. + """ + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + bodies = self.calls_to(method) + if len(bodies) >= count: + return bodies + await asyncio.sleep(0.01) + raise AssertionError( + f"{method} was called {len(self.calls_to(method))} times, " + f"expected {count}. Calls seen: {[n for n, _ in self.calls]}", + ) + + async def settle(self, delay: float = 0.15) -> None: + """Give the bot time to finish dispatching before asserting a negative.""" + await asyncio.sleep(delay) diff --git a/tests/test_config_resolution.py b/tests/test_config_resolution.py index 01de1739..1116d986 100644 --- a/tests/test_config_resolution.py +++ b/tests/test_config_resolution.py @@ -18,6 +18,7 @@ CronConfig, NerveConfig, ProxyConfig, + SlackConfig, SSLConfig, TelegramConfig, WorkflowRunsConfig, @@ -200,6 +201,86 @@ def test_allowed_users_coerced_to_int(self): assert cfg.allowed_users == [123, 456] +class TestSlackIsOptIn: + """Slack must not switch itself on for installations that predate it. + + Every existing config has no ``slack`` section. Reading that as enabled + made ``nerve doctor`` report missing tokens and exit 1 on all of them. + """ + + def test_a_config_with_no_slack_section_is_disabled(self): + assert NerveConfig.from_dict({"model": "x"}).slack.enabled is False + + def test_an_empty_slack_section_is_disabled(self): + assert SlackConfig.from_dict({}).enabled is False + + def test_a_section_without_tokens_is_disabled(self): + assert SlackConfig.from_dict({"allow_users": ["U0123ABC"]}).enabled is False + + def test_both_tokens_turn_it_on_without_an_enabled_key(self): + cfg = SlackConfig.from_dict({"bot_token": "xoxb-1", "app_token": "xapp-1"}) + assert cfg.enabled is True + + def test_one_token_alone_is_not_enough(self): + assert SlackConfig.from_dict({"bot_token": "xoxb-1"}).enabled is False + + def test_an_explicit_false_wins_over_the_tokens(self): + cfg = SlackConfig.from_dict({ + "enabled": False, "bot_token": "xoxb-1", "app_token": "xapp-1", + }) + assert cfg.enabled is False + + def test_an_explicit_true_is_honoured_without_tokens(self): + # Someone who asks for Slack and forgets the tokens should be told + # so by the doctor, not silently left with the channel off. + assert SlackConfig.from_dict({"enabled": True}).enabled is True + + def test_lockdown_keeps_a_token_bearing_section_off(self): + # Lockdown drops the machine-local layer that decides whether this + # box answers Slack, so shared settings alone must not start it. + cfg = SlackConfig.from_dict( + {"bot_token": "xoxb-1", "app_token": "xapp-1"}, locked=True, + ) + assert cfg.enabled is False + + def test_an_unreadable_value_falls_back_to_off(self): + cfg = SlackConfig.from_dict({ + "enabled": "${SLACK_ON}", "bot_token": "xoxb-1", "app_token": "xapp-1", + }) + assert cfg.enabled is False + + def test_the_doctor_says_nothing_about_an_unconfigured_slack(self): + from nerve.cli import doctor_report + + report = doctor_report(NerveConfig.from_dict({"model": "x"})) + assert "Slack enabled but" not in report + assert "[--] Slack disabled" in report + + def test_the_doctor_still_reports_missing_tokens_when_asked_for(self): + from nerve.cli import doctor_report + + report = doctor_report(NerveConfig.from_dict({"slack": {"enabled": True}})) + assert "[ERR] Slack enabled but bot_token, app_token not set" in report + + +class TestSlackDefaultCommands: + def test_globally_scoped_commands_are_off_by_default(self): + # Both reach every session in the instance, web and Telegram + # included, and Slack has no ownership model to narrow them to the + # caller. Listing them in slack.commands turns them back on. + from nerve.config import SLACK_DEFAULT_COMMANDS + + assert "sessions" not in SLACK_DEFAULT_COMMANDS + assert "reply" not in SLACK_DEFAULT_COMMANDS + + def test_they_are_still_available_on_request(self): + from nerve.config import SLACK_ALL_COMMANDS + + cfg = SlackConfig.from_dict({"commands": ["sessions", "reply"]}) + assert cfg.commands == ["sessions", "reply"] + assert {"sessions", "reply"} <= set(SLACK_ALL_COMMANDS) + + class TestAppendTelegramAllowedUser: def test_creates_file_when_missing(self, tmp_path): assert append_telegram_allowed_user(tmp_path, 42) is True diff --git a/tests/test_db.py b/tests/test_db.py index 3e4f453a..7f184be8 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1756,3 +1756,75 @@ async def test_finish_keeps_start_link(self, db: Database): logs = await db.get_cron_logs(job_id="job-live2") assert logs[0]["session_id"] == "cron:job-live2:20260610-130000" + + +class TestChannelSessionsByPrefix: + """One conversation can own several sessions — a Slack channel keys one + per thread — so a caller holding only the conversation needs the set. + """ + + async def _seed(self, db, mapping, statuses=None): + statuses = statuses or {} + for channel_key, session_id in mapping.items(): + await db.create_session(session_id, title=f"t-{session_id}") + if session_id in statuses: + await db.update_session_fields( + session_id, {"status": statuses[session_id]}, + ) + await db.set_channel_session(channel_key, session_id) + + @pytest.mark.asyncio + async def test_it_finds_every_thread_under_one_channel(self, db): + await self._seed(db, { + "slack:C1": "base", + "slack:C1:1.0": "thread_a", + "slack:C1:2.0": "thread_b", + }) + rows = await db.list_channel_sessions_by_prefix("slack:C1") + assert {r["session_id"] for r in rows} == {"base", "thread_a", "thread_b"} + + @pytest.mark.asyncio + async def test_it_does_not_bleed_into_a_longer_channel_id(self, db): + # "slack:C1" is a prefix of "slack:C12" as a string. + await self._seed(db, {"slack:C1": "mine", "slack:C12": "theirs"}) + rows = await db.list_channel_sessions_by_prefix("slack:C1") + assert "theirs" in {r["session_id"] for r in rows}, ( + "the SQL is a prefix match; the caller narrows further" + ) + rows = await db.list_channel_sessions_by_prefix("slack:C12") + assert {r["session_id"] for r in rows} == {"theirs"} + + @pytest.mark.asyncio + async def test_wildcards_in_the_key_are_escaped(self, db): + # An unescaped _ or % would silently widen the match. + await self._seed(db, {"slack:C_1": "literal", "slack:CX1": "other"}) + rows = await db.list_channel_sessions_by_prefix("slack:C_1") + assert {r["session_id"] for r in rows} == {"literal"} + + @pytest.mark.asyncio + async def test_a_mapping_cannot_point_at_a_missing_session(self, db): + # channel_sessions.session_id is a foreign key, so the join in the + # prefix query is for the title and status, not to filter orphans — + # there are none to filter. + import sqlite3 + + with pytest.raises(sqlite3.IntegrityError): + await db.set_channel_session("slack:C1:9.9", "never-created") + + @pytest.mark.asyncio + async def test_excluded_statuses_are_left_out(self, db): + await self._seed( + db, + {"slack:C1:1.0": "live", "slack:C1:2.0": "gone"}, + statuses={"gone": "archived"}, + ) + rows = await db.list_channel_sessions_by_prefix( + "slack:C1", exclude_statuses=("archived",), + ) + assert {r["session_id"] for r in rows} == {"live"} + + @pytest.mark.asyncio + async def test_rows_carry_the_session_title(self, db): + await self._seed(db, {"slack:C1:1.0": "s1"}) + rows = await db.list_channel_sessions_by_prefix("slack:C1") + assert rows[0]["title"] == "t-s1" diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py new file mode 100644 index 00000000..0768b15d --- /dev/null +++ b/tests/test_slack_channel.py @@ -0,0 +1,1483 @@ +"""Slack channel — formatting, addressing, dispatch, and guardrails. + +Most of the surface is pure functions at module level, so they need no +transport. The event handlers are driven with a real SlackChannel whose +web client and router are stubs. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nerve.channels.base import ChannelCapability, OutboundMessage +from nerve.channels.slack import ( + MAX_MSG_LEN, + SlackChannel, + _md_to_slack, + build_notification_blocks, + build_sessions_blocks, + format_target, + is_slack_id, + parse_target, + slack_emoji_name, + slack_to_plain, + split_message, +) +from nerve.config import NerveConfig, SlackConfig + + +def _config(**slack_kwargs) -> NerveConfig: + cfg = NerveConfig() + cfg.slack = SlackConfig( + enabled=True, + bot_token="xoxb-test", + app_token="xapp-test", + **slack_kwargs, + ) + return cfg + + +def _channel(**slack_kwargs) -> SlackChannel: + """A channel with a stub transport, ready to take events.""" + cfg = _config(**slack_kwargs) + channel = SlackChannel(lambda: cfg, router=MagicMock()) + channel._web = MagicMock() + channel._web.chat_postMessage = AsyncMock(return_value={"ts": "1.1"}) + channel._web.chat_update = AsyncMock(return_value={"ok": True}) + channel._web.chat_delete = AsyncMock(return_value={"ok": True}) + channel._web.reactions_add = AsyncMock(return_value={"ok": True}) + channel._bot_user_id = "U0BOT" + channel.router.handle_message = AsyncMock(return_value="done") + channel.router.get_last_session = AsyncMock(return_value=None) + return channel + + +# ---------------------------------------------------------------------- # +# Addressing # +# ---------------------------------------------------------------------- # + + +class TestTargets: + def test_a_channel_without_a_thread_round_trips(self): + assert parse_target(format_target("C1")) == ("C1", None) + + def test_a_thread_round_trips(self): + assert parse_target(format_target("C1", "169.9")) == ("C1", "169.9") + + def test_a_trailing_colon_is_not_a_thread(self): + assert parse_target("C1:") == ("C1", None) + + +# ---------------------------------------------------------------------- # +# Formatting # +# ---------------------------------------------------------------------- # + + +class TestMarkdownToSlack: + def test_double_star_becomes_slack_bold(self): + assert _md_to_slack("**bold**") == "*bold*" + + def test_single_star_becomes_slack_italic(self): + assert _md_to_slack("*emphasis*") == "_emphasis_" + + def test_a_heading_becomes_a_bold_line_not_an_italic_one(self): + # The heading rewrite emits a bold marker, which the italic pass + # would otherwise consume and turn into _H_. + assert _md_to_slack("## Heading") == "*Heading*" + + def test_code_spans_are_left_alone(self): + assert _md_to_slack("`**kwargs`") == "`**kwargs`" + + def test_code_fences_are_left_alone(self): + assert _md_to_slack("```\na = **b**\n```") == "```\na = **b**\n```" + + def test_links_become_slack_link_syntax(self): + assert _md_to_slack("[docs](http://x/y)") == "" + + def test_reserved_characters_are_escaped(self): + assert _md_to_slack("a < b & c > d") == "a < b & c > d" + + def test_a_link_url_is_escaped_the_way_slack_stores_it(self): + # Verified against a live workspace: Slack rewrites a bare & inside a + # link to &, so emitting it raw made the stored message differ + # from the one we sent. + assert _md_to_slack("[q](http://x?a=1&b=2)") == "" + + def test_bullets_become_real_bullets(self): + assert _md_to_slack("- one\n- two") == "• one\n• two" + + +class TestSlackToPlain: + def test_the_bots_own_mention_is_dropped(self): + assert slack_to_plain("<@U0BOT> hello", "U0BOT") == "hello" + + def test_a_channel_reference_reads_as_a_channel(self): + assert slack_to_plain("see <#C1|general>") == "see #general" + + def test_a_link_keeps_both_label_and_url(self): + assert slack_to_plain("") == "X (http://x)" + + def test_broadcast_mentions_survive(self): + assert slack_to_plain(" ping") == "@here ping" + + def test_entities_are_unescaped(self): + assert slack_to_plain("a & b <c>") == "a & b " + + +class TestSplitMessage: + def test_short_text_is_one_chunk(self): + assert split_message("hi", 100) == ["hi"] + + def test_empty_text_produces_nothing(self): + assert split_message("", 100) == [] + + def test_splitting_prefers_line_boundaries(self): + assert split_message("aaaa\nbbbb\ncccc", 9) == ["aaaa\nbbbb", "cccc"] + + def test_an_overlong_single_line_is_cut(self): + assert split_message("a" * 10, 4) == ["aaaa", "aaaa", "aa"] + + def test_every_chunk_respects_the_limit(self): + text = "\n".join("line %d" % i for i in range(500)) + assert all(len(c) <= 40 for c in split_message(text, 40)) + + def test_nothing_is_lost(self): + text = "\n".join("line %d" % i for i in range(200)) + assert "\n".join(split_message(text, 40)) == text + + +class TestEmojiNames: + def test_a_unicode_emoji_maps_to_a_short_name(self): + assert slack_emoji_name("👍") == "thumbsup" + + def test_a_short_name_passes_through_without_colons(self): + assert slack_emoji_name(":tada:") == "tada" + + def test_an_unmapped_emoji_is_refused_rather_than_guessed(self): + assert slack_emoji_name("🫥") is None + + +# ---------------------------------------------------------------------- # +# Block Kit # +# ---------------------------------------------------------------------- # + + +class TestSessionBlocks: + def test_the_current_session_is_marked(self): + blocks = build_sessions_blocks([{"id": "a1", "title": "Work"}], "a1") + labels = [ + e["text"]["text"] + for b in blocks if b["type"] == "actions" for e in b["elements"] + ] + assert any(label.startswith("✓ ") for label in labels) + + def test_a_starred_session_shows_a_filled_star(self): + blocks = build_sessions_blocks( + [{"id": "a1", "title": "Work", "starred": True}], None, + ) + stars = [ + e["text"]["text"] + for b in blocks if b["type"] == "actions" for e in b["elements"] + ] + assert "⭐" in stars + + def test_the_session_id_rides_in_the_action_id(self): + blocks = build_sessions_blocks([{"id": "a1", "title": "W"}], None) + ids = [ + e["action_id"] + for b in blocks if b["type"] == "actions" for e in b["elements"] + ] + assert "sess:a1" in ids + assert "sessstar:a1" in ids + + def test_an_empty_list_still_offers_a_new_session(self): + blocks = build_sessions_blocks([], None) + assert any( + e["action_id"] == "sess:new" + for b in blocks if b["type"] == "actions" for e in b["elements"] + ) + + def test_button_labels_stay_inside_slacks_limit(self): + blocks = build_sessions_blocks([{"id": "a1", "title": "x" * 300}], None) + labels = [ + e["text"]["text"] + for b in blocks if b["type"] == "actions" for e in b["elements"] + ] + assert all(len(label) <= 75 for label in labels) + + +class TestNotificationBlocks: + def test_a_plain_notification_has_no_buttons(self): + blocks = build_notification_blocks("hi", "n1") + assert all(b["type"] != "actions" for b in blocks) + + def test_the_notification_id_and_value_ride_on_the_button(self): + blocks = build_notification_blocks( + "Deploy?", "n1", [("✅ Approve", "approve")], + ) + button = blocks[1]["elements"][0] + assert button["action_id"] == "notif:n1:approve" + assert button["value"] == "approve" + + def test_approval_decisions_are_colour_coded(self): + blocks = build_notification_blocks( + "Deploy?", "n1", [("Approve", "approve"), ("Decline", "decline")], + ) + styles = [e.get("style") for e in blocks[1]["elements"]] + assert styles == ["primary", "danger"] + + def test_section_text_stays_inside_slacks_limit(self): + blocks = build_notification_blocks("x" * 5000, "n1") + assert len(blocks[0]["text"]["text"]) <= 3000 + + +# ---------------------------------------------------------------------- # +# Channel wiring # +# ---------------------------------------------------------------------- # + + +class TestCapabilities: + def test_partial_stream_mode_declares_streaming(self): + assert ChannelCapability.STREAMING in _channel(stream_mode="partial").capabilities + + def test_full_stream_mode_does_not(self): + assert ChannelCapability.STREAMING not in _channel(stream_mode="full").capabilities + + def test_constraints_match_slacks_edit_rate_limit(self): + constraints = _channel().constraints + assert constraints.supports_message_edit + assert constraints.max_message_length == MAX_MSG_LEN + assert constraints.min_edit_interval >= 1.0 + + def test_the_policy_follows_a_config_reload(self): + # The channel outlives a reload, so the lists are read per use. + cfg = _config(allow_users=["U1"]) + channel = SlackChannel(lambda: cfg, router=MagicMock()) + assert channel.policy.users.allow == ["U1"] + cfg.slack.allow_users = ["U2"] + assert channel.policy.users.allow == ["U2"] + + +class TestAuthorization: + @pytest.mark.asyncio + async def test_an_unconfigured_policy_refuses_without_calling_slack(self): + channel = _channel() + channel._web.users_info = AsyncMock() + assert not await channel._authorize("U1", "D1", "im") + channel._web.users_info.assert_not_called() + + @pytest.mark.asyncio + async def test_an_id_allow_list_needs_no_name_lookup(self): + channel = _channel(allow_users=["U0123ABC"]) + channel._web.users_info = AsyncMock() + assert await channel._authorize("U0123ABC", "D1", "im") + channel._web.users_info.assert_not_called() + + @pytest.mark.asyncio + async def test_a_handle_allow_list_resolves_the_name(self): + channel = _channel(allow_users=["alex"]) + channel._web.users_info = AsyncMock( + return_value={"user": {"name": "alex", "profile": {}}}, + ) + assert await channel._authorize("U1", "D1", "im") + + @pytest.mark.asyncio + async def test_a_failed_lookup_with_a_deny_list_refuses(self): + channel = _channel(allow_users=["U1"], deny_users=["*-bot"]) + channel._web.users_info = AsyncMock(side_effect=RuntimeError("no scope")) + assert not await channel._authorize("U1", "D1", "im") + + @pytest.mark.asyncio + async def test_resolved_names_are_cached(self): + channel = _channel(allow_users=["alex"]) + channel._web.users_info = AsyncMock( + return_value={"user": {"name": "alex", "profile": {}}}, + ) + await channel._authorize("U1", "D1", "im") + await channel._authorize("U1", "D1", "im") + assert channel._web.users_info.await_count == 1 + + @pytest.mark.asyncio + async def test_a_direct_message_is_matched_as_dm(self): + channel = _channel(allow_users=["U1"], allow_channels=["dm"]) + assert await channel._authorize("U1", "D1", "im") + + +class TestMessageEvents: + @pytest.mark.asyncio + async def test_a_direct_message_reaches_the_router(self): + channel = _channel(allow_users=["U1"]) + await channel._handle_message_event({ + "type": "message", "channel": "D1", "channel_type": "im", + "user": "U1", "ts": "1.1", "text": "hello", + }) + msg = channel.router.handle_message.await_args[0][0] + assert msg.channel_name == "slack" + assert msg.text == "hello" + assert msg.sender_id == "D1" + assert msg.metadata["message_id"] == "1.1" + + @pytest.mark.asyncio + async def test_an_unauthorized_sender_never_reaches_the_router(self): + channel = _channel(allow_users=["U-other"]) + await channel._handle_message_event({ + "type": "message", "channel": "D1", "channel_type": "im", + "user": "U1", "ts": "1.1", "text": "hello", + }) + channel.router.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_the_bots_own_message_is_ignored(self): + channel = _channel(allow_users=["U0BOT"]) + await channel._handle_message_event({ + "type": "message", "channel": "D1", "channel_type": "im", + "user": "U0BOT", "ts": "1.1", "text": "hi", + }) + channel.router.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_another_bots_message_is_ignored(self): + channel = _channel(allow_users=["U1"]) + await channel._handle_message_event({ + "type": "message", "channel": "C1", "user": "U1", + "bot_id": "B9", "ts": "1.1", "text": "hi", + }) + channel.router.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_a_join_notice_is_ignored(self): + channel = _channel(allow_users=["U1"]) + await channel._handle_message_event({ + "type": "message", "subtype": "channel_join", + "channel": "C1", "user": "U1", "ts": "1.1", "text": "joined", + }) + channel.router.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_channel_chatter_without_a_mention_is_ignored(self): + # Adding the bot to a busy channel must not start a turn per remark. + channel = _channel(allow_users=["U1"]) + await channel._handle_message_event({ + "type": "message", "channel": "C1", "channel_type": "channel", + "user": "U1", "ts": "1.1", "text": "morning all", + }) + channel.router.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_a_mention_in_a_channel_is_answered(self): + channel = _channel(allow_users=["U1"]) + await channel._handle_message_event({ + "type": "message", "channel": "C1", "channel_type": "channel", + "user": "U1", "ts": "1.1", "text": "<@U0BOT> status?", + }) + msg = channel.router.handle_message.await_args[0][0] + assert msg.text == "status?" + + @pytest.mark.asyncio + async def test_a_channel_reply_opens_a_thread_on_the_message(self): + channel = _channel(allow_users=["U1"]) + await channel._handle_message_event({ + "type": "message", "channel": "C1", "channel_type": "channel", + "user": "U1", "ts": "1.1", "text": "<@U0BOT> hi", + }) + msg = channel.router.handle_message.await_args[0][0] + assert msg.sender_id == "C1:1.1" + assert msg.channel_key == "slack:C1:1.1" + + @pytest.mark.asyncio + async def test_each_thread_is_its_own_session(self): + channel = _channel(allow_users=["U1"]) + for ts, thread in (("1.1", "1.0"), ("2.1", "2.0")): + await channel._handle_message_event({ + "type": "message", "channel": "C1", "channel_type": "channel", + "user": "U1", "ts": ts, "thread_ts": thread, + "text": "<@U0BOT> hi", + }) + keys = [ + call[0][0].channel_key + for call in channel.router.handle_message.await_args_list + ] + assert keys == ["slack:C1:1.0", "slack:C1:2.0"] + + @pytest.mark.asyncio + async def test_thread_replies_continue_a_session_without_a_mention(self): + channel = _channel(allow_users=["U1"]) + channel.router.get_last_session = AsyncMock(return_value="s1") + await channel._handle_message_event({ + "type": "message", "channel": "C1", "channel_type": "channel", + "user": "U1", "ts": "1.2", "thread_ts": "1.0", "text": "and then?", + }) + channel.router.handle_message.assert_called_once() + + @pytest.mark.asyncio + async def test_reply_in_thread_off_keeps_one_session_per_channel(self): + channel = _channel(allow_users=["U1"], reply_in_thread=False) + await channel._handle_message_event({ + "type": "message", "channel": "C1", "channel_type": "channel", + "user": "U1", "ts": "1.1", "thread_ts": "1.0", + "text": "<@U0BOT> hi", + }) + msg = channel.router.handle_message.await_args[0][0] + assert msg.channel_key == "slack:C1" + + @pytest.mark.asyncio + async def test_a_redelivered_event_runs_once(self): + # Slack retries anything it thinks was not acked. + channel = _channel(allow_users=["U1"]) + event = { + "type": "message", "channel": "D1", "channel_type": "im", + "user": "U1", "ts": "1.1", "text": "hello", + } + await channel._handle_message_event(event) + await channel._handle_message_event(dict(event)) + channel.router.handle_message.assert_called_once() + + @pytest.mark.asyncio + async def test_a_message_and_its_app_mention_twin_run_once(self): + channel = _channel(allow_users=["U1"]) + base = { + "channel": "C1", "channel_type": "channel", "user": "U1", + "ts": "1.1", "text": "<@U0BOT> hi", + } + await channel._handle_message_event({"type": "app_mention", **base}) + await channel._handle_message_event({"type": "message", **base}) + channel.router.handle_message.assert_called_once() + + @pytest.mark.asyncio + async def test_an_empty_message_is_dropped(self): + channel = _channel(allow_users=["U1"]) + await channel._handle_message_event({ + "type": "message", "channel": "D1", "channel_type": "im", + "user": "U1", "ts": "1.1", "text": "", + }) + channel.router.handle_message.assert_not_called() + + +class TestReactionEvents: + @pytest.mark.asyncio + async def test_a_reaction_on_a_known_message_reaches_the_router(self): + channel = _channel(allow_users=["U1"]) + channel._cache_message("1.1", "D1", "the original") + await channel._handle_reaction_event({ + "type": "reaction_added", "user": "U1", "reaction": "tada", + "item": {"channel": "D1", "ts": "1.1"}, + }) + msg = channel.router.handle_message.await_args[0][0] + assert ":tada:" in msg.text + assert "the original" in msg.text + + @pytest.mark.asyncio + async def test_a_reaction_on_an_unknown_message_is_ignored(self): + # Otherwise a stray emoji anywhere in the workspace opens a session. + channel = _channel(allow_users=["U1"]) + await channel._handle_reaction_event({ + "type": "reaction_added", "user": "U1", "reaction": "tada", + "item": {"channel": "D1", "ts": "9.9"}, + }) + channel.router.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_an_unauthorized_reaction_is_ignored(self): + channel = _channel(allow_users=["U-other"]) + channel._cache_message("1.1", "D1", "the original") + await channel._handle_reaction_event({ + "type": "reaction_added", "user": "U1", "reaction": "tada", + "item": {"channel": "D1", "ts": "1.1"}, + }) + channel.router.handle_message.assert_not_called() + + +class TestOutbound: + @pytest.mark.asyncio + async def test_send_converts_markdown_and_targets_the_thread(self): + channel = _channel() + await channel.send(OutboundMessage(target="C1:1.0", text="**hi**")) + kwargs = channel._web.chat_postMessage.await_args.kwargs + assert kwargs["channel"] == "C1" + assert kwargs["thread_ts"] == "1.0" + assert kwargs["text"] == "*hi*" + + @pytest.mark.asyncio + async def test_a_long_reply_is_sent_as_several_messages(self): + channel = _channel() + await channel.send( + OutboundMessage(target="C1", text="\n".join(["x" * 100] * 100)), + ) + assert channel._web.chat_postMessage.await_count > 1 + + @pytest.mark.asyncio + async def test_nothing_is_truncated_away(self): + channel = _channel() + body = "\n".join(f"line {i}" for i in range(2000)) + await channel.send(OutboundMessage(target="C1", text=body)) + sent = "\n".join( + c.kwargs["text"] for c in channel._web.chat_postMessage.await_args_list + ) + assert sent == body + + @pytest.mark.asyncio + async def test_an_edit_stays_inside_the_length_limit(self): + channel = _channel() + await channel.edit_message("C1", "1.1", "y" * (MAX_MSG_LEN + 500)) + assert len(channel._web.chat_update.await_args.kwargs["text"]) <= MAX_MSG_LEN + 1 + + @pytest.mark.asyncio + async def test_a_failed_post_is_reported_not_swallowed(self): + # The caller has to see this: StreamAdapter's recovery path is the + # only thing that keeps the reply from disappearing. + channel = _channel() + channel._web.chat_postMessage = AsyncMock(side_effect=RuntimeError("ratelimited")) + with pytest.raises(RuntimeError): + await channel.send(OutboundMessage(target="C1", text="hi")) + + @pytest.mark.asyncio + async def test_set_reaction_translates_the_emoji(self): + channel = _channel() + await channel.set_reaction("C1:1.0", "1.1", "👍") + kwargs = channel._web.reactions_add.await_args.kwargs + assert kwargs == {"channel": "C1", "timestamp": "1.1", "name": "thumbsup"} + + @pytest.mark.asyncio + async def test_an_unmappable_reaction_is_skipped(self): + channel = _channel() + await channel.set_reaction("C1", "1.1", "🫥") + channel._web.reactions_add.assert_not_called() + + @pytest.mark.asyncio + async def test_the_typing_ack_reacts_to_the_message_being_answered(self): + channel = _channel(allow_users=["U1"]) + await channel._handle_message_event({ + "type": "message", "channel": "D1", "channel_type": "im", + "user": "U1", "ts": "1.1", "text": "hello", + }) + await channel.send_typing("D1") + assert channel._web.reactions_add.await_args.kwargs["timestamp"] == "1.1" + + @pytest.mark.asyncio + async def test_send_file_refuses_a_missing_path(self): + assert not await _channel().send_file("C1", "/nope/missing.txt") + + @pytest.mark.asyncio + async def test_the_watchdog_reconnects_a_dropped_socket(self, monkeypatch): + # is_connected() is a coroutine. Reading it without awaiting yields a + # truthy coroutine object, so the watchdog would call a dead socket + # healthy forever and never reconnect. + import nerve.channels.slack as slack_module + + monkeypatch.setattr(slack_module, "WATCHDOG_INTERVAL", 0.01) + channel = _channel() + dead = MagicMock() + dead.is_connected = AsyncMock(return_value=False) + dead.close = AsyncMock() + channel._client = dead + + fresh = MagicMock() + fresh.is_connected = AsyncMock(return_value=True) + fresh.connect = AsyncMock() + channel._build_socket_client = MagicMock(return_value=fresh) + + task = asyncio.create_task(channel._run_watchdog()) + await asyncio.sleep(0.05) + channel._stopping = True + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + fresh.connect.assert_awaited() + # The old socket must be closed first. Slack gives each event to one + # connection only, so a leftover one quietly takes a share of them. + dead.close.assert_awaited() + assert channel._client is fresh + + @pytest.mark.asyncio + async def test_send_file_refuses_without_a_target(self): + # The router passes an empty target when the session was not bound + # to this channel; uploading then would leak the file. + assert not await _channel().send_file("", "/etc/hostname") + + +# ---------------------------------------------------------------------- # +# Review regressions # +# ---------------------------------------------------------------------- # + + +class TestIdPredicate: + def test_real_slack_ids_are_recognised(self): + assert is_slack_id("U0123ABC") + assert is_slack_id("C0456DEF") + assert is_slack_id("W01ABCDEFGH") + + def test_an_uppercase_name_is_not_an_id(self): + # This is the bug: a case heuristic read ALICE as an id, skipped the + # users.info lookup, and let deny_users=["ALICE"] admit her. + assert not is_slack_id("ALICE") + assert not is_slack_id("ENGINEERING") + + def test_a_handle_or_email_is_not_an_id(self): + assert not is_slack_id("alex.soffronow") + assert not is_slack_id("a@b.com") + + def test_a_too_short_token_is_not_an_id(self): + assert not is_slack_id("U012") + + +class TestGuardrailRegressions: + @pytest.mark.asyncio + async def test_an_uppercase_deny_name_still_forces_a_lookup(self): + channel = _channel(deny_users=["ALICE"], allow_channels=["C0456DEF"]) + channel._web.users_info = AsyncMock(return_value={ + "user": {"id": "U999", "name": "ALICE", "profile": {"email": "a@b.c"}}, + }) + channel._web.conversations_info = AsyncMock(return_value={ + "channel": {"id": "C0456DEF", "name": "eng"}, + }) + assert not await channel._authorize("U999", "C0456DEF", "channel") + channel._web.users_info.assert_awaited() + + @pytest.mark.asyncio + async def test_a_missing_email_refuses_an_email_deny_rule(self): + # users.info answers 200 without profile.email when the token lacks + # users:read.email, so the deny pattern silently matched nothing. + channel = _channel(allow_users=["U999"], deny_users=["blocked@x.com"]) + channel._web.users_info = AsyncMock(return_value={ + "user": {"id": "U999", "name": "blocked", "profile": {}}, + }) + assert not await channel._authorize("U999", "D1", "im") + + @pytest.mark.asyncio + async def test_an_email_deny_rule_still_works_with_the_scope(self): + channel = _channel(allow_users=["*"], deny_users=["blocked@x.com"]) + channel._web.users_info = AsyncMock(return_value={ + "user": {"id": "U9", "name": "b", "profile": {"email": "blocked@x.com"}}, + }) + assert not await channel._authorize("U9", "D1", "im") + + @pytest.mark.asyncio + async def test_an_innocent_user_is_not_caught_by_an_email_deny_rule(self): + channel = _channel(allow_users=["*"], deny_users=["blocked@x.com"]) + channel._web.users_info = AsyncMock(return_value={ + "user": {"id": "U1", "name": "ok", "profile": {"email": "ok@x.com"}}, + }) + assert await channel._authorize("U1", "D1", "im") + + @pytest.mark.asyncio + async def test_id_only_lists_still_skip_the_lookup(self): + channel = _channel(allow_users=["U0123ABC"]) + channel._web.users_info = AsyncMock() + assert await channel._authorize("U0123ABC", "D1", "im") + channel._web.users_info.assert_not_called() + + @pytest.mark.asyncio + async def test_a_nameless_channel_lookup_refuses_a_channel_deny_rule(self): + channel = _channel(allow_users=["U1"], deny_channels=["*-secret"]) + channel._web.conversations_info = AsyncMock( + return_value={"channel": {"id": "C1"}}, + ) + assert not await channel._authorize("U1", "C1", "channel") + + +class TestOutboundFailureRegressions: + @pytest.mark.asyncio + async def test_send_propagates_a_failure(self): + # StreamAdapter recovers by editing the placeholder, but only if it + # is told. Swallowing the error dropped the whole reply. + channel = _channel() + channel._web.chat_postMessage = AsyncMock(side_effect=RuntimeError("ratelimited")) + with pytest.raises(RuntimeError): + await channel.send(OutboundMessage(target="C1", text="hi")) + + @pytest.mark.asyncio + async def test_a_failed_placeholder_returns_none_without_raising(self): + channel = _channel() + channel._web.chat_postMessage = AsyncMock(side_effect=RuntimeError("ratelimited")) + assert await channel.send_placeholder("C1", "s1") is None + + @pytest.mark.asyncio + async def test_a_missing_placeholder_still_delivers_the_reply(self): + # supports STREAMING + edit, but the placeholder post failed: this + # matched neither adapter branch and the turn vanished. + from nerve.channels.stream_adapter import StreamAdapter + + channel = _channel(stream_mode="partial") + channel._web.chat_postMessage = AsyncMock( + side_effect=[RuntimeError("ratelimited"), {"ts": "2.2"}], + ) + adapter = StreamAdapter(channel, "C1", "s1") + await adapter.initialize() + assert adapter._placeholder_id is None + await adapter.on_event("s1", {"type": "token", "content": "the answer"}) + await adapter.on_event("s1", {"type": "done"}) + sent = [c.kwargs["text"] for c in channel._web.chat_postMessage.await_args_list] + assert "the answer" in sent[-1] + + +class TestDispatchBounds: + @pytest.mark.asyncio + async def test_envelopes_past_the_cap_are_dropped(self): + import nerve.channels.slack as slack_module + + channel = _channel(allow_users=["U1"]) + monkeypatched = asyncio.Event() + channel._inflight = { # type: ignore[assignment] + asyncio.create_task(monkeypatched.wait()) + for _ in range(slack_module._MAX_INFLIGHT) + } + client = MagicMock() + client.send_socket_mode_response = AsyncMock() + req = MagicMock(envelope_id="e1", type="events_api", payload={}) + + before = len(channel._inflight) + await channel._on_request(client, req) + # Acked regardless — the drop must not look like a delivery failure. + client.send_socket_mode_response.assert_awaited_once() + assert len(channel._inflight) == before + + monkeypatched.set() + await asyncio.gather(*channel._inflight, return_exceptions=True) + + @pytest.mark.asyncio + async def test_stop_waits_for_inflight_dispatches(self): + channel = _channel() + started = asyncio.Event() + + async def _slow(): + started.set() + await asyncio.sleep(30) + + task = asyncio.create_task(_slow()) + channel._inflight.add(task) + await started.wait() + await channel.stop() + assert task.done() + + +class TestNotificationBlockLimits: + def test_options_are_chunked_to_slacks_actions_limit(self): + import nerve.channels.slack as slack_module + + options = [(f"opt{i}", f"v{i}") for i in range(60)] + blocks = build_notification_blocks("pick", "n1", options) + actions = [b for b in blocks if b["type"] == "actions"] + assert all( + len(b["elements"]) <= slack_module._MAX_ACTION_ELEMENTS for b in actions + ) + assert sum(len(b["elements"]) for b in actions) == 60 + + def test_block_ids_stay_unique_across_chunks(self): + options = [(f"opt{i}", f"v{i}") for i in range(60)] + blocks = build_notification_blocks("pick", "n1", options) + ids = [b["block_id"] for b in blocks if b["type"] == "actions"] + assert len(ids) == len(set(ids)) + + +class TestSubtypes: + @pytest.mark.asyncio + async def test_a_thread_broadcast_reply_is_answered(self): + # "Also send to channel" from inside a live agent thread was being + # discarded as a system subtype. + channel = _channel(allow_users=["U1"]) + channel.router.get_last_session = AsyncMock(return_value="s1") + await channel._handle_message_event({ + "type": "message", "subtype": "thread_broadcast", + "channel": "C1", "channel_type": "channel", + "user": "U1", "ts": "1.2", "thread_ts": "1.0", "text": "and then?", + }) + channel.router.handle_message.assert_called_once() + + +class TestOwnMessageDetection: + """Regression cover for a bug only a real workspace surfaced. + + Slack stamps a ``bot_id`` onto a message a *person* sent through an app + or integration, while still naming them in ``user``. Treating every + ``bot_id`` as our own silently dropped those people. + """ + + def _event(self, **over): + base = { + "type": "message", "channel": "C1", "channel_type": "channel", + "user": "U-human", "ts": "1.1", "text": "hi", + } + base.update(over) + return base + + def _ch(self): + channel = _channel(allow_users=["U-human"]) + channel._bot_user_id = "U0BOT" + channel._bot_id = "B0SELF" + return channel + + def test_our_own_bot_id_is_ours(self): + assert self._ch()._is_own_message(self._event(bot_id="B0SELF", user=None)) + + def test_our_own_bot_user_id_is_ours(self): + assert self._ch()._is_own_message(self._event(user="U0BOT")) + + def test_a_human_posting_through_an_app_is_not_ours(self): + # The live workspace produced exactly this: a real user id alongside + # another app's bot_id. + assert not self._ch()._is_own_message( + self._event(bot_id="B0OTHERAPP", user="U-human"), + ) + + def test_a_plain_human_message_is_not_ours(self): + assert not self._ch()._is_own_message(self._event()) + + @pytest.mark.asyncio + async def test_a_human_posting_through_an_app_reaches_the_router(self): + channel = self._ch() + await channel._handle_message_event( + self._event(bot_id="B0OTHERAPP", text="<@U0BOT> via an integration"), + ) + msg = channel.router.handle_message.await_args[0][0] + assert msg.text == "via an integration" + + @pytest.mark.asyncio + async def test_another_bot_is_still_ignored(self): + # Loop prevention now rests on the subtype, which is what a message + # with no human behind it carries. + channel = self._ch() + await channel._handle_message_event( + self._event(subtype="bot_message", bot_id="B0OTHERAPP", user=None), + ) + channel.router.handle_message.assert_not_called() + + +class TestAmpersandEscaping: + def test_an_ampersand_in_a_link_url_is_escaped(self): + # Slack rewrites a bare & inside a link, so emitting it unescaped + # made what we sent differ from what Slack stored. + assert _md_to_slack("[q](http://x?a=1&b=2)") == "" + + def test_an_ampersand_in_a_link_label_is_escaped(self): + assert _md_to_slack("[a & b](http://x)") == "" + + def test_an_existing_entity_is_not_double_escaped(self): + assert _md_to_slack("a & b") == "a & b" + assert _md_to_slack("x < y") == "x < y" + + def test_a_bare_ampersand_in_prose_is_still_escaped(self): + assert _md_to_slack("a & b") == "a & b" + + +class TestSlashCommandsAreThreadBlind: + """Slack refuses `/nerve` inside a thread — it answers "not supported in + threads" — so a command never carries thread context and must resolve + across the whole conversation. Verified against a live workspace. + """ + + def _ch(self, sessions, **kw): + channel = _channel(allow_users=["U1"], **kw) + channel.router.list_conversation_sessions = AsyncMock(return_value=sessions) + channel.router.engine.stop_session = AsyncMock(return_value=True) + channel._web.chat_postEphemeral = AsyncMock(return_value={"ok": True}) + return channel + + @staticmethod + def _row(sid, thread=None, title=None): + key = f"slack:C1:{thread}" if thread else "slack:C1" + return {"channel_key": key, "session_id": sid, "title": title or sid} + + @pytest.mark.asyncio + async def test_a_thread_session_is_found_from_a_channel_command(self): + # The regression: the command's own key owns nothing while a thread + # beside it is busy, and stop used to report "No active session". + channel = self._ch([self._row("s1", thread="1.0")]) + await channel._cmd_stop("C1", "U1", "slack:C1") + channel.router.engine.stop_session.assert_awaited_once_with("s1") + said = channel._web.chat_postEphemeral.await_args.kwargs["text"] + assert "s1" in said and "thread" in said + + @pytest.mark.asyncio + async def test_nothing_live_says_so_plainly(self): + channel = self._ch([]) + await channel._cmd_stop("C1", "U1", "slack:C1") + channel.router.engine.stop_session.assert_not_called() + assert "No active session" in channel._web.chat_postEphemeral.await_args.kwargs["text"] + + @pytest.mark.asyncio + async def test_several_live_sessions_ask_instead_of_guessing(self): + # Stopping someone else's thread silently would be worse than asking. + channel = self._ch([ + self._row("s1", thread="1.0", title="Deploy"), + self._row("s2", thread="2.0", title="Triage"), + ]) + await channel._cmd_stop("C1", "U1", "slack:C1") + channel.router.engine.stop_session.assert_not_called() + blocks = channel._web.chat_postEphemeral.await_args.kwargs["blocks"] + action_ids = [ + e["action_id"] + for b in blocks if b["type"] == "actions" for e in b["elements"] + ] + assert action_ids == ["sessstop:s1", "sessstop:s2"] + + @pytest.mark.asyncio + async def test_the_picker_button_stops_the_chosen_session(self): + channel = self._ch([]) + channel._replace_via_url = AsyncMock() + await channel._handle_interactive({ + "type": "block_actions", + "user": {"id": "U1"}, + "channel": {"id": "C1"}, + "response_url": "https://hooks.slack.test/x", + "actions": [{"action_id": "sessstop:s2", "value": "s2"}], + }) + channel.router.engine.stop_session.assert_awaited_once_with("s2") + + @pytest.mark.asyncio + async def test_another_channels_sessions_are_not_touched(self): + # "slack:C1" is a prefix of "slack:C12", so the query result is + # re-checked per row. + channel = self._ch([ + {"channel_key": "slack:C12:9.9", "session_id": "other"}, + self._row("mine", thread="1.0"), + ]) + found = await channel._live_sessions_for_channel("C1") + assert [r["session_id"] for r in found] == ["mine"] + + @pytest.mark.asyncio + async def test_star_also_resolves_across_threads(self): + channel = self._ch([self._row("s1", thread="1.0")]) + channel.router.set_session_starred = AsyncMock(return_value=True) + await channel._cmd_star("C1", "U1", "slack:C1", True) + channel.router.set_session_starred.assert_awaited_once_with("s1", True) + + +class TestCommandExposure: + def _ch(self, **kw): + channel = _channel(allow_users=["U1"], **kw) + channel._web.chat_postEphemeral = AsyncMock(return_value={"ok": True}) + return channel + + async def _run(self, channel, text): + await channel._handle_slash_command({ + "user_id": "U1", "channel_id": "C1", "text": text, + }) + return channel._web.chat_postEphemeral.await_args.kwargs["text"] + + def test_operator_commands_are_off_by_default(self): + # doctor prints host health into a shared workspace and restart lets + # anyone on the allow list bounce the daemon. + enabled = self._ch().enabled_commands + assert "doctor" not in enabled + assert "restart" not in enabled + assert {"new", "stop", "star", "unstar"} <= enabled + + def test_globally_scoped_commands_are_off_by_default(self): + # sessions lists and attaches every session in the instance, and + # reply answers whichever question is pending anywhere. In a + # workspace where several people may DM the bot, that is one + # member reading and continuing another's work. + enabled = self._ch().enabled_commands + assert "sessions" not in enabled + assert "reply" not in enabled + + @pytest.mark.asyncio + async def test_sessions_is_refused_unless_it_was_asked_for(self): + channel = self._ch() + channel.router.list_sessions = AsyncMock(return_value=[]) + said = await self._run(channel, "sessions") + assert "turned off" in said + channel.router.list_sessions.assert_not_called() + + @pytest.mark.asyncio + async def test_reply_is_refused_unless_it_was_asked_for(self): + channel = self._ch() + channel._notification_service = MagicMock() + said = await self._run(channel, "reply yes") + assert "turned off" in said + + def test_both_are_still_available_on_request(self): + enabled = self._ch(commands=["sessions", "reply"]).enabled_commands + assert enabled == frozenset({"sessions", "reply"}) + + def test_an_explicit_list_narrows_the_set(self): + assert self._ch(commands=["reply"]).enabled_commands == frozenset({"reply"}) + + def test_an_empty_list_turns_every_command_off(self): + assert self._ch(commands=[]).enabled_commands == frozenset() + + def test_all_enables_everything(self): + from nerve.config import SLACK_ALL_COMMANDS + + assert self._ch(commands=["all"]).enabled_commands == frozenset(SLACK_ALL_COMMANDS) + + @pytest.mark.asyncio + async def test_a_disabled_command_is_refused_not_run(self): + channel = self._ch(commands=["reply"]) + channel.router.engine.stop_session = AsyncMock() + said = await self._run(channel, "stop") + assert "turned off" in said + channel.router.engine.stop_session.assert_not_called() + + @pytest.mark.asyncio + async def test_restart_is_refused_by_default(self): + # The one that spawns a process, so it must not fall through. + channel = self._ch() + said = await self._run(channel, "restart") + assert "turned off" in said + + @pytest.mark.asyncio + async def test_an_unknown_command_reads_differently_from_a_disabled_one(self): + assert "No such command" in await self._run(self._ch(), "frobnicate") + + @pytest.mark.asyncio + async def test_help_lists_only_what_is_enabled(self): + said = await self._run(self._ch(commands=["stop", "reply"]), "help") + assert "/nerve stop" in said and "/nerve reply" in said + assert "doctor" not in said and "sessions" not in said + + @pytest.mark.asyncio + async def test_help_says_so_when_nothing_is_enabled(self): + assert "No `/nerve` commands" in await self._run(self._ch(commands=[]), "help") + + +class TestCommandsBindTheKeyMessagesRead: + """A slash command can only name ``slack:``. + + With ``reply_in_thread`` on, a channel message opens a thread and routes + to ``slack::``, so a session bound at channel level was + never read again: `/nerve new` reported a new session, left the running + thread alone, and the next mention started somewhere else. + """ + + def _ch(self, **kw): + channel = _channel(allow_users=["U1"], **kw) + channel._web.chat_postEphemeral = AsyncMock(return_value={"ok": True}) + channel.router.create_session = AsyncMock(return_value="s-new") + channel.router.switch_session = AsyncMock() + channel.router.engine.stop_session = AsyncMock(return_value=True) + channel.router.list_sessions = AsyncMock(return_value=[]) + return channel + + async def _route(self, bot, **event) -> str: + """The channel key an ordinary message in this conversation lands on.""" + base = {"type": "message", "user": "U1", "text": "<@U0BOT> hi"} + await bot._handle_message_event({**base, **event}) + return bot.router.handle_message.await_args[0][0].channel_key + + async def _run(self, channel, channel_id, text) -> str: + await channel._handle_slash_command({ + "user_id": "U1", "channel_id": channel_id, "text": text, + }) + return channel._web.chat_postEphemeral.await_args.kwargs["text"] + + @pytest.mark.asyncio + async def test_a_dm_command_binds_the_key_a_dm_message_reads(self): + channel = self._ch() + routed = await self._route( + channel, channel="D1", channel_type="im", ts="1.1", + ) + await self._run(channel, "D1", "new") + bound = channel.router.create_session.await_args[0][0] + assert bound == routed == "slack:D1" + + @pytest.mark.asyncio + async def test_a_dm_thread_keeps_a_session_of_its_own(self): + # A reply inside a DM thread is its own conversation, so it does not + # pick up what the command bound to the DM itself. + channel = self._ch() + routed = await self._route( + channel, channel="D1", channel_type="im", ts="1.2", thread_ts="1.0", + ) + assert routed == "slack:D1:1.0" + + @pytest.mark.asyncio + async def test_a_threaded_channel_refuses_rather_than_orphaning_a_session(self): + channel = self._ch() + routed = await self._route( + channel, channel="C1", channel_type="channel", ts="1.1", + ) + assert routed == "slack:C1:1.1" + + said = await self._run(channel, "C1", "new") + channel.router.create_session.assert_not_called() + channel.router.engine.stop_session.assert_not_called() + assert "needs a thread" in said + + @pytest.mark.asyncio + async def test_a_thread_reply_is_not_stopped_by_a_channel_command(self): + channel = self._ch() + await self._route( + channel, channel="C1", channel_type="channel", + ts="1.2", thread_ts="1.0", + ) + await self._run(channel, "C1", "new") + channel.router.engine.stop_session.assert_not_called() + + @pytest.mark.asyncio + async def test_an_unthreaded_channel_binds_the_key_a_message_reads(self): + channel = self._ch(reply_in_thread=False) + routed = await self._route( + channel, channel="C1", channel_type="channel", ts="1.1", + ) + await self._run(channel, "C1", "new") + bound = channel.router.create_session.await_args[0][0] + assert bound == routed == "slack:C1" + + @pytest.mark.asyncio + async def test_an_unthreaded_thread_reply_shares_the_channel_session(self): + channel = self._ch(reply_in_thread=False) + routed = await self._route( + channel, channel="C1", channel_type="channel", + ts="1.2", thread_ts="1.0", + ) + assert routed == "slack:C1" + + @pytest.mark.asyncio + async def test_the_session_picker_is_refused_in_a_threaded_channel(self): + # Its selection is written under the channel-level key too. + channel = self._ch(commands=["sessions"]) + said = await self._run(channel, "C1", "sessions") + channel.router.list_sessions.assert_not_called() + assert "needs a thread" in said + + @pytest.mark.asyncio + async def test_the_session_picker_still_opens_in_a_dm(self): + channel = self._ch(commands=["sessions"]) + channel.router.get_last_session = AsyncMock(return_value=None) + await self._run(channel, "D1", "sessions") + channel.router.list_sessions.assert_awaited() + + @pytest.mark.asyncio + async def test_a_picker_button_refuses_to_bind_in_a_threaded_channel(self): + channel = self._ch(commands=["sessions"]) + channel._replace_via_url = AsyncMock() + await channel._handle_interactive({ + "type": "block_actions", + "user": {"id": "U1"}, + "channel": {"id": "C1"}, + "response_url": "https://hooks.slack.test/x", + "actions": [{"action_id": "sess:s9", "value": "s9"}], + }) + channel.router.switch_session.assert_not_called() + assert "needs a thread" in channel._replace_via_url.await_args[0][1] + + @pytest.mark.asyncio + async def test_a_picker_button_still_switches_in_a_dm(self): + channel = self._ch(commands=["sessions"]) + channel._replace_via_url = AsyncMock() + channel.router.get_last_session = AsyncMock(return_value=None) + await channel._handle_interactive({ + "type": "block_actions", + "user": {"id": "U1"}, + "channel": {"id": "D1"}, + "response_url": "https://hooks.slack.test/x", + "actions": [{"action_id": "sess:s9", "value": "s9"}], + }) + channel.router.switch_session.assert_awaited_once_with("slack:D1", "s9") + + @pytest.mark.asyncio + async def test_starring_from_the_card_works_in_a_threaded_channel(self): + # Starring changes no mapping, so the thread guard must not block it. + channel = self._ch(commands=["sessions"]) + channel._replace_via_url = AsyncMock() + channel.router.toggle_session_starred = AsyncMock(return_value=True) + channel.router.get_last_session = AsyncMock(return_value=None) + await channel._handle_interactive({ + "type": "block_actions", + "user": {"id": "U1"}, + "channel": {"id": "C1"}, + "response_url": "https://hooks.slack.test/x", + "actions": [{"action_id": "sessstar:s9", "value": "s9"}], + }) + channel.router.toggle_session_starred.assert_awaited_once_with("s9") + + +class TestStarPicker: + """`/nerve star` used to act on ``candidates[0]``. + + `/nerve stop` shows a picker and the documentation said both did, so a + star landed on whichever thread the query returned first. + """ + + def _ch(self, sessions): + channel = _channel(allow_users=["U1"]) + channel.router.list_conversation_sessions = AsyncMock(return_value=sessions) + channel.router.set_session_starred = AsyncMock(return_value=True) + channel._web.chat_postEphemeral = AsyncMock(return_value={"ok": True}) + return channel + + @staticmethod + def _row(sid, thread=None, title=None): + key = f"slack:C1:{thread}" if thread else "slack:C1" + return {"channel_key": key, "session_id": sid, "title": title or sid} + + @pytest.mark.asyncio + async def test_nothing_live_says_so_plainly(self): + channel = self._ch([]) + await channel._cmd_star("C1", "U1", "slack:C1", True) + channel.router.set_session_starred.assert_not_called() + said = channel._web.chat_postEphemeral.await_args.kwargs["text"] + assert "No active session to star" in said + + @pytest.mark.asyncio + async def test_nothing_live_names_the_unstar_verb(self): + channel = self._ch([]) + await channel._cmd_star("C1", "U1", "slack:C1", False) + said = channel._web.chat_postEphemeral.await_args.kwargs["text"] + assert "unstar" in said + + @pytest.mark.asyncio + async def test_one_candidate_is_starred_and_named(self): + channel = self._ch([self._row("s1", thread="1.0")]) + await channel._cmd_star("C1", "U1", "slack:C1", True) + channel.router.set_session_starred.assert_awaited_once_with("s1", True) + assert "s1" in channel._web.chat_postEphemeral.await_args.kwargs["text"] + + @pytest.mark.asyncio + async def test_several_candidates_ask_instead_of_guessing(self): + channel = self._ch([ + self._row("s1", thread="1.0", title="Deploy"), + self._row("s2", thread="2.0", title="Triage"), + ]) + await channel._cmd_star("C1", "U1", "slack:C1", True) + channel.router.set_session_starred.assert_not_called() + blocks = channel._web.chat_postEphemeral.await_args.kwargs["blocks"] + action_ids = [ + e["action_id"] + for b in blocks if b["type"] == "actions" for e in b["elements"] + ] + assert action_ids == ["starpick:1:s1", "starpick:1:s2"] + + @pytest.mark.asyncio + async def test_the_unstar_picker_carries_the_state_it_will_set(self): + # The session card's own toggle flips whatever the row holds; a + # picker has to set what the command asked for. + channel = self._ch([ + self._row("s1", thread="1.0"), self._row("s2", thread="2.0"), + ]) + await channel._cmd_star("C1", "U1", "slack:C1", False) + blocks = channel._web.chat_postEphemeral.await_args.kwargs["blocks"] + action_ids = [ + e["action_id"] + for b in blocks if b["type"] == "actions" for e in b["elements"] + ] + assert action_ids == ["starpick:0:s1", "starpick:0:s2"] + + @pytest.mark.asyncio + async def test_the_picker_button_stars_the_chosen_session(self): + channel = self._ch([]) + channel._replace_via_url = AsyncMock() + await channel._handle_interactive({ + "type": "block_actions", + "user": {"id": "U1"}, + "channel": {"id": "C1"}, + "response_url": "https://hooks.slack.test/x", + "actions": [{"action_id": "starpick:1:s2", "value": "s2"}], + }) + channel.router.set_session_starred.assert_awaited_once_with("s2", True) + + @pytest.mark.asyncio + async def test_the_picker_button_unstars_the_chosen_session(self): + channel = self._ch([]) + channel._replace_via_url = AsyncMock() + await channel._handle_interactive({ + "type": "block_actions", + "user": {"id": "U1"}, + "channel": {"id": "C1"}, + "response_url": "https://hooks.slack.test/x", + "actions": [{"action_id": "starpick:0:s2", "value": "s2"}], + }) + channel.router.set_session_starred.assert_awaited_once_with("s2", False) + + @pytest.mark.asyncio + async def test_a_vanished_session_is_reported_not_raised(self): + channel = self._ch([]) + channel._replace_via_url = AsyncMock() + channel.router.set_session_starred = AsyncMock(side_effect=ValueError("gone")) + await channel._handle_interactive({ + "type": "block_actions", + "user": {"id": "U1"}, + "channel": {"id": "C1"}, + "response_url": "https://hooks.slack.test/x", + "actions": [{"action_id": "starpick:1:s2", "value": "s2"}], + }) + assert "no longer available" in channel._replace_via_url.await_args[0][1] + + +class TestNotificationSections: + """3000 characters is the limit on one section, not on the message. + + Slicing there dropped everything past it with no sign anything was + missing, while Block Kit allows the content to be spread over several + sections instead. + """ + + @staticmethod + def _sections(blocks) -> list[str]: + return [b["text"]["text"] for b in blocks if b["type"] == "section"] + + def test_content_just_below_the_limit_is_one_section(self): + assert len(self._sections(build_notification_blocks("x" * 2999, "n1"))) == 1 + + def test_content_exactly_at_the_limit_is_one_section(self): + assert len(self._sections(build_notification_blocks("x" * 3000, "n1"))) == 1 + + def test_content_one_past_the_limit_is_split_not_cut(self): + sections = self._sections(build_notification_blocks("x" * 3001, "n1")) + assert len(sections) == 2 + assert "".join(sections) == "x" * 3001 + + def test_a_long_body_keeps_every_line(self): + body = "\n".join(f"line {i:04d}" for i in range(800)) + sections = self._sections(build_notification_blocks(body, "n1")) + assert len(sections) > 1 + assert "\n".join(sections) == body + + def test_every_section_fits_slacks_limit(self): + body = "\n".join(f"line {i:04d}" for i in range(2000)) + sections = self._sections(build_notification_blocks(body, "n1")) + assert all(len(s) <= 3000 for s in sections) + + def test_the_option_buttons_still_follow_the_text(self): + blocks = build_notification_blocks( + "y" * 7000, "n1", [("Approve", "approve")], + ) + assert blocks[-1]["type"] == "actions" + assert blocks[-1]["elements"][0]["action_id"] == "notif:n1:approve" + + def test_a_body_past_the_block_limit_says_what_it_dropped(self): + # 50 blocks is a hard limit on the whole message, so the only + # content that can be lost is content Slack would refuse anyway. + import nerve.channels.slack as slack_module + + blocks = build_notification_blocks("z" * 400_000, "n1") + sections = self._sections(blocks) + assert len(sections) == slack_module._MAX_SECTION_BLOCKS + assert "more characters" in sections[-1] + + +class _CapturedPosts: + """Stand-in for ``httpx.AsyncClient`` recording response_url bodies.""" + + def __init__(self) -> None: + self.bodies: list[dict] = [] + + def __call__(self, *args, **kwargs): + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def post(self, url, json=None): + self.bodies.append(json) + + +class TestNotificationCardRoundTrip: + """The card's section text is the mrkdwn Slack already stores. + + Running it back through the Markdown converter when the button was + pressed turned *bold* into _italic_ and escaped the link markup, so the + message visibly changed on answering. + """ + + RAW = ( + "## Deploy to production\n" + "**Ship it?** See [the run](https://ci.example.com/x?a=1&b=2).\n" + "Threshold is a < b & c > d, ask <@U9>." + ) + + async def _answer(self, monkeypatch, raw: str = RAW): + import httpx + + posts = _CapturedPosts() + monkeypatch.setattr(httpx, "AsyncClient", posts) + + channel = _channel(allow_users=["U1"]) + service = MagicMock() + service.handle_answer = AsyncMock(return_value=True) + service.db.get_notification = AsyncMock(return_value=None) + channel._notification_service = service + + blocks = build_notification_blocks(raw, "n1", [("Approve", "approve")]) + await channel._handle_interactive({ + "type": "block_actions", + "user": {"id": "U1"}, + "channel": {"id": "C1"}, + "response_url": "https://hooks.slack.test/x", + "actions": [{"action_id": "notif:n1:approve", "value": "approve"}], + "message": {"blocks": blocks}, + }) + return channel, blocks, posts.bodies[-1]["text"] + + @pytest.mark.asyncio + async def test_the_card_text_is_carried_across_unchanged(self, monkeypatch): + _, blocks, updated = await self._answer(monkeypatch) + original = blocks[0]["text"]["text"] + assert updated.startswith(original) + + @pytest.mark.asyncio + async def test_a_heading_stays_bold_instead_of_turning_italic(self, monkeypatch): + _, _, updated = await self._answer(monkeypatch) + assert "*Deploy to production*" in updated + assert "_Deploy to production_" not in updated + + @pytest.mark.asyncio + async def test_bold_stays_bold(self, monkeypatch): + _, _, updated = await self._answer(monkeypatch) + assert "*Ship it?*" in updated + assert "_Ship it?_" not in updated + + @pytest.mark.asyncio + async def test_a_link_with_query_parameters_survives(self, monkeypatch): + _, _, updated = await self._answer(monkeypatch) + assert "" in updated + assert "<https://" not in updated + + @pytest.mark.asyncio + async def test_escaping_is_not_applied_twice(self, monkeypatch): + _, _, updated = await self._answer(monkeypatch) + assert "a < b & c > d" in updated + assert "&lt;" not in updated + assert "&amp;" not in updated + + @pytest.mark.asyncio + async def test_a_mention_keeps_the_escaping_it_was_sent_with(self, monkeypatch): + _, _, updated = await self._answer(monkeypatch) + assert "<@U9>" in updated + + @pytest.mark.asyncio + async def test_the_answer_is_appended(self, monkeypatch): + _, _, updated = await self._answer(monkeypatch) + assert "✅ Answered: approve" in updated + + @pytest.mark.asyncio + async def test_a_split_card_keeps_every_section(self, monkeypatch): + body = "\n".join(f"line {i:04d}" for i in range(800)) + _, blocks, updated = await self._answer(monkeypatch, raw=body) + assert updated.startswith(body) + + +class TestApprovalAttribution: + """`answered_by="slack"` alone loses which member pressed the button.""" + + async def _press(self, action_id="notif:n1:approve", value="approve"): + channel = _channel(allow_users=["U0123ABC"]) + channel._replace_via_url = AsyncMock() + service = MagicMock() + service.handle_answer = AsyncMock(return_value=True) + service.db.get_notification = AsyncMock(return_value=None) + channel._notification_service = service + await channel._handle_interactive({ + "type": "block_actions", + "user": {"id": "U0123ABC"}, + "channel": {"id": "C1"}, + "response_url": "https://hooks.slack.test/x", + "actions": [{"action_id": action_id, "value": value}], + "message": {"blocks": build_notification_blocks("Ship it?", "n1")}, + }) + return channel, service + + @pytest.mark.asyncio + async def test_the_settled_card_names_who_answered(self): + channel, _ = await self._press() + assert "(by <@U0123ABC>)" in channel._replace_via_url.await_args[0][1] diff --git a/tests/test_slack_integration.py b/tests/test_slack_integration.py new file mode 100644 index 00000000..ae481247 --- /dev/null +++ b/tests/test_slack_integration.py @@ -0,0 +1,228 @@ +"""Slack end-to-end over a real Socket Mode connection. + +These drive the whole channel — connect, receive an envelope, ack it, +authorize, dispatch, reply — against :mod:`tests.fake_slack` rather than +mocks. What they cover that the unit tests cannot: the transport wiring, +the ack contract, and the shape of the calls actually put on the wire. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +import pytest_asyncio + +from nerve.channels.slack import SlackChannel +from nerve.config import NerveConfig, SlackConfig +from tests.fake_slack import FakeSlack + + +def _config(**slack_kwargs) -> NerveConfig: + cfg = NerveConfig() + cfg.slack = SlackConfig( + enabled=True, + bot_token="xoxb-fake", + app_token="xapp-fake", + **slack_kwargs, + ) + return cfg + + +@pytest_asyncio.fixture +async def slack(): + async with FakeSlack() as server: + yield server + + +async def _started(server: FakeSlack, monkeypatch, **slack_kwargs): + """Bring a SlackChannel up against the fake, with a stub router.""" + cfg = _config(**slack_kwargs) + router = MagicMock() + router.handle_message = AsyncMock(return_value="ok") + router.get_last_session = AsyncMock(return_value=None) + channel = SlackChannel(lambda: cfg, router) + server.patch_client(monkeypatch) + await channel.start() + await server.wait_connected() + return channel, router + + +@pytest.mark.asyncio +class TestSocketMode: + async def test_the_channel_connects_and_learns_its_own_id( + self, slack, monkeypatch, + ): + channel, _ = await _started(slack, monkeypatch, allow_users=["U1"]) + try: + assert channel._bot_user_id == "U0BOT" + assert slack.calls_to("auth.test") + assert slack.calls_to("apps.connections.open") + finally: + await channel.stop() + + async def test_every_envelope_is_acked(self, slack, monkeypatch): + # Slack redelivers anything unacked within three seconds, and an + # agent turn is far longer than that. + channel, router = await _started(slack, monkeypatch, allow_users=["U1"]) + try: + envelope_id = await slack.push_event({ + "type": "message", "channel": "D1", "channel_type": "im", + "user": "U1", "ts": "1.1", "text": "hello", + }) + await slack.settle() + assert envelope_id in slack.acks + router.handle_message.assert_called_once() + finally: + await channel.stop() + + async def test_an_unauthorized_envelope_is_still_acked( + self, slack, monkeypatch, + ): + # A refusal must not look like a delivery failure, or Slack retries + # the same rejected message until it gives up. + channel, router = await _started(slack, monkeypatch, allow_users=["U-other"]) + try: + envelope_id = await slack.push_event({ + "type": "message", "channel": "D1", "channel_type": "im", + "user": "U1", "ts": "1.1", "text": "hello", + }) + await slack.settle() + assert envelope_id in slack.acks + router.handle_message.assert_not_called() + finally: + await channel.stop() + + async def test_stop_closes_the_socket(self, slack, monkeypatch): + channel, _ = await _started(slack, monkeypatch, allow_users=["U1"]) + await channel.stop() + assert not await channel._client.is_connected() + + +@pytest.mark.asyncio +class TestConversation: + async def test_a_direct_message_produces_a_reply_in_the_dm( + self, slack, monkeypatch, + ): + channel, router = await _started(slack, monkeypatch, allow_users=["U1"]) + try: + async def _reply(msg): + from nerve.channels.base import OutboundMessage + await channel.send( + OutboundMessage(target=msg.sender_id, text="**done**"), + ) + return "done" + + router.handle_message = AsyncMock(side_effect=_reply) + await slack.push_event({ + "type": "message", "channel": "D1", "channel_type": "im", + "user": "U1", "ts": "1.1", "text": "run it", + }) + posted = await slack.wait_for("chat.postMessage") + assert posted[0]["channel"] == "D1" + assert posted[0]["text"] == "*done*" + finally: + await channel.stop() + + async def test_a_channel_mention_replies_inside_a_thread( + self, slack, monkeypatch, + ): + channel, router = await _started(slack, monkeypatch, allow_users=["U1"]) + try: + captured = {} + + async def _capture(msg): + captured["key"] = msg.channel_key + from nerve.channels.base import OutboundMessage + await channel.send(OutboundMessage(target=msg.sender_id, text="hi")) + return "hi" + + router.handle_message = AsyncMock(side_effect=_capture) + await slack.push_event({ + "type": "app_mention", "channel": "C1", "channel_type": "channel", + "user": "U1", "ts": "1.1", "text": "<@U0BOT> status", + }) + posted = await slack.wait_for("chat.postMessage") + assert captured["key"] == "slack:C1:1.1" + assert posted[0]["thread_ts"] == "1.1" + finally: + await channel.stop() + + async def test_a_name_allow_list_resolves_through_the_api( + self, slack, monkeypatch, + ): + slack.users["U1"] = { + "id": "U1", "name": "alex.soffronow", + "profile": {"email": "alex@example.com"}, + } + channel, router = await _started( + slack, monkeypatch, allow_users=["alex.soffronow"], + ) + try: + await slack.push_event({ + "type": "message", "channel": "D1", "channel_type": "im", + "user": "U1", "ts": "1.1", "text": "hello", + }) + await slack.settle() + router.handle_message.assert_called_once() + assert slack.calls_to("users.info") + finally: + await channel.stop() + + async def test_a_channel_glob_is_checked_against_the_real_name( + self, slack, monkeypatch, + ): + slack.conversations["C1"] = {"id": "C1", "name": "eng-platform"} + slack.conversations["C2"] = {"id": "C2", "name": "random"} + channel, router = await _started( + slack, monkeypatch, allow_users=["U1"], allow_channels=["eng-*"], + ) + try: + await slack.push_event({ + "type": "app_mention", "channel": "C2", "channel_type": "channel", + "user": "U1", "ts": "1.1", "text": "<@U0BOT> hi", + }) + await slack.settle() + router.handle_message.assert_not_called() + + await slack.push_event({ + "type": "app_mention", "channel": "C1", "channel_type": "channel", + "user": "U1", "ts": "2.1", "text": "<@U0BOT> hi", + }) + await slack.settle() + router.handle_message.assert_called_once() + finally: + await channel.stop() + + async def test_a_denied_lookup_refuses_rather_than_guesses( + self, slack, monkeypatch, + ): + # users.info fails, and a deny list cannot be evaluated without it. + slack.errors["users.info"] = "missing_scope" + channel, router = await _started( + slack, monkeypatch, allow_users=["U1"], deny_users=["*-bot"], + ) + try: + await slack.push_event({ + "type": "message", "channel": "D1", "channel_type": "im", + "user": "U1", "ts": "1.1", "text": "hello", + }) + await slack.settle() + router.handle_message.assert_not_called() + finally: + await channel.stop() + + +@pytest.mark.asyncio +class TestStreaming: + async def test_a_placeholder_is_posted_then_edited(self, slack, monkeypatch): + channel, _ = await _started(slack, monkeypatch, allow_users=["U1"]) + try: + message_id = await channel.send_placeholder("D1", "s1") + assert message_id + await channel.edit_message("D1", message_id, "partial **output**") + edits = await slack.wait_for("chat.update") + assert edits[0]["ts"] == message_id + assert edits[0]["text"] == "partial *output*" + finally: + await channel.stop() diff --git a/uv.lock b/uv.lock index 867c6ada..7165a73a 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,108 @@ resolution-markers = [ "python_full_version < '3.14'", ] +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "aiosqlite" version = "0.22.1" @@ -633,6 +735,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "googleapis-common-protos" version = "1.75.1" @@ -654,7 +829,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" }, { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" }, { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, @@ -662,7 +839,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, @@ -670,14 +849,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" }, { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" }, { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, @@ -685,7 +868,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" }, { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" }, { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, @@ -1187,11 +1372,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/4e/7631a5adb70e8974038266671d60bc86de2682600674fa0fb06c3afdad1c/memu_py-1.4.0-cp313-abi3-win_amd64.whl", hash = "sha256:a69e4fc86feda6f5b52ff7ed544793958cc625d656762f6b6afd603cc5340d1e", size = 268153, upload-time = "2026-02-06T00:59:16.703Z" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "nerve" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiohttp" }, { name = "aiosqlite" }, { name = "anthropic", extra = ["bedrock"] }, { name = "apscheduler" }, @@ -1210,6 +1477,7 @@ dependencies = [ { name = "pyjwt" }, { name = "python-telegram-bot" }, { name = "pyyaml" }, + { name = "slack-sdk" }, { name = "telethon" }, { name = "uvicorn", extra = ["standard"] }, { name = "watchfiles" }, @@ -1225,6 +1493,7 @@ test = [ [package.metadata] requires-dist = [ + { name = "aiohttp", specifier = ">=3.10" }, { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "anthropic", extras = ["bedrock"], specifier = ">=0.45.0" }, { name = "apscheduler", specifier = ">=3.11.0" }, @@ -1245,6 +1514,7 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.24,<2" }, { name = "python-telegram-bot", specifier = ">=21.0" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "slack-sdk", specifier = ">=3.33.0" }, { name = "telethon", specifier = ">=1.38.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, { name = "watchfiles", specifier = ">=1.0.0" }, @@ -1558,6 +1828,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + [[package]] name = "protobuf" version = "7.35.1" @@ -1983,6 +2330,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slack-sdk" +version = "3.43.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07", size = 252769, upload-time = "2026-06-30T18:04:41.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585", size = 315866, upload-time = "2026-06-30T18:04:39.636Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -2600,6 +2956,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/81/49f718beb0c55d0411bc4bd90b50a3fbe5863a0e97a2f4d11682ba13d298/xxhash-4.0.1-cp315-cp315t-win_arm64.whl", hash = "sha256:f00330ac7e24769e2032203f2b01794d670916b0c1799fd261340f1af9499875", size = 34590, upload-time = "2026-08-17T08:23:19.597Z" }, ] +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] + [[package]] name = "zstandard" version = "0.25.0" From 6871240e129182892bad7d5baa20545f3a08eecd Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Sun, 23 Aug 2026 19:47:23 +0200 Subject: [PATCH 02/18] Document Slack channel configuration and operation --- README.md | 17 +++- config.example.yaml | 38 ++++++++ docs/architecture.md | 4 + docs/config.md | 215 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 271 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7a67e9eb..56a73533 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ --- -Nerve is a self-hosted runtime for AI agents, built around the [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk). It gives agents everything they need to be useful long-term: persistent memory, scheduled execution, task management, learnable skills, and channels to reach you through — web UI, Telegram, or autonomous cron jobs. +Nerve is a self-hosted runtime for AI agents, built around the [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk). It gives agents everything they need to be useful long-term: persistent memory, scheduled execution, task management, learnable skills, and channels to reach you through — web UI, Telegram, Slack, or autonomous cron jobs. Ship a **personal assistant** that develops a personality, remembers your preferences, and manages your inbox. Or deploy a **worker agent** that monitors your CI, reviews PRs, and fixes flaky tests — all plan-driven with human approval. Same engine, different mission. @@ -122,6 +122,18 @@ Powered by `python-telegram-bot` v21+. - `/reply` command for free-text answers - Configurable DM policy (`open` or `pairing`) +### 💬 Slack Bot + +Reach your agent where your team already works — a direct message, or a channel +you have invited it to. Nothing to expose: it connects outwards, so it runs +behind NAT with no public URL. + +- Answers appear as they are written, and stay in the thread you started +- Each thread is its own conversation, so several people can work in one channel +- In a channel it stays quiet until you `@mention` it +- Reply to its questions by pressing a button, or send it a file to read +- You decide who may talk to it and where — by person, by channel, or both + ### ⏰ Cron Jobs Scheduled AI sessions via APScheduler. Three session modes: @@ -231,7 +243,8 @@ nerve (single Python process) │ ├── Channels │ ├── Web — passive WebSocket channel -│ └── Telegram — bot with streaming + inline keyboards +│ ├── Telegram — bot with streaming + inline keyboards +│ └── Slack — bot with streaming + buttons, per-thread sessions │ ├── Cron (APScheduler) │ ├── AI jobs (isolated / persistent / main session modes) diff --git a/config.example.yaml b/config.example.yaml index 26b96d06..8e155813 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -110,6 +110,44 @@ telegram: # allowed_users: [123456789] # numeric Telegram user IDs (or pair instead) stream_mode: partial # "partial" (edit messages) or "full" (wait for complete) +slack: + # Omitting this key leaves Slack off until both tokens below are set, so an + # install that does not use Slack never has to say so. Set it to true to be + # told about a missing token instead of having the channel stay down. + enabled: false + # Socket Mode — the bot dials out, so no public URL is needed. Both tokens + # go in config.local.yaml: + # bot_token: xoxb-… (OAuth & Permissions → Bot User OAuth Token) + # app_token: xapp-… (Basic Information → App-Level Tokens, connections:write) + # + # Access guardrails. Patterns match a Slack id (U0123ABC / C0456DEF), a + # handle, a display name, an email, or a channel name — case-insensitive, + # with globs. Deny always beats allow. A direct message is matched under + # the synthetic channel name "dm". + # + # With no allow list at all the bot refuses every message. That is + # deliberate: an open bot in a workspace is full agent access for anyone + # in it. Set at least one of these. + # allow_users: ["U0123ABC", "alex.soffronow"] + # deny_users: ["*-bot"] + # allow_channels: ["dm", "eng-*", "C0456DEF"] + # deny_channels: ["*-random", "*-social"] + # + # Each thread becomes its own session and replies stay in-thread. Off means + # one session per channel and replies at channel level. + reply_in_thread: true + stream_mode: partial # "partial" (edit messages) or "full" (wait for complete) + # Which /nerve subcommands the workspace may run. Chatting and notification + # buttons are not commands, so they are unaffected. + # omitted — new, stop, star, unstar + # [] — no slash commands at all + # [all] — everything, including doctor and restart + # doctor prints host health into a shared workspace and restart lets anyone + # on the allow list bounce the daemon. sessions and reply reach every + # session in the instance, Telegram and web ones included, and are not + # scoped to the caller. All four are opt-in. + # commands: [sessions, new, stop, reply] + # Quiet hours (local timezone) quiet_start: "02:00" quiet_end: "12:00" diff --git a/docs/architecture.md b/docs/architecture.md index 5fbddba9..616ace6c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,8 +78,12 @@ Abstract communication layer with three components: - **ChannelRouter** — centralized session resolution, streaming adapter lifecycle, interactive tool routing, and cron output delivery. Replaces per-channel session management. - **StreamAdapter** — translates `StreamBroadcaster` events into channel-appropriate output (edit-in-place for Telegram, accumulated send for simple channels). Created per inbound message. +- **AccessPolicy** (`access.py`) — allow/deny guardrails for senders and conversations, applied by a channel before a message becomes an `InboundMessage`. Deny wins, a non-empty allow list is a gate, and a policy with no allow patterns refuses everything. Same semantics as the inbox filters in `nerve/sources/filters.py`. +- **Archives** (`archives.py`) — bounded one-level ZIP unpacking shared by Telegram and Slack. The download cap is on compressed bytes, so entry count, per-entry and aggregate uncompressed size, and compression ratio are all checked against the archive directory before an entry is read. + Implementations: - **Telegram** — python-telegram-bot v21+ with partial message streaming (edit-in-place, 1.5s rate limit), inline keyboard buttons for notification questions, `/reply` command for free-text answers +- **Slack** — slack_sdk Socket Mode (outbound WebSocket, no public URL) with partial streaming via `chat.update`, Block Kit buttons, `/nerve` slash command, and per-thread sessions. Guarded by `AccessPolicy`; in channels it answers only on mention or in a thread it already owns. - **Web** — Passive channel using gateway WebSocket Adding a new channel (Discord, WhatsApp, etc.) requires implementing ~5 methods and zero session/routing logic. diff --git a/docs/config.md b/docs/config.md index af122d04..bf4b610b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -23,7 +23,7 @@ and migration splits a legacy `config.yaml` on the same table: | Layer | Gets | |-------|------| -| `config.yaml` | `workspace`, `deployment`, `provider.aws_profile`, `gateway.ssl.*`, `proxy`, `docker`, `telegram.enabled`, `sync.gmail.accounts`, `external_agents`, `mcp_endpoint`, `workflows.runs_dir` | +| `config.yaml` | `workspace`, `deployment`, `provider.aws_profile`, `gateway.ssl.*`, `proxy`, `docker`, `telegram.enabled`, `slack.enabled`, `sync.gmail.accounts`, `external_agents`, `mcp_endpoint`, `workflows.runs_dir` | | `settings.yaml` | `timezone`, `gateway.host`/`port`, `provider.type`/`aws_region` (incl. the region-scoped Bedrock model IDs), `agent.*`, `memory.*`, `sessions.*`, `sync.*`, the rest of `workflows.*` (the budget caps and cadence), `houseofagents.*`, quiet hours, `telegram.dm_policy`/`stream_mode` | The test is whether the value would be wrong on another machine: filesystem @@ -245,6 +245,7 @@ A reload is always explicit. Two things cause one: | `external_agents.targets` (including each target's `enabled`), `.sync_interval_minutes`, `.conflict_policy` | ✅ from the next sweep, provided at least one target existed at startup (see the restart table) | | `sessions.sticky_period_minutes` | ✅ | | `telegram.dm_policy`, `.stream_mode` | ✅ read per update. Tightening `open` to `pairing` takes effect on the next message; `allowed_users` does not follow it (see the restart table) | +| `slack.allow_users`, `.deny_users`, `.allow_channels`, `.deny_channels`, `.reply_in_thread`, `.stream_mode` | ✅ read per event, so tightening a guardrail takes effect on the next message. The two tokens do not follow it (see the restart table) | | `workflows.*` and `workflows.review_loop.*` — budget caps, concurrency, the warning fraction, iteration and criteria caps, leg engines/models, the verifier sandbox | ✅ read per use, by loops and runs already in flight as well as new ones. The two `enabled` flags and the two loop cadences are the exceptions; see the restart table | | `provider.*` and the API keys it selects (`aws_region`, `aws_profile`, `aws_access_key_id`, and the effective Anthropic key) | ✅ for sessions started **after** the reload. Each client's environment is built from the live reference when the session is created, by the same seam as `agent.*` below | | **`agent.*` and `codex.*`**: backend choice and models (`agent.backend`, `agent.cron_model`, `agent.model`, `codex.model`, `codex.cron_model`), `max_turns`, `agent.effort`/`cron_effort` and `codex.effort_map`, `agent.thinking`, `agent.context_1m*`, `agent.background_agent_permissions`, `agent.agent_teams`, idle timeouts, cache TTL, `codex.sandbox`, `.approval_policy`, `.web_search`, `.extra_config`, `.tool_timeout_sec`, `.bin_path`, `.auth`/`.api_key`/`.api_key_env`, `.pricing`, `.min_version`/`.max_version`, `.ultracode.*` | ✅ for sessions and turns **started after** the reload. The engine and both backends resolve these through one live reference, so a key cannot be hot in one and frozen in the other | @@ -279,6 +280,7 @@ reload cannot inspect, and are documented here only. | `sync.codex.*` (`enabled`, every `origins[*]` field, `store_encrypted_reasoning`, `workspace_filter.*`) | Codex thread sync is a **different service** from the cron sources above, built once at startup with one polling worker per origin. Adding or editing an origin and reloading reports `ok` and ingests nothing | | `langfuse.*` | set up before the engine, caching its host, redaction patterns and `LANGFUSE_*` environment exports in process globals | | `telegram.enabled`, `.bot_token`, `.allowed_users` | the bot was built with that token, and the allow-list was copied into a set when it was built. Notification *delivery* does follow a reload, so after changing `allowed_users` the two can disagree until a restart. `dm_policy` and `stream_mode` are read per update and do follow a reload (see the table above) | +| `slack.enabled`, `.bot_token`, `.app_token` | both tokens were handed to the Socket Mode transport when it connected, and the connection outlives a reload. The four allow/deny lists are read per event and do follow a reload (see the table above) | | `mcp_endpoint.*` | fixed when the app was created | | `auth.jwt_secret` | half-hot: the web gateway reads it per request, so its own auth follows a reload, but the MCP endpoint captured it when the app was mounted and keeps checking `/mcp/v1` against the old secret. Rotating it moves one and not the other until a restart | | `workflows.enabled`, `workflows.review_loop.enabled` | each service is created at startup and only when its flag is on. Turning one **off** does not stop the service already running, and turning it **on** creates nothing for a reload to reach | @@ -607,6 +609,7 @@ ignored when locked, so a secret that lives only there stops being read, and the feature depending on it breaks on the next restart. Supply each one as `${ENV_VAR}` referenced from `settings.yaml` before you lock the box. The usual ones: `auth.jwt_secret`, `auth.password_hash`, `telegram.bot_token`, +`slack.bot_token`/`slack.app_token`, `anthropic_api_key`/`openai_api_key`, `xmemory.api_key`. `auth.jwt_secret` is the one to get right. A locked instance that ends up without @@ -1070,6 +1073,216 @@ An unauthorized `/start` gets a reply with the sender's numeric ID and pairing instructions (rate-limited); all other messages from unauthorized users are ignored. +## Slack + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `slack.enabled` | bool | see below | Enable the Slack bot | +| `slack.bot_token` | string | - | Bot User OAuth Token (`xoxb-…`) | +| `slack.app_token` | string | - | App-Level Token for Socket Mode (`xapp-…`) | +| `slack.allow_users` | list[str] | `[]` | Senders allowed to reach the agent | +| `slack.deny_users` | list[str] | `[]` | Senders always refused | +| `slack.allow_channels` | list[str] | `[]` | Conversations the agent answers in | +| `slack.deny_channels` | list[str] | `[]` | Conversations always refused | +| `slack.reply_in_thread` | bool | `true` | One session per thread; replies stay in-thread | +| `slack.stream_mode` | string | `partial` | `partial` (edit msgs) or `full` | +| `slack.commands` | list[str] | see below | Which `/nerve` subcommands the workspace may run | + +Both tokens are secrets — put them in `config.local.yaml`. Changing either +needs a restart; the four guardrail lists take effect on reload. + +Slack is opt-in. Without an explicit `slack.enabled`, the channel is on only +when both tokens are set, so a configuration that predates Slack — one with +no `slack` section at all — stays off and `nerve doctor` says nothing about +it. Set `enabled: true` to be told about missing tokens instead of having the +channel quietly stay down. Under lockdown the channel is off unless +`enabled: true` is explicit, because lockdown drops the machine-local layer +that decides whether this box answers Slack. + +### Setting up the Slack app + +Socket Mode means the bot dials out to Slack, so Nerve needs no public URL. + +1. Create an app at from the manifest below. +2. **Basic Information → App-Level Tokens**: generate a token with the + `connections:write` scope. This is `slack.app_token` (`xapp-…`). +3. **Install App**: install to the workspace and copy the Bot User OAuth + Token. This is `slack.bot_token` (`xoxb-…`). +4. **App Home** → *Show Tabs* → turn on the **Messages Tab** and tick + *Allow users to send Slash commands and messages from the messages tab*. + Without it the direct-message conversation with the bot is read-only and + Slack refuses every DM with `restricted_action_read_only_channel`. The + manifest cannot set this. +5. Set at least one allow list (see below), then restart Nerve. + +```yaml +display_information: + name: Nerve +features: + bot_user: + display_name: Nerve + always_online: true + slash_commands: + - command: /nerve + description: Control the Nerve agent + usage_hint: sessions | new | stop | star | reply | doctor +oauth_config: + scopes: + bot: + - app_mentions:read + - channels:history + - channels:read + - chat:write + - commands + - files:read + - files:write + - groups:history + - groups:read + - im:history + - im:read + - mpim:history + - mpim:read + - reactions:read + - reactions:write + - users:read + - users:read.email +settings: + event_subscriptions: + bot_events: + - app_mention + - message.channels + - message.groups + - message.im + - message.mpim + - reaction_added + interactivity: + is_enabled: true + socket_mode_enabled: true +``` + +`users:read`, `users:read.email` and `channels:read` are only needed if the +allow/deny lists name people or channels rather than raw ids. With ids only, +Nerve never calls those endpoints and you can drop the scopes. + +If a **deny** rule names an email address, `users:read.email` is required. +Without it Slack still answers `users.info` successfully but omits the email, +so the rule could never match — Nerve refuses those users rather than let the +deny list pass silently, and logs why. Write the rule against the handle or +the member id if you would rather not grant the scope. + +### Guardrails + +The allow/deny lists are the whole authorization story — there is no pairing +step, because the workspace already decides who can reach the bot at all. + +A pattern matches a Slack id (`U0123ABC`, `C0456DEF`), a handle, a display +name, an email, or a channel name. Matching is case-insensitive and supports +globs. A direct message is matched under the synthetic channel name `dm`. + +```yaml +slack: + allow_users: ["U0123ABC", "alex.soffronow"] + deny_users: ["*-bot"] + allow_channels: ["dm", "eng-*"] + deny_channels: ["*-social"] +``` + +Rules, per list: + +- **Deny wins.** A value matching any deny pattern is refused whatever the + allow list says. +- **A non-empty allow list is a gate.** The value must match one of its + patterns. +- **An empty allow list means "anything not denied".** + +The sender and the conversation are checked independently and both must +pass. `allow_users` alone lets those people talk to the agent anywhere; +`allow_channels` alone lets anyone in those channels talk to it. + +Two failure modes are closed on purpose: + +- **No allow list at all refuses everything.** A deny list is not an opt-in. + An unconfigured bot logs a warning at startup and answers nobody, rather + than handing the whole workspace full agent access. +- **A name that cannot be looked up is refused** when a deny list is set. + This covers a lookup that failed *and* one that succeeded while omitting an + alias the rules need. An allow list already fails closed on an unknown + name, but a deny list would otherwise fail open. + +A pattern counts as a literal id only if it has the real Slack shape — a +`U`/`W`/`B`/`C`/`D`/`G`/`T` followed by at least seven uppercase alphanumerics. +Anything else is treated as a name and resolved through the API, so an +all-caps channel name like `ENGINEERING` is matched as a name, not mistaken +for an id. + +Note that a `${VAR}` reference on a list field becomes one entry, not a +comma-separated set — spell several values as a YAML list. + +### Behaviour + +- In a **direct message** the bot answers everything. +- In a **channel** it answers only when mentioned, or when the message + continues a thread it already has a session for. Adding the bot to a busy + channel does not start an agent turn per remark. +- With `reply_in_thread` on (the default) each thread is a separate session, + so several people can run separate conversations in one channel. +- `/nerve ` mirrors the Telegram command set. Replies are + ephemeral — only the person who ran the command sees them. + +### Limiting what the workspace can do + +`slack.commands` chooses which subcommands exist. Chatting with the agent and +answering notification buttons are not commands, so they are unaffected. + +```yaml +slack: + commands: [] # no slash commands at all — chat only + commands: [reply] # only answer pending questions + commands: [sessions, new, stop] # session control, nothing operational + commands: [all] # everything, including doctor and restart +``` + +Omitting the key gives `new, stop, star, unstar`. Four subcommands are off +unless you ask for them: + +- **`doctor`** prints host health into a shared workspace. +- **`restart`** lets anyone on the allow list bounce the daemon. +- **`sessions`** lists every session in the instance, Telegram and web ones + included, and attaches the one you pick to your own Slack conversation. +- **`reply`** answers the latest pending question anywhere in the instance. + +The last two are not scoped to the caller. Nerve has no ownership model for +Slack yet, so with `allow_channels: [dm]` any member of the workspace who may +DM the bot could otherwise enumerate someone else's private session, continue +it, or answer a question that was never theirs. Turn them on in a workspace +where everyone on the allow list is trusted with every session. + +An unknown name is dropped with a warning rather than refused, since the key +exists to narrow access and a typo should not stop the daemon booting. + +`/nerve help` lists only what is enabled, and a disabled command says it is +turned off rather than pretending not to exist. + +### Slash commands cannot run in threads + +Slack refuses them outright — it answers *"/nerve is not supported in +threads"* — and the payload carries no thread reference. So a command run at +channel level has to work out which thread you meant. + +`/nerve stop`, `/nerve star` and `/nerve unstar` therefore resolve across +every live session in the channel, not just the channel-level one. With one +session live they act on it and name it; with several they show a picker +rather than guess, since acting on someone else's thread silently is worse +than asking. With none they say so. + +`/nerve new` and `/nerve sessions` cannot work that way, because both have to +*bind* a session to a conversation and only a thread id identifies one. In a +channel with `reply_in_thread` on they refuse and say why. Nothing is lost: +each new mention in such a channel already opens its own thread and its own +session, and `/nerve stop` ends one. Both commands work normally in a direct +message and in a channel with `reply_in_thread: false`, where an ordinary +message routes to the channel-level key the command can name. + ## Quiet Hours | Key | Type | Default | Description | From 17280e86d12d77dc0bd2cdeefdbf66b4d163dc7a Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Sun, 23 Aug 2026 19:49:32 +0200 Subject: [PATCH 03/18] Verify Slack shares the archive safety bounds --- tests/test_channel_archives.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/test_channel_archives.py b/tests/test_channel_archives.py index a384b1e5..793cb4fc 100644 --- a/tests/test_channel_archives.py +++ b/tests/test_channel_archives.py @@ -158,7 +158,27 @@ def test_a_stored_entry_at_ratio_one_is_allowed(self): @pytest.mark.asyncio -class TestTelegramUsesTheBounds: +class TestChannelsShareTheBounds: + async def test_slack_refuses_a_bomb_in_an_attachment(self, monkeypatch): + from nerve.channels.slack import SlackChannel + from nerve.config import NerveConfig, SlackConfig + + cfg = NerveConfig() + cfg.slack = SlackConfig( + enabled=True, bot_token="xoxb-t", app_token="xapp-t", + ) + channel = SlackChannel(lambda: cfg, router=MagicMock()) + channel._download_file = AsyncMock( + return_value=_zip({"bomb.png": b"\x00" * 4_000_000}), + ) + + text, blocks = await channel._extract_files([{ + "name": "payload.zip", "mimetype": "application/zip", + "size": 5000, "url_private_download": "https://files.slack.test/x", + }]) + assert blocks == [] + assert "compression ratio too high" in text + async def test_telegram_refuses_a_bomb_in_a_document(self): from nerve.channels.telegram import TelegramChannel from nerve.config import NerveConfig From 35eaaeb9f4953bd9b8c24a7304fee023000c5799 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Mon, 24 Aug 2026 09:26:17 +0200 Subject: [PATCH 04/18] Remove redundant Slack channel tests Keep the stronger behavioral coverage while dropping duplicate and misleading cases. Consolidate long-message splitting and losslessness into one assertion path. --- tests/test_slack_channel.py | 42 ++----------------------------------- 1 file changed, 2 insertions(+), 40 deletions(-) diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index 0768b15d..e3d2ae01 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -100,12 +100,6 @@ def test_links_become_slack_link_syntax(self): def test_reserved_characters_are_escaped(self): assert _md_to_slack("a < b & c > d") == "a < b & c > d" - def test_a_link_url_is_escaped_the_way_slack_stores_it(self): - # Verified against a live workspace: Slack rewrites a bare & inside a - # link to &, so emitting it raw made the stored message differ - # from the one we sent. - assert _md_to_slack("[q](http://x?a=1&b=2)") == "" - def test_bullets_become_real_bullets(self): assert _md_to_slack("- one\n- two") == "• one\n• two" @@ -338,15 +332,6 @@ async def test_the_bots_own_message_is_ignored(self): }) channel.router.handle_message.assert_not_called() - @pytest.mark.asyncio - async def test_another_bots_message_is_ignored(self): - channel = _channel(allow_users=["U1"]) - await channel._handle_message_event({ - "type": "message", "channel": "C1", "user": "U1", - "bot_id": "B9", "ts": "1.1", "text": "hi", - }) - channel.router.handle_message.assert_not_called() - @pytest.mark.asyncio async def test_a_join_notice_is_ignored(self): channel = _channel(allow_users=["U1"]) @@ -501,18 +486,11 @@ async def test_send_converts_markdown_and_targets_the_thread(self): assert kwargs["text"] == "*hi*" @pytest.mark.asyncio - async def test_a_long_reply_is_sent_as_several_messages(self): - channel = _channel() - await channel.send( - OutboundMessage(target="C1", text="\n".join(["x" * 100] * 100)), - ) - assert channel._web.chat_postMessage.await_count > 1 - - @pytest.mark.asyncio - async def test_nothing_is_truncated_away(self): + async def test_a_long_reply_is_split_without_truncation(self): channel = _channel() body = "\n".join(f"line {i}" for i in range(2000)) await channel.send(OutboundMessage(target="C1", text=body)) + assert channel._web.chat_postMessage.await_count > 1 sent = "\n".join( c.kwargs["text"] for c in channel._web.chat_postMessage.await_args_list ) @@ -665,13 +643,6 @@ async def test_an_innocent_user_is_not_caught_by_an_email_deny_rule(self): }) assert await channel._authorize("U1", "D1", "im") - @pytest.mark.asyncio - async def test_id_only_lists_still_skip_the_lookup(self): - channel = _channel(allow_users=["U0123ABC"]) - channel._web.users_info = AsyncMock() - assert await channel._authorize("U0123ABC", "D1", "im") - channel._web.users_info.assert_not_called() - @pytest.mark.asyncio async def test_a_nameless_channel_lookup_refuses_a_channel_deny_rule(self): channel = _channel(allow_users=["U1"], deny_channels=["*-secret"]) @@ -682,15 +653,6 @@ async def test_a_nameless_channel_lookup_refuses_a_channel_deny_rule(self): class TestOutboundFailureRegressions: - @pytest.mark.asyncio - async def test_send_propagates_a_failure(self): - # StreamAdapter recovers by editing the placeholder, but only if it - # is told. Swallowing the error dropped the whole reply. - channel = _channel() - channel._web.chat_postMessage = AsyncMock(side_effect=RuntimeError("ratelimited")) - with pytest.raises(RuntimeError): - await channel.send(OutboundMessage(target="C1", text="hi")) - @pytest.mark.asyncio async def test_a_failed_placeholder_returns_none_without_raising(self): channel = _channel() From fe72dc7c13a9e3ef49911738172107dcc7e22191 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Mon, 24 Aug 2026 13:07:19 +0200 Subject: [PATCH 05/18] Hot-reload Slack credentials Rotate the Web API and Socket Mode clients as one active credential pair during config reload. Preflight both tokens, prevent overlapping sockets, bound connection attempts, and restore the prior transport on failure. --- docs/config.md | 6 +- nerve/channels/slack.py | 292 ++++++++++++++++++++++++++++---- nerve/config_reload.py | 61 ++++++- tests/test_config_reload.py | 135 +++++++++++++++ tests/test_slack_channel.py | 84 +++++++++ tests/test_slack_integration.py | 46 +++++ 6 files changed, 582 insertions(+), 42 deletions(-) diff --git a/docs/config.md b/docs/config.md index bf4b610b..1874cae4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -245,7 +245,7 @@ A reload is always explicit. Two things cause one: | `external_agents.targets` (including each target's `enabled`), `.sync_interval_minutes`, `.conflict_policy` | ✅ from the next sweep, provided at least one target existed at startup (see the restart table) | | `sessions.sticky_period_minutes` | ✅ | | `telegram.dm_policy`, `.stream_mode` | ✅ read per update. Tightening `open` to `pairing` takes effect on the next message; `allowed_users` does not follow it (see the restart table) | -| `slack.allow_users`, `.deny_users`, `.allow_channels`, `.deny_channels`, `.reply_in_thread`, `.stream_mode` | ✅ read per event, so tightening a guardrail takes effect on the next message. The two tokens do not follow it (see the restart table) | +| `slack.bot_token`, `.app_token`, `.allow_users`, `.deny_users`, `.allow_channels`, `.deny_channels`, `.reply_in_thread`, `.stream_mode` | ✅ token changes reconnect the running transport; a failed rotation restores the previous connection and is reported as a reload error. The other settings are read per event, so tightening a guardrail takes effect on the next message | | `workflows.*` and `workflows.review_loop.*` — budget caps, concurrency, the warning fraction, iteration and criteria caps, leg engines/models, the verifier sandbox | ✅ read per use, by loops and runs already in flight as well as new ones. The two `enabled` flags and the two loop cadences are the exceptions; see the restart table | | `provider.*` and the API keys it selects (`aws_region`, `aws_profile`, `aws_access_key_id`, and the effective Anthropic key) | ✅ for sessions started **after** the reload. Each client's environment is built from the live reference when the session is created, by the same seam as `agent.*` below | | **`agent.*` and `codex.*`**: backend choice and models (`agent.backend`, `agent.cron_model`, `agent.model`, `codex.model`, `codex.cron_model`), `max_turns`, `agent.effort`/`cron_effort` and `codex.effort_map`, `agent.thinking`, `agent.context_1m*`, `agent.background_agent_permissions`, `agent.agent_teams`, idle timeouts, cache TTL, `codex.sandbox`, `.approval_policy`, `.web_search`, `.extra_config`, `.tool_timeout_sec`, `.bin_path`, `.auth`/`.api_key`/`.api_key_env`, `.pricing`, `.min_version`/`.max_version`, `.ultracode.*` | ✅ for sessions and turns **started after** the reload. The engine and both backends resolve these through one live reference, so a key cannot be hot in one and frozen in the other | @@ -280,7 +280,7 @@ reload cannot inspect, and are documented here only. | `sync.codex.*` (`enabled`, every `origins[*]` field, `store_encrypted_reasoning`, `workspace_filter.*`) | Codex thread sync is a **different service** from the cron sources above, built once at startup with one polling worker per origin. Adding or editing an origin and reloading reports `ok` and ingests nothing | | `langfuse.*` | set up before the engine, caching its host, redaction patterns and `LANGFUSE_*` environment exports in process globals | | `telegram.enabled`, `.bot_token`, `.allowed_users` | the bot was built with that token, and the allow-list was copied into a set when it was built. Notification *delivery* does follow a reload, so after changing `allowed_users` the two can disagree until a restart. `dm_policy` and `stream_mode` are read per update and do follow a reload (see the table above) | -| `slack.enabled`, `.bot_token`, `.app_token` | both tokens were handed to the Socket Mode transport when it connected, and the connection outlives a reload. The four allow/deny lists are read per event and do follow a reload (see the table above) | +| `slack.enabled` | the channel object is created and registered only at startup. Tokens and the four allow/deny lists do follow a reload (see the table above) | | `mcp_endpoint.*` | fixed when the app was created | | `auth.jwt_secret` | half-hot: the web gateway reads it per request, so its own auth follows a reload, but the MCP endpoint captured it when the app was mounted and keeps checking `/mcp/v1` against the old secret. Rotating it moves one and not the other until a restart | | `workflows.enabled`, `workflows.review_loop.enabled` | each service is created at startup and only when its flag is on. Turning one **off** does not stop the service already running, and turning it **on** creates nothing for a reload to reach | @@ -1089,7 +1089,7 @@ users are ignored. | `slack.commands` | list[str] | see below | Which `/nerve` subcommands the workspace may run | Both tokens are secrets — put them in `config.local.yaml`. Changing either -needs a restart; the four guardrail lists take effect on reload. +reconnects Slack on reload; the four guardrail lists also take effect then. Slack is opt-in. Without an explicit `slack.enabled`, the channel is on only when both tokens are set, so a configuration that predates Slack — one with diff --git a/nerve/channels/slack.py b/nerve/channels/slack.py index cb3f2e1d..cc1a41ce 100644 --- a/nerve/channels/slack.py +++ b/nerve/channels/slack.py @@ -64,6 +64,10 @@ # Watchdog: check every 30s, log heartbeat every ~5 min. WATCHDOG_INTERVAL = 30 WATCHDOG_HEARTBEAT_EVERY = 10 +# The SDK retries a rejected Socket Mode handshake forever. A reload must +# return a failure and restore the previous transport instead of hanging the +# config endpoint indefinitely. +SOCKET_CONNECT_TIMEOUT = 30 # Bounded caches: event dedupe, message text for reaction context, resolved names. _DEDUPE_MAX = 500 _MESSAGE_CACHE_MAX = 200 @@ -474,6 +478,13 @@ def __init__( self.router = router self._client: Any = None # AsyncSocketModeClient self._web: Any = None # AsyncWebClient + # The config object is hot, but a connected transport must use one + # coherent credential pair. Keep the pair that actually built the + # clients so a watchdog reconnect cannot combine a newly loaded app + # token with the previous Web API client. + self._active_bot_token = "" + self._active_app_token = "" + self._transport_lock = asyncio.Lock() self._bot_user_id: str = "" self._bot_id: str = "" # the app's own bot_id, to spot our own posts self._notification_service = None # Set after service is created @@ -513,8 +524,8 @@ def config(self) -> NerveConfig: The bot outlives every reload and the guardrail lists decide, on each event, whether a message reaches the agent. Reading them per use means a reload that tightens ``deny_users`` takes effect immediately. The - tokens are handed to the transport at connect time, so those still - need a restart — they are listed in ``config_reload`` as such. + tokens are handed to the transport at connect time; ``reload_all`` + rotates that transport explicitly when either token changes. """ return self._config() @@ -585,48 +596,107 @@ async def start(self) -> None: ) return + self._stopping = False + async with self._transport_lock: + web, client, auth = await self._prepare_transport( + cfg.bot_token, cfg.app_token, + ) + self._activate_transport( + web, client, auth, cfg.bot_token, cfg.app_token, + ) + try: + await self._connect_socket(client) + except (Exception, asyncio.CancelledError): + await self._close_socket_quietly(client) + self._client = None + self._web = None + self._active_bot_token = "" + self._active_app_token = "" + raise + + self._last_event_time = time.monotonic() + self._log_auth(auth, "Slack authenticated") + logger.info("Slack Socket Mode connected") + + self._announce_auth_state() + + self._watchdog_task = asyncio.create_task( + self._run_watchdog(), name="slack-socket-watchdog", + ) + + @staticmethod + def _build_web_client(bot_token: str): + """Build the Web API half of one credential pair.""" from slack_sdk.http_retry.builtin_async_handlers import ( AsyncRateLimitErrorRetryHandler, ) from slack_sdk.web.async_client import AsyncWebClient - self._stopping = False - self._web = AsyncWebClient(token=cfg.bot_token) + web = AsyncWebClient(token=bot_token) # The SDK retries connection errors out of the box but not 429s, and # Slack rate limits chat.postMessage to roughly one call per second # per channel. A streamed reply arrives as a burst of edits followed # by a post, so without this the last message of a long answer is the # one most likely to be dropped. - self._web.retry_handlers.append( + web.retry_handlers.append( AsyncRateLimitErrorRetryHandler(max_retry_count=3), ) + return web - auth = await self._web.auth_test() + async def _prepare_transport(self, bot_token: str, app_token: str): + """Validate both tokens and build, but do not connect, both clients.""" + web = self._build_web_client(bot_token) + try: + auth = await web.auth_test() + except Exception as e: + raise RuntimeError( + f"the Slack bot token failed validation ({type(e).__name__})", + ) from e + client = self._build_socket_client( + app_token=app_token, web_client=web, + ) + try: + client.wss_uri = await asyncio.wait_for( + client.issue_new_wss_url(), timeout=SOCKET_CONNECT_TIMEOUT, + ) + except (Exception, asyncio.CancelledError) as e: + await self._close_socket_quietly(client) + if isinstance(e, asyncio.CancelledError): + raise + raise RuntimeError( + f"the Slack app token failed validation ({type(e).__name__})", + ) from e + return web, client, auth + + def _activate_transport( + self, web, client, auth: dict, bot_token: str, app_token: str, + ) -> None: + """Publish one coherent Web API and Socket Mode credential pair.""" + self._web = web + self._client = client + self._active_bot_token = bot_token + self._active_app_token = app_token self._bot_user_id = auth.get("user_id", "") self._bot_id = auth.get("bot_id", "") + + def _log_auth(self, auth: dict, action: str) -> None: logger.info( - "Slack authenticated as %s (%s, %s) in workspace %s", + "%s as %s (%s, %s) in workspace %s", + action, auth.get("user"), self._bot_user_id, self._bot_id, auth.get("team"), ) - self._client = self._build_socket_client() - await self._client.connect() - self._last_event_time = time.monotonic() - logger.info("Slack Socket Mode connected") - - self._announce_auth_state() - - self._watchdog_task = asyncio.create_task( - self._run_watchdog(), name="slack-socket-watchdog", - ) - - def _build_socket_client(self): + def _build_socket_client( + self, *, app_token: str | None = None, web_client=None, + ): """A fresh Socket Mode client wired to this channel's dispatcher.""" from slack_sdk.socket_mode.aiohttp import SocketModeClient client = SocketModeClient( - app_token=self.config.slack.app_token, - web_client=self._web, + app_token=( + self._active_app_token if app_token is None else app_token + ), + web_client=self._web if web_client is None else web_client, # Slack closes and reissues a socket roughly hourly; without this # the channel goes quiet until the daemon restarts. auto_reconnect_enabled=True, @@ -634,6 +704,140 @@ def _build_socket_client(self): client.socket_mode_request_listeners.append(self._on_request) return client + @staticmethod + async def _close_socket_quietly(client) -> None: + """Best-effort cleanup for a client that will not be reused.""" + if client is None: + return + try: + await asyncio.wait_for(client.close(), timeout=10) + except Exception as e: + logger.warning("Slack socket close raised: %s", e) + + @staticmethod + async def _connect_socket(client) -> None: + """Connect with a bound wait; the Slack SDK otherwise retries forever.""" + try: + await asyncio.wait_for( + client.connect(), timeout=SOCKET_CONNECT_TIMEOUT, + ) + except TimeoutError as e: + raise RuntimeError("the Slack socket connection timed out") from e + + @staticmethod + async def _close_socket_for_replacement(client) -> None: + """Close *client*, refusing to create a competing connection on failure.""" + if client is None: + return + try: + await asyncio.wait_for(client.close(), timeout=10) + except Exception as e: + raise RuntimeError( + "the existing Slack socket could not be closed", + ) from e + + def needs_credential_reload(self, bot_token: str, app_token: str) -> bool: + """Whether the connected clients differ from the desired token pair.""" + return ( + bot_token != self._active_bot_token + or app_token != self._active_app_token + ) + + async def reload_credentials(self, bot_token: str, app_token: str) -> None: + """Rotate both Slack clients without ever leaving two sockets connected. + + The bot token can be validated while the old transport remains live. + Socket Mode is different: connecting the candidate before closing the + old socket lets the two clients steal each other's events. Close first, + and rebuild the previous transport if the candidate cannot connect. + + Active credentials are tracked separately from the live config so a + failed reload is retryable and a watchdog rebuild keeps using the last + coherent pair rather than mixing old and new tokens. + """ + if not bot_token or not app_token: + raise RuntimeError("the new Slack credentials are incomplete") + + async with self._transport_lock: + if self._stopping: + raise RuntimeError("the Slack channel is stopping") + if not self.needs_credential_reload(bot_token, app_token): + return + + web, client, auth = await self._prepare_transport( + bot_token, app_token, + ) + + old_client = self._client + old_web = self._web + old_bot_token = self._active_bot_token + old_app_token = self._active_app_token + old_bot_user_id = self._bot_user_id + old_bot_id = self._bot_id + + try: + await self._close_socket_for_replacement(old_client) + except (Exception, asyncio.CancelledError): + await self._close_socket_quietly(client) + raise + + # Publish before connect so an envelope arriving immediately after + # the handshake sees the matching Web client and bot identity. + self._activate_transport( + web, client, auth, bot_token, app_token, + ) + # Identity and message ids are workspace-local. Token rotation is + # normally within one app, but clear before connecting so a move + # to another workspace cannot race an event through stale auth + # context. A failed rotation leaves these disposable caches empty. + self._seen_events.clear() + self._message_cache.clear() + self._last_inbound_ts.clear() + self._name_cache.clear() + try: + await self._connect_socket(client) + except (Exception, asyncio.CancelledError) as connect_error: + await self._close_socket_quietly(client) + self._web = old_web + self._active_bot_token = old_bot_token + self._active_app_token = old_app_token + self._bot_user_id = old_bot_user_id + self._bot_id = old_bot_id + self._client = old_client + + if old_web is not None and old_app_token: + try: + rollback = self._build_socket_client( + app_token=old_app_token, web_client=old_web, + ) + self._client = rollback + await self._connect_socket(rollback) + except Exception as rollback_error: + logger.error( + "Slack credential rollback failed (%s)", + type(rollback_error).__name__, + ) + raise RuntimeError( + "the new Slack credentials failed and the previous " + "connection could not be restored", + ) from connect_error + if isinstance(connect_error, asyncio.CancelledError): + raise + raise RuntimeError( + "the new Slack app token failed to connect; the previous " + "connection was restored", + ) from connect_error + + if isinstance(connect_error, asyncio.CancelledError): + raise + raise RuntimeError( + "the new Slack app token failed to connect and no previous " + "connection was available", + ) from connect_error + + self._last_event_time = time.monotonic() + self._log_auth(auth, "Slack credentials reloaded") + def _announce_auth_state(self) -> None: """Log how access is configured — loudly when it lets nobody in. @@ -666,11 +870,8 @@ async def stop(self) -> None: task.cancel() if inflight: await asyncio.gather(*inflight, return_exceptions=True) - if self._client: - try: - await self._client.close() - except Exception as e: - logger.warning("Slack socket close raised: %s", e) + async with self._transport_lock: + await self._close_socket_quietly(self._client) # ------------------------------------------------------------------ # # Watchdog # @@ -690,7 +891,13 @@ async def _run_watchdog(self) -> None: check_count += 1 # is_connected() is a coroutine: it pings the socket rather than # reading a flag. - connected = bool(await self._client.is_connected()) + try: + connected = bool(await self._client.is_connected()) + except Exception: + # A credential rotation can close this client between the + # loop's null check and the ping. _rebuild rechecks under the + # lifecycle lock and leaves a replacement alone if it won. + connected = False if check_count % WATCHDOG_HEARTBEAT_EVERY == 0: since = time.monotonic() - self._last_event_time logger.info( @@ -720,15 +927,27 @@ async def _rebuild(self) -> None: A brief gap is the safe trade. Slack redelivers an unacked envelope, while a split connection loses events with no sign anything is wrong. """ - old = self._client - if old is not None: + async with self._transport_lock: + # A credential reload may have repaired the socket while the + # watchdog was waiting for the lifecycle lock. + if self._client is not None: + try: + if await self._client.is_connected(): + return + except Exception: + pass + + old = self._client + await self._close_socket_for_replacement(old) + client = self._build_socket_client( + app_token=self._active_app_token, web_client=self._web, + ) + self._client = client try: - await asyncio.wait_for(old.close(), timeout=10) - except Exception as e: - logger.warning("Slack: closing the old socket raised: %s", e) - - self._client = self._build_socket_client() - await self._client.connect() + await self._connect_socket(client) + except Exception: + await self._close_socket_quietly(client) + raise def _touch(self) -> None: """Record that an envelope arrived from Slack.""" @@ -1083,10 +1302,11 @@ async def _download_file(self, url: str) -> bytes | None: import httpx try: + token = self._active_bot_token or self.config.slack.bot_token async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.get( url, - headers={"Authorization": f"Bearer {self.config.slack.bot_token}"}, + headers={"Authorization": f"Bearer {token}"}, follow_redirects=True, ) resp.raise_for_status() diff --git a/nerve/config_reload.py b/nerve/config_reload.py index 1949cdc3..c6be2a4c 100644 --- a/nerve/config_reload.py +++ b/nerve/config_reload.py @@ -14,7 +14,8 @@ change. Editing a config file on the box does not apply itself. Restart-only (NOT reloaded here): the gateway socket (host/port/SSL), the -Telegram bot's token and allow-list, the MCP endpoint (including the +Telegram bot's token and allow-list, whether chat channels are enabled, and +the MCP endpoint (including the ``auth.jwt_secret`` it checks ``/mcp/v1`` against, which the web gateway reads per request), Langfuse, the memory bridges, the Codex thread-sync service (``sync.codex.*`` — a different service from the cron sources under ``sync.*``, @@ -86,8 +87,6 @@ "mcp_endpoint.path", "memory", "proxy", - "slack.app_token", - "slack.bot_token", "slack.enabled", "sync.codex", "telegram.allowed_users", @@ -276,6 +275,59 @@ def hand_over(label: str, target) -> None: return problems +async def _reload_slack_credentials(old_config, new_config, engine) -> str | None: + """Rotate a running Slack transport when its desired tokens changed. + + The channel compares against the credentials that actually built its + clients, not merely old versus new config. That distinction makes a failed + rotation retryable on the next reload even though the process-wide config + already contains the new values. + """ + if engine is None or not new_config.slack.enabled: + return None + + from nerve.channels.slack import SlackChannel + + try: + channel = engine.router.get_channel("slack") + except Exception as e: # noqa: BLE001 — keep the unified reload best-effort + logger.warning( + "Could not locate the running Slack channel (%s)", type(e).__name__, + ) + return f"{_ERROR_PREFIX}could not locate the running Slack channel" + if not isinstance(channel, SlackChannel): + # Turning Slack on still needs a restart: no channel object exists for + # the reload path to start or for gateway shutdown to own. If it was + # already meant to be on, its startup failed and a clean reload must + # not pretend that it recovered anything. + if old_config is not None and old_config.slack.enabled: + return ( + f"{_ERROR_PREFIX}Slack is enabled but its channel is not running; " + "restart required" + ) + return None + + bot_token = new_config.slack.bot_token + app_token = new_config.slack.app_token + if not channel.needs_credential_reload(bot_token, app_token): + return None + + try: + await channel.reload_credentials(bot_token, app_token) + except Exception as e: # noqa: BLE001 — report the subsystem, continue reload + detail = str(e) or type(e).__name__ + # The channel raises credential-free messages, but keep this boundary + # safe for SDK errors and test doubles too: the summary is returned over + # HTTP and logged by callers. + for secret in (bot_token, app_token): + if secret: + detail = detail.replace(secret, "") + logger.warning("Slack credential reload failed: %s", detail) + return f"{_ERROR_PREFIX}{detail}" + + return "credentials reloaded" + + async def reload_all(engine, cron_service, config_dir: Path) -> dict: """Re-read config and hot-reload all reloadable subsystems. @@ -319,6 +371,9 @@ async def reload_all(engine, cron_service, config_dir: Path) -> dict: # running the new config in some places and the old one in others, # which is the state worth shouting about. summary["services"] = f"{_ERROR_PREFIX}{'; '.join(stale)}" + slack = await _reload_slack_credentials(old_config, new_config, engine) + if slack is not None: + summary["slack"] = slack # 2. Cron jobs + sources. if cron_service is not None: diff --git a/tests/test_config_reload.py b/tests/test_config_reload.py index a093e705..b8732ff8 100644 --- a/tests/test_config_reload.py +++ b/tests/test_config_reload.py @@ -767,6 +767,141 @@ async def test_a_rotated_secret_is_reported_without_its_value( assert "new-secret" not in summary["restart_required"] +class TestSlackCredentialReload: + @staticmethod + def _body(bot_token, app_token, allow_user=None): + allow = f" allow_users: [{allow_user}]\n" if allow_user else "" + return ( + "slack:\n" + " enabled: true\n" + f" bot_token: {bot_token}\n" + f" app_token: {app_token}\n" + f"{allow}" + ) + + @classmethod + def _running_channel( + cls, config_dir, workspace, monkeypatch, + bot_token="xoxb-old", app_token="xapp-old", allow_user=None, + ): + import nerve.config as cfgmod + from nerve.channels.slack import SlackChannel + + _write_config( + config_dir, workspace, + cls._body(bot_token, app_token, allow_user), + ) + monkeypatch.setattr(cfgmod, "_config", cfgmod.load_config(config_dir)) + channel = SlackChannel(cfgmod.get_config, router=MagicMock()) + channel._active_bot_token = bot_token + channel._active_app_token = app_token + return channel + + @staticmethod + def _engine(channel): + router = MagicMock() + router.get_channel.return_value = channel + return SimpleNamespace( + router=router, + reload_mcp_config=AsyncMock(return_value=[]), + _skill_manager=None, + ) + + @pytest.mark.asyncio + async def test_changed_tokens_rotate_the_running_transport( + self, tmp_path, monkeypatch, + ): + config_dir, ws = tmp_path / "cfg", tmp_path / "ws" + ws.mkdir() + channel = self._running_channel(config_dir, ws, monkeypatch) + + async def rotate(bot_token, app_token): + channel._active_bot_token = bot_token + channel._active_app_token = app_token + + channel.reload_credentials = AsyncMock(side_effect=rotate) + engine = self._engine(channel) + + _write_config( + config_dir, ws, self._body("xoxb-new", "xapp-new"), + ) + summary = await reload_all(engine, None, config_dir) + + channel.reload_credentials.assert_awaited_once_with( + "xoxb-new", "xapp-new", + ) + assert summary["slack"] == "credentials reloaded" + assert "slack.bot_token" not in summary.get("restart_required", "") + assert "slack.app_token" not in summary.get("restart_required", "") + assert reload_failures(summary) == {} + + @pytest.mark.asyncio + async def test_enabling_the_channel_still_requires_a_restart( + self, tmp_path, monkeypatch, + ): + import nerve.config as cfgmod + + config_dir, ws = tmp_path / "cfg", tmp_path / "ws" + ws.mkdir() + _write_config(config_dir, ws) + monkeypatch.setattr(cfgmod, "_config", cfgmod.load_config(config_dir)) + + _write_config( + config_dir, ws, self._body("xoxb-new", "xapp-new"), + ) + summary = await reload_all(self._engine(None), None, config_dir) + + assert "slack.enabled" in summary["restart_required"] + assert "slack" not in summary + assert reload_failures(summary) == {} + + @pytest.mark.asyncio + async def test_a_guardrail_only_reload_does_not_reconnect( + self, tmp_path, monkeypatch, + ): + config_dir, ws = tmp_path / "cfg", tmp_path / "ws" + ws.mkdir() + channel = self._running_channel( + config_dir, ws, monkeypatch, + "xoxb-same", "xapp-same", "U0000001", + ) + channel.reload_credentials = AsyncMock() + + _write_config(config_dir, ws, self._body( + "xoxb-same", "xapp-same", "U0000002", + )) + summary = await reload_all(self._engine(channel), None, config_dir) + + channel.reload_credentials.assert_not_awaited() + assert "slack" not in summary + assert reload_failures(summary) == {} + + @pytest.mark.asyncio + async def test_a_failed_rotation_is_redacted_and_retryable( + self, tmp_path, monkeypatch, + ): + config_dir, ws = tmp_path / "cfg", tmp_path / "ws" + ws.mkdir() + channel = self._running_channel(config_dir, ws, monkeypatch) + channel.reload_credentials = AsyncMock(side_effect=RuntimeError( + "could not use xoxb-new with xapp-new", + )) + engine = self._engine(channel) + + _write_config( + config_dir, ws, self._body("xoxb-new", "xapp-new"), + ) + first = await reload_all(engine, None, config_dir) + second = await reload_all(engine, None, config_dir) + + assert channel.reload_credentials.await_count == 2 + for summary in (first, second): + failure = reload_failures(summary)["slack"] + assert "xoxb-new" not in failure + assert "xapp-new" not in failure + assert failure == "could not use with " + + class TestReloadRoute: @pytest.mark.asyncio async def test_route_returns_summary(self, tmp_path, monkeypatch): diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index e3d2ae01..389ee8dc 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -547,6 +547,9 @@ async def test_the_watchdog_reconnects_a_dropped_socket(self, monkeypatch): monkeypatch.setattr(slack_module, "WATCHDOG_INTERVAL", 0.01) channel = _channel() + channel._active_app_token = "xapp-active" + channel.config.slack.app_token = "xapp-new-in-config" + active_web = channel._web dead = MagicMock() dead.is_connected = AsyncMock(return_value=False) dead.close = AsyncMock() @@ -571,6 +574,87 @@ async def test_the_watchdog_reconnects_a_dropped_socket(self, monkeypatch): # connection only, so a leftover one quietly takes a share of them. dead.close.assert_awaited() assert channel._client is fresh + channel._build_socket_client.assert_called_once_with( + app_token="xapp-active", web_client=active_web, + ) + + @pytest.mark.asyncio + async def test_a_failed_new_socket_restores_the_previous_credentials(self): + channel = _channel() + events = [] + + old_web = channel._web + old_client = MagicMock() + + async def close_old(): + events.append("old closed") + + old_client.close = AsyncMock(side_effect=close_old) + channel._client = old_client + channel._active_bot_token = "xoxb-old" + channel._active_app_token = "xapp-old" + channel._bot_user_id = "U0OLD" + channel._bot_id = "B0OLD" + + candidate_web = MagicMock() + candidate = MagicMock() + + async def connect_candidate(): + events.append("candidate connected") + raise RuntimeError("invalid app token") + + async def close_candidate(): + events.append("candidate closed") + + candidate.connect = AsyncMock(side_effect=connect_candidate) + candidate.close = AsyncMock(side_effect=close_candidate) + channel._prepare_transport = AsyncMock(return_value=( + candidate_web, + candidate, + {"user_id": "U0NEW", "bot_id": "B0NEW"}, + )) + + rollback = MagicMock() + + async def connect_rollback(): + events.append("rollback connected") + + rollback.connect = AsyncMock(side_effect=connect_rollback) + channel._build_socket_client = MagicMock(return_value=rollback) + + with pytest.raises(RuntimeError, match="previous connection was restored"): + await channel.reload_credentials("xoxb-new", "xapp-new") + + assert events == [ + "old closed", + "candidate connected", + "candidate closed", + "rollback connected", + ] + assert channel._client is rollback + assert channel._web is old_web + assert channel._active_bot_token == "xoxb-old" + assert channel._active_app_token == "xapp-old" + assert channel._bot_user_id == "U0OLD" + assert channel._bot_id == "B0OLD" + assert channel.needs_credential_reload("xoxb-new", "xapp-new") + + @pytest.mark.asyncio + async def test_a_socket_handshake_cannot_hold_reload_open_forever( + self, monkeypatch, + ): + import nerve.channels.slack as slack_module + + monkeypatch.setattr(slack_module, "SOCKET_CONNECT_TIMEOUT", 0.01) + client = MagicMock() + + async def never_connect(): + await asyncio.sleep(30) + + client.connect = AsyncMock(side_effect=never_connect) + + with pytest.raises(RuntimeError, match="connection timed out"): + await SlackChannel._connect_socket(client) @pytest.mark.asyncio async def test_send_file_refuses_without_a_target(self): diff --git a/tests/test_slack_integration.py b/tests/test_slack_integration.py index ae481247..87cf86b1 100644 --- a/tests/test_slack_integration.py +++ b/tests/test_slack_integration.py @@ -61,6 +61,52 @@ async def test_the_channel_connects_and_learns_its_own_id( finally: await channel.stop() + async def test_credentials_rotate_on_the_running_channel( + self, slack, monkeypatch, + ): + channel, router = await _started( + slack, monkeypatch, allow_users=["U1"], + ) + old_client = channel._client + try: + await channel.reload_credentials("xoxb-replaced", "xapp-replaced") + + assert channel._client is not old_client + assert not await old_client.is_connected() + assert await channel._client.is_connected() + assert channel._web.token == "xoxb-replaced" + assert channel._active_app_token == "xapp-replaced" + + await slack.push_event({ + "type": "message", "channel": "D1", "channel_type": "im", + "user": "U1", "ts": "1.1", "text": "after rotation", + }) + await slack.settle() + router.handle_message.assert_called_once() + finally: + await channel.stop() + + @pytest.mark.parametrize(("failed_method", "bot_token", "app_token"), [ + ("auth.test", "xoxb-invalid", "xapp-replaced"), + ("apps.connections.open", "xoxb-replaced", "xapp-invalid"), + ]) + async def test_invalid_new_credentials_keep_the_old_connection( + self, slack, monkeypatch, failed_method, bot_token, app_token, + ): + channel, _ = await _started(slack, monkeypatch, allow_users=["U1"]) + old_client = channel._client + slack.errors[failed_method] = "invalid_auth" + try: + with pytest.raises(RuntimeError, match="token failed validation"): + await channel.reload_credentials(bot_token, app_token) + + assert channel._client is old_client + assert await old_client.is_connected() + assert channel._active_bot_token == "xoxb-fake" + assert channel._active_app_token == "xapp-fake" + finally: + await channel.stop() + async def test_every_envelope_is_acked(self, slack, monkeypatch): # Slack redelivers anything unacked within three seconds, and an # agent turn is far longer than that. From 54ec31914e34c77b143dc3b4faae3308ba204f51 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Mon, 24 Aug 2026 13:41:16 +0200 Subject: [PATCH 06/18] Configure Slack direct messages explicitly --- config.example.yaml | 11 +++-- docs/config.md | 35 ++++++++------ nerve/channels/slack.py | 30 ++++++------ nerve/cli.py | 17 +++++-- nerve/config.py | 18 +++++--- tests/test_config_resolution.py | 19 ++++++++ tests/test_slack_channel.py | 81 +++++++++++++++++++++++++-------- tests/test_slack_integration.py | 18 ++++++-- 8 files changed, 162 insertions(+), 67 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 8e155813..ee89dc77 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -122,15 +122,16 @@ slack: # # Access guardrails. Patterns match a Slack id (U0123ABC / C0456DEF), a # handle, a display name, an email, or a channel name — case-insensitive, - # with globs. Deny always beats allow. A direct message is matched under - # the synthetic channel name "dm". + # with globs. Deny always beats allow. Direct messages are a separate, + # explicit opt-in. # - # With no allow list at all the bot refuses every message. That is + # With no allow grant at all the bot refuses every message. That is # deliberate: an open bot in a workspace is full agent access for anyone - # in it. Set at least one of these. + # in it. Allow specific users, direct messages, and/or specific channels. # allow_users: ["U0123ABC", "alex.soffronow"] # deny_users: ["*-bot"] - # allow_channels: ["dm", "eng-*", "C0456DEF"] + allow_direct_messages: false + # allow_channels: ["eng-*", "C0456DEF"] # deny_channels: ["*-random", "*-social"] # # Each thread becomes its own session and replies stay in-thread. Off means diff --git a/docs/config.md b/docs/config.md index 1874cae4..495d3484 100644 --- a/docs/config.md +++ b/docs/config.md @@ -245,7 +245,7 @@ A reload is always explicit. Two things cause one: | `external_agents.targets` (including each target's `enabled`), `.sync_interval_minutes`, `.conflict_policy` | ✅ from the next sweep, provided at least one target existed at startup (see the restart table) | | `sessions.sticky_period_minutes` | ✅ | | `telegram.dm_policy`, `.stream_mode` | ✅ read per update. Tightening `open` to `pairing` takes effect on the next message; `allowed_users` does not follow it (see the restart table) | -| `slack.bot_token`, `.app_token`, `.allow_users`, `.deny_users`, `.allow_channels`, `.deny_channels`, `.reply_in_thread`, `.stream_mode` | ✅ token changes reconnect the running transport; a failed rotation restores the previous connection and is reported as a reload error. The other settings are read per event, so tightening a guardrail takes effect on the next message | +| `slack.bot_token`, `.app_token`, `.allow_users`, `.deny_users`, `.allow_direct_messages`, `.allow_channels`, `.deny_channels`, `.reply_in_thread`, `.stream_mode` | ✅ token changes reconnect the running transport; a failed rotation restores the previous connection and is reported as a reload error. The other settings are read per event, so tightening a guardrail takes effect on the next message | | `workflows.*` and `workflows.review_loop.*` — budget caps, concurrency, the warning fraction, iteration and criteria caps, leg engines/models, the verifier sandbox | ✅ read per use, by loops and runs already in flight as well as new ones. The two `enabled` flags and the two loop cadences are the exceptions; see the restart table | | `provider.*` and the API keys it selects (`aws_region`, `aws_profile`, `aws_access_key_id`, and the effective Anthropic key) | ✅ for sessions started **after** the reload. Each client's environment is built from the live reference when the session is created, by the same seam as `agent.*` below | | **`agent.*` and `codex.*`**: backend choice and models (`agent.backend`, `agent.cron_model`, `agent.model`, `codex.model`, `codex.cron_model`), `max_turns`, `agent.effort`/`cron_effort` and `codex.effort_map`, `agent.thinking`, `agent.context_1m*`, `agent.background_agent_permissions`, `agent.agent_teams`, idle timeouts, cache TTL, `codex.sandbox`, `.approval_policy`, `.web_search`, `.extra_config`, `.tool_timeout_sec`, `.bin_path`, `.auth`/`.api_key`/`.api_key_env`, `.pricing`, `.min_version`/`.max_version`, `.ultracode.*` | ✅ for sessions and turns **started after** the reload. The engine and both backends resolve these through one live reference, so a key cannot be hot in one and frozen in the other | @@ -1082,6 +1082,7 @@ users are ignored. | `slack.app_token` | string | - | App-Level Token for Socket Mode (`xapp-…`) | | `slack.allow_users` | list[str] | `[]` | Senders allowed to reach the agent | | `slack.deny_users` | list[str] | `[]` | Senders always refused | +| `slack.allow_direct_messages` | bool | `false` | Allow direct-message conversations | | `slack.allow_channels` | list[str] | `[]` | Conversations the agent answers in | | `slack.deny_channels` | list[str] | `[]` | Conversations always refused | | `slack.reply_in_thread` | bool | `true` | One session per thread; replies stay in-thread | @@ -1089,7 +1090,7 @@ users are ignored. | `slack.commands` | list[str] | see below | Which `/nerve` subcommands the workspace may run | Both tokens are secrets — put them in `config.local.yaml`. Changing either -reconnects Slack on reload; the four guardrail lists also take effect then. +reconnects Slack on reload; the access guardrails also take effect then. Slack is opt-in. Without an explicit `slack.enabled`, the channel is on only when both tokens are set, so a configuration that predates Slack — one with @@ -1113,7 +1114,7 @@ Socket Mode means the bot dials out to Slack, so Nerve needs no public URL. Without it the direct-message conversation with the bot is read-only and Slack refuses every DM with `restricted_action_read_only_channel`. The manifest cannot set this. -5. Set at least one allow list (see below), then restart Nerve. +5. Configure at least one access grant (see below), then restart Nerve. ```yaml display_information: @@ -1172,18 +1173,20 @@ the member id if you would rather not grant the scope. ### Guardrails -The allow/deny lists are the whole authorization story — there is no pairing -step, because the workspace already decides who can reach the bot at all. +These guardrails are the whole authorization story — there is no pairing step, +because the workspace already decides who can reach the bot at all. A pattern matches a Slack id (`U0123ABC`, `C0456DEF`), a handle, a display name, an email, or a channel name. Matching is case-insensitive and supports -globs. A direct message is matched under the synthetic channel name `dm`. +globs. Direct messages are controlled separately by +`allow_direct_messages`; they are refused by default. ```yaml slack: allow_users: ["U0123ABC", "alex.soffronow"] deny_users: ["*-bot"] - allow_channels: ["dm", "eng-*"] + allow_direct_messages: true + allow_channels: ["eng-*"] deny_channels: ["*-social"] ``` @@ -1196,14 +1199,18 @@ Rules, per list: - **An empty allow list means "anything not denied".** The sender and the conversation are checked independently and both must -pass. `allow_users` alone lets those people talk to the agent anywhere; -`allow_channels` alone lets anyone in those channels talk to it. +pass. Direct messages require `allow_direct_messages: true`, and sender +allow/deny rules still apply to them. That setting alone admits any workspace +member who can DM the bot, just as `allow_channels` alone admits anyone in +those channels. `allow_users` alone lets those people talk in shared channels, +but does not enable direct messages. Two failure modes are closed on purpose: -- **No allow list at all refuses everything.** A deny list is not an opt-in. - An unconfigured bot logs a warning at startup and answers nobody, rather - than handing the whole workspace full agent access. +- **No allow grant at all refuses everything.** A deny list is not an opt-in. + With both allow lists empty and `allow_direct_messages: false`, the bot logs + a warning at startup and answers nobody rather than handing the whole + workspace full agent access. - **A name that cannot be looked up is refused** when a deny list is set. This covers a lookup that failed *and* one that succeeded while omitting an alias the rules need. An allow list already fails closed on an unknown @@ -1252,8 +1259,8 @@ unless you ask for them: - **`reply`** answers the latest pending question anywhere in the instance. The last two are not scoped to the caller. Nerve has no ownership model for -Slack yet, so with `allow_channels: [dm]` any member of the workspace who may -DM the bot could otherwise enumerate someone else's private session, continue +Slack yet, so with `allow_direct_messages: true` any permitted member of the +workspace could otherwise enumerate someone else's private session, continue it, or answer a question that was never theirs. Turn them on in a workspace where everyone on the allow list is trusted with every session. diff --git a/nerve/channels/slack.py b/nerve/channels/slack.py index cc1a41ce..29503377 100644 --- a/nerve/channels/slack.py +++ b/nerve/channels/slack.py @@ -536,6 +536,7 @@ def policy(self) -> AccessPolicy: return AccessPolicy.from_lists( allow_users=cfg.allow_users, deny_users=cfg.deny_users, + allow_direct_messages=cfg.allow_direct_messages, allow_channels=cfg.allow_channels, deny_channels=cfg.deny_channels, ) @@ -848,9 +849,10 @@ def _announce_auth_state(self) -> None: policy = self.policy if not policy.configured: logger.warning( - "Slack: no slack.allow_users or slack.allow_channels configured " - "— every message will be refused. Add your Slack member id " - "(Profile → ⋮ → Copy member ID) to slack.allow_users.", + "Slack: no slack.allow_users, slack.allow_channels, or " + "slack.allow_direct_messages configured — every message will " + "be refused. Add your Slack member id (Profile → ⋮ → Copy " + "member ID) to slack.allow_users.", ) return logger.info("Slack access policy: %s", policy.describe()) @@ -1067,14 +1069,9 @@ async def _identify_user( async def _identify_conversation( self, channel_id: str, channel_type: str, resolve: bool, ) -> Identity: - """Build the Identity for a conversation. - - A direct message has no name, so it is given the synthetic name - ``dm`` — that is how ``allow_channels: ["dm", "eng-*"]`` admits - direct messages alongside a set of channels. - """ - if channel_type == "im": - return Identity(id=channel_id, names=("dm",)) + """Build the Identity for a conversation.""" + if channel_type == "im" or channel_id.startswith("D"): + return Identity(id=channel_id) if not resolve: return Identity(id=channel_id) cached = self._name_cache.get(f"c:{channel_id}") @@ -1084,7 +1081,7 @@ async def _identify_conversation( info = await self._web.conversations_info(channel=channel_id) channel = info.get("channel") or {} if channel.get("is_im"): - identity = Identity(id=channel_id, names=("dm",)) + identity = Identity(id=channel_id) else: name = channel.get("name") or "" identity = Identity( @@ -1115,6 +1112,11 @@ async def _authorize( ) return False + direct_message = channel_type == "im" or channel_id.startswith("D") + if direct_message and not policy.allow_direct_messages: + logger.info("Slack refused a message: direct messages are not allowed") + return False + user = await self._identify_user( user_id, needs_name_resolution(policy.users, is_id=is_slack_id), @@ -1125,7 +1127,9 @@ async def _authorize( channel_type, needs_name_resolution(policy.conversations, is_id=is_slack_id), ) - verdict = policy.check(user, conversation) + verdict = policy.check( + user, conversation, direct_message=direct_message, + ) if not verdict.allowed: logger.info("Slack refused a message: %s", verdict.reason) return verdict.allowed diff --git a/nerve/cli.py b/nerve/cli.py index 5e645ed1..9374aba3 100644 --- a/nerve/cli.py +++ b/nerve/cli.py @@ -1126,17 +1126,24 @@ def doctor_report(config, config_source: str = "", check_api: bool = False) -> s else: lines.append(f"[OK] Slack bot token: ...{config.slack.bot_token[-4:]}") slack = config.slack - if not slack.allow_users and not slack.allow_channels: + if not ( + slack.allow_users + or slack.allow_channels + or slack.allow_direct_messages + ): warnings.append( - "[WARN] slack.allow_users and slack.allow_channels are both " - "empty — the bot refuses every message. Add your Slack " - "member id to slack.allow_users" + "[WARN] slack.allow_users and slack.allow_channels are " + "empty and slack.allow_direct_messages is false — the bot " + "refuses every message. Add your Slack member id to " + "slack.allow_users" ) else: lines.append( f"[OK] Slack guardrails: {len(slack.allow_users)} allowed " f"user(s), {len(slack.allow_channels)} allowed channel(s), " - f"{len(slack.deny_users) + len(slack.deny_channels)} deny rule(s)" + f"{len(slack.deny_users) + len(slack.deny_channels)} deny " + f"rule(s), direct messages " + f"{'allowed' if slack.allow_direct_messages else 'refused'}" ) else: lines.append("[--] Slack disabled") diff --git a/nerve/config.py b/nerve/config.py index 77cda6ea..c060a83b 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -1057,13 +1057,13 @@ def _slack_commands(raw: object) -> list[str] | None: class SlackConfig: """Slack bot channel — Socket Mode transport plus access guardrails. - The allow/deny lists are the whole authorization story: Slack has no - pairing step, because a workspace already decides who can reach the bot - at all. Patterns match a Slack id (``U0123ABC``), a handle, a display - name, an email, or a channel name, case-insensitively and with globs - (``eng-*``). See :mod:`nerve.channels.access` for the semantics — deny - wins, a non-empty allow list is a gate, and a policy with no allow - patterns at all refuses everything. + Slack has no pairing step, because a workspace already decides who can + reach the bot at all. Direct messages are an explicit opt-in; sender and + channel patterns match a Slack id (``U0123ABC``), handle, display name, + email, or channel name case-insensitively and with globs (``eng-*``). + See :mod:`nerve.channels.access` for the semantics — deny wins, a + non-empty allow list is a gate, and a policy with no allow grant at all + refuses everything. """ # Off until the workspace is set up. Slack reaches an installation that @@ -1074,6 +1074,9 @@ class SlackConfig: app_token: str = "" # xapp-… — Socket Mode connection allow_users: list[str] = field(default_factory=list) deny_users: list[str] = field(default_factory=list) + # Direct messages are a distinct conversation kind, not a channel name. + # Keep them opt-in even when a sender allow-list grants access elsewhere. + allow_direct_messages: bool = False allow_channels: list[str] = field(default_factory=list) deny_channels: list[str] = field(default_factory=list) stream_mode: str = "partial" @@ -1129,6 +1132,7 @@ def from_dict(cls, d: dict, locked: bool = False) -> SlackConfig: app_token=app_token, allow_users=d.get("allow_users") or [], deny_users=d.get("deny_users") or [], + allow_direct_messages=d.get("allow_direct_messages", False), allow_channels=d.get("allow_channels") or [], deny_channels=d.get("deny_channels") or [], stream_mode=stream_mode, diff --git a/tests/test_config_resolution.py b/tests/test_config_resolution.py index 1116d986..09701256 100644 --- a/tests/test_config_resolution.py +++ b/tests/test_config_resolution.py @@ -249,6 +249,11 @@ def test_an_unreadable_value_falls_back_to_off(self): }) assert cfg.enabled is False + def test_direct_messages_are_an_explicit_opt_in(self): + assert SlackConfig.from_dict({}).allow_direct_messages is False + cfg = SlackConfig.from_dict({"allow_direct_messages": "true"}) + assert cfg.allow_direct_messages is True + def test_the_doctor_says_nothing_about_an_unconfigured_slack(self): from nerve.cli import doctor_report @@ -262,6 +267,20 @@ def test_the_doctor_still_reports_missing_tokens_when_asked_for(self): report = doctor_report(NerveConfig.from_dict({"slack": {"enabled": True}})) assert "[ERR] Slack enabled but bot_token, app_token not set" in report + def test_the_doctor_counts_direct_messages_as_a_guardrail(self): + from nerve.cli import doctor_report + + report = doctor_report(NerveConfig.from_dict({ + "slack": { + "enabled": True, + "bot_token": "xoxb-test", + "app_token": "xapp-test", + "allow_direct_messages": True, + }, + })) + assert "direct messages allowed" in report + assert "bot refuses every message" not in report + class TestSlackDefaultCommands: def test_globally_scoped_commands_are_off_by_default(self): diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index 389ee8dc..a35dcdf6 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -247,12 +247,15 @@ def test_constraints_match_slacks_edit_rate_limit(self): assert constraints.min_edit_interval >= 1.0 def test_the_policy_follows_a_config_reload(self): - # The channel outlives a reload, so the lists are read per use. + # The channel outlives a reload, so every guardrail is read per use. cfg = _config(allow_users=["U1"]) channel = SlackChannel(lambda: cfg, router=MagicMock()) assert channel.policy.users.allow == ["U1"] + assert channel.policy.allow_direct_messages is False cfg.slack.allow_users = ["U2"] + cfg.slack.allow_direct_messages = True assert channel.policy.users.allow == ["U2"] + assert channel.policy.allow_direct_messages is True class TestAuthorization: @@ -265,14 +268,16 @@ async def test_an_unconfigured_policy_refuses_without_calling_slack(self): @pytest.mark.asyncio async def test_an_id_allow_list_needs_no_name_lookup(self): - channel = _channel(allow_users=["U0123ABC"]) + channel = _channel( + allow_users=["U0123ABC"], allow_direct_messages=True, + ) channel._web.users_info = AsyncMock() assert await channel._authorize("U0123ABC", "D1", "im") channel._web.users_info.assert_not_called() @pytest.mark.asyncio async def test_a_handle_allow_list_resolves_the_name(self): - channel = _channel(allow_users=["alex"]) + channel = _channel(allow_users=["alex"], allow_direct_messages=True) channel._web.users_info = AsyncMock( return_value={"user": {"name": "alex", "profile": {}}}, ) @@ -280,13 +285,16 @@ async def test_a_handle_allow_list_resolves_the_name(self): @pytest.mark.asyncio async def test_a_failed_lookup_with_a_deny_list_refuses(self): - channel = _channel(allow_users=["U1"], deny_users=["*-bot"]) + channel = _channel( + allow_users=["U1"], deny_users=["*-bot"], + allow_direct_messages=True, + ) channel._web.users_info = AsyncMock(side_effect=RuntimeError("no scope")) assert not await channel._authorize("U1", "D1", "im") @pytest.mark.asyncio async def test_resolved_names_are_cached(self): - channel = _channel(allow_users=["alex"]) + channel = _channel(allow_users=["alex"], allow_direct_messages=True) channel._web.users_info = AsyncMock( return_value={"user": {"name": "alex", "profile": {}}}, ) @@ -295,15 +303,29 @@ async def test_resolved_names_are_cached(self): assert channel._web.users_info.await_count == 1 @pytest.mark.asyncio - async def test_a_direct_message_is_matched_as_dm(self): - channel = _channel(allow_users=["U1"], allow_channels=["dm"]) + async def test_direct_messages_are_refused_by_default(self): + channel = _channel(allow_users=["U1"]) + channel._web.users_info = AsyncMock() + assert not await channel._authorize("U1", "D1", "im") + channel._web.users_info.assert_not_called() + + @pytest.mark.asyncio + async def test_the_direct_message_setting_allows_them(self): + channel = _channel( + allow_users=["U1"], allow_direct_messages=True, + ) assert await channel._authorize("U1", "D1", "im") + @pytest.mark.asyncio + async def test_dm_is_not_a_magic_channel_name(self): + channel = _channel(allow_users=["U1"], allow_channels=["dm"]) + assert not await channel._authorize("U1", "D1", "im") + class TestMessageEvents: @pytest.mark.asyncio async def test_a_direct_message_reaches_the_router(self): - channel = _channel(allow_users=["U1"]) + channel = _channel(allow_users=["U1"], allow_direct_messages=True) await channel._handle_message_event({ "type": "message", "channel": "D1", "channel_type": "im", "user": "U1", "ts": "1.1", "text": "hello", @@ -316,7 +338,9 @@ async def test_a_direct_message_reaches_the_router(self): @pytest.mark.asyncio async def test_an_unauthorized_sender_never_reaches_the_router(self): - channel = _channel(allow_users=["U-other"]) + channel = _channel( + allow_users=["U-other"], allow_direct_messages=True, + ) await channel._handle_message_event({ "type": "message", "channel": "D1", "channel_type": "im", "user": "U1", "ts": "1.1", "text": "hello", @@ -325,7 +349,9 @@ async def test_an_unauthorized_sender_never_reaches_the_router(self): @pytest.mark.asyncio async def test_the_bots_own_message_is_ignored(self): - channel = _channel(allow_users=["U0BOT"]) + channel = _channel( + allow_users=["U0BOT"], allow_direct_messages=True, + ) await channel._handle_message_event({ "type": "message", "channel": "D1", "channel_type": "im", "user": "U0BOT", "ts": "1.1", "text": "hi", @@ -411,7 +437,7 @@ async def test_reply_in_thread_off_keeps_one_session_per_channel(self): @pytest.mark.asyncio async def test_a_redelivered_event_runs_once(self): # Slack retries anything it thinks was not acked. - channel = _channel(allow_users=["U1"]) + channel = _channel(allow_users=["U1"], allow_direct_messages=True) event = { "type": "message", "channel": "D1", "channel_type": "im", "user": "U1", "ts": "1.1", "text": "hello", @@ -433,7 +459,7 @@ async def test_a_message_and_its_app_mention_twin_run_once(self): @pytest.mark.asyncio async def test_an_empty_message_is_dropped(self): - channel = _channel(allow_users=["U1"]) + channel = _channel(allow_users=["U1"], allow_direct_messages=True) await channel._handle_message_event({ "type": "message", "channel": "D1", "channel_type": "im", "user": "U1", "ts": "1.1", "text": "", @@ -444,7 +470,7 @@ async def test_an_empty_message_is_dropped(self): class TestReactionEvents: @pytest.mark.asyncio async def test_a_reaction_on_a_known_message_reaches_the_router(self): - channel = _channel(allow_users=["U1"]) + channel = _channel(allow_users=["U1"], allow_direct_messages=True) channel._cache_message("1.1", "D1", "the original") await channel._handle_reaction_event({ "type": "reaction_added", "user": "U1", "reaction": "tada", @@ -466,7 +492,9 @@ async def test_a_reaction_on_an_unknown_message_is_ignored(self): @pytest.mark.asyncio async def test_an_unauthorized_reaction_is_ignored(self): - channel = _channel(allow_users=["U-other"]) + channel = _channel( + allow_users=["U-other"], allow_direct_messages=True, + ) channel._cache_message("1.1", "D1", "the original") await channel._handle_reaction_event({ "type": "reaction_added", "user": "U1", "reaction": "tada", @@ -526,7 +554,7 @@ async def test_an_unmappable_reaction_is_skipped(self): @pytest.mark.asyncio async def test_the_typing_ack_reacts_to_the_message_being_answered(self): - channel = _channel(allow_users=["U1"]) + channel = _channel(allow_users=["U1"], allow_direct_messages=True) await channel._handle_message_event({ "type": "message", "channel": "D1", "channel_type": "im", "user": "U1", "ts": "1.1", "text": "hello", @@ -705,7 +733,10 @@ async def test_an_uppercase_deny_name_still_forces_a_lookup(self): async def test_a_missing_email_refuses_an_email_deny_rule(self): # users.info answers 200 without profile.email when the token lacks # users:read.email, so the deny pattern silently matched nothing. - channel = _channel(allow_users=["U999"], deny_users=["blocked@x.com"]) + channel = _channel( + allow_users=["U999"], deny_users=["blocked@x.com"], + allow_direct_messages=True, + ) channel._web.users_info = AsyncMock(return_value={ "user": {"id": "U999", "name": "blocked", "profile": {}}, }) @@ -713,7 +744,10 @@ async def test_a_missing_email_refuses_an_email_deny_rule(self): @pytest.mark.asyncio async def test_an_email_deny_rule_still_works_with_the_scope(self): - channel = _channel(allow_users=["*"], deny_users=["blocked@x.com"]) + channel = _channel( + allow_users=["*"], deny_users=["blocked@x.com"], + allow_direct_messages=True, + ) channel._web.users_info = AsyncMock(return_value={ "user": {"id": "U9", "name": "b", "profile": {"email": "blocked@x.com"}}, }) @@ -721,7 +755,10 @@ async def test_an_email_deny_rule_still_works_with_the_scope(self): @pytest.mark.asyncio async def test_an_innocent_user_is_not_caught_by_an_email_deny_rule(self): - channel = _channel(allow_users=["*"], deny_users=["blocked@x.com"]) + channel = _channel( + allow_users=["*"], deny_users=["blocked@x.com"], + allow_direct_messages=True, + ) channel._web.users_info = AsyncMock(return_value={ "user": {"id": "U1", "name": "ok", "profile": {"email": "ok@x.com"}}, }) @@ -996,7 +1033,9 @@ async def test_star_also_resolves_across_threads(self): class TestCommandExposure: def _ch(self, **kw): - channel = _channel(allow_users=["U1"], **kw) + channel = _channel( + allow_users=["U1"], allow_direct_messages=True, **kw, + ) channel._web.chat_postEphemeral = AsyncMock(return_value={"ok": True}) return channel @@ -1093,7 +1132,9 @@ class TestCommandsBindTheKeyMessagesRead: """ def _ch(self, **kw): - channel = _channel(allow_users=["U1"], **kw) + channel = _channel( + allow_users=["U1"], allow_direct_messages=True, **kw, + ) channel._web.chat_postEphemeral = AsyncMock(return_value={"ok": True}) channel.router.create_session = AsyncMock(return_value="s-new") channel.router.switch_session = AsyncMock() diff --git a/tests/test_slack_integration.py b/tests/test_slack_integration.py index 87cf86b1..4ec2036a 100644 --- a/tests/test_slack_integration.py +++ b/tests/test_slack_integration.py @@ -66,6 +66,7 @@ async def test_credentials_rotate_on_the_running_channel( ): channel, router = await _started( slack, monkeypatch, allow_users=["U1"], + allow_direct_messages=True, ) old_client = channel._client try: @@ -110,7 +111,10 @@ async def test_invalid_new_credentials_keep_the_old_connection( async def test_every_envelope_is_acked(self, slack, monkeypatch): # Slack redelivers anything unacked within three seconds, and an # agent turn is far longer than that. - channel, router = await _started(slack, monkeypatch, allow_users=["U1"]) + channel, router = await _started( + slack, monkeypatch, allow_users=["U1"], + allow_direct_messages=True, + ) try: envelope_id = await slack.push_event({ "type": "message", "channel": "D1", "channel_type": "im", @@ -127,7 +131,10 @@ async def test_an_unauthorized_envelope_is_still_acked( ): # A refusal must not look like a delivery failure, or Slack retries # the same rejected message until it gives up. - channel, router = await _started(slack, monkeypatch, allow_users=["U-other"]) + channel, router = await _started( + slack, monkeypatch, allow_users=["U-other"], + allow_direct_messages=True, + ) try: envelope_id = await slack.push_event({ "type": "message", "channel": "D1", "channel_type": "im", @@ -150,7 +157,10 @@ class TestConversation: async def test_a_direct_message_produces_a_reply_in_the_dm( self, slack, monkeypatch, ): - channel, router = await _started(slack, monkeypatch, allow_users=["U1"]) + channel, router = await _started( + slack, monkeypatch, allow_users=["U1"], + allow_direct_messages=True, + ) try: async def _reply(msg): from nerve.channels.base import OutboundMessage @@ -203,6 +213,7 @@ async def test_a_name_allow_list_resolves_through_the_api( } channel, router = await _started( slack, monkeypatch, allow_users=["alex.soffronow"], + allow_direct_messages=True, ) try: await slack.push_event({ @@ -247,6 +258,7 @@ async def test_a_denied_lookup_refuses_rather_than_guesses( slack.errors["users.info"] = "missing_scope" channel, router = await _started( slack, monkeypatch, allow_users=["U1"], deny_users=["*-bot"], + allow_direct_messages=True, ) try: await slack.push_event({ From b69c2c8a2f4511d3d8ecda53795a64d721640b19 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Mon, 24 Aug 2026 14:36:42 +0200 Subject: [PATCH 07/18] Condense Slack configuration documentation --- docs/config.md | 208 +++++++++++++++++-------------------------------- 1 file changed, 70 insertions(+), 138 deletions(-) diff --git a/docs/config.md b/docs/config.md index 495d3484..c49157a6 100644 --- a/docs/config.md +++ b/docs/config.md @@ -245,7 +245,7 @@ A reload is always explicit. Two things cause one: | `external_agents.targets` (including each target's `enabled`), `.sync_interval_minutes`, `.conflict_policy` | ✅ from the next sweep, provided at least one target existed at startup (see the restart table) | | `sessions.sticky_period_minutes` | ✅ | | `telegram.dm_policy`, `.stream_mode` | ✅ read per update. Tightening `open` to `pairing` takes effect on the next message; `allowed_users` does not follow it (see the restart table) | -| `slack.bot_token`, `.app_token`, `.allow_users`, `.deny_users`, `.allow_direct_messages`, `.allow_channels`, `.deny_channels`, `.reply_in_thread`, `.stream_mode` | ✅ token changes reconnect the running transport; a failed rotation restores the previous connection and is reported as a reload error. The other settings are read per event, so tightening a guardrail takes effect on the next message | +| `slack.*` except `.enabled` | ✅ token changes reconnect (and roll back on failure); other changes apply to the next event | | `workflows.*` and `workflows.review_loop.*` — budget caps, concurrency, the warning fraction, iteration and criteria caps, leg engines/models, the verifier sandbox | ✅ read per use, by loops and runs already in flight as well as new ones. The two `enabled` flags and the two loop cadences are the exceptions; see the restart table | | `provider.*` and the API keys it selects (`aws_region`, `aws_profile`, `aws_access_key_id`, and the effective Anthropic key) | ✅ for sessions started **after** the reload. Each client's environment is built from the live reference when the session is created, by the same seam as `agent.*` below | | **`agent.*` and `codex.*`**: backend choice and models (`agent.backend`, `agent.cron_model`, `agent.model`, `codex.model`, `codex.cron_model`), `max_turns`, `agent.effort`/`cron_effort` and `codex.effort_map`, `agent.thinking`, `agent.context_1m*`, `agent.background_agent_permissions`, `agent.agent_teams`, idle timeouts, cache TTL, `codex.sandbox`, `.approval_policy`, `.web_search`, `.extra_config`, `.tool_timeout_sec`, `.bin_path`, `.auth`/`.api_key`/`.api_key_env`, `.pricing`, `.min_version`/`.max_version`, `.ultracode.*` | ✅ for sessions and turns **started after** the reload. The engine and both backends resolve these through one live reference, so a key cannot be hot in one and frozen in the other | @@ -280,7 +280,7 @@ reload cannot inspect, and are documented here only. | `sync.codex.*` (`enabled`, every `origins[*]` field, `store_encrypted_reasoning`, `workspace_filter.*`) | Codex thread sync is a **different service** from the cron sources above, built once at startup with one polling worker per origin. Adding or editing an origin and reloading reports `ok` and ingests nothing | | `langfuse.*` | set up before the engine, caching its host, redaction patterns and `LANGFUSE_*` environment exports in process globals | | `telegram.enabled`, `.bot_token`, `.allowed_users` | the bot was built with that token, and the allow-list was copied into a set when it was built. Notification *delivery* does follow a reload, so after changing `allowed_users` the two can disagree until a restart. `dm_policy` and `stream_mode` are read per update and do follow a reload (see the table above) | -| `slack.enabled` | the channel object is created and registered only at startup. Tokens and the four allow/deny lists do follow a reload (see the table above) | +| `slack.enabled` | the Slack channel is registered only at startup | | `mcp_endpoint.*` | fixed when the app was created | | `auth.jwt_secret` | half-hot: the web gateway reads it per request, so its own auth follows a reload, but the MCP endpoint captured it when the app was mounted and keeps checking `/mcp/v1` against the old secret. Rotating it moves one and not the other until a restart | | `workflows.enabled`, `workflows.review_loop.enabled` | each service is created at startup and only when its flag is on. Turning one **off** does not stop the service already running, and turning it **on** creates nothing for a reload to reach | @@ -1077,43 +1077,38 @@ users are ignored. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `slack.enabled` | bool | see below | Enable the Slack bot | +| `slack.enabled` | bool | see below | Enable Slack | | `slack.bot_token` | string | - | Bot User OAuth Token (`xoxb-…`) | | `slack.app_token` | string | - | App-Level Token for Socket Mode (`xapp-…`) | -| `slack.allow_users` | list[str] | `[]` | Senders allowed to reach the agent | -| `slack.deny_users` | list[str] | `[]` | Senders always refused | -| `slack.allow_direct_messages` | bool | `false` | Allow direct-message conversations | -| `slack.allow_channels` | list[str] | `[]` | Conversations the agent answers in | -| `slack.deny_channels` | list[str] | `[]` | Conversations always refused | +| `slack.allow_users` | list[str] | `[]` | Allowed senders | +| `slack.deny_users` | list[str] | `[]` | Blocked senders | +| `slack.allow_direct_messages` | bool | `false` | Allow DMs; sender rules still apply | +| `slack.allow_channels` | list[str] | `[]` | Allowed shared conversations | +| `slack.deny_channels` | list[str] | `[]` | Blocked shared conversations | | `slack.reply_in_thread` | bool | `true` | One session per thread; replies stay in-thread | -| `slack.stream_mode` | string | `partial` | `partial` (edit msgs) or `full` | -| `slack.commands` | list[str] | see below | Which `/nerve` subcommands the workspace may run | +| `slack.stream_mode` | string | `partial` | `partial` (edit one message) or `full` | +| `slack.commands` | list[str] | see below | Enabled `/nerve` subcommands | -Both tokens are secrets — put them in `config.local.yaml`. Changing either -reconnects Slack on reload; the access guardrails also take effect then. +Put both tokens in `config.local.yaml`. Token changes reconnect on reload; +guardrails, commands, and message behavior apply to the next event. Only +`slack.enabled` requires a restart. -Slack is opt-in. Without an explicit `slack.enabled`, the channel is on only -when both tokens are set, so a configuration that predates Slack — one with -no `slack` section at all — stays off and `nerve doctor` says nothing about -it. Set `enabled: true` to be told about missing tokens instead of having the -channel quietly stay down. Under lockdown the channel is off unless -`enabled: true` is explicit, because lockdown drops the machine-local layer -that decides whether this box answers Slack. +Slack runs when `enabled: true`. If the key is omitted, it runs only when both +tokens are present; under lockdown, `enabled: true` is always required. An +explicit `true` also makes `nerve doctor` report missing tokens. ### Setting up the Slack app Socket Mode means the bot dials out to Slack, so Nerve needs no public URL. 1. Create an app at from the manifest below. -2. **Basic Information → App-Level Tokens**: generate a token with the - `connections:write` scope. This is `slack.app_token` (`xapp-…`). -3. **Install App**: install to the workspace and copy the Bot User OAuth - Token. This is `slack.bot_token` (`xoxb-…`). -4. **App Home** → *Show Tabs* → turn on the **Messages Tab** and tick - *Allow users to send Slash commands and messages from the messages tab*. - Without it the direct-message conversation with the bot is read-only and - Slack refuses every DM with `restricted_action_read_only_channel`. The - manifest cannot set this. +2. Under **Basic Information → App-Level Tokens**, create a + `connections:write` token for `slack.app_token` (`xapp-…`). +3. Install the app and copy its Bot User OAuth Token to `slack.bot_token` + (`xoxb-…`). +4. Under **App Home → Show Tabs**, enable **Messages Tab** and **Allow users + to send Slash commands and messages**. The manifest cannot set this; without + it DMs are read-only. 5. Configure at least one access grant (see below), then restart Nerve. ```yaml @@ -1161,25 +1156,16 @@ settings: socket_mode_enabled: true ``` -`users:read`, `users:read.email` and `channels:read` are only needed if the -allow/deny lists name people or channels rather than raw ids. With ids only, -Nerve never calls those endpoints and you can drop the scopes. - -If a **deny** rule names an email address, `users:read.email` is required. -Without it Slack still answers `users.info` successfully but omits the email, -so the rule could never match — Nerve refuses those users rather than let the -deny list pass silently, and logs why. Write the rule against the handle or -the member id if you would rather not grant the scope. +Rules that use only raw IDs do not need `users:read`, `users:read.email`, or +`channels:read`. Names need the corresponding read scope; email rules need +`users:read.email`. If Slack omits identity data needed by a rule, Nerve +refuses the message and logs why. ### Guardrails -These guardrails are the whole authorization story — there is no pairing step, -because the workspace already decides who can reach the bot at all. - -A pattern matches a Slack id (`U0123ABC`, `C0456DEF`), a handle, a display -name, an email, or a channel name. Matching is case-insensitive and supports -globs. Direct messages are controlled separately by -`allow_direct_messages`; they are refused by default. +Patterns match Slack IDs (`U0123ABC`, `C0456DEF`), handles, display names, +emails, or channel names. Matching is case-insensitive and supports globs. +Use raw IDs to avoid name lookups. ```yaml slack: @@ -1190,105 +1176,51 @@ slack: deny_channels: ["*-social"] ``` -Rules, per list: - -- **Deny wins.** A value matching any deny pattern is refused whatever the - allow list says. -- **A non-empty allow list is a gate.** The value must match one of its - patterns. -- **An empty allow list means "anything not denied".** - -The sender and the conversation are checked independently and both must -pass. Direct messages require `allow_direct_messages: true`, and sender -allow/deny rules still apply to them. That setting alone admits any workspace -member who can DM the bot, just as `allow_channels` alone admits anyone in -those channels. `allow_users` alone lets those people talk in shared channels, -but does not enable direct messages. - -Two failure modes are closed on purpose: - -- **No allow grant at all refuses everything.** A deny list is not an opt-in. - With both allow lists empty and `allow_direct_messages: false`, the bot logs - a warning at startup and answers nobody rather than handing the whole - workspace full agent access. -- **A name that cannot be looked up is refused** when a deny list is set. - This covers a lookup that failed *and* one that succeeded while omitting an - alias the rules need. An allow list already fails closed on an unknown - name, but a deny list would otherwise fail open. - -A pattern counts as a literal id only if it has the real Slack shape — a -`U`/`W`/`B`/`C`/`D`/`G`/`T` followed by at least seven uppercase alphanumerics. -Anything else is treated as a name and resolved through the API, so an -all-caps channel name like `ENGINEERING` is matched as a name, not mistaken -for an id. - -Note that a `${VAR}` reference on a list field becomes one entry, not a -comma-separated set — spell several values as a YAML list. - -### Behaviour - -- In a **direct message** the bot answers everything. -- In a **channel** it answers only when mentioned, or when the message - continues a thread it already has a session for. Adding the bot to a busy - channel does not start an agent turn per remark. -- With `reply_in_thread` on (the default) each thread is a separate session, - so several people can run separate conversations in one channel. -- `/nerve ` mirrors the Telegram command set. Replies are - ephemeral — only the person who ran the command sees them. - -### Limiting what the workspace can do - -`slack.commands` chooses which subcommands exist. Chatting with the agent and -answering notification buttons are not commands, so they are unaffected. +- Deny rules always win. +- A non-empty allow list restricts that dimension; an empty one allows + anything not denied. +- Sender and conversation rules are independent. `allow_users` alone permits + those users in any non-denied shared channel; `allow_channels` alone permits + any non-denied user in those channels. +- DMs also require `allow_direct_messages: true`. With no `allow_users`, that + permits any non-denied member who can DM the bot. +- With no `allow_users`, `allow_channels`, or DM grant, Nerve refuses everyone. + Deny rules alone never enable access. +- If a required name lookup fails or omits data, Nerve refuses the message. + +### Message behavior + +- In an allowed DM, the bot answers every message. +- In a shared channel, it answers mentions and threads where it already has a + session. +- With `reply_in_thread: true`, each thread has its own session and replies + stay there. +- `/nerve` responses are ephemeral. + +### Commands + +`slack.commands` controls slash commands only; chat and notification buttons +are unaffected. ```yaml slack: - commands: [] # no slash commands at all — chat only - commands: [reply] # only answer pending questions - commands: [sessions, new, stop] # session control, nothing operational - commands: [all] # everything, including doctor and restart + commands: [] # disable /nerve + commands: [reply] # enable only reply + commands: [sessions, new, stop] # enable this exact set + commands: [all] # enable every subcommand ``` -Omitting the key gives `new, stop, star, unstar`. Four subcommands are off -unless you ask for them: - -- **`doctor`** prints host health into a shared workspace. -- **`restart`** lets anyone on the allow list bounce the daemon. -- **`sessions`** lists every session in the instance, Telegram and web ones - included, and attaches the one you pick to your own Slack conversation. -- **`reply`** answers the latest pending question anywhere in the instance. - -The last two are not scoped to the caller. Nerve has no ownership model for -Slack yet, so with `allow_direct_messages: true` any permitted member of the -workspace could otherwise enumerate someone else's private session, continue -it, or answer a question that was never theirs. Turn them on in a workspace -where everyone on the allow list is trusted with every session. - -An unknown name is dropped with a warning rather than refused, since the key -exists to narrow access and a typo should not stop the daemon booting. - -`/nerve help` lists only what is enabled, and a disabled command says it is -turned off rather than pretending not to exist. - -### Slash commands cannot run in threads - -Slack refuses them outright — it answers *"/nerve is not supported in -threads"* — and the payload carries no thread reference. So a command run at -channel level has to work out which thread you meant. - -`/nerve stop`, `/nerve star` and `/nerve unstar` therefore resolve across -every live session in the channel, not just the channel-level one. With one -session live they act on it and name it; with several they show a picker -rather than guess, since acting on someone else's thread silently is worse -than asking. With none they say so. - -`/nerve new` and `/nerve sessions` cannot work that way, because both have to -*bind* a session to a conversation and only a thread id identifies one. In a -channel with `reply_in_thread` on they refuse and say why. Nothing is lost: -each new mention in such a channel already opens its own thread and its own -session, and `/nerve stop` ends one. Both commands work normally in a direct -message and in a channel with `reply_in_thread: false`, where an ordinary -message routes to the channel-level key the command can name. +Omitting the key enables `new`, `stop`, `star`, and `unstar`. `doctor`, +`restart`, `sessions`, and `reply` are opt-in: the first two expose host +operations, while the latter two can reach sessions outside Slack and are not +scoped to the caller. Enable them only when every permitted user is trusted +with the whole instance. Unknown command names are ignored with a warning; +`/nerve help` shows the enabled set. + +Slack slash-command payloads have no thread ID. In a threaded shared channel, +`new` and `sessions` therefore refuse, while `stop`, `star`, and `unstar` +select among that channel's active sessions. Commands work normally in DMs +and channels with `reply_in_thread: false`. ## Quiet Hours From 195e3e1520c68abc175bd76fce57d03d9e15d5c4 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 25 Aug 2026 09:52:47 +0200 Subject: [PATCH 08/18] Condense Slack implementation commentary --- config.example.yaml | 21 ++--- nerve/channels/slack.py | 114 +++++++-------------------- nerve/config.py | 50 +++--------- nerve/config_reload.py | 45 ++--------- nerve/templates/config/settings.yaml | 24 ++---- tests/fake_slack.py | 25 ++---- 6 files changed, 64 insertions(+), 215 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index ee89dc77..e0a0acee 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -115,19 +115,12 @@ slack: # install that does not use Slack never has to say so. Set it to true to be # told about a missing token instead of having the channel stay down. enabled: false - # Socket Mode — the bot dials out, so no public URL is needed. Both tokens - # go in config.local.yaml: + # Socket Mode needs both tokens in config.local.yaml; no public URL is needed: # bot_token: xoxb-… (OAuth & Permissions → Bot User OAuth Token) # app_token: xapp-… (Basic Information → App-Level Tokens, connections:write) # - # Access guardrails. Patterns match a Slack id (U0123ABC / C0456DEF), a - # handle, a display name, an email, or a channel name — case-insensitive, - # with globs. Deny always beats allow. Direct messages are a separate, - # explicit opt-in. - # - # With no allow grant at all the bot refuses every message. That is - # deliberate: an open bot in a workspace is full agent access for anyone - # in it. Allow specific users, direct messages, and/or specific channels. + # Access patterns match Slack IDs or names, case-insensitively with globs. + # Deny wins, DMs require explicit opt-in, and no allow grant refuses everyone. # allow_users: ["U0123ABC", "alex.soffronow"] # deny_users: ["*-bot"] allow_direct_messages: false @@ -138,15 +131,11 @@ slack: # one session per channel and replies at channel level. reply_in_thread: true stream_mode: partial # "partial" (edit messages) or "full" (wait for complete) - # Which /nerve subcommands the workspace may run. Chatting and notification - # buttons are not commands, so they are unaffected. + # `/nerve` defaults to new, stop, star, and unstar. # omitted — new, stop, star, unstar # [] — no slash commands at all # [all] — everything, including doctor and restart - # doctor prints host health into a shared workspace and restart lets anyone - # on the allow list bounce the daemon. sessions and reply reach every - # session in the instance, Telegram and web ones included, and are not - # scoped to the caller. All four are opt-in. + # doctor/restart affect the host; sessions/reply reach other channels. Opt in. # commands: [sessions, new, stop, reply] # Quiet hours (local timezone) diff --git a/nerve/channels/slack.py b/nerve/channels/slack.py index 29503377..09f8b1cc 100644 --- a/nerve/channels/slack.py +++ b/nerve/channels/slack.py @@ -1,23 +1,8 @@ -"""Slack bot channel — receive messages, run agent, respond. - -Uses slack_sdk Socket Mode: the bot opens an outbound WebSocket to Slack, so -no public URL and no inbound firewall hole are needed. That is the same shape -as the Telegram channel's long-polling transport, and it keeps a self-hosted -Nerve reachable from behind NAT. - -Session management is delegated to ChannelRouter. Access control is not: -a Slack workspace carries traffic the operator never meant for the agent, so -every inbound event passes :class:`~nerve.channels.access.AccessPolicy` -before it becomes an InboundMessage. See :mod:`nerve.channels.access`. - -Addressing ----------- -A Slack conversation is a channel id, optionally narrowed to one thread. The -two are packed into a single ``target`` string — ``C0456DEF`` or -``C0456DEF:1699887766.123456`` — because :class:`BaseChannel` gives a channel -one opaque address per destination. ``channel_key`` is ``slack:``, so -with ``reply_in_thread`` on, each thread is its own session and two people can -run separate conversations in one channel. +"""Slack bot channel over Socket Mode. + +Inbound messages are authorized before routing. Targets encode a channel and +optional thread as ``C0456DEF[:timestamp]``; channel keys add the ``slack:`` +prefix, allowing per-thread sessions when configured. """ from __future__ import annotations @@ -173,14 +158,8 @@ def parse_target(target: str) -> tuple[str, str | None]: def _md_to_slack(text: str) -> str: """Convert standard Markdown to Slack mrkdwn. - Slack's flavour collides with Markdown on the two most common markers: - ``*text*`` is bold rather than italic, and ``_text_`` is the only italic. - Links are ````. Headings and tables do not exist, so headings - become bold lines. - - Code spans and fences are lifted out first and restored last, so the - substitutions never rewrite code — the failure that makes a snippet of - Python containing ``**kwargs`` render as bold. + Slack uses ``*`` for bold, ``_`` for italic, and ```` for links. + Protect code spans and fences before substitution so they stay literal. """ protected: list[str] = [] @@ -415,16 +394,10 @@ def build_notification_blocks( notification_id: str, options: list[tuple[str, str]] | None = None, ) -> list[dict[str, Any]]: - """Render a notification card, with one button per option. + """Render notification text and option buttons as Block Kit. - ``options`` is a list of ``(label, value)``. The value rides in the - button's ``value`` field and the notification id in ``action_id``; - Slack allows 2000 chars for each, so neither needs the truncation - Telegram's 64-byte ``callback_data`` forces. - - 3000 characters is the limit on one section, not on the message, so a - long body is spread over several sections at line boundaries. Only a - body past the whole-message block limit loses anything, and it says so. + Text is split at Slack's section limit and truncated only at the full + message's block limit. IDs and values use their dedicated button fields. """ chunks = split_message(_md_to_slack(text), _MAX_SECTION_LEN) if len(chunks) > _MAX_SECTION_BLOCKS: @@ -745,16 +718,11 @@ def needs_credential_reload(self, bot_token: str, app_token: str) -> bool: ) async def reload_credentials(self, bot_token: str, app_token: str) -> None: - """Rotate both Slack clients without ever leaving two sockets connected. - - The bot token can be validated while the old transport remains live. - Socket Mode is different: connecting the candidate before closing the - old socket lets the two clients steal each other's events. Close first, - and rebuild the previous transport if the candidate cannot connect. + """Replace the Web API and Socket Mode clients as one credential pair. - Active credentials are tracked separately from the live config so a - failed reload is retryable and a watchdog rebuild keeps using the last - coherent pair rather than mixing old and new tokens. + Validate before closing the old socket, but connect only after closing + it to avoid competing consumers. Restore the old transport on failure; + tracked active credentials keep the reload retryable. """ if not bot_token or not app_token: raise RuntimeError("the new Slack credentials are incomplete") @@ -918,16 +886,10 @@ async def _run_watchdog(self) -> None: logger.error("Slack reconnect failed: %s", e, exc_info=True) async def _rebuild(self) -> None: - """Replace the socket, closing the old one first. + """Replace a disconnected socket after closing the old client. - Calling ``connect()`` again on a live client leaves the previous - session running: Slack hands each event to exactly one of an app's - open connections, so the orphan silently takes a share of the - traffic and the agent sees only part of its own conversation. The - old client is therefore closed before a new one is built. - - A brief gap is the safe trade. Slack redelivers an unacked envelope, - while a split connection loses events with no sign anything is wrong. + Connecting twice can leave competing Slack consumers, so a brief gap + is safer than overlapping connections. """ async with self._transport_lock: # A credential reload may have repaired the socket while the @@ -1147,18 +1109,11 @@ async def _handle_event(self, event: dict[str, Any]) -> None: await self._handle_reaction_event(event) def _is_own_message(self, event: dict[str, Any]) -> bool: - """True only for messages this app itself posted. - - Treating every ``bot_id`` as our own is too broad: Slack stamps one - onto a message a *person* sent through any app or integration — - a workflow, a scheduled send, a client posting with a user token — - while still naming them in ``user``. Ignoring those drops real people - mid-conversation. - - Other bots are turned away by the ``bot_message`` subtype instead, - which is what a message with no human behind it carries. A person - posting through an app is a person, and the access policy judges - them on their own id. + """Whether this app posted the message. + + A foreign ``bot_id`` may represent a person using an integration, so + only this app's bot and user IDs count. The ``bot_message`` subtype + filters other bots. """ if self._bot_id and event.get("bot_id") == self._bot_id: return True @@ -1689,15 +1644,9 @@ def _help_text(enabled: frozenset[str]) -> str: ) def _binds_to_channel_key(self, channel_id: str) -> bool: - """Whether ordinary messages here land on the command's own key. - - A slash command carries no thread reference, so it can only name - ``slack:``. A direct message routes there, and so does a - channel message while ``reply_in_thread`` is off. With it on, a - channel message opens a thread and routes to - ``slack::`` instead, so a session bound at channel level - is never read again: the command reports success and the next message - starts somewhere else. + """Whether slash commands and messages share the channel-level key. + + DMs always do; shared channels do only when threaded replies are off. """ if not channel_id: return False @@ -1721,17 +1670,10 @@ async def _cmd_new( ) async def _live_sessions_for_channel(self, channel_id: str) -> list[dict[str, Any]]: - """Every live session reachable from this channel, newest first. - - Slack refuses to run a slash command inside a thread — it answers - "/nerve is not supported in threads" — so a command never carries - thread context and cannot simply read the session for its own key. - With per-thread routing that key usually owns nothing while the - threads beside it are busy, which is how ``/nerve stop`` came to - report "No active session" with three turns still running. + """Return this channel's live sessions, including threaded ones. - The prefix is re-checked per row because ``slack:C123`` is also a - prefix of ``slack:C1234``. + Slash commands carry no thread context, so search by channel prefix. + Re-check parsed IDs because ``slack:C123`` also prefixes ``slack:C1234``. """ rows = await self.router.list_conversation_sessions(f"slack:{channel_id}") matching: list[dict[str, Any]] = [] diff --git a/nerve/config.py b/nerve/config.py index c060a83b..181150e3 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -1018,14 +1018,8 @@ def from_dict(cls, d: dict, locked: bool = False) -> TelegramConfig: def _slack_commands(raw: object) -> list[str] | None: """Normalize ``slack.commands``. - ``None`` (absent) keeps the default set; ``[]`` disables the slash - command. ``"all"`` as the sole entry means every known subcommand, so a - trusted workspace does not have to list them. - - An unknown name is dropped with a warning rather than refused: the whole - point of the key is to *narrow* what a workspace can reach, and a typo - that stopped the daemon booting would be a worse failure than a command - that stays off. + Absence keeps the defaults, an empty list disables commands, and a sole + ``all`` or ``*`` enables every command. Unknown names are warned and dropped. """ if raw is None: return None @@ -1055,15 +1049,10 @@ def _slack_commands(raw: object) -> list[str] | None: @dataclass class SlackConfig: - """Slack bot channel — Socket Mode transport plus access guardrails. - - Slack has no pairing step, because a workspace already decides who can - reach the bot at all. Direct messages are an explicit opt-in; sender and - channel patterns match a Slack id (``U0123ABC``), handle, display name, - email, or channel name case-insensitively and with globs (``eng-*``). - See :mod:`nerve.channels.access` for the semantics — deny wins, a - non-empty allow list is a gate, and a policy with no allow grant at all - refuses everything. + """Slack Socket Mode and access settings. + + Direct messages require explicit opt-in. Sender and channel patterns match + Slack IDs or resolved names using :mod:`nerve.channels.access` semantics. """ # Off until the workspace is set up. Slack reaches an installation that @@ -1084,14 +1073,8 @@ class SlackConfig: # its own session. Off means every message in a channel shares one session # and replies land at channel level. reply_in_thread: bool = True - # Which `/nerve` subcommands the workspace may run. None keeps the - # default set, which acts only on this channel's own sessions; an empty - # list turns the slash command off entirely. `doctor` and `restart` are - # operator tools and are opt-in: the first prints host health into a - # shared workspace, and the second lets anyone on the allow list bounce - # the daemon. `sessions` and `reply` are opt-in too, because they reach - # every session in the instance. - # See SLACK_ALL_COMMANDS / SLACK_DEFAULT_COMMANDS. + # None keeps safe defaults; [] disables commands. Host-wide and + # cross-channel commands are opt-in. See SLACK_*_COMMANDS. commands: list[str] | None = None @classmethod @@ -1107,20 +1090,9 @@ def from_dict(cls, d: dict, locked: bool = False) -> SlackConfig: stream_mode = "partial" bot_token = d.get("bot_token", "") app_token = d.get("app_token", "") - # Slack is opt-in. An installation that predates the channel has no - # `slack` section at all, and reading that as "on" makes `nerve - # doctor` fail on every one of them over credentials nobody meant to - # set. Without an explicit `enabled`, the section counts as switched - # on only once both Socket Mode tokens are there. - # - # Lockdown still wins, for the reason TelegramConfig.from_dict gives: - # whether a box answers Slack is a per-machine decision written to the - # machine-local config.yaml, and lockdown drops that layer. Shared - # settings carrying a token this box can resolve must not start - # serving a workspace on their own. - # - # An unparseable value falls back to off rather than to the declared - # default, so `enabled: ${SLACK_ON:-nope}` cannot turn the bot on. + # Explicit `enabled` wins. Otherwise both tokens enable Slack, except + # under lockdown where the machine-local opt-in is unavailable. Invalid + # boolean values fail closed. enabled = ( _as_bool(d["enabled"], False, label="SlackConfig.enabled") if "enabled" in d diff --git a/nerve/config_reload.py b/nerve/config_reload.py index c6be2a4c..7e95dbb8 100644 --- a/nerve/config_reload.py +++ b/nerve/config_reload.py @@ -1,40 +1,11 @@ -"""Unified config hot-reload. - -A single entry point that re-reads config from disk and reloads every subsystem -that supports it without a restart: the process config object (so lockdown and -settings changes engage), the long-lived services that captured that object at -start-up, cron jobs, cron sources, MCP servers, and skills. Best-effort per -subsystem — one failure is reported but does not abort the rest, because -refusing a valid cron edit over an unrelated typo in ``settings.yaml`` is the -worse outcome. Which subsystems fell over is reported, never inferred: see -:func:`reload_failures`. - -Nothing reloads on its own. A reload happens when an operator asks for one -(``nerve reload`` / ``POST /api/config/reload``) or when a workspace sync merges a -change. Editing a config file on the box does not apply itself. - -Restart-only (NOT reloaded here): the gateway socket (host/port/SSL), the -Telegram bot's token and allow-list, whether chat channels are enabled, and -the MCP endpoint (including the -``auth.jwt_secret`` it checks ``/mcp/v1`` against, which the web gateway reads -per request), Langfuse, the memory bridges, the Codex thread-sync service -(``sync.codex.*`` — a different service from the cron sources under ``sync.*``, -and the one place those two names diverge), anything a service derived from -config at construction, and a background loop that was never started because its -feature was off. The hot-reload table in ``docs/config.md`` is the -operator-facing list of exactly what a reload covers; keep the two in step. - -:func:`restart_required` diffs the old and new config over -:data:`_RESTART_ONLY_PATHS` and records what changed in the summary. Without it -the summary reports only what was applied, which a caller cannot distinguish from -"nothing needed applying". ``gateway.host``/``port`` are the case that matters: -they live in the tracked settings, so the change can arrive by workspace sync. - -It reports; it does not gate. A setting whose only protection is a line in that -report is a setting the daemon is not applying — which is tolerable for a bound -socket and not for a policy that was tightened. Where the holder can resolve -config per read instead, that is the fix, and the path leaves this list: see -:func:`_repoint`. +"""Unified best-effort config hot reload. + +``reload_all`` replaces process config and refreshes services, cron sources, +MCP servers, and skills. It reports each failure without aborting the others. +Reloads happen only through the CLI/API or workspace sync. + +``_RESTART_ONLY_PATHS`` tracks settings that cannot apply live and must match +the operator table in ``docs/config.md``. """ from __future__ import annotations diff --git a/nerve/templates/config/settings.yaml b/nerve/templates/config/settings.yaml index 5148246b..f1f10cf7 100644 --- a/nerve/templates/config/settings.yaml +++ b/nerve/templates/config/settings.yaml @@ -21,23 +21,13 @@ # # Everything below is commented out; uncomment and edit what you want to share. -# Remote-only, read-only mode. When true, config comes ONLY from this workspace -# + ${ENV_VAR}; machine config.yaml/config.local.yaml overrides are ignored and -# runtime edits are blocked. Secrets (incl. auth.jwt_secret) have to be supplied -# via ${ENV_VAR} here or the environment — config.local.yaml is not read when -# locked, so a secret that lives only there stops being read. -# May be an env reference (lockdown: ${NERVE_LOCKDOWN}) so one repo can serve a -# fleet where only some boxes are locked; a value that is neither true nor false — -# an unset or blank variable included — is refused rather than read as unlocked, -# so spell a default-unlocked box ${NERVE_LOCKDOWN:-false}. -# NERVE_LOCKDOWN can also be set in the environment directly: put it (with -# NERVE_WORKSPACE beside it) in the service definition and the box is locked -# whatever any file says, which is what stops a machine-local edit from repointing -# `workspace:` at a tree that isn't locked. Setting it here alone does not survive -# that. -# Anything `nerve init` writes to config.yaml has to be restated here to survive -# locking: notably telegram.enabled and slack.enabled, which a locked instance -# treats as OFF unless this file says otherwise. +# Lockdown reads only this workspace plus env references; machine config and +# runtime edits are ignored. Supply secrets as ${ENV_VAR}. +# Set NERVE_LOCKDOWN with NERVE_WORKSPACE in the service to prevent local +# repointing; setting lockdown only here cannot. Invalid or unset references +# fail closed, so use ${NERVE_LOCKDOWN:-false} for intentionally unlocked boxes. +# Re-state machine-local flags such as telegram.enabled and slack.enabled; they +# default off because machine config is ignored. # lockdown: true # timezone: America/New_York diff --git a/tests/fake_slack.py b/tests/fake_slack.py index 747b912c..c247c893 100644 --- a/tests/fake_slack.py +++ b/tests/fake_slack.py @@ -1,23 +1,8 @@ -"""A local stand-in for Slack — the Web API and the Socket Mode gateway. - -Socket Mode is just a WebSocket the bot dials out to, and the URL for it -comes from ``apps.connections.open`` on the Web API. Point a -:class:`slack_sdk.web.async_client.AsyncWebClient` at a different -``base_url`` and both halves are ours, so a Slack integration test needs no -workspace, no tokens, and no network. - -Usage:: - - async with FakeSlack() as slack: - channel = SlackChannel(lambda: cfg, router) - with slack.patch_client(monkeypatch): - await channel.start() - await slack.push_event({"type": "message", ...}) - await slack.wait_for("chat.postMessage") - -What it deliberately does not do: rate limits, pagination, scope -enforcement, or Block Kit validation. It answers the calls this channel -makes, records them, and lets a test push envelopes at the bot. +"""Local Slack Web API and Socket Mode stand-in for integration tests. + +``FakeSlack`` records the calls used by ``SlackChannel`` and can push socket +envelopes without credentials or network access. It omits rate limits, +pagination, scope enforcement, and Block Kit validation. """ from __future__ import annotations From b9bb20ae0c8ff4ae302a57dde93bb2e00f58428f Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 25 Aug 2026 10:17:18 +0200 Subject: [PATCH 09/18] Separate Slack access policy from generic matching --- docs/architecture.md | 4 +- nerve/channels/slack.py | 33 ++++-------- nerve/channels/slack_access.py | 93 +++++++++++++++++++++++++++++++++ tests/test_slack_access.py | 94 ++++++++++++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 24 deletions(-) create mode 100644 nerve/channels/slack_access.py create mode 100644 tests/test_slack_access.py diff --git a/docs/architecture.md b/docs/architecture.md index 616ace6c..e19c30d2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,12 +78,12 @@ Abstract communication layer with three components: - **ChannelRouter** — centralized session resolution, streaming adapter lifecycle, interactive tool routing, and cron output delivery. Replaces per-channel session management. - **StreamAdapter** — translates `StreamBroadcaster` events into channel-appropriate output (edit-in-place for Telegram, accumulated send for simple channels). Created per inbound message. -- **AccessPolicy** (`access.py`) — allow/deny guardrails for senders and conversations, applied by a channel before a message becomes an `InboundMessage`. Deny wins, a non-empty allow list is a gate, and a policy with no allow patterns refuses everything. Same semantics as the inbox filters in `nerve/sources/filters.py`. +- **Access matching** (`access.py`) — transport-neutral identity aliases and fail-closed allow/deny pattern matching. Channels compose these primitives into their own policy before creating an `InboundMessage`. - **Archives** (`archives.py`) — bounded one-level ZIP unpacking shared by Telegram and Slack. The download cap is on compressed bytes, so entry count, per-entry and aggregate uncompressed size, and compression ratio are all checked against the archive directory before an entry is read. Implementations: - **Telegram** — python-telegram-bot v21+ with partial message streaming (edit-in-place, 1.5s rate limit), inline keyboard buttons for notification questions, `/reply` command for free-text answers -- **Slack** — slack_sdk Socket Mode (outbound WebSocket, no public URL) with partial streaming via `chat.update`, Block Kit buttons, `/nerve` slash command, and per-thread sessions. Guarded by `AccessPolicy`; in channels it answers only on mention or in a thread it already owns. +- **Slack** — slack_sdk Socket Mode (outbound WebSocket, no public URL) with partial streaming via `chat.update`, Block Kit buttons, `/nerve` slash command, and per-thread sessions. `SlackAccessPolicy` (`slack_access.py`) composes user, channel, and DM guardrails; in channels the bot answers only on mention or in a thread it already owns. - **Web** — Passive channel using gateway WebSocket Adding a new channel (Discord, WhatsApp, etc.) requires implementing ~5 methods and zero session/routing logic. diff --git a/nerve/channels/slack.py b/nerve/channels/slack.py index 09f8b1cc..b0028889 100644 --- a/nerve/channels/slack.py +++ b/nerve/channels/slack.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Any, Callable, TYPE_CHECKING -from nerve.channels.access import AccessPolicy, Identity, needs_name_resolution +from nerve.channels.access import Identity, needs_name_resolution from nerve.channels.archives import ( IMAGE_EXT_TO_MIME, MAX_TEXT_SIZE, @@ -30,6 +30,7 @@ InboundMessage, OutboundMessage, ) +from nerve.channels.slack_access import SlackAccessPolicy from nerve.config import ( SLACK_ALL_COMMANDS, SLACK_DEFAULT_COMMANDS, @@ -503,16 +504,9 @@ def config(self) -> NerveConfig: return self._config() @property - def policy(self) -> AccessPolicy: + def policy(self) -> SlackAccessPolicy: """The access policy, rebuilt per read so reloads apply at once.""" - cfg = self.config.slack - return AccessPolicy.from_lists( - allow_users=cfg.allow_users, - deny_users=cfg.deny_users, - allow_direct_messages=cfg.allow_direct_messages, - allow_channels=cfg.allow_channels, - deny_channels=cfg.deny_channels, - ) + return SlackAccessPolicy.from_config(self.config.slack) @property def enabled_commands(self) -> frozenset[str]: @@ -1067,27 +1061,22 @@ async def _authorize( ) -> bool: """Run the access policy for one event, logging any refusal.""" policy = self.policy - if not policy.configured: - logger.warning( - "Slack: refusing %s in %s — no allow list configured", - user_id, channel_id, - ) - return False - direct_message = channel_type == "im" or channel_id.startswith("D") - if direct_message and not policy.allow_direct_messages: - logger.info("Slack refused a message: direct messages are not allowed") - return False + early = policy.preflight(direct_message=direct_message) + if early is not None: + log = logger.warning if not policy.configured else logger.info + log("Slack refused a message: %s", early.reason) + return early.allowed user = await self._identify_user( user_id, needs_name_resolution(policy.users, is_id=is_slack_id), - need_email=policy.users.deny_needs(lambda p: "@" in p), + need_email=policy.users.any_deny_pattern(lambda p: "@" in p), ) conversation = await self._identify_conversation( channel_id, channel_type, - needs_name_resolution(policy.conversations, is_id=is_slack_id), + needs_name_resolution(policy.channels, is_id=is_slack_id), ) verdict = policy.check( user, conversation, direct_message=direct_message, diff --git a/nerve/channels/slack_access.py b/nerve/channels/slack_access.py new file mode 100644 index 00000000..cd002e45 --- /dev/null +++ b/nerve/channels/slack_access.py @@ -0,0 +1,93 @@ +"""Slack-specific composition of the shared access matching primitives.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from nerve.channels.access import Decision, Identity, PatternGate + +if TYPE_CHECKING: + from nerve.config import SlackConfig + + +@dataclass +class SlackAccessPolicy: + """Apply Slack's user, channel, and direct-message guardrails.""" + + users: PatternGate = field(default_factory=lambda: PatternGate("user")) + channels: PatternGate = field(default_factory=lambda: PatternGate("channel")) + allow_direct_messages: bool = False + + @classmethod + def from_config(cls, config: SlackConfig) -> SlackAccessPolicy: + """Build a policy from the live Slack configuration.""" + return cls( + users=PatternGate( + "user", + allow=list(config.allow_users), + deny=list(config.deny_users), + ), + channels=PatternGate( + "channel", + allow=list(config.allow_channels), + deny=list(config.deny_channels), + ), + allow_direct_messages=config.allow_direct_messages, + ) + + @property + def configured(self) -> bool: + """Whether Slack has any explicit access grant.""" + return bool( + self.users.allow + or self.channels.allow + or self.allow_direct_messages + ) + + def preflight(self, *, direct_message: bool) -> Decision | None: + """Return a decision that can be made before resolving Slack aliases.""" + if not self.configured: + return Decision( + False, + "no slack.allow_users, slack.allow_channels, or " + "slack.allow_direct_messages configured", + ) + if direct_message and not self.allow_direct_messages: + return Decision(False, "direct messages are not allowed") + return None + + def check( + self, user: Identity, channel: Identity, *, direct_message: bool = False, + ) -> Decision: + """Decide whether a Slack user may interact in this conversation.""" + early = self.preflight(direct_message=direct_message) + if early is not None: + return early + + verdict = self.users.check(user) + if not verdict.allowed: + return verdict + + if direct_message: + return Decision(True, "direct messages are allowed") + + # A DM grant alone must not open shared channels. + if not (self.users.allow or self.channels.allow): + return Decision( + False, + "slack.allow_direct_messages does not allow shared channels", + ) + return self.channels.check(channel) + + def describe(self) -> str: + """Summarize the policy without exposing configured patterns.""" + return ( + f"users(allow={len(self.users.allow)}, deny={len(self.users.deny)}) " + f"channels(allow={len(self.channels.allow)}, " + f"deny={len(self.channels.deny)}) " + f"direct_messages={'allowed' if self.allow_direct_messages else 'refused'}" + ) + + +__all__ = ["SlackAccessPolicy"] diff --git a/tests/test_slack_access.py b/tests/test_slack_access.py new file mode 100644 index 00000000..37b662c7 --- /dev/null +++ b/tests/test_slack_access.py @@ -0,0 +1,94 @@ +"""Slack-specific composition of user, channel, and DM access rules.""" + +from nerve.channels.access import Identity +from nerve.channels.slack_access import SlackAccessPolicy +from nerve.config import SlackConfig + + +def _policy(**values) -> SlackAccessPolicy: + return SlackAccessPolicy.from_config(SlackConfig(**values)) + + +class TestSlackAccessPolicy: + def test_nothing_configured_means_nobody(self): + policy = _policy() + assert not policy.configured + verdict = policy.check(Identity(id="U1"), Identity(id="C1")) + assert not verdict.allowed + assert "slack.allow_direct_messages" in verdict.reason + + def test_a_deny_list_alone_still_refuses_everyone(self): + policy = _policy(deny_users=["*-bot"]) + assert not policy.configured + assert not policy.check(Identity(id="U1"), Identity(id="C1")).allowed + + def test_both_gates_must_pass(self): + policy = _policy(allow_users=["U1"], allow_channels=["eng-*"]) + allowed_channel = Identity(id="C1", names=("eng-platform",)) + other_channel = Identity(id="C2", names=("sales",)) + assert policy.check(Identity(id="U1"), allowed_channel).allowed + assert not policy.check(Identity(id="U2"), allowed_channel).allowed + assert not policy.check(Identity(id="U1"), other_channel).allowed + + def test_allow_users_alone_admits_every_shared_channel(self): + policy = _policy(allow_users=["U1"]) + assert policy.check(Identity(id="U1"), Identity(id="C9")).allowed + + def test_allow_channels_alone_admits_every_member(self): + policy = _policy(allow_channels=["eng-*"]) + user = Identity(id="U-anyone") + assert policy.check(user, Identity(id="C1", names=("eng-x",))).allowed + assert not policy.check(user, Identity(id="C2", names=("hr",))).allowed + + def test_direct_messages_need_the_explicit_setting(self): + policy = _policy(allow_users=["U1"]) + assert not policy.check( + Identity(id="U1"), Identity(id="D1"), direct_message=True, + ).allowed + + def test_the_direct_message_setting_admits_a_dm(self): + policy = _policy(allow_direct_messages=True) + assert policy.check( + Identity(id="U1"), Identity(id="D1"), direct_message=True, + ).allowed + + def test_the_direct_message_setting_still_respects_the_user_gate(self): + policy = _policy(allow_users=["U1"], allow_direct_messages=True) + assert policy.check( + Identity(id="U1"), Identity(id="D1"), direct_message=True, + ).allowed + assert not policy.check( + Identity(id="U2"), Identity(id="D1"), direct_message=True, + ).allowed + + def test_the_direct_message_setting_does_not_open_shared_channels(self): + policy = _policy(allow_direct_messages=True) + assert not policy.check( + Identity(id="U1"), Identity(id="C1", names=("general",)), + ).allowed + + def test_the_user_gate_is_reported_before_the_channel_gate(self): + policy = _policy(allow_users=["U1"], allow_channels=["eng-*"]) + verdict = policy.check( + Identity(id="U2"), Identity(id="C2", names=("hr",)), + ) + assert "user" in verdict.reason + + def test_preflight_refuses_without_resolving_aliases(self): + assert _policy().preflight(direct_message=False) is not None + assert _policy(allow_users=["U1"]).preflight( + direct_message=True, + ) is not None + assert _policy(allow_channels=["C1"]).preflight( + direct_message=False, + ) is None + + def test_describe_counts_patterns_without_leaking_them(self): + policy = _policy( + allow_users=["U1", "U2"], deny_channels=["secret-*"], + ) + summary = policy.describe() + assert "allow=2" in summary + assert "direct_messages=refused" in summary + assert "U1" not in summary + assert "secret-*" not in summary From 36ae8fc4c622cdc1c3c8ebac6d14c694996bb9b7 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 25 Aug 2026 11:39:39 +0200 Subject: [PATCH 10/18] Hot-reload the Slack channel lifecycle --- docs/config.md | 10 +-- nerve/channels/slack.py | 68 ++++++++++++++---- nerve/config_reload.py | 89 +++++++++++++++-------- nerve/gateway/server.py | 2 + tests/fake_slack.py | 3 +- tests/test_config_reload.py | 136 ++++++++++++++++++++++++++++++++++-- tests/test_send_file.py | 13 ++++ tests/test_slack_channel.py | 77 +++++++++++++++++++- 8 files changed, 341 insertions(+), 57 deletions(-) diff --git a/docs/config.md b/docs/config.md index c49157a6..b85a6f57 100644 --- a/docs/config.md +++ b/docs/config.md @@ -245,7 +245,7 @@ A reload is always explicit. Two things cause one: | `external_agents.targets` (including each target's `enabled`), `.sync_interval_minutes`, `.conflict_policy` | ✅ from the next sweep, provided at least one target existed at startup (see the restart table) | | `sessions.sticky_period_minutes` | ✅ | | `telegram.dm_policy`, `.stream_mode` | ✅ read per update. Tightening `open` to `pairing` takes effect on the next message; `allowed_users` does not follow it (see the restart table) | -| `slack.*` except `.enabled` | ✅ token changes reconnect (and roll back on failure); other changes apply to the next event | +| `slack.*` | ✅ `enabled` starts or stops the channel; same-workspace token changes reconnect and roll back on failure; other changes apply to the next event | | `workflows.*` and `workflows.review_loop.*` — budget caps, concurrency, the warning fraction, iteration and criteria caps, leg engines/models, the verifier sandbox | ✅ read per use, by loops and runs already in flight as well as new ones. The two `enabled` flags and the two loop cadences are the exceptions; see the restart table | | `provider.*` and the API keys it selects (`aws_region`, `aws_profile`, `aws_access_key_id`, and the effective Anthropic key) | ✅ for sessions started **after** the reload. Each client's environment is built from the live reference when the session is created, by the same seam as `agent.*` below | | **`agent.*` and `codex.*`**: backend choice and models (`agent.backend`, `agent.cron_model`, `agent.model`, `codex.model`, `codex.cron_model`), `max_turns`, `agent.effort`/`cron_effort` and `codex.effort_map`, `agent.thinking`, `agent.context_1m*`, `agent.background_agent_permissions`, `agent.agent_teams`, idle timeouts, cache TTL, `codex.sandbox`, `.approval_policy`, `.web_search`, `.extra_config`, `.tool_timeout_sec`, `.bin_path`, `.auth`/`.api_key`/`.api_key_env`, `.pricing`, `.min_version`/`.max_version`, `.ultracode.*` | ✅ for sessions and turns **started after** the reload. The engine and both backends resolve these through one live reference, so a key cannot be hot in one and frozen in the other | @@ -280,7 +280,6 @@ reload cannot inspect, and are documented here only. | `sync.codex.*` (`enabled`, every `origins[*]` field, `store_encrypted_reasoning`, `workspace_filter.*`) | Codex thread sync is a **different service** from the cron sources above, built once at startup with one polling worker per origin. Adding or editing an origin and reloading reports `ok` and ingests nothing | | `langfuse.*` | set up before the engine, caching its host, redaction patterns and `LANGFUSE_*` environment exports in process globals | | `telegram.enabled`, `.bot_token`, `.allowed_users` | the bot was built with that token, and the allow-list was copied into a set when it was built. Notification *delivery* does follow a reload, so after changing `allowed_users` the two can disagree until a restart. `dm_policy` and `stream_mode` are read per update and do follow a reload (see the table above) | -| `slack.enabled` | the Slack channel is registered only at startup | | `mcp_endpoint.*` | fixed when the app was created | | `auth.jwt_secret` | half-hot: the web gateway reads it per request, so its own auth follows a reload, but the MCP endpoint captured it when the app was mounted and keeps checking `/mcp/v1` against the old secret. Rotating it moves one and not the other until a restart | | `workflows.enabled`, `workflows.review_loop.enabled` | each service is created at startup and only when its flag is on. Turning one **off** does not stop the service already running, and turning it **on** creates nothing for a reload to reach | @@ -1089,9 +1088,10 @@ users are ignored. | `slack.stream_mode` | string | `partial` | `partial` (edit one message) or `full` | | `slack.commands` | list[str] | see below | Enabled `/nerve` subcommands | -Put both tokens in `config.local.yaml`. Token changes reconnect on reload; -guardrails, commands, and message behavior apply to the next event. Only -`slack.enabled` requires a restart. +Put both tokens in `config.local.yaml`. Reloading can start or stop Slack, and +same-workspace token changes reconnect with rollback on failure. Credentials +for another workspace require a restart. Guardrails, commands, and message +behavior apply to the next event. Slack runs when `enabled: true`. If the key is omitted, it runs only when both tokens are present; under lockdown, `enabled: true` is always required. An diff --git a/nerve/channels/slack.py b/nerve/channels/slack.py index b0028889..9dfd1cab 100644 --- a/nerve/channels/slack.py +++ b/nerve/channels/slack.py @@ -54,6 +54,8 @@ # return a failure and restore the previous transport instead of hanging the # config endpoint indefinitely. SOCKET_CONNECT_TIMEOUT = 30 +# Give acknowledged work a short chance to finish when Slack is disabled live. +_STOP_DRAIN_TIMEOUT = 30.0 # Bounded caches: event dedupe, message text for reaction context, resolved names. _DEDUPE_MAX = 500 _MESSAGE_CACHE_MAX = 200 @@ -461,6 +463,7 @@ def __init__( self._transport_lock = asyncio.Lock() self._bot_user_id: str = "" self._bot_id: str = "" # the app's own bot_id, to spot our own posts + self._team_id: str = "" self._notification_service = None # Set after service is created self._watchdog_task: asyncio.Task | None = None self._stopping = False @@ -580,6 +583,9 @@ async def start(self) -> None: self._web = None self._active_bot_token = "" self._active_app_token = "" + self._bot_user_id = "" + self._bot_id = "" + self._team_id = "" raise self._last_event_time = time.monotonic() @@ -646,6 +652,7 @@ def _activate_transport( self._active_app_token = app_token self._bot_user_id = auth.get("user_id", "") self._bot_id = auth.get("bot_id", "") + self._team_id = auth.get("team_id") or self._team_id def _log_auth(self, auth: dict, action: str) -> None: logger.info( @@ -730,6 +737,13 @@ async def reload_credentials(self, bot_token: str, app_token: str) -> None: web, client, auth = await self._prepare_transport( bot_token, app_token, ) + team_id = auth.get("team_id", "") + if self._team_id and team_id and team_id != self._team_id: + await self._close_socket_quietly(client) + raise RuntimeError( + "the new Slack credentials belong to a different workspace; " + "restart required", + ) old_client = self._client old_web = self._web @@ -737,6 +751,7 @@ async def reload_credentials(self, bot_token: str, app_token: str) -> None: old_app_token = self._active_app_token old_bot_user_id = self._bot_user_id old_bot_id = self._bot_id + old_team_id = self._team_id try: await self._close_socket_for_replacement(old_client) @@ -749,10 +764,8 @@ async def reload_credentials(self, bot_token: str, app_token: str) -> None: self._activate_transport( web, client, auth, bot_token, app_token, ) - # Identity and message ids are workspace-local. Token rotation is - # normally within one app, but clear before connecting so a move - # to another workspace cannot race an event through stale auth - # context. A failed rotation leaves these disposable caches empty. + # Names, dedupe keys, and message ids belong to the credential + # generation. A failed rotation leaves these disposable caches empty. self._seen_events.clear() self._message_cache.clear() self._last_inbound_ts.clear() @@ -766,6 +779,7 @@ async def reload_credentials(self, bot_token: str, app_token: str) -> None: self._active_app_token = old_app_token self._bot_user_id = old_bot_user_id self._bot_id = old_bot_id + self._team_id = old_team_id self._client = old_client if old_web is not None and old_app_token: @@ -819,7 +833,8 @@ def _announce_auth_state(self) -> None: return logger.info("Slack access policy: %s", policy.describe()) - async def stop(self) -> None: + async def stop(self, *, drain: bool = False) -> None: + """Stop receiving, optionally draining acknowledged dispatches first.""" self._stopping = True if self._watchdog_task and not self._watchdog_task.done(): self._watchdog_task.cancel() @@ -827,15 +842,41 @@ async def stop(self) -> None: await self._watchdog_task except asyncio.CancelledError: pass - # Cancel and then wait: closing the socket out from under a dispatch - # still touching the router or the web client races teardown. + self._watchdog_task = None + + if drain: + async with self._transport_lock: + await self._close_socket_quietly(self._client) + inflight = list(self._inflight) - for task in inflight: - task.cancel() - if inflight: + if drain and inflight: + _, pending = await asyncio.wait( + inflight, timeout=_STOP_DRAIN_TIMEOUT, + ) + if pending: + logger.warning( + "Slack: cancelling %d dispatch(es) after drain timeout", + len(pending), + ) + for task in pending: + task.cancel() await asyncio.gather(*inflight, return_exceptions=True) - async with self._transport_lock: - await self._close_socket_quietly(self._client) + elif not drain: + # Normal process shutdown stays prompt and closes the socket only + # after dispatches stop touching the router and Web client. + for task in inflight: + task.cancel() + if inflight: + await asyncio.gather(*inflight, return_exceptions=True) + async with self._transport_lock: + await self._close_socket_quietly(self._client) + + self._web = None + self._active_bot_token = "" + self._active_app_token = "" + self._bot_user_id = "" + self._bot_id = "" + self._team_id = "" # ------------------------------------------------------------------ # # Watchdog # @@ -932,6 +973,9 @@ async def _on_request(self, client: Any, req: Any) -> None: except Exception as e: logger.warning("Slack ack failed for %s: %s", req.envelope_id, e) + if self._stopping: + return + # The envelope is acked, so Slack will not resend it. Dropping past # the cap is therefore a real loss, but a bounded one: without it a # burst — including one made entirely of messages the policy will diff --git a/nerve/config_reload.py b/nerve/config_reload.py index 7e95dbb8..b34d1158 100644 --- a/nerve/config_reload.py +++ b/nerve/config_reload.py @@ -58,7 +58,6 @@ "mcp_endpoint.path", "memory", "proxy", - "slack.enabled", "sync.codex", "telegram.allowed_users", "telegram.bot_token", @@ -246,18 +245,24 @@ def hand_over(label: str, target) -> None: return problems -async def _reload_slack_credentials(old_config, new_config, engine) -> str | None: - """Rotate a running Slack transport when its desired tokens changed. +def _redact_slack_error( + error: Exception, bot_token: str, app_token: str, +) -> str: + """Describe a Slack lifecycle error without exposing either token.""" + detail = str(error) or type(error).__name__ + for secret in (bot_token, app_token): + if secret: + detail = detail.replace(secret, "") + return detail - The channel compares against the credentials that actually built its - clients, not merely old versus new config. That distinction makes a failed - rotation retryable on the next reload even though the process-wide config - already contains the new values. - """ - if engine is None or not new_config.slack.enabled: + +async def _reconcile_slack(new_config, engine) -> str | None: + """Make the registered Slack channel match its enabled state and tokens.""" + if engine is None: return None from nerve.channels.slack import SlackChannel + from nerve.config import get_config try: channel = engine.router.get_channel("slack") @@ -266,33 +271,55 @@ async def _reload_slack_credentials(old_config, new_config, engine) -> str | Non "Could not locate the running Slack channel (%s)", type(e).__name__, ) return f"{_ERROR_PREFIX}could not locate the running Slack channel" - if not isinstance(channel, SlackChannel): - # Turning Slack on still needs a restart: no channel object exists for - # the reload path to start or for gateway shutdown to own. If it was - # already meant to be on, its startup failed and a clean reload must - # not pretend that it recovered anything. - if old_config is not None and old_config.slack.enabled: - return ( - f"{_ERROR_PREFIX}Slack is enabled but its channel is not running; " - "restart required" - ) - return None - bot_token = new_config.slack.bot_token app_token = new_config.slack.app_token - if not channel.needs_credential_reload(bot_token, app_token): - return None + + if not new_config.slack.enabled: + if not isinstance(channel, SlackChannel): + return None + try: + engine.router.unregister(channel) + await channel.stop(drain=True) + except Exception as e: # noqa: BLE001 — report and keep reloading + detail = _redact_slack_error(e, bot_token, app_token) + logger.warning("Slack disable failed: %s", detail) + return f"{_ERROR_PREFIX}{detail}" + return "disabled" + + if channel is not None and not isinstance(channel, SlackChannel): + return f"{_ERROR_PREFIX}the registered Slack channel has an unexpected type" + + if channel is None: + if not bot_token or not app_token: + return f"{_ERROR_PREFIX}Slack needs both bot_token and app_token" + + candidate = None + try: + candidate = SlackChannel(get_config, engine.router) + candidate.set_notification_service( + getattr(engine, "notification_service", None), + ) + await candidate.start() + engine.router.register(candidate) + except Exception as e: # noqa: BLE001 — leave Slack absent and retryable + if candidate is not None: + try: + await candidate.stop() + except Exception: + logger.debug( + "Slack cleanup after failed enable raised", exc_info=True, + ) + detail = _redact_slack_error(e, bot_token, app_token) + logger.warning("Slack enable failed: %s", detail) + return f"{_ERROR_PREFIX}{detail}" + return "enabled" try: + if not channel.needs_credential_reload(bot_token, app_token): + return None await channel.reload_credentials(bot_token, app_token) except Exception as e: # noqa: BLE001 — report the subsystem, continue reload - detail = str(e) or type(e).__name__ - # The channel raises credential-free messages, but keep this boundary - # safe for SDK errors and test doubles too: the summary is returned over - # HTTP and logged by callers. - for secret in (bot_token, app_token): - if secret: - detail = detail.replace(secret, "") + detail = _redact_slack_error(e, bot_token, app_token) logger.warning("Slack credential reload failed: %s", detail) return f"{_ERROR_PREFIX}{detail}" @@ -342,7 +369,7 @@ async def reload_all(engine, cron_service, config_dir: Path) -> dict: # running the new config in some places and the old one in others, # which is the state worth shouting about. summary["services"] = f"{_ERROR_PREFIX}{'; '.join(stale)}" - slack = await _reload_slack_credentials(old_config, new_config, engine) + slack = await _reconcile_slack(new_config, engine) if slack is not None: summary["slack"] = slack diff --git a/nerve/gateway/server.py b/nerve/gateway/server.py index 21c09e78..647e4853 100644 --- a/nerve/gateway/server.py +++ b/nerve/gateway/server.py @@ -709,6 +709,8 @@ async def _periodic_notify_maintenance(): # the polling and socket tasks before we get a chance to stop them cleanly. if telegram_channel: await telegram_channel.stop() + # Slack may have been enabled or disabled since startup. + slack_channel = _engine.router.get_channel("slack") if slack_channel: await slack_channel.stop() if ws_sync_task: diff --git a/tests/fake_slack.py b/tests/fake_slack.py index c247c893..ee71f685 100644 --- a/tests/fake_slack.py +++ b/tests/fake_slack.py @@ -175,7 +175,8 @@ def _api_auth_test(self, body: dict) -> dict: "ok": True, "user_id": self.bot_user_id, "user": "nerve", - "team": "T0FAKE", + "team": "fake", + "team_id": "T0FAKE", } def _api_apps_connections_open(self, body: dict) -> dict: diff --git a/tests/test_config_reload.py b/tests/test_config_reload.py index b8732ff8..43791088 100644 --- a/tests/test_config_reload.py +++ b/tests/test_config_reload.py @@ -767,7 +767,7 @@ async def test_a_rotated_secret_is_reported_without_its_value( assert "new-secret" not in summary["restart_required"] -class TestSlackCredentialReload: +class TestSlackLifecycleReload: @staticmethod def _body(bot_token, app_token, allow_user=None): allow = f" allow_users: [{allow_user}]\n" if allow_user else "" @@ -798,11 +798,27 @@ def _running_channel( return channel @staticmethod - def _engine(channel): + def _engine(channel, notification_service=None): + state = {"channel": channel} router = MagicMock() - router.get_channel.return_value = channel + router.get_channel.side_effect = lambda name: ( + state["channel"] if name == "slack" else None + ) + + def register(candidate): + state["channel"] = candidate + + def unregister(candidate): + if state["channel"] is not candidate: + return False + state["channel"] = None + return True + + router.register.side_effect = register + router.unregister.side_effect = unregister return SimpleNamespace( router=router, + notification_service=notification_service, reload_mcp_config=AsyncMock(return_value=[]), _skill_manager=None, ) @@ -836,25 +852,131 @@ async def rotate(bot_token, app_token): assert reload_failures(summary) == {} @pytest.mark.asyncio - async def test_enabling_the_channel_still_requires_a_restart( + async def test_enabling_starts_wires_and_registers_the_channel( self, tmp_path, monkeypatch, ): import nerve.config as cfgmod + from nerve.channels.slack import SlackChannel config_dir, ws = tmp_path / "cfg", tmp_path / "ws" ws.mkdir() _write_config(config_dir, ws) monkeypatch.setattr(cfgmod, "_config", cfgmod.load_config(config_dir)) + started = [] + + async def start(channel): + started.append(channel) + + monkeypatch.setattr(SlackChannel, "start", start) + notifications = object() + engine = self._engine(None, notifications) + _write_config( config_dir, ws, self._body("xoxb-new", "xapp-new"), ) - summary = await reload_all(self._engine(None), None, config_dir) + summary = await reload_all(engine, None, config_dir) - assert "slack.enabled" in summary["restart_required"] - assert "slack" not in summary + channel = engine.router.get_channel("slack") + assert started == [channel] + assert channel._notification_service is notifications + engine.router.register.assert_called_once_with(channel) + assert summary["slack"] == "enabled" + assert "restart_required" not in summary + assert reload_failures(summary) == {} + + @pytest.mark.asyncio + async def test_disabling_unregisters_and_drains_the_channel( + self, tmp_path, monkeypatch, + ): + config_dir, ws = tmp_path / "cfg", tmp_path / "ws" + ws.mkdir() + channel = self._running_channel(config_dir, ws, monkeypatch) + channel.stop = AsyncMock() + engine = self._engine(channel) + + _write_config(config_dir, ws, "slack:\n enabled: false\n") + summary = await reload_all(engine, None, config_dir) + + engine.router.unregister.assert_called_once_with(channel) + channel.stop.assert_awaited_once_with(drain=True) + assert engine.router.get_channel("slack") is None + assert summary["slack"] == "disabled" + assert "restart_required" not in summary assert reload_failures(summary) == {} + @pytest.mark.asyncio + async def test_a_disable_cleanup_failure_is_reported_after_unregister( + self, tmp_path, monkeypatch, + ): + config_dir, ws = tmp_path / "cfg", tmp_path / "ws" + ws.mkdir() + channel = self._running_channel(config_dir, ws, monkeypatch) + channel.stop = AsyncMock(side_effect=RuntimeError("socket stuck")) + engine = self._engine(channel) + + _write_config(config_dir, ws, "slack:\n enabled: false\n") + summary = await reload_all(engine, None, config_dir) + + assert engine.router.get_channel("slack") is None + assert reload_failures(summary)["slack"] == "socket stuck" + + @pytest.mark.asyncio + async def test_a_failed_enable_stays_absent_and_retries( + self, tmp_path, monkeypatch, + ): + import nerve.config as cfgmod + from nerve.channels.slack import SlackChannel + + config_dir, ws = tmp_path / "cfg", tmp_path / "ws" + ws.mkdir() + _write_config(config_dir, ws) + monkeypatch.setattr(cfgmod, "_config", cfgmod.load_config(config_dir)) + + attempts = 0 + + async def start(channel): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise RuntimeError("bad xoxb-new") + + monkeypatch.setattr(SlackChannel, "start", start) + engine = self._engine(None) + _write_config( + config_dir, ws, self._body("xoxb-new", "xapp-new"), + ) + + first = await reload_all(engine, None, config_dir) + second = await reload_all(engine, None, config_dir) + + assert reload_failures(first)["slack"] == "bad " + assert second["slack"] == "enabled" + assert engine.router.get_channel("slack") is not None + assert attempts == 2 + + @pytest.mark.asyncio + async def test_enabling_without_both_tokens_stays_absent( + self, tmp_path, monkeypatch, + ): + import nerve.config as cfgmod + + config_dir, ws = tmp_path / "cfg", tmp_path / "ws" + ws.mkdir() + _write_config(config_dir, ws) + monkeypatch.setattr(cfgmod, "_config", cfgmod.load_config(config_dir)) + engine = self._engine(None) + + _write_config( + config_dir, ws, + "slack:\n enabled: true\n bot_token: xoxb-only\n", + ) + summary = await reload_all(engine, None, config_dir) + + assert engine.router.get_channel("slack") is None + assert "both bot_token and app_token" in reload_failures(summary)["slack"] + engine.router.register.assert_not_called() + @pytest.mark.asyncio async def test_a_guardrail_only_reload_does_not_reconnect( self, tmp_path, monkeypatch, diff --git a/tests/test_send_file.py b/tests/test_send_file.py index 688d47a0..4af990fd 100644 --- a/tests/test_send_file.py +++ b/tests/test_send_file.py @@ -71,6 +71,19 @@ async def send_file(self, target: str, file_path: str) -> bool: # type: ignore[ # --------------------------------------------------------------------------- +class TestRouterRegistry: + def test_unregister_only_removes_the_registered_instance(self): + router = ChannelRouter(MagicMock()) + current = _StubChannel(name="slack") + stale = _StubChannel(name="slack") + router.register(current) + + assert not router.unregister(stale) + assert router.get_channel("slack") is current + assert router.unregister(current) + assert router.get_channel("slack") is None + + @pytest.mark.asyncio class TestRouterSendFile: async def test_no_channel_arg_returns_false_no_context(self): diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index a35dcdf6..f0988709 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -623,6 +623,7 @@ async def close_old(): channel._active_app_token = "xapp-old" channel._bot_user_id = "U0OLD" channel._bot_id = "B0OLD" + channel._team_id = "T0OLD" candidate_web = MagicMock() candidate = MagicMock() @@ -639,7 +640,7 @@ async def close_candidate(): channel._prepare_transport = AsyncMock(return_value=( candidate_web, candidate, - {"user_id": "U0NEW", "bot_id": "B0NEW"}, + {"user_id": "U0NEW", "bot_id": "B0NEW", "team_id": "T0OLD"}, )) rollback = MagicMock() @@ -665,8 +666,34 @@ async def connect_rollback(): assert channel._active_app_token == "xapp-old" assert channel._bot_user_id == "U0OLD" assert channel._bot_id == "B0OLD" + assert channel._team_id == "T0OLD" assert channel.needs_credential_reload("xoxb-new", "xapp-new") + @pytest.mark.asyncio + async def test_credentials_for_another_workspace_need_a_restart(self): + channel = _channel() + old_web = channel._web + old_client = MagicMock() + old_client.close = AsyncMock() + channel._client = old_client + channel._active_bot_token = "xoxb-old" + channel._active_app_token = "xapp-old" + channel._team_id = "T0OLD" + + candidate = MagicMock() + candidate.close = AsyncMock() + channel._prepare_transport = AsyncMock(return_value=( + MagicMock(), candidate, {"team_id": "T0NEW"}, + )) + + with pytest.raises(RuntimeError, match="different workspace"): + await channel.reload_credentials("xoxb-new", "xapp-new") + + candidate.close.assert_awaited_once() + old_client.close.assert_not_awaited() + assert channel._web is old_web + assert channel._team_id == "T0OLD" + @pytest.mark.asyncio async def test_a_socket_handshake_cannot_hold_reload_open_forever( self, monkeypatch, @@ -800,6 +827,20 @@ async def test_a_missing_placeholder_still_delivers_the_reply(self): class TestDispatchBounds: + @pytest.mark.asyncio + async def test_stopping_acks_without_starting_more_work(self): + channel = _channel() + channel._stopping = True + channel._dispatch = AsyncMock() + client = MagicMock() + client.send_socket_mode_response = AsyncMock() + req = MagicMock(envelope_id="e1", type="events_api", payload={}) + + await channel._on_request(client, req) + + client.send_socket_mode_response.assert_awaited_once() + channel._dispatch.assert_not_awaited() + @pytest.mark.asyncio async def test_envelopes_past_the_cap_are_dropped(self): import nerve.channels.slack as slack_module @@ -838,6 +879,40 @@ async def _slow(): await channel.stop() assert task.done() + @pytest.mark.asyncio + async def test_live_disable_can_drain_inflight_dispatches(self): + channel = _channel() + channel._client = MagicMock() + channel._client.close = AsyncMock() + finished = asyncio.Event() + + async def _finish(): + await asyncio.sleep(0) + finished.set() + + task = asyncio.create_task(_finish()) + channel._inflight.add(task) + await channel.stop(drain=True) + + assert finished.is_set() + assert not task.cancelled() + assert channel._web is None + + @pytest.mark.asyncio + async def test_live_disable_bounds_the_drain(self, monkeypatch): + import nerve.channels.slack as slack_module + + monkeypatch.setattr(slack_module, "_STOP_DRAIN_TIMEOUT", 0) + channel = _channel() + channel._client = MagicMock() + channel._client.close = AsyncMock() + task = asyncio.create_task(asyncio.Event().wait()) + channel._inflight.add(task) + + await channel.stop(drain=True) + + assert task.cancelled() + class TestNotificationBlockLimits: def test_options_are_chunked_to_slacks_actions_limit(self): From 1523005d679acc43794c805c23d98aa680814d05 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 25 Aug 2026 11:43:11 +0200 Subject: [PATCH 11/18] Handle stub routers during Slack lifecycle reload --- nerve/config_reload.py | 3 +++ nerve/gateway/server.py | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/nerve/config_reload.py b/nerve/config_reload.py index b34d1158..e2490eb9 100644 --- a/nerve/config_reload.py +++ b/nerve/config_reload.py @@ -11,6 +11,7 @@ from __future__ import annotations import dataclasses +import inspect import logging from pathlib import Path @@ -266,6 +267,8 @@ async def _reconcile_slack(new_config, engine) -> str | None: try: channel = engine.router.get_channel("slack") + if inspect.isawaitable(channel): + channel = await channel except Exception as e: # noqa: BLE001 — keep the unified reload best-effort logger.warning( "Could not locate the running Slack channel (%s)", type(e).__name__, diff --git a/nerve/gateway/server.py b/nerve/gateway/server.py index 647e4853..ed3bfd8f 100644 --- a/nerve/gateway/server.py +++ b/nerve/gateway/server.py @@ -710,8 +710,10 @@ async def _periodic_notify_maintenance(): if telegram_channel: await telegram_channel.stop() # Slack may have been enabled or disabled since startup. + from nerve.channels.slack import SlackChannel + slack_channel = _engine.router.get_channel("slack") - if slack_channel: + if isinstance(slack_channel, SlackChannel): await slack_channel.stop() if ws_sync_task: # Exit through the loop's own stop path rather than cancelling it where From 4cab25de8199b28e2c4a05a59e1b53424a448666 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 25 Aug 2026 12:49:34 +0200 Subject: [PATCH 12/18] Make Slack runtime and thread sessions coherent --- config.example.yaml | 10 +- docs/config.md | 21 +- nerve/channels/slack.py | 715 +++++++++------------------ nerve/channels/slack_presentation.py | 335 +++++++++++++ nerve/channels/slack_runtime.py | 212 ++++++++ nerve/config.py | 15 +- nerve/config_reload.py | 81 +-- nerve/gateway/server.py | 51 +- tests/test_channel_archives.py | 2 +- tests/test_config_reload.py | 46 +- tests/test_config_resolution.py | 7 +- tests/test_db.py | 18 +- tests/test_slack_channel.py | 509 ++++++++++++------- tests/test_slack_integration.py | 17 +- tests/test_slack_runtime.py | 325 ++++++++++++ 15 files changed, 1519 insertions(+), 845 deletions(-) create mode 100644 nerve/channels/slack_presentation.py create mode 100644 nerve/channels/slack_runtime.py create mode 100644 tests/test_slack_runtime.py diff --git a/config.example.yaml b/config.example.yaml index e0a0acee..6bd7ff31 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -127,15 +127,13 @@ slack: # allow_channels: ["eng-*", "C0456DEF"] # deny_channels: ["*-random", "*-social"] # - # Each thread becomes its own session and replies stay in-thread. Off means - # one session per channel and replies at channel level. - reply_in_thread: true + # Every shared-channel thread is its own session; DMs use one conversation. stream_mode: partial # "partial" (edit messages) or "full" (wait for complete) - # `/nerve` defaults to new, stop, star, and unstar. - # omitted — new, stop, star, unstar + # `/nerve` defaults to new, stop, star, unstar, and reply. + # omitted — new, stop, star, unstar, reply # [] — no slash commands at all # [all] — everything, including doctor and restart - # doctor/restart affect the host; sessions/reply reach other channels. Opt in. + # doctor/restart affect the host; sessions lists other channels. Opt in. # commands: [sessions, new, stop, reply] # Quiet hours (local timezone) diff --git a/docs/config.md b/docs/config.md index b85a6f57..bd618f09 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1084,7 +1084,6 @@ users are ignored. | `slack.allow_direct_messages` | bool | `false` | Allow DMs; sender rules still apply | | `slack.allow_channels` | list[str] | `[]` | Allowed shared conversations | | `slack.deny_channels` | list[str] | `[]` | Blocked shared conversations | -| `slack.reply_in_thread` | bool | `true` | One session per thread; replies stay in-thread | | `slack.stream_mode` | string | `partial` | `partial` (edit one message) or `full` | | `slack.commands` | list[str] | see below | Enabled `/nerve` subcommands | @@ -1193,8 +1192,8 @@ slack: - In an allowed DM, the bot answers every message. - In a shared channel, it answers mentions and threads where it already has a session. -- With `reply_in_thread: true`, each thread has its own session and replies - stay there. +- Each shared-channel thread has its own session and all replies stay there; + shared channels never have a channel-wide session. - `/nerve` responses are ephemeral. ### Commands @@ -1210,17 +1209,15 @@ slack: commands: [all] # enable every subcommand ``` -Omitting the key enables `new`, `stop`, `star`, and `unstar`. `doctor`, -`restart`, `sessions`, and `reply` are opt-in: the first two expose host -operations, while the latter two can reach sessions outside Slack and are not -scoped to the caller. Enable them only when every permitted user is trusted -with the whole instance. Unknown command names are ignored with a warning; +Omitting the key enables `new`, `stop`, `star`, `unstar`, and `reply`. +`doctor`, `restart`, and `sessions` are opt-in: the first two expose host +operations, while `sessions` can reach sessions outside Slack and is not +scoped to the caller. Unknown command names are ignored with a warning; `/nerve help` shows the enabled set. -Slack slash-command payloads have no thread ID. In a threaded shared channel, -`new` and `sessions` therefore refuse, while `stop`, `star`, and `unstar` -select among that channel's active sessions. Commands work normally in DMs -and channels with `reply_in_thread: false`. +Slack slash-command payloads have no thread ID. In a shared channel, `new` and +`sessions` therefore refuse, while `stop`, `star`, and `unstar` select among +that channel's active thread sessions. Commands work normally in DMs. ## Quiet Hours diff --git a/nerve/channels/slack.py b/nerve/channels/slack.py index 9dfd1cab..0cccd8b2 100644 --- a/nerve/channels/slack.py +++ b/nerve/channels/slack.py @@ -2,7 +2,7 @@ Inbound messages are authorized before routing. Targets encode a channel and optional thread as ``C0456DEF[:timestamp]``; channel keys add the ``slack:`` -prefix, allowing per-thread sessions when configured. +prefix. Shared channels always use thread-scoped sessions. """ from __future__ import annotations @@ -14,7 +14,7 @@ import re import time from pathlib import Path -from typing import Any, Callable, TYPE_CHECKING +from typing import Any, TYPE_CHECKING from nerve.channels.access import Identity, needs_name_resolution from nerve.channels.archives import ( @@ -31,6 +31,16 @@ OutboundMessage, ) from nerve.channels.slack_access import SlackAccessPolicy +from nerve.channels.slack_presentation import ( + MAX_MSG_LEN, + _MAX_ACTION_ELEMENTS, + _SESSIONS_BUTTON_LIMIT, + _md_to_slack, + build_sessions_blocks, + slack_emoji_name, + slack_to_plain, + split_message, +) from nerve.config import ( SLACK_ALL_COMMANDS, SLACK_DEFAULT_COMMANDS, @@ -42,9 +52,6 @@ logger = logging.getLogger(__name__) -# chat.postMessage accepts 40k chars but renders only the first ~4k as a -# single block, so split well below that and let each chunk stand alone. -MAX_MSG_LEN = 3900 # chat.update is limited to roughly one call per second per channel. EDIT_INTERVAL = 1.2 # Watchdog: check every 30s, log heartbeat every ~5 min. @@ -65,8 +72,6 @@ # Concurrent dispatch tasks. The router serialises per session, so this only # bounds envelopes not yet routed — including ones headed for a refusal. _MAX_INFLIGHT = 100 -# Slack renders at most 25 elements in one actions block. -_MAX_ACTION_ELEMENTS = 25 # Star picker action ids: ``starpick:<1|0>:``. Distinct from the # session card's ``sessstar:`` toggle, which flips whatever the row holds — # a picker has to set the state the command asked for. @@ -97,26 +102,6 @@ _TEXT_EXTENSIONS = TEXT_EXTENSIONS _IMAGE_EXT_TO_MIME = IMAGE_EXT_TO_MIME -# Unicode emoji → Slack short name. The agent's set_reaction tool speaks the -# Telegram reaction vocabulary; reactions.add only accepts short names. An -# emoji outside this table is skipped rather than guessed at, so a reaction -# never silently lands as the wrong one. -_EMOJI_TO_SLACK: dict[str, str] = { - "👍": "thumbsup", "👎": "thumbsdown", "❤": "heart", "❤️": "heart", - "🔥": "fire", "🥰": "smiling_face_with_3_hearts", "👏": "clap", - "😁": "grin", "🤔": "thinking_face", "🤯": "exploding_head", - "😱": "scream", "😢": "cry", "🎉": "tada", "🤩": "star-struck", - "🙏": "pray", "👌": "ok_hand", "🥱": "yawning_face", "😍": "heart_eyes", - "🌚": "new_moon_with_face", "💯": "100", "🤣": "rolling_on_the_floor_laughing", - "⚡": "zap", "🏆": "trophy", "💔": "broken_heart", "🤨": "face_with_raised_eyebrow", - "😐": "neutral_face", "🍾": "champagne", "👀": "eyes", "🙈": "see_no_evil", - "😇": "innocent", "🤝": "handshake", "🤗": "hugging_face", "🫡": "saluting_face", - "🆒": "cool", "😎": "sunglasses", "✅": "white_check_mark", "❌": "x", - "⏳": "hourglass_flowing_sand", "🚀": "rocket", "✍": "writing_hand", - "🤡": "clown_face", "💩": "hankey", "😴": "sleeping", "👻": "ghost", -} - - # ---------------------------------------------------------------------- # # Pure helpers — module level so they are testable without a transport # # ---------------------------------------------------------------------- # @@ -134,15 +119,6 @@ def is_slack_id(pattern: str) -> bool: return bool(_SLACK_ID_RE.match(pattern)) -# A bare & — one that is not already the start of an escape Slack recognises. -_BARE_AMPERSAND_RE = re.compile(r"&(?!(?:amp|lt|gt);)") - - -def _escape_ampersands(text: str) -> str: - """Escape ``&`` without double-escaping one that is already an entity.""" - return _BARE_AMPERSAND_RE.sub("&", text) - - def format_target(channel_id: str, thread_ts: str | None = None) -> str: """Pack a conversation address into one opaque target string.""" return f"{channel_id}:{thread_ts}" if thread_ts else channel_id @@ -158,288 +134,6 @@ def parse_target(target: str) -> tuple[str, str | None]: return channel_id, (thread_ts if sep and thread_ts else None) -def _md_to_slack(text: str) -> str: - """Convert standard Markdown to Slack mrkdwn. - - Slack uses ``*`` for bold, ``_`` for italic, and ```` for links. - Protect code spans and fences before substitution so they stay literal. - """ - protected: list[str] = [] - - def _protect(replacement: str) -> str: - idx = len(protected) - protected.append(replacement) - return f"\x00{idx}\x00" - - def _fence(m: re.Match) -> str: - # Slack has no language tag — it would render as the first line of - # the block — and needs the newline after the opening fence kept, - # or the whole block collapses onto one line. - return _protect("```\n" + m.group(2).strip("\n") + "\n```") - text = re.sub(r"```(\w*)\n?(.*?)```", _fence, text, flags=re.DOTALL) - - def _code(m: re.Match) -> str: - return _protect(f"`{m.group(1)}`") - text = re.sub(r"`([^`]+)`", _code, text) - - def _link(m: re.Match) -> str: - # Slack escapes & inside a link too, and rewrites the message if we - # do not. Doing it here keeps what we send byte-identical to what - # Slack stores, so a later edit does not fight the normalisation. - label = _escape_ampersands(m.group(1)) - url = _escape_ampersands(m.group(2)) - return _protect(f"<{url}|{label}>") - text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", _link, text) - - # Slack requires these three escaped in message text; everything else is - # literal. Do it before adding markup so the markup itself survives. - text = _escape_ampersands(text).replace("<", "<").replace(">", ">") - - # Headings have no equivalent — render the text as a bold line. Bold is - # staged behind \x01 until the italic pass has run, so a bold marker is - # never re-read as a pair of italic ones. - text = re.sub( - r"^\s{0,3}#{1,6}\s+(.+?)\s*$", - lambda m: f"\x01{m.group(1)}\x01", - text, - flags=re.MULTILINE, - ) - text = re.sub( - r"\*\*(.+?)\*\*", lambda m: f"\x01{m.group(1)}\x01", text, flags=re.DOTALL, - ) - text = re.sub(r"(? str: - """Turn Slack's wire format into something worth putting in a prompt. - - Unwraps ```` and ``<@U123>`` markup, drops the bot's own - mention (the agent does not need to be told it was addressed), and - unescapes the three reserved entities. - """ - if bot_user_id: - text = re.sub(rf"<@{re.escape(bot_user_id)}(\|[^>]*)?>", "", text) - # Entity forms first — each is a <…|…> too, so the generic link rule - # would otherwise claim them and render "#general" as "general (#C1)". - text = re.sub(r"<#C[A-Z0-9]+\|([^>]+)>", r"#\1", text) - text = re.sub(r"<#(C[A-Z0-9]+)>", r"#\1", text) - text = re.sub(r"<@([UW][A-Z0-9]+)\|([^>]+)>", r"@\2", text) - text = re.sub(r"<@([UW][A-Z0-9]+)>", r"@\1", text) - text = re.sub(r"]+)>", r"@\1", text) - text = re.sub(r"]*)?>", r"@\1", text) - text = re.sub(r"<([^|>]+)\|([^>]+)>", r"\2 (\1)", text) - text = re.sub(r"<((?:https?|mailto):[^>]+)>", r"\1", text) - text = text.replace("<", "<").replace(">", ">").replace("&", "&") - return text.strip() - - -def split_message(text: str, limit: int = MAX_MSG_LEN) -> list[str]: - """Split *text* into chunks under *limit*, preferring line boundaries. - - A hard slice mid-line breaks code fences and lists across messages, so - lines are packed greedily and only a single over-long line is cut. - """ - if len(text) <= limit: - return [text] if text else [] - - chunks: list[str] = [] - current = "" - for line in text.split("\n"): - while len(line) > limit: - if current: - chunks.append(current) - current = "" - chunks.append(line[:limit]) - line = line[limit:] - if not current: - current = line - elif len(current) + 1 + len(line) <= limit: - current = f"{current}\n{line}" - else: - chunks.append(current) - current = line - if current: - chunks.append(current) - return chunks - - -def slack_emoji_name(emoji: str) -> str | None: - """Map a unicode emoji (or an already-short name) to a Slack short name.""" - cleaned = emoji.strip().strip(":") - if cleaned and all(c.isalnum() or c in "-_+" for c in cleaned): - return cleaned - return _EMOJI_TO_SLACK.get(emoji.strip()) or _EMOJI_TO_SLACK.get( - emoji.strip().rstrip("️"), - ) - - -# Block Kit rendering ---------------------------------------------------- # - -_SESSIONS_BUTTON_LIMIT = 8 -_SESSION_LABEL_MAX = 70 # Slack button text is capped at 75 chars - - -def _session_label(session: dict, current_id: str | None) -> str: - """Button label for one session: current marked ✓, starred marked ⭐.""" - title = (session.get("title") or "").strip() or session.get("id", "?") - prefix = "✓ " if session.get("id") == current_id else "" - if session.get("starred"): - prefix += "⭐ " - label = f"{prefix}{title}" - if len(label) > _SESSION_LABEL_MAX: - label = label[: _SESSION_LABEL_MAX - 1] + "…" - return label - - -def build_sessions_blocks( - sessions: list[dict], current_id: str | None, -) -> list[dict[str, Any]]: - """Render the ``/nerve sessions`` Block Kit view (pure, sync — testable). - - One tap-to-switch button per session with the id carried in ``value``, - a ⭐ toggle beside it, and a trailing "New session" button. Switching - away leaves the previous session running; its output still reaches the - conversation it was bound to. - """ - blocks: list[dict[str, Any]] = [] - shown = sessions[:_SESSIONS_BUTTON_LIMIT] - - if not shown: - blocks.append({ - "type": "section", - "text": {"type": "mrkdwn", "text": "No sessions yet — start one below."}, - }) - else: - current_title = next( - ( - (s.get("title") or s.get("id")) - for s in shown - if s.get("id") == current_id - ), - None, - ) - header = "*Sessions* — tap to switch." - if current_title: - header += f"\nCurrent: {current_title}" - header += "\n⭐ keeps a session alive (never auto-closed)." - blocks.append({ - "type": "section", "text": {"type": "mrkdwn", "text": header}, - }) - for s in shown: - sid = s.get("id") - if not sid: - continue - blocks.append({ - "type": "actions", - "block_id": f"sess_row:{sid}", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": _session_label(s, current_id), - "emoji": True, - }, - "action_id": f"sess:{sid}", - "value": sid, - }, - { - "type": "button", - "text": { - "type": "plain_text", - "text": "⭐" if s.get("starred") else "☆", - "emoji": True, - }, - "action_id": f"sessstar:{sid}", - "value": sid, - }, - ], - }) - - blocks.append({ - "type": "actions", - "block_id": "sess_new", - "elements": [{ - "type": "button", - "text": {"type": "plain_text", "text": "➕ New session", "emoji": True}, - "action_id": "sess:new", - "value": "new", - "style": "primary", - }], - }) - return blocks - - -# One section block holds 3000 chars, and one message holds 50 blocks. The -# section budget leaves room for the option rows below them. -_MAX_SECTION_LEN = 3000 -_MAX_SECTION_BLOCKS = 45 - -# Slack renders a styled button in green or red. Keys are the canonical -# approval ``value`` strings that NotificationService sends. -_APPROVAL_STYLES: dict[str, str] = { - "approve": "primary", "yes": "primary", "allow": "primary", - "decline": "danger", "deny": "danger", "no": "danger", "reject": "danger", -} - - -def build_notification_blocks( - text: str, - notification_id: str, - options: list[tuple[str, str]] | None = None, -) -> list[dict[str, Any]]: - """Render notification text and option buttons as Block Kit. - - Text is split at Slack's section limit and truncated only at the full - message's block limit. IDs and values use their dedicated button fields. - """ - chunks = split_message(_md_to_slack(text), _MAX_SECTION_LEN) - if len(chunks) > _MAX_SECTION_BLOCKS: - dropped = sum(len(c) for c in chunks[_MAX_SECTION_BLOCKS - 1:]) - chunks = chunks[: _MAX_SECTION_BLOCKS - 1] - chunks.append( - f"_… {dropped} more characters — open the notification in the " - "web UI to read the rest._", - ) - blocks: list[dict[str, Any]] = [ - {"type": "section", "text": {"type": "mrkdwn", "text": chunk}} - for chunk in chunks - ] - elements = [ - { - "type": "button", - "text": {"type": "plain_text", "text": label[:75], "emoji": True}, - "action_id": f"notif:{notification_id}:{value}"[:255], - "value": value[:2000], - **( - {"style": _APPROVAL_STYLES[value.lower()]} - if value.lower() in _APPROVAL_STYLES - else {} - ), - } - for label, value in (options or []) - ] - # Slack rejects the whole message with invalid_blocks past 25 elements in - # one actions block, so a long option list is spread over several rows. - for start in range(0, len(elements), _MAX_ACTION_ELEMENTS): - chunk = elements[start:start + _MAX_ACTION_ELEMENTS] - blocks.append({ - "type": "actions", - "block_id": f"notif:{notification_id}:{start}", - "elements": chunk, - }) - return blocks - - class SlackChannel(BaseChannel): """Slack bot channel over Socket Mode. @@ -448,16 +142,14 @@ class SlackChannel(BaseChannel): """ def __init__( - self, config: Callable[[], NerveConfig], router: ChannelRouter, + self, config: NerveConfig, router: ChannelRouter, ): self._config = config self.router = router self._client: Any = None # AsyncSocketModeClient self._web: Any = None # AsyncWebClient - # The config object is hot, but a connected transport must use one - # coherent credential pair. Keep the pair that actually built the - # clients so a watchdog reconnect cannot combine a newly loaded app - # token with the previous Web API client. + # Credentials from the runtime's active config generation. + # A watchdog reconnect must reuse this coherent pair. self._active_bot_token = "" self._active_app_token = "" self._transport_lock = asyncio.Lock() @@ -465,8 +157,8 @@ def __init__( self._bot_id: str = "" # the app's own bot_id, to spot our own posts self._team_id: str = "" self._notification_service = None # Set after service is created - self._watchdog_task: asyncio.Task | None = None self._stopping = False + self._state = "stopped" self._last_event_time: float = 0.0 # monotonic, set on any inbound envelope # Envelopes are dispatched off the ack path; hold a strong reference so # the loop cannot collect a task mid-flight. @@ -480,8 +172,8 @@ def __init__( collections.OrderedDict() ) # target -> ts of the last inbound message, for the read-receipt ack. - # With reply_in_thread on, every thread is a distinct target, so this - # and the name cache are bounded rather than left to grow per thread. + # Every shared-channel thread is a distinct target, so this and the + # name cache are bounded rather than left to grow per thread. self._last_inbound_ts: collections.OrderedDict[str, str] = ( collections.OrderedDict() ) @@ -496,15 +188,17 @@ def set_notification_service(self, service) -> None: @property def config(self) -> NerveConfig: - """The live config, resolved per read rather than captured. + """The config generation active for this Slack runtime.""" + return self._config - The bot outlives every reload and the guardrail lists decide, on each - event, whether a message reaches the agent. Reading them per use means - a reload that tightens ``deny_users`` takes effect immediately. The - tokens are handed to the transport at connect time; ``reload_all`` - rotates that transport explicitly when either token changes. - """ - return self._config() + def apply_config(self, config: NerveConfig) -> None: + """Atomically advance behavior that needs no transport work.""" + self._config = config + + @property + def is_available(self) -> bool: + """Whether external delivery may use this channel.""" + return self._state == "running" and self._web is not None @property def policy(self) -> SlackAccessPolicy: @@ -561,24 +255,28 @@ async def start(self) -> None: """Connect the Socket Mode client and start dispatching events.""" cfg = self.config.slack if not cfg.bot_token or not cfg.app_token: - logger.warning( - "Slack needs both bot_token (xoxb-…) and app_token (xapp-…) — " - "channel not started", + raise RuntimeError( + "Slack needs both bot_token (xoxb-…) and app_token (xapp-…)", ) - return self._stopping = False + self._state = "starting" async with self._transport_lock: - web, client, auth = await self._prepare_transport( - cfg.bot_token, cfg.app_token, - ) - self._activate_transport( - web, client, auth, cfg.bot_token, cfg.app_token, - ) try: + web, client, auth = await self._prepare_transport( + cfg.bot_token, + cfg.app_token, + ) + self._activate_transport( + web, + client, + auth, + cfg.bot_token, + cfg.app_token, + ) await self._connect_socket(client) except (Exception, asyncio.CancelledError): - await self._close_socket_quietly(client) + await self._close_socket_quietly(self._client) self._client = None self._web = None self._active_bot_token = "" @@ -586,18 +284,16 @@ async def start(self) -> None: self._bot_user_id = "" self._bot_id = "" self._team_id = "" + self._state = "stopped" raise self._last_event_time = time.monotonic() + self._state = "running" self._log_auth(auth, "Slack authenticated") logger.info("Slack Socket Mode connected") self._announce_auth_state() - self._watchdog_task = asyncio.create_task( - self._run_watchdog(), name="slack-socket-watchdog", - ) - @staticmethod def _build_web_client(bot_token: str): """Build the Web API half of one credential pair.""" @@ -714,17 +410,18 @@ async def _close_socket_for_replacement(client) -> None: def needs_credential_reload(self, bot_token: str, app_token: str) -> bool: """Whether the connected clients differ from the desired token pair.""" return ( - bot_token != self._active_bot_token - or app_token != self._active_app_token + bot_token != self._active_bot_token or app_token != self._active_app_token ) - async def reload_credentials(self, bot_token: str, app_token: str) -> None: - """Replace the Web API and Socket Mode clients as one credential pair. + async def reload_credentials(self, config: NerveConfig) -> None: + """Apply a config generation while replacing its credential pair. Validate before closing the old socket, but connect only after closing - it to avoid competing consumers. Restore the old transport on failure; - tracked active credentials keep the reload retryable. + it to avoid competing consumers. The behavior snapshot changes before + the new socket can deliver and rolls back with the previous transport. """ + bot_token = config.slack.bot_token + app_token = config.slack.app_token if not bot_token or not app_token: raise RuntimeError("the new Slack credentials are incomplete") @@ -752,18 +449,27 @@ async def reload_credentials(self, bot_token: str, app_token: str) -> None: old_bot_user_id = self._bot_user_id old_bot_id = self._bot_id old_team_id = self._team_id + old_config = self._config + old_state = self._state + self._state = "rotating" try: await self._close_socket_for_replacement(old_client) except (Exception, asyncio.CancelledError): + self._state = old_state await self._close_socket_quietly(client) raise # Publish before connect so an envelope arriving immediately after # the handshake sees the matching Web client and bot identity. self._activate_transport( - web, client, auth, bot_token, app_token, + web, + client, + auth, + bot_token, + app_token, ) + self._config = config # Names, dedupe keys, and message ids belong to the credential # generation. A failed rotation leaves these disposable caches empty. self._seen_events.clear() @@ -781,6 +487,7 @@ async def reload_credentials(self, bot_token: str, app_token: str) -> None: self._bot_id = old_bot_id self._team_id = old_team_id self._client = old_client + self._config = old_config if old_web is not None and old_app_token: try: @@ -790,6 +497,10 @@ async def reload_credentials(self, bot_token: str, app_token: str) -> None: self._client = rollback await self._connect_socket(rollback) except Exception as rollback_error: + self._state = "stopped" + self._stopping = True + self._web = None + self._client = None logger.error( "Slack credential rollback failed (%s)", type(rollback_error).__name__, @@ -798,6 +509,7 @@ async def reload_credentials(self, bot_token: str, app_token: str) -> None: "the new Slack credentials failed and the previous " "connection could not be restored", ) from connect_error + self._state = old_state if isinstance(connect_error, asyncio.CancelledError): raise raise RuntimeError( @@ -807,12 +519,15 @@ async def reload_credentials(self, bot_token: str, app_token: str) -> None: if isinstance(connect_error, asyncio.CancelledError): raise + self._state = "stopped" + self._stopping = True raise RuntimeError( "the new Slack app token failed to connect and no previous " "connection was available", ) from connect_error self._last_event_time = time.monotonic() + self._state = "running" self._log_auth(auth, "Slack credentials reloaded") def _announce_auth_state(self) -> None: @@ -836,13 +551,7 @@ def _announce_auth_state(self) -> None: async def stop(self, *, drain: bool = False) -> None: """Stop receiving, optionally draining acknowledged dispatches first.""" self._stopping = True - if self._watchdog_task and not self._watchdog_task.done(): - self._watchdog_task.cancel() - try: - await self._watchdog_task - except asyncio.CancelledError: - pass - self._watchdog_task = None + self._state = "quiescing" if drain: async with self._transport_lock: @@ -877,50 +586,27 @@ async def stop(self, *, drain: bool = False) -> None: self._bot_user_id = "" self._bot_id = "" self._team_id = "" + self._client = None + self._state = "stopped" # ------------------------------------------------------------------ # # Watchdog # # ------------------------------------------------------------------ # - async def _run_watchdog(self) -> None: - """Reconnect the socket when Slack's own auto-reconnect gives up.""" - check_count = 0 - while not self._stopping: - try: - await asyncio.sleep(WATCHDOG_INTERVAL) - except asyncio.CancelledError: - break - if self._client is None or self._stopping: - break - - check_count += 1 - # is_connected() is a coroutine: it pings the socket rather than - # reading a flag. - try: - connected = bool(await self._client.is_connected()) - except Exception: - # A credential rotation can close this client between the - # loop's null check and the ping. _rebuild rechecks under the - # lifecycle lock and leaves a replacement alone if it won. - connected = False - if check_count % WATCHDOG_HEARTBEAT_EVERY == 0: - since = time.monotonic() - self._last_event_time - logger.info( - "Slack watchdog: %s (check #%d, last event %.0fs ago)", - "connected" if connected else "disconnected", check_count, since, - ) - if connected: - continue + async def transport_connected(self) -> bool: + """Check whether the active Socket Mode client answers a ping.""" + if self._client is None or self._stopping: + return False + try: + return bool(await self._client.is_connected()) + except Exception: + return False - logger.warning("Slack socket is down — rebuilding") - try: - await self._rebuild() - self._last_event_time = time.monotonic() - logger.info("Slack socket reconnected") - except Exception as e: - logger.error("Slack reconnect failed: %s", e, exc_info=True) + @property + def seconds_since_last_event(self) -> float: + return time.monotonic() - self._last_event_time - async def _rebuild(self) -> None: + async def rebuild_transport(self) -> None: """Replace a disconnected socket after closing the old client. Connecting twice can leave competing Slack consumers, so a brief gap @@ -947,6 +633,7 @@ async def _rebuild(self) -> None: except Exception: await self._close_socket_quietly(client) raise + self._last_event_time = time.monotonic() def _touch(self) -> None: """Record that an envelope arrived from Slack.""" @@ -1091,17 +778,24 @@ async def _identify_conversation( ) except Exception as e: logger.warning( - "Slack conversations.info failed for %s: %s", channel_id, e, + "Slack conversations.info failed for %s: %s", + channel_id, + e, ) identity = Identity(id=channel_id, complete=False) self._remember( - self._name_cache, f"c:{channel_id}", - (identity, time.monotonic() + _NAME_CACHE_TTL), _NAME_CACHE_MAX, + self._name_cache, + f"c:{channel_id}", + (identity, time.monotonic() + _NAME_CACHE_TTL), + _NAME_CACHE_MAX, ) return identity async def _authorize( - self, user_id: str, channel_id: str, channel_type: str, + self, + user_id: str, + channel_id: str, + channel_type: str, ) -> bool: """Run the access policy for one event, logging any refusal.""" policy = self.policy @@ -1192,12 +886,11 @@ async def _handle_message_event(self, event: dict[str, Any]) -> None: channel_type = event.get("channel_type") or ( "im" if channel_id.startswith("D") else "channel" ) - cfg = self.config.slack - thread_ts = event.get("thread_ts") if cfg.reply_in_thread else None - # A first reply in a channel opens a thread on the message itself, so - # the conversation stays out of the channel's main flow. - if cfg.reply_in_thread and not thread_ts and channel_type != "im": - thread_ts = ts + # Shared channels are containers for thread sessions, never sessions + # themselves. A top-level mention becomes its own thread root. + thread_ts = None + if channel_type != "im": + thread_ts = event.get("thread_ts") or ts target = format_target(channel_id, thread_ts) channel_key = f"slack:{target}" @@ -1328,7 +1021,8 @@ async def _extract_files( size = int(f.get("size") or 0) ext = f".{name.rsplit('.', 1)[-1].lower()}" if "." in name else "" size_str = ( - f"{size / 1024:.0f} KB" if size < 1_000_000 + f"{size / 1024:.0f} KB" + if size < 1_000_000 else f"{size / 1_000_000:.1f} MB" ) meta = f"[File: {name} ({size_str}, {mime or 'unknown type'})]" @@ -1345,7 +1039,9 @@ async def _extract_files( ) if is_text: if size > _MAX_TEXT_SIZE: - parts.append(f"{meta}\n(Text file too large to inline — {size_str})") + parts.append( + f"{meta}\n(Text file too large to inline — {size_str})" + ) continue data = await self._download_file(url) if data is None: @@ -1382,7 +1078,10 @@ async def _extract_files( parts.append(meta) continue - if ext == ".zip" or mime in ("application/zip", "application/x-zip-compressed"): + if ext == ".zip" or mime in ( + "application/zip", + "application/x-zip-compressed", + ): data = await self._download_file(url) if data is None: parts.append(meta) @@ -1583,7 +1282,9 @@ async def _handle_slash_command(self, payload: dict[str, Any]) -> None: channel_type = "im" if channel_id.startswith("D") else "channel" if not await self._authorize(user_id, channel_id, channel_type): await self._respond_ephemeral( - channel_id, user_id, "You are not authorized to use this bot.", + channel_id, + user_id, + "You are not authorized to use this bot.", ) return @@ -1606,12 +1307,13 @@ async def _handle_slash_command(self, payload: dict[str, Any]) -> None: ) return - # `sessions` and `new` bind a session to the command's own key. In a - # threaded channel nothing ever reads that key, so they would answer - # as if they had worked and change nothing. - if sub in ("sessions", "new") and not self._binds_to_channel_key(channel_id): + # Slack slash commands carry no thread context. Commands that require + # one exact conversation therefore remain DM-only. + if sub in ("sessions", "new") and not self._has_slash_session_key(channel_id): await self._respond_ephemeral( - channel_id, user_id, self._THREADED_CHANNEL_REFUSAL.format(sub=sub), + channel_id, + user_id, + self._THREADED_CHANNEL_REFUSAL.format(sub=sub), ) return @@ -1671,52 +1373,50 @@ def _help_text(enabled: frozenset[str]) -> str: _THREADED_CHANNEL_REFUSAL = ( "`/nerve {sub}` needs a thread to bind the session to, and Slack does " "not run `/nerve` inside one. Every new mention in this channel " - "already opens its own thread and its own session. Use `/nerve stop` " - "to end one, or set `slack.reply_in_thread: false` to keep a single " - "session per channel." + "opens its own thread and session. Use `/nerve stop` to select a " + "running thread, or use this command in a DM." ) - def _binds_to_channel_key(self, channel_id: str) -> bool: - """Whether slash commands and messages share the channel-level key. - - DMs always do; shared channels do only when threaded replies are off. - """ - if not channel_id: - return False - if channel_id.startswith("D"): - return True - return not self.config.slack.reply_in_thread + @staticmethod + def _has_slash_session_key(channel_id: str) -> bool: + """Whether a slash command names an exact conversation session.""" + return bool(channel_id and channel_id.startswith("D")) async def _cmd_new( - self, channel_id: str, user_id: str, channel_key: str, args: list[str], + self, + channel_id: str, + user_id: str, + channel_key: str, + args: list[str], ) -> None: prev = await self.router.get_last_session(channel_key) if prev: - await self.router.engine.stop_session(prev) + await self.router.stop_session(prev) title = " ".join(args) or None session_id = await self.router.create_session( - channel_key, title=title, source="slack", + channel_key, + title=title, + source="slack", ) await self._respond_ephemeral( - channel_id, user_id, + channel_id, + user_id, f"New session `{session_id}`" + (f" — {title}" if title else ""), ) async def _live_sessions_for_channel(self, channel_id: str) -> list[dict[str, Any]]: - """Return this channel's live sessions, including threaded ones. - - Slash commands carry no thread context, so search by channel prefix. - Re-check parsed IDs because ``slack:C123`` also prefixes ``slack:C1234``. - """ + """Return live sessions in this exact Slack conversation.""" rows = await self.router.list_conversation_sessions(f"slack:{channel_id}") matching: list[dict[str, Any]] = [] for row in rows: key = row.get("channel_key") or "" if not key.startswith("slack:"): continue - row_channel, thread_ts = parse_target(key[len("slack:"):]) + row_channel, thread_ts = parse_target(key[len("slack:") :]) if row_channel != channel_id: continue + if not channel_id.startswith("D") and thread_ts is None: + continue matching.append({**row, "thread_ts": thread_ts}) return matching @@ -1724,7 +1424,7 @@ async def _live_sessions_for_channel(self, channel_id: str) -> list[dict[str, An def _session_choice_label(row: dict[str, Any]) -> str: """Button label naming one session, and the thread it belongs to.""" title = (row.get("title") or "").strip() or row.get("session_id", "?") - where = "in thread" if row.get("thread_ts") else "in channel" + where = "in thread" if row.get("thread_ts") else "in conversation" label = f"{title} ({where})" return label[:74] + "…" if len(label) > 75 else label @@ -1787,29 +1487,41 @@ async def _cmd_stop( return await self._respond_ephemeral_blocks( - channel_id, user_id, + channel_id, + user_id, text="Which session should I stop?", blocks=self._session_picker_blocks( - candidates, "Pick the one to stop:", "sessstop:", style="danger", + candidates, + "Pick the one to stop:", + "sessstop:", + style="danger", ), ) async def _stop_and_report( - self, channel_id: str, user_id: str, row: dict[str, Any], + self, + channel_id: str, + user_id: str, + row: dict[str, Any], ) -> None: """Stop one session and say which one, so the answer is checkable.""" session_id = row["session_id"] - stopped = await self.router.engine.stop_session(session_id) + stopped = await self.router.stop_session(session_id) where = "the thread" if row.get("thread_ts") else "this channel" await self._respond_ephemeral( - channel_id, user_id, + channel_id, + user_id, f"Stopped `{session_id}` in {where}." if stopped else f"`{session_id}` was not running.", ) async def _cmd_star( - self, channel_id: str, user_id: str, channel_key: str, starred: bool, + self, + channel_id: str, + user_id: str, + channel_key: str, + starred: bool, ) -> None: # Same thread-blindness as stop: resolve across the conversation # rather than the command's own key, which usually owns nothing. @@ -1857,58 +1569,75 @@ async def _star_and_report( async def _cmd_reply(self, channel_id: str, user_id: str, answer: str) -> None: if not answer: await self._respond_ephemeral( - channel_id, user_id, "Usage: `/nerve reply `", + channel_id, + user_id, + "Usage: `/nerve reply `", ) return if not self._notification_service: await self._respond_ephemeral( - channel_id, user_id, "Notification service not available.", + channel_id, + user_id, + "Notification service not available.", ) return - pending = await self._notification_service.db.list_notifications( - status="pending", type="question", limit=1, - ) - if not pending: - await self._respond_ephemeral(channel_id, user_id, "No pending questions.") - return - ok = await self._notification_service.handle_answer( - notification_id=pending[0]["id"], answer=answer, answered_by="slack", + result = await self._notification_service.answer_latest_question( + answer, + channel="slack", + target=channel_id, + actor=user_id, ) await self._respond_ephemeral( - channel_id, user_id, - f"Answer recorded for: {pending[0]['title']}" - if ok - else "Failed to record answer.", + channel_id, + user_id, + f"Answer recorded for: {result['title']}" + if result + else "No pending questions in this conversation.", ) async def _respond_ephemeral( - self, channel_id: str, user_id: str, text: str, + self, + channel_id: str, + user_id: str, + text: str, ) -> None: """Reply so only the person who ran the command sees it.""" if self._web is None: return try: await self._web.chat_postEphemeral( - channel=channel_id, user=user_id, text=_md_to_slack(text), + channel=channel_id, + user=user_id, + text=_md_to_slack(text), ) except Exception as e: logger.warning("Slack chat.postEphemeral failed: %s", e) async def _respond_ephemeral_blocks( - self, channel_id: str, user_id: str, text: str, blocks: list[dict], + self, + channel_id: str, + user_id: str, + text: str, + blocks: list[dict], ) -> None: """Ephemeral reply carrying Block Kit, for the pickers.""" if self._web is None: return try: await self._web.chat_postEphemeral( - channel=channel_id, user=user_id, text=text, blocks=blocks, + channel=channel_id, + user=user_id, + text=text, + blocks=blocks, ) except Exception as e: logger.warning("Slack ephemeral blocks failed: %s", e) async def _send_sessions_view( - self, channel_id: str, user_id: str, channel_key: str, + self, + channel_id: str, + user_id: str, + channel_key: str, ) -> None: """Post the session switcher, visible only to the requester.""" if self._web is None: @@ -1966,7 +1695,7 @@ async def _handle_interactive(self, payload: dict[str, Any]) -> None: return if action_id.startswith("sessstop:"): - stopped = await self.router.engine.stop_session(value) + stopped = await self.router.stop_session(value) await self._replace_via_url( response_url, f"Stopped `{value}`." if stopped else f"`{value}` was not running.", @@ -2022,11 +1751,9 @@ async def _handle_session_button( """Switch, create, or star a session from the switcher card.""" channel_key = f"slack:{format_target(channel_id)}" - # A card posted before `reply_in_thread` was turned on, or one kept - # open in a threaded channel, would bind the session to a key no - # message ever reads. Starring does not touch the mapping, so it is - # still allowed. - if not action_id.startswith("sessstar:") and not self._binds_to_channel_key( + # Session cards are only actionable where Slack gives the interaction + # an exact session key. Starring does not change the mapping. + if not action_id.startswith("sessstar:") and not self._has_slash_session_key( channel_id, ): await self._replace_via_url( @@ -2076,12 +1803,21 @@ async def _handle_notification_button( return actor = (payload.get("user") or {}).get("id") or "" - success = await self._notification_service.handle_answer( - notification_id=notification_id, answer=answer, answered_by="slack", + thread_ts = (payload.get("message") or {}).get("thread_ts") or None + result = await self._notification_service.answer_delivered_notification( + notification_id, + answer, + channel="slack", + target=format_target( + (payload.get("channel") or {}).get("id") or "", + thread_ts, + ), + actor=actor, ) - if not success: + if not result: await self._replace_via_url( - response_url, "Already answered or expired.", + response_url, + "Already answered or expired.", ) return @@ -2096,7 +1832,7 @@ async def _handle_notification_button( ).strip("\n") status = f"✅ Answered: {_md_to_slack(answer)}" - snoozed_until = await self._get_snoozed_until(notification_id) + snoozed_until = self._snoozed_until(result) if snoozed_until: status = f"💤 Snoozed until {snoozed_until} — will resurface" # Written as raw mention markup: the converter would escape it, and @@ -2109,24 +1845,17 @@ async def _handle_notification_button( already_mrkdwn=True, ) - async def _get_snoozed_until(self, notification_id: str) -> str | None: - """Human-readable re-delivery time if the row was snoozed. - - A snoozed approval is the only outcome that leaves the row pending - with ``redeliver_at`` set. Cosmetic, so every failure yields None. - """ + @staticmethod + def _snoozed_until(notification: dict[str, Any]) -> str | None: + """Render the next delivery time when an answer snoozed the row.""" try: - notif = await self._notification_service.db.get_notification( - notification_id, - ) - if ( - not notif - or notif.get("status") != "pending" - or not notif.get("redeliver_at") + if notification.get("status") != "pending" or not notification.get( + "redeliver_at" ): return None from datetime import datetime - dt = datetime.fromisoformat(notif["redeliver_at"]) + + dt = datetime.fromisoformat(notification["redeliver_at"]) return dt.astimezone().strftime("%Y-%m-%d %H:%M %Z") except Exception: return None diff --git a/nerve/channels/slack_presentation.py b/nerve/channels/slack_presentation.py new file mode 100644 index 00000000..273390df --- /dev/null +++ b/nerve/channels/slack_presentation.py @@ -0,0 +1,335 @@ +"""Pure Slack text conversion and Block Kit rendering.""" + +from __future__ import annotations + +import re +from typing import Any + +MAX_MSG_LEN = 3900 +_MAX_ACTION_ELEMENTS = 25 +_SESSIONS_BUTTON_LIMIT = 8 +_SESSION_LABEL_MAX = 70 +_MAX_SECTION_LEN = 3000 +_MAX_SECTION_BLOCKS = 45 + +_EMOJI_TO_SLACK: dict[str, str] = { + "👍": "thumbsup", + "👎": "thumbsdown", + "❤": "heart", + "❤️": "heart", + "🔥": "fire", + "🥰": "smiling_face_with_3_hearts", + "👏": "clap", + "😁": "grin", + "🤔": "thinking_face", + "🤯": "exploding_head", + "😱": "scream", + "😢": "cry", + "🎉": "tada", + "🤩": "star-struck", + "🙏": "pray", + "👌": "ok_hand", + "🥱": "yawning_face", + "😍": "heart_eyes", + "🌚": "new_moon_with_face", + "💯": "100", + "🤣": "rolling_on_the_floor_laughing", + "⚡": "zap", + "🏆": "trophy", + "💔": "broken_heart", + "🤨": "face_with_raised_eyebrow", + "😐": "neutral_face", + "🍾": "champagne", + "👀": "eyes", + "🙈": "see_no_evil", + "😇": "innocent", + "🤝": "handshake", + "🤗": "hugging_face", + "🫡": "saluting_face", + "🆒": "cool", + "😎": "sunglasses", + "✅": "white_check_mark", + "❌": "x", + "⏳": "hourglass_flowing_sand", + "🚀": "rocket", + "✍": "writing_hand", + "🤡": "clown_face", + "💩": "hankey", + "😴": "sleeping", + "👻": "ghost", +} + +_APPROVAL_STYLES: dict[str, str] = { + "approve": "primary", + "yes": "primary", + "allow": "primary", + "decline": "danger", + "deny": "danger", + "no": "danger", + "reject": "danger", +} + +_BARE_AMPERSAND_RE = re.compile(r"&(?!(?:amp|lt|gt);)") + + +def _escape_ampersands(text: str) -> str: + """Escape ``&`` without double-escaping an existing entity.""" + return _BARE_AMPERSAND_RE.sub("&", text) + + +def _md_to_slack(text: str) -> str: + """Convert standard Markdown to Slack mrkdwn.""" + protected: list[str] = [] + + def protect(replacement: str) -> str: + index = len(protected) + protected.append(replacement) + return f"\x00{index}\x00" + + def fence(match: re.Match) -> str: + return protect("```\n" + match.group(2).strip("\n") + "\n```") + + text = re.sub(r"```(\w*)\n?(.*?)```", fence, text, flags=re.DOTALL) + text = re.sub(r"`([^`]+)`", lambda match: protect(f"`{match.group(1)}`"), text) + + def link(match: re.Match) -> str: + label = _escape_ampersands(match.group(1)) + url = _escape_ampersands(match.group(2)) + return protect(f"<{url}|{label}>") + + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", link, text) + text = _escape_ampersands(text).replace("<", "<").replace(">", ">") + text = re.sub( + r"^\s{0,3}#{1,6}\s+(.+?)\s*$", + lambda match: f"\x01{match.group(1)}\x01", + text, + flags=re.MULTILINE, + ) + text = re.sub( + r"\*\*(.+?)\*\*", + lambda match: f"\x01{match.group(1)}\x01", + text, + flags=re.DOTALL, + ) + text = re.sub( + r"(? str: + """Turn Slack wire markup into readable prompt text.""" + if bot_user_id: + text = re.sub(rf"<@{re.escape(bot_user_id)}(\|[^>]*)?>", "", text) + text = re.sub(r"<#C[A-Z0-9]+\|([^>]+)>", r"#\1", text) + text = re.sub(r"<#(C[A-Z0-9]+)>", r"#\1", text) + text = re.sub(r"<@([UW][A-Z0-9]+)\|([^>]+)>", r"@\2", text) + text = re.sub(r"<@([UW][A-Z0-9]+)>", r"@\1", text) + text = re.sub(r"]+)>", r"@\1", text) + text = re.sub(r"]*)?>", r"@\1", text) + text = re.sub(r"<([^|>]+)\|([^>]+)>", r"\2 (\1)", text) + text = re.sub(r"<((?:https?|mailto):[^>]+)>", r"\1", text) + return text.replace("<", "<").replace(">", ">").replace("&", "&").strip() + + +def split_message(text: str, limit: int = MAX_MSG_LEN) -> list[str]: + """Split text under *limit*, preferring line boundaries.""" + if len(text) <= limit: + return [text] if text else [] + + chunks: list[str] = [] + current = "" + for line in text.split("\n"): + while len(line) > limit: + if current: + chunks.append(current) + current = "" + chunks.append(line[:limit]) + line = line[limit:] + if not current: + current = line + elif len(current) + 1 + len(line) <= limit: + current = f"{current}\n{line}" + else: + chunks.append(current) + current = line + if current: + chunks.append(current) + return chunks + + +def slack_emoji_name(emoji: str) -> str | None: + """Map a unicode emoji or existing short name to a Slack short name.""" + cleaned = emoji.strip().strip(":") + if cleaned and all(char.isalnum() or char in "-_+" for char in cleaned): + return cleaned + return _EMOJI_TO_SLACK.get(emoji.strip()) or _EMOJI_TO_SLACK.get( + emoji.strip().rstrip("️"), + ) + + +def _session_label(session: dict, current_id: str | None) -> str: + title = (session.get("title") or "").strip() or session.get("id", "?") + prefix = "✓ " if session.get("id") == current_id else "" + if session.get("starred"): + prefix += "⭐ " + label = f"{prefix}{title}" + return ( + label[: _SESSION_LABEL_MAX - 1] + "…" + if len(label) > _SESSION_LABEL_MAX + else label + ) + + +def build_sessions_blocks( + sessions: list[dict], + current_id: str | None, +) -> list[dict[str, Any]]: + """Render the ``/nerve sessions`` Block Kit view.""" + blocks: list[dict[str, Any]] = [] + shown = sessions[:_SESSIONS_BUTTON_LIMIT] + if not shown: + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "No sessions yet — start one below.", + }, + } + ) + else: + current_title = next( + ( + (session.get("title") or session.get("id")) + for session in shown + if session.get("id") == current_id + ), + None, + ) + header = "*Sessions* — tap to switch." + if current_title: + header += f"\nCurrent: {current_title}" + header += "\n⭐ keeps a session alive (never auto-closed)." + blocks.append( + { + "type": "section", + "text": {"type": "mrkdwn", "text": header}, + } + ) + for session in shown: + session_id = session.get("id") + if not session_id: + continue + blocks.append( + { + "type": "actions", + "block_id": f"sess_row:{session_id}", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": _session_label(session, current_id), + "emoji": True, + }, + "action_id": f"sess:{session_id}", + "value": session_id, + }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "⭐" if session.get("starred") else "☆", + "emoji": True, + }, + "action_id": f"sessstar:{session_id}", + "value": session_id, + }, + ], + } + ) + blocks.append( + { + "type": "actions", + "block_id": "sess_new", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "➕ New session", + "emoji": True, + }, + "action_id": "sess:new", + "value": "new", + "style": "primary", + } + ], + } + ) + return blocks + + +def build_notification_blocks( + text: str, + notification_id: str, + options: list[tuple[str, str]] | None = None, +) -> list[dict[str, Any]]: + """Render notification text and option buttons as Block Kit.""" + chunks = split_message(_md_to_slack(text), _MAX_SECTION_LEN) + if len(chunks) > _MAX_SECTION_BLOCKS: + dropped = sum(len(chunk) for chunk in chunks[_MAX_SECTION_BLOCKS - 1 :]) + chunks = chunks[: _MAX_SECTION_BLOCKS - 1] + chunks.append( + f"_… {dropped} more characters — open the notification in the " + "web UI to read the rest._", + ) + blocks: list[dict[str, Any]] = [ + {"type": "section", "text": {"type": "mrkdwn", "text": chunk}} + for chunk in chunks + ] + elements = [ + { + "type": "button", + "text": {"type": "plain_text", "text": label[:75], "emoji": True}, + "action_id": f"notif:{notification_id}:{value}"[:255], + "value": value[:2000], + **( + {"style": _APPROVAL_STYLES[value.lower()]} + if value.lower() in _APPROVAL_STYLES + else {} + ), + } + for label, value in (options or []) + ] + for start in range(0, len(elements), _MAX_ACTION_ELEMENTS): + blocks.append( + { + "type": "actions", + "block_id": f"notif:{notification_id}:{start}", + "elements": elements[start : start + _MAX_ACTION_ELEMENTS], + } + ) + return blocks + + +__all__ = [ + "MAX_MSG_LEN", + "_MAX_ACTION_ELEMENTS", + "_MAX_SECTION_BLOCKS", + "_SESSIONS_BUTTON_LIMIT", + "_md_to_slack", + "build_notification_blocks", + "build_sessions_blocks", + "slack_emoji_name", + "slack_to_plain", + "split_message", +] diff --git a/nerve/channels/slack_runtime.py b/nerve/channels/slack_runtime.py new file mode 100644 index 00000000..d80317ff --- /dev/null +++ b/nerve/channels/slack_runtime.py @@ -0,0 +1,212 @@ +"""Serialized lifecycle ownership for the Slack channel.""" + +from __future__ import annotations + +import asyncio +import copy +import logging +from typing import TYPE_CHECKING + +from nerve.channels.slack import ( + WATCHDOG_HEARTBEAT_EVERY, + WATCHDOG_INTERVAL, + SlackChannel, +) + +if TYPE_CHECKING: + from nerve.channels.router import ChannelRouter + from nerve.config import NerveConfig + from nerve.notifications.service import NotificationService + +logger = logging.getLogger(__name__) + + +class SlackRuntimeError(RuntimeError): + """A sanitized Slack transition failure safe for logs and HTTP output.""" + + +class SlackRuntime: + """Own one Slack instance and serialize every lifecycle transition.""" + + name = "slack" + + def __init__( + self, + router: ChannelRouter, + notification_service: NotificationService | None = None, + ) -> None: + self.router = router + self.notification_service = notification_service + self._lock = asyncio.Lock() + self._channel: SlackChannel | None = None + self._active_config: NerveConfig | None = None + self._watchdog_task: asyncio.Task | None = None + + @property + def channel(self) -> SlackChannel | None: + return self._channel + + @property + def active_config(self) -> NerveConfig | None: + return self._active_config + + async def reconcile(self, config: NerveConfig) -> str | None: + """Atomically reconcile one desired process config generation.""" + desired = copy.deepcopy(config) + async with self._lock: + if not desired.slack.enabled: + if self._channel is None: + return None + try: + await self._stop_locked(drain=True) + except Exception as error: + raise self._safe_error(error, desired) from error + return "disabled" + + if not desired.slack.bot_token or not desired.slack.app_token: + raise self._safe_error( + RuntimeError("Slack needs both bot_token and app_token"), + desired, + ) + + if self._channel is None: + return await self._enable_locked(desired) + + channel = self._channel + if self.router.get_channel(self.name) is not channel: + raise SlackRuntimeError( + "the Slack runtime no longer owns the registered channel", + ) + if not channel.is_available: + await self._stop_locked(drain=False) + return await self._enable_locked(desired) + + slack = desired.slack + if channel.needs_credential_reload(slack.bot_token, slack.app_token): + try: + await channel.reload_credentials(desired) + except Exception as error: + if not channel.is_available: + await self._stop_locked(drain=False) + raise self._safe_error(error, desired) from error + self._active_config = desired + return "credentials reloaded" + + channel.apply_config(desired) + self._active_config = desired + return None + + async def shutdown(self) -> None: + """Stop and unpublish whichever Slack instance is current.""" + async with self._lock: + if self._channel is not None: + await self._stop_locked(drain=False) + + async def _enable_locked(self, desired: NerveConfig) -> str: + existing = self.router.get_channel(self.name) + if existing is not None: + raise SlackRuntimeError( + "a Slack channel exists outside the runtime lifecycle", + ) + + candidate = SlackChannel(desired, self.router) + candidate.set_notification_service(self.notification_service) + self._channel = candidate + self.router.register(candidate) + try: + await candidate.start() + except (Exception, asyncio.CancelledError) as error: + try: + await candidate.stop() + except Exception: + logger.debug( + "Slack cleanup after failed enable raised", + exc_info=True, + ) + finally: + self.router.unregister(candidate) + self._channel = None + self._active_config = None + if isinstance(error, asyncio.CancelledError): + raise + raise self._safe_error(error, desired) from error + + self._active_config = desired + self._watchdog_task = asyncio.create_task( + self._watchdog(candidate), + name="slack-socket-watchdog", + ) + return "enabled" + + async def _stop_locked(self, *, drain: bool) -> None: + channel = self._channel + if channel is None: + return + await self._cancel_watchdog_locked() + try: + await channel.stop(drain=drain) + finally: + self.router.unregister(channel) + if self._channel is channel: + self._channel = None + self._active_config = None + + async def _cancel_watchdog_locked(self) -> None: + task = self._watchdog_task + self._watchdog_task = None + if task is None or task.done(): + return + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + async def _watchdog(self, channel: SlackChannel) -> None: + check_count = 0 + while True: + await asyncio.sleep(WATCHDOG_INTERVAL) + async with self._lock: + if self._channel is not channel or not channel.is_available: + return + check_count += 1 + connected = await channel.transport_connected() + if check_count % WATCHDOG_HEARTBEAT_EVERY == 0: + logger.info( + "Slack watchdog: %s (check #%d, last event %.0fs ago)", + "connected" if connected else "disconnected", + check_count, + channel.seconds_since_last_event, + ) + if connected: + continue + logger.warning("Slack socket is down — rebuilding") + try: + await channel.rebuild_transport() + except Exception as error: + logger.error( + "Slack reconnect failed: %s", + self._safe_detail(error, self._active_config), + ) + else: + logger.info("Slack socket reconnected") + + def _safe_error( + self, + error: Exception, + desired: NerveConfig | None, + ) -> SlackRuntimeError: + return SlackRuntimeError( + self._safe_detail(error, self._active_config, desired), + ) + + @staticmethod + def _safe_detail(error: Exception, *configs: NerveConfig | None) -> str: + detail = str(error) or type(error).__name__ + for config in configs: + if config is None: + continue + for secret in (config.slack.bot_token, config.slack.app_token): + if secret: + detail = detail.replace(secret, "") + return detail diff --git a/nerve/config.py b/nerve/config.py index 181150e3..4bc4f91c 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -1005,13 +1005,11 @@ def from_dict(cls, d: dict, locked: bool = False) -> TelegramConfig: SLACK_ALL_COMMANDS: tuple[str, ...] = ( "sessions", "new", "stop", "star", "unstar", "reply", "doctor", "restart", ) -# `sessions` and `reply` are absent on purpose. Both reach every session in -# the instance, including web and Telegram ones, and Slack has no ownership -# model yet to narrow them to the caller. In a workspace where several people -# may DM the bot that would let any of them list, attach to, continue, or -# answer someone else's work. List them in `slack.commands` to turn them on. +# `sessions` is absent on purpose: it reaches every interactive session in the +# instance, and Slack has no ownership model yet to narrow that list. Replies +# are safe by default because notification lookup is delivery-target scoped. SLACK_DEFAULT_COMMANDS: tuple[str, ...] = ( - "new", "stop", "star", "unstar", + "new", "stop", "star", "unstar", "reply", ) @@ -1069,10 +1067,6 @@ class SlackConfig: allow_channels: list[str] = field(default_factory=list) deny_channels: list[str] = field(default_factory=list) stream_mode: str = "partial" - # Reply inside the thread the message came from, and treat each thread as - # its own session. Off means every message in a channel shares one session - # and replies land at channel level. - reply_in_thread: bool = True # None keeps safe defaults; [] disables commands. Host-wide and # cross-channel commands are opt-in. See SLACK_*_COMMANDS. commands: list[str] | None = None @@ -1108,7 +1102,6 @@ def from_dict(cls, d: dict, locked: bool = False) -> SlackConfig: allow_channels=d.get("allow_channels") or [], deny_channels=d.get("deny_channels") or [], stream_mode=stream_mode, - reply_in_thread=d.get("reply_in_thread", True), commands=_slack_commands(d.get("commands")), ) diff --git a/nerve/config_reload.py b/nerve/config_reload.py index e2490eb9..ad1dbd54 100644 --- a/nerve/config_reload.py +++ b/nerve/config_reload.py @@ -11,7 +11,6 @@ from __future__ import annotations import dataclasses -import inspect import logging from pathlib import Path @@ -246,88 +245,20 @@ def hand_over(label: str, target) -> None: return problems -def _redact_slack_error( - error: Exception, bot_token: str, app_token: str, -) -> str: - """Describe a Slack lifecycle error without exposing either token.""" - detail = str(error) or type(error).__name__ - for secret in (bot_token, app_token): - if secret: - detail = detail.replace(secret, "") - return detail - - async def _reconcile_slack(new_config, engine) -> str | None: - """Make the registered Slack channel match its enabled state and tokens.""" + """Ask the installed Slack lifecycle owner to reconcile this generation.""" if engine is None: return None - - from nerve.channels.slack import SlackChannel - from nerve.config import get_config - try: - channel = engine.router.get_channel("slack") - if inspect.isawaitable(channel): - channel = await channel - except Exception as e: # noqa: BLE001 — keep the unified reload best-effort - logger.warning( - "Could not locate the running Slack channel (%s)", type(e).__name__, - ) - return f"{_ERROR_PREFIX}could not locate the running Slack channel" - bot_token = new_config.slack.bot_token - app_token = new_config.slack.app_token - - if not new_config.slack.enabled: - if not isinstance(channel, SlackChannel): + runtime = engine.get_channel_runtime("slack") + if runtime is None: return None - try: - engine.router.unregister(channel) - await channel.stop(drain=True) - except Exception as e: # noqa: BLE001 — report and keep reloading - detail = _redact_slack_error(e, bot_token, app_token) - logger.warning("Slack disable failed: %s", detail) - return f"{_ERROR_PREFIX}{detail}" - return "disabled" - - if channel is not None and not isinstance(channel, SlackChannel): - return f"{_ERROR_PREFIX}the registered Slack channel has an unexpected type" - - if channel is None: - if not bot_token or not app_token: - return f"{_ERROR_PREFIX}Slack needs both bot_token and app_token" - - candidate = None - try: - candidate = SlackChannel(get_config, engine.router) - candidate.set_notification_service( - getattr(engine, "notification_service", None), - ) - await candidate.start() - engine.router.register(candidate) - except Exception as e: # noqa: BLE001 — leave Slack absent and retryable - if candidate is not None: - try: - await candidate.stop() - except Exception: - logger.debug( - "Slack cleanup after failed enable raised", exc_info=True, - ) - detail = _redact_slack_error(e, bot_token, app_token) - logger.warning("Slack enable failed: %s", detail) - return f"{_ERROR_PREFIX}{detail}" - return "enabled" - - try: - if not channel.needs_credential_reload(bot_token, app_token): - return None - await channel.reload_credentials(bot_token, app_token) + return await runtime.reconcile(new_config) except Exception as e: # noqa: BLE001 — report the subsystem, continue reload - detail = _redact_slack_error(e, bot_token, app_token) - logger.warning("Slack credential reload failed: %s", detail) + detail = str(e) or type(e).__name__ + logger.warning("Slack reload failed: %s", detail) return f"{_ERROR_PREFIX}{detail}" - return "credentials reloaded" - async def reload_all(engine, cron_service, config_dir: Path) -> dict: """Re-read config and hot-reload all reloadable subsystems. diff --git a/nerve/gateway/server.py b/nerve/gateway/server.py index ed3bfd8f..0cee078d 100644 --- a/nerve/gateway/server.py +++ b/nerve/gateway/server.py @@ -6,10 +6,8 @@ from __future__ import annotations import asyncio -import json import logging import os -import ssl import uuid from contextlib import asynccontextmanager from pathlib import Path @@ -17,7 +15,6 @@ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware -from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from nerve import paths @@ -344,33 +341,20 @@ async def lifespan(app: FastAPI): await telegram_channel.start() logger.info("Telegram bot started") - # Start Slack bot if enabled - slack_channel = None - if config.slack.enabled and config.slack.bot_token and config.slack.app_token: - from nerve.channels.slack import SlackChannel - # get_config, not the object read above: the channel resolves config per - # use so a reload reaches the reads that happen per event (the - # allow/deny lists). - slack_channel = SlackChannel(get_config, _engine.router) - slack_channel.set_notification_service(notification_service) - try: - await slack_channel.start() - except Exception as e: - # A bad token or a revoked app must not stop the daemon booting — - # every other channel and the web UI still work without Slack. - # Registration happens only after a clean start: a half-built - # channel left in the router still answers get_channel("slack"), - # so notification fanout would keep posting into a dead client and - # recording the result as delivered. - logger.error("Slack bot failed to start: %s", e, exc_info=True) - try: - await slack_channel.stop() - except Exception: - logger.debug("Slack cleanup after failed start raised", exc_info=True) - slack_channel = None - else: - _engine.register_channel(slack_channel) - logger.info("Slack bot started") + # Install the lifecycle owner even when Slack starts disabled, so a later + # reload can enable it without rebuilding gateway state. + from nerve.channels.slack_runtime import SlackRuntime + + slack_runtime = SlackRuntime(_engine.router, notification_service) + _engine.register_channel_runtime("slack", slack_runtime) + try: + slack_outcome = await slack_runtime.reconcile(config) + if slack_outcome: + logger.info("Slack bot %s", slack_outcome) + except Exception as e: + # Slack is optional; its runtime leaves a failed candidate absent and + # retryable while the web UI and other channels continue starting. + logger.error("Slack bot failed to start: %s", e, exc_info=True) # Start cron service global _cron_service @@ -709,12 +693,7 @@ async def _periodic_notify_maintenance(): # the polling and socket tasks before we get a chance to stop them cleanly. if telegram_channel: await telegram_channel.stop() - # Slack may have been enabled or disabled since startup. - from nerve.channels.slack import SlackChannel - - slack_channel = _engine.router.get_channel("slack") - if isinstance(slack_channel, SlackChannel): - await slack_channel.stop() + await slack_runtime.shutdown() if ws_sync_task: # Exit through the loop's own stop path rather than cancelling it where # it stands: a cycle interrupted between the merge and the reload leaves diff --git a/tests/test_channel_archives.py b/tests/test_channel_archives.py index 793cb4fc..57490912 100644 --- a/tests/test_channel_archives.py +++ b/tests/test_channel_archives.py @@ -167,7 +167,7 @@ async def test_slack_refuses_a_bomb_in_an_attachment(self, monkeypatch): cfg.slack = SlackConfig( enabled=True, bot_token="xoxb-t", app_token="xapp-t", ) - channel = SlackChannel(lambda: cfg, router=MagicMock()) + channel = SlackChannel(cfg, router=MagicMock()) channel._download_file = AsyncMock( return_value=_zip({"bomb.png": b"\x00" * 4_000_000}), ) diff --git a/tests/test_config_reload.py b/tests/test_config_reload.py index 43791088..7318a766 100644 --- a/tests/test_config_reload.py +++ b/tests/test_config_reload.py @@ -792,13 +792,17 @@ def _running_channel( cls._body(bot_token, app_token, allow_user), ) monkeypatch.setattr(cfgmod, "_config", cfgmod.load_config(config_dir)) - channel = SlackChannel(cfgmod.get_config, router=MagicMock()) + channel = SlackChannel(cfgmod.get_config(), router=MagicMock()) channel._active_bot_token = bot_token channel._active_app_token = app_token + channel._state = "running" + channel._web = object() return channel @staticmethod def _engine(channel, notification_service=None): + from nerve.channels.slack_runtime import SlackRuntime + state = {"channel": channel} router = MagicMock() router.get_channel.side_effect = lambda name: ( @@ -816,12 +820,19 @@ def unregister(candidate): router.register.side_effect = register router.unregister.side_effect = unregister - return SimpleNamespace( + if channel is not None: + channel.router = router + runtime = SlackRuntime(router, notification_service) + runtime._channel = channel + runtime._active_config = channel.config if channel is not None else None + engine = SimpleNamespace( router=router, notification_service=notification_service, reload_mcp_config=AsyncMock(return_value=[]), _skill_manager=None, ) + engine.get_channel_runtime = lambda name: runtime if name == "slack" else None + return engine @pytest.mark.asyncio async def test_changed_tokens_rotate_the_running_transport( @@ -831,21 +842,25 @@ async def test_changed_tokens_rotate_the_running_transport( ws.mkdir() channel = self._running_channel(config_dir, ws, monkeypatch) - async def rotate(bot_token, app_token): - channel._active_bot_token = bot_token - channel._active_app_token = app_token + async def rotate(config): + channel._active_bot_token = config.slack.bot_token + channel._active_app_token = config.slack.app_token + channel.apply_config(config) channel.reload_credentials = AsyncMock(side_effect=rotate) engine = self._engine(channel) _write_config( - config_dir, ws, self._body("xoxb-new", "xapp-new"), + config_dir, + ws, + self._body("xoxb-new", "xapp-new"), ) summary = await reload_all(engine, None, config_dir) - channel.reload_credentials.assert_awaited_once_with( - "xoxb-new", "xapp-new", - ) + channel.reload_credentials.assert_awaited_once() + desired = channel.reload_credentials.await_args.args[0] + assert desired.slack.bot_token == "xoxb-new" + assert desired.slack.app_token == "xapp-new" assert summary["slack"] == "credentials reloaded" assert "slack.bot_token" not in summary.get("restart_required", "") assert "slack.app_token" not in summary.get("restart_required", "") @@ -867,6 +882,8 @@ async def test_enabling_starts_wires_and_registers_the_channel( async def start(channel): started.append(channel) + channel._state = "running" + channel._web = object() monkeypatch.setattr(SlackChannel, "start", start) notifications = object() @@ -940,6 +957,8 @@ async def start(channel): attempts += 1 if attempts == 1: raise RuntimeError("bad xoxb-new") + channel._state = "running" + channel._web = object() monkeypatch.setattr(SlackChannel, "start", start) engine = self._engine(None) @@ -1058,9 +1077,12 @@ async def test_route_does_not_claim_a_reload_that_failed(self, tmp_path, monkeyp monkeypatch.setattr(route_mod, "get_deps", lambda: SimpleNamespace(engine=None)) monkeypatch.setattr( "nerve.config_reload.reload_all", - AsyncMock(return_value={ - "config": "error: bad yaml", "cron": {"enabled": 3}, - }), + AsyncMock( + return_value={ + "config": "error: bad yaml", + "cron": {"enabled": 3}, + } + ), ) result = await route_mod.reload_config_route(user={}) assert result["ok"] is False diff --git a/tests/test_config_resolution.py b/tests/test_config_resolution.py index 09701256..421da02a 100644 --- a/tests/test_config_resolution.py +++ b/tests/test_config_resolution.py @@ -283,14 +283,11 @@ def test_the_doctor_counts_direct_messages_as_a_guardrail(self): class TestSlackDefaultCommands: - def test_globally_scoped_commands_are_off_by_default(self): - # Both reach every session in the instance, web and Telegram - # included, and Slack has no ownership model to narrow them to the - # caller. Listing them in slack.commands turns them back on. + def test_only_globally_scoped_session_listing_is_off_by_default(self): from nerve.config import SLACK_DEFAULT_COMMANDS assert "sessions" not in SLACK_DEFAULT_COMMANDS - assert "reply" not in SLACK_DEFAULT_COMMANDS + assert "reply" in SLACK_DEFAULT_COMMANDS def test_they_are_still_available_on_request(self): from nerve.config import SLACK_ALL_COMMANDS diff --git a/tests/test_db.py b/tests/test_db.py index 7f184be8..66933e2e 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1758,7 +1758,7 @@ async def test_finish_keeps_start_link(self, db: Database): assert logs[0]["session_id"] == "cron:job-live2:20260610-130000" -class TestChannelSessionsByPrefix: +class TestChannelSessionsForConversation: """One conversation can own several sessions — a Slack channel keys one per thread — so a caller holding only the conversation needs the set. """ @@ -1780,25 +1780,23 @@ async def test_it_finds_every_thread_under_one_channel(self, db): "slack:C1:1.0": "thread_a", "slack:C1:2.0": "thread_b", }) - rows = await db.list_channel_sessions_by_prefix("slack:C1") + rows = await db.list_channel_sessions_for_conversation("slack:C1") assert {r["session_id"] for r in rows} == {"base", "thread_a", "thread_b"} @pytest.mark.asyncio async def test_it_does_not_bleed_into_a_longer_channel_id(self, db): # "slack:C1" is a prefix of "slack:C12" as a string. await self._seed(db, {"slack:C1": "mine", "slack:C12": "theirs"}) - rows = await db.list_channel_sessions_by_prefix("slack:C1") - assert "theirs" in {r["session_id"] for r in rows}, ( - "the SQL is a prefix match; the caller narrows further" - ) - rows = await db.list_channel_sessions_by_prefix("slack:C12") + rows = await db.list_channel_sessions_for_conversation("slack:C1") + assert {r["session_id"] for r in rows} == {"mine"} + rows = await db.list_channel_sessions_for_conversation("slack:C12") assert {r["session_id"] for r in rows} == {"theirs"} @pytest.mark.asyncio async def test_wildcards_in_the_key_are_escaped(self, db): # An unescaped _ or % would silently widen the match. await self._seed(db, {"slack:C_1": "literal", "slack:CX1": "other"}) - rows = await db.list_channel_sessions_by_prefix("slack:C_1") + rows = await db.list_channel_sessions_for_conversation("slack:C_1") assert {r["session_id"] for r in rows} == {"literal"} @pytest.mark.asyncio @@ -1818,7 +1816,7 @@ async def test_excluded_statuses_are_left_out(self, db): {"slack:C1:1.0": "live", "slack:C1:2.0": "gone"}, statuses={"gone": "archived"}, ) - rows = await db.list_channel_sessions_by_prefix( + rows = await db.list_channel_sessions_for_conversation( "slack:C1", exclude_statuses=("archived",), ) assert {r["session_id"] for r in rows} == {"live"} @@ -1826,5 +1824,5 @@ async def test_excluded_statuses_are_left_out(self, db): @pytest.mark.asyncio async def test_rows_carry_the_session_title(self, db): await self._seed(db, {"slack:C1:1.0": "s1"}) - rows = await db.list_channel_sessions_by_prefix("slack:C1") + rows = await db.list_channel_sessions_for_conversation("slack:C1") assert rows[0]["title"] == "t-s1" diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index f0988709..560c8c5c 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import copy from unittest.mock import AsyncMock, MagicMock import pytest @@ -17,7 +18,6 @@ MAX_MSG_LEN, SlackChannel, _md_to_slack, - build_notification_blocks, build_sessions_blocks, format_target, is_slack_id, @@ -26,6 +26,7 @@ slack_to_plain, split_message, ) +from nerve.channels.slack_presentation import build_notification_blocks from nerve.config import NerveConfig, SlackConfig @@ -43,18 +44,30 @@ def _config(**slack_kwargs) -> NerveConfig: def _channel(**slack_kwargs) -> SlackChannel: """A channel with a stub transport, ready to take events.""" cfg = _config(**slack_kwargs) - channel = SlackChannel(lambda: cfg, router=MagicMock()) + channel = SlackChannel(cfg, router=MagicMock()) channel._web = MagicMock() channel._web.chat_postMessage = AsyncMock(return_value={"ts": "1.1"}) channel._web.chat_update = AsyncMock(return_value={"ok": True}) channel._web.chat_delete = AsyncMock(return_value={"ok": True}) channel._web.reactions_add = AsyncMock(return_value={"ok": True}) + channel._state = "running" channel._bot_user_id = "U0BOT" channel.router.handle_message = AsyncMock(return_value="done") channel.router.get_last_session = AsyncMock(return_value=None) return channel +def _with_credentials( + channel: SlackChannel, + bot_token: str, + app_token: str, +) -> NerveConfig: + config = copy.deepcopy(channel.config) + config.slack.bot_token = bot_token + config.slack.app_token = app_token + return config + + # ---------------------------------------------------------------------- # # Addressing # # ---------------------------------------------------------------------- # @@ -249,7 +262,7 @@ def test_constraints_match_slacks_edit_rate_limit(self): def test_the_policy_follows_a_config_reload(self): # The channel outlives a reload, so every guardrail is read per use. cfg = _config(allow_users=["U1"]) - channel = SlackChannel(lambda: cfg, router=MagicMock()) + channel = SlackChannel(cfg, router=MagicMock()) assert channel.policy.users.allow == ["U1"] assert channel.policy.allow_direct_messages is False cfg.slack.allow_users = ["U2"] @@ -269,7 +282,8 @@ async def test_an_unconfigured_policy_refuses_without_calling_slack(self): @pytest.mark.asyncio async def test_an_id_allow_list_needs_no_name_lookup(self): channel = _channel( - allow_users=["U0123ABC"], allow_direct_messages=True, + allow_users=["U0123ABC"], + allow_direct_messages=True, ) channel._web.users_info = AsyncMock() assert await channel._authorize("U0123ABC", "D1", "im") @@ -312,7 +326,8 @@ async def test_direct_messages_are_refused_by_default(self): @pytest.mark.asyncio async def test_the_direct_message_setting_allows_them(self): channel = _channel( - allow_users=["U1"], allow_direct_messages=True, + allow_users=["U1"], + allow_direct_messages=True, ) assert await channel._authorize("U1", "D1", "im") @@ -339,61 +354,99 @@ async def test_a_direct_message_reaches_the_router(self): @pytest.mark.asyncio async def test_an_unauthorized_sender_never_reaches_the_router(self): channel = _channel( - allow_users=["U-other"], allow_direct_messages=True, + allow_users=["U-other"], + allow_direct_messages=True, + ) + await channel._handle_message_event( + { + "type": "message", + "channel": "D1", + "channel_type": "im", + "user": "U1", + "ts": "1.1", + "text": "hello", + } ) - await channel._handle_message_event({ - "type": "message", "channel": "D1", "channel_type": "im", - "user": "U1", "ts": "1.1", "text": "hello", - }) channel.router.handle_message.assert_not_called() @pytest.mark.asyncio async def test_the_bots_own_message_is_ignored(self): channel = _channel( - allow_users=["U0BOT"], allow_direct_messages=True, + allow_users=["U0BOT"], + allow_direct_messages=True, + ) + await channel._handle_message_event( + { + "type": "message", + "channel": "D1", + "channel_type": "im", + "user": "U0BOT", + "ts": "1.1", + "text": "hi", + } ) - await channel._handle_message_event({ - "type": "message", "channel": "D1", "channel_type": "im", - "user": "U0BOT", "ts": "1.1", "text": "hi", - }) channel.router.handle_message.assert_not_called() @pytest.mark.asyncio async def test_a_join_notice_is_ignored(self): channel = _channel(allow_users=["U1"]) - await channel._handle_message_event({ - "type": "message", "subtype": "channel_join", - "channel": "C1", "user": "U1", "ts": "1.1", "text": "joined", - }) + await channel._handle_message_event( + { + "type": "message", + "subtype": "channel_join", + "channel": "C1", + "user": "U1", + "ts": "1.1", + "text": "joined", + } + ) channel.router.handle_message.assert_not_called() @pytest.mark.asyncio async def test_channel_chatter_without_a_mention_is_ignored(self): # Adding the bot to a busy channel must not start a turn per remark. channel = _channel(allow_users=["U1"]) - await channel._handle_message_event({ - "type": "message", "channel": "C1", "channel_type": "channel", - "user": "U1", "ts": "1.1", "text": "morning all", - }) + await channel._handle_message_event( + { + "type": "message", + "channel": "C1", + "channel_type": "channel", + "user": "U1", + "ts": "1.1", + "text": "morning all", + } + ) channel.router.handle_message.assert_not_called() @pytest.mark.asyncio async def test_a_mention_in_a_channel_is_answered(self): channel = _channel(allow_users=["U1"]) - await channel._handle_message_event({ - "type": "message", "channel": "C1", "channel_type": "channel", - "user": "U1", "ts": "1.1", "text": "<@U0BOT> status?", - }) + await channel._handle_message_event( + { + "type": "message", + "channel": "C1", + "channel_type": "channel", + "user": "U1", + "ts": "1.1", + "text": "<@U0BOT> status?", + } + ) msg = channel.router.handle_message.await_args[0][0] assert msg.text == "status?" @pytest.mark.asyncio async def test_a_channel_reply_opens_a_thread_on_the_message(self): channel = _channel(allow_users=["U1"]) - await channel._handle_message_event({ - "type": "message", "channel": "C1", "channel_type": "channel", - "user": "U1", "ts": "1.1", "text": "<@U0BOT> hi", - }) + await channel._handle_message_event( + { + "type": "message", + "channel": "C1", + "channel_type": "channel", + "user": "U1", + "ts": "1.1", + "text": "<@U0BOT> hi", + } + ) msg = channel.router.handle_message.await_args[0][0] assert msg.sender_id == "C1:1.1" assert msg.channel_key == "slack:C1:1.1" @@ -417,22 +470,68 @@ async def test_each_thread_is_its_own_session(self): async def test_thread_replies_continue_a_session_without_a_mention(self): channel = _channel(allow_users=["U1"]) channel.router.get_last_session = AsyncMock(return_value="s1") - await channel._handle_message_event({ - "type": "message", "channel": "C1", "channel_type": "channel", - "user": "U1", "ts": "1.2", "thread_ts": "1.0", "text": "and then?", - }) + await channel._handle_message_event( + { + "type": "message", + "channel": "C1", + "channel_type": "channel", + "user": "U1", + "ts": "1.2", + "thread_ts": "1.0", + "text": "and then?", + } + ) channel.router.handle_message.assert_called_once() @pytest.mark.asyncio - async def test_reply_in_thread_off_keeps_one_session_per_channel(self): - channel = _channel(allow_users=["U1"], reply_in_thread=False) - await channel._handle_message_event({ - "type": "message", "channel": "C1", "channel_type": "channel", - "user": "U1", "ts": "1.1", "thread_ts": "1.0", - "text": "<@U0BOT> hi", - }) + async def test_unowned_thread_reply_without_a_mention_is_ignored(self): + channel = _channel(allow_users=["U1"]) + await channel._handle_message_event( + { + "type": "message", + "channel": "C1", + "channel_type": "channel", + "user": "U1", + "ts": "1.2", + "thread_ts": "1.0", + "text": "hello", + } + ) + channel.router.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_a_mention_claims_an_unowned_thread(self): + channel = _channel(allow_users=["U1"]) + await channel._handle_message_event( + { + "type": "app_mention", + "channel": "C1", + "channel_type": "channel", + "user": "U1", + "ts": "1.2", + "thread_ts": "1.0", + "text": "<@U0BOT> help", + } + ) + message = channel.router.handle_message.await_args.args[0] + assert message.channel_key == "slack:C1:1.0" + + @pytest.mark.asyncio + async def test_an_existing_thread_never_uses_the_channel_key(self): + channel = _channel(allow_users=["U1"]) + await channel._handle_message_event( + { + "type": "message", + "channel": "C1", + "channel_type": "channel", + "user": "U1", + "ts": "1.1", + "thread_ts": "1.0", + "text": "<@U0BOT> hi", + } + ) msg = channel.router.handle_message.await_args[0][0] - assert msg.channel_key == "slack:C1" + assert msg.channel_key == "slack:C1:1.0" @pytest.mark.asyncio async def test_a_redelivered_event_runs_once(self): @@ -484,22 +583,31 @@ async def test_a_reaction_on_a_known_message_reaches_the_router(self): async def test_a_reaction_on_an_unknown_message_is_ignored(self): # Otherwise a stray emoji anywhere in the workspace opens a session. channel = _channel(allow_users=["U1"]) - await channel._handle_reaction_event({ - "type": "reaction_added", "user": "U1", "reaction": "tada", - "item": {"channel": "D1", "ts": "9.9"}, - }) + await channel._handle_reaction_event( + { + "type": "reaction_added", + "user": "U1", + "reaction": "tada", + "item": {"channel": "D1", "ts": "9.9"}, + } + ) channel.router.handle_message.assert_not_called() @pytest.mark.asyncio async def test_an_unauthorized_reaction_is_ignored(self): channel = _channel( - allow_users=["U-other"], allow_direct_messages=True, + allow_users=["U-other"], + allow_direct_messages=True, ) channel._cache_message("1.1", "D1", "the original") - await channel._handle_reaction_event({ - "type": "reaction_added", "user": "U1", "reaction": "tada", - "item": {"channel": "D1", "ts": "1.1"}, - }) + await channel._handle_reaction_event( + { + "type": "reaction_added", + "user": "U1", + "reaction": "tada", + "item": {"channel": "D1", "ts": "1.1"}, + } + ) channel.router.handle_message.assert_not_called() @@ -567,16 +675,9 @@ async def test_send_file_refuses_a_missing_path(self): assert not await _channel().send_file("C1", "/nope/missing.txt") @pytest.mark.asyncio - async def test_the_watchdog_reconnects_a_dropped_socket(self, monkeypatch): - # is_connected() is a coroutine. Reading it without awaiting yields a - # truthy coroutine object, so the watchdog would call a dead socket - # healthy forever and never reconnect. - import nerve.channels.slack as slack_module - - monkeypatch.setattr(slack_module, "WATCHDOG_INTERVAL", 0.01) + async def test_rebuild_uses_the_active_credential_pair(self): channel = _channel() channel._active_app_token = "xapp-active" - channel.config.slack.app_token = "xapp-new-in-config" active_web = channel._web dead = MagicMock() dead.is_connected = AsyncMock(return_value=False) @@ -588,14 +689,7 @@ async def test_the_watchdog_reconnects_a_dropped_socket(self, monkeypatch): fresh.connect = AsyncMock() channel._build_socket_client = MagicMock(return_value=fresh) - task = asyncio.create_task(channel._run_watchdog()) - await asyncio.sleep(0.05) - channel._stopping = True - task.cancel() - try: - await task - except asyncio.CancelledError: - pass + await channel.rebuild_transport() fresh.connect.assert_awaited() # The old socket must be closed first. Slack gives each event to one @@ -630,6 +724,7 @@ async def close_old(): async def connect_candidate(): events.append("candidate connected") + assert not channel.is_available raise RuntimeError("invalid app token") async def close_candidate(): @@ -651,8 +746,10 @@ async def connect_rollback(): rollback.connect = AsyncMock(side_effect=connect_rollback) channel._build_socket_client = MagicMock(return_value=rollback) + desired = _with_credentials(channel, "xoxb-new", "xapp-new") + desired.slack.allow_users = ["U2"] with pytest.raises(RuntimeError, match="previous connection was restored"): - await channel.reload_credentials("xoxb-new", "xapp-new") + await channel.reload_credentials(desired) assert events == [ "old closed", @@ -667,6 +764,8 @@ async def connect_rollback(): assert channel._bot_user_id == "U0OLD" assert channel._bot_id == "B0OLD" assert channel._team_id == "T0OLD" + assert channel.config.slack.allow_users == [] + assert channel.is_available assert channel.needs_credential_reload("xoxb-new", "xapp-new") @pytest.mark.asyncio @@ -682,17 +781,24 @@ async def test_credentials_for_another_workspace_need_a_restart(self): candidate = MagicMock() candidate.close = AsyncMock() - channel._prepare_transport = AsyncMock(return_value=( - MagicMock(), candidate, {"team_id": "T0NEW"}, - )) + channel._prepare_transport = AsyncMock( + return_value=( + MagicMock(), + candidate, + {"team_id": "T0NEW"}, + ) + ) + desired = _with_credentials(channel, "xoxb-new", "xapp-new") + desired.slack.allow_users = ["U2"] with pytest.raises(RuntimeError, match="different workspace"): - await channel.reload_credentials("xoxb-new", "xapp-new") + await channel.reload_credentials(desired) candidate.close.assert_awaited_once() old_client.close.assert_not_awaited() assert channel._web is old_web assert channel._team_id == "T0OLD" + assert channel.config.slack.allow_users == [] @pytest.mark.asyncio async def test_a_socket_handshake_cannot_hold_reload_open_forever( @@ -747,12 +853,16 @@ class TestGuardrailRegressions: @pytest.mark.asyncio async def test_an_uppercase_deny_name_still_forces_a_lookup(self): channel = _channel(deny_users=["ALICE"], allow_channels=["C0456DEF"]) - channel._web.users_info = AsyncMock(return_value={ - "user": {"id": "U999", "name": "ALICE", "profile": {"email": "a@b.c"}}, - }) - channel._web.conversations_info = AsyncMock(return_value={ - "channel": {"id": "C0456DEF", "name": "eng"}, - }) + channel._web.users_info = AsyncMock( + return_value={ + "user": {"id": "U999", "name": "ALICE", "profile": {"email": "a@b.c"}}, + } + ) + channel._web.conversations_info = AsyncMock( + return_value={ + "channel": {"id": "C0456DEF", "name": "eng"}, + } + ) assert not await channel._authorize("U999", "C0456DEF", "channel") channel._web.users_info.assert_awaited() @@ -761,34 +871,47 @@ async def test_a_missing_email_refuses_an_email_deny_rule(self): # users.info answers 200 without profile.email when the token lacks # users:read.email, so the deny pattern silently matched nothing. channel = _channel( - allow_users=["U999"], deny_users=["blocked@x.com"], + allow_users=["U999"], + deny_users=["blocked@x.com"], allow_direct_messages=True, ) - channel._web.users_info = AsyncMock(return_value={ - "user": {"id": "U999", "name": "blocked", "profile": {}}, - }) + channel._web.users_info = AsyncMock( + return_value={ + "user": {"id": "U999", "name": "blocked", "profile": {}}, + } + ) assert not await channel._authorize("U999", "D1", "im") @pytest.mark.asyncio async def test_an_email_deny_rule_still_works_with_the_scope(self): channel = _channel( - allow_users=["*"], deny_users=["blocked@x.com"], + allow_users=["*"], + deny_users=["blocked@x.com"], allow_direct_messages=True, ) - channel._web.users_info = AsyncMock(return_value={ - "user": {"id": "U9", "name": "b", "profile": {"email": "blocked@x.com"}}, - }) + channel._web.users_info = AsyncMock( + return_value={ + "user": { + "id": "U9", + "name": "b", + "profile": {"email": "blocked@x.com"}, + }, + } + ) assert not await channel._authorize("U9", "D1", "im") @pytest.mark.asyncio async def test_an_innocent_user_is_not_caught_by_an_email_deny_rule(self): channel = _channel( - allow_users=["*"], deny_users=["blocked@x.com"], + allow_users=["*"], + deny_users=["blocked@x.com"], allow_direct_messages=True, ) - channel._web.users_info = AsyncMock(return_value={ - "user": {"id": "U1", "name": "ok", "profile": {"email": "ok@x.com"}}, - }) + channel._web.users_info = AsyncMock( + return_value={ + "user": {"id": "U1", "name": "ok", "profile": {"email": "ok@x.com"}}, + } + ) assert await channel._authorize("U1", "D1", "im") @pytest.mark.asyncio @@ -916,13 +1039,14 @@ async def test_live_disable_bounds_the_drain(self, monkeypatch): class TestNotificationBlockLimits: def test_options_are_chunked_to_slacks_actions_limit(self): - import nerve.channels.slack as slack_module + import nerve.channels.slack_presentation as presentation_module options = [(f"opt{i}", f"v{i}") for i in range(60)] blocks = build_notification_blocks("pick", "n1", options) actions = [b for b in blocks if b["type"] == "actions"] assert all( - len(b["elements"]) <= slack_module._MAX_ACTION_ELEMENTS for b in actions + len(b["elements"]) <= presentation_module._MAX_ACTION_ELEMENTS + for b in actions ) assert sum(len(b["elements"]) for b in actions) == 60 @@ -1032,7 +1156,7 @@ class TestSlashCommandsAreThreadBlind: def _ch(self, sessions, **kw): channel = _channel(allow_users=["U1"], **kw) channel.router.list_conversation_sessions = AsyncMock(return_value=sessions) - channel.router.engine.stop_session = AsyncMock(return_value=True) + channel.router.stop_session = AsyncMock(return_value=True) channel._web.chat_postEphemeral = AsyncMock(return_value={"ok": True}) return channel @@ -1047,7 +1171,7 @@ async def test_a_thread_session_is_found_from_a_channel_command(self): # beside it is busy, and stop used to report "No active session". channel = self._ch([self._row("s1", thread="1.0")]) await channel._cmd_stop("C1", "U1", "slack:C1") - channel.router.engine.stop_session.assert_awaited_once_with("s1") + channel.router.stop_session.assert_awaited_once_with("s1") said = channel._web.chat_postEphemeral.await_args.kwargs["text"] assert "s1" in said and "thread" in said @@ -1055,22 +1179,29 @@ async def test_a_thread_session_is_found_from_a_channel_command(self): async def test_nothing_live_says_so_plainly(self): channel = self._ch([]) await channel._cmd_stop("C1", "U1", "slack:C1") - channel.router.engine.stop_session.assert_not_called() - assert "No active session" in channel._web.chat_postEphemeral.await_args.kwargs["text"] + channel.router.stop_session.assert_not_called() + assert ( + "No active session" + in channel._web.chat_postEphemeral.await_args.kwargs["text"] + ) @pytest.mark.asyncio async def test_several_live_sessions_ask_instead_of_guessing(self): # Stopping someone else's thread silently would be worse than asking. - channel = self._ch([ - self._row("s1", thread="1.0", title="Deploy"), - self._row("s2", thread="2.0", title="Triage"), - ]) + channel = self._ch( + [ + self._row("s1", thread="1.0", title="Deploy"), + self._row("s2", thread="2.0", title="Triage"), + ] + ) await channel._cmd_stop("C1", "U1", "slack:C1") - channel.router.engine.stop_session.assert_not_called() + channel.router.stop_session.assert_not_called() blocks = channel._web.chat_postEphemeral.await_args.kwargs["blocks"] action_ids = [ e["action_id"] - for b in blocks if b["type"] == "actions" for e in b["elements"] + for b in blocks + if b["type"] == "actions" + for e in b["elements"] ] assert action_ids == ["sessstop:s1", "sessstop:s2"] @@ -1078,26 +1209,40 @@ async def test_several_live_sessions_ask_instead_of_guessing(self): async def test_the_picker_button_stops_the_chosen_session(self): channel = self._ch([]) channel._replace_via_url = AsyncMock() - await channel._handle_interactive({ - "type": "block_actions", - "user": {"id": "U1"}, - "channel": {"id": "C1"}, - "response_url": "https://hooks.slack.test/x", - "actions": [{"action_id": "sessstop:s2", "value": "s2"}], - }) - channel.router.engine.stop_session.assert_awaited_once_with("s2") + await channel._handle_interactive( + { + "type": "block_actions", + "user": {"id": "U1"}, + "channel": {"id": "C1"}, + "response_url": "https://hooks.slack.test/x", + "actions": [{"action_id": "sessstop:s2", "value": "s2"}], + } + ) + channel.router.stop_session.assert_awaited_once_with("s2") @pytest.mark.asyncio async def test_another_channels_sessions_are_not_touched(self): - # "slack:C1" is a prefix of "slack:C12", so the query result is - # re-checked per row. - channel = self._ch([ - {"channel_key": "slack:C12:9.9", "session_id": "other"}, - self._row("mine", thread="1.0"), - ]) + # Keep the adapter defensive even though the shared query is exact. + channel = self._ch( + [ + {"channel_key": "slack:C12:9.9", "session_id": "other"}, + self._row("mine", thread="1.0"), + ] + ) found = await channel._live_sessions_for_channel("C1") assert [r["session_id"] for r in found] == ["mine"] + @pytest.mark.asyncio + async def test_a_legacy_channel_level_mapping_is_never_consumed(self): + channel = self._ch( + [ + self._row("legacy"), + self._row("thread", thread="1.0"), + ] + ) + found = await channel._live_sessions_for_channel("C1") + assert [row["session_id"] for row in found] == ["thread"] + @pytest.mark.asyncio async def test_star_also_resolves_across_threads(self): channel = self._ch([self._row("s1", thread="1.0")]) @@ -1126,16 +1271,12 @@ def test_operator_commands_are_off_by_default(self): enabled = self._ch().enabled_commands assert "doctor" not in enabled assert "restart" not in enabled - assert {"new", "stop", "star", "unstar"} <= enabled + assert {"new", "stop", "star", "unstar", "reply"} <= enabled - def test_globally_scoped_commands_are_off_by_default(self): - # sessions lists and attaches every session in the instance, and - # reply answers whichever question is pending anywhere. In a - # workspace where several people may DM the bot, that is one - # member reading and continuing another's work. + def test_only_the_globally_scoped_session_list_is_off_by_default(self): enabled = self._ch().enabled_commands assert "sessions" not in enabled - assert "reply" not in enabled + assert "reply" in enabled @pytest.mark.asyncio async def test_sessions_is_refused_unless_it_was_asked_for(self): @@ -1146,11 +1287,20 @@ async def test_sessions_is_refused_unless_it_was_asked_for(self): channel.router.list_sessions.assert_not_called() @pytest.mark.asyncio - async def test_reply_is_refused_unless_it_was_asked_for(self): + async def test_reply_is_scoped_to_the_slack_conversation_and_actor(self): channel = self._ch() channel._notification_service = MagicMock() + channel._notification_service.answer_latest_question = AsyncMock( + return_value={"title": "Proceed?"}, + ) said = await self._run(channel, "reply yes") - assert "turned off" in said + assert "Proceed?" in said + channel._notification_service.answer_latest_question.assert_awaited_once_with( + "yes", + channel="slack", + target="C1", + actor="U1", + ) def test_both_are_still_available_on_request(self): enabled = self._ch(commands=["sessions", "reply"]).enabled_commands @@ -1165,15 +1315,17 @@ def test_an_empty_list_turns_every_command_off(self): def test_all_enables_everything(self): from nerve.config import SLACK_ALL_COMMANDS - assert self._ch(commands=["all"]).enabled_commands == frozenset(SLACK_ALL_COMMANDS) + assert self._ch(commands=["all"]).enabled_commands == frozenset( + SLACK_ALL_COMMANDS + ) @pytest.mark.asyncio async def test_a_disabled_command_is_refused_not_run(self): channel = self._ch(commands=["reply"]) - channel.router.engine.stop_session = AsyncMock() + channel.router.stop_session = AsyncMock() said = await self._run(channel, "stop") assert "turned off" in said - channel.router.engine.stop_session.assert_not_called() + channel.router.stop_session.assert_not_called() @pytest.mark.asyncio async def test_restart_is_refused_by_default(self): @@ -1198,13 +1350,7 @@ async def test_help_says_so_when_nothing_is_enabled(self): class TestCommandsBindTheKeyMessagesRead: - """A slash command can only name ``slack:``. - - With ``reply_in_thread`` on, a channel message opens a thread and routes - to ``slack::``, so a session bound at channel level was - never read again: `/nerve new` reported a new session, left the running - thread alone, and the next mention started somewhere else. - """ + """Slash commands bind only DMs; shared messages always bind threads.""" def _ch(self, **kw): channel = _channel( @@ -1213,7 +1359,7 @@ def _ch(self, **kw): channel._web.chat_postEphemeral = AsyncMock(return_value={"ok": True}) channel.router.create_session = AsyncMock(return_value="s-new") channel.router.switch_session = AsyncMock() - channel.router.engine.stop_session = AsyncMock(return_value=True) + channel.router.stop_session = AsyncMock(return_value=True) channel.router.list_sessions = AsyncMock(return_value=[]) return channel @@ -1240,14 +1386,16 @@ async def test_a_dm_command_binds_the_key_a_dm_message_reads(self): assert bound == routed == "slack:D1" @pytest.mark.asyncio - async def test_a_dm_thread_keeps_a_session_of_its_own(self): - # A reply inside a DM thread is its own conversation, so it does not - # pick up what the command bound to the DM itself. + async def test_a_dm_thread_uses_the_dm_conversation(self): channel = self._ch() routed = await self._route( - channel, channel="D1", channel_type="im", ts="1.2", thread_ts="1.0", + channel, + channel="D1", + channel_type="im", + ts="1.2", + thread_ts="1.0", ) - assert routed == "slack:D1:1.0" + assert routed == "slack:D1" @pytest.mark.asyncio async def test_a_threaded_channel_refuses_rather_than_orphaning_a_session(self): @@ -1259,37 +1407,21 @@ async def test_a_threaded_channel_refuses_rather_than_orphaning_a_session(self): said = await self._run(channel, "C1", "new") channel.router.create_session.assert_not_called() - channel.router.engine.stop_session.assert_not_called() + channel.router.stop_session.assert_not_called() assert "needs a thread" in said @pytest.mark.asyncio async def test_a_thread_reply_is_not_stopped_by_a_channel_command(self): channel = self._ch() await self._route( - channel, channel="C1", channel_type="channel", - ts="1.2", thread_ts="1.0", + channel, + channel="C1", + channel_type="channel", + ts="1.2", + thread_ts="1.0", ) await self._run(channel, "C1", "new") - channel.router.engine.stop_session.assert_not_called() - - @pytest.mark.asyncio - async def test_an_unthreaded_channel_binds_the_key_a_message_reads(self): - channel = self._ch(reply_in_thread=False) - routed = await self._route( - channel, channel="C1", channel_type="channel", ts="1.1", - ) - await self._run(channel, "C1", "new") - bound = channel.router.create_session.await_args[0][0] - assert bound == routed == "slack:C1" - - @pytest.mark.asyncio - async def test_an_unthreaded_thread_reply_shares_the_channel_session(self): - channel = self._ch(reply_in_thread=False) - routed = await self._route( - channel, channel="C1", channel_type="channel", - ts="1.2", thread_ts="1.0", - ) - assert routed == "slack:C1" + channel.router.stop_session.assert_not_called() @pytest.mark.asyncio async def test_the_session_picker_is_refused_in_a_threaded_channel(self): @@ -1507,11 +1639,11 @@ def test_the_option_buttons_still_follow_the_text(self): def test_a_body_past_the_block_limit_says_what_it_dropped(self): # 50 blocks is a hard limit on the whole message, so the only # content that can be lost is content Slack would refuse anyway. - import nerve.channels.slack as slack_module + import nerve.channels.slack_presentation as presentation_module blocks = build_notification_blocks("z" * 400_000, "n1") sections = self._sections(blocks) - assert len(sections) == slack_module._MAX_SECTION_BLOCKS + assert len(sections) == presentation_module._MAX_SECTION_BLOCKS assert "more characters" in sections[-1] @@ -1556,8 +1688,12 @@ async def _answer(self, monkeypatch, raw: str = RAW): channel = _channel(allow_users=["U1"]) service = MagicMock() - service.handle_answer = AsyncMock(return_value=True) - service.db.get_notification = AsyncMock(return_value=None) + service.answer_delivered_notification = AsyncMock( + return_value={ + "status": "answered", + "redeliver_at": None, + } + ) channel._notification_service = service blocks = build_notification_blocks(raw, "n1", [("Approve", "approve")]) @@ -1626,20 +1762,33 @@ async def _press(self, action_id="notif:n1:approve", value="approve"): channel = _channel(allow_users=["U0123ABC"]) channel._replace_via_url = AsyncMock() service = MagicMock() - service.handle_answer = AsyncMock(return_value=True) - service.db.get_notification = AsyncMock(return_value=None) + service.answer_delivered_notification = AsyncMock( + return_value={ + "status": "answered", + "redeliver_at": None, + } + ) channel._notification_service = service - await channel._handle_interactive({ - "type": "block_actions", - "user": {"id": "U0123ABC"}, - "channel": {"id": "C1"}, - "response_url": "https://hooks.slack.test/x", - "actions": [{"action_id": action_id, "value": value}], - "message": {"blocks": build_notification_blocks("Ship it?", "n1")}, - }) + await channel._handle_interactive( + { + "type": "block_actions", + "user": {"id": "U0123ABC"}, + "channel": {"id": "C1"}, + "response_url": "https://hooks.slack.test/x", + "actions": [{"action_id": action_id, "value": value}], + "message": {"blocks": build_notification_blocks("Ship it?", "n1")}, + } + ) return channel, service @pytest.mark.asyncio async def test_the_settled_card_names_who_answered(self): - channel, _ = await self._press() + channel, service = await self._press() assert "(by <@U0123ABC>)" in channel._replace_via_url.await_args[0][1] + service.answer_delivered_notification.assert_awaited_once_with( + "n1", + "approve", + channel="slack", + target="C1", + actor="U0123ABC", + ) diff --git a/tests/test_slack_integration.py b/tests/test_slack_integration.py index 4ec2036a..e1248405 100644 --- a/tests/test_slack_integration.py +++ b/tests/test_slack_integration.py @@ -8,6 +8,7 @@ from __future__ import annotations +import copy from unittest.mock import AsyncMock, MagicMock import pytest @@ -41,7 +42,7 @@ async def _started(server: FakeSlack, monkeypatch, **slack_kwargs): router = MagicMock() router.handle_message = AsyncMock(return_value="ok") router.get_last_session = AsyncMock(return_value=None) - channel = SlackChannel(lambda: cfg, router) + channel = SlackChannel(cfg, router) server.patch_client(monkeypatch) await channel.start() await server.wait_connected() @@ -70,7 +71,10 @@ async def test_credentials_rotate_on_the_running_channel( ) old_client = channel._client try: - await channel.reload_credentials("xoxb-replaced", "xapp-replaced") + config = copy.deepcopy(channel.config) + config.slack.bot_token = "xoxb-replaced" + config.slack.app_token = "xapp-replaced" + await channel.reload_credentials(config) assert channel._client is not old_client assert not await old_client.is_connected() @@ -99,7 +103,10 @@ async def test_invalid_new_credentials_keep_the_old_connection( slack.errors[failed_method] = "invalid_auth" try: with pytest.raises(RuntimeError, match="token failed validation"): - await channel.reload_credentials(bot_token, app_token) + config = copy.deepcopy(channel.config) + config.slack.bot_token = bot_token + config.slack.app_token = app_token + await channel.reload_credentials(config) assert channel._client is old_client assert await old_client.is_connected() @@ -148,8 +155,10 @@ async def test_an_unauthorized_envelope_is_still_acked( async def test_stop_closes_the_socket(self, slack, monkeypatch): channel, _ = await _started(slack, monkeypatch, allow_users=["U1"]) + client = channel._client await channel.stop() - assert not await channel._client.is_connected() + assert channel._client is None + assert not await client.is_connected() @pytest.mark.asyncio diff --git a/tests/test_slack_runtime.py b/tests/test_slack_runtime.py new file mode 100644 index 00000000..ae2a132e --- /dev/null +++ b/tests/test_slack_runtime.py @@ -0,0 +1,325 @@ +"""Slack lifecycle ownership, serialization, and config generations.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from nerve.channels.slack_runtime import SlackRuntime, SlackRuntimeError +from nerve.config import NerveConfig, SlackConfig + + +class _Router: + def __init__(self) -> None: + self.channels: dict[str, Any] = {} + self.registered: list[Any] = [] + self.unregistered: list[Any] = [] + + def register(self, channel) -> None: + self.channels[channel.name] = channel + self.registered.append(channel) + + def unregister(self, channel) -> bool: + if self.channels.get(channel.name) is not channel: + return False + del self.channels[channel.name] + self.unregistered.append(channel) + return True + + def get_channel(self, name: str): + return self.channels.get(name) + + +class _FakeChannel: + name = "slack" + instances: list[_FakeChannel] = [] + start_hook = None + stop_hook = None + reload_hook = None + rebuild_hook = None + connected = True + + def __init__(self, config, router) -> None: + self.config = config + self.router = router + self.service = None + self.state = "stopped" + self.web = None + self.bot_token = config.slack.bot_token + self.app_token = config.slack.app_token + self.stop_calls: list[bool] = [] + type(self).instances.append(self) + + @property + def is_available(self) -> bool: + return self.state == "running" and self.web is not None + + def set_notification_service(self, service) -> None: + self.service = service + + async def start(self) -> None: + self.state = "starting" + hook = type(self).start_hook + if hook: + await hook(self) + self.web = object() + self.state = "running" + + async def stop(self, *, drain: bool = False) -> None: + self.state = "quiescing" + self.stop_calls.append(drain) + hook = type(self).stop_hook + if hook: + await hook(self, drain) + self.web = None + self.state = "stopped" + + def needs_credential_reload(self, bot_token: str, app_token: str) -> bool: + return (bot_token, app_token) != (self.bot_token, self.app_token) + + async def reload_credentials(self, config) -> None: + hook = type(self).reload_hook + if hook: + await hook(self, config) + self.bot_token = config.slack.bot_token + self.app_token = config.slack.app_token + self.config = config + + def apply_config(self, config) -> None: + self.config = config + + async def transport_connected(self) -> bool: + return type(self).connected + + async def rebuild_transport(self) -> None: + hook = type(self).rebuild_hook + if hook: + await hook(self) + + @property + def seconds_since_last_event(self) -> float: + return 0.0 + + +def _config( + *, + enabled: bool = True, + bot_token: str = "xoxb-old", + app_token: str = "xapp-old", + allow_users: list[str] | None = None, +) -> NerveConfig: + config = NerveConfig() + config.slack = SlackConfig( + enabled=enabled, + bot_token=bot_token, + app_token=app_token, + allow_users=allow_users or [], + ) + return config + + +@pytest.fixture(autouse=True) +def _fake_channel(monkeypatch): + from nerve.channels import slack_runtime + + _FakeChannel.instances = [] + _FakeChannel.start_hook = None + _FakeChannel.stop_hook = None + _FakeChannel.reload_hook = None + _FakeChannel.rebuild_hook = None + _FakeChannel.connected = True + monkeypatch.setattr(slack_runtime, "SlackChannel", _FakeChannel) + + +@pytest.mark.asyncio +async def test_concurrent_enables_construct_and_register_once(): + router = _Router() + runtime = SlackRuntime(router) + entered = asyncio.Event() + release = asyncio.Event() + + async def slow_start(channel): + entered.set() + await release.wait() + + _FakeChannel.start_hook = slow_start + first = asyncio.create_task(runtime.reconcile(_config())) + second = asyncio.create_task(runtime.reconcile(_config())) + await entered.wait() + await asyncio.sleep(0) + + assert len(_FakeChannel.instances) == 1 + assert len(router.registered) == 1 + assert not second.done() + + release.set() + assert await asyncio.gather(first, second) == ["enabled", None] + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_enable_is_routable_before_the_socket_can_deliver(): + router = _Router() + runtime = SlackRuntime(router) + + async def assert_publication(channel): + assert router.get_channel("slack") is channel + assert not channel.is_available + + _FakeChannel.start_hook = assert_publication + await runtime.reconcile(_config()) + assert runtime.channel is router.get_channel("slack") + assert runtime.channel.is_available + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_failed_enable_is_absent_redacted_and_retryable(): + router = _Router() + runtime = SlackRuntime(router) + attempts = 0 + + async def fail_once(channel): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise RuntimeError("rejected xoxb-secret and xapp-secret") + + _FakeChannel.start_hook = fail_once + desired = _config(bot_token="xoxb-secret", app_token="xapp-secret") + with pytest.raises(SlackRuntimeError) as failure: + await runtime.reconcile(desired) + + assert "xoxb-secret" not in str(failure.value) + assert "xapp-secret" not in str(failure.value) + assert runtime.channel is None + assert router.get_channel("slack") is None + assert await runtime.reconcile(desired) == "enabled" + assert attempts == 2 + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_disable_quiesces_and_drains_before_unregistering(): + router = _Router() + runtime = SlackRuntime(router) + await runtime.reconcile(_config()) + channel = runtime.channel + entered = asyncio.Event() + release = asyncio.Event() + + async def slow_stop(candidate, drain): + assert drain is True + assert router.get_channel("slack") is candidate + entered.set() + await release.wait() + + _FakeChannel.stop_hook = slow_stop + disabling = asyncio.create_task(runtime.reconcile(_config(enabled=False))) + await entered.wait() + + assert router.get_channel("slack") is channel + assert not channel.is_available + release.set() + assert await disabling == "disabled" + assert router.get_channel("slack") is None + + +@pytest.mark.asyncio +async def test_failed_rotation_retains_the_whole_active_generation(): + router = _Router() + runtime = SlackRuntime(router) + original = _config(allow_users=["U1"]) + await runtime.reconcile(original) + channel = runtime.channel + + async def fail_rotation(candidate, desired): + raise RuntimeError( + f"could not use {desired.slack.bot_token} with {desired.slack.app_token}", + ) + + _FakeChannel.reload_hook = fail_rotation + desired = _config( + bot_token="xoxb-new", + app_token="xapp-new", + allow_users=["U2"], + ) + with pytest.raises(SlackRuntimeError) as failure: + await runtime.reconcile(desired) + + assert "xoxb-new" not in str(failure.value) + assert "xapp-new" not in str(failure.value) + assert channel.config.slack.allow_users == ["U1"] + assert runtime.active_config.slack.allow_users == ["U1"] + assert router.get_channel("slack") is channel + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_config_only_reload_advances_without_transport_replacement(): + router = _Router() + runtime = SlackRuntime(router) + await runtime.reconcile(_config(allow_users=["U1"])) + channel = runtime.channel + desired = _config(allow_users=["U2"]) + + assert await runtime.reconcile(desired) is None + assert runtime.channel is channel + assert channel.config.slack.allow_users == ["U2"] + assert len(_FakeChannel.instances) == 1 + await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_unrecoverable_rotation_fails_closed(): + router = _Router() + runtime = SlackRuntime(router) + await runtime.reconcile(_config()) + + async def lose_transport(channel, desired): + channel.state = "stopped" + channel.web = None + raise RuntimeError("rollback failed") + + _FakeChannel.reload_hook = lose_transport + with pytest.raises(SlackRuntimeError, match="rollback failed"): + await runtime.reconcile( + _config(bot_token="xoxb-new", app_token="xapp-new"), + ) + + assert runtime.channel is None + assert router.get_channel("slack") is None + + +@pytest.mark.asyncio +async def test_watchdog_rebuild_serializes_with_config_reconcile(monkeypatch): + from nerve.channels import slack_runtime as runtime_module + + monkeypatch.setattr(runtime_module, "WATCHDOG_INTERVAL", 0.001) + router = _Router() + runtime = SlackRuntime(router) + entered = asyncio.Event() + release = asyncio.Event() + _FakeChannel.connected = False + + async def slow_rebuild(channel): + entered.set() + await release.wait() + type(channel).connected = True + + _FakeChannel.rebuild_hook = slow_rebuild + await runtime.reconcile(_config(allow_users=["U1"])) + await entered.wait() + + reconcile = asyncio.create_task( + runtime.reconcile(_config(allow_users=["U2"])), + ) + await asyncio.sleep(0) + assert not reconcile.done() + + release.set() + assert await reconcile is None + assert runtime.channel.config.slack.allow_users == ["U2"] + await runtime.shutdown() From 459033c647df5fe898a55946093bc85dbca1209c Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 25 Aug 2026 15:04:09 +0200 Subject: [PATCH 13/18] Close two fail-open paths in Slack access control A button press was authorized only when the payload carried a channel, so a block_actions envelope without one reached stop_session, the star toggle, and the notification answer route with no policy check at all. Slack omits the channel for interactions on a view surface. Refuse a press that names no conversation, then authorize every press. Allow rules matched profile.display_name and profile.real_name, which a member edits at will, so any member could rename themselves onto allow_users and take a full agent turn. Split the resolved aliases: a grant may rest on the member ID, the handle, or the verified email, while a deny rule keeps matching the self-set names too. A grant that matches only a self-set name is refused with a reason that names the fix. --- config.example.yaml | 2 + docs/config.md | 13 ++++-- nerve/channels/slack.py | 35 ++++++++++++--- tests/test_slack_channel.py | 88 +++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 9 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 6bd7ff31..b2b0565b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -121,6 +121,8 @@ slack: # # Access patterns match Slack IDs or names, case-insensitively with globs. # Deny wins, DMs require explicit opt-in, and no allow grant refuses everyone. + # Grant users by member ID, handle, or email only: a member edits their own + # display and full name, so allow_users never matches those. # allow_users: ["U0123ABC", "alex.soffronow"] # deny_users: ["*-bot"] allow_direct_messages: false diff --git a/docs/config.md b/docs/config.md index bd618f09..eeb839ea 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1162,9 +1162,16 @@ refuses the message and logs why. ### Guardrails -Patterns match Slack IDs (`U0123ABC`, `C0456DEF`), handles, display names, -emails, or channel names. Matching is case-insensitive and supports globs. -Use raw IDs to avoid name lookups. +Patterns match Slack IDs (`U0123ABC`, `C0456DEF`), handles, emails, or +channel names. Matching is case-insensitive and supports globs. Use raw IDs +to avoid name lookups. + +A member edits their own display name and full name, so an `allow_users` +rule never grants on those. Write user grants against the member ID, the +handle, or the email. `deny_users` does match display and full names, +because refusing on more names than a grant may rest on is always safe. A +grant that matches only a self-set name is refused and logged, so the rule +does not fail silently. ```yaml slack: diff --git a/nerve/channels/slack.py b/nerve/channels/slack.py index 0cccd8b2..09af8b05 100644 --- a/nerve/channels/slack.py +++ b/nerve/channels/slack.py @@ -715,6 +715,11 @@ async def _identify_user( answers 200, so an absent email there is indistinguishable from a user who has none — either way the candidate set is short of what the deny list needs, and the identity is marked incomplete. + + A member edits ``display_name`` and ``real_name`` at will, so those + reach the policy as self-set names that only a deny rule may match. + The handle and the email are provisioned or verified, so a grant may + rest on them. """ if not resolve: return Identity(id=user_id) @@ -728,14 +733,18 @@ async def _identify_user( profile = user.get("profile") or {} email = profile.get("email") names = tuple( + n for n in (user.get("name"), email) if n + ) + self_set_names = tuple( n for n in ( - user.get("name"), profile.get("display_name"), profile.get("real_name"), - email, ) if n ) - complete = bool(names) and (email is not None or not need_email) + complete = ( + bool(names or self_set_names) + and (email is not None or not need_email) + ) if need_email and email is None: logger.warning( "Slack users.info returned no email for %s — the deny list " @@ -743,7 +752,12 @@ async def _identify_user( "or write the deny rule against the handle or id instead.", user_id, ) - identity = Identity(id=user_id, names=names, complete=complete) + identity = Identity( + id=user_id, + names=names, + self_set_names=self_set_names, + complete=complete, + ) except Exception as e: logger.warning("Slack users.info failed for %s: %s", user_id, e) identity = Identity(id=user_id, complete=False) @@ -1687,11 +1701,20 @@ async def _handle_interactive(self, payload: dict[str, Any]) -> None: user_id = (payload.get("user") or {}).get("id") or "" channel_id = (payload.get("channel") or {}).get("id") or "" response_url = payload.get("response_url") or "" - if not user_id: + # Both halves of the policy need a subject and a conversation. Slack + # omits the conversation for interactions on a view surface, which + # this app does not publish, so a press without one is refused rather + # than run against half a policy. + if not user_id or not channel_id: + logger.warning( + "Slack refused a %s press with no %s", + action_id or "button", + "sender" if not user_id else "conversation", + ) return channel_type = "im" if channel_id.startswith("D") else "channel" - if channel_id and not await self._authorize(user_id, channel_id, channel_type): + if not await self._authorize(user_id, channel_id, channel_type): return if action_id.startswith("sessstop:"): diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index 560c8c5c..cb5e1d08 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -297,6 +297,51 @@ async def test_a_handle_allow_list_resolves_the_name(self): ) assert await channel._authorize("U1", "D1", "im") + @pytest.mark.asyncio + async def test_a_spoofed_profile_name_does_not_grant_access(self): + # A member edits their own full name, so an allow list must not + # grant on it: anyone could rename themselves onto the list. + channel = _channel( + allow_users=["alex.soffronow"], allow_direct_messages=True, + ) + channel._web.users_info = AsyncMock( + return_value={"user": { + "name": "mallory", + "profile": { + "real_name": "alex.soffronow", + "display_name": "alex.soffronow", + }, + }}, + ) + assert not await channel._authorize("U-mallory", "D1", "im") + + @pytest.mark.asyncio + async def test_a_deny_rule_still_matches_a_profile_name(self): + channel = _channel( + allow_users=["*"], deny_users=["*-bot"], + allow_direct_messages=True, + ) + channel._web.users_info = AsyncMock( + return_value={"user": { + "name": "integration-42", + "profile": {"display_name": "deploy-bot", "email": "i@x.test"}, + }}, + ) + assert not await channel._authorize("U-int", "D1", "im") + + @pytest.mark.asyncio + async def test_an_email_allow_list_grants(self): + channel = _channel( + allow_users=["alex@clickhouse.com"], allow_direct_messages=True, + ) + channel._web.users_info = AsyncMock( + return_value={"user": { + "name": "alex", + "profile": {"email": "alex@clickhouse.com"}, + }}, + ) + assert await channel._authorize("U1", "D1", "im") + @pytest.mark.asyncio async def test_a_failed_lookup_with_a_deny_list_refuses(self): channel = _channel( @@ -1220,6 +1265,49 @@ async def test_the_picker_button_stops_the_chosen_session(self): ) channel.router.stop_session.assert_awaited_once_with("s2") + @pytest.mark.asyncio + async def test_a_press_from_an_unauthorized_sender_is_refused(self): + channel = self._ch([]) + channel.config.slack.allow_users = ["U-owner"] + channel._replace_via_url = AsyncMock() + await channel._handle_interactive( + { + "type": "block_actions", + "user": {"id": "U-mallory"}, + "channel": {"id": "C1"}, + "response_url": "https://hooks.slack.test/x", + "actions": [{"action_id": "sessstop:s2", "value": "s2"}], + } + ) + channel.router.stop_session.assert_not_called() + + @pytest.mark.asyncio + async def test_a_press_with_no_conversation_is_refused(self): + # The conversation half of the policy cannot run without a channel, + # so the press is refused rather than checked against half of it. + channel = self._ch([]) + channel.config.slack.allow_users = ["U-owner"] + channel._replace_via_url = AsyncMock() + channel._notification_service = MagicMock() + channel._notification_service.answer_delivered_notification = AsyncMock() + for action_id, value in ( + ("sessstop:s2", "s2"), + ("starpick:s2", "s2"), + ("notif:n1:yes", "yes"), + ): + await channel._handle_interactive( + { + "type": "block_actions", + "user": {"id": "U-mallory"}, + "response_url": "https://hooks.slack.test/x", + "actions": [{"action_id": action_id, "value": value}], + } + ) + channel.router.stop_session.assert_not_called() + channel.router.set_session_starred.assert_not_called() + channel._notification_service.answer_delivered_notification\ + .assert_not_awaited() + @pytest.mark.asyncio async def test_another_channels_sessions_are_not_touched(self): # Keep the adapter defensive even though the shared query is exact. From 993ca7389c24255fb58b06477c0d0dbfd7d32ea0 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 25 Aug 2026 15:08:09 +0200 Subject: [PATCH 14/18] Ignore messages another app wrote itself A shared-channel reply continues an owned thread with no further mention, so two agents in one channel answered each other without end once a person mentioned both. With a channel-only grant, which docs/config.md offers for shared channels, nothing stopped the exchange. bot_id alone cannot decide it: a person posting through an integration keeps their own user id and gains the app's bot_id, and those messages are meant to arrive. Ask users.info whether the sender is a bot user, cache the verdict beside the resolved names, and treat an unresolved sender next to a bot_id as an app. A message with no bot_id costs no lookup. --- docs/config.md | 3 ++ nerve/channels/slack.py | 49 ++++++++++++++++++++--- tests/test_slack_channel.py | 80 +++++++++++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/docs/config.md b/docs/config.md index eeb839ea..8dae5da0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1201,6 +1201,9 @@ slack: session. - Each shared-channel thread has its own session and all replies stay there; shared channels never have a channel-wide session. +- Messages another app wrote itself are ignored, so two agents in one channel + cannot answer each other without end. A person posting through an + integration still reaches the agent, because they keep their own user ID. - `/nerve` responses are ephemeral. ### Commands diff --git a/nerve/channels/slack.py b/nerve/channels/slack.py index 09af8b05..12f3a780 100644 --- a/nerve/channels/slack.py +++ b/nerve/channels/slack.py @@ -850,16 +850,51 @@ async def _handle_event(self, event: dict[str, Any]) -> None: await self._handle_reaction_event(event) def _is_own_message(self, event: dict[str, Any]) -> bool: - """Whether this app posted the message. - - A foreign ``bot_id`` may represent a person using an integration, so - only this app's bot and user IDs count. The ``bot_message`` subtype - filters other bots. - """ + """Whether this app posted the message.""" if self._bot_id and event.get("bot_id") == self._bot_id: return True return bool(self._bot_user_id and event.get("user") == self._bot_user_id) + async def _is_another_app_talking(self, event: dict[str, Any]) -> bool: + """Whether another app authored this message itself. + + ``bot_id`` alone does not answer it: a person posting through an + integration keeps their own ``user`` id and gains the app's + ``bot_id``, and those messages are meant to reach the agent. What + separates the two is whether the sender is a bot user, which only + ``users.info`` can say. + + Answering another app is what lets two agents in one channel reply + to each other without end, because a reply continues an owned thread + with no further mention needed. An unresolved sender beside a + ``bot_id`` is therefore treated as an app. + """ + if not event.get("bot_id"): + return False + user_id = event.get("user") or "" + if not user_id: + return True + + cache_key = f"bot:{user_id}" + cached = self._name_cache.get(cache_key) + if cached and cached[1] > time.monotonic(): + return bool(cached[0]) + try: + info = await self._web.users_info(user=user_id) + is_bot = bool((info.get("user") or {}).get("is_bot")) + except Exception as e: + logger.warning( + "Slack users.info failed for %s beside bot_id %s, so the " + "message is treated as another app's: %s", + user_id, event.get("bot_id"), e, + ) + is_bot = True + self._remember( + self._name_cache, cache_key, + (is_bot, time.monotonic() + _NAME_CACHE_TTL), _NAME_CACHE_MAX, + ) + return is_bot + async def _should_answer( self, event: dict[str, Any], channel_type: str, channel_key: str, ) -> bool: @@ -887,6 +922,8 @@ async def _handle_message_event(self, event: dict[str, Any]) -> None: subtype = event.get("subtype") if subtype in _IGNORED_SUBTYPES: return + if await self._is_another_app_talking(event): + return channel_id = event.get("channel") or "" user_id = event.get("user") or "" diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index cb5e1d08..a2046f40 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -1158,6 +1158,9 @@ def test_a_plain_human_message_is_not_ours(self): @pytest.mark.asyncio async def test_a_human_posting_through_an_app_reaches_the_router(self): channel = self._ch() + channel._web.users_info = AsyncMock( + return_value={"user": {"is_bot": False, "profile": {}}}, + ) await channel._handle_message_event( self._event(bot_id="B0OTHERAPP", text="<@U0BOT> via an integration"), ) @@ -1165,15 +1168,86 @@ async def test_a_human_posting_through_an_app_reaches_the_router(self): assert msg.text == "via an integration" @pytest.mark.asyncio - async def test_another_bot_is_still_ignored(self): - # Loop prevention now rests on the subtype, which is what a message - # with no human behind it carries. + async def test_a_legacy_webhook_post_is_ignored(self): channel = self._ch() await channel._handle_message_event( self._event(subtype="bot_message", bot_id="B0OTHERAPP", user=None), ) channel.router.handle_message.assert_not_called() + def _open_channel(self): + """A channel-only grant, which admits any member of C1. + + This is the config that makes the loop reachable, and it is the one + docs/config.md offers for a shared channel, so the loop guard has to + hold without help from the user gate. + """ + channel = _channel(allow_channels=["C1"]) + channel._bot_user_id = "U0BOT" + channel._bot_id = "B0SELF" + channel.router.get_last_session = AsyncMock(return_value="s1") + return channel + + @pytest.mark.asyncio + async def test_an_authorized_human_reply_reaches_the_router(self): + # The control for the two tests below: this config really does admit + # a thread reply with no mention, which is what makes a loop possible. + channel = self._open_channel() + await channel._handle_message_event( + self._event(thread_ts="1.0", text="on it"), + ) + channel.router.handle_message.assert_awaited_once() + + @pytest.mark.asyncio + async def test_another_agents_own_reply_is_ignored(self): + # Two of these in one channel would otherwise answer each other for + # ever: a reply continues an owned thread with no mention needed. + channel = self._open_channel() + channel._web.users_info = AsyncMock( + return_value={"user": {"is_bot": True, "profile": {}}}, + ) + await channel._handle_message_event( + self._event( + bot_id="B0OTHERAGENT", + user="U0OTHERAGENT", + thread_ts="1.0", + text="on it", + ), + ) + channel.router.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_an_unresolvable_sender_beside_a_bot_id_is_ignored(self): + channel = self._open_channel() + channel._web.users_info = AsyncMock(side_effect=RuntimeError("no scope")) + await channel._handle_message_event( + self._event( + bot_id="B0OTHERAGENT", + user="U0OTHERAGENT", + thread_ts="1.0", + text="on it", + ), + ) + channel.router.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_a_message_with_no_bot_id_costs_no_lookup(self): + channel = self._ch() + channel._web.users_info = AsyncMock() + assert not await channel._is_another_app_talking(self._event()) + channel._web.users_info.assert_not_called() + + @pytest.mark.asyncio + async def test_the_bot_verdict_is_cached(self): + channel = self._ch() + channel._web.users_info = AsyncMock( + return_value={"user": {"is_bot": True, "profile": {}}}, + ) + event = self._event(bot_id="B0OTHERAGENT", user="U0OTHERAGENT") + assert await channel._is_another_app_talking(event) + assert await channel._is_another_app_talking(event) + assert channel._web.users_info.await_count == 1 + class TestAmpersandEscaping: def test_an_ampersand_in_a_link_url_is_escaped(self): From e8e769615c4bd54b150d8a5cd89ec6ba8b51e35d Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 25 Aug 2026 15:09:38 +0200 Subject: [PATCH 15/18] Key cached messages by conversation as well as timestamp A Slack ts is unique inside one conversation, not across the workspace, but the reaction cache was keyed on ts alone. A reaction in one channel could therefore find a cached message from another, and the turn was authorized against the channel the reaction came from while being routed into the session of the channel that held the cache entry. Key on the conversation and the ts together. The stored target still keeps the thread, which the key does not. --- nerve/channels/slack.py | 15 ++++++++++++--- tests/test_slack_channel.py | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/nerve/channels/slack.py b/nerve/channels/slack.py index 12f3a780..9cddd4a1 100644 --- a/nerve/channels/slack.py +++ b/nerve/channels/slack.py @@ -1002,7 +1002,7 @@ async def _handle_reaction_event(self, event: dict[str, Any]) -> None: if self._is_duplicate(f"reaction:{channel_id}:{ts}:{user_id}:{reaction}"): return - cached = self._message_cache.get(ts) + cached = self._message_cache.get(format_target(channel_id, ts)) if not cached: # Only react to reactions on messages from this conversation that # we still hold context for; anything else has no session to @@ -1306,12 +1306,21 @@ def _remember( cache.popitem(last=False) def _cache_message(self, ts: str, target: str, text: str) -> None: - """Store a message snippet in the LRU cache for reaction lookups.""" + """Store a message snippet in the LRU cache for reaction lookups. + + A Slack ts is unique inside one conversation rather than across the + workspace, so the key carries the conversation too. The stored + target keeps the thread, which the key does not. + """ snippet = (text or "")[:200] if not snippet: return + channel_id, _ = parse_target(target) self._remember( - self._message_cache, ts, (target, snippet), _MESSAGE_CACHE_MAX, + self._message_cache, + format_target(channel_id, ts), + (target, snippet), + _MESSAGE_CACHE_MAX, ) # ------------------------------------------------------------------ # diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index a2046f40..778e6a96 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -655,6 +655,29 @@ async def test_an_unauthorized_reaction_is_ignored(self): ) channel.router.handle_message.assert_not_called() + @pytest.mark.asyncio + async def test_a_reaction_does_not_cross_conversations_on_a_shared_ts(self): + # A Slack ts is unique inside one conversation only, so the same ts + # in another channel must not reach the first channel's session. + channel = _channel(allow_channels=["C1", "C2"]) + channel._cache_message("1.1", "C1:1.0", "the original in C1") + await channel._handle_reaction_event({ + "type": "reaction_added", "user": "U1", "reaction": "tada", + "item": {"channel": "C2", "ts": "1.1"}, + }) + channel.router.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_a_reaction_still_reaches_its_own_thread_session(self): + channel = _channel(allow_channels=["C1"]) + channel._cache_message("1.1", "C1:1.0", "the original in C1") + await channel._handle_reaction_event({ + "type": "reaction_added", "user": "U1", "reaction": "tada", + "item": {"channel": "C1", "ts": "1.1"}, + }) + msg = channel.router.handle_message.await_args[0][0] + assert msg.channel_key == "slack:C1:1.0" + class TestOutbound: @pytest.mark.asyncio From d875f7662d188c3a19f6ad910b6fb8169f729fed Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 25 Aug 2026 15:13:42 +0200 Subject: [PATCH 16/18] Keep the Slack lifecycle lock off socket I/O and past shutdown Three faults in one owner, fixed together because they share its structure. shutdown() left no mark, so the next reconcile took the enable path and built a fresh channel, socket and watchdog. The lifespan stops Slack before it stops the sync loop, and that loop is given a bounded wait to finish the cycle it is in, whose git phase runs in a worker thread past cancellation. A reload therefore arrives after shutdown and reopened a socket that outlived the process. Mark the runtime closed and report the reload as such. The watchdog held the lifecycle lock while rebuilding the socket, which waits on a close and a connect. While Slack was unreachable that blocked every config reload and the shutdown for up to the sum of those bounds. Hold the lock for the decision, repair outside it, and refuse a rebuild once the channel is stopping. Repair and rotation still serialize on the channel's transport lock. Redaction read the active generation after stopping had already cleared it, so disabling Slack and removing its tokens in one edit put the live token verbatim into the summary that reload_all returns over HTTP. Capture the secrets before the transition and redact every failure leaving reconcile against them. --- nerve/channels/slack.py | 7 +- nerve/channels/slack_runtime.py | 165 ++++++++++++++++++-------------- tests/test_config_reload.py | 28 ++++++ tests/test_slack_runtime.py | 58 +++++++++-- 4 files changed, 178 insertions(+), 80 deletions(-) diff --git a/nerve/channels/slack.py b/nerve/channels/slack.py index 9cddd4a1..661dd7e4 100644 --- a/nerve/channels/slack.py +++ b/nerve/channels/slack.py @@ -613,8 +613,13 @@ async def rebuild_transport(self) -> None: is safer than overlapping connections. """ async with self._transport_lock: + # The watchdog repairs outside the lifecycle lock, so a stop may + # have started while it waited here. Connecting now would leave + # a socket behind the channel that is going away. + if self._stopping: + return # A credential reload may have repaired the socket while the - # watchdog was waiting for the lifecycle lock. + # watchdog was waiting for the transport lock. if self._client is not None: try: if await self._client.is_connected(): diff --git a/nerve/channels/slack_runtime.py b/nerve/channels/slack_runtime.py index d80317ff..776c4a58 100644 --- a/nerve/channels/slack_runtime.py +++ b/nerve/channels/slack_runtime.py @@ -25,6 +25,23 @@ class SlackRuntimeError(RuntimeError): """A sanitized Slack transition failure safe for logs and HTTP output.""" +def _secrets_of(*configs: NerveConfig | None) -> tuple[str, ...]: + """Every token that must not appear in a message handed to a caller.""" + return tuple( + secret + for config in configs + if config is not None + for secret in (config.slack.bot_token, config.slack.app_token) + if secret + ) + + +def _redact(detail: str, secrets: tuple[str, ...]) -> str: + for secret in secrets: + detail = detail.replace(secret, "") + return detail + + class SlackRuntime: """Own one Slack instance and serialize every lifecycle transition.""" @@ -41,6 +58,7 @@ def __init__( self._channel: SlackChannel | None = None self._active_config: NerveConfig | None = None self._watchdog_task: asyncio.Task | None = None + self._closed = False @property def channel(self) -> SlackChannel | None: @@ -52,53 +70,69 @@ def active_config(self) -> NerveConfig | None: async def reconcile(self, config: NerveConfig) -> str | None: """Atomically reconcile one desired process config generation.""" - desired = copy.deepcopy(config) async with self._lock: - if not desired.slack.enabled: - if self._channel is None: - return None - try: - await self._stop_locked(drain=True) - except Exception as error: - raise self._safe_error(error, desired) from error - return "disabled" - - if not desired.slack.bot_token or not desired.slack.app_token: - raise self._safe_error( - RuntimeError("Slack needs both bot_token and app_token"), - desired, - ) + if self._closed: + # The process is shutting down. The workspace sync loop is + # given a bounded wait to finish the cycle it is in, and its + # git phase runs in a worker thread past cancellation, so a + # reload can still arrive here. Starting a socket now leaves + # one open past exit. + return "shutting down" + # Captured before any transition, because stopping clears the + # active generation and the tokens it held are exactly the ones + # a failure in that transition can name. + secrets = _secrets_of(self._active_config, config) + try: + return await self._reconcile_locked(copy.deepcopy(config)) + except (SlackRuntimeError, asyncio.CancelledError): + raise + except Exception as error: + raise SlackRuntimeError( + _redact(str(error) or type(error).__name__, secrets), + ) from error + async def _reconcile_locked(self, desired: NerveConfig) -> str | None: + """Move to *desired*, assuming the lifecycle lock is held.""" + if not desired.slack.enabled: if self._channel is None: - return await self._enable_locked(desired) + return None + await self._stop_locked(drain=True) + return "disabled" - channel = self._channel - if self.router.get_channel(self.name) is not channel: - raise SlackRuntimeError( - "the Slack runtime no longer owns the registered channel", - ) - if not channel.is_available: - await self._stop_locked(drain=False) - return await self._enable_locked(desired) - - slack = desired.slack - if channel.needs_credential_reload(slack.bot_token, slack.app_token): - try: - await channel.reload_credentials(desired) - except Exception as error: - if not channel.is_available: - await self._stop_locked(drain=False) - raise self._safe_error(error, desired) from error - self._active_config = desired - return "credentials reloaded" - - channel.apply_config(desired) + if not desired.slack.bot_token or not desired.slack.app_token: + raise RuntimeError("Slack needs both bot_token and app_token") + + if self._channel is None: + return await self._enable_locked(desired) + + channel = self._channel + if self.router.get_channel(self.name) is not channel: + raise SlackRuntimeError( + "the Slack runtime no longer owns the registered channel", + ) + if not channel.is_available: + await self._stop_locked(drain=False) + return await self._enable_locked(desired) + + slack = desired.slack + if channel.needs_credential_reload(slack.bot_token, slack.app_token): + try: + await channel.reload_credentials(desired) + except Exception: + if not channel.is_available: + await self._stop_locked(drain=False) + raise self._active_config = desired - return None + return "credentials reloaded" + + channel.apply_config(desired) + self._active_config = desired + return None async def shutdown(self) -> None: - """Stop and unpublish whichever Slack instance is current.""" + """Stop Slack for good; no later reconcile may bring it back.""" async with self._lock: + self._closed = True if self._channel is not None: await self._stop_locked(drain=False) @@ -129,7 +163,7 @@ async def _enable_locked(self, desired: NerveConfig) -> str: self._active_config = None if isinstance(error, asyncio.CancelledError): raise - raise self._safe_error(error, desired) from error + raise self._active_config = desired self._watchdog_task = asyncio.create_task( @@ -166,6 +200,12 @@ async def _watchdog(self, channel: SlackChannel) -> None: check_count = 0 while True: await asyncio.sleep(WATCHDOG_INTERVAL) + # The lifecycle lock covers the decision only. Repair runs + # outside it, because rebuild_transport waits on a socket close + # and a connect, and holding the lock across that made a config + # reload and a shutdown wait on an unreachable Slack. The + # channel's own transport lock still serializes repair against + # credential rotation. async with self._lock: if self._channel is not channel or not channel.is_available: return @@ -178,35 +218,18 @@ async def _watchdog(self, channel: SlackChannel) -> None: check_count, channel.seconds_since_last_event, ) - if connected: - continue - logger.warning("Slack socket is down — rebuilding") - try: - await channel.rebuild_transport() - except Exception as error: - logger.error( - "Slack reconnect failed: %s", - self._safe_detail(error, self._active_config), - ) - else: - logger.info("Slack socket reconnected") - - def _safe_error( - self, - error: Exception, - desired: NerveConfig | None, - ) -> SlackRuntimeError: - return SlackRuntimeError( - self._safe_detail(error, self._active_config, desired), - ) - - @staticmethod - def _safe_detail(error: Exception, *configs: NerveConfig | None) -> str: - detail = str(error) or type(error).__name__ - for config in configs: - if config is None: + if connected: continue - for secret in (config.slack.bot_token, config.slack.app_token): - if secret: - detail = detail.replace(secret, "") - return detail + logger.warning("Slack socket is down, rebuilding") + try: + await channel.rebuild_transport() + except Exception as error: + logger.error( + "Slack reconnect failed: %s", + _redact( + str(error) or type(error).__name__, + _secrets_of(self._active_config), + ), + ) + else: + logger.info("Slack socket reconnected") diff --git a/tests/test_config_reload.py b/tests/test_config_reload.py index 7318a766..594cbbbd 100644 --- a/tests/test_config_reload.py +++ b/tests/test_config_reload.py @@ -938,6 +938,34 @@ async def test_a_disable_cleanup_failure_is_reported_after_unregister( assert engine.router.get_channel("slack") is None assert reload_failures(summary)["slack"] == "socket stuck" + @pytest.mark.asyncio + async def test_a_disable_failure_redacts_the_generation_it_stopped( + self, tmp_path, monkeypatch, + ): + # The desired generation carries no tokens once the operator removes + # them, so redacting against it alone left the live token in a + # message that reload_all returns over HTTP. + config_dir, ws = tmp_path / "cfg", tmp_path / "ws" + ws.mkdir() + channel = self._running_channel( + config_dir, ws, monkeypatch, + bot_token="xoxb-live-secret", app_token="xapp-live-secret", + ) + channel.stop = AsyncMock( + side_effect=RuntimeError( + "close failed for xoxb-live-secret and xapp-live-secret", + ), + ) + engine = self._engine(channel) + + _write_config(config_dir, ws, "slack:\n enabled: false\n") + summary = await reload_all(engine, None, config_dir) + + failure = reload_failures(summary)["slack"] + assert "xoxb-live-secret" not in failure + assert "xapp-live-secret" not in failure + assert failure == "close failed for and " + @pytest.mark.asyncio async def test_a_failed_enable_stays_absent_and_retries( self, tmp_path, monkeypatch, diff --git a/tests/test_slack_runtime.py b/tests/test_slack_runtime.py index ae2a132e..d654269b 100644 --- a/tests/test_slack_runtime.py +++ b/tests/test_slack_runtime.py @@ -294,7 +294,10 @@ async def lose_transport(channel, desired): @pytest.mark.asyncio -async def test_watchdog_rebuild_serializes_with_config_reconcile(monkeypatch): +async def test_a_config_reconcile_does_not_wait_on_a_socket_rebuild(monkeypatch): + # A rebuild waits on a socket close and a connect. Holding the lifecycle + # lock across that made every config reload and the shutdown wait on an + # unreachable Slack, so the lock now covers the decision only. from nerve.channels import slack_runtime as runtime_module monkeypatch.setattr(runtime_module, "WATCHDOG_INTERVAL", 0.001) @@ -313,13 +316,52 @@ async def slow_rebuild(channel): await runtime.reconcile(_config(allow_users=["U1"])) await entered.wait() - reconcile = asyncio.create_task( - runtime.reconcile(_config(allow_users=["U2"])), - ) - await asyncio.sleep(0) - assert not reconcile.done() + assert await runtime.reconcile(_config(allow_users=["U2"])) is None + assert runtime.channel.config.slack.allow_users == ["U2"] release.set() - assert await reconcile is None - assert runtime.channel.config.slack.allow_users == ["U2"] await runtime.shutdown() + + +@pytest.mark.asyncio +async def test_a_shutdown_does_not_wait_on_a_socket_rebuild(monkeypatch): + from nerve.channels import slack_runtime as runtime_module + + monkeypatch.setattr(runtime_module, "WATCHDOG_INTERVAL", 0.001) + router = _Router() + runtime = SlackRuntime(router) + entered = asyncio.Event() + release = asyncio.Event() + _FakeChannel.connected = False + + async def slow_rebuild(channel): + entered.set() + await release.wait() + type(channel).connected = True + + _FakeChannel.rebuild_hook = slow_rebuild + await runtime.reconcile(_config(allow_users=["U1"])) + await entered.wait() + + await asyncio.wait_for(runtime.shutdown(), timeout=1) + assert runtime.channel is None + assert router.get_channel("slack") is None + release.set() + + +@pytest.mark.asyncio +async def test_a_reconcile_after_shutdown_does_not_restart_slack(): + # The sync loop is given a bounded wait to finish its cycle, and its git + # phase runs in a worker thread past cancellation, so a reload can land + # after the channel has already been stopped for exit. + router = _Router() + runtime = SlackRuntime(router) + assert await runtime.reconcile(_config(allow_users=["U1"])) == "enabled" + + await runtime.shutdown() + assert runtime.channel is None + + assert await runtime.reconcile(_config(allow_users=["U1"])) == "shutting down" + assert runtime.channel is None + assert router.get_channel("slack") is None + assert runtime._watchdog_task is None From b5cdcf3587171226b36f08a48f408cacfd87e63a Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 25 Aug 2026 15:17:51 +0200 Subject: [PATCH 17/18] Gate outbound Slack calls on the running generation Seven outbound methods checked only that a Web client existed. That attribute is also set while the channel starts, and again while a rotation validates the next credential pair before connecting its socket, so a caller could reach Slack through a client whose generation is not the one serving events. send() had the worse shape: it returned quietly, and StreamAdapter reads a quiet return as a delivered reply and deletes the streaming placeholder, so the user was left with neither the placeholder nor the answer. It now refuses, which is what its own docstring already promised, and the adapter takes its recovery branch. Post paths refuse. Best-effort paths, which already swallow their own failures, stay quiet and now agree with is_available. --- nerve/channels/slack.py | 44 +++++++++++++++++++--------- tests/test_slack_channel.py | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/nerve/channels/slack.py b/nerve/channels/slack.py index 661dd7e4..f3d7bdee 100644 --- a/nerve/channels/slack.py +++ b/nerve/channels/slack.py @@ -134,6 +134,10 @@ def parse_target(target: str) -> tuple[str, str | None]: return channel_id, (thread_ts if sep and thread_ts else None) +class SlackUnavailable(RuntimeError): + """The channel cannot carry traffic for the generation now running.""" + + class SlackChannel(BaseChannel): """Slack bot channel over Socket Mode. @@ -1155,6 +1159,19 @@ async def _extract_files( # Outbound # # ------------------------------------------------------------------ # + def _available_web(self) -> Any: + """The Web client of the running generation, or refuse to use one. + + ``_web`` is set while starting and again while a rotation validates + the next credential pair, so a caller that checks only the attribute + can post through a client whose generation is not the one serving + events. Refusing is what lets StreamAdapter keep the placeholder and + the notification service record nothing as delivered. + """ + if not self.is_available: + raise SlackUnavailable(f"the Slack channel is {self._state}") + return self._web + async def _post( self, target: str, text: str, blocks: list[dict] | None = None, ) -> str | None: @@ -1167,10 +1184,9 @@ async def _post( notification as delivered. Callers that genuinely want best-effort catch it themselves. """ - if self._web is None: - return None + web = self._available_web() channel_id, thread_ts = parse_target(target) - resp = await self._web.chat_postMessage( + resp = await web.chat_postMessage( channel=channel_id, text=text, blocks=blocks, @@ -1184,10 +1200,10 @@ async def send(self, message: OutboundMessage) -> None: """Send a complete message, split to fit Slack's render limit. Propagates a failure so StreamAdapter can fall back to editing the - streaming placeholder; swallowing it loses the whole turn. + streaming placeholder; swallowing it loses the whole turn. An + unavailable channel is one of those failures. """ - if self._web is None: - return + self._available_web() for chunk in split_message(message.text, MAX_MSG_LEN): ts = await self._post(message.target, _md_to_slack(chunk)) if ts: @@ -1219,7 +1235,7 @@ async def send_placeholder(self, target: str, session_id: str) -> str | None: async def edit_message(self, target: str, message_id: str, text: str) -> None: """Rewrite a previously sent message with the latest streamed text.""" - if self._web is None: + if not self.is_available: return channel_id, _ = parse_target(target) body = _md_to_slack(text) @@ -1235,7 +1251,7 @@ async def edit_message(self, target: str, message_id: str, text: str) -> None: async def delete_message(self, target: str, message_id: str) -> None: """Remove a message — used to clear the streaming placeholder.""" - if self._web is None: + if not self.is_available: return channel_id, _ = parse_target(target) try: @@ -1250,7 +1266,7 @@ async def send_typing(self, target: str) -> None: message would be one more post to clean up. A reaction on the message being answered says the same thing and disappears with it. """ - if self._web is None: + if not self.is_available: return ts = self._last_inbound_ts.get(target) if not ts: @@ -1266,7 +1282,7 @@ async def send_typing(self, target: str) -> None: async def set_reaction(self, target: str, message_id: Any, emoji: str) -> None: """Set an emoji reaction on a message.""" - if self._web is None: + if not self.is_available: return name = slack_emoji_name(emoji) if not name: @@ -1282,7 +1298,7 @@ async def set_reaction(self, target: str, message_id: Any, emoji: str) -> None: async def send_file(self, target: str, file_path: str) -> bool: """Upload a file into the conversation as an attachment.""" - if self._web is None or not target: + if not self.is_available or not target: return False path = Path(file_path) if not path.is_file(): @@ -1667,7 +1683,7 @@ async def _respond_ephemeral( text: str, ) -> None: """Reply so only the person who ran the command sees it.""" - if self._web is None: + if not self.is_available: return try: await self._web.chat_postEphemeral( @@ -1686,7 +1702,7 @@ async def _respond_ephemeral_blocks( blocks: list[dict], ) -> None: """Ephemeral reply carrying Block Kit, for the pickers.""" - if self._web is None: + if not self.is_available: return try: await self._web.chat_postEphemeral( @@ -1705,7 +1721,7 @@ async def _send_sessions_view( channel_key: str, ) -> None: """Post the session switcher, visible only to the requester.""" - if self._web is None: + if not self.is_available: return blocks = await self._sessions_blocks_for(channel_key) try: diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index 778e6a96..7570963b 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -17,6 +17,7 @@ from nerve.channels.slack import ( MAX_MSG_LEN, SlackChannel, + SlackUnavailable, _md_to_slack, build_sessions_blocks, format_target, @@ -706,6 +707,62 @@ async def test_an_edit_stays_inside_the_length_limit(self): await channel.edit_message("C1", "1.1", "y" * (MAX_MSG_LEN + 500)) assert len(channel._web.chat_update.await_args.kwargs["text"]) <= MAX_MSG_LEN + 1 + @pytest.mark.asyncio + async def test_sending_while_rotating_is_refused_not_dropped(self): + # A rotation publishes the next Web client before it connects the + # next socket, so _web alone is set while the generation serving + # events is still the previous one. Returning quietly here told + # StreamAdapter the reply had been sent and it deleted the + # placeholder, leaving the user with neither. + channel = _channel() + channel._state = "rotating" + with pytest.raises(SlackUnavailable): + await channel.send(OutboundMessage(target="C1", text="hi")) + channel._web.chat_postMessage.assert_not_called() + + @pytest.mark.asyncio + async def test_a_stopped_channel_refuses_to_post(self): + channel = _channel() + channel._state = "quiescing" + with pytest.raises(SlackUnavailable): + await channel._post("C1", "hi") + + @pytest.mark.asyncio + async def test_streaming_keeps_the_placeholder_when_the_channel_goes_away( + self, + ): + # The user-visible half: a quiet refusal read as success, so the + # placeholder was deleted and the reply never posted. + from nerve.channels.stream_adapter import StreamAdapter + + channel = _channel() + adapter = StreamAdapter(channel, "C1", "s1") + adapter._placeholder_id = "1.1" + adapter._buffer = "the answer" + channel.edit_message = AsyncMock() + channel.delete_message = AsyncMock() + channel._state = "rotating" + + await adapter._handle_done() + + # The recovery branch, not the "sent, so drop the placeholder" one. + channel.edit_message.assert_awaited_once() + channel.delete_message.assert_not_called() + + @pytest.mark.asyncio + async def test_best_effort_paths_stay_quiet_while_rotating(self): + channel = _channel() + channel._state = "rotating" + channel._remember(channel._last_inbound_ts, "C1", "1.1", 10) + await channel.edit_message("C1", "1.1", "text") + await channel.delete_message("C1", "1.1") + await channel.send_typing("C1") + await channel.set_reaction("C1", "1.1", "🎉") + assert not await channel.send_file("C1", __file__) + channel._web.chat_update.assert_not_called() + channel._web.chat_delete.assert_not_called() + channel._web.reactions_add.assert_not_called() + @pytest.mark.asyncio async def test_a_failed_post_is_reported_not_swallowed(self): # The caller has to see this: StreamAdapter's recovery path is the From faa53ab113c38fd0dc1e3ac64d16895bef136493 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 25 Aug 2026 15:24:37 +0200 Subject: [PATCH 18/18] Meter streaming edits per Slack conversation Slack rate limits chat.update per conversation, but the interval was held per StreamAdapter and there is one of those per inbound message. Several threads streaming in one channel therefore went over the limit together. The SDK answers a 429 by sleeping inside the request, and the streaming listener is awaited from the agent's token loop, so those sleeps stalled the run. Two changes. The adapter stamps the interval before the attempt, so a failed edit still holds it instead of letting every later token retry at once. And an edit the caller can afford to lose is marked throttle=True, which lets Slack shed it against a per-conversation clock. A final or recovery edit leaves the flag unset and always goes out, because that path is the only thing between a failed send and a lost reply. Raise the failed-edit log out of debug; the interval bounds its rate. --- nerve/channels/base.py | 9 ++++++++- nerve/channels/slack.py | 33 ++++++++++++++++++++++++++++++-- nerve/channels/stream_adapter.py | 6 +++++- nerve/channels/telegram.py | 12 ++++++++++-- tests/test_slack_channel.py | 26 +++++++++++++++++++++++++ tests/test_streaming.py | 33 ++++++++++++++++++++++++++++++++ 6 files changed, 113 insertions(+), 6 deletions(-) diff --git a/nerve/channels/base.py b/nerve/channels/base.py index 9e1866e8..65f84f34 100644 --- a/nerve/channels/base.py +++ b/nerve/channels/base.py @@ -125,9 +125,16 @@ async def send_placeholder(self, target: str, session_id: str) -> str | None: """ return None - async def edit_message(self, target: str, message_id: str, text: str) -> None: + async def edit_message( + self, target: str, message_id: str, text: str, + *, throttle: bool = False, + ) -> None: """Edit a previously sent message (for streaming). + ``throttle`` marks an edit the caller can afford to lose, so a + channel may drop it to stay inside a per-conversation rate limit. + A final or recovery edit leaves it False and always goes out. + Only called if channel declares STREAMING capability and constraints.supports_message_edit is True. """ diff --git a/nerve/channels/slack.py b/nerve/channels/slack.py index f3d7bdee..241784f3 100644 --- a/nerve/channels/slack.py +++ b/nerve/channels/slack.py @@ -68,6 +68,7 @@ _MESSAGE_CACHE_MAX = 200 _NAME_CACHE_MAX = 500 _INBOUND_TS_MAX = 500 +_EDIT_CLOCK_MAX = 500 _NAME_CACHE_TTL = 600.0 # Concurrent dispatch tasks. The router serialises per session, so this only # bounds envelopes not yet routed — including ones headed for a refusal. @@ -181,6 +182,11 @@ def __init__( self._last_inbound_ts: collections.OrderedDict[str, str] = ( collections.OrderedDict() ) + # conversation -> monotonic time of the last droppable edit. Slack + # rate limits chat.update per conversation, not per thread. + self._last_channel_edit: collections.OrderedDict[str, float] = ( + collections.OrderedDict() + ) # Resolved names: id -> (Identity, monotonic deadline). self._name_cache: collections.OrderedDict[str, tuple[Identity, float]] = ( collections.OrderedDict() @@ -1233,11 +1239,34 @@ async def send_placeholder(self, target: str, session_id: str) -> str | None: ) return None - async def edit_message(self, target: str, message_id: str, text: str) -> None: + def _claim_channel_edit(self, channel_id: str) -> bool: + """Whether a droppable edit may go out for this conversation now. + + Slack rate limits ``chat.update`` per conversation rather than per + thread, so every thread streaming in one channel shares one budget. + The SDK answers a 429 by sleeping inside the request, and the + streaming listener is awaited from the agent's token loop, so those + sleeps stall the run itself. Dropping the edit costs less: the next + token brings another. + """ + now = time.monotonic() + if now - self._last_channel_edit.get(channel_id, 0.0) < EDIT_INTERVAL: + return False + self._remember( + self._last_channel_edit, channel_id, now, _EDIT_CLOCK_MAX, + ) + return True + + async def edit_message( + self, target: str, message_id: str, text: str, + *, throttle: bool = False, + ) -> None: """Rewrite a previously sent message with the latest streamed text.""" if not self.is_available: return channel_id, _ = parse_target(target) + if throttle and not self._claim_channel_edit(channel_id): + return body = _md_to_slack(text) if len(body) > MAX_MSG_LEN: body = body[:MAX_MSG_LEN] + "…" @@ -1246,7 +1275,7 @@ async def edit_message(self, target: str, message_id: str, text: str) -> None: channel=channel_id, ts=message_id, text=body, ) except Exception as e: - logger.debug("Slack chat.update failed for %s: %s", target, e) + logger.warning("Slack chat.update failed for %s: %s", target, e) self._cache_message(message_id, target, text) async def delete_message(self, target: str, message_id: str) -> None: diff --git a/nerve/channels/stream_adapter.py b/nerve/channels/stream_adapter.py index fc12979f..5811acca 100644 --- a/nerve/channels/stream_adapter.py +++ b/nerve/channels/stream_adapter.py @@ -132,13 +132,17 @@ async def _handle_token(self, content: str) -> None: return async with self._edit_lock: + # Stamped before the attempt. A failed edit still has to hold the + # interval, or every later token retries at once and each retry + # can sleep on a rate limit inside the agent's token loop. + self._last_edit = asyncio.get_event_loop().time() try: indicator = STREAMING_INDICATOR display = self._truncate(display, reserve=len(indicator)) await self.channel.edit_message( self.target, self._placeholder_id, display + indicator, + throttle=True, ) - self._last_edit = now except Exception: pass # Edit failures are non-fatal diff --git a/nerve/channels/telegram.py b/nerve/channels/telegram.py index 43c575a2..e53fa952 100644 --- a/nerve/channels/telegram.py +++ b/nerve/channels/telegram.py @@ -852,8 +852,16 @@ async def send_placeholder(self, target: str, session_id: str) -> str | None: msg = await self._app.bot.send_message(chat_id=chat_id, text="⏳") return str(msg.message_id) - async def edit_message(self, target: str, message_id: str, text: str) -> None: - """Edit a previously sent message (for streaming updates).""" + async def edit_message( + self, target: str, message_id: str, text: str, + *, throttle: bool = False, + ) -> None: + """Edit a previously sent message (for streaming updates). + + Telegram limits edits per chat and one chat is one session here, so + the caller's per-adapter interval already covers it and ``throttle`` + needs no extra shedding. + """ if self._app is None: return chat_id = int(target) diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index 7570963b..ae0e2462 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -701,6 +701,32 @@ async def test_a_long_reply_is_split_without_truncation(self): ) assert sent == body + @pytest.mark.asyncio + async def test_threads_in_one_channel_share_the_edit_budget(self): + # Slack meters chat.update per conversation, so two threads streaming + # at the per-adapter interval together exceed it. + channel = _channel() + await channel.edit_message("C1:1.0", "1.1", "a", throttle=True) + await channel.edit_message("C1:2.0", "2.1", "b", throttle=True) + assert channel._web.chat_update.await_count == 1 + + @pytest.mark.asyncio + async def test_separate_channels_do_not_share_the_edit_budget(self): + channel = _channel() + await channel.edit_message("C1:1.0", "1.1", "a", throttle=True) + await channel.edit_message("C2:1.0", "1.1", "b", throttle=True) + assert channel._web.chat_update.await_count == 2 + + @pytest.mark.asyncio + async def test_a_final_edit_is_never_dropped(self): + # The recovery path in StreamAdapter is the only thing standing + # between a failed send and a lost reply, so it must not be shed. + channel = _channel() + await channel.edit_message("C1:1.0", "1.1", "streamed", throttle=True) + await channel.edit_message("C1:1.0", "1.1", "the answer") + assert channel._web.chat_update.await_count == 2 + assert channel._web.chat_update.await_args.kwargs["text"] == "the answer" + @pytest.mark.asyncio async def test_an_edit_stays_inside_the_length_limit(self): channel = _channel() diff --git a/tests/test_streaming.py b/tests/test_streaming.py index 40e22e64..f8ff0b0c 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -1,10 +1,43 @@ """Tests for nerve.agent.streaming — StreamBroadcaster bounded buffers.""" import asyncio +from unittest.mock import AsyncMock, MagicMock import pytest from nerve.agent.streaming import StreamBroadcaster +from nerve.channels.base import ChannelCapability, ChannelConstraints +from nerve.channels.stream_adapter import StreamAdapter + + +@pytest.mark.asyncio +class TestEditThrottle: + """The interval has to hold across a failed edit.""" + + @staticmethod + def _adapter(): + channel = MagicMock() + channel.capabilities = ( + ChannelCapability.SEND_TEXT | ChannelCapability.STREAMING + ) + channel.constraints = ChannelConstraints( + max_message_length=4000, + min_edit_interval=1.2, + supports_message_edit=True, + ) + channel.format_response = lambda t: t + channel.edit_message = AsyncMock(side_effect=RuntimeError("429")) + adapter = StreamAdapter(channel, "C1", "s1") + adapter._placeholder_id = "1.1" + return adapter, channel + + async def test_a_failed_edit_still_holds_the_interval(self): + # Leaving the stamp unset made every following token retry at once, + # and each retry can sleep on a rate limit inside the token loop. + adapter, channel = self._adapter() + for _ in range(20): + await adapter._handle_token("x") + assert channel.edit_message.await_count == 1 @pytest.mark.asyncio