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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ All notable changes to this project are documented here. The format is based on

- Documentation polish for v0 release: filled per-package READMEs, expanded CHANGELOG with known gaps (DES-192).

### Changed

- Adapter dispatch surface (DES-196) — Slack and GChat Part B ported (webhook + outbound message/reaction methods); `docs/parity.md` now carries a "Dispatch surface" table and enumerates every deliberate `NotImplementedError` stub; CHANGELOG now reflects reality for Telegram/WhatsApp (previously labelled "placeholder stub" — dispatch has actually been present since DES-182 / DES-183). `chat.types.Adapter` promoted from `Any` to a `@runtime_checkable` Protocol mirroring upstream `types.ts`.

## [0.1.0] — 2026-04-22

Initial Python port of [`vercel/chat`](https://github.com/vercel/chat) v4.26.0. `uv`-native workspace, Python 3.13, async-first.
Expand Down
53 changes: 53 additions & 0 deletions docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,59 @@ Python has one first-class Redis client — `redis-py` with its `redis.asyncio`

`@workflow/serde`'s `WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE` symbols become Python `__chat_serialize__` / `__chat_deserialize__` methods (mirror of `__reduce__` / `__setstate__` but scoped to this SDK's serialization path).

## Dispatch surface

Every adapter's `handle_webhook` + outbound message surface, as of the DES-196 port. States:

- `full` — implemented and exercised by `chat-integration-tests/test_dispatch_memory.py`.
- `stub` — declared on the adapter, raises `chat.errors.NotImplementedError` at call site (see "Deliberate NotImplementedError stubs" below).
- `n/a (upstream limit)` — upstream TypeScript does not implement this method either; parity preserved.

| adapter | handle_webhook | post | edit | delete | react | streaming | notes |
| --------- | -------------- | ---- | ---- | ------ | ----- | --------- | ---------------------------------------------------------------- |
| slack | full | full | full | full | full | full | HTTP Events API + Socket Mode (Phase 1 + Phase 2 of DES-196). |
| gchat | full | full | full | full | full | full | HTTP webhook + Pub/Sub push (Phase 3 of DES-196). |
| discord | full | full | full | full | full | full | HTTP interactions; modals stubbed (Discord has no modal surface). |
| github | full | full | full | full | stub | full | Issue-comment reactions via GitHub reactions API (limited set). |
| teams | full | full | full | full | stub | full | 7 deliberate stubs — see below. |
| linear | full | full | full | full | partial | full | `add_reaction` is full (Linear ``reactionCreate`` GraphQL); `remove_reaction` is stubbed — see below. |
| telegram | full | full | full | full | full | full | 1 deliberate stub — see below. |
| whatsapp | full | full | full | full | full | full | DM-only (WhatsApp Cloud API); 2 deliberate stubs — see below. |

### Deliberate NotImplementedError stubs

These methods are declared on the adapter but raise `chat.errors.NotImplementedError` on call. They are pinned by tests in each adapter's `test_unsupported_features.py` (or equivalent) so behaviour can't silently change.

