1
0
Fork 0
chalkbot/main.py
Fanir e1b0e83e98 redone configuration: is now in an external file, parsed with configobj
redone logging, now using module logging of the standard library
added argument "checkconf" for viewing the configuration
added configuration options for external apis
added option to wait between channel privmsgs
added DuckDuckGo command
prepared Forecast command
2014-03-05 15:44:04 +01:00

505 lines
20 KiB
Python
Executable file

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# main.py
#
# Copyright 2014 Fanir <projects@mail.fanir.de>
#
# 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 2 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, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
# MA 02110-1301, USA.
#
#
# SETTINGS CAN BE FOUND AT THE BOTTOM OF THE FILE
import sys, string, socket, re, signal, json, logging
from random import choice
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
logging.basicConfig(format="[%(asctime)s] %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
class pircbot():
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.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.user = {}
self.socket = None
self.recvbuffer = bytearray(1024)
self.recvloop = None
self.parseloop = None
self.die_event = Event()
self.ready = False
self.mode_reply = {}
self.last_msg_ts = time()
def connect(self):
# connect
self.socket = socket.socket()
self.log.debug("--- SOCKET OPENING ---")
try:
self.socket.connect((self.server, self.port))
except socket.error as e:
self.log.critical("Fehler: %s" % e)
return
# start getting data
self.recvloop = Thread(target=self.recv, name="recvloop")
self.recvloop.start()
# optionally send a server password
if self.serverpasswd != "": self.send("PASS %s" % self.serverpasswd)
# get a nick
self.send("NICK %s" % self.nicknames.pop(0))
# set user data
self.send("USER %s 0 * :%s" % (self.ident, self.realname))
# implements irc command QUIT and (more or less) clean exiting
def disconnect(self, reason="", send_quit=True):
if send_quit:
try:
self.send("QUIT :%s" % reason)
sleep(1)
except: pass
self.die_event.set()
ctr = 0
while self.recvloop.is_alive() and self.parseloop.is_alive() and ctr < 15:
ctr += 1
sleep(1)
self.log.debug("--- SOCKET CLOSING ---")
try:
self.socket.shutdown(socket.SHUT_RDWR)
self.socket.close()
except: pass
### threaded functions ###
# loop for recieving data from irc
def recv(self):
"""
Loop for reciving data
"""
self.log.debug("--- RECVLOOP STARTING ---")
self.parseloop = Thread(target=self.parser, name="parser")
p = poll()
p.register(self.socket.fileno(), POLLIN)
while not self.die_event.is_set():
ap = p.poll(1000)
if (self.socket.fileno(), POLLIN) in ap:
self.recvbuffer.extend(self.socket.recv(1024))
if not self.parseloop.is_alive():
self.parseloop = Thread(target=self.parser, name="parser")
self.parseloop.start()
self.log.debug("--- RECVLOOP EXITING ---")
# loop for parsing incoming data
def parser(self):
"""
Loop for parsing incoming data
"""
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
rawline, _, self.recvbuffer = self.recvbuffer.partition(b"\r\n")
rawline = self.decode(rawline)
# prepare line
line = rawline.split(" :", 1)
head = line[0].lstrip("\x00").split(" ")
larg = line[1] if len(line)>1 else ""
# parse prefix
origin = {}
if head[0].startswith(":"):
prefix = head.pop(0)[1:]
if "@" in prefix:
origin["nick"], origin["ident"], origin["host"] = re.match(r"(\S+)!(\S+)@(\S+)", prefix).groups()
origin["mask"] = prefix
else:
origin["server"] = prefix
else:
prefix = ""
# parse command
command = head.pop(0)
# parse params
params = head
params.append(larg.strip())
self.log.debug(" > %s" % rawline)
# PING
if command == "PING":
self.send("PONG %s" % params[0])
# PRIVMSG and NOTICE
elif command == "PRIVMSG" or command == "NOTICE":
if params[1][0] == self.cmdprefix:
args = []
for v in params[1][1:].split(" "):
if v!="": args.append(v)
rp = self.on_command(command, prefix, origin, params[0], args[0].lower(), args[1:])
if rp not in (None, ""):
self.reply(origin, params[0], rp, command)
# 221 (RPL_UMODEIS)
elif command == "221" and self.mode_reply is not {}:
self.query(self.mode_reply["to"], "Modes are %s" % params[1], self.mode_reply["type"])
self.mode_reply = {}
# INVITE
elif command == "INVITE":
if self.check_privileges(origin["mask"], command):
self.join(params[1])
else:
self.query(origin["nick"], "You can not force me to do that!", "NOTICE")
# 001 (RPL_WELCOME)
elif command == "001":
self.user["mask"] = re.search(r" (\S+!\S+@\S+)$", rawline.split(" :", 1)[1]).groups()[0]
self.user["nick"], self.user["ident"], self.user["host"] = re.match(r"(\S+)!(\S+)@(\S+)", self.user["mask"]).groups()
self.ready = True
# 433 (ERR_NICKNAMEINUSE)
elif command == "433":
self.send("NICK %s" % self.nicknames.pop(0))
# KILL
elif command == "KILL":
self.log.warning("Got killed by %s: %s" % (params[0], params[1:]))
self.disconnect(send_quit=False)
else:
sleep(self.parser_wait_time)
self.log.debug("--- PARSELOOP EXITING ---")
### helper functions ###
# encodes data for irc
def encode(self, textstring):
for codec in self.encodings:
try: return textstring.encode(codec)
except UnicodeDecodeError: continue
return textstring.encode(self.encodings[0], 'ignore')
# decodes data from irc
def decode(self, textstring):
for codec in self.encodings:
try: return textstring.decode(codec)
except UnicodeDecodeError: continue
return textstring.decode(self.encodings[0], 'ignore')
# checks if a given user may execute a given command
def check_privileges(self, usermask, command):
for user, privs in self.users.items():
if re.search(user, usermask, re.IGNORECASE) != None:
if command.lower() in privs or "*" in privs:
return True
return False
# checks if s is a valid channel name
def is_channel(self, s):
return (True if s[0] in ('&', '#', '!') else False)
def exec_on_ready(self, command, retry_times=-1, interval=1):
if command.startswith("self."):
if retry_times == -1:
while not self.ready: sleep(interval)
else:
cnt = 0
while not self.ready and cnt<retry_times: sleep(interval)
if not self.ready:
self.log.warning("Connection did not get ready in time (%sx %s seconds), \"%s\" not executed" % (retry_times, interval, command))
return True
exec("ret = %s" % command)
return False
else:
self.log.critical("exec_on_ready() called with an invalid command: %s" % command)
return True
def exec_retry(self, command, times=5, interval=1, wait_for_ready=True):
if command.startswith("self."):
if wait_for_ready:
while not self.ready: sleep(interval)
cnt = 0
exec("while %s and cnt<times: sleep(interval)" % command)
else: self.log.critical("exec_retry() called with an invalid command.")
def send(self, data):
self.log.debug("< %s" % data)
try: self.socket.send(self.encode("".join((data, "\r\n"))))
except Exception as e:
self.log.critical(e)
self.disconnect(send_quit=False)
# decides whether to reply to user or to channel
def reply(self, origin, source, msg, in_query_type):
if self.is_channel(source):
self.chanmsg(source, msg)
else:
self.query(origin["nick"], msg, in_query_type)
### irc command wrapper ###
# replies to channel by PRIVMSG
def chanmsg(self, channel, msg):
while self.last_msg_ts + self.msg_wait_time > time():
sleep(0.1)
self.last_msg_ts = time()
self.send("".join(("PRIVMSG ", channel, " :", msg)))
# replies to user by NOTICE or PRIVMSG
def query(self, nick, msg, in_query_type):
self.send("".join((self.query_type if self.query_type!="" else in_query_type, " ", nick, " :", msg)))
def nick(self, nick):
self.exec_on_ready("".join(('self.send("JOIN %s" % "', channel, '")')))
def join(self, channel):
self.exec_on_ready("".join(('self.send("JOIN %s" % "', channel, '")')))
def part(self, channel):
self.exec_on_ready("".join(('self.send("PART %s" % "', channel, '")')))
def set_mode(self, modes, target=""):
self.exec_on_ready("".join(('self.send("MODE %s :%s" % (', ''.join(('"', target, '"')) if target != "" else 'self.user["nick"]', ', "', modes, '"))')))
def get_modes(self, target=""):
self.exec_on_ready("".join(('self.send("MODE %s" % ', ''.join(('"', target, '"')) if target != "" else 'self.user["nick"]', ')')))
### handler ###
# bot-command handler
def on_command(self, in_query_type, prefix, origin, source, command, params):
"""
Executed when getting a PRIVMSG starting with self.cmdprefix
Prefix contains the optional prefix of the raw line.
Origin is a map holding the parsed prefix, containing
"nick", "ident" and "host" for users and "server" for servers.
Source is the first parameter after the irc-command, specifying
the channel or user, from where the line comes.
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"] +"!"))
return greeting
# say
if command == "say":
return " ".join(params)
# DuckDuckGo, ddg <query>
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 <https://duckduckgo.com>)"
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 <http://forecast.io/>)"
else:
return "Usage: %s <lat> <lon>" % command
# join <channel>
elif command == "join":
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 <channel>
elif command == "part":
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 ±<modes>
elif command == "mode":
if self.check_privileges(origin["mask"], command):
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 numparams>0 else params)
else: self.query(origin["nick"], "You cannot do that!", in_query_type)
# die [<quitmsg>]
elif command == "die":
if self.check_privileges(origin["mask"], command):
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 = [
("What? \"%s\" is not a command!", 15),
("%s? What's that?", 3),
("Sorry, I don't know how to %s...", 1)
]
self.query(origin["nick"], choice([val for val, cnt in replies for i in range(cnt)]) % (command), in_query_type)
def parseargs():
import argparse
p = argparse.ArgumentParser(
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 = 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:
sleep(1)
if not bot.die_event.is_set():
# set modes and join 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.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!")
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__':
main()