Add clear command functionality to message handler
Browse files- Implemented handling of the `/clear` command to clear specific branches or entire trees based on message replies.
- Added tests for various scenarios of the clear command, including clearing branches, handling unknown replies, and clearing entire trees.
- Enhanced `TreeQueueManager` with methods to cancel branches and remove subtrees, ensuring proper state management in the session store.
- Updated `SessionStore` and `TreeRepository` to support removal of node mappings and trees, improving data integrity during clear operations.
- README.md +2 -2
- messaging/handler.py +117 -47
- messaging/session.py +16 -0
- messaging/tree_data.py +45 -0
- messaging/tree_queue.py +69 -0
- messaging/tree_repository.py +20 -0
- tests/test_handler.py +100 -0
- tests/test_tree_queue.py +168 -0
README.md
CHANGED
|
@@ -190,7 +190,7 @@ Control Claude Code remotely from Discord. Send tasks, watch live progress, and
|
|
| 190 |
- Session persistence across server restarts
|
| 191 |
- Live streaming of thinking tokens, tool calls, and results
|
| 192 |
- Up to 10 concurrent Claude CLI sessions
|
| 193 |
-
- Commands: `/stop` (cancel tasks), `/clear` (reset all sessions), `/stats`
|
| 194 |
|
| 195 |
### Setup
|
| 196 |
|
|
@@ -219,7 +219,7 @@ ALLOWED_DIR=C:/Users/yourname/projects
|
|
| 219 |
uv run uvicorn server:app --host 0.0.0.0 --port 8082
|
| 220 |
```
|
| 221 |
|
| 222 |
-
5. **Invite the bot** to your server (OAuth2 → URL Generator, scopes: `bot`, permissions: Read Messages, Send Messages, Manage Messages, Read Message History). Send a message in an allowed channel with a task. Claude responds with thinking tokens, tool calls as they execute, and the final result. Reply
|
| 223 |
|
| 224 |
### Telegram (Alternative)
|
| 225 |
|
|
|
|
| 190 |
- Session persistence across server restarts
|
| 191 |
- Live streaming of thinking tokens, tool calls, and results
|
| 192 |
- Up to 10 concurrent Claude CLI sessions
|
| 193 |
+
- Commands: `/stop` (cancel tasks; reply to a message to stop only that task), `/clear` (standalone: reset all sessions; reply to a message to clear that branch downwards), `/stats`
|
| 194 |
|
| 195 |
### Setup
|
| 196 |
|
|
|
|
| 219 |
uv run uvicorn server:app --host 0.0.0.0 --port 8082
|
| 220 |
```
|
| 221 |
|
| 222 |
+
5. **Invite the bot** to your server (OAuth2 → URL Generator, scopes: `bot`, permissions: Read Messages, Send Messages, Manage Messages, Read Message History). Send a message in an allowed channel with a task. Claude responds with thinking tokens, tool calls as they execute, and the final result. Reply to messages to cancel tasks or clear branches (see Commands above).
|
| 223 |
|
| 224 |
### Telegram (Alternative)
|
| 225 |
|
messaging/handler.py
CHANGED
|
@@ -802,15 +802,126 @@ class ClaudeMessageHandler:
|
|
| 802 |
incoming.platform, incoming.chat_id, msg_id, "command"
|
| 803 |
)
|
| 804 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 805 |
async def _handle_clear_command(self, incoming: IncomingMessage) -> None:
|
| 806 |
"""
|
| 807 |
-
Handle /clear
|
| 808 |
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
2. Best-effort delete tracked chat messages for this chat.
|
| 812 |
-
3. Clear sessions.json (entire store) and reset in-memory queue state.
|
| 813 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 814 |
# 1) Stop tasks first (ensures no more work is running).
|
| 815 |
await self.stop_all_tasks()
|
| 816 |
|
|
@@ -853,48 +964,7 @@ class ClaudeMessageHandler:
|
|
| 853 |
if incoming.message_id is not None:
|
| 854 |
msg_ids.add(str(incoming.message_id))
|
| 855 |
|
| 856 |
-
|
| 857 |
-
try:
|
| 858 |
-
return int(str(s))
|
| 859 |
-
except Exception:
|
| 860 |
-
return None
|
| 861 |
-
|
| 862 |
-
numeric: list[tuple[int, str]] = []
|
| 863 |
-
non_numeric: list[str] = []
|
| 864 |
-
for mid in msg_ids:
|
| 865 |
-
n = _as_int(mid)
|
| 866 |
-
if n is None:
|
| 867 |
-
non_numeric.append(mid)
|
| 868 |
-
else:
|
| 869 |
-
numeric.append((n, mid))
|
| 870 |
-
|
| 871 |
-
numeric.sort(reverse=True)
|
| 872 |
-
ordered = [mid for _, mid in numeric] + non_numeric
|
| 873 |
-
|
| 874 |
-
# If platform supports batch deletes, prefer it.
|
| 875 |
-
batch_fn = getattr(self.platform, "queue_delete_messages", None)
|
| 876 |
-
if callable(batch_fn):
|
| 877 |
-
try:
|
| 878 |
-
# Telegram supports up to 100 per request.
|
| 879 |
-
CHUNK = 100
|
| 880 |
-
for i in range(0, len(ordered), CHUNK):
|
| 881 |
-
chunk = ordered[i : i + CHUNK]
|
| 882 |
-
await batch_fn(incoming.chat_id, chunk, fire_and_forget=False)
|
| 883 |
-
except Exception as e:
|
| 884 |
-
logger.debug(f"/clear batch delete failed: {type(e).__name__}: {e}")
|
| 885 |
-
else:
|
| 886 |
-
for mid in ordered:
|
| 887 |
-
try:
|
| 888 |
-
await self.platform.queue_delete_message(
|
| 889 |
-
incoming.chat_id,
|
| 890 |
-
mid,
|
| 891 |
-
fire_and_forget=False,
|
| 892 |
-
)
|
| 893 |
-
except Exception as e:
|
| 894 |
-
# Deleting is best-effort; platform adapters also treat common cases as no-op.
|
| 895 |
-
logger.debug(
|
| 896 |
-
f"/clear delete failed for msg {mid}: {type(e).__name__}: {e}"
|
| 897 |
-
)
|
| 898 |
|
| 899 |
# 3) Clear persistent state and reset in-memory queue/tree state.
|
| 900 |
try:
|
|
|
|
| 802 |
incoming.platform, incoming.chat_id, msg_id, "command"
|
| 803 |
)
|
| 804 |
|
| 805 |
+
async def _handle_clear_branch(
|
| 806 |
+
self, incoming: IncomingMessage, branch_root_id: str
|
| 807 |
+
) -> None:
|
| 808 |
+
"""
|
| 809 |
+
Clear a branch (replied-to node + all descendants).
|
| 810 |
+
|
| 811 |
+
Order: cancel tasks, delete messages, remove branch, update session store.
|
| 812 |
+
"""
|
| 813 |
+
tree = self.tree_queue.get_tree_for_node(branch_root_id)
|
| 814 |
+
if not tree:
|
| 815 |
+
return
|
| 816 |
+
|
| 817 |
+
# 1) Cancel branch tasks (no stop_all)
|
| 818 |
+
cancelled = await self.tree_queue.cancel_branch(branch_root_id)
|
| 819 |
+
self._update_cancelled_nodes_ui(cancelled)
|
| 820 |
+
|
| 821 |
+
# 2) Collect message IDs from branch nodes only
|
| 822 |
+
msg_ids: set[str] = set()
|
| 823 |
+
branch_ids = tree.get_descendants(branch_root_id)
|
| 824 |
+
for nid in branch_ids:
|
| 825 |
+
node = tree.get_node(nid)
|
| 826 |
+
if node:
|
| 827 |
+
if node.incoming.message_id:
|
| 828 |
+
msg_ids.add(str(node.incoming.message_id))
|
| 829 |
+
if node.status_message_id:
|
| 830 |
+
msg_ids.add(str(node.status_message_id))
|
| 831 |
+
if incoming.message_id:
|
| 832 |
+
msg_ids.add(str(incoming.message_id))
|
| 833 |
+
|
| 834 |
+
# 3) Delete messages (best-effort)
|
| 835 |
+
await self._delete_message_ids(incoming.chat_id, msg_ids)
|
| 836 |
+
|
| 837 |
+
# 4) Remove branch from tree
|
| 838 |
+
removed, root_id, removed_entire_tree = await self.tree_queue.remove_branch(
|
| 839 |
+
branch_root_id
|
| 840 |
+
)
|
| 841 |
+
|
| 842 |
+
# 5) Update session store
|
| 843 |
+
try:
|
| 844 |
+
self.session_store.remove_node_mappings([n.node_id for n in removed])
|
| 845 |
+
if removed_entire_tree:
|
| 846 |
+
self.session_store.remove_tree(root_id)
|
| 847 |
+
else:
|
| 848 |
+
updated_tree = self.tree_queue.get_tree(root_id)
|
| 849 |
+
if updated_tree:
|
| 850 |
+
self.session_store.save_tree(root_id, updated_tree.to_dict())
|
| 851 |
+
except Exception as e:
|
| 852 |
+
logger.warning(f"Failed to update session store after branch clear: {e}")
|
| 853 |
+
|
| 854 |
+
async def _delete_message_ids(self, chat_id: str, msg_ids: set[str]) -> None:
|
| 855 |
+
"""Best-effort delete messages by ID. Sorts numeric IDs descending."""
|
| 856 |
+
if not msg_ids:
|
| 857 |
+
return
|
| 858 |
+
|
| 859 |
+
def _as_int(s: str) -> int | None:
|
| 860 |
+
try:
|
| 861 |
+
return int(str(s))
|
| 862 |
+
except Exception:
|
| 863 |
+
return None
|
| 864 |
+
|
| 865 |
+
numeric: list[tuple[int, str]] = []
|
| 866 |
+
non_numeric: list[str] = []
|
| 867 |
+
for mid in msg_ids:
|
| 868 |
+
n = _as_int(mid)
|
| 869 |
+
if n is None:
|
| 870 |
+
non_numeric.append(mid)
|
| 871 |
+
else:
|
| 872 |
+
numeric.append((n, mid))
|
| 873 |
+
numeric.sort(reverse=True)
|
| 874 |
+
ordered = [mid for _, mid in numeric] + non_numeric
|
| 875 |
+
|
| 876 |
+
batch_fn = getattr(self.platform, "queue_delete_messages", None)
|
| 877 |
+
if callable(batch_fn):
|
| 878 |
+
try:
|
| 879 |
+
CHUNK = 100
|
| 880 |
+
for i in range(0, len(ordered), CHUNK):
|
| 881 |
+
chunk = ordered[i : i + CHUNK]
|
| 882 |
+
await batch_fn(chat_id, chunk, fire_and_forget=False)
|
| 883 |
+
except Exception as e:
|
| 884 |
+
logger.debug(f"Batch delete failed: {type(e).__name__}: {e}")
|
| 885 |
+
else:
|
| 886 |
+
for mid in ordered:
|
| 887 |
+
try:
|
| 888 |
+
await self.platform.queue_delete_message(
|
| 889 |
+
chat_id, mid, fire_and_forget=False
|
| 890 |
+
)
|
| 891 |
+
except Exception as e:
|
| 892 |
+
logger.debug(
|
| 893 |
+
f"Delete failed for msg {mid}: {type(e).__name__}: {e}"
|
| 894 |
+
)
|
| 895 |
+
|
| 896 |
async def _handle_clear_command(self, incoming: IncomingMessage) -> None:
|
| 897 |
"""
|
| 898 |
+
Handle /clear command.
|
| 899 |
|
| 900 |
+
Reply-scoped: reply to a message to clear that branch (node + descendants).
|
| 901 |
+
Standalone: global clear (stop all, delete all chat messages, reset store).
|
|
|
|
|
|
|
| 902 |
"""
|
| 903 |
+
if incoming.is_reply() and incoming.reply_to_message_id:
|
| 904 |
+
reply_id = incoming.reply_to_message_id
|
| 905 |
+
tree = self.tree_queue.get_tree_for_node(reply_id)
|
| 906 |
+
branch_root_id = (
|
| 907 |
+
self.tree_queue.resolve_parent_node_id(reply_id) if tree else None
|
| 908 |
+
)
|
| 909 |
+
if not branch_root_id:
|
| 910 |
+
msg_id = await self.platform.queue_send_message(
|
| 911 |
+
incoming.chat_id,
|
| 912 |
+
self._format_status(
|
| 913 |
+
"🗑", "Cleared.", "Nothing to clear for that message."
|
| 914 |
+
),
|
| 915 |
+
fire_and_forget=False,
|
| 916 |
+
)
|
| 917 |
+
self._record_outgoing_message(
|
| 918 |
+
incoming.platform, incoming.chat_id, msg_id, "command"
|
| 919 |
+
)
|
| 920 |
+
return
|
| 921 |
+
await self._handle_clear_branch(incoming, branch_root_id)
|
| 922 |
+
return
|
| 923 |
+
|
| 924 |
+
# Global clear
|
| 925 |
# 1) Stop tasks first (ensures no more work is running).
|
| 926 |
await self.stop_all_tasks()
|
| 927 |
|
|
|
|
| 964 |
if incoming.message_id is not None:
|
| 965 |
msg_ids.add(str(incoming.message_id))
|
| 966 |
|
| 967 |
+
await self._delete_message_ids(incoming.chat_id, msg_ids)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 968 |
|
| 969 |
# 3) Clear persistent state and reset in-memory queue/tree state.
|
| 970 |
try:
|
messaging/session.py
CHANGED
|
@@ -288,6 +288,22 @@ class SessionStore:
|
|
| 288 |
self._node_to_tree[node_id] = root_id
|
| 289 |
self._schedule_save()
|
| 290 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
def get_all_trees(self) -> Dict[str, dict]:
|
| 292 |
"""Get all stored trees (public accessor)."""
|
| 293 |
with self._lock:
|
|
|
|
| 288 |
self._node_to_tree[node_id] = root_id
|
| 289 |
self._schedule_save()
|
| 290 |
|
| 291 |
+
def remove_node_mappings(self, node_ids: List[str]) -> None:
|
| 292 |
+
"""Remove node IDs from the node-to-tree mapping."""
|
| 293 |
+
with self._lock:
|
| 294 |
+
for nid in node_ids:
|
| 295 |
+
self._node_to_tree.pop(nid, None)
|
| 296 |
+
self._schedule_save()
|
| 297 |
+
|
| 298 |
+
def remove_tree(self, root_id: str) -> None:
|
| 299 |
+
"""Remove a tree and all its node mappings from the store."""
|
| 300 |
+
with self._lock:
|
| 301 |
+
tree_data = self._trees.pop(root_id, None)
|
| 302 |
+
if tree_data:
|
| 303 |
+
for node_id in tree_data.get("nodes", {}).keys():
|
| 304 |
+
self._node_to_tree.pop(node_id, None)
|
| 305 |
+
self._schedule_save()
|
| 306 |
+
|
| 307 |
def get_all_trees(self) -> Dict[str, dict]:
|
| 308 |
"""Get all stored trees (public accessor)."""
|
| 309 |
with self._lock:
|
messaging/tree_data.py
CHANGED
|
@@ -386,3 +386,48 @@ class MessageTree:
|
|
| 386 |
"""Find the node that has this status message ID (O(1) lookup)."""
|
| 387 |
node_id = self._status_to_node.get(status_msg_id)
|
| 388 |
return self._nodes.get(node_id) if node_id else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
"""Find the node that has this status message ID (O(1) lookup)."""
|
| 387 |
node_id = self._status_to_node.get(status_msg_id)
|
| 388 |
return self._nodes.get(node_id) if node_id else None
|
| 389 |
+
|
| 390 |
+
def get_descendants(self, node_id: str) -> List[str]:
|
| 391 |
+
"""
|
| 392 |
+
Get node_id and all descendant IDs (subtree).
|
| 393 |
+
|
| 394 |
+
Returns:
|
| 395 |
+
List of node IDs including the given node.
|
| 396 |
+
"""
|
| 397 |
+
if node_id not in self._nodes:
|
| 398 |
+
return []
|
| 399 |
+
result = [node_id]
|
| 400 |
+
node = self._nodes[node_id]
|
| 401 |
+
for child_id in node.children_ids:
|
| 402 |
+
result.extend(self.get_descendants(child_id))
|
| 403 |
+
return result
|
| 404 |
+
|
| 405 |
+
def remove_branch(self, branch_root_id: str) -> List[MessageNode]:
|
| 406 |
+
"""
|
| 407 |
+
Remove a subtree (branch_root and all descendants) from the tree.
|
| 408 |
+
|
| 409 |
+
Updates parent's children_ids. Caller must hold lock for consistency.
|
| 410 |
+
Does not acquire lock internally.
|
| 411 |
+
|
| 412 |
+
Returns:
|
| 413 |
+
List of removed nodes.
|
| 414 |
+
"""
|
| 415 |
+
if branch_root_id not in self._nodes:
|
| 416 |
+
return []
|
| 417 |
+
|
| 418 |
+
parent = self.get_parent(branch_root_id)
|
| 419 |
+
removed = []
|
| 420 |
+
for nid in self.get_descendants(branch_root_id):
|
| 421 |
+
node = self._nodes.get(nid)
|
| 422 |
+
if node:
|
| 423 |
+
removed.append(node)
|
| 424 |
+
del self._nodes[nid]
|
| 425 |
+
del self._status_to_node[node.status_message_id]
|
| 426 |
+
|
| 427 |
+
if parent and branch_root_id in parent.children_ids:
|
| 428 |
+
parent.children_ids = [
|
| 429 |
+
c for c in parent.children_ids if c != branch_root_id
|
| 430 |
+
]
|
| 431 |
+
|
| 432 |
+
logger.debug(f"Removed branch {branch_root_id} ({len(removed)} nodes)")
|
| 433 |
+
return removed
|
messaging/tree_queue.py
CHANGED
|
@@ -368,6 +368,75 @@ class TreeQueueManager:
|
|
| 368 |
"""Register a node ID to a tree (for external mapping)."""
|
| 369 |
self._repository.register_node(node_id, root_id)
|
| 370 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
def to_dict(self) -> dict:
|
| 372 |
"""Serialize all trees."""
|
| 373 |
return self._repository.to_dict()
|
|
|
|
| 368 |
"""Register a node ID to a tree (for external mapping)."""
|
| 369 |
self._repository.register_node(node_id, root_id)
|
| 370 |
|
| 371 |
+
async def cancel_branch(self, branch_root_id: str) -> List[MessageNode]:
|
| 372 |
+
"""
|
| 373 |
+
Cancel all PENDING/IN_PROGRESS nodes in the subtree (branch_root + descendants).
|
| 374 |
+
|
| 375 |
+
Does not call cli_manager.stop_all(). Returns list of cancelled nodes.
|
| 376 |
+
"""
|
| 377 |
+
tree = self._repository.get_tree_for_node(branch_root_id)
|
| 378 |
+
if not tree:
|
| 379 |
+
return []
|
| 380 |
+
|
| 381 |
+
branch_ids = set(tree.get_descendants(branch_root_id))
|
| 382 |
+
cancelled: List[MessageNode] = []
|
| 383 |
+
|
| 384 |
+
async with tree.with_lock():
|
| 385 |
+
for nid in branch_ids:
|
| 386 |
+
node = tree.get_node(nid)
|
| 387 |
+
if not node or node.state in (
|
| 388 |
+
MessageState.COMPLETED,
|
| 389 |
+
MessageState.ERROR,
|
| 390 |
+
):
|
| 391 |
+
continue
|
| 392 |
+
|
| 393 |
+
if tree.is_current_node(nid):
|
| 394 |
+
self._processor.cancel_current(tree)
|
| 395 |
+
node.state = MessageState.ERROR
|
| 396 |
+
node.error_message = "Cancelled by user"
|
| 397 |
+
node.completed_at = datetime.now(timezone.utc)
|
| 398 |
+
cancelled.append(node)
|
| 399 |
+
else:
|
| 400 |
+
tree.remove_from_queue(nid)
|
| 401 |
+
node.state = MessageState.ERROR
|
| 402 |
+
node.error_message = "Cancelled by user"
|
| 403 |
+
node.completed_at = datetime.now(timezone.utc)
|
| 404 |
+
cancelled.append(node)
|
| 405 |
+
|
| 406 |
+
if cancelled:
|
| 407 |
+
logger.info(f"Cancelled {len(cancelled)} nodes in branch {branch_root_id}")
|
| 408 |
+
return cancelled
|
| 409 |
+
|
| 410 |
+
async def remove_branch(
|
| 411 |
+
self, branch_root_id: str
|
| 412 |
+
) -> tuple[List[MessageNode], str, bool]:
|
| 413 |
+
"""
|
| 414 |
+
Remove a branch (subtree) from the tree.
|
| 415 |
+
|
| 416 |
+
If branch_root is the tree root, removes the entire tree.
|
| 417 |
+
|
| 418 |
+
Returns:
|
| 419 |
+
(removed_nodes, root_id, removed_entire_tree)
|
| 420 |
+
"""
|
| 421 |
+
tree = self._repository.get_tree_for_node(branch_root_id)
|
| 422 |
+
if not tree:
|
| 423 |
+
return ([], "", False)
|
| 424 |
+
|
| 425 |
+
root_id = tree.root_id
|
| 426 |
+
|
| 427 |
+
if branch_root_id == root_id:
|
| 428 |
+
cancelled = self.cancel_tree(root_id)
|
| 429 |
+
removed_tree = self._repository.remove_tree(root_id)
|
| 430 |
+
if removed_tree:
|
| 431 |
+
return (removed_tree.all_nodes(), root_id, True)
|
| 432 |
+
return (cancelled, root_id, True)
|
| 433 |
+
|
| 434 |
+
async with tree.with_lock():
|
| 435 |
+
removed = tree.remove_branch(branch_root_id)
|
| 436 |
+
|
| 437 |
+
self._repository.unregister_nodes([n.node_id for n in removed])
|
| 438 |
+
return (removed, root_id, False)
|
| 439 |
+
|
| 440 |
def to_dict(self) -> dict:
|
| 441 |
"""Serialize all trees."""
|
| 442 |
return self._repository.to_dict()
|
messaging/tree_repository.py
CHANGED
|
@@ -129,6 +129,26 @@ class TreeRepository:
|
|
| 129 |
"""Get all tree root IDs."""
|
| 130 |
return list(self._trees.keys())
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
def to_dict(self) -> dict:
|
| 133 |
"""Serialize all trees."""
|
| 134 |
return {
|
|
|
|
| 129 |
"""Get all tree root IDs."""
|
| 130 |
return list(self._trees.keys())
|
| 131 |
|
| 132 |
+
def unregister_nodes(self, node_ids: List[str]) -> None:
|
| 133 |
+
"""Remove node IDs from the node-to-tree mapping."""
|
| 134 |
+
for nid in node_ids:
|
| 135 |
+
self._node_to_tree.pop(nid, None)
|
| 136 |
+
|
| 137 |
+
def remove_tree(self, root_id: str) -> Optional[MessageTree]:
|
| 138 |
+
"""
|
| 139 |
+
Remove a tree and all its node mappings from the repository.
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
The removed tree, or None if not found.
|
| 143 |
+
"""
|
| 144 |
+
tree = self._trees.pop(root_id, None)
|
| 145 |
+
if not tree:
|
| 146 |
+
return None
|
| 147 |
+
for node in tree.all_nodes():
|
| 148 |
+
self._node_to_tree.pop(node.node_id, None)
|
| 149 |
+
logger.debug("TREE_REPO: remove_tree root_id=%s", root_id)
|
| 150 |
+
return tree
|
| 151 |
+
|
| 152 |
def to_dict(self) -> dict:
|
| 153 |
"""Serialize all trees."""
|
| 154 |
return {
|
tests/test_handler.py
CHANGED
|
@@ -517,3 +517,103 @@ async def test_handle_message_clear_command_deletes_message_log_ids(
|
|
| 517 |
|
| 518 |
deleted = {c.args[1] for c in mock_platform.queue_delete_message.call_args_list}
|
| 519 |
assert deleted == {"42", "43", "150"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
|
| 518 |
deleted = {c.args[1] for c in mock_platform.queue_delete_message.call_args_list}
|
| 519 |
assert deleted == {"42", "43", "150"}
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
@pytest.mark.asyncio
|
| 523 |
+
async def test_handle_message_clear_command_reply_clears_branch(
|
| 524 |
+
handler, mock_platform, mock_session_store, incoming_message_factory
|
| 525 |
+
):
|
| 526 |
+
"""Reply /clear to a message clears only that branch."""
|
| 527 |
+
root_incoming = incoming_message_factory(
|
| 528 |
+
text="root", chat_id="chat_1", message_id="100", reply_to_message_id=None
|
| 529 |
+
)
|
| 530 |
+
tree = await handler.tree_queue.create_tree(
|
| 531 |
+
node_id="100", incoming=root_incoming, status_message_id="101"
|
| 532 |
+
)
|
| 533 |
+
handler.tree_queue.register_node("101", tree.root_id)
|
| 534 |
+
|
| 535 |
+
child_incoming = incoming_message_factory(
|
| 536 |
+
text="child",
|
| 537 |
+
chat_id="chat_1",
|
| 538 |
+
message_id="102",
|
| 539 |
+
reply_to_message_id="100",
|
| 540 |
+
)
|
| 541 |
+
await handler.tree_queue.add_to_tree(
|
| 542 |
+
parent_node_id="100",
|
| 543 |
+
node_id="102",
|
| 544 |
+
incoming=child_incoming,
|
| 545 |
+
status_message_id="103",
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
deleted_ids = []
|
| 549 |
+
|
| 550 |
+
async def _capture_delete(chat_id, message_id, fire_and_forget=True):
|
| 551 |
+
deleted_ids.append(message_id)
|
| 552 |
+
|
| 553 |
+
mock_platform.queue_delete_message = AsyncMock(side_effect=_capture_delete)
|
| 554 |
+
|
| 555 |
+
incoming = incoming_message_factory(
|
| 556 |
+
text="/clear",
|
| 557 |
+
chat_id="chat_1",
|
| 558 |
+
message_id="150",
|
| 559 |
+
reply_to_message_id="102",
|
| 560 |
+
)
|
| 561 |
+
await handler.handle_message(incoming)
|
| 562 |
+
|
| 563 |
+
assert set(deleted_ids) == {"102", "103", "150"}
|
| 564 |
+
assert "100" not in deleted_ids
|
| 565 |
+
assert "101" not in deleted_ids
|
| 566 |
+
mock_session_store.remove_node_mappings.assert_called()
|
| 567 |
+
assert handler.tree_queue.get_tree_for_node("102") is None
|
| 568 |
+
assert handler.tree_queue.get_tree_for_node("100") is not None
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
@pytest.mark.asyncio
|
| 572 |
+
async def test_handle_message_clear_command_reply_unknown_sends_nothing(
|
| 573 |
+
handler, mock_platform, mock_session_store, incoming_message_factory
|
| 574 |
+
):
|
| 575 |
+
"""Reply /clear to unknown message sends 'Nothing to clear'."""
|
| 576 |
+
incoming = incoming_message_factory(
|
| 577 |
+
text="/clear",
|
| 578 |
+
chat_id="chat_1",
|
| 579 |
+
message_id="150",
|
| 580 |
+
reply_to_message_id="999",
|
| 581 |
+
)
|
| 582 |
+
await handler.handle_message(incoming)
|
| 583 |
+
|
| 584 |
+
mock_platform.queue_send_message.assert_called_once()
|
| 585 |
+
call_args = mock_platform.queue_send_message.call_args[0]
|
| 586 |
+
assert "Nothing to clear" in call_args[1]
|
| 587 |
+
mock_session_store.clear_all.assert_not_called()
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
@pytest.mark.asyncio
|
| 591 |
+
async def test_handle_message_clear_command_reply_to_root_clears_tree(
|
| 592 |
+
handler, mock_platform, mock_session_store, incoming_message_factory
|
| 593 |
+
):
|
| 594 |
+
"""Reply /clear to root message clears entire tree."""
|
| 595 |
+
root_incoming = incoming_message_factory(
|
| 596 |
+
text="root", chat_id="chat_1", message_id="100", reply_to_message_id=None
|
| 597 |
+
)
|
| 598 |
+
await handler.tree_queue.create_tree(
|
| 599 |
+
node_id="100", incoming=root_incoming, status_message_id="101"
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
deleted_ids = []
|
| 603 |
+
|
| 604 |
+
async def _capture_delete(chat_id, message_id, fire_and_forget=True):
|
| 605 |
+
deleted_ids.append(message_id)
|
| 606 |
+
|
| 607 |
+
mock_platform.queue_delete_message = AsyncMock(side_effect=_capture_delete)
|
| 608 |
+
|
| 609 |
+
incoming = incoming_message_factory(
|
| 610 |
+
text="/clear",
|
| 611 |
+
chat_id="chat_1",
|
| 612 |
+
message_id="150",
|
| 613 |
+
reply_to_message_id="100",
|
| 614 |
+
)
|
| 615 |
+
await handler.handle_message(incoming)
|
| 616 |
+
|
| 617 |
+
assert set(deleted_ids) == {"100", "101", "150"}
|
| 618 |
+
mock_session_store.remove_tree.assert_called_once_with("100")
|
| 619 |
+
assert handler.tree_queue.get_tree_count() == 0
|
tests/test_tree_queue.py
CHANGED
|
@@ -282,6 +282,83 @@ class TestMessageTree:
|
|
| 282 |
assert node is not None
|
| 283 |
assert node.session_id == "sess_1"
|
| 284 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
|
| 286 |
class TestTreeQueueManager:
|
| 287 |
"""Test TreeQueueManager class."""
|
|
@@ -441,6 +518,97 @@ class TestTreeQueueManager:
|
|
| 441 |
|
| 442 |
processing_complete.set()
|
| 443 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
|
| 445 |
class TestSessionStoreTrees:
|
| 446 |
"""Test SessionStore tree methods."""
|
|
|
|
| 282 |
assert node is not None
|
| 283 |
assert node.session_id == "sess_1"
|
| 284 |
|
| 285 |
+
@pytest.mark.asyncio
|
| 286 |
+
async def test_get_descendants(self):
|
| 287 |
+
"""Test get_descendants returns node and all descendants."""
|
| 288 |
+
root_incoming = IncomingMessage(
|
| 289 |
+
text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
|
| 290 |
+
)
|
| 291 |
+
root = MessageNode(
|
| 292 |
+
node_id="root", incoming=root_incoming, status_message_id="s1"
|
| 293 |
+
)
|
| 294 |
+
tree = MessageTree(root)
|
| 295 |
+
|
| 296 |
+
child_incoming = IncomingMessage(
|
| 297 |
+
text="Child",
|
| 298 |
+
chat_id="1",
|
| 299 |
+
user_id="1",
|
| 300 |
+
message_id="child",
|
| 301 |
+
platform="test",
|
| 302 |
+
reply_to_message_id="root",
|
| 303 |
+
)
|
| 304 |
+
await tree.add_node("child", child_incoming, "s2", "root")
|
| 305 |
+
|
| 306 |
+
grandchild_incoming = IncomingMessage(
|
| 307 |
+
text="Grand",
|
| 308 |
+
chat_id="1",
|
| 309 |
+
user_id="1",
|
| 310 |
+
message_id="grand",
|
| 311 |
+
platform="test",
|
| 312 |
+
reply_to_message_id="child",
|
| 313 |
+
)
|
| 314 |
+
await tree.add_node("grand", grandchild_incoming, "s3", "child")
|
| 315 |
+
|
| 316 |
+
assert tree.get_descendants("root") == ["root", "child", "grand"]
|
| 317 |
+
assert tree.get_descendants("child") == ["child", "grand"]
|
| 318 |
+
assert tree.get_descendants("grand") == ["grand"]
|
| 319 |
+
assert tree.get_descendants("nonexistent") == []
|
| 320 |
+
|
| 321 |
+
@pytest.mark.asyncio
|
| 322 |
+
async def test_remove_branch(self):
|
| 323 |
+
"""Test remove_branch removes subtree and updates parent."""
|
| 324 |
+
root_incoming = IncomingMessage(
|
| 325 |
+
text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
|
| 326 |
+
)
|
| 327 |
+
root = MessageNode(
|
| 328 |
+
node_id="root", incoming=root_incoming, status_message_id="s1"
|
| 329 |
+
)
|
| 330 |
+
tree = MessageTree(root)
|
| 331 |
+
|
| 332 |
+
child_incoming = IncomingMessage(
|
| 333 |
+
text="Child",
|
| 334 |
+
chat_id="1",
|
| 335 |
+
user_id="1",
|
| 336 |
+
message_id="child",
|
| 337 |
+
platform="test",
|
| 338 |
+
reply_to_message_id="root",
|
| 339 |
+
)
|
| 340 |
+
await tree.add_node("child", child_incoming, "s2", "root")
|
| 341 |
+
|
| 342 |
+
grandchild_incoming = IncomingMessage(
|
| 343 |
+
text="Grand",
|
| 344 |
+
chat_id="1",
|
| 345 |
+
user_id="1",
|
| 346 |
+
message_id="grand",
|
| 347 |
+
platform="test",
|
| 348 |
+
reply_to_message_id="child",
|
| 349 |
+
)
|
| 350 |
+
await tree.add_node("grand", grandchild_incoming, "s3", "child")
|
| 351 |
+
|
| 352 |
+
async with tree.with_lock():
|
| 353 |
+
removed = tree.remove_branch("child")
|
| 354 |
+
|
| 355 |
+
assert len(removed) == 2
|
| 356 |
+
assert {n.node_id for n in removed} == {"child", "grand"}
|
| 357 |
+
assert tree.get_node("child") is None
|
| 358 |
+
assert tree.get_node("grand") is None
|
| 359 |
+
assert tree.get_node("root") is not None
|
| 360 |
+
assert "child" not in tree.get_root().children_ids
|
| 361 |
+
|
| 362 |
|
| 363 |
class TestTreeQueueManager:
|
| 364 |
"""Test TreeQueueManager class."""
|
|
|
|
| 518 |
|
| 519 |
processing_complete.set()
|
| 520 |
|
| 521 |
+
@pytest.mark.asyncio
|
| 522 |
+
async def test_cancel_branch(self):
|
| 523 |
+
"""Test cancel_branch cancels only nodes in subtree."""
|
| 524 |
+
manager = TreeQueueManager()
|
| 525 |
+
|
| 526 |
+
root_incoming = IncomingMessage(
|
| 527 |
+
text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
|
| 528 |
+
)
|
| 529 |
+
await manager.create_tree("root", root_incoming, "s1")
|
| 530 |
+
|
| 531 |
+
child_incoming = IncomingMessage(
|
| 532 |
+
text="Child",
|
| 533 |
+
chat_id="1",
|
| 534 |
+
user_id="1",
|
| 535 |
+
message_id="child",
|
| 536 |
+
platform="test",
|
| 537 |
+
reply_to_message_id="root",
|
| 538 |
+
)
|
| 539 |
+
tree, _ = await manager.add_to_tree("root", "child", child_incoming, "s2")
|
| 540 |
+
|
| 541 |
+
sibling_incoming = IncomingMessage(
|
| 542 |
+
text="Sibling",
|
| 543 |
+
chat_id="1",
|
| 544 |
+
user_id="1",
|
| 545 |
+
message_id="sibling",
|
| 546 |
+
platform="test",
|
| 547 |
+
reply_to_message_id="root",
|
| 548 |
+
)
|
| 549 |
+
await manager.add_to_tree("root", "sibling", sibling_incoming, "s3")
|
| 550 |
+
|
| 551 |
+
cancelled = await manager.cancel_branch("child")
|
| 552 |
+
assert len(cancelled) == 1
|
| 553 |
+
assert cancelled[0].node_id == "child"
|
| 554 |
+
|
| 555 |
+
child_node = tree.get_node("child")
|
| 556 |
+
assert child_node is not None
|
| 557 |
+
assert child_node.state == MessageState.ERROR
|
| 558 |
+
|
| 559 |
+
sibling_node = tree.get_node("sibling")
|
| 560 |
+
assert sibling_node is not None
|
| 561 |
+
assert sibling_node.state == MessageState.PENDING
|
| 562 |
+
|
| 563 |
+
@pytest.mark.asyncio
|
| 564 |
+
async def test_remove_branch_non_root(self):
|
| 565 |
+
"""Test remove_branch removes only the subtree when branch is not root."""
|
| 566 |
+
manager = TreeQueueManager()
|
| 567 |
+
|
| 568 |
+
root_incoming = IncomingMessage(
|
| 569 |
+
text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
|
| 570 |
+
)
|
| 571 |
+
await manager.create_tree("root", root_incoming, "s1")
|
| 572 |
+
|
| 573 |
+
child_incoming = IncomingMessage(
|
| 574 |
+
text="Child",
|
| 575 |
+
chat_id="1",
|
| 576 |
+
user_id="1",
|
| 577 |
+
message_id="child",
|
| 578 |
+
platform="test",
|
| 579 |
+
reply_to_message_id="root",
|
| 580 |
+
)
|
| 581 |
+
tree, _ = await manager.add_to_tree("root", "child", child_incoming, "s2")
|
| 582 |
+
|
| 583 |
+
removed, root_id, removed_entire = await manager.remove_branch("child")
|
| 584 |
+
|
| 585 |
+
assert len(removed) == 1
|
| 586 |
+
assert removed[0].node_id == "child"
|
| 587 |
+
assert root_id == "root"
|
| 588 |
+
assert removed_entire is False
|
| 589 |
+
assert manager.get_tree_for_node("child") is None
|
| 590 |
+
assert manager.get_tree("root") is not None
|
| 591 |
+
assert tree.get_node("child") is None
|
| 592 |
+
assert "child" not in tree.get_root().children_ids
|
| 593 |
+
|
| 594 |
+
@pytest.mark.asyncio
|
| 595 |
+
async def test_remove_branch_root_removes_tree(self):
|
| 596 |
+
"""Test remove_branch when branch is root removes entire tree."""
|
| 597 |
+
manager = TreeQueueManager()
|
| 598 |
+
|
| 599 |
+
root_incoming = IncomingMessage(
|
| 600 |
+
text="Root", chat_id="1", user_id="1", message_id="root", platform="test"
|
| 601 |
+
)
|
| 602 |
+
await manager.create_tree("root", root_incoming, "s1")
|
| 603 |
+
|
| 604 |
+
removed, root_id, removed_entire = await manager.remove_branch("root")
|
| 605 |
+
|
| 606 |
+
assert len(removed) == 1
|
| 607 |
+
assert root_id == "root"
|
| 608 |
+
assert removed_entire is True
|
| 609 |
+
assert manager.get_tree("root") is None
|
| 610 |
+
assert manager.get_tree_for_node("root") is None
|
| 611 |
+
|
| 612 |
|
| 613 |
class TestSessionStoreTrees:
|
| 614 |
"""Test SessionStore tree methods."""
|