- **`chat-adapter-discord`** — 1 site in `packages/chat-adapter-discord/src/chat_adapter_discord/adapter.py`:
- `open_modal` — Discord has no standalone modal-open surface; modals are delivered as responses to an interaction (`APPLICATION_MODAL`). Upstream does not wire `open_modal` for Discord either; we raise `chat.NotImplementedError(feature="modals")` to satisfy the Protocol.
- **`chat-adapter-gchat`** — 1 site in `packages/chat-adapter-gchat/src/chat_adapter_gchat/adapter.py` (approx. `:1115`):
- `open_modal` — Google Chat has no Slack-style modal; use a Card v2 response instead. Raises `chat.NotImplementedError(feature="modals")` to satisfy the Protocol.
- **`chat-adapter-github`** — 4 sites in `packages/chat-adapter-github/src/chat_adapter_github/adapter.py`:
- `open_dm` — GitHub has no DM surface; issues and PRs are always repo-scoped. Raises `chat.NotImplementedError(feature="open_dm")`.
- `open_modal` — GitHub has no modal surface; use issue comments or PR review comments for interactive flows. Raises `chat.NotImplementedError(feature="open_modal")`.
- `post_channel_message` — GitHub has no channel-level post surface; messages are always thread-scoped (issue or PR). Raises `chat.NotImplementedError(feature="post_channel_message")`.
- `fetch_channel_messages` — GitHub has no flat channel-message stream; comments belong to individual issues/PRs. Raises `chat.NotImplementedError(feature="fetch_channel_messages")`.
- **`chat-adapter-teams`** — 9 sites in `packages/chat-adapter-teams/src/chat_adapter_teams/adapter.py` (7 in `:444-498`, plus `post_channel_message` / `open_modal` added in DES-196 phase 7). Pinned by `packages/chat-adapter-teams/tests/test_unsupported_features.py`:
- `add_reaction` / `remove_reaction` — Teams' Bot Framework REST transport does not expose message reactions. Raises `chat.NotImplementedError(feature="addReaction" | "removeReaction")`.
- `fetch_messages` / `fetch_thread` / `fetch_channel_messages` / `list_threads` / `fetch_channel_info` — Teams history requires the Graph API reader which is not yet ported. Each raises `chat.NotImplementedError` with a matching camelCase `feature` attribute.
- `post_channel_message` — requires Graph API (creating a new conversation in a channel is not supported via Bot Framework REST). Raises `chat.NotImplementedError(feature="postChannelMessage")`.
- `open_modal` — Teams task modules ship via the `taskModule/continue` invoke response flow, which isn't wired through this webhook facade yet. Raises `chat.NotImplementedError(feature="openModal")`.
- Certificate-based auth (`certificate` config) — deprecated upstream and unsupported here. Construction raises `chat_adapter_shared.ValidationError` (not `NotImplementedError`); pinned by the same test module.
- **`chat-adapter-whatsapp`** — 7 sites in `packages/chat-adapter-whatsapp/src/chat_adapter_whatsapp/adapter.py`:
- `edit_message` — WhatsApp Cloud API has no edit endpoint; callers must send a new message. Raises `chat.NotImplementedError(feature="editMessage")`.
- `delete_message` — WhatsApp Cloud API has no delete endpoint. Raises `chat.NotImplementedError(feature="deleteMessage")`.
- `post_channel_message` / `fetch_channel_info` / `fetch_channel_messages` / `list_threads` — WhatsApp Cloud API is 1:1 DM-only; there is no channel surface. Each raises `chat.NotImplementedError` with a matching `feature` attribute.
- `open_modal` — WhatsApp has no modal surface; use interactive messages (buttons / list) instead. Raises `chat.NotImplementedError(feature="open_modal")`.
- **`chat-adapter-telegram`** — 3 sites in `packages/chat-adapter-telegram/src/chat_adapter_telegram/adapter.py`:
- `edit_message` (approx. `:584`) — Telegram's `editMessageText` can return `true` (boolean) instead of a full `Message` object when the edit succeeds without any observable change. When that happens *and* the cached message for the edited id has also been evicted, we cannot reconstruct the updated `Message` locally. Raises `chat.NotImplementedError(feature="editMessage")`. Upstream has the same stub state — see `packages/adapter-telegram/src/index.ts` in `vercel/chat`. In practice callers should treat this as a warning rather than a hard failure and retry with `force=True`.
- `open_modal` — Telegram has no modal surface; use inline keyboards via cards. Raises `chat.NotImplementedError(feature="openModal")`.
- `stream` — streaming-edit surface not ported (upstream also leaves this unimplemented — Telegram's edit rate-limits make naive streaming impractical). Raises `chat.NotImplementedError(feature="stream")`.
- **`chat-adapter-linear`** — 7 sites in `packages/chat-adapter-linear/src/chat_adapter_linear/adapter.py` (added in DES-196 phase 8):
- `remove_reaction` — Linear's GraphQL surface requires a reaction-id lookup before `reactionDelete`; upstream does not implement it either. Raises `chat.NotImplementedError(feature="removeReaction")`. `add_reaction` is fully implemented via `reactionCreate`.
- `post_channel_message` / `fetch_channel_info` / `fetch_channel_messages` / `list_threads` — Linear has no flat channel surface; comments are always issue-scoped. Each raises `chat.NotImplementedError` with a matching `feature` attribute.
- `open_dm` — Linear has no DM surface. Raises `chat.NotImplementedError(feature="openDM")`.
- `open_modal` — Linear has no modal surface. Raises `chat.NotImplementedError(feature="openModal")`.

## Entrypoints not yet ported

(None at initial release — 100% port is the goal. This section gets populated only if we defer something.)
51 changes: 44 additions & 7 deletions examples/e2e/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,23 @@

from __future__ import annotations

import asyncio
import contextlib
import os
import signal
import sys
from collections.abc import Iterable
from pathlib import Path
from typing import TYPE_CHECKING, Any

try:
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import Response
except ImportError:
uvicorn = None # type: ignore[assignment]
FastAPI = Request = Response = None # type: ignore[assignment,misc]

if TYPE_CHECKING:
from chat import Chat

Expand Down Expand Up @@ -80,22 +91,17 @@ def run_webhook_server(
- `extra_routes` lets a scenario mount additional handlers (e.g. health
checks, static URL-verify endpoints).
"""
try:
import uvicorn
from fastapi import FastAPI, Request
except ImportError:
if FastAPI is None or uvicorn is None:
sys.exit("[e2e] fastapi / uvicorn not installed. Run `uv sync --group e2e` first.")

app = FastAPI()
webhook_route = route or f"/api/webhooks/{adapter_name}"

@app.post(webhook_route)
async def handle(request: Request) -> Any: # type: ignore[no-redef]
async def handle(request: Request) -> Any: # type: ignore[no-redef,valid-type]
body = await request.body()
headers = dict(request.headers)
status, resp_headers, resp_body = await bot.handle_webhook(adapter_name, body, headers)
from fastapi.responses import Response

return Response(content=resp_body, status_code=status, headers=dict(resp_headers))

@app.get("/health")
Expand All @@ -113,3 +119,34 @@ async def health() -> dict[str, str]:
"provider's webhook config."
)
uvicorn.run(app, host="127.0.0.1", port=port, log_level="info")


def run_socket_client(bot: Chat, adapter_name: str) -> None:
"""Run an adapter that delivers events over a websocket (no HTTP server).

Used by Slack Socket Mode: calls :py:meth:`Chat.initialize`, which in turn
calls the adapter's ``initialize`` (which opens the socket), then blocks
until SIGINT / SIGTERM. On shutdown, disconnects the adapter cleanly.
"""

async def _run() -> None:
print(f"[e2e] starting {adapter_name} in socket mode (no HTTP server).")
await bot.initialize()
print(f"[e2e] {adapter_name} connected — waiting for events (Ctrl+C to stop).")

stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, stop.set)

try:
await stop.wait()
finally:
with contextlib.suppress(Exception):
adapter = bot.get_adapter(adapter_name)
if hasattr(adapter, "disconnect"):
await adapter.disconnect()
print(f"[e2e] {adapter_name} disconnected.")

asyncio.run(_run())
100 changes: 100 additions & 0 deletions examples/e2e/discord/echo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Discord E2E — echo scenario.

What this tests
---------------
- Bot receives `APPLICATION_COMMAND` interactions (slash commands) → replies.
- Bot receives forwarded Gateway `MESSAGE_CREATE` events (bot mention or DM)
→ echoes them.
- Discord request-signature verification round-trips (Ed25519 via
``DISCORD_PUBLIC_KEY``).

Required env vars
-----------------
- ``DISCORD_BOT_TOKEN`` bot token (Developer Portal → Bot → Reset Token)
- ``DISCORD_PUBLIC_KEY`` 64-hex-char public key (Developer Portal →
General Information → Public Key)
- ``DISCORD_APPLICATION_ID`` application (client) ID

Optional
- ``E2E_PORT`` defaults to 8000

Run
---
uv sync --group e2e
uv run python examples/e2e/discord/echo.py

In a second terminal:
ngrok http 8000
# paste the https://…ngrok.app URL + ``/api/webhooks/discord`` into
# Discord app → General Information → Interactions Endpoint URL.

Discord app setup (once)
------------------------
- Create an application at https://discord.com/developers/applications
- General Information → copy Application ID and Public Key
- Bot → create bot, Reset Token, copy token; enable ``Message Content``
Gateway Intent if you want to echo plain messages.
- OAuth2 → URL Generator: pick scope ``applications.commands`` (plus ``bot``
if you also want the bot presence); install the generated URL in your
server.
- Register at least one slash command (``/echo``) via the Discord REST API,
for example:
``curl -X POST \\
-H "Authorization: Bot $DISCORD_BOT_TOKEN" \\
-H "Content-Type: application/json" \\
-d '{"name":"echo","description":"Echo back what you typed",\\
"options":[{"name":"text","type":3,"description":"text","required":true}]}' \\
https://discord.com/api/v10/applications/$DISCORD_APPLICATION_ID/commands``
- Point Discord at your ngrok URL: General Information → Interactions
Endpoint URL. Discord will send a PING — the adapter auto-replies PONG.
"""

from __future__ import annotations

import os
import sys
from pathlib import Path
from typing import Any

# Make ``examples/e2e/_common.py`` importable when invoked as a script.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from _common import load_env, require_env, run_webhook_server
from chat import Chat
from chat_adapter_discord import create_discord_adapter
from chat_adapter_state_memory import create_memory_state

load_env()
require_env("DISCORD_BOT_TOKEN", "DISCORD_PUBLIC_KEY", "DISCORD_APPLICATION_ID")

bot = Chat(
user_name="chat-py-e2e",
adapters={"discord": create_discord_adapter()}, # picks up DISCORD_* env vars
state=create_memory_state(),
)


@bot.on_slash_command("/echo")
async def on_echo(event: dict[str, Any]) -> None:
channel = event["channel"]
text = event.get("text") or ""
print(f"[e2e] /echo from {event['user'].user_name}: {text!r}")
await channel.post(f"echo: {text}")


@bot.on_new_mention
async def on_mention(thread: Any, message: Any) -> None:
print(f"[e2e] mention from {message.author.user_id}: {message.text!r}")
await thread.subscribe()
await thread.post(f"hi <@{message.author.user_id}>, I'm subscribed now — reply and I'll echo.")


@bot.on_subscribed_message
async def on_thread_message(thread: Any, message: Any) -> None:
print(f"[e2e] subscribed-thread message: {message.text!r}")
await thread.post(f"echo: {message.text}")


if __name__ == "__main__":
port = int(os.environ.get("E2E_PORT", "8000"))
run_webhook_server(bot, "discord", port=port)
108 changes: 108 additions & 0 deletions examples/e2e/gchat/echo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Google Chat E2E — echo scenario.

What this tests
---------------
- Bot receives `MESSAGE` events on the HTTP webhook path → replies in the
same thread.
- (Optional) Bot receives `message.created` Workspace Events via Pub/Sub
push envelope → routed through the same handler.
- Bearer JWT verification round-trips via `GOOGLE_CHAT_PROJECT_NUMBER` /
`GOOGLE_CHAT_PUBSUB_AUDIENCE`.

Required env vars
-----------------
- `GOOGLE_CHAT_CREDENTIALS` Service-account JSON (single line / ``\\n`` escaped)
OR set `GOOGLE_CHAT_USE_ADC=true` if running on GCP.
- `GOOGLE_CHAT_PROJECT_NUMBER` Chat app's GCP project number (for webhook JWT audience).
- `GCHAT_BOT_NAME` Bot's user ID (``users/...``) — used to detect self-mentions.
When unset, the bot treats every leading-space argumentText
as a mention (Google Chat convention).

Optional
- `GOOGLE_CHAT_PUBSUB_AUDIENCE` Pub/Sub JWT audience (if you're pushing via Pub/Sub).
- `GOOGLE_CHAT_PUBSUB_TOPIC` ``projects/<p>/topics/<t>`` — when set, the adapter
auto-subscribes to Workspace Events on ADDED_TO_SPACE.
- `GCHAT_APP_URL` The public URL the Chat app will call — informational only.
- `E2E_PORT` defaults to 8000

Run
---
uv sync --group e2e
uv run python examples/e2e/gchat/echo.py

In a second terminal:
ngrok http 8000
# paste the https://…ngrok.app URL + `/api/webhooks/gchat` into
# Google Cloud console → your Chat app → Configuration → App URL

Google Chat app setup (once)
----------------------------
- Create a GCP project (or reuse one); enable the Google Chat API.
- Create a service account with the `Chat Bot` role and download its
JSON key. Paste the JSON as a single line into `.env` as
`GOOGLE_CHAT_CREDENTIALS='{...}'`.
- In https://console.cloud.google.com/apis/api/chat.googleapis.com/config
set the Chat app's App URL to `https://<ngrok>.ngrok.app/api/webhooks/gchat`.
- Add the bot to a space (``@Bot``-mention or invite) and send a message.

For the Pub/Sub flow (optional)
-------------------------------
- Create a Pub/Sub topic + push subscription that POSTs to
`/api/webhooks/gchat` (the same endpoint — this script auto-detects the
envelope shape).
- Set `GOOGLE_CHAT_PUBSUB_TOPIC` so ``ADDED_TO_SPACE`` triggers a
Workspace Events subscription automatically.
"""

from __future__ import annotations

import os
import sys
from pathlib import Path

# Make `examples/e2e/_common.py` importable when invoked as a script.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from _common import load_env, require_env, run_webhook_server
from chat import Chat
from chat_adapter_gchat import create_google_chat_adapter
from chat_adapter_state_memory import create_memory_state

load_env()

# Either service account JSON or ADC must be present.
if (
not os.environ.get("GOOGLE_CHAT_CREDENTIALS")
and os.environ.get("GOOGLE_CHAT_USE_ADC") != "true"
):
sys.exit(
"[e2e] need GOOGLE_CHAT_CREDENTIALS=<service-account-json> "
"or GOOGLE_CHAT_USE_ADC=true in .env"
)
require_env("GOOGLE_CHAT_PROJECT_NUMBER")

bot = Chat(
user_name=os.environ.get("GCHAT_BOT_NAME", "chat-py-e2e"),
adapters={"gchat": create_google_chat_adapter()}, # picks up GOOGLE_CHAT_* env vars
state=create_memory_state(),
)


@bot.on_new_mention
async def on_mention(thread, message):
print(f"[e2e] mention from {message.author.user_id}: {message.text!r}")
await thread.subscribe()
await thread.post(f"hi <{message.author.user_id}>, I'm subscribed now — reply and I'll echo.")


@bot.on_subscribed_message
async def on_thread_message(thread, message):
if message.author.is_me:
return
print(f"[e2e] subscribed-thread message: {message.text!r}")
await thread.post(f"echo: {message.text}")


if __name__ == "__main__":
port = int(os.environ.get("E2E_PORT", "8000"))
run_webhook_server(bot, "gchat", port=port)
Loading
Loading