Skip to content

Commit 3c42ca1

Browse files
authored
Merge pull request chigwell#275 from omid-io/feat/custom-emoji-packs-272
feat(emojis): browse and preview custom emoji packs (chigwell#272)
2 parents 0cda318 + ef22802 commit 3c42ca1

5 files changed

Lines changed: 723 additions & 0 deletions

File tree

‎telegram_mcp/runtime.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,7 @@ async def ensure_connected(cl: TelegramClient = None):
925925
"send_sticker": {".webp"},
926926
"set_profile_photo": {".jpg", ".jpeg", ".png", ".webp"},
927927
"edit_chat_photo": {".jpg", ".jpeg", ".png", ".webp"},
928+
"preview_custom_emoji": {".webp", ".png", ".jpg", ".jpeg", ".tgs", ".webm"},
928929
}
929930
# Mutable, TELEGRAM_FILE_EXTENSIONS-aware allowlist actually consulted by
930931
# _ensure_extension_allowed(). Rebuilt from _DEFAULT_EXTENSION_ALLOWLISTS by
@@ -940,6 +941,7 @@ async def ensure_connected(cl: TelegramClient = None):
940941
"send_sticker": 10 * 1024 * 1024,
941942
"set_profile_photo": 50 * 1024 * 1024,
942943
"edit_chat_photo": 50 * 1024 * 1024,
944+
"preview_custom_emoji": 20 * 1024 * 1024,
943945
}
944946
ROOTS_UNSUPPORTED_ERROR_CODES = {-32601}
945947
ROOTS_STATUS_READY = "ready"

‎telegram_mcp/tools/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
from telegram_mcp.tools.profile import *
1010
from telegram_mcp.tools.folders import *
1111
from telegram_mcp.tools.events import *
12+
from telegram_mcp.tools.custom_emoji import *
1213

1314
__all__ = [name for name in globals() if not name.startswith("_")]

