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