File size: 10,045 Bytes
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
848377f
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f159e6
61d6e2a
 
 
 
 
 
 
 
 
 
4f159e6
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4f159e6
 
 
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
65651b1
 
 
 
61d6e2a
65651b1
 
 
 
 
61d6e2a
 
 
65651b1
 
 
 
 
 
 
61d6e2a
 
65651b1
 
 
 
 
 
 
 
61d6e2a
de342dc
 
 
61d6e2a
de342dc
61d6e2a
 
 
 
 
314215f
61d6e2a
de342dc
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
1dfae7a
 
61d6e2a
 
 
 
 
 
848377f
 
 
 
 
 
 
61d6e2a
 
1dfae7a
 
61d6e2a
 
 
2e79f82
 
 
 
 
 
 
 
 
61d6e2a
2e79f82
 
 
 
 
 
 
 
61d6e2a
 
2e79f82
 
 
de342dc
 
 
 
53e3df0
314215f
53e3df0
 
 
 
 
314215f
 
 
de342dc
61d6e2a
 
 
de342dc
 
 
 
 
 
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
4f159e6
 
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
2e79f82
 
 
 
 
 
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
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
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
"""Drive the real handlers with fake events and a fake Telegram client (no network)."""
import itertools
import re
from types import SimpleNamespace

from telethon import events, functions, types

from app.common import Ctx
from app.config import Config
from app.db import Database

from bot import register_all

from .dbutil import db_url_for
from .fakes import FakeChannelClient, rpc


class FakeSent:
    """A message the bot sent to the user (so `.edit()` works on it)."""

    def __init__(self, out, text, buttons):
        self.out, self.text, self.buttons = out, text, buttons

    async def edit(self, text=None, buttons=None, **kw):
        if text is not None:
            self.text = text
        self.buttons = buttons
        self.out.log.append(("edit", self.text, buttons))


class FakeEvent:
    def __init__(self, uid, out, text="", data=None, message=None, reply=None):
        self.sender_id, self.out = uid, out
        self.is_private = True
        self.is_channel = False
        self.is_group = False
        self.raw_text = text
        self.text = text
        self.message = message or SimpleNamespace(
            raw_text=text, entities=[], media=None, grouped_id=None, forward=None
        )
        self.pattern_match = None
        self._reply = reply
        self._is_callback = data is not None
        if data is not None:
            self.data = data.encode()

    async def respond(self, text=None, buttons=None, file=None, **kw):
        self.out.log.append(("say", text, buttons))
        return FakeSent(self.out, text, buttons)

    async def edit(self, text=None, buttons=None, **kw):
        self.out.log.append(("edit", text, buttons))

    async def answer(self, *a, **kw):
        return None

    async def get_input_chat(self):
        return types.InputPeerUser(self.sender_id, 0)

    async def get_reply_message(self):
        return self._reply


class Outbox:
    def __init__(self):
        self.log = []

    @property
    def last_text(self):
        return self.log[-1][1] if self.log else None

    def last_buttons(self):
        b = self.log[-1][2]
        return [[getattr(x, "text", None) for x in row] for row in (b or [])]

    def _data_of(self, btn):
        t = btn.type if hasattr(btn, "type") else btn
        return t.data.decode()

    def callback_data(self, label_part):
        """Data of the first inline button (in the last message) whose label contains `label_part`.

        A channel chooser writes the names in the message and puts only numbers on its buttons: when no button has
        the label, `label_part` is looked for in the numbered list and the button with that number is taken - what a
        person does when they find the channel in the list and tap its number."""
        for row in self.log[-1][2] or []:
            for btn in row:
                if label_part in btn.text:
                    return self._data_of(btn)
        listed = re.search(rf"(?m)^(\d+)\. [^\n]*{re.escape(label_part)}", norm(self.last_text))
        if listed:
            for row in self.log[-1][2] or []:
                for btn in row:
                    if btn.text == listed.group(1):
                        return self._data_of(btn)
        raise AssertionError(f"no button {label_part!r} in {self.last_buttons()}")

    def callback_exact(self, label):
        """Data of the button whose label is exactly `label` (the number buttons of a chooser, ◀ ▶ ...)."""
        for row in self.log[-1][2] or []:
            for btn in row:
                if btn.text == label:
                    return self._data_of(btn)
        raise AssertionError(f"no button {label!r} in {self.last_buttons()}")


