File size: 9,131 Bytes
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53e3df0
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53e3df0
 
 
 
2e79f82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
"""Links that point at other posts of the same channel (t.me/name/115, t.me/c/123456/115, tg://...&post=115).

When a channel is reposted its posts get new message ids, so a link to post 115 has to point at the copy of post 115.
Only the digits of the message id change; everything around them (?single, ?comment=3, the visible text) stays.
Links can sit in three places: a hyperlink behind text, a link typed into the text, and a button.
"""
from __future__ import annotations

import copy
import re
from dataclasses import dataclass, field
from typing import Optional

from telethon import types
from telethon.helpers import add_surrogate, del_surrogate

from .linkswap import apply_span_replacements
from .richmsg import is_rich_message
from .tgutil import button_url, with_url

_HOST = r"(?:t\.me|telegram\.me|telegram\.dog)"
_NAME = r"[A-Za-z][A-Za-z0-9_]{2,31}"
# https://t.me/name/115   https://t.me/c/123456/115   (also t.me/s/name/115, the web preview)
_WEB = re.compile(
    rf"^(?:https?://)?(?:www\.)?{_HOST}/(?:s/)?(?P<seg>c/(?P<cid>\d+)|(?P<name>{_NAME}))/(?P<id>\d+)(?![A-Za-z0-9_])", re.I
)
_TYPED = re.compile(
    rf"(?<![\w@./=?&#-])(?:https?://)?(?:www\.)?{_HOST}/(?:s/)?(?:c/\d+|{_NAME})/\d+(?![A-Za-z0-9_])", re.I
)
# tg://resolve?domain=name&post=115   tg://privatepost?channel=123456&post=115
_TG = re.compile(r"^tg://(?P<kind>resolve|privatepost)\?(?P<query>[^#]*)", re.I)
_POST_PARAM = re.compile(r"(?<=[?&])post=(\d+)")


@dataclass(frozen=True)
class Ref:
    """A link to a post: which channel (username in lower case, or the numeric id) and where the id's digits sit."""

    name: Optional[str]
    cid: Optional[int]
    msg_id: int
    start: int  # the digits of the message id ...
    end: int
    seg_start: Optional[int] = None  # ... and the channel part in front of them ("name" or "c/123"); web links only


def parse_ref(url: str) -> Optional[Ref]:
    """What `url` points at, if it is a link to a post of some channel; None for every other link."""
    url = url or ""
    m = _WEB.match(url)
    if m:
        return Ref(
            name=m.group("name").lower() if m.group("name") else None,
            cid=int(m.group("cid")) if m.group("cid") else None,
            msg_id=int(m.group("id")),
            start=m.start("id"),
            end=m.end("id"),
            seg_start=m.start("seg"),
        )
    m = _TG.match(url)
    if m:
        params = dict(p.split("=", 1) for p in m.group("query").split("&") if "=" in p)
        post = _POST_PARAM.search(url)
        if post is None:
            return None
        if m.group("kind").lower() == "resolve" and re.fullmatch(r"[A-Za-z0-9_]+", params.get("domain", "")):
            return Ref(params["domain"].lower(), None, int(post.group(1)), post.start(1), post.end(1))
        if m.group("kind").lower() == "privatepost" and params.get("channel", "").isdigit():
            return Ref(None, int(params["channel"]), int(post.group(1)), post.start(1), post.end(1))
    return None


