From fbbd202a0416fbf5cfac8a8d2b4e43e86e15762b Mon Sep 17 00:00:00 2001 From: Tim Schmidt Date: Mon, 3 Dec 2018 17:22:53 +0100 Subject: initial commit --- .gitignore | 5 ++ config.py.sample | 38 ++++++++++ control.py | 207 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ hackchatcustom.py | 130 ++++++++++++++++++++++++++++++++++ telegrambot.py | 68 ++++++++++++++++++ 5 files changed, 448 insertions(+) create mode 100644 .gitignore create mode 100644 config.py.sample create mode 100755 control.py create mode 100644 hackchatcustom.py create mode 100644 telegrambot.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7e681f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +config.py +*.swp +__pycache__ +.ropeproject +*.log diff --git a/config.py.sample b/config.py.sample new file mode 100644 index 0000000..a55ab42 --- /dev/null +++ b/config.py.sample @@ -0,0 +1,38 @@ +#!/bin/python + +######################### +## hack.chat config +######################### +# Name of hack.chat channel +CHANNEL = "sometestchannel" +# Username to be used the the HC bot +USER = "testuser" +# Password used for tripcode generation +PASS = "5azniuJ2m8BxYX9x2w8oRDVs" + +######################### +## Telegram config +######################### +# API token for the Telegram bot (you get this from @BotFather) +API_TOKEN = "---" +# ID of chat between your TG account and the TG bot +# The TG bot will reject anyone else trying to message it and only listen on +# your 1-to-1 chat with it +# To get your chat ID, enter the API token above, run telegrambot.py +# (not control.py) and start a chat with your bot on Telegram. +# The bot will reply with the CHAT_ID on Telegram when you send /start. +CHAT_ID = -1 + +######################### +## Miscellaneous +######################### +# Amount of seconds to wait before trying to reconnect +RECONNECT_DELAY = 30 +# Name of logfile +LOG_FILENAME = "log-%s.log" % CHANNEL + +######################### +## Auto-generated values +## DO NOT CHANGE! +######################### +USER_AND_PASS = "%s#%s" % (USER, PASS) diff --git a/control.py b/control.py new file mode 100755 index 0000000..075656b --- /dev/null +++ b/control.py @@ -0,0 +1,207 @@ +#!/bin/python + +import datetime +import sys +import time +import threading +import traceback +import signal +import logging + +# Custom scripts +import hackchatcustom as hackchat +import telegrambot + +# Config +import config + +# Constants +CONFIG_FILE = "config.py" + +# Global variables holding both bots +hcBot = None +tgBot = None + +### General +def log(s, suppress_console=True): + """Writes a message to the channel's logfile. + Will also output to the console if suppress_console is False.""" + # Write to logfile + with open(config.LOG_FILENAME, "a") as f: + f.write("%s %s\n" % (datetime.datetime.now().isoformat(), s)) + # Send via TG-Bot + if not suppress_console: + print(s) + +def mdescape(s): + """Hacky escapes for Telegrams Markdown parser. + Should really replace this with HTML.""" + special = '*[`_' + for x in special: + s = s.replace(x, "\\" + x) + return s + +### HV-Bot +def getUser(update): + """Convenience function that extracts a nick and tripcode (if any) from + an update object and returns the nickname in the following format: + If there is not tripcode: nick + If there is a tripcode: nick#tripcode""" + nick = update["nick"] + trip = 'null' + if "trip" in update: + trip = update["trip"] + + if trip == 'null': + return nick + else: + return "%s#%s" % (nick, trip) + +def onMessage(chat, update): + """Callback function handling users submitting messages to the channel.""" + message = update["text"] + nick = update["nick"] + trip = 'null' + if "trip" in update: + trip = update["trip"] + sender = getUser(update) + + log("[%s] %s" % (sender, message)) + if nick != config.USER: + if trip == 'null': + toTG("\\[*%s*] %s" % (mdescape(nick), mdescape(message))) + else: + toTG("\\[*%s*#%s] %s" % (mdescape(nick), trip, mdescape(message))) + +def onJoin(chat, update): + """Callback function handling users joining the channel.""" + user = getUser(update) + log("# %s joined" % user) + toTG("# %s joined" % mdescape(user)) + +def onLeave(chat, update): + """Callback function handling users leaving the channel.""" + user = getUser(update) + log("# %s left" % user) + toTG("# %s left" % mdescape(user)) + +def startHCBot(): + """Starts the HC bot.""" + global hcBot + bot = hackchat.HackChat(config.USER_AND_PASS, config.CHANNEL) + bot.on_message += [onMessage] + bot.on_join += [onJoin] + bot.on_leave += [onLeave] + hcBot = bot + bot.run() + +def botCrashed(signum, frame): + """Recovery routine that restarts the HC bot upon crash. + This is run automatically when a SIGALRT is received + (tested on Linux only) and thus relies on the HC bot + catching any relevant exception and sending a SIGALRT.""" + hcBot.stop() + + log("=!= Bot crashed / lost connection. Retrying in %i seconds..."\ + % config.RECONNECT_DELAY, suppress_console=False) + toTG("=!= Bot crashed / lost connection. Retrying in %i seconds..."\ + % config.RECONNECT_DELAY) + time.sleep(config.RECONNECT_DELAY) + log("Reconnecting...", suppress_console=False) + toTG("Reconnecting...") + startHCBot() + log("Reconnected!", suppress_console=False) + toTG("Reconnected!") + +def kill(): + """Debug command to test reconnect functionality""" + hcBot.ws.close() + +### TG-Bot Config +def onTGMessage(text): + """Handles receiving messages from the Telegram bot. + Currently, they are simple forwarded to the HC bot + which will then send them""" + hcBot.send_message(text) + +def toTG(s): + """Handles sending messages to the Telegram bot. + Currently, the TG bot will simply send + the message with Markdown parsing enabled.""" + tgBot.send(s) + +def startTGBot(): + """Starts the Telegram bot and sets the global + tgBot variable.""" + global tgBot + tgBot = telegrambot.TGBot() + tgBot.texthandlers += [onTGMessage] + tgBot.addCommand("active", cmdActive) + tgBot.addCommand("online", cmdOnline) + tgBot.run() + +def cmdActive(bot, update): + """TG command: /active + Checks wether the HC bot has been stopped. + This might not work correctly if the HC bot crashed.""" + if not hcBot.stopped: + tgBot.send("+++ ACTIVE") + else: + tgBot.send("--- _not_ active") + +def cmdOnline(bot, update): + """TG command: /online + Returns the list of users that are in the HC channel. + Users are listed without their tripcode.""" + users = list(hcBot.online_users) + users.sort() + tgBot.send("Users online:\n%s" % + mdescape(", ".join(users))) + + +### Common +def quit(): + """Gracefully shuts down both bots""" + global should_quit + hcBot.stop() + tgBot.stop() + should_quit = True + +### Main +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + COMMANDS_CLI = { + "kill": kill, + "quit": quit + #"online" : cmdOnline + } + + should_quit = False + + # Interpret SIGALRM as bot crash + signal.signal(signal.SIGALRM, botCrashed) + + try: + startTGBot() + startHCBot() + while not should_quit: + cmd = input("> ") + if not cmd in COMMANDS_CLI: + print("Unknown command!") + else: + COMMANDS_CLI[cmd]() + + except (KeyboardInterrupt, EOFError) as e: + print("Interrupt received. Shutting down...") + quit() + + except: + print("====================") + print("Main thread crashed!") + print("====================") + print() + traceback.print_exc() + + diff --git a/hackchatcustom.py b/hackchatcustom.py new file mode 100644 index 0000000..1c10adc --- /dev/null +++ b/hackchatcustom.py @@ -0,0 +1,130 @@ +# This module is based on https://github.com/gkbrk/hackchat (MIT License) + +import json +import threading +import time +import websocket +import sys +import traceback +import signal + +class HackChat: + """A library to connect to https://hack.chat. + is of callback functions to receive data from + https://hack.chat. Add your callback functions to this attribute. + e.g., on_message += [my_callback] + The callback function should have 3 parameters, the first for the + object, the second for the message someone sent and the + third for the nickname of the sender of the message. + """ + + def __init__(self, nick, channel="programming"): + """Connects to a channel on https://hack.chat. + Keyword arguments: + nick -- ; the nickname to use upon joining the channel + channel -- ; the channel to connect to on https://hack.chat + """ + self.nick = nick + self.channel = channel + self.online_users = [] + self.on_message = [] + self.on_join = [] + self.on_leave = [] + + self.stopped = False + + self._stop = threading.Event() + # Receiver thread + self._recv_thread = threading.Thread(target = self._receive) + self._recv_thread.daemon = True + + # Keepalive thread + self._ka_thread = threading.Thread(target = self._ping) + self._ka_thread.daemon = True + + def send_message(self, msg): + """Sends a message on the channel.""" + self._send_packet({"cmd": "chat", "text": msg}) + + def _send_packet(self, packet): + """Sends () to https://hack.chat.""" + encoded = json.dumps(packet) + self.ws.send(encoded) + + def run(self): + """Starts the bot asynchronously.""" + if self.stopped: + raise ValueError("Can't run a stopped bot.") + + self.ws = websocket.create_connection("wss://hack.chat/chat-ws") + self._send_packet({"cmd": "join", "channel": self.channel, "nick": self.nick}) + self._recv_thread.start() + self._ka_thread.start() + + def _receive(self): + """Waits for data and then sends it to the callback functions. + Will send a SIGALRM to its own process upon connection loss or crash.""" + try: + while not self._stop.wait(timeout=0): + self.ws.settimeout(1) + try: + result_raw = self.ws.recv() + #print(result_raw) + result = json.loads(result_raw) + #print(result) + self._handleCommand(result) + except websocket._exceptions.WebSocketTimeoutException: + # Ignore timeouts + pass + except (json.decoder.JSONDecodeError, + websocket._exceptions.WebSocketConnectionClosedException) as e: + print("Connection lost!") + # Signal main thread that bot crashed + signal.alarm(1) + + except: + print("Receiver thread crashed!") + traceback.print_exc() + # Signal main thread that bot crashed + signal.alarm(1) + + print("Receiver thread shut down.") + + def _handleCommand(self, result): + """Will demultiplex incoming packets to their respective callback + functions.""" + if result["cmd"] == "chat" and not result["nick"] == self.nick: + for handler in list(self.on_message): + handler(self, result) + elif result["cmd"] == "onlineAdd": + self.online_users.append(result["nick"]) + for handler in list(self.on_join): + handler(self, result) + elif result["cmd"] == "onlineRemove": + self.online_users.remove(result["nick"]) + for handler in list(self.on_leave): + handler(self, result) + elif result["cmd"] == "onlineSet": + for nick in result["nicks"]: + self.online_users.append(nick) + + def stop(self): + """Gracefully stops all bot threads and closes WebSocket connection.""" + if self.stopped: + return + + self._stop.set() + self._recv_thread.join() + self._ka_thread.join() + self.ws.close() + self.stopped = True + + def _ping(self): + """Retains the websocket connection.""" + while self.ws.connected \ + and not self._stop.wait(timeout=60): + self._send_packet({"cmd": "ping"}) + #print("PING") + + print("Keepalive thread shut down.") + diff --git a/telegrambot.py b/telegrambot.py new file mode 100644 index 0000000..314e40c --- /dev/null +++ b/telegrambot.py @@ -0,0 +1,68 @@ +#!/bin/python + +# This is based on +# https://github.com/python-telegram-bot/python-telegram-bot (GPLv3 License) + +import telegram +from telegram.ext import Updater, CommandHandler, MessageHandler, Filters +import logging + +import config + +def _onStart(bot, update): + if config.CHAT_ID != -1: + bot.send_message(chat_id=update.message.chat_id, + text="Sod off, this is a private bot.") + else: + bot.send_message(chat_id=update.message.chat_id, + text="Your CHAT_ID is %i. Add this to your config file." + % update.message.chat_id) + +class TGBot(): + + def __init__(self): + self._updater = Updater(token=config.API_TOKEN) + self._dispatcher = self._updater.dispatcher + self._dispatcher.add_handler(CommandHandler('start', _onStart)) + self._dispatcher.add_handler(MessageHandler(Filters.text, self._onText)) + self.texthandlers = [] + + def run(self): + self._updater.start_polling() + + def stop(self): + self._updater.stop() + print("Telegram Bot shut down.") + + def _onText(self, bot, update): + chat_id = update.message.chat_id + if chat_id != config.CHAT_ID: + return + for handler in self.texthandlers: + handler(update.message.text) + + def send(self, text): + self._updater.bot.send_message( + chat_id=config.CHAT_ID, + text=text, + parse_mode=telegram.ParseMode.MARKDOWN + ) + + def addCommand(self, command, handler): + # Wrap the handler to include a CHAT_ID check + self._dispatcher.add_handler(CommandHandler(command, + lambda bot, update: self._commandWrapper(handler, bot, update))) + + def _commandWrapper(self, handler, bot, update): + chat_id = update.message.chat_id + if chat_id != config.CHAT_ID: + return + else: + return handler(bot, update) + +if __name__ == "__main__": + #logging.basicConfig(level=logging.DEBUG, + # format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + bot = TGBot() + bot.run() -- cgit v1.2.1