Skip to content

Commit 3c727ec

Browse files
Merge pull request #11 from Chinchill-AI/docs/comprehensive-documentation
docs: Add comprehensive project documentation
2 parents 8fb7547 + a94deec commit 3c727ec

6 files changed

Lines changed: 1233 additions & 0 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ async def handle_mention(thread, message):
7272
| State abstraction (mem/redis/pg) | Built-in | DIY | DIY |
7373
| Drop down to native SDK | Yes | N/A | Partially |
7474

75+
## Documentation
76+
77+
| Document | Description |
78+
|----------|-------------|
79+
| [Architecture](docs/ARCHITECTURE.md) | Module dependency graph, adapter protocol, card system, concurrency strategies, state backends, markdown pipeline, streaming pipeline |
80+
| [Upstream Sync](docs/UPSTREAM_SYNC.md) | How to keep the Python port in sync with the Vercel Chat TS SDK, translation patterns, known footguns |
81+
| [Security](docs/SECURITY.md) | Webhook verification per platform, SSRF protections, crypto details, known limitations, production audit checklist |
82+
| [Testing](docs/TESTING.md) | Test categories, how to run tests, how to add adapter tests, coverage gaps, dispatch key validation |
83+
| [Design Decisions](docs/DECISIONS.md) | Rationale for key architectural choices (hand-rolled parser, PascalCase builders, global singleton, zero deps) |
84+
| [Contributing](CONTRIBUTING.md) | Dev setup, code quality, PR expectations |
85+
| [Changelog](CHANGELOG.md) | Release history |
86+
7587
## Development
7688

