Skip to content

Commit 89d6bad

Browse files
authored
Merge pull request #173 from ex3lite/feat/contact-aliases-and-qr-fixes
feat(contacts): favorite aliases resolved everywhere; fix session generator 2FA retry and non-interactive stdin
2 parents 85dbf2a + 98337d5 commit 89d6bad

7 files changed

Lines changed: 175 additions & 16 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,4 @@ test_upload.txt
199199
test_voice.ogg
200200
sticker.webp
201201
two.png
202+
aliases.json

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ The server currently includes 80+ MCP tools grouped into these areas:
4646
- **Accounts:** list configured accounts and route tool calls by account label.
4747
- **Chats and groups:** list chats, inspect metadata, create groups/channels, join or leave chats, invite users, manage admins, bans, default permissions, slow mode, topics, invite links, common chats, read receipts, and message links.
4848
- **Messages:** send, schedule, edit, delete, forward, pin, unpin, mark read, reply, search, inspect context, create polls, manage reactions, inspect inline buttons, and press inline callbacks.
49-
- **Contacts:** list, search, add, delete, block, unblock, import, export, inspect direct chats, and find recent contact interactions.
49+
- **Contacts:** list, search, add, delete, block, unblock, import, export, inspect direct chats, find recent contact interactions, and manage favorite aliases (e.g. save "andrew" so any tool accepting a `chat_id` resolves it; searches check favorites first).
5050
- **Media:** send files, download media, upload files, send voice notes, stickers, GIFs, and inspect message media.
5151
- **Profile and privacy:** get your own account info, update profile fields, set or delete profile photos, inspect privacy settings, get user info/photos/status, and manage bot commands.
5252
- **Folders and drafts:** list, create, update, reorder, and delete Telegram folders; save, list, and clear drafts.

session_string_generator.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,15 @@ def _qr_login(client: TelegramClient) -> None:
108108
print("\nQR code expired, here is a fresh one.")
109109
_render_qr(qr)
110110
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
111+
while True:
112+
pw = getpass.getpass(
113+
"\nTwo-factor authentication enabled. Please enter your password: "
114+
)
115+
try:
116+
client.sign_in(password=pw)
117+
return
118+
except errors.PasswordHashInvalidError:
119+
print("Invalid password, please try again.")
116120

117121
print("\nQR code expired too many times. Please run the generator again.")
118122
client.disconnect()
@@ -170,11 +174,15 @@ def main() -> None:
170174
"\nYour credentials will NOT be stored on any server and are only used for local authentication.\n"
171175
)
172176

173-
label = (
174-
input("Account label (optional, e.g. 'work', 'personal'; leave empty for default): ")
175-
.strip()
176-
.lower()
177-
)
177+
try:
178+
label = (
179+
input("Account label (optional, e.g. 'work', 'personal'; leave empty for default): ")
180+
.strip()
181+
.lower()
182+
)
183+
except EOFError:
184+
# Non-interactive stdin (piped/scripted runs): fall back to the default label.
185+
label = ""
178186

179187
if args.qr:
180188
method = "1"
@@ -210,9 +218,12 @@ def main() -> None:
210218
print(f"{env_var}={session_string}")
211219
print("\nIMPORTANT: Keep this string private and never share it with anyone!")
212220

213-
choice = input(
214-
"\nWould you like to automatically update your .env file with this session string? (y/N): "
215-
)
221+
try:
222+
choice = input(
223+
"\nWould you like to automatically update your .env file with this session string? (y/N): "
224+
)
225+
except EOFError:
226+
choice = "n"
216227
if choice.lower() == "y":
217228
try:
218229
with open(".env", "r") as file:

telegram_mcp/runtime.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,31 @@ def format_entity(entity) -> Dict[str, Any]:
816816
return result
817817

818818

