Skip to content

Commit 56d63b7

Browse files
authored
Merge pull request chigwell#245 from YorickLane/fix/list-messages-search-to-date
list_messages: start searches at to_date instead of the newest match
2 parents 9c96947 + 312cd00 commit 56d63b7

2 files changed

Lines changed: 145 additions & 2 deletions

File tree

‎telegram_mcp/tools/messages.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,9 +1058,17 @@ async def list_messages(
10581058
# Prepare filter parameters
10591059
params = {}
10601060
if search_query:
1061-
# IMPORTANT: Do not combine offset_date with search.
1062-
# Use server-side search alone, then enforce date bounds client-side.
1061+
# With search, Telethon sends offset_date as messages.search
1062+
# max_date ("sending date smaller than") on the first request only
1063+
# and pages by offset_id after that, so walking newest -> oldest
1064+
# starts at to_date instead of at the newest match. What must not
1065+
# be combined with search is reverse=True (max_date then cuts off
1066+
# the direction being walked). The client-side checks below stay
1067+
# as a safety net in case the server ignores max_date.
10631068
params["search"] = search_query
1069+
if to_date_obj:
1070+
# Next midnight exactly: whole seconds, so to_date stays inclusive.
1071+
params["offset_date"] = to_date_obj + timedelta(microseconds=1)
10641072
messages = []
10651073
async for msg in cl.iter_messages(entity, **params): # newest -> oldest
10661074
if to_date_obj and msg.date > to_date_obj:
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""list_messages with search_query + to_date must start the search at to_date.
2+
3+
Without a server-side bound, a search that should cover one old day walks
4+
every newer match first (newest -> oldest, 100 per request, 1s apart), which
5+
times out in busy groups long before reaching the requested day.
6+
"""
7+
8+
import json
9+
from datetime import datetime, timezone
10+
from types import SimpleNamespace
11+
from unittest.mock import AsyncMock
12+
13+
import pytest
14+
from telethon import TelegramClient
15+
from telethon.sessions import StringSession
16+
from telethon.tl import types
17+
from telethon.tl.functions.messages import SearchRequest
18+
19+
from telegram_mcp.tools import messages
20+
21+
NEXT_MIDNIGHT = datetime(2026, 9, 24, tzinfo=timezone.utc)
22+
23+
24+
def _msg(msg_id, date):
25+
return SimpleNamespace(
26+
id=msg_id, sender=None, sender_id=1, date=date, message="前端", reply_to=None
27+
)
28+
29+
30+
class _RecordingClient:
31+
def __init__(self, found):
32+
self.found = found
33+
self.calls = []
34+
35+
def iter_messages(self, entity, **kwargs):
36+
self.calls.append(kwargs)
37+
38+
async def _gen():
39+
for m in self.found:
40+
yield m
41+
42+
return _gen()
43+
44+
45+
@pytest.fixture
46+
def patch_client(monkeypatch):
47+
def _patch(client):
48+
monkeypatch.setattr(messages, "get_client", lambda account=None: client)
49+
monkeypatch.setattr(messages, "resolve_entity", AsyncMock(return_value=SimpleNamespace()))
50+
monkeypatch.setattr(messages, "get_marked_id", lambda entity: -1001)
51+
monkeypatch.setattr(messages.transcription, "prefetch_transcripts", AsyncMock())
52+
53+
return _patch
54+
55+
56+
@pytest.mark.asyncio
57+
async def test_search_with_to_date_starts_at_next_midnight(patch_client):
58+
client = _RecordingClient([_msg(7, datetime(2026, 9, 23, 23, 59, 59, tzinfo=timezone.utc))])
59+
patch_client(client)
60+
61+
result = await messages.list_messages(
62+
chat_id=-1001, search_query="前端", to_date="2026-09-23", limit=5, account=None
63+
)
64+
65+
assert client.calls == [{"search": "前端", "offset_date": NEXT_MIDNIGHT}]
66+
assert [r["id"] for r in json.loads(result)["results"]] == [7]
67+
68+
69+
@pytest.mark.asyncio
70+
async def test_search_without_to_date_has_no_offset_date(patch_client):
71+
client = _RecordingClient([_msg(7, datetime(2026, 9, 23, tzinfo=timezone.utc))])
72+
patch_client(client)
73+
74+
await messages.list_messages(
75+
chat_id=-1001, search_query="前端", from_date="2026-09-01", limit=5, account=None
76+
)
77+
78+
assert client.calls == [{"search": "前端"}]
79+
80+
81+
@pytest.mark.asyncio
82+
async def test_search_still_drops_newer_matches_if_server_ignores_max_date(patch_client):
83+
client = _RecordingClient(
84+
[
85+
_msg(9, datetime(2026, 9, 24, 0, 0, 0, tzinfo=timezone.utc)),
86+
_msg(8, datetime(2026, 9, 23, 12, 0, tzinfo=timezone.utc)),
87+
]
88+
)
89+
patch_client(client)
90+
91+
result = await messages.list_messages(
92+
chat_id=-1001, search_query="前端", to_date="2026-09-23", limit=5, account=None
93+
)
94+
95+
assert [r["id"] for r in json.loads(result)["results"]] == [8]
96+
97+
98+
class _CapturingTelethonClient(TelegramClient):
99+
"""Real Telethon request building; only the network call is replaced."""
100+
101+
def __init__(self):
102+
super().__init__(StringSession(), 1, "0" * 32)
103+
self.requests = []
104+
105+
async def __call__(self, request, ordered=False, flood_sleep_threshold=None):
106+
self.requests.append(
107+
(
108+
type(request),
109+
getattr(request, "max_date", None),
110+
getattr(request, "offset_id", None),
111+
)
112+
)
113+
peer = types.PeerChannel(1001)
114+
if len(self.requests) == 1:
115+
batch = [
116+
types.Message(id=500 - i, peer_id=peer, date=NEXT_MIDNIGHT, message="x")
117+
for i in range(100)
118+
]
119+
return types.messages.MessagesSlice(
120+
count=1000, messages=batch, topics=[], chats=[], users=[]
121+
)
122+
return types.messages.MessagesSlice(count=1000, messages=[], topics=[], chats=[], users=[])
123+
124+
125+
@pytest.mark.asyncio
126+
async def test_telethon_sends_offset_date_as_search_max_date_on_first_request_only():
127+
client = _CapturingTelethonClient()
128+
entity = types.InputPeerChannel(channel_id=1001, access_hash=1)
129+
130+
async for _ in client.iter_messages(entity, search="前端", offset_date=NEXT_MIDNIGHT):
131+
pass
132+
133+
first, second = client.requests
134+
assert first == (SearchRequest, NEXT_MIDNIGHT, 0)
135+
assert second == (SearchRequest, None, 401)

0 commit comments

Comments
 (0)