turnstack 0.1.0__tar.gz → 0.1.2__tar.gz

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.
Files changed (28) hide show
  1. {turnstack-0.1.0 → turnstack-0.1.2}/PKG-INFO +1 -1
  2. {turnstack-0.1.0 → turnstack-0.1.2}/pyproject.toml +1 -1
  3. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/engine.py +24 -5
  4. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/handlers/action.py +30 -34
  5. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack.egg-info/PKG-INFO +1 -1
  6. {turnstack-0.1.0 → turnstack-0.1.2}/README.md +0 -0
  7. {turnstack-0.1.0 → turnstack-0.1.2}/setup.cfg +0 -0
  8. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/__init__.py +0 -0
  9. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/exceptions.py +0 -0
  10. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/handlers/__init__.py +0 -0
  11. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/handlers/base.py +0 -0
  12. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/handlers/confirm.py +0 -0
  13. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/handlers/input.py +0 -0
  14. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/handlers/list_handler.py +0 -0
  15. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/handlers/media_handler.py +0 -0
  16. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/handlers/menu.py +0 -0
  17. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/handlers/render_helpers.py +0 -0
  18. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/handlers/router.py +0 -0
  19. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/message.py +0 -0
  20. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/nodes.py +0 -0
  21. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/reply.py +0 -0
  22. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/session.py +0 -0
  23. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/stores/__init__.py +0 -0
  24. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/stores/memory.py +0 -0
  25. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack/tree.py +0 -0
  26. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack.egg-info/SOURCES.txt +0 -0
  27. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack.egg-info/dependency_links.txt +0 -0
  28. {turnstack-0.1.0 → turnstack-0.1.2}/turnstack.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: turnstack
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: WhatsApp bot engine with node-based flows and interactive replies
5
5
  Author-email: IdrisFallout <dev@waithakasam.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "turnstack"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "WhatsApp bot engine with node-based flows and interactive replies"
5
5
  authors = [{ name = "IdrisFallout", email = "dev@waithakasam.com" }]
6
6
  license = { text = "MIT" }
@@ -189,6 +189,8 @@ class BotEngine:
189
189
  if incoming.type == "text" and incoming.text:
190
190
  cmd_reply = await self._handle_global_command(session, incoming.text.strip().lower())
191
191
  if cmd_reply:
192
+ if cmd_reply.type == "end":
193
+ return [cmd_reply]
192
194
  # Command handled – render the resulting node
193
195
  reply = await self._render_current(session)
194
196
  if reply is None:
@@ -212,6 +214,24 @@ class BotEngine:
212
214
  phone=session.user_id,
213
215
  current_node=session.current_node,
214
216
  )
217
+
218
+ # ── 7a. action two-message split ──────────────────────────────
219
+ # ActionHandler tags the reply with _action_replies=[action_reply, next_reply]
220
+ # when the fn returned text/Reply. Unwrap them and send as two
221
+ # separate messages so the action text and the follow-up node
222
+ # always arrive as distinct WhatsApp bubbles.
223
+ from .handlers.action import ActionHandler
224
+ action_pair = getattr(reply, ActionHandler.MULTI_REPLY_ATTR, None)
225
+ if action_pair and len(action_pair) == 2:
226
+ action_reply, next_reply = action_pair
227
+ next_reply = self._enrich_menu_reply(next_reply, session)
228
+ action_reply.session_state = session.lifecycle_state
229
+ action_reply.current_node = session.current_node
230
+ next_reply.session_state = session.lifecycle_state
231
+ next_reply.current_node = session.current_node
232
+ await self.session_store.save(session)
233
+ return [action_reply, next_reply]
234
+
215
235
  reply = self._enrich_menu_reply(reply, session)
216
236
 
217
237
  # ── 8. attach meta and save ───────────────────────────────────
@@ -246,14 +266,13 @@ class BotEngine:
246
266
  """
247
267
  # Exit / reset
248
268
  if text in self.exit_keywords:
249
- session.reset(self.tree.entry)
250
- # Optionally send a goodbye message (could be configured)
269
+ await self.session_store.delete(session.user_id)
251
270
  return Reply(
252
- type="text",
253
- body="👋 Session reset. Type anything to start over.",
271
+ type="end",
272
+ body="👋 Goodbye! Send us a message anytime to start a new session.",
254
273
  phone=session.user_id,
255
274
  node_type="text",
256
- current_node=session.current_node,
275
+ current_node=None,
257
276
  )
258
277
 
259
278
  # Go home
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import inspect
3
- from typing import Any, Dict, TYPE_CHECKING
3
+ from typing import Any, Dict, List, TYPE_CHECKING
4
4
 
5
5
  from ..message import IncomingMessage
6
6
  from ..reply import Reply
@@ -16,14 +16,21 @@ class ActionHandler(NodeHandler):
16
16
  Handles ``action`` nodes.
17
17
 
18
18
  The developer's ``fn`` may return:
19
- - A ``str`` — sent as a text message, then the flow advances to ``next``.
19
+ - A ``str`` — sent as a separate text message, then the next node is
20
+ sent as a second message.
20
21
  - 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``.
22
+ then the next node is sent as a second message.
23
+ - ``None`` — nothing extra is sent; only the next node reply is sent.
23
24
 
24
25
  Both sync and async functions are supported.
26
+
27
+ The engine receives a list and sends each reply in order, so the action
28
+ text and the follow-up node always arrive as two distinct messages.
25
29
  """
26
30
 
31
+ # Sentinel attribute the engine checks to unwrap multi-reply actions.
32
+ MULTI_REPLY_ATTR = "_action_replies"
33
+
27
34
  async def handle(
28
35
  self,
29
36
  node: Dict[str, Any],
@@ -65,39 +72,28 @@ class ActionHandler(NodeHandler):
65
72
 
66
73
  self._transition_to(session, next_key)
67
74
 
68
- # ── build the combined reply ──────────────────────────────────
69
- # Enter next node (may chain another action/router silently)
75
+ # ── get next node reply ───────────────────────────────────────
70
76
  next_reply = await self._enter_node(session, tree, _depth + 1)
71
77
  if next_reply is None:
72
78
  next_reply = self._error(session, "Next node returned no reply.")
73
79
 
80
+ # ── no action text → just return next reply as usual ─────────
81
+ if result is None:
82
+ return next_reply
83
+
84
+ # ── action produced text or a Reply → send as TWO messages ───
85
+ # We attach the pair on the reply object; engine.py unwraps it.
74
86
  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
87
+ action_reply = result
90
88
  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
- )
89
+ action_reply = Reply(
90
+ type="text",
91
+ body=str(result),
92
+ phone=session.user_id,
93
+ current_node=session.current_node,
94
+ )
95
+
96
+ # Carry options/node_type only on the SECOND (next_reply) message.
97
+ # Tag the first reply so the engine knows to split them.
98
+ setattr(action_reply, self.MULTI_REPLY_ATTR, [action_reply, next_reply])
99
+ return action_reply
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: turnstack
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: WhatsApp bot engine with node-based flows and interactive replies
5
5
  Author-email: IdrisFallout <dev@waithakasam.com>
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes
File without changes