Use batch delete boundary for messaging (#996)

## 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 -->
This commit is contained in:
Ali Khokhar 2026-07-05 22:07:09 -07:00 committed by GitHub
parent 28e8996121
commit 755a3851f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 303 additions and 192 deletions

View file

@ -571,8 +571,10 @@ Outbound SDK calls live in
[messaging/platforms/telegram_io.py](messaging/platforms/telegram_io.py) and
[messaging/platforms/discord_io.py](messaging/platforms/discord_io.py). Shared
delivery policy lives in [messaging/platforms/outbox.py](messaging/platforms/outbox.py),
which owns queued send/edit/delete, dedup keys, limiter delegation, and
fire-and-forget behavior.
which owns queued send/edit/list-based delete, dedup keys, limiter delegation,
and fire-and-forget behavior. Workflow and command code request deletion of
message ID lists; platform IO decides whether to use native batch deletion
(Telegram) or internal per-message deletion (Discord).
Shared voice-note orchestration lives in
[messaging/platforms/voice_flow.py](messaging/platforms/voice_flow.py), which owns
pending voice registration, temp-file cleanup, transcription, cancellation, error
@ -628,9 +630,10 @@ APIs to runtime code. Debounced atomic writes live in
[messaging/session/persistence.py](messaging/session/persistence.py), and
per-chat message ID tracking for `/clear` lives in
[messaging/session/message_log.py](messaging/session/message_log.py). `/clear`
guarantees FCC state cleanup and tries each tracked platform delete, but
Discord/Telegram can still reject individual message deletions for platform
reasons such as permissions, age, or missing messages.
guarantees FCC state cleanup and tries tracked platform deletes through the
list-based outbound delete port, but Discord/Telegram can still reject
individual message deletions for platform reasons such as permissions, age, or
missing messages.
```mermaid
sequenceDiagram

View file

@ -3,21 +3,12 @@
Commands depend on MessagingCommandContext instead of the concrete workflow.
"""
from dataclasses import dataclass
from loguru import logger
from .command_context import MessagingCommandContext
from .models import IncomingMessage
@dataclass(frozen=True, slots=True)
class MessageDeleteReport:
attempted: int
deleted: int
failed: int
async def handle_stop_command(
handler: MessagingCommandContext, incoming: IncomingMessage
) -> None:
@ -95,10 +86,10 @@ async def handle_stats_command(
async def _delete_message_ids(
handler: MessagingCommandContext, chat_id: str, msg_ids: set[str]
) -> MessageDeleteReport:
) -> None:
"""Best-effort delete messages by ID. Sorts numeric IDs descending."""
if not msg_ids:
return MessageDeleteReport(attempted=0, deleted=0, failed=0)
return
def _as_int(s: str) -> int | None:
try:
@ -117,28 +108,23 @@ async def _delete_message_ids(
numeric.sort(reverse=True)
ordered = [mid for _, mid in numeric] + non_numeric
deleted = 0
failed = 0
for mid in ordered:
try:
await handler.outbound.queue_delete_message(
chat_id, mid, fire_and_forget=False
)
deleted += 1
except Exception as e:
failed += 1
logger.debug(
"Message delete failed for chat {}: {}", chat_id, type(e).__name__
)
try:
await handler.outbound.queue_delete_messages(
chat_id,
ordered,
fire_and_forget=False,
)
except Exception as e:
failed = len(ordered)
logger.debug("Message delete failed for chat {}: {}", chat_id, type(e).__name__)
if ordered:
logger.info(
"Clear delete attempted={} deleted={} failed={}",
"Clear delete attempted={} failed={}",
len(ordered),
deleted,
failed,
)
return MessageDeleteReport(attempted=len(ordered), deleted=deleted, failed=failed)
async def _handle_clear_branch(

View file

@ -141,7 +141,7 @@ class DiscordRuntime:
discord_voice_request_from_event(message, attachment, channel_id),
message_handler=self._message_handler,
queue_send_message=self.outbound.queue_send_message,
queue_delete_message=self.outbound.queue_delete_message,
queue_delete_messages=self.outbound.queue_delete_messages,
)
async def _on_discord_message(self, message: Any) -> None:

View file

@ -35,7 +35,6 @@ class DiscordMessenger:
get_limiter=get_limiter,
send=self.send_message,
edit=self.edit_message,
delete=self.delete_message,
delete_many=self.delete_messages,
)
@ -146,15 +145,6 @@ class DiscordMessenger:
fire_and_forget,
)
async def queue_delete_message(
self,
chat_id: str,
message_id: str,
fire_and_forget: bool = True,
) -> None:
"""Queue a Discord delete."""
await self._outbox.queue_delete_message(chat_id, message_id, fire_and_forget)
async def queue_delete_messages(
self,
chat_id: str,

View file

@ -1,6 +1,7 @@
"""Shared queued delivery helper for messaging platforms."""
import asyncio
import hashlib
from collections.abc import Awaitable, Callable
from typing import Any, cast
@ -9,7 +10,6 @@ SendOperation = Callable[
Awaitable[str],
]
EditOperation = Callable[[str, str, str, str | None], Awaitable[None]]
DeleteOperation = Callable[[str, str], Awaitable[None]]
DeleteManyOperation = Callable[[str, list[str]], Awaitable[None]]
LimiterGetter = Callable[[], Any | None]
@ -23,13 +23,11 @@ class PlatformOutbox:
get_limiter: LimiterGetter,
send: SendOperation,
edit: EditOperation,
delete: DeleteOperation,
delete_many: DeleteManyOperation,
) -> None:
self._get_limiter = get_limiter
self._send = send
self._edit = edit
self._delete = delete
self._delete_many = delete_many
async def queue_send_message(
@ -89,27 +87,6 @@ class PlatformOutbox:
else:
await limiter.enqueue(_edit, dedup_key=dedup_key)
async def queue_delete_message(
self,
chat_id: str,
message_id: str,
fire_and_forget: bool = True,
) -> None:
"""Queue or immediately delete a platform message."""
limiter = self._get_limiter()
if limiter is None:
await self._delete(chat_id, message_id)
return
async def _delete() -> None:
await self._delete(chat_id, message_id)
dedup_key = f"del:{chat_id}:{message_id}"
if fire_and_forget:
limiter.fire_and_forget(_delete, dedup_key=dedup_key)
else:
await limiter.enqueue(_delete, dedup_key=dedup_key)
async def queue_delete_messages(
self,
chat_id: str,
@ -117,18 +94,20 @@ class PlatformOutbox:
fire_and_forget: bool = True,
) -> None:
"""Queue or immediately bulk-delete platform messages."""
if not message_ids:
ids_snapshot = tuple(str(message_id) for message_id in message_ids)
if not ids_snapshot:
return
limiter = self._get_limiter()
if limiter is None:
await self._delete_many(chat_id, message_ids)
await self._delete_many(chat_id, list(ids_snapshot))
return
async def _delete_many() -> None:
await self._delete_many(chat_id, message_ids)
await self._delete_many(chat_id, list(ids_snapshot))
dedup_key = f"del_bulk:{chat_id}:{hash(tuple(message_ids))}"
digest = hashlib.sha256("\x1f".join(ids_snapshot).encode()).hexdigest()[:16]
dedup_key = f"del_bulk:{chat_id}:{digest}"
if fire_and_forget:
limiter.fire_and_forget(_delete_many, dedup_key=dedup_key)
else:

View file

@ -49,13 +49,6 @@ class OutboundMessenger(Protocol):
fire_and_forget: bool = True,
) -> None: ...
async def queue_delete_message(
self,
chat_id: str,
message_id: str,
fire_and_forget: bool = True,
) -> None: ...
async def queue_delete_messages(
self,
chat_id: str,

View file

@ -270,5 +270,5 @@ class TelegramRuntime:
request,
message_handler=self._message_handler,
queue_send_message=self.outbound.queue_send_message,
queue_delete_message=self.outbound.queue_delete_message,
queue_delete_messages=self.outbound.queue_delete_messages,
)

View file

@ -9,6 +9,8 @@ from loguru import logger
from .outbox import PlatformOutbox
TELEGRAM_DELETE_MESSAGES_BATCH_SIZE = 100
TelegramNetworkError: type[BaseException]
TelegramRetryAfter: type[BaseException]
TelegramBaseError: type[BaseException]
@ -49,12 +51,15 @@ class TelegramMessenger:
get_limiter=get_limiter,
send=self.send_message,
edit=self.edit_message,
delete=self.delete_message,
delete_many=self.delete_messages,
)
async def _with_retry(
self, func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any
self,
func: Callable[..., Awaitable[Any]],
*args: Any,
suppress_known_message_errors: bool = True,
**kwargs: Any,
) -> Any:
"""Execute a Telegram API call with the platform retry policy."""
max_retries = 3
@ -63,7 +68,9 @@ class TelegramMessenger:
return await func(*args, **kwargs)
except (TimeoutError, TelegramNetworkError) as e:
if "Message is not modified" in str(e):
return None
if suppress_known_message_errors:
return None
raise
if attempt < max_retries - 1:
wait_time = 2**attempt
logger.warning(
@ -95,7 +102,9 @@ class TelegramMessenger:
except TelegramBaseError as e:
err_lower = str(e).lower()
if "message is not modified" in err_lower:
return None
if suppress_known_message_errors:
return None
raise
if any(
x in err_lower
for x in [
@ -106,7 +115,9 @@ class TelegramMessenger:
"not enough rights to delete",
]
):
return None
if suppress_known_message_errors:
return None
raise
if "Can't parse entities" in str(e) and kwargs.get("parse_mode"):
logger.warning("Markdown failed, retrying without parse_mode")
kwargs["parse_mode"] = None
@ -183,23 +194,40 @@ class TelegramMessenger:
raise RuntimeError("Telegram application or bot not initialized")
bot = app.bot
if hasattr(bot, "delete_messages"):
async def _do_bulk() -> None:
mids: list[int] = []
for mid in message_ids:
try:
mids.append(int(mid))
except Exception:
continue
if mids:
await bot.delete_messages(chat_id=chat_id, message_ids=mids)
await self._with_retry(_do_bulk)
mids: list[int] = []
for mid in message_ids:
try:
mids.append(int(mid))
except Exception:
continue
if not mids:
return
for mid in message_ids:
await self.delete_message(chat_id, mid)
if hasattr(bot, "delete_messages"):
for start in range(0, len(mids), TELEGRAM_DELETE_MESSAGES_BATCH_SIZE):
chunk = mids[start : start + TELEGRAM_DELETE_MESSAGES_BATCH_SIZE]
chunk_snapshot = tuple(chunk)
async def _do_bulk(ids: tuple[int, ...] = chunk_snapshot) -> None:
await bot.delete_messages(chat_id=chat_id, message_ids=list(ids))
try:
await self._with_retry(
_do_bulk,
suppress_known_message_errors=False,
)
except Exception as e:
logger.debug(
"Telegram bulk delete failed for chat {}: {}; falling back",
chat_id,
type(e).__name__,
)
for mid in chunk:
await self.delete_message(chat_id, str(mid))
return
for mid in mids:
await self.delete_message(chat_id, str(mid))
async def queue_send_message(
self,
@ -237,15 +265,6 @@ class TelegramMessenger:
fire_and_forget,
)
async def queue_delete_message(
self,
chat_id: str,
message_id: str,
fire_and_forget: bool = True,
) -> None:
"""Queue a Telegram delete."""
await self._outbox.queue_delete_message(chat_id, message_id, fire_and_forget)
async def queue_delete_messages(
self,
chat_id: str,

View file

@ -22,7 +22,7 @@ VOICE_TRANSCRIPTION_ERROR_MESSAGE = (
MessageHandler = Callable[[IncomingMessage], Awaitable[None]]
QueueSend = Callable[..., Awaitable[str | None]]
QueueDelete = Callable[..., Awaitable[None]]
QueueDeleteMany = Callable[..., Awaitable[None]]
@dataclass(frozen=True)
@ -148,7 +148,7 @@ class VoiceNoteFlow:
*,
message_handler: MessageHandler | None,
queue_send_message: QueueSend,
queue_delete_message: QueueDelete,
queue_delete_messages: QueueDeleteMany,
) -> bool:
"""Transcribe a voice note and hand the resulting turn to messaging."""
if await self.reply_if_disabled(request.reply_text):
@ -192,7 +192,7 @@ class VoiceNoteFlow:
request.chat_id,
request.message_id,
):
await queue_delete_message(request.chat_id, status_msg_id_text)
await queue_delete_messages(request.chat_id, [status_msg_id_text])
return True
await self.complete_pending_voice(
@ -222,7 +222,7 @@ class VoiceNoteFlow:
await self._clear_failed_pending_voice(
request,
status_msg_id_text,
queue_delete_message,
queue_delete_messages,
handed_off=handed_off,
)
await request.reply_text(format_user_error_preview(e))
@ -231,7 +231,7 @@ class VoiceNoteFlow:
await self._clear_failed_pending_voice(
request,
status_msg_id_text,
queue_delete_message,
queue_delete_messages,
handed_off=handed_off,
)
await request.reply_text(format_user_error_preview(e))
@ -240,7 +240,7 @@ class VoiceNoteFlow:
await self._clear_failed_pending_voice(
request,
status_msg_id_text,
queue_delete_message,
queue_delete_messages,
handed_off=handed_off,
)
if self._log_api_error_tracebacks:
@ -260,7 +260,7 @@ class VoiceNoteFlow:
self,
request: VoiceNoteRequest,
status_msg_id: str,
queue_delete_message: QueueDelete,
queue_delete_messages: QueueDeleteMany,
*,
handed_off: bool,
) -> None:
@ -271,7 +271,7 @@ class VoiceNoteFlow:
)
if not handed_off:
with contextlib.suppress(Exception):
await queue_delete_message(request.chat_id, status_msg_id)
await queue_delete_messages(request.chat_id, [status_msg_id])
def _log_transcription(self, request: VoiceNoteRequest, transcribed: str) -> None:
label = request.platform.upper()

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "free-claude-code"
version = "3.4.1"
version = "3.4.2"
description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM"
readme = "README.md"
requires-python = ">=3.14.0"

View file

@ -408,14 +408,6 @@ class FakePlatform:
) -> None:
await self.edit_message(chat_id, message_id, text, parse_mode=parse_mode)
async def queue_delete_message(
self,
chat_id: str,
message_id: str,
fire_and_forget: bool = True,
) -> None:
await self.delete_message(chat_id, message_id)
async def queue_delete_messages(
self,
chat_id: str,
@ -423,7 +415,7 @@ class FakePlatform:
fire_and_forget: bool = True,
) -> None:
for message_id in message_ids:
await self.queue_delete_message(chat_id, message_id, fire_and_forget=False)
await self.delete_message(chat_id, message_id)
def register_pending_voice(
self, chat_id: str, voice_message_id: str, status_message_id: str

View file

@ -51,7 +51,6 @@ def _fake_messaging_components(runtime: MagicMock | None = None) -> SimpleNamesp
outbound = MagicMock()
outbound.queue_send_message = AsyncMock(return_value="msg")
outbound.queue_edit_message = AsyncMock()
outbound.queue_delete_message = AsyncMock()
outbound.queue_delete_messages = AsyncMock()
outbound.fire_and_forget = MagicMock()
return SimpleNamespace(

View file

@ -116,16 +116,7 @@ def mock_platform():
platform.delete_message = AsyncMock()
platform.queue_send_message = AsyncMock(return_value="msg_123")
platform.queue_edit_message = AsyncMock()
platform.queue_delete_message = AsyncMock()
async def _queue_delete_messages(
chat_id: str, message_ids: list[str], *, fire_and_forget: bool = True
) -> None:
qdm = platform.queue_delete_message
for mid in message_ids:
await qdm(chat_id, mid, fire_and_forget=fire_and_forget)
platform.queue_delete_messages = AsyncMock(side_effect=_queue_delete_messages)
platform.queue_delete_messages = AsyncMock()
platform.cancel_pending_voice = AsyncMock(return_value=None)
def _fire_and_forget(task):

View file

@ -551,6 +551,12 @@ def test_messaging_platforms_use_shared_outbox_and_voice_flow() -> None:
assert (platforms_root / "ports.py").exists()
assert (platforms_root / "outbox.py").exists()
assert (platforms_root / "voice_flow.py").exists()
assert "def queue_delete_message(" not in (platforms_root / "ports.py").read_text(
encoding="utf-8"
)
assert "def queue_delete_message(" not in (platforms_root / "outbox.py").read_text(
encoding="utf-8"
)
for runtime in {
platforms_root / "telegram.py",
@ -568,6 +574,7 @@ def test_messaging_platforms_use_shared_outbox_and_voice_flow() -> None:
}:
text = messenger.read_text(encoding="utf-8")
assert "PlatformOutbox" in text
assert "def queue_delete_message(" not in text
def test_cli_surfaces_are_explicit_launchers_and_managed_claude() -> None:

View file

@ -558,11 +558,11 @@ async def test_handle_message_clear_command_stops_deletes_and_wipes_state(
events.append("stop")
return 0
async def _del(chat_id, message_id, fire_and_forget=True):
events.append(f"del:{chat_id}:{message_id}:{fire_and_forget}")
async def _del_many(chat_id, message_ids, fire_and_forget=True):
events.append(("del", chat_id, tuple(message_ids), fire_and_forget))
handler.stop_all_tasks = AsyncMock(side_effect=_stop)
mock_platform.queue_delete_message = AsyncMock(side_effect=_del)
mock_platform.queue_delete_messages = AsyncMock(side_effect=_del_many)
incoming = incoming_message_factory(
text="/clear", chat_id="chat_1", message_id="150"
@ -570,9 +570,9 @@ async def test_handle_message_clear_command_stops_deletes_and_wipes_state(
await handler.handle_message(incoming)
assert events and events[0] == "stop"
deleted_ids = {e.split(":")[2] for e in events[1:]}
deleted_ids = set(events[1][2])
assert deleted_ids == {"100", "101", "150"}
assert all(e.endswith(":False") for e in events[1:])
assert events[1][3] is False
mock_session_store.clear_all.assert_called_once()
assert handler.tree_queue.get_tree_count() == 0
@ -591,9 +591,9 @@ async def test_handle_message_clear_command_with_mention(
await handler.handle_message(incoming)
handler.stop_all_tasks.assert_called_once()
mock_platform.queue_delete_message.assert_called_once_with(
mock_platform.queue_delete_messages.assert_called_once_with(
"chat_1",
"10",
["10"],
fire_and_forget=False,
)
mock_session_store.clear_all.assert_called_once()
@ -611,8 +611,11 @@ async def test_handle_message_clear_command_deletes_message_log_ids(
)
await handler.handle_message(incoming)
deleted = {c.args[1] for c in mock_platform.queue_delete_message.call_args_list}
assert deleted == {"42", "43", "150"}
mock_platform.queue_delete_messages.assert_called_once_with(
"chat_1",
["150", "43", "42"],
fire_and_forget=False,
)
@pytest.mark.asyncio
@ -622,19 +625,21 @@ async def test_handle_message_clear_command_continues_after_delete_failure(
handler.stop_all_tasks = AsyncMock(return_value=0)
mock_session_store.get_message_ids_for_chat.return_value = ["41", "42", "43"]
async def delete_message(chat_id, message_id, fire_and_forget=True):
if message_id == "42":
raise RuntimeError("platform rejected delete")
async def delete_messages(chat_id, message_ids, fire_and_forget=True):
raise RuntimeError("platform rejected delete")
mock_platform.queue_delete_message = AsyncMock(side_effect=delete_message)
mock_platform.queue_delete_messages = AsyncMock(side_effect=delete_messages)
incoming = incoming_message_factory(
text="/clear", chat_id="chat_1", message_id="150"
)
await handler.handle_message(incoming)
deleted = [c.args[1] for c in mock_platform.queue_delete_message.call_args_list]
assert deleted == ["150", "43", "42", "41"]
mock_platform.queue_delete_messages.assert_called_once_with(
"chat_1",
["150", "43", "42", "41"],
fire_and_forget=False,
)
mock_session_store.clear_all.assert_called_once()
@ -666,10 +671,10 @@ async def test_handle_message_clear_command_reply_clears_branch(
deleted_ids = []
async def _capture_delete(chat_id, message_id, fire_and_forget=True):
deleted_ids.append(message_id)
async def _capture_delete(chat_id, message_ids, fire_and_forget=True):
deleted_ids.extend(message_ids)
mock_platform.queue_delete_message = AsyncMock(side_effect=_capture_delete)
mock_platform.queue_delete_messages = AsyncMock(side_effect=_capture_delete)
incoming = incoming_message_factory(
text="/clear",
@ -723,10 +728,10 @@ async def test_handle_message_clear_command_reply_to_root_clears_tree(
deleted_ids = []
async def _capture_delete(chat_id, message_id, fire_and_forget=True):
deleted_ids.append(message_id)
async def _capture_delete(chat_id, message_ids, fire_and_forget=True):
deleted_ids.extend(message_ids)
mock_platform.queue_delete_message = AsyncMock(side_effect=_capture_delete)
mock_platform.queue_delete_messages = AsyncMock(side_effect=_capture_delete)
incoming = incoming_message_factory(
text="/clear",
@ -800,13 +805,12 @@ async def test_handle_message_clear_command_reply_pending_voice_cancels(
return None
mock_platform.cancel_pending_voice = AsyncMock(side_effect=cancel_pending)
mock_platform.queue_delete_message = AsyncMock()
deleted_ids = []
async def _capture_delete(chat_id, message_id, fire_and_forget=True):
deleted_ids.append(message_id)
async def _capture_delete(chat_id, message_ids, fire_and_forget=True):
deleted_ids.extend(message_ids)
mock_platform.queue_delete_message = AsyncMock(side_effect=_capture_delete)
mock_platform.queue_delete_messages = AsyncMock(side_effect=_capture_delete)
incoming = incoming_message_factory(
text="/clear",

View file

@ -58,7 +58,6 @@ class TestMessagingPorts:
outbound = MagicMock()
outbound.queue_send_message = AsyncMock()
outbound.queue_edit_message = AsyncMock()
outbound.queue_delete_message = AsyncMock()
outbound.queue_delete_messages = AsyncMock()
outbound.fire_and_forget = MagicMock()
components = MessagingPlatformComponents(

View file

@ -5,7 +5,7 @@ import pytest
from messaging.platforms.outbox import PlatformOutbox
def _noop_outbox(*, limiter=None) -> PlatformOutbox:
def _noop_outbox(*, limiter=None, delete_many=None) -> PlatformOutbox:
async def send(
chat_id: str,
text: str,
@ -23,18 +23,14 @@ def _noop_outbox(*, limiter=None) -> PlatformOutbox:
) -> None:
return None
async def delete(chat_id: str, message_id: str) -> None:
return None
async def delete_many(chat_id: str, message_ids: list[str]) -> None:
async def default_delete_many(chat_id: str, message_ids: list[str]) -> None:
return None
return PlatformOutbox(
get_limiter=lambda: limiter,
send=send,
edit=edit,
delete=delete,
delete_many=delete_many,
delete_many=delete_many or default_delete_many,
)
@ -74,17 +70,6 @@ async def test_queue_edit_awaits_limiter_with_dedup_key() -> None:
await operation()
@pytest.mark.asyncio
async def test_queue_delete_uses_fire_and_forget_with_dedup_key() -> None:
limiter = MagicMock()
outbox = _noop_outbox(limiter=limiter)
await outbox.queue_delete_message("chat", "message", fire_and_forget=True)
limiter.fire_and_forget.assert_called_once()
assert limiter.fire_and_forget.call_args.kwargs["dedup_key"] == "del:chat:message"
@pytest.mark.asyncio
async def test_queue_delete_many_skips_empty_batches() -> None:
limiter = MagicMock()
@ -103,6 +88,26 @@ async def test_queue_delete_many_dedups_by_batch() -> None:
await outbox.queue_delete_messages("chat", ["1", "2"], fire_and_forget=True)
limiter.fire_and_forget.assert_called_once()
assert limiter.fire_and_forget.call_args.kwargs["dedup_key"] == (
f"del_bulk:chat:{hash(('1', '2'))}"
assert (
limiter.fire_and_forget.call_args.kwargs["dedup_key"]
== "del_bulk:chat:11f0530a8259fffb"
)
@pytest.mark.asyncio
async def test_queue_delete_many_snapshots_ids_before_queueing() -> None:
limiter = MagicMock()
deleted: list[list[str]] = []
async def delete_many(_chat_id: str, message_ids: list[str]) -> None:
deleted.append(message_ids)
outbox = _noop_outbox(limiter=limiter, delete_many=delete_many)
message_ids = ["1", "2"]
await outbox.queue_delete_messages("chat", message_ids, fire_and_forget=True)
message_ids.append("3")
operation = limiter.fire_and_forget.call_args.args[0]
await operation()
assert deleted == [["1", "2"]]

View file

@ -69,7 +69,7 @@ async def test_voice_flow_success_builds_incoming_message(monkeypatch) -> None:
_request(download_to=download_to),
message_handler=handler,
queue_send_message=queue_send,
queue_delete_message=queue_delete,
queue_delete_messages=queue_delete,
)
assert handled is True
@ -105,7 +105,7 @@ async def test_voice_flow_disabled_replies_without_transcribing(monkeypatch) ->
_request(reply_text=reply_text),
message_handler=AsyncMock(),
queue_send_message=AsyncMock(),
queue_delete_message=AsyncMock(),
queue_delete_messages=AsyncMock(),
)
assert handled is True
@ -134,12 +134,12 @@ async def test_voice_flow_cancelled_transcription_deletes_status(monkeypatch) ->
_request(),
message_handler=handler,
queue_send_message=queue_send,
queue_delete_message=queue_delete,
queue_delete_messages=queue_delete,
)
assert handled is True
handler.assert_not_awaited()
queue_delete.assert_awaited_once_with("chat", "status")
queue_delete.assert_awaited_once_with("chat", ["status"])
@pytest.mark.asyncio
@ -157,12 +157,12 @@ async def test_voice_flow_download_failure_cleans_pending_state(monkeypatch) ->
_request(download_to=failing_download, reply_text=reply_text),
message_handler=AsyncMock(),
queue_send_message=AsyncMock(return_value="status"),
queue_delete_message=queue_delete,
queue_delete_messages=queue_delete,
)
assert handled is True
transcribe.assert_not_awaited()
queue_delete.assert_awaited_once_with("chat", "status")
queue_delete.assert_awaited_once_with("chat", ["status"])
reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE)
assert await flow.cancel_pending_voice("chat", "voice") is None
@ -184,11 +184,11 @@ async def test_voice_flow_transcription_failure_cleans_pending_state(
_request(reply_text=reply_text),
message_handler=AsyncMock(),
queue_send_message=AsyncMock(return_value="status"),
queue_delete_message=queue_delete,
queue_delete_messages=queue_delete,
)
assert handled is True
queue_delete.assert_awaited_once_with("chat", "status")
queue_delete.assert_awaited_once_with("chat", ["status"])
reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE)
assert await flow.cancel_pending_voice("chat", "voice") is None
@ -213,7 +213,7 @@ async def test_voice_flow_handler_failure_cleans_pending_without_deleting_status
_request(reply_text=reply_text),
message_handler=failing_handler,
queue_send_message=AsyncMock(return_value="status"),
queue_delete_message=queue_delete,
queue_delete_messages=queue_delete,
)
assert handled is True

View file

@ -1,6 +1,7 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from telegram.error import TelegramError
from messaging.platforms.telegram import TelegramRuntime
@ -113,6 +114,132 @@ async def test_telegram_platform_edit_message_success(telegram_platform):
)
@pytest.mark.asyncio
async def test_telegram_platform_delete_messages_uses_batch_api(telegram_platform):
mock_bot = AsyncMock()
telegram_platform._application = MagicMock()
telegram_platform._application.bot = mock_bot
await telegram_platform.outbound.delete_messages("chat_1", ["1", "2", "bad"])
mock_bot.delete_messages.assert_awaited_once_with(
chat_id="chat_1",
message_ids=[1, 2],
)
mock_bot.delete_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_telegram_platform_delete_messages_chunks_batch_api(telegram_platform):
mock_bot = AsyncMock()
telegram_platform._application = MagicMock()
telegram_platform._application.bot = mock_bot
await telegram_platform.outbound.delete_messages(
"chat_1",
[str(i) for i in range(105)],
)
assert mock_bot.delete_messages.await_count == 2
assert mock_bot.delete_messages.await_args_list[0].kwargs["message_ids"] == list(
range(100)
)
assert mock_bot.delete_messages.await_args_list[1].kwargs["message_ids"] == list(
range(100, 105)
)
@pytest.mark.asyncio
async def test_telegram_platform_delete_messages_falls_back_without_batch(
telegram_platform,
):
class BotWithoutBatch:
def __init__(self) -> None:
self.delete_message = AsyncMock()
bot = BotWithoutBatch()
telegram_platform._application = MagicMock()
telegram_platform._application.bot = bot
await telegram_platform.outbound.delete_messages("chat_1", ["1", "2"])
assert bot.delete_message.await_args_list[0].kwargs == {
"chat_id": "chat_1",
"message_id": 1,
}
assert bot.delete_message.await_args_list[1].kwargs == {
"chat_id": "chat_1",
"message_id": 2,
}
@pytest.mark.asyncio
async def test_telegram_platform_delete_messages_falls_back_after_batch_failure(
telegram_platform,
):
mock_bot = AsyncMock()
mock_bot.delete_messages.side_effect = RuntimeError("bulk failed")
telegram_platform._application = MagicMock()
telegram_platform._application.bot = mock_bot
await telegram_platform.outbound.delete_messages("chat_1", ["1", "2"])
mock_bot.delete_messages.assert_awaited_once_with(
chat_id="chat_1",
message_ids=[1, 2],
)
assert mock_bot.delete_message.await_args_list[0].kwargs == {
"chat_id": "chat_1",
"message_id": 1,
}
assert mock_bot.delete_message.await_args_list[1].kwargs == {
"chat_id": "chat_1",
"message_id": 2,
}
@pytest.mark.asyncio
async def test_telegram_platform_delete_messages_falls_back_after_swallowed_error(
telegram_platform,
):
mock_bot = AsyncMock()
mock_bot.delete_messages.side_effect = TelegramError("message can't be deleted")
telegram_platform._application = MagicMock()
telegram_platform._application.bot = mock_bot
await telegram_platform.outbound.delete_messages("chat_1", ["1", "2"])
mock_bot.delete_messages.assert_awaited_once_with(
chat_id="chat_1",
message_ids=[1, 2],
)
assert mock_bot.delete_message.await_args_list[0].kwargs == {
"chat_id": "chat_1",
"message_id": 1,
}
assert mock_bot.delete_message.await_args_list[1].kwargs == {
"chat_id": "chat_1",
"message_id": 2,
}
@pytest.mark.asyncio
async def test_telegram_platform_single_delete_still_swallows_known_error(
telegram_platform,
):
mock_bot = AsyncMock()
mock_bot.delete_message.side_effect = TelegramError("message can't be deleted")
telegram_platform._application = MagicMock()
telegram_platform._application.bot = mock_bot
await telegram_platform.outbound.delete_message("chat_1", "1")
mock_bot.delete_message.assert_awaited_once_with(
chat_id="chat_1",
message_id=1,
)
@pytest.mark.asyncio
async def test_telegram_platform_queue_send_message(telegram_platform):
mock_limiter = AsyncMock()

View file

@ -113,6 +113,23 @@ async def test_with_retry_drops_parse_mode_on_markdown_entity_error():
assert calls == ["MarkdownV2", None]
@pytest.mark.asyncio
async def test_with_retry_can_raise_known_message_errors_for_bulk_fallback():
with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):
from messaging.platforms.telegram import TelegramRuntime
platform = TelegramRuntime(bot_token="t")
async def _f():
raise TelegramError("message can't be deleted")
with pytest.raises(TelegramError):
await platform.outbound._with_retry(
_f,
suppress_known_message_errors=False,
)
@pytest.mark.asyncio
async def test_queue_send_message_without_limiter_calls_send_message():
with patch("messaging.platforms.telegram.TELEGRAM_AVAILABLE", True):

2
uv.lock generated
View file

@ -561,7 +561,7 @@ wheels = [
[[package]]
name = "free-claude-code"
version = "3.4.1"
version = "3.4.2"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },