# Friendly Telegram (telegram userbot) # Copyright (C) 2018-2019 The Authors # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. import logging import os import sys import argparse import asyncio import json import functools import collections import sqlite3 import importlib import signal import shlex from telethon import TelegramClient, events from telethon.sessions import StringSession, SQLiteSession from telethon.errors.rpcerrorlist import PhoneNumberInvalidError, MessageNotModifiedError, ApiIdInvalidError from telethon.tl.functions.channels import DeleteChannelRequest from . import utils, loader from .database import backend, local_backend, frontend from .translations.core import Translator try: from .web import core except ImportError: web_available = False logging.error("Unable to import web") else: web_available = True importlib.import_module(".modules", __package__) # Required on 3.5 only class MemoryHandler(logging.Handler): """Keeps 2 buffers. One for dispatched messages. One for unused messages. When the length of the 2 together is 100 truncate to make them 100 together, first trimming handled then unused.""" def __init__(self, target, capacity): super().__init__(0) self.target = target self.capacity = capacity self.buffer = [] self.handledbuffer = [] self.lvl = logging.WARNING # Default loglevel def setLevel(self, level): self.lvl = level def dump(self): """Return a list of logging entries""" return self.handledbuffer + self.buffer def dumps(self, lvl=0): """Return all entries of minimum level as list of strings""" return [self.target.format(record) for record in (self.buffer + self.handledbuffer) if record.levelno >= lvl] def emit(self, record): if len(self.buffer) + len(self.handledbuffer) >= self.capacity: if self.handledbuffer: del self.handledbuffer[0] else: del self.buffer[0] self.buffer.append(record) if record.levelno >= self.lvl and self.lvl >= 0: self.acquire() try: for precord in self.buffer: self.target.handle(precord) self.handledbuffer = self.handledbuffer[-(self.capacity - len(self.buffer)):] + self.buffer self.buffer = [] finally: self.release() _formatter = logging.Formatter(logging.BASIC_FORMAT, "") # pylint: disable=C0103 _handler = logging.StreamHandler() # pylint: disable=C0103 _handler.setFormatter(_formatter) logging.getLogger().handlers = [] logging.getLogger().addHandler(MemoryHandler(_handler, 500)) logging.getLogger().setLevel(0) logging.captureWarnings(True) async def handle_command(modules, db, event): """Handle all commands""" prefix = db.get(__name__, "command_prefix", False) or "." # Empty string evaluates to False, so the `or` activates if not hasattr(event, "message") or getattr(event.message, "message", "") == "": return if event.message.message[0:len(prefix)] != prefix: return logging.debug("Incoming command!") if not event.message: logging.debug("Ignoring command with no text.") return if event.via_bot_id: logging.debug("Ignoring inline bot.") return message = utils.censor(event.message) blacklist_chats = db.get(__name__, "blacklist_chats", []) whitelist_chats = db.get(__name__, "whitelist_chats", []) whitelist_modules = db.get(__name__, "whitelist_modules", []) if utils.get_chat_id(message) in blacklist_chats or (whitelist_chats and utils.get_chat_id(message) not in whitelist_chats) or message.from_id is None: logging.debug("Message is blacklisted") return if len(message.message) > len(prefix) and message.message[:len(prefix) * 2] == prefix * 2 \ and message.message != len(message.message) // len(prefix) * prefix: # Allow escaping commands using .'s await message.edit(utils.escape_html(message.message[len(prefix):])) logging.debug(message) # Make sure we don't get confused about spaces or other shit in the prefix message.message = message.message[len(prefix):] try: shlex.split(message.message) except ValueError as e: await message.edit("Invalid Syntax: " + str(e)) return if not message.message: return # Message is just the prefix command = message.message.split(maxsplit=1)[0] logging.debug(command) txt, func = modules.dispatch(command) message.message = txt + message.message[len(command):] if func is not None: if str(utils.get_chat_id(message)) + "." + func.__self__.__module__ in blacklist_chats: logging.debug("Command is blacklisted in chat") return if whitelist_modules and not (str(utils.get_chat_id(message)) + "." + func.__self__.__module__ in whitelist_modules): logging.debug("Command is not whitelisted in chat") return try: await func(message) except Exception as e: logging.exception("Command failed") try: await message.edit("<b>Request failed! Request was</b> <code>" + utils.escape_html(message.message) + "</code><b>. Please report it in the support group (</b><code>{0}support</code>" "<b>) along with the logs (</b><code>{0}logs error</code><b>)</b>".format(prefix)) finally: raise e async def handle_incoming(modules, db, event): """Handle all incoming messages""" logging.debug("Incoming message!") message = utils.censor(event.message) blacklist_chats = db.get(__name__, "blacklist_chats", []) whitelist_chats = db.get(__name__, "whitelist_chats", []) whitelist_modules = db.get(__name__, "whitelist_modules", []) if utils.get_chat_id(message) in blacklist_chats or (whitelist_chats and utils.get_chat_id(message) not in whitelist_chats) or message.from_id is None: logging.debug("Message is blacklisted") return for func in modules.watchers: if str(utils.get_chat_id(message)) + "." + func.__self__.__module__ in blacklist_chats: logging.debug("Command is blacklisted in chat") return if whitelist_modules and not (str(utils.get_chat_id(message)) + "." + func.__self__.__module__ in whitelist_modules): logging.debug("Command is not whitelisted in chat") return try: await func(message) except Exception: logging.exception("Error running watcher") def run_config(db, phone=None, modules=None): """Load configurator.py""" from . import configurator return configurator.run(db, phone, phone is None, modules) def parse_arguments(): """Parse the arguments""" parser = argparse.ArgumentParser() parser.add_argument("--setup", "-s", action="store_true") parser.add_argument("--phone", "-p", action="append") parser.add_argument("--token", "-t", action="append", dest="tokens") parser.add_argument("--heroku", action="store_true") parser.add_argument("--local-db", dest="local", action="store_true") parser.add_argument("--web-only", dest="web_only", action="store_true") parser.add_argument("--no-web", dest="web", action="store_false") parser.add_argument("--heroku-web-internal", dest="heroku_web_internal", action="store_true", help="This is for internal use only. If you use it, things will go wrong.") arguments = parser.parse_args() logging.debug(arguments) if sys.platform == "win32": # Subprocess support; not needed in 3.8 but not harmful asyncio.set_event_loop(asyncio.ProactorEventLoop()) return arguments def get_phones(arguments): """Get phones from the --token, --phone, and environment""" phones = set(arguments.phone if arguments.phone else []) phones.update(map(lambda f: f[18:-8], filter(lambda f: f.startswith("friendly-telegram-") and f.endswith(".session"), os.listdir(os.path.dirname(utils.get_base_dir()))))) authtoken = os.environ.get("authorization_strings", False) # for heroku if authtoken and not arguments.setup: try: authtoken = json.loads(authtoken) except json.decoder.JSONDecodeError: logging.warning("authtoken invalid") authtoken = False if arguments.setup or (arguments.tokens and not authtoken): authtoken = {} if arguments.tokens: for token in arguments.tokens: phone = sorted(phones).pop(0) phones.remove(phone) # Handled seperately by authtoken logic authtoken.update(**{phone: token}) return phones, authtoken def get_api_token(): """Get API Token from disk or environment""" while True: try: from . import api_token except ImportError: try: api_token = collections.namedtuple("api_token", ("ID", "HASH"))(os.environ["api_id"], os.environ["api_hash"]) except KeyError: return None else: return api_token else: return api_token def sigterm(signum, handler): # This ensures that we call atexit hooks and close FDs when Heroku kills us un-gracefully sys.exit(143) # SIGTERM + 128 def main(): # noqa: C901 """Main entrypoint""" arguments = parse_arguments() loop = asyncio.get_event_loop() clients = [] phones, authtoken = get_phones(arguments) api_token = get_api_token() if web_available: web = core.Web(api_token=api_token) if arguments.web else None else: if arguments.heroku_web_internal: raise RuntimeError("Web required but unavailable") web = None if api_token is None: if web: loop.run_until_complete(web.start()) print("Web mode ready for configuration") # noqa: T001 if not arguments.heroku_web_internal: print("Please visit http://localhost:" + str(web.port)) # noqa: T001 loop.run_until_complete(web.wait_for_api_token_setup()) api_token = web.api_token else: run_config({}) if authtoken: for phone, token in authtoken.items(): try: clients += [TelegramClient(StringSession(token), api_token.ID, api_token.HASH, connection_retries=None).start(phone)] except ValueError: run_config({}) return clients[-1].phone = phone # for consistency if not clients and not phones: if web: if not web.running.is_set(): loop.run_until_complete(web.start()) print("Web mode ready for configuration") # noqa: T001 if not arguments.heroku_web_internal: print("Please visit http://localhost:" + str(web.port)) # noqa: T001 loop.run_until_complete(web.wait_for_clients_setup()) arguments.heroku = web.heroku_api_token clients = web.clients for client in clients: if arguments.heroku: session = StringSession() else: session = SQLiteSession(os.path.join(os.path.dirname(utils.get_base_dir()), "friendly-telegram-" + client.phone)) session.set_dc(client.session.dc_id, client.session.server_address, client.session.port) session.auth_key = client.session.auth_key if not arguments.heroku: session.save() client.session = session else: try: phones = [input("Please enter your phone: ")] except EOFError: print() # noqa: T001 print("=" * 30) # noqa: T001 print() # noqa: T001 print("Hello. If you are seeing this, it means YOU ARE DOING SOMETHING WRONG!") # noqa: T001 print() # noqa: T001 print("It is likely that you tried to deploy to heroku - " # noqa: T001 "you cannot do this via the web interface.") print("To deploy to heroku, go to " # noqa: T001 "https://friendly-telegram.gitlab.io/heroku to learn more") print() # noqa: T001 print("In addition, you seem to have forked the friendly-telegram repo. THIS IS WRONG!") # noqa: T001 print("You should remove the forked repo, and read https://friendly-telegram.gitlab.io") # noqa: T001 print() # noqa: T001 print("If you're not using Heroku, then you are using a non-interactive prompt but " # noqa: T001 "you have not got a session configured, meaning authentication to Telegram is " "impossible.") # noqa: T001 print() # noqa: T001 print("THIS ERROR IS YOUR FAULT. DO NOT REPORT IT AS A BUG!") # noqa: T001 print("Goodbye.") # noqa: T001 sys.exit(1) for phone in phones: try: clients += [TelegramClient(StringSession() if arguments.heroku else os.path.join(os.path.dirname(utils.get_base_dir()), "friendly-telegram" + (("-" + phone) if phone else "")), api_token.ID, api_token.HASH, connection_retries=None).start(phone)] except sqlite3.OperationalError as ex: print("Error initialising phone " + (phone if phone else "unknown") + " " + ",".join(ex.args) # noqa: T001 + ": this is probably your fault. Try checking that this is the only instance running and " "that the session is not copied. If that doesn't help, delete the file named '" "friendly-telegram" + (("-" + phone) if phone else "") + ".session'") continue except (ValueError, ApiIdInvalidError): # Bad API hash/ID run_config({}) return except PhoneNumberInvalidError: print("Please check the phone number. Use international format (+XX...)" # noqa: T001 " and don't put spaces in it.") continue clients[-1].phone = phone # so we can format stuff nicer in configurator if arguments.heroku: if isinstance(arguments.heroku, str): key = arguments.heroku else: key = input("Please enter your Heroku API key (from https://dashboard.heroku.com/account): ").strip() from . import heroku app = heroku.publish(clients, key, api_token) print("Installed to heroku successfully! Type .help in Telegram for help.") # noqa: T001 if web: web.redirect_url = app.web_url web.ready.set() loop.run_until_complete(web.root_redirected.wait()) return if arguments.heroku_web_internal: signal.signal(signal.SIGTERM, sigterm) loops = [amain(client, clients, web, arguments) for client in clients] loop.set_exception_handler(lambda _, x: logging.error("Exception on event loop! %s", x["message"], exc_info=x["exception"])) loop.run_until_complete(asyncio.gather(*loops)) async def amain(client, allclients, web, arguments): """Entrypoint for async init, run once for each user""" setup = arguments.setup local = arguments.local web_only = arguments.web_only async with client: client.parse_mode = "HTML" await client.start() [handler] = logging.getLogger().handlers dbc = local_backend.LocalBackend if local else backend.CloudBackend if setup: db = dbc(client) await db.init(lambda e: None) jdb = await db.do_download() try: pdb = json.loads(jdb) except (json.decoder.JSONDecodeError, TypeError): pdb = {} modules = loader.Modules() babelfish = Translator([]) await babelfish.init(client) modules.register_all(babelfish) fdb = frontend.Database(dbc(client), True) await fdb.init() modules.send_config(fdb, babelfish) await modules.send_ready(client, fdb, allclients) # Allow normal init even in setup handler.setLevel(50) pdb = run_config(pdb, getattr(client, "phone", "Unknown Number"), modules) if pdb is None: await client(DeleteChannelRequest(db.db)) return try: await db.do_upload(json.dumps(pdb)) except MessageNotModifiedError: pass return db = frontend.Database(dbc(client)) await db.init() logging.debug("got db") logging.info("Loading logging config...") handler.setLevel(db.get(__name__, "loglevel", logging.WARNING)) babelfish = Translator(db.get(__name__, "langpacks", []), db.get(__name__, "language", ["en"])) await babelfish.init(client) modules = loader.Modules() modules.register_all(babelfish) modules.send_config(db, babelfish) await modules.send_ready(client, db, allclients) if not web_only: client.add_event_handler(functools.partial(handle_incoming, modules, db), events.NewMessage(incoming=True)) client.add_event_handler(functools.partial(handle_command, modules, db), events.NewMessage(outgoing=True, forwards=False)) print("Started for " + str((await client.get_me(True)).user_id)) # noqa: T001 if web: await web.add_loader(client, modules, db) await web.start_if_ready(len(allclients)) await client.run_until_disconnected()