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/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
+ ]