‎telegram_mcp/tools/custom_emoji.py‎

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
"""Custom Emoji MCP tools for browsing, inspecting, and previewing custom emoji packs."""
2+
3+
import json
4+
import re
5+
from typing import Any, Dict, List, Optional, Union
6+
7+
from mcp.server.fastmcp import Context, FastMCP, Image
8+
from mcp.types import ToolAnnotations
9+
from telethon import functions, types
10+
from telethon.errors.rpcerrorlist import StickersetInvalidError
11+
12+
from sanitize import sanitize_name, sanitize_user_content
13+
from telegram_mcp.runtime import (
14+
_resolve_writable_file_path,
15+
ensure_connected,
16+
get_client,
17+
json_serializer,
18+
log_and_format_error,
19+
mcp,
20+
with_account,
21+
)
22+
23+
24+
def extract_pack_short_name(pack: str) -> str:
25+
"""
26+
Extract the sticker or emoji pack short name from a URL or raw identifier.
27+
28+
Supports:
29+
- https://t.me/addemoji/<name>
30+
- t.me/addemoji/<name>
31+
- https://t.me/addstickers/<name>
32+
- t.me/addstickers/<name>
33+
- tg://addemoji?set=<name>
34+
- tg://addstickers?set=<name>
35+
- plain short name (e.g. 'AnimatedDogs')
36+
"""
37+
if not pack:
38+
return ""
39+
s = pack.strip()
40+
m = re.search(r"(?:addemoji|addstickers)/([a-zA-Z0-9_]+)", s)
41+
if m:
42+
return m.group(1)
43+
m = re.search(r"set=([a-zA-Z0-9_]+)", s)
44+
if m:
45+
return m.group(1)
46+
m = re.match(r"^[a-zA-Z0-9_]+$", s)
47+
if m:
48+
return s
49+
clean = s.rstrip("/").split("/")[-1]
50+
clean = re.sub(r"[^a-zA-Z0-9_]", "", clean)
51+
return clean
52+
53+
54+
@mcp.tool(
55+
annotations=ToolAnnotations(
56+
title="Get Custom Emoji Packs", openWorldHint=True, readOnlyHint=True
57+
)
58+
)
59+
@with_account(readonly=True)
60+
async def get_custom_emoji_packs(
61+
query: Optional[str] = None,
62+
featured: bool = False,
63+
limit: int = 50,
64+
account: Optional[str] = None,
65+
) -> str:
66+
"""
67+
Browse installed or featured custom emoji packs, or search for emoji packs by query.
68+
69+
Args:
70+
query: Optional search keyword to search for emoji packs.
71+
featured: If True, fetches featured emoji packs instead of installed ones.
72+
limit: Maximum number of emoji packs to return (default: 50).
73+
account: Optional account alias or phone number.
74+
"""
75+
try:
76+
cl = get_client(account)
77+
await ensure_connected(cl)
78+
79+
if query and query.strip():
80+
result = await cl(
81+
functions.messages.SearchEmojiStickerSetsRequest(q=query.strip(), hash=0)
82+
)
83+
raw_sets = getattr(result, "sets", []) or []
84+
elif featured:
85+
result = await cl(functions.messages.GetFeaturedEmojiStickersRequest(hash=0))
86+
raw_sets = getattr(result, "sets", []) or []
87+
else:
88+
result = await cl(functions.messages.GetEmojiStickersRequest(hash=0))
89+
raw_sets = getattr(result, "sets", []) or []
90+
91+
packs = []
92+
for item in raw_sets:
93+
s = getattr(item, "set", item)
94+
short_name = getattr(s, "short_name", "")
95+
pack_info = {
96+
"id": str(getattr(s, "id", "")),
97+
"title": sanitize_name(getattr(s, "title", "")),
98+
"short_name": short_name,
99+
"count": getattr(s, "count", 0),
100+
"archived": bool(getattr(s, "archived", False)),
101+
"official": bool(getattr(s, "official", False)),
102+
"masks": bool(getattr(s, "masks", False)),
103+
"emojis": bool(getattr(s, "emojis", True)),
104+
"url": f"https://t.me/addemoji/{short_name}" if short_name else None,
105+
}
106+
if getattr(s, "installed_date", None):
107+
pack_info["installed_date"] = s.installed_date.isoformat()
108+
packs.append(pack_info)
109+
if len(packs) >= limit:
110+
break
111+
112+
return json.dumps(packs, indent=2, default=json_serializer)
113+
except Exception as e:
114+
return log_and_format_error(
115+
"get_custom_emoji_packs", e, query=query, featured=featured, limit=limit
116+
)
117+
118+
119+
@mcp.tool(
120+
annotations=ToolAnnotations(
121+
title="Get Custom Emoji Pack", openWorldHint=True, readOnlyHint=True
122+
)
123+
)
124+
@with_account(readonly=True)
125+
async def get_custom_emoji_pack(
126+
pack: str,
127+
account: Optional[str] = None,
128+
) -> str:
129+
"""
130+
Open a custom emoji pack (by short name or t.me/addemoji/... link) and get its emoji with document IDs and fallback characters.
131+
132+
Args:
133+
pack: The emoji pack short name or URL (e.g. 'AnimatedDogs', 't.me/addemoji/AnimatedDogs', 'https://t.me/addstickers/...').
134+
account: Optional account alias or phone number.
135+
"""
136+
try:
137+
short_name = extract_pack_short_name(pack)
138+
if not short_name:
139+
return "Invalid pack name or URL provided."
140+
141+
cl = get_client(account)
142+
await ensure_connected(cl)
143+
144+
try:
145+
result = await cl(
146+
functions.messages.GetStickerSetRequest(
147+
stickerset=types.InputStickerSetShortName(short_name=short_name),
148+
hash=0,
149+
)
150+
)
151+
except StickersetInvalidError:
152+
return f"Emoji pack '{short_name}' not found or is invalid."
153+
154+
set_meta = getattr(result, "set", None)
155+
if not set_meta:
156+
return f"Emoji pack '{short_name}' has no set metadata."
157+
158+
is_emoji = bool(getattr(set_meta, "emojis", False))
159+
url_prefix = "addemoji" if is_emoji else "addstickers"
160+
pack_info = {
161+
"id": str(getattr(set_meta, "id", "")),
162+
"title": sanitize_name(getattr(set_meta, "title", "")),
163+
"short_name": getattr(set_meta, "short_name", short_name),
164+
"count": getattr(set_meta, "count", 0),
165+
"is_emoji": is_emoji,
166+
"archived": bool(getattr(set_meta, "archived", False)),
167+
"official": bool(getattr(set_meta, "official", False)),
168+
"url": (
169+
f"https://t.me/{url_prefix}/{set_meta.short_name}"
170+
if getattr(set_meta, "short_name", None)
171+
else None
172+
),
173+
}
174+
175+
# Build emoticon mapping from packs
176+
doc_to_emoticon: Dict[int, str] = {}
177+
for sp in getattr(result, "packs", []) or []:
178+
emoticon = getattr(sp, "emoticon", "")
179+
for doc_id in getattr(sp, "documents", []) or []:
180+
if doc_id not in doc_to_emoticon:
181+
doc_to_emoticon[doc_id] = emoticon
182+
183+
emojis = []
184+
for doc in getattr(result, "documents", []) or []:
185+
doc_id = getattr(doc, "id", None)
186+
if doc_id is None:
187+
continue
188+
189+
alt = doc_to_emoticon.get(doc_id, "")
190+
free = False
191+
for attr in getattr(doc, "attributes", []) or []:
192+
if isinstance(attr, types.DocumentAttributeCustomEmoji):
193+
alt = getattr(attr, "alt", alt) or alt
194+
free = bool(getattr(attr, "free", False))
195+
elif isinstance(attr, types.DocumentAttributeSticker):
196+
alt = getattr(attr, "alt", alt) or alt
197+
198+
mime_type = getattr(doc, "mime_type", "")
199+
is_animated = mime_type == "application/x-tgsticker" or any(
200+
isinstance(a, types.DocumentAttributeAnimated)
201+
for a in getattr(doc, "attributes", []) or []
202+
)
203+
is_video = mime_type == "video/webm" or any(
204+
isinstance(a, types.DocumentAttributeVideo)
205+
for a in getattr(doc, "attributes", []) or []
206+
)
207+
if is_animated:
208+
fmt = "animated"
209+
elif is_video:
210+
fmt = "video"
211+
else:
212+
fmt = "static"
213+
214+
emojis.append(
215+
{
216+
"document_id": str(doc_id),
217+
"id": str(doc_id),
218+
"alt": sanitize_user_content(alt),
219+
"emoji": sanitize_user_content(alt),
220+
"free": free,
221+
"format": fmt,
222+
"mime_type": mime_type,
223+
}
224+
)
225+
226+
return json.dumps({"pack": pack_info, "emojis": emojis}, indent=2, default=json_serializer)
227+
except Exception as e:
228+
return log_and_format_error("get_custom_emoji_pack", e, pack=pack)
229+
230+
231+
@mcp.tool(
232+
annotations=ToolAnnotations(
233+
title="Preview Custom Emoji", openWorldHint=True, readOnlyHint=True
234+
)
235+
)
236+
@with_account(readonly=True)
237+
async def preview_custom_emoji(
238+
document_id: Union[int, str],
239+
thumbnail: bool = True,
240+
save_path: Optional[str] = None,
241+
ctx: Optional[Context] = None,
242+
account: Optional[str] = None,
243+
):
244+
"""
245+
Preview or download a custom emoji by its Telegram document ID.
246+
247+
Args:
248+
document_id: Telegram document ID of the custom emoji (from get_custom_emoji_pack or message metadata).
249+
thumbnail: If True, downloads the static thumbnail image preview (ideal for animated TGS and video WebM emojis).
250+
save_path: Optional path under allowed roots to save the preview or emoji file.
251+
account: Optional account alias or phone number.
252+
"""
253+
try:
254+
try:
255+
doc_id = int(str(document_id).strip())
256+
if doc_id <= 0:
257+
raise ValueError()
258+
except (ValueError, TypeError):
259+
return "Invalid document_id: must be a positive integer."
260+
261+
cl = get_client(account)
262+
await ensure_connected(cl)
263+
264+
docs = await cl(functions.messages.GetCustomEmojiDocumentsRequest(document_id=[doc_id]))
265+
if not docs:
266+
return f"Custom emoji with document ID {doc_id} not found."
267+
268+
doc = docs[0]
269+
270+
data = None
271+
if thumbnail:
272+
try:
273+
data = await cl.download_media(doc, file=bytes, thumb=-1)
274+
except Exception:
275+
data = None
276+
if not data:
277+
data = await cl.download_media(doc, file=bytes)
278+
279+
if not data:
280+
return f"Failed to download media for custom emoji {doc_id}."
281+
282+
ext = ".webp"
283+
img_format = None
284+
if data.startswith(b"\xff\xd8\xff"):
285+
ext = ".jpg"
286+
img_format = "jpeg"
287+
elif data.startswith(b"\x89PNG"):
288+
ext = ".png"
289+
img_format = "png"
290+
elif data.startswith(b"RIFF") and len(data) >= 12 and data[8:12] == b"WEBP":
291+
ext = ".webp"
292+
img_format = "webp"
293+
elif data.startswith(b"\x1f\x8b"):
294+
ext = ".tgs"
295+
elif data.startswith(b"\x1aE\xdf\xa3"):
296+
ext = ".webm"
297+
298+
saved_path_str = None
299+
if save_path:
300+
kept_path, path_error = await _resolve_writable_file_path(
301+
raw_path=save_path,
302+
default_filename=f"custom_emoji_{doc_id}{ext}",
303+
ctx=ctx,
304+
tool_name="preview_custom_emoji",
305+
)
306+
if path_error:
307+
return path_error
308+
kept_path.write_bytes(data)
309+
saved_path_str = str(kept_path)
310+
311+
if img_format:
312+
image = Image(data=data, format=img_format)
313+
if saved_path_str:
314+
return [f"Custom emoji {doc_id} saved to {saved_path_str}.", image]
315+
return image
316+
317+
if saved_path_str:
318+
return f"Custom emoji {doc_id} downloaded and saved to {saved_path_str}."
319+
320+
return (
321+
f"Custom emoji {doc_id} has format '{ext.lstrip('.')}' ({getattr(doc, 'mime_type', 'unknown')}) "
322+
"which cannot be rendered as a static image without thumbnail. "
323+
"Please call with thumbnail=True or provide save_path to download."
324+
)
325+
except Exception as e:
326+
return log_and_format_error(
327+
"preview_custom_emoji", e, document_id=document_id, save_path=save_path
328+
)
329+
330+
331+
__all__ = [
332+
"extract_pack_short_name",
333+
"get_custom_emoji_packs",
334+
"get_custom_emoji_pack",
335+
"preview_custom_emoji",
336+
]

‎telegram_mcp/tools/stickers.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Compatibility alias module for stickers and custom emoji tools."""
2+
3+
from telegram_mcp.tools.custom_emoji import (
4+
extract_pack_short_name,
5+
get_custom_emoji_pack,
6+
get_custom_emoji_packs,
7+
preview_custom_emoji,
8+
)
9+
10+
__all__ = [
11+
"extract_pack_short_name",
12+
"get_custom_emoji_packs",
13+
"get_custom_emoji_pack",
14+
"preview_custom_emoji",
15+
]

0 commit comments

Comments
 (0)