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