File size: 9,100 Bytes
de342dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The repost / delete / buttons / userbot flows through a REAL Telethon client object.

Telethon resolves and serialises every request exactly as it does before sending it to Telegram; only the network at
the very end is replaced by the simulated channel. A request that would not build or serialise in production fails here.
"""
import asyncio
import datetime

import pytest
from telethon import TelegramClient, functions, types, utils
from telethon.sessions import MemorySession

from app.buttons_sync import ensure_markup, fix_buttons, scan_buttons
from app.repost_engine import RepostOptions, delete_copies, plan_repost, run_repost
from app.tgutil import build_markup, button_url, get_rights, peer_of
from app.userbot import Userbot

from .dbutil import db_url_for, fresh_db
from .fakes import FakeChannelClient, FakeUserClient, make_msg, markup, url_btn
from .test_repost import NEW, OLD, build


@pytest.fixture()
def wired(monkeypatch):
    chan = FakeChannelClient(build())
    chan.rights = dict(admin=True, post=True, edit=True, delete=True, invite=True, add_admins=True)
    seen = []

    async def fake_call(self, request, ordered=False, flood_sleep_threshold=None):
        assert not isinstance(request, (list, tuple))
        await request.resolve(self, utils)  # what Telethon does just before sending
        seen.append(type(request).__name__)
        return await chan(request)  # serialises again and answers like Telegram

    monkeypatch.setattr(TelegramClient, "__call__", fake_call)
    return TelegramClient(MemorySession(), 1, "h"), chan, seen


async def setup(tmp_path, client):
    db = await fresh_db(db_url_for(tmp_path))
    await db.save_channel(1, 5, "Test", "testch", 99)
    ch = await db.get_channel(1)
    plan = await plan_repost(client, ch, RepostOptions(OLD, NEW))
    assert plan.error is None
    await db.create_migration(
        "m1", 1, old=OLD, new=NEW, include_typed=True, include_posts=False, first_id=plan.first, last_id=plan.last, partial=False, user_id=99
    )
    return db, ch, await db.get_migration("m1")


def test_repost_then_delete_through_a_real_client(wired, tmp_path):
    client, chan, seen = wired

    async def go():
        db, ch, mig = await setup(tmp_path, client)
        res = await run_repost(client, db, ch, mig, 99, delay=0, settle_pause=0)
        assert (res.copied_units, res.copied_messages, res.failed, res.aborted) == (7, 8, [], None)
        assert (res.forwarded, res.rebuilt) == (3, 4)
        new = [chan.msgs[i] for i in sorted(chan.msgs) if i > 10]
        assert len(new) == 8 and new[0].message == "Welcome"
        assert button_url(new[1].reply_markup.rows[0].buttons[0]) == f"https://t.me/{NEW}?start=AAA"
        out = await delete_copies(client, db, ch, mig, "old", delay=0)
        assert (out.deleted, out.remaining, out.error) == (8, 0, None)
        assert sorted(chan.msgs) == [6] + list(range(11, 19))
        await db.close()

    asyncio.run(go())
    assert {"GetFullChannelRequest", "GetMessagesRequest", "ForwardMessagesRequest", "SendMessageRequest", "SendMediaRequest", "DeleteMessagesRequest"} <= set(seen)


def test_a_channel_that_forbids_forwarding_rebuilds_albums_through_a_real_client(wired, tmp_path):
    client, chan, seen = wired
    chan.restrict_forwards = True

    async def go():
        db, ch, mig = await setup(tmp_path, client)
        res = await run_repost(client, db, ch, mig, 99, delay=0, settle_pause=0)
        assert (res.copied_units, res.failed, res.forwarded, res.rebuilt) == (7, [], 0, 7)
        new = {i: m for i, m in chan.msgs.items() if i > 10}
        assert new[14].grouped_id == new[15].grouped_id and new[14].media.photo.id == 41
        await db.close()

    asyncio.run(go())
    assert "SendMultiMediaRequest" in seen


def test_the_repair_edits_go_through_a_real_client(wired, tmp_path):
    client, chan, seen = wired
    chan.hide_keyboard_on_create = 1

    async def go():
        db, ch, mig = await setup(tmp_path, client)
        res = await run_repost(client, db, ch, mig, 99, delay=0, settle_pause=0)
        assert res.failed == [] and res.repaired == 1
        await db.close()

    asyncio.run(go())
    # the plain edit (Telegram answers "not modified"), then the stand-in keyboard, then the real one
    assert seen.count("EditMessageRequest") == 3 and len(chan.edits) == 2


def test_checking_and_fixing_buttons_through_a_real_client(wired, tmp_path):
    client, chan, seen = wired
    A, B = "https://t.me/goku?start=A", "https://t.me/goku?start=B"
    chan.msgs = {
        1: make_msg(1, "fine", markup=markup([url_btn("Get", A)]), out=True),
        2: make_msg(2, "lost its buttons", out=True),
        3: make_msg(3, "wrong link", markup=markup([url_btn("Get", B)]), out=True),
    }
    chan._top = 3

    async def go():
        db = await fresh_db(db_url_for(tmp_path))
        await db.save_channel(1, 5, "Test", "testch", 99)
        ch = await db.get_channel(1)
        posts = [
            await db.create_post(channel_id=1, message_id=i, status="sent", source="bot", text=f"p{i}", entities=[], buttons=[[{"t": "Get", "u": A}]])
            for i in (1, 2, 3)
        ]
        scan = await scan_buttons(client, ch, posts)
        assert (scan.total, scan.fine, [p.message_id for p, _, _ in scan.bad], scan.gone) == (3, 1, [2, 3], [])
        res = await fix_buttons(client, ch, scan.bad, delay=0, pause=0)
        assert (res.fixed, res.failed) == (2, [])
        assert [button_url(chan.msgs[i].reply_markup.rows[0].buttons[0]) for i in (1, 2, 3)] == [A, A, A]
        assert await ensure_markup(client, peer_of(ch), 2, build_markup([[{"t": "Get", "u": A}]]), pause=0) == "fine"
        await db.close()

    asyncio.run(go())
    assert "EditMessageRequest" in seen


def test_the_bots_own_rights_are_read_through_a_real_client(wired):
    client, chan, seen = wired

    async def go():
        ch = type("Ch", (), {"id": 1, "access_hash": 5})()
        r = await get_rights(client, ch)
        assert (r.admin, r.post, r.delete, r.invite, r.add_admins) == (True, True, True, True, True)
        chan.rights = dict(admin=True, post=True, edit=False, delete=False, invite=False, add_admins=False)
        r = await get_rights(client, ch)
        assert (r.post, r.delete, r.invite, r.add_admins) == (True, False, False, False)

    asyncio.run(go())
    assert seen == ["GetParticipantRequest", "GetParticipantRequest"]


def test_userbot_clean_up_with_a_real_bot_client(wired, tmp_path):
    """Bot side (invite link, promotion, demotion, revoking) runs through the real client; the helper account is simulated."""
    client, chan, seen = wired
    chan.undeletable = {1, 2, 3, 4, 5}

    class Cfg:
        userbot_session, userbot_keep_admin, api_id, api_hash = "S", False, 1, "h"

    async def go():
        db, ch, mig = await setup(tmp_path, client)
        await run_repost(client, db, ch, mig, 99, delay=0, settle_pause=0)
        ub = Userbot(client, Cfg(), client_factory=lambda cfg: FakeUserClient(chan))
        fallback, deleter = await ub.fallback_for(ch)
        try:
            out = await delete_copies(client, db, ch, mig, "old", delay=0, fallback=fallback)
        finally:
            await deleter.close()
        assert (out.deleted, out.by_userbot, out.remaining, out.error, out.fallback_error) == (8, 5, 0, None, None)
        assert chan.admins == {} and all(v["revoked"] for v in chan.invites.values())
        await db.close()

    asyncio.run(go())
    for name in ("ExportChatInviteRequest", "EditAdminRequest", "GetParticipantsRequest", "EditExportedChatInviteRequest"):
        assert name in seen, (name, seen)
    assert seen.count("EditAdminRequest") == 2  # promote, then demote


def test_invite_and_admin_requests_build_with_the_values_the_userbot_code_uses(wired):
    client, chan, seen = wired
    peer = types.InputPeerChannel(1, 5)
    expires = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1)

    async def go():
        inv = await client(functions.messages.ExportChatInviteRequest(peer=peer, expire_date=expires, usage_limit=1, title="Cleanup helper"))
        assert inv.link.startswith("https://t.me/+") and inv.usage_limit == 1
        await client(functions.messages.EditExportedChatInviteRequest(peer=peer, link=inv.link, revoked=True))
        chan.users[777] = types.User(id=777, access_hash=555, first_name="H")
        await client(
            functions.channels.EditAdminRequest(
                channel=peer, user_id=types.InputUser(777, 555), admin_rights=types.ChatAdminRights(delete_messages=True), rank=""
            )
        )
        assert chan.admins[777].delete_messages
        await client(
            functions.channels.EditAdminRequest(channel=peer, user_id=types.InputUser(777, 555), admin_rights=types.ChatAdminRights(), rank="")
        )
        assert chan.admins == {}
        res = await client(
            functions.channels.GetParticipantsRequest(channel=peer, filter=types.ChannelParticipantsSearch("h"), offset=0, limit=200, hash=0)
        )
        assert res.users == []

    asyncio.run(go())