Spaces:
Paused
Paused
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 ""))
|