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/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,3 @@
1
+ from .memory import InMemorySessionStore
2
+
3
+ __all__ = ["InMemorySessionStore"]
@@ -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}'")