4242 TextWithEntities ,
4343)
4444import 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+
4553from functools import wraps
4654import telethon .errors .rpcerrorlist
4755from 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+
283365def _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 (
0 commit comments