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,273 @@
|
|
|
1
|
+
"""
|
|
2
|
+
turnstack.handlers.base
|
|
3
|
+
=======================
|
|
4
|
+
Abstract base for all node handlers.
|
|
5
|
+
|
|
6
|
+
Key responsibilities:
|
|
7
|
+
- ``handle()`` — implemented by each subclass
|
|
8
|
+
- ``_enter_node()`` — shared logic for entering any node after a transition
|
|
9
|
+
(handles chained actions, routers, etc.)
|
|
10
|
+
- ``_transition_to()`` — moves session forward, pushes nav stack
|
|
11
|
+
- ``_go_back()`` — pops nav stack, moves session backward
|
|
12
|
+
- ``_go_home()`` — clears nav stack, returns to entry node
|
|
13
|
+
- ``_render()`` — builds a Reply for any renderable node type
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
import inspect
|
|
19
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from ..message import IncomingMessage
|
|
22
|
+
from ..reply import Reply, ReplyOption
|
|
23
|
+
from ..session import Session
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from ..tree import FlowTree
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NodeHandler(ABC):
|
|
30
|
+
"""Abstract handler for a single node type."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def handle(
|
|
34
|
+
self,
|
|
35
|
+
node: Dict[str, Any],
|
|
36
|
+
session: Session,
|
|
37
|
+
message: IncomingMessage,
|
|
38
|
+
tree: "FlowTree",
|
|
39
|
+
) -> Reply:
|
|
40
|
+
"""Process the incoming message for this node and return a Reply."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
# ── transition helpers ────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def _transition_to(self, session: Session, next_node: str) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Move session to ``next_node``, pushing the current node onto nav_stack.
|
|
48
|
+
|
|
49
|
+
Does NOT push if going to "__end__" or if the destination is the same node.
|
|
50
|
+
"""
|
|
51
|
+
if not next_node or next_node == "__end__":
|
|
52
|
+
return
|
|
53
|
+
if next_node != session.current_node:
|
|
54
|
+
session.nav_stack.append(session.current_node)
|
|
55
|
+
session.current_node = next_node
|
|
56
|
+
|
|
57
|
+
def _go_back(self, session: Session, entry_node: str) -> None:
|
|
58
|
+
"""Pop the nav stack and go to the previous node (or entry if at root)."""
|
|
59
|
+
prev = session.go_back()
|
|
60
|
+
session.current_node = prev if prev else entry_node
|
|
61
|
+
|
|
62
|
+
def _go_home(self, session: Session, entry_node: str) -> None:
|
|
63
|
+
"""Jump to entry node, clearing the nav stack and collected data."""
|
|
64
|
+
session.go_home(entry_node)
|
|
65
|
+
|
|
66
|
+
# ── shared enter-node logic ───────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
async def _enter_node(
|
|
69
|
+
self,
|
|
70
|
+
session: Session,
|
|
71
|
+
tree: "FlowTree",
|
|
72
|
+
_depth: int = 0,
|
|
73
|
+
) -> Reply:
|
|
74
|
+
"""
|
|
75
|
+
Render the node ``session.current_node`` is pointing at.
|
|
76
|
+
|
|
77
|
+
Handles transparent chaining:
|
|
78
|
+
- router → evaluates silently, enters the target node
|
|
79
|
+
- action → runs fn, then enters next node
|
|
80
|
+
- media → generates and returns media reply
|
|
81
|
+
- anything else → renders and returns
|
|
82
|
+
|
|
83
|
+
``_depth`` guards against infinite loops (max 10 silent hops).
|
|
84
|
+
"""
|
|
85
|
+
if _depth > 10:
|
|
86
|
+
return self._error(session, "Infinite routing loop detected.")
|
|
87
|
+
|
|
88
|
+
node = tree.get(session.current_node)
|
|
89
|
+
if not node:
|
|
90
|
+
return self._error(session, f"Node '{session.current_node}' not found.")
|
|
91
|
+
|
|
92
|
+
t = node.get("type")
|
|
93
|
+
|
|
94
|
+
if t == "router":
|
|
95
|
+
from .router import RouterHandler
|
|
96
|
+
return await RouterHandler()._run_router(node, session, tree, _depth)
|
|
97
|
+
|
|
98
|
+
if t == "action":
|
|
99
|
+
from .action import ActionHandler
|
|
100
|
+
return await ActionHandler()._run_action(node, session, tree, _depth)
|
|
101
|
+
|
|
102
|
+
if t == "media":
|
|
103
|
+
from .media_handler import MediaHandler
|
|
104
|
+
dummy = IncomingMessage(user_id=session.user_id, type="text", text="")
|
|
105
|
+
return await MediaHandler().handle(node, session, dummy, tree)
|
|
106
|
+
|
|
107
|
+
# Everything else (menu, confirm, input, list): render
|
|
108
|
+
return self._render(node, session)
|
|
109
|
+
|
|
110
|
+
# ── reply builders ────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def _render(self, node: Dict[str, Any], session: Session) -> Reply:
|
|
113
|
+
"""Build a Reply for any renderable node type."""
|
|
114
|
+
t = node.get("type")
|
|
115
|
+
|
|
116
|
+
if t == "menu":
|
|
117
|
+
return self._render_menu(node, session)
|
|
118
|
+
|
|
119
|
+
if t == "confirm":
|
|
120
|
+
return self._render_confirm(node, session)
|
|
121
|
+
|
|
122
|
+
if t == "input":
|
|
123
|
+
return self._render_input(node, session)
|
|
124
|
+
|
|
125
|
+
if t == "list":
|
|
126
|
+
return self._render_list(node, session)
|
|
127
|
+
|
|
128
|
+
# Media is a transparent/auto-executing node (like router/action).
|
|
129
|
+
# It must be dispatched by the engine, never rendered statically.
|
|
130
|
+
# If we reach here it means _render_current had a gap — surface a
|
|
131
|
+
# clear error instead of going silent.
|
|
132
|
+
if t == "media":
|
|
133
|
+
return self._error(
|
|
134
|
+
session,
|
|
135
|
+
"MediaReply node reached _render() — this is a bug. "
|
|
136
|
+
"Ensure engine._render_current dispatches 'media' directly.",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return self._error(session, f"Cannot render node type '{t}'.")
|
|
140
|
+
|
|
141
|
+
def _render_menu(self, node: Dict[str, Any], session: Session) -> Reply:
|
|
142
|
+
# Delegate to MenuHandler so pagination is applied consistently
|
|
143
|
+
# whether we arrive via a cold entry (router/action chain) or
|
|
144
|
+
# directly through MenuHandler.handle().
|
|
145
|
+
from .menu import MenuHandler
|
|
146
|
+
all_options = node.get("options", [])
|
|
147
|
+
pkey = f"menu_{session.current_node}_page"
|
|
148
|
+
page = session.pagination.get(pkey, 0)
|
|
149
|
+
|
|
150
|
+
from .menu import MAX_MENU_ROWS
|
|
151
|
+
items_per_page = MAX_MENU_ROWS - 2 if len(all_options) > MAX_MENU_ROWS else MAX_MENU_ROWS
|
|
152
|
+
total_pages = max(1, (len(all_options) + items_per_page - 1) // items_per_page)
|
|
153
|
+
if page >= total_pages:
|
|
154
|
+
page = max(0, total_pages - 1)
|
|
155
|
+
session.pagination[pkey] = page
|
|
156
|
+
|
|
157
|
+
return MenuHandler()._render_menu_page(node, session, all_options, page, total_pages)
|
|
158
|
+
|
|
159
|
+
def _render_confirm(self, node: Dict[str, Any], session: Session) -> Reply:
|
|
160
|
+
text = node.get("text", "")
|
|
161
|
+
body = text(session.collected) if callable(text) else text
|
|
162
|
+
|
|
163
|
+
options = node.get("options", [])
|
|
164
|
+
reply_options: List[ReplyOption] = []
|
|
165
|
+
|
|
166
|
+
for opt in options:
|
|
167
|
+
label = opt.get("label", "") if isinstance(opt, dict) else opt[0]
|
|
168
|
+
value = opt.get("value", opt.get("next", "")) if isinstance(opt, dict) else opt[1]
|
|
169
|
+
reply_options.append(ReplyOption(label=label, value=value))
|
|
170
|
+
|
|
171
|
+
return Reply(
|
|
172
|
+
type="text",
|
|
173
|
+
body=body,
|
|
174
|
+
phone=session.user_id,
|
|
175
|
+
options=reply_options,
|
|
176
|
+
node_type="confirm",
|
|
177
|
+
suggested_replies=[o.value for o in reply_options],
|
|
178
|
+
current_node=session.current_node,
|
|
179
|
+
session_state=session.lifecycle_state,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _render_input(self, node: Dict[str, Any], session: Session) -> Reply:
|
|
183
|
+
"""
|
|
184
|
+
Render the current field inside an input node.
|
|
185
|
+
|
|
186
|
+
Two cases:
|
|
187
|
+
- Cold entry (no idx in pagination): reset the node and render field 0.
|
|
188
|
+
- Back-nav within the node (idx already set by engine): skip reset,
|
|
189
|
+
render whichever field the engine stepped back to.
|
|
190
|
+
"""
|
|
191
|
+
from .input import InputHandler, _IDX_KEY_TMPL, _flatten_fields
|
|
192
|
+
fields = node.get("fields", [])
|
|
193
|
+
|
|
194
|
+
if not fields:
|
|
195
|
+
return self._error(session, "Input: no fields defined.")
|
|
196
|
+
|
|
197
|
+
handler = InputHandler()
|
|
198
|
+
idx_key = _IDX_KEY_TMPL.format(node=session.current_node)
|
|
199
|
+
|
|
200
|
+
if idx_key in session.pagination:
|
|
201
|
+
# Back-nav has already positioned the index -- just re-render that field.
|
|
202
|
+
# Flatten so BranchFields that are now active are included and the
|
|
203
|
+
# step counter reflects the real remaining work.
|
|
204
|
+
idx = session.pagination[idx_key]
|
|
205
|
+
fields = _flatten_fields(fields, session)
|
|
206
|
+
else:
|
|
207
|
+
# Genuine cold entry -- reset first (wipes collected + pagination),
|
|
208
|
+
# then flatten against the now-clean session so branch conditions
|
|
209
|
+
# that depended on stale answers from a previous run evaluate to
|
|
210
|
+
# False. Without the re-flatten the BranchField object itself is
|
|
211
|
+
# counted as a field, giving "Step 1 of 8" instead of "Step 1 of 7".
|
|
212
|
+
handler._reset_input(session, node, fields)
|
|
213
|
+
fields = _flatten_fields(fields, session)
|
|
214
|
+
idx = 0
|
|
215
|
+
|
|
216
|
+
return handler._render_field(node, session, fields, idx)
|
|
217
|
+
|
|
218
|
+
def _render_list(self, node: Dict[str, Any], session: Session) -> Reply:
|
|
219
|
+
from .list_handler import ListHandler, MAX_ROWS
|
|
220
|
+
|
|
221
|
+
fetch = node.get("fetch")
|
|
222
|
+
if not fetch:
|
|
223
|
+
return self._error(session, "ListNode has no 'fetch' function.")
|
|
224
|
+
|
|
225
|
+
page_size = min(MAX_ROWS, max(1, node.get("page_size", 8)))
|
|
226
|
+
interactive = node.get("interactive", False)
|
|
227
|
+
|
|
228
|
+
sig = inspect.signature(fetch)
|
|
229
|
+
paginated_mode = len(sig.parameters) >= 3
|
|
230
|
+
|
|
231
|
+
pkey = f"list_{session.current_node}_page"
|
|
232
|
+
page = session.pagination.get(pkey, 0)
|
|
233
|
+
|
|
234
|
+
if paginated_mode:
|
|
235
|
+
try:
|
|
236
|
+
result = fetch(session, page, page_size)
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
return self._error(session, f"Paginated fetch raised: {exc}")
|
|
239
|
+
if isinstance(result, tuple) and len(result) == 2:
|
|
240
|
+
items_page, total_items = result
|
|
241
|
+
elif isinstance(result, dict):
|
|
242
|
+
items_page = result.get("items", [])
|
|
243
|
+
total_items = result.get("total", 0)
|
|
244
|
+
else:
|
|
245
|
+
return self._error(session, "Paginated fetch must return (items, total) or dict.")
|
|
246
|
+
items_page = list(items_page or [])
|
|
247
|
+
else:
|
|
248
|
+
try:
|
|
249
|
+
items = fetch(session)
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
return self._error(session, f"Fetch raised: {exc}")
|
|
252
|
+
items = list(items or [])
|
|
253
|
+
total_items = len(items)
|
|
254
|
+
start = page * page_size
|
|
255
|
+
items_page = items[start: start + page_size]
|
|
256
|
+
|
|
257
|
+
total_pages = max(1, (total_items + page_size - 1) // page_size)
|
|
258
|
+
if page >= total_pages:
|
|
259
|
+
page = max(0, total_pages - 1)
|
|
260
|
+
session.pagination[pkey] = page
|
|
261
|
+
|
|
262
|
+
return ListHandler()._render_list_page(
|
|
263
|
+
node, session, items_page, page, total_pages, interactive, page_size, paginated_mode
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _error(self, session: Session, msg: str) -> Reply:
|
|
267
|
+
return Reply(
|
|
268
|
+
type="error",
|
|
269
|
+
body=msg,
|
|
270
|
+
phone=session.user_id,
|
|
271
|
+
current_node=session.current_node,
|
|
272
|
+
session_state=session.lifecycle_state,
|
|
273
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
|
6
|
+
from ..session import Session
|
|
7
|
+
from .base import NodeHandler
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ..tree import FlowTree
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfirmHandler(NodeHandler):
|
|
14
|
+
"""Handles ``confirm`` nodes — review screen before a write action."""
|
|
15
|
+
|
|
16
|
+
async def handle(
|
|
17
|
+
self,
|
|
18
|
+
node: Dict[str, Any],
|
|
19
|
+
session: Session,
|
|
20
|
+
message: IncomingMessage,
|
|
21
|
+
tree: "FlowTree",
|
|
22
|
+
) -> Reply:
|
|
23
|
+
raw = (message.interactive_id or message.text or "").strip()
|
|
24
|
+
|
|
25
|
+
if not raw:
|
|
26
|
+
return self._render_confirm(node, session)
|
|
27
|
+
|
|
28
|
+
matched_next = self._match_option(node, message, raw)
|
|
29
|
+
|
|
30
|
+
if not matched_next:
|
|
31
|
+
rendered = self._render_confirm(node, session)
|
|
32
|
+
return Reply(
|
|
33
|
+
type="text",
|
|
34
|
+
body="Please choose one of the options.\n\n" + rendered.body,
|
|
35
|
+
phone=session.user_id,
|
|
36
|
+
options=rendered.options,
|
|
37
|
+
node_type="confirm",
|
|
38
|
+
current_node=session.current_node,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if matched_next == tree.entry:
|
|
42
|
+
session.collected = {}
|
|
43
|
+
|
|
44
|
+
self._transition_to(session, matched_next)
|
|
45
|
+
return await self._enter_node(session, tree)
|
|
46
|
+
|
|
47
|
+
def _match_option(
|
|
48
|
+
self,
|
|
49
|
+
node: Dict[str, Any],
|
|
50
|
+
message: IncomingMessage,
|
|
51
|
+
raw: str,
|
|
52
|
+
) -> Optional[str]:
|
|
53
|
+
options = node.get("options", [])
|
|
54
|
+
allow_numeric = node.get("allow_numeric", False)
|
|
55
|
+
|
|
56
|
+
for i, opt in enumerate(options, 1):
|
|
57
|
+
label = opt.get("label", "") if isinstance(opt, dict) else opt[0]
|
|
58
|
+
value = opt.get("value", opt.get("next", "")) if isinstance(opt, dict) else opt[1]
|
|
59
|
+
next_key = opt.get("next", "") if isinstance(opt, dict) else opt[1]
|
|
60
|
+
|
|
61
|
+
if message.interactive_id and (
|
|
62
|
+
message.interactive_id == value or message.interactive_id == next_key
|
|
63
|
+
):
|
|
64
|
+
return next_key
|
|
65
|
+
if allow_numeric and raw == str(i):
|
|
66
|
+
return next_key
|
|
67
|
+
if raw.lower() == label.lower():
|
|
68
|
+
return next_key
|
|
69
|
+
|
|
70
|
+
return None
|