diff --git a/README.md b/README.md index af6fed3..041e170 100644 --- a/README.md +++ b/README.md @@ -19,18 +19,23 @@ INSTALL On Ubuntu/Debian you need `python-irclib` and `python-skype` as well as Skype itself to run the script. +This bot runs with `python-irclib` 0.4.8. Newer versions may not have the necessary modules. + For `python-skype` I used the version 1.0.31.0 provided at `ppa:skype-wrapper/ppa`. Although newer version is packaged even for Ubuntu 11.04, this package didn't work out of the box on Ubuntu 12.04. +Module deps: +* quotes and addquote require python-requests + CONFIGURE --------- -You can configure the IRC servers and Skype chatrooms to mirror in the header of `skype2irc.py`. You may define one IRC server and as many pairs of IRC channels and Skype chatrooms as you like. Skype chatrooms are defined by the blob, which you can obtain writing `/get uri` in a chatroom. +You can configure the IRC servers and Skype chatrooms to mirror in the configuration file `config.json`. You may define one IRC network (optionally with multiple servers) and as many pairs of IRC channels and Skype chatrooms as you like. Skype chatrooms are defined by the blob, which you can obtain writing `/get uri` in a chatroom. You may need to join your Skype chatroom to be mirrored before actually starting the gateway, because it seems that Skype API isn't always able to successfully join the chatroom using a blob provided (I usually get a timeout error). So make sure you have an access to chatroom using GUI before starting to hassle with the code. -The default values provided in the header of `skype2irc.py` should be enough to give the program a test run. +The default values provided should be enough to give the program a test run. -If you want to use an option to save broadcast states for IRC users, working directory for the script has to be writable. +If you want to use an option to save broadcast states for IRC users, the working directory for the script has to be writable. RUN --- @@ -40,3 +45,5 @@ To run the gateway, Skype must be running and you must be logged in. You can do You can run `skype2irc.py` just from command line or use `ircbot.sh` to loop it. You can also run it from plain terminal providing the X desktop Skype will be started like `DISPLAY="host:0.0" ./skype2irc.py`. It could also make sense to run it using `ssh -X user@host` session or with something similar. + +On Mac OS X, ensure the bot is being run in 32-bit Python. 64-bit Python will result in segfaults. diff --git a/auto_reply.py b/auto_reply.py new file mode 100644 index 0000000..77dae39 --- /dev/null +++ b/auto_reply.py @@ -0,0 +1,35 @@ +#Bot will auto-respond to lines containing a specified regex +#I <3 PR + +import re + +config = None +usemap = None +ircbot = None + +#put the regex you want here +my_exp = re.compile('') + +#put the message to auto-respond with here +#suggestion: "Just ignore her." +my_response = '' + +def message_scan(source, msg): + msg = msg.strip() + ismatch = my_exp.match(msg) + if ismatch: + return source + ": " + my_response + +def irc_msg(source, target, msg): + if not target in config['channels']: + return + scanner = message_scan(source, msg) + if scanner and target in config['channels']: + ircbot.say(target, scanner) + usemap[target].SendMessage(scanner) + +def skype_msg(sourceDisplay, sourceHandle, target, msg): + scanner = message_scan(sourceDisplay, msg) + if scanner and usemap[target] in config['channels']: + ircbot.say(usemap[target], scanner) + target.SendMessage(scanner) \ No newline at end of file diff --git a/config.py.default b/config.py.default new file mode 100644 index 0000000..a78291c --- /dev/null +++ b/config.py.default @@ -0,0 +1,16 @@ +# GumBot config file + +config = { + "servers": [("127.0.0.1", 6667)], + "nick": "GumBot", + "botname": "IRC <-> Skype Bot", + "password": "", + "vhost": False, + "mirrors": { + "#example": "blob" + }, + "modules": { +# "example": {}, +# "quotes": {"url": "", "http-user": "", "http-pass": "", "channels": ["#example"]}, + } +} diff --git a/example.py b/example.py new file mode 100644 index 0000000..4a0c1b4 --- /dev/null +++ b/example.py @@ -0,0 +1,13 @@ +# example GumBot module + +config = None +usemap = None +ircbot = None + +def irc_msg(source, target, msg): + usemap[target].SendMessage('EXAMPLE: IRC msg %s -> %s: %s' % (source, target, msg)) + print 'EXAMPLE: IRC msg %s -> %s: %s' % (source, target, msg) + +def skype_msg(sourceDisplay, sourceHandle, target, msg): + ircbot.say(usemap[target], 'EXAMPLE: Skype msg %s (%s) -> %s: %s' % (sourceHandle, sourceDisplay, target, msg)) + print 'EXAMPLE: Skype msg %s (%s) -> %s: %s' % (sourceHandle, sourceDisplay, target, msg) diff --git a/ircbot.sh b/ircbot.sh old mode 100755 new mode 100644 diff --git a/ircbot_once.sh b/ircbot_once.sh new file mode 100644 index 0000000..f016e9d --- /dev/null +++ b/ircbot_once.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +DISPLAY="host:0.0" python skype2irc.py diff --git a/karma.py b/karma.py new file mode 100644 index 0000000..c4a7810 --- /dev/null +++ b/karma.py @@ -0,0 +1,123 @@ +import re +import sqlite3 +import datetime + +with sqlite3.connect('karma.db') as conn: + cur = conn.cursor() + try: + cur.execute('''CREATE TABLE "karma" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL COLLATE NOCASE, + "delta" INTEGER, + "comment" TEXT, + "add_time" TEXT NOT NULL + );''') + conn.commit() + except sqlite3.OperationalError: + pass + +config = None +usemap = None +ircbot = None + +exp = re.compile("^(\([^)]+\)|[^ ]+)([+]{2}|[-]{2})(.*)$") + +def getsubject(msg): + msg = msg.strip().lower() + if msg[0] == '(' and msg[len(msg) - 1] == ')': + msg = msg[0:len(msg)-1].strip() + return msg + +def getreason(msg, sender): + msg = msg.strip() + if msg.startswith('#'): + return msg[1:].strip() + elif msg.startswith('//'): + return msg[2:].strip() + else: + return '' + +def fetch_karma(ktgt): + with sqlite3.connect('karma.db') as conn: + cur = conn.cursor() + cur.execute('''SELECT SUM(`delta`) FROM karma WHERE `name` LIKE ?;''', (ktgt,)) + ksum = cur.fetchone()[0] + return ksum + +def karmaparse(source, msg): + msg = msg.strip() + ismatch = exp.match(msg) + if ismatch: + subject = getsubject(ismatch.group(1)) + amount = ismatch.group(2) + if amount == '--': + amount = -1 + ktype = 'negative' + else: + amount = 1 + ktype = 'positive' + reason = getreason(ismatch.group(3), source) + if reason: + reason = ' for "%s"' % reason + if subject == source.lower() and amount > 0: + return "You can't give karma to yourself... loser." + else: + with sqlite3.connect('karma.db') as conn: + cur = conn.cursor() + cur.execute('''INSERT INTO karma (`name`,`delta`,`comment`,`add_time`) VALUES (?,?,?,?)''', (subject, amount, reason, datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S'))) + conn.commit() + if subject == config['nick'].lower() and amount > 0: + return "Karma for %s is now %s. Thanks, %s!" % (subject, fetch_karma(subject), source) + elif subject == config['nick'].lower() and amount < 0: + return "Karma for %s is now %s. Pfft." % (subject, fetch_karma(subject)) + else: + return "Karma for %s is now %s." % (subject, fetch_karma(subject)) + else: + return None + +def get_karma(msg): + m = re.match('!karma (.+)', msg) + if not m: + return + ktgt = m.group(1).strip() + ksum = fetch_karma(ktgt) + if ksum is None: + kstr = 'No karma has ever been assigned to %s.' % ktgt + else: + kstr = '%s has %s karma.' % (ktgt, ksum) + return kstr + + +""" +def explain(msg): + m = re.match('!explain (.+)', msg) + if not m: + return + ktgt = m.group(1).strip() + cur.execute('''SELECT comment FROM karma where `name` LIKE ?;''', (ktgt,)) +""" + + +def irc_msg(source, target, msg): + if not target in config['channels']: + return + if msg.startswith('!karma'): + kstr = get_karma(msg) + ircbot.say(target, kstr) + usemap[target].SendMessage(kstr) + else: + karma = karmaparse(source, msg) + if karma and target in config['channels']: + ircbot.say(target, karma) + usemap[target].SendMessage(karma) + +def skype_msg(sourceDisplay, sourceHandle, target, msg): + if msg.startswith('!karma'): + kstr = get_karma(msg) + ircbot.say(usemap[target], kstr) + target.SendMessage(kstr) + else: + karma = karmaparse(sourceDisplay, msg) + if karma and usemap[target] in config['channels']: + ircbot.say(usemap[target], karma) + target.SendMessage(karma) diff --git a/killbot.sh b/killbot.sh new file mode 100644 index 0000000..08a4efe --- /dev/null +++ b/killbot.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +kill `ps aux | grep skype2irc | grep -v grep | awk '{print $2;}'` diff --git a/quotes.py b/quotes.py new file mode 100644 index 0000000..8a19524 --- /dev/null +++ b/quotes.py @@ -0,0 +1,76 @@ +# GumBot quotes module +# requires config keys: +# - url +# - http-user +# - http-pass + +import requests +import re +import traceback + +def encode_utf8_to_iso88591(utf8_text): + # Borrowed from http://jamesmurty.com/2011/12/30/python-code-utf8-to-latin1/ + """ + Encode and return the given UTF-8 text as ISO-8859-1 (latin1) with + unsupported characters replaced by '?', except for common special + characters like smart quotes and symbols that we handle as well as we + can. + For example, the copyright symbol => '(c)' etc. + + If the given value is not a string it is returned unchanged. + + References: + + en.wikipedia.org/wiki/Quotation_mark_glyphs#Quotation_marks_in_Unicode + en.wikipedia.org/wiki/Copyright_symbol + en.wikipedia.org/wiki/Registered_trademark_symbol + en.wikipedia.org/wiki/Sound_recording_copyright_symbol + en.wikipedia.org/wiki/Service_mark_symbol + en.wikipedia.org/wiki/Trademark_symbol + """ + if not isinstance(utf8_text, basestring): + return utf8_text + # Replace "smart" and other single-quote like things + utf8_text = re.sub( + u'[\u02bc\u2018\u2019\u201a\u201b\u2039\u203a\u300c\u300d\x91\x92]', + "'", utf8_text) + # Replace "smart" and other double-quote like things + utf8_text = re.sub( + u'[\u00ab\u00bb\u201c\u201d\u201e\u201f\u300e\u300f\x93\x94]', + '"', utf8_text) + # Replace copyright symbol + utf8_text = re.sub(u'[\u00a9\u24b8\u24d2]', '(c)', utf8_text) + # Replace registered trademark symbol + utf8_text = re.sub(u'[\u00ae\u24c7]', '(r)', utf8_text) + # Replace sound recording copyright symbol + utf8_text = re.sub(u'[\u2117\u24c5\u24df]', '(p)', utf8_text) + # Replace service mark symbol + utf8_text = re.sub(u'[\u2120]', '(sm)', utf8_text) + # Replace trademark symbol + utf8_text = re.sub(u'[\u2122]', '(tm)', utf8_text) + # Replace/clobber any remaining UTF-8 characters that aren't in ISO-8859-1 + return utf8_text.encode('ISO-8859-1', 'replace') + +def get_quote(): + try: + r = requests.get(config['url'], auth=(config['http-user'], config['http-pass']), timeout=3) + if r.status_code != 200: + raise Exception() + # FUCK CHARACTER ENCODINGS. + quote = encode_utf8_to_iso88591(r.content.decode('ISO-8859-1')) + return quote + except Exception, e: + traceback.print_exc() + return 'error getting quote :( (%s)' % e + +def irc_msg(source, target, msg): + if msg == '!quote' and target in config['channels']: + quote = get_quote() + ircbot.say(target, quote) + usemap[target].SendMessage(quote) + +def skype_msg(sourceDisplay, sourceHandle, target, msg): + if msg == '!quote' and usemap[target] in config['channels']: + quote = get_quote() + ircbot.say(usemap[target], quote) + target.SendMessage(quote) diff --git a/skype2irc.py b/skype2irc.py old mode 100755 new mode 100644 index 90578a0..39f0e52 --- a/skype2irc.py +++ b/skype2irc.py @@ -13,7 +13,7 @@ # 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 . @@ -28,29 +28,35 @@ import sys, signal import time, datetime import string, textwrap +import re +import json from ircbot import SingleServerIRCBot from irclib import ServerNotConnectedError from threading import Timer -version = "0.2" +from config import config + +import importlib +import logging +import traceback + +logging.basicConfig(level=logging.WARN, + format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s', + datefmt='%Y-%m-%d %H:%M:%S') + +version = "0.22" + +servers = config['servers'] -servers = [ -("irc.freenode.net", 6667), -("hitchcock.freenode.net", 6667), -("leguin.freenode.net", 6667), -("verne.freenode.net", 6667), -("roddenberry.freenode.net", 6667), -] +nick = config['nick'] +botname = config['botname'].decode("UTF-8") +password = config['password'] +vhost = config['vhost'] -nick = "skype-}" -botname = "IRC ⟷ Skype".decode('UTF-8') -password = None +mirrors = config['mirrors'] -mirrors = { -'#test': -'X0_uprrk9XD40sCzSx_QtLT-oELEiV63Jw402jjG0dUaHiq2CD-F-6gKEQiFrgF_YPiUBcH-d6JcgmyWRPnteETG', -} +modconfig = config['modules'] max_irc_msg_len = 442 ping_interval = 2*60 @@ -63,14 +69,12 @@ preferred_encodings = ["UTF-8", "CP1252", "ISO-8859-1"] -name_start = "◀".decode('UTF-8') # "<" -name_end = "▶".decode('UTF-8') # ">" -emote_char = "✱".decode('UTF-8') # "*" +name_start = "<".decode('UTF-8') # "<" +name_end = ">".decode('UTF-8') # ">" +emote_char = "*".decode('UTF-8') # "*" muted_list_filename = nick + '.%s.muted' -topics = "" - usemap = {} bot = None mutedl = {} @@ -94,37 +98,37 @@ def get_relative_time(dt): now = datetime.datetime.now() delta_time = now - dt - delta = delta_time.days * DAY + delta_time.seconds + delta = delta_time.days * DAY + delta_time.seconds minutes = delta / MINUTE hours = delta / HOUR days = delta / DAY if delta <= 0: return "in the future" - if delta < 1 * MINUTE: + if delta < 1 * MINUTE: if delta == 1: return "moment ago" else: return str(delta) + " seconds ago" - if delta < 2 * MINUTE: + if delta < 2 * MINUTE: return "a minute ago" - if delta < 45 * MINUTE: + if delta < 45 * MINUTE: return str(minutes) + " minutes ago" - if delta < 90 * MINUTE: + if delta < 90 * MINUTE: return "an hour ago" if delta < 24 * HOUR: return str(hours) + " hours ago" - if delta < 48 * HOUR: + if delta < 48 * HOUR: return "yesterday" - if delta < 30 * DAY: + if delta < 30 * DAY: return str(days) + " days ago" - if delta < 12 * MONTH: + if delta < 12 * MONTH: months = delta / MONTH if months <= 1: return "one month ago" else: return str(months) + " months ago" - else: + else: years = days / 365.0 if years <= 1: return "one year ago" @@ -180,9 +184,11 @@ def OnMessageStatus(Message, Status): if chat in usemap: if Status == 'RECEIVED': if msgtype == 'EMOTED': - bot.say(usemap[chat], emote_char + " " + senderHandle + " " + raw) + bot.say(usemap[chat], emote_char + " " + senderDisplay.encode('ascii', 'ignore') + " " + raw.encode('ascii', 'ignore')) elif msgtype == 'SAID': - bot.say(usemap[chat], name_start + senderHandle + name_end + " " + raw) + bot.say(usemap[chat], name_start + senderDisplay.encode('ascii', 'ignore') + name_end + " " + raw.encode('ascii', 'ignore')) + for modname in modules: + modules[modname].skype_msg(senderHandle, senderDisplay, chat, raw) def decode_irc(raw, preferred_encs = preferred_encodings): """Heuristic IRC charset decoder""" @@ -202,7 +208,9 @@ def decode_irc(raw, preferred_encs = preferred_encodings): except: res = raw.decode(enc, 'ignore') #enc += "+IGNORE" - return res + # Strip mIRC color codes (\x03XX,YY) from the message + stripped = re.sub(r"\x03\d{1,2}(,\d{1,2})?", "", res) + return stripped def signal_handler(signal, frame): print "Ctrl+C pressed!" @@ -221,7 +229,7 @@ class MirrorBot(SingleServerIRCBot): """Create IRC bot class""" def __init__(self): - SingleServerIRCBot.__init__(self, servers, nick, (botname + " " + topics).encode("UTF-8"), reconnect_interval) + SingleServerIRCBot.__init__(self, servers, nick, (botname).encode("UTF-8"), reconnect_interval) def start(self): """Override default start function to avoid starting/stalling the bot with no connection""" @@ -271,7 +279,7 @@ def say(self, target, msg, do_say = True): time.sleep(delay_btw_seqs) # to avoid flood excess except ServerNotConnectedError: print "{" +target + " " + msg+"} SKIPPED!" - + def notice(self, target, msg): """Send notices to channels/nicks""" self.say(self, target, msg, False) @@ -281,6 +289,11 @@ def on_welcome(self, connection, event): print "Connected to", self.connection.get_server_name() if password is not None: bot.say("NickServ", "identify " + password) + if vhost: + bot.say("HostServ", "ON") + time.sleep(1) + # ensure handler is present exactly once by removing it before adding + self.connection.remove_global_handler("ctcp", self.handle_ctcp) self.connection.add_global_handler("ctcp", self.handle_ctcp) for pair in mirrors: connection.join(pair) @@ -291,9 +304,9 @@ def on_pubmsg(self, connection, event): """React to channel messages""" args = event.arguments() source = event.source().split('!')[0] - target = event.target() + target = event.target().lower() cmds = args[0].split() - if cmds[0].rstrip(":,") == nick: + if cmds and cmds[0].rstrip(":,") == nick: if len(cmds)==2: if cmds[1].upper() == 'ON' and source in mutedl[target]: mutedl[target].remove(source) @@ -302,24 +315,27 @@ def on_pubmsg(self, connection, event): mutedl[target].append(source) save_mutes(target) return - if source in mutedl[target]: + if not mutedl.has_key(target) or source in mutedl[target]: return - msg = name_start + source + name_end + " " + msg_hdr = name_start + source + name_end + " " + msg_body = '' for raw in args: - msg += decode_irc(raw) + "\n" - msg = msg.rstrip("\n") - print cut_title(usemap[target].FriendlyName), msg - usemap[target].SendMessage(msg) + msg_body += decode_irc(raw) + "\n" + msg_body = msg_body.rstrip("\n") + print cut_title(usemap[target].FriendlyName), msg_hdr + msg_body + usemap[target].SendMessage(msg_hdr + msg_body) + for modname in modules: + modules[modname].irc_msg(source, target, msg_body) def handle_ctcp(self, connection, event): """Handle CTCP events for emoting""" args = event.arguments() source = event.source().split('!')[0] - target = event.target() + target = event.target().lower() if target in mirrors.keys(): if source in mutedl[target]: return - if args[0]=='ACTION' and len(args) == 2: + if args[0]=='ACTION' and len(args) == 2 and target in usemap: # An emote/action message has been sent to us msg = emote_char + " " + source + " " + decode_irc(args[1]) + "\n" print cut_title(usemap[target].FriendlyName), msg @@ -330,8 +346,10 @@ def on_privmsg(self, connection, event): source = event.source().split('!')[0] raw = event.arguments()[0].decode('utf-8', 'ignore') args = raw.split() + if not args: + return two = args[0][:2].upper() - + if two == 'ST': # STATUS muteds = [] brdcsts = [] @@ -344,21 +362,21 @@ def on_privmsg(self, connection, event): bot.say(source, "You're mirrored to Skype from " + ", ".join(brdcsts)) if len(muteds) > 0: bot.say(source, "You're silent to Skype on " + ", ".join(muteds)) - + if two == 'OF': # OFF for channel in mirrors.keys(): if source not in mutedl[channel]: mutedl[channel].append(source) save_mutes(channel) bot.say(source, "You're silent to Skype now") - + elif two == 'ON': # ON for channel in mirrors.keys(): if source in mutedl[channel]: mutedl[channel].remove(source) save_mutes(channel) bot.say(source, "You're mirrored to Skype now") - + elif two == 'IN' and len(args) > 1 and args[1] in mirrors: # INFO chat = usemap[args[1]] members = chat.Members @@ -386,9 +404,9 @@ def on_privmsg(self, connection, event): msg += desc + '\n' msg = msg.rstrip("\n") bot.say(source, msg) - + elif two in ('?', 'HE', 'HI', 'WT'): # HELP - bot.say(source, botname + " " + version + " " + topics + "\n * ON/OFF/STATUS --- Trigger mirroring to Skype\n * INFO #channel --- Display list of users from relevant Skype chat\nDetails: https://github.com/boamaod/skype2irc#readme") + bot.say(source, botname + " " + version + " " + "\n * ON/OFF/STATUS --- Trigger mirroring to Skype\n * INFO #channel --- Display list of users from relevant Skype chat\nDetails: https://github.com/boamaod/skype2irc#readme") # *** Start everything up! *** @@ -425,18 +443,34 @@ def on_privmsg(self, connection, event): print 'Skype API initialised.' -topics = "[" for pair in mirrors: chat = skype.CreateChatUsingBlob(mirrors[pair]) topic = chat.FriendlyName print "Joined \"" + topic + "\"" - topics += cut_title(topic) + "|" usemap[pair] = chat usemap[chat] = pair -topics = topics.rstrip("|") + "]" load_mutes() bot = MirrorBot() + +# module API: +# main module sets config, usemap, and irc_say +# on Skype msg, main module calls module.skype_msg(senderDisplay, senderHandle, chat, msg) +# on IRC msg, main module calls module.irc_msg(source, target, msg) + +modules = {} +for modname in modconfig: + try: + module = importlib.import_module(modname) + module.config = modconfig[modname] + module.usemap = usemap + module.ircbot = bot + modules[modname] = module + logging.info('Loaded module %s' % modname) + except Exception, exc: + logging.error('Failed to load module %s! Exception follows:' % modname) + traceback.print_exc() + print "Starting IRC bot..." bot.start()