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
turnstack/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TurnStack — WhatsApp conversation flow engine.
|
|
3
|
+
|
|
4
|
+
Quick start::
|
|
5
|
+
|
|
6
|
+
from turnstack import BotEngine, FlowTree, InMemorySessionStore, IncomingMessage
|
|
7
|
+
from turnstack.nodes import Menu, Input, Action, Confirm, Router, ListNode, Option, Field, Route
|
|
8
|
+
|
|
9
|
+
tree = FlowTree(entry="entry")
|
|
10
|
+
|
|
11
|
+
tree.add("entry", Router(
|
|
12
|
+
routes=[Route(when=lambda s: s.context.get("user"), next="home")],
|
|
13
|
+
default="welcome",
|
|
14
|
+
))
|
|
15
|
+
|
|
16
|
+
tree.add("welcome", Menu(
|
|
17
|
+
text="Welcome! What would you like to do?",
|
|
18
|
+
options=[
|
|
19
|
+
Option("Register", next="register_name"),
|
|
20
|
+
Option("Learn More", next="learn_more"),
|
|
21
|
+
]
|
|
22
|
+
))
|
|
23
|
+
|
|
24
|
+
engine = BotEngine(tree=tree)
|
|
25
|
+
|
|
26
|
+
# In your webhook handler:
|
|
27
|
+
reply = await engine.process(IncomingMessage(phone="254711234567", type="text", text="1"))
|
|
28
|
+
# reply.type → "text" | "media" | "end" | "error"
|
|
29
|
+
# reply.body → text to send
|
|
30
|
+
# reply.options → ReplyOption list for building interactive messages
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from .engine import BotEngine
|
|
34
|
+
from .tree import FlowTree
|
|
35
|
+
from .session import Session, SessionStore
|
|
36
|
+
from .message import IncomingMessage
|
|
37
|
+
from .reply import Reply, ReplyOption
|
|
38
|
+
from .stores.memory import InMemorySessionStore
|
|
39
|
+
from .exceptions import (
|
|
40
|
+
TurnStackError,
|
|
41
|
+
FlowValidationError,
|
|
42
|
+
NodeNotFoundError,
|
|
43
|
+
SessionNotFoundError,
|
|
44
|
+
HandlerNotFoundError,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__version__ = "0.2.0"
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
# core
|
|
51
|
+
"BotEngine",
|
|
52
|
+
"FlowTree",
|
|
53
|
+
"Session",
|
|
54
|
+
"SessionStore",
|
|
55
|
+
"IncomingMessage",
|
|
56
|
+
"Reply",
|
|
57
|
+
"ReplyOption",
|
|
58
|
+
"InMemorySessionStore",
|
|
59
|
+
# exceptions
|
|
60
|
+
"TurnStackError",
|
|
61
|
+
"FlowValidationError",
|
|
62
|
+
"NodeNotFoundError",
|
|
63
|
+
"SessionNotFoundError",
|
|
64
|
+
"HandlerNotFoundError",
|
|
65
|
+
]
|
turnstack/engine.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""
|
|
2
|
+
turnstack.engine
|
|
3
|
+
================
|
|
4
|
+
BotEngine — the main entry point.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
engine = BotEngine(tree=tree, session_store=InMemorySessionStore())
|
|
9
|
+
replies = await engine.process(incoming_message)
|
|
10
|
+
for reply in replies:
|
|
11
|
+
await send_whatsapp(reply)
|
|
12
|
+
|
|
13
|
+
``process()`` always returns a **list** of :class:`Reply` objects.
|
|
14
|
+
In the common case the list has one item. When a ``media`` node fires,
|
|
15
|
+
the list has two items: the file reply followed immediately by the
|
|
16
|
+
rendered next node — so the developer never has to touch session internals.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
from typing import Dict, List, Optional, Set, Union
|
|
21
|
+
|
|
22
|
+
from .message import IncomingMessage
|
|
23
|
+
from .reply import Reply
|
|
24
|
+
from .session import Session, SessionStore
|
|
25
|
+
from .stores.memory import InMemorySessionStore
|
|
26
|
+
from .tree import (
|
|
27
|
+
FlowTree,
|
|
28
|
+
NODE_MENU, NODE_INPUT, NODE_CONFIRM, NODE_ACTION,
|
|
29
|
+
NODE_ROUTER, NODE_LIST, NODE_MEDIA, NODE_INPUT,
|
|
30
|
+
)
|
|
31
|
+
from .handlers.base import NodeHandler
|
|
32
|
+
from .handlers.menu import MenuHandler
|
|
33
|
+
from .handlers.confirm import ConfirmHandler
|
|
34
|
+
from .handlers.action import ActionHandler
|
|
35
|
+
from .handlers.router import RouterHandler
|
|
36
|
+
from .handlers.list_handler import ListHandler
|
|
37
|
+
from .handlers.input import InputHandler
|
|
38
|
+
from .handlers.media_handler import MediaHandler
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BotEngine:
|
|
42
|
+
"""
|
|
43
|
+
The TurnStack engine.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
tree: A validated :class:`FlowTree`.
|
|
48
|
+
session_store: Optional :class:`SessionStore` implementation.
|
|
49
|
+
Defaults to :class:`InMemorySessionStore` (dev/testing).
|
|
50
|
+
session_timeout: Seconds of inactivity before a session expires. Default: 1800.
|
|
51
|
+
back_keywords: Set of text strings that trigger "go back" (default: {"0","back","go back"}).
|
|
52
|
+
home_keywords: Set of text strings that trigger "go home" (default: {"00","home","main menu","start over"}).
|
|
53
|
+
exit_keywords: Set of text strings that trigger "exit/reset session" (default: {"exit","quit","reset","goodbye"}).
|
|
54
|
+
|
|
55
|
+
The engine:
|
|
56
|
+
1. Loads or creates the user's session.
|
|
57
|
+
2. Checks for expiry and resets if needed.
|
|
58
|
+
3. Intercepts global navigation commands (back, home, exit).
|
|
59
|
+
4. Dispatches to the correct :class:`NodeHandler`.
|
|
60
|
+
5. Saves the updated session.
|
|
61
|
+
6. Returns a :class:`Reply` the developer maps to their WhatsApp provider.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
tree: FlowTree,
|
|
67
|
+
session_store: Optional[SessionStore] = None,
|
|
68
|
+
session_timeout: int = 1800,
|
|
69
|
+
back_keywords: Optional[Set[str]] = None,
|
|
70
|
+
home_keywords: Optional[Set[str]] = None,
|
|
71
|
+
exit_keywords: Optional[Set[str]] = None,
|
|
72
|
+
unsupported_text: Optional[str] = None,
|
|
73
|
+
):
|
|
74
|
+
self.tree = tree
|
|
75
|
+
self.session_store = session_store or InMemorySessionStore(session_timeout=session_timeout)
|
|
76
|
+
self.session_timeout = session_timeout
|
|
77
|
+
|
|
78
|
+
# Global command keywords (case‑insensitive)
|
|
79
|
+
self.back_keywords = back_keywords or {"0", "back", "go back"}
|
|
80
|
+
self.home_keywords = home_keywords or {"00", "home", "menu", "start over"}
|
|
81
|
+
self.exit_keywords = exit_keywords or {"000", "exit", "quit", "reset", "goodbye", "bye"}
|
|
82
|
+
|
|
83
|
+
# Message shown when an unsupported type (sticker, audio, reaction…) arrives
|
|
84
|
+
self.unsupported_text = (
|
|
85
|
+
unsupported_text
|
|
86
|
+
or "⚠️ Sorry, I can't process the message. Please retry."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Types the engine knows how to route; everything else gets unsupported_text
|
|
90
|
+
self._supported_types = {"text", "interactive", "image", "document", "location"}
|
|
91
|
+
|
|
92
|
+
# validate tree on startup — fail loud, not silent
|
|
93
|
+
self.tree.validate()
|
|
94
|
+
|
|
95
|
+
# ── default handler registry ──────────────────────────────────
|
|
96
|
+
self._handlers: Dict[str, NodeHandler] = {
|
|
97
|
+
NODE_MENU: MenuHandler(),
|
|
98
|
+
NODE_CONFIRM: ConfirmHandler(),
|
|
99
|
+
NODE_ACTION: ActionHandler(),
|
|
100
|
+
NODE_ROUTER: RouterHandler(),
|
|
101
|
+
NODE_LIST: ListHandler(),
|
|
102
|
+
NODE_INPUT: InputHandler(),
|
|
103
|
+
NODE_MEDIA: MediaHandler(),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def register_handler(self, node_type: str, handler: NodeHandler) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Register a custom handler for a node type.
|
|
109
|
+
|
|
110
|
+
Use this to extend the engine with new node types or override
|
|
111
|
+
built-in behaviour::
|
|
112
|
+
|
|
113
|
+
engine.register_handler("payment_prompt", MyPaymentHandler())
|
|
114
|
+
"""
|
|
115
|
+
self._handlers[node_type] = handler
|
|
116
|
+
|
|
117
|
+
async def process(self, incoming: IncomingMessage) -> List[Reply]:
|
|
118
|
+
"""
|
|
119
|
+
Process one incoming message and return a list of Reply objects.
|
|
120
|
+
|
|
121
|
+
This is the only public method the developer calls.
|
|
122
|
+
|
|
123
|
+
Usually the list contains a single item. When a ``media`` node
|
|
124
|
+
fires, the list contains two items: the file reply followed by the
|
|
125
|
+
rendered follow-up node (e.g. the menu the user lands on after
|
|
126
|
+
receiving the file). The developer simply iterates and sends each
|
|
127
|
+
reply in order — no session introspection required.
|
|
128
|
+
"""
|
|
129
|
+
# ── 1. load or create session ─────────────────────────────────
|
|
130
|
+
session = await self.session_store.get(incoming.user_id)
|
|
131
|
+
if session is None:
|
|
132
|
+
session = Session(user_id=incoming.user_id, current_node=self.tree.entry)
|
|
133
|
+
|
|
134
|
+
# ── 2. handle expiry ──────────────────────────────────────────
|
|
135
|
+
if session.is_expired(self.session_timeout):
|
|
136
|
+
session.reset(self.tree.entry)
|
|
137
|
+
await self.session_store.save(session)
|
|
138
|
+
# re-process as a fresh session (shows entry node)
|
|
139
|
+
entry_reply = await self._dispatch(
|
|
140
|
+
session,
|
|
141
|
+
IncomingMessage(user_id=incoming.user_id, type="text", text=""),
|
|
142
|
+
)
|
|
143
|
+
if entry_reply is None:
|
|
144
|
+
entry_reply = Reply(
|
|
145
|
+
type="error",
|
|
146
|
+
body="Internal error.",
|
|
147
|
+
phone=incoming.user_id,
|
|
148
|
+
)
|
|
149
|
+
entry_reply = self._enrich_menu_reply(entry_reply, session)
|
|
150
|
+
await self.session_store.save(session)
|
|
151
|
+
return [entry_reply]
|
|
152
|
+
|
|
153
|
+
# ── 3. touch (activate if new) ────────────────────────────────
|
|
154
|
+
session.touch()
|
|
155
|
+
|
|
156
|
+
# ── 4. unsupported message type → polite reply, no state change ──
|
|
157
|
+
if incoming.type not in self._supported_types:
|
|
158
|
+
reply = await self._render_current(session)
|
|
159
|
+
if reply is None:
|
|
160
|
+
reply = Reply(
|
|
161
|
+
type="text",
|
|
162
|
+
body=self.unsupported_text,
|
|
163
|
+
phone=incoming.user_id,
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
reply.body = self.unsupported_text + "\n\n" + reply.body
|
|
167
|
+
reply = self._enrich_menu_reply(reply, session)
|
|
168
|
+
reply.session_state = session.lifecycle_state
|
|
169
|
+
reply.current_node = session.current_node
|
|
170
|
+
await self.session_store.save(session)
|
|
171
|
+
return [reply]
|
|
172
|
+
|
|
173
|
+
# ── 5. new session first message — always render entry node ───
|
|
174
|
+
if session.lifecycle_state == "new" or _is_blank(incoming):
|
|
175
|
+
session.touch()
|
|
176
|
+
reply = await self._render_current(session)
|
|
177
|
+
if reply is None:
|
|
178
|
+
reply = Reply(
|
|
179
|
+
type="error",
|
|
180
|
+
body="Failed to render entry node.",
|
|
181
|
+
phone=incoming.user_id,
|
|
182
|
+
)
|
|
183
|
+
reply = self._enrich_menu_reply(reply, session)
|
|
184
|
+
await self.session_store.save(session)
|
|
185
|
+
return [reply]
|
|
186
|
+
|
|
187
|
+
# ── 6. INTERCEPT GLOBAL COMMANDS (before dispatch) ────────────
|
|
188
|
+
# Only plain text messages (not interactive selections) can be commands
|
|
189
|
+
if incoming.type == "text" and incoming.text:
|
|
190
|
+
cmd_reply = await self._handle_global_command(session, incoming.text.strip().lower())
|
|
191
|
+
if cmd_reply:
|
|
192
|
+
# Command handled – render the resulting node
|
|
193
|
+
reply = await self._render_current(session)
|
|
194
|
+
if reply is None:
|
|
195
|
+
reply = Reply(
|
|
196
|
+
type="error",
|
|
197
|
+
body="Failed to render after command.",
|
|
198
|
+
phone=incoming.user_id,
|
|
199
|
+
)
|
|
200
|
+
reply = self._enrich_menu_reply(reply, session)
|
|
201
|
+
reply.session_state = session.lifecycle_state
|
|
202
|
+
reply.current_node = session.current_node
|
|
203
|
+
await self.session_store.save(session)
|
|
204
|
+
return [reply]
|
|
205
|
+
|
|
206
|
+
# ── 7. normal dispatch ────────────────────────────────────────
|
|
207
|
+
reply = await self._dispatch(session, incoming)
|
|
208
|
+
if reply is None:
|
|
209
|
+
reply = Reply(
|
|
210
|
+
type="error",
|
|
211
|
+
body="No reply generated.",
|
|
212
|
+
phone=session.user_id,
|
|
213
|
+
current_node=session.current_node,
|
|
214
|
+
)
|
|
215
|
+
reply = self._enrich_menu_reply(reply, session)
|
|
216
|
+
|
|
217
|
+
# ── 8. attach meta and save ───────────────────────────────────
|
|
218
|
+
reply.session_state = session.lifecycle_state
|
|
219
|
+
reply.current_node = session.current_node
|
|
220
|
+
await self.session_store.save(session)
|
|
221
|
+
|
|
222
|
+
# ── 9. media follow-up: render the next node automatically ────
|
|
223
|
+
# When a media node fires it sends a file and then advances the
|
|
224
|
+
# session to `next`. The user must receive that follow-up node
|
|
225
|
+
# (usually a menu) as a second message immediately — the engine
|
|
226
|
+
# handles this so the developer never has to call session_store or
|
|
227
|
+
# _render_current themselves.
|
|
228
|
+
if reply.type == "media" and session.current_node:
|
|
229
|
+
follow_reply = await self._render_current(session)
|
|
230
|
+
if follow_reply:
|
|
231
|
+
follow_reply = self._enrich_menu_reply(follow_reply, session)
|
|
232
|
+
follow_reply.session_state = session.lifecycle_state
|
|
233
|
+
follow_reply.current_node = session.current_node
|
|
234
|
+
await self.session_store.save(session)
|
|
235
|
+
return [reply, follow_reply]
|
|
236
|
+
|
|
237
|
+
return [reply]
|
|
238
|
+
|
|
239
|
+
# ── internal ──────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
async def _handle_global_command(self, session: Session, text: str) -> Optional[Reply]:
|
|
242
|
+
"""
|
|
243
|
+
Check if the input matches a global command.
|
|
244
|
+
Returns a Reply only if the command should interrupt normal flow.
|
|
245
|
+
Otherwise returns None.
|
|
246
|
+
"""
|
|
247
|
+
# Exit / reset
|
|
248
|
+
if text in self.exit_keywords:
|
|
249
|
+
session.reset(self.tree.entry)
|
|
250
|
+
# Optionally send a goodbye message (could be configured)
|
|
251
|
+
return Reply(
|
|
252
|
+
type="text",
|
|
253
|
+
body="👋 Session reset. Type anything to start over.",
|
|
254
|
+
phone=session.user_id,
|
|
255
|
+
node_type="text",
|
|
256
|
+
current_node=session.current_node,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Go home
|
|
260
|
+
if text in self.home_keywords:
|
|
261
|
+
session.go_home(self.tree.entry)
|
|
262
|
+
return None # No extra message; we will re‑render the entry node
|
|
263
|
+
|
|
264
|
+
# Go back
|
|
265
|
+
if text in self.back_keywords:
|
|
266
|
+
# ── input-aware back: step within the input node first ────
|
|
267
|
+
node = self.tree.get(session.current_node)
|
|
268
|
+
if node and node.get("type") == "input":
|
|
269
|
+
from .handlers.input import _IDX_KEY_TMPL
|
|
270
|
+
idx_key = _IDX_KEY_TMPL.format(node=session.current_node)
|
|
271
|
+
idx = session.pagination.get(idx_key, 0)
|
|
272
|
+
if idx > 0:
|
|
273
|
+
# Clear the previously collected value for that field
|
|
274
|
+
fields = node.get("fields", [])
|
|
275
|
+
prev_field = fields[idx - 1]
|
|
276
|
+
session.collected.pop(prev_field.get("name", ""), None)
|
|
277
|
+
session.pagination[idx_key] = idx - 1
|
|
278
|
+
return None # engine will call _render_current → re-renders the stepped-back field
|
|
279
|
+
# idx == 0: fall through to normal node-level back below
|
|
280
|
+
# ── normal node-level back ────────────────────────────────
|
|
281
|
+
previous = session.go_back()
|
|
282
|
+
if previous:
|
|
283
|
+
session.current_node = previous
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
async def _dispatch(self, session: Session, incoming: IncomingMessage) -> Optional[Reply]:
|
|
289
|
+
node = self.tree.get(session.current_node)
|
|
290
|
+
if not node:
|
|
291
|
+
return Reply(
|
|
292
|
+
type="error",
|
|
293
|
+
body=f"Node '{session.current_node}' not found in tree.",
|
|
294
|
+
phone=incoming.user_id,
|
|
295
|
+
session_state=session.lifecycle_state,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
node_type = node.get("type")
|
|
299
|
+
handler = self._handlers.get(node_type)
|
|
300
|
+
if not handler:
|
|
301
|
+
return Reply(
|
|
302
|
+
type="error",
|
|
303
|
+
body=f"No handler registered for node type '{node_type}'.",
|
|
304
|
+
phone=incoming.user_id,
|
|
305
|
+
session_state=session.lifecycle_state,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return await handler.handle(node, session, incoming, self.tree)
|
|
309
|
+
|
|
310
|
+
async def _render_current(self, session: Session) -> Optional[Reply]:
|
|
311
|
+
"""Render the current node without processing any input."""
|
|
312
|
+
node = self.tree.get(session.current_node)
|
|
313
|
+
if not node:
|
|
314
|
+
return Reply(type="error", body="Entry node not found.", phone=session.user_id)
|
|
315
|
+
|
|
316
|
+
t = node.get("type")
|
|
317
|
+
|
|
318
|
+
# router, action, and media nodes run immediately even on first render
|
|
319
|
+
if t in ("router", "action", "media"):
|
|
320
|
+
return await self._dispatch(
|
|
321
|
+
session,
|
|
322
|
+
IncomingMessage(user_id=session.user_id, type="text", text=""),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# renderable nodes
|
|
326
|
+
handler = self._handlers.get(t) or next(iter(self._handlers.values()))
|
|
327
|
+
return handler._render(node, session)
|
|
328
|
+
|
|
329
|
+
def _enrich_menu_reply(self, reply: Reply, session: Session) -> Reply:
|
|
330
|
+
"""
|
|
331
|
+
Ensure menu replies have button_label from the node.
|
|
332
|
+
|
|
333
|
+
This method is called automatically after every reply is generated.
|
|
334
|
+
Developers never need to touch `reply.meta` – they only set `button_label`
|
|
335
|
+
on their Menu nodes, and the engine propagates it to the WhatsApp adapter.
|
|
336
|
+
"""
|
|
337
|
+
# Guard against None (should not happen, but safety)
|
|
338
|
+
if reply is None:
|
|
339
|
+
return Reply(
|
|
340
|
+
type="error",
|
|
341
|
+
body="Internal error: missing reply.",
|
|
342
|
+
phone=session.user_id,
|
|
343
|
+
current_node=session.current_node,
|
|
344
|
+
)
|
|
345
|
+
if reply.node_type == "menu" and not reply.meta.get("button_label"):
|
|
346
|
+
node_name = reply.current_node or session.current_node
|
|
347
|
+
node = self.tree.get(node_name)
|
|
348
|
+
if node and node.get("button_label"):
|
|
349
|
+
reply.meta["button_label"] = node["button_label"]
|
|
350
|
+
return reply
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _is_blank(msg: IncomingMessage) -> bool:
|
|
354
|
+
return (
|
|
355
|
+
not msg.text
|
|
356
|
+
and not msg.interactive_id
|
|
357
|
+
and not msg.media_id
|
|
358
|
+
and not msg.location
|
|
359
|
+
)
|
turnstack/exceptions.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class TurnStackError(Exception):
|
|
2
|
+
"""Base exception for all TurnStack errors."""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
class FlowValidationError(TurnStackError):
|
|
6
|
+
"""Raised when the flow tree is invalid at startup."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class NodeNotFoundError(TurnStackError):
|
|
10
|
+
"""Raised when a node key referenced in the tree does not exist."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class SessionNotFoundError(TurnStackError):
|
|
14
|
+
"""Raised when the session store cannot find a session."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
class HandlerNotFoundError(TurnStackError):
|
|
18
|
+
"""Raised when no handler is registered for a node type."""
|
|
19
|
+
pass
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .base import NodeHandler
|
|
2
|
+
from .menu import MenuHandler
|
|
3
|
+
from .confirm import ConfirmHandler
|
|
4
|
+
from .action import ActionHandler
|
|
5
|
+
from .router import RouterHandler
|
|
6
|
+
from .list_handler import ListHandler
|
|
7
|
+
from .input import InputHandler
|
|
8
|
+
from .media_handler import MediaHandler
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"NodeHandler",
|
|
12
|
+
"MenuHandler",
|
|
13
|
+
"ConfirmHandler",
|
|
14
|
+
"ActionHandler",
|
|
15
|
+
"RouterHandler",
|
|
16
|
+
"ListHandler",
|
|
17
|
+
"InputHandler",
|
|
18
|
+
"MediaHandler",
|
|
19
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
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 ActionHandler(NodeHandler):
|
|
15
|
+
"""
|
|
16
|
+
Handles ``action`` nodes.
|
|
17
|
+
|
|
18
|
+
The developer's ``fn`` may return:
|
|
19
|
+
- A ``str`` — sent as a text message, then the flow advances to ``next``.
|
|
20
|
+
- A ``Reply`` — used as-is (allows sending media, ending session, etc.),
|
|
21
|
+
then the flow advances to ``next``.
|
|
22
|
+
- ``None`` — nothing extra is sent; the flow advances to ``next``.
|
|
23
|
+
|
|
24
|
+
Both sync and async functions are supported.
|
|
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
|
+
return await self._run_action(node, session, tree, _depth=0)
|
|
35
|
+
|
|
36
|
+
async def _run_action(
|
|
37
|
+
self,
|
|
38
|
+
node: Dict[str, Any],
|
|
39
|
+
session: Session,
|
|
40
|
+
tree: "FlowTree",
|
|
41
|
+
_depth: int = 0,
|
|
42
|
+
) -> Reply:
|
|
43
|
+
fn = node.get("fn")
|
|
44
|
+
if not fn:
|
|
45
|
+
return self._error(session, "Action node has no 'fn' defined.")
|
|
46
|
+
|
|
47
|
+
# ── call fn (sync or async) ───────────────────────────────────
|
|
48
|
+
try:
|
|
49
|
+
if inspect.iscoroutinefunction(fn):
|
|
50
|
+
result = await fn(session, session.collected)
|
|
51
|
+
else:
|
|
52
|
+
result = fn(session, session.collected)
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
return self._error(session, f"Action fn raised: {exc}")
|
|
55
|
+
|
|
56
|
+
# ── advance to next node ──────────────────────────────────────
|
|
57
|
+
next_key = node.get("next", "welcome")
|
|
58
|
+
if next_key == "__end__":
|
|
59
|
+
body = result if isinstance(result, str) else (result.body if result else "")
|
|
60
|
+
return Reply(type="end", body=body, phone=session.user_id,
|
|
61
|
+
current_node=session.current_node)
|
|
62
|
+
|
|
63
|
+
if next_key == tree.entry:
|
|
64
|
+
session.collected = {}
|
|
65
|
+
|
|
66
|
+
self._transition_to(session, next_key)
|
|
67
|
+
|
|
68
|
+
# ── build the combined reply ──────────────────────────────────
|
|
69
|
+
# Enter next node (may chain another action/router silently)
|
|
70
|
+
next_reply = await self._enter_node(session, tree, _depth + 1)
|
|
71
|
+
if next_reply is None:
|
|
72
|
+
next_reply = self._error(session, "Next node returned no reply.")
|
|
73
|
+
|
|
74
|
+
if isinstance(result, Reply):
|
|
75
|
+
# Developer returned a full Reply — send it, then queue next render
|
|
76
|
+
# We concatenate bodies so the user sees both in one message
|
|
77
|
+
if next_reply.body:
|
|
78
|
+
result.body = result.body + "\n\n" + next_reply.body
|
|
79
|
+
result.current_node = next_reply.current_node
|
|
80
|
+
result.options = next_reply.options
|
|
81
|
+
result.node_type = next_reply.node_type
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
# Developer returned a str or None
|
|
85
|
+
action_text = str(result) if result is not None else ""
|
|
86
|
+
if action_text and next_reply.body:
|
|
87
|
+
combined = action_text + "\n\n" + next_reply.body
|
|
88
|
+
elif action_text:
|
|
89
|
+
combined = action_text
|
|
90
|
+
else:
|
|
91
|
+
combined = next_reply.body
|
|
92
|
+
|
|
93
|
+
return Reply(
|
|
94
|
+
type=next_reply.type,
|
|
95
|
+
body=combined,
|
|
96
|
+
phone=session.user_id,
|
|
97
|
+
options=next_reply.options,
|
|
98
|
+
node_type=next_reply.node_type,
|
|
99
|
+
file_bytes=next_reply.file_bytes,
|
|
100
|
+
filename=next_reply.filename,
|
|
101
|
+
mime_type=next_reply.mime_type,
|
|
102
|
+
current_node=next_reply.current_node,
|
|
103
|
+
)
|