Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 82 additions & 0 deletions docs/slack.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,32 @@ 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.

## Mirroring web inputs into Slack

Use `app.mirror()` to display an already accepted web input in a bound Slack
Expand Down Expand Up @@ -490,3 +516,59 @@ python -m pip install -e ".[dev]"
ruff check .
pytest
```

### Applications with their own Slack inbox

`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.

```python
from agentchat.channels.slack.host import SlackHost

host = SlackHost(
bot_user_id="UBOT00001",
workspace_id="TWORK0001",
exclude_prefixes=("!aside",), # Optional application policy.
)
event = host.normalize_event(authenticated_payload)
if event is not None:
context = await host.thread_context(event, authorized_page_request)
```

`authorized_page_request` is an async callback receiving `conversations.replies`
parameters. It returns the raw Slack response (`ok`, `messages`, `has_more`, and
`response_metadata.next_cursor`). Connector adapters may instead return their
next cursor in `next_arguments.cursor`. The callback runs on the caller's event
loop; it supplies credentials and enforces the application's access policy.
Authorization exceptions propagate. A provider error returned as `ok: false`
produces explicit context coverage; a failed later page preserves earlier messages.

The normalized event carries its current preview data bound to the event origin
and author. Passing that event to `thread_context` preserves its previews if the
provider read fails and merges them with fetched previews without duplication.
Pass `request_page=None` (or omit it) when application policy forbids a history
read, such as a direct-message workflow. This performs no I/O and reports
`history_not_requested` with partial or unavailable coverage. A reference copied
from a different event is rejected before the callback runs.

Context includes the parent and recent messages, with author IDs, file references,
and link previews. Current-message text, files, and composer `rich_text` blocks are
excluded because the current request already contains them; current link previews
remain. Source data is returned as bounded JSON text and must be presented as
untrusted reference material, separately from the user's request. Context reading
does not fetch linked URLs or download attachments.

