File size: 5,876 Bytes
61d6e2a
 
 
 
 
 
de342dc
61d6e2a
 
 
 
 
 
 
de342dc
2e79f82
 
 
65651b1
2e79f82
 
 
 
 
 
 
 
 
 
 
 
61d6e2a
2e79f82
de342dc
61d6e2a
 
 
 
 
 
2e79f82
848377f
61d6e2a
 
2e79f82
848377f
109b8ca
2e79f82
b2a4cfc
2e79f82
 
61d6e2a
848377f
61d6e2a
65651b1
849f335
 
 
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65651b1
61d6e2a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de342dc
 
 
 
 
 
 
 
 
 
 
 
61d6e2a
 
 
 
 
 
de342dc
 
 
 
 
 
2e79f82
de342dc
61d6e2a
de342dc
 
 
849f335
2e79f82
 
de342dc
61d6e2a
 
 
 
 
 
de342dc
 
 
 
 
 
 
 
 
 
2e79f82
 
61d6e2a
de342dc
 
 
61d6e2a
2e79f82
 
 
 
de342dc
 
 
 
61d6e2a
 
 
 
 
 
19099ec
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
"""Channel Post Bot - entry point.  Run:  python bot.py"""
from __future__ import annotations

import asyncio
import logging
import os
from urllib.parse import unquote, urlsplit

from telethon import TelegramClient, functions, types
from telethon.sessions import StringSession

from app.common import Ctx
from app.config import Config, load_config
from app.db import Database
from app.errorlog import ErrorReporter, TelegramLogHandler
from app.expiry import Expirer
from app.handlers import (
    autopost,
    backup,
    basic,
    broadcast,
    channels,
    checkbtn,
    create,
    masslinks,
    posts,
    replace,
    repost,
    shift,
    sync,
)
from app.health import start_health_server
from app.syncer import Syncer
from app.userbot import Userbot

log = logging.getLogger("bot")

COMMANDS = [
    ("new", "Create a post"),
    ("posts", "My posts and drafts"),
    ("autopost", "Post a series of episode download buttons"),
    ("formatting", "How to format text: bold, links, quotes ... (HTML / Markdown)"),
    ("addchannel", "Register a channel"),
    ("channels", "List channels"),
    ("broadcast", "Send one message to all channels, optionally timed (owner)"),
    ("replace", "Replace a post's media and caption (owner)"),
    ("swap", "Swap a username in links (owner)"),
    ("handleswap", "Swap a @username written in posts (owner)"),
    ("repost", "Copy a whole channel in order (owner)"),
    ("shift", "Copy posts from one channel to another (owner)"),
    ("sync", "Compare channels with My posts now (owner)"),
    ("undo", "Undo the last replace (owner)"),
    ("userbot", "Status of the helper account that deletes and edits what the bot can't (owner)"),
    ("export", "Backup as JSON (owner)"),
    ("import", "Restore a backup made by /export (owner)"),
    ("admins", "List admins (owner)"),
    ("addadmin", "Add an admin by numeric ID (owner)"),
    ("removeadmin", "Remove an admin by numeric ID (owner)"),
    ("cancel", "Cancel what I'm doing"),
    ("help", "Show help"),
]


def build_client(cfg: Config) -> TelegramClient:
    if cfg.session_string:
        session = StringSession(cfg.session_string)
    else:
        os.makedirs(os.path.dirname(cfg.session_path) or ".", exist_ok=True)
        session = cfg.session_path
    client = TelegramClient(session, cfg.api_id, cfg.api_hash)
    client.parse_mode = "html"
    return client


def register_all(ctx: Ctx) -> None:
    for module in (basic, backup, channels, create, posts, masslinks, autopost, broadcast, checkbtn, replace, repost, shift, sync):
        module.register(ctx)


async def set_commands(client: TelegramClient) -> None:
    try:
        await client(
            functions.bots.SetBotCommandsRequest(
                scope=types.BotCommandScopeDefault(),
                lang_code="",
                commands=[types.BotCommand(command=c, description=d) for c, d in COMMANDS],
            )
        )
    except Exception as e:  # cosmetic only
        log.warning("could not set the command menu: %s", e)


def secrets_of(cfg: Config) -> list:
    """Texts that must never appear in the error log."""
    out = [cfg.bot_token, cfg.api_hash, cfg.session_string, cfg.userbot_session]
    try:
        parts = urlsplit(cfg.database_url)
        if parts.password:
            out += [parts.password, unquote(parts.password)]
    except Exception:
        pass
    return [s for s in out if s]


async def main() -> None:
    cfg = load_config()
    logging.basicConfig(level=cfg.log_level, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
    logging.getLogger("telethon").setLevel(logging.WARNING)

    client = build_client(cfg)
    reporter = ErrorReporter(client, cfg.error_log_chat_id, bot_token=cfg.bot_token, secrets=secrets_of(cfg))
    reporter.start()
    logging.getLogger().addHandler(TelegramLogHandler(reporter))
    asyncio.get_running_loop().set_exception_handler(reporter.loop_exception_handler)

    db = None
    ctx = None
    userbot = Userbot(client, cfg)
    try:
        db = Database(cfg.database_url, cfg.db_pool)
        await db.init()
        ctx = Ctx(cfg=cfg, db=db, client=client, userbot=userbot, reporter=reporter)
        ctx.admins = await db.list_admins()
        ctx.expirer = Expirer(ctx)
        ctx.syncer = Syncer(ctx)
        register_all(ctx)
        await client.start(bot_token=cfg.bot_token)
        me = await client.get_me()
        log.info("Logged in as @%s (owners: %s)", me.username, ", ".join(map(str, sorted(cfg.owners))))
        await set_commands(client)
        if cfg.port:
            await start_health_server(cfg.port)
        problem = await reporter.check()
        if reporter.enabled:
            if problem:
                log.warning("errors cannot be sent to chat %s yet: %s", cfg.error_log_chat_id, problem)
            else:
                log.info("errors are sent to chat %s", cfg.error_log_chat_id)
        else:
            log.info("error log chat: off")
        if userbot.enabled:
            await userbot.connect()  # never fatal: the userbot is only needed for old posts
        ctx.expirer.start()  # deletes timed broadcasts when their time is up
        ctx.syncer.start()  # compares the channels with My posts now and then (and notices changes made meanwhile)
        await client.run_until_disconnected()
    except Exception as e:
        await reporter.report_fatal("The bot stopped because of an unhandled exception", e)
        raise
    finally:
        if ctx is not None and ctx.syncer is not None:
            await ctx.syncer.stop()
        if ctx is not None and ctx.expirer is not None:
            await ctx.expirer.stop()
        await userbot.close()
        if db is not None:
            await db.close()
        await reporter.stop()


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass