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,339 @@
|
|
|
1
|
+
"""
|
|
2
|
+
handlers/list_handler.py
|
|
3
|
+
========================
|
|
4
|
+
ListHandler — dynamic list with optional server‑side pagination.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from typing import Any, Dict, List, TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from .base import NodeHandler
|
|
13
|
+
from ..message import IncomingMessage
|
|
14
|
+
from ..reply import Reply, ReplyOption
|
|
15
|
+
from ..session import Session
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ..tree import FlowTree
|
|
19
|
+
|
|
20
|
+
NEXT_PAGE_KEYWORDS = {"n", "next", "more"}
|
|
21
|
+
PREV_PAGE_KEYWORDS = {"p", "prev", "previous", "back page"}
|
|
22
|
+
|
|
23
|
+
PREV_PAGE_VALUE = "__prev_page__"
|
|
24
|
+
NEXT_PAGE_VALUE = "__next_page__"
|
|
25
|
+
|
|
26
|
+
MAX_ROWS = 10 # WhatsApp interactive list row limit
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ListHandler(NodeHandler):
|
|
30
|
+
|
|
31
|
+
async def handle(
|
|
32
|
+
self,
|
|
33
|
+
node: Dict[str, Any],
|
|
34
|
+
session: Session,
|
|
35
|
+
message: IncomingMessage,
|
|
36
|
+
tree: "FlowTree",
|
|
37
|
+
) -> Reply:
|
|
38
|
+
raw = (message.interactive_id or message.text or "").strip().lower()
|
|
39
|
+
interactive = node.get("interactive", False)
|
|
40
|
+
|
|
41
|
+
fetch = node.get("fetch")
|
|
42
|
+
if not fetch:
|
|
43
|
+
return self._error(session, "ListNode has no 'fetch' function.")
|
|
44
|
+
|
|
45
|
+
page_size = node.get("page_size", 8)
|
|
46
|
+
page_size = min(MAX_ROWS, max(1, page_size))
|
|
47
|
+
|
|
48
|
+
sig = inspect.signature(fetch)
|
|
49
|
+
paginated_mode = len(sig.parameters) >= 3
|
|
50
|
+
|
|
51
|
+
pkey = f"list_{session.current_node}_page"
|
|
52
|
+
page = session.pagination.get(pkey, 0)
|
|
53
|
+
|
|
54
|
+
# ── fetch data for current page ───────────────────────────────
|
|
55
|
+
if paginated_mode:
|
|
56
|
+
try:
|
|
57
|
+
if inspect.iscoroutinefunction(fetch):
|
|
58
|
+
result = await fetch(session, page, page_size)
|
|
59
|
+
else:
|
|
60
|
+
result = fetch(session, page, page_size)
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
return self._error(session, f"Paginated fetch raised: {exc}")
|
|
63
|
+
|
|
64
|
+
if isinstance(result, tuple) and len(result) == 2:
|
|
65
|
+
items_page, total = result
|
|
66
|
+
elif isinstance(result, dict):
|
|
67
|
+
items_page = result.get("items", [])
|
|
68
|
+
total = result.get("total", 0)
|
|
69
|
+
else:
|
|
70
|
+
return self._error(session, "Paginated fetch must return (items, total) or dict with 'items' and 'total'")
|
|
71
|
+
items_page = list(items_page or [])
|
|
72
|
+
total_items = total
|
|
73
|
+
else:
|
|
74
|
+
try:
|
|
75
|
+
if inspect.iscoroutinefunction(fetch):
|
|
76
|
+
all_items = await fetch(session)
|
|
77
|
+
else:
|
|
78
|
+
all_items = fetch(session)
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
return self._error(session, f"Fetch raised: {exc}")
|
|
81
|
+
all_items = list(all_items or [])
|
|
82
|
+
total_items = len(all_items)
|
|
83
|
+
start = page * page_size
|
|
84
|
+
items_page = all_items[start: start + page_size]
|
|
85
|
+
|
|
86
|
+
total_pages = max(1, (total_items + page_size - 1) // page_size)
|
|
87
|
+
if page >= total_pages:
|
|
88
|
+
page = total_pages - 1 if total_pages > 0 else 0
|
|
89
|
+
session.pagination[pkey] = page
|
|
90
|
+
if paginated_mode and page != (session.pagination.get(pkey, 0)):
|
|
91
|
+
try:
|
|
92
|
+
if inspect.iscoroutinefunction(fetch):
|
|
93
|
+
result = await fetch(session, page, page_size)
|
|
94
|
+
else:
|
|
95
|
+
result = fetch(session, page, page_size)
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
return self._error(session, f"Paginated fetch raised: {exc}")
|
|
98
|
+
if isinstance(result, tuple):
|
|
99
|
+
items_page, total = result
|
|
100
|
+
else:
|
|
101
|
+
items_page = result.get("items", [])
|
|
102
|
+
items_page = list(items_page or [])
|
|
103
|
+
|
|
104
|
+
# First render (no input)
|
|
105
|
+
if not raw:
|
|
106
|
+
session.pagination[pkey] = page
|
|
107
|
+
return self._render_list_page(
|
|
108
|
+
node, session, items_page, page, total_pages, interactive, page_size, paginated_mode
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# ── handle interactive mode ─────────────────────────────────────
|
|
112
|
+
if interactive:
|
|
113
|
+
# Case 1: user clicked an interactive element (button or list row)
|
|
114
|
+
if message.interactive_id:
|
|
115
|
+
selected = message.interactive_id
|
|
116
|
+
|
|
117
|
+
# Pagination controls
|
|
118
|
+
if selected == PREV_PAGE_VALUE and page > 0:
|
|
119
|
+
session.pagination[pkey] = page - 1
|
|
120
|
+
return await self._enter_node(session, tree)
|
|
121
|
+
if selected == NEXT_PAGE_VALUE and page + 1 < total_pages:
|
|
122
|
+
session.pagination[pkey] = page + 1
|
|
123
|
+
return await self._enter_node(session, tree)
|
|
124
|
+
|
|
125
|
+
# Extra options (only shown on last page)
|
|
126
|
+
extra_opts = node.get("extra_options", [])
|
|
127
|
+
if page == total_pages - 1:
|
|
128
|
+
for opt in extra_opts:
|
|
129
|
+
opt_value = opt.get("value", opt.get("next", ""))
|
|
130
|
+
if selected == opt_value:
|
|
131
|
+
target = opt.get("next", tree.entry)
|
|
132
|
+
self._transition_to(session, target)
|
|
133
|
+
return await self._enter_node(session, tree)
|
|
134
|
+
|
|
135
|
+
# Normal item selection
|
|
136
|
+
if selected.startswith("list_idx_"):
|
|
137
|
+
try:
|
|
138
|
+
abs_idx = int(selected.split("_")[-1])
|
|
139
|
+
start_idx = page * page_size
|
|
140
|
+
local = abs_idx - start_idx
|
|
141
|
+
if 0 <= local < len(items_page):
|
|
142
|
+
selected_item = items_page[local]
|
|
143
|
+
session.context["selected_item"] = selected_item
|
|
144
|
+
session.context["selected_index"] = abs_idx
|
|
145
|
+
session.pagination.pop(pkey, None)
|
|
146
|
+
on_select = node.get("on_select", tree.entry)
|
|
147
|
+
self._transition_to(session, on_select)
|
|
148
|
+
return await self._enter_node(session, tree)
|
|
149
|
+
except (ValueError, IndexError):
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# Invalid interactive selection – re‑render with error prefix
|
|
153
|
+
rendered = self._render_list_page(
|
|
154
|
+
node, session, items_page, page, total_pages, interactive, page_size, paginated_mode
|
|
155
|
+
)
|
|
156
|
+
rendered.body = "Invalid selection.\n\n" + rendered.body
|
|
157
|
+
return rendered
|
|
158
|
+
|
|
159
|
+
# Case 2: interactive mode, but user typed plain text (not a command)
|
|
160
|
+
else:
|
|
161
|
+
# Plain text in interactive mode is invalid – re‑render with error prefix
|
|
162
|
+
rendered = self._render_list_page(
|
|
163
|
+
node, session, items_page, page, total_pages, interactive, page_size, paginated_mode
|
|
164
|
+
)
|
|
165
|
+
rendered.body = "Invalid selection.\n\n" + rendered.body
|
|
166
|
+
return rendered
|
|
167
|
+
|
|
168
|
+
# ── plain text (non‑interactive) mode ──────────────────────────
|
|
169
|
+
else:
|
|
170
|
+
if raw in NEXT_PAGE_KEYWORDS and page + 1 < total_pages:
|
|
171
|
+
session.pagination[pkey] = page + 1
|
|
172
|
+
return await self._enter_node(session, tree)
|
|
173
|
+
if raw in PREV_PAGE_KEYWORDS and page > 0:
|
|
174
|
+
session.pagination[pkey] = page - 1
|
|
175
|
+
return await self._enter_node(session, tree)
|
|
176
|
+
if raw.isdigit():
|
|
177
|
+
local_idx = int(raw) - 1
|
|
178
|
+
if 0 <= local_idx < len(items_page):
|
|
179
|
+
selected_item = items_page[local_idx]
|
|
180
|
+
abs_idx = page * page_size + local_idx
|
|
181
|
+
session.context["selected_item"] = selected_item
|
|
182
|
+
session.context["selected_index"] = abs_idx
|
|
183
|
+
session.pagination.pop(pkey, None)
|
|
184
|
+
on_select = node.get("on_select", tree.entry)
|
|
185
|
+
self._transition_to(session, on_select)
|
|
186
|
+
return await self._enter_node(session, tree)
|
|
187
|
+
|
|
188
|
+
# Invalid text input in plain text mode – re‑render with error prefix
|
|
189
|
+
rendered = self._render_list_page(
|
|
190
|
+
node, session, items_page, page, total_pages, interactive, page_size, paginated_mode
|
|
191
|
+
)
|
|
192
|
+
rendered.body = "Invalid selection.\n\n" + rendered.body
|
|
193
|
+
return rendered
|
|
194
|
+
|
|
195
|
+
def _render_list_page(
|
|
196
|
+
self,
|
|
197
|
+
node: Dict[str, Any],
|
|
198
|
+
session: Session,
|
|
199
|
+
items_page: List[Any],
|
|
200
|
+
page: int,
|
|
201
|
+
total_pages: int,
|
|
202
|
+
interactive: bool,
|
|
203
|
+
page_size: int,
|
|
204
|
+
paginated_mode: bool,
|
|
205
|
+
) -> Reply:
|
|
206
|
+
if not items_page and total_pages == 0:
|
|
207
|
+
empty_text = node.get("empty_text", "No items available.")
|
|
208
|
+
if interactive:
|
|
209
|
+
return Reply(
|
|
210
|
+
type="text",
|
|
211
|
+
body=f"{node.get('title', 'List')}\n\n{empty_text}",
|
|
212
|
+
phone=session.user_id,
|
|
213
|
+
node_type="list",
|
|
214
|
+
current_node=session.current_node,
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
return Reply(
|
|
218
|
+
type="text",
|
|
219
|
+
body=f"{node.get('title', 'List')}\n\n{empty_text}\n\nReply 0 to go back.",
|
|
220
|
+
phone=session.user_id,
|
|
221
|
+
node_type="list",
|
|
222
|
+
current_node=session.current_node,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
title_raw = node.get("title", "Select an option")
|
|
226
|
+
if callable(title_raw):
|
|
227
|
+
title = title_raw(session)
|
|
228
|
+
else:
|
|
229
|
+
title = title_raw
|
|
230
|
+
item_label_fn = node.get("item_label", str)
|
|
231
|
+
item_desc_fn = node.get("item_description")
|
|
232
|
+
extra_opts = node.get("extra_options", [])
|
|
233
|
+
|
|
234
|
+
if interactive:
|
|
235
|
+
pagination_slots = 2 if (0 < page < total_pages - 1) else 1 if total_pages > 1 else 0
|
|
236
|
+
show_extra_on_last = (page == total_pages - 1) and len(extra_opts) > 0
|
|
237
|
+
extra_slots = len(extra_opts) if show_extra_on_last else 0
|
|
238
|
+
max_items = MAX_ROWS - pagination_slots - extra_slots
|
|
239
|
+
if max_items < 1:
|
|
240
|
+
max_items = 1
|
|
241
|
+
display_items = items_page[:max_items]
|
|
242
|
+
|
|
243
|
+
sections = []
|
|
244
|
+
|
|
245
|
+
if display_items:
|
|
246
|
+
items_section = {
|
|
247
|
+
"title": node.get("items_section_title", "Options"),
|
|
248
|
+
"rows": []
|
|
249
|
+
}
|
|
250
|
+
start_idx = page * page_size
|
|
251
|
+
for local_i, item in enumerate(display_items):
|
|
252
|
+
abs_i = start_idx + local_i
|
|
253
|
+
label = item_label_fn(item)
|
|
254
|
+
desc = item_desc_fn(item) if item_desc_fn else ""
|
|
255
|
+
items_section["rows"].append({
|
|
256
|
+
"id": f"list_idx_{abs_i}",
|
|
257
|
+
"title": label[:24],
|
|
258
|
+
"description": desc[:72],
|
|
259
|
+
})
|
|
260
|
+
sections.append(items_section)
|
|
261
|
+
|
|
262
|
+
actions_rows = []
|
|
263
|
+
if total_pages > 1:
|
|
264
|
+
if page > 0:
|
|
265
|
+
actions_rows.append({
|
|
266
|
+
"id": PREV_PAGE_VALUE,
|
|
267
|
+
"title": "⬅️ Previous Page",
|
|
268
|
+
"description": f"Page {page}/{total_pages}" if page > 1 else "Previous page",
|
|
269
|
+
})
|
|
270
|
+
if page < total_pages - 1:
|
|
271
|
+
actions_rows.append({
|
|
272
|
+
"id": NEXT_PAGE_VALUE,
|
|
273
|
+
"title": "➡️ Next Page",
|
|
274
|
+
"description": f"Page {page+2}/{total_pages}" if page + 2 < total_pages else "Next page",
|
|
275
|
+
})
|
|
276
|
+
if show_extra_on_last:
|
|
277
|
+
for opt in extra_opts:
|
|
278
|
+
actions_rows.append({
|
|
279
|
+
"id": opt.get("value", opt.get("next", "")),
|
|
280
|
+
"title": opt.get("label", "")[:24],
|
|
281
|
+
"description": opt.get("description", "")[:72],
|
|
282
|
+
})
|
|
283
|
+
if actions_rows:
|
|
284
|
+
sections.append({
|
|
285
|
+
"title": node.get("actions_section_title", "Actions"),
|
|
286
|
+
"rows": actions_rows
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
button_label = node.get("button_label", "Options")
|
|
290
|
+
return Reply(
|
|
291
|
+
type="text",
|
|
292
|
+
body=title,
|
|
293
|
+
phone=session.user_id,
|
|
294
|
+
node_type="menu",
|
|
295
|
+
options=[],
|
|
296
|
+
current_node=session.current_node,
|
|
297
|
+
meta={
|
|
298
|
+
"button_label": button_label,
|
|
299
|
+
"sections": sections,
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
else:
|
|
304
|
+
# Plain text rendering
|
|
305
|
+
lines = [title]
|
|
306
|
+
if total_pages > 1:
|
|
307
|
+
lines.append(f"(Page {page+1}/{total_pages})")
|
|
308
|
+
lines.append("")
|
|
309
|
+
reply_options = []
|
|
310
|
+
start_idx = page * page_size
|
|
311
|
+
for local_i, item in enumerate(items_page):
|
|
312
|
+
abs_i = start_idx + local_i
|
|
313
|
+
label = item_label_fn(item)
|
|
314
|
+
desc = item_desc_fn(item) if item_desc_fn else ""
|
|
315
|
+
lines.append(f"{local_i+1}. {label}")
|
|
316
|
+
if desc:
|
|
317
|
+
lines.append(f" {desc}")
|
|
318
|
+
reply_options.append(ReplyOption(
|
|
319
|
+
label=label,
|
|
320
|
+
value=f"list_idx_{abs_i}",
|
|
321
|
+
description=desc,
|
|
322
|
+
))
|
|
323
|
+
lines.append("")
|
|
324
|
+
nav = []
|
|
325
|
+
if page > 0:
|
|
326
|
+
nav.append("p=prev")
|
|
327
|
+
if page < total_pages - 1:
|
|
328
|
+
nav.append("n=next")
|
|
329
|
+
nav.append("0=back")
|
|
330
|
+
lines.append(" · ".join(nav))
|
|
331
|
+
return Reply(
|
|
332
|
+
type="text",
|
|
333
|
+
body="\n".join(lines),
|
|
334
|
+
phone=session.user_id,
|
|
335
|
+
options=reply_options,
|
|
336
|
+
node_type="list",
|
|
337
|
+
suggested_replies=[o.value for o in reply_options],
|
|
338
|
+
current_node=session.current_node,
|
|
339
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import inspect
|
|
3
|
+
from typing import Any, Dict, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..message import IncomingMessage
|
|
6
|
+
from ..reply import Reply
|
|
7
|
+
from ..session import Session
|
|
8
|
+
from .base import NodeHandler
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..tree import FlowTree
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MediaHandler(NodeHandler):
|
|
15
|
+
"""
|
|
16
|
+
Handles ``media`` nodes.
|
|
17
|
+
|
|
18
|
+
Calls ``generate(session, collected)`` to produce file bytes,
|
|
19
|
+
returns a ``Reply(type="media", ...)`` and then advances to ``next``.
|
|
20
|
+
|
|
21
|
+
The developer's WhatsApp adapter sends the file using their provider.
|
|
22
|
+
|
|
23
|
+
``filename`` and ``caption`` can be plain strings or callables
|
|
24
|
+
``(session, collected) -> str``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
async def handle(
|
|
28
|
+
self,
|
|
29
|
+
node: Dict[str, Any],
|
|
30
|
+
session: Session,
|
|
31
|
+
message: IncomingMessage,
|
|
32
|
+
tree: "FlowTree",
|
|
33
|
+
) -> Reply:
|
|
34
|
+
generate = node.get("generate")
|
|
35
|
+
if not generate:
|
|
36
|
+
return self._error(session, "MediaNode has no 'generate' function.")
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
if inspect.iscoroutinefunction(generate):
|
|
40
|
+
file_bytes = await generate(session, session.collected)
|
|
41
|
+
else:
|
|
42
|
+
file_bytes = generate(session, session.collected)
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
return self._error(session, f"MediaNode generate raised: {exc}")
|
|
45
|
+
|
|
46
|
+
# resolve filename
|
|
47
|
+
filename_raw = node.get("filename", "file")
|
|
48
|
+
if callable(filename_raw):
|
|
49
|
+
filename = filename_raw(session, session.collected)
|
|
50
|
+
else:
|
|
51
|
+
filename = filename_raw
|
|
52
|
+
|
|
53
|
+
# resolve caption
|
|
54
|
+
caption_raw = node.get("caption", "")
|
|
55
|
+
if callable(caption_raw):
|
|
56
|
+
caption = caption_raw(session, session.collected)
|
|
57
|
+
else:
|
|
58
|
+
caption = caption_raw
|
|
59
|
+
|
|
60
|
+
# advance session
|
|
61
|
+
next_key = node.get("next", "welcome")
|
|
62
|
+
self._transition_to(session, next_key)
|
|
63
|
+
|
|
64
|
+
return Reply(
|
|
65
|
+
type="media",
|
|
66
|
+
node_type="media",
|
|
67
|
+
body=caption,
|
|
68
|
+
phone=session.user_id,
|
|
69
|
+
file_bytes=file_bytes,
|
|
70
|
+
filename=filename,
|
|
71
|
+
mime_type=node.get("mime_type", "application/octet-stream"),
|
|
72
|
+
current_node=session.current_node,
|
|
73
|
+
)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Dict, Optional, TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from ..message import IncomingMessage
|
|
5
|
+
from ..reply import Reply, ReplyOption
|
|
6
|
+
from ..session import Session
|
|
7
|
+
from .base import NodeHandler
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ..tree import FlowTree
|
|
11
|
+
|
|
12
|
+
# WhatsApp interactive list max rows
|
|
13
|
+
MAX_MENU_ROWS = 10
|
|
14
|
+
PREV_PAGE = "__menu_prev__"
|
|
15
|
+
NEXT_PAGE = "__menu_next__"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MenuHandler(NodeHandler):
|
|
19
|
+
"""
|
|
20
|
+
Handles ``menu`` nodes.
|
|
21
|
+
|
|
22
|
+
Accepts input via:
|
|
23
|
+
1. Interactive ID (``message.interactive_id``) — always checked first.
|
|
24
|
+
The value must match an Option's ``value`` field.
|
|
25
|
+
2. Numeric digit ("1", "2" …) — only if ``allow_numeric=True`` on the node.
|
|
26
|
+
3. Label text (case-insensitive) — fallback for text-only channels.
|
|
27
|
+
4. Back / Home keywords — "0" goes back, "00" goes home.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
async def handle(
|
|
31
|
+
self,
|
|
32
|
+
node: Dict[str, Any],
|
|
33
|
+
session: Session,
|
|
34
|
+
message: IncomingMessage,
|
|
35
|
+
tree: "FlowTree",
|
|
36
|
+
) -> Reply:
|
|
37
|
+
raw_input = (message.interactive_id or message.text or "").strip()
|
|
38
|
+
all_options = node.get("options", [])
|
|
39
|
+
|
|
40
|
+
# ── pagination state ─────────────────────────────────────────
|
|
41
|
+
# Use conservative capacity (MAX_MENU_ROWS - 2) so total_pages is
|
|
42
|
+
# stable. Exception: all options fit on one page with no nav rows.
|
|
43
|
+
if len(all_options) <= MAX_MENU_ROWS:
|
|
44
|
+
items_per_page = MAX_MENU_ROWS
|
|
45
|
+
else:
|
|
46
|
+
items_per_page = MAX_MENU_ROWS - 2 # always room for Prev + Next
|
|
47
|
+
|
|
48
|
+
pkey = f"menu_{session.current_node}_page"
|
|
49
|
+
page = session.pagination.get(pkey, 0)
|
|
50
|
+
total_pages = max(1, (len(all_options) + items_per_page - 1) // items_per_page)
|
|
51
|
+
if page >= total_pages:
|
|
52
|
+
page = total_pages - 1 if total_pages > 0 else 0
|
|
53
|
+
session.pagination[pkey] = page
|
|
54
|
+
|
|
55
|
+
# ── nothing yet — first render ────────────────────────────────
|
|
56
|
+
if not raw_input:
|
|
57
|
+
return self._render_menu_page(node, session, all_options, page, total_pages)
|
|
58
|
+
|
|
59
|
+
# ── interactive pagination ────────────────────────────────────
|
|
60
|
+
if message.interactive_id == PREV_PAGE:
|
|
61
|
+
if page > 0:
|
|
62
|
+
session.pagination[pkey] = page - 1
|
|
63
|
+
return await self._enter_node(session, tree)
|
|
64
|
+
if message.interactive_id == NEXT_PAGE:
|
|
65
|
+
if page + 1 < total_pages:
|
|
66
|
+
session.pagination[pkey] = page + 1
|
|
67
|
+
return await self._enter_node(session, tree)
|
|
68
|
+
|
|
69
|
+
# ── match option on current page ──────────────────────────────
|
|
70
|
+
start = page * items_per_page
|
|
71
|
+
page_options = all_options[start: start + items_per_page]
|
|
72
|
+
matched_next = self._match_option(page_options, message, raw_input, node.get("allow_numeric", False))
|
|
73
|
+
|
|
74
|
+
if not matched_next:
|
|
75
|
+
rendered = self._render_menu_page(node, session, all_options, page, total_pages)
|
|
76
|
+
return Reply(
|
|
77
|
+
type="text",
|
|
78
|
+
body="Invalid option. Please choose from the list.\n\n" + rendered.body,
|
|
79
|
+
phone=session.user_id,
|
|
80
|
+
options=rendered.options,
|
|
81
|
+
node_type="menu",
|
|
82
|
+
current_node=session.current_node,
|
|
83
|
+
meta=rendered.meta,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# store selected value in context for downstream access
|
|
87
|
+
session.context["last_option"] = matched_next
|
|
88
|
+
|
|
89
|
+
# clear collected when going home
|
|
90
|
+
if matched_next == tree.entry:
|
|
91
|
+
session.collected = {}
|
|
92
|
+
|
|
93
|
+
self._transition_to(session, matched_next)
|
|
94
|
+
return await self._enter_node(session, tree)
|
|
95
|
+
|
|
96
|
+
def _match_option(
|
|
97
|
+
self,
|
|
98
|
+
options: list,
|
|
99
|
+
message: IncomingMessage,
|
|
100
|
+
raw_input: str,
|
|
101
|
+
allow_numeric: bool,
|
|
102
|
+
) -> Optional[str]:
|
|
103
|
+
for i, opt in enumerate(options, 1):
|
|
104
|
+
label = opt.get("label", "") if isinstance(opt, dict) else opt[0]
|
|
105
|
+
value = opt.get("value", opt.get("next", "")) if isinstance(opt, dict) else opt[1]
|
|
106
|
+
next_key = opt.get("next", "") if isinstance(opt, dict) else opt[1]
|
|
107
|
+
|
|
108
|
+
# 1. Interactive ID match
|
|
109
|
+
if message.interactive_id and (
|
|
110
|
+
message.interactive_id == value or message.interactive_id == next_key
|
|
111
|
+
):
|
|
112
|
+
return next_key
|
|
113
|
+
|
|
114
|
+
# 2. Numeric
|
|
115
|
+
if allow_numeric and raw_input == str(i):
|
|
116
|
+
return next_key
|
|
117
|
+
|
|
118
|
+
# 3. Label (case-insensitive)
|
|
119
|
+
if raw_input.lower() == label.lower():
|
|
120
|
+
return next_key
|
|
121
|
+
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def _render_menu_page(
|
|
125
|
+
self,
|
|
126
|
+
node: Dict[str, Any],
|
|
127
|
+
session: Session,
|
|
128
|
+
all_options: list,
|
|
129
|
+
page: int,
|
|
130
|
+
total_pages: int,
|
|
131
|
+
) -> Reply:
|
|
132
|
+
text_raw = node.get("text", "")
|
|
133
|
+
if callable(text_raw):
|
|
134
|
+
text = text_raw(session)
|
|
135
|
+
else:
|
|
136
|
+
text = text_raw
|
|
137
|
+
button_label = node.get("button_label", "Options")
|
|
138
|
+
|
|
139
|
+
# Reserve slots for pagination rows so total rows never exceed MAX_MENU_ROWS.
|
|
140
|
+
# On a middle page we need both Prev and Next (2 slots).
|
|
141
|
+
# On the first or last page we need one (1 slot).
|
|
142
|
+
# On a single page we need none.
|
|
143
|
+
if total_pages == 1:
|
|
144
|
+
pagination_slots = 0
|
|
145
|
+
elif 0 < page < total_pages - 1:
|
|
146
|
+
pagination_slots = 2 # Prev + Next
|
|
147
|
+
else:
|
|
148
|
+
pagination_slots = 1 # Prev or Next only
|
|
149
|
+
|
|
150
|
+
max_items = MAX_MENU_ROWS - pagination_slots
|
|
151
|
+
start = page * (MAX_MENU_ROWS - 2) if total_pages > 1 else 0
|
|
152
|
+
page_options = all_options[start: start + max_items]
|
|
153
|
+
|
|
154
|
+
# Build the reply options for the current page
|
|
155
|
+
reply_options = []
|
|
156
|
+
for opt in page_options:
|
|
157
|
+
label = opt.get("label", "") if isinstance(opt, dict) else opt[0]
|
|
158
|
+
value = opt.get("value", opt.get("next", "")) if isinstance(opt, dict) else opt[1]
|
|
159
|
+
desc = opt.get("description", "") if isinstance(opt, dict) else ""
|
|
160
|
+
reply_options.append(ReplyOption(label=label, value=value, description=desc))
|
|
161
|
+
|
|
162
|
+
# Add pagination rows if needed
|
|
163
|
+
if total_pages > 1:
|
|
164
|
+
if page > 0:
|
|
165
|
+
reply_options.append(ReplyOption(
|
|
166
|
+
label="◀ Previous Page",
|
|
167
|
+
value=PREV_PAGE,
|
|
168
|
+
description=f"Page {page}/{total_pages}",
|
|
169
|
+
))
|
|
170
|
+
if page < total_pages - 1:
|
|
171
|
+
reply_options.append(ReplyOption(
|
|
172
|
+
label="Next Page ▶",
|
|
173
|
+
value=NEXT_PAGE,
|
|
174
|
+
description=f"Page {page + 2}/{total_pages}",
|
|
175
|
+
))
|
|
176
|
+
|
|
177
|
+
body = text
|
|
178
|
+
|
|
179
|
+
return Reply(
|
|
180
|
+
type="text",
|
|
181
|
+
body=body,
|
|
182
|
+
phone=session.user_id,
|
|
183
|
+
options=reply_options,
|
|
184
|
+
node_type="menu",
|
|
185
|
+
current_node=session.current_node,
|
|
186
|
+
session_state=session.lifecycle_state,
|
|
187
|
+
meta={"button_label": button_label},
|
|
188
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Dict, Any
|
|
2
|
+
from ..session import Session
|
|
3
|
+
|
|
4
|
+
def render_node_prompt(node: Dict[str, Any], session: Session) -> str:
|
|
5
|
+
t = node.get("type")
|
|
6
|
+
if t == "menu":
|
|
7
|
+
lines = [node.get("text", ""), ""]
|
|
8
|
+
for i, opt in enumerate(node.get("options", []), 1):
|
|
9
|
+
label = opt.get("label") if isinstance(opt, dict) else opt[0]
|
|
10
|
+
lines.append(f"{i}. {label}")
|
|
11
|
+
return "\n".join(lines)
|
|
12
|
+
elif t == "input":
|
|
13
|
+
return node.get("prompt", "")
|
|
14
|
+
elif t == "confirm":
|
|
15
|
+
text = node.get("text", "")
|
|
16
|
+
if callable(text):
|
|
17
|
+
body = text(session.collected)
|
|
18
|
+
else:
|
|
19
|
+
body = text
|
|
20
|
+
lines = [body, ""]
|
|
21
|
+
for i, opt in enumerate(node.get("options", []), 1):
|
|
22
|
+
label = opt.get("label") if isinstance(opt, dict) else opt[0]
|
|
23
|
+
lines.append(f"{i}. {label}")
|
|
24
|
+
return "\n".join(lines)
|
|
25
|
+
elif t == "action":
|
|
26
|
+
return "" # Actions produce their own reply
|
|
27
|
+
else:
|
|
28
|
+
return "Unknown node type."
|