turnstack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- turnstack/__init__.py +65 -0
- turnstack/engine.py +359 -0
- turnstack/exceptions.py +19 -0
- turnstack/handlers/__init__.py +19 -0
- turnstack/handlers/action.py +103 -0
- turnstack/handlers/base.py +273 -0
- turnstack/handlers/confirm.py +70 -0
- turnstack/handlers/input.py +652 -0
- turnstack/handlers/list_handler.py +339 -0
- turnstack/handlers/media_handler.py +73 -0
- turnstack/handlers/menu.py +188 -0
- turnstack/handlers/render_helpers.py +28 -0
- turnstack/handlers/router.py +104 -0
- turnstack/message.py +39 -0
- turnstack/nodes.py +709 -0
- turnstack/reply.py +67 -0
- turnstack/session.py +130 -0
- turnstack/stores/__init__.py +3 -0
- turnstack/stores/memory.py +60 -0
- turnstack/tree.py +151 -0
- turnstack-0.1.0.dist-info/METADATA +1844 -0
- turnstack-0.1.0.dist-info/RECORD +24 -0
- turnstack-0.1.0.dist-info/WHEEL +5 -0
- turnstack-0.1.0.dist-info/top_level.txt +1 -0
turnstack/nodes.py
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
"""
|
|
2
|
+
turnstack.nodes
|
|
3
|
+
===============
|
|
4
|
+
Typed node classes — the developer-facing API for building flow trees.
|
|
5
|
+
|
|
6
|
+
Every node serialises to a plain dict internally so the engine never changes,
|
|
7
|
+
but developers get full IDE autocomplete, type checking, and clear docstrings.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from turnstack.nodes import (
|
|
12
|
+
Menu, Input, Action, Confirm, Router, ListNode, MediaReply,
|
|
13
|
+
Option, Field, MenuField, ButtonsField, ImageField, DocumentField, LocationField,
|
|
14
|
+
Route,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
tree.add("register", Input(
|
|
18
|
+
title="Registration",
|
|
19
|
+
fields=[
|
|
20
|
+
Field("name", "What is your name?"),
|
|
21
|
+
MenuField("category", "Pick a category:", options=[
|
|
22
|
+
Option("Housing", next="housing"),
|
|
23
|
+
Option("Transport", next="transport"),
|
|
24
|
+
]),
|
|
25
|
+
ButtonsField("role", "Are you a landlord or tenant?", options=[
|
|
26
|
+
Option("Landlord", next="landlord"),
|
|
27
|
+
Option("Tenant", next="tenant"),
|
|
28
|
+
]),
|
|
29
|
+
ImageField("photo", "Please send your profile photo 📸"),
|
|
30
|
+
DocumentField("id_doc", "Upload your ID document 📄"),
|
|
31
|
+
LocationField("home_loc", "Share your home location 📍"),
|
|
32
|
+
],
|
|
33
|
+
next="confirm_node",
|
|
34
|
+
))
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
NodeDict = Dict[str, Any]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _to_dict(node: "BaseNode") -> NodeDict:
|
|
48
|
+
"""Recursively serialise a node object to a plain dict the engine uses."""
|
|
49
|
+
if isinstance(node, BaseNode):
|
|
50
|
+
return node.to_dict()
|
|
51
|
+
return node # already a dict (legacy support)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── option / route helpers ────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Option:
|
|
58
|
+
"""
|
|
59
|
+
A single choice in a Menu, Confirm, MenuField, or ButtonsField.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
label: Text shown to the user (truncated to 24 chars on WhatsApp buttons).
|
|
63
|
+
next: Node key to navigate to when this option is selected.
|
|
64
|
+
Inside a MenuField / ButtonsField this is used only when the
|
|
65
|
+
field is the *last* field — normally the Input node advances to
|
|
66
|
+
its own ``next`` after all fields are collected.
|
|
67
|
+
description: Optional subtitle shown in list-style interactive menus (max 72 chars).
|
|
68
|
+
value: Machine-readable value stored in session.collected for this field.
|
|
69
|
+
Defaults to the ``next`` key if not set.
|
|
70
|
+
"""
|
|
71
|
+
label: str
|
|
72
|
+
next: str = ""
|
|
73
|
+
description: str = ""
|
|
74
|
+
value: Optional[str] = None
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> Dict[str, str]:
|
|
77
|
+
d: Dict[str, Any] = {"label": self.label, "next": self.next}
|
|
78
|
+
if self.description:
|
|
79
|
+
d["description"] = self.description
|
|
80
|
+
d["value"] = self.value if self.value is not None else (self.next or self.label)
|
|
81
|
+
return d
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Route:
|
|
86
|
+
"""
|
|
87
|
+
A single conditional branch inside a Router node.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
when: Callable ``(session: Session) -> bool``.
|
|
91
|
+
The router evaluates conditions in order and takes the first True branch.
|
|
92
|
+
next: Node key to navigate to when the condition is True.
|
|
93
|
+
"""
|
|
94
|
+
when: Callable[["Session"], bool] # type: ignore[name-defined]
|
|
95
|
+
next: str
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
98
|
+
return {"when": self.when, "next": self.next}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── base ──────────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
class BaseNode:
|
|
104
|
+
"""All node classes inherit from this. Provides .to_dict() serialisation."""
|
|
105
|
+
|
|
106
|
+
def to_dict(self) -> NodeDict: # pragma: no cover
|
|
107
|
+
raise NotImplementedError
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── field base ────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class BaseField:
|
|
114
|
+
"""
|
|
115
|
+
Base class for all field types used inside an Input node.
|
|
116
|
+
|
|
117
|
+
All fields share ``name``, ``validate``, and ``transform``.
|
|
118
|
+
Subclasses add their type-specific rendering config.
|
|
119
|
+
|
|
120
|
+
The ``field_type`` class variable is the string tag the InputHandler
|
|
121
|
+
switches on — it must match one of the cases in ``InputHandler._render_field``.
|
|
122
|
+
|
|
123
|
+
Note: subclasses that add positional args (e.g. ``prompt``) declare them
|
|
124
|
+
directly so the dataclass field order stays intuitive for callers.
|
|
125
|
+
|
|
126
|
+
``skip_if`` — optional callable ``(session: Session) -> bool``.
|
|
127
|
+
When provided, the InputHandler evaluates it at runtime just before
|
|
128
|
+
presenting the field. If it returns ``True`` the field is silently
|
|
129
|
+
skipped and ``None`` is stored under its name in ``session.collected``.
|
|
130
|
+
This lets you conditionally show follow-up fields based on earlier answers.
|
|
131
|
+
"""
|
|
132
|
+
name: str
|
|
133
|
+
skip_if: Optional[Callable[..., bool]] = field(default=None, init=True, repr=False, kw_only=True)
|
|
134
|
+
|
|
135
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
136
|
+
d: Dict[str, Any] = {
|
|
137
|
+
"field_type": getattr(self, "field_type", "text"),
|
|
138
|
+
"name": self.name,
|
|
139
|
+
}
|
|
140
|
+
validate = getattr(self, "validate", None)
|
|
141
|
+
transform = getattr(self, "transform", None)
|
|
142
|
+
skip_if = getattr(self, "skip_if", None)
|
|
143
|
+
if validate:
|
|
144
|
+
d["validate"] = validate
|
|
145
|
+
if transform:
|
|
146
|
+
d["transform"] = transform
|
|
147
|
+
if skip_if is not None:
|
|
148
|
+
if not callable(skip_if):
|
|
149
|
+
raise TypeError(
|
|
150
|
+
f"Field '{self.name}': skip_if must be a callable "
|
|
151
|
+
f"(e.g. lambda session: ...), got {type(skip_if).__name__!r}."
|
|
152
|
+
)
|
|
153
|
+
d["skip_if"] = skip_if
|
|
154
|
+
return d
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── concrete field types ──────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class Field(BaseField):
|
|
161
|
+
"""
|
|
162
|
+
A plain text input field.
|
|
163
|
+
|
|
164
|
+
The engine sends a text prompt and accepts any text reply.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
name: Key under which the collected value is stored in session.collected.
|
|
168
|
+
prompt: Question shown to the user for this field.
|
|
169
|
+
validate: Optional callable ``(value: str) -> Optional[str]``.
|
|
170
|
+
Return an error string to reject input, or None to accept.
|
|
171
|
+
transform: Optional callable ``(value: str) -> Any`` applied before storing.
|
|
172
|
+
"""
|
|
173
|
+
prompt: str = ""
|
|
174
|
+
validate: Optional[Callable[[Any], Optional[str]]] = None
|
|
175
|
+
transform: Optional[Callable[[Any], Any]] = None
|
|
176
|
+
field_type: str = field(default="text", init=False, repr=False)
|
|
177
|
+
|
|
178
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
179
|
+
d = super().to_dict()
|
|
180
|
+
d["prompt"] = self.prompt
|
|
181
|
+
return d
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Alias so existing code using Field(name, prompt, ...) keeps working
|
|
185
|
+
TextField = Field
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class MenuField(BaseField):
|
|
190
|
+
"""
|
|
191
|
+
A list-style interactive selection field inside an Input node.
|
|
192
|
+
|
|
193
|
+
Renders as a WhatsApp interactive list message. The user picks one
|
|
194
|
+
option; its ``value`` (or ``label`` as fallback) is stored in
|
|
195
|
+
session.collected under ``name``.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
name: Key under which the selected value is stored.
|
|
199
|
+
prompt: Question / body text shown above the list.
|
|
200
|
+
options: List of :class:`Option` objects.
|
|
201
|
+
button_label: Custom label for the interactive list open button.
|
|
202
|
+
Default: "Options".
|
|
203
|
+
header: Optional header line.
|
|
204
|
+
footer: Optional footer line.
|
|
205
|
+
allow_numeric: Also accept "1", "2" … digit input as fallback.
|
|
206
|
+
"""
|
|
207
|
+
prompt: str = ""
|
|
208
|
+
options: Union[List[Option], Callable[..., List[Option]]] = field(default_factory=list)
|
|
209
|
+
button_label: str = "Options"
|
|
210
|
+
header: str = ""
|
|
211
|
+
footer: str = ""
|
|
212
|
+
allow_numeric: bool = False
|
|
213
|
+
validate: Optional[Callable[[Any], Optional[str]]] = None
|
|
214
|
+
transform: Optional[Callable[[Any], Any]] = None
|
|
215
|
+
field_type: str = field(default="menu", init=False, repr=False)
|
|
216
|
+
|
|
217
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
218
|
+
d = super().to_dict()
|
|
219
|
+
d["prompt"] = self.prompt
|
|
220
|
+
# options may be a static list OR a callable resolved at render time
|
|
221
|
+
d["options"] = self.options # kept as-is; InputHandler resolves callables
|
|
222
|
+
d["button_label"] = self.button_label
|
|
223
|
+
d["header"] = self.header
|
|
224
|
+
d["footer"] = self.footer
|
|
225
|
+
d["allow_numeric"] = self.allow_numeric
|
|
226
|
+
return d
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class ButtonsField(BaseField):
|
|
231
|
+
"""
|
|
232
|
+
An interactive button selection field inside an Input node.
|
|
233
|
+
|
|
234
|
+
Renders as WhatsApp reply buttons (max 3 buttons). The user taps one;
|
|
235
|
+
its ``value`` is stored in session.collected under ``name``.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
name: Key under which the selected value is stored.
|
|
239
|
+
prompt: Question / body text shown with the buttons.
|
|
240
|
+
options: List of :class:`Option` objects (max 3).
|
|
241
|
+
allow_numeric: Also accept "1" / "2" / "3" digit input as fallback.
|
|
242
|
+
"""
|
|
243
|
+
prompt: str = ""
|
|
244
|
+
options: List[Option] = field(default_factory=list)
|
|
245
|
+
allow_numeric: bool = False
|
|
246
|
+
validate: Optional[Callable[[Any], Optional[str]]] = None
|
|
247
|
+
transform: Optional[Callable[[Any], Any]] = None
|
|
248
|
+
field_type: str = field(default="buttons", init=False, repr=False)
|
|
249
|
+
|
|
250
|
+
def __post_init__(self):
|
|
251
|
+
if len(self.options) > 3:
|
|
252
|
+
raise ValueError(
|
|
253
|
+
f"ButtonsField '{self.name}' has {len(self.options)} options — "
|
|
254
|
+
"WhatsApp interactive buttons support at most 3."
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
258
|
+
d = super().to_dict()
|
|
259
|
+
d["prompt"] = self.prompt
|
|
260
|
+
d["options"] = [o.to_dict() for o in self.options]
|
|
261
|
+
d["allow_numeric"] = self.allow_numeric
|
|
262
|
+
return d
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass
|
|
266
|
+
class ImageField(BaseField):
|
|
267
|
+
"""
|
|
268
|
+
A media input field that waits for the user to send an image.
|
|
269
|
+
|
|
270
|
+
The engine rejects any non-image message with ``rejection_text`` until
|
|
271
|
+
a valid image is received. The collected value is a dict::
|
|
272
|
+
|
|
273
|
+
{
|
|
274
|
+
"media_id": str, # WhatsApp media ID
|
|
275
|
+
"mime_type": str, # e.g. "image/jpeg"
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
name: Key under which the collected dict is stored.
|
|
280
|
+
prompt: Message asking the user to send their image.
|
|
281
|
+
rejection_text: Message shown when the user sends the wrong type.
|
|
282
|
+
"""
|
|
283
|
+
prompt: str = ""
|
|
284
|
+
rejection_text: str = "⚠️ Please send an image (photo)."
|
|
285
|
+
validate: Optional[Callable[[Any], Optional[str]]] = None
|
|
286
|
+
transform: Optional[Callable[[Any], Any]] = None
|
|
287
|
+
field_type: str = field(default="image", init=False, repr=False)
|
|
288
|
+
|
|
289
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
290
|
+
d = super().to_dict()
|
|
291
|
+
d["prompt"] = self.prompt
|
|
292
|
+
d["rejection_text"] = self.rejection_text
|
|
293
|
+
return d
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass
|
|
297
|
+
class DocumentField(BaseField):
|
|
298
|
+
"""
|
|
299
|
+
A media input field that waits for the user to send a document.
|
|
300
|
+
|
|
301
|
+
The engine rejects any non-document message with ``rejection_text`` until
|
|
302
|
+
a valid document is received. Optionally filter by ``accept`` MIME types.
|
|
303
|
+
|
|
304
|
+
The collected value is a dict::
|
|
305
|
+
|
|
306
|
+
{
|
|
307
|
+
"media_id": str, # WhatsApp media ID
|
|
308
|
+
"mime_type": str, # e.g. "application/pdf"
|
|
309
|
+
"filename": str, # original filename (may be empty)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
name: Key under which the collected dict is stored.
|
|
314
|
+
prompt: Message asking the user to send their document.
|
|
315
|
+
accept: Optional list of accepted MIME types,
|
|
316
|
+
e.g. ``["application/pdf", "image/jpeg"]``.
|
|
317
|
+
If empty, any document is accepted.
|
|
318
|
+
rejection_text: Message shown when the user sends the wrong type.
|
|
319
|
+
"""
|
|
320
|
+
prompt: str = ""
|
|
321
|
+
accept: List[str] = field(default_factory=list)
|
|
322
|
+
rejection_text: str = "⚠️ Please send a document file."
|
|
323
|
+
validate: Optional[Callable[[Any], Optional[str]]] = None
|
|
324
|
+
transform: Optional[Callable[[Any], Any]] = None
|
|
325
|
+
field_type: str = field(default="document", init=False, repr=False)
|
|
326
|
+
|
|
327
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
328
|
+
d = super().to_dict()
|
|
329
|
+
d["prompt"] = self.prompt
|
|
330
|
+
d["accept"] = list(self.accept)
|
|
331
|
+
d["rejection_text"] = self.rejection_text
|
|
332
|
+
return d
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@dataclass
|
|
336
|
+
class LocationField(BaseField):
|
|
337
|
+
"""
|
|
338
|
+
A location input field that sends a location-request message and waits
|
|
339
|
+
for the user to share their location.
|
|
340
|
+
|
|
341
|
+
The engine rejects any non-location message with ``rejection_text``.
|
|
342
|
+
|
|
343
|
+
The collected value is a dict::
|
|
344
|
+
|
|
345
|
+
{
|
|
346
|
+
"latitude": float,
|
|
347
|
+
"longitude": float,
|
|
348
|
+
"name": str | None,
|
|
349
|
+
"address": str | None,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
name: Key under which the collected dict is stored.
|
|
354
|
+
prompt: Text shown alongside the "Send Location" button.
|
|
355
|
+
On WhatsApp, this becomes the body of a
|
|
356
|
+
``interactive.type = "location_request_message"``.
|
|
357
|
+
rejection_text: Message shown when the user sends something other
|
|
358
|
+
than a location.
|
|
359
|
+
"""
|
|
360
|
+
prompt: str = "Please share your location 📍"
|
|
361
|
+
rejection_text: str = "⚠️ Please use the 📍 button to share your location."
|
|
362
|
+
validate: Optional[Callable[[Any], Optional[str]]] = None
|
|
363
|
+
transform: Optional[Callable[[Any], Any]] = None
|
|
364
|
+
field_type: str = field(default="location", init=False, repr=False)
|
|
365
|
+
|
|
366
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
367
|
+
d = super().to_dict()
|
|
368
|
+
d["prompt"] = self.prompt
|
|
369
|
+
d["rejection_text"] = self.rejection_text
|
|
370
|
+
return d
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@dataclass
|
|
374
|
+
class BranchField:
|
|
375
|
+
"""
|
|
376
|
+
A conditional group of fields inside an Input node.
|
|
377
|
+
|
|
378
|
+
Acts like a :class:`Route` but for fields — when ``when(session)`` returns
|
|
379
|
+
``True`` the enclosed fields are injected into the active field sequence at
|
|
380
|
+
that position; when it returns ``False`` they are silently skipped.
|
|
381
|
+
|
|
382
|
+
Unlike ``skip_if`` (which marks individual fields), ``BranchField`` groups
|
|
383
|
+
a whole set of related fields under a single condition, keeping the form
|
|
384
|
+
definition readable and logically organised.
|
|
385
|
+
|
|
386
|
+
Multiple ``BranchField`` blocks can share the same branch point (e.g. one
|
|
387
|
+
for each value of an earlier MenuField), giving you Router-style branching
|
|
388
|
+
inside a single Input node.
|
|
389
|
+
|
|
390
|
+
``BranchField`` objects are *not* ``BaseField`` subclasses — they carry no
|
|
391
|
+
``name`` of their own. The InputHandler resolves them at runtime by
|
|
392
|
+
flattening the active field list before processing each step.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
when: Callable ``(session: Session) -> bool``.
|
|
396
|
+
Evaluated once, just before the first field in the branch
|
|
397
|
+
would be presented. The full ``session`` (including
|
|
398
|
+
``session.collected`` populated so far) is available.
|
|
399
|
+
fields: Ordered list of field objects to inject when ``when`` is True.
|
|
400
|
+
Any mix of :class:`Field`, :class:`MenuField`,
|
|
401
|
+
:class:`ButtonsField`, :class:`ImageField`,
|
|
402
|
+
:class:`DocumentField`, :class:`LocationField`, or even
|
|
403
|
+
nested :class:`BranchField` objects.
|
|
404
|
+
|
|
405
|
+
Example::
|
|
406
|
+
|
|
407
|
+
Input(
|
|
408
|
+
title="Property Registration",
|
|
409
|
+
fields=[
|
|
410
|
+
MenuField("property_type", "What type of property?", options=[
|
|
411
|
+
Option("🏠 Residential", value="residential"),
|
|
412
|
+
Option("🏢 Commercial", value="commercial"),
|
|
413
|
+
]),
|
|
414
|
+
|
|
415
|
+
BranchField(
|
|
416
|
+
when=lambda s: s.collected.get("property_type") == "residential",
|
|
417
|
+
fields=[
|
|
418
|
+
Field("num_bedrooms", "How many bedrooms?"),
|
|
419
|
+
ButtonsField("has_parking", "Does it have parking?", options=[
|
|
420
|
+
Option("✅ Yes", value="yes"),
|
|
421
|
+
Option("❌ No", value="no"),
|
|
422
|
+
]),
|
|
423
|
+
],
|
|
424
|
+
),
|
|
425
|
+
|
|
426
|
+
BranchField(
|
|
427
|
+
when=lambda s: s.collected.get("property_type") == "commercial",
|
|
428
|
+
fields=[
|
|
429
|
+
Field("floor_area", "What is the floor area (sqm)?"),
|
|
430
|
+
Field("zoning", "What is the zoning class?"),
|
|
431
|
+
],
|
|
432
|
+
),
|
|
433
|
+
|
|
434
|
+
Field("asking_price", "What is the asking price (Ksh)?"),
|
|
435
|
+
],
|
|
436
|
+
next="confirm_property",
|
|
437
|
+
)
|
|
438
|
+
"""
|
|
439
|
+
when: Callable[..., bool]
|
|
440
|
+
fields: List[Any] # List[BaseField | BranchField]
|
|
441
|
+
|
|
442
|
+
field_type: str = field(default="branch", init=False, repr=False)
|
|
443
|
+
|
|
444
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
445
|
+
return {
|
|
446
|
+
"field_type": "branch",
|
|
447
|
+
"when": self.when,
|
|
448
|
+
"fields": [
|
|
449
|
+
f.to_dict() if hasattr(f, "to_dict") else f
|
|
450
|
+
for f in self.fields
|
|
451
|
+
],
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ── top-level node classes ────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
@dataclass
|
|
458
|
+
class Menu(BaseNode):
|
|
459
|
+
"""
|
|
460
|
+
Show the user a numbered list of options.
|
|
461
|
+
|
|
462
|
+
The engine renders this as a WhatsApp interactive list message.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
text: Message body shown above the options.
|
|
466
|
+
options: List of :class:`Option` objects.
|
|
467
|
+
allow_numeric: If True, also accept plain digit input ("1", "2" …).
|
|
468
|
+
header: Optional header line shown in the interactive list.
|
|
469
|
+
footer: Optional footer line shown in the interactive list.
|
|
470
|
+
button_label: Custom label for the interactive list button. Default: "Options".
|
|
471
|
+
"""
|
|
472
|
+
text: str
|
|
473
|
+
options: List[Option]
|
|
474
|
+
allow_numeric: bool = False
|
|
475
|
+
header: str = ""
|
|
476
|
+
footer: str = ""
|
|
477
|
+
button_label: str = "Options"
|
|
478
|
+
|
|
479
|
+
def to_dict(self) -> NodeDict:
|
|
480
|
+
return {
|
|
481
|
+
"type": "menu",
|
|
482
|
+
"text": self.text,
|
|
483
|
+
"options": [o.to_dict() for o in self.options],
|
|
484
|
+
"allow_numeric": self.allow_numeric,
|
|
485
|
+
"header": self.header,
|
|
486
|
+
"footer": self.footer,
|
|
487
|
+
"button_label": self.button_label,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@dataclass
|
|
492
|
+
class Input(BaseNode):
|
|
493
|
+
"""
|
|
494
|
+
Collect several fields in sequence, owned by a single logical form node.
|
|
495
|
+
|
|
496
|
+
Each field can be a different type — text, interactive list, buttons,
|
|
497
|
+
image, document, or location. The engine walks through ``fields`` one at a
|
|
498
|
+
time, sends the appropriate prompt, validates the response, stores the value
|
|
499
|
+
in ``session.collected``, then advances to ``next`` when all fields are done.
|
|
500
|
+
|
|
501
|
+
Supported field types:
|
|
502
|
+
- :class:`Field` / :class:`TextField` — plain text answer
|
|
503
|
+
- :class:`MenuField` — interactive list selection
|
|
504
|
+
- :class:`ButtonsField` — interactive button selection (≤3)
|
|
505
|
+
- :class:`ImageField` — waits for an image
|
|
506
|
+
- :class:`DocumentField` — waits for a document
|
|
507
|
+
- :class:`LocationField` — waits for a shared location
|
|
508
|
+
|
|
509
|
+
Args:
|
|
510
|
+
fields: Ordered list of field objects (any mix of types above).
|
|
511
|
+
next: Node key to navigate to after all fields are collected.
|
|
512
|
+
title: Optional flow title shown on every step as "Title - Step N of M".
|
|
513
|
+
If omitted, only the bare "(N/M)" counter is shown.
|
|
514
|
+
intro: Deprecated alias for ``title``. If both are set, ``title`` wins.
|
|
515
|
+
"""
|
|
516
|
+
fields: List[BaseField]
|
|
517
|
+
next: str
|
|
518
|
+
title: str = ""
|
|
519
|
+
intro: str = "" # kept for backward compatibility — maps to title if title is blank
|
|
520
|
+
|
|
521
|
+
def to_dict(self) -> NodeDict:
|
|
522
|
+
effective_title = self.title or self.intro # title wins; fall back to intro
|
|
523
|
+
return {
|
|
524
|
+
"type": "input",
|
|
525
|
+
"fields": [f.to_dict() for f in self.fields],
|
|
526
|
+
"next": self.next,
|
|
527
|
+
"title": effective_title,
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@dataclass
|
|
532
|
+
class Confirm(BaseNode):
|
|
533
|
+
"""
|
|
534
|
+
Show a summary and ask the user to confirm before committing a write action.
|
|
535
|
+
|
|
536
|
+
Args:
|
|
537
|
+
text: Summary text. Can be a plain string or a callable
|
|
538
|
+
``(collected: dict) -> str``.
|
|
539
|
+
options: List of :class:`Option` objects (max 3, WhatsApp button limit).
|
|
540
|
+
allow_numeric: Also accept digit input. Default: False.
|
|
541
|
+
"""
|
|
542
|
+
text: Union[str, Callable[[Dict[str, Any]], str]]
|
|
543
|
+
options: List[Option]
|
|
544
|
+
allow_numeric: bool = False
|
|
545
|
+
|
|
546
|
+
def __post_init__(self):
|
|
547
|
+
if len(self.options) > 3:
|
|
548
|
+
raise ValueError(
|
|
549
|
+
f"Confirm node can have at most 3 options (WhatsApp button limit), got {len(self.options)}."
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
def to_dict(self) -> NodeDict:
|
|
553
|
+
return {
|
|
554
|
+
"type": "confirm",
|
|
555
|
+
"text": self.text,
|
|
556
|
+
"options": [o.to_dict() for o in self.options],
|
|
557
|
+
"allow_numeric": self.allow_numeric,
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
@dataclass
|
|
562
|
+
class Action(BaseNode):
|
|
563
|
+
"""
|
|
564
|
+
Execute a side-effect function and advance to the next node.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
fn: Callable ``(session: Session, collected: dict) -> str | Reply``.
|
|
568
|
+
next: Node key to navigate to after the action completes.
|
|
569
|
+
Use ``"__end__"`` to terminate the session cleanly.
|
|
570
|
+
"""
|
|
571
|
+
fn: Callable[..., Any]
|
|
572
|
+
next: str = "welcome"
|
|
573
|
+
|
|
574
|
+
def to_dict(self) -> NodeDict:
|
|
575
|
+
return {"type": "action", "fn": self.fn, "next": self.next}
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@dataclass
|
|
579
|
+
class Router(BaseNode):
|
|
580
|
+
"""
|
|
581
|
+
Silently branch to different nodes based on session state — no user input required.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
routes: Ordered list of :class:`Route` objects.
|
|
585
|
+
default: Node key used when no route condition matches.
|
|
586
|
+
before: Optional callable ``(session) -> None`` run before evaluation.
|
|
587
|
+
"""
|
|
588
|
+
routes: List[Route]
|
|
589
|
+
default: str
|
|
590
|
+
before: Optional[Callable[..., Any]] = None
|
|
591
|
+
|
|
592
|
+
def to_dict(self) -> NodeDict:
|
|
593
|
+
return {
|
|
594
|
+
"type": "router",
|
|
595
|
+
"routes": [r.to_dict() for r in self.routes],
|
|
596
|
+
"default": self.default,
|
|
597
|
+
"before": self.before,
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@dataclass
|
|
602
|
+
class ListNode(BaseNode):
|
|
603
|
+
"""
|
|
604
|
+
Render a dynamic list of items fetched at runtime with automatic pagination.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
fetch: Callable. Simple: ``(session) -> list``.
|
|
608
|
+
Paginated: ``(session, page, page_size) -> (list, int) | dict``.
|
|
609
|
+
item_label: Callable ``(item: Any) -> str`` for display label.
|
|
610
|
+
on_select: Node to go to when an item is selected.
|
|
611
|
+
title: Heading shown above the list.
|
|
612
|
+
empty_text: Text shown when the list is empty.
|
|
613
|
+
item_description: Optional subtitle callable.
|
|
614
|
+
extra_options: Static :class:`Option` list (max 3, last page only).
|
|
615
|
+
interactive: If True, render as interactive list.
|
|
616
|
+
button_label: Custom label for the interactive list button.
|
|
617
|
+
page_size: Items per page (default 8, clamped to 1–10).
|
|
618
|
+
"""
|
|
619
|
+
fetch: Callable[..., Any]
|
|
620
|
+
item_label: Callable[[Any], str]
|
|
621
|
+
on_select: str
|
|
622
|
+
title: str = "Select an option"
|
|
623
|
+
empty_text: str = "No items available."
|
|
624
|
+
item_description: Optional[Callable[[Any], str]] = None
|
|
625
|
+
extra_options: List[Option] = field(default_factory=list)
|
|
626
|
+
interactive: bool = False
|
|
627
|
+
button_label: str = "Options"
|
|
628
|
+
page_size: int = 8
|
|
629
|
+
|
|
630
|
+
def __post_init__(self):
|
|
631
|
+
if self.interactive and len(self.extra_options) > 3:
|
|
632
|
+
raise ValueError(
|
|
633
|
+
f"ListNode interactive mode supports at most 3 extra_options, got {len(self.extra_options)}."
|
|
634
|
+
)
|
|
635
|
+
self.page_size = max(1, min(10, self.page_size))
|
|
636
|
+
|
|
637
|
+
def to_dict(self) -> NodeDict:
|
|
638
|
+
return {
|
|
639
|
+
"type": "list",
|
|
640
|
+
"fetch": self.fetch,
|
|
641
|
+
"item_label": self.item_label,
|
|
642
|
+
"on_select": self.on_select,
|
|
643
|
+
"title": self.title,
|
|
644
|
+
"empty_text": self.empty_text,
|
|
645
|
+
"item_description": self.item_description,
|
|
646
|
+
"extra_options": [opt.to_dict() for opt in self.extra_options],
|
|
647
|
+
"interactive": self.interactive,
|
|
648
|
+
"button_label": self.button_label,
|
|
649
|
+
"page_size": self.page_size,
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@dataclass
|
|
654
|
+
class MediaReply(BaseNode):
|
|
655
|
+
"""
|
|
656
|
+
Send a file (PDF, Excel, image, etc.) to the user, then continue the flow.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
generate: Callable ``(session, collected) -> bytes``.
|
|
660
|
+
filename: Filename or callable ``(session, collected) -> str``.
|
|
661
|
+
mime_type: MIME type string, e.g. ``"application/pdf"``.
|
|
662
|
+
caption: Optional caption text or callable ``(session, collected) -> str``.
|
|
663
|
+
next: Node key to navigate to after sending the file.
|
|
664
|
+
"""
|
|
665
|
+
generate: Callable[..., bytes]
|
|
666
|
+
filename: Union[str, Callable[..., str]]
|
|
667
|
+
mime_type: str
|
|
668
|
+
caption: Union[str, Callable[..., str]] = ""
|
|
669
|
+
next: str = "welcome"
|
|
670
|
+
|
|
671
|
+
def to_dict(self) -> NodeDict:
|
|
672
|
+
return {
|
|
673
|
+
"type": "media",
|
|
674
|
+
"generate": self.generate,
|
|
675
|
+
"filename": self.filename,
|
|
676
|
+
"mime_type": self.mime_type,
|
|
677
|
+
"caption": self.caption,
|
|
678
|
+
"next": self.next,
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
# ── convenience re-exports ────────────────────────────────────────────────────
|
|
683
|
+
|
|
684
|
+
__all__ = [
|
|
685
|
+
# option / route helpers
|
|
686
|
+
"Option",
|
|
687
|
+
"Route",
|
|
688
|
+
# field types
|
|
689
|
+
"BaseField",
|
|
690
|
+
"Field",
|
|
691
|
+
"TextField", # alias for Field
|
|
692
|
+
"MenuField",
|
|
693
|
+
"ButtonsField",
|
|
694
|
+
"ImageField",
|
|
695
|
+
"DocumentField",
|
|
696
|
+
"LocationField",
|
|
697
|
+
"BranchField",
|
|
698
|
+
# node types
|
|
699
|
+
"BaseNode",
|
|
700
|
+
"Menu",
|
|
701
|
+
"Input",
|
|
702
|
+
"Confirm",
|
|
703
|
+
"Action",
|
|
704
|
+
"Router",
|
|
705
|
+
"ListNode",
|
|
706
|
+
"MediaReply",
|
|
707
|
+
# type alias
|
|
708
|
+
"NodeDict",
|
|
709
|
+
]
|