aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorTim Schmidt <tim.schmidt@ewe.net>2018-12-03 17:22:53 +0100
committerTim Schmidt <tim.schmidt@ewe.net>2018-12-03 17:22:53 +0100
commitfbbd202a0416fbf5cfac8a8d2b4e43e86e15762b (patch)
tree40cd7c5be1c6b102fbb0f23ea7a2b82c234c0427
downloadhchat-tg-bridge-fbbd202a0416fbf5cfac8a8d2b4e43e86e15762b.tar.gz
hchat-tg-bridge-fbbd202a0416fbf5cfac8a8d2b4e43e86e15762b.zip
initial commit
-rw-r--r--.gitignore5
-rw-r--r--config.py.sample38
-rwxr-xr-xcontrol.py207
-rw-r--r--hackchatcustom.py130
-rw-r--r--telegrambot.py68
5 files changed, 448 insertions, 0 deletions
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.
+ <on_message> is <list> 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
+ <HackChat> 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 -- <str>; the nickname to use upon joining the channel
+ channel -- <str>; 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 <packet> (<dict>) 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()