File size: 9,683 Bytes
65651b1
61d6e2a
 
 
 
 
 
 
 
65651b1
 
53e3df0
848377f
de342dc
61d6e2a
 
 
 
 
 
 
c53c2ab
2e79f82
848377f
53e3df0
61d6e2a
 
 
c53c2ab
61d6e2a
2e79f82
848377f
109b8ca
2e79f82
109b8ca
2e79f82
c71e4ac
 
 
 
65651b1
848377f
65651b1
 
849f335
61d6e2a
 
 
 
 
 
849f335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61d6e2a
 
 
 
 
 
848377f
 
 
 
 
53e3df0
 
 
 
 
 
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
65651b1
 
 
 
 
61d6e2a
de342dc
 
 
 
 
 
 
 
848377f
 
 
 
 
de342dc
 
1dfae7a
 
de342dc
 
 
 
1dfae7a
 
 
 
 
 
 
de342dc
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65651b1
 
 
 
 
 
 
 
61d6e2a
 
65651b1
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
"""/start /help /cancel /export /userbot, plus the routers for free text and inline-button presses (/import is in backup.py)."""
from __future__ import annotations

import io
import json
import logging

from telethon import events

from ..common import Ctx, UserError, cmd, clear_state, discard_temp, guard, say, show
from ..picker import PAGE_CB
from ..richmsg import RICH_HELP, RICH_HELP_HTML
from ..richtext import CHEAT_SHEET
from ..tgutil import esc

log = logging.getLogger(__name__)

HELP = (
    "<b>Channel Post Bot</b>\n\n"
    "<b>Posts</b>\n"
    "/new - create a post (text or media, with buttons)\n"
    "/posts - your posts and drafts: edit text, caption, media and buttons, or delete them - a published post is deleted in the channel and in My posts (a button post also has “Mass replace links”)\n"
    "/autopost 114 20 - post “Episodes 01 to 20”, “21 to 40” ... up to 114, each with a download button, then fill in the links\n"
    "/formatting - bold, italic, links, quotes ...: Telegram's own formatting is kept, or type HTML / Markdown instead\n"
    "/rich - rich messages: headings, lists, tables, expandable sections, math, maps, pictures, buttons ... typed as HTML / Markdown, or sent as a Telegram rich message\n"
    "/cancel - stop what you're doing\n\n"
    "<b>Channels</b>\n"
    "/addchannel - register a channel (the bot must be an admin there)\n"
    "/channels - list your channels, or take one out (the bot asks whether its saved posts in My posts are deleted too)\n\n"
    "<b>Owner tools</b>\n"
    "/broadcast [time] - send one message to all channels; with a time (50m, 1h, 5d) it is deleted again from all channels and My posts afterwards\n"
    "/replace <post link> - reply to a new media message to replace that post's media and caption (where Telegram refuses the bot, the userbot does it)\n"
    "/swap @old @new - swap the username inside t.me links (button links and hyperlinks) in all channels\n"
    "/handleswap @old @new - change the username @old written in posts (text, captions, button names) to @new in all channels; links stay\n"
    "/undo - undo the last /swap or /handleswap\n"
    "/repost @old @new - copy every post of a channel in order (links swapped, replies, pins and links between posts kept), then delete the old posts\n"
    "/shift @source @destination [from_id] [to_id] [-c] [-all] [--no-media] - copy posts from one channel into another (buttons kept, listed in My posts); "
    "a channel Telegram restricts (copyright strike) is copied from My posts; "
    "-c: links to the source's posts in all your channels are changed to the copies; -all: the destination also gets the source's name, description and photo; "
    "--no-media: no files, only the text and buttons\n"
    "/sync - compare every channel with My posts right now (posts deleted, edited or added by others, the older posts from before the bot was added; service messages are deleted)\n"
    "/userbot - status of the helper account that, by itself and without asking, deletes and edits the posts the bot is not allowed to\n"
    "/export - download a JSON backup of your channels and posts\n"
    "/import - put a backup made by /export back (only adds: nothing that exists is changed or deleted)"
    "\n/admins - list admins (owner)\n/addadmin 123456789 - add an admin (owner)\n/removeadmin 123456789 - remove an admin (owner)"
)


