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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog

## 0.0.1a3 (2026-04-06)

Initial alpha release.

- Core SDK: Chat orchestrator, Thread, Channel, Message, Cards, Modals, Emoji
- 8 adapters: Slack, Discord, Teams, Telegram, WhatsApp, Google Chat, GitHub, Linear
- 3 state backends: Memory, Redis, PostgreSQL
- 2,467 tests passing
- Security hardened: JWT verification, SSRF protection, timing-safe comparisons
55 changes: 55 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Contributing to chat-sdk-python

Thanks for your interest in contributing! This guide covers the essentials.

## Dev Environment Setup

```bash
# Clone and install (requires Python 3.11+ and uv)
git clone https://github.com/Chinchill-AI/chat-sdk-python.git
cd chat-sdk-python
uv sync --group dev
```

## Running Tests

```bash
uv run pytest tests/ # all tests
uv run pytest tests/ -x # stop on first failure
uv run pytest tests/unit/ # unit tests only
```

## Code Quality

```bash
uv run ruff check src/ tests/ # lint
uv run ruff format src/ tests/ # auto-format
```

All PRs must pass `ruff check` with zero errors.

## Adding a New Adapter

1. Create `src/chat_sdk/adapters/<platform>/` with at minimum:
- `adapter.py` -- the adapter class implementing the Adapter protocol
- `__init__.py` -- public exports and a `create_<platform>_adapter()` factory
2. Follow the patterns in existing adapters (Slack and Teams are good references).
3. Add an optional-dependency group in `pyproject.toml`.
4. Add tests under `tests/unit/adapters/<platform>/`.

## Pull Request Expectations

- **Tests required.** Every bugfix or feature needs at least one test.
- **Ruff clean.** `uv run ruff check src/ tests/` must pass with no errors.
- **Small, focused PRs** are easier to review than large ones.
- **Descriptive commit messages.** Explain *why*, not just *what*.

## Issues and PRs

- Check existing issues before opening a new one.
- Reference the issue number in your PR description (e.g., `Fixes #42`).
- For large changes, open an issue first to discuss the approach.

## License

By contributing you agree that your contributions will be licensed under the MIT License.
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@

