Skip to content

Commit 87c6171

Browse files
ex3liteclaude
andcommitted
fix: address review on chigwell#176 — edit parse_mode regression, feed file mode and location
- edit_message: only forward parse_mode when the caller set it. Telethon treats an explicit None as "disable parsing" while an omitted argument uses its default parser, so passing None unconditionally turned previously formatted edits (**bold**) into literal text. - Feed file is now opened via os.open(O_CREAT|O_APPEND, 0o600) with an fchmod fallback, so an existing or externally rotated 0644 file is tightened too — Path.touch(mode=...) only applies its mode on creation. - Feed default path no longer derives from __file__ (unwritable under a read-only site-packages or container layer): it follows XDG state (${XDG_STATE_HOME:-~/.local/state}/telegram-mcp/incoming_feed.jsonl) and creates that directory. An explicit TELEGRAM_EVENT_FEED_FILE still must point at an existing directory so typos fail loudly. Tests: 6 new (default location, dir creation, 0600 on create/existing/ rotated, parse_mode forwarding); suite 200 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3c7c351 commit 87c6171

5 files changed

Lines changed: 133 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ By default, an agent waits for replies by calling `wait_for_settled_message`, wh
6060

6161
Clients that can wake an agent on external output (Claude Code's persistent `Monitor` on `tail -f`) can switch to callback mode instead:
6262

63-
1. The agent calls `enable_incoming_feed` (or set `TELEGRAM_EVENT_FEED=1` in the environment to auto-enable). Each settled incoming burst is appended as one JSON line to `incoming_feed.jsonl` (path configurable via `TELEGRAM_EVENT_FEED_FILE`).
63+
1. The agent calls `enable_incoming_feed` (or set `TELEGRAM_EVENT_FEED=1` in the environment to auto-enable). Each settled incoming burst is appended as one JSON line to `${XDG_STATE_HOME:-~/.local/state}/telegram-mcp/incoming_feed.jsonl`, created owner-only (0600). Override the path with `TELEGRAM_EVENT_FEED_FILE` — an explicit path's directory must already exist. `incoming_feed_status` reports the effective path and a ready-to-use watch command.
6464
2. The agent arms a persistent Monitor with the `watch_command` returned by the tool. Every new line re-invokes the agent with the burst summary; no blocking tool call is held open, and the chat stays free.
6565

6666
`disable_incoming_feed` switches back; `incoming_feed_status` reports the current mode. While the feed is enabled it consumes settled bursts, so don't combine it with `wait_for_settled_message`. Feed lines contain user-generated `name` fields — treat them as untrusted data.

telegram_mcp/tools/events.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import json
1111
import os
1212
import shlex
13+
import stat
1314
import time
1415
import logging
1516
from pathlib import Path
@@ -29,7 +30,7 @@
2930
# JSONL lines to the feed file, so an external watcher (e.g. Claude Code's
3031
# Monitor on `tail -f`) can wake an agent per event instead of the agent
3132
# holding a blocking wait_for_settled_message call open.
32-
_FEED_DEFAULT = Path(__file__).resolve().parent.parent.parent / "incoming_feed.jsonl"
33+
_FEED_FILE_ENV = "TELEGRAM_EVENT_FEED_FILE"
3334
_feed_task: Optional[asyncio.Task] = None
3435
_feed_settle_ms: int = 6000
3536
_feed_autostart_done: bool = False
@@ -75,17 +76,50 @@ def _burst_summary(chat_id: int, rec: Dict[str, Any]) -> Dict[str, Any]:
7576
}
7677

7778

79+
def _default_feed_file() -> Path:
80+
"""Runtime data location, never the install directory.
81+
82+
The package may live in a read-only site-packages or container layer, so the
83+
default feed path follows the XDG state convention instead of `__file__`.
84+
"""
85+
base = os.getenv("XDG_STATE_HOME") or Path.home() / ".local" / "state"
86+
return Path(base) / "telegram-mcp" / "incoming_feed.jsonl"
87+
88+
7889
def feed_file_path() -> Path:
79-
return Path(os.getenv("TELEGRAM_EVENT_FEED_FILE", str(_FEED_DEFAULT)))
90+
override = os.getenv(_FEED_FILE_ENV)
91+
return Path(override) if override else _default_feed_file()
8092

