mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-07-10 00:14:16 +00:00
## Problem
Messaging cleanup had two public queued delete paths. Command code could
loop single-message deletes and bypass Telegram batch deletion.
## Changes
| Before | After |
| --- | --- |
| Workflow code could call `queue_delete_message` or
`queue_delete_messages`. | Workflow code calls only
`queue_delete_messages`. |
| Telegram `/clear` cleanup used one API request per message. | Telegram
`/clear` cleanup uses `deleteMessages` in chunks of 100. |
| The outbox dedupe key used Python process hashing. | The outbox dedupe
key uses a stable SHA-based digest. |
| Voice and smoke cleanup depended on the single-delete queue API. |
Voice and smoke cleanup pass one-item delete lists. |
<!-- greptile_comment -->
<details open><summary><h3>Greptile Summary</h3></summary>
This PR moves messaging cleanup to a list-based delete boundary. The
main changes are:
- `/clear` now sends collected message IDs through
`queue_delete_messages`.
- Telegram deletion uses `deleteMessages` in 100-message chunks with
per-message fallback.
- Discord keeps per-message deletion behind the list-based outbound API.
- Delete-batch dedupe keys now use a stable SHA digest instead of Python
process hashing.
- Voice cleanup, smoke fakes, protocol tests, and messaging tests were
updated for the new delete boundary.
</details>
<h3>Confidence Score: 5/5</h3>
Safe to merge with minimal risk.
The change is well-scoped to the messaging delete boundary, keeps
platform-specific best-effort behavior, addresses the batch-fallback
concern, updates protocol consumers and tests, and includes the required
version and lockfile bump.
No files require special attention.
<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>
**What T-Rex did**
- No execution evidence is available for this session; no harness was
created, no tests were run, and no artifacts were produced.
<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>
<details open><summary><h3>Important Files Changed</h3></summary>
| Filename | Overview |
|----------|----------|
| messaging/commands.py | Routes `/clear` cleanup through
`queue_delete_messages` once per collected message set while preserving
best-effort state cleanup. |
| messaging/platforms/outbox.py | Removes single-delete queueing,
snapshots delete batches, and uses a stable SHA digest for delete dedupe
keys. |
| messaging/platforms/telegram_io.py | Adds Telegram `deleteMessages`
batching with 100-message chunks and per-message fallback when batch
deletion fails. |
| messaging/platforms/discord_io.py | Removes the public single
queued-delete wrapper and backs queued deletion with the list-based
outbox API. |
| messaging/platforms/voice_flow.py | Changes shared voice cleanup call
sites to submit one-item lists to the delete queue. |
| messaging/platforms/ports.py | Narrows the outbound protocol to the
list-based delete queue method. |
| tests/messaging/test_telegram.py | Adds Telegram batch delete,
chunking, and fallback coverage. |
| tests/messaging/test_platform_outbox.py | Covers stable delete-batch
dedupe keys and snapshotting mutable message ID lists before queueing. |
| pyproject.toml | Bumps the package patch version for the production
messaging changes. |
| uv.lock | Updates the editable package version in the lockfile to
match `pyproject.toml`. |
</details>
<details open><summary><h3>Sequence Diagram</h3></summary>
<a href="#gh-light-mode-only">
```mermaid
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Command as /clear or voice cleanup
participant Outbound as OutboundMessenger.queue_delete_messages
participant Outbox as PlatformOutbox
participant Telegram as TelegramMessenger
participant Discord as DiscordMessenger
participant API as Platform API
Command->>Outbound: queue_delete_messages(chat_id, message_ids)
Outbound->>Outbox: snapshot IDs and dedupe batch
alt Telegram
Outbox->>Telegram: delete_messages(chat_id, ids)
loop chunks of 100
Telegram->>API: deleteMessages(chat_id, chunk)
alt batch fails
Telegram->>API: deleteMessage(chat_id, each id)
end
end
else Discord
Outbox->>Discord: delete_messages(chat_id, ids)
loop each id
Discord->>API: fetch_message + delete
end
end
```
</a>
<a href="#gh-dark-mode-only">
```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Command as /clear or voice cleanup
participant Outbound as OutboundMessenger.queue_delete_messages
participant Outbox as PlatformOutbox
participant Telegram as TelegramMessenger
participant Discord as DiscordMessenger
participant API as Platform API
Command->>Outbound: queue_delete_messages(chat_id, message_ids)
Outbound->>Outbox: snapshot IDs and dedupe batch
alt Telegram
Outbox->>Telegram: delete_messages(chat_id, ids)
loop chunks of 100
Telegram->>API: deleteMessages(chat_id, chunk)
alt batch fails
Telegram->>API: deleteMessage(chat_id, each id)
end
end
else Discord
Outbox->>Discord: delete_messages(chat_id, ids)
loop each id
Discord->>API: fetch_message + delete
end
end
```
</a>
</details>
<sub>Reviews (2): Last reviewed commit: ["Preserve Telegram batch delete
fallback"](87090edcd9)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=41993526)</sub>
<!-- /greptile_comment -->
213 lines
6.7 KiB
Python
213 lines
6.7 KiB
Python
"""Tests for messaging/ module."""
|
|
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
# --- Existing Tests ---
|
|
|
|
|
|
class TestMessagingModels:
|
|
"""Test messaging models."""
|
|
|
|
def test_incoming_message_creation(self):
|
|
"""Test IncomingMessage dataclass."""
|
|
from messaging.models import IncomingMessage
|
|
|
|
msg = IncomingMessage(
|
|
text="Hello",
|
|
chat_id="123",
|
|
user_id="456",
|
|
message_id="789",
|
|
platform="telegram",
|
|
)
|
|
assert msg.text == "Hello"
|
|
assert msg.chat_id == "123"
|
|
assert msg.platform == "telegram"
|
|
assert msg.is_reply() is False
|
|
|
|
def test_incoming_message_with_reply(self):
|
|
"""Test IncomingMessage as a reply."""
|
|
from messaging.models import IncomingMessage
|
|
|
|
msg = IncomingMessage(
|
|
text="Reply text",
|
|
chat_id="123",
|
|
user_id="456",
|
|
message_id="789",
|
|
platform="discord",
|
|
reply_to_message_id="100",
|
|
)
|
|
assert msg.is_reply() is True
|
|
assert msg.reply_to_message_id == "100"
|
|
|
|
|
|
class TestMessagingPorts:
|
|
"""Test explicit messaging platform component ports."""
|
|
|
|
def test_components_bundle_runtime_and_outbound(self):
|
|
"""Verify the factory handoff shape is explicit."""
|
|
from messaging.platforms.ports import MessagingPlatformComponents
|
|
|
|
runtime = MagicMock()
|
|
runtime.name = "telegram"
|
|
runtime.start = AsyncMock()
|
|
runtime.stop = AsyncMock()
|
|
runtime.on_message = MagicMock()
|
|
outbound = MagicMock()
|
|
outbound.queue_send_message = AsyncMock()
|
|
outbound.queue_edit_message = AsyncMock()
|
|
outbound.queue_delete_messages = AsyncMock()
|
|
outbound.fire_and_forget = MagicMock()
|
|
components = MessagingPlatformComponents(
|
|
name="telegram",
|
|
runtime=runtime,
|
|
outbound=outbound,
|
|
voice_cancellation=None,
|
|
)
|
|
assert components.runtime is runtime
|
|
assert components.outbound is outbound
|
|
|
|
|
|
class TestSessionStore:
|
|
"""Test SessionStore."""
|
|
|
|
def test_session_store_init(self, tmp_path):
|
|
"""Test SessionStore initialization."""
|
|
from messaging.session import SessionStore
|
|
|
|
store = SessionStore(storage_path=str(tmp_path / "sessions.json"))
|
|
assert store.load_conversation_snapshot().is_empty
|
|
|
|
# --- Tree Tests ---
|
|
|
|
def test_save_and_get_tree(self, tmp_path):
|
|
"""Test saving and retrieving trees."""
|
|
from messaging.session import SessionStore
|
|
from messaging.trees import TreeSnapshot
|
|
|
|
store = SessionStore(storage_path=str(tmp_path / "sessions.json"))
|
|
|
|
tree_data = {
|
|
"root_id": "r1",
|
|
"nodes": {
|
|
"r1": {"node_id": "r1", "status_message_id": "s1"},
|
|
"n1": {"node_id": "n1", "status_message_id": "s2"},
|
|
},
|
|
}
|
|
snapshot = TreeSnapshot.from_json(tree_data)
|
|
assert snapshot is not None
|
|
store.save_tree_snapshot(snapshot)
|
|
|
|
loaded = store.get_tree_snapshot("r1")
|
|
assert loaded == snapshot
|
|
|
|
node_map = store.load_conversation_snapshot().derive_node_to_tree()
|
|
assert node_map["r1"] == "r1"
|
|
assert node_map["n1"] == "r1"
|
|
|
|
# --- Persistence & Edge Cases ---
|
|
|
|
def test_load_existing_file_with_trees(self, tmp_path):
|
|
"""Test loading file with trees (legacy sessions ignored)."""
|
|
from messaging.session import SessionStore
|
|
|
|
data = {
|
|
"sessions": {},
|
|
"trees": {"r1": {"root_id": "r1", "nodes": {"r1": {}}}},
|
|
"node_to_tree": {"r1": "r1"},
|
|
"message_log": {},
|
|
}
|
|
|
|
p = tmp_path / "sessions.json"
|
|
with open(p, "w") as f:
|
|
json.dump(data, f)
|
|
|
|
store = SessionStore(storage_path=str(p))
|
|
assert store.get_tree_snapshot("r1") is not None
|
|
|
|
def test_load_corrupt_file(self, tmp_path):
|
|
"""Test loading corrupt/invalid json file."""
|
|
p = tmp_path / "sessions.json"
|
|
with open(p, "w") as f:
|
|
f.write("{invalid json")
|
|
|
|
from messaging.session import SessionStore
|
|
|
|
# Should log error and start empty, avoiding crash
|
|
store = SessionStore(storage_path=str(p))
|
|
assert store.load_conversation_snapshot().is_empty
|
|
|
|
def test_save_error_handling(self, tmp_path):
|
|
"""Test error during save."""
|
|
from messaging.session import SessionStore
|
|
from messaging.trees import TreeSnapshot
|
|
|
|
store = SessionStore(storage_path=str(tmp_path / "sessions.json"))
|
|
snapshot = TreeSnapshot(root_id="r1", nodes={"r1": {}})
|
|
store.save_tree_snapshot(snapshot)
|
|
|
|
with patch(
|
|
"messaging.session.persistence.os.replace", side_effect=OSError("Disk full")
|
|
):
|
|
store.flush_pending_save()
|
|
|
|
assert store.dirty is True
|
|
assert store.get_tree_snapshot("r1") is not None
|
|
|
|
|
|
class TestTreeQueueManager:
|
|
"""Test TreeQueueManager."""
|
|
|
|
def test_tree_queue_manager_init(self):
|
|
"""Test TreeQueueManager initialization."""
|
|
from messaging.trees import TreeQueueManager
|
|
|
|
mgr = TreeQueueManager()
|
|
assert mgr.get_tree_count() == 0
|
|
|
|
def test_tree_not_busy_initially(self):
|
|
"""Test tree is not busy when no messages."""
|
|
from messaging.trees import TreeQueueManager
|
|
|
|
mgr = TreeQueueManager()
|
|
assert mgr.is_tree_busy("nonexistent") is False
|
|
|
|
def test_get_queue_size_empty(self):
|
|
"""Test queue size is 0 for non-existent node."""
|
|
from messaging.trees import TreeQueueManager
|
|
|
|
mgr = TreeQueueManager()
|
|
assert mgr.get_queue_size("nonexistent") == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_tree_and_enqueue(self):
|
|
"""Test creating a tree and enqueueing."""
|
|
from messaging.models import IncomingMessage
|
|
from messaging.trees import TreeQueueManager
|
|
|
|
mgr = TreeQueueManager()
|
|
processed = []
|
|
|
|
async def processor(node_id, node):
|
|
processed.append(node_id)
|
|
|
|
incoming = IncomingMessage(
|
|
text="test", chat_id="1", user_id="1", message_id="1", platform="test"
|
|
)
|
|
|
|
await mgr.create_tree("1", incoming, "status_1")
|
|
was_queued = await mgr.enqueue("1", processor)
|
|
|
|
# First message should process immediately, not queue
|
|
assert was_queued is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_tree_empty(self):
|
|
"""Test cancelling non-existent tree."""
|
|
from messaging.trees import TreeQueueManager
|
|
|
|
mgr = TreeQueueManager()
|
|
cancelled = await mgr.cancel_tree("nonexistent")
|
|
assert cancelled == []
|