Skip to content

Commit 4aeaa6b

Browse files
committed
feat: configurable device identity and self-refreshing QR login
Two improvements to the authentication flow. Device identity: add a shared client_identity_kwargs() helper that reads TELEGRAM_DEVICE_MODEL, TELEGRAM_SYSTEM_VERSION and TELEGRAM_APP_VERSION from the environment, and apply it both in the session string generator (at login) and in the server client builder (on every connect). Without this Telethon falls back to the host platform (for example "arm64"), and because the values are re-sent on each connection the long-running server overwrites the name chosen at login on every reconnect, so the Settings > Devices entry never keeps a stable, recognisable name. QR login: the generator previously did a single 120s wait and then exited with "QR code expired". It now regenerates the QR via recreate() when the token expires, re-rendering until the user scans or a refresh cap is reached, so slow scans no longer fail. Docs (.env.example, README) and tests cover both changes.
1 parent afc39fc commit 4aeaa6b

8 files changed

Lines changed: 239 additions & 45 deletions

‎.env.example‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ TELEGRAM_SESSION_NAME=telegram_session
1414
# TELEGRAM_SESSION_STRING_WORK=<session string for work account>
1515
# TELEGRAM_SESSION_STRING_PERSONAL=<session string for personal account>
1616

17+
# --- Device identity (optional) ---
18+
# Controls how this client appears in Telegram > Settings > Devices. If unset,
19+
# Telethon falls back to the host platform (e.g. "arm64"). Set these so the
20+
# session keeps a stable, recognisable name across reconnects. Applied both
21+
# when generating a session string and when the server connects.
22+
# TELEGRAM_DEVICE_MODEL=Telegram MCP
23+
# TELEGRAM_SYSTEM_VERSION=1.0
24+
# TELEGRAM_APP_VERSION=1.0
25+
1726
# --- MCP tool exposure (optional) ---
1827
# Default is all. Set to read-only to expose only tools annotated with
1928
# readOnlyHint=True through MCP. This does not reduce Telegram session authority

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Message sent successfully:
3030
- [Quick Start](#quick-start)
3131
- [MCP Client Configuration](#mcp-client-configuration)
3232
- [Multi-Account Setup](#multi-account-setup)
33+
- [Device Identity](#device-identity)
3334
- [Proxy Support](#proxy-support)
3435
- [File Path Security](#file-path-security)
3536
- [Docker](#docker)
@@ -222,6 +223,24 @@ Example prompts:
222223
- "Show unread messages from all accounts"
223224
- "Send this from my work account to @example"
224225

226+
## Device Identity
227+
228+
These optional variables control how the client appears in Telegram under
229+
**Settings > Devices** (the active-sessions list):
230+
231+
```env
232+
TELEGRAM_DEVICE_MODEL=Telegram MCP
233+
TELEGRAM_SYSTEM_VERSION=1.0
234+
TELEGRAM_APP_VERSION=1.0
235+
```
236+
237+
If left unset, Telethon falls back to the host platform (for example `arm64`).
238+
Because these values are re-sent on every connection, a long-running server
239+
would otherwise overwrite the name chosen during login on each reconnect, so
240+
set them to keep a stable, recognisable device name. The same variables are
241+
read both by the session string generator (at login) and by the server (on
242+
every connect), so set them in the same place as your other credentials.
243+
225244
## Proxy Support
226245

227246
Route Telegram traffic through a proxy by setting the `TELEGRAM_PROXY_*`

‎session_string_generator.py‎

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,18 @@
2626
import io
2727
import os
2828
import sys
29+
from datetime import datetime, timezone
2930

3031
from dotenv import load_dotenv
3132
from telethon import errors
3233
from telethon.sessions import StringSession
3334
from telethon.sync import TelegramClient
35+
from telegram_mcp.client_identity import client_identity_kwargs
3436
from telegram_mcp.install_guard import UnsafeInstallationError, assert_safe_distribution
3537

38+
# How many times the QR code is regenerated after expiry before giving up.
39+
_QR_MAX_REFRESHES = 10
40+
3641
load_dotenv()
3742

3843

@@ -62,11 +67,9 @@ def _check_installation() -> None:
6267
sys.exit(1)
6368

6469

65-
def _qr_login(client: TelegramClient) -> None:
70+
def _render_qr(qr) -> None:
6671
import qrcode
6772

68-
qr = client.qr_login()
69-
7073
print("\n----- QR Code Login -----\n")
7174

7275
qr_obj = qrcode.QRCode(border=1)
@@ -82,15 +85,38 @@ def _qr_login(client: TelegramClient) -> None:
8285
print(f"Expires at: {qr.expires.strftime('%H:%M:%S')}")
8386
print("Waiting for you to scan...")
8487

85-
try:
86-
client.loop.run_until_complete(qr.wait(timeout=120))
87-
except asyncio.TimeoutError:
88-
print("\nQR code expired. Please try again.")
89-
client.disconnect()
90-
sys.exit(1)
91-
except errors.SessionPasswordNeededError:
92-
pw = getpass.getpass("\nTwo-factor authentication enabled. Please enter your password: ")
93-
client.sign_in(password=pw)
88+
89+
def _seconds_until_expiry(qr) -> float:
90+
"""Seconds left before this QR token expires, with a small safety margin."""
91+
expires = qr.expires
92+
if expires.tzinfo is None:
93+
expires = expires.replace(tzinfo=timezone.utc)
94+
remaining = (expires - datetime.now(timezone.utc)).total_seconds()
95+
return max(1.0, remaining - 1.0)
96+
97+
98+
def _qr_login(client: TelegramClient) -> None:
99+
qr = client.qr_login()
100+
_render_qr(qr)
101+
102+
for _ in range(_QR_MAX_REFRESHES):
103+
try:
104+
client.loop.run_until_complete(qr.wait(timeout=_seconds_until_expiry(qr)))
105+
return
106+
except asyncio.TimeoutError:
107+
client.loop.run_until_complete(qr.recreate())
108+
print("\nQR code expired, here is a fresh one.")
109+
_render_qr(qr)
110+
except errors.SessionPasswordNeededError:
111+
pw = getpass.getpass(
112+
"\nTwo-factor authentication enabled. Please enter your password: "
113+
)
114+
client.sign_in(password=pw)
115+
return
116+
117+
print("\nQR code expired too many times. Please run the generator again.")
118+
client.disconnect()
119+
sys.exit(1)
94120

95121

96122
def _phone_login(client: TelegramClient) -> None:
@@ -161,7 +187,7 @@ def main() -> None:
161187
method = input("\nEnter 1 or 2 [default: 1]: ").strip() or "1"
162188

163189
try:
164-
client = TelegramClient(StringSession(), API_ID, API_HASH)
190+
client = TelegramClient(StringSession(), API_ID, API_HASH, **client_identity_kwargs())
165191
client.connect()
166192

167193
if not client.is_user_authorized():

‎telegram_mcp/client_identity.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Resolve the Telethon device identity used when connecting to Telegram.
2+
3+
These values are what Telegram shows in Settings > Devices (active sessions).
4+
When they are not provided, Telethon falls back to the host platform (for
5+
example ``arm64``), and because the values are re-sent on every connection,
6+
a long-running server would otherwise overwrite the name chosen during login
7+
on each reconnect. Making them configurable keeps a stable, recognisable name.
8+
"""
9+
10+
import os
11+
12+
# Maps the Telethon constructor keyword to the environment variable that sets it.
13+
_DEVICE_ENV = {
14+
"device_model": "TELEGRAM_DEVICE_MODEL",
15+
"system_version": "TELEGRAM_SYSTEM_VERSION",
16+
"app_version": "TELEGRAM_APP_VERSION",
17+
}
18+
19+
20+
def client_identity_kwargs() -> dict:
21+
"""Return TelegramClient device kwargs derived from the environment.
22+
23+
Only variables that are set to a non-empty value are included, so unset
24+
ones keep Telethon's own defaults.
25+
"""
26+
kwargs = {}
27+
for kwarg, env_var in _DEVICE_ENV.items():
28+
value = os.environ.get(env_var)
29+
if value:
30+
kwargs[kwarg] = value
31+
return kwargs

‎telegram_mcp/runtime.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from functools import wraps
4646
import telethon.errors.rpcerrorlist
4747
from sanitize import sanitize_user_content, sanitize_name, sanitize_dict, format_tool_result
48+
from telegram_mcp.client_identity import client_identity_kwargs
4849

4950

5051
class ValidationError(Exception):
@@ -275,6 +276,7 @@ def _build_client(session: Any, label: str) -> TelegramClient:
275276
kwargs["proxy"] = proxy
276277
if connection is not None:
277278
kwargs["connection"] = connection
279+
kwargs.update(client_identity_kwargs())
278280
return TelegramClient(session, TELEGRAM_API_ID, TELEGRAM_API_HASH, **kwargs)
279281

280282

‎tests/test_client_identity.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from telegram_mcp.client_identity import client_identity_kwargs
2+
3+
4+
def _clear_device_env(monkeypatch):
5+
for key in ("TELEGRAM_DEVICE_MODEL", "TELEGRAM_SYSTEM_VERSION", "TELEGRAM_APP_VERSION"):
6+
monkeypatch.delenv(key, raising=False)
7+
8+
9+
def test_client_identity_kwargs_empty_when_unset(monkeypatch):
10+
_clear_device_env(monkeypatch)
11+
12+
assert client_identity_kwargs() == {}
13+
14+
15+
def test_client_identity_kwargs_maps_all_variables(monkeypatch):
16+
_clear_device_env(monkeypatch)
17+
monkeypatch.setenv("TELEGRAM_DEVICE_MODEL", "Telegram MCP")
18+
monkeypatch.setenv("TELEGRAM_SYSTEM_VERSION", "2.0")
19+
monkeypatch.setenv("TELEGRAM_APP_VERSION", "3.1")
20+
21+
assert client_identity_kwargs() == {
22+
"device_model": "Telegram MCP",
23+
"system_version": "2.0",
24+
"app_version": "3.1",
25+
}
26+
27+
28+
def test_client_identity_kwargs_ignores_empty_values(monkeypatch):
29+
_clear_device_env(monkeypatch)
30+
monkeypatch.setenv("TELEGRAM_DEVICE_MODEL", "Telegram MCP")
31+
monkeypatch.setenv("TELEGRAM_SYSTEM_VERSION", "")
32+
33+
assert client_identity_kwargs() == {"device_model": "Telegram MCP"}

‎tests/test_runtime.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,25 @@ def test_discover_accounts_passes_proxy_kwargs_to_client(monkeypatch):
257257
assert client.kwargs["connection"] is ConnectionTcpMTProxyRandomizedIntermediate
258258

259259

260+
def test_discover_accounts_passes_device_identity_kwargs_to_client(monkeypatch):
261+
_clear_session_env(monkeypatch)
262+
_clear_proxy_env(monkeypatch)
263+
for key in ("TELEGRAM_DEVICE_MODEL", "TELEGRAM_SYSTEM_VERSION", "TELEGRAM_APP_VERSION"):
264+
monkeypatch.delenv(key, raising=False)
265+
monkeypatch.setenv("TELEGRAM_SESSION_STRING", "default-session")
266+
monkeypatch.setenv("TELEGRAM_DEVICE_MODEL", "Telegram MCP")
267+
monkeypatch.setenv("TELEGRAM_APP_VERSION", "3.1")
268+
monkeypatch.setattr(runtime, "TelegramClient", _FakeTelegramClient)
269+
monkeypatch.setattr(runtime, "StringSession", lambda value: f"StringSession:{value}")
270+
271+
accounts = runtime._discover_accounts()
272+
273+
client = accounts["default"]
274+
assert client.kwargs["device_model"] == "Telegram MCP"
275+
assert client.kwargs["app_version"] == "3.1"
276+
assert "system_version" not in client.kwargs
277+
278+
260279
def test_get_client_single_and_multi_account_paths(monkeypatch):
261280
only = object()
262281
monkeypatch.setattr(runtime, "clients", {"only": only})

‎tests/test_session_string_generator.py‎

Lines changed: 87 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,110 @@
1+
import asyncio
2+
from datetime import datetime, timedelta, timezone
3+
14
import pytest
25

36
import session_string_generator
47

58

6-
def test_qr_login_uses_hidden_password_prompt_for_2fa(monkeypatch):
7-
class _Loop:
8-
def run_until_complete(self, awaitable):
9-
awaitable.close()
10-
raise session_string_generator.errors.SessionPasswordNeededError(request=None)
9+
class _NS:
10+
def __init__(self, expires):
11+
self.expires = expires
1112

12-
class _Client:
13-
def __init__(self):
14-
self.loop = _Loop()
15-
self.sign_in_calls = []
1613

17-
def qr_login(self):
18-
class _Qr:
19-
url = "https://example.test/qr"
14+
class _FakeQR:
15+
def __init__(self, wait_outcomes):
16+
# Each entry is either an exception to raise on wait(), or None to succeed.
17+
self._wait_outcomes = list(wait_outcomes)
18+
self.url = "tg://login?token=fake"
19+
self.expires = datetime.now(timezone.utc) + timedelta(seconds=30)
20+
self.recreate_calls = 0
2021

21-
class _Expiry:
22-
def strftime(self, _fmt):
23-
return "00:00:00"
22+
def wait(self, timeout=None):
23+
return ("wait", self._wait_outcomes.pop(0))
2424

25-
expires = _Expiry()
25+
def recreate(self):
26+
self.recreate_calls += 1
27+
self.expires = datetime.now(timezone.utc) + timedelta(seconds=30)
28+
return ("recreate", None)
2629

27-
async def wait(self, timeout=120):
28-
return None
2930

30-
return _Qr()
31+
class _FakeLoop:
32+
def run_until_complete(self, marker):
33+
kind, outcome = marker
34+
if kind == "wait" and outcome is not None:
35+
raise outcome
36+
return None
3137

32-
def sign_in(self, password=None):
33-
self.sign_in_calls.append(password)
3438

35-
class _FakeQrCode:
36-
def __init__(self, border=1):
37-
self.border = border
39+
class _FakeClient:
40+
def __init__(self, qr):
41+
self._qr = qr
42+
self.loop = _FakeLoop()
43+
self.disconnected = False
44+
self.sign_in_calls = []
3845

39-
def add_data(self, _data):
40-
return None
46+
def qr_login(self):
47+
return self._qr
4148

42-
def make(self, fit=True):
43-
return None
49+
def sign_in(self, password=None):
50+
self.sign_in_calls.append(password)
4451

45-
def print_ascii(self, out, invert=True):
46-
out.write("qr")
52+
def disconnect(self):
53+
self.disconnected = True
4754

48-
client = _Client()
55+
56+
def test_seconds_until_expiry_handles_naive_and_aware():
57+
naive = datetime(2999, 1, 1)
58+
aware = datetime.now(timezone.utc) + timedelta(seconds=30)
59+
60+
assert session_string_generator._seconds_until_expiry(_NS(naive)) > 1.0
61+
assert session_string_generator._seconds_until_expiry(_NS(aware)) > 1.0
62+
63+
64+
def test_seconds_until_expiry_never_below_one():
65+
past = datetime.now(timezone.utc) - timedelta(seconds=10)
66+
67+
assert session_string_generator._seconds_until_expiry(_NS(past)) == 1.0
68+
69+
70+
def test_qr_login_returns_on_first_successful_scan():
71+
qr = _FakeQR([None])
72+
client = _FakeClient(qr)
73+
74+
session_string_generator._qr_login(client)
75+
76+
assert qr.recreate_calls == 0
77+
assert client.disconnected is False
78+
79+
80+
def test_qr_login_refreshes_after_expiry_then_succeeds():
81+
qr = _FakeQR([asyncio.TimeoutError(), None])
82+
client = _FakeClient(qr)
83+
84+
session_string_generator._qr_login(client)
85+
86+
assert qr.recreate_calls == 1
87+
assert client.disconnected is False
88+
89+
90+
def test_qr_login_gives_up_after_max_refreshes(monkeypatch):
91+
monkeypatch.setattr(session_string_generator, "_QR_MAX_REFRESHES", 3)
92+
qr = _FakeQR([asyncio.TimeoutError()] * 3)
93+
client = _FakeClient(qr)
94+
95+
with pytest.raises(SystemExit):
96+
session_string_generator._qr_login(client)
97+
98+
assert qr.recreate_calls == 3
99+
assert client.disconnected is True
100+
101+
102+
def test_qr_login_uses_hidden_password_prompt_for_2fa(monkeypatch):
103+
qr = _FakeQR([session_string_generator.errors.SessionPasswordNeededError(request=None)])
104+
client = _FakeClient(qr)
49105
prompted = []
50106

51107
monkeypatch.setattr("getpass.getpass", lambda prompt: prompted.append(prompt) or "secret")
52-
monkeypatch.setattr("qrcode.QRCode", _FakeQrCode)
53108

54109
session_string_generator._qr_login(client)
55110

0 commit comments

Comments
 (0)