Defaults limit the read to five pages, 40,000 content characters, 8,000 characters
per message, and eight seconds. Results report `complete`, `partial`, or
`unavailable`; missing roots, pagination limits, truncation, and provider failures
are explicit. The root is retained when older replies must be removed to meet the
budget. The timestamp cutoff is checked locally even when a connector cannot send
Slack's `latest` parameter. Code and quoted mentions remain literal text and do
not address the bot. Malformed supported events raise `ValueError`; ignored
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.
38 changes: 38 additions & 0 deletions python/agentchat/channels/slack/host.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Slack protocol helpers for applications that own authentication and durable ingress."""
from typing import Literal, NotRequired, TypedDict

from agentchat._native import SlackHost as SlackHost


class SlackRouting(TypedDict):
text: str
mentioned: bool
excluded: bool

class ThreadOrigin(TypedDict):
team_id: str
channel_id: str
thread_ts: str
message_ts: str

class CurrentReference(ThreadOrigin):
user_id: str
data: dict[str, object]

class SlackHostEvent(ThreadOrigin, SlackRouting):
user_id: str
direct: bool
files: list[dict[str, object]]
reference: CurrentReference

class ContextMessage(TypedDict):
ts: str
author: str
content: str
truncated: bool

class ThreadContext(ThreadOrigin):
status: Literal["complete", "partial", "unavailable"]
messages: list[ContextMessage]
error: NotRequired[str]
warning: NotRequired[str]
50 changes: 50 additions & 0 deletions python/agentchat/channels/slack/host.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Literal, NotRequired, TypedDict

class SlackRouting(TypedDict):
text: str
mentioned: bool
excluded: bool

class ThreadOrigin(TypedDict):
team_id: str
channel_id: str
thread_ts: str
message_ts: str

class CurrentReference(ThreadOrigin):
user_id: str
data: dict[str, object]

class SlackHostEvent(ThreadOrigin, SlackRouting):
user_id: str
direct: bool
files: list[dict[str, object]]
reference: CurrentReference

class ContextMessage(TypedDict):
ts: str
author: str
content: str
truncated: bool

class ThreadContext(ThreadOrigin):
status: Literal["complete", "partial", "unavailable"]
messages: list[ContextMessage]
error: NotRequired[str]
warning: NotRequired[str]

class SlackHost:
def __init__(
self, *, bot_user_id: str, workspace_id: str | None = ...,
exclude_prefixes: Sequence[str] = ...,
) -> None: ...
def routing_text(self, text: str) -> SlackRouting: ...
def normalize_event(self, payload: Mapping[str, object]) -> SlackHostEvent | None: ...
async def thread_context(
self, origin: Mapping[str, object],
request_page: Callable[[dict[str, object]], Awaitable[Mapping[str, object]]] | None = ...,
*,
max_pages: int = ..., max_chars: int = ..., max_message_chars: int = ...,
timeout: float = ..., # noqa: ASYNC109 - bounded native callback protocol
) -> ThreadContext: ...
11 changes: 10 additions & 1 deletion python/agentchat/channels/slack/rendering.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
"""Slack rendering implemented in Rust."""
from typing import Literal, TypedDict

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_text_blocks as text_blocks

__all__ = ['rich_payload', 'text_blocks']

class SlackMessageRequest(TypedDict):
method: Literal["chat.postMessage", "chat.update"]
payload: dict[str, object]


__all__ = ['SlackMessageRequest', 'message_request', 'rich_payload', 'text_blocks']
12 changes: 12 additions & 0 deletions python/agentchat/channels/slack/rendering.pyi
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Public compatibility signatures; update with the corresponding Rust binding API.
from collections.abc import Mapping
from typing import Literal, TypedDict

from agentchat.models import RichReply

class SlackMessageRequest(TypedDict):
method: Literal["chat.postMessage", "chat.update"]
payload: dict[str, object]

def text_blocks(text: str) -> list[dict[str, object]]: ...
def rich_payload(reply: RichReply) -> dict[str, object]: ...
def message_request(
reply: RichReply, *, channel_id: str, thread_ts: str | None = ...,
message_ts: str | None = ..., metadata: Mapping[str, object] | None = ...,
fallback_blocks: bool = ...,
) -> SlackMessageRequest: ...
19 changes: 19 additions & 0 deletions scripts/python-typing-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
scripts/python-typing-smoke.py, using the project environment's Python executable.
"""

from collections.abc import Awaitable, Callable, Mapping
from typing import assert_type

from agentchat import AgentChat, Conversation, Message, MessageContext, Sender
Expand All @@ -12,11 +13,14 @@
from agentchat.channels.discord import Discord
from agentchat.channels.imessage import IMessage
from agentchat.channels.slack import Slack, SlackUser
from agentchat.channels.slack.host import SlackHost, SlackHostEvent, SlackRouting, ThreadContext
from agentchat.channels.slack.rendering import SlackMessageRequest, message_request
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.state import MemoryState


Expand All @@ -41,3 +45,18 @@ async def check_api(
assert_type(await whatsapp.reply(message, "hello"), Message)
assert_type(render("**hello**"), RenderedText)
assert_type(TelegramBotClient("99:token"), TelegramBotClient)


async def check_slack_host(
payload: Mapping[str, object],
request_page: Callable[[dict[str, object]], Awaitable[Mapping[str, object]]],
reply: RichReply,
) -> None:
host = SlackHost(bot_user_id="UBOT00001", exclude_prefixes=("!aside",))
assert_type(host.routing_text("hello"), SlackRouting)
normalized = host.normalize_event(payload)
assert_type(normalized, SlackHostEvent | None)
if normalized is not None:
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)
1 change: 1 addition & 0 deletions src/agentchat-slack/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ tokio-tungstenite = { version = "0.30", features = ["rustls-tls-webpki-roots"] }
tokio-util = { version = "0.7", features = ["rt"] }
tracing = "0.1"
unicode-general-category = "1"
regex = "1"

[dev-dependencies]
axum = { version = "0.8", features = ["ws"] }
Expand Down
Loading
Loading