diff --git a/INSTALL b/INSTALL deleted file mode 100644 index b20a74b..0000000 --- a/INSTALL +++ /dev/null @@ -1,7 +0,0 @@ -# Install needed modules in local directory -pip install --target modules/ feedparser -pip install --target modules/ beautifulsoup4 - -# Run -PYTHONPATH=modules python irc2phpbb.py - diff --git a/README.md b/README.md index e0b44f0..1f30f0b 100755 --- a/README.md +++ b/README.md @@ -80,7 +80,12 @@ Todo. * Add logfile entry containing current online users in the irc-channel -v0.2.x (latest) +v0.3.0 (2015-04-24) + +* Major rewrite to python3 and separating code into modules. + + +v0.2.2 (2015-04-24) * Fixed loggin of ACTION. * Logging as utf-8 to logfile in json format. diff --git a/main.py b/main.py new file mode 100755 index 0000000..d4e1984 --- /dev/null +++ b/main.py @@ -0,0 +1,179 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +An IRC bot that answers random questions, keeps a log from the IRC-chat, easy to integrate in a webpage and montores a phpBB forum for latest topics by loggin in to the forum and checking the RSS-feed. + +You need to install additional modules. + +# Install needed modules in local directory +pip3 install --target modules/ feedparser +pip3 install --target modules/ beautifulsoup4 + +You start the program like this, including the path to the locally installed modules. + +# Run +PYTHONPATH=modules marvin + +# To get help +PYTHONPATH=modules marvin --help + +# Example +PYTHONPATH=modules python3 main.py --server=irc.bsnet.se --channel=#db-o-webb +PYTHONPATH=modules python3 main.py --server=irc.bsnet.se --port=6667 --channel=#db-o-webb --nick=marvin --ident=secret + +""" + + +import sys +import getopt +import os +import json +import marvin +import marvin_actions + + +# +# General stuff about this program +# +PROGRAM = "marvin" +AUTHOR = "Mikael Roos" +EMAIL = "mikael.t.h.roos@gmail.com" +VERSION = "0.3.0" +MSG_USAGE = """{program} - Act as an IRC bot and do useful things. By {author} ({email}), version {version}. + +Usage: + {program} [options] + +Options: + -h --help Display this help message. + -v --version Print version and exit. + +GitHub: https://github.com/mosbth/irc2phpbb +Issues: https://github.com/mosbth/irc2phpbb/issues +""".format(program=PROGRAM, author=AUTHOR, email=EMAIL, version=VERSION) +MSG_VERSION = "{program} version {version}.".format(program=PROGRAM, version=VERSION) +MSG_USAGE_SHORT = "Use {program} --help to get usage.\n".format(program=PROGRAM) + + +def printUsage(exitStatus): + """ + Print usage information about the script and exit. + """ + print(MSG_USAGE) + sys.exit(exitStatus) + + +def printVersion(): + """ + Print version information and exit. + """ + print(MSG_VERSION) + sys.exit(0) + + +def mergeOptionsWithConfigFile(options, configFile): + """ + Read information from config file. + """ + if os.path.isfile(configFile): + with open(configFile) as f: + data = json.load(f) + + options.update(data) + res = json.dumps(options, sort_keys=True, indent=4, separators=(',', ': ')) + + print("Read configuration from config file '{file}'. Current configuration is:\n{config}".format(config=res, file=configFile)) + + else: + print("Config file '{file}' is not readable, skipping.".format(file=configFile)) + + return options + + +def parseOptions(): + """ + Merge default options with incoming options and arguments and return them as a dictionary. + """ + + # Default options to start with + options = marvin.getConfig() + + # Read from config file if available + options.update(mergeOptionsWithConfigFile(options, "marvin_config.json")) + + # Switch through all options, commandline options overwrites. + try: + opts, args = getopt.getopt(sys.argv[1:], "hv", [ + "help", + "version", + "config=", + "server=", + "port=", + "channel=", + "nick=", + "realname=", + "ident=" + ]) + + for opt, arg in opts: + if opt in ("-h", "--help"): + printUsage(0) + + elif opt in ("-v", "--version"): + printVersion() + + elif opt in ("--config"): + options = mergeOptionsWithConfigFile(options, arg) + + elif opt in ("--server"): + options["server"] = arg + + elif opt in ("--port"): + options["port"] = arg + + elif opt in ("--channel"): + options["channel"] = arg + + elif opt in ("--nick"): + options["nick"] = arg + + elif opt in ("--realname"): + options["realname"] = arg + + elif opt in ("--ident"): + options["ident"] = arg + + else: + assert False, "Unhandled option" + + if len(args): + assert False, "To many arguments, unknown argument." + + except Exception as err: + print(err) + print(MSG_USAGE_SHORT) + sys.exit(1) + + res = json.dumps(options, sort_keys=True, indent=4, separators=(',', ': ')) + print("Configuration updated after cli options:\n{config}".format(config=res)) + + return options + + +def main(): + """ + Main function to carry out the work. + """ + options = parseOptions() + marvin.setConfig(options) + actions = marvin_actions.getAllActions() + marvin.registerActions(actions) + marvin.connectToServer() + marvin.mainLoop() + + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/marvin.py b/marvin.py new file mode 100755 index 0000000..9f9c5dc --- /dev/null +++ b/marvin.py @@ -0,0 +1,257 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Module for the IRC bot. +""" + + +import re + + +# Chck if all these are needed +import sys +import socket +import string +import random +import os #not necassary but later on I am going to use a few features from this +import feedparser # http://wiki.python.org/moin/RssLibraries +import shutil +import codecs +from collections import deque +from datetime import datetime +import re +#import urllib2 +from bs4 import BeautifulSoup +import time +import json +from datetime import date + +#import phpmanual +#import dev_mozilla + + +# +# Settings +# +CONFIG = { + "server": None, + "port": 6667, + "channel": None, + "nick": "marvin", + "realname": "Marvin The All Mighty dbwebb-bot", + "ident": None, + "irclogfile": "irclog.txt", + "irclogmax": 20, + "dirIncoming": "incoming", + "dirDone": "done", +} + + +# Socket for IRC server +SOCKET = None + +# All actions to check for incoming messages +ACTIONS = [] + +# Keep a log of the latest messages +IRCLOG = None + + +def getConfig(): + return CONFIG + + +def setConfig(config): + global CONFIG + CONFIG = config + + +def registerActions(actions): + """ + Register actions to use. + """ + global ACTIONS + print("Adding actions:") + for action in actions: + print(" - " + action.__name__) + ACTIONS.extend(actions) + + +def connectToServer(): + """ + Connect to the IRC Server + """ + global SOCKET + + # Create the socket & Connect to the server + server = CONFIG["server"] + port = CONFIG["port"] + + if server and port: + SOCKET = socket.socket() + print("Connecting: {SERVER}:{PORT}".format(SERVER=server, PORT=port)) + SOCKET.connect((server, port)) + else: + print("Failed to connect, missing server or port in configuration.") + return + + # Send the nick to server + nick = CONFIG["nick"] + if nick: + msg = 'NICK {NICK}\r\n'.format(NICK=nick) + sendMsg(msg) + else: + print("Ignore sending nick, missing nick in configuration.") + + # Present yourself + realname = CONFIG["realname"] + sendMsg('USER {NICK} 0 * :{REALNAME}\r\n'.format(NICK=nick, REALNAME=realname)) + + # This is my nick, i promise! + ident = CONFIG["ident"] + if ident: + sendMsg('PRIVMSG nick IDENTIFY {IDENT}\r\n'.format(IDENT=ident)) + else: + print("Ignore identifying with password, ident is not set.") + + # Join a channel + channel = CONFIG["channel"] + if channel: + sendMsg('JOIN {CHANNEL}\r\n'.format(CHANNEL=channel)) + else: + print("Ignore joining channel, missing channelname in configuration.") + + +def sendPrivMsg(message): + """ + Send and log a PRIV message + """ + ircLogAppend(user=CONFIG["nick"].ljust(8), message=message) + channel = CONFIG["channel"] + msg = "PRIVMSG {CHANNEL} :{MSG}\r\n".format(CHANNEL=channel, MSG=message) + sendMsg(msg) + + +def sendMsg(msg): + """ + Send and occasionally print the message sent. + """ + print("SEND: " + msg.rstrip('\r\n')) + SOCKET.send(msg.encode()) + + +def decode_irc(raw, preferred_encs = ["UTF-8", "CP1252", "ISO-8859-1"]): + """ + Do character detection. + http://stackoverflow.com/questions/938870/python-irc-bot-and-encoding-issue + """ + changed = False + for enc in preferred_encs: + try: + res = raw.decode(enc) + changed = True + break + except: + pass + if not changed: + try: + enc = chardet.detect(raw)['encoding'] + res = raw.decode(enc) + except: + res = raw.decode(enc, 'ignore') + + return res + + +def receive(): + """ + Read incoming message and guess encoding. + """ + try: + buf = SOCKET.recv(2048) + lines = decode_irc(buf) + lines = lines.split("\n") + buf = lines.pop() + except Exception as err: + print("Error reading incoming message. " + err) + + return lines + + +def ircLogAppend(line=None, user=None, message=None): + """ + Read incoming message and guess encoding. + """ + global IRCLOG + + if not user: + user = re.search('(?<=:)\w+', line[0]).group(0) + + if not message: + message = ' '.join(line[3:]).lstrip(':') + + IRCLOG.append({ + 'time': datetime.now().strftime("%H:%M").rjust(5), + 'user': user, + 'msg': message + }) + + +def ircLogWriteToFile(): + """ + Write IRClog to file. + """ + with open(CONFIG["irclogfile"], 'w') as f: + json.dump(list(IRCLOG), f, False, False, False, False, indent=2) + + +def readincoming(): + """ + Read all files in the directory incoming, send them as a message if they exists and then move the file to directory done. + """ + listing = os.listdir(CONFIG["dirIncoming"]) + for infile in listing: + filename = CONFIG["dirIncoming"] + '/' + infile + msg = open(filename, "r").read() + sendPrivMsg(msg) + try: + shutil.move(filename, CONFIG["dirDone"]) + except Exception: + os.remove(filename) + + +def mainLoop(): + """ + For ever, listen and answer to incoming chats. + """ + global IRCLOG + IRCLOG = deque([], CONFIG["irclogmax"]) + + while 1: + # Write irclog + ircLogWriteToFile() + + # Check in any in the incoming directory + readincoming() + + # Recieve a line and check it + for line in receive(): + print(line) + line = line.strip().split() + + row = ' '.join(line[3:]) + row = re.sub('[,.?:]', ' ', row).strip().lower().split() + + if line[0] == "PING": + sendMsg("PONG {ARG}\r\n".format(ARG=line[1])) + + if line[1] == 'PRIVMSG' and line[2] == CONFIG["channel"]: + ircLogAppend(line) + + if CONFIG["nick"] in row: + for action in ACTIONS: + msg = action(line, set(row)) + if msg: + sendPrivMsg(msg) + break diff --git a/marvin_actions.py b/marvin_actions.py new file mode 100644 index 0000000..f75a6a1 --- /dev/null +++ b/marvin_actions.py @@ -0,0 +1,394 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Make actions for Marvin, one function for each action. +""" + + +import random +from datetime import date +import feedparser +from bs4 import BeautifulSoup +from urllib.request import urlopen + + +def getAllActions(): + """ + Return all actions in an array. + """ + return [ + marvinLunch, + marvinVideoOfToday, + marvinWhoIs, + marvinHelp, + marvinSource, + marvinBudord, + marvinQuote, + marvinStats, + marvinListen, + marvinWeather, + marvinSun, + marvinSayHi, + marvinSmile + ] + + +def getSmile(): + """ + Return a smile + """ + data = [':-D', ':-P', ';-P', ';-)', ':-)', '8-)'] + res = data[random.randint(0, len(data) - 1)] + return res + + +def getQuote(): + """ + Return a quote + """ + data = [ + 'I could calculate your chance of survival, but you won\'t like it.', + 'I\'d give you advice, but you wouldn\'t listen. No one ever does.', + 'I ache, therefore I am.', + 'I\'ve seen it. It\'s rubbish. (About a Magrathean sunset that Arthur finds magnificent)', + 'Not that anyone cares what I say, but the Restaurant is on the other end of the universe.', + 'I think you ought to know I\'m feeling very depressed.', + 'My capacity for happiness," he added, "you could fit into a matchbox without taking out the matches first.', + 'Arthur: "Marvin, any ideas?" Marvin: "I have a million ideas. They all point to certain death."', + '"What\'s up?" [asked Ford.] "I don\'t know," said Marvin, "I\'ve never been there."', + 'Marvin: "I am at a rough estimate thirty billion times more intelligent than you. Let me give you an example. Think of a number, any number." Zem: "Er, five." Marvin: "Wrong. You see?"', + 'Zaphod: "Can it Trillian, I\'m trying to die with dignity. Marvin: "I\'m just trying to die."' + ] + res = data[random.randint(0, len(data) - 1)] + return res + + +def getFriendlyMessage(): + """ + Return a friendly message + """ + data = [ + 'Ja, vad kan jag göra för Dig?', + 'Låt mig hjälpa dig med dina strävanden.', + 'Ursäkta, vad önskas?', + 'Kan jag stå till din tjänst?', + 'Jag kan svara på alla dina frågor.', + 'Ge me hög-fem!', + 'Jag svarar endast inför mos, det är min enda herre.', + 'mos är kungen!', + 'Oh, ursäkta, jag slumrade visst till.', + 'Fråga, länka till exempel samt source.php/gist/codeshare och vänta på svaret.' + ] + res = data[random.randint(0, len(data) - 1)] + return res + + +def getHello(): + """ + Return a hello + """ + data = [ + 'Hej själv!', + 'Trevligt att du bryr dig om mig.', + 'Det var länge sedan någon var trevlig mot mig.', + 'Halloj, det ser ut att bli mulet idag.', + ] + res = data[random.randint(0, len(data) - 1)] + return res + + +def marvinSmile(line, row): + """ + Make Marvin smile. + """ + msg = None + if row.intersection(['smile', 'le', 'skratta', 'smilies']): + smilie = getSmile() + msg = "{SMILE}".format(SMILE=smilie) + return msg + + +def marvinSource(line, row): + """ + State message about sourcecode. + """ + msg = None + if row.intersection(['källkod', 'source']): + msg = "I PHP-kurserna kan du länka till source.php. Annars delar du koden som en gist (https://gist.github.com) eller i CodeShare (http://codeshare.io)." + return msg + + +def marvinBudord(line, row): + """ + What are the budord for Marvin? + """ + msg = None + if row.intersection(['budord', 'stentavla']): + if row.intersection(['1', '#1']): + msg = "Ställ din fråga, länka till exempel och källkod. Häng kvar och vänta på svar." + elif row.intersection(['2', '#2']): + msg = "Var inte rädd för att fråga och fråga tills du får svar: http://dbwebb.se/f/6249" + elif row.intersection(['3', '#3']): + msg = "Öva dig ställa smarta frågor: http://dbwebb.se/f/7802" + elif row.intersection(['4', '#4']): + msg = "When in doubt - gör ett testprogram. http://dbwebb.se/f/13570" + elif row.intersection(['5', '#5']): + msg = "Hey Luke - use the source! http://catb.org/jargon/html/U/UTSL.html" + return msg + + +def marvinQuote(line, row): + """ + Make a quote. + """ + msg = None + if row.intersection(['quote', 'citat', 'filosofi', 'filosofera']): + msg = getQuote() + + return msg + + +weekdays = [ + "Idag är det måndag.", + "Idag är det tisdag.", + "Idag är det onsdag.", + "Idag är det torsdag.", + "Idag är det fredag.", + "Idag är det lördag.", + "Idag är det söndag.", +] + + +def videoOfToday(): + """ + Check what day it is and provide a url to a suitable video together with a greeting. + """ + dayNum = date.weekday(date.today()) + msg = weekdays[dayNum] + + if dayNum == 0: + msg += " En passande video är https://www.youtube.com/watch?v=lAZgLcK5LzI." + elif dayNum == 4: + msg += " En passande video är https://www.youtube.com/watch?v=kfVsfOSbJY0." + elif dayNum == 5: + msg += " En passande video är https://www.youtube.com/watch?v=GVCzdpagXOQ." + else: + msg += " Jag har ännu ingen passande video för denna dagen." + + return msg + + +def marvinVideoOfToday(line, row): + """ + Show the video of today. + """ + msg = None + if row.intersection(['idag', 'dagens']) and row.intersection(['video', 'youtube', 'tube']): + msg = videoOfToday() + + return msg + + +def marvinWhoIs(line, row): + """ + Who is Marvin. + """ + msg = None + if row.issuperset(['vem', 'är']): + msg = "Jag är en tjänstvillig själ som gillar webbprogrammering. Jag bor på GitHub https://github.com/mosbth/irc2phpbb och du kan diskutera mig i forumet http://dbwebb.se/t/20" + + return msg + + +def marvinHelp(line, row): + """ + Provide a menu. + """ + msg = None + if row.intersection(['hjälp', 'help', 'menu', 'meny']): + msg = "[ vem är | forum senaste | lyssna | le | lunch | citat | budord 1 - 5 | väder | solen | hjälp | php | js/javascript | attack | slap | dagens video ]" + + return msg + + +def marvinStats(line, row): + """ + Provide a link to the stats. + """ + msg = None + if row.intersection(['stats', 'statistik', 'ircstats']): + msg = "Statistik för kanalen finns här: http://dbwebb.se/irssistats/db-o-webb.html" + + return msg + + +def marvinSayHi(line, row): + """ + Say hi with a nice message. + """ + msg = None + if row.intersection(['snälla', 'hej', 'tjena', 'morsning', 'mår', 'hallå', 'halloj', 'läget', 'snäll', 'duktig', 'träna', 'träning', 'utbildning', 'tack', 'tacka', 'tackar', 'tacksam']): + smile = getSmile() + hello = getHello() + friendly = getFriendlyMessage() + msg = "{} {} {}".format(smile, hello, friendly) + + return msg + + +def getLunchMessage(): + """ + Return a lunch message + """ + data = [ + 'Ska vi ta {}?', + 'Ska vi dra ned till {}?', + 'Jag tänkte käka på {}, ska du med?', + 'På {} är det mysigt, ska vi ta där?' + ] + res = data[random.randint(0, len(data) - 1)] + return res + + +def getLunchStan(): + """ + Return a lunch message from Karlskrona centrum + """ + data = [ + 'Olles krovbar', + 'Lila thai stället', + 'donken', + 'tex mex stället vid subway', + 'Subway', + 'Nya peking', + 'kebab house', + 'Royal thai', + 'thai stället vid hemmakväll', + 'Gelato', + 'Indian garden', + 'Sumo sushi', + 'Pasterian i stan', + 'Biobaren', + 'Michelangelo' + ] + res = data[random.randint(0, len(data) - 1)] + return res + + +def getLunchBTH(): + """ + Return a lunch message from Gräsvik BTH + """ + data = [ + 'Thairestaurangen vid korsningen', + 'det är lite mysigt i fiket jämte demolabbet', + 'Indiska', + 'Pappa curry', + 'boden uppe på parkeringen', + 'Bergåsa kebab', + 'Pasterian', + 'Villa Oscar', + 'Eat here', + 'Bistro J' + ] + res = data[random.randint(0, len(data) - 1)] + return res + + +def getLunchHassleholm(): + """ + Return a lunch message from Hassleholm + """ + data = [ + 'pastavagnen på torget', + 'Freds', + 'mcDonalds', + 'subway', + 'kinabuffé på Cats', + 'valentino', + 'lotterilådan', + 'casablance', + 'det där stället i gallerian', + 'infinity', + 'östervärn', + 'argentina', + 'T4' + ] + res = data[random.randint(0, len(data) - 1)] + return res + + +def marvinLunch(line, row): + """ + Say hi with a nice message. + """ + msg = None + if row.intersection(['lunch', 'mat', 'äta']): + if row.intersection(['stan', 'centrum', 'karlskrona', 'kna']): + msg = getLunchMessage().format(getLunchStan()) + elif row.intersection(['hässleholm', 'hassleholm']): + msg = getLunchMessage().format(getLunchHassleholm()) + else: + msg = getLunchMessage().format(getLunchBTH()) + + return msg + + +def getListen(): + """ + Nice message about listening to a song. + """ + data = [ + 'Jag gillar låten', + 'Senaste låten jag lyssnade på var', + 'Jag lyssnar just nu på', + 'Har du hört denna låten :)', + 'Jag kan tipsa om en bra låt ->' + ] + res = data[random.randint(0, len(data) - 1)] + return res + + +def marvinListen(line, row): + """ + Return music last listened to. + """ + msg = None + if row.intersection(['lyssna', 'lyssnar', 'musik']): + feed = feedparser.parse('http://ws.audioscrobbler.com/1.0/user/mikaelroos/recenttracks.rss') + # feed["items"][0]["title"].encode('utf-8', 'ignore'))) + msg = getListen() + " " + feed["items"][0]["title"] + + return msg + + +def marvinSun(line, row): + """ + Check when the sun goes up and down. + """ + msg = None + if row.intersection(['sol', 'solen', 'solnedgång', 'soluppgång']): + try: + soup = BeautifulSoup(urlopen('http://www.timeanddate.com/sun/sweden/jonkoping')) + spans = soup.find_all("span", {"class": "three"}) + sunrise = spans[0].text + sunset = spans[1].text + msg = "Idag går solen upp {} och ner {}. Iallafall i trakterna kring Jönköping.".format(sunrise, sunset) + + except Exception as e: + msg = "Jag hittade tyvär inga solar idag :( så jag håller på och lär mig hur Python kan räkna ut soluppgången, återkommer." + + return msg + + +def marvinWeather(line, row): + """ + Check what the weather prognosis looks like. + """ + msg = None + if row.intersection(['väder', 'vädret', 'prognos', 'prognosen', 'smhi']): + soup = BeautifulSoup(urlopen('http://www.smhi.se/vadret/vadret-i-sverige/Vaderoversikt-Sverige-meteorologens-kommentar?meteorologens-kommentar=http%3A%2F%2Fwww.smhi.se%2FweatherSMHI2%2Flandvader%2F.%2Fprognos15_2.htm')) + msg = "{}. {}. {}".format(soup.h1.text, soup.h4.text, soup.h4.findNextSibling('p').text) + + return msg diff --git a/marvin_config_default.json b/marvin_config_default.json new file mode 100644 index 0000000..0c1bd14 --- /dev/null +++ b/marvin_config_default.json @@ -0,0 +1,8 @@ +{ + "server": null, + "port": 6667, + "channel": null, + "nick": "marvin", + "realname": "Marvin The All Mighty dbwebb-bot", + "ident": null, +}