Skip to content

Commit 0d7b7e3

Browse files
authored
Merge pull request chigwell#144 from shayanline/feat/configurable-device-identity-and-qr-refresh
feat: configurable device identity and self-refreshing QR login
2 parents afc39fc + 4aeaa6b commit 0d7b7e3

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)