@dataclass
class PostLinks:
    """The links that point into one channel, and where its posts went: `ids` maps old message id -> new one.

    By default the copies are in the same channel (/repost): only the message id changes. With `target_id` the
    copies are in another channel (/shift): the channel part of the link changes too."""

    username: Optional[str]
    channel_id: int
    ids: dict = field(default_factory=dict)
    target_username: Optional[str] = None
    target_id: Optional[int] = None

    @classmethod
    def for_channel(cls, ch, ids: Optional[dict] = None) -> "PostLinks":
        return cls(username=(ch.username or None), channel_id=int(ch.id), ids=ids if ids is not None else {})

    # -------------------------------------------------------------------------------------- one url
    def ref_of(self, url: str) -> Optional[Ref]:
        """The reference in `url` if it points at a post of THIS channel."""
        r = parse_ref(url)
        if r is None:
            return None
        if r.name is not None and self.username and r.name == self.username.lower():
            return r
        if r.cid is not None and r.cid == self.channel_id:
            return r
        return None

    def edit_of(self, url: str) -> Optional[tuple]:
        """(start, end, new text) to apply to `url` so it points at the copy of its post; None if it stays as it is."""
        r = self.ref_of(url)
        if r is None or r.msg_id not in self.ids:
            return None
        new_id = str(self.ids[r.msg_id])
        if self.target_id is None:
            return r.start, r.end, new_id
        if r.seg_start is None:  # tg:// links are not moved to another channel
            return None
        channel = self.target_username or f"c/{self.target_id}"
        return r.seg_start, r.end, f"{channel}/{new_id}"

    def remap_url(self, url: str) -> str:
        e = self.edit_of(url)
        return url if e is None else url[: e[0]] + e[2] + url[e[1] :]

    # --------------------------------------------------------------------------------- whole message
    def count(self, text: str, entities, markup) -> int:
        """How many links in a message point at a post of this channel (whether or not the post has a new id)."""
        n = 0
        sur = add_surrogate(text or "")
        for m in _TYPED.finditer(sur):
            if self.ref_of(m.group(0)):
                n += 1
        for e in entities or []:
            if isinstance(e, types.MessageEntityTextUrl) and self.ref_of(e.url):
                n += 1
        if isinstance(markup, types.ReplyInlineMarkup):
            for row in markup.rows:
                for b in row.buttons:
                    u = button_url(b)
                    if u is not None and self.ref_of(u):
                        n += 1
        return n

    def remap_text(self, text: str, entities) -> tuple:
        """-> (text, entities, typed_links_changed, hyperlinks_changed). Offsets of all entities stay right."""
        ents = list(entities or [])
        sur = add_surrogate(text or "")
        repls = []
        for m in _TYPED.finditer(sur):
            e = self.edit_of(m.group(0))
            if e is not None:
                repls.append((m.start() + e[0], m.start() + e[1], e[2]))
        if repls:
            new_sur, new_ents = apply_span_replacements(sur, ents, repls)
        else:
            new_sur, new_ents = sur, [copy.copy(e) for e in ents]
        hyper = 0
        for e in new_ents:
            if isinstance(e, types.MessageEntityTextUrl):
                new_url = self.remap_url(e.url)
                if new_url != e.url:
                    e.url = new_url
                    hyper += 1
        return del_surrogate(new_sur), new_ents, len(repls), hyper

    def remap_markup(self, markup) -> tuple:
        """-> (markup, buttons_changed); the keyboard object is only rebuilt when a button changes."""
        if not isinstance(markup, types.ReplyInlineMarkup):
            return markup, 0
        rows, changed = [], 0
        for row in markup.rows:
            btns = []
            for b in row.buttons:
                u = button_url(b)
                new = self.remap_url(u) if u is not None else None
                if new is not None and new != u:
                    btns.append(with_url(b, new))
                    changed += 1
                else:
                    btns.append(b)
            rows.append(types.KeyboardButtonRow(btns))
        return (types.ReplyInlineMarkup(rows) if changed else markup), changed


@dataclass
class Relinked:
    """A message whose links to other posts of the channel were pointed at the copies."""

    text: str
    entities: list
    markup: Optional[types.ReplyInlineMarkup]
    text_changed: bool  # the text or its entities differ
    markup_changed: bool
    n_typed: int
    n_hyper: int
    n_buttons: int

    @property
    def links(self) -> int:
        return self.n_typed + self.n_hyper + self.n_buttons


def relink_message(msg, links: PostLinks) -> Optional[Relinked]:
    """The new text / entities / buttons of `msg` once its links are remapped; None when nothing changes."""
    if is_rich_message(msg):  # the text of a rich message is blocks: only its buttons are changed
        text, entities, n_typed, n_hyper = "", [], 0, 0
    else:
        text, entities, n_typed, n_hyper = links.remap_text(msg.message or "", msg.entities)
    markup, n_buttons = links.remap_markup(msg.reply_markup)
    if not (n_typed or n_hyper or n_buttons):
        return None
    return Relinked(
        text=text,
        entities=entities,
        markup=markup,
        text_changed=bool(n_typed or n_hyper),
        markup_changed=bool(n_buttons),
        n_typed=n_typed,
        n_hyper=n_hyper,
        n_buttons=n_buttons,
    )


def resolve_chain(step: dict) -> dict:
    """Several reposts in a row: {1: 5, 5: 9} -> {1: 9, 5: 9} (a post that moved twice points at its latest copy)."""
    out = {}
    for old in step:
        cur, seen = old, 0
        while cur in step and seen < 100:
            cur, seen = step[cur], seen + 1
        out[old] = cur
    return out