Skip to content

Commit e6dd352

Browse files
authored
Merge pull request chigwell#259 from vsem-azamat/fix/non-interactive-reconnect
fix(runtime): don't start interactive login on reconnect; add optional TELEGRAM_EXPECTED_USERNAME check
2 parents efb2187 + f360d80 commit e6dd352

7 files changed

Lines changed: 222 additions & 6 deletions

File tree

‎.env.example‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ TELEGRAM_SESSION_NAME=telegram_session
5050
# TELEGRAM_SYSTEM_VERSION=1.0
5151
# TELEGRAM_APP_VERSION=1.0
5252

53+
# --- Expected account check (optional) ---
54+
# Refuse to start if the session belongs to another account. _<LABEL> overrides.
55+
# TELEGRAM_EXPECTED_USERNAME=my_username
56+
# TELEGRAM_EXPECTED_USERNAME_WORK=my_work_username
57+
5358
# --- MCP tool exposure (optional) ---
5459
# Default is all. Set to read-only to expose only tools annotated with
5560
# readOnlyHint=True through MCP. This does not reduce Telegram session authority

‎README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Message sent successfully:
3232
- [MCP Client Configuration](#mcp-client-configuration)
3333
- [Multi-Account Setup](#multi-account-setup)
3434
- [Device Identity](#device-identity)
35+
- [Expected Account Check](#expected-account-check)
3536
- [Proxy Support](#proxy-support)
3637
- [File Path Security](#file-path-security)
3738
- [Chat Access Privacy (Allowlist)](#chat-access-privacy-allowlist)
@@ -567,6 +568,10 @@ set them to keep a stable, recognisable device name. The same variables are
567568
read both by the session string generator (at login) and by the server (on
568569
every connect), so set them in the same place as your other credentials.
569570

571+
## Expected Account Check
572+
573+
Set `TELEGRAM_EXPECTED_USERNAME` (or `TELEGRAM_EXPECTED_USERNAME_<LABEL>` per account) and the server refuses to start when the session belongs to a different account. The comparison is case-insensitive and ignores a leading `@`. Unset means no check.
574+
570575
## Proxy Support
571576

572577
Route Telegram traffic through a proxy by setting the `TELEGRAM_PROXY_*`
@@ -842,7 +847,8 @@ Telegram messages, display names, chat titles, and button labels are untrusted c
842847
the MCP server when you can scan from an existing Telegram app, or
843848
`uv run session_string_generator.py --phone` when you need phone-code login.
844849
Then set `TELEGRAM_SESSION_STRING` in `.env`. The MCP server does not perform
845-
interactive phone-code login over stdio.
850+
interactive phone-code login over stdio, including after a reconnect: a session
851+
terminated while the server runs fails with "not authorized after reconnect".
846852
- **Invalid API credentials:** verify `TELEGRAM_API_ID` and `TELEGRAM_API_HASH` at [my.telegram.org/apps](https://my.telegram.org/apps).
847853
- **Database is locked:** make sure no other process is using the same file session.
848854
- **`AuthKeyDuplicatedError` / "Another telegram-mcp process is already connected with this session":** two processes tried to connect the same Telegram session at once (e.g. an MCP client restarted the connector before the old process exited), which Telegram rejects and can invalidate the session for both. The server now takes an exclusive lock per session before connecting; a second concurrent launch waits briefly (default 20s, override with `TELEGRAM_LOCK_GRACE_SECONDS`) for the first to release it and otherwise exits without ever calling `connect()`, instead of racing into a duplicate connection. Retry once only one instance is running — the refusal names the PID holding the lock. If several instances on this host are meant to share one session (all reaching Telegram from the same IP), set `TELEGRAM_SESSION_LOCK=shared`; see [Sharing one session from one host](#sharing-one-session-from-one-host).

‎telegram_mcp/runner.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,44 @@ def _session_lock_shared() -> bool:
6363
return raw == "shared"
6464

6565

66+
def _normalize_username(value) -> str:
67+
return (value or "").strip().lstrip("@").casefold()
68+
69+
70+
def _expected_username(label: str) -> str:
71+
"""TELEGRAM_EXPECTED_USERNAME_<LABEL>, else TELEGRAM_EXPECTED_USERNAME; "" if unset."""
72+
raw = os.getenv(f"TELEGRAM_EXPECTED_USERNAME_{label.upper()}") or os.getenv(
73+
"TELEGRAM_EXPECTED_USERNAME"
74+
)
75+
return _normalize_username(raw)
76+
77+
78+
async def _verify_expected_username(label: str, client) -> None:
79+
"""Refuse to serve a session logged in to a different account."""
80+
expected = _expected_username(label)
81+
if not expected:
82+
return
83+
84+
me = await client.get_me()
85+
usernames = {_normalize_username(getattr(me, "username", None))}
86+
for entry in getattr(me, "usernames", None) or []:
87+
if getattr(entry, "active", False):
88+
usernames.add(_normalize_username(getattr(entry, "username", None)))
89+
usernames.discard("")
90+
91+
if expected in usernames:
92+
return
93+
94+
try:
95+
await client.disconnect()
96+
except Exception:
97+
pass
98+
raise RuntimeError(
99+
f"Telegram client '{label}' is logged in to a different account than "
100+
f"TELEGRAM_EXPECTED_USERNAME_{label.upper()} / TELEGRAM_EXPECTED_USERNAME expects."
101+
)
102+
103+
66104
async def _connect_authorized_client(label, client) -> None:
67105
# First, prevent our own duplicate-spawn case outright: a per-session lock
68106
# means a second instance of this server never even attempts to connect
@@ -106,6 +144,7 @@ async def _connect_authorized_client(label, client) -> None:
106144
await asyncio.sleep(delay)
107145

108146
if await client.is_user_authorized():
147+
await _verify_expected_username(label, client)
109148
return
110149

111150
raise RuntimeError(

‎telegram_mcp/runtime.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -813,8 +813,16 @@ async def _force_reconnect(cl: TelegramClient):
813813
f"Reconnecting to Telegram timed out after {_RECONNECT_TIMEOUT:.0f}s."
814814
) from exc
815815
if not await cl.is_user_authorized():
816-
reconnect_logger.warning("Client not authorized after reconnect, calling start()...")
817-
await asyncio.wait_for(cl.start(), timeout=_RECONNECT_TIMEOUT)
816+
# cl.start() would prompt via blocking input(): it stalls the event loop
817+
# and, over stdio, reads protocol frames as a phone number.
818+
try:
819+
await cl.disconnect()
820+
except Exception:
821+
pass
822+
raise RuntimeError(
823+
"Telegram session is not authorized after reconnect. Re-authorize it "
824+
"outside the server (session_string_generator.py) and restart."
825+
)
818826
_last_conn_verified[id(cl)] = time.time()
819827
reconnect_logger.warning("Forced reconnect successful")
820828

‎tests/conftest.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,11 @@ def transcript_cache_dir(tmp_path, monkeypatch):
1515
d = tmp_path / "transcripts"
1616
monkeypatch.setenv("TELEGRAM_TRANSCRIPT_CACHE_DIR", str(d))
1717
return d
18+
19+
20+
@pytest.fixture(autouse=True)
21+
def _clear_expected_username(monkeypatch):
22+
"""Keep a developer's TELEGRAM_EXPECTED_USERNAME* (.env) away from fake clients."""
23+
for key in list(os.environ):
24+
if key.startswith("TELEGRAM_EXPECTED_USERNAME"):
25+
monkeypatch.delenv(key, raising=False)

‎tests/test_runner.py‎

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
import sys
3+
from types import SimpleNamespace
34

45
import pytest
56

@@ -16,18 +17,36 @@ def save(self):
1617

1718

1819
class _FakeClient:
19-
def __init__(self, *, authorized: bool, identity: str = "test-identity"):
20+
def __init__(
21+
self,
22+
*,
23+
authorized: bool,
24+
identity: str = "test-identity",
25+
username: str | None = "work_account",
26+
usernames: list | None = None,
27+
):
2028
self.authorized = authorized
2129
self.connected = False
30+
self.disconnected = False
2231
self.started = False
32+
self.get_me_calls = 0
33+
self.username = username
34+
self.usernames = usernames
2335
self.session = _FakeSession(identity)
2436

2537
async def connect(self):
2638
self.connected = True
2739

40+
async def disconnect(self):
41+
self.disconnected = True
42+
2843
async def is_user_authorized(self):
2944
return self.authorized
3045

46+
async def get_me(self):
47+
self.get_me_calls += 1
48+
return SimpleNamespace(username=self.username, usernames=self.usernames)
49+
3150
async def start(self):
3251
self.started = True
3352

@@ -75,6 +94,110 @@ async def test_connect_authorized_client_rejects_unauthorized_session():
7594
assert client.started is False
7695

7796

97+
@pytest.mark.asyncio
98+
async def test_connect_authorized_client_skips_identity_check_when_unset():
99+
client = _FakeClient(authorized=True, username="anyone")
100+
101+
await runner._connect_authorized_client("default", client)
102+
103+
assert client.get_me_calls == 0
104+
assert client.disconnected is False
105+
106+
107+
@pytest.mark.asyncio
108+
async def test_connect_authorized_client_accepts_expected_username(monkeypatch):
109+
monkeypatch.setenv("TELEGRAM_EXPECTED_USERNAME", " @Work_Account ")
110+
client = _FakeClient(authorized=True, username="work_account")
111+
112+
await runner._connect_authorized_client("default", client)
113+
114+
assert client.get_me_calls == 1
115+
assert client.disconnected is False
116+
117+
118+
@pytest.mark.asyncio
119+
async def test_connect_authorized_client_rejects_wrong_account(monkeypatch):
120+
monkeypatch.setenv("TELEGRAM_EXPECTED_USERNAME", "work_account")
121+
client = _FakeClient(authorized=True, username="personal_account")
122+
123+
with pytest.raises(RuntimeError, match="different account") as excinfo:
124+
await runner._connect_authorized_client("default", client)
125+
126+
assert "TELEGRAM_EXPECTED_USERNAME" in str(excinfo.value)
127+
# The other account's username is not echoed into logs.
128+
assert "personal_account" not in str(excinfo.value)
129+
assert client.disconnected is True
130+
131+
132+
@pytest.mark.asyncio
133+
async def test_connect_authorized_client_rejects_account_without_username(monkeypatch):
134+
monkeypatch.setenv("TELEGRAM_EXPECTED_USERNAME", "work_account")
135+
client = _FakeClient(authorized=True, username=None)
136+
137+
with pytest.raises(RuntimeError, match="different account"):
138+
await runner._connect_authorized_client("default", client)
139+
140+
assert client.disconnected is True
141+
142+
143+
@pytest.mark.asyncio
144+
async def test_connect_authorized_client_accepts_active_collectible_username(monkeypatch):
145+
monkeypatch.setenv("TELEGRAM_EXPECTED_USERNAME", "collectible")
146+
client = _FakeClient(
147+
authorized=True,
148+
username=None,
149+
usernames=[
150+
SimpleNamespace(username="inactive", active=False),
151+
SimpleNamespace(username="Collectible", active=True),
152+
],
153+
)
154+
155+
await runner._connect_authorized_client("default", client)
156+
157+
assert client.disconnected is False
158+
159+
160+
@pytest.mark.asyncio
161+
async def test_connect_authorized_client_ignores_inactive_collectible_username(monkeypatch):
162+
monkeypatch.setenv("TELEGRAM_EXPECTED_USERNAME", "inactive")
163+
client = _FakeClient(
164+
authorized=True,
165+
username="primary",
166+
usernames=[SimpleNamespace(username="inactive", active=False)],
167+
)
168+
169+
with pytest.raises(RuntimeError, match="different account"):
170+
await runner._connect_authorized_client("default", client)
171+
172+
173+
@pytest.mark.asyncio
174+
async def test_connect_authorized_client_per_label_expected_username(monkeypatch):
175+
monkeypatch.setenv("TELEGRAM_EXPECTED_USERNAME", "personal_account")
176+
monkeypatch.setenv("TELEGRAM_EXPECTED_USERNAME_WORK", "work_account")
177+
work = _FakeClient(authorized=True, identity="session-work", username="work_account")
178+
personal = _FakeClient(
179+
authorized=True, identity="session-personal", username="personal_account"
180+
)
181+
182+
await runner._connect_authorized_client("work", work)
183+
await runner._connect_authorized_client("personal", personal)
184+
185+
assert work.disconnected is False
186+
assert personal.disconnected is False
187+
188+
189+
@pytest.mark.asyncio
190+
async def test_connect_authorized_client_per_label_mismatch_names_label(monkeypatch):
191+
monkeypatch.setenv("TELEGRAM_EXPECTED_USERNAME_WORK", "work_account")
192+
client = _FakeClient(authorized=True, username="personal_account")
193+
194+
with pytest.raises(RuntimeError, match="TELEGRAM_EXPECTED_USERNAME_WORK") as excinfo:
195+
await runner._connect_authorized_client("work", client)
196+
197+
assert "'work'" in str(excinfo.value)
198+
assert client.disconnected is True
199+
200+
78201
@pytest.mark.asyncio
79202
async def test_connect_authorized_client_refuses_concurrent_duplicate_session():
80203
first = _FakeClient(authorized=True, identity="shared-session")

‎tests/test_runtime.py‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -545,15 +545,42 @@ async def __call__(self, _request):
545545

546546
@pytest.mark.asyncio
547547
async def test_ensure_connected_reconnects_disconnected_client(monkeypatch):
548-
client = _ConnectivityClient(connected=False, authorized=False)
548+
client = _ConnectivityClient(connected=False, authorized=True)
549549
monkeypatch.setattr(runtime, "_last_conn_verified", {})
550550

551551
await runtime.ensure_connected(client)
552552

553-
assert client.calls == ["is_connected", "disconnect", "connect", "is_user_authorized", "start"]
553+
assert client.calls == ["is_connected", "disconnect", "connect", "is_user_authorized"]
554554
assert runtime._last_conn_verified[id(client)] > 0
555555

556556

557+
@pytest.mark.asyncio
558+
async def test_force_reconnect_never_starts_interactive_login(monkeypatch):
559+
client = _ConnectivityClient(connected=False, authorized=False)
560+
monkeypatch.setattr(runtime, "_last_conn_verified", {})
561+
562+
with pytest.raises(RuntimeError, match="not authorized after reconnect") as excinfo:
563+
await runtime._force_reconnect(client)
564+
565+
assert "session_string_generator.py" in str(excinfo.value)
566+
assert "start" not in client.calls
567+
# The unauthorized connection is dropped so the next tool call reconnects
568+
# and reports the same error instead of reusing a half-open session.
569+
assert client.calls == ["disconnect", "connect", "is_user_authorized", "disconnect"]
570+
assert id(client) not in runtime._last_conn_verified
571+
572+
573+
@pytest.mark.asyncio
574+
async def test_ensure_connected_surfaces_revoked_session(monkeypatch):
575+
client = _ConnectivityClient(connected=False, authorized=False)
576+
monkeypatch.setattr(runtime, "_last_conn_verified", {})
577+
578+
with pytest.raises(RuntimeError, match="not authorized after reconnect"):
579+
await runtime.ensure_connected(client)
580+
581+
assert "start" not in client.calls
582+
583+
557584
@pytest.mark.asyncio
558585
async def test_ensure_connected_pings_and_reconnects_on_failed_ping(monkeypatch):
559586
client = _ConnectivityClient(connected=True, authorized=True, ping_error=ConnectionError())

0 commit comments

Comments
 (0)