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,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."