819+
_ALIASES_FILE = Path(__file__).resolve().parent.parent / "aliases.json"
820+
821+
822+
def load_aliases() -> Dict[str, int]:
823+
try:
824+
with open(_ALIASES_FILE, "r", encoding="utf-8") as f:
825+
return {k.lower(): int(v) for k, v in json.load(f).items()}
826+
except (FileNotFoundError, ValueError):
827+
return {}
828+
829+
830+
def save_aliases(aliases: Dict[str, int]) -> None:
831+
with open(_ALIASES_FILE, "w", encoding="utf-8") as f:
832+
json.dump(aliases, f, ensure_ascii=False, indent=2)
833+
834+
835+
def apply_alias(identifier: Union[int, str]) -> Union[int, str]:
836+
"""If identifier matches a saved alias (case-insensitive), return its chat ID."""
837+
if isinstance(identifier, str):
838+
alias_id = load_aliases().get(identifier.strip().lstrip("@").lower())
839+
if alias_id is not None:
840+
return alias_id
841+
return identifier
842+
843+
819844
def _marked_id_candidates(identifier: Union[int, str]) -> list[int]:
820845
"""Return marked chat/channel ID variants for a bare positive integer ID."""
821846
if not isinstance(identifier, int) or identifier <= 0:
@@ -839,6 +864,7 @@ async def resolve_entity(identifier: Union[int, str], client=None) -> Any:
839864
840865
On ConnectionError, reconnects and retries once.
841866
"""
867+
identifier = apply_alias(identifier)
842868
if client is None:
843869
client = get_client()
844870
await ensure_connected(client)
@@ -882,6 +908,7 @@ async def resolve_input_entity(identifier: Union[int, str], client=None) -> Any:
882908
883909
Uses the same cache warming, marked-ID fallback, and reconnect behavior.
884910
"""
911+
identifier = apply_alias(identifier)
885912
if client is None:
886913
client = get_client()
887914
await ensure_connected(client)