7789
```bash

docs/ARCHITECTURE.md

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
# Architecture
2+
3+
Internal architecture guide for maintainers and contributors of `chat-sdk-python`.
4+
5+
## Module Dependency Graph
6+
7+
```
8+
Chat (orchestrator)
9+
|
10+
+-- ThreadImpl (message posting, streaming, state, subscriptions)
11+
| |
12+
| +-- ChannelImpl (channel-level posting, thread enumeration, metadata)
13+
| |
14+
| +-- Adapter (platform protocol -- Slack, Discord, Teams, etc.)
15+
| |
16+
| +-- BaseAdapter (default implementations for optional methods)
17+
| +-- FormatConverter (markdown <-> platform format)
18+
| +-- Cards renderer (CardElement -> platform-specific payload)
19+
|
20+
+-- StateAdapter (subscriptions, locking, cache, queues)
21+
| |
22+
| +-- MemoryStateAdapter (dev/testing)
23+
| +-- RedisStateAdapter (production, Lua scripts for atomicity)
24+
| +-- PostgresStateAdapter (production, row-level locking)
25+
|
26+
+-- Types (Adapter protocol, Message, Author, events, config dataclasses)
27+
+-- Errors (ChatError, LockError, ChatNotImplementedError, RateLimitError)
28+
```
29+
30+
### Import Rules
31+
32+
- `types.py` imports only from `cards.py`, `errors.py`, and `logger.py`.
33+
- `thread.py` imports from `types.py` and `errors.py`. It defines the Chat singleton access point (`set_chat_singleton`, `get_chat_singleton`) to avoid circular imports with `chat.py`.
34+
- `channel.py` imports from `thread.py` (for singleton access and helpers) and `types.py`.
35+
- `chat.py` imports from `thread.py`, `channel.py`, and `types.py`. It is the only module that creates `ThreadImpl` and `ChannelImpl` instances in production.
36+
- Adapters import from `types.py`, `shared/`, `cards.py`, and their own sub-packages. They never import `chat.py` directly; they receive a `ChatInstance` reference during `initialize()`.
37+
38+
### Circular Import Avoidance
39+
40+
The `Thread -> Chat` dependency is broken by the singleton pattern in `thread.py`. The `Chat` class calls `set_chat_singleton(self)` during registration, and `ThreadImpl`/`ChannelImpl` call `get_chat_singleton()` for lazy adapter resolution during deserialization. This mirrors the `chat-singleton.ts` pattern from the TS SDK.
41+
42+
## How Adapters Work
43+
44+
### The Adapter Protocol
45+
46+
Defined in `types.py` as a `Protocol` class with `@runtime_checkable`:
47+
48+
```python
49+
@runtime_checkable
50+
class Adapter(Protocol):
51+
@property
52+
def name(self) -> str: ...
53+
@property
54+
def user_name(self) -> str: ...
55+
@property
56+
def bot_user_id(self) -> str | None: ...
57+
58+
async def post_message(self, thread_id: str, message: AdapterPostableMessage) -> RawMessage: ...
59+
async def edit_message(self, thread_id: str, message_id: str, message: AdapterPostableMessage) -> RawMessage: ...
60+
async def delete_message(self, thread_id: str, message_id: str) -> None: ...
61+
async def fetch_messages(self, thread_id: str, options: FetchOptions | None = None) -> FetchResult: ...
62+
async def handle_webhook(self, request: Any, options: WebhookOptions | None = None) -> Any: ...
63+
async def initialize(self, chat: ChatInstance) -> None: ...
64+
# ... plus ~10 more required methods
65+
```
66+
67+
Required methods cover the complete lifecycle: webhook handling, message CRUD, reactions, typing indicators, thread ID encoding/decoding, and format rendering.
68+
69+
### BaseAdapter
70+
71+
`BaseAdapter` in `types.py` provides default implementations for **optional** methods that raise `ChatNotImplementedError`:
72+
73+
- `stream()` -- native streaming (only Slack implements this currently)
74+
- `open_dm()` -- DM channel creation
75+
- `post_ephemeral()` -- ephemeral messages
76+
- `schedule_message()` -- future delivery
77+
- `open_modal()` -- modal dialogs
78+
- `fetch_channel_info()` -- channel metadata
79+
- `list_threads()` -- thread enumeration
80+
81+
Concrete adapters inherit from `BaseAdapter` and override what they support.
82+
83+
### Format Converters
84+
85+
Each adapter has a `FormatConverter` that extends `BaseFormatConverter`:
86+
87+
```
88+
Markdown string
89+
|
90+
v parse_markdown()
91+
mdast AST (dict) <-- canonical internal representation
92+
|
93+
v from_ast()
94+
Platform format string (mrkdwn, HTML, Adaptive Card text, etc.)
95+
```
96+
97+
The `BaseFormatConverter` provides:
98+
- `from_markdown(md) -> str` -- parse then render
99+
- `to_markdown(platform_text) -> str` -- parse then stringify
100+
- `render_postable(message)` -- handles the full `AdapterPostableMessage` union (str, PostableRaw, PostableMarkdown, PostableAst, PostableCard, CardElement)
101+
- Template helpers: `_render_list()`, `_default_node_to_text()`
102+
103+
Each adapter subclass implements `from_ast(ast)` and `to_ast(platform_text)` for its platform's native format:
104+
105+
| Adapter | Format | Converter |
106+
|---------|--------|-----------|
107+
| Slack | mrkdwn (Slack markdown) | `SlackFormatConverter` |
108+
| Discord | Discord markdown | `DiscordFormatConverter` |
109+
| Teams | HTML subset | `TeamsFormatConverter` |
110+
| Telegram | HTML (MarkdownV2 considered too fragile) | `TelegramFormatConverter` |
111+
| WhatsApp | WhatsApp formatting (*bold*, _italic_) | `WhatsAppFormatConverter` |
112+
| Google Chat | Google Chat markup | `GoogleChatFormatConverter` |
113+
| GitHub | Standard GFM | `GitHubFormatConverter` |
114+
| Linear | Standard markdown | `LinearFormatConverter` |
115+
116+
### Webhook Flow
117+
118+
```
119+
HTTP POST from platform
120+
|
121+
v
122+
chat.webhooks["slack"](request)
123+
|
124+
v
125+
Chat._handle_webhook(adapter_name, request, options)
126+
|
127+
v
128+
adapter.handle_webhook(request, options)
129+
| (adapter verifies signature, parses event, normalizes to typed event)
130+
v
131+
chat.process_message(adapter, thread_id, message)
132+
or chat.process_action(event)
133+
or chat.process_reaction(event)
134+
or chat.process_slash_command(event)
135+
or chat.process_modal_submit(event)
136+
|
137+
v
138+
asyncio.create_task(handler coroutine)
139+
```
140+
141+
## How the Card System Works
142+
143+
Cards provide cross-platform rich messaging. The card model is defined as TypedDicts in `cards.py`:
144+
145+
```
146+
CardElement (root)
147+
+-- title, subtitle, image_url
148+
+-- children: list[CardChild]
149+
|
150+
+-- TextElement -> Slack: section block, Teams: TextBlock
151+
+-- ImageElement -> Slack: image block, Teams: Image
152+
+-- DividerElement -> Slack: divider block, Teams: ---
153+
+-- ActionsElement -> Slack: actions block, Teams: ActionSet
154+
| +-- ButtonElement -> Slack: button, Teams: Action.Submit
155+
| +-- LinkButtonElement -> Slack: button with url, Teams: Action.OpenUrl
156+
+-- FieldsElement -> Slack: section with fields, Teams: FactSet
157+
+-- TableElement -> Slack: ASCII table in code block, Teams: Table
158+
+-- SectionElement -> Groups children
159+
+-- LinkElement -> Inline hyperlink
160+
```
161+
162+
### PascalCase Builders
163+
164+
Builder functions use PascalCase (`Card()`, `Button()`, `Text()`) to match the TS SDK. snake_case aliases are also provided (`card()`, `button()`, `text_element()`).
165+
166+
### Platform Rendering
167+
168+
Each adapter has a `cards.py` module with a renderer:
169+
170+
- **Slack**: `card_to_block_kit()` -- produces Block Kit JSON
171+
- **Discord**: `card_to_discord_embed()` -- produces Discord embed dicts
172+
- **Teams**: `card_to_adaptive_card()` -- produces Adaptive Card JSON
173+
- **Telegram**: `card_to_telegram_inline_keyboard()` -- produces inline keyboard markup
174+
- **WhatsApp**: `card_to_whatsapp_interactive()` -- produces WhatsApp interactive message
175+
- **Google Chat**: `card_to_gchat_card()` -- produces Google Chat card v2
176+
- **GitHub**: Falls back to markdown text
177+
- **Linear**: Falls back to markdown text
178+
179+
Platforms that cannot render cards natively get `card_to_fallback_text()`, which produces a plain-text representation with `**title**`, field labels, ASCII tables, and `[alt](url)` for images.
180+
181+
## How Concurrency Works
182+
183+
The `Chat` class manages four concurrency strategies, configured via `ChatConfig.concurrency`:
184+
185+
### Drop (default)
186+
187+
```
188+
Message arrives -> acquire_lock(thread_id, 30s TTL)
189+
Lock acquired?
190+
Yes -> dispatch to handlers -> release lock
191+
No -> raise LockError (message dropped)
192+
```
193+
194+
The simplest strategy. If another handler is already processing the same thread, the new message is dropped. Suitable for bots where only the latest context matters.
195+
196+
### Queue
197+
198+
```
199+
Message arrives -> acquire_lock
200+
Lock acquired?
201+
Yes -> dispatch to handlers -> drain_queue() -> release lock
202+
No -> enqueue(message, max_size) -> return
203+
(overflow behavior: drop-oldest or drop-newest)
204+
205+
drain_queue():
206+
while queue not empty:
207+
dequeue all entries
208+
skip expired entries
209+
dispatch latest entry (skip intermediate messages)
210+
extend lock
211+
```
212+
213+
Messages that arrive while the lock is held are queued. After the current handler completes, the queue is drained. Only the latest queued message is actually processed; intermediate messages are passed as `context.skipped`.
214+
215+
### Debounce
216+
217+
```
218+
Message arrives -> acquire_lock
219+
Lock acquired?
220+
Yes -> enqueue message -> debounce_loop()
221+
No -> enqueue message (max_size=1, replaces previous)
222+
223+
debounce_loop() (max 20 iterations):
224+
sleep(debounce_ms)
225+
extend lock
226+
dequeue entry
227+
if queue empty -> break (no new messages arrived, process this one)
228+
if queue has more -> entry superseded, loop again
229+
dispatch final message
230+
```
231+
232+
Waits for the user to stop typing. Each new message resets the debounce timer. Only the final message after a quiet period is processed.
233+
234+
### Concurrent
235+
236+
```
237+
Message arrives -> dispatch to handlers (no lock, no queue)
238+
```
239+
240+
No locking at all. Every message is processed immediately. Use when handlers are idempotent and fast.
241+
242+
### Lock Scope
243+
244+
Locks can be scoped to `thread` (default) or `channel`. The scope is determined by:
245+
1. `ChatConfig.lock_scope` (static or callable)
246+
2. `adapter.lock_scope` property (adapter default)
247+
248+
Channel-scoped locking serializes all messages in a channel, which is useful for bots that maintain channel-level state.
249+
250+
## How State Backends Work
251+
252+
### StateAdapter Protocol
253+
254+
The `StateAdapter` protocol in `types.py` defines 18 async methods across 6 categories:
255+
256+
| Category | Methods |
257+
|----------|---------|
258+
| Subscriptions | `subscribe`, `unsubscribe`, `is_subscribed` |
259+
| Locking | `acquire_lock`, `release_lock`, `extend_lock`, `force_release_lock` |
260+
| Key/Value Cache | `get`, `set`, `set_if_not_exists`, `delete` |
261+
| Lists | `append_to_list`, `get_list` |
262+
| Queues | `enqueue`, `dequeue`, `queue_depth` |
263+
| Lifecycle | `connect`, `disconnect` |
264+
265+
### Lock Semantics
266+
267+
- `acquire_lock(thread_id, ttl_ms)` returns a `Lock` object with a unique token (CSPRNG-generated), or `None` if already held.
268+
- `release_lock(lock)` releases only if the token matches (prevents releasing someone else's lock).
269+
- `extend_lock(lock, ttl_ms)` extends the TTL, returning `False` if the lock was lost.
270+
- `force_release_lock(thread_id)` unconditionally releases (admin escape hatch).
271+
272+
Lock tokens are generated using `secrets.token_hex(16)` for cryptographic randomness, prefixed with the backend name for debuggability (`mem_`, `redis_`, `pg_`).
273+
274+
### Backend Implementations
275+
276+
| Backend | Lock mechanism | Atomicity | Production-ready |
277+
|---------|---------------|-----------|-----------------|
278+
| Memory | In-process dict with expiry | Single-process only | No (dev/test) |
279+
| Redis | `SET NX PX` + Lua scripts | Atomic via Lua | Yes |
280+
| PostgreSQL | `INSERT ... ON CONFLICT` + row locks | Atomic via transactions | Yes |
281+
282+
Redis uses Lua scripts for `release_lock` and `extend_lock` to ensure token-check-and-delete is atomic. PostgreSQL uses `SELECT FOR UPDATE SKIP LOCKED` for non-blocking lock acquisition.
283+
284+
## The Markdown Pipeline
285+
286+
```
287+
Input markdown string
288+
|
289+
v
290+
parse_markdown(text) -> Root (mdast-compatible dict AST)
291+
|
292+
v
293+
walk_ast(root, visitor) -> Root (transform nodes)
294+
|
295+
v
296+
stringify_markdown(ast) -> str (back to markdown)
297+
or
298+
from_ast(ast) -> str (to platform format)
299+
```
300+
301+
### Parser Details (`shared/markdown_parser.py`)
302+
303+
The parser is hand-rolled (not based on any library) and produces [mdast](https://github.com/syntax-tree/mdast)-compatible dict nodes.
304+
305+
**Block-level parsing** (line-by-line):
306+
- Fenced code blocks (``` and ~~~)
307+
- Thematic breaks (`---`, `***`, `___`)
308+
- Headings (`# ` through `###### `)
309+
- GFM tables (pipe-delimited with alignment row)
310+
- Blockquotes (`> `)
311+
- Ordered lists (`1. `, `2) `)
312+
- Unordered lists (`- `, `* `, `+ `)
313+
- Paragraphs (everything else)
314+
315+
**Inline parsing** (regex-based, priority-ordered):
316+
- Images: `![alt](url "title")`
317+
- Links: `[text](url "title")`
318+
- Inline code: `` `code` ``
319+
- Bold: `**text**`, `__text__`
320+
- Strikethrough: `~~text~~`
321+
- Emphasis: `*text*`, `_text_`
322+
323+
The inline parser uses an iterative approach for suffix text (to avoid stack overflow on long strings) while recursing into match content (bounded by match length).
324+
325+
### AST Utilities
326+
327+
- `walk_ast(node, visitor)` -- deep-copy + transform visitor pattern
328+
- `ast_to_plain_text(node)` -- strip all formatting
329+
- `table_to_ascii(node)` -- render mdast table as padded ASCII
330+
- `stringify_markdown(ast)` -- AST back to markdown string
331+
332+
## The Streaming Pipeline
333+
334+
```
335+
LLM stream (AsyncIterable)
336+
|
337+
v
338+
from_full_stream() -- normalize text-delta events, inject step separators
339+
|
340+
v
341+
StreamingMarkdownRenderer -- buffer incomplete constructs
342+
.push(chunk) -- append text
343+
.render() -- get safe-to-display markdown (for edit_message)
344+
.get_committable_text() -- get safe-for-append text (for native streaming)
345+
.finish() -- flush everything
346+
|
347+
v
348+
adapter.stream() or fallback (post + edit loop)
349+
```
350+
351+
### StreamingMarkdownRenderer
352+
353+
The renderer solves the problem of rendering incomplete markdown during LLM streaming. Key behaviors:
354+
355+
1. **Table buffering**: Lines matching `|...|` are held back until a separator row (`|---|---|`) confirms them as a table. Without this, pipe characters in regular text would be misinterpreted.
356+
357+
2. **Inline marker repair** (`_remend()`): Closes unclosed `**`, `*`, `~~`, `` ` ``, and `[` constructs by appending matching closers. This prevents broken formatting during mid-token streaming.
358+
359+
3. **Code fence tracking**: Uses incremental O(1) fence toggle counting. When inside a code fence, table buffering and inline repair are skipped.
360+
361+
4. **Table wrapping** (`_wrap_tables_for_append()`): For append-only streaming (Slack's native streaming API), confirmed tables are wrapped in code fences so pipe characters render as literal text.
362+
363+
5. **Clean prefix detection** (`_find_clean_prefix()`): Finds the longest prefix where all inline markers are balanced, used by `get_committable_text()`.
364+
365+
### Fallback Streaming (post + edit)
366+
367+
When an adapter does not support native streaming, `ThreadImpl._fallback_stream()` uses a post-then-edit pattern:
368+
369+
1. Post an initial placeholder message (configurable, default `"..."`)
370+
2. Start a background `_edit_loop()` that updates the message at intervals (default 500ms)
371+
3. Accumulate text from the stream
372+
4. After stream ends, stop the edit loop and send a final edit with the complete text
373+
374+
The edit loop uses `asyncio.create_task()` for the background timer and checks a `stopped` flag to terminate cleanly.

0 commit comments

Comments
 (0)