turnstack 0.1.1__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.1 → turnstack-0.1.2}/PKG-INFO +1 -1
  2. {turnstack-0.1.1 → turnstack-0.1.2}/pyproject.toml +1 -1
  3. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/engine.py +18 -0
  4. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/handlers/action.py +30 -34
  5. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack.egg-info/PKG-INFO +1 -1
  6. {turnstack-0.1.1 → turnstack-0.1.2}/README.md +0 -0
  7. {turnstack-0.1.1 → turnstack-0.1.2}/setup.cfg +0 -0
  8. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/__init__.py +0 -0
  9. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/exceptions.py +0 -0
  10. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/handlers/__init__.py +0 -0
  11. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/handlers/base.py +0 -0
  12. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/handlers/confirm.py +0 -0
  13. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/handlers/input.py +0 -0
  14. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/handlers/list_handler.py +0 -0
  15. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/handlers/media_handler.py +0 -0
  16. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/handlers/menu.py +0 -0
  17. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/handlers/render_helpers.py +0 -0
  18. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/handlers/router.py +0 -0
  19. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/message.py +0 -0
  20. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/nodes.py +0 -0
  21. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/reply.py +0 -0
  22. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/session.py +0 -0
  23. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/stores/__init__.py +0 -0
  24. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/stores/memory.py +0 -0
  25. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack/tree.py +0 -0
  26. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack.egg-info/SOURCES.txt +0 -0
  27. {turnstack-0.1.1 → turnstack-0.1.2}/turnstack.egg-info/dependency_links.txt +0 -0
  28. {turnstack-0.1.1 → 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.1
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.1"
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" }
@@ -214,6 +214,24 @@ class BotEngine:
214
214
  phone=session.user_id,
215
215
  current_node=session.current_node,
216
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
+
217
235
  reply = self._enrich_menu_reply(reply, session)
218
236
 
219
237
  # ── 8. attach meta and save ───────────────────────────────────
@@ -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.1
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