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:
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"
+ ]
+}