turnstack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- turnstack/__init__.py +65 -0
- turnstack/engine.py +359 -0
- turnstack/exceptions.py +19 -0
- turnstack/handlers/__init__.py +19 -0
- turnstack/handlers/action.py +103 -0
- turnstack/handlers/base.py +273 -0
- turnstack/handlers/confirm.py +70 -0
- turnstack/handlers/input.py +652 -0
- turnstack/handlers/list_handler.py +339 -0
- turnstack/handlers/media_handler.py +73 -0
- turnstack/handlers/menu.py +188 -0
- turnstack/handlers/render_helpers.py +28 -0
- turnstack/handlers/router.py +104 -0
- turnstack/message.py +39 -0
- turnstack/nodes.py +709 -0
- turnstack/reply.py +67 -0
- turnstack/session.py +130 -0
- turnstack/stores/__init__.py +3 -0
- turnstack/stores/memory.py +60 -0
- turnstack/tree.py +151 -0
- turnstack-0.1.0.dist-info/METADATA +1844 -0
- turnstack-0.1.0.dist-info/RECORD +24 -0
- turnstack-0.1.0.dist-info/WHEEL +5 -0
- turnstack-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1844 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: turnstack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: WhatsApp bot engine with node-based flows and interactive replies
|
|
5
|
+
Author-email: IdrisFallout <dev@waithakasam.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/IdrisFallout/turnstack
|
|
8
|
+
Project-URL: Repository, https://github.com/IdrisFallout/turnstack
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/IdrisFallout/turnstack/issues
|
|
10
|
+
Keywords: whatsapp,bot,chatbot,flow,engine
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# TurnStack — Developer Documentation
|
|
20
|
+
|
|
21
|
+
> **The WhatsApp conversation engine that gets out of your way.**
|
|
22
|
+
> You define the flow. TurnStack drives it.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Table of Contents
|
|
27
|
+
|
|
28
|
+
1. [What TurnStack Is (and Isn't)](#1-what-turnstack-is-and-isnt)
|
|
29
|
+
2. [Core Concepts](#2-core-concepts)
|
|
30
|
+
3. [Quick Start](#3-quick-start)
|
|
31
|
+
4. [The Flow Tree](#4-the-flow-tree)
|
|
32
|
+
5. [Building Blocks — Node Reference](#5-building-blocks--node-reference)
|
|
33
|
+
- [Menu](#51-menu)
|
|
34
|
+
- [Input](#52-input)
|
|
35
|
+
- [Confirm](#53-confirm)
|
|
36
|
+
- [Action](#54-action)
|
|
37
|
+
- [Router](#55-router)
|
|
38
|
+
- [ListNode](#56-listnode)
|
|
39
|
+
- [MediaReply](#57-mediareply)
|
|
40
|
+
6. [Field Types (inside Input)](#6-field-types-inside-input)
|
|
41
|
+
- [Field / TextField](#61-field--textfield)
|
|
42
|
+
- [MenuField](#62-menufield)
|
|
43
|
+
- [ButtonsField](#63-buttonsfield)
|
|
44
|
+
- [ImageField](#64-imagefield)
|
|
45
|
+
- [DocumentField](#65-documentfield)
|
|
46
|
+
- [LocationField](#66-locationfield)
|
|
47
|
+
- [BranchField](#67-branchfield)
|
|
48
|
+
7. [The Engine](#7-the-engine)
|
|
49
|
+
- [Instantiation](#71-instantiation)
|
|
50
|
+
- [process()](#72-process)
|
|
51
|
+
- [IncomingMessage](#73-incomingmessage)
|
|
52
|
+
- [Reply](#74-reply)
|
|
53
|
+
8. [Session & State](#8-session--state)
|
|
54
|
+
- [Session object](#81-session-object)
|
|
55
|
+
- [session.collected](#82-sessioncollected)
|
|
56
|
+
- [session.context](#83-sessioncontext)
|
|
57
|
+
- [session.pagination](#84-sessionpagination)
|
|
58
|
+
9. [Session Stores](#9-session-stores)
|
|
59
|
+
- [InMemorySessionStore](#91-inmemorysessionstore)
|
|
60
|
+
- [Custom stores](#92-custom-stores)
|
|
61
|
+
10. [Navigation — Built-in Commands](#10-navigation--built-in-commands)
|
|
62
|
+
11. [Sending Replies — Adapter Pattern](#11-sending-replies--adapter-pattern)
|
|
63
|
+
- [Reading reply fields](#111-reading-reply-fields)
|
|
64
|
+
- [Sending via REST](#112-sending-via-rest)
|
|
65
|
+
- [Sending via pywa / any library](#113-sending-via-pywa--any-library)
|
|
66
|
+
12. [Wiring to a Webhook](#12-wiring-to-a-webhook)
|
|
67
|
+
13. [Validation & Transformation](#13-validation--transformation)
|
|
68
|
+
14. [Dynamic Content](#14-dynamic-content)
|
|
69
|
+
15. [Conditional Fields — BranchField](#15-conditional-fields--branchfield)
|
|
70
|
+
16. [Pagination — Automatic Behaviour](#16-pagination--automatic-behaviour)
|
|
71
|
+
17. [Custom Node Handlers](#17-custom-node-handlers)
|
|
72
|
+
18. [Error Handling](#18-error-handling)
|
|
73
|
+
19. [Debug Utilities](#19-debug-utilities)
|
|
74
|
+
20. [Complete Example — Customer Support Bot](#20-complete-example--customer-support-bot)
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## 1. What TurnStack Is (and Isn't)
|
|
79
|
+
|
|
80
|
+
**TurnStack is a conversation-flow engine.** You give it a tree of nodes. It receives raw WhatsApp messages, drives the user through the tree, manages all session state, and hands you back structured `Reply` objects ready to send.
|
|
81
|
+
|
|
82
|
+
**What TurnStack handles for you:**
|
|
83
|
+
|
|
84
|
+
- Session lifecycle (create, persist, expire, reset)
|
|
85
|
+
- Navigation state machine (current node, history stack, back/home/exit)
|
|
86
|
+
- Multi-step form collection with per-field validation
|
|
87
|
+
- Menu and list pagination (automatic, configurable)
|
|
88
|
+
- Interactive vs plain-text fallback rendering hints
|
|
89
|
+
- Unsupported message types (stickers, audio, reactions) — polite reply, no state change
|
|
90
|
+
- Media file delivery followed by the next node — both sent automatically
|
|
91
|
+
- Global navigation commands (`back`, `home`, `exit`) intercepted before dispatch
|
|
92
|
+
|
|
93
|
+
**What TurnStack does NOT do:**
|
|
94
|
+
|
|
95
|
+
- Send messages — that's your adapter (REST, pywa, Twilio, or anything else)
|
|
96
|
+
- Store sessions to a database — plug in your own `SessionStore`
|
|
97
|
+
- Parse raw WhatsApp webhook payloads — your webhook handler does that (it's a one-time ~40-line setup)
|
|
98
|
+
- Lock you into any web framework — FastAPI, Flask, Django, Lambda, raw asyncio — all fine
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 2. Core Concepts
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
Raw WA payload
|
|
106
|
+
│
|
|
107
|
+
▼
|
|
108
|
+
Your webhook ──► builds IncomingMessage
|
|
109
|
+
│
|
|
110
|
+
▼
|
|
111
|
+
engine.process(incoming)
|
|
112
|
+
│
|
|
113
|
+
▼
|
|
114
|
+
List[Reply] ──► your adapter sends each reply
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**FlowTree** — a dictionary of named nodes you build once at startup.
|
|
118
|
+
|
|
119
|
+
**Node** — a single step in the conversation. Each node has a type (menu, input, action, etc.) and a `next` key pointing to the next node.
|
|
120
|
+
|
|
121
|
+
**Session** — per-user state the engine manages. Contains `current_node`, collected form data, navigation history, and arbitrary context your code can read/write.
|
|
122
|
+
|
|
123
|
+
**IncomingMessage** — a normalised message object you build from the raw WA payload and pass to the engine.
|
|
124
|
+
|
|
125
|
+
**Reply** — a structured response object the engine returns. You read `reply.node_type` and `reply.options` to decide how to send it (interactive list, buttons, plain text, document, etc.).
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## 3. Quick Start
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from turnstack import BotEngine, FlowTree, IncomingMessage
|
|
133
|
+
from turnstack.nodes import Menu, Input, Action, Option, Field
|
|
134
|
+
|
|
135
|
+
# 1. Build the tree
|
|
136
|
+
tree = FlowTree(entry="welcome")
|
|
137
|
+
|
|
138
|
+
tree.add("welcome", Menu(
|
|
139
|
+
text="👋 Welcome! What would you like to do?",
|
|
140
|
+
options=[
|
|
141
|
+
Option("📝 Book appointment", next="book_form"),
|
|
142
|
+
Option("ℹ️ About us", next="about"),
|
|
143
|
+
],
|
|
144
|
+
))
|
|
145
|
+
|
|
146
|
+
tree.add("book_form", Input(
|
|
147
|
+
title="Booking",
|
|
148
|
+
fields=[
|
|
149
|
+
Field("name", "What is your full name?"),
|
|
150
|
+
Field("date", "What date works for you? (YYYY-MM-DD)"),
|
|
151
|
+
],
|
|
152
|
+
next="confirm_booking",
|
|
153
|
+
))
|
|
154
|
+
|
|
155
|
+
tree.add("confirm_booking", Action(
|
|
156
|
+
fn=lambda session, collected: f"✅ Booking confirmed for {collected['name']} on {collected['date']}!",
|
|
157
|
+
next="welcome",
|
|
158
|
+
))
|
|
159
|
+
|
|
160
|
+
tree.add("about", Action(
|
|
161
|
+
fn=lambda s, c: "We are an example company. Reply anything to go back.",
|
|
162
|
+
next="welcome",
|
|
163
|
+
))
|
|
164
|
+
|
|
165
|
+
# 2. Create the engine
|
|
166
|
+
engine = BotEngine(tree=tree)
|
|
167
|
+
|
|
168
|
+
# 3. In your webhook, normalise the payload and call process()
|
|
169
|
+
async def handle_message(user_id: str, text: str):
|
|
170
|
+
incoming = IncomingMessage(user_id=user_id, type="text", text=text)
|
|
171
|
+
replies = await engine.process(incoming)
|
|
172
|
+
for reply in replies:
|
|
173
|
+
print(reply.body) # send this via your WhatsApp provider
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## 4. The Flow Tree
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
from turnstack import FlowTree
|
|
182
|
+
|
|
183
|
+
tree = FlowTree(entry="welcome")
|
|
184
|
+
tree.add("welcome", Menu(...))
|
|
185
|
+
tree.add("register", Input(...))
|
|
186
|
+
tree.add("done", Action(...))
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`FlowTree(entry="<node_key>")` — the `entry` key is where all new sessions start.
|
|
190
|
+
|
|
191
|
+
`tree.add(key, node)` — register a node. The key is a plain string; any node type is valid.
|
|
192
|
+
|
|
193
|
+
`tree.validate()` — called automatically when `BotEngine` starts. Raises if any `next` reference points to a missing node, or if no entry node is defined.
|
|
194
|
+
|
|
195
|
+
**Special destination key: `"__end__"`**
|
|
196
|
+
|
|
197
|
+
Use `next="__end__"` on any node to cleanly terminate the session. The engine sends the final message and the session is marked closed. The next message from the user starts a fresh session from the entry node.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
tree.add("goodbye", Action(
|
|
201
|
+
fn=lambda s, c: "👋 Thanks for using our service. Goodbye!",
|
|
202
|
+
next="__end__",
|
|
203
|
+
))
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 5. Building Blocks — Node Reference
|
|
209
|
+
|
|
210
|
+
### 5.1 Menu
|
|
211
|
+
|
|
212
|
+
Presents the user with a list of options. Renders as a WhatsApp interactive list message (with automatic pagination when options exceed the display limit).
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from turnstack.nodes import Menu, Option
|
|
216
|
+
|
|
217
|
+
tree.add("main_menu", Menu(
|
|
218
|
+
text="What would you like to do?",
|
|
219
|
+
options=[
|
|
220
|
+
Option("🛒 Place order", next="order_flow"),
|
|
221
|
+
Option("📦 Track order", next="track_flow"),
|
|
222
|
+
Option("🆘 Support", next="support_flow"),
|
|
223
|
+
Option("❌ Cancel order", next="cancel_flow"),
|
|
224
|
+
],
|
|
225
|
+
button_label="Main Menu", # label on the interactive list button
|
|
226
|
+
header="MyCo Services", # optional header
|
|
227
|
+
footer="Reply 00 for home", # optional footer
|
|
228
|
+
allow_numeric=True, # also accept "1", "2", "3"…
|
|
229
|
+
))
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**`Option` fields:**
|
|
233
|
+
|
|
234
|
+
| Field | Type | Description |
|
|
235
|
+
|---|---|---|
|
|
236
|
+
| `label` | `str` | Displayed text (keep under 24 chars for buttons) |
|
|
237
|
+
| `next` | `str` | Node key to navigate to when selected |
|
|
238
|
+
| `value` | `str` | Value stored in collected / used as interactive ID. Defaults to `next`. |
|
|
239
|
+
| `description` | `str` | Optional subtitle in list-style menus (max 72 chars) |
|
|
240
|
+
|
|
241
|
+
When the user selects an option, the engine navigates to the `next` node. No code required.
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
### 5.2 Input
|
|
246
|
+
|
|
247
|
+
A multi-step form. Walks through a list of fields one at a time, validating each response before moving on. After all fields are collected, advances to `next`.
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from turnstack.nodes import Input, Field, MenuField, ButtonsField
|
|
251
|
+
|
|
252
|
+
tree.add("support_ticket", Input(
|
|
253
|
+
title="Support Ticket", # shown as "Support Ticket — Step 1 of 3"
|
|
254
|
+
fields=[
|
|
255
|
+
Field("summary", "Briefly describe your issue:"),
|
|
256
|
+
MenuField("priority", "How urgent is this?", options=[
|
|
257
|
+
Option("🔴 Critical", value="critical"),
|
|
258
|
+
Option("🟡 Medium", value="medium"),
|
|
259
|
+
Option("🟢 Low", value="low"),
|
|
260
|
+
]),
|
|
261
|
+
Field("contact_email", "What email should we reach you at?"),
|
|
262
|
+
],
|
|
263
|
+
next="ticket_confirm",
|
|
264
|
+
))
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
| Argument | Type | Description |
|
|
268
|
+
|---|---|---|
|
|
269
|
+
| `fields` | `List[Field\|...]` | Ordered list of field objects (any mix of types) |
|
|
270
|
+
| `next` | `str` | Node to go to after all fields are collected |
|
|
271
|
+
| `title` | `str` | Optional flow title shown on each step |
|
|
272
|
+
|
|
273
|
+
The user can go `back` at any point to re-answer the previous field, or `0` to step back field by field within the same Input node.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
### 5.3 Confirm
|
|
278
|
+
|
|
279
|
+
Presents a summary and asks the user to confirm before you commit a side effect.
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
from turnstack.nodes import Confirm, Option
|
|
283
|
+
|
|
284
|
+
tree.add("ticket_confirm", Confirm(
|
|
285
|
+
text=lambda collected: (
|
|
286
|
+
f"Please confirm your ticket:\n\n"
|
|
287
|
+
f"Issue: {collected['summary']}\n"
|
|
288
|
+
f"Priority: {collected['priority']}\n"
|
|
289
|
+
f"Email: {collected['contact_email']}"
|
|
290
|
+
),
|
|
291
|
+
options=[
|
|
292
|
+
Option("✅ Submit", next="ticket_action"),
|
|
293
|
+
Option("✏️ Edit", next="support_ticket"),
|
|
294
|
+
Option("❌ Cancel", next="main_menu"),
|
|
295
|
+
],
|
|
296
|
+
))
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`text` can be a plain string or a callable `(collected: dict) -> str`. The callable receives `session.collected` so you can summarise what the user entered.
|
|
300
|
+
|
|
301
|
+
The engine renders Confirm as interactive buttons (max 3 options, WhatsApp limit).
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
### 5.4 Action
|
|
306
|
+
|
|
307
|
+
Runs your Python function, sends the return value as a text message, then navigates to `next`.
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
from turnstack.nodes import Action
|
|
311
|
+
|
|
312
|
+
tree.add("ticket_action", Action(
|
|
313
|
+
fn=save_ticket, # your function
|
|
314
|
+
next="main_menu",
|
|
315
|
+
))
|
|
316
|
+
|
|
317
|
+
def save_ticket(session, collected):
|
|
318
|
+
ticket_id = db.create_ticket(
|
|
319
|
+
user_id = session.user_id,
|
|
320
|
+
summary = collected["summary"],
|
|
321
|
+
priority = collected["priority"],
|
|
322
|
+
email = collected["contact_email"],
|
|
323
|
+
)
|
|
324
|
+
return f"✅ Ticket #{ticket_id} created. We'll reply to {collected['contact_email']}."
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
**`fn` signature:** `(session: Session, collected: dict) -> str`
|
|
328
|
+
|
|
329
|
+
The string you return becomes the message body. If you return `None` or an empty string the engine sends no message body (useful when you only want a side effect before a menu appears).
|
|
330
|
+
|
|
331
|
+
`fn` can also be an `async` coroutine:
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
async def async_action(session, collected):
|
|
335
|
+
result = await external_api.call(collected["query"])
|
|
336
|
+
return f"Result: {result}"
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
### 5.5 Router
|
|
342
|
+
|
|
343
|
+
Silently branches to a different node based on session state — no user input, no visible message. Use it as the entry point or at any junction where you need conditional routing.
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
from turnstack.nodes import Router, Route
|
|
347
|
+
|
|
348
|
+
tree = FlowTree(entry="entry_router")
|
|
349
|
+
|
|
350
|
+
tree.add("entry_router", Router(
|
|
351
|
+
before=load_user_profile, # optional hook run before evaluation
|
|
352
|
+
routes=[
|
|
353
|
+
Route(when=lambda s: not s.context.get("user"), next="onboarding"),
|
|
354
|
+
Route(when=lambda s: s.context["user"]["role"] == "admin", next="admin_menu"),
|
|
355
|
+
],
|
|
356
|
+
default="main_menu", # fallback when no route matches
|
|
357
|
+
))
|
|
358
|
+
|
|
359
|
+
def load_user_profile(session):
|
|
360
|
+
"""before hook — populate session.context before route conditions run."""
|
|
361
|
+
row = db.get_user(session.user_id)
|
|
362
|
+
if row:
|
|
363
|
+
session.context["user"] = dict(row)
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
`before` is called once before any `when` condition is evaluated. Use it to load data from your database into `session.context` so route conditions stay clean and declarative.
|
|
367
|
+
|
|
368
|
+
`Route.when` receives the full `session` object and must return `bool`. Routes are evaluated in order; the first `True` wins.
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
### 5.6 ListNode
|
|
373
|
+
|
|
374
|
+
Renders a dynamic list fetched at runtime with built-in pagination and optional interactive selection.
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
from turnstack.nodes import ListNode, Option
|
|
378
|
+
|
|
379
|
+
tree.add("product_list", ListNode(
|
|
380
|
+
fetch = fetch_products,
|
|
381
|
+
item_label = lambda p: f"{p['name']} — Ksh {p['price']:,}",
|
|
382
|
+
item_description = lambda p: p.get("category", ""),
|
|
383
|
+
on_select = "product_detail",
|
|
384
|
+
title = "🛒 Our Products",
|
|
385
|
+
empty_text = "No products available right now.",
|
|
386
|
+
interactive = True,
|
|
387
|
+
button_label = "Browse",
|
|
388
|
+
page_size = 8,
|
|
389
|
+
extra_options=[
|
|
390
|
+
Option("🔙 Back to menu", next="main_menu"),
|
|
391
|
+
],
|
|
392
|
+
))
|
|
393
|
+
|
|
394
|
+
def fetch_products(session):
|
|
395
|
+
"""Simple fetch — returns a flat list."""
|
|
396
|
+
return db.get_all_products()
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
**Paginated fetch** (when you have thousands of records):
|
|
400
|
+
|
|
401
|
+
```python
|
|
402
|
+
def fetch_products(session, page: int, page_size: int):
|
|
403
|
+
"""Paginated fetch — return (items_on_this_page, total_count)."""
|
|
404
|
+
rows = db.get_products(offset=page * page_size, limit=page_size)
|
|
405
|
+
total = db.count_products()
|
|
406
|
+
return rows, total
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
The engine detects which signature you use (3 params = paginated) and calls accordingly. Prev/Next navigation is added automatically.
|
|
410
|
+
|
|
411
|
+
When the user selects an item, the selected item's identifier is stored in `session.context["list_selected"]` and the engine navigates to `on_select`.
|
|
412
|
+
|
|
413
|
+
| Argument | Type | Default | Description |
|
|
414
|
+
|---|---|---|---|
|
|
415
|
+
| `fetch` | `Callable` | required | Simple or paginated fetch function |
|
|
416
|
+
| `item_label` | `Callable[[item], str]` | required | Display label for each item |
|
|
417
|
+
| `on_select` | `str` | required | Node to go to on selection |
|
|
418
|
+
| `title` | `str` | `"Select an option"` | Heading above the list |
|
|
419
|
+
| `empty_text` | `str` | `"No items available."` | Shown when fetch returns empty |
|
|
420
|
+
| `item_description` | `Callable[[item], str]` | `None` | Optional subtitle per item |
|
|
421
|
+
| `extra_options` | `List[Option]` | `[]` | Static options appended on last page (max 3) |
|
|
422
|
+
| `interactive` | `bool` | `False` | Render as interactive list |
|
|
423
|
+
| `button_label` | `str` | `"Options"` | Interactive list button label |
|
|
424
|
+
| `page_size` | `int` | `8` | Items per page (1–10) |
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
### 5.7 MediaReply
|
|
429
|
+
|
|
430
|
+
Generates a file (PDF, Excel, image, etc.) and sends it to the user, then automatically navigates to `next` and sends the next node's reply. Your adapter receives two `Reply` objects in the list — the file and the follow-up.
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
from turnstack.nodes import MediaReply
|
|
434
|
+
|
|
435
|
+
tree.add("export_report", MediaReply(
|
|
436
|
+
generate = build_report,
|
|
437
|
+
filename = lambda s, c: f"report_{s.user_id}.xlsx",
|
|
438
|
+
mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
439
|
+
caption = "📊 Here is your report.",
|
|
440
|
+
next = "main_menu",
|
|
441
|
+
))
|
|
442
|
+
|
|
443
|
+
def build_report(session, collected) -> bytes:
|
|
444
|
+
"""Return raw file bytes."""
|
|
445
|
+
wb = build_workbook(session.user_id)
|
|
446
|
+
buf = io.BytesIO()
|
|
447
|
+
wb.save(buf)
|
|
448
|
+
return buf.getvalue()
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
`generate` can be sync or `async`. `filename` and `caption` can be plain strings or callables `(session, collected) -> str`.
|
|
452
|
+
|
|
453
|
+
---
|
|
454
|
+
|
|
455
|
+
## 6. Field Types (inside Input)
|
|
456
|
+
|
|
457
|
+
### 6.1 Field / TextField
|
|
458
|
+
|
|
459
|
+
Plain text input. Accepts any text message.
|
|
460
|
+
|
|
461
|
+
```python
|
|
462
|
+
Field("full_name", "What is your full name?")
|
|
463
|
+
TextField("full_name", "What is your full name?") # identical alias
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
With validation and transformation:
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
Field(
|
|
470
|
+
"age",
|
|
471
|
+
"How old are you?",
|
|
472
|
+
validate = lambda v: "Must be a number." if not v.isdigit() else None,
|
|
473
|
+
transform = int,
|
|
474
|
+
)
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
### 6.2 MenuField
|
|
480
|
+
|
|
481
|
+
Interactive list selection inside a form. The user picks one option; the value is stored in `session.collected`.
|
|
482
|
+
|
|
483
|
+
```python
|
|
484
|
+
MenuField(
|
|
485
|
+
"department",
|
|
486
|
+
"Which department?",
|
|
487
|
+
options=[
|
|
488
|
+
Option("Engineering", value="eng"),
|
|
489
|
+
Option("Sales", value="sales"),
|
|
490
|
+
Option("Operations", value="ops"),
|
|
491
|
+
],
|
|
492
|
+
button_label = "Choose Department",
|
|
493
|
+
header = "Departments",
|
|
494
|
+
footer = "Pick one",
|
|
495
|
+
allow_numeric = True,
|
|
496
|
+
)
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
`options` can also be a callable `(session) -> List[Option]` for dynamic option lists built at runtime.
|
|
500
|
+
|
|
501
|
+
---
|
|
502
|
+
|
|
503
|
+
### 6.3 ButtonsField
|
|
504
|
+
|
|
505
|
+
Interactive reply buttons (max 3). Use when you have a small number of choices.
|
|
506
|
+
|
|
507
|
+
```python
|
|
508
|
+
ButtonsField(
|
|
509
|
+
"approval",
|
|
510
|
+
"Do you approve this request?",
|
|
511
|
+
options=[
|
|
512
|
+
Option("✅ Approve", value="approved"),
|
|
513
|
+
Option("❌ Reject", value="rejected"),
|
|
514
|
+
Option("⏸ Hold", value="on_hold"),
|
|
515
|
+
],
|
|
516
|
+
)
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
### 6.4 ImageField
|
|
522
|
+
|
|
523
|
+
Waits for the user to send a photo. Rejects anything else with a configurable message.
|
|
524
|
+
|
|
525
|
+
```python
|
|
526
|
+
ImageField(
|
|
527
|
+
"profile_photo",
|
|
528
|
+
"Please send a clear photo of yourself 📸",
|
|
529
|
+
rejection_text="⚠️ That's not a photo. Please send an image.",
|
|
530
|
+
)
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Collected value:
|
|
534
|
+
|
|
535
|
+
```python
|
|
536
|
+
{
|
|
537
|
+
"media_id": "wamid.xxx", # WhatsApp media ID — use this to download
|
|
538
|
+
"mime_type": "image/jpeg",
|
|
539
|
+
}
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
---
|
|
543
|
+
|
|
544
|
+
### 6.5 DocumentField
|
|
545
|
+
|
|
546
|
+
Waits for the user to send a document. Optionally restrict to specific MIME types.
|
|
547
|
+
|
|
548
|
+
```python
|
|
549
|
+
DocumentField(
|
|
550
|
+
"id_document",
|
|
551
|
+
"Upload a scanned copy of your national ID (PDF only) 📄",
|
|
552
|
+
accept = ["application/pdf"],
|
|
553
|
+
rejection_text = "⚠️ Please upload a PDF file.",
|
|
554
|
+
)
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Collected value:
|
|
558
|
+
|
|
559
|
+
```python
|
|
560
|
+
{
|
|
561
|
+
"media_id": "wamid.xxx",
|
|
562
|
+
"mime_type": "application/pdf",
|
|
563
|
+
"filename": "id_scan.pdf",
|
|
564
|
+
}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
---
|
|
568
|
+
|
|
569
|
+
### 6.6 LocationField
|
|
570
|
+
|
|
571
|
+
Sends a WhatsApp location-request message and waits for the user to share their location.
|
|
572
|
+
|
|
573
|
+
```python
|
|
574
|
+
LocationField(
|
|
575
|
+
"pickup_location",
|
|
576
|
+
"Please share your pickup location 📍",
|
|
577
|
+
rejection_text = "⚠️ Please use the 📍 button to share your location.",
|
|
578
|
+
)
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
Collected value:
|
|
582
|
+
|
|
583
|
+
```python
|
|
584
|
+
{
|
|
585
|
+
"latitude": -1.286389,
|
|
586
|
+
"longitude": 36.817223,
|
|
587
|
+
"name": "Nairobi CBD", # may be None
|
|
588
|
+
"address": "Kenyatta Ave", # may be None
|
|
589
|
+
}
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
---
|
|
593
|
+
|
|
594
|
+
### 6.7 BranchField
|
|
595
|
+
|
|
596
|
+
Conditionally injects a group of fields into the form based on earlier answers. The step counter updates dynamically — the user only sees steps relevant to their path.
|
|
597
|
+
|
|
598
|
+
```python
|
|
599
|
+
Input(
|
|
600
|
+
title="Loan Application",
|
|
601
|
+
fields=[
|
|
602
|
+
ButtonsField("employment_type", "Are you employed or self-employed?", options=[
|
|
603
|
+
Option("Employed", value="employed"),
|
|
604
|
+
Option("Self-employed", value="self_employed"),
|
|
605
|
+
]),
|
|
606
|
+
|
|
607
|
+
# Only shown for employed applicants
|
|
608
|
+
BranchField(
|
|
609
|
+
when=lambda s: s.collected.get("employment_type") == "employed",
|
|
610
|
+
fields=[
|
|
611
|
+
Field("employer_name", "Who is your employer?"),
|
|
612
|
+
Field("monthly_salary", "What is your monthly salary (KES)?",
|
|
613
|
+
validate=lambda v: None if v.isdigit() else "Enter a number."),
|
|
614
|
+
],
|
|
615
|
+
),
|
|
616
|
+
|
|
617
|
+
# Only shown for self-employed applicants
|
|
618
|
+
BranchField(
|
|
619
|
+
when=lambda s: s.collected.get("employment_type") == "self_employed",
|
|
620
|
+
fields=[
|
|
621
|
+
Field("business_name", "What is your business name?"),
|
|
622
|
+
Field("monthly_revenue", "What is your average monthly revenue (KES)?"),
|
|
623
|
+
],
|
|
624
|
+
),
|
|
625
|
+
|
|
626
|
+
Field("loan_amount", "How much would you like to borrow (KES)?"),
|
|
627
|
+
],
|
|
628
|
+
next="loan_confirm",
|
|
629
|
+
)
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
`BranchField` is not itself a field — it has no `name`. It's a conditional wrapper that flattens transparently at runtime. Branches can be nested.
|
|
633
|
+
|
|
634
|
+
A field's `skip_if` argument is an alternative for single-field conditional skipping:
|
|
635
|
+
|
|
636
|
+
```python
|
|
637
|
+
Field(
|
|
638
|
+
"company_name",
|
|
639
|
+
"What is your company name?",
|
|
640
|
+
skip_if=lambda s: s.collected.get("employment_type") == "self_employed",
|
|
641
|
+
)
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
---
|
|
645
|
+
|
|
646
|
+
## 7. The Engine
|
|
647
|
+
|
|
648
|
+
### 7.1 Instantiation
|
|
649
|
+
|
|
650
|
+
```python
|
|
651
|
+
from turnstack import BotEngine, FlowTree
|
|
652
|
+
from turnstack.stores.memory import InMemorySessionStore
|
|
653
|
+
|
|
654
|
+
engine = BotEngine(
|
|
655
|
+
tree = tree,
|
|
656
|
+
session_store = InMemorySessionStore(), # default
|
|
657
|
+
session_timeout = 1800, # seconds of inactivity before expiry
|
|
658
|
+
back_keywords = {"0", "back", "go back"},
|
|
659
|
+
home_keywords = {"00", "home", "menu", "start over"},
|
|
660
|
+
exit_keywords = {"000", "exit", "quit", "reset", "goodbye", "bye"},
|
|
661
|
+
unsupported_text = "⚠️ Sorry, I can't process that message. Please try again.",
|
|
662
|
+
)
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
All parameters except `tree` are optional. The engine validates the tree on startup and raises immediately if any node reference is broken.
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
### 7.2 process()
|
|
670
|
+
|
|
671
|
+
```python
|
|
672
|
+
replies: List[Reply] = await engine.process(incoming)
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
The single public method you call for every inbound message. Always returns a `List[Reply]`.
|
|
676
|
+
|
|
677
|
+
In the common case the list contains one item. When a `MediaReply` node fires, the list contains two items — the file reply and the follow-up node — sent in order. You just loop:
|
|
678
|
+
|
|
679
|
+
```python
|
|
680
|
+
for reply in replies:
|
|
681
|
+
await send_via_whatsapp(reply)
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
The engine handles everything internally:
|
|
685
|
+
- Session load / create / expire
|
|
686
|
+
- Global command interception (back, home, exit)
|
|
687
|
+
- Node dispatch and state transition
|
|
688
|
+
- Session save
|
|
689
|
+
|
|
690
|
+
You never touch the session store or call internal engine methods.
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
### 7.3 IncomingMessage
|
|
695
|
+
|
|
696
|
+
Build this from the raw WhatsApp webhook payload and pass it to `process()`.
|
|
697
|
+
|
|
698
|
+
```python
|
|
699
|
+
from turnstack import IncomingMessage
|
|
700
|
+
|
|
701
|
+
# Text message
|
|
702
|
+
IncomingMessage(
|
|
703
|
+
user_id = "2547XXXXXXXX",
|
|
704
|
+
type = "text",
|
|
705
|
+
text = "Hello",
|
|
706
|
+
raw = raw_payload, # optional, for your own reference
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
# Interactive selection (button or list reply)
|
|
710
|
+
IncomingMessage(
|
|
711
|
+
user_id = "2547XXXXXXXX",
|
|
712
|
+
type = "interactive",
|
|
713
|
+
interactive_id = "option_value", # the id from button_reply or list_reply
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
# Image
|
|
717
|
+
IncomingMessage(
|
|
718
|
+
user_id = "2547XXXXXXXX",
|
|
719
|
+
type = "image",
|
|
720
|
+
media_id = msg["image"]["id"],
|
|
721
|
+
media_mime = msg["image"].get("mime_type"),
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
# Document
|
|
725
|
+
IncomingMessage(
|
|
726
|
+
user_id = "2547XXXXXXXX",
|
|
727
|
+
type = "document",
|
|
728
|
+
media_id = msg["document"]["id"],
|
|
729
|
+
media_mime = msg["document"].get("mime_type"),
|
|
730
|
+
media_name = msg["document"].get("filename"),
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
# Location
|
|
734
|
+
IncomingMessage(
|
|
735
|
+
user_id = "2547XXXXXXXX",
|
|
736
|
+
type = "location",
|
|
737
|
+
location = {
|
|
738
|
+
"latitude": loc["latitude"],
|
|
739
|
+
"longitude": loc["longitude"],
|
|
740
|
+
"name": loc.get("name"),
|
|
741
|
+
"address": loc.get("address"),
|
|
742
|
+
},
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
# Unsupported type (sticker, audio, reaction…)
|
|
746
|
+
# Pass it through — engine replies politely and holds state
|
|
747
|
+
IncomingMessage(user_id="2547XXXXXXXX", type="sticker")
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
| Field | Type | Description |
|
|
751
|
+
|---|---|---|
|
|
752
|
+
| `user_id` | `str` | Unique user identifier (phone number or WA user ID) |
|
|
753
|
+
| `type` | `str` | `"text"`, `"interactive"`, `"image"`, `"document"`, `"location"`, or any other |
|
|
754
|
+
| `text` | `str\|None` | Text body (type=text) |
|
|
755
|
+
| `interactive_id` | `str\|None` | Selected option ID (type=interactive) |
|
|
756
|
+
| `media_id` | `str\|None` | WhatsApp media ID (type=image or document) |
|
|
757
|
+
| `media_mime` | `str\|None` | MIME type of the media |
|
|
758
|
+
| `media_name` | `str\|None` | Original filename (documents) |
|
|
759
|
+
| `location` | `dict\|None` | Location dict with latitude/longitude/name/address |
|
|
760
|
+
| `raw` | `Any` | Original raw payload — stored for your reference, engine ignores it |
|
|
761
|
+
|
|
762
|
+
---
|
|
763
|
+
|
|
764
|
+
### 7.4 Reply
|
|
765
|
+
|
|
766
|
+
The object returned by `process()`. Read its fields to decide how to send the message.
|
|
767
|
+
|
|
768
|
+
```python
|
|
769
|
+
@dataclass
|
|
770
|
+
class Reply:
|
|
771
|
+
type: Literal["text", "media", "end", "error"]
|
|
772
|
+
body: str # message text / caption for media
|
|
773
|
+
phone: str # recipient (same as user_id by default)
|
|
774
|
+
|
|
775
|
+
# media
|
|
776
|
+
file_bytes: Optional[bytes]
|
|
777
|
+
filename: Optional[str]
|
|
778
|
+
mime_type: Optional[str]
|
|
779
|
+
|
|
780
|
+
# interactive hints
|
|
781
|
+
options: List[ReplyOption] # populated for menu/confirm nodes
|
|
782
|
+
node_type: Optional[str] # "menu" | "confirm" | "input" | "input_buttons"
|
|
783
|
+
# "input_location" | "list" | "media" | "text" | "error"
|
|
784
|
+
suggested_replies: List[str] # option labels for quick-reply chips
|
|
785
|
+
|
|
786
|
+
# meta
|
|
787
|
+
current_node: Optional[str]
|
|
788
|
+
session_state: Optional[str] # "new" | "active" | "expired"
|
|
789
|
+
meta: Dict[str, Any] # extra hints — e.g. meta["button_label"]
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
**`ReplyOption`:**
|
|
793
|
+
|
|
794
|
+
```python
|
|
795
|
+
@dataclass
|
|
796
|
+
class ReplyOption:
|
|
797
|
+
label: str # display text
|
|
798
|
+
value: str # the id to send back when selected
|
|
799
|
+
description: str # optional subtitle (list menus)
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
**`node_type` reference — use this to decide message format:**
|
|
803
|
+
|
|
804
|
+
| `node_type` | What to send |
|
|
805
|
+
|---|---|
|
|
806
|
+
| `"menu"` | Interactive list message. Use `reply.options` and `reply.meta["button_label"]` |
|
|
807
|
+
| `"list"` | Interactive list (same as menu) |
|
|
808
|
+
| `"confirm"` | Interactive buttons (max 3). Use `reply.options` |
|
|
809
|
+
| `"input_buttons"` | Interactive buttons (ButtonsField inside Input) |
|
|
810
|
+
| `"input_location"` | Location request interactive message |
|
|
811
|
+
| `"input"` | Plain text prompt (TextField) |
|
|
812
|
+
| `"media"` | Document/image send. Use `file_bytes`, `filename`, `mime_type`, `body` as caption |
|
|
813
|
+
| `"text"` | Plain text message |
|
|
814
|
+
| `"error"` | Something went wrong — log and optionally show `body` to the user |
|
|
815
|
+
|
|
816
|
+
---
|
|
817
|
+
|
|
818
|
+
## 8. Session & State
|
|
819
|
+
|
|
820
|
+
### 8.1 Session object
|
|
821
|
+
|
|
822
|
+
The engine manages this for you. You only interact with it inside `fn`, `when`, `before`, `fetch`, `validate`, `transform`, and dynamic text callables.
|
|
823
|
+
|
|
824
|
+
```python
|
|
825
|
+
session.user_id # str — the user's identifier
|
|
826
|
+
session.current_node # str — which node the user is currently on
|
|
827
|
+
session.collected # dict — all form values collected so far
|
|
828
|
+
session.context # dict — your arbitrary data (not cleared between nodes)
|
|
829
|
+
session.nav_stack # list — navigation history (for back/go home)
|
|
830
|
+
session.lifecycle_state # "new" | "active" | "expired"
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
---
|
|
834
|
+
|
|
835
|
+
### 8.2 session.collected
|
|
836
|
+
|
|
837
|
+
Form data collected by `Input` nodes. Keys are the `name` values of your fields.
|
|
838
|
+
|
|
839
|
+
```python
|
|
840
|
+
def confirm_order(session, collected):
|
|
841
|
+
return (
|
|
842
|
+
f"Order summary:\n"
|
|
843
|
+
f"Item: {collected['item_name']}\n"
|
|
844
|
+
f"Quantity: {collected['quantity']}\n"
|
|
845
|
+
f"Address: {collected['delivery_address']['address']}"
|
|
846
|
+
)
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
`collected` is cleared when an `Input` node is entered fresh (not on back-navigation within it). Data from previous Input nodes persists until explicitly cleared or the session expires.
|
|
850
|
+
|
|
851
|
+
---
|
|
852
|
+
|
|
853
|
+
### 8.3 session.context
|
|
854
|
+
|
|
855
|
+
A free-form dict for your own data. Nothing in the engine reads or writes it (except `ListNode` which writes `context["list_selected"]` on item selection). Persists for the lifetime of the session.
|
|
856
|
+
|
|
857
|
+
```python
|
|
858
|
+
# In a Router before hook
|
|
859
|
+
def load_user(session):
|
|
860
|
+
session.context["user"] = db.get_user(session.user_id)
|
|
861
|
+
|
|
862
|
+
# In a Menu text callable
|
|
863
|
+
Menu(
|
|
864
|
+
text=lambda s, c=None: f"Hello {s.context['user']['first_name']}! What can I help you with?",
|
|
865
|
+
...
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
# In an Action
|
|
869
|
+
def process_order(session, collected):
|
|
870
|
+
user = session.context["user"]
|
|
871
|
+
...
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
---
|
|
875
|
+
|
|
876
|
+
### 8.4 session.pagination
|
|
877
|
+
|
|
878
|
+
Stores page indices for menu and list pagination. Managed entirely by the engine — you should not write to this directly. Readable for debugging.
|
|
879
|
+
|
|
880
|
+
---
|
|
881
|
+
|
|
882
|
+
## 9. Session Stores
|
|
883
|
+
|
|
884
|
+
### 9.1 InMemorySessionStore
|
|
885
|
+
|
|
886
|
+
The default. Fast, zero-config, but sessions are lost on restart. Good for development.
|
|
887
|
+
|
|
888
|
+
```python
|
|
889
|
+
from turnstack.stores.memory import InMemorySessionStore
|
|
890
|
+
|
|
891
|
+
engine = BotEngine(tree=tree, session_store=InMemorySessionStore(session_timeout=600))
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
---
|
|
895
|
+
|
|
896
|
+
### 9.2 Custom Stores
|
|
897
|
+
|
|
898
|
+
Implement the `SessionStore` interface to persist sessions to Redis, a database, or anywhere:
|
|
899
|
+
|
|
900
|
+
```python
|
|
901
|
+
from turnstack.session import SessionStore, Session
|
|
902
|
+
import json
|
|
903
|
+
|
|
904
|
+
class RedisSessionStore(SessionStore):
|
|
905
|
+
|
|
906
|
+
def __init__(self, redis_client, timeout: int = 1800):
|
|
907
|
+
self.redis = redis_client
|
|
908
|
+
self.timeout = timeout
|
|
909
|
+
|
|
910
|
+
async def get(self, user_id: str) -> Session | None:
|
|
911
|
+
data = await self.redis.get(f"session:{user_id}")
|
|
912
|
+
if not data:
|
|
913
|
+
return None
|
|
914
|
+
return Session.from_dict(json.loads(data))
|
|
915
|
+
|
|
916
|
+
async def save(self, session: Session) -> None:
|
|
917
|
+
await self.redis.setex(
|
|
918
|
+
f"session:{user_id}",
|
|
919
|
+
self.timeout,
|
|
920
|
+
json.dumps(session.to_dict()),
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
async def delete(self, user_id: str) -> None:
|
|
924
|
+
await self.redis.delete(f"session:{user_id}")
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
Pass it to the engine:
|
|
928
|
+
|
|
929
|
+
```python
|
|
930
|
+
engine = BotEngine(tree=tree, session_store=RedisSessionStore(redis, timeout=1800))
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
---
|
|
934
|
+
|
|
935
|
+
## 10. Navigation — Built-in Commands
|
|
936
|
+
|
|
937
|
+
The engine intercepts these plain-text messages before dispatching to any node handler. They work anywhere in the flow without any node configuration.
|
|
938
|
+
|
|
939
|
+
| Keyword(s) | Action |
|
|
940
|
+
|---|---|
|
|
941
|
+
| `0`, `back`, `go back` | Step back — goes to previous field inside an Input, or previous node |
|
|
942
|
+
| `00`, `home`, `menu`, `start over` | Jump to the entry node, clearing the navigation stack |
|
|
943
|
+
| `000`, `exit`, `quit`, `reset`, `goodbye`, `bye` | End the session — user receives a goodbye message; next message starts fresh |
|
|
944
|
+
|
|
945
|
+
All keyword sets are configurable on `BotEngine`:
|
|
946
|
+
|
|
947
|
+
```python
|
|
948
|
+
engine = BotEngine(
|
|
949
|
+
tree = tree,
|
|
950
|
+
back_keywords = {"b", "back"},
|
|
951
|
+
home_keywords = {"h", "home"},
|
|
952
|
+
exit_keywords = {"x", "exit"},
|
|
953
|
+
)
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
**Back within an Input node** is field-aware: pressing back steps to the previous field (clearing its collected value) rather than leaving the Input node entirely. Once at field 0, pressing back leaves the Input node and goes to the previous node in the stack.
|
|
957
|
+
|
|
958
|
+
---
|
|
959
|
+
|
|
960
|
+
## 11. Sending Replies — Adapter Pattern
|
|
961
|
+
|
|
962
|
+
TurnStack is send-agnostic. You read `reply.node_type` and `reply.options` to decide how to format the outgoing message, then send it however you like.
|
|
963
|
+
|
|
964
|
+
### 11.1 Reading reply fields
|
|
965
|
+
|
|
966
|
+
```python
|
|
967
|
+
replies = await engine.process(incoming)
|
|
968
|
+
|
|
969
|
+
for reply in replies:
|
|
970
|
+
if reply.type == "error":
|
|
971
|
+
logger.error(f"Engine error at {reply.current_node}: {reply.body}")
|
|
972
|
+
continue
|
|
973
|
+
|
|
974
|
+
await send(user_id=reply.phone, reply=reply)
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
### 11.2 Sending via REST
|
|
978
|
+
|
|
979
|
+
```python
|
|
980
|
+
async def send(user_id: str, phone: str, reply: Reply):
|
|
981
|
+
|
|
982
|
+
if reply.type == "media":
|
|
983
|
+
# Upload and send document/image
|
|
984
|
+
media_id = await upload_media(reply.file_bytes, reply.mime_type, reply.filename)
|
|
985
|
+
payload = {
|
|
986
|
+
"messaging_product": "whatsapp",
|
|
987
|
+
"to": phone,
|
|
988
|
+
"type": "document",
|
|
989
|
+
"document": {
|
|
990
|
+
"id": media_id,
|
|
991
|
+
"caption": reply.body,
|
|
992
|
+
"filename": reply.filename,
|
|
993
|
+
},
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
elif reply.node_type in ("menu", "list"):
|
|
997
|
+
# Interactive list from ListNode (sections in meta) or regular menu (options)
|
|
998
|
+
if reply.meta.get("sections"):
|
|
999
|
+
# ListNode interactive mode – use pre-built sections
|
|
1000
|
+
sections = reply.meta["sections"]
|
|
1001
|
+
button_label = reply.meta.get("button_label", "Options")
|
|
1002
|
+
payload = {
|
|
1003
|
+
"messaging_product": "whatsapp",
|
|
1004
|
+
"to": phone,
|
|
1005
|
+
"type": "interactive",
|
|
1006
|
+
"interactive": {
|
|
1007
|
+
"type": "list",
|
|
1008
|
+
"body": {"text": reply.body},
|
|
1009
|
+
"action": {
|
|
1010
|
+
"button": button_label,
|
|
1011
|
+
"sections": sections,
|
|
1012
|
+
},
|
|
1013
|
+
},
|
|
1014
|
+
}
|
|
1015
|
+
else:
|
|
1016
|
+
# Regular menu – build rows from reply.options
|
|
1017
|
+
rows = [
|
|
1018
|
+
{"id": opt.value, "title": opt.label[:24], "description": opt.description[:72]}
|
|
1019
|
+
for opt in reply.options
|
|
1020
|
+
]
|
|
1021
|
+
payload = {
|
|
1022
|
+
"messaging_product": "whatsapp",
|
|
1023
|
+
"to": phone,
|
|
1024
|
+
"type": "interactive",
|
|
1025
|
+
"interactive": {
|
|
1026
|
+
"type": "list",
|
|
1027
|
+
"body": {"text": reply.body},
|
|
1028
|
+
"action": {
|
|
1029
|
+
"button": reply.meta.get("button_label", "Options"),
|
|
1030
|
+
"sections": [{"title": "Options", "rows": rows}],
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
elif reply.node_type in ("confirm", "input_buttons"):
|
|
1036
|
+
# Interactive buttons
|
|
1037
|
+
buttons = [
|
|
1038
|
+
{"type": "reply", "reply": {"id": opt.value, "title": opt.label[:20]}}
|
|
1039
|
+
for opt in reply.options[:3]
|
|
1040
|
+
]
|
|
1041
|
+
payload = {
|
|
1042
|
+
"messaging_product": "whatsapp",
|
|
1043
|
+
"to": phone,
|
|
1044
|
+
"type": "interactive",
|
|
1045
|
+
"interactive": {
|
|
1046
|
+
"type": "button",
|
|
1047
|
+
"body": {"text": reply.body},
|
|
1048
|
+
"action": {"buttons": buttons},
|
|
1049
|
+
},
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
elif reply.node_type == "input_location":
|
|
1053
|
+
payload = {
|
|
1054
|
+
"messaging_product": "whatsapp",
|
|
1055
|
+
"to": phone,
|
|
1056
|
+
"type": "interactive",
|
|
1057
|
+
"interactive": {
|
|
1058
|
+
"type": "location_request_message",
|
|
1059
|
+
"body": {"text": reply.body},
|
|
1060
|
+
"action": {"name": "send_location"},
|
|
1061
|
+
},
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
else:
|
|
1065
|
+
# Plain text (TextField prompt, Action message, error, etc.)
|
|
1066
|
+
payload = {
|
|
1067
|
+
"messaging_product": "whatsapp",
|
|
1068
|
+
"to": phone,
|
|
1069
|
+
"type": "text",
|
|
1070
|
+
"text": {"body": reply.body},
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async with httpx.AsyncClient() as client:
|
|
1074
|
+
await client.post(
|
|
1075
|
+
f"https://graph.facebook.com/v19.0/{PHONE_ID}/messages",
|
|
1076
|
+
headers={"Authorization": f"Bearer {WA_TOKEN}"},
|
|
1077
|
+
json=payload,
|
|
1078
|
+
)
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
### 11.3 Sending via pywa / any library
|
|
1082
|
+
|
|
1083
|
+
If you use [pywa](https://github.com/david-lev/pywa) or another WhatsApp SDK, you adapt the same `reply.node_type` switch to your library's API:
|
|
1084
|
+
|
|
1085
|
+
```python
|
|
1086
|
+
from pywa import WhatsApp
|
|
1087
|
+
from pywa.types import Button, SectionList, Section, SectionRow
|
|
1088
|
+
|
|
1089
|
+
wa = WhatsApp(phone_id=PHONE_ID, token=WA_TOKEN)
|
|
1090
|
+
|
|
1091
|
+
async def send(reply: Reply):
|
|
1092
|
+
if reply.node_type in ("menu", "list"):
|
|
1093
|
+
rows = [SectionRow(id=o.value, title=o.label) for o in reply.options]
|
|
1094
|
+
await wa.send_message(
|
|
1095
|
+
to=reply.phone,
|
|
1096
|
+
text=reply.body,
|
|
1097
|
+
buttons=SectionList(
|
|
1098
|
+
button_title=reply.meta.get("button_label", "Options"),
|
|
1099
|
+
sections=[Section(title="Options", rows=rows)],
|
|
1100
|
+
),
|
|
1101
|
+
)
|
|
1102
|
+
elif reply.node_type in ("confirm", "input_buttons"):
|
|
1103
|
+
btns = [Button(id=o.value, title=o.label) for o in reply.options]
|
|
1104
|
+
await wa.send_message(to=reply.phone, text=reply.body, buttons=btns)
|
|
1105
|
+
else:
|
|
1106
|
+
await wa.send_message(to=reply.phone, text=reply.body)
|
|
1107
|
+
```
|
|
1108
|
+
|
|
1109
|
+
The engine's output is always the same structured `Reply` — the send layer is fully swappable.
|
|
1110
|
+
|
|
1111
|
+
---
|
|
1112
|
+
|
|
1113
|
+
## 12. Wiring to a Webhook
|
|
1114
|
+
|
|
1115
|
+
```python
|
|
1116
|
+
from fastapi import FastAPI, Request, Response, HTTPException
|
|
1117
|
+
import traceback
|
|
1118
|
+
|
|
1119
|
+
app = FastAPI()
|
|
1120
|
+
|
|
1121
|
+
@app.get("/webhook/whatsapp")
|
|
1122
|
+
async def verify(request: Request):
|
|
1123
|
+
"""WhatsApp webhook verification."""
|
|
1124
|
+
p = request.query_params
|
|
1125
|
+
if p.get("hub.mode") == "subscribe" and p.get("hub.verify_token") == WA_VERIFY_TOKEN:
|
|
1126
|
+
return Response(content=p.get("hub.challenge"), media_type="text/plain")
|
|
1127
|
+
raise HTTPException(403)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
@app.post("/webhook/whatsapp")
|
|
1131
|
+
async def webhook(request: Request):
|
|
1132
|
+
raw = await request.json()
|
|
1133
|
+
try:
|
|
1134
|
+
value = raw["entry"][0]["changes"][0]["value"]
|
|
1135
|
+
if "messages" not in value:
|
|
1136
|
+
return {"status": "no_messages"}
|
|
1137
|
+
|
|
1138
|
+
msg = value["messages"][0]
|
|
1139
|
+
phone = msg.get("from", "")
|
|
1140
|
+
user_id = msg.get("from_user_id", phone) # fall back to phone if no user_id
|
|
1141
|
+
msg_type = msg.get("type", "")
|
|
1142
|
+
|
|
1143
|
+
# ── normalise raw payload → IncomingMessage ───────────────────
|
|
1144
|
+
if msg_type == "text":
|
|
1145
|
+
incoming = IncomingMessage(
|
|
1146
|
+
user_id=user_id, type="text",
|
|
1147
|
+
text=msg["text"]["body"], raw=raw,
|
|
1148
|
+
)
|
|
1149
|
+
elif msg_type == "interactive":
|
|
1150
|
+
itype = msg["interactive"]["type"]
|
|
1151
|
+
iid = (msg["interactive"]["button_reply"]["id"]
|
|
1152
|
+
if itype == "button_reply"
|
|
1153
|
+
else msg["interactive"]["list_reply"]["id"])
|
|
1154
|
+
incoming = IncomingMessage(
|
|
1155
|
+
user_id=user_id, type="interactive", interactive_id=iid, raw=raw,
|
|
1156
|
+
)
|
|
1157
|
+
elif msg_type == "image":
|
|
1158
|
+
incoming = IncomingMessage(
|
|
1159
|
+
user_id=user_id, type="image",
|
|
1160
|
+
media_id=msg["image"]["id"],
|
|
1161
|
+
media_mime=msg["image"].get("mime_type"), raw=raw,
|
|
1162
|
+
)
|
|
1163
|
+
elif msg_type == "document":
|
|
1164
|
+
incoming = IncomingMessage(
|
|
1165
|
+
user_id=user_id, type="document",
|
|
1166
|
+
media_id=msg["document"]["id"],
|
|
1167
|
+
media_mime=msg["document"].get("mime_type"),
|
|
1168
|
+
media_name=msg["document"].get("filename"), raw=raw,
|
|
1169
|
+
)
|
|
1170
|
+
elif msg_type == "location":
|
|
1171
|
+
loc = msg["location"]
|
|
1172
|
+
incoming = IncomingMessage(
|
|
1173
|
+
user_id=user_id, type="location",
|
|
1174
|
+
location={
|
|
1175
|
+
"latitude": loc.get("latitude"),
|
|
1176
|
+
"longitude": loc.get("longitude"),
|
|
1177
|
+
"name": loc.get("name"),
|
|
1178
|
+
"address": loc.get("address"),
|
|
1179
|
+
}, raw=raw,
|
|
1180
|
+
)
|
|
1181
|
+
else:
|
|
1182
|
+
# Sticker, audio, reaction, etc. — engine handles gracefully
|
|
1183
|
+
incoming = IncomingMessage(user_id=user_id, type=msg_type, raw=raw)
|
|
1184
|
+
|
|
1185
|
+
# ── process & send ────────────────────────────────────────────
|
|
1186
|
+
replies = await engine.process(incoming)
|
|
1187
|
+
for reply in replies:
|
|
1188
|
+
await send_whatsapp(user_id, phone, reply)
|
|
1189
|
+
|
|
1190
|
+
return {"status": "ok"}
|
|
1191
|
+
|
|
1192
|
+
except Exception:
|
|
1193
|
+
traceback.print_exc()
|
|
1194
|
+
raise HTTPException(500)
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
This webhook setup is a one-time boilerplate. After that your entire development effort is in the `FlowTree`.
|
|
1198
|
+
|
|
1199
|
+
---
|
|
1200
|
+
|
|
1201
|
+
## 13. Validation & Transformation
|
|
1202
|
+
|
|
1203
|
+
Every field type (`Field`, `MenuField`, `ButtonsField`, `ImageField`, `DocumentField`, `LocationField`) supports two optional hooks:
|
|
1204
|
+
|
|
1205
|
+
**`validate(value) -> str | None`**
|
|
1206
|
+
|
|
1207
|
+
Return an error message to reject the input. Return `None` to accept.
|
|
1208
|
+
|
|
1209
|
+
```python
|
|
1210
|
+
import re
|
|
1211
|
+
|
|
1212
|
+
def validate_email(v: str):
|
|
1213
|
+
if not re.match(r"^[^@]+@[^@]+\.[^@]+$", v):
|
|
1214
|
+
return "⚠️ That doesn't look like a valid email address."
|
|
1215
|
+
return None
|
|
1216
|
+
|
|
1217
|
+
def validate_positive_integer(v: str):
|
|
1218
|
+
if not v.isdigit() or int(v) <= 0:
|
|
1219
|
+
return "⚠️ Please enter a positive whole number."
|
|
1220
|
+
return None
|
|
1221
|
+
|
|
1222
|
+
Field("email", "Your email address?", validate=validate_email)
|
|
1223
|
+
Field("quantity", "How many units? (1–100)", validate=validate_positive_integer)
|
|
1224
|
+
```
|
|
1225
|
+
|
|
1226
|
+
When validation fails the engine re-asks the same question with the error message prepended. No state change occurs.
|
|
1227
|
+
|
|
1228
|
+
**`transform(value) -> Any`**
|
|
1229
|
+
|
|
1230
|
+
Applied after validation passes, before storing in `session.collected`. Use to cast types or normalise input.
|
|
1231
|
+
|
|
1232
|
+
```python
|
|
1233
|
+
Field(
|
|
1234
|
+
"units",
|
|
1235
|
+
"How many units?",
|
|
1236
|
+
validate = lambda v: None if v.isdigit() else "Please enter a number.",
|
|
1237
|
+
transform = int, # stored as int, not string
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
Field(
|
|
1241
|
+
"full_name",
|
|
1242
|
+
"Your full name?",
|
|
1243
|
+
transform = str.strip,
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
Field(
|
|
1247
|
+
"date_of_birth",
|
|
1248
|
+
"Date of birth (YYYY-MM-DD)?",
|
|
1249
|
+
validate = lambda v: None if re.match(r"\d{4}-\d{2}-\d{2}", v) else "Format: YYYY-MM-DD",
|
|
1250
|
+
transform = lambda v: datetime.strptime(v, "%Y-%m-%d").date(),
|
|
1251
|
+
)
|
|
1252
|
+
```
|
|
1253
|
+
|
|
1254
|
+
---
|
|
1255
|
+
|
|
1256
|
+
## 14. Dynamic Content
|
|
1257
|
+
|
|
1258
|
+
Most text-bearing arguments accept a callable so you can personalise the UI at runtime.
|
|
1259
|
+
|
|
1260
|
+
**Menu text:**
|
|
1261
|
+
|
|
1262
|
+
```python
|
|
1263
|
+
Menu(
|
|
1264
|
+
text=lambda session: f"Hi {session.context.get('user', {}).get('name', 'there')}! What can I do for you?",
|
|
1265
|
+
options=[...],
|
|
1266
|
+
)
|
|
1267
|
+
```
|
|
1268
|
+
|
|
1269
|
+
Note: Menu `text` callable receives `(session)`. Confirm `text` callable receives `(collected)`.
|
|
1270
|
+
|
|
1271
|
+
**Option descriptions from a database:**
|
|
1272
|
+
|
|
1273
|
+
```python
|
|
1274
|
+
MenuField(
|
|
1275
|
+
"branch",
|
|
1276
|
+
"Select your nearest branch:",
|
|
1277
|
+
options=lambda session: [
|
|
1278
|
+
Option(b["name"], value=str(b["id"]), description=b["address"])
|
|
1279
|
+
for b in db.get_branches(session.context.get("city"))
|
|
1280
|
+
],
|
|
1281
|
+
)
|
|
1282
|
+
```
|
|
1283
|
+
|
|
1284
|
+
**Dynamic filename and caption on MediaReply:**
|
|
1285
|
+
|
|
1286
|
+
```python
|
|
1287
|
+
MediaReply(
|
|
1288
|
+
generate = build_statement,
|
|
1289
|
+
filename = lambda s, c: f"statement_{s.context['user']['account_no']}.pdf",
|
|
1290
|
+
caption = lambda s, c: f"📄 Statement for {c['period']}",
|
|
1291
|
+
mime_type = "application/pdf",
|
|
1292
|
+
next = "main_menu",
|
|
1293
|
+
)
|
|
1294
|
+
```
|
|
1295
|
+
|
|
1296
|
+
---
|
|
1297
|
+
|
|
1298
|
+
## 15. Conditional Fields — BranchField
|
|
1299
|
+
|
|
1300
|
+
See [Section 6.7](#67-branchfield) for the full reference. Quick pattern summary:
|
|
1301
|
+
|
|
1302
|
+
```python
|
|
1303
|
+
# Pattern: branch on a ButtonsField answer
|
|
1304
|
+
Input(
|
|
1305
|
+
fields=[
|
|
1306
|
+
ButtonsField("type", "What are you reporting?", options=[
|
|
1307
|
+
Option("Bug", value="bug"),
|
|
1308
|
+
Option("Feature", value="feature"),
|
|
1309
|
+
]),
|
|
1310
|
+
BranchField(
|
|
1311
|
+
when=lambda s: s.collected.get("type") == "bug",
|
|
1312
|
+
fields=[
|
|
1313
|
+
Field("steps_to_reproduce", "How do you reproduce it?"),
|
|
1314
|
+
Field("expected_behaviour", "What did you expect to happen?"),
|
|
1315
|
+
],
|
|
1316
|
+
),
|
|
1317
|
+
BranchField(
|
|
1318
|
+
when=lambda s: s.collected.get("type") == "feature",
|
|
1319
|
+
fields=[
|
|
1320
|
+
Field("feature_description", "Describe the feature you'd like:"),
|
|
1321
|
+
Field("business_value", "Why would this be valuable?"),
|
|
1322
|
+
],
|
|
1323
|
+
),
|
|
1324
|
+
Field("contact_email", "Your email for follow-up?"),
|
|
1325
|
+
],
|
|
1326
|
+
next="submit_ticket",
|
|
1327
|
+
)
|
|
1328
|
+
```
|
|
1329
|
+
|
|
1330
|
+
The step counter shown to the user (`Step N of M`) reflects only the active fields for their path.
|
|
1331
|
+
|
|
1332
|
+
---
|
|
1333
|
+
|
|
1334
|
+
## 16. Pagination — Automatic Behaviour
|
|
1335
|
+
|
|
1336
|
+
**Menu pagination** kicks in automatically when a `Menu` or `MenuField` has more options than WhatsApp can show in a single interactive list. The engine:
|
|
1337
|
+
|
|
1338
|
+
1. Splits options into pages (max 8 real options per page, with Prev/Next controls)
|
|
1339
|
+
2. Tracks the current page in `session.pagination`
|
|
1340
|
+
3. Sends the correct page on each interaction
|
|
1341
|
+
|
|
1342
|
+
You do nothing. Just define as many options as you need.
|
|
1343
|
+
|
|
1344
|
+
**ListNode pagination** works the same way. For large datasets use the paginated fetch signature `(session, page, page_size) -> (items, total)` to avoid loading all records into memory.
|
|
1345
|
+
|
|
1346
|
+
**Page size** on `ListNode` is configurable (1–10, default 8):
|
|
1347
|
+
|
|
1348
|
+
```python
|
|
1349
|
+
ListNode(fetch=..., ..., page_size=5)
|
|
1350
|
+
```
|
|
1351
|
+
|
|
1352
|
+
---
|
|
1353
|
+
|
|
1354
|
+
## 17. Custom Node Handlers
|
|
1355
|
+
|
|
1356
|
+
If you need a node type that doesn't exist in TurnStack, register a custom handler:
|
|
1357
|
+
|
|
1358
|
+
```python
|
|
1359
|
+
from turnstack.handlers.base import NodeHandler
|
|
1360
|
+
from turnstack.reply import Reply
|
|
1361
|
+
from turnstack.session import Session
|
|
1362
|
+
from turnstack.message import IncomingMessage
|
|
1363
|
+
from turnstack.tree import FlowTree
|
|
1364
|
+
|
|
1365
|
+
class PaymentPromptHandler(NodeHandler):
|
|
1366
|
+
async def handle(
|
|
1367
|
+
self,
|
|
1368
|
+
node: dict,
|
|
1369
|
+
session: Session,
|
|
1370
|
+
message: IncomingMessage,
|
|
1371
|
+
tree: FlowTree,
|
|
1372
|
+
) -> Reply:
|
|
1373
|
+
# generate a payment link, store the reference, etc.
|
|
1374
|
+
ref = payment_gateway.create_link(session.user_id, node["amount"])
|
|
1375
|
+
session.context["payment_ref"] = ref
|
|
1376
|
+
|
|
1377
|
+
self._transition_to(session, node.get("next", "main_menu"))
|
|
1378
|
+
return Reply(
|
|
1379
|
+
type="text",
|
|
1380
|
+
body=f"Please complete payment here: {ref['url']}",
|
|
1381
|
+
phone=session.user_id,
|
|
1382
|
+
node_type="text",
|
|
1383
|
+
current_node=session.current_node,
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
# Register with the engine
|
|
1387
|
+
engine.register_handler("payment_prompt", PaymentPromptHandler())
|
|
1388
|
+
|
|
1389
|
+
# Use in the tree
|
|
1390
|
+
tree.add("pay_now", {
|
|
1391
|
+
"type": "payment_prompt",
|
|
1392
|
+
"amount": 500,
|
|
1393
|
+
"next": "payment_confirm",
|
|
1394
|
+
})
|
|
1395
|
+
```
|
|
1396
|
+
|
|
1397
|
+
---
|
|
1398
|
+
|
|
1399
|
+
## 18. Error Handling
|
|
1400
|
+
|
|
1401
|
+
The engine never raises exceptions to the caller. All internal errors produce a `Reply(type="error", ...)` with a descriptive `body`. In your adapter:
|
|
1402
|
+
|
|
1403
|
+
```python
|
|
1404
|
+
for reply in replies:
|
|
1405
|
+
if reply.type == "error":
|
|
1406
|
+
logger.error(
|
|
1407
|
+
f"Engine error | node={reply.current_node} | {reply.body}"
|
|
1408
|
+
)
|
|
1409
|
+
# Optionally send a generic error message to the user
|
|
1410
|
+
await send_plain_text(reply.phone, "⚠️ Something went wrong. Please try again.")
|
|
1411
|
+
continue
|
|
1412
|
+
await send_whatsapp(reply.phone, reply)
|
|
1413
|
+
```
|
|
1414
|
+
|
|
1415
|
+
**Common error causes:**
|
|
1416
|
+
|
|
1417
|
+
- A `next` key references a node that doesn't exist in the tree (caught at startup by `validate()`)
|
|
1418
|
+
- A `generate` function in `MediaReply` raises an exception (logged in `body`)
|
|
1419
|
+
- A `fetch` function in `ListNode` raises (logged in `body`)
|
|
1420
|
+
- No handler registered for a node type (only happens with custom types)
|
|
1421
|
+
|
|
1422
|
+
**Your own exceptions in `Action.fn`** are caught and surfaced as error replies. It's good practice to catch expected exceptions yourself and return a user-friendly message:
|
|
1423
|
+
|
|
1424
|
+
```python
|
|
1425
|
+
def save_order(session, collected):
|
|
1426
|
+
try:
|
|
1427
|
+
order_id = db.create_order(session.user_id, collected)
|
|
1428
|
+
return f"✅ Order #{order_id} placed!"
|
|
1429
|
+
except db.OutOfStockError:
|
|
1430
|
+
return "⚠️ Sorry, that item is out of stock. Please choose another."
|
|
1431
|
+
except Exception as e:
|
|
1432
|
+
logger.exception("Unexpected error saving order")
|
|
1433
|
+
return "⚠️ Something went wrong. Please try again later."
|
|
1434
|
+
```
|
|
1435
|
+
|
|
1436
|
+
---
|
|
1437
|
+
|
|
1438
|
+
## 19. Debug Utilities
|
|
1439
|
+
|
|
1440
|
+
**Inspect all active sessions:**
|
|
1441
|
+
|
|
1442
|
+
```python
|
|
1443
|
+
# InMemorySessionStore exposes .all()
|
|
1444
|
+
for user_id, session in engine.session_store.all().items():
|
|
1445
|
+
print(user_id, session.current_node, session.collected)
|
|
1446
|
+
```
|
|
1447
|
+
|
|
1448
|
+
**Reset a single session** (useful during development):
|
|
1449
|
+
|
|
1450
|
+
```python
|
|
1451
|
+
await engine.session_store.delete("2547XXXXXXXX")
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
**Add a debug endpoint to your API:**
|
|
1455
|
+
|
|
1456
|
+
```python
|
|
1457
|
+
@app.get("/debug/sessions")
|
|
1458
|
+
async def debug_sessions():
|
|
1459
|
+
return {
|
|
1460
|
+
uid: {
|
|
1461
|
+
"node": s.current_node,
|
|
1462
|
+
"state": s.lifecycle_state,
|
|
1463
|
+
"collected": s.collected,
|
|
1464
|
+
"context": s.context,
|
|
1465
|
+
"nav_stack": s.nav_stack,
|
|
1466
|
+
}
|
|
1467
|
+
for uid, s in engine.session_store.all().items()
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
@app.delete("/debug/sessions/{user_id}")
|
|
1471
|
+
async def reset_session(user_id: str):
|
|
1472
|
+
await engine.session_store.delete(user_id)
|
|
1473
|
+
return {"reset": user_id}
|
|
1474
|
+
```
|
|
1475
|
+
|
|
1476
|
+
**Log reply metadata** in your send function:
|
|
1477
|
+
|
|
1478
|
+
```python
|
|
1479
|
+
print(f"[{reply.session_state}] node={reply.current_node} type={reply.node_type} → {reply.body[:60]}")
|
|
1480
|
+
```
|
|
1481
|
+
|
|
1482
|
+
---
|
|
1483
|
+
|
|
1484
|
+
## 20. Complete Example — Customer Support Bot
|
|
1485
|
+
|
|
1486
|
+
A complete, runnable example showing the majority of TurnStack features together.
|
|
1487
|
+
|
|
1488
|
+
```python
|
|
1489
|
+
"""
|
|
1490
|
+
support_bot.py
|
|
1491
|
+
==============
|
|
1492
|
+
Customer support bot using TurnStack.
|
|
1493
|
+
"""
|
|
1494
|
+
|
|
1495
|
+
import asyncio
|
|
1496
|
+
import traceback
|
|
1497
|
+
import httpx
|
|
1498
|
+
from fastapi import FastAPI, Request, Response, HTTPException
|
|
1499
|
+
from turnstack import BotEngine, FlowTree, IncomingMessage
|
|
1500
|
+
from turnstack.nodes import (
|
|
1501
|
+
Menu, Input, Confirm, Action, Router, ListNode, MediaReply,
|
|
1502
|
+
Option, Field, MenuField, ButtonsField, ImageField, DocumentField,
|
|
1503
|
+
LocationField, BranchField, Route,
|
|
1504
|
+
)
|
|
1505
|
+
|
|
1506
|
+
# ── database (stub — replace with your real DB) ───────────────────────────────
|
|
1507
|
+
|
|
1508
|
+
users = {} # user_id -> {name, tier}
|
|
1509
|
+
tickets = [] # list of ticket dicts
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def get_user(user_id):
|
|
1513
|
+
return users.get(user_id)
|
|
1514
|
+
|
|
1515
|
+
def save_user(user_id, name, tier):
|
|
1516
|
+
users[user_id] = {"name": name, "tier": tier}
|
|
1517
|
+
|
|
1518
|
+
def create_ticket(user_id, data):
|
|
1519
|
+
tid = len(tickets) + 1
|
|
1520
|
+
tickets.append({"id": tid, "user_id": user_id, **data})
|
|
1521
|
+
return tid
|
|
1522
|
+
|
|
1523
|
+
def get_tickets(user_id):
|
|
1524
|
+
return [t for t in tickets if t["user_id"] == user_id]
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
# ── router hooks ──────────────────────────────────────────────────────────────
|
|
1528
|
+
|
|
1529
|
+
def load_profile(session):
|
|
1530
|
+
user = get_user(session.user_id)
|
|
1531
|
+
if user:
|
|
1532
|
+
session.context["user"] = user
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
# ── action functions ──────────────────────────────────────────────────────────
|
|
1536
|
+
|
|
1537
|
+
def do_register(session, collected):
|
|
1538
|
+
save_user(session.user_id, collected["name"], collected["tier"])
|
|
1539
|
+
session.context["user"] = {"name": collected["name"], "tier": collected["tier"]}
|
|
1540
|
+
return f"✅ Welcome, {collected['name']}! Your account is set up."
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def do_submit_ticket(session, collected):
|
|
1544
|
+
tid = create_ticket(session.user_id, {
|
|
1545
|
+
"type": collected["ticket_type"],
|
|
1546
|
+
"summary": collected["summary"],
|
|
1547
|
+
"detail": collected.get("detail"),
|
|
1548
|
+
"image_id": collected.get("screenshot", {}).get("media_id"),
|
|
1549
|
+
})
|
|
1550
|
+
return f"✅ Ticket #{tid} submitted. Our team will respond within 24 hours."
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def do_learn_more(session, collected):
|
|
1554
|
+
tier = session.context.get("user", {}).get("tier", "standard")
|
|
1555
|
+
if tier == "premium":
|
|
1556
|
+
return "⭐ As a Premium member you get 24/7 live support and dedicated SLAs."
|
|
1557
|
+
return "📋 Standard support includes email responses within 24 hours."
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
# ── flow tree ─────────────────────────────────────────────────────────────────
|
|
1561
|
+
|
|
1562
|
+
tree = FlowTree(entry="entry")
|
|
1563
|
+
|
|
1564
|
+
# Entry router — send new users to onboarding, returning users to main menu
|
|
1565
|
+
tree.add("entry", Router(
|
|
1566
|
+
before=load_profile,
|
|
1567
|
+
routes=[
|
|
1568
|
+
Route(when=lambda s: s.context.get("user") is None, next="welcome_new"),
|
|
1569
|
+
],
|
|
1570
|
+
default="main_menu",
|
|
1571
|
+
))
|
|
1572
|
+
|
|
1573
|
+
# New user welcome + registration
|
|
1574
|
+
tree.add("welcome_new", Menu(
|
|
1575
|
+
text="👋 Welcome to SupportBot! Looks like you're new here. Let's get you set up.",
|
|
1576
|
+
options=[
|
|
1577
|
+
Option("Get started", next="register"),
|
|
1578
|
+
Option("Learn more", next="about_action"),
|
|
1579
|
+
],
|
|
1580
|
+
))
|
|
1581
|
+
|
|
1582
|
+
tree.add("about_action", Action(
|
|
1583
|
+
fn=lambda s, c: (
|
|
1584
|
+
"SupportBot lets you raise and track support tickets, "
|
|
1585
|
+
"download reports, and manage your account — all on WhatsApp."
|
|
1586
|
+
),
|
|
1587
|
+
next="welcome_new",
|
|
1588
|
+
))
|
|
1589
|
+
|
|
1590
|
+
tree.add("register", Input(
|
|
1591
|
+
title="Registration",
|
|
1592
|
+
fields=[
|
|
1593
|
+
Field("name", "What is your name?",
|
|
1594
|
+
validate=lambda v: "Name must be at least 2 characters." if len(v.strip()) < 2 else None,
|
|
1595
|
+
transform=str.strip),
|
|
1596
|
+
ButtonsField("tier", "Which plan are you on?", options=[
|
|
1597
|
+
Option("Standard", value="standard"),
|
|
1598
|
+
Option("Premium", value="premium"),
|
|
1599
|
+
]),
|
|
1600
|
+
],
|
|
1601
|
+
next="register_action",
|
|
1602
|
+
))
|
|
1603
|
+
|
|
1604
|
+
tree.add("register_action", Action(fn=do_register, next="main_menu"))
|
|
1605
|
+
|
|
1606
|
+
# Main menu
|
|
1607
|
+
tree.add("main_menu", Menu(
|
|
1608
|
+
text=lambda s: f"Hi {s.context.get('user', {}).get('name', 'there')} 👋 How can I help?",
|
|
1609
|
+
options=[
|
|
1610
|
+
Option("🎫 New ticket", next="new_ticket"),
|
|
1611
|
+
Option("📋 My tickets", next="my_tickets"),
|
|
1612
|
+
Option("📊 Download report", next="report_media"),
|
|
1613
|
+
Option("ℹ️ My plan", next="plan_action"),
|
|
1614
|
+
],
|
|
1615
|
+
button_label="Main Menu",
|
|
1616
|
+
))
|
|
1617
|
+
|
|
1618
|
+
# New ticket flow (with conditional fields)
|
|
1619
|
+
tree.add("new_ticket", Input(
|
|
1620
|
+
title="New Ticket",
|
|
1621
|
+
fields=[
|
|
1622
|
+
ButtonsField("ticket_type", "What type of issue is this?", options=[
|
|
1623
|
+
Option("🐛 Bug", value="bug"),
|
|
1624
|
+
Option("💡 Feature", value="feature"),
|
|
1625
|
+
Option("❓ Question", value="question"),
|
|
1626
|
+
]),
|
|
1627
|
+
Field("summary", "Describe your issue in one sentence:"),
|
|
1628
|
+
|
|
1629
|
+
# Bug-only fields
|
|
1630
|
+
BranchField(
|
|
1631
|
+
when=lambda s: s.collected.get("ticket_type") == "bug",
|
|
1632
|
+
fields=[
|
|
1633
|
+
Field("detail", "What steps reproduce the bug?"),
|
|
1634
|
+
ImageField("screenshot", "Attach a screenshot (optional — send any text to skip):",
|
|
1635
|
+
rejection_text="Please send an image or type 'skip'."),
|
|
1636
|
+
],
|
|
1637
|
+
),
|
|
1638
|
+
|
|
1639
|
+
# Feature-only fields
|
|
1640
|
+
BranchField(
|
|
1641
|
+
when=lambda s: s.collected.get("ticket_type") == "feature",
|
|
1642
|
+
fields=[
|
|
1643
|
+
Field("detail", "Describe the feature you'd like in more detail:"),
|
|
1644
|
+
],
|
|
1645
|
+
),
|
|
1646
|
+
],
|
|
1647
|
+
next="confirm_ticket",
|
|
1648
|
+
))
|
|
1649
|
+
|
|
1650
|
+
tree.add("confirm_ticket", Confirm(
|
|
1651
|
+
text=lambda c: (
|
|
1652
|
+
f"📋 Ticket summary:\n\n"
|
|
1653
|
+
f"Type: {c['ticket_type']}\n"
|
|
1654
|
+
f"Issue: {c['summary']}\n"
|
|
1655
|
+
f"Details: {c.get('detail', '—')}\n\n"
|
|
1656
|
+
f"Submit this ticket?"
|
|
1657
|
+
),
|
|
1658
|
+
options=[
|
|
1659
|
+
Option("✅ Submit", next="submit_ticket_action"),
|
|
1660
|
+
Option("✏️ Edit", next="new_ticket"),
|
|
1661
|
+
Option("❌ Cancel", next="main_menu"),
|
|
1662
|
+
],
|
|
1663
|
+
))
|
|
1664
|
+
|
|
1665
|
+
tree.add("submit_ticket_action", Action(fn=do_submit_ticket, next="main_menu"))
|
|
1666
|
+
|
|
1667
|
+
# My tickets — dynamic list
|
|
1668
|
+
tree.add("my_tickets", ListNode(
|
|
1669
|
+
fetch = lambda session: get_tickets(session.user_id),
|
|
1670
|
+
item_label = lambda t: f"#{t['id']} — {t['type']}",
|
|
1671
|
+
item_description = lambda t: t["summary"][:60],
|
|
1672
|
+
on_select = "main_menu", # in a real app: go to ticket detail node
|
|
1673
|
+
title = "📋 Your Tickets",
|
|
1674
|
+
empty_text = "You haven't raised any tickets yet.",
|
|
1675
|
+
interactive = True,
|
|
1676
|
+
button_label = "My Tickets",
|
|
1677
|
+
extra_options=[Option("🔙 Back", next="main_menu")],
|
|
1678
|
+
))
|
|
1679
|
+
|
|
1680
|
+
# Report download
|
|
1681
|
+
tree.add("report_media", MediaReply(
|
|
1682
|
+
generate = lambda session, collected: b"%PDF-1.4 ... (real PDF bytes here)",
|
|
1683
|
+
filename = lambda s, c: f"report_{s.user_id}.pdf",
|
|
1684
|
+
mime_type = "application/pdf",
|
|
1685
|
+
caption = "📊 Here is your support report.",
|
|
1686
|
+
next = "main_menu",
|
|
1687
|
+
))
|
|
1688
|
+
|
|
1689
|
+
# Plan info
|
|
1690
|
+
tree.add("plan_action", Action(fn=do_learn_more, next="main_menu"))
|
|
1691
|
+
|
|
1692
|
+
# ── engine ────────────────────────────────────────────────────────────────────
|
|
1693
|
+
|
|
1694
|
+
engine = BotEngine(tree=tree, session_timeout=600)
|
|
1695
|
+
|
|
1696
|
+
# ── WhatsApp send helper (REST) ───────────────────────────────────────────────
|
|
1697
|
+
|
|
1698
|
+
import os
|
|
1699
|
+
WA_TOKEN = os.getenv("WA_TOKEN", "")
|
|
1700
|
+
WA_PHONE_ID = os.getenv("WA_PHONE_ID", "")
|
|
1701
|
+
|
|
1702
|
+
async def send_whatsapp(user_id: str, phone: str, reply):
|
|
1703
|
+
from turnstack.reply import Reply
|
|
1704
|
+
url = f"https://graph.facebook.com/v19.0/{WA_PHONE_ID}/messages"
|
|
1705
|
+
headers = {"Authorization": f"Bearer {WA_TOKEN}", "Content-Type": "application/json"}
|
|
1706
|
+
|
|
1707
|
+
if reply.type == "media":
|
|
1708
|
+
# upload first, then send — simplified here
|
|
1709
|
+
payload = {"messaging_product": "whatsapp", "to": phone, "type": "text",
|
|
1710
|
+
"text": {"body": f"[File: {reply.filename}] {reply.body}"}}
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
elif reply.node_type in ("menu", "list"):
|
|
1714
|
+
# Interactive list from ListNode (sections in meta) or regular menu (options)
|
|
1715
|
+
if reply.meta.get("sections"):
|
|
1716
|
+
# ListNode interactive mode – use pre-built sections
|
|
1717
|
+
sections = reply.meta["sections"]
|
|
1718
|
+
button_label = reply.meta.get("button_label", "Options")
|
|
1719
|
+
payload = {
|
|
1720
|
+
"messaging_product": "whatsapp",
|
|
1721
|
+
"to": phone,
|
|
1722
|
+
"type": "interactive",
|
|
1723
|
+
"interactive": {
|
|
1724
|
+
"type": "list",
|
|
1725
|
+
"body": {"text": reply.body},
|
|
1726
|
+
"action": {
|
|
1727
|
+
"button": button_label,
|
|
1728
|
+
"sections": sections,
|
|
1729
|
+
},
|
|
1730
|
+
},
|
|
1731
|
+
}
|
|
1732
|
+
else:
|
|
1733
|
+
# Regular menu – build rows from reply.options
|
|
1734
|
+
rows = [{"id": o.value, "title": o.label[:24]} for o in reply.options]
|
|
1735
|
+
payload = {
|
|
1736
|
+
"messaging_product": "whatsapp",
|
|
1737
|
+
"to": phone,
|
|
1738
|
+
"type": "interactive",
|
|
1739
|
+
"interactive": {
|
|
1740
|
+
"type": "list",
|
|
1741
|
+
"body": {"text": reply.body},
|
|
1742
|
+
"action": {
|
|
1743
|
+
"button": reply.meta.get("button_label", "Options"),
|
|
1744
|
+
"sections": [{"title": "Options", "rows": rows}],
|
|
1745
|
+
},
|
|
1746
|
+
},
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
elif reply.node_type in ("confirm", "input_buttons"):
|
|
1750
|
+
buttons = [{"type": "reply", "reply": {"id": o.value, "title": o.label[:20]}}
|
|
1751
|
+
for o in reply.options[:3]]
|
|
1752
|
+
payload = {
|
|
1753
|
+
"messaging_product": "whatsapp", "to": phone, "type": "interactive",
|
|
1754
|
+
"interactive": {
|
|
1755
|
+
"type": "button", "body": {"text": reply.body},
|
|
1756
|
+
"action": {"buttons": buttons},
|
|
1757
|
+
},
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
elif reply.node_type == "input_location":
|
|
1761
|
+
payload = {
|
|
1762
|
+
"messaging_product": "whatsapp", "to": phone, "type": "interactive",
|
|
1763
|
+
"interactive": {
|
|
1764
|
+
"type": "location_request_message",
|
|
1765
|
+
"body": {"text": reply.body},
|
|
1766
|
+
"action": {"name": "send_location"},
|
|
1767
|
+
},
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
else:
|
|
1771
|
+
payload = {"messaging_product": "whatsapp", "to": phone,
|
|
1772
|
+
"type": "text", "text": {"body": reply.body}}
|
|
1773
|
+
|
|
1774
|
+
async with httpx.AsyncClient() as client:
|
|
1775
|
+
await client.post(url, json=payload, headers=headers)
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
# ── FastAPI webhook ───────────────────────────────────────────────────────────
|
|
1779
|
+
|
|
1780
|
+
app = FastAPI()
|
|
1781
|
+
|
|
1782
|
+
@app.get("/webhooks/whatsapp")
|
|
1783
|
+
async def verify(request: Request):
|
|
1784
|
+
p = request.query_params
|
|
1785
|
+
if p.get("hub.mode") == "subscribe" and p.get("hub.verify_token") == os.getenv("WA_VERIFY_TOKEN"):
|
|
1786
|
+
return Response(content=p.get("hub.challenge"), media_type="text/plain")
|
|
1787
|
+
raise HTTPException(403)
|
|
1788
|
+
|
|
1789
|
+
@app.post("/webhooks/whatsapp")
|
|
1790
|
+
async def webhook(request: Request):
|
|
1791
|
+
raw = await request.json()
|
|
1792
|
+
try:
|
|
1793
|
+
value = raw["entry"][0]["changes"][0]["value"]
|
|
1794
|
+
if "messages" not in value:
|
|
1795
|
+
return {"status": "no_messages"}
|
|
1796
|
+
|
|
1797
|
+
msg = value["messages"][0]
|
|
1798
|
+
phone = msg.get("from", "")
|
|
1799
|
+
user_id = msg.get("from_user_id", phone)
|
|
1800
|
+
msg_type = msg.get("type", "")
|
|
1801
|
+
|
|
1802
|
+
if msg_type == "text":
|
|
1803
|
+
incoming = IncomingMessage(user_id=user_id, type="text",
|
|
1804
|
+
text=msg["text"]["body"], raw=raw)
|
|
1805
|
+
elif msg_type == "interactive":
|
|
1806
|
+
itype = msg["interactive"]["type"]
|
|
1807
|
+
iid = (msg["interactive"]["button_reply"]["id"]
|
|
1808
|
+
if itype == "button_reply"
|
|
1809
|
+
else msg["interactive"]["list_reply"]["id"])
|
|
1810
|
+
incoming = IncomingMessage(user_id=user_id, type="interactive",
|
|
1811
|
+
interactive_id=iid, raw=raw)
|
|
1812
|
+
elif msg_type == "image":
|
|
1813
|
+
incoming = IncomingMessage(user_id=user_id, type="image",
|
|
1814
|
+
media_id=msg["image"]["id"],
|
|
1815
|
+
media_mime=msg["image"].get("mime_type"), raw=raw)
|
|
1816
|
+
elif msg_type == "document":
|
|
1817
|
+
incoming = IncomingMessage(user_id=user_id, type="document",
|
|
1818
|
+
media_id=msg["document"]["id"],
|
|
1819
|
+
media_mime=msg["document"].get("mime_type"),
|
|
1820
|
+
media_name=msg["document"].get("filename"), raw=raw)
|
|
1821
|
+
elif msg_type == "location":
|
|
1822
|
+
loc = msg["location"]
|
|
1823
|
+
incoming = IncomingMessage(user_id=user_id, type="location",
|
|
1824
|
+
location={"latitude": loc.get("latitude"),
|
|
1825
|
+
"longitude": loc.get("longitude"),
|
|
1826
|
+
"name": loc.get("name"),
|
|
1827
|
+
"address": loc.get("address")}, raw=raw)
|
|
1828
|
+
else:
|
|
1829
|
+
incoming = IncomingMessage(user_id=user_id, type=msg_type, raw=raw)
|
|
1830
|
+
|
|
1831
|
+
replies = await engine.process(incoming)
|
|
1832
|
+
for reply in replies:
|
|
1833
|
+
await send_whatsapp(user_id, phone, reply)
|
|
1834
|
+
|
|
1835
|
+
return {"status": "ok"}
|
|
1836
|
+
|
|
1837
|
+
except Exception:
|
|
1838
|
+
traceback.print_exc()
|
|
1839
|
+
raise HTTPException(500)
|
|
1840
|
+
```
|
|
1841
|
+
|
|
1842
|
+
---
|
|
1843
|
+
|
|
1844
|
+
*TurnStack — build the conversation, not the plumbing.*
|