Skip to content

Commit c5a059a

Browse files
authored
Merge pull request #152 from nikit34/feat/session-pool-authkey
feat: session pool + AuthKeyDuplicatedError retry for concurrent clients on one account
2 parents 48a6237 + 2ae9889 commit c5a059a

5 files changed

Lines changed: 301 additions & 6 deletions

File tree

.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ 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+
# --- Session pool (same account, concurrent clients) ---
18+
# Run several MCP clients (e.g. the desktop app AND a terminal CLI) against ONE
19+
# Telegram account without hitting AuthKeyDuplicatedError. Telegram forbids a
20+
# single session (auth key) being used from two IPs at once; on a VPN/dual-stack
21+
# host two local clients can collide. List several interchangeable session
22+
# strings (whitespace, comma or semicolon separated) and each process claims a
23+
# free one via an advisory file lock. Generate extra sessions with
24+
# `uv run session_string_generator.py`. Takes precedence over
25+
# TELEGRAM_SESSION_STRING for the default account.
26+
# TELEGRAM_SESSION_STRINGS=<session A> <session B> <session C>
27+
1728
# --- Device identity (optional) ---
1829
# Controls how this client appears in Telegram > Settings > Devices. If unset,
1930
# Telethon falls back to the host platform (e.g. "arm64"). Set these so the

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,26 @@ Example prompts:
263263

264264
- "List my accounts"
265265
- "Show unread messages from all accounts"
266+
267+
### Session pool (one account, several concurrent clients)
268+
269+
To run several MCP clients against the **same** Telegram account at once (for
270+
example the desktop app *and* a terminal CLI), give each client its own
271+
authorized session. Telegram forbids one session (auth key) being used from two
272+
IPs simultaneously, so on a VPN or dual-stack host two local clients can collide
273+
with `AuthKeyDuplicatedError`. List several interchangeable session strings in
274+
`TELEGRAM_SESSION_STRINGS` (separated by whitespace, comma or semicolon); each
275+
process claims a free one via an advisory file lock, so clients deterministically
276+
pick distinct sessions:
277+
278+
```env
279+
TELEGRAM_SESSION_STRINGS=<session A> <session B> <session C>
280+
```
281+
282+
Generate extra sessions with `uv run session_string_generator.py`. The pool
283+
takes precedence over `TELEGRAM_SESSION_STRING` for the default account. As an
284+
extra safety net, a transient `AuthKeyDuplicatedError` at connect time (e.g.
285+
during a VPN reconnect) is retried with backoff before the server gives up.
266286
- "Send this from my work account to @example"
267287

268288
## Device Identity

telegram_mcp/runner.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,42 @@
77
except UnsafeInstallationError as exc:
88
raise SystemExit(str(exc)) from None
99

10+
from telethon.errors import AuthKeyDuplicatedError
11+
1012
from telegram_mcp import runtime as _runtime
1113
from telegram_mcp.runtime import *
1214
import telegram_mcp.tools # noqa: F401 - registers MCP tools via decorators
1315

1416

1517
async def _connect_authorized_client(label, client) -> None:
16-
await client.connect()
18+
# Tolerate a transient AuthKeyDuplicatedError (the same session briefly seen
19+
# from two IPs, e.g. during a VPN reconnect) with a bounded retry so a blip
20+
# does not take the whole server down. Give each concurrent client its own
21+
# session (TELEGRAM_SESSION_STRINGS pool or TELEGRAM_SESSION_STRING_<LABEL>)
22+
# to avoid the collision entirely.
23+
max_attempts = 4
24+
for attempt in range(1, max_attempts + 1):
25+
try:
26+
await client.connect()
27+
break
28+
except AuthKeyDuplicatedError:
29+
if attempt >= max_attempts:
30+
raise
31+
delay = min(2**attempt, 15)
32+
print(
33+
f"AuthKeyDuplicatedError connecting '{label}' (attempt "
34+
f"{attempt}/{max_attempts}): session in use from another IP. "
35+
f"Retrying in {delay}s. If this persists, give each concurrent "
36+
"client its own session via TELEGRAM_SESSION_STRINGS or "
37+
"TELEGRAM_SESSION_STRING_<LABEL>.",
38+
file=sys.stderr,
39+
)
40+
try:
41+
await client.disconnect()
42+
except Exception:
43+
pass
44+
await asyncio.sleep(delay)
45+
1746
if await client.is_user_authorized():
1847
return
1948

