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,104 @@
1
+ """
2
+ handlers/router.py
3
+ ==================
4
+ RouterHandler — silent branching, no user input.
5
+
6
+ This is the entry-point node type. When the engine lands on a router node
7
+ it evaluates the conditions in order, picks the first True branch, and
8
+ transparently navigates to that node — the user never sees the router itself.
9
+
10
+ The optional ``before`` callable runs first (useful for loading user profile
11
+ from DB into ``session.context``).
12
+
13
+ Both sync and async ``before`` and ``when`` callables are supported.
14
+
15
+ Example::
16
+
17
+ from turnstack.nodes import Router, Route
18
+
19
+ async def load_user(session):
20
+ session.context["user"] = await db.get_user(session.phone)
21
+
22
+ tree.add("entry", Router(
23
+ before=load_user,
24
+ routes=[
25
+ Route(when=lambda s: s.context.get("user") and s.context["user"]["role"] == "landlord",
26
+ next="landlord_home"),
27
+ Route(when=lambda s: s.context.get("user") and s.context["user"]["role"] == "tenant",
28
+ next="tenant_home"),
29
+ Route(when=lambda s: s.context.get("invitation"),
30
+ next="tenant_onboarding"),
31
+ ],
32
+ default="public_welcome",
33
+ ))
34
+ """
35
+
36
+ from __future__ import annotations
37
+ import inspect
38
+ from typing import Any, Dict, TYPE_CHECKING
39
+
40
+ from ..message import IncomingMessage
41
+ from ..reply import Reply
42
+ from ..session import Session
43
+ from .base import NodeHandler
44
+
45
+ if TYPE_CHECKING:
46
+ from ..tree import FlowTree
47
+
48
+
49
+ class RouterHandler(NodeHandler):
50
+
51
+ async def handle(
52
+ self,
53
+ node: Dict[str, Any],
54
+ session: Session,
55
+ message: IncomingMessage,
56
+ tree: "FlowTree",
57
+ ) -> Reply:
58
+ return await self._run_router(node, session, tree, _depth=0)
59
+
60
+ async def _run_router(
61
+ self,
62
+ node: Dict[str, Any],
63
+ session: Session,
64
+ tree: "FlowTree",
65
+ _depth: int = 0,
66
+ ) -> Reply:
67
+ # ── run before hook (e.g. load user from DB) ──────────────────
68
+ before = node.get("before")
69
+ if before:
70
+ try:
71
+ if inspect.iscoroutinefunction(before):
72
+ await before(session)
73
+ else:
74
+ before(session)
75
+ except Exception as exc:
76
+ return self._error(session, f"Router 'before' hook raised: {exc}")
77
+
78
+ # ── evaluate conditions in order ──────────────────────────────
79
+ target = node.get("default", tree.entry)
80
+
81
+ for route in node.get("routes", []):
82
+ condition = route.get("when")
83
+ if condition is None:
84
+ continue
85
+ try:
86
+ result = (
87
+ await condition(session)
88
+ if inspect.iscoroutinefunction(condition)
89
+ else condition(session)
90
+ )
91
+ except Exception:
92
+ continue # failed condition → skip to next
93
+
94
+ if result:
95
+ target = route.get("next", target)
96
+ break
97
+
98
+ # ── navigate silently ─────────────────────────────────────────
99
+ # Router nodes are NOT pushed onto the nav stack — they are
100
+ # transparent. The user's "Back" takes them to whatever was
101
+ # before the router, not to the router itself.
102
+ session.current_node = target
103
+
104
+ return await self._enter_node(session, tree, _depth + 1)
turnstack/message.py ADDED
@@ -0,0 +1,39 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional, Dict, Any
3
+
4
+
5
+ @dataclass
6
+ class IncomingMessage:
7
+ """
8
+ Normalised incoming message — transport-agnostic.
9
+
10
+ The developer's adapter (in their webhook handler) converts the raw
11
+ WhatsApp/Twilio/pywa payload into this object and passes it to
12
+ ``BotEngine.process()``. The engine never sees raw API payloads.
13
+
14
+ Fields
15
+ ------
16
+ phone: Session key — the sender's phone number (e.g. "254711234567").
17
+ type: Message type: "text" | "interactive" | "image" |
18
+ "document" | "audio" | "video" | "location" | "unknown".
19
+ text: Populated for type="text". The raw message body.
20
+ interactive_id: Populated for type="interactive". The ID of the button
21
+ or list item the user selected (after mapping, this is
22
+ the node key / value the developer set on the Option).
23
+ media_id: Provider media ID for image/document/audio/video.
24
+ Developer must download via their provider's media API.
25
+ media_mime: MIME type of the media (e.g. "image/jpeg").
26
+ media_name: Original filename (documents only).
27
+ location: Dict with keys: latitude, longitude, name, address.
28
+ raw: The original provider payload — available in action
29
+ functions via session for advanced use cases.
30
+ """
31
+ user_id: str
32
+ type: str # "text" | "interactive" | ...
33
+ text: Optional[str] = None
34
+ interactive_id: Optional[str] = None
35
+ media_id: Optional[str] = None
36
+ media_mime: Optional[str] = None
37
+ media_name: Optional[str] = None
38
+ location: Optional[Dict[str, Any]] = None
39
+ raw: Optional[Dict[str, Any]] = None