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