linkchanbot

Telegram bot frontend proxy link substituter
Log | Files | Refs | README | LICENSE

commit 8c884489ed3723dec0199fc07f7c798b8e1d3148
parent 82d4e017399ed259e2765cf2ff38dcddacf1e323
Author: Byron Torres <b@torresjrjr.com>
Date:   Sat, 27 Mar 2021 15:34:27 +0000

Add executable and project files

Diffstat:
A.gitignore | 2++
AMakefile | 45+++++++++++++++++++++++++++++++++++++++++++++
MREADME.md | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adoc/linkchanbot.1.scd | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alinkchanbot | 717+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arequirements.txt | 1+
Asample.config/alts.json | 283+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asample.config/auth.cfg | 5+++++
Asample.config/queries.json | 11+++++++++++
Asample.config/services.json | 21+++++++++++++++++++++
10 files changed, 1233 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,2 @@ +*.swp +linkchanbot.1 diff --git a/Makefile b/Makefile @@ -0,0 +1,45 @@ + +.POSIX: +.SUFFIXES: +.SUFFIXES: .1 .1.scd + +VERSION=0.0.0 + +PREFIX?=/usr/local +BINDIR?=$(PREFIX)/bin +SHAREDIR?=$(PREFIX)/share/linkchanbot +MANDIR?=$(PREFIX)/share/man + +VPATH=doc + +DOCS := \ + linkchanbot.1 + +all: doc + +doc: $(DOCS) + +.1.scd.1: + scdoc < $< > $@ + +clean: + $(RM) $(DOCS) + +install: all + mkdir -m755 -p $(DESTDIR)$(BINDIR) $(DESTDIR)$(SHAREDIR) $(DESTDIR)$(MANDIR)/man1 + install -m755 linkchanbot $(DESTDIR)$(BINDIR)/linkchanbot + install -m644 linkchanbot.1 $(DESTDIR)$(MANDIR)/man1/linkchanbot.1 + install -m644 sample.config/auth.cfg $(DESTDIR)$(SHAREDIR)/auth.cfg + install -m644 sample.config/alts.json $(DESTDIR)$(SHAREDIR)/alts.json + install -m644 sample.config/services.json $(DESTDIR)$(SHAREDIR)/services.json + install -m644 sample.config/queries.json $(DESTDIR)$(SHAREDIR)/queries.json + +uninstall: + $(RM) $(DESTDIR)$(BINDIR)/linkchanbot + $(RM) $(DESTDIR)$(MANDIR)/man1/linkchanbot.1 + $(RM) $(DESTDIR)$(SHAREDIR)/auth.cfg + $(RM) $(DESTDIR)$(SHAREDIR)/alts.json + $(RM) $(DESTDIR)$(SHAREDIR)/services.json + $(RM) $(DESTDIR)$(SHAREDIR)/queries.json + +.PHONY: all doc clean install uninstall diff --git a/README.md b/README.md @@ -3,3 +3,79 @@ linkchanbot A Telegram Bot which sanitises and substitutes share links with lightweight, privacy respecting proxy frontend alternatives. + +Supported services (configurable): + +- twitter.com -> Nitter +- youtube.com -> Inividious +- instagram.com -> Bibliogram +- reddit.com -> Teddit, Old Reddit + + +Installation +------------ + +### Prerequisites + +- A Telegram bot token (visit [@botfather](https://t.me/botfather)). + +### Dependencies + +- [scdoc](https://sr.ht/~sircmpwn/scdoc) (build dep.) +- Python >= 3.9 +- PyPI: python-telegram-bot >= 13 + +### Install + + $ git clone https://git.sr.ht/~torresjrjr/linkchanbot + $ cd linkchanbot + $ python -m pip install -r requirements.txt + # make install + +To start serving, linkchanbot needs further configuration. + + +Configuration +------------- + +### Telegram + +- Visit [@botfather](https://t.me/botfather). +- Create a new bot (or select an existing one). +- Save the bot API token. +- Enable "Inline mode". +- Optionally set the "inline placeholder" to "Paste link..." +- Optionally set "inline feedback" to "100%" for logging. + +### Server + +Add the required bot token (and optionally an admin username) +either in `auth.cfg` in the linkchan config directory +(`$XDG_CONFIG_HOME/linkchan` or `$HOME/.config/linkchan`): + + [auth] + # required + token = 123:ABC... + # optional, provides /restart and /shutdown + admin = username + +Or by environment variable: + + $ export LINKCHAN_TOKEN='123:ABC...' + $ export LINKCHAN_ADMIN='admin_username' + +Your bot should now be ready. + +### Advanced configuration + +See `linkchanbot(1)` + + +Usage +----- + +See `linkchanbot(1)` or `linkchanbot --help`. + + + [xdg]: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + diff --git a/doc/linkchanbot.1.scd b/doc/linkchanbot.1.scd @@ -0,0 +1,72 @@ +linkchanbot(1) + +# NAME + +*linkchanbot* - +A Telegram Bot which sanitises and substitutes share links +with lightweight, privacy respecting proxy frontend alternatives. + +# SYNOPSIS + +*linkchanbot* [-h] [-v] [-l LOGFILE] + +# DESCRIPTION + +*linkchanbot* is a Telegram bot utility that transforms share links from common +social media services to links which point to alternative lightweight, privacy +respecting frontends, like Nitter, Invidious, Bibliogram, and Teddit. +*linkchanbot* can substitute links in chat, inline mode, and group chats. + +# USAGE + +\-h, --help + show help and exit + +\-v, --version + print version and exit + +\-l _LOGFILE_, --logfile _LOGFILE_ + specify the log file + +# CONFIGURATION + +*linkchanbot* adheres to the XDG Base Directory System, +and sources configuration files +from _$XDG_CONFIG_HOME/linkchan_ (defaults to _$HOME/.config/linkchan_). +*linkchanbot* will copy missing configuration files +from _/etc/local/share/linkchanbot_. + +_auth.cfg_ + Defines the _token_ and _admin_ variables. + + _token_ + Required. The Telegram bot token. Visit @botfather. + _admin_ + Optional. A Telegram username to whom the bot will provide the special + _/restart_ and _/shutdown_ commands. + +_alts.json_ + Defines the alternative services (proxies or otherwise) available as + a substitute. An alt's _service_ value should exist as a key in + _services.json_, else it won't be recognised as available. + +_services.json_ + Defines the domains and subdomains of common services to be + recognised and replaced. + +_queries.json_ + Defines whitelists of queries to be left untouched during substitution. + +# TELEGRAM USAGE + +Send _/start_ on Telegram. *linkchanbot* will present help. + +Additionally, if _admin_ is specified in configuration, the Telegram user +with the username _admin_ will be provided with the following commands: + +_/restart_ + Restart the bot. Reads new configuration. + +_/shutdown_ + Shutdown the bot. + diff --git a/linkchanbot b/linkchanbot @@ -0,0 +1,717 @@ +#!/usr/bin/python +""" +This is a Telegram Bot which sanitises and substitutes share links +for lightweight, privacy respecting proxy alternatives. +""" + +from telegram import ( + MessageEntity, ParseMode, + InlineQueryResultArticle, InputTextMessageContent, + InlineKeyboardMarkup, InlineKeyboardButton, +) +from telegram.ext import ( + Updater, Filters, + MessageHandler, CommandHandler, + InlineQueryHandler, ChosenInlineResultHandler, +) +from telegram.constants import MAX_INLINE_QUERY_RESULTS as MAX_RESULTS +from telegram import error + +from urllib.parse import urlparse, urlencode, parse_qs +import argparse +import configparser +import functools +import json +import logging +import os +import pathlib +import random +import shutil +import signal +import sys +import threading + + +# Constants + +VERSION = "1.0.0" + +TEMPLATE = """ +{new} +[source]({old}) +""" + +EXAMPLES = { + "Twitter": [ + "https://twitter.com/anvxmes/status/1375175567587356673", + "https://twitter.com/Chrisvb700/status/1373169970117496833", + "https://twitter.com/HdWallpaperCart/status/1374405341954285572", + ], + "YouTube": [ + "https://www.youtube.com/watch?v=J---aiyznGQ#", + "https://www.youtube.com/watch?v=KmtzQCSh6xk", + "https://www.youtube.com/watch?v=9Gj47G2e1Jc", + ], + "Instagram": [ + "https://www.instagram.com/p/B-b-POVFb1r/", + "https://www.instagram.com/p/CMW0Fx6lum6/", + "https://www.instagram.com/p/CL_vMidl_W2/", + ], + "Reddit": [ + "https://www.reddit.com/r/wallpaper/comments/mctm44/dope19201080/", + "https://www.reddit.com/r/wallpaper/comments/m98fnz/great_art_by_mike_fazbear_3840x2160/", + "https://www.reddit.com/r/reddit.com/comments/17913/reddit_now_supports_comments/c51/", + ], +} + + +# Initialisation + +def args(): + """ + Parse command-line arguments. Provide basic help interface. + """ + parser = argparse.ArgumentParser( + prog = "linkchanbot", + formatter_class = argparse.RawDescriptionHelpFormatter, + description = \ + "A Telegram bot that substitutes common share link with\n" + "lightweight, privacy respecting proxy alternatives.", + epilog = f"linkchanbot {VERSION}" + ) + parser.add_argument('-v', '--version', help='print version and exit', action='store_true') + parser.add_argument('-l', '--logfile', help='specify the log file') + + args = parser.parse_args() + + if args.version: + stderr(f"linkchanbot {VERSION}") + exit(0) + + return args + + +def init(args): + """ + Loads configuration from config files and environment variables. + To be called before main logic. + + Has side effects. See globals below. + """ + # Filesystem + cache_home = pathlib.Path(os.getenv('XDG_CACHE_HOME', os.getenv('HOME') + '/.cache')) + cache_dir = cache_home/'linkchan' + cache_dir.mkdir(parents=True, exist_ok=True) # EFFECT + + config_home = pathlib.Path(os.getenv('XDG_CONFIG_HOME', os.getenv('HOME') + '/.config')) + config_dir = config_home/'linkchan' + config_dir.mkdir(parents=True, exist_ok=True) # EFFECT + sys_config_dir = pathlib.Path('/usr/local/share/linkchanbot') + + config_files = ('auth.cfg', 'alts.json', 'services.json', 'queries.json') + + # Copy system global config files to local XDG config dir. + # Fail if files not found. + for file in config_files: + locfile = config_dir/file + sysfile = sys_config_dir/file + + if locfile.is_file(): + continue + else: + if sysfile.is_file(): + shutil.copy(sysfile, locfile) + else: + stderr( + f"Error: config file '{file}' not found in" + "'{config_dir}' or '{sys_config_dir}'", + ) + exit(1) + + # Logging + LOGFILE = args.logfile or os.getenv('LINKCHAN_LOGFILE') or cache_dir/'log' + + try: + logging.basicConfig( + filename = LOGFILE, + filemode = 'a', + format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level = logging.INFO, + ) + except FileNotFoundError as e: + stderr("Error: logfile:", e) + exit(1) + + # Config + TOKEN = os.getenv('LINKCHAN_TOKEN') + ADMIN = os.getenv('LINKCHAN_ADMIN') + + AUTH = configparser.ConfigParser() + AUTH.read(config_dir/'auth.cfg') + + TOKEN = TOKEN or AUTH.get("auth", "token", fallback=False) + ADMIN = ADMIN or AUTH.get("auth", "admin", fallback=False) + + if not TOKEN: + stderr("Error: No bot token provided") + exit(1) + + global ALTS + global SERVICES + global QUERIES + + try: + with open(config_dir/'alts.json', 'r') as file: + ALTS = json.load(file) + with open(config_dir/'services.json', 'r') as file: + SERVICES = json.load(file) + with open(config_dir/'queries.json', 'r') as file: + QUERIES = json.load(file) + except FileNotFoundError as e: + stderr("Error: Missing config file:", e) + exit(1) + except json.decoder.JSONDecodeError as e: + stderr( + f"Error: JSON syntax error in '{file.name}':", e, + ) + exit(1) + + return TOKEN, ADMIN, LOGFILE + + + +# Util + +def stderr(*args, **kwargs): + """ + Prints to stderr. + """ + print(file=sys.stderr, *args, **kwargs) + +def logger(old_cb_func): + """ + Wraps callback functions, logs incomming telegram updates. + """ + @functools.wraps(old_cb_func) + def new_cb_func(upd, ctx, **kwargs): + if upd.message and upd.message.text: + status = mk_status(upd, 'msg', '<:', oneline(upd.message.text)) + elif upd.message and upd.message.caption: + status = mk_status(upd, 'cap', '<:', oneline(upd.message.caption)) + elif upd.message: + status = mk_status(upd, 'msg', '#:', upd.effective_message) + elif upd.chosen_inline_result: + status = mk_status(upd, 'cir', '::', oneline(upd.chosen_inline_result.result_id)) + elif upd.inline_query: + status = mk_status(upd, 'ilq', '?:', oneline(upd.inline_query.query)) + else: + status = mk_status(upd, 'ukn', '#:', upd.effective_message) + + logging.info(status) + print(status) + + return old_cb_func(upd, ctx, **kwargs) + + return new_cb_func + + +def mk_status(upd, utype, dl='<<', text=None): + """ + Prepares a standardised string for logging. + Called by wrapped callbacks (see logger()) + or by callbacks for terminal output. + """ + uid = upd.update_id + user_id = upd.effective_user.id + user_name = upd.effective_user.name + + chat = upd.effective_chat + if chat: + chat_id = chat.id + chat_name = chat.link or chat.title or chat.full_name + chat_name = chat_name.replace('https://t.me/', '@') + else: + chat_id = '#' + chat_name = '#' + + if not text: + text = upd.effective_message + + status = f"{uid} [{utype}] - {user_id} <{user_name}> - {chat_id} ({chat_name}) - {dl} {text}" + return status + + +@functools.cache +def mk_newlinks(link): + """ + The core logic of link substitution. + Given a link, returns either: + [str...] A list of new links. + [False] A list with a single False element. + """ + # Prepare and parse link string + if not link.startswith('https://') and not link.startswith('http://'): + link = 'https://' + link + + url = urlparse(link) + + # Enforce HTTPS + url = url._replace(scheme='https') + + # Recognise service + if url.netloc in SERVICES.keys(): + service = url.netloc + else: + for main, others in SERVICES.items(): + if url.netloc in others: + service = main + break + else: + # Fail if service is unrecognised + return [False] + + # Keep only allowed URL queries + allowed_queries = QUERIES.get(service) or [] + old_queries = parse_qs(url.query, keep_blank_values=True) + new_queries = { + query:v for (query,v) in old_queries.items() + if query in allowed_queries + } + url = url._replace( + query = urlencode(new_queries, doseq=True) + ) + + # Find alts for replacing `service` + applicable_alts = { + altsite: alt for (altsite, alt) in ALTS.items() + if alt['service'] == service + } + + # Make new substitutes + newlinks = list(map( + lambda newdomain: url._replace(netloc=newdomain).geturl(), + applicable_alts.keys() + )) + + return newlinks + + +@functools.cache +def oneline(s: str) -> str: + """ + Converts newlines and tabs to ASCII representations. + """ + s = s.replace('\\', '\\\\') + return s.replace('\n', '\\n').replace('\t', '\\t') + + +# Callback Handlers + +@logger +def cb_start(upd, ctx): + """ + /start callback + """ + + # If user pressed "See examples", they were sent to bot PMs + # to /start with the payload "examples". + if ctx.args and ctx.args[0] == 'examples': + examples(upd, ctx) + return + + BOT_USERNAME = ctx.bot.get_me().username + + # outgoing text + msg = f""" +@{BOT_USERNAME} cleans & proxies your share links. +I support Twitter, YouTube, Instagram and Reddit. + +*Try inline* + Type: `@{BOT_USERNAME} <link>` + [See examples](t.me/{BOT_USERNAME}?start=examples). + +*Try bot PMs* + [Send me](t.me/{BOT_USERNAME}) a link. + +*Try group chats* + [Add me](t.me/{BOT_USERNAME}?startgroup=1) and promote me to admin, then share links. + +See /help or /about +""" + + # Inline keyboard with "Try inline" button. + # See: https://core.telegram.org/bots/api#inlinekeyboardbutton + reply_markup = InlineKeyboardMarkup([ + [ + InlineKeyboardButton( + 'Try inline', + # Launches inline mode on button press with no query + switch_inline_query_current_chat = '', + ), + ], + ]) + + # Send message + upd.message.reply_text( + msg, + disable_web_page_preview = True, + parse_mode = ParseMode.MARKDOWN, + reply_markup = reply_markup, + ) + + +def cb_help(upd, ctx): + """ + /help callback + """ + BOT_USERNAME = ctx.bot.get_me().username + + # Outgoing text + msg = f""" +*DESCRIPTION* +@{BOT_USERNAME} substitutes the share links of popular services for lightweight and privacy respecting alternatives, and sanitises unnecesary queries and trackers. + +*USAGE* +Inline + Type: `@{BOT_USERNAME} <link>` +Bot PMs + Send any text with links. +Group chats + Add me and promote me to admin. + +*SUPPORTED SERVICES* +- twitter.com => Nitter (nitter.net) +- youtube.com => Inividious (invidio.us) +- instagram.com => Bibliogram (bibliogram.art) +- reddit.com => Teddit, Old Reddit (teddit.net, old.reddit.com) + +*NOTES* +For in-chat replies, default proxies are used. For inline queries, a menu of proxies are available. To cycle through proxy menus, append '#' to your link. + +All URL query parameters for all domains are removed, except for whitelisted queries per service. + +*PRIVACY* +This bot needs admin privileges to access group chat messages. The official instance (@linkchanbot) only logs messages with links, but be wary of other instances with modified source code. + +This bot receives no chat data when used in inline mode, only the user data of the user who is using inline mode. Use inline mode for ultimate privacy. +""" + + # Send message + upd.message.reply_text( + msg, + disable_web_page_preview = True, + parse_mode = ParseMode.MARKDOWN + ) + + +@logger +def cb_about(upd, ctx): + """ + /about callback + """ + BOT_USERNAME = ctx.bot.get_me().username + + # Outgoing text + msg = f""" +@{BOT_USERNAME} (@linkchanbot) + +Version + {VERSION} +Source code + https://sr.ht/~torresjrjr/linkchanbot +Maintainer + @torresjrjr <b@torresjrjr.com> +License + GNU Affero General Public License +""" + + # Send message + upd.message.reply_text( + msg, + parse_mode = ParseMode.MARKDOWN + ) + + +def examples(upd, ctx): + """ + Returns an inline keyboard of examples of inline queries. + Called when user sends /start with payload "examples". + See cb_start(). + """ + + # Inline keyboard with a button for each example in `EXAMPLES`. + # See: https://core.telegram.org/bots/api#inlinekeyboardbutton + reply_markup = InlineKeyboardMarkup([ + [ + InlineKeyboardButton( + service, + # Lauches inline mode on button press + # with example as the query. + switch_inline_query_current_chat = links[0], + ) + ] \ + for service, links in EXAMPLES.items() + ]) + + # Send message + upd.message.reply_text( + "Try inline query examples", + parse_mode = ParseMode.MARKDOWN, + reply_markup = reply_markup, + ) + + + +@logger +def cb_link_handler(upd, ctx): + """ + Handles messages with links (see main > MessageHandler). + Replies with `TEMPLATE` with new links. + """ + links = [] + + # Telegram returns message metadata called 'entities' + # (commands, formatted text, links, etc.). + # We extract the link entities. + entities = {} + entities.update(upd.message.parse_entities()) + entities.update(upd.message.parse_caption_entities()) + + for ent, link in entities.items(): + link = oneline(link) + + if ent['type'] == 'url': + links += [ link ] + + if ent['type'] == 'text_link': + links += [ ent['url'] ] + + # Filter for links which have substitutes. + # mk_newlinks() returns either [str...] or [False] + oldlinks = list(filter( + lambda old: mk_newlinks(old)[0], + links + )) + + # Generate corresponding newlinks, + # by picking the first suggestion from mk_newlinks() + newlinks = list(map( + lambda old: mk_newlinks(old)[0], + oldlinks + )) + + # Send substitutes as separate messages + for oldlink, newlink in zip(oldlinks, newlinks): + logging.info(mk_status(upd, 'out', '::', f"{newlink}")) + + msg = TEMPLATE.format(new=newlink, old=oldlink) + upd.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN) + + +@logger +def cb_inline_query(upd, ctx): + """ + Handles inline queries. Sends back prompt menu of new links. + """ + query = upd.inline_query.query + newlinks = mk_newlinks(query) + + # If the query string is not a URL, + # return a menu of a random sample of alts. + if query == '' or not newlinks[0]: + nr_results = MAX_RESULTS if MAX_RESULTS <= len(ALTS) else len(ALTS) + + results = [ + InlineQueryResultArticle( + id = altsite, + title = altsite, + url = altsite, + description = alt['description'], + thumb_url = alt['thumb_url'], + input_message_content = InputTextMessageContent(altsite) + ) \ + for altsite, alt in random.sample( + sorted(ALTS.items()), nr_results + ) + ] + # Otherwise, return a menu of a random sample of newlinks + # and their alt metadata to populate the inline results menu. + else: + alts = { + newlink: ALTS[urlparse(newlink).netloc] + for newlink in newlinks + } + + nr_results = MAX_RESULTS if MAX_RESULTS <= len(alts) else len(alts) + + results = [ + InlineQueryResultArticle( + id = f"{upd.update_id}+{urlparse(newlink).netloc}", + title = urlparse(newlink).netloc, + url = newlink, + description = alt['description'], + thumb_url = alt['thumb_url'], + input_message_content = InputTextMessageContent( + TEMPLATE.format(new=newlink, old=query), + parse_mode=ParseMode.MARKDOWN, + ) + ) \ + for newlink, alt in random.sample( + sorted(alts.items()), nr_results + ) + ] + + BOT_USERNAME = ctx.bot.get_me().username + + # Answer inline query + upd.inline_query.answer( + results, + # switch_pm_* adds a button the the inline results menu + # to open the bot chat. + # See: https://core.telegram.org/bots/api#answerinlinequery + switch_pm_text=f"Open @{BOT_USERNAME}", + switch_pm_parameter='inline', + ) + + +@logger +def cb_chosen_inline_result(upd, ctx): + """ + Callback for chosen inline query results. For logging only. + See logger() + """ + pass + + +def cb_error(update, context): + try: + raise context.error + except error.TelegramError as e: + print("Error: TelegramError:", e, update) + except error.ChatMigrated as e: + print("Error: ChatMigrated:", e, update) + except error.Conflict as e: + print("Error: Confict:", e, update) + except error.InvalidToken as e: + print("Error: InvalidToken:", e, update) + except error.RetryAfter as e: + print("Error: RetryAfter:", e, update) + except error.Unauthorized as e: + print("Error: Unauthorized:", e, update) + + except error.NetworkError as e: + print("Error: NetworkError:", e, update) + except error.BadRequest as e: + print("Error: BadRequest:", e, update) + except error.TimedOut as e: + print("Error: TimedOut:", e, update) + + + +# Main + +def main(): + TOKEN, ADMIN, LOGFILE = init(args()) + + # Init bot + try: + updater = Updater(TOKEN, use_context=True) + except error.InvalidToken as e: + stderr(f"Error: Invalid token '{TOKEN}'") + exit(1) + + # Test token + try: + bot_user = updater.bot.get_me() + except Unauthorized as e: + stderr("Error: Faulty token:", e) + exit(1) + + BOT_USERNAME = bot_user.username + BOT_ID = bot_user.id + + + dp = updater.dispatcher + dp.add_error_handler(cb_error) + + dp.add_handler(CommandHandler('start', cb_start)) + dp.add_handler(CommandHandler('help', cb_help)) + dp.add_handler(CommandHandler('about', cb_about)) + + dp.add_handler(InlineQueryHandler(cb_inline_query)) + dp.add_handler(ChosenInlineResultHandler(cb_chosen_inline_result)) + + dp.add_handler(MessageHandler( + ~Filters.via_bot(username=BOT_USERNAME) & ( + Filters.entity(MessageEntity.URL) | + Filters.entity(MessageEntity.TEXT_LINK) | + Filters.caption_entity(MessageEntity.URL) | + Filters.caption_entity(MessageEntity.TEXT_LINK) + ), + cb_link_handler + )) + + if ADMIN: + # Admin callbacks + # See: https://github.com/python-telegram-bot/python-telegram-bot/wiki/Code-snippets/1c6ab0d3324a83de2a0a41910491211be2ffb46b#simple-way-of-restarting-the-bot + def stop_and_restart(): + """ + Gracefully stop the updater + and replace the current process with a new one. + Called by cb_restart(). + """ + # `updater` in scope of function definition + updater.stop() + return os.execl(sys.executable, sys.executable, *sys.argv) + + @logger + def cb_restart(upd, ctx): + """ + /restart callback. Restarts the bot. + See handler for authorisation. + """ + status = mk_status(upd, 'cmd', '::', "Authorised - restarting bot...") + logging.info(status) + print(status) + upd.message.reply_text(status) + + return threading.Thread(target=stop_and_restart).start() + + @logger + def cb_shutdown(upd, ctx): + """ + /shutdown callback. Shuts down the bot. + See handler for authorisation. + """ + status = mk_status(upd, 'cmd', '::', "Authorised - shutdown SIGINT") + logging.info(status) + print(status) + upd.message.reply_text(status) + + os.kill(os.getpid(), signal.SIGINT) + + # Admin handlers + dp.add_handler(CommandHandler( + 'restart', cb_restart, + filters=Filters.user(username=ADMIN) + )) + dp.add_handler(CommandHandler( + 'shutdown', cb_shutdown, + filters=Filters.user(username=ADMIN) + )) + + # Start serving + stderr(f"linkchanbot {VERSION}") + stderr(f"logfile: {LOGFILE}") + stderr(f"bot: {BOT_ID} <@{BOT_USERNAME}>") + stderr("Bot serving...") + + updater.start_polling() + updater.idle() + + stderr("Bot stopped.") + return + + +if __name__=='__main__': + main() diff --git a/requirements.txt b/requirements.txt @@ -0,0 +1 @@ +python-telegram-bot >= 13 diff --git a/sample.config/alts.json b/sample.config/alts.json @@ -0,0 +1,283 @@ +{ + "tweet.lambda.dance": { + "description": "Twitter proxy", + "thumb_url": "https://tweet.lambda.dance/logo.png", + "service": "twitter.com" + }, + "nitter.himiko.cloud": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.himiko.cloud/logo.png", + "service": "twitter.com" + }, + "nitter.net": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.net/logo.png", + "service": "twitter.com" + }, + "nitter.cc": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.cc/logo.png", + "service": "twitter.com" + }, + "nitter.42l.fr": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.42l.fr/logo.png", + "service": "twitter.com" + }, + "nitter.pussthecat.org": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.pussthecat.org/logo.png", + "service": "twitter.com" + }, + "nitter.nixnet.services": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.nixnet.services/logo.png", + "service": "twitter.com" + }, + "nitter.mastodont.cat": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.mastodont.cat/logo.png", + "service": "twitter.com" + }, + "nitter.tedomum.net": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.tedomum.net/logo.png", + "service": "twitter.com" + }, + "nitter.fdn.fr": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.fdn.fr/logo.png", + "service": "twitter.com" + }, + "nitter.1d4.us": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.1d4.us/logo.png", + "service": "twitter.com" + }, + "nitter.kavin.rocks": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.kavin.rocks/logo.png", + "service": "twitter.com" + }, + "nitter.vxempire.xyz": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.vxempire.xyz/logo.png", + "service": "twitter.com" + }, + "nitter.unixfox.eu": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.unixfox.eu/logo.png", + "service": "twitter.com" + }, + "nitter.domain.glass": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.domain.glass/logo.png", + "service": "twitter.com" + }, + "nitter.eu": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.eu/logo.png", + "service": "twitter.com" + }, + "nitter.ethibox.fr": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.ethibox.fr/logo.png", + "service": "twitter.com" + }, + "nitter.namazso.eu": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.namazso.eu/logo.png", + "service": "twitter.com" + }, + "nitter.mailstation.de": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.mailstation.de/logo.png", + "service": "twitter.com" + }, + "nitter.actionsack.com": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.actionsack.com/logo.png", + "service": "twitter.com" + }, + "nitter.cattube.org": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.cattube.org/logo.png", + "service": "twitter.com" + }, + "nitter.dark.fail": { + "description": "Twitter proxy", + "thumb_url": "https://nitter.dark.fail/logo.png", + "service": "twitter.com" + }, + "birdsite.xanny.family": { + "description": "Twitter proxy", + "thumb_url": "https://birdsite.xanny.family/logo.png", + "service": "twitter.com" + }, + + + "invidious.himiko.cloud": { + "description": "YouTube proxy", + "thumb_url": "https://invidious.himiko.cloud/apple-touch-icon.png", + "service": "youtube.com" + }, + "invidious.snopyta.org": { + "description": "YouTube proxy", + "thumb_url": "https://invidious.snopyta.org/apple-touch-icon.png", + "service": "youtube.com" + }, + "vid.puffyan.us": { + "description": "YouTube proxy", + "thumb_url": "https://vid.puffyan.us/apple-touch-icon.png", + "service": "youtube.com" + }, + "invidious.kavin.rocks": { + "description": "YouTube proxy", + "thumb_url": "https://invidious.kavin.rocks/apple-touch-icon.png", + "service": "youtube.com" + }, + "inv.skyn3t.in": { + "description": "YouTube proxy", + "thumb_url": "https://inv.skyn3t.in/apple-touch-icon.png", + "service": "youtube.com" + }, + "invidious.tube": { + "description": "YouTube proxy", + "thumb_url": "https://invidious.tube/apple-touch-icon.png", + "service": "youtube.com" + }, + "tube.incog.host": { + "description": "YouTube proxy", + "thumb_url": "https://tube.incog.host/apple-touch-icon.png", + "service": "youtube.com" + }, + "invidious.namazso.eu": { + "description": "YouTube proxy", + "thumb_url": "https://invidious.namazso.eu/apple-touch-icon.png", + "service": "youtube.com" + }, + "ytprivate.com": { + "description": "YouTube proxy", + "thumb_url": "https://ytprivate.com/apple-touch-icon.png", + "service": "youtube.com" + }, + "invidious.zapashcanon.fr": { + "description": "YouTube proxy", + "thumb_url": "https://invidious.zapashcanon.fr/apple-touch-icon.png", + "service": "youtube.com" + }, + "invidious.fdn.fr": { + "description": "YouTube proxy", + "thumb_url": "https://invidious.fdn.fr/apple-touch-icon.png", + "service": "youtube.com" + }, + "yewtu.be": { + "description": "YouTube proxy", + "thumb_url": "https://yewtu.be/apple-touch-icon.png", + "service": "youtube.com" + }, + "invidious.xyz": { + "description": "YouTube proxy", + "thumb_url": "https://invidious.xyz/apple-touch-icon.png", + "service": "youtube.com" + }, + "invidious.048596.xyz": { + "description": "YouTube proxy", + "thumb_url": "https://invidious.048596.xyz/apple-touch-icon.png", + "service": "youtube.com" + }, + "invidious.site": { + "description": "YouTube proxy", + "thumb_url": "https://invidious.site/apple-touch-icon.png", + "service": "youtube.com" + }, + "invidiou.site": { + "description": "YouTube proxy", + "thumb_url": "https://invidiou.site/apple-touch-icon.png", + "service": "youtube.com" + }, + "invidious.zee.li": { + "description": "YouTube proxy", + "thumb_url": "https://invidious.zee.li/apple-touch-icon.png", + "service": "youtube.com" + }, + "tube.connect.cafe": { + "description": "YouTube proxy", + "thumb_url": "https://tube.connect.cafe/apple-touch-icon.png", + "service": "youtube.com" + }, + + + "bibliogram.art": { + "description": "Instagram proxy", + "thumb_url": "https://bibliogram.art/apple-touch-icon.png", + "service": "instagram.com" + }, + "bibliogram.snopyta.org": { + "description": "Instagram proxy", + "thumb_url": "https://bibliogram.snopyta.org/apple-touch-icon.png", + "service": "instagram.com" + }, + "bibliogram.pussthecat.org": { + "description": "Instagram proxy", + "thumb_url": "https://bibliogram.pussthecat.org/apple-touch-icon.png", + "service": "instagram.com" + }, + "bibliogram.nixnet.services": { + "description": "Instagram proxy", + "thumb_url": "https://bibliogram.nixnet.services/apple-touch-icon.png", + "service": "instagram.com" + }, + "bibliogram.ethibox.fr": { + "description": "Instagram proxy", + "thumb_url": "https://bibliogram.ethibox.fr/apple-touch-icon.png", + "service": "instagram.com" + }, + "bibliogram.hamster.dance": { + "description": "Instagram proxy", + "thumb_url": "https://bibliogram.hamster.dance/apple-touch-icon.png", + "service": "instagram.com" + }, + + + "teddit.net": { + "description": "Reddit proxy", + "thumb_url": "https://teddit.net/favicon.png", + "service": "reddit.com" + }, + "teddit.ggc-project.de": { + "description": "Reddit proxy", + "thumb_url": "https://teddit.ggc-project.de/favicon.png", + "service": "reddit.com" + }, + "teddit.kavin.rocks": { + "description": "Reddit proxy", + "thumb_url": "https://teddit.kavin.rocks/favicon.png", + "service": "reddit.com" + }, + "teddit.zaggy.nl": { + "description": "Reddit proxy", + "thumb_url": "https://teddit.zaggy.nl/favicon.png", + "service": "reddit.com" + }, + "teddit.namazso.eu": { + "description": "Reddit proxy", + "thumb_url": "https://teddit.namazso.eu/favicon.png", + "service": "reddit.com" + }, + "teddit.nautolan.racing": { + "description": "Reddit proxy", + "thumb_url": "https://teddit.nautolan.racing/favicon.png", + "service": "reddit.com" + }, + "old.reddit.com": { + "description": "Old Reddit (old.)", + "thumb_url": "https://old.reddit.com/apple-touch-icon.png", + "service": "reddit.com" + }, + "w3.reddit.com": { + "description": "Old Reddit (w3.)", + "thumb_url": "https://old.reddit.com/apple-touch-icon.png", + "service": "reddit.com" + } +} diff --git a/sample.config/auth.cfg b/sample.config/auth.cfg @@ -0,0 +1,5 @@ +[auth] +# required, e.g.: 1234567890:AAmHQK0lP8Nb_n-cSaSjh3KLnOP14THGhbF +token = +# optional, e.g.: example_username +admin = diff --git a/sample.config/queries.json b/sample.config/queries.json @@ -0,0 +1,11 @@ +{ + "twitter.com": [], + "instagram.com": [], + "youtube.com": [ + "v", + "list" + ], + "reddit.com": [ + "context" + ] +} diff --git a/sample.config/services.json b/sample.config/services.json @@ -0,0 +1,21 @@ +{ + "twitter.com": [ + "www.twitter.com", + "mobile.twitter.com", + "m.twitter.com" + ], + "instagram.com": [ + "www.instagram.com", + "m.instagram.com" + ], + "youtube.com": [ + "www.youtube.com", + "m.youtube.com", + "youtu.be" + ], + "reddit.com": [ + "www.reddit.com", + "m.reddit.com", + "redd.it" + ] +}