diff --git a/Cargo.lock b/Cargo.lock index 60372ea..a3aeb33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,15 +74,20 @@ dependencies = [ "async-trait", "axum", "futures-util", + "hmac", + "html5gum", + "mime", "regex", "reqwest", "serde", "serde_json", + "sha2", "tokio", "tokio-tungstenite 0.30.0", "tokio-util", "tracing", "unicode-general-category", + "url", ] [[package]] @@ -599,6 +604,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "html5gum" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3918b5f36d61861b757261da986b51be562c7a87ac4e531d4158e67e08bff72" +dependencies = [ + "jetscii", +] + [[package]] name = "http" version = "1.5.0" @@ -828,6 +842,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jetscii" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" + [[package]] name = "jni" version = "0.22.4" diff --git a/docs/slack.md b/docs/slack.md index 8be4f9b..adf80c5 100644 --- a/docs/slack.md +++ b/docs/slack.md @@ -338,31 +338,10 @@ before every allocation, byte transfer, and final share. The helper owns upload validation, transfer and receipt verification; it does not require Socket Mode. `agentchat.channels.slack.rendering.rich_payload(reply)` builds content fields while the host supplies its saved routing. -Durable hosts can also delegate routing and update payloads to -`agentchat.channels.slack.rendering.message_request`. It validates the channel and -timestamps, renders the reply, selects `chat.postMessage` or `chat.update`, and -clears old attachments on updates. It does not send, authenticate or retry: - -```python -from agentchat import RichReply -from agentchat.channels.slack.rendering import message_request - -request = message_request( - RichReply("Completed", blocks=()), - channel_id="C01234567", - thread_ts="1792100000.000", - message_ts="1792100000.001", # Omit to post a new reply. - metadata={"event_type": "saved_delivery", "event_payload": {"id": "one"}}, - fallback_blocks=False, -) -# After the host's durable claim and authorization recheck: -result = await request_once(request["method"], request["payload"]) -``` - -By default, empty `RichReply.blocks` generates readable text sections. -`fallback_blocks=False` preserves explicit empty blocks for plain replacements -or card-only replies. Persist the host's delivery identity before calling its -one-attempt transport, and reconcile uncertain outcomes instead of resending. +Applications with durable queues can compose the SDK's signed webhook, context, +file-reader and one-attempt delivery services without another state store. See +[applications with their own Slack inbox](#applications-with-their-own-slack-inbox) +for the shared provider APIs and the responsibilities retained by the host. ## Mirroring web inputs into Slack @@ -509,20 +488,45 @@ See [the source protocol](../python/agentchat/channels/slack/transport.py) and [the injected-source tests](../tests/channels/slack/test_event_source.py) for a concrete queue-source composition that reuses the same adapter. -## Development +## Applications with their own Slack inbox -```bash -python -m pip install -e ".[dev]" -ruff check . -pytest +These services reuse Slack provider behavior without creating another inbox, +credential store, subscription store or outbox. The host owns authorization, +workspace and participant policy, commands, conversation admission, transaction +boundaries and durable effect claims. The host functions in the examples below +are integration hooks, not SDK-provided storage or credential APIs. + +### Verify HTTP requests + +```python +from agentchat.channels.slack.webhook import SlackWebhook + +webhook = SlackWebhook(signing_secret) # Obtained from the host's secret store. + +async def receive_event(raw, headers): + incoming = webhook.event(raw, headers) + if incoming["kind"] == "challenge": + return {"challenge": incoming["challenge"]} + if incoming["kind"] == "event": + payload = incoming["payload"] + await authorize_workspace(payload["team_id"]) + await persist_inbox(payload) # Deduplicate before acknowledging. + return {"ok": True} ``` -### Applications with their own Slack inbox +Read HTTP bodies with a streaming size limit before calling the helper. `event` +checks the supplied byte limit (default 256 KiB), timestamp freshness, HMAC and +JSON envelope. `interaction(raw, headers)` verifies and parses a single form +`payload` with a default 64 KiB limit. Both return decoded data; neither admits a +conversation. Map `SlackSignatureError`, `SlackPayloadError` and +`SlackBodyTooLarge` to the host's HTTP errors (typically 401, 400 and 413). +The size error subclasses `SlackPayloadError`, so handle it first. `verify` is +available when only signature verification is needed; `now=` supports a host clock. -`SlackHost` provides the same event routing and message interpretation as the Slack -channel without creating another inbox, credential store, or subscription store. -The application authenticates the incoming event, authorizes each context read, -and owns admission, retries, and persistence. +### Assemble authorized context + +`SlackHost` supplies event routing, attribution and bounded thread context. Pass +only authenticated events, and authorize every page in the read callback. ```python from agentchat.channels.slack.host import SlackHost @@ -572,3 +576,124 @@ subtypes and configured exclusions return `None`. Existing `Slack.thread_history` remains supported and shares record normalization with this API. Applications should persist admitted context if retries must see the same snapshot. + +### Send once and reconcile uncertain delivery + +```python +from agentchat import RichReply +from agentchat.channels.slack.delivery import SlackDelivery +from agentchat.channels.slack.rendering import message_request + +async def request(http_method, api_method, payload): + await authorize_delivery() # Runs before each send or reconciliation page. + return await authenticated_request_once(http_method, api_method, payload) + +delivery = SlackDelivery(request, transport_errors=(HostTransportError,)) +plan = message_request( + RichReply("Completed"), channel_id=saved_channel, thread_ts=saved_thread, + metadata={"event_type": "saved_delivery", "event_payload": {"id": delivery_id}}, +) +if await claim_pending_delivery(delivery_id): + outcome = await delivery.send_request(plan) + if outcome["status"] != "uncertain": + await save_delivery_receipt(delivery_id, outcome) + # Otherwise retain the in-flight claim. Do not post again. +``` + +Prepare and validate the plan before acquiring the durable sending claim, then +recheck current authorization before external I/O. `send_request` performs one +callback invocation and returns `confirmed`, `rejected` or `uncertain`, with a +receipt when available. Pass transport/response-decoding exception classes from +your client; other exceptions and cancellation propagate. The callback and its +underlying client must also disable automatic write retries. The SDK never +releases or retries the host's claim after a lost response or cancellation. + +`send(reply, channel_id=..., ...)` combines planning and one attempt when separate +planning is unnecessary. Set `message_ts=` to update an existing message; +`fallback_blocks=False` preserves explicit empty blocks for plain updates or +card-only replies. Updates clear old attachments. `set_status(channel, thread, +text)` uses the same callback and outcome type for one working-status attempt; +the host owns refresh scheduling and clears it with an empty string. + +Reconcile a saved in-flight delivery using reads only: + +```python +outcome = await delivery.reconcile( + saved_channel, saved_thread, event_type="saved_delivery", event_id=delivery_id, + max_pages=5, timeout=8, +) +if outcome["status"] == "confirmed": + await save_delivery_receipt(delivery_id, outcome) +``` + +The SDK chooses GET for reconciliation and POST for writes. Reconciliation matches +both metadata event type and ID within the requested thread, follows bounded +pagination, and stops on repeated cursors. An absent receipt, failed read or +exhausted budget stays `uncertain`; none proves that a write failed or permits a +resend. Authorize inside the callback so revocation stops subsequent pages. + +### Read files with rotating credentials + +`agentchat.channels.slack.files.SlackFileReader` lets a host keep its credential +store and authorization checks while the SDK owns acquisition and verification. +The existing `download_slack_file` and native attachment download API share the +same metadata and transfer-integrity policies. + +```python +from agentchat.channels.slack.files import SlackFileReader, file_operation, inspect_canvas + +reader = SlackFileReader(max_bytes=25 * 1024 * 1024) +file = await reader.read( + "F12345678", + token=current_bot_token, # async () -> str + request=authorized_request, # async (method, payload, token) -> Slack response + authorize=require_access, # async () -> None; checked around provider reads +) +try: + if file.representation == "canvas_html": + media_type, _, preview = await file_operation(inspect_canvas, file.raw) + # The seekable original is verified and positioned at byte zero. + # The host performs its own inspection, transcription and durable retention. +finally: + file.raw.close() +``` + +Translate safe connector failures to `SlackTransportError` in the host callback; +include only its code, status, retry interval and dispatch/rejection facts. +Authorization exceptions propagate unchanged; an explicit `SlackFileError` +provides a safe attachment diagnostic. Access is checked before credential +acquisition, after it, after metadata lookup and before returning the file. +Defaults allow two read attempts within 90 seconds. Credential acquisition is never +replayed when a refresh outcome is uncertain; eligible read retries respect the +complete provider interval, shared cooldown and total budget. + +Private downloads reject redirects, unexpected content encoding, partial responses, +size mismatches and storage-limit violations before returning a file. Cancelled +file-worker operations finish before their temporary file closes. Canvas projection +reads a bounded UTF-8 prefix, ignores hidden and executable HTML, marks truncated +previews and leaves original bytes untouched. Applications remain responsible for +access policy, content inspection, image previews, audio transcription and durable +retention. Treat extracted content as untrusted, and close the returned file. + +### Format replies without moving application policy + +`rendering.markdown_text(text, mentions=allowed_ids, resolve_link=resolver)` escapes +unapproved mentions, adapts Markdown headings and bold, and preserves code bodies. +The optional synchronous resolver receives local Markdown targets and inline-code +filenames and returns an authorized URL or `None`. It does not receive fenced code +or HTTP links. The host chooses mention permissions and resolves local file access; +`RichReply` does not convert its text implicitly. + +`rendering.text_blocks(text, limit=2900)` and `rendering.split_text(text, limit=2900)` +share Unicode-, link- and fence-safe chunking. Section content defaults to 2,992 +characters, leaving room for boundary fences. `split_text` preserves newlines by +default; `trim_boundary_newlines=True` retains the older boundary policy when +needed. Application-specific fallback wording and product controls stay in the host. + +## Development + +```bash +python -m pip install -e ".[dev]" +ruff check . +pytest +``` diff --git a/python/agentchat/channels/slack/delivery.py b/python/agentchat/channels/slack/delivery.py new file mode 100644 index 0000000..58fcd22 --- /dev/null +++ b/python/agentchat/channels/slack/delivery.py @@ -0,0 +1,21 @@ +"""One-attempt Slack sends and bounded read reconciliation, implemented in Rust. + +The async request callback receives ``(http_method, api_method, payload)`` and +returns the parsed provider response. Supply transport exception classes through +``transport_errors``; other exceptions and cancellation propagate. A send is +never retried. Hosts retain authorization, durable claims, and receipt storage. + +``send_request`` accepts the plan from ``rendering.message_request`` so the host +can prepare a validated request before acquiring its durable sending claim. +``reconcile`` matches both metadata event type and id. Absence or failed reads +remain uncertain and never authorize another write. +""" +from typing import Literal, TypedDict + +from agentchat._native import SlackDelivery as SlackDelivery + + +class SlackDeliveryResult(TypedDict): + status: Literal["confirmed", "rejected", "uncertain"] + receipt: dict[str, object] | None + reason: str | None diff --git a/python/agentchat/channels/slack/delivery.pyi b/python/agentchat/channels/slack/delivery.pyi new file mode 100644 index 0000000..0d95e1d --- /dev/null +++ b/python/agentchat/channels/slack/delivery.pyi @@ -0,0 +1,30 @@ +from collections.abc import Awaitable, Callable, Mapping +from typing import Literal, TypedDict + +from agentchat.models import RichReply + +class SlackDeliveryResult(TypedDict): + status: Literal["confirmed", "rejected", "uncertain"] + receipt: dict[str, object] | None + reason: str | None + +class SlackDelivery: + def __init__( + self, + request: Callable[[str, str, dict[str, object]], Awaitable[Mapping[str, object]]], + *, transport_errors: tuple[type[Exception], ...] = ..., + ) -> None: ... + async def send( + self, reply: RichReply, *, channel_id: str, thread_ts: str | None = ..., + message_ts: str | None = ..., metadata: Mapping[str, object] | None = ..., + fallback_blocks: bool = ..., + ) -> SlackDeliveryResult: ... + async def send_request(self, request: Mapping[str, object]) -> SlackDeliveryResult: ... + async def set_status( + self, channel_id: str, thread_ts: str, status: str, + ) -> SlackDeliveryResult: ... + async def reconcile( + self, channel_id: str, thread_ts: str, *, event_type: str, event_id: str, + max_pages: int = ..., + timeout: float = ..., # noqa: ASYNC109 - bounded native callback protocol + ) -> SlackDeliveryResult: ... diff --git a/python/agentchat/channels/slack/files.py b/python/agentchat/channels/slack/files.py index 67ef344..f5b1acc 100644 --- a/python/agentchat/channels/slack/files.py +++ b/python/agentchat/channels/slack/files.py @@ -1,11 +1,38 @@ """Slack file interfaces; Rust owns transfer policy and sequencing.""" + from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from typing import BinaryIO import aiohttp as aiohttp +from agentchat._native import ( + SLACK_CANVAS_MIME as CANVAS_MIME, +) +from agentchat._native import ( + SlackFileReader as SlackFileReader, +) +from agentchat._native import ( + slack_canvas_bounded_preview as bounded_preview, +) from agentchat._native import ( slack_download_file as download_slack_file, ) +from agentchat._native import ( + slack_file_diagnostic, + slack_file_error_init, + slack_file_error_string, + slack_transport_error_init, +) +from agentchat._native import ( + slack_file_operation as file_operation, +) +from agentchat._native import ( + slack_inspect_canvas as inspect_canvas, +) +from agentchat._native import ( + slack_is_canvas as is_canvas, +) from agentchat._native import ( slack_upload_bytes as upload_bytes, ) @@ -19,7 +46,75 @@ SlackRequest = Callable[[str, dict[str, object]], Awaitable[Mapping[str, object]]] DeliveryGuard = Callable[[], Awaitable[None]] + +class CanvasFormatError(ValueError): + """The bounded export is not readable Canvas HTML; never contains its content.""" + + +class SlackFileError(ValueError): + """Safe read failure with content-free attempt diagnostics.""" + + def __init__(self, code, *, retryable=False, retry_after=None): + slack_file_error_init(self, code, retryable=retryable, retry_after=retry_after) + + def diagnostic(self): + return slack_file_diagnostic(self) + + def __str__(self): + return slack_file_error_string(self) + + +class SlackTransportError(Exception): + """Host callback failure metadata. Do not pass response bodies or credentials.""" + + def __init__( + self, + code, + *, + status_code=None, + dispatched=None, + rejected=False, + retryable=False, + retry_after=None, + ): + slack_transport_error_init( + self, + code, + status_code=status_code, + dispatched=dispatched, + rejected=rejected, + retryable=retryable, + retry_after=retry_after, + ) + + +@dataclass(frozen=True) +class SlackDownloadedFile: + """Caller owns and must close raw; original bytes remain available after preview.""" + + name: str + raw: BinaryIO + media_type: str + representation: str + history: list[dict[str, object]] + state: dict[str, object] + + __all__ = [ - "DeliveryGuard", "SlackRequest", "download_slack_file", "upload_bytes", - "upload_slack_files", "validate_destination", + "CANVAS_MIME", + "CanvasFormatError", + "DeliveryGuard", + "SlackDownloadedFile", + "SlackFileError", + "SlackFileReader", + "SlackRequest", + "SlackTransportError", + "bounded_preview", + "download_slack_file", + "file_operation", + "inspect_canvas", + "is_canvas", + "upload_bytes", + "upload_slack_files", + "validate_destination", ] diff --git a/python/agentchat/channels/slack/files.pyi b/python/agentchat/channels/slack/files.pyi index 444c1ed..2667819 100644 --- a/python/agentchat/channels/slack/files.pyi +++ b/python/agentchat/channels/slack/files.pyi @@ -1,5 +1,6 @@ # Public compatibility signatures; update with the corresponding Rust binding API. from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import Any, BinaryIO, TypeVar from agentchat.models import Attachment, DownloadedFile, UploadedFile, UploadFile @@ -24,3 +25,63 @@ async def download_slack_file( max_bytes: int = ..., before_download: DeliveryGuard | None = ..., ) -> DownloadedFile: ... + +CANVAS_MIME: str + +class CanvasFormatError(ValueError): ... + +class SlackFileError(ValueError): + code: str + retryable: bool + retry_after: float | None + history: list[dict[str, Any]] + retry_blocker: str + def __init__( + self, code: str, *, retryable: bool = False, retry_after: float | None = None + ) -> None: ... + def diagnostic(self) -> dict[str, Any]: ... + +class SlackTransportError(Exception): + def __init__( + self, + code: str, + *, + status_code: int | None = None, + dispatched: bool | None = None, + rejected: bool = False, + retryable: bool = False, + retry_after: float | None = None, + ) -> None: ... + +class SlackDownloadedFile: + name: str + raw: BinaryIO + media_type: str + representation: str + history: list[dict[str, Any]] + state: dict[str, Any] + +class SlackFileReader: + def __init__( + self, + *, + max_bytes: int, + read_attempts: int = 2, + read_budget: float = 90.0, + retry_delay: float = 1.0, + ) -> None: ... + async def read( + self, + file_id: str, + *, + token: Callable[[], Awaitable[str]], + request: Callable[[str, dict[str, object], str], Awaitable[Mapping[str, object]]], + authorize: Callable[[], Awaitable[None]], + ) -> SlackDownloadedFile: ... + +_T = TypeVar("_T") + +def file_operation(operation: Callable[..., _T], *args: Any) -> Awaitable[_T]: ... +def is_canvas(file: Mapping[str, object]) -> bool: ... +def bounded_preview(text: str, *, truncated: bool = False) -> str: ... +def inspect_canvas(raw: BinaryIO) -> tuple[str, bytes, str]: ... diff --git a/python/agentchat/channels/slack/rendering.py b/python/agentchat/channels/slack/rendering.py index db5fabe..1eaebd0 100644 --- a/python/agentchat/channels/slack/rendering.py +++ b/python/agentchat/channels/slack/rendering.py @@ -1,8 +1,10 @@ """Slack rendering implemented in Rust.""" from typing import Literal, TypedDict +from agentchat._native import slack_markdown_text as markdown_text from agentchat._native import slack_message_request as message_request from agentchat._native import slack_rich_payload as rich_payload +from agentchat._native import slack_split_text as split_text from agentchat._native import slack_text_blocks as text_blocks @@ -11,4 +13,7 @@ class SlackMessageRequest(TypedDict): payload: dict[str, object] -__all__ = ['SlackMessageRequest', 'message_request', 'rich_payload', 'text_blocks'] +__all__ = [ + 'SlackMessageRequest', 'markdown_text', 'message_request', 'rich_payload', + 'split_text', 'text_blocks', +] diff --git a/python/agentchat/channels/slack/rendering.pyi b/python/agentchat/channels/slack/rendering.pyi index cdd4da2..a2d86c7 100644 --- a/python/agentchat/channels/slack/rendering.pyi +++ b/python/agentchat/channels/slack/rendering.pyi @@ -1,5 +1,5 @@ # Public compatibility signatures; update with the corresponding Rust binding API. -from collections.abc import Mapping +from collections.abc import Callable, Iterable, Mapping from typing import Literal, TypedDict from agentchat.models import RichReply @@ -8,7 +8,14 @@ class SlackMessageRequest(TypedDict): method: Literal["chat.postMessage", "chat.update"] payload: dict[str, object] -def text_blocks(text: str) -> list[dict[str, object]]: ... +def text_blocks(text: str, *, limit: int = ...) -> list[dict[str, object]]: ... +def markdown_text( + text: str, *, mentions: Iterable[str] | None = ..., + resolve_link: Callable[[str], str | None] | None = ..., +) -> str: ... +def split_text( + text: str, *, limit: int = ..., trim_boundary_newlines: bool = ..., +) -> list[str]: ... def rich_payload(reply: RichReply) -> dict[str, object]: ... def message_request( reply: RichReply, *, channel_id: str, thread_ts: str | None = ..., diff --git a/python/agentchat/channels/slack/webhook.py b/python/agentchat/channels/slack/webhook.py new file mode 100644 index 0000000..1eed44e --- /dev/null +++ b/python/agentchat/channels/slack/webhook.py @@ -0,0 +1,39 @@ +"""Signed Slack HTTP parsing; hosts own workspace authorization and durable admission. + +Read request bodies with a streaming limit before calling these helpers. The SDK +also enforces the supplied byte limit, authenticates the exact raw bytes, and +separates signature, syntax, and size errors without a web framework dependency. +""" +from typing import Literal, TypedDict + +from agentchat._native import ( + SlackBodyTooLarge as SlackBodyTooLarge, +) +from agentchat._native import ( + SlackPayloadError as SlackPayloadError, +) +from agentchat._native import ( + SlackSignatureError as SlackSignatureError, +) +from agentchat._native import ( + SlackWebhook as SlackWebhook, +) + + +class SlackEventEnvelope(TypedDict): + kind: Literal["event"] + payload: dict[str, object] + + +class SlackChallengeEnvelope(TypedDict): + kind: Literal["challenge"] + payload: dict[str, object] + challenge: str + + +class SlackIgnoredEnvelope(TypedDict): + kind: Literal["ignored"] + payload: dict[str, object] + + +SlackWebhookEvent = SlackEventEnvelope | SlackChallengeEnvelope | SlackIgnoredEnvelope diff --git a/python/agentchat/channels/slack/webhook.pyi b/python/agentchat/channels/slack/webhook.pyi new file mode 100644 index 0000000..5e5638f --- /dev/null +++ b/python/agentchat/channels/slack/webhook.pyi @@ -0,0 +1,38 @@ +from collections.abc import Mapping +from typing import Literal, TypedDict + +class SlackSignatureError(PermissionError): ... +class SlackPayloadError(ValueError): ... +class SlackBodyTooLarge(SlackPayloadError): ... + +class SlackEventEnvelope(TypedDict): + kind: Literal["event"] + payload: dict[str, object] + + +class SlackChallengeEnvelope(TypedDict): + kind: Literal["challenge"] + payload: dict[str, object] + challenge: str + + +class SlackIgnoredEnvelope(TypedDict): + kind: Literal["ignored"] + payload: dict[str, object] + + +SlackWebhookEvent = SlackEventEnvelope | SlackChallengeEnvelope | SlackIgnoredEnvelope + +class SlackWebhook: + def __init__(self, secret: str) -> None: ... + def verify( + self, raw: bytes, headers: Mapping[str, str], *, now: float | None = ..., + ) -> None: ... + def event( + self, raw: bytes, headers: Mapping[str, str], *, now: float | None = ..., + max_bytes: int = ..., + ) -> SlackWebhookEvent: ... + def interaction( + self, raw: bytes, headers: Mapping[str, str], *, now: float | None = ..., + max_bytes: int = ..., + ) -> dict[str, object]: ... diff --git a/scripts/python-package-smoke.py b/scripts/python-package-smoke.py index c25b951..a44b6f0 100644 --- a/scripts/python-package-smoke.py +++ b/scripts/python-package-smoke.py @@ -5,7 +5,12 @@ from agentchat import AgentChat, Message, Sender from agentchat._native import core_version +from agentchat.channels.slack.delivery import SlackDelivery from agentchat.channels.slack.events import to_message +from agentchat.channels.slack.files import SlackFileReader +from agentchat.channels.slack.rendering import markdown_text +from agentchat.channels.slack.webhook import SlackSignatureError, SlackWebhook +from agentchat.models import RichReply class EchoChannel: @@ -29,6 +34,21 @@ async def close(self): async def main(): assert files("agentchat").joinpath("py.typed").is_file() assert files("agentchat").joinpath("app.pyi").is_file() + assert markdown_text("**hello**") == "*hello*" + assert SlackFileReader(max_bytes=1024) is not None + try: + SlackWebhook("secret").verify(b"{}", {}) + except SlackSignatureError: + pass + else: + raise AssertionError("Unsigned Slack payload was accepted") + + async def request(verb, method, payload): + assert verb == "POST" and method == "chat.postMessage" + return {"ok": True, "channel": payload["channel"], "ts": "1792100000.000001"} + + result = await SlackDelivery(request).send(RichReply(text="hello"), channel_id="CCHANNEL1") + assert result["status"] == "confirmed" event = {"type": "message", "channel_type": "im", "channel": "D123", "user": "U123", "text": "hello", "ts": "1.000001"} normalized = to_message({"team_id": "T123", "event": event}, event, diff --git a/scripts/python-typing-smoke.py b/scripts/python-typing-smoke.py index 103427d..d81fd70 100644 --- a/scripts/python-typing-smoke.py +++ b/scripts/python-typing-smoke.py @@ -13,14 +13,17 @@ from agentchat.channels.discord import Discord from agentchat.channels.imessage import IMessage from agentchat.channels.slack import Slack, SlackUser +from agentchat.channels.slack.delivery import SlackDelivery, SlackDeliveryResult +from agentchat.channels.slack.files import SlackDownloadedFile, SlackFileReader from agentchat.channels.slack.host import SlackHost, SlackHostEvent, SlackRouting, ThreadContext -from agentchat.channels.slack.rendering import SlackMessageRequest, message_request +from agentchat.channels.slack.rendering import SlackMessageRequest, markdown_text, message_request +from agentchat.channels.slack.webhook import SlackWebhook, SlackWebhookEvent from agentchat.channels.teams import Teams from agentchat.channels.telegram import Telegram from agentchat.channels.telegram.rendering import RenderedText, render from agentchat.channels.telegram.transport import TelegramBotClient from agentchat.channels.whatsapp import WhatsApp -from agentchat.rich import RichReply +from agentchat.models import RichReply from agentchat.state import MemoryState @@ -60,3 +63,32 @@ async def check_slack_host( assert_type(await host.thread_context(normalized, request_page), ThreadContext) assert_type(await host.thread_context(normalized), ThreadContext) assert_type(message_request(reply, channel_id="CCHANNEL1"), SlackMessageRequest) + + +async def check_slack_services( + raw: bytes, + headers: Mapping[str, str], + request: Callable[[str, str, dict[str, object]], Awaitable[Mapping[str, object]]], + file_request: Callable[[str, dict[str, object], str], Awaitable[Mapping[str, object]]], + token: Callable[[], Awaitable[str]], + authorize: Callable[[], Awaitable[None]], +) -> None: + envelope = SlackWebhook("secret").event(raw, headers) + assert_type(envelope, SlackWebhookEvent) + if envelope["kind"] == "challenge": + assert_type(envelope["challenge"], str) + delivery = SlackDelivery(request) + plan = message_request(RichReply(text="hello"), channel_id="CCHANNEL1") + assert_type(await delivery.send_request(plan), SlackDeliveryResult) + assert_type( + await delivery.reconcile( + "CCHANNEL1", "1792100000.000001", event_type="reply", event_id="1", + ), + SlackDeliveryResult, + ) + reader = SlackFileReader(max_bytes=1024) + assert_type( + await reader.read("FFILE0001", token=token, request=file_request, authorize=authorize), + SlackDownloadedFile, + ) + assert_type(markdown_text("**hello**"), str) diff --git a/src/agentchat-slack/Cargo.toml b/src/agentchat-slack/Cargo.toml index b69e2b5..5ad6458 100644 --- a/src/agentchat-slack/Cargo.toml +++ b/src/agentchat-slack/Cargo.toml @@ -19,6 +19,11 @@ tokio-util = { version = "0.7", features = ["rt"] } tracing = "0.1" unicode-general-category = "1" regex = "1" +hmac = "0.12" +sha2 = "0.10" +url = "2" +html5gum = "0.7" +mime = "0.3" [dev-dependencies] axum = { version = "0.8", features = ["ws"] } diff --git a/src/agentchat-slack/src/delivery.rs b/src/agentchat-slack/src/delivery.rs new file mode 100644 index 0000000..6ae9b81 --- /dev/null +++ b/src/agentchat-slack/src/delivery.rs @@ -0,0 +1,267 @@ +//! One-attempt write receipts and bounded, read-only reconciliation. +//! Durable claims, credentials and application authorization stay in the host. +use crate::{ + message_request::{MessageMethod, MessageRequest}, + policy::ReplyDestination, + route::SlackTimestamp, +}; +use agentchat_core::{Error, Result, RichReply}; +use serde::Serialize; +use serde_json::{json, Map, Value}; +use std::collections::HashSet; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DeliveryStatus { + Confirmed, + Rejected, + Uncertain, +} + +#[derive(Clone, Debug, Serialize)] +pub struct DeliveryResult { + pub status: DeliveryStatus, + pub receipt: Option, + pub reason: Option, +} +impl DeliveryResult { + pub fn uncertain(reason: &str) -> Self { + Self { + status: DeliveryStatus::Uncertain, + receipt: None, + reason: Some(reason.into()), + } + } + pub fn from_response(request: &MessageRequest, response: Value) -> Self { + if response.get("ok") == Some(&Value::Bool(false)) { + return Self { + status: DeliveryStatus::Rejected, + receipt: Some(response), + reason: None, + }; + } + let timestamp = response + .get("ts") + .and_then(Value::as_str) + .and_then(SlackTimestamp::parse); + let channel_matches = response + .get("channel") + .is_none_or(|channel| channel == &request.payload["channel"]); + let update_matches = request.method != MessageMethod::Update + || response.get("ts") == request.payload.get("ts"); + if response.get("ok") == Some(&Value::Bool(true)) + && timestamp.is_some() + && channel_matches + && update_matches + { + Self { + status: DeliveryStatus::Confirmed, + receipt: Some(response), + reason: None, + } + } else { + Self::uncertain("unconfirmed_response") + } + } +} + +/// Working status is a separate effect: confirmation does not require a message timestamp. +pub struct StatusRequest { + pub method: &'static str, + pub payload: Value, +} +impl StatusRequest { + pub fn new(destination: ReplyDestination, status: &str) -> Self { + Self { + method: "assistant.threads.setStatus", + payload: json!({"channel_id":destination.channel_id,"thread_ts":destination.thread_ts,"status":status}), + } + } + pub fn response(response: Value) -> DeliveryResult { + let status = match response.get("ok") { + Some(Value::Bool(true)) => DeliveryStatus::Confirmed, + Some(Value::Bool(false)) => DeliveryStatus::Rejected, + _ => return DeliveryResult::uncertain("unconfirmed_response"), + }; + DeliveryResult { + status, + receipt: Some(response), + reason: None, + } + } +} + +/// Reconstruct a supplied plan through the same renderer/route validator used by new replies. +pub fn prepare_request(plan: &Value) -> Result { + let invalid = || Error::InvalidInput("Expected a Slack message request".into()); + let payload = plan + .get("payload") + .filter(|value| value.is_object()) + .ok_or_else(invalid)?; + let method = plan["method"].as_str().ok_or_else(invalid)?; + if !matches!(method, "chat.postMessage" | "chat.update") { + return Err(invalid()); + } + let text = payload["text"].as_str().ok_or_else(invalid)?; + let list = |key| -> Result>> { + match payload.get(key) { + None => Ok(Vec::new()), + Some(Value::Array(items)) => items + .iter() + .map(|item| item.as_object().cloned().ok_or_else(invalid)) + .collect(), + _ => Err(invalid()), + } + }; + let optional = |key| -> Result> { + match payload.get(key) { + None => Ok(None), + Some(Value::String(value)) => Ok(Some(value)), + _ => Err(invalid()), + } + }; + let timestamp = optional("ts")?; + if (method == "chat.update") != timestamp.is_some() { + return Err(invalid()); + } + MessageRequest::new( + &RichReply { + text: text.into(), + blocks: list("blocks")?, + attachments: list("attachments")?, + }, + payload["channel"].as_str().ok_or_else(invalid)?, + optional("thread_ts")?, + timestamp, + payload.get("metadata"), + false, + ) +} + +/// Per-call state; concurrent reconciliations never share cursors or identities. +pub struct Reconciliation { + destination: ReplyDestination, + event_type: String, + event_id: String, + max_pages: usize, + pages: usize, + cursor: String, + seen: HashSet, + result: DeliveryResult, +} +impl Reconciliation { + pub fn new( + channel: &str, + thread: &str, + event_type: &str, + event_id: &str, + max_pages: usize, + ) -> Result { + if !(1..=100).contains(&max_pages) + || event_type.is_empty() + || event_id.is_empty() + || event_type.len() > 256 + || event_id.len() > 1024 + { + return Err(Error::InvalidInput( + "Expected bounded Slack reconciliation and metadata identity".into(), + )); + } + Ok(Self { + destination: ReplyDestination::validate(channel, Some(thread))?, + event_type: event_type.into(), + event_id: event_id.into(), + max_pages, + pages: 0, + cursor: String::new(), + seen: HashSet::new(), + result: DeliveryResult::uncertain("not_found"), + }) + } + pub fn request(&self) -> Value { + let mut payload = json!({"channel":self.destination.channel_id,"ts":self.destination.thread_ts,"limit":100,"include_all_metadata":"true"}); + if !self.cursor.is_empty() { + payload["cursor"] = json!(self.cursor); + } + payload + } + pub fn fail(&mut self, reason: &str) { + self.result = DeliveryResult::uncertain(reason); + } + pub fn finish(self) -> DeliveryResult { + self.result + } + /// Returns true when no further page should be requested. Absence never permits replay. + pub fn accept(&mut self, response: &Value) -> bool { + self.pages += 1; + if response.get("ok") != Some(&Value::Bool(true)) { + self.fail("history_unavailable"); + return true; + } + let Some(messages) = response.get("messages").and_then(Value::as_array) else { + self.fail("invalid_history"); + return true; + }; + if messages.len() > 1000 { + self.fail("invalid_history"); + return true; + } + let thread = self.destination.thread_ts.as_deref().unwrap_or_default(); + for message in messages { + let Some(timestamp) = message["ts"].as_str().and_then(SlackTimestamp::parse) else { + continue; + }; + let Some(root) = SlackTimestamp::parse(thread) else { + continue; + }; + if timestamp.is_before(&root) + || message + .get("thread_ts") + .is_some_and(|value| value.as_str() != Some(thread)) + || message + .get("channel") + .is_some_and(|value| value.as_str() != Some(&self.destination.channel_id)) + { + continue; + } + let metadata = &message["metadata"]; + if metadata["event_type"].as_str() == Some(&self.event_type) + && metadata["event_payload"]["id"].as_str() == Some(&self.event_id) + { + self.result = DeliveryResult { + status: DeliveryStatus::Confirmed, + receipt: Some(message.clone()), + reason: None, + }; + return true; + } + } + let cursor = response + .get("response_metadata") + .and_then(|value| value.get("next_cursor")); + let next = match cursor { + None | Some(Value::Null) => "", + Some(Value::String(value)) => value.trim(), + _ => { + self.fail("invalid_cursor"); + return true; + } + }; + if next.is_empty() { + if response["has_more"] == true { + self.fail("missing_cursor"); + } + return true; + } + if next.len() > 2048 || !self.seen.insert(next.into()) { + self.fail("repeated_cursor"); + return true; + } + if self.pages >= self.max_pages { + self.fail("page_limit"); + return true; + } + self.cursor = next.into(); + false + } +} diff --git a/src/agentchat-slack/src/file_transfers.rs b/src/agentchat-slack/src/file_transfers.rs index 6302ce1..cde47ad 100644 --- a/src/agentchat-slack/src/file_transfers.rs +++ b/src/agentchat-slack/src/file_transfers.rs @@ -1,4 +1,7 @@ //! File-transfer policy independent of the HTTP or foreign-SDK transport. +pub mod canvas; +pub mod failure; +pub mod reader; use agentchat_core::{Attachment, DownloadedFile, Error, UploadFile, UploadedFile}; use async_trait::async_trait; use reqwest::Url; @@ -26,6 +29,16 @@ pub trait FileTransport: Sync { token: &str, max_bytes: usize, ) -> Result, Self::Error>; + /// Metadata-aware transports override this to verify headers before buffering. + /// The default preserves existing implementations; the caller still verifies bytes. + async fn download_file( + &self, + file: &reader::FileMetadata, + token: &str, + max_bytes: usize, + ) -> Result, Self::Error> { + self.download(&file.url, token, max_bytes).await + } async fn guard(&self) -> Result<(), Self::Error>; } @@ -248,38 +261,30 @@ pub async fn download( .request("files.info", &json!({"file":attachment.id})) .await .map_err(TransferError::Transport)?; - let file = &info["file"]; - if info["ok"] != true || file["id"] != attachment.id || file["is_external"] == true { - return Err(Error::InvalidInput( - "This Slack file is unavailable. Upload the attachment again.".into(), - ) - .into()); - } - let size = file["size"] - .as_u64() - .ok_or_else(|| Error::InvalidInput("Slack returned an invalid file size".into()))?; - if size > max_bytes as u64 { - return Err(download_limit_error(max_bytes).into()); - } - let url = super::events::nonempty(&file["url_private_download"]) - .or_else(|| super::events::nonempty(&file["url_private"])) - .ok_or_else(|| Error::InvalidInput("Slack returned an unsupported file location".into()))?; + let state = std::sync::Arc::new(std::sync::Mutex::new(reader::ReadProgress::default())); + let file = reader::FileMetadata::parse(&attachment.id, &info, max_bytes, &state).map_err( + |failure| match failure.code { + failure::FailureCode::HardLimit => download_limit_error(max_bytes), + _ => Error::InvalidInput(failure.code.detail().0.into()), + }, + )?; transport.guard().await.map_err(TransferError::Transport)?; let bytes = transport - .download(url, bot_token, max_bytes) + .download_file(&file, bot_token, max_bytes) .await .map_err(TransferError::Transport)?; transport.guard().await.map_err(TransferError::Transport)?; let mut buffer = DownloadBuffer::new(max_bytes); buffer.append(&bytes)?; + if !bytes.is_empty() { + reader::verify_size(file.size, file.canvas, bytes.len() as u64) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + } Ok(DownloadedFile { id: attachment.id.clone(), - name: file["name"] - .as_str() - .map(super::events::filename) - .unwrap_or_else(|| "attachment".into()), + name: file.name, content: buffer.finish()?, - media_type: file["mimetype"].as_str().map(str::to_owned), + media_type: (!file.media_type.is_empty()).then_some(file.media_type), }) } diff --git a/src/agentchat-slack/src/file_transfers/canvas.rs b/src/agentchat-slack/src/file_transfers/canvas.rs new file mode 100644 index 0000000..1831bc9 --- /dev/null +++ b/src/agentchat-slack/src/file_transfers/canvas.rs @@ -0,0 +1,349 @@ +//! Inert, bounded text projection of Slack's generated Canvas HTML export. +use html5gum::{DefaultEmitter, StartTag, Token, Tokenizer}; +use regex::Regex; +use serde_json::Value; +use std::sync::LazyLock; + +pub const MIME: &str = "application/vnd.slack-docs"; +pub const PREVIEW_CHARACTERS: usize = 8000; +pub const PREVIEW_SOURCE_BYTES: usize = 256 * 1024; +pub const TRUNCATION_NOTICE:&str="\n\n[Canvas text preview truncated. The complete original HTML is retained in the attachment.]"; +const MAX_NESTING: usize = 256; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CanvasError { + InvalidUtf8, + InvalidExport, + Nesting, + UnclosedWrapper, +} +impl std::fmt::Display for CanvasError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::InvalidUtf8 => "The Canvas export was not valid UTF-8.", + Self::InvalidExport => "The response was not a readable Canvas export.", + Self::Nesting => "The Canvas export exceeded the HTML nesting limit.", + Self::UnclosedWrapper => "The Canvas export did not finish its wrapper.", + }) + } +} +pub fn is_canvas(file: &Value) -> bool { + file["mimetype"] == MIME && file["filetype"] == "quip" && file["mode"] == "quip" +} +pub fn bounded_preview(text: &str, truncated: bool) -> String { + if truncated || text.chars().count() > PREVIEW_CHARACTERS { + format!( + "{}{}", + text.chars() + .take(PREVIEW_CHARACTERS - TRUNCATION_NOTICE.chars().count()) + .collect::() + .trim_end(), + TRUNCATION_NOTICE + ) + } else { + text.into() + } +} +struct Frame { + tag: String, + hidden: bool, + checklist: bool, +} +struct List { + ordered: bool, + number: usize, + checklist: bool, +} +#[derive(Default)] +struct Preview { + frames: Vec, + lists: Vec, + items: Vec<(Option, String)>, + parts: Vec, + root_depth: Option, + root_closed: bool, + invalid: bool, + truncated: bool, + length: usize, +} +impl Preview { + fn append(&mut self, text: &str) -> Option { + let remaining = (PREVIEW_CHARACTERS + 1).saturating_sub(self.length); + if text.chars().count() > remaining { + self.truncated = true; + } + if remaining == 0 { + return None; + } + let text: String = text.chars().take(remaining).collect(); + self.length += text.chars().count(); + self.parts.push(text); + Some(self.parts.len() - 1) + } + fn start(&mut self, start: StartTag) -> Result<(), CanvasError> { + static HIDDEN: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)(?:display\s*:\s*none|visibility\s*:\s*hidden)").expect("static regex") + }); + let tag = String::from_utf8_lossy(&start.name).into_owned(); + let attr = |key: &str| { + start + .attributes + .get(key.as_bytes()) + .map(|v| String::from_utf8_lossy(v).into_owned()) + }; + let classes = attr("class").unwrap_or_default(); + let class = |v: &str| classes.split_whitespace().any(|x| x == v); + let hidden = self.frames.last().is_some_and(|v| v.hidden) + || matches!( + tag.as_str(), + "head" + | "script" + | "style" + | "noscript" + | "template" + | "iframe" + | "object" + | "svg" + | "math" + | "canvas" + | "form" + ) + || attr("hidden").is_some() + || attr("aria-hidden").as_deref() == Some("true") + || HIDDEN.is_match(attr("style").as_deref().unwrap_or_default()); + let checklist = self.frames.last().is_some_and(|v| v.checklist) + || attr("data-section-style").as_deref() == Some("7") + || class("checklist"); + if tag == "div" && class("quip-canvas-content") { + if self.root_depth.is_some() { + self.invalid = true; + } else { + self.root_depth = Some(self.frames.len() + 1); + } + } + let inside = self + .root_depth + .is_some_and(|d| !self.root_closed && self.frames.len() + 1 >= d); + if !matches!( + tag.as_str(), + "area" + | "base" + | "br" + | "col" + | "embed" + | "hr" + | "img" + | "input" + | "link" + | "meta" + | "param" + | "source" + | "track" + | "wbr" + ) { + if self.frames.len() >= MAX_NESTING { + return Err(CanvasError::Nesting); + } + self.frames.push(Frame { + tag: tag.clone(), + hidden, + checklist, + }); + } + if inside + && (tag == "form" + || (tag == "input" + && attr("type").is_some_and(|s| s.eq_ignore_ascii_case("password")))) + { + self.invalid = true; + } + if !inside || hidden { + return Ok(()); + } + if let Some(level) = heading(&tag) { + self.append(&format!("\n\n{} ", "#".repeat(level))); + } else { + match tag.as_str() { + "p" | "div" | "section" | "article" | "blockquote" | "tr" | "br" | "hr" => { + self.append("\n"); + } + "ul" | "ol" => { + self.append("\n"); + self.lists.push(List { + ordered: tag == "ol", + number: 0, + checklist, + }); + } + "li" => { + self.append("\n"); + let indent = " ".repeat(self.lists.len().saturating_sub(1).min(8)); + let (base, list_task) = if let Some(list) = self.lists.last_mut() { + list.number += 1; + ( + format!( + "{indent}{}", + if list.ordered { + format!("{}. ", list.number) + } else { + "- ".into() + } + ), + list.checklist, + ) + } else { + (format!("{indent}- "), false) + }; + let state = attr("data-checked").or_else(|| attr("aria-checked")); + let task = list_task + || class("checked") + || matches!(state.as_deref(), Some("true" | "false")); + let marker = if class("checked") || state.as_deref() == Some("true") { + "[x] " + } else { + "[ ] " + }; + let index = self.append(&format!("{base}{}", if task { marker } else { "" })); + self.items.push((index, base)); + } + "input" if attr("type").is_some_and(|s| s.eq_ignore_ascii_case("checkbox")) => { + let marker = if attr("checked").is_some() + || attr("aria-checked").as_deref() == Some("true") + { + "[x] " + } else { + "[ ] " + }; + if let Some((Some(index), base)) = self.items.last() { + let replacement = format!("{base}{marker}"); + self.length = self + .length + .saturating_sub(self.parts[*index].chars().count()) + + replacement.chars().count(); + self.parts[*index] = replacement; + } else { + self.append(marker); + } + } + "td" | "th" => { + self.append(" | "); + } + _ => {} + } + } + Ok(()) + } + fn end(&mut self, tag: &str) { + let Some(index) = self.frames.iter().rposition(|frame| frame.tag == tag) else { + return; + }; + if self + .root_depth + .is_some_and(|d| !self.root_closed && index + 1 >= d) + && !self.frames[index].hidden + { + if tag == "li" { + self.items.pop(); + } + if matches!(tag, "ul" | "ol") { + self.lists.pop(); + } + if matches!( + tag, + "li" | "p" | "div" | "ul" | "ol" | "tr" | "section" | "article" | "blockquote" + ) || heading(tag).is_some() + { + self.append("\n"); + } + } + if self.root_depth == Some(index + 1) { + self.root_closed = true; + } + self.frames.truncate(index); + } + fn data(&mut self, text: &str) { + if self + .root_depth + .is_none_or(|d| self.root_closed || self.frames.len() < d) + || self.frames.last().is_none_or(|f| f.hidden) + { + return; + } + static SPACE: LazyLock = LazyLock::new(|| Regex::new(r"\s+").expect("static regex")); + self.append(&SPACE.replace_all(text, " ")); + } +} +fn heading(tag: &str) -> Option { + let bytes = tag.as_bytes(); + (bytes.len() == 2 && bytes[0] == b'h' && (b'1'..=b'6').contains(&bytes[1])) + .then(|| (bytes[1] - b'0') as usize) +} +/// `prefix` is bounded by PREVIEW_SOURCE_BYTES; tail is the final <=256 bytes. +/// The caller retains the original file separately and resets its seek position. +pub fn preview(prefix: &[u8], tail: &[u8], truncated_source: bool) -> Result { + let prefix = prefix.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(prefix); + let text = match std::str::from_utf8(prefix) { + Ok(v) => v, + Err(e) if truncated_source && e.error_len().is_none() => { + std::str::from_utf8(&prefix[..e.valid_up_to()]).map_err(|_| CanvasError::InvalidUtf8)? + } + Err(_) => return Err(CanvasError::InvalidUtf8), + }; + let mut emitter = DefaultEmitter::default(); + emitter.naively_switch_states(true); + let mut parser = Preview::default(); + for token in Tokenizer::new_with_emitter(text, emitter) { + match token.expect("in-memory tokenizer cannot fail") { + Token::StartTag(tag) => parser.start(tag)?, + Token::EndTag(tag) => parser.end(&String::from_utf8_lossy(&tag.name)), + Token::String(text) => parser.data(&String::from_utf8_lossy(&text)), + _ => {} + } + } + static TRAILING: LazyLock = + LazyLock::new(|| Regex::new(r"[ \t]+\n").expect("static regex")); + static NEWLINES: LazyLock = + LazyLock::new(|| Regex::new(r"\n{3,}").expect("static regex")); + let joined = parser.parts.concat(); + let text = NEWLINES + .replace_all(&TRAILING.replace_all(&joined, "\n"), "\n\n") + .trim() + .to_owned(); + if parser.invalid || parser.root_depth.is_none() || text.is_empty() { + return Err(CanvasError::InvalidExport); + } + if truncated_source { + if !String::from_utf8_lossy(tail) + .trim_end() + .to_ascii_lowercase() + .ends_with("") + { + return Err(CanvasError::UnclosedWrapper); + } + } else if !parser.root_closed { + return Err(CanvasError::UnclosedWrapper); + } + Ok(bounded_preview(&text, truncated_source || parser.truncated)) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn inert_canvas_text() { + let html=br#"

Plan & notes

Visible

  • ship
  • next
"#; + assert_eq!( + preview(html, b"", false).unwrap(), + "# Plan & notes\n\nVisible\n\n- [x] ship\n\n- [ ] next" + ); + } + #[test] + fn rejects_auth_and_truncation() { + assert!(preview(b"Sign in", b"", false).is_err()); + assert!(preview(b"
text", b"", false).is_err()); + assert!(preview( + b"
login
", + b"", + false + ) + .is_err()); + } +} diff --git a/src/agentchat-slack/src/file_transfers/failure.rs b/src/agentchat-slack/src/file_transfers/failure.rs new file mode 100644 index 0000000..7bb6d5a --- /dev/null +++ b/src/agentchat-slack/src/file_transfers/failure.rs @@ -0,0 +1,253 @@ +//! Safe, content-free attachment read failures shared by transports and hosts. +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FailureCode { + AccessChanged, + Authentication, + CredentialUpstream, + CredentialTransport, + CredentialUnconfirmed, + CredentialUnavailable, + MissingScope, + Unavailable, + InvalidIdentifier, + InvalidSize, + HardLimit, + UnsupportedLocation, + Redirect, + EncodedResponse, + InvalidHeaders, + PartialResponse, + MetadataSizeMismatch, + TruncatedStream, + RateLimited, + TransientHttp, + Timeout, + ReadBudget, + Transport, + Tls, + Storage, + InspectionTimeout, + CanvasRepresentation, + TransferSizeMismatch, +} +impl FailureCode { + pub fn detail(self) -> (&'static str, &'static str) { + match self { + Self::AccessChanged => ("Slack access changed.", "Reconnect Slack and resend the attachment."), + Self::Authentication => ("Could not read this Slack attachment because access was denied.", "Reconnect Slack and verify the bot can access the file."), + Self::CredentialUpstream => ("Slack’s credential service returned an upstream error.", "Wait for the service to recover and check the saved Slack connection before retrying."), + Self::CredentialTransport => ("The Slack credential service could not be reached.", "Check the network and saved Slack connection before retrying."), + Self::CredentialUnconfirmed => ("The Slack credential acquisition result could not be confirmed.", "Check the saved Slack connection before retrying an unconfirmed token refresh."), + Self::CredentialUnavailable => ("Slack bot credentials could not be obtained.", "Check the saved Slack connection state before retrying."), + Self::MissingScope => ("Reconnect Slack with files:read permission.", "Reconnect Slack and resend the attachment."), + Self::Unavailable => ("This Slack file is unavailable.", "Upload the attachment again or describe its contents."), + Self::InvalidIdentifier => ("Expected a Slack file identifier.", "Resend the attachment from Slack."), + Self::InvalidSize => ("Slack returned an invalid file size.", "Upload the attachment again."), + Self::HardLimit => ("This file exceeds the workspace attachment storage budget.", "Ask an administrator to increase the budget, or send a smaller file."), + Self::UnsupportedLocation => ("Slack returned an unsupported file location.", "Upload the attachment directly to Slack again."), + Self::Redirect => ("Could not read this Slack attachment without following a redirect.", "Upload the attachment again; private-file redirects are not followed."), + Self::EncodedResponse => ("Slack returned an encoded transfer despite the identity request.", "Retry later or upload the file again; compressed transfer bytes were not accepted as file bytes."), + Self::InvalidHeaders => ("Slack returned unverifiable download headers.", "Retry later or upload the file again."), + Self::PartialResponse => ("Slack returned only a partial file response.", "Retry later or upload the complete attachment again."), + Self::MetadataSizeMismatch => ("Slack file did not match its declared size.", "Upload the attachment again or describe its contents; the complete file could not be verified."), + Self::TruncatedStream => ("Slack file download ended with an incomplete stream.", "Retry later or upload the attachment again."), + Self::RateLimited => ("Slack rate-limited the attachment read.", "Wait for Slack’s retry interval, then retry the attachment."), + Self::TransientHttp => ("Slack temporarily could not serve the attachment.", "Retry later or describe the missing content."), + Self::Timeout => ("Slack attachment read timed out.", "Retry later or send a smaller attachment."), + Self::ReadBudget => ("Slack attachment recovery reached its time budget.", "Retry later or send a smaller attachment."), + Self::Transport => ("The Slack attachment connection was interrupted.", "Retry later or upload the attachment again."), + Self::Tls => ("The Slack attachment connection could not be verified.", "Ask an administrator to check the network connection."), + Self::Storage => ("The attachment could not be written to temporary storage.", "Ask an administrator to check workspace storage."), + Self::InspectionTimeout => ("The attachment could not be inspected within its time budget.", "Send a smaller attachment or describe its contents."), + Self::CanvasRepresentation => ("Slack did not return a readable Canvas HTML export.", "Retry the Canvas export or provide its text; the response was not used as document content."), + Self::TransferSizeMismatch => ("The downloaded attachment did not match its HTTP Content-Length.", "Retry later; incomplete or inconsistent transfer bytes were not retained."), + } + } +} +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReadStage { + #[default] + Access, + Metadata, + Headers, + Download, + RetryWait, + Inspection, +} +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct Attempt { + pub stage: ReadStage, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attempt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub declared_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub received_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_length_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_encoding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_encoding_present: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub representation: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ReadFailure { + pub code: FailureCode, + #[serde(default)] + pub retryable: bool, + #[serde(default)] + pub retry_after: Option, + #[serde(default)] + pub history: Vec, + #[serde(default)] + pub retry_blocker: String, +} +impl ReadFailure { + pub fn new(code: FailureCode) -> Self { + Self { + code, + retryable: false, + retry_after: None, + history: Vec::new(), + retry_blocker: String::new(), + } + } + pub fn retry(code: FailureCode) -> Self { + Self { + retryable: true, + ..Self::new(code) + } + } + pub fn diagnostic(&self) -> Value { + let latest = self.history.last().cloned().unwrap_or_default(); + let mut value = json!(latest); + let attempts = latest.attempt.unwrap_or(0); + let (message, action) = self.code.detail(); + let data = value.as_object_mut().expect("Attempt is an object"); + data.extend(json!({"phase":"slack_attachment_read","code":self.code,"attempts":attempts, + "attempt_history":self.history,"recovery_tried": if attempts>1 {"A read-only retry was attempted; the attempt history identifies the stage reached."} else {"No retry was made."}, + "remaining_blocker":if self.retry_blocker.is_empty(){message}else{&self.retry_blocker},"next_action":action}).as_object().expect("object").clone()); + if let Some(seconds) = self.retry_after { + data.insert("retry_after_seconds".into(), json!(seconds)); + } + value + } +} +impl std::fmt::Display for ReadFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let detail = self.diagnostic(); + let string = |key: &str| detail[key].as_str().unwrap_or_default().to_owned(); + write!( + f, + "{} [stage={}, code={}, attempts={}", + self.code.detail().0, + string("stage"), + string("code"), + detail["attempts"] + )?; + for key in ["declared_bytes", "received_bytes", "content_encoding"] { + if let Some(value) = detail.get(key) { + write!( + f, + ", {key}={}", + value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()) + )?; + } + } + write!( + f, + "] {} Remaining blocker: {} Next action: {}", + string("recovery_tried"), + string("remaining_blocker"), + string("next_action") + ) + } +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TransportFailure { + pub code: String, + #[serde(default)] + pub status_code: Option, + #[serde(default)] + pub dispatched: Option, + #[serde(default)] + pub rejected: bool, + #[serde(default)] + pub retryable: bool, + #[serde(default)] + pub retry_after: Option, +} +pub fn retry_seconds(value: Option<&str>) -> Option { + value? + .trim() + .parse::() + .ok() + .filter(|v| v.is_finite() && *v >= 0.0) +} +pub fn transient(status: Option) -> bool { + matches!(status, Some(408 | 500 | 502 | 503 | 504)) +} +impl TransportFailure { + pub fn into_read_failure(self, stage: ReadStage) -> ReadFailure { + use FailureCode::*; + let auth = matches!( + self.code.as_str(), + "invalid_auth" + | "token_revoked" + | "not_authed" + | "account_inactive" + | "invalid_client_id" + | "bad_client_secret" + | "invalid_refresh_token" + | "invalid_grant" + ); + let rate = self.status_code == Some(429) + || matches!(self.code.as_str(), "ratelimited" | "rate_limited"); + let mut failure = if matches!(self.status_code, Some(401 | 403)) || auth { + ReadFailure::new(Authentication) + } else if self.code == "missing_scope" { + ReadFailure::new(MissingScope) + } else if stage == ReadStage::Access { + let code = if rate { + RateLimited + } else if transient(self.status_code) { + CredentialUpstream + } else if self.code == "connection_unavailable" && self.dispatched == Some(false) { + CredentialTransport + } else if self.code == "response_unavailable" + || (self.dispatched != Some(false) && !self.rejected) + { + CredentialUnconfirmed + } else { + CredentialUnavailable + }; + let mut failure = ReadFailure::new(code); + failure.retry_blocker="Token acquisition may perform an OAuth refresh POST; attachment recovery did not replay it.".into(); + failure + } else if rate { + ReadFailure::retry(RateLimited) + } else if transient(self.status_code) || self.retryable { + ReadFailure::retry(Transport) + } else { + ReadFailure::new(Unavailable) + }; + if rate { + failure.retry_after = self.retry_after.filter(|v| v.is_finite() && *v >= 0.0); + } + failure + } +} diff --git a/src/agentchat-slack/src/file_transfers/reader.rs b/src/agentchat-slack/src/file_transfers/reader.rs new file mode 100644 index 0000000..7696144 --- /dev/null +++ b/src/agentchat-slack/src/file_transfers/reader.rs @@ -0,0 +1,583 @@ +//! Bounded private-file acquisition. Hosts supply authorization and transport effects. +use super::{canvas, failure::*, file_location, FilePurpose}; +use async_trait::async_trait; +use serde_json::Value; +use std::{ + sync::{Arc, Mutex}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +#[derive(Default)] +pub struct ReadProgress { + pub current: Attempt, + pub history: Vec, +} +pub type ReadState = Arc>; +pub fn update(state: &ReadState, change: impl FnOnce(&mut Attempt)) { + change(&mut state.lock().expect("read state poisoned").current); +} +pub fn snapshot(state: &ReadState) -> Attempt { + state.lock().expect("read state poisoned").current.clone() +} +pub enum ReadError { + Failure(ReadFailure), + Foreign(E), +} +impl From for ReadError { + fn from(value: ReadFailure) -> Self { + Self::Failure(value) + } +} +#[derive(Clone, Debug)] +pub struct FileMetadata { + pub name: String, + pub url: String, + pub media_type: String, + pub size: Option, + pub canvas: bool, +} +impl FileMetadata { + /// Compatibility transport calls have a URL but no metadata to compare. + pub fn location(url: &str) -> Self { + Self { + name: String::new(), + url: url.into(), + media_type: String::new(), + size: None, + canvas: false, + } + } + + pub fn parse( + id: &str, + response: &Value, + max_bytes: usize, + state: &ReadState, + ) -> Result { + use FailureCode::*; + if response["ok"] == false && response["error"] == "missing_scope" { + return Err(ReadFailure::new(MissingScope)); + } + let file = &response["file"]; + if response["ok"] != true + || !file.is_object() + || file["id"] != id + || (!file["is_external"].is_null() && file["is_external"] != false) + { + return Err(ReadFailure::new(Unavailable)); + } + let canvas = canvas::is_canvas(file); + let size = file["size"].as_u64(); + if !canvas && size.is_none() { + return Err(ReadFailure::new(InvalidSize)); + } + update(state, |s| { + s.declared_bytes = size; + s.representation = canvas.then(|| "canvas_html".into()); + }); + if !canvas && size.is_some_and(|size| size > max_bytes as u64) { + return Err(ReadFailure::new(HardLimit)); + } + let url = file["url_private_download"] + .as_str() + .filter(|v| !v.is_empty()) + .or_else(|| file["url_private"].as_str()) + .ok_or_else(|| ReadFailure::new(UnsupportedLocation))?; + let name = file["name"] + .as_str() + .filter(|v| !v.is_empty()) + .unwrap_or("attachment"); + let mut name = crate::events::filename(name) + .chars() + .take(180) + .collect::(); + if canvas && !name.to_lowercase().ends_with(".html") { + name = format!("{}.html", name.chars().take(175).collect::()); + } + Ok(Self { + name, + url: url.into(), + media_type: file["mimetype"].as_str().unwrap_or_default().into(), + size, + canvas, + }) + } +} +#[derive(Default)] +pub struct DownloadHeaders { + pub status: u16, + pub encoding: Option, + pub length: Option, + pub content_range: Option, + pub content_type: Option, + pub retry_after: Option, +} +pub struct TransferVerifier { + max_bytes: u64, + declared: Option, + length: Option, + canvas: bool, + received: u64, + state: ReadState, +} +impl TransferVerifier { + pub fn new( + metadata: &FileMetadata, + headers: DownloadHeaders, + max_bytes: usize, + state: ReadState, + ) -> Result { + use FailureCode::*; + let encoding = headers + .encoding + .as_deref() + .unwrap_or("identity") + .trim() + .to_ascii_lowercase(); + update(&state, |s| { + s.http_status = Some(headers.status); + s.content_encoding_present = Some(headers.encoding.is_some()); + s.content_encoding = Some( + if matches!( + encoding.as_str(), + "identity" | "gzip" | "br" | "deflate" | "zstd" + ) { + encoding.clone() + } else { + "unsupported".into() + }, + ); + }); + if headers.status == 429 { + return Err(ReadFailure { + retry_after: retry_seconds(headers.retry_after.as_deref()), + ..ReadFailure::retry(RateLimited) + }); + } + if matches!(headers.status, 401 | 403) { + return Err(ReadFailure::new(Authentication)); + } + if (300..400).contains(&headers.status) { + return Err(ReadFailure::new(Redirect)); + } + if transient(Some(headers.status)) { + return Err(ReadFailure::retry(TransientHttp)); + } + if headers.status == 206 + || headers + .content_range + .as_ref() + .is_some_and(|v| !v.is_empty()) + { + return Err(ReadFailure::retry(PartialResponse)); + } + if headers.status != 200 { + return Err(ReadFailure::new(Unavailable)); + } + if encoding != "identity" { + return Err(ReadFailure::retry(EncodedResponse)); + } + if metadata.canvas { + let valid = headers + .content_type + .as_ref() + .and_then(|v| v.parse::().ok()) + .is_some_and(|v| { + v.essence_str() == "text/html" + && v.get_param(mime::CHARSET) + .is_none_or(|v| matches!(v.as_str(), "utf-8" | "utf8")) + }); + if !valid { + return Err(ReadFailure::new(CanvasRepresentation)); + } + } + let length = if let Some(value) = headers.length { + if value.is_empty() || value.len() > 20 || !value.bytes().all(|v| v.is_ascii_digit()) { + return Err(ReadFailure::retry(InvalidHeaders)); + } + let length = value + .parse::() + .map_err(|_| ReadFailure::retry(InvalidHeaders))?; + update(&state, |s| s.content_length_bytes = Some(length)); + if length > max_bytes as u64 { + return Err(ReadFailure::new(HardLimit)); + } + if !metadata.canvas && metadata.size.is_some_and(|size| size != length) { + return Err(ReadFailure::retry(MetadataSizeMismatch)); + } + Some(length) + } else { + None + }; + update(&state, |s| s.stage = ReadStage::Download); + Ok(Self { + max_bytes: max_bytes as u64, + declared: metadata.size, + length, + canvas: metadata.canvas, + received: 0, + state, + }) + } + pub fn accept(&mut self, count: usize) -> Result<(), ReadFailure> { + self.received = self.received.saturating_add(count as u64); + update(&self.state, |s| s.received_bytes = Some(self.received)); + if self.received > self.max_bytes { + return Err(ReadFailure::new(FailureCode::HardLimit)); + } + if self.length.is_some_and(|n| self.received > n) { + return Err(ReadFailure::retry(FailureCode::TransferSizeMismatch)); + } + if !self.canvas && self.declared.is_some_and(|n| self.received > n) { + return Err(ReadFailure::retry(FailureCode::MetadataSizeMismatch)); + } + Ok(()) + } + pub fn finish(&self) -> Result<(), ReadFailure> { + update(&self.state, |s| s.received_bytes = Some(self.received)); + if self.length.is_some_and(|n| n != self.received) { + return Err(ReadFailure::retry(FailureCode::TruncatedStream)); + } + verify_size(self.declared, self.canvas, self.received)?; + Ok(()) + } +} +pub fn verify_size(declared: Option, canvas: bool, received: u64) -> Result<(), ReadFailure> { + if !canvas && declared.is_some_and(|size| size != received) { + return Err(ReadFailure::retry(FailureCode::MetadataSizeMismatch)); + } + if canvas && received == 0 { + return Err(ReadFailure::new(FailureCode::CanvasRepresentation)); + } + Ok(()) +} +#[async_trait] +pub trait ReadTransport: Sync { + type Error: Send; + type File: Send; + async fn authorize(&self) -> Result<(), ReadError>; + async fn token(&self, state: &ReadState) -> Result>; + async fn metadata( + &self, + id: &str, + token: &str, + state: &ReadState, + ) -> Result>; + async fn download( + &self, + file: &FileMetadata, + token: &str, + max_bytes: usize, + state: &ReadState, + ) -> Result>; + async fn sleep(&self, seconds: f64) -> Result<(), ReadError>; +} +pub struct VerifiedFile { + pub metadata: FileMetadata, + pub file: F, + pub history: Vec, + pub state: Attempt, +} +pub struct FileReader { + pub max_bytes: usize, + pub attempts: usize, + pub budget: Duration, + pub retry_delay: f64, + cooldown: Mutex>, +} +impl FileReader { + pub fn new( + max_bytes: usize, + attempts: usize, + budget: f64, + retry_delay: f64, + ) -> Result { + if max_bytes == 0 + || attempts == 0 + || !budget.is_finite() + || budget <= 0.0 + || budget > 86_400.0 + || !retry_delay.is_finite() + || retry_delay < 0.0 + { + return Err(ReadFailure::new(FailureCode::InvalidSize)); + } + Ok(Self { + max_bytes, + attempts, + budget: Duration::from_secs_f64(budget), + retry_delay, + cooldown: Mutex::new(None), + }) + } + fn rate_limit(&self) -> Result<(), ReadFailure> { + if let Some(until) = *self.cooldown.lock().expect("cooldown poisoned") { + if let Some(remaining) = until.checked_duration_since(Instant::now()) { + return Err(ReadFailure { + retry_after: Some(remaining.as_secs_f64()), + ..ReadFailure::retry(FailureCode::RateLimited) + }); + } + } + Ok(()) + } + async fn attempt( + &self, + id: &str, + transport: &T, + state: &ReadState, + ) -> Result<(FileMetadata, T::File), ReadError> { + transport.authorize().await?; + if !crate::events::valid_file_id(id) { + return Err(ReadFailure::new(FailureCode::InvalidIdentifier).into()); + } + update(state, |s| s.stage = ReadStage::Metadata); + self.rate_limit()?; + update(state, |s| s.stage = ReadStage::Access); + let token = transport.token(state).await?; + transport.authorize().await?; + update(state, |s| s.stage = ReadStage::Metadata); + let response = transport.metadata(id, &token, state).await?; + transport.authorize().await?; + let file = FileMetadata::parse(id, &response, self.max_bytes, state)?; + if !file.canvas && file.size == Some(0) { + return Err(ReadFailure::new(FailureCode::InvalidSize).into()); + } + file_location(&file.url, FilePurpose::Download, None) + .map_err(|_| ReadFailure::new(FailureCode::UnsupportedLocation))?; + update(state, |s| s.stage = ReadStage::Headers); + self.rate_limit()?; + let raw = transport + .download(&file, &token, self.max_bytes, state) + .await?; + transport.authorize().await?; + Ok((file, raw)) + } + pub async fn read( + &self, + id: &str, + transport: &T, + state: &ReadState, + ) -> Result, ReadError> { + let deadline = Instant::now() + self.budget; + let mut history = Vec::new(); + for attempt in 1..=self.attempts { + update(state, |s| { + *s = Attempt { + attempt: Some(attempt), + ..Attempt::default() + } + }); + match self.attempt(id, transport, state).await { + Ok((metadata, file)) => { + return Ok(VerifiedFile { + metadata, + file, + history, + state: snapshot(state), + }) + } + Err(ReadError::Foreign(error)) => return Err(ReadError::Foreign(error)), + Err(ReadError::Failure(mut error)) => { + if error.code == FailureCode::RateLimited { + if let Some(delay) = error.retry_after { + let until = Duration::try_from_secs_f64(delay) + .ok() + .and_then(|duration| Instant::now().checked_add(duration)); + let mut cooldown = self.cooldown.lock().expect("cooldown poisoned"); + *cooldown = (*cooldown).max(until); + } + } + let mut current = snapshot(state); + current.code = Some(error.code); + history.push(current); + error.history = history.clone(); + state.lock().expect("read state poisoned").history = history.clone(); + if !error.retryable || attempt == self.attempts { + return Err(error.into()); + } + // Independent readers spread ordinary retries. Provider intervals are never shortened. + let jitter = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as f64 + / 1_000_000_000.0 + + 0.5; + let delay = if error.code == FailureCode::RateLimited { + error.retry_after + } else { + Some(self.retry_delay * jitter) + }; + let Some(delay) = delay else { + error.retry_blocker="Slack did not supply a usable Retry-After interval; no early retry was made.".into(); + return Err(error.into()); + }; + if delay + >= deadline + .saturating_duration_since(Instant::now()) + .as_secs_f64() + { + error.retry_blocker = if error.code == FailureCode::RateLimited { + "Slack’s retry interval exceeds the remaining read budget; no early retry was made." + } else { + "The remaining read budget is too short for another attempt." + }.into(); + return Err(error.into()); + } + update(state, |s| s.stage = ReadStage::RetryWait); + transport.sleep(delay).await?; + } + } + } + unreachable!("validated nonzero attempts") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn headers_and_chunks_have_independent_bounds() { + let state = Arc::new(Mutex::new(ReadProgress::default())); + let file=FileMetadata::parse("F1",&json!({"ok":true,"file":{"id":"F1","size":5,"url_private":"https://files.slack.com/files-pri/file"}}),10,&state).unwrap(); + let mut verifier = TransferVerifier::new( + &file, + DownloadHeaders { + status: 200, + length: Some("5".into()), + ..Default::default() + }, + 10, + state.clone(), + ) + .unwrap(); + verifier.accept(3).unwrap(); + assert_eq!( + verifier.finish().unwrap_err().code, + FailureCode::TruncatedStream + ); + assert_eq!( + verifier.accept(3).unwrap_err().code, + FailureCode::TransferSizeMismatch + ); + assert_eq!(snapshot(&state).received_bytes, Some(6)); + } + + #[test] + fn unsafe_encodings_are_reported_without_copying_provider_text() { + let state = Arc::new(Mutex::new(ReadProgress::default())); + let file = FileMetadata::location("https://files.slack.com/files-pri/file"); + let result = TransferVerifier::new( + &file, + DownloadHeaders { + status: 200, + encoding: Some("provider secret".into()), + ..Default::default() + }, + 10, + state.clone(), + ); + assert!(matches!( + result, + Err(ReadFailure { + code: FailureCode::EncodedResponse, + .. + }) + )); + assert_eq!( + snapshot(&state).content_encoding.as_deref(), + Some("unsupported") + ); + } +} + +#[cfg(test)] +mod compatibility_tests { + use super::*; + use serde_json::json; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + #[test] + fn url_only_transport_still_verifies_content_length_without_declared_size() { + let state = Arc::new(Mutex::new(ReadProgress::default())); + let file = FileMetadata::location("https://files.slack.com/files-pri/file"); + let mut verifier = TransferVerifier::new( + &file, + DownloadHeaders { + status: 200, + length: Some("5".into()), + ..Default::default() + }, + 10, + state, + ) + .unwrap(); + verifier.accept(5).unwrap(); + verifier.finish().unwrap(); + } + + struct RetainedFile(Arc); + impl Drop for RetainedFile { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + struct Transport { + checks: AtomicUsize, + closed: Arc, + } + #[async_trait] + impl ReadTransport for Transport { + type Error = &'static str; + type File = RetainedFile; + async fn authorize(&self) -> Result<(), ReadError> { + if self.checks.fetch_add(1, Ordering::SeqCst) == 3 { + Err(ReadError::Foreign("revoked")) + } else { + Ok(()) + } + } + async fn token(&self, _: &ReadState) -> Result> { + Ok("token".into()) + } + async fn metadata( + &self, + _: &str, + _: &str, + _: &ReadState, + ) -> Result> { + Ok( + json!({"ok":true,"file":{"id":"F1","size":5,"url_private":"https://files.slack.com/files-pri/file"}}), + ) + } + async fn download( + &self, + _: &FileMetadata, + _: &str, + _: usize, + _: &ReadState, + ) -> Result> { + Ok(RetainedFile(self.closed.clone())) + } + async fn sleep(&self, _: f64) -> Result<(), ReadError> { + unreachable!() + } + } + #[tokio::test] + async fn core_rechecks_authority_after_transfer_before_publishing() { + let transport = Transport { + checks: AtomicUsize::new(0), + closed: Arc::new(AtomicBool::new(false)), + }; + let state = Arc::new(Mutex::new(ReadProgress::default())); + let result = FileReader::new(100, 2, 90.0, 0.0) + .unwrap() + .read("F1", &transport, &state) + .await; + assert!(matches!(result, Err(ReadError::Foreign("revoked")))); + assert!(transport.closed.load(Ordering::SeqCst)); + assert_eq!(transport.checks.load(Ordering::SeqCst), 4); + } +} diff --git a/src/agentchat-slack/src/files.rs b/src/agentchat-slack/src/files.rs index a3bc462..4856452 100644 --- a/src/agentchat-slack/src/files.rs +++ b/src/agentchat-slack/src/files.rs @@ -66,31 +66,66 @@ impl FileTransport for Slack { Ok(()) } async fn download(&self, url: &str, token: &str, max_bytes: usize) -> Result> { + self.download_file( + &file_transfers::reader::FileMetadata::location(url), + token, + max_bytes, + ) + .await + } + async fn download_file( + &self, + file: &file_transfers::reader::FileMetadata, + token: &str, + max_bytes: usize, + ) -> Result> { let url = self .inner .client - .file_url(url, file_transfers::FilePurpose::Download)?; + .file_url(&file.url, file_transfers::FilePurpose::Download)?; let response = self .inner .client .http .get(url) .bearer_auth(token) + .header("Accept-Encoding", "identity") .send() .await .map_err(|_| Error::Transport("Slack file download failed".into()))?; - if response.status() != reqwest::StatusCode::OK { - return Err(Error::Transport( - "Could not read this Slack attachment".into(), - )); - } + let header = |name: &str| { + response + .headers() + .get(name) + .map(|v| v.to_str().unwrap_or("invalid").to_owned()) + }; + let state = std::sync::Arc::new(std::sync::Mutex::new( + file_transfers::reader::ReadProgress::default(), + )); + let policy_error = + |error: file_transfers::failure::ReadFailure| Error::InvalidInput(error.to_string()); + let mut verifier = file_transfers::reader::TransferVerifier::new( + file, + file_transfers::reader::DownloadHeaders { + status: response.status().as_u16(), + encoding: header("Content-Encoding"), + length: header("Content-Length"), + content_range: header("Content-Range"), + content_type: header("Content-Type"), + retry_after: header("Retry-After"), + }, + max_bytes, + state, + ) + .map_err(policy_error)?; let mut stream = response.bytes_stream(); let mut buffer = DownloadBuffer::new(max_bytes); while let Some(chunk) = stream.next().await { - buffer.append( - &chunk.map_err(|_| Error::Transport("Slack file download failed".into()))?, - )?; + let chunk = chunk.map_err(|_| Error::Transport("Slack file download failed".into()))?; + verifier.accept(chunk.len()).map_err(policy_error)?; + buffer.append(&chunk)?; } + verifier.finish().map_err(policy_error)?; buffer.finish() } async fn guard(&self) -> Result<()> { diff --git a/src/agentchat-slack/src/lib.rs b/src/agentchat-slack/src/lib.rs index e4b01d2..b02272b 100644 --- a/src/agentchat-slack/src/lib.rs +++ b/src/agentchat-slack/src/lib.rs @@ -4,6 +4,7 @@ //! provider receipt; a failed write may have an uncertain delivery outcome. //! Socket acknowledgements accept bounded in-memory work, not a durable queue. +pub mod delivery; pub mod events; pub mod file_transfers; mod files; @@ -15,6 +16,7 @@ mod route; mod socket; mod transport; pub mod web_api; +pub mod webhook; use std::collections::HashMap; use std::fmt; @@ -455,14 +457,21 @@ impl Slack { let Some(thread) = destination.thread_timestamp else { return Ok(false); }; - self.inner + let request = delivery::StatusRequest::new( + policy::ReplyDestination::validate(&destination.channel_id, Some(thread.as_str()))?, + status, + ); + let response = self + .inner .client - .call( - "assistant.threads.setStatus", - &json!({"channel_id":destination.channel_id, - "thread_ts":thread,"status":status}), - ) + .call(request.method, &request.payload) .await?; + if delivery::StatusRequest::response(response).status != delivery::DeliveryStatus::Confirmed + { + return Err(Error::Transport( + "Slack working status could not be confirmed".into(), + )); + } Ok(true) } diff --git a/src/agentchat-slack/src/rendering.rs b/src/agentchat-slack/src/rendering.rs index e7f426c..172503b 100644 --- a/src/agentchat-slack/src/rendering.rs +++ b/src/agentchat-slack/src/rendering.rs @@ -1,34 +1,90 @@ //! Provider-owned formatting, preserving Unicode character boundaries. use agentchat_core::{Error, Message, Result, RichReply, Role, Sender}; +use regex::Regex; use serde_json::{json, Map, Value}; +use std::sync::LazyLock; + +mod markdown; +pub use markdown::{markdown_text, MarkdownOptions}; pub const MIRROR_EVENT_TYPE: &str = "agentchat_user_mirror_v1"; -/// Split a single Slack message into expanded sections without breaking code fences. -pub fn text_blocks(text: &str) -> Result> { - if super::events::trim_python(text).is_empty() { +/// Content budgets exclude the fence delimiters inserted around split code blocks. +pub struct SplitOptions { + pub limit: usize, + pub trim_boundary_newlines: bool, +} + +impl Default for SplitOptions { + fn default() -> Self { + Self { + limit: 2600, + trim_boundary_newlines: false, + } + } +} + +/// Split mrkdwn without breaking Unicode, code-fence markers or fitting link tokens. +pub fn split_text(text: &str, options: SplitOptions) -> Result> { + split_chunks(text, options, None) +} + +fn split_chunks( + text: &str, + options: SplitOptions, + max_chunks: Option, +) -> Result> { + if !(3..=2992).contains(&options.limit) { return Err(Error::InvalidInput( - "Slack replies require readable text".into(), + "Slack content limit must be between 3 and 2992".into(), )); } + static LINKS: LazyLock = LazyLock::new(|| Regex::new(r"]+>").unwrap()); + let mut previous_byte = 0; + let mut previous_char = 0; + let links: Vec<_> = LINKS + .find_iter(text) + .map(|found| { + let start = previous_char + text[previous_byte..found.start()].chars().count(); + let end = start + found.as_str().chars().count(); + previous_byte = found.end(); + previous_char = end; + start..end + }) + .collect(); let chars: Vec = text.chars().collect(); - let mut rest = chars.as_slice(); + let mut offset = 0; let mut opened = false; - let mut blocks = Vec::new(); - while !rest.is_empty() { - let limit = 2992; + let mut chunks = Vec::new(); + while offset < chars.len() { + if max_chunks.is_some_and(|limit| chunks.len() >= limit) { + return Err(Error::InvalidInput( + "Slack replies support at most 50 sections; split the reply".into(), + )); + } + let rest = &chars[offset..]; + let limit = options.limit; let mut end = if rest.len() <= limit { rest.len() } else { rest[..limit] .iter() .rposition(|c| *c == '\n') - .map_or(0, |i| i + 1) + .map_or(0, |i| i + usize::from(!options.trim_boundary_newlines)) }; if end < limit / 2 && rest.len() > limit { end = limit; } + if let Some(link) = links.iter().find(|link| { + link.start < offset + end && offset + end < link.end && link.len() <= limit + }) { + end = if link.start > offset { + link.start + } else { + link.end + } - offset; + } for offset in [2, 1] { if end >= offset && rest.get(end - offset..end - offset + 3) == Some(&['`', '`', '`'][..]) @@ -45,16 +101,44 @@ pub fn text_blocks(text: &str) -> Result> { part, if in_fence { "\n```" } else { "" } ); - blocks.push(json!({"type":"section", "expand":true, - "text":{"type":"mrkdwn", "text":body, "verbatim":true}})); - if blocks.len() > 50 { - return Err(Error::InvalidInput( - "Slack replies support at most 50 sections; split the reply".into(), - )); + chunks.push(body); + offset += end; + if options.trim_boundary_newlines { + while chars.get(offset) == Some(&'\n') { + offset += 1; + } } - rest = &rest[end..]; opened = in_fence; } + Ok(chunks) +} + +/// Split a single Slack message into expanded sections without breaking code fences. +pub fn text_blocks(text: &str) -> Result> { + text_blocks_with_limit(text, 2992) +} + +pub fn text_blocks_with_limit(text: &str, limit: usize) -> Result> { + if super::events::trim_python(text).is_empty() { + return Err(Error::InvalidInput( + "Slack replies require readable text".into(), + )); + } + let chunks = split_chunks( + text, + SplitOptions { + limit, + ..SplitOptions::default() + }, + Some(50), + )?; + let blocks = chunks + .into_iter() + .map(|body| { + json!({"type":"section", "expand":true, + "text":{"type":"mrkdwn", "text":body, "verbatim":true}}) + }) + .collect(); Ok(blocks) } diff --git a/src/agentchat-slack/src/rendering/markdown.rs b/src/agentchat-slack/src/rendering/markdown.rs new file mode 100644 index 0000000..04e433f --- /dev/null +++ b/src/agentchat-slack/src/rendering/markdown.rs @@ -0,0 +1,173 @@ +//! Explicit Markdown-to-mrkdwn conversion. Plain RichReply text is never rewritten. + +use regex::Regex; +use std::sync::LazyLock; + +static FENCES: LazyLock = LazyLock::new(|| Regex::new(r"```[\s\S]*?(?:```|\z)").unwrap()); +static CODE: LazyLock = + LazyLock::new(|| Regex::new(r"```[\s\S]*?(?:```|\z)|`[^`\n]+`").unwrap()); +// Python-compatible whitespace also includes the ASCII separator controls. +static HEADINGS: LazyLock = + LazyLock::new(|| Regex::new(r"(?m)^#{1,6}[ \t]+([^\s\x1c-\x1f][^\r\n]*)").unwrap()); +static FENCE_INFO: LazyLock = LazyLock::new(|| Regex::new(r"\A```[^`\r\n]*\r?\n").unwrap()); +static LINKS: LazyLock = + LazyLock::new(|| Regex::new(r"!?\[([^\]\n]+)\]\(([^\s\x1c-\x1f)<>]+)\)").unwrap()); +static MENTIONS: LazyLock = + LazyLock::new(|| Regex::new(r"<@([UW][A-Z0-9]{7,30})>").unwrap()); + +#[derive(Default)] +pub struct MarkdownOptions<'a> { + pub allowed_mentions: &'a [String], +} + +enum Span { + Text(String), + InlineCode(String), + FencedCode(String), +} + +impl Span { + fn content(&self) -> &str { + match self { + Self::Text(value) | Self::InlineCode(value) | Self::FencedCode(value) => value, + } + } +} + +fn spans(text: &str, pattern: &Regex) -> Vec { + let mut output = Vec::new(); + let mut cursor = 0; + for found in pattern.find_iter(text) { + output.push(Span::Text(text[cursor..found.start()].into())); + let code = found.as_str().to_owned(); + output.push(if code.starts_with("```") { + Span::FencedCode(code) + } else { + Span::InlineCode(code) + }); + cursor = found.end(); + } + output.push(Span::Text(text[cursor..].into())); + output +} + +fn joined(spans: &[Span]) -> String { + spans.iter().map(Span::content).collect() +} + +fn paired_bold(spans: &mut [Span], replacement: &str) { + let markers: Vec<_> = spans + .iter() + .enumerate() + .flat_map(|(index, span)| match span { + Span::Text(text) => text + .match_indices("**") + .map(move |(offset, _)| (index, offset)) + .collect(), + _ => Vec::new(), + }) + .collect(); + // Preserve the unmatched final marker, including globs and exponent notation. + for &(index, offset) in markers[..markers.len() / 2 * 2].iter().rev() { + if let Span::Text(text) = &mut spans[index] { + text.replace_range(offset..offset + 2, replacement); + } + } +} + +fn blocks(text: &str) -> String { + let mut pieces = spans(text, &FENCES); + for span in &mut pieces { + match span { + Span::FencedCode(code) => *code = FENCE_INFO.replace(code, "```\n").into_owned(), + Span::Text(text) => { + *text = HEADINGS + .replace_all(text, |found: ®ex::Captures<'_>| { + let mut heading = + spans(super::super::events::trim_python(&found[1]), &CODE); + paired_bold(&mut heading, ""); + format!("*{}*", joined(&heading)) + }) + .into_owned(); + } + Span::InlineCode(_) => unreachable!("the block tokenizer only matches fences"), + } + } + joined(&pieces) +} + +fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn unescape(text: &str) -> String { + // Decode exactly the escaping above, once; preserve original entity literals. + text.replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +fn links( + text: &str, + resolve_link: &mut impl FnMut(&str) -> Result, E>, +) -> Result { + let mut result = String::new(); + let mut cursor = 0; + for found in LINKS.captures_iter(text) { + let whole = found.get(0).unwrap(); + result.push_str(&text[cursor..whole.start()]); + let label = &found[1]; + let target = &found[2]; + let url = if target.starts_with("https://") || target.starts_with("http://") { + Some(target.to_owned()) + } else { + resolve_link(&unescape(target))? + .filter(|url| !url.is_empty()) + .map(|url| escape(&url)) + }; + match url { + Some(url) if url.chars().count() > 1500 => result.push_str(label), + Some(url) => result.push_str(&format!("<{}|{}>", url, label.replace('|', "¦"))), + None => result.push_str(whole.as_str()), + } + cursor = whole.end(); + } + result.push_str(&text[cursor..]); + Ok(result) +} + +/// Render Markdown, optionally resolving application-owned local references. +/// The resolver sees unescaped targets; it is never called for fenced code or HTTP Markdown links. +pub fn markdown_text( + text: &str, + options: MarkdownOptions<'_>, + mut resolve_link: impl FnMut(&str) -> Result, E>, +) -> Result { + let mut pieces = spans(&blocks(&escape(text)), &CODE); + for span in &mut pieces { + if let Span::Text(text) = span { + let linked = links(text, &mut resolve_link)?; + *text = MENTIONS + .replace_all(&linked, |found: ®ex::Captures<'_>| { + if options.allowed_mentions.iter().any(|id| id == &found[1]) { + format!("<@{}>", &found[1]) + } else { + found[0].to_owned() + } + }) + .into_owned(); + } + } + paired_bold(&mut pieces, "*"); + for span in &mut pieces { + if let Span::InlineCode(code) = span { + let label = &code[1..code.len() - 1]; + if let Some(url) = resolve_link(&unescape(label))?.filter(|url| !url.is_empty()) { + *code = format!("<{}|{}>", escape(&url), label.replace('|', "¦")); + } + } + } + Ok(joined(&pieces)) +} diff --git a/src/agentchat-slack/src/webhook.rs b/src/agentchat-slack/src/webhook.rs new file mode 100644 index 0000000..8383c3f --- /dev/null +++ b/src/agentchat-slack/src/webhook.rs @@ -0,0 +1,183 @@ +//! Signed HTTP ingress without application authorization or durable admission. +use hmac::{Hmac, Mac}; +use serde::Serialize; +use serde_json::Value; +use sha2::Sha256; +use std::{error::Error, fmt}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WebhookError { + Signature, + Payload, + TooLarge, +} +impl fmt::Display for WebhookError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Signature => "Invalid Slack signature", + Self::Payload => "Invalid Slack payload", + Self::TooLarge => "Slack payload is too large", + }) + } +} +impl Error for WebhookError {} + +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WebhookEvent { + Challenge { challenge: String, payload: Value }, + Event { payload: Value }, + Ignored { payload: Value }, +} + +/// Verification precedes parsing. Workspace authorization belongs to the host. +pub struct SlackWebhook { + secret: Vec, +} +impl SlackWebhook { + pub fn new(secret: &str) -> Self { + Self { + secret: secret.as_bytes().to_vec(), + } + } + + pub fn verify( + &self, + raw: &[u8], + timestamp: &str, + signature: &str, + now: f64, + ) -> Result<(), WebhookError> { + let stamp = timestamp + .parse::() + .map_err(|_| WebhookError::Signature)?; + if self.secret.is_empty() || !now.is_finite() || (now - stamp as f64).abs() > 300.0 { + return Err(WebhookError::Signature); + } + let hex = signature + .strip_prefix("v0=") + .ok_or(WebhookError::Signature)?; + if hex.len() != 64 { + return Err(WebhookError::Signature); + } + let mut supplied = [0u8; 32]; + for (index, pair) in hex.as_bytes().chunks_exact(2).enumerate() { + let digit = |value: u8| match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + _ => None, + }; + supplied[index] = digit(pair[0]).ok_or(WebhookError::Signature)? * 16 + + digit(pair[1]).ok_or(WebhookError::Signature)?; + } + let mut mac = + Hmac::::new_from_slice(&self.secret).map_err(|_| WebhookError::Signature)?; + mac.update(b"v0:"); + mac.update(timestamp.as_bytes()); + mac.update(b":"); + mac.update(raw); + mac.verify_slice(&supplied) + .map_err(|_| WebhookError::Signature) + } + + fn authenticated( + &self, + raw: &[u8], + timestamp: &str, + signature: &str, + now: f64, + max_bytes: usize, + ) -> Result<(), WebhookError> { + if max_bytes == 0 || raw.len() > max_bytes { + return Err(WebhookError::TooLarge); + } + self.verify(raw, timestamp, signature, now) + } + + pub fn event( + &self, + raw: &[u8], + timestamp: &str, + signature: &str, + now: f64, + max_bytes: usize, + ) -> Result { + self.authenticated(raw, timestamp, signature, now, max_bytes)?; + let payload = object(raw)?; + match payload.get("type").and_then(Value::as_str) { + Some("url_verification") => { + let challenge = payload["challenge"] + .as_str() + .ok_or(WebhookError::Payload)? + .to_owned(); + Ok(WebhookEvent::Challenge { challenge, payload }) + } + Some("event_callback") => { + if !["team_id", "event_id"].iter().all(|key| { + payload[*key].as_str().is_some_and(|value| { + !value.is_empty() + && value.len() <= 100 + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') + }) + }) + }) || !payload["event"].is_object() + { + return Err(WebhookError::Payload); + } + Ok(WebhookEvent::Event { payload }) + } + _ => Ok(WebhookEvent::Ignored { payload }), + } + } + + pub fn interaction( + &self, + raw: &[u8], + timestamp: &str, + signature: &str, + now: f64, + max_bytes: usize, + ) -> Result { + self.authenticated(raw, timestamp, signature, now, max_bytes)?; + let input = std::str::from_utf8(raw).map_err(|_| WebhookError::Payload)?; + // Reject malformed escapes and excessive field counts before decoding. + let fields: Vec<_> = input.split('&').take(101).collect(); + if fields.len() > 100 || fields.iter().any(|field| !field.contains('=')) { + return Err(WebhookError::Payload); + } + let mut decoded = Vec::with_capacity(raw.len()); + let mut bytes = input.as_bytes().iter(); + while let Some(byte) = bytes.next() { + if *byte == b'%' { + let high = bytes.next().and_then(|value| (*value as char).to_digit(16)); + let low = bytes.next().and_then(|value| (*value as char).to_digit(16)); + decoded.push( + (high.ok_or(WebhookError::Payload)? * 16 + low.ok_or(WebhookError::Payload)?) + as u8, + ); + } else { + decoded.push(*byte); + } + } + std::str::from_utf8(&decoded).map_err(|_| WebhookError::Payload)?; + let mut payload = None; + for (key, value) in url::form_urlencoded::parse(raw) { + if key == "payload" { + if payload.is_some() { + return Err(WebhookError::Payload); + } + payload = Some(object(value.as_bytes())?); + } + } + payload.ok_or(WebhookError::Payload) + } +} +fn object(raw: &[u8]) -> Result { + let value: Value = serde_json::from_slice(raw).map_err(|_| WebhookError::Payload)?; + if value.is_object() { + Ok(value) + } else { + Err(WebhookError::Payload) + } +} diff --git a/src/agentchat-slack/tests/markdown.rs b/src/agentchat-slack/tests/markdown.rs new file mode 100644 index 0000000..6d37488 --- /dev/null +++ b/src/agentchat-slack/tests/markdown.rs @@ -0,0 +1,124 @@ +use agentchat_slack::rendering::{ + markdown_text, split_text, text_blocks, MarkdownOptions, SplitOptions, +}; +use std::convert::Infallible; + +fn render(text: &str) -> String { + markdown_text(text, MarkdownOptions::default(), |_| { + Ok::<_, Infallible>(None) + }) + .unwrap() +} + +#[test] +fn headings_and_paired_bold_leave_inline_code_and_dangling_markers_literal() { + for (input, expected) in [ + ( + "### **Model** `name` is **available**", + "*Model `name` is available*", + ), + ( + "#### `**literal**` and **bold**", + "*`**literal**` and bold*", + ), + ("**see `x` now** **", "*see `x` now* **"), + ("# Glob: **/*.py", "*Glob: **/*.py*"), + ("# Math: 2**10", "*Math: 2**10*"), + ("### \nNormal", "### \nNormal"), + ( + "# [Docs](https://example.com)", + "**", + ), + ] { + assert_eq!(render(input), expected); + } +} + +#[test] +fn fences_drop_info_strings_and_protect_closed_and_unclosed_code() { + for closing in ["\n```", ""] { + let code = "# **literal** <@U12345678> [report](report.md)\n`inline`"; + assert_eq!( + render(&format!("```json\r\n{code}{closing}")), + format!( + "```\n{}{closing}", + code.replace('<', "<").replace('>', ">") + ) + ); + } + assert_eq!( + render("```same line``` and `python`"), + "```same line``` and `python`" + ); +} + +#[test] +fn only_allowed_mentions_activate_and_local_links_use_the_host_resolver() { + let mentions = vec!["U12345678".into()]; + let mut requested = Vec::new(); + let text = markdown_text( + "<@U12345678> <@U87654321> & \n[私|有](a&b.md) `notes.md` `missing.md`\n```\n[code](secret.md)\n```", + MarkdownOptions { allowed_mentions: &mentions }, + |target| { + requested.push(target.to_owned()); + Ok::<_, Infallible>((target != "missing.md").then(|| format!("https://app.test/?file={target}&view=1"))) + }, + ).unwrap(); + assert_eq!(requested, ["a&b.md", "notes.md", "missing.md"]); + assert!(text.contains("<@U12345678> <@U87654321> <!here> & <tag>")); + assert!(text.contains("")); + assert!(text.contains("`missing.md`")); + assert!(text.contains("[code](secret.md)")); +} + +#[test] +fn chunks_protect_unicode_links_and_fences_with_explicit_newline_policy() { + let link = ""; + let text = format!("{}{}{}", "🌊".repeat(2980), link, "z".repeat(40)); + let blocks = text_blocks(&text).unwrap(); + let bodies: Vec<_> = blocks + .iter() + .map(|block| block["text"]["text"].as_str().unwrap()) + .collect(); + assert_eq!(bodies.concat(), text); + assert!(bodies.iter().any(|body| body.contains(link))); + for trim_boundary_newlines in [false, true] { + let text = "abcdefghij\n\nklmnopqrst"; + let chunks = split_text( + text, + SplitOptions { + limit: 12, + trim_boundary_newlines, + }, + ) + .unwrap(); + assert_eq!( + chunks.concat(), + if trim_boundary_newlines { + "abcdefghij\nklmnopqrst" + } else { + text + } + ); + } + let text = format!("{}{}", "a".repeat(11), "```\n🦀🦀🦀🦀🦀\n```"); + let chunks = split_text( + &text, + SplitOptions { + limit: 12, + trim_boundary_newlines: false, + }, + ) + .unwrap(); + assert!(chunks + .iter() + .all(|chunk| chunk.matches("```").count() % 2 == 0)); + assert!(split_text( + "x", + SplitOptions { + limit: 0, + ..SplitOptions::default() + } + ) + .is_err()); +} diff --git a/src/agentchat-slack/tests/services.rs b/src/agentchat-slack/tests/services.rs new file mode 100644 index 0000000..0d661ef --- /dev/null +++ b/src/agentchat-slack/tests/services.rs @@ -0,0 +1,172 @@ +use agentchat_core::RichReply; +use agentchat_slack::{ + delivery::{prepare_request, DeliveryResult, DeliveryStatus, Reconciliation}, + message_request::MessageRequest, + webhook::{SlackWebhook, WebhookError, WebhookEvent}, +}; +use hmac::{Hmac, Mac}; +use serde_json::json; +use sha2::Sha256; + +fn signature(raw: &[u8]) -> String { + let mut mac = Hmac::::new_from_slice(b"secret").unwrap(); + mac.update(b"v0:1000:"); + mac.update(raw); + format!("v0={:x}", mac.finalize().into_bytes()) +} +#[test] +fn signed_envelope_and_challenge_are_framework_independent() { + let webhook = SlackWebhook::new("secret"); + let raw = + br#"{"type":"event_callback","team_id":"T1","event_id":"Ev1","event":{"type":"message"}}"#; + assert!(matches!( + webhook + .event(raw, "1000", &signature(raw), 1000.0, 262144) + .unwrap(), + WebhookEvent::Event { .. } + )); + let raw = br#"{"type":"url_verification","challenge":"confirm"}"#; + assert!( + matches!(webhook.event(raw,"1000",&signature(raw),1000.0,262144).unwrap(), WebhookEvent::Challenge { challenge, .. } if challenge == "confirm") + ); + assert!(webhook.verify(raw, "1000", &signature(raw), 1300.0).is_ok()); + assert_eq!( + webhook.verify(raw, "1000", &signature(raw), 1300.1), + Err(WebhookError::Signature) + ); +} +#[test] +fn webhook_authentication_size_and_syntax_errors_are_distinct() { + let webhook = SlackWebhook::new("secret"); + let raw = b"invalid"; + assert!(matches!( + webhook.event(raw, "1000", "bad", 1000.0, 100), + Err(WebhookError::Signature) + )); + assert!(matches!( + webhook.event(raw, "1000", &signature(raw), 1000.0, 100), + Err(WebhookError::Payload) + )); + assert!(matches!( + webhook.event(raw, "1000", &signature(raw), 1000.0, 1), + Err(WebhookError::TooLarge) + )); + for raw in [ + br#"[]"#.as_slice(), + br#"{"type":"event_callback","team_id":"T1","event_id":false,"event":{}}"#, + br#"{"type":"url_verification","challenge":null}"#, + ] { + assert!(matches!( + webhook.event(raw, "1000", &signature(raw), 1000.0, 1000), + Err(WebhookError::Payload) + )); + } +} +#[test] +fn interaction_parses_one_payload_and_rejects_ambiguous_or_invalid_encodings() { + let webhook = SlackWebhook::new("secret"); + let raw = b"payload=%7B%22type%22%3A%22block_actions%22%7D"; + assert_eq!( + webhook + .interaction(raw, "1000", &signature(raw), 1000.0, 65536) + .unwrap()["type"], + "block_actions" + ); + for raw in [ + b"payload=%7B%7D&payload=%7B%7D".as_slice(), + b"payload=%GG", + b"payload=%7B%22x%22%3A%22%FF%22%7D", + b"payload=%5B%5D", + b"payload=%7B%7D&bad", + ] { + assert!(matches!( + webhook.interaction(raw, "1000", &signature(raw), 1000.0, 65536), + Err(WebhookError::Payload) + )); + } +} +fn request() -> MessageRequest { + MessageRequest::new( + &RichReply { + text: "Ready".into(), + blocks: vec![], + attachments: vec![], + }, + "C123", + Some("123.001"), + None, + None, + false, + ) + .unwrap() +} +#[test] +fn receipts_require_positive_evidence_and_valid_routing() { + let request = request(); + assert_eq!( + DeliveryResult::from_response(&request, json!({"ok":true,"ts":"123.002"})).status, + DeliveryStatus::Confirmed + ); + assert_eq!( + DeliveryResult::from_response(&request, json!({"ok":false,"error":"missing_scope"})).status, + DeliveryStatus::Rejected + ); + for response in [ + json!({"ok":true}), + json!({"ok":1,"ts":"123.002"}), + json!({"ok":true,"ts":"invalid"}), + json!({"ok":true,"ts":"123.002","channel":"COTHER"}), + json!([]), + ] { + assert_eq!( + DeliveryResult::from_response(&request, response).status, + DeliveryStatus::Uncertain + ); + } + assert_eq!( + serde_json::to_value(prepare_request(&serde_json::to_value(&request).unwrap()).unwrap()) + .unwrap(), + serde_json::to_value(request).unwrap() + ); + assert!(prepare_request(&json!({"method":"other","payload":{}})).is_err()); +} +fn message(kind: &str, identity: &str, thread: &str) -> serde_json::Value { + json!({"ts":"123.002","thread_ts":thread,"metadata":{"event_type":kind,"event_payload":{"id":identity}}}) +} +#[test] +fn reconciliation_matches_type_id_and_thread_then_follows_cursor() { + let mut reader = Reconciliation::new("C123", "123.001", "delivery", "one", 5).unwrap(); + assert!(!reader.accept(&json!({"ok":true,"messages":[message("other","one","123.001"),message("delivery","other","123.001"),message("delivery","one","999.001")],"response_metadata":{"next_cursor":"second"}}))); + assert_eq!(reader.request()["cursor"], "second"); + assert!(reader.accept(&json!({"ok":true,"messages":[message("delivery","one","123.001")]}))); + assert_eq!(reader.finish().status, DeliveryStatus::Confirmed); +} +#[test] +fn reconciliation_never_treats_absence_limits_or_provider_failure_as_failed_write() { + for (response, reason) in [ + (json!({"ok":true,"messages":[]}), "not_found"), + ( + json!({"ok":false,"error":"missing_scope"}), + "history_unavailable", + ), + ( + json!({"ok":true,"messages":[],"has_more":true}), + "missing_cursor", + ), + ( + json!({"ok":true,"messages":[],"response_metadata":{"next_cursor":"next"}}), + "page_limit", + ), + ] { + let mut reader = Reconciliation::new("C123", "123.001", "delivery", "one", 1).unwrap(); + assert!(reader.accept(&response)); + let result = reader.finish(); + assert_eq!(result.status, DeliveryStatus::Uncertain); + assert_eq!(result.reason.as_deref(), Some(reason)); + } + let mut reader = Reconciliation::new("C123", "123.001", "delivery", "one", 5).unwrap(); + let response = json!({"ok":true,"messages":[],"response_metadata":{"next_cursor":"repeat"}}); + assert!(!reader.accept(&response)); + assert!(reader.accept(&response)); + assert_eq!(reader.finish().reason.as_deref(), Some("repeated_cursor")); +} diff --git a/src/bindings/python/src/lib.rs b/src/bindings/python/src/lib.rs index a6b50ce..6c87f3a 100644 --- a/src/bindings/python/src/lib.rs +++ b/src/bindings/python/src/lib.rs @@ -25,6 +25,8 @@ mod providers { pub mod slack_files; pub mod slack_host; pub mod slack_policy; + pub mod slack_reader; + pub mod slack_services; pub mod slack_socket; pub mod teams; pub mod telegram; @@ -185,6 +187,8 @@ fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { providers::slack::register(module)?; providers::slack_files::register(module)?; providers::slack_host::register(module)?; + providers::slack_reader::register(module)?; + providers::slack_services::register(module)?; providers::slack_policy::register(module)?; providers::discord::register(module)?; providers::imessage::register(module)?; diff --git a/src/bindings/python/src/providers/slack.rs b/src/bindings/python/src/providers/slack.rs index 3c8e405..a7ef13a 100644 --- a/src/bindings/python/src/providers/slack.rs +++ b/src/bindings/python/src/providers/slack.rs @@ -526,19 +526,20 @@ impl Slack { let destination = ReplyDestination::from_source(&from_message(&source)?, workspace.as_deref()) .map_err(policy_error)?; - let Some(thread) = destination.thread_ts else { + if destination.thread_ts.is_none() { return j(json!(false)); - }; + } + let request = agentchat_slack::delivery::StatusRequest::new(destination, &status); let response = acall( &web, "assistant_threads_setStatus", vec![], - Some(j( - json!({"channel_id":destination.channel_id,"thread_ts":thread,"status":status}), - )?), + Some(j(request.payload)?), ) .await?; - if value(&response)?["ok"] != true { + if agentchat_slack::delivery::StatusRequest::response(value(&response)?).status + != agentchat_slack::delivery::DeliveryStatus::Confirmed + { return Err(PyRuntimeError::new_err( "Slack working status could not be confirmed", )); diff --git a/src/bindings/python/src/providers/slack_files.rs b/src/bindings/python/src/providers/slack_files.rs index a0854a7..4b5e2a0 100644 --- a/src/bindings/python/src/providers/slack_files.rs +++ b/src/bindings/python/src/providers/slack_files.rs @@ -2,10 +2,10 @@ use super::transport::*; use crate::{convert, host}; use agentchat_core::{Error, UploadFile}; -use agentchat_slack::file_transfers::{self, DownloadBuffer, FileTransport, TransferError}; +use agentchat_slack::file_transfers::{self, reader::FileMetadata, FileTransport, TransferError}; use async_trait::async_trait; use pyo3::{ - exceptions::{PyRuntimeError, PyStopAsyncIteration, PyValueError}, + exceptions::{PyRuntimeError, PyValueError}, prelude::*, types::{PyBool, PyBytes, PyTuple}, }; @@ -110,7 +110,16 @@ impl FileTransport for PythonFiles { Ok(()) } async fn download(&self, url: &str, token: &str, max_bytes: usize) -> PyResult> { - download_bytes(url, token, max_bytes).await + self.download_file(&FileMetadata::location(url), token, max_bytes) + .await + } + async fn download_file( + &self, + file: &FileMetadata, + token: &str, + max_bytes: usize, + ) -> PyResult> { + super::slack_reader::download_bytes(file, token, max_bytes).await } async fn guard(&self) -> PyResult<()> { if let Some(guard) = &self.guard { @@ -132,30 +141,6 @@ async fn session(timeout: u64) -> PyResult> { let session = call(&aiohttp, "ClientSession", vec![], Some(kwargs))?; Ok(session) } -async fn download_bytes(url: &str, token: &str, max_bytes: usize) -> PyResult> { - file_transfers::validate_file_url(url, false).map_err(policy_error)?; - let session = session(30).await?; - let client = acall(&session, "__aenter__", vec![], None).await?; - let result=async { - let response=call(&client,"get",vec![j(json!(url))?],Some(j(json!({"headers":{"Authorization":format!("Bearer {token}")},"allow_redirects":false}))?))?; - let received=acall(&response,"__aenter__",vec![],None).await?; - let result=async{ - if extract::(&get(&received,"status")?)?!=200{return Err(PyRuntimeError::new_err("Could not read this Slack attachment. Upload it again."));} - let chunks=call(&get(&received,"content")?,"iter_chunked",vec![j(json!(65536))?],None)?; - let iterator=call(&chunks,"__aiter__",vec![],None)?; - let mut buffer=DownloadBuffer::new(max_bytes); - loop {match acall(&iterator,"__anext__",vec![],None).await { - Ok(chunk)=>{let bytes=Python::attach(|py|chunk.bind(py).cast::().map(|b|b.as_bytes().to_vec()).map_err(PyErr::from))?;buffer.append(&bytes).map_err(policy_error)?;}, - Err(error) if Python::attach(|py|error.is_instance_of::(py))=>break, - Err(error)=>return Err(error), - }} - let bytes=buffer.finish().map_err(policy_error)?;Ok(Python::attach(|py|PyBytes::new(py,&bytes).into_any().unbind())) - }.await; - exit_context(&response,result).await - }.await; - let raw = exit_context(&session, result).await?; - Python::attach(|py| Ok(raw.bind(py).cast::()?.as_bytes().to_vec())) -} #[pyfunction] fn slack_upload_bytes(py: Python<'_>, url: String, raw: Vec) -> PyResult> { host::coroutine(py, async move { diff --git a/src/bindings/python/src/providers/slack_policy.rs b/src/bindings/python/src/providers/slack_policy.rs index 0155c6e..9e89f96 100644 --- a/src/bindings/python/src/providers/slack_policy.rs +++ b/src/bindings/python/src/providers/slack_policy.rs @@ -51,13 +51,59 @@ fn slack_to_message( .unwrap_or_else(|| Ok(py.None())) } #[pyfunction] -fn slack_text_blocks(py: Python<'_>, text: &str) -> PyResult> { +#[pyo3(signature=(text,*,limit=2992))] +fn slack_text_blocks(py: Python<'_>, text: &str, limit: usize) -> PyResult> { convert::from_json( py, - &Value::Array(rendering::text_blocks(text).map_err(policy_error)?), + &Value::Array(rendering::text_blocks_with_limit(text, limit).map_err(policy_error)?), ) } #[pyfunction] +#[pyo3(signature=(text,*,mentions=None,resolve_link=None))] +fn slack_markdown_text( + text: &str, + mentions: Option<&Bound<'_, PyAny>>, + resolve_link: Option<&Bound<'_, PyAny>>, +) -> PyResult { + let allowed_mentions = mentions + .map(|values| { + values + .try_iter()? + .map(|value| value?.extract::()) + .collect::>>() + }) + .transpose()? + .unwrap_or_default(); + rendering::markdown_text( + text, + rendering::MarkdownOptions { + allowed_mentions: &allowed_mentions, + }, + |target| { + resolve_link + .map(|resolve| resolve.call1((target,))?.extract::>()) + .transpose() + .map(Option::flatten) + }, + ) +} +#[pyfunction] +#[pyo3(signature=(text,*,limit=2600,trim_boundary_newlines=false))] +fn slack_split_text( + text: &str, + limit: usize, + trim_boundary_newlines: bool, +) -> PyResult> { + rendering::split_text( + text, + rendering::SplitOptions { + limit, + trim_boundary_newlines, + }, + ) + .map_err(policy_error) +} +#[pyfunction] fn slack_rich_payload(py: Python<'_>, content: &Bound<'_, PyAny>) -> PyResult> { convert::from_json( py, @@ -126,6 +172,8 @@ pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_function(wrap_pyfunction!(slack_filename, module)?)?; module.add_function(wrap_pyfunction!(slack_to_message, module)?)?; module.add_function(wrap_pyfunction!(slack_text_blocks, module)?)?; + module.add_function(wrap_pyfunction!(slack_markdown_text, module)?)?; + module.add_function(wrap_pyfunction!(slack_split_text, module)?)?; module.add_function(wrap_pyfunction!(slack_rich_payload, module)?)?; module.add_function(wrap_pyfunction!(slack_message_request, module)?)?; module.add_function(wrap_pyfunction!(slack_mirror_payload, module)?)?; @@ -142,6 +190,12 @@ pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { module .getattr("slack_text_blocks")? .setattr("__module__", "agentchat.channels.slack.rendering")?; + module + .getattr("slack_markdown_text")? + .setattr("__module__", "agentchat.channels.slack.rendering")?; + module + .getattr("slack_split_text")? + .setattr("__module__", "agentchat.channels.slack.rendering")?; module .getattr("slack_rich_payload")? .setattr("__module__", "agentchat.channels.slack.rendering")?; diff --git a/src/bindings/python/src/providers/slack_reader.rs b/src/bindings/python/src/providers/slack_reader.rs new file mode 100644 index 0000000..61a40f3 --- /dev/null +++ b/src/bindings/python/src/providers/slack_reader.rs @@ -0,0 +1,592 @@ +//! Asyncio/temp-file effects for the shared Slack file reader. No credential storage. +use super::transport::*; +use crate::{convert, host}; +use agentchat_slack::file_transfers::{canvas, failure::*, reader::*}; +use async_trait::async_trait; +use pyo3::{ + exceptions::{PyStopAsyncIteration, PyTimeoutError, PyValueError}, + prelude::*, + types::{PyBytes, PyTuple}, +}; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; + +fn public(name: &str) -> PyResult> { + get(&module("agentchat.channels.slack.files")?, name) +} +fn exception(name: &str, args: Vec>) -> PyResult { + let obj = call(&public(name)?, "__call__", args, None)?; + Python::attach(|py| Ok(PyErr::from_value(obj.into_bound(py)))) +} +fn failure(error: ReadFailure) -> PyErr { + let result = (|| { + let exc = call( + &public("SlackFileError")?, + "__call__", + vec![j(json!(error.code))?], + Some(j( + json!({"retryable":error.retryable,"retry_after":error.retry_after}), + )?), + )?; + set(&exc, "history", j(json!(error.history))?)?; + set(&exc, "retry_blocker", j(json!(error.retry_blocker))?)?; + Python::attach(|py| Ok(PyErr::from_value(exc.into_bound(py)))) + })(); + result.unwrap_or_else(|e| e) +} +fn read_error(error: ReadError) -> PyErr { + match error { + ReadError::Failure(e) => failure(e), + ReadError::Foreign(e) => e, + } +} +fn is_exception(error: &PyErr, class: &Py) -> bool { + Python::attach(|py| error.value(py).is_instance(class.bind(py)).unwrap_or(false)) +} +fn file_error(value: &Py) -> PyResult { + let code = value_attr(value, "code")?; + let history = value_attr(value, "history")?; + serde_json::from_value(json!({ + "code": code, + "retryable": value_attr(value, "retryable")?, + "retry_after": value_attr(value, "retry_after")?, + "history": history, + "retry_blocker": value_attr(value, "retry_blocker")?, + })) + .map_err(|_| PyValueError::new_err("Invalid Slack file diagnostic")) +} +fn value_attr(obj: &Py, key: &str) -> PyResult { + value(&get(obj, key)?) +} +fn classify(error: PyErr, state: &ReadState) -> ReadError { + let result = (|| -> PyResult> { + let object = Python::attach(|py| error.value(py).clone().into_any().unbind()); + if is_exception(&error, &public("SlackFileError")?) { + return Ok(Some(file_error(&object)?)); + } + if is_exception(&error, &public("SlackTransportError")?) { + let transport: TransportFailure = + serde_json::from_value(value_attr(&object, "details")?) + .map_err(|_| PyValueError::new_err("Invalid Slack transport failure"))?; + update(state, |s| s.http_status = transport.status_code); + return Ok(Some(transport.into_read_failure(snapshot(state).stage))); + } + let aiohttp = module("aiohttp")?; + let mut matched = None; + for (name, code, retry) in [ + ("ClientPayloadError", FailureCode::TruncatedStream, true), + ("ClientSSLError", FailureCode::Tls, false), + ("ServerFingerprintMismatch", FailureCode::Tls, false), + ("ClientError", FailureCode::Transport, true), + ] { + if is_exception(&error, &get(&aiohttp, name)?) { + matched = Some(if retry { + ReadFailure::retry(code) + } else { + ReadFailure::new(code) + }); + break; + } + } + if matched.is_none() && Python::attach(|py| error.is_instance_of::(py)) { + matched = Some(ReadFailure::retry(FailureCode::Timeout)); + } + if matched.is_none() + && Python::attach(|py| error.is_instance_of::(py)) + { + matched = Some(ReadFailure::new(FailureCode::Storage)); + } + Ok(matched) + })(); + match result { + Ok(Some(f)) => ReadError::Failure(f), + _ => ReadError::Foreign(error), + } +} + +#[pyfunction] +#[pyo3(signature=(obj,code,*,retryable=false,retry_after=None))] +fn slack_file_error_init( + obj: &Bound<'_, PyAny>, + code: &str, + retryable: bool, + retry_after: Option, +) -> PyResult<()> { + let code: FailureCode = serde_json::from_value(json!(code)) + .map_err(|_| PyValueError::new_err("Unknown Slack file failure code"))?; + obj.setattr("code", convert::from_json(obj.py(), &json!(code))?)?; + obj.setattr("retryable", retryable)?; + obj.setattr("retry_after", retry_after)?; + obj.setattr("history", convert::from_json(obj.py(), &json!([]))?)?; + obj.setattr("retry_blocker", "")?; + obj.setattr("args", (code.detail().0,))?; + Ok(()) +} +#[pyfunction] +fn slack_file_diagnostic(obj: Py) -> PyResult> { + j(file_error(&obj)?.diagnostic()) +} +#[pyfunction] +fn slack_file_error_string(obj: Py) -> PyResult { + Ok(file_error(&obj)?.to_string()) +} +#[pyfunction] +#[pyo3(signature=(obj,code,*,status_code=None,dispatched=None,rejected=false,retryable=false,retry_after=None))] +#[allow(clippy::too_many_arguments)] +fn slack_transport_error_init( + obj: &Bound<'_, PyAny>, + code: String, + status_code: Option, + dispatched: Option, + rejected: bool, + retryable: bool, + retry_after: Option, +) -> PyResult<()> { + obj.setattr( + "details", + convert::from_json( + obj.py(), + &json!(TransportFailure { + code, + status_code, + dispatched, + rejected, + retryable, + retry_after + }), + )?, + )?; + obj.setattr("args", ("Slack transport request failed",))?; + Ok(()) +} +#[pyfunction] +#[pyo3(signature=(text,*,truncated=false))] +fn slack_canvas_bounded_preview(text: &str, truncated: bool) -> String { + canvas::bounded_preview(text, truncated) +} +#[pyfunction] +fn slack_is_canvas(file: &Bound<'_, PyAny>) -> PyResult { + Ok(canvas::is_canvas(&convert::to_json(file)?)) +} +#[pyfunction] +fn slack_inspect_canvas(py: Python<'_>, raw: Py) -> PyResult> { + let result = (|| { + call(&raw, "seek", vec![j(json!(0))?], None)?; + let bytes: Vec = extract(&call( + &raw, + "read", + vec![j(json!(canvas::PREVIEW_SOURCE_BYTES + 1))?], + None, + )?)?; + let truncated = bytes.len() > canvas::PREVIEW_SOURCE_BYTES; + let tail = if truncated { + let end: i64 = extract(&call(&raw, "seek", vec![j(json!(0))?, j(json!(2))?], None)?)?; + call(&raw, "seek", vec![j(json!((end - 256).max(0)))?], None)?; + extract::>(&call(&raw, "read", vec![j(json!(256))?], None)?)? + } else { + Vec::new() + }; + let text = canvas::preview( + &bytes[..bytes.len().min(canvas::PREVIEW_SOURCE_BYTES)], + &tail, + truncated, + ) + .map_err(|e| { + exception( + "CanvasFormatError", + vec![j(json!(e.to_string())).unwrap_or_else(|_| none())], + ) + .unwrap_or_else(|e| e) + })?; + Ok(PyTuple::new( + py, + [ + j(json!(canvas::MIME))?, + PyBytes::new(py, b"").into_any().unbind(), + j(json!(text))?, + ], + )? + .into_any() + .unbind()) + })(); + let reset = call(&raw, "seek", vec![j(json!(0))?], None); + match result { + Err(e) => Err(e), + Ok(v) => { + reset?; + Ok(v) + } + } +} + +/// Join the owned worker despite repeated cancellation before its file may close. +async fn file_operation(operation: Py, args: Vec>) -> PyResult> { + let asyncio = module("asyncio")?; + let mut values = vec![operation]; + values.extend(args); + let work = call(&asyncio, "to_thread", values, None)?; + let task = call(&asyncio, "create_task", vec![work], None)?; + let mut cancelled = None; + while !extract::(&call(&task, "done", vec![], None)?)? { + if let Err(error) = acall(&asyncio, "shield", vec![clone_obj(&task)], None).await { + if is_exception(&error, &get(&asyncio, "CancelledError")?) { + if cancelled.is_none() { + cancelled = Some(error); + } + } else { + return Err(cancelled.unwrap_or(error)); + } + } + } + let result = call(&task, "result", vec![], None); + if let Some(error) = cancelled { + Err(error) + } else { + result + } +} +#[pyfunction] +#[pyo3(signature=(operation,*args))] +fn slack_file_operation( + py: Python<'_>, + operation: Py, + args: &Bound<'_, PyTuple>, +) -> PyResult> { + let args = args.iter().map(Bound::unbind).collect(); + host::coroutine(py, file_operation(operation, args)) +} +struct OwnedFile(Option>); +impl Drop for OwnedFile { + fn drop(&mut self) { + if let Some(raw) = self.0.take() { + let _ = call(&raw, "close", vec![], None); + } + } +} +struct PythonReader { + token: Py, + request: Py, + authorize: Py, +} +impl PythonReader { + async fn callback( + &self, + callback: &Py, + args: Vec>, + state: &ReadState, + ) -> Result, ReadError> { + acall(callback, "__call__", args, None) + .await + .map_err(|e| classify(e, state)) + } + async fn stream( + file: &FileMetadata, + token: &str, + max_bytes: usize, + state: &ReadState, + ) -> PyResult { + agentchat_slack::file_transfers::validate_file_url(&file.url, false) + .map_err(super::slack_files::policy_error)?; + let aiohttp = get(&module("agentchat.channels.slack.files")?, "aiohttp")?; + let timeout = call( + &aiohttp, + "ClientTimeout", + vec![], + Some(j(json!({"total":45,"sock_connect":10,"sock_read":15}))?), + )?; + let options = dict(); + set_item(&options, "timeout", timeout)?; + set_item(&options, "auto_decompress", j(json!(false))?)?; + let session = call(&aiohttp, "ClientSession", vec![], Some(options))?; + let client = acall(&session, "__aenter__", vec![], None).await?; + let result = Self::response(&client, file, token, max_bytes, state).await; + close_file_context(&session, result).await + } + + async fn response( + client: &Py, + file: &FileMetadata, + token: &str, + max_bytes: usize, + state: &ReadState, + ) -> PyResult { + let options = j(json!({ + "headers": {"Authorization": format!("Bearer {token}"), "Accept-Encoding": "identity"}, + "allow_redirects": false, + }))?; + let context = call(client, "get", vec![j(json!(file.url))?], Some(options))?; + let response = acall(&context, "__aenter__", vec![], None).await?; + let result = Self::body(&response, file, max_bytes, state).await; + close_file_context(&context, result).await + } + + async fn body( + response: &Py, + file: &FileMetadata, + max_bytes: usize, + state: &ReadState, + ) -> PyResult { + let headers = get(response, "headers")?; + let header = |name: &str| -> PyResult> { + extract(&call(&headers, "get", vec![j(json!(name))?], None)?) + }; + let headers = DownloadHeaders { + status: extract(&get(response, "status")?)?, + encoding: header("Content-Encoding")?, + length: header("Content-Length")?, + content_range: header("Content-Range")?, + content_type: header("Content-Type")?, + retry_after: header("Retry-After")?, + }; + let mut verifier = + TransferVerifier::new(file, headers, max_bytes, state.clone()).map_err(failure)?; + let raw = OwnedFile(Some(call( + &module("tempfile")?, + "TemporaryFile", + vec![], + None, + )?)); + let chunks = call( + &get(response, "content")?, + "iter_chunked", + vec![j(json!(64 * 1024))?], + None, + )?; + let iterator = call(&chunks, "__aiter__", vec![], None)?; + loop { + match acall(&iterator, "__anext__", vec![], None).await { + Ok(chunk) => { + let size = Python::attach(|py| chunk.bind(py).len())?; + verifier.accept(size).map_err(failure)?; + file_operation( + get(raw.0.as_ref().expect("owned file"), "write")?, + vec![chunk], + ) + .await?; + } + Err(error) + if Python::attach(|py| error.is_instance_of::(py)) => + { + break + } + Err(error) => return Err(error), + } + } + verifier.finish().map_err(failure)?; + Ok(raw) + } +} +pub(super) async fn download_bytes( + file: &FileMetadata, + token: &str, + max_bytes: usize, +) -> PyResult> { + let state = Arc::new(Mutex::new(ReadProgress::default())); + let raw = PythonReader::stream(file, token, max_bytes, &state) + .await + .map_err(|error| read_error(classify(error, &state)))?; + call( + raw.0.as_ref().expect("verified file"), + "seek", + vec![j(json!(0))?], + None, + )?; + extract(&file_operation(get(raw.0.as_ref().expect("verified file"), "read")?, vec![]).await?) +} +fn set_item(dict: &Py, key: &str, value: Py) -> PyResult<()> { + Python::attach(|py| dict.bind(py).set_item(key, value)) +} +async fn close_file_context( + context: &Py, + result: PyResult, +) -> PyResult { + match result { + Ok(raw) => { + exit_context(context, Ok(none())).await?; + Ok(raw) + } + Err(error) => { + let original = Python::attach(|py| error.clone_ref(py)); + let _ = exit_context(context, Err(error)).await; + Err(original) + } + } +} +#[async_trait] +impl ReadTransport for PythonReader { + type Error = PyErr; + type File = OwnedFile; + async fn authorize(&self) -> Result<(), ReadError> { + match acall(&self.authorize, "__call__", vec![], None).await { + Ok(_) => Ok(()), + Err(error) => { + if public("SlackFileError").is_ok_and(|class| is_exception(&error, &class)) { + let object = Python::attach(|py| error.value(py).clone().into_any().unbind()); + if let Ok(failure) = file_error(&object) { + return Err(ReadError::Failure(failure)); + } + } + Err(ReadError::Foreign(error)) + } + } + } + async fn token(&self, state: &ReadState) -> Result> { + let token = self.callback(&self.token, vec![], state).await?; + extract(&token).map_err(ReadError::Foreign) + } + async fn metadata( + &self, + id: &str, + token: &str, + state: &ReadState, + ) -> Result> { + let args = (|| { + Ok(vec![ + j(json!("files.info"))?, + j(json!({"file":id}))?, + j(json!(token))?, + ]) + })() + .map_err(ReadError::Foreign)?; + value(&self.callback(&self.request, args, state).await?).map_err(ReadError::Foreign) + } + async fn download( + &self, + file: &FileMetadata, + token: &str, + max_bytes: usize, + state: &ReadState, + ) -> Result> { + let raw = Self::stream(file, token, max_bytes, state) + .await + .map_err(|e| classify(e, state))?; + call( + raw.0.as_ref().expect("owned file"), + "seek", + vec![j(json!(0)).map_err(ReadError::Foreign)?], + None, + ) + .map_err(ReadError::Foreign)?; + Ok(raw) + } + async fn sleep(&self, seconds: f64) -> Result<(), ReadError> { + sleep(seconds).await.map_err(ReadError::Foreign) + } +} +#[pyclass(frozen, module = "agentchat.channels.slack.files")] +struct SlackFileReader { + inner: Arc, +} +#[pymethods] +impl SlackFileReader { + #[new] + #[pyo3(signature=(*,max_bytes,read_attempts=2,read_budget=90.0,retry_delay=1.0))] + fn new( + max_bytes: usize, + read_attempts: usize, + read_budget: f64, + retry_delay: f64, + ) -> PyResult { + Ok(Self { + inner: Arc::new( + FileReader::new(max_bytes, read_attempts, read_budget, retry_delay) + .map_err(failure)?, + ), + }) + } + #[pyo3(signature=(file_id,*,token,request,authorize))] + fn read( + &self, + py: Python<'_>, + file_id: String, + token: Py, + request: Py, + authorize: Py, + ) -> PyResult> { + let reader = self.inner.clone(); + let budget = reader.budget.as_secs_f64(); + let state = Arc::new(Mutex::new(ReadProgress::default())); + let capture = state.clone(); + let work = host::coroutine(py, async move { + let transport = PythonReader { + token, + request, + authorize, + }; + let mut verified = reader + .read(&file_id, &transport, &state) + .await + .map_err(read_error)?; + let raw = verified.file.0.as_ref().expect("verified file"); + let result = call( + &public("SlackDownloadedFile")?, + "__call__", + vec![], + Some({ + let kwargs = dict(); + set_item(&kwargs, "name", j(json!(verified.metadata.name))?)?; + set_item(&kwargs, "raw", clone_obj(raw))?; + set_item( + &kwargs, + "media_type", + j(json!(verified.metadata.media_type))?, + )?; + set_item( + &kwargs, + "representation", + j(json!(if verified.metadata.canvas { + "canvas_html" + } else { + "file" + }))?, + )?; + set_item(&kwargs, "history", j(json!(verified.history))?)?; + set_item(&kwargs, "state", j(json!(verified.state))?)?; + kwargs + }), + ); + if result.is_ok() { + verified.file.0.take(); + } + result + })?; + host::coroutine(py, async move { + let started = std::time::Instant::now(); + match acall( + &module("asyncio")?, + "wait_for", + vec![work], + Some(j(json!({"timeout":budget}))?), + ) + .await + { + Err(error) + if started.elapsed().as_secs_f64() >= budget + && Python::attach(|py| error.is_instance_of::(py)) => + { + let progress = capture.lock().expect("read state poisoned"); + let mut error = ReadFailure::new(FailureCode::ReadBudget); + error.history = progress.history.clone(); + let mut last = progress.current.clone(); + last.code = Some(error.code); + error.history.push(last); + Err(failure(error)) + } + result => result, + } + }) + } +} +pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_function(wrap_pyfunction!(slack_file_error_init, module)?)?; + module.add_function(wrap_pyfunction!(slack_file_diagnostic, module)?)?; + module.add_function(wrap_pyfunction!(slack_file_error_string, module)?)?; + module.add_function(wrap_pyfunction!(slack_transport_error_init, module)?)?; + module.add_function(wrap_pyfunction!(slack_canvas_bounded_preview, module)?)?; + module.add_function(wrap_pyfunction!(slack_is_canvas, module)?)?; + module.add_function(wrap_pyfunction!(slack_inspect_canvas, module)?)?; + module.add_function(wrap_pyfunction!(slack_file_operation, module)?)?; + module.add("SLACK_CANVAS_MIME", canvas::MIME)?; + Ok(()) +} diff --git a/src/bindings/python/src/providers/slack_services.rs b/src/bindings/python/src/providers/slack_services.rs new file mode 100644 index 0000000..f36a9b9 --- /dev/null +++ b/src/bindings/python/src/providers/slack_services.rs @@ -0,0 +1,367 @@ +//! Python callbacks and exception translation for native Slack HTTP services. +use super::{slack_files::policy_error, transport::*}; +use crate::{convert, host}; +use agentchat_slack::{ + delivery::{self, DeliveryResult, Reconciliation, StatusRequest}, + message_request::MessageRequest, + policy::ReplyDestination, + webhook::{SlackWebhook as CoreWebhook, WebhookError}, +}; +use pyo3::{ + create_exception, + exceptions::{ + PyConnectionError, PyException, PyPermissionError, PyTimeoutError, PyTypeError, + PyValueError, + }, + prelude::*, + types::{PyBytes, PyTuple, PyType}, +}; +use serde_json::json; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +create_exception!(agentchat, SlackSignatureError, PyPermissionError); +create_exception!(agentchat, SlackPayloadError, PyValueError); +create_exception!(agentchat, SlackBodyTooLarge, SlackPayloadError); +fn webhook_error(error: WebhookError) -> PyErr { + match error { + WebhookError::Signature => SlackSignatureError::new_err(error.to_string()), + WebhookError::Payload => SlackPayloadError::new_err(error.to_string()), + WebhookError::TooLarge => SlackBodyTooLarge::new_err(error.to_string()), + } +} +fn headers(value: &Bound<'_, PyAny>) -> PyResult<(String, String)> { + let mut stamp = None; + let mut signature = None; + for pair in value.call_method0("items")?.try_iter()? { + let (key, value): (String, String) = pair?.extract()?; + let slot = if key.eq_ignore_ascii_case("x-slack-request-timestamp") { + &mut stamp + } else if key.eq_ignore_ascii_case("x-slack-signature") { + &mut signature + } else { + continue; + }; + if slot.replace(value).is_some() { + return Err(webhook_error(WebhookError::Signature)); + } + } + Ok((stamp.unwrap_or_default(), signature.unwrap_or_default())) +} +fn current_time(now: Option) -> f64 { + now.unwrap_or_else(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(f64::NAN, |duration| duration.as_secs_f64()) + }) +} + +#[pyclass(frozen, module = "agentchat.channels.slack.webhook")] +struct SlackWebhook { + inner: CoreWebhook, +} +#[pymethods] +impl SlackWebhook { + #[new] + fn new(secret: &str) -> Self { + Self { + inner: CoreWebhook::new(secret), + } + } + #[pyo3(signature=(raw,headers,*,now=None))] + fn verify( + &self, + raw: &Bound<'_, PyBytes>, + headers: &Bound<'_, PyAny>, + now: Option, + ) -> PyResult<()> { + let (stamp, signature) = self::headers(headers)?; + self.inner + .verify(raw.as_bytes(), &stamp, &signature, current_time(now)) + .map_err(webhook_error) + } + #[pyo3(signature=(raw,headers,*,now=None,max_bytes=262_144))] + fn event( + &self, + py: Python<'_>, + raw: &Bound<'_, PyBytes>, + headers: &Bound<'_, PyAny>, + now: Option, + max_bytes: usize, + ) -> PyResult> { + let (stamp, signature) = self::headers(headers)?; + let result = self + .inner + .event( + raw.as_bytes(), + &stamp, + &signature, + current_time(now), + max_bytes, + ) + .map_err(webhook_error)?; + convert::from_json(py, &json!(result)) + } + #[pyo3(signature=(raw,headers,*,now=None,max_bytes=65_536))] + fn interaction( + &self, + py: Python<'_>, + raw: &Bound<'_, PyBytes>, + headers: &Bound<'_, PyAny>, + now: Option, + max_bytes: usize, + ) -> PyResult> { + let (stamp, signature) = self::headers(headers)?; + let result = self + .inner + .interaction( + raw.as_bytes(), + &stamp, + &signature, + current_time(now), + max_bytes, + ) + .map_err(webhook_error)?; + convert::from_json(py, &result) + } +} + +#[pyclass(frozen, module = "agentchat.channels.slack.delivery")] +struct SlackDelivery { + request: Py, + transport_errors: Py, +} +impl SlackDelivery { + fn send_prepared(&self, py: Python<'_>, prepared: MessageRequest) -> PyResult> { + let request = self.request.clone_ref(py); + let errors = self.transport_errors.clone_ref(py); + host::coroutine(py, async move { + let method = serde_json::to_value(prepared.method) + .map_err(|_| PyValueError::new_err("Invalid Slack message method"))?; + let response = acall( + &request, + "__call__", + vec![j(json!("POST"))?, j(method)?, j(prepared.payload.clone())?], + None, + ) + .await; + let outcome = match response { + Ok(response) => match value(&response) { + Ok(response) => DeliveryResult::from_response(&prepared, response), + Err(_) => DeliveryResult::uncertain("invalid_response"), + }, + Err(error) if is_transport_error(&error, &errors) => { + DeliveryResult::uncertain("transport") + } + Err(error) => return Err(error), + }; + j(json!(outcome)) + }) + } +} +fn is_transport_error(error: &PyErr, errors: &Py) -> bool { + Python::attach(|py| { + error + .value(py) + .is_instance(errors.bind(py)) + .unwrap_or(false) + }) +} +#[pymethods] +impl SlackDelivery { + #[new] + #[pyo3(signature=(request,*,transport_errors=None))] + fn new( + py: Python<'_>, + request: Py, + transport_errors: Option<&Bound<'_, PyTuple>>, + ) -> PyResult { + if !request.bind(py).is_callable() { + return Err(PyTypeError::new_err( + "Expected an async Slack request callback", + )); + } + let errors = match transport_errors { + Some(errors) => errors.clone(), + None => PyTuple::new( + py, + [ + py.get_type::(), + py.get_type::(), + ], + )?, + }; + for error in errors.iter() { + let valid = error.cast::().is_ok_and(|kind| { + kind.is_subclass(&py.get_type::()) + .unwrap_or(false) + }); + if !valid { + return Err(PyTypeError::new_err( + "Transport errors must be Exception classes", + )); + } + } + Ok(Self { + request, + transport_errors: errors.unbind(), + }) + } + fn set_status( + &self, + py: Python<'_>, + channel_id: &str, + thread_ts: &str, + status: &str, + ) -> PyResult> { + let prepared = StatusRequest::new( + ReplyDestination::validate(channel_id, Some(thread_ts)).map_err(policy_error)?, + status, + ); + let request = self.request.clone_ref(py); + let errors = self.transport_errors.clone_ref(py); + host::coroutine(py, async move { + let response = acall( + &request, + "__call__", + vec![ + j(json!("POST"))?, + j(json!(prepared.method))?, + j(prepared.payload)?, + ], + None, + ) + .await; + let result = match response { + Ok(response) => match value(&response) { + Ok(response) => StatusRequest::response(response), + Err(_) => DeliveryResult::uncertain("invalid_response"), + }, + Err(error) if is_transport_error(&error, &errors) => { + DeliveryResult::uncertain("transport") + } + Err(error) => return Err(error), + }; + j(json!(result)) + }) + } + fn send_request(&self, py: Python<'_>, request: &Bound<'_, PyAny>) -> PyResult> { + let prepared = + delivery::prepare_request(&convert::to_json(request)?).map_err(policy_error)?; + self.send_prepared(py, prepared) + } + #[allow(clippy::too_many_arguments)] + #[pyo3(signature=(reply,*,channel_id,thread_ts=None,message_ts=None,metadata=None,fallback_blocks=true))] + fn send( + &self, + py: Python<'_>, + reply: &Bound<'_, PyAny>, + channel_id: &str, + thread_ts: Option<&str>, + message_ts: Option<&str>, + metadata: Option<&Bound<'_, PyAny>>, + fallback_blocks: bool, + ) -> PyResult> { + let reply = convert::from_rich_reply(reply)?; + let metadata = metadata.map(convert::to_json).transpose()?; + let prepared = MessageRequest::new( + &reply, + channel_id, + thread_ts, + message_ts, + metadata.as_ref(), + fallback_blocks, + ) + .map_err(policy_error)?; + self.send_prepared(py, prepared) + } + #[allow(clippy::too_many_arguments)] + #[pyo3(signature=(channel_id,thread_ts,*,event_type,event_id,max_pages=5,timeout=8.0))] + fn reconcile( + &self, + py: Python<'_>, + channel_id: &str, + thread_ts: &str, + event_type: &str, + event_id: &str, + max_pages: usize, + timeout: f64, + ) -> PyResult> { + if !timeout.is_finite() || timeout <= 0.0 { + return Err(PyValueError::new_err("Expected a positive finite timeout")); + } + let mut reader = + Reconciliation::new(channel_id, thread_ts, event_type, event_id, max_pages) + .map_err(policy_error)?; + let request = self.request.clone_ref(py); + let errors = self.transport_errors.clone_ref(py); + host::coroutine(py, async move { + let started = Instant::now(); + loop { + let remaining = timeout - started.elapsed().as_secs_f64(); + if remaining <= 0.0 { + reader.fail("timeout"); + break; + } + let pending = call( + &request, + "__call__", + vec![ + j(json!("GET"))?, + j(json!("conversations.replies"))?, + j(reader.request())?, + ], + None, + )?; + let pending = call( + &module("asyncio")?, + "wait_for", + vec![pending], + Some(j(json!({"timeout":remaining}))?), + )?; + let response = match host::await_py(pending).await { + Ok(response) => response, + Err(error) + if Python::attach(|py| error.is_instance_of::(py)) => + { + reader.fail("timeout"); + break; + } + Err(error) if is_transport_error(&error, &errors) => { + reader.fail("transport"); + break; + } + Err(error) => return Err(error), + }; + match value(&response) { + Ok(response) => { + if reader.accept(&response) { + break; + } + } + Err(_) => { + reader.fail("invalid_response"); + break; + } + } + } + j(json!(reader.finish())) + }) + } +} +pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add( + "SlackSignatureError", + module.py().get_type::(), + )?; + module.add( + "SlackPayloadError", + module.py().get_type::(), + )?; + module.add( + "SlackBodyTooLarge", + module.py().get_type::(), + )?; + Ok(()) +} diff --git a/tests/channels/slack/test_files.py b/tests/channels/slack/test_files.py index b36d431..d6ed776 100644 --- a/tests/channels/slack/test_files.py +++ b/tests/channels/slack/test_files.py @@ -17,6 +17,7 @@ class Response: def __init__(self): self.status = status self.content = self + self.headers = {} async def __aenter__(self): return self @@ -30,7 +31,8 @@ async def iter_chunked(self, size): class Session: def __init__(self, **kwargs): - assert kwargs["timeout"].total == 30 + assert kwargs["timeout"].total == 45 + assert kwargs["auto_decompress"] is False async def __aenter__(self): return self @@ -96,7 +98,8 @@ async def handle(context): assert history == tuple(received) assert requests == [("files.info", {"http_verb": "GET", "params": {"file": "F12345678"}})] assert calls == [(metadata()["url_private_download"], { - "headers": {"Authorization": "Bearer bot-only"}, "allow_redirects": False, + "headers": {"Authorization": "Bearer bot-only", "Accept-Encoding": "identity"}, + "allow_redirects": False, })] @@ -178,8 +181,8 @@ async def request(api, payload): @pytest.mark.asyncio @pytest.mark.parametrize("chunks,status,error", [ - ((b"12345", b"678901"), 200, "download limit"), - ((), 200, "empty"), ((b"image",), 302, "Could not read"), + ((b"12345", b"678901"), 200, "storage budget"), + ((), 200, "declared size"), ((b"image",), 302, "Could not read"), ((b"image",), 403, "Could not read"), ]) async def test_download_bounds_actual_bytes_and_never_follows_redirects( @@ -188,7 +191,7 @@ async def test_download_bounds_actual_bytes_and_never_follows_redirects( calls = transport(monkeypatch, chunks=chunks, status=status) async def request(api, payload): - return {"ok": True, "file": metadata(size=1)} + return {"ok": True, "file": metadata(size=10)} with pytest.raises((ValueError, RuntimeError), match=error): await slack_files.download_slack_file(Attachment("F12345678"), bot_token="secret", diff --git a/tests/channels/slack/test_markdown.py b/tests/channels/slack/test_markdown.py new file mode 100644 index 0000000..a7e8a96 --- /dev/null +++ b/tests/channels/slack/test_markdown.py @@ -0,0 +1,82 @@ +import pytest + +from agentchat.channels.slack.rendering import markdown_text, split_text, text_blocks + + +@pytest.mark.parametrize(("text", "expected"), [ + ("**see `x` now** **", "*see `x` now* **"), + ("## **Model** `name` is **available**", "*Model `name` is available*"), + ("# Glob: **/*.py", "*Glob: **/*.py*"), + ("# Math: 2**10", "*Math: 2**10*"), + ("#### `**literal**` and **bold**", "*`**literal**` and bold*"), + ("### \nNormal", "### \nNormal"), + ("# \x1ctest", "# \x1ctest"), + ("[text](https://example.com/\x1cfile)", "[text](https://example.com/\x1cfile)"), + ("```python\r\n## **literal**\n`x`", "```\n## **literal**\n`x`"), + ("# [Docs](https://example.com)", "**"), +]) +def test_markdown_headings_bold_and_unclosed_fences(text, expected): + assert markdown_text(text) == expected + + +def test_local_resolution_preserves_authority_and_excludes_fenced_code(): + requested = [] + + def resolve(target): + requested.append(target) + return f"https://app.test/?file={target}&view=1" if target != "missing.md" else None + + result = markdown_text( + " <@U12345678> <@U87654321> & \n" + "[私|有](a&b.md) `notes.md` `missing.md` [web](https://example.com)\n" + "```python\n<@U12345678> [x](secret.md) `secret.md`\n```", + mentions=frozenset({"U12345678"}), resolve_link=resolve, + ) + assert requested == ["a&b.md", "notes.md", "missing.md"] + assert result.startswith("<!channel> <@U12345678> <@U87654321> & <tag>") + assert "" in result + assert "`missing.md`" in result + assert "" in result + assert result.endswith("```\n<@U12345678> [x](secret.md) `secret.md`\n```") + + +def test_resolver_failure_propagates_without_emitting_a_partial_reply(): + def denied(_target): + raise PermissionError("link is unavailable") + + with pytest.raises(PermissionError, match="unavailable"): + markdown_text("[private](private.md)", resolve_link=denied) + + +def test_resolver_receives_original_entity_literals_once(): + requested = [] + markdown_text("[link](a&b.md) `c<d.md`", resolve_link=requested.append) + assert requested == ["a&b.md", "c<d.md"] + + +def test_direct_web_links_are_bounded_and_escape_labels(): + assert markdown_text("[long](https://example.com/" + "x" * 1600 + ")") == "long" + assert markdown_text("[a|b](https://example.com/?a=1&b=2)") == ( + "" + ) + + +@pytest.mark.parametrize("limit", [30, 2600, 2900, 2992]) +def test_shared_chunker_keeps_fitting_links_intact_and_unicode_unmodified(limit): + link = "" + text = "🌊" * (limit - 5) + link + "文档" * 40 + chunks = split_text(text, limit=limit) + assert "".join(chunks) == text + assert any(link in chunk for chunk in chunks) + assert all(len(chunk) <= limit for chunk in chunks) + assert [block["text"]["text"] for block in text_blocks(text, limit=limit)] == chunks + + +def test_split_options_keep_legacy_newline_policy_explicit(): + text = "abcdefghij\n\nklmnopqrst" + assert "".join(split_text(text, limit=12)) == text + trimmed = split_text(text, limit=12, trim_boundary_newlines=True) + assert "".join(trimmed) == "abcdefghij\nklmnopqrst" + assert split_text("") == [] + with pytest.raises(ValueError, match="between 3 and 2992"): + split_text("text", limit=0) diff --git a/tests/channels/slack/test_reader.py b/tests/channels/slack/test_reader.py new file mode 100644 index 0000000..ecb724d --- /dev/null +++ b/tests/channels/slack/test_reader.py @@ -0,0 +1,331 @@ +"""Public host reader parity: verified bytes, bounded retries and inert Canvas views.""" + +import asyncio +import tempfile +import threading +from contextlib import asynccontextmanager +from io import BytesIO +from unittest.mock import AsyncMock + +import pytest + +from agentchat.channels.slack import files + +FILE_ID = "F12345678" +URL = "https://files.slack.com/files-pri/T123-F12345678/notes.txt" + + +def metadata(**overrides): + return { + "ok": True, + "file": { + "id": FILE_ID, + "name": "notes.txt", + "mimetype": "text/plain", + "size": 5, + "url_private": URL, + **overrides, + }, + } + + +def setup(monkeypatch, *, chunks=(b"hello",), status=200, headers=None, info=None): + calls = [] + temporaries = [] + real_tempfile = tempfile.TemporaryFile + + def temporary(): + raw = real_tempfile() + temporaries.append(raw) + return raw + + class Response: + def __init__(self): + self.status = status + self.headers = headers if headers is not None else {"Content-Length": "5"} + self.content = self + + async def iter_chunked(self, size): + assert size == 64 * 1024 + for chunk in chunks: + if isinstance(chunk, BaseException): + raise chunk + yield chunk + + class Client: + @asynccontextmanager + async def get(self, url, *, headers, allow_redirects): + assert url == URL + assert headers == {"Authorization": "Bearer fresh-token", "Accept-Encoding": "identity"} + assert allow_redirects is False + calls.append("download") + yield Response() + + @asynccontextmanager + async def session(*, timeout, auto_decompress): # noqa: ASYNC109 + assert (timeout.total, timeout.sock_connect, timeout.sock_read) == (45, 10, 15) + assert auto_decompress is False + yield Client() + + async def request(method, payload, token): + assert (method, payload, token) == ("files.info", {"file": FILE_ID}, "fresh-token") + calls.append("metadata") + return metadata() if info is None else info + + monkeypatch.setattr(files.aiohttp, "ClientSession", session) + monkeypatch.setattr(tempfile, "TemporaryFile", temporary) + return ( + { + "token": AsyncMock(return_value="fresh-token"), + "request": request, + "authorize": AsyncMock(), + }, + calls, + temporaries, + ) + + +@pytest.mark.asyncio +async def test_verified_file_is_seekable_and_owned_by_caller(monkeypatch): + callbacks, calls, temporaries = setup(monkeypatch, chunks=(b"he", b"llo")) + result = await files.SlackFileReader(max_bytes=100).read(FILE_ID, **callbacks) + try: + assert (result.name, result.media_type, result.raw.read()) == ( + "notes.txt", + "text/plain", + b"hello", + ) + assert result.state["received_bytes"] == 5 + assert calls == ["metadata", "download"] + assert callbacks["authorize"].await_count == 4 + assert not temporaries[0].closed + finally: + result.raw.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("headers", "status", "chunks", "code"), + [ + ({"Content-Length": "4"}, 200, (b"hello",), "metadata_size_mismatch"), + ({"Content-Length": "5"}, 200, (b"he",), "truncated_stream"), + ({"Content-Length": "5"}, 200, (b"helloo",), "transfer_size_mismatch"), + ({}, 200, (b"helloo",), "metadata_size_mismatch"), + ({"Content-Length": "five"}, 200, (b"hello",), "invalid_headers"), + ({"Content-Encoding": "gzip"}, 200, (b"hello",), "encoded_response"), + ({"Content-Encoding": "secret-provider-header"}, 200, (b"hello",), "encoded_response"), + ({"Content-Range": "bytes 0-4/10"}, 200, (b"hello",), "partial_response"), + ({}, 206, (b"hello",), "partial_response"), + ({}, 302, (b"hello",), "redirect"), + ({}, 403, (b"hello",), "authentication"), + ({}, 503, (b"hello",), "transient_http"), + ], +) +async def test_integrity_failures_do_not_publish_partial_files( + monkeypatch, headers, status, chunks, code +): + callbacks, _, temporaries = setup(monkeypatch, headers=headers, status=status, chunks=chunks) + with pytest.raises(files.SlackFileError) as caught: + await files.SlackFileReader(max_bytes=100, read_attempts=1).read(FILE_ID, **callbacks) + assert caught.value.code == code + assert all(raw.closed for raw in temporaries) + assert "secret-provider-header" not in str(caught.value) + assert caught.value.diagnostic()["attempts"] == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "change", + [ + {"id": "FOTHER"}, + {"is_external": True}, + {"size": True}, + {"size": 0}, + {"url_private": "https://attacker.test/files-pri/x"}, + {"url_private": "https://token@files.slack.com/files-pri/x"}, + {"url_private": "https://files.slack.com/files-pri/x#fragment"}, + ], +) +async def test_rejects_unverifiable_metadata_before_download(monkeypatch, change): + callbacks, calls, _ = setup(monkeypatch, info=metadata(**change)) + with pytest.raises(files.SlackFileError): + await files.SlackFileReader(max_bytes=100).read(FILE_ID, **callbacks) + assert calls == ["metadata"] + + +@pytest.mark.asyncio +async def test_retries_get_failure_but_never_uncertain_credential_refresh(monkeypatch): + callbacks, calls, _ = setup(monkeypatch, status=503) + with pytest.raises(files.SlackFileError) as caught: + await files.SlackFileReader(max_bytes=100, retry_delay=0).read(FILE_ID, **callbacks) + assert calls == ["metadata", "download", "metadata", "download"] + assert len(caught.value.history) == 2 + callbacks["token"].side_effect = files.SlackTransportError( + "response_unavailable", retryable=True, dispatched=True + ) + calls.clear() + with pytest.raises(files.SlackFileError) as caught: + await files.SlackFileReader(max_bytes=100, retry_delay=0).read(FILE_ID, **callbacks) + assert caught.value.code == "credential_unconfirmed" + assert caught.value.diagnostic()["attempts"] == 1 + assert "did not replay" in caught.value.retry_blocker + assert calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("retry_after", [None, "NaN", "-1", "300"]) +async def test_never_shortens_provider_retry_interval(monkeypatch, retry_after): + headers = {} if retry_after is None else {"Retry-After": retry_after} + callbacks, calls, _ = setup(monkeypatch, headers=headers, status=429) + reader = files.SlackFileReader(max_bytes=100, retry_delay=0, read_budget=1) + with pytest.raises(files.SlackFileError) as caught: + await reader.read(FILE_ID, **callbacks) + assert caught.value.code == "rate_limited" + assert caught.value.diagnostic()["attempts"] == 1 + assert "no early retry" in caught.value.retry_blocker + assert calls == ["metadata", "download"] + if retry_after == "300": + with pytest.raises(files.SlackFileError): + await reader.read(FILE_ID, **callbacks) + assert calls == ["metadata", "download"] + + +@pytest.mark.asyncio +async def test_access_revocation_closes_verified_tempfile(monkeypatch): + callbacks, _, temporaries = setup(monkeypatch) + callbacks["authorize"].side_effect = [None, None, None, files.SlackFileError("access_changed")] + with pytest.raises(files.SlackFileError, match="access_changed"): + await files.SlackFileReader(max_bytes=100).read(FILE_ID, **callbacks) + assert temporaries[0].closed + + +@pytest.mark.asyncio +async def test_whole_read_budget_is_bounded(monkeypatch): + callbacks, _, _ = setup(monkeypatch) + + async def slow_token(): + await asyncio.sleep(10) + + callbacks["token"] = slow_token + with pytest.raises(files.SlackFileError) as caught: + await files.SlackFileReader(max_bytes=100, read_budget=0.01).read(FILE_ID, **callbacks) + assert caught.value.code == "read_budget" + assert caught.value.diagnostic()["stage"] == "access" + + +@pytest.mark.asyncio +async def test_cancelled_file_worker_finishes_before_caller_can_close(): + started, finish = threading.Event(), threading.Event() + raw = BytesIO() + + def write(): + started.set() + assert finish.wait(2) + raw.write(b"completed") + + task = asyncio.create_task(files.file_operation(write)) + await asyncio.to_thread(started.wait, 2) + task.cancel() + await asyncio.sleep(0.01) + task.cancel() + assert not task.done() + finish.set() + with pytest.raises(asyncio.CancelledError): + await task + assert raw.getvalue() == b"completed" + + +@pytest.mark.asyncio +async def test_canvas_export_size_is_independent_and_original_is_retained(monkeypatch): + body = ( + b'

Plan & notes

Visible

' + b"
" + ) + callbacks, _, _ = setup( + monkeypatch, + info=metadata(mimetype=files.CANVAS_MIME, filetype="quip", mode="quip", size=99999), + chunks=(body,), + headers={"Content-Length": str(len(body)), "Content-Type": "text/html; charset=utf-8"}, + ) + result = await files.SlackFileReader(max_bytes=1000).read(FILE_ID, **callbacks) + try: + assert result.name == "notes.txt.html" + assert result.representation == "canvas_html" + assert files.inspect_canvas(result.raw) == ( + files.CANVAS_MIME, + b"", + "# Plan & notes\n\nVisible", + ) + assert result.raw.read() == body + finally: + result.raw.close() + + +@pytest.mark.parametrize( + "body", + [ + b"Sign in", + b'
unfinished', + b'
login
', + b'
\xff
', + ], +) +def test_invalid_canvas_is_not_document_content(body): + raw = BytesIO(body) + with pytest.raises(files.CanvasFormatError): + files.inspect_canvas(raw) + assert raw.tell() == 0 + + +def test_canvas_preview_bounds_source_output_and_hidden_content(): + body = b'
' + b"hello " * 50000 + b"
" + raw = BytesIO(body) + mime, _, text = files.inspect_canvas(raw) + assert mime == files.CANVAS_MIME + assert len(text) <= 8000 + assert text.endswith("retained in the attachment.]") + assert raw.read() == body + + +@pytest.mark.asyncio +@pytest.mark.parametrize("error", [PermissionError("revoked"), TimeoutError("authority timed out")]) +async def test_authority_failures_propagate_without_network_or_retry(monkeypatch, error): + callbacks, calls, _ = setup(monkeypatch) + callbacks["authorize"].side_effect = error + with pytest.raises(type(error)) as caught: + await files.SlackFileReader(max_bytes=100, retry_delay=0).read(FILE_ID, **callbacks) + assert caught.value is error + assert calls == [] + assert callbacks["authorize"].await_count == 1 + + +@pytest.mark.asyncio +async def test_budget_failure_retains_earlier_attempts(monkeypatch): + callbacks, _, _ = setup(monkeypatch, status=503) + tokens = 0 + + async def token(): + nonlocal tokens + tokens += 1 + if tokens == 2: + await asyncio.sleep(10) + return "fresh-token" + + callbacks["token"] = token + with pytest.raises(files.SlackFileError) as caught: + await files.SlackFileReader(max_bytes=100, read_budget=0.02, retry_delay=0).read( + FILE_ID, **callbacks + ) + assert [attempt["code"] for attempt in caught.value.history] == [ + "transient_http", + "read_budget", + ] + assert caught.value.history[-1]["attempt"] == 2 + + +@pytest.mark.asyncio +async def test_very_large_retry_after_is_safe_and_never_retried_early(monkeypatch): + callbacks, calls, _ = setup(monkeypatch, status=429, headers={"Retry-After": "1e300"}) + with pytest.raises(files.SlackFileError, match="no early retry"): + await files.SlackFileReader(max_bytes=100).read(FILE_ID, **callbacks) + assert calls == ["metadata", "download"] diff --git a/tests/channels/slack/test_services.py b/tests/channels/slack/test_services.py new file mode 100644 index 0000000..f764db2 --- /dev/null +++ b/tests/channels/slack/test_services.py @@ -0,0 +1,264 @@ +"""Public native HTTP services with host-owned async transport and persistence.""" +import asyncio +import hashlib +import hmac +import json +from urllib.parse import urlencode + +import pytest + +from agentchat.channels.slack.delivery import SlackDelivery +from agentchat.channels.slack.rendering import message_request +from agentchat.channels.slack.webhook import ( + SlackBodyTooLarge, + SlackPayloadError, + SlackSignatureError, + SlackWebhook, +) +from agentchat.models import RichReply + + +def signed(raw, stamp="1000"): + digest = hmac.new(b"secret", b"v0:" + stamp.encode() + b":" + raw, hashlib.sha256) + return {"X-Slack-Request-Timestamp": stamp, "X-Slack-Signature": "v0=" + digest.hexdigest()} + + +def event_raw(**changes): + payload = {"type": "event_callback", "team_id": "T1", "event_id": "Ev1", "event": {}} + return json.dumps(payload | changes).encode() + + +def test_signed_event_challenge_and_ignored_envelope_keep_source(): + hook = SlackWebhook("secret") + raw = event_raw() + assert hook.event(raw, signed(raw), now=1000) == {"kind": "event", "payload": json.loads(raw)} + raw = json.dumps({"type": "url_verification", "challenge": "proof"}).encode() + assert hook.event(raw, signed(raw), now=1000)["challenge"] == "proof" + raw = b'{"type":"app_rate_limited"}' + assert hook.event(raw, signed(raw), now=1000)["kind"] == "ignored" + + +@pytest.mark.parametrize("change", [ + {"team_id": "T:injected"}, {"event_id": "Ev:other"}, {"event_id": "x" * 101}, + {"event_id": "é"}, {"event_id": False}, {"event_id": ""}, {"event": []}, +]) +def test_envelope_identity_and_structure_are_checked_before_admission(change): + raw = event_raw(**change) + with pytest.raises(SlackPayloadError): + SlackWebhook("secret").event(raw, signed(raw), now=1000) + + +@pytest.mark.parametrize("raw", [b"[1]", b"invalid", b'\xff', b'{"type":"url_verification"}']) +def test_signed_invalid_json_or_envelope_is_a_payload_error(raw): + with pytest.raises(SlackPayloadError): + SlackWebhook("secret").event(raw, signed(raw), now=1000) + + +def test_signature_and_size_have_distinct_errors_and_no_secret_in_message(): + raw = event_raw() + hook = SlackWebhook("secret") + hook.verify(raw, signed(raw), now=1300) + for headers, now, body in [(signed(raw), 1300.1, raw), (signed(raw), 699.9, raw), + ({}, 1000, raw), (signed(raw), 1000, b"tampered")]: + with pytest.raises(SlackSignatureError, match="Invalid Slack signature"): + hook.event(body, headers, now=now) + with pytest.raises(SlackBodyTooLarge): + hook.event(raw, signed(raw), now=1000, max_bytes=1) + with pytest.raises(SlackSignatureError): + SlackWebhook("").verify(raw, signed(raw), now=1000) + + +def test_interaction_uses_the_signed_form_bytes_and_preserves_unicode(): + payload = {"type": "block_actions", "team": {"id": "T1"}, "value": "café + &"} + raw = urlencode({"payload": json.dumps(payload)}).encode() + assert SlackWebhook("secret").interaction(raw, signed(raw), now=1000) == payload + + +@pytest.mark.parametrize("raw", [ + b"payload=%7B%7D&payload=%7B%7D", b"payload=[]", b"other={}", b"payload=%GG", + b"payload=%7B%22x%22%3A%22%FF%22%7D", b"payload={}&bad", b"payload=%", +]) +def test_interaction_rejects_ambiguous_or_malformed_payload(raw): + with pytest.raises(SlackPayloadError): + SlackWebhook("secret").interaction(raw, signed(raw), now=1000) + + +@pytest.mark.asyncio +async def test_reply_and_update_use_existing_request_plan_once(): + calls = [] + + async def request(verb, method, payload): + calls.append((verb, method, payload)) + return {"ok": True, "ts": payload.get("ts", "123.002"), "channel": payload["channel"]} + + service = SlackDelivery(request) + result = await service.send(RichReply("Ready"), channel_id="C123", thread_ts="123.001") + assert result["status"] == "confirmed" + assert len(calls) == 1 and calls[0][:2] == ("POST", "chat.postMessage") + plan = message_request(RichReply("Complete"), channel_id="C123", message_ts="123.002", + fallback_blocks=False) + result = await service.send_request(plan) + assert result["status"] == "confirmed" + assert calls[-1] == ("POST", "chat.update", plan["payload"]) + assert calls[-1][2]["blocks"] == [] and calls[-1][2]["attachments"] == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("response,status", [ + ({"ok": True, "ts": "123.002"}, "confirmed"), + ({"ok": False, "error": "missing_scope"}, "rejected"), + ({"ok": True}, "uncertain"), ({"ok": "yes", "ts": "123.002"}, "uncertain"), + ({"ok": True, "ts": "not-a-timestamp"}, "uncertain"), + ({"ok": True, "ts": "123.002", "channel": "COTHER"}, "uncertain"), + ([], "uncertain"), +]) +async def test_send_classifies_only_confirmed_receipts(response, status): + calls = [] + + async def request(*args): + calls.append(args) + return response + + result = await SlackDelivery(request).send(RichReply("Ready"), channel_id="C123") + assert result["status"] == status and len(calls) == 1 + + +@pytest.mark.asyncio +async def test_invalid_plan_fails_before_transport(): + calls = [] + + async def request(*args): + calls.append(args) + return {"ok": True} + + with pytest.raises(ValueError): + await SlackDelivery(request).send_request({"method": "chat.delete", "payload": {}}) + assert calls == [] + + +@pytest.mark.asyncio +async def test_uncertain_write_is_not_retried_and_authority_errors_propagate(): + calls = [] + + async def lost_response(*args): + calls.append(args) + raise ConnectionError("lost response") + + result = await SlackDelivery(lost_response).send(RichReply("Ready"), channel_id="C123") + assert result["status"] == "uncertain" and len(calls) == 1 + + async def denied(*_args): + raise PermissionError("authority changed") + + with pytest.raises(PermissionError, match="authority changed"): + await SlackDelivery(denied).send( + RichReply("Ready"), channel_id="C123", + ) + + +def receipt(identity="one", kind="delivery", thread="123.001"): + return {"ts": "123.002", "thread_ts": thread, + "metadata": {"event_type": kind, "event_payload": {"id": identity}}} + + +@pytest.mark.asyncio +async def test_reconciliation_paginates_and_matches_both_metadata_fields_and_thread(): + calls = [] + + async def request(verb, method, payload): + calls.append((verb, method, payload)) + if "cursor" not in payload: + return {"ok": True, "messages": [receipt(kind="forged"), receipt("other"), + receipt(thread="999.001")], + "response_metadata": {"next_cursor": "second"}} + return {"ok": True, "messages": [receipt()]} + + result = await SlackDelivery(request).reconcile("C123", "123.001", event_type="delivery", + event_id="one") + assert result["status"] == "confirmed" + assert len(calls) == 2 + assert all(call[:2] == ("GET", "conversations.replies") for call in calls) + assert calls[-1][2]["cursor"] == "second" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("response,reason", [ + ({"ok": True, "messages": []}, "not_found"), + ({"ok": False, "error": "missing_scope"}, "history_unavailable"), + ({"ok": True, "messages": [], "has_more": True}, "missing_cursor"), + ({"ok": True, "messages": [], "response_metadata": {"next_cursor": "next"}}, "page_limit"), +]) +async def test_read_failure_or_absence_never_proves_write_failed(response, reason): + calls = [] + + async def request(*args): + calls.append(args) + return response + + result = await SlackDelivery(request).reconcile("C123", "123.001", event_type="delivery", + event_id="one", max_pages=1) + assert result["status"] == "uncertain" and result["reason"] == reason and len(calls) == 1 + + +@pytest.mark.asyncio +async def test_reconciliation_is_bounded_and_concurrent_calls_do_not_share_cursors(): + async def request(_verb, _method, payload): + await asyncio.sleep(0) + if "cursor" not in payload: + return {"ok": True, "messages": [], + "response_metadata": {"next_cursor": payload["channel"]}} + assert payload["cursor"] == payload["channel"] + return {"ok": True, "messages": [receipt(payload["channel"])]} + + service = SlackDelivery(request) + results = await asyncio.gather(*( + service.reconcile(channel, "123.001", event_type="delivery", event_id=channel) + for channel in ("C123", "C456") + )) + assert all(result["status"] == "confirmed" for result in results) + + async def repeated(*_args): + return {"ok": True, "messages": [], "response_metadata": {"next_cursor": "same"}} + + result = await SlackDelivery(repeated).reconcile( + "C123", "123.001", event_type="delivery", event_id="one", + ) + assert result["reason"] == "repeated_cursor" + + +@pytest.mark.asyncio +async def test_read_timeout_cancels_callback_and_cancellation_of_write_propagates(): + cancelled = asyncio.Event() + + async def waiting(*_args): + try: + await asyncio.Future() + finally: + cancelled.set() + + service = SlackDelivery(waiting) + result = await service.reconcile( + "C123", "123.001", event_type="delivery", event_id="one", timeout=.01, + ) + assert result["reason"] == "timeout" and cancelled.is_set() + cancelled.clear() + task = asyncio.create_task(service.send(RichReply("Ready"), channel_id="C123")) + await asyncio.sleep(.01) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert cancelled.is_set() + + +@pytest.mark.asyncio +async def test_working_status_uses_shared_plan_without_message_receipt_or_retry(): + calls = [] + + async def request(*args): + calls.append(args) + return {"ok": True} + + result = await SlackDelivery(request).set_status("C123", "123.001", "Working") + assert result["status"] == "confirmed" + assert calls == [("POST", "assistant.threads.setStatus", + {"channel_id": "C123", "thread_ts": "123.001", "status": "Working"})]