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,652 @@
1
+ """
2
+ turnstack.handlers.input
3
+ ========================
4
+ InputHandler — processes ``input`` nodes containing typed fields.
5
+
6
+ Supported field types
7
+ ---------------------
8
+ - ``text`` (:class:`~turnstack.nodes.Field`)
9
+ Plain text prompt → accepts any text reply.
10
+
11
+ - ``menu`` (:class:`~turnstack.nodes.MenuField`)
12
+ Interactive list prompt → accepts ``interactive_id`` (list_reply)
13
+ or numeric digit fallback when ``allow_numeric=True``.
14
+
15
+ - ``buttons`` (:class:`~turnstack.nodes.ButtonsField`)
16
+ Interactive button prompt → accepts ``interactive_id`` (button_reply)
17
+ or numeric digit fallback when ``allow_numeric=True``.
18
+ The stored value is the selected option's ``value``.
19
+
20
+ - ``image`` (:class:`~turnstack.nodes.ImageField`)
21
+ Text prompt → accepts ``message.type == "image"``.
22
+ Rejects everything else with the field's ``rejection_text``.
23
+ Stored value: ``{"media_id": str, "mime_type": str}``.
24
+
25
+ - ``document`` (:class:`~turnstack.nodes.DocumentField`)
26
+ Text prompt → accepts ``message.type == "document"``.
27
+ Optional MIME filtering via ``accept`` list.
28
+ Rejects everything else with the field's ``rejection_text``.
29
+ Stored value: ``{"media_id": str, "mime_type": str, "filename": str}``.
30
+
31
+ - ``location`` (:class:`~turnstack.nodes.LocationField`)
32
+ Location-request prompt → accepts ``message.type == "location"``.
33
+ Rejects everything else with the field's ``rejection_text``.
34
+ Stored value: ``{"latitude": float, "longitude": float,
35
+ "name": str|None, "address": str|None}``.
36
+ """
37
+
38
+ from __future__ import annotations
39
+ from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
40
+
41
+ from ..message import IncomingMessage
42
+ from ..reply import Reply, ReplyOption
43
+ from ..session import Session
44
+ from .base import NodeHandler
45
+
46
+ if TYPE_CHECKING:
47
+ from ..tree import FlowTree
48
+
49
+ # session.pagination key for the current field index inside an Input node
50
+ _IDX_KEY_TMPL = "mi_{node}_idx"
51
+ _MENU_PAGE_TMPL = "mi_{node}_f{field}_pg" # per-field menu page state
52
+ _MAX_MENU_ROWS = 10
53
+ _MENU_PREV = "__mf_prev__"
54
+ _MENU_NEXT = "__mf_next__"
55
+
56
+
57
+ def _flatten_fields(raw_fields: list, session) -> list:
58
+ """
59
+ Recursively expand BranchField entries into a flat, concrete field list.
60
+
61
+ Handles both serialised dicts (field_type == "branch") and
62
+ BranchField objects directly, since Input.to_dict() serialises all
63
+ fields but developers may also pass objects that reach here unserialized.
64
+ """
65
+ result = []
66
+ for f in raw_fields:
67
+ # Support both dict and BranchField object
68
+ if isinstance(f, dict):
69
+ ftype = f.get("field_type")
70
+ when = f.get("when")
71
+ children = f.get("fields", [])
72
+ else:
73
+ ftype = getattr(f, "field_type", None)
74
+ when = getattr(f, "when", None)
75
+ children = getattr(f, "fields", [])
76
+
77
+ if ftype == "branch":
78
+ try:
79
+ active = callable(when) and bool(when(session))
80
+ except Exception:
81
+ active = False
82
+ if active:
83
+ # Serialise children to dicts before recursing so the rest
84
+ # of the handler always works with plain dicts
85
+ children_dicts = [
86
+ c.to_dict() if hasattr(c, "to_dict") else c
87
+ for c in children
88
+ ]
89
+ result.extend(_flatten_fields(children_dicts, session))
90
+ # False branch → nothing added
91
+ else:
92
+ # Serialise to dict if it's still an object
93
+ result.append(f.to_dict() if hasattr(f, "to_dict") else f)
94
+ return result
95
+
96
+
97
+ def _skip_if(field_dict: dict, session) -> bool:
98
+ """
99
+ Safely evaluate a field's ``skip_if`` predicate.
100
+
101
+ Returns ``True`` only when ``skip_if`` is a callable that returns truthy.
102
+ A missing, ``None``, or non-callable value (e.g. an accidentally-passed
103
+ string) always returns ``False`` so the field is shown rather than
104
+ silently dropped.
105
+ """
106
+ predicate = field_dict.get("skip_if")
107
+ return callable(predicate) and bool(predicate(session))
108
+
109
+
110
+ class InputHandler(NodeHandler):
111
+ """
112
+ Handles ``input`` nodes.
113
+
114
+ Walks through ``fields`` one at a time. For each field it:
115
+ 1. Renders the appropriate prompt (text / interactive list / buttons /
116
+ location request).
117
+ 2. On the next message, checks the *incoming type* matches the field type.
118
+ 3. Validates the value (optional ``validate`` callable on the field).
119
+ 4. Transforms and stores the value in ``session.collected``.
120
+ 5. Advances to the next field or, when all are done, transitions to
121
+ ``node["next"]``.
122
+ """
123
+
124
+ async def handle(
125
+ self,
126
+ node: Dict[str, Any],
127
+ session: Session,
128
+ message: IncomingMessage,
129
+ tree: "FlowTree",
130
+ ) -> Reply:
131
+ # ── flatten BranchFields into a concrete list for this message ─
132
+ # This is the single source of truth for the active field sequence.
133
+ # idx stored in session.pagination always points into THIS list.
134
+ # Re-flattening every message is intentional: after the user answers
135
+ # a branching field, the next flatten reflects their answer and the
136
+ # correct branch fields appear at the right positions.
137
+ fields = _flatten_fields(node.get("fields", []), session)
138
+ idx_key = _IDX_KEY_TMPL.format(node=session.current_node)
139
+ idx = session.pagination.get(idx_key, 0)
140
+
141
+ # Guard: all fields somehow already collected
142
+ if idx >= len(fields):
143
+ session.pagination.pop(idx_key, None)
144
+ self._transition_to(session, node["next"])
145
+ return await self._enter_node(session, tree)
146
+
147
+ current_field = fields[idx]
148
+ field_type = current_field.get("field_type", "text")
149
+
150
+ # ── first entry (no meaningful input yet) ───────────────────
151
+ # Reset only on a genuine cold entry (no idx tracked yet).
152
+ # When the engine's back-nav has already decremented the idx,
153
+ # we must NOT reset — we want to re-render that specific field.
154
+ if _is_blank(message):
155
+ if idx_key not in session.pagination:
156
+ self._reset_input(session, node, fields)
157
+ # Re-flatten after reset: collected is now wiped, so any branch
158
+ # whose condition depended on a stale answer from a previous run
159
+ # will correctly evaluate to False. Without this, a user who
160
+ # previously picked "__custom__" would see "Step 1 of 8" on their
161
+ # next fresh entry because the branch was still expanded.
162
+ fields = _flatten_fields(node.get("fields", []), session)
163
+ idx = 0
164
+ # Advance past any leading skip_if fields on fresh entry
165
+ while idx < len(fields):
166
+ if _skip_if(fields[idx], session):
167
+ session.collected[fields[idx]["name"]] = None
168
+ idx += 1
169
+ session.pagination[idx_key] = idx
170
+ else:
171
+ break
172
+ else:
173
+ # Back-nav: engine already decremented idx by 1.
174
+ # Walk backwards further past any skip_if fields that were
175
+ # never actually shown (auto-skipped).
176
+ idx = _skip_backwards(fields, idx, session)
177
+ session.pagination[idx_key] = idx
178
+ return self._render_field(node, session, fields, idx)
179
+
180
+ # ── guard: never store navigation keywords as field values ───
181
+ # This catches cases where the engine's global-command interception
182
+ # is bypassed (e.g. duplicate webhook delivery, direct handler call).
183
+ # Only applies to plain-text messages; interactive replies are safe.
184
+ if message.type == "text" and message.text:
185
+ cmd = message.text.strip().lower()
186
+ _BACK = {"0", "back", "go back"}
187
+ _HOME = {"00", "home", "menu", "start over"}
188
+ _EXIT = {"000", "exit", "quit", "reset", "goodbye", "bye"}
189
+ if cmd in _BACK or cmd in _HOME or cmd in _EXIT:
190
+ # Engine already decremented idx before reaching here.
191
+ # Walk backwards past any skip_if fields never shown.
192
+ idx = _skip_backwards(fields, idx, session)
193
+ session.pagination[idx_key] = idx
194
+ return self._render_field(node, session, fields, idx)
195
+
196
+ # ── try to accept the incoming message for this field ─────────
197
+ value, error = self._accept(current_field, field_type, message, session, idx)
198
+
199
+ if error:
200
+ # Pagination sentinels from MenuField — just re-render the field
201
+ if error in (_MENU_PREV, _MENU_NEXT):
202
+ return self._render_field(node, session, fields, idx)
203
+ # Wrong type or failed validation → re-prompt with error prefix
204
+ prompt_reply = self._render_field(node, session, fields, idx)
205
+ prompt_reply.body = f"{error}\n\n{prompt_reply.body}"
206
+ return prompt_reply
207
+
208
+ # ── apply transform ───────────────────────────────────────────
209
+ transform = current_field.get("transform")
210
+ if transform:
211
+ value = transform(value)
212
+
213
+ # ── store ─────────────────────────────────────────────────────
214
+ session.collected[current_field["name"]] = value
215
+ idx += 1
216
+ session.pagination[idx_key] = idx
217
+
218
+ # ── re-flatten AFTER storing so branch conditions see the new answer ──
219
+ # The answer just stored may control a BranchField condition. We must
220
+ # re-evaluate the full field list now so the correct branch fields
221
+ # are present (or absent) when we advance to the next idx.
222
+ fields = _flatten_fields(node.get("fields", []), session)
223
+
224
+ # ── skip fields whose condition is met ────────────────────────
225
+ while idx < len(fields):
226
+ if _skip_if(fields[idx], session):
227
+ session.collected[fields[idx]["name"]] = None
228
+ idx += 1
229
+ session.pagination[idx_key] = idx
230
+ else:
231
+ break
232
+
233
+ # ── advance to next field or finish ───────────────────────────
234
+ if idx >= len(fields):
235
+ session.pagination.pop(idx_key, None)
236
+ self._transition_to(session, node["next"])
237
+ return await self._enter_node(session, tree)
238
+
239
+ return self._render_field(node, session, fields, idx)
240
+
241
+ # ── reset helper ─────────────────────────────────────────────────
242
+
243
+ def _reset_input(
244
+ self,
245
+ session: Session,
246
+ node: Dict[str, Any],
247
+ fields: List[Dict[str, Any]],
248
+ ) -> None:
249
+ """
250
+ Wipe all state for this Input node so it starts clean.
251
+
252
+ ``fields`` is the already-flattened list for the *current* run.
253
+ We also walk the raw node fields recursively to clear any branch
254
+ children that were active on a *previous* run (handles the case
255
+ where the user restarts after having taken a different branch).
256
+ """
257
+ node_key = session.current_node
258
+ session.pagination.pop(_IDX_KEY_TMPL.format(node=node_key), None)
259
+ for i in range(len(fields)):
260
+ session.pagination.pop(_MENU_PAGE_TMPL.format(node=node_key, field=i), None)
261
+ # Clear active (flattened) fields
262
+ for f in fields:
263
+ session.collected.pop(f.get("name", ""), None)
264
+ # Also clear any branch children from previous runs
265
+ def _clear_raw(raw_fields):
266
+ for f in raw_fields:
267
+ if f.get("field_type") == "branch":
268
+ _clear_raw(f.get("fields", []))
269
+ else:
270
+ session.collected.pop(f.get("name", ""), None)
271
+ _clear_raw(node.get("fields", []))
272
+
273
+ # ── accept helpers (type-dispatch) ───────────────────────────────
274
+
275
+ def _accept(
276
+ self,
277
+ f: Dict[str, Any],
278
+ field_type: str,
279
+ message: IncomingMessage,
280
+ session: "Session" = None,
281
+ field_idx: int = 0,
282
+ ) -> Tuple[Any, Optional[str]]:
283
+ """
284
+ Try to extract a value from *message* for field *f*.
285
+
286
+ Returns ``(value, None)`` on success or ``(None, error_str)`` on failure.
287
+ For menu fields with >10 options the error string may be the sentinel
288
+ ``_MENU_NEXT`` / ``_MENU_PREV`` — the caller re-renders instead of showing an error.
289
+ Validation (the field's ``validate`` callable) is run here too.
290
+ """
291
+ if field_type == "text":
292
+ return self._accept_text(f, message)
293
+
294
+ if field_type == "menu":
295
+ return self._accept_menu(f, message, session, field_idx)
296
+
297
+ if field_type == "buttons":
298
+ return self._accept_buttons(f, message)
299
+
300
+ if field_type == "image":
301
+ return self._accept_image(f, message)
302
+
303
+ if field_type == "document":
304
+ return self._accept_document(f, message)
305
+
306
+ if field_type == "location":
307
+ return self._accept_location(f, message)
308
+
309
+ # Unknown field type — fall back to raw text
310
+ return self._accept_text(f, message)
311
+
312
+ def _accept_text(
313
+ self, f: Dict[str, Any], message: IncomingMessage
314
+ ) -> Tuple[Any, Optional[str]]:
315
+ raw = (message.text or "").strip()
316
+ if not raw:
317
+ return None, f.get("rejection_text", "⚠️ Please send a text reply.")
318
+ validate = f.get("validate")
319
+ if validate:
320
+ err = validate(raw)
321
+ if err:
322
+ return None, f"⚠️ {err}"
323
+ return raw, None
324
+
325
+ def _accept_menu(
326
+ self, f: Dict[str, Any], message: IncomingMessage, session: Session, field_idx: int
327
+ ) -> Tuple[Any, Optional[str]]:
328
+ options = _resolve_options(f, session)
329
+ allow_numeric = f.get("allow_numeric", False)
330
+ pkey = _MENU_PAGE_TMPL.format(node=session.current_node, field=field_idx)
331
+ items_per_page = _MAX_MENU_ROWS - 2 if len(options) > _MAX_MENU_ROWS else _MAX_MENU_ROWS
332
+ page = session.pagination.get(pkey, 0)
333
+ total_pages = max(1, (len(options) + items_per_page - 1) // items_per_page)
334
+
335
+ # Pagination controls – handle before attempting a value match
336
+ if message.interactive_id == _MENU_NEXT:
337
+ if page + 1 < total_pages:
338
+ session.pagination[pkey] = page + 1
339
+ return None, _MENU_NEXT # sentinel: re-render same field
340
+ if message.interactive_id == _MENU_PREV:
341
+ if page > 0:
342
+ session.pagination[pkey] = page - 1
343
+ return None, _MENU_PREV # sentinel: re-render same field
344
+
345
+ start = page * items_per_page
346
+ page_options = options[start: start + items_per_page]
347
+ raw = (message.interactive_id or message.text or "").strip()
348
+
349
+ for i, opt in enumerate(page_options, start + 1):
350
+ label = opt.get("label", "")
351
+ value = opt.get("value", opt.get("next", label))
352
+
353
+ if message.interactive_id and message.interactive_id == value:
354
+ session.pagination.pop(pkey, None)
355
+ return value, None
356
+
357
+ if allow_numeric and (message.text or "").strip() == str(i):
358
+ session.pagination.pop(pkey, None)
359
+ return value, None
360
+
361
+ if raw.lower() == label.lower():
362
+ session.pagination.pop(pkey, None)
363
+ return value, None
364
+
365
+ return None, "⚠️ Please choose one of the options from the list."
366
+
367
+ def _accept_buttons(
368
+ self, f: Dict[str, Any], message: IncomingMessage
369
+ ) -> Tuple[Any, Optional[str]]:
370
+ options = _resolve_options(f, None)
371
+ allow_numeric = f.get("allow_numeric", False)
372
+ raw = (message.interactive_id or message.text or "").strip()
373
+
374
+ for i, opt in enumerate(options, 1):
375
+ label = opt.get("label", "")
376
+ value = opt.get("value", opt.get("next", label))
377
+
378
+ if message.interactive_id and message.interactive_id == value:
379
+ return value, None
380
+
381
+ if allow_numeric and (message.text or "").strip() == str(i):
382
+ return value, None
383
+
384
+ if raw.lower() == label.lower():
385
+ return value, None
386
+
387
+ return None, "⚠️ Please tap one of the buttons to continue."
388
+
389
+ def _accept_image(
390
+ self, f: Dict[str, Any], message: IncomingMessage
391
+ ) -> Tuple[Any, Optional[str]]:
392
+ if message.type != "image" or not message.media_id:
393
+ return None, f.get("rejection_text", "⚠️ Please send an image.")
394
+ value = {
395
+ "media_id": message.media_id,
396
+ "mime_type": message.media_mime or "image/jpeg",
397
+ }
398
+ validate = f.get("validate")
399
+ if validate:
400
+ err = validate(value)
401
+ if err:
402
+ return None, f"⚠️ {err}"
403
+ return value, None
404
+
405
+ def _accept_document(
406
+ self, f: Dict[str, Any], message: IncomingMessage
407
+ ) -> Tuple[Any, Optional[str]]:
408
+ if message.type != "document" or not message.media_id:
409
+ return None, f.get("rejection_text", "⚠️ Please send a document file.")
410
+
411
+ accept = f.get("accept", [])
412
+ if accept and message.media_mime and message.media_mime not in accept:
413
+ accepted_str = ", ".join(accept)
414
+ return None, f"⚠️ Unsupported file type. Accepted: {accepted_str}"
415
+
416
+ value = {
417
+ "media_id": message.media_id,
418
+ "mime_type": message.media_mime or "application/octet-stream",
419
+ "filename": getattr(message, "media_name", "") or "",
420
+ }
421
+ validate = f.get("validate")
422
+ if validate:
423
+ err = validate(value)
424
+ if err:
425
+ return None, f"⚠️ {err}"
426
+ return value, None
427
+
428
+ def _accept_location(
429
+ self, f: Dict[str, Any], message: IncomingMessage
430
+ ) -> Tuple[Any, Optional[str]]:
431
+ if message.type != "location" or not message.location:
432
+ return None, f.get("rejection_text", "⚠️ Please share your location using the 📍 button.")
433
+
434
+ loc = message.location
435
+ value = {
436
+ "latitude": loc.get("latitude"),
437
+ "longitude": loc.get("longitude"),
438
+ "name": loc.get("name"),
439
+ "address": loc.get("address"),
440
+ }
441
+ validate = f.get("validate")
442
+ if validate:
443
+ err = validate(value)
444
+ if err:
445
+ return None, f"⚠️ {err}"
446
+ return value, None
447
+
448
+ # ── render helpers (type-dispatch) ───────────────────────────────
449
+
450
+ def _render_field(
451
+ self,
452
+ node: Dict[str, Any],
453
+ session: Session,
454
+ fields: List[Dict[str, Any]],
455
+ idx: int,
456
+ ) -> Reply:
457
+ """Build the correct Reply for ``fields[idx]`` based on its type."""
458
+ f = fields[idx]
459
+ field_type = f.get("field_type", "text")
460
+ # Use the current flattened list length as total — this reflects exactly
461
+ # how many fields the user will actually see given their answers so far.
462
+ # The total may change when a branch resolves (e.g. custom date adds 1
463
+ # field, or the else-branch removes 1). That is correct and expected:
464
+ # the step counter shows "N of M" where M is the real remaining work.
465
+ total = len(fields)
466
+ visible_idx = idx + 1
467
+ title_raw = node.get("title", "") or node.get("intro", "")
468
+ if callable(title_raw):
469
+ title = title_raw(session)
470
+ else:
471
+ title = title_raw
472
+
473
+ # Header line — "Title - Step N of M" when title is set, else plain "(N/M)"
474
+ def _prefixed(prompt: str) -> str:
475
+ if total > 1:
476
+ step_label = f"Step {visible_idx} of {total}"
477
+ header = f"*{title} - {step_label}*" if title else f"({visible_idx}/{total})"
478
+ else:
479
+ header = f"*{title}*" if title else ""
480
+ return f"{header}\n\n{prompt}" if header else prompt
481
+
482
+ if field_type == "text":
483
+ return Reply(
484
+ type="text",
485
+ body=_prefixed(f.get("prompt", "")),
486
+ phone=session.user_id,
487
+ node_type="input",
488
+ current_node=session.current_node,
489
+ )
490
+
491
+ if field_type == "menu":
492
+ return self._render_menu_field(f, session, _prefixed(f.get("prompt", "")), field_idx=idx)
493
+
494
+ if field_type == "buttons":
495
+ return self._render_buttons_field(f, session, _prefixed(f.get("prompt", "")))
496
+
497
+ if field_type in ("image", "document"):
498
+ return Reply(
499
+ type="text",
500
+ body=_prefixed(f.get("prompt", "")),
501
+ phone=session.user_id,
502
+ node_type=f"input_{field_type}", # lets adapter know what to expect
503
+ current_node=session.current_node,
504
+ )
505
+
506
+ if field_type == "location":
507
+ return Reply(
508
+ type="text",
509
+ body=_prefixed(f.get("prompt", "")),
510
+ phone=session.user_id,
511
+ node_type="input_location", # adapter maps this to location_request_message
512
+ current_node=session.current_node,
513
+ )
514
+
515
+ # Unknown type — plain text fallback
516
+ return Reply(
517
+ type="text",
518
+ body=_prefixed(f.get("prompt", "")),
519
+ phone=session.user_id,
520
+ node_type="input",
521
+ current_node=session.current_node,
522
+ )
523
+
524
+ def _render_menu_field(
525
+ self,
526
+ f: Dict[str, Any],
527
+ session: Session,
528
+ body: str,
529
+ field_idx: int = 0,
530
+ ) -> Reply:
531
+ options = _resolve_options(f, session)
532
+ button_label = f.get("button_label", "Options")
533
+ items_per_page = _MAX_MENU_ROWS - 2 if len(options) > _MAX_MENU_ROWS else _MAX_MENU_ROWS
534
+ pkey = _MENU_PAGE_TMPL.format(node=session.current_node, field=field_idx)
535
+ page = session.pagination.get(pkey, 0)
536
+ total_pages = max(1, (len(options) + items_per_page - 1) // items_per_page)
537
+
538
+ # Clamp page in case options shrank
539
+ if page >= total_pages:
540
+ page = max(0, total_pages - 1)
541
+ session.pagination[pkey] = page
542
+
543
+ start = page * items_per_page
544
+ page_options = options[start: start + items_per_page]
545
+
546
+ reply_options = [
547
+ ReplyOption(
548
+ label=opt.get("label", ""),
549
+ value=opt.get("value", opt.get("next", opt.get("label", ""))),
550
+ description=opt.get("description", ""),
551
+ )
552
+ for opt in page_options
553
+ ]
554
+
555
+ # Pagination rows
556
+ if total_pages > 1:
557
+ if page > 0:
558
+ reply_options.append(ReplyOption(
559
+ label="◀ Previous",
560
+ value=_MENU_PREV,
561
+ description=f"Page {page}/{total_pages}",
562
+ ))
563
+ if page < total_pages - 1:
564
+ reply_options.append(ReplyOption(
565
+ label="Next ▶",
566
+ value=_MENU_NEXT,
567
+ description=f"Page {page + 2}/{total_pages}",
568
+ ))
569
+
570
+ return Reply(
571
+ type="text",
572
+ body=body,
573
+ phone=session.user_id,
574
+ options=reply_options,
575
+ node_type="input_menu",
576
+ current_node=session.current_node,
577
+ meta={"button_label": button_label},
578
+ )
579
+
580
+ def _render_buttons_field(
581
+ self,
582
+ f: Dict[str, Any],
583
+ session: Session,
584
+ body: str,
585
+ ) -> Reply:
586
+ options = _resolve_options(f, session)
587
+
588
+ reply_options = [
589
+ ReplyOption(
590
+ label=opt.get("label", ""),
591
+ value=opt.get("value", opt.get("next", opt.get("label", ""))),
592
+ )
593
+ for opt in options
594
+ ]
595
+ return Reply(
596
+ type="text",
597
+ body=body,
598
+ phone=session.user_id,
599
+ options=reply_options,
600
+ node_type="input_buttons", # adapter renders as interactive reply buttons
601
+ current_node=session.current_node,
602
+ )
603
+
604
+
605
+ # ── module-level helper ───────────────────────────────────────────────────────
606
+
607
+ def _is_blank(message: IncomingMessage) -> bool:
608
+ """True when the message carries no usable content at all."""
609
+ return (
610
+ not message.text
611
+ and not message.interactive_id
612
+ and not message.media_id
613
+ and not message.location
614
+ )
615
+
616
+ def _resolve_options(f: dict, session) -> list:
617
+ """
618
+ Return a plain list of option dicts for a menu/buttons field.
619
+
620
+ ``options`` may be stored as:
621
+ - a list of dicts (already serialised) → returned as-is
622
+ - a callable ``(session) -> list[Option|dict]`` → called now, then serialised
623
+ """
624
+ try:
625
+ from ..nodes import Option # noqa: F401 – used in isinstance check below
626
+ _Option = Option
627
+ except ImportError:
628
+ _Option = None
629
+
630
+ raw = f.get("options", [])
631
+ if callable(raw):
632
+ raw = raw(session)
633
+ result = []
634
+ for o in raw:
635
+ if _Option and isinstance(o, _Option):
636
+ result.append(o.to_dict())
637
+ else:
638
+ result.append(o) # already a dict
639
+ return result
640
+
641
+ def _skip_backwards(fields: list, idx: int, session) -> int:
642
+ """
643
+ After back-nav has decremented idx by 1, keep stepping back while the
644
+ field at the current idx was auto-skipped (skip_if returns True).
645
+ This ensures the user never lands on a conditional field they never saw.
646
+ """
647
+ while idx > 0:
648
+ if _skip_if(fields[idx], session):
649
+ idx -= 1
650
+ else:
651
+ break
652
+ return idx