diff --git a/bot.py b/bot.py
old mode 100755
new mode 100644
index e790b48..5b4a5ad
--- a/bot.py
+++ b/bot.py
@@ -1,146 +1,296 @@
-#!/usr/bin/env python3
+# TODO: Clean up message sending by consolidating all those PRIVMSG bits
+# TODO: Make separate adminhelp command for admin-only commands
-# Copyright (C) 2020 Jake Bauer
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-
-# For basic functionality
+import sys
+import signal
import socket
import ssl
-
-# For fun and features
+import json
+import time
+import base64
import random
import requests
+from threading import Timer
from datetime import datetime
from pytz import timezone
-VERSION = "v0.5.0"
-AUTHOR = "Jake Bauer"
-server = "irc.paritybit.ca"
-port = 6697
-channels = ["#test"]
-botnick = "testbot"
-quotesfile = "quotes.txt"
-eightBallResponses = ["It is certain.",
- "It is decidedly so.",
- "Without a doubt.",
- "Yes – definitely.",
- "You may rely on it.",
- "As I see it, yes.",
- "Most likely.",
- "Yes.",
- "Signs point to yes.",
- "Reply hazy, try again",
- "Ask again later.",
- "Better not tell you now.",
- "Cannot predict now.",
- "Concentrate and ask again.",
- "Don't count on it."
- "My reply is no."
- "My sources say no."
- "Outlook not so good."
- "Very doubtful."]
-ircsock = None
+# RepeatedTimer class from Stackoverflow question: https://stackoverflow.com/questions/3393612
+# Author of Question: https://stackoverflow.com/users/374797/john-howard
+# Answer: https://stackoverflow.com/a/13151299
+# Author of Answer: https://stackoverflow.com/users/624066
+class RepeatedTimer(object):
+ def __init__(self, interval, function, *args, **kwargs):
+ self._timer = None
+ self.interval = interval
+ self.function = function
+ self.args = args
+ self.kwargs = kwargs
+ self.is_running = False
+ self.start()
-def send_msg(msg):
- ircsock.sendall(msg.encode('utf-8'))
+ def _run(self):
+ self.is_running = False
+ self.start()
+ self.function(*self.args, **self.kwargs)
+
+ def start(self):
+ if not self.is_running:
+ self._timer = Timer(self.interval, self._run)
+ self._timer.start()
+ self.is_running = True
+
+ def stop(self):
+ self._timer.cancel()
+ self.is_running = False
-def get_channel(ircmsg):
- return ircmsg.split(' ')[2]
+class Bot:
+ def __init__(self):
+ self.VERSION = "v0.8.0"
+ self.AUTHOR = "Jake Bauer (jbauer)"
+ self.ircsock = None
+ self.server = ""
+ self.port = 0
+ self.nick = ""
+ self.password = ""
+ self.channels = []
+ self.admins = []
+ self.eightBallResponses = []
+ self.quotesfile = ""
+ self.commandList = "+help +version +quit +quote +time +roll +weather +8ball +repeat +addadmin +rmadmin +admins +chgpass"
+ self.exception = ""
+ def load_config(self):
+ try:
+ with open ("config.json", "r") as file:
+ config = json.load(file)
+ self.server = config.get("server")
+ self.port = config.get("port")
+ self.nick = config.get("nick")
+ self.password = config.get("password") or ""
+ self.channels = config.get("channels") or []
+ self.admins = config.get("admins") or []
+ self.eightBallResponses = config.get("eightBallResponses") or []
+ self.quotesfile = config.get("quotesfile") or ""
+ if not self.server or not self.port or not self.nick:
+ sys.stderr.write("Missing value for server, port, or nick, check your config file!\n")
+ sys.exit(1)
+ except Exception as e:
+ sys.stderr.write("Could not open config file: " + str(e) + "\n")
+ sys.exit(1)
-def init():
- global ircsock
- # Set up the socket
- s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- s.connect((server, port))
- ircsock = ssl.wrap_socket(s)
+ def save_config(self):
+ try:
+ with open ("config.json", "w") as file:
+ config = {
+ "server": self.server,
+ "port": self.port,
+ "nick": self.nick,
+ "password": self.password,
+ "channels": self.channels,
+ "admins": self.admins,
+ "eightBallResponses": self.eightBallResponses,
+ "quotesfile": self.quotesfile
+ }
+ json.dump(config, file, indent=4)
+ except Exception as e:
+ sys.stderr.write("Could not open config file: " + str(e) + "\n")
- # Perform the initial connection
- message = "USER " + " " + botnick + " 0 * " + botnick +"\n"
- send_msg(message)
- message = "NICK "+ botnick +"\n"
- send_msg(message)
+ def listen(self):
+ incoming = self.ircsock.recv(2048).decode('utf-8').strip('\n\r')
+ print("\033[32m>>\033[0m " + incoming, flush=True)
+ return incoming
- # Join the configured channels
- for channel in channels:
- message = "JOIN "+ channel +"\n"
- send_msg(message)
+ def waitfor(self, message):
+ while True:
+ incoming = self.listen()
+ if message in incoming:
+ return incoming
-def main():
- while 1:
- ircmsg = ircsock.recv(2048).decode('utf-8')
- ircmsg = ircmsg.strip('\n\r')
- print(ircmsg)
+ def get_channel(self, message):
+ return message.split(' ')[2]
- if ircmsg.find(":,tbhelp") != -1:
- message = "PRIVMSG "+ get_channel(ircmsg) +" :Available commands: ,tbhelp ,tbversion ,tbwhereami ,tbquote ,tbtime ,tbdiceroll ,tbweather ,tb8ball\n"
- send_msg(message)
+ def get_user(self, message):
+ return message.split(' ')[0].split('!')[0][1:]
- elif ircmsg.find(":,tbversion") != -1:
- message = "PRIVMSG " + get_channel(ircmsg) + " :" + botnick + "version " + VERSION + " by " + AUTHOR + ".\n"
- send_msg(message)
+ def is_admin(self, user):
+ if user in self.admins:
+ return True
+ return False
- elif ircmsg.find(":,tbwhereami") != -1:
- message = "PRIVMSG "+ get_channel(ircmsg) +" :You are currently in " + get_channel(ircmsg) + ".\n"
- send_msg(message)
+ def send_message(self, message):
+ self.ircsock.sendall(message.encode('utf-8'))
+ print("\033[31m<<\033[0m " + message, end='', flush=True)
- elif ircmsg.find(":,tbquote") != -1:
- with open(quotesfile, "r") as f:
- lines = f.read().splitlines()
- selectedLine = random.choice(lines)
- message = "PRIVMSG " + get_channel(ircmsg) + " :" + selectedLine + "\n"
- send_msg(message)
+ def announce(self, message):
+ for channel in self.channels:
+ self.send_message(message)
- elif ircmsg.find(":,tbtime") != -1:
- try:
- message = "PRIVMSG " + get_channel(ircmsg) + " :" + datetime.now(timezone(ircmsg.split(' ')[4])).strftime('%Y-%m-%d %H:%M:%S') + "\n"
- except Exception as e:
- message = "PRIVMSG " + get_channel(ircmsg) + " :Please specify a valid timezone (e.g. ,tbtime America/Toronto).\n"
- send_msg(message)
+ def handle_exception(self, incoming, exception):
+ self.exception = exception
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :An unexpected error occurred. Run +showexception to see what went wrong.\n")
- elif ircmsg.find(":,tbdiceroll") != -1:
- try:
- message = "PRIVMSG " + get_channel(ircmsg) + " :" + str(random.randint(1, int(ircmsg.split(' ')[4]))) + "\n"
- except Exception as e:
- message = "PRIVMSG " + get_channel(ircmsg) + " :Please specify a maximum number (e.g. ,tbdiceroll 20).\n"
- send_msg(message)
+ def repeat(self, channel, message):
+ self.send_message("PRIVMSG " + channel + " :" + message + "\n")
- elif ircmsg.find(":,tbweather") != -1:
- try:
- city = ircmsg.split(' ')[4]
- url = "https://wttr.in/" + city + "?m&format=3"
- response = requests.get(url)
- message = "PRIVMSG " + get_channel(ircmsg) + " :" + response.text.strip('\n\r') + "\n"
- except Exception as e:
- message = "PRIVMSG " + get_channel(ircmsg) + " :Please specify a city (e.g. ,tbweather Toronto).\n"
- send_msg(message)
+ def connect(self):
+ sys.stderr.write("Connecting to: " + self.server + ":" + str(self.port) + "\n")
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.connect((self.server, self.port))
+ self.ircsock = ssl.wrap_socket(s)
+ self.send_message("USER " + self.nick + " 0 * " + self.nick + "\n")
+ self.send_message("NICK "+ self.nick + "\n")
+ self.sasl_auth() or self.nickserv_auth()
+ for channel in self.channels:
+ self.send_message("JOIN "+ channel +"\n")
- elif ircmsg.find(":,tb8ball") != -1:
- try:
- ircmsg.split(' ')[4]
- message = "PRIVMSG " + get_channel(ircmsg) + " :" + random.choice(eightBallResponses) + "\n"
- except Exception as e:
- message = "PRIVMSG " + get_channel(ircmsg) + " :Please ask me a question.\n"
- send_msg(message)
+ def sasl_auth(self):
+ self.send_message("CAP REQ :sasl\n")
+ incoming = self.waitfor("CAP")
+ if "ACK :sasl" in incoming:
+ self.send_message("AUTHENTICATE PLAIN\n")
+ self.waitfor("+")
+ creds = self.nick + "\0" + self.nick + "\0" + self.password
+ creds = base64.b64encode(creds.encode('utf8')).decode('utf8')
+ self.send_message("AUTHENTICATE " + creds + "\n")
+ incoming = self.waitfor(":SASL authentication")
+ if ("903" in incoming):
+ self.send_message("CAP END\n")
+ return True
+ else:
+ return False
+ else:
+ return False
- elif ircmsg.find("PING ") != -1:
- print("Responding to PING.")
- message = "PONG :" + botnick + "\n"
- send_msg(message)
+ def nickserv_auth(self):
+ self.send_message("PRIVMSG NickServ :" + self.password + "\n")
+ incoming = self.waitfor("NickServ")
+ if ("You are now identified"):
+ return True
+ else:
+ return False
-init()
-main()
+ def disconnect(self):
+ self.send_message("QUIT :Quitting...\n")
+ self.ircsock.close()
+
+ def quit (self, incoming):
+ if self.is_admin(incoming):
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :*sigh* alright then...\n")
+ self.disconnect()
+ self.save_config()
+ return True
+ else:
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :You don't have permission to use this command.\n")
+ return False
+
+ def command_help(self, incoming):
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :Available commands: " + self.commandList + "\n")
+
+ def command_version(self, incoming):
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :" + self.nick + "version " + self.VERSION + " by " + self.AUTHOR + ".\n")
+
+ def command_quote(self, incoming):
+ with open(self.quotesfile, "r") as f:
+ lines = f.read().splitlines()
+ selectedLine = random.choice(lines)
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :" + selectedLine + "\n")
+
+ def command_time(self, incoming):
+ try:
+ message = "PRIVMSG " + self.get_channel(incoming) + " :" + datetime.now(timezone(incoming.split(' ')[4])).strftime('%Y-%m-%d %H:%M:%S') + "\n"
+ except:
+ message = "PRIVMSG " + self.get_channel(incoming) + " :Please specify a valid timezone (e.g. +time America/Toronto).\n"
+ self.send_message(message)
+
+ def command_roll(self, incoming):
+ try:
+ message = "PRIVMSG " + self.get_channel(incoming) + " :" + str(random.randint(1, int(incoming.split(' ')[4]))) + "\n"
+ except:
+ message = "PRIVMSG " + self.get_channel(incoming) + " :Please specify a maximum number (e.g. '+roll 20').\n"
+ self.send_message(message)
+
+ def command_weather(self, incoming):
+ try:
+ city = incoming.split(' ')[4]
+ url = "https://wttr.in/" + city + "?m&format=3"
+ response = requests.get(url)
+ message = "PRIVMSG " + self.get_channel(incoming) + " :" + response.text.strip('\n\r') + "\n"
+ except:
+ message = "PRIVMSG " + self.get_channel(incoming) + " :Please specify a city (e.g. '+weather Toronto').\n"
+ self.send_message(message)
+
+ def command_eightball(self, incoming):
+ try:
+ incoming.split(' ')[4]
+ message = "PRIVMSG " + self.get_channel(incoming) + " :" + random.choice(self.eightBallResponses) + "\n"
+ except Exception as e:
+ print(e)
+ message = "PRIVMSG " + self.get_channel(incoming) + " :Please ask me a question.\n"
+ self.send_message(message)
+
+ def command_repeatsetup(self, incoming):
+ if not self.is_admin(incoming):
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :You don't have permission to use this command.\n")
+ return
+ try:
+ channel = self.get_channel(incoming)
+ interval = int(incoming.split(' ')[4])
+ message = ' '.join(incoming.split(' ')[5:])
+ except Exception as e:
+ print(e)
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :Invalid syntax. Expected +repeat \n")
+ rt = RepeatedTimer(interval, self.repeat, channel, message)
+
+ def command_admins(self, incoming):
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :Admins: " + ', '.join(self.admins) + "\n")
+
+ def command_addadmin(self, incoming):
+ try:
+ candidate = incoming.split(' ')[4]
+ except:
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :Invalid syntax. Expected +addadmin \n")
+ if self.is_admin(self.get_user(incoming)):
+ if self.is_admin(candidate):
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :" + candidate + " is already an admin.\n")
+ self.admins.append(candidate)
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :" + candidate + " granted admin privileges.\n")
+ else:
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :You don't have permission to use this command.\n")
+
+ def command_rmadmin(self, incoming):
+ try:
+ candidate = incoming.split(' ')[4]
+ except:
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :Invalid syntax. Expected +rmadmin \n")
+ if self.is_admin(self.get_user(incoming)):
+ if not self.is_admin(candidate):
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :" + candidate + " is not an admin.\n")
+ self.admins.remove(candidate)
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " : Revoked " + candidate + "'s admin privileges.\n")
+ else:
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :You don't have permission to use this command.\n")
+
+ def command_showexception(self, incoming):
+ if self.is_admin(self.get_user(incoming)):
+ if not self.exception:
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " : No exceptions occurred... yet.\n")
+ else:
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :" + str(self.exception) + "\n")
+ else:
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :You don't have permission to use this command.\n")
+
+ def command_chgpass(self, incoming):
+ try:
+ password = incoming.split(' ')[4]
+ if self.is_admin(self.get_user(incoming)):
+ self.send_message("PRIVMSG NickServ :SET PASSWORD " + password + "\n")
+ response = self.waitfor("NickServ")
+ if "successfully" in response:
+ self.password = password
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :Password changed.\n")
+ else:
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :Failed to change password.\n")
+ except:
+ self.send_message("PRIVMSG " + self.get_channel(incoming) + " :Invalid syntax. Expected +chgpass \n")
diff --git a/config.default.json b/config.default.json
new file mode 100644
index 0000000..fc31a05
--- /dev/null
+++ b/config.default.json
@@ -0,0 +1,34 @@
+{
+ "server": "irc.example.com",
+ "port": 6697,
+ "nick": "botty_mcbotface",
+ "password": "password",
+ "channels": [
+ "#example"
+ ],
+ "admins": [
+ "botmaster"
+ ],
+ "eightBallResponses": [
+ "It is certain.",
+ "It is decidedly so.",
+ "Without a doubt.",
+ "Yes \u2013 definitely.",
+ "You may rely on it.",
+ "As I see it, yes.",
+ "Most likely.",
+ "Yes.",
+ "Signs point to yes.",
+ "Reply hazy, try again",
+ "Ask again later.",
+ "Better not tell you now.",
+ "Cannot predict now.",
+ "Concentrate and ask again.",
+ "Don't count on it.",
+ "My reply is no.",
+ "My sources say no.",
+ "Outlook not so good.",
+ "Very doubtful."
+ ],
+ "quotesfile": "quotes.txt"
+}
diff --git a/run.py b/run.py
new file mode 100755
index 0000000..7f14f5e
--- /dev/null
+++ b/run.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+
+import sys
+import signal
+from bot import Bot
+
+def main():
+ bot = Bot()
+ bot.load_config()
+ bot.connect()
+
+ def signal_handler(sig, frame):
+ print("\n!!CAUGHT SIGINT!!")
+ bot.disconnect()
+ bot.save_config()
+ sys.exit(0)
+
+ signal.signal(signal.SIGINT, signal_handler)
+
+ while True:
+ try:
+ incoming = bot.listen()
+
+ # Properly respond to DMs
+ if "PING " in incoming:
+ print("Responding to PING.")
+ message = "PONG :" + bot.nick + "\n"
+ bot.send_message(message)
+ continue
+
+ if bot.get_channel(incoming) == bot.nick:
+ incoming = incoming.replace(bot.nick, bot.get_user(incoming))
+
+ if ":+quit" in incoming:
+ quit = bot.quit(incoming)
+ if quit:
+ sys.exit(0)
+ elif ":+help" in incoming:
+ bot.command_help(incoming)
+ elif ":+version" in incoming:
+ bot.command_version(incoming)
+ elif ":+quote" in incoming:
+ bot.command_quote(incoming)
+ elif ":+time" in incoming:
+ bot.command_time(incoming)
+ elif ":+roll" in incoming:
+ bot.command_roll(incoming)
+ elif ":+weather" in incoming:
+ bot.command_weather(incoming)
+ elif ":+8ball" in incoming:
+ bot.command_eightball(incoming)
+ elif ":+repeat" in incoming:
+ bot.command_repeatsetup(incoming)
+ elif ":+addadmin" in incoming:
+ bot.command_addadmin(incoming)
+ elif ":+rmadmin" in incoming:
+ bot.command_rmadmin(incoming)
+ elif ":+admins" in incoming:
+ bot.command_admins(incoming)
+ elif ":+showexception" in incoming:
+ bot.command_showexception(incoming)
+ elif ":+chgpass" in incoming:
+ bot.command_chgpass(incoming)
+ except Exception as e:
+ bot.handle_exception(incoming, e)
+ continue
+
+
+if __name__ == '__main__':
+ main()