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..b2b0565b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -110,6 +110,34 @@ 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 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 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 + # allow_channels: ["eng-*", "C0456DEF"] + # deny_channels: ["*-random", "*-social"] + # + # 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, 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 lists other channels. 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..e19c30d2 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. +- **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. `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/docs/config.md b/docs/config.md index af122d04..8dae5da0 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.*` | ✅ `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 | @@ -607,6 +608,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 +1072,163 @@ 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 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] | `[]` | 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.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`. 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 +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. 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 +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 +``` + +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 + +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: + allow_users: ["U0123ABC", "alex.soffronow"] + deny_users: ["*-bot"] + allow_direct_messages: true + allow_channels: ["eng-*"] + deny_channels: ["*-social"] +``` + +- 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. +- 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 + +`slack.commands` controls slash commands only; chat and notification buttons +are unaffected. + +```yaml +slack: + commands: [] # disable /nerve + commands: [reply] # enable only reply + commands: [sessions, new, stop] # enable this exact set + commands: [all] # enable every subcommand +``` + +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 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 | Key | Type | Default | Description | 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 new file mode 100644 index 00000000..241784f3 --- /dev/null +++ b/nerve/channels/slack.py @@ -0,0 +1,2012 @@ +"""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. Shared channels always use thread-scoped sessions. +""" + +from __future__ import annotations + +import asyncio +import base64 +import collections +import logging +import re +import time +from pathlib import Path +from typing import Any, TYPE_CHECKING + +from nerve.channels.access import 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.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, + NerveConfig, +) + +if TYPE_CHECKING: + from nerve.channels.router import ChannelRouter + +logger = logging.getLogger(__name__) + +# 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 +# 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 +# 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 +_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. +_MAX_INFLIGHT = 100 +# 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 + +# ---------------------------------------------------------------------- # +# 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)) + + +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) + + +class SlackUnavailable(RuntimeError): + """The channel cannot carry traffic for the generation now running.""" + + +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: NerveConfig, router: ChannelRouter, + ): + self._config = config + self.router = router + self._client: Any = None # AsyncSocketModeClient + self._web: Any = None # AsyncWebClient + # 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() + 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._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. + 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. + # 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() + ) + # 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() + ) + + def set_notification_service(self, service) -> None: + """Wire the notification service for button presses.""" + self._notification_service = service + + @property + def config(self) -> NerveConfig: + """The config generation active for this Slack runtime.""" + 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: + """The access policy, rebuilt per read so reloads apply at once.""" + return SlackAccessPolicy.from_config(self.config.slack) + + @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: + raise RuntimeError( + "Slack needs both bot_token (xoxb-…) and app_token (xapp-…)", + ) + + self._stopping = False + self._state = "starting" + async with self._transport_lock: + 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(self._client) + self._client = None + self._web = None + self._active_bot_token = "" + self._active_app_token = "" + 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() + + @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 + + 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. + web.retry_handlers.append( + AsyncRateLimitErrorRetryHandler(max_retry_count=3), + ) + return web + + 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", "") + self._team_id = auth.get("team_id") or self._team_id + + def _log_auth(self, auth: dict, action: str) -> None: + logger.info( + "%s as %s (%s, %s) in workspace %s", + action, + auth.get("user"), self._bot_user_id, self._bot_id, auth.get("team"), + ) + + 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._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, + ) + 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, 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. 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") + + 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, + ) + 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 + 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 + 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, + ) + 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() + 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._team_id = old_team_id + self._client = old_client + self._config = old_config + + 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: + self._state = "stopped" + self._stopping = True + self._web = None + self._client = None + 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 + self._state = old_state + 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 + 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: + """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, 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()) + + async def stop(self, *, drain: bool = False) -> None: + """Stop receiving, optionally draining acknowledged dispatches first.""" + self._stopping = True + self._state = "quiescing" + + if drain: + async with self._transport_lock: + await self._close_socket_quietly(self._client) + + inflight = list(self._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) + 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 = "" + self._client = None + self._state = "stopped" + + # ------------------------------------------------------------------ # + # Watchdog # + # ------------------------------------------------------------------ # + + 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 + + @property + def seconds_since_last_event(self) -> float: + return time.monotonic() - self._last_event_time + + 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 + 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 transport 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 self._connect_socket(client) + 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.""" + 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) + + 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 + # 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. + + 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) + 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"), email) if n + ) + self_set_names = tuple( + n for n in ( + profile.get("display_name"), + profile.get("real_name"), + ) if n + ) + 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 " + "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, + 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) + 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.""" + 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}") + 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) + 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 + direct_message = channel_type == "im" or channel_id.startswith("D") + 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.any_deny_pattern(lambda p: "@" in p), + ) + conversation = await self._identify_conversation( + channel_id, + channel_type, + needs_name_resolution(policy.channels, is_id=is_slack_id), + ) + 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 + + # ------------------------------------------------------------------ # + # 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: + """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: + """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 + if await self._is_another_app_talking(event): + 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" + ) + # 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}" + + 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(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 + # 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: + 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 {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 # + # ------------------------------------------------------------------ # + + 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: + """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. + """ + web = self._available_web() + channel_id, thread_ts = parse_target(target) + resp = await 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. An + unavailable channel is one of those failures. + """ + 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: + 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 + + 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] + "…" + try: + await self._web.chat_update( + channel=channel_id, ts=message_id, text=body, + ) + except Exception as 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: + """Remove a message — used to clear the streaming placeholder.""" + if not self.is_available: + 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 not self.is_available: + 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 not self.is_available: + 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 not self.is_available 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. + + 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, + format_target(channel_id, 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 + + # 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), + ) + 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 " + "opens its own thread and session. Use `/nerve stop` to select a " + "running thread, or use this command in a DM." + ) + + @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], + ) -> None: + prev = await self.router.get_last_session(channel_key) + if 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", + ) + 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]]: + """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:") :]) + 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 + + @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 conversation" + 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.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 + 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: {result['title']}" + if result + else "No pending questions in this conversation.", + ) + + 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 not self.is_available: + 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 not self.is_available: + 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 not self.is_available: + 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 "" + # 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 not await self._authorize(user_id, channel_id, channel_type): + return + + if action_id.startswith("sessstop:"): + 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.", + ) + 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)}" + + # 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( + 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 "" + 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 result: + 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 = 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 + # 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, + ) + + @staticmethod + def _snoozed_until(notification: dict[str, Any]) -> str | None: + """Render the next delivery time when an answer snoozed the row.""" + try: + if notification.get("status") != "pending" or not notification.get( + "redeliver_at" + ): + return None + from datetime import datetime + + dt = datetime.fromisoformat(notification["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/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/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..776c4a58 --- /dev/null +++ b/nerve/channels/slack_runtime.py @@ -0,0 +1,235 @@ +"""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.""" + + +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.""" + + 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 + self._closed = False + + @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.""" + async with self._lock: + 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 None + await self._stop_locked(drain=True) + return "disabled" + + 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 "credentials reloaded" + + channel.apply_config(desired) + self._active_config = desired + return None + + async def shutdown(self) -> None: + """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) + + 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._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) + # 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 + 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", + _redact( + str(error) or type(error).__name__, + _secrets_of(self._active_config), + ), + ) + else: + logger.info("Slack socket reconnected") diff --git a/nerve/channels/stream_adapter.py b/nerve/channels/stream_adapter.py index 9a41661a..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 @@ -170,8 +174,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/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/nerve/cli.py b/nerve/cli.py index 97acedb1..9374aba3 100644 --- a/nerve/cli.py +++ b/nerve/cli.py @@ -1110,6 +1110,44 @@ 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 + or slack.allow_channels + or slack.allow_direct_messages + ): + warnings.append( + "[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 " + f"rule(s), direct messages " + f"{'allowed' if slack.allow_direct_messages else 'refused'}" + ) + 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..4bc4f91c 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -999,6 +999,113 @@ 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` 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", "reply", +) + + +def _slack_commands(raw: object) -> list[str] | None: + """Normalize ``slack.commands``. + + 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 + 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 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 + # 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) + # 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" + # None keeps safe defaults; [] disables commands. Host-wide and + # cross-channel commands are opt-in. See SLACK_*_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", "") + # 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 + 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_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, + commands=_slack_commands(d.get("commands")), + ) + + @dataclass class TelegramSyncConfig: enabled: bool = True @@ -2600,6 +2707,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 +2936,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..ad1dbd54 100644 --- a/nerve/config_reload.py +++ b/nerve/config_reload.py @@ -1,39 +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, 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 @@ -105,6 +77,8 @@ "auth.jwt_secret", "langfuse.public_key", "langfuse.secret_key", + "slack.app_token", + "slack.bot_token", "telegram.bot_token", }) @@ -271,6 +245,21 @@ def hand_over(label: str, target) -> None: return problems +async def _reconcile_slack(new_config, engine) -> str | None: + """Ask the installed Slack lifecycle owner to reconcile this generation.""" + if engine is None: + return None + try: + runtime = engine.get_channel_runtime("slack") + if runtime is None: + return None + return await runtime.reconcile(new_config) + except Exception as e: # noqa: BLE001 — report the subsystem, continue reload + detail = str(e) or type(e).__name__ + logger.warning("Slack reload failed: %s", detail) + return f"{_ERROR_PREFIX}{detail}" + + async def reload_all(engine, cron_service, config_dir: Path) -> dict: """Re-read config and hot-reload all reloadable subsystems. @@ -314,6 +303,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 _reconcile_slack(new_config, engine) + if slack is not None: + summary["slack"] = slack # 2. Cron jobs + sources. if cron_service is not None: diff --git a/nerve/gateway/server.py b/nerve/gateway/server.py index 3f409ed8..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,6 +341,21 @@ async def lifespan(app: FastAPI): await telegram_channel.start() logger.info("Telegram 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 cron_task = None @@ -675,12 +687,13 @@ 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() + 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/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..ee71f685 --- /dev/null +++ b/tests/fake_slack.py @@ -0,0 +1,237 @@ +"""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 + +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": "fake", + "team_id": "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_channel_archives.py b/tests/test_channel_archives.py index a384b1e5..57490912 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(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 diff --git a/tests/test_config_reload.py b/tests/test_config_reload.py index a093e705..594cbbbd 100644 --- a/tests/test_config_reload.py +++ b/tests/test_config_reload.py @@ -767,6 +767,310 @@ async def test_a_rotated_secret_is_reported_without_its_value( assert "new-secret" not in summary["restart_required"] +class TestSlackLifecycleReload: + @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 + 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: ( + 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 + 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( + 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(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"), + ) + summary = await reload_all(engine, None, config_dir) + + 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", "") + assert reload_failures(summary) == {} + + @pytest.mark.asyncio + 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) + channel._state = "running" + channel._web = object() + + 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(engine, None, config_dir) + + 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_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, + ): + 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") + channel._state = "running" + channel._web = object() + + 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, + ): + 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): @@ -801,9 +1105,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 01de1739..421da02a 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,102 @@ 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_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 + + 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 + + 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_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" 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..66933e2e 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1756,3 +1756,73 @@ 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 TestChannelSessionsForConversation: + """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_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_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_for_conversation("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_for_conversation( + "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_for_conversation("slack:C1") + assert rows[0]["title"] == "t-s1" 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_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 diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py new file mode 100644 index 00000000..ae0e2462 --- /dev/null +++ b/tests/test_slack_channel.py @@ -0,0 +1,2062 @@ +"""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 +import copy +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nerve.channels.base import ChannelCapability, OutboundMessage +from nerve.channels.slack import ( + MAX_MSG_LEN, + SlackChannel, + SlackUnavailable, + _md_to_slack, + build_sessions_blocks, + format_target, + is_slack_id, + parse_target, + slack_emoji_name, + slack_to_plain, + split_message, +) +from nerve.channels.slack_presentation import build_notification_blocks +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(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 # +# ---------------------------------------------------------------------- # + + +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_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 every guardrail is read per use. + cfg = _config(allow_users=["U1"]) + channel = SlackChannel(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: + @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"], + 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"], allow_direct_messages=True) + 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_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( + 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"], allow_direct_messages=True) + 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_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"], allow_direct_messages=True) + 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"], + allow_direct_messages=True, + ) + 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, + ) + 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", + } + ) + 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_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:1.0" + + @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"], allow_direct_messages=True) + 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"], allow_direct_messages=True) + 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"], 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"}, + }) + 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"], + 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"}, + } + ) + 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 + 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_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 + ) + 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() + 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 + # 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"], 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.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_rebuild_uses_the_active_credential_pair(self): + channel = _channel() + channel._active_app_token = "xapp-active" + active_web = channel._web + 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) + + await channel.rebuild_transport() + + 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 + 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" + channel._team_id = "T0OLD" + + candidate_web = MagicMock() + candidate = MagicMock() + + async def connect_candidate(): + events.append("candidate connected") + assert not channel.is_available + 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", "team_id": "T0OLD"}, + )) + + 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) + + 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(desired) + + 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._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 + 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"}, + ) + ) + + desired = _with_credentials(channel, "xoxb-new", "xapp-new") + desired.slack.allow_users = ["U2"] + with pytest.raises(RuntimeError, match="different workspace"): + 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( + 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): + # 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"], + allow_direct_messages=True, + ) + 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_direct_messages=True, + ) + 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_direct_messages=True, + ) + 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_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_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_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 + + 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() + + @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): + 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"]) <= presentation_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() + 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"), + ) + msg = channel.router.handle_message.await_args[0][0] + assert msg.text == "via an integration" + + @pytest.mark.asyncio + 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): + # 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.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.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.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.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.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. + 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")]) + 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"], allow_direct_messages=True, **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", "reply"} <= enabled + + 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" 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_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 "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 + 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.stop_session = AsyncMock() + said = await self._run(channel, "stop") + assert "turned off" in said + channel.router.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: + """Slash commands bind only DMs; shared messages always bind threads.""" + + def _ch(self, **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() + channel.router.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_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", + ) + assert routed == "slack:D1" + + @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.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.stop_session.assert_not_called() + + @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_presentation as presentation_module + + blocks = build_notification_blocks("z" * 400_000, "n1") + sections = self._sections(blocks) + assert len(sections) == presentation_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.answer_delivered_notification = AsyncMock( + return_value={ + "status": "answered", + "redeliver_at": 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.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")}, + } + ) + return channel, service + + @pytest.mark.asyncio + async def test_the_settled_card_names_who_answered(self): + 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 new file mode 100644 index 00000000..e1248405 --- /dev/null +++ b/tests/test_slack_integration.py @@ -0,0 +1,295 @@ +"""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 + +import copy +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(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_credentials_rotate_on_the_running_channel( + self, slack, monkeypatch, + ): + channel, router = await _started( + slack, monkeypatch, allow_users=["U1"], + allow_direct_messages=True, + ) + old_client = channel._client + try: + 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() + 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"): + 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() + 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. + 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", + "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"], + allow_direct_messages=True, + ) + 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"]) + client = channel._client + await channel.stop() + assert channel._client is None + assert not await 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"], + allow_direct_messages=True, + ) + 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"], + allow_direct_messages=True, + ) + 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"], + allow_direct_messages=True, + ) + 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/tests/test_slack_runtime.py b/tests/test_slack_runtime.py new file mode 100644 index 00000000..d654269b --- /dev/null +++ b/tests/test_slack_runtime.py @@ -0,0 +1,367 @@ +"""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_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) + 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() + + assert await runtime.reconcile(_config(allow_users=["U2"])) is None + assert runtime.channel.config.slack.allow_users == ["U2"] + + release.set() + 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 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 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"