113 lines
4.6 KiB
Python
113 lines
4.6 KiB
Python
"""Parse and validate Telegram message links.
|
|
|
|
Supported:
|
|
https://t.me/c/3909337424/14 private/internal channel link
|
|
https://t.me/c/3909337424/7/14 same, with a discussion-thread segment
|
|
https://t.me/channelname/14 public channel post
|
|
https://t.me/s/channelname/14 public web-preview link
|
|
|
|
Variants with ``www.``, ``telegram.me``, no scheme, a trailing slash, query
|
|
parameters (``?single``) or a fragment are accepted.
|
|
|
|
For ``/c/<id>/`` links the number is the channel's *internal* ID. The MTProto
|
|
"marked" peer ID used by Telethon is ``-(10**12 + id)`` (for 10-digit IDs this
|
|
looks like ``-100`` followed by the ID). It is only an identifier: access is
|
|
still verified by resolving it through the authenticated client.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from urllib.parse import urlsplit
|
|
|
|
_HOSTS = {"t.me", "telegram.me", "telegram.dog"}
|
|
_USERNAME = re.compile(r"^[A-Za-z][A-Za-z0-9_]{3,31}$")
|
|
# Paths on t.me that are not channel usernames.
|
|
_RESERVED = {
|
|
"addstickers", "addemoji", "addtheme", "addlist", "boost", "bg", "share", "proxy", "socks", "iv",
|
|
"login", "setlanguage", "invoice", "joinchat", "contact", "m", "confirmphone", "giftcode",
|
|
}
|
|
EXAMPLE = "https://t.me/c/3909337424/14"
|
|
|
|
|
|
class LinkError(ValueError):
|
|
"""Invalid link; the message is safe to show to the user."""
|
|
|
|
|
|
def marked_channel_id(internal_id: int) -> int:
|
|
return -(10**12 + internal_id)
|
|
|
|
|
|
def internal_channel_id(marked_id: int) -> int:
|
|
return -marked_id - 10**12
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MessageLink:
|
|
kind: str # "private" or "public"
|
|
channel: str # internal numeric ID (private) or username (public)
|
|
message_id: int
|
|
url: str
|
|
|
|
@property
|
|
def channel_ref(self) -> str:
|
|
"""Reference understood by telegram_client.resolve_channel."""
|
|
if self.kind == "private":
|
|
return str(marked_channel_id(int(self.channel)))
|
|
return self.channel
|
|
|
|
@property
|
|
def same_channel_key(self) -> str:
|
|
return self.channel if self.kind == "private" else self.channel.lower()
|
|
|
|
|
|
def parse_message_link(text: str | None) -> MessageLink:
|
|
raw = (text or "").strip()
|
|
if not raw:
|
|
raise LinkError(f"Please send a Telegram message link, for example {EXAMPLE}")
|
|
if any(ch.isspace() for ch in raw):
|
|
raise LinkError("Send only the link, without any other text.")
|
|
url = raw if re.match(r"^[a-z]+://", raw, re.IGNORECASE) else "https://" + raw
|
|
try:
|
|
parts = urlsplit(url)
|
|
except ValueError as exc:
|
|
raise LinkError(f"That is not a valid URL. Example: {EXAMPLE}") from exc
|
|
host = (parts.hostname or "").lower()
|
|
if host.startswith("www."):
|
|
host = host[4:]
|
|
if parts.scheme.lower() not in ("http", "https") or host not in _HOSTS:
|
|
raise LinkError(f"That is not a Telegram (t.me) link. Example: {EXAMPLE}")
|
|
|
|
segments = [s for s in parts.path.split("/") if s]
|
|
if not segments:
|
|
raise LinkError(f"The link does not point to a message. Example: {EXAMPLE}")
|
|
head = segments[0]
|
|
if head.startswith("+") or head.lower() == "joinchat":
|
|
raise LinkError("Invite links cannot be used. Open the channel, then copy the link of a specific post.")
|
|
|
|
if head == "c":
|
|
if len(segments) not in (3, 4) or not all(s.isdigit() for s in segments[1:]):
|
|
raise LinkError(f"Private-channel links must look like {EXAMPLE} (channel ID, then message ID).")
|
|
channel, message = segments[1], segments[-1]
|
|
kind = "private"
|
|
if int(channel) == 0:
|
|
raise LinkError("The channel ID in the link is invalid.")
|
|
else:
|
|
if head == "s":
|
|
segments = segments[1:]
|
|
if len(segments) == 1:
|
|
raise LinkError("This link points to a channel, not to a message. Copy the link of a specific post.")
|
|
if len(segments) not in (2, 3) or not segments[-1].isdigit():
|
|
raise LinkError(f"Unsupported Telegram link format. Use a post link such as {EXAMPLE}")
|
|
channel, message = segments[0], segments[-1]
|
|
if channel.lower() in _RESERVED or not _USERNAME.match(channel):
|
|
raise LinkError("Unsupported Telegram link type; send the link of a channel post.")
|
|
if len(segments) == 3 and not segments[1].isdigit():
|
|
raise LinkError(f"Unsupported Telegram link format. Use a post link such as {EXAMPLE}")
|
|
kind = "public"
|
|
|
|
message_id = int(message)
|
|
if message_id <= 0:
|
|
raise LinkError("The message ID in the link must be a positive number.")
|
|
return MessageLink(kind=kind, channel=channel, message_id=message_id, url=raw)
|