Skip to content

Commit 2816a4e

Browse files
authored
Merge pull request #130 from artgas1/feat/event-driven-wait-tools
feat: event-driven wait_for_new_message + debounced wait_for_settled_message
2 parents 4e16be8 + e9f83a0 commit 2816a4e

2 files changed

Lines changed: 197 additions & 0 deletions

File tree

telegram_mcp/tools/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@
88
from telegram_mcp.tools.media import *
99
from telegram_mcp.tools.profile import *
1010
from telegram_mcp.tools.folders import *
11+
from telegram_mcp.tools.events import *
1112

1213
__all__ = [name for name in globals() if not name.startswith("_")]

telegram_mcp/tools/events.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""Event-driven incoming-message tracking + debounce (settle window).
2+
3+
Lets agents react to new client messages instead of polling. A Telethon
4+
NewMessage(incoming=True) handler records incoming private (non-bot, non-self)
5+
messages per chat; the two tools below expose them, with wait_for_settled_message
6+
debouncing a burst (several messages typed in a row) into a single settled event.
7+
"""
8+
9+
import asyncio
10+
import json
11+
import time
12+
import logging
13+
from typing import Any, Dict, Optional
14+
15+
from telethon import events as _events
16+
from telethon import utils
17+
18+
from telegram_mcp.runtime import * # mcp, clients, ToolAnnotations, log_and_format_error
19+
20+
# chat_id -> {first_ts, last_ts, count, first_id, last_id, name, username}
21+
_pending_msgs: Dict[int, Dict[str, Any]] = {}
22+
_activity_event: Optional[asyncio.Event] = None
23+
24+
25+
def _get_activity_event() -> asyncio.Event:
26+
"""Lazily create the asyncio.Event on the running loop."""
27+
global _activity_event
28+
if _activity_event is None:
29+
_activity_event = asyncio.Event()
30+
return _activity_event
31+
32+
33+
async def _on_new_incoming(event) -> None:
34+
"""Record incoming private (non-bot, non-self) messages for the debounce tools."""
35+
try:
36+
if not event.is_private:
37+
return
38+
sender = await event.get_sender()
39+
if sender is None:
40+
return
41+
if getattr(sender, "bot", False) or getattr(sender, "is_self", False):
42+
return
43+
chat_id = event.chat_id
44+
now = time.time()
45+
msg_id = event.message.id
46+
rec = _pending_msgs.get(chat_id)
47+
if rec is None:
48+
_pending_msgs[chat_id] = {
49+
"first_ts": now,
50+
"last_ts": now,
51+
"count": 1,
52+
"first_id": msg_id,
53+
"last_id": msg_id,
54+
"name": utils.get_display_name(sender) or str(chat_id),
55+
"username": getattr(sender, "username", None),
56+
}
57+
else:
58+
rec["last_ts"] = now
59+
rec["last_id"] = msg_id
60+
rec["count"] += 1
61+
_get_activity_event().set()
62+
except Exception:
63+
logging.getLogger("telegram_mcp").exception("error in _on_new_incoming")
64+
65+
66+
def register_incoming_handlers() -> None:
67+
"""Attach the incoming-message handler to every configured client.
68+
69+
Safe to call before clients connect — Telethon registers the handler and
70+
delivers events once connected. Called at import time so the package's
71+
`import telegram_mcp.tools` registration also wires up the listener.
72+
"""
73+
for cl in clients.values():
74+
try:
75+
cl.add_event_handler(_on_new_incoming, _events.NewMessage(incoming=True))
76+
except Exception:
77+
logging.getLogger("telegram_mcp").exception("failed to register incoming handler")
78+
79+
80+
@mcp.tool(
81+
annotations=ToolAnnotations(
82+
title="Wait For New Message", openWorldHint=True, readOnlyHint=True
83+
)
84+
)
85+
async def wait_for_new_message(timeout: float = 50.0) -> str:
86+
"""
87+
Block until a new incoming private message from a non-bot user arrives, then
88+
return immediately with the list of chats that currently have pending
89+
(unprocessed) incoming messages. If nothing arrives within `timeout` seconds,
90+
returns {"event": false, "reason": "timeout"}. Lets the agent react to events
91+
instead of polling. Does NOT consume the pending set — use
92+
wait_for_settled_message to consume a debounced burst.
93+
94+
Args:
95+
timeout: Max seconds to block (default 50).
96+
"""
97+
try:
98+
ev = _get_activity_event()
99+
if not _pending_msgs:
100+
ev.clear()
101+
try:
102+
await asyncio.wait_for(ev.wait(), timeout=timeout)
103+
except asyncio.TimeoutError:
104+
return json.dumps({"event": False, "reason": "timeout"}, ensure_ascii=False)
105+
chats = [
106+
{
107+
"chat_id": cid,
108+
"name": rec["name"],
109+
"username": rec["username"],
110+
"count": rec["count"],
111+
"last_message_id": rec["last_id"],
112+
}
113+
for cid, rec in _pending_msgs.items()
114+
]
115+
return json.dumps({"event": True, "pending_chats": chats}, ensure_ascii=False)
116+
except Exception as e:
117+
return log_and_format_error("wait_for_new_message", e)
118+
119+
120+
@mcp.tool(
121+
annotations=ToolAnnotations(
122+
title="Wait For Settled Message", openWorldHint=True, readOnlyHint=True
123+
)
124+
)
125+
async def wait_for_settled_message(settle_ms: int = 6000, max_wait_ms: int = 50000) -> str:
126+
"""
127+
Event-driven, DEBOUNCED wait. Blocks until some private user chat has received
128+
one or more incoming messages AND then gone quiet for `settle_ms` — so a client
129+
who types several messages (or sends file + text) in a row is delivered as ONE
130+
settled burst instead of waking the agent on every message. Returns that chat's
131+
burst summary and removes it from the pending set, so the next call returns the
132+
next settled chat. If no chat settles within `max_wait_ms`, returns
133+
{"event": false, "reason": "timeout"} (caller should simply call again).
134+
135+
Recommended usage (replaces blind per-minute polling): call this, get a settled
136+
chat, process it (read full history -> draft -> notify -> mark read), call again.
137+
138+
Args:
139+
settle_ms: Quiet period after the LAST message before a burst is "settled"
140+
(default 6000 = 6s). Each new message in the chat resets this timer.
141+
max_wait_ms: Max total time to block before returning a timeout (default 50000).
142+
"""
143+
try:
144+
settle = settle_ms / 1000.0
145+
deadline = time.time() + max_wait_ms / 1000.0
146+
ev = _get_activity_event()
147+
while True:
148+
now = time.time()
149+
settled_cid = None
150+
soonest_remaining = None
151+
for cid, rec in list(_pending_msgs.items()):
152+
quiet = now - rec["last_ts"]
153+
if quiet >= settle:
154+
settled_cid = cid
155+
break
156+
rem = settle - quiet
157+
if soonest_remaining is None or rem < soonest_remaining:
158+
soonest_remaining = rem
159+
if settled_cid is not None:
160+
rec = _pending_msgs.pop(settled_cid)
161+
return json.dumps(
162+
{
163+
"event": True,
164+
"chat_id": settled_cid,
165+
"name": rec["name"],
166+
"username": rec["username"],
167+
"message_count": rec["count"],
168+
"first_message_id": rec["first_id"],
169+
"last_message_id": rec["last_id"],
170+
"burst_seconds": round(rec["last_ts"] - rec["first_ts"], 2),
171+
},
172+
ensure_ascii=False,
173+
)
174+
remaining_total = deadline - now
175+
if remaining_total <= 0:
176+
return json.dumps({"event": False, "reason": "timeout"}, ensure_ascii=False)
177+
if soonest_remaining is not None:
178+
# A chat is pending but not yet quiet — sleep until it would settle,
179+
# then re-check (a new message meanwhile resets its timer).
180+
await asyncio.sleep(min(soonest_remaining, remaining_total))
181+
else:
182+
# Nothing pending — block on new activity until deadline.
183+
ev.clear()
184+
try:
185+
await asyncio.wait_for(ev.wait(), timeout=remaining_total)
186+
except asyncio.TimeoutError:
187+
return json.dumps({"event": False, "reason": "timeout"}, ensure_ascii=False)
188+
except Exception as e:
189+
return log_and_format_error("wait_for_settled_message", e)
190+
191+
192+
# Wire up the listener as soon as this module is imported (alongside tool registration).
193+
register_incoming_handlers()
194+
195+
196+
__all__ = ["wait_for_new_message", "wait_for_settled_message", "register_incoming_handlers"]

0 commit comments

Comments
 (0)