def register(ctx: Ctx) -> None:
    client, db = ctx.client, ctx.db

    @client.on(cmd("admins"))
    @guard(ctx, owner=True)
    async def h_admins(event):
        ids = sorted(ctx.admins | set(ctx.cfg.admins))
        await say(event, "Admins: " + (", ".join(map(str, ids)) if ids else "none"))

    @client.on(cmd("addadmin"))
    @guard(ctx, owner=True)
    async def h_addadmin(event):
        parts = (event.raw_text or "").split()
        if len(parts) != 2 or not parts[1].isdigit() or int(parts[1]) <= 0:
            raise UserError("Use /addadmin followed by a numeric Telegram user ID.")
        uid = int(parts[1])
        if ctx.cfg.is_owner(uid):
            raise UserError("That user is already an owner.")
        if not await db.add_admin(uid):
            await say(event, f"{uid} is already an admin.")
            return
        ctx.admins.add(uid)
        await say(event, f"Added {uid} as an admin.")

    @client.on(cmd("removeadmin"))
    @guard(ctx, owner=True)
    async def h_removeadmin(event):
        parts = (event.raw_text or "").split()
        if len(parts) != 2 or not parts[1].isdigit() or int(parts[1]) <= 0:
            raise UserError("Use /removeadmin followed by a numeric Telegram user ID.")
        uid = int(parts[1])
        if not await db.remove_admin(uid):
            await say(event, f"{uid} is not an admin.")
            return
        ctx.admins.discard(uid)
        await say(event, f"Removed {uid} as an admin.")

    @client.on(cmd("start"))
    @client.on(cmd("help"))
    @guard(ctx)
    async def h_help(event):
        await say(event, HELP)

    @client.on(cmd("formatting"))
    @guard(ctx)
    async def h_formatting(event):
        await say(event, CHEAT_SHEET)

    @client.on(cmd("rich"))
    @guard(ctx)
    async def h_rich(event):
        await say(event, RICH_HELP)
        await say(event, RICH_HELP_HTML)

    @client.on(cmd("cancel"))
    @guard(ctx)
    async def h_cancel(event):
        await discard_temp(ctx, event.sender_id)
        clear_state(ctx, event.sender_id)
        await say(event, "Cancelled.")

    @client.on(cmd("export"))
    @guard(ctx, owner=True)
    async def h_export(event):
        data = await db.export_all()
        buf = io.BytesIO(json.dumps(data, ensure_ascii=False, indent=1).encode("utf-8"))
        buf.name = "channel-posts-export.json"
        await event.respond(
            f"📦 Backup: {len(data['channels'])} channel(s), {len(data['posts'])} post(s). "
            "To put it back (on this bot or a new one), send /import and then this file.",
            file=buf,
        )

    @client.on(cmd("userbot"))
    @guard(ctx, owner=True)
    async def h_userbot(event):
        ub = ctx.userbot
        if ub is None or not ub.enabled:
            await say(
                event,
                "🤖 <b>Userbot: not set up.</b>\n"
                "The bot does its work itself first. If Telegram refuses - deleting posts older than 48 hours, or "
                "editing a post that belongs to another bot (/replace, text and caption edits) - a helper account, "
                "the userbot, takes over by itself, without asking you each time: it joins the channel through an "
                "invite link the bot makes, the bot gives it the “Delete messages” or “Edit messages of others” right "
                "if it has the “Add new admins” right, and gives the rights back afterwards.\n\n"
                "To set it up: on your own computer run <code>python make_userbot_session.py</code> with a "
                "<b>separate</b> Telegram account, and put the printed text into the bot's secrets as "
                "<code>USERBOT_SESSION</code>. Then send /start to this bot from that account (new media reaches the "
                "userbot through its chat with the bot - that is what makes a replace quick). See the README.",
            )
            return
        if not ub.ready:
            await ub.connect()
        kept = "stays an admin" if ub.keep_admin else "is made admin only while it deletes or edits"
        await say(
            event,
            f"🤖 <b>Userbot:</b> {esc(ub.describe())}\nIt {kept} (USERBOT_KEEP_ADMIN).\n"
            "It must have started this bot (/start from that account): new media for an edit goes through its chat "
            "with the bot, which is what makes a big file as quick as a small one.",
        )

    @client.on(
        events.NewMessage(incoming=True, func=lambda e: e.is_private and not (e.raw_text or "").startswith("/"))
    )
    @guard(ctx)
    async def h_free_text(event):
        st = ctx.state.get(event.sender_id)
        if not st:
            await say(event, "Use /new to create a post or /posts to manage your posts. /help lists everything.")
            return
        fn = ctx.text_handlers.get(st["mode"])
        if fn is None:
            clear_state(ctx, event.sender_id)
            await say(event, "That step is no longer active. Start again with /new.")
            return
        await fn(event, st)

    @client.on(events.CallbackQuery())
    @guard(ctx)
    async def h_callback(event):
        try:
            await event.answer()  # stop the loading spinner right away
        except Exception:
            pass
        data = event.data.decode("utf-8", "replace")
        prefix, *parts = data.split(":")
        fn = ctx.callbacks.get(prefix)
        if fn is None:
            await event.respond("This button is no longer active. Use /new or /posts.")
            return
        await fn(event, parts)

    async def cb_noop(event, parts):
        await event.answer()

    async def cb_cancel(event, parts):
        uid = event.sender_id
        await discard_temp(ctx, uid)
        clear_state(ctx, uid)
        await show(event, "Cancelled.")

    async def cb_channel_page(event, parts):
        """◀ ▶ under a channel chooser: draw the same chooser again on another page."""
        kind, arg, page = parts[0], parts[1], int(parts[2])
        draw = ctx.pickers.get(kind)
        if draw is None:
            raise UserError("This list is no longer active. Start the command again.")
        await draw(event, arg, page)

    ctx.callbacks["noop"] = cb_noop
    ctx.callbacks["cx"] = cb_cancel
    ctx.callbacks[PAGE_CB] = cb_channel_page