diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0403a89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.conf +__pycache__ +*.pyc +*.pyo +*.nogit diff --git a/bot.conf.example b/bot.conf.example new file mode 100644 index 0000000..5bbf7d6 --- /dev/null +++ b/bot.conf.example @@ -0,0 +1,91 @@ +########### +### BOT ### +########### + +[Network] + +# The Server name to connect to. Duh! +Server = fanir.de + +# "Default" is 6667. An often used port for SSL would be 6697, if SSL would be +# supported. Maybe in a future, far far away... +Port = 6667 + +# Serverpassword. Empty in most cases. +ServerPasswd = + +# A comma-seperated list of channels to join. +# The channels must be enclosed with quotation marks ("")! +Channels = "#bots", "#your_channel" + +# The encodings to try when getting messages from IRC. Will be tried in the given order. +# The first one is used to encode data when sending stuff. +# The list given shoud do just fine in most networks, I assume. +# Also comma seperated. +Encodings = utf-8, latin-1, iso-8859-1, cp1252 + + +[Bot] +# The list of nicknames to try. If the first one is not aviable, it will +# try the second, and so on... +# You should specfy at least two nicks. +Nicknames = chalkbot, chalkbot_, chalkbot__ + +# Also known as username. Some IRC-internal. +# By default, the nickname will be used as ident. +Ident = chalkbot + +Realname = A ChalkBot Instance + +# Command for registering with nickserv, without leading slash. +NickservCommand = + + +Modes = +iB + + +[Permissions] +# List of users (hostmasks as regex) and the commands they are allowed to execute. +# * can be used instead of commands to allow every command. You should append a trailing comma. +# All command names should be lowercase. +yournickname\!yourusername@.* = *, +\!@some other user = join, part, invite +.*\!murderer@(localhost|127(\.0){2}\.1) = die, + + +[Behavior] +# The prefix for commands for the bot. +CommandPrefix = ! + +# Which way should be used to speak to users? +# "" (Nothing) means the type of the incoming message should be used. +# One of: NOTICE, PRIVMSG, "" (Nothing) +QueryType = + +# With how much information do you want to be annoyed? +# DEBUG spams most, FATAL least. WARNING should be a good tradeoff. +# One of: DEBUG, INFO, WARNING, ERROR, CRITICAL +Loglevel = WARNING + +# Time to wait between two PRIVMSGS in seconds. +# Can prevent the bot from running into flood limits. +MsgWaitTime = 0.1 + +# The time the parser for incoming data should wait between each attempt to read new data in seconds. +# High values will certainly make the bot reply slowly while very low values increads cpu load and therefore will perform badly on slow machines. +# You should keep it between 1 and 0.001 seconds. +# For gods sake, don't ever set it to 0! +ParserWaitTime = 0.05 + + + +######################### +### EXTERNAL SERVICES ### +######################### + +[DuckDuckGo] +Active = True + +[Forecast.io] +Active = False +ApiKey = your_api_key diff --git a/main.py b/main.py index 32cae4d..b304486 100755 --- a/main.py +++ b/main.py @@ -26,53 +26,60 @@ # SETTINGS CAN BE FOUND AT THE BOTTOM OF THE FILE -import sys, string, socket, re, signal +import sys, string, socket, re, signal, json, logging from random import choice -from time import sleep +from time import sleep, time from select import poll, POLLIN, POLLPRI, POLLOUT,\ POLLERR, POLLHUP, POLLNVAL from threading import Thread, Event +from urllib.request import urlopen +from urllib.parse import quote_plus +from configobj import ConfigObj bot = None - -class log(): - DEBUG, INFO, WARNING, ERROR, FATAL, SILENT =\ - 0, 1, 2, 3, 4, 5 - - levels = { - 0: "[DEBUG] ", 1: "[INFO] ", 2: "[WARNING] ", - 3: "[ERROR] ", 4: "[FATAL] ", 5: "[SILENT] " - } - - def show(level, msg): - if level in range(log.DEBUG, log.SILENT): - if LOGLEVEL <= level: print("".join((log.levels[level], msg))) - else: - raise ValueError("That's not a loglevel!") - +logging.basicConfig(format="[%(asctime)s] %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S") class pircbot(): - def __init__(self, nicknames, server, port=6667, - ident="pircbot", realname="pircbot", serverpasswd="", - encodings=("utf-8", "latin-1"), users="", - query_type="", command_prefix=".", - parser_wait_time=0.1): - self.server = server - self.port = port - self.serverpasswd = serverpasswd - self.encodings = encodings - + def __init__( self, + server, + nicknames, + ident = "pircbot", + realname = "pircbot", + port = 6667, + serverpasswd = "", + encodings = ("utf-8", "latin-1"), + query_type = "", + command_prefix = "!", + msg_wait_time = 0, + parser_wait_time = 0.1, + users = "", + duckduckgo_cfg = {"Active": "0"}, + forecast_cfg = {"Active": "0"}, + logger = logging.getLogger(logging.basicConfig()) + ): self.nicknames = nicknames self.ident = ident self.realname = realname - self.users = users - self.cmdprefix = command_prefix - self.query_type = query_type.upper() + self.server = server + self.port = int(port) + self.serverpasswd = serverpasswd + self.encodings = encodings + + self.query_type = query_type.upper() + self.cmdprefix = command_prefix + self.msg_wait_time = float(msg_wait_time) + self.parser_wait_time = float(parser_wait_time) + + self.users = users + + self.duckduckgo_cfg = duckduckgo_cfg + self.forecast_cfg = forecast_cfg + + self.log = logger - self.parser_wait_time=parser_wait_time self.user = {} @@ -86,15 +93,17 @@ class pircbot(): self.ready = False self.mode_reply = {} + + self.last_msg_ts = time() def connect(self): # connect self.socket = socket.socket() - log.show(log.DEBUG, "--- SOCKET OPENING ---") + self.log.debug("--- SOCKET OPENING ---") try: self.socket.connect((self.server, self.port)) except socket.error as e: - log.show(log.FATAL, "Fehler: %s" % e) + self.log.critical("Fehler: %s" % e) return # start getting data @@ -120,7 +129,7 @@ class pircbot(): while self.recvloop.is_alive() and self.parseloop.is_alive() and ctr < 15: ctr += 1 sleep(1) - log.show(log.DEBUG, "--- SOCKET CLOSING ---") + self.log.debug("--- SOCKET CLOSING ---") try: self.socket.shutdown(socket.SHUT_RDWR) self.socket.close() @@ -134,7 +143,7 @@ class pircbot(): """ Loop for reciving data """ - log.show(log.DEBUG, "--- RECVLOOP STARTING ---") + self.log.debug("--- RECVLOOP STARTING ---") self.parseloop = Thread(target=self.parser, name="parser") p = poll() p.register(self.socket.fileno(), POLLIN) @@ -145,14 +154,14 @@ class pircbot(): if not self.parseloop.is_alive(): self.parseloop = Thread(target=self.parser, name="parser") self.parseloop.start() - log.show(log.DEBUG, "--- RECVLOOP EXITING ---") + self.log.debug("--- RECVLOOP EXITING ---") # loop for parsing incoming data def parser(self): """ Loop for parsing incoming data """ - log.show(log.DEBUG, "--- PARSELOOP STARTING ---") + self.log.debug("--- PARSELOOP STARTING ---") while not self.die_event.is_set():# and self.recvbuffer.endswith(b"\r\n"):# != b"": if self.recvbuffer.endswith(b"\r\n"): # get and decode line from buffer @@ -179,7 +188,7 @@ class pircbot(): params = head params.append(larg.strip()) - log.show(log.DEBUG, " > %s" % rawline) + self.log.debug(" > %s" % rawline) # PING if command == "PING": @@ -213,11 +222,11 @@ class pircbot(): self.send("NICK %s" % self.nicknames.pop(0)) # KILL elif command == "KILL": - log.show(log.WARNING, "Got killed by %s: %s" % (params[0], params[1:])) + self.log.warning("Got killed by %s: %s" % (params[0], params[1:])) self.disconnect(send_quit=False) else: sleep(self.parser_wait_time) - log.show(log.DEBUG, "--- PARSELOOP EXITING ---") + self.log.debug("--- PARSELOOP EXITING ---") ### helper functions ### @@ -259,12 +268,12 @@ class pircbot(): cnt = 0 while not self.ready and cnt time(): + sleep(0.1) + self.last_msg_ts = time() self.send("".join(("PRIVMSG ", channel, " :", msg))) # replies to user by NOTICE or PRIVMSG @@ -330,6 +342,7 @@ class pircbot(): Command is the command for the bot. Params contains a list of originally space separated parameters. """ + numparams = len(params) # hello if command == "hello": greeting = "".join(("Hi " + origin["nick"] +"!")) @@ -337,30 +350,68 @@ class pircbot(): # say if command == "say": return " ".join(params) + + # DuckDuckGo, ddg + elif command in ("duckduckgo", "ddg") and self.duckduckgo_cfg["Active"] == "1": + if numparams==0: + return "You didn't ask anything..." + try: rp = urlopen("https://api.duckduckgo.com/?q=%s&format=json&no_html=1&no_redirect=1&t=pircbot:chalkbot" + % quote_plus(" ".join(params))) + except Exception as e: + self.log.error("Error while querying DuckDuckGo: %s" % e) + return "Error while querying DuckDuckGo: %s" % e + if rp.getcode() == 200: + used_fields = ( + "Heading", "AbstractText", "AbstractSource", "AbstractURL", + "AnswerType", "Answer", + "Definition", "DefinitionSource", "DefinitionURL", + ) + rj = json.loads(str(rp.readall(), "utf-8")) + empty_field_counter = 0 + for elem in [rj for rj in used_fields]: + if rj[elem] not in ("", []): + self.reply(origin, source, "%s: %s" % (elem, rj[elem]), in_query_type) + else: + empty_field_counter+=1 + if empty_field_counter == len(used_fields): + return "No suitable reply from DuckDuckGo for query %s" % " ".join(params) + else: + return "(Results from DuckDuckGo )" + else: + return "Error while querying DuckDuckGo, got HTTP-Status %i" % rp.getcode() + # Forecast, fc, weather + elif command in ("weather", "forecast", "fc") and self.forecast_cfg["Active"] == "1": + if numparams==2: + + return "(Powered by Forecast )" + else: + return "Usage: %s " % command + # join elif command == "join": - if len(params)>0: + if numparams>0: if self.check_privileges(origin["mask"], command): self.join(params[0]) else: self.query(origin["nick"], "You cannot do that!", in_query_type) # part elif command == "part": - if len(params)>0: + if numparams>0: if self.check_privileges(origin["mask"], command): self.part(params[0]) else: self.query(origin["nick"], "You cannot do that!", in_query_type) # mode ± elif command == "mode": if self.check_privileges(origin["mask"], command): - if len(params)==0: + if numparams==0: self.mode_reply["to"] = origin["nick"] self.mode_reply["type"] = in_query_type self.get_modes() else: - self.set_mode(" ".join(params) if len(params)>0 else params) + self.set_mode(" ".join(params) if numparams>0 else params) else: self.query(origin["nick"], "You cannot do that!", in_query_type) + # die [] elif command == "die": if self.check_privileges(origin["mask"], command): - self.disconnect("".join(params) if len(params)>0 else "".join((origin["nick"], " shot me, dying now... Bye..."))) + self.disconnect("".join(params) if numparams>0 else "".join((origin["nick"], " shot me, dying now... Bye..."))) else: self.query(origin["nick"], "Go die yourself!", in_query_type) else: replies = [ @@ -375,21 +426,54 @@ class pircbot(): def parseargs(): import argparse p = argparse.ArgumentParser( - description = "i think my desc is missing") - p.add_argument("action", default="help", choices = ["start", "stop"], help="What to do?") + description = "guess what? i think my desc is still missing!" + ) + p.add_argument("action", + default = "help", + choices = ["start", "stop", "checkconf"], + help = "What to do?" + ) + p.add_argument("--loglevel", "-l", + choices = ["critical", "error", "warning", "info", "debug"], + help = "Verbosity of logging" + ) #p.add_argument("--daemon", "-d", type = bool, choices = [1, 0], default=1, help="Daemonize, Default: 1") return p.parse_args() def main(): + log = logging.getLogger(__name__) + global bot args = parseargs() + cfg = ConfigObj("bot.conf") + + nll = getattr(logging, cfg["Behavior"]["Loglevel"].upper(), None) + if not isinstance(nll, int): + raise ValueError('Invalid log level: %s' % cfg["Behavior"]["Loglevel"]) + if args.loglevel != None: nll = getattr(logging, args.loglevel.upper(), None) + if not isinstance(nll, int): + raise ValueError('Invalid log level: %s' % args.loglevel) + log.setLevel(nll) + if args.action == "start": try: - bot = pircbot(nicknames=NICKNAMES, ident=IDENT, realname=REALNAME, - server=SERVER, port=PORT, serverpasswd=SERVERPASSWD, - encodings=ENCODINGS, users=USERS, - query_type=QUERY_TYPE, command_prefix=COMMAND_PREFIX, - parser_wait_time=PARSER_WAIT_TIME) + bot = pircbot( + nicknames = cfg["Bot"]["Nicknames"], + ident = cfg["Bot"]["Ident"], + realname = cfg["Bot"]["Realname"], + server = cfg["Network"]["Server"], + port = cfg["Network"]["Port"], + serverpasswd = cfg["Network"]["ServerPasswd"], + encodings = cfg["Network"]["Encodings"], + query_type = cfg["Behavior"]["QueryType"], + command_prefix = cfg["Behavior"]["CommandPrefix"], + msg_wait_time = cfg["Behavior"]["MsgWaitTime"], + parser_wait_time = cfg["Behavior"]["ParserWaitTime"], + users = cfg["Permissions"], + duckduckgo_cfg = cfg["DuckDuckGo"], + forecast_cfg = cfg["Forecast.io"], + logger = log, + ) bot.connect() # wait for the bot to become ready while bot.ready and not bot.die_event.is_set() == False: @@ -397,97 +481,25 @@ def main(): if not bot.die_event.is_set(): # set modes and join channels - bot.set_mode(MODES) - for channel in CHANNELS: + bot.set_mode(cfg["Bot"]["Modes"]) + for channel in cfg["Network"]["Channels"]: bot.join(channel) # while bot is active, do nothing while not bot.die_event.is_set(): sleep(1) except KeyboardInterrupt: - log.show(log.INFO, "Got Ctrl-C, dying now...") + log.info("Got Ctrl-C, dying now...") bot.disconnect("Ouch! Got shot by Ctrl-C, dying now... See you!") - + log.debug("--- MAIN EXITING ---") elif args.action == "stop": print("nope!") - log.show(log.DEBUG, "--- MAIN EXITING ---") + elif args.action == "checkconf": + for section, settings in cfg.items(): + print("".join(("[", section, "]"))) + for e in settings: + print("%20s : %s" % (e, settings[e])) return 0 if __name__ == '__main__': - - ################ - ### SETTINGS ### - ################ - - # Also known as username. Some IRC-internal. - # By default, the nickname will be used as ident. - IDENT = "chalkbot" - - # The list of nicknames to try. If the first one is not aviable, it will - # try the second, and so on... - # You should specfy at least two nicks. - NICKNAMES = ["chalkbot", "chalkbot_", "chalkbot__"] - - REALNAME = "A ChalkBot Instance" - - # Command for registering with nickserv, without leading slash. - NICKSERVCMD = "" - - MODES = "+iB" - - - # The Server name to connect to. Duh! - SERVER = "fanir.de" - #SERVER = "localhost" - - # "Default" is 6667. An often used port for SSL would be 6697, if SSL would be - # supported. Maybe in a future, far far away... - PORT = 6667 - - # Serverpassword. Empty in most cases. - SERVERPASSWD = "" - - # A comma-seperated list of channels to join, enclosed by braces. - CHANNELS = ["#bots"] - - # The encodings to try when getting messages from IRC. Will be tried in the given order. - # The first one is used to encode data when sending stuff. - # The list given shoud do just fine in most networks, I assume. - # Also comma seperated and enclosed by braces. - ENCODINGS = ['utf-8', 'latin-1', 'iso-8859-1', 'cp1252'] - - - # List of users (hostmasks as regex) and the commands they are allowed to execute. - # "*" Can be used instead of commands to allow every command. - # All command names should be lowercase. - USERS = { - "Fanir\!fanir@.*": ["*"], - "\!@some weird user that cannot exist": ["join", "part", "invite"], - } - - # The prefix for commands for the bot. - COMMAND_PREFIX = "." - - # Which way should be used to speak to users? - # "" means the type of the incoming message should be used. - # One of: "NOTICE", "PRIVMSG", "" - QUERY_TYPE = "" - - - # With how much information do you want to be annoyed? - # DEBUG spams most, FATAL least. WARNING should be a good tradeoff. - # One of: log.DEBUG, log.INFO, log.WARNING, log.ERROR, log.FATAL - LOGLEVEL = log.DEBUG - - # The time the parser for incoming data should wait between each attempt to read new data in seconds. - # High values will certainly make the bot reply slowly while very low values increads cpu load and therefore will perform badly on slow machines. - # You should keep it between 1 and 0.001 seconds. - # For gods sake, don't set it to 0! - PARSER_WAIT_TIME = 0.05 - - ####################### - ### END OF SETTINGS ### - ####################### - - main()