turnstack 0.1.2__tar.gz → 0.1.3__tar.gz

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