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.
@@ -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.*