8193

8294
def feed_enabled() -> bool:
8395
return _feed_task is not None and not _feed_task.done()
8496

8597

98+
def _open_feed_append():
99+
"""Append-open the feed file, enforcing 0600 — it holds private contact metadata.
100+
101+
`Path.touch(mode=...)` only applies its mode when creating, so an existing or
102+
externally rotated 0644 file keeps its permissions; fchmod on the open
103+
descriptor fixes that without a TOCTOU window.
104+
"""
105+
path = feed_file_path()
106+
if not os.getenv(_FEED_FILE_ENV):
107+
# Only auto-create the directory we own; an explicit path must exist so a
108+
# typo fails loudly instead of scattering directories.
109+
path.parent.mkdir(parents=True, exist_ok=True)
110+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
111+
try:
112+
if stat.S_IMODE(os.fstat(fd).st_mode) != 0o600:
113+
os.fchmod(fd, 0o600)
114+
except OSError:
115+
os.close(fd)
116+
raise
117+
return os.fdopen(fd, "a", encoding="utf-8")
118+
119+
86120
def _touch_feed_file() -> None:
87-
"""Create the feed file owner-only (0600) if missing; contact metadata is private."""
88-
feed_file_path().touch(mode=0o600, exist_ok=True)
121+
"""Create (or fix the mode of) the feed file before starting the consumer."""
122+
_open_feed_append().close()
89123

90124

91125
async def _feed_loop(settle_ms: int) -> None:
@@ -100,7 +134,7 @@ async def _feed_loop(settle_ms: int) -> None:
100134
line = dict(_burst_summary(settled_cid, rec), ts=round(time.time(), 2))
101135
del line["event"]
102136
# ponytail: append-only file; rotate it manually (tail -F survives rotation)
103-
with open(feed_file_path(), "a", encoding="utf-8") as f:
137+
with _open_feed_append() as f:
104138
f.write(json.dumps(line, ensure_ascii=False) + "\n")
105139
# Pop only after a successful write (no await in between, so no
106140
# consumer can observe the burst twice); a write failure retries