telegram_mcp/tools/contacts.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ async def list_contacts(account: Optional[str] = None) -> str:
4747
async def search_contacts(query: str, account: Optional[str] = None) -> str:
4848
"""
4949
Search for contacts by name, username, or phone number using Telethon's SearchRequest.
50+
Saved favorite aliases matching the query are checked first and returned at the top.
5051
Args:
5152
query: The search term to look for in contact names, usernames, or phone numbers.
5253
@@ -55,11 +56,17 @@ async def search_contacts(query: str, account: Optional[str] = None) -> str:
5556
try:
5657
cl = get_client(account)
5758
await ensure_connected(cl)
59+
q = query.strip().lstrip("@").lower()
60+
alias_records = [
61+
{"alias": alias, "id": chat_id, "favorite": True}
62+
for alias, chat_id in load_aliases().items()
63+
if q and (q in alias or alias in q)
64+
]
5865
result = await cl(functions.contacts.SearchRequest(q=query, limit=50))
5966
users = result.users
60-
if not users:
67+
if not users and not alias_records:
6168
return f"No contacts found matching '{query}'."
62-
records = []
69+
records = alias_records
6370
for user in users:
6471
name = f"{getattr(user, 'first_name', '')} {getattr(user, 'last_name', '')}".strip()
6572
record = {
@@ -596,6 +603,57 @@ async def send_contact(
596603
return log_and_format_error("send_contact", e, chat_id=chat_id, phone_number=phone_number)
597604

598605

606+
@mcp.tool(annotations=ToolAnnotations(title="Set Contact Alias", openWorldHint=True))
607+
@with_account(readonly=True)
608+
async def set_contact_alias(alias: str, chat_id: str, account: Optional[str] = None) -> str:
609+
"""
610+
Save a favorite alias for a contact or chat. After this, the alias can be used
611+
anywhere a chat_id is accepted (e.g. alias "андрей" -> send_message("андрей", ...)).
612+
Args:
613+
alias: Short name to remember (case-insensitive, e.g. "андрей").
614+
chat_id: Chat ID, username (@user), or phone of the target.
615+
"""
616+
try:
617+
cl = get_client(account)
618+
entity = await resolve_entity(chat_id, cl)
619+
marked_id = get_marked_id(entity)
620+
aliases = load_aliases()
621+
aliases[alias.strip().lstrip("@").lower()] = marked_id
622+
save_aliases(aliases)
623+
return format_tool_result(
624+
{"alias": alias.strip().lower(), "resolved": format_entity(entity)}
625+
)
626+
except Exception as e:
627+
return log_and_format_error("set_contact_alias", e, alias=alias, chat_id=chat_id)
628+
629+
630+
@mcp.tool(annotations=ToolAnnotations(title="List Contact Aliases", readOnlyHint=True))
631+
@with_account(readonly=True)
632+
async def list_contact_aliases(account: Optional[str] = None) -> str:
633+
"""List all saved favorite aliases and their chat IDs."""
634+
try:
635+
aliases = load_aliases()
636+
return format_tool_result(aliases) if aliases else "No aliases saved."
637+
except Exception as e:
638+
return log_and_format_error("list_contact_aliases", e)
639+
640+
641+
@mcp.tool(annotations=ToolAnnotations(title="Delete Contact Alias", openWorldHint=True))
642+
@with_account(readonly=True)
643+
async def delete_contact_alias(alias: str, account: Optional[str] = None) -> str:
644+
"""Delete a saved favorite alias."""
645+
try:
646+
aliases = load_aliases()
647+
key = alias.strip().lstrip("@").lower()
648+
if key not in aliases:
649+
return f"Alias '{alias}' not found."
650+
del aliases[key]
651+
save_aliases(aliases)
652+
return f"Alias '{alias}' deleted."
653+
except Exception as e:
654+
return log_and_format_error("delete_contact_alias", e, alias=alias)
655+
656+
599657
__all__ = [
600658
"list_contacts",
601659
"search_contacts",
@@ -611,4 +669,7 @@ async def send_contact(
611669
"export_contacts",
612670
"get_blocked_users",
613671
"send_contact",
672+
"set_contact_alias",
673+
"list_contact_aliases",
674+
"delete_contact_alias",
614675
]

tests/test_aliases.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Tests for favorite contact aliases."""
2+
3+
from telegram_mcp import runtime
4+
5+
6+
def _use_tmp_aliases(monkeypatch, tmp_path):
7+
monkeypatch.setattr(runtime, "_ALIASES_FILE", tmp_path / "aliases.json")
8+
9+
10+
def test_apply_alias_returns_saved_id(monkeypatch, tmp_path):
11+
_use_tmp_aliases(monkeypatch, tmp_path)
12+
runtime.save_aliases({"андрей": 12345})
13+
14+
assert runtime.apply_alias("андрей") == 12345
15+
assert runtime.apply_alias("Андрей") == 12345
16+
assert runtime.apply_alias("@андрей") == 12345
17+
assert runtime.apply_alias(" андрей ") == 12345
18+
19+
20+
def test_apply_alias_passes_through_unknown_values(monkeypatch, tmp_path):
21+
_use_tmp_aliases(monkeypatch, tmp_path)
22+
runtime.save_aliases({"андрей": 12345})
23+
24+
assert runtime.apply_alias("bob") == "bob"
25+
assert runtime.apply_alias(678) == 678
26+
27+
28+
def test_load_aliases_missing_or_corrupt_file(monkeypatch, tmp_path):
29+
_use_tmp_aliases(monkeypatch, tmp_path)
30+
assert runtime.load_aliases() == {}
31+
32+
(tmp_path / "aliases.json").write_text("not json")
33+
assert runtime.load_aliases() == {}
34+
35+
36+
def test_save_and_load_roundtrip_lowercases_keys(monkeypatch, tmp_path):
37+
_use_tmp_aliases(monkeypatch, tmp_path)
38+
runtime.save_aliases({"Работа": -1001234567890})
39+
40+
assert runtime.load_aliases() == {"работа": -1001234567890}

tests/test_session_string_generator.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,25 @@ def test_qr_login_uses_hidden_password_prompt_for_2fa(monkeypatch):
112112
assert client.sign_in_calls == ["secret"]
113113

114114

115+
def test_qr_login_retries_2fa_on_invalid_password(monkeypatch):
116+
qr = _FakeQR([session_string_generator.errors.SessionPasswordNeededError(request=None)])
117+
client = _FakeClient(qr)
118+
attempts = []
119+
120+
def sign_in(password=None):
121+
attempts.append(password)
122+
if len(attempts) == 1:
123+
raise session_string_generator.errors.PasswordHashInvalidError(request=None)
124+
125+
client.sign_in = sign_in
126+
entered = iter(["wrong", "right"])
127+
monkeypatch.setattr("getpass.getpass", lambda prompt: next(entered))
128+
129+
session_string_generator._qr_login(client)
130+
131+
assert attempts == ["wrong", "right"]
132+
133+
115134
def test_phone_login_uses_hidden_password_prompt_for_2fa(monkeypatch):
116135
class _Client:
117136
def __init__(self):

0 commit comments

Comments
 (0)