class FakeTG(FakeChannelClient):
    """The bot's Telegram connection: a simulated channel plus a log of what the bot sent to the user."""

    def __init__(self):
        super().__init__()
        self.handlers = []
        self.sent = []
        self.edits = []
        self.deleted = []
        self.ids = itertools.count(100)
        self.dm_rich_ids = []  # ids of the rich messages the bot sent to a person (previews and checks)
        self.parse_mode = "html"
        self.rights = dict(admin=True, post=True, edit=True, delete=True, invite=False, add_admins=False)

    def on(self, builder):
        def deco(fn):
            self.handlers.append((builder, fn))
            return fn

        return deco

    async def send_message(self, peer, message="", **kw):
        m = SimpleNamespace(id=next(self.ids))
        self.sent.append(("text", peer, message, kw))
        return m

    async def send_file(self, peer, file, **kw):
        if self._is_dm(peer):  # to the userbot account: the chat simulation
            return await super().send_file(peer, file, **kw)
        m = SimpleNamespace(id=next(self.ids))
        self.sent.append(("file", peer, file, kw))
        return m

    async def edit_message(self, peer, message, **kw):
        self.edits.append(("edit_message", message, kw))
        err = self.fail.get("edit_message")  # Telegram refuses the bot's high-level edit (text / caption / new media)
        if err is not None:
            raise err
        chan = self.registry.get(getattr(peer, "channel_id", None), self)
        m = chan.msgs.get(message)
        if m is not None and getattr(m, "via_other_bot", False):  # its keyboard belongs to another bot: only that bot may edit
            raise rpc("MessageIdInvalidError")

    async def delete_messages(self, peer, ids, **kw):
        if self._is_dm(peer):
            return await super().delete_messages(peer, ids, **kw)
        self.deleted.append(list(ids))
        return [SimpleNamespace(pts_count=len(ids))]

    async def get_entity(self, what):
        """Public channels by @username, or any channel by PeerChannel - the ones put next to this one."""
        for chan in self.registry.values():
            if isinstance(what, str) and chan.username and chan.username.lower() == what.lstrip("@").lower():
                return chan._channel_obj()
            if isinstance(what, types.PeerChannel) and what.channel_id == chan.cid:
                return chan._channel_obj()
        raise ValueError(f"Cannot find any entity corresponding to {what!r}")

    async def __call__(self, req):
        if isinstance(req, functions.messages.CheckChatInviteRequest):
            for chan in self.registry.values():
                if chan.invite_hash == req.hash:
                    return types.ChatInviteAlready(chat=chan._channel_obj())
            raise ValueError("INVITE_HASH_INVALID")
        target = self._target(req)
        if target is not self:
            return await target(req)
        if isinstance(req, functions.messages.EditMessageRequest):
            self.edits.append(("raw", req.id, req))
            err = self.fail.get("EditMessageRequest")
            if err is not None:
                raise err
            if req.id not in self.msgs:  # a post the simulation doesn't hold: just note the edit
                return None
        if isinstance(req, functions.channels.DeleteMessagesRequest):
            self.deleted.append(list(req.id))
        if isinstance(req, functions.messages.SendMessageRequest) and req.rich_message is not None:
            self.sent.append(("rich", req.peer, req.rich_message, {"reply_markup": req.reply_markup, "silent": req.silent}))
            if isinstance(req.peer, types.InputPeerUser):  # a chat with a person (the preview): nothing lands in a channel
                if self.fail.get("SendMessageRequest") is not None:
                    raise self.fail["SendMessageRequest"]
                bytes(req)  # it must serialise
                self._rich(req.rich_message)  # Telegram has to be able to make blocks of it
                mid = next(self.ids)
                self.dm_rich_ids.append(mid)
                return types.UpdateShortSentMessage(id=mid, pts=0, pts_count=0, date=None, out=True)
        return await super().__call__(req)


class App:
    def __init__(self, tmp_path, owner=1, **cfg_overrides):
        base = dict(api_id=1, api_hash="x", bot_token="1:x", owners=frozenset({owner}), admins=frozenset(),
                    database_url=db_url_for(tmp_path, "h.db"), db_pool="null", session_path="x", session_string="",
                    edit_delay=0.3, replace_typed_links=True, port=0, log_level="INFO")
        base.update(cfg_overrides)
        cfg = Config(**base)
        self.tg = FakeTG()
        self.db = Database(cfg.database_url)
        self.ctx = Ctx(cfg=cfg, db=self.db, client=self.tg)
        self.uid = owner
        self.out = Outbox()

    async def start(self):
        from app.db import Base

        async with self.db.engine.begin() as conn:
            await conn.run_sync(Base.metadata.drop_all)
        await self.db.init()
        register_all(self.ctx)

    async def text(self, s, message=None, reply=None):
        ev = FakeEvent(self.uid, self.out, s, message=message, reply=reply)
        for builder, cb in self.tg.handlers:
            if not isinstance(builder, events.NewMessage):
                continue
            if builder.pattern:
                m = builder.pattern(s)
                if not m:
                    continue
                ev.pattern_match = m
            if builder.func and not builder.func(ev):
                continue
            await cb(ev)
        return ev

    async def raw(self, update):
        """Hand a raw Telegram update (a new / edited / deleted channel message ...) to the handlers that listen for it."""
        for builder, cb in self.tg.handlers:
            if isinstance(builder, events.Raw) and builder.filter(update) is not None:
                await cb(update)

    async def press(self, data):
        ev = FakeEvent(self.uid, self.out, data=data)
        for builder, cb in self.tg.handlers:
            if isinstance(builder, events.CallbackQuery):
                await cb(ev)
        return ev


def norm(s):
    import html

    return html.unescape(re.sub(r"<[^>]+>", "", s or ""))