telegram_mcp/tools/messages.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1144,13 +1144,20 @@ async def edit_message(
11441144
'html', or 'rich'/'rich_markdown'/'rich_html' for full server-side formatting
11451145
(tables, headings, formulas; REQUIRES Telegram Premium — without it nothing is
11461146
changed and a structured telegram_premium_required result is returned).
1147+
Omitting it keeps the previous behavior of this tool: Telethon's client
1148+
default (Markdown), so **bold** in existing edits still renders.
11471149
"""
11481150
try:
11491151
cl = get_client(account)
11501152
entity = await resolve_entity(chat_id, cl)
11511153
if parse_mode and parse_mode.lower() in RICH_PARSE_MODES:
11521154
return await _edit_rich(cl, entity, message_id, new_text, parse_mode.lower())
1153-
await cl.edit_message(entity, message_id, new_text, parse_mode=parse_mode)
1155+
# Only pass parse_mode when the caller set it: Telethon treats an explicit
1156+
# None as "disable parsing", while omitting the argument uses its default
1157+
# parser. Passing None unconditionally would turn previously formatted
1158+
# edits into literal text.
1159+
extra = {"parse_mode": parse_mode} if parse_mode is not None else {}
1160+
await cl.edit_message(entity, message_id, new_text, **extra)
11541161
return f"Message {message_id} edited."
11551162
except Exception as e:
11561163
return log_and_format_error(

tests/test_event_feed.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import asyncio
44
import json
5+
import os
6+
import stat
57
import time
8+
from pathlib import Path
69

710
import pytest
811

@@ -172,6 +175,58 @@ async def test_write_failure_retains_burst(monkeypatch, tmp_path):
172175
assert 42 in events._pending_msgs # not silently destroyed
173176

174177

178+
def test_default_feed_path_is_runtime_state_not_install_dir(monkeypatch, tmp_path):
179+
monkeypatch.delenv("TELEGRAM_EVENT_FEED_FILE", raising=False)
180+
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path))
181+
182+
path = events.feed_file_path()
183+
184+
assert path == tmp_path / "telegram-mcp" / "incoming_feed.jsonl"
185+
assert Path(events.__file__).parent not in path.parents
186+
187+
188+
def test_default_feed_path_creates_its_directory(monkeypatch, tmp_path):
189+
monkeypatch.delenv("TELEGRAM_EVENT_FEED_FILE", raising=False)
190+
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "fresh"))
191+
192+
events._touch_feed_file()
193+
194+
assert events.feed_file_path().exists()
195+
196+
197+
def test_feed_file_created_owner_only():
198+
events._touch_feed_file()
199+
mode = stat.S_IMODE(events.feed_file_path().stat().st_mode)
200+
assert mode == 0o600
201+
202+
203+
def test_existing_world_readable_feed_file_is_tightened():
204+
path = events.feed_file_path()
205+
path.touch()
206+
os.chmod(path, 0o644)
207+
208+
events._touch_feed_file()
209+
210+
assert stat.S_IMODE(path.stat().st_mode) == 0o600
211+
212+
213+
@pytest.mark.asyncio
214+
async def test_rotated_world_readable_file_is_tightened_on_write():
215+
path = events.feed_file_path()
216+
path.touch()
217+
os.chmod(path, 0o644)
218+
events._pending_msgs[42] = _pending_record(_mono(1.0))
219+
events._start_feed(settle_ms=50)
220+
221+
for _ in range(50):
222+
await asyncio.sleep(0.02)
223+
if not events._pending_msgs:
224+
break
225+
226+
assert stat.S_IMODE(path.stat().st_mode) == 0o600
227+
assert path.read_text(encoding="utf-8").strip()
228+
229+
175230
@pytest.mark.asyncio
176231
async def test_status_reports_autostart_pending(monkeypatch):
177232
monkeypatch.setenv("TELEGRAM_EVENT_FEED", "on") # repo-style bool value

tests/test_rich_messages.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,36 @@ async def test_send_rich_other_rpc_error_propagates():
7575
await messages._send_rich(cl, "peer", "x", "rich")
7676

7777

78+
class _EditRecorder:
79+
"""Records how edit_message forwards parse_mode to Telethon."""
80+
81+
def __init__(self):
82+
self.calls = []
83+
84+
async def edit_message(self, entity, message_id, text, **kwargs):
85+
self.calls.append(kwargs)
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_edit_message_omits_parse_mode_when_not_given(monkeypatch):
90+
# Telethon treats an explicit None as "disable parsing" while an omitted
91+
# argument uses its default parser, so callers who never passed parse_mode
92+
# must keep getting formatted edits.
93+
cl = _EditRecorder()
94+
monkeypatch.setattr(messages, "get_client", lambda account=None: cl)
95+
96+
async def fake_resolve(chat_id, client=None):
97+
return "entity"
98+
99+
monkeypatch.setattr(messages, "resolve_entity", fake_resolve)
100+
101+
await messages.edit_message(chat_id=1, message_id=2, new_text="**bold**")
102+
assert cl.calls == [{}]
103+
104+
await messages.edit_message(chat_id=1, message_id=2, new_text="x", parse_mode="html")
105+
assert cl.calls[-1] == {"parse_mode": "html"}
106+
107+
78108
@pytest.mark.asyncio
79109
async def test_edit_rich_both_premium_cases():
80110
ok = _FakeClient(premium=True)

0 commit comments

Comments
 (0)