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/reply.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional, List, Literal, Dict, Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class ReplyOption:
|
|
7
|
+
"""
|
|
8
|
+
A single option hint returned alongside a menu/confirm reply.
|
|
9
|
+
|
|
10
|
+
The developer's WhatsApp adapter uses these to build interactive
|
|
11
|
+
button or list payloads. The engine always populates this on
|
|
12
|
+
menu and confirm nodes so the adapter never has to re-parse the tree.
|
|
13
|
+
"""
|
|
14
|
+
label: str
|
|
15
|
+
value: str # the node key / Option.value — what gets sent back as interactive_id
|
|
16
|
+
description: str = ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Reply:
|
|
21
|
+
"""
|
|
22
|
+
The engine's response object.
|
|
23
|
+
|
|
24
|
+
``BotEngine.process()`` always returns one of these.
|
|
25
|
+
The developer's adapter pattern-matches on ``type`` to decide
|
|
26
|
+
how to send it via their WhatsApp provider.
|
|
27
|
+
|
|
28
|
+
Fields
|
|
29
|
+
------
|
|
30
|
+
type: "text" | "media" | "end" | "error"
|
|
31
|
+
body: Text body to send. For media, this is the caption.
|
|
32
|
+
phone: Recipient phone number.
|
|
33
|
+
file_bytes: Raw file bytes (populated when type="media").
|
|
34
|
+
filename: Filename for media (e.g. "report_june.pdf").
|
|
35
|
+
mime_type: MIME type for media (e.g. "application/pdf").
|
|
36
|
+
options: Option hints for menu/confirm nodes.
|
|
37
|
+
Use these to build interactive buttons/lists without
|
|
38
|
+
re-reading the tree yourself.
|
|
39
|
+
node_type: The type of the current node ("menu", "input", etc.).
|
|
40
|
+
Lets the adapter decide whether to send interactive or plain text.
|
|
41
|
+
current_node: Current node key — useful for debugging.
|
|
42
|
+
session_state: "new" | "active" | "expired"
|
|
43
|
+
suggested_replies: Simple string list for quick-reply chips (subset of options.label).
|
|
44
|
+
meta: Arbitrary metadata dictionary for the adapter.
|
|
45
|
+
For example, set ``meta={"button_label": "Select Property"}``
|
|
46
|
+
to override the default "Options" button text in interactive lists.
|
|
47
|
+
"""
|
|
48
|
+
type: Literal["text", "media", "end", "error"]
|
|
49
|
+
body: str
|
|
50
|
+
phone: str
|
|
51
|
+
|
|
52
|
+
# media fields
|
|
53
|
+
file_bytes: Optional[bytes] = None
|
|
54
|
+
filename: Optional[str] = None
|
|
55
|
+
mime_type: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
# interactive hints — populated for menu/confirm nodes
|
|
58
|
+
options: List[ReplyOption] = field(default_factory=list)
|
|
59
|
+
node_type: Optional[str] = None # "menu" | "confirm" | "input" | etc.
|
|
60
|
+
|
|
61
|
+
# convenience
|
|
62
|
+
suggested_replies: List[str] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
# debug / meta
|
|
65
|
+
current_node: Optional[str] = None
|
|
66
|
+
session_state: Optional[str] = None
|
|
67
|
+
meta: Dict[str, Any] = field(default_factory=dict)
|
turnstack/session.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Dict, Any, Optional, List
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Session:
|
|
9
|
+
"""
|
|
10
|
+
A single user's conversation state.
|
|
11
|
+
|
|
12
|
+
The engine reads and writes this object on every message.
|
|
13
|
+
Developers can read ``session.collected``,
|
|
14
|
+
and ``session.context`` inside action and router functions.
|
|
15
|
+
|
|
16
|
+
Fields
|
|
17
|
+
------
|
|
18
|
+
phone: Session key — the user's phone number.
|
|
19
|
+
lifecycle_state: "new" | "active" | "expired"
|
|
20
|
+
current_node: Key of the node the user is currently on.
|
|
21
|
+
nav_stack: Back-navigation history. The engine pushes before
|
|
22
|
+
every forward transition and pops on "go back".
|
|
23
|
+
Developers should not modify this directly.
|
|
24
|
+
collected: Data gathered via Input nodes.
|
|
25
|
+
Cleared when the session returns to the entry node.
|
|
26
|
+
pagination: Internal state for ListNode pagination.
|
|
27
|
+
context: Arbitrary dict for cross-node data.
|
|
28
|
+
Use this in router ``before`` functions to load a user
|
|
29
|
+
profile, store an invitation, etc.
|
|
30
|
+
last_active: UTC timestamp of the last message processed.
|
|
31
|
+
"""
|
|
32
|
+
user_id: str
|
|
33
|
+
lifecycle_state: str = "new"
|
|
34
|
+
current_node: str = "entry"
|
|
35
|
+
nav_stack: List[str] = field(default_factory=list)
|
|
36
|
+
collected: Dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
pagination: Dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
context: Dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
last_active: Optional[datetime] = None
|
|
40
|
+
|
|
41
|
+
def __post_init__(self):
|
|
42
|
+
if self.last_active is None:
|
|
43
|
+
self.last_active = datetime.utcnow()
|
|
44
|
+
|
|
45
|
+
# ── lifecycle ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def touch(self) -> None:
|
|
48
|
+
"""Update last_active and activate a new/expired session."""
|
|
49
|
+
self.last_active = datetime.utcnow()
|
|
50
|
+
if self.lifecycle_state in ("new", "expired"):
|
|
51
|
+
self.lifecycle_state = "active"
|
|
52
|
+
|
|
53
|
+
def is_expired(self, timeout_seconds: int) -> bool:
|
|
54
|
+
if self.lifecycle_state == "expired":
|
|
55
|
+
return True
|
|
56
|
+
if self.lifecycle_state == "active" and self.last_active:
|
|
57
|
+
delta = datetime.utcnow() - self.last_active
|
|
58
|
+
return delta.total_seconds() > timeout_seconds
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
def expire(self) -> None:
|
|
62
|
+
self.lifecycle_state = "expired"
|
|
63
|
+
|
|
64
|
+
def reset(self, entry_node: str) -> None:
|
|
65
|
+
"""Reset all flow state, keep phone."""
|
|
66
|
+
self.current_node = entry_node
|
|
67
|
+
self.nav_stack = []
|
|
68
|
+
self.collected = {}
|
|
69
|
+
self.pagination = {}
|
|
70
|
+
self.context = {}
|
|
71
|
+
self.lifecycle_state = "active"
|
|
72
|
+
self.touch()
|
|
73
|
+
|
|
74
|
+
# ── navigation helpers (used by the engine, available to developers) ──
|
|
75
|
+
|
|
76
|
+
def go_back(self) -> Optional[str]:
|
|
77
|
+
"""
|
|
78
|
+
Pop and return the previous node from the nav stack.
|
|
79
|
+
Returns None if already at the root.
|
|
80
|
+
"""
|
|
81
|
+
if self.nav_stack:
|
|
82
|
+
return self.nav_stack.pop()
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def go_home(self, entry_node: str) -> None:
|
|
86
|
+
"""Clear the nav stack and return to the entry node."""
|
|
87
|
+
self.nav_stack.clear()
|
|
88
|
+
self.current_node = entry_node
|
|
89
|
+
self.collected = {}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SessionStore(ABC):
|
|
93
|
+
"""
|
|
94
|
+
Abstract base class for session persistence.
|
|
95
|
+
|
|
96
|
+
Implement this for your storage backend (Redis, Postgres, SQLite, etc.).
|
|
97
|
+
TurnStack ships with :class:`~turnstack.stores.memory.InMemorySessionStore`
|
|
98
|
+
for development and testing.
|
|
99
|
+
|
|
100
|
+
Example Redis implementation outline::
|
|
101
|
+
|
|
102
|
+
class RedisSessionStore(SessionStore):
|
|
103
|
+
def __init__(self, redis_client):
|
|
104
|
+
self.r = redis_client
|
|
105
|
+
|
|
106
|
+
async def get(self, phone):
|
|
107
|
+
data = await self.r.get(f"session:{phone}")
|
|
108
|
+
return pickle.loads(data) if data else None
|
|
109
|
+
|
|
110
|
+
async def save(self, session):
|
|
111
|
+
await self.r.setex(f"session:{session.phone}", 3600, pickle.dumps(session))
|
|
112
|
+
|
|
113
|
+
async def delete(self, phone):
|
|
114
|
+
await self.r.delete(f"session:{phone}")
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
async def get(self, phone: str) -> Optional[Session]:
|
|
119
|
+
"""Load session by phone number. Return None if not found."""
|
|
120
|
+
...
|
|
121
|
+
|
|
122
|
+
@abstractmethod
|
|
123
|
+
async def save(self, session: Session) -> None:
|
|
124
|
+
"""Persist session."""
|
|
125
|
+
...
|
|
126
|
+
|
|
127
|
+
@abstractmethod
|
|
128
|
+
async def delete(self, phone: str) -> None:
|
|
129
|
+
"""Delete session."""
|
|
130
|
+
...
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Dict, Optional
|
|
3
|
+
from ..session import Session, SessionStore
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InMemorySessionStore(SessionStore):
|
|
7
|
+
"""
|
|
8
|
+
In-memory session store with automatic eviction of expired sessions.
|
|
9
|
+
|
|
10
|
+
- Sessions are removed when they exceed `session_timeout` seconds since last activity.
|
|
11
|
+
- Optional `max_sessions` prevents unbounded memory growth.
|
|
12
|
+
- Cleanup runs automatically on every `get()` and `save()` (with a periodic full sweep).
|
|
13
|
+
|
|
14
|
+
For production with high concurrency, consider a persistent store (Redis, SQLite).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, session_timeout: int = 1800, max_sessions: int = 10000):
|
|
18
|
+
self._sessions: Dict[str, Session] = {}
|
|
19
|
+
self.session_timeout = session_timeout
|
|
20
|
+
self.max_sessions = max_sessions
|
|
21
|
+
self._last_cleanup = time.time()
|
|
22
|
+
self._cleanup_interval = 60 # seconds
|
|
23
|
+
|
|
24
|
+
async def get(self, user_id: str) -> Optional[Session]:
|
|
25
|
+
"""Return session if exists and not expired, otherwise None."""
|
|
26
|
+
self._maybe_cleanup()
|
|
27
|
+
session = self._sessions.get(user_id)
|
|
28
|
+
if session and session.is_expired(self.session_timeout):
|
|
29
|
+
await self.delete(user_id)
|
|
30
|
+
return None
|
|
31
|
+
return session
|
|
32
|
+
|
|
33
|
+
async def save(self, session: Session) -> None:
|
|
34
|
+
"""Store session, evicting oldest if max_sessions exceeded."""
|
|
35
|
+
self._maybe_cleanup()
|
|
36
|
+
# Enforce session limit (only when adding a new session)
|
|
37
|
+
if len(self._sessions) >= self.max_sessions and session.user_id not in self._sessions:
|
|
38
|
+
# Remove the session with the oldest last_active
|
|
39
|
+
oldest_uid = min(self._sessions.items(), key=lambda x: x[1].last_active or 0)[0]
|
|
40
|
+
await self.delete(oldest_uid)
|
|
41
|
+
self._sessions[session.user_id] = session
|
|
42
|
+
|
|
43
|
+
async def delete(self, user_id: str) -> None:
|
|
44
|
+
self._sessions.pop(user_id, None)
|
|
45
|
+
|
|
46
|
+
def all(self) -> Dict[str, Session]:
|
|
47
|
+
"""Return copy of all sessions (debug only)."""
|
|
48
|
+
return dict(self._sessions)
|
|
49
|
+
|
|
50
|
+
def _maybe_cleanup(self):
|
|
51
|
+
"""Periodically remove all expired sessions."""
|
|
52
|
+
now = time.time()
|
|
53
|
+
if now - self._last_cleanup >= self._cleanup_interval:
|
|
54
|
+
expired = [
|
|
55
|
+
uid for uid, sess in self._sessions.items()
|
|
56
|
+
if sess.is_expired(self.session_timeout)
|
|
57
|
+
]
|
|
58
|
+
for uid in expired:
|
|
59
|
+
del self._sessions[uid]
|
|
60
|
+
self._last_cleanup = now
|
turnstack/tree.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
turnstack.tree
|
|
3
|
+
==============
|
|
4
|
+
FlowTree — the single source of truth for a bot's conversation flow.
|
|
5
|
+
|
|
6
|
+
Developers define their entire flow here using node classes from
|
|
7
|
+
:mod:`turnstack.nodes`. The tree is validated at engine startup so broken
|
|
8
|
+
flows fail loudly, not silently at runtime.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
from typing import Dict, Any, List, Optional, Union
|
|
13
|
+
from .exceptions import FlowValidationError
|
|
14
|
+
from .nodes import BaseNode, NodeDict
|
|
15
|
+
|
|
16
|
+
# ── node type constants ───────────────────────────────────────────────────────
|
|
17
|
+
NODE_MENU = "menu"
|
|
18
|
+
NODE_INPUT = "input"
|
|
19
|
+
NODE_CONFIRM = "confirm"
|
|
20
|
+
NODE_ACTION = "action"
|
|
21
|
+
NODE_ROUTER = "router"
|
|
22
|
+
NODE_LIST = "list"
|
|
23
|
+
NODE_MEDIA = "media"
|
|
24
|
+
NODE_INPUT = "input"
|
|
25
|
+
|
|
26
|
+
# All types that have a single "next" key
|
|
27
|
+
_SINGLE_NEXT = {NODE_INPUT, NODE_ACTION, NODE_MEDIA, NODE_INPUT}
|
|
28
|
+
# All types that have options with individual "next" keys
|
|
29
|
+
_OPTION_NEXT = {NODE_MENU, NODE_CONFIRM}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FlowTree:
|
|
33
|
+
"""
|
|
34
|
+
Container for all flow nodes.
|
|
35
|
+
|
|
36
|
+
Usage::
|
|
37
|
+
|
|
38
|
+
from turnstack import FlowTree
|
|
39
|
+
from turnstack.nodes import Menu, Option, Input, Action, Router, Route
|
|
40
|
+
|
|
41
|
+
tree = FlowTree(entry="entry")
|
|
42
|
+
|
|
43
|
+
tree.add("entry", Router(
|
|
44
|
+
routes=[Route(when=lambda s: s.context.get("user"), next="home")],
|
|
45
|
+
default="welcome",
|
|
46
|
+
))
|
|
47
|
+
|
|
48
|
+
tree.add("welcome", Menu(
|
|
49
|
+
text="Welcome! What would you like to do?",
|
|
50
|
+
options=[
|
|
51
|
+
Option("Register", next="register_name"),
|
|
52
|
+
Option("Learn More", next="learn_more"),
|
|
53
|
+
]
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
Nodes can be added as node class instances *or* raw dicts (for backwards
|
|
57
|
+
compatibility with existing code).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, entry: str = "welcome"):
|
|
61
|
+
self._nodes: Dict[str, NodeDict] = {}
|
|
62
|
+
self.entry = entry
|
|
63
|
+
|
|
64
|
+
# ── public API ────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def add(self, name: str, node: Union[BaseNode, NodeDict]) -> "FlowTree":
|
|
67
|
+
"""
|
|
68
|
+
Register a node under ``name``.
|
|
69
|
+
|
|
70
|
+
Accepts either a node class instance (recommended) or a raw dict
|
|
71
|
+
(legacy / advanced use).
|
|
72
|
+
|
|
73
|
+
Returns self so calls can be chained::
|
|
74
|
+
|
|
75
|
+
tree.add("a", ...).add("b", ...).add("c", ...)
|
|
76
|
+
"""
|
|
77
|
+
self._nodes[name] = node.to_dict() if isinstance(node, BaseNode) else node
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def get(self, name: str) -> Optional[NodeDict]:
|
|
81
|
+
"""Return the raw node dict for ``name``, or None if not found."""
|
|
82
|
+
return self._nodes.get(name)
|
|
83
|
+
|
|
84
|
+
def all_nodes(self) -> Dict[str, NodeDict]:
|
|
85
|
+
"""Return a copy of all nodes (read-only view for tooling)."""
|
|
86
|
+
return dict(self._nodes)
|
|
87
|
+
|
|
88
|
+
# ── validation ────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def validate(self) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Walk the entire tree and raise :class:`FlowValidationError` on the
|
|
93
|
+
first broken reference.
|
|
94
|
+
|
|
95
|
+
Called automatically by ``BotEngine.__init__``.
|
|
96
|
+
Developers can also call it manually after building the tree.
|
|
97
|
+
|
|
98
|
+
Checks:
|
|
99
|
+
- Entry node exists
|
|
100
|
+
- Every ``next`` key points to a real node (or ``"__end__"``)
|
|
101
|
+
- Every option ``next`` key in menu/confirm nodes points to a real node
|
|
102
|
+
- Every router ``default`` points to a real node
|
|
103
|
+
- Every router route ``next`` points to a real node
|
|
104
|
+
- Every list ``on_select`` points to a real node
|
|
105
|
+
- No action node references itself as ``next``
|
|
106
|
+
"""
|
|
107
|
+
errors: List[str] = []
|
|
108
|
+
|
|
109
|
+
if self.entry not in self._nodes:
|
|
110
|
+
errors.append(f"Entry node '{self.entry}' is not defined in the tree.")
|
|
111
|
+
|
|
112
|
+
for node_name, node in self._nodes.items():
|
|
113
|
+
t = node.get("type", "")
|
|
114
|
+
|
|
115
|
+
# Existing checks
|
|
116
|
+
if t in _SINGLE_NEXT:
|
|
117
|
+
self._check_ref(node_name, node.get("next"), errors)
|
|
118
|
+
|
|
119
|
+
elif t in _OPTION_NEXT:
|
|
120
|
+
options = node.get("options", [])
|
|
121
|
+
if t == NODE_CONFIRM and len(options) > 3:
|
|
122
|
+
errors.append(
|
|
123
|
+
f"Confirm node '{node_name}' has {len(options)} options. "
|
|
124
|
+
"WhatsApp interactive buttons support at most 3."
|
|
125
|
+
)
|
|
126
|
+
for opt in options:
|
|
127
|
+
target = opt.get("next") if isinstance(opt, dict) else None
|
|
128
|
+
self._check_ref(f"{node_name} option '{opt.get('label', '?')}'", target, errors)
|
|
129
|
+
|
|
130
|
+
elif t == NODE_ROUTER:
|
|
131
|
+
self._check_ref(f"{node_name} default", node.get("default"), errors)
|
|
132
|
+
for route in node.get("routes", []):
|
|
133
|
+
self._check_ref(f"{node_name} route", route.get("next"), errors)
|
|
134
|
+
|
|
135
|
+
elif t == NODE_LIST:
|
|
136
|
+
self._check_ref(f"{node_name} on_select", node.get("on_select"), errors)
|
|
137
|
+
extra_opts = node.get("extra_options", [])
|
|
138
|
+
if len(extra_opts) > 3:
|
|
139
|
+
errors.append(
|
|
140
|
+
f"ListNode '{node_name}' has {len(extra_opts)} extra_options, "
|
|
141
|
+
"but interactive lists support at most 3 static actions."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if errors:
|
|
145
|
+
raise FlowValidationError(
|
|
146
|
+
"Flow tree validation failed:\n" + "\n".join(f" • {e}" for e in errors)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _check_ref(self, context: str, key: Optional[str], errors: List[str]) -> None:
|
|
150
|
+
if key and key != "__end__" and key not in self._nodes:
|
|
151
|
+
errors.append(f"{context} → references missing node '{key}'")
|