telegram_mcp/runtime.py

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@
4242
TextWithEntities,
4343
)
4444
import re
45+
import hashlib
46+
import tempfile
47+
48+
try:
49+
import fcntl # POSIX advisory locks; unavailable on Windows
50+
except ImportError: # pragma: no cover - Windows fallback
51+
fcntl = None
52+
4553
from functools import wraps
4654
import telethon.errors.rpcerrorlist
4755
from sanitize import sanitize_user_content, sanitize_name, sanitize_dict, format_tool_result
@@ -280,11 +288,88 @@ def _build_client(session: Any, label: str) -> TelegramClient:
280288
return TelegramClient(session, TELEGRAM_API_ID, TELEGRAM_API_HASH, **kwargs)
281289

282290

291+
# --- Session pool ------------------------------------------------------------
292+
# A POOL of interchangeable authorized sessions for the SAME account lets
293+
# several concurrent MCP clients (e.g. the desktop app AND a terminal CLI) run
294+
# against one Telegram account without tripping AuthKeyDuplicatedError.
295+
#
296+
# Telegram forbids one auth key (one StringSession) being used from two IPs at
297+
# once; on a dual-stack / VPN host two local clients can egress via different
298+
# source IPs and collide. The fix is one authorized session PER concurrent
299+
# client (Telegram allows one account on many "devices"). Generate extra
300+
# sessions with `uv run session_string_generator.py` and list them in
301+
# TELEGRAM_SESSION_STRINGS (whitespace/comma/semicolon separated). Each process
302+
# claims the first session not already locked by a live process via an advisory
303+
# flock, so clients deterministically pick distinct slots; the OS releases the
304+
# lock if a process dies.
305+
306+
# Acquired lock handles are held for the process lifetime so the advisory locks
307+
# stay held until exit (or crash, when the OS releases them).
308+
_SESSION_LOCKS: list = []
309+
310+
311+
def _parse_session_pool() -> List[str]:
312+
"""Parse TELEGRAM_SESSION_STRINGS into a de-duplicated list of sessions."""
313+
raw = os.getenv("TELEGRAM_SESSION_STRINGS")
314+
if not raw:
315+
return []
316+
pool: List[str] = []
317+
for tok in re.split(r"[\s,;]+", raw.strip()):
318+
if tok and tok not in pool:
319+
pool.append(tok)
320+
return pool
321+
322+
323+
def _acquire_session(pool: List[str]) -> str:
324+
"""Claim the first free session in the pool via an advisory file lock."""
325+
if fcntl is None:
326+
# No advisory locks (e.g. Windows): can't coordinate slots, so use the
327+
# first session. For concurrent clients there, prefer distinct
328+
# TELEGRAM_SESSION_STRING_<LABEL> accounts instead.
329+
return pool[0]
330+
lock_dir = os.path.join(tempfile.gettempdir(), "telegram-mcp-session-locks")
331+
try:
332+
os.makedirs(lock_dir, exist_ok=True)
333+
except OSError:
334+
lock_dir = tempfile.gettempdir()
335+
for idx, session in enumerate(pool):
336+
digest = hashlib.sha1(session.encode("utf-8")).hexdigest()[:16]
337+
lock_path = os.path.join(lock_dir, f"session-{digest}.lock")
338+
try:
339+
fh = open(lock_path, "w")
340+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
341+
except OSError:
342+
# Locked by another live client — try the next session.
343+
try:
344+
fh.close()
345+
except Exception:
346+
pass
347+
continue
348+
_SESSION_LOCKS.append(fh)
349+
try:
350+
fh.write(f"pid={os.getpid()}\n")
351+
fh.flush()
352+
except OSError:
353+
pass
354+
print(f"Using Telegram session slot {idx + 1}/{len(pool)}.", file=sys.stderr)
355+
return session
356+
print(
357+
f"WARNING: all {len(pool)} pooled Telegram session(s) are already in use "
358+
"by other clients; reusing the first (may raise AuthKeyDuplicatedError). "
359+
"Add another session to TELEGRAM_SESSION_STRINGS to run more clients.",
360+
file=sys.stderr,
361+
)
362+
return pool[0]
363+
364+
283365
def _discover_accounts() -> dict[str, TelegramClient]:
284366
"""Scan env vars to build account label -> TelegramClient mapping.
285367
286368
Detection rules:
287369
- TELEGRAM_SESSION_STRING_<LABEL> / TELEGRAM_SESSION_NAME_<LABEL> -> multi-mode
370+
- TELEGRAM_SESSION_STRINGS (whitespace/comma/semicolon separated) -> a pool
371+
of interchangeable sessions for the default account; each process claims a
372+
free slot to avoid AuthKeyDuplicatedError (takes precedence for "default")
288373
- Unsuffixed TELEGRAM_SESSION_STRING / TELEGRAM_SESSION_NAME -> label "default"
289374
- If both suffixed and unsuffixed exist -> unsuffixed becomes "default"
290375
@@ -304,14 +389,21 @@ def _discover_accounts() -> dict[str, TelegramClient]:
304389
label = key[len(prefix_name) :].lower()
305390
accounts[label] = _build_client(value, label)
306391

307-
# Backward-compatible unsuffixed variables
392+
# Backward-compatible unsuffixed variables. A pool (TELEGRAM_SESSION_STRINGS)
393+
# takes precedence for the default account and claims a free session slot.
394+
session_pool = _parse_session_pool()
308395
session_string = os.getenv("TELEGRAM_SESSION_STRING")
309396
session_name = os.getenv("TELEGRAM_SESSION_NAME")
310397

311-
if session_string and "default" not in accounts:
312-
accounts["default"] = _build_client(StringSession(session_string), "default")
313-
elif session_name and "default" not in accounts:
314-
accounts["default"] = _build_client(session_name, "default")
398+
if "default" not in accounts:
399+
if session_pool:
400+
accounts["default"] = _build_client(
401+
StringSession(_acquire_session(session_pool)), "default"
402+
)
403+
elif session_string:
404+
accounts["default"] = _build_client(StringSession(session_string), "default")
405+
elif session_name:
406+
accounts["default"] = _build_client(session_name, "default")
315407

316408
if not accounts:
317409
print(

tests/test_session_pool.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import hashlib
2+
import os
3+
4+
import pytest
5+
6+
from telegram_mcp import runner, runtime
7+
8+
# --- _parse_session_pool -----------------------------------------------------
9+
10+
11+
def test_parse_session_pool_splits_and_dedupes(monkeypatch):
12+
monkeypatch.setenv("TELEGRAM_SESSION_STRINGS", "s1, s2 ;s3\n s1 s2")
13+
assert runtime._parse_session_pool() == ["s1", "s2", "s3"]
14+
15+
16+
def test_parse_session_pool_empty_when_unset(monkeypatch):
17+
monkeypatch.delenv("TELEGRAM_SESSION_STRINGS", raising=False)
18+
assert runtime._parse_session_pool() == []
19+
20+
21+
# --- _acquire_session --------------------------------------------------------
22+
23+
24+
@pytest.fixture
25+
def isolated_lock_dir(tmp_path, monkeypatch):
26+
monkeypatch.setattr(runtime.tempfile, "gettempdir", lambda: str(tmp_path))
27+
monkeypatch.setattr(runtime, "_SESSION_LOCKS", [])
28+
return tmp_path
29+
30+
31+
def _lock_slot(lock_dir, session):
32+
"""Simulate another live client holding the slot for ``session``."""
33+
import fcntl
34+
35+
digest = hashlib.sha1(session.encode("utf-8")).hexdigest()[:16]
36+
path = os.path.join(str(lock_dir), "telegram-mcp-session-locks", f"session-{digest}.lock")
37+
os.makedirs(os.path.dirname(path), exist_ok=True)
38+
fh = open(path, "w")
39+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
40+
return fh
41+
42+
43+
def test_acquire_session_claims_first_free_slot(isolated_lock_dir):
44+
assert runtime._acquire_session(["AAA", "BBB", "CCC"]) == "AAA"
45+
46+
47+
def test_acquire_session_skips_slot_locked_by_another_client(isolated_lock_dir):
48+
foreign = _lock_slot(isolated_lock_dir, "AAA")
49+
try:
50+
assert runtime._acquire_session(["AAA", "BBB", "CCC"]) == "BBB"
51+
finally:
52+
foreign.close()
53+
54+
55+
def test_acquire_session_falls_back_to_first_when_pool_exhausted(isolated_lock_dir):
56+
held = [_lock_slot(isolated_lock_dir, s) for s in ("AAA", "BBB")]
57+
try:
58+
# Only two slots exist and both are taken -> reuse the first.
59+
assert runtime._acquire_session(["AAA", "BBB"]) == "AAA"
60+
finally:
61+
for fh in held:
62+
fh.close()
63+
64+
65+
def test_acquire_session_without_fcntl_uses_first(isolated_lock_dir, monkeypatch):
66+
monkeypatch.setattr(runtime, "fcntl", None)
67+
assert runtime._acquire_session(["AAA", "BBB"]) == "AAA"
68+
69+
70+
# --- _discover_accounts prefers the pool for the default account -------------
71+
72+
73+
def test_discover_accounts_uses_pool_for_default(monkeypatch):
74+
monkeypatch.setenv("TELEGRAM_SESSION_STRINGS", "pooled-1 pooled-2")
75+
monkeypatch.delenv("TELEGRAM_SESSION_STRING", raising=False)
76+
monkeypatch.delenv("TELEGRAM_SESSION_NAME", raising=False)
77+
monkeypatch.setattr(runtime, "_acquire_session", lambda pool: pool[0])
78+
monkeypatch.setattr(runtime, "StringSession", lambda value=None: f"str::{value}")
79+
captured = {}
80+
81+
def _fake_build_client(session, label):
82+
captured[label] = session
83+
return object()
84+
85+
monkeypatch.setattr(runtime, "_build_client", _fake_build_client)
86+
87+
accounts = runtime._discover_accounts()
88+
89+
assert "default" in accounts
90+
assert captured["default"] == "str::pooled-1"
91+
92+
93+
# --- runner retries a transient AuthKeyDuplicatedError -----------------------
94+
95+
96+
class _FlakyClient:
97+
def __init__(self, fail_times):
98+
self.fail_times = fail_times
99+
self.connects = 0
100+
self.disconnects = 0
101+
102+
async def connect(self):
103+
self.connects += 1
104+
if self.connects <= self.fail_times:
105+
from telethon.errors import AuthKeyDuplicatedError
106+
107+
raise AuthKeyDuplicatedError(request=None)
108+
109+
async def disconnect(self):
110+
self.disconnects += 1
111+
112+
async def is_user_authorized(self):
113+
return True
114+
115+
116+
@pytest.fixture
117+
def no_sleep(monkeypatch):
118+
async def _noop(_delay):
119+
return None
120+
121+
monkeypatch.setattr(runner.asyncio, "sleep", _noop)
122+
123+
124+
@pytest.mark.asyncio
125+
async def test_connect_recovers_after_transient_authkey_duplicated(no_sleep):
126+
client = _FlakyClient(fail_times=2)
127+
128+
await runner._connect_authorized_client("default", client)
129+
130+
assert client.connects == 3
131+
assert client.disconnects == 2
132+
133+
134+
@pytest.mark.asyncio
135+
async def test_connect_reraises_authkey_duplicated_after_max_attempts(no_sleep):
136+
from telethon.errors import AuthKeyDuplicatedError
137+
138+
client = _FlakyClient(fail_times=99)
139+
140+
with pytest.raises(AuthKeyDuplicatedError):
141+
await runner._connect_authorized_client("default", client)
142+
143+
assert client.connects == 4

0 commit comments

Comments
 (0)