Multi-platform async chat SDK for Python. Port of [Vercel Chat](https://github.com/vercel/chat).

> **Status: Alpha (0.0.1a1)** — API may change. Not yet tested in production.
> **Status: Alpha (0.0.1a3)** — API may change. Not yet tested in production.

## Why chat-sdk?

- **Write once, deploy to 8 platforms.** One handler runs on Slack, Discord, Teams, Telegram, WhatsApp, Google Chat, GitHub, and Linear.
- **Built-in concurrency primitives.** Deduplication, thread locking, and message queuing are handled for you.
- **Cross-platform cards.** Author a `Card` once and it renders as Block Kit (Slack), Adaptive Cards (Teams), embeds (Discord), and more.
- **Not a replacement for platform SDKs.** chat-sdk is built *on top of* them. You can always drop down to the native SDK when you need to.

## Install

Expand Down Expand Up @@ -54,6 +61,17 @@ async def handle_mention(thread, message):
| Redis | `chat-sdk[redis]` |
| PostgreSQL | `chat-sdk[postgres]` |

## Compared to Alternatives

| Feature | chat-sdk | Raw platform SDKs | BotFramework SDK |
|---------|----------|--------------------|------------------|
| Multi-platform from one codebase | 8 platforms | 1 per SDK | Teams + limited |
| Async-native (Python 3.11+) | Yes | Varies | No |
| Cross-platform cards | Card model | Platform-specific | Adaptive Cards only |
| Thread locking / dedup | Built-in | DIY | DIY |
| State abstraction (mem/redis/pg) | Built-in | DIY | DIY |
| Drop down to native SDK | Yes | N/A | Partially |

## Development

```bash
Expand Down
16 changes: 14 additions & 2 deletions src/chat_sdk/adapters/slack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ def __init__(self, config: SlackAdapterConfig | None = None) -> None:
# Channel external/shared cache
self._external_channels: set[str] = set()

# Cache of AsyncWebClient instances keyed by bot token
self._client_cache: dict[str, Any] = {}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _client_cache dictionary stores AsyncWebClient instances indefinitely. In multi-tenant applications with a large number of workspaces or frequent token rotations, this could lead to significant memory consumption over time. Consider using a cache with an eviction policy (such as an LRU cache) or limiting the maximum number of cached clients to prevent unbounded growth.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in PR #8 (LRU-bounded OrderedDict cache with max=100) and further improved in PR #13 by making client_cache_max configurable via SlackAdapterConfig.


# Multi-workspace OAuth fields
self._client_id: str | None = config.client_id or (os.environ.get("SLACK_CLIENT_ID") if zero_config else None)
self._client_secret: str | None = config.client_secret or (
Expand Down Expand Up @@ -258,11 +261,20 @@ def _get_token(self) -> str:
def _get_client(self, token: str | None = None) -> Any:
"""Return an ``AsyncWebClient`` for the given (or current) token.

The import is deferred so that ``slack_sdk`` is only required at call-time.
Clients are cached by token so we avoid creating a new instance on
every request. The import is deferred so that ``slack_sdk`` is only
required at call-time.
"""
resolved_token = token or self._get_token()
cached = self._client_cache.get(resolved_token)
if cached is not None:
return cached

from slack_sdk.web.async_client import AsyncWebClient

return AsyncWebClient(token=token or self._get_token())
client = AsyncWebClient(token=resolved_token)
self._client_cache[resolved_token] = client
return client

# ------------------------------------------------------------------
# Initialization
Expand Down
33 changes: 27 additions & 6 deletions src/chat_sdk/adapters/teams/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ def __init__(self, config: TeamsAdapterConfig | None = None) -> None:
"Use app_password (client secret) or federated (workload identity) authentication instead.",
)

if not self._app_id:
self._logger.warn(
"Teams app_id is empty — webhook verification will reject all incoming requests. "
"Set TEAMS_APP_ID or pass app_id in config."
)

self._bot_user_id: str | None = self._app_id or None
self._access_token: str | None = None
self._token_expiry: float = 0
Expand Down Expand Up @@ -207,10 +213,13 @@ async def handle_webhook(
self._logger.debug("Teams webhook raw body", {"body": body[:500] if body else ""})

# ---- JWT verification (Bot Framework tokens) ----
if self._app_id:
auth_result = await self._verify_bot_framework_token(request)
if auth_result is not None:
return auth_result
if not self._app_id:
self._logger.warn("Rejecting Teams webhook: app_id is not configured, cannot verify JWT")
return self._make_response("Unauthorized – Teams app_id not configured", 401)

auth_result = await self._verify_bot_framework_token(request)
if auth_result is not None:
return auth_result

try:
activity: dict[str, Any] = json.loads(body)
Expand Down Expand Up @@ -259,10 +268,19 @@ async def _cache_user_context(self, activity: dict[str, Any]) -> None:
ttl = CACHE_TTL_MS
state = self._chat.get_state()

# Cache serviceUrl
# Cache serviceUrl (validate against SSRF allow-list first)
service_url = activity.get("serviceUrl")
if service_url and state:
await state.set(f"teams:serviceUrl:{user_id}", service_url, ttl)
try:
_validate_service_url(service_url)
except ValidationError:
self._logger.warn(
"Refusing to cache disallowed serviceUrl",
{"serviceUrl": service_url},
)
service_url = None
if service_url:
await state.set(f"teams:serviceUrl:{user_id}", service_url, ttl)

# Cache tenantId
channel_data = activity.get("channelData", {})
Expand Down Expand Up @@ -1112,6 +1130,8 @@ async def open_dm(self, user_id: str) -> str:
if not service_url:
service_url = "https://smba.trafficmanager.net/teams/"

_validate_service_url(service_url)

import aiohttp

token = await self._get_access_token()
Expand Down Expand Up @@ -1693,6 +1713,7 @@ async def _verify_bot_framework_token(self, request: Any) -> Any | None:
signing_key.key,
algorithms=["RS256"],
audience=self._app_id,
issuer="https://api.botframework.com",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The JWT verification process relies on pyjwt's PyJWKClient.get_signing_key_from_jwt, which is a synchronous method that performs network I/O to fetch signing keys. This blocks the event loop, which can impact performance under high concurrency. Since this PR aims to improve performance, consider offloading this blocking call to a thread using asyncio.to_thread (Python 3.9+) or loop.run_in_executor.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in PR #13 -- get_signing_key_from_jwt is now wrapped in asyncio.to_thread() to avoid blocking the event loop during the synchronous JWKS HTTP fetch.

)
self._logger.debug(
"Teams JWT verified",
Expand Down
Loading