Skip to content

Instantly share code, notes, and snippets.

@boxmein
Last active February 28, 2016 11:15
Show Gist options
  • Save boxmein/d67844d1e3d064d80570 to your computer and use it in GitHub Desktop.
Save boxmein/d67844d1e3d064d80570 to your computer and use it in GitHub Desktop.
Multi-server single-file no-dependency asynchronous self-updating extensible Python IRC relay bot. (Alright, the config is in a separate file...)
#!/usr/bin/env python3
# encoding: utf8
#
# IRC Relay bot
# -------------
#
# by boxmein 2015-12-09. license at the end of this file.
#
# Can relay between multiple servers and channels. Relay links can be defined
# live or during configuration.
#
# It also defines three utility commands:
#
# !link <from-host>:<from-channel> <to-host>:<to-channel>
# Adds a link between the left and right side. See configuration of
# links between channels.
#
# !dellink <from-host>:<from-channel> <to-host>:<to-channel>
# Deletes an existing link between the left and right side.
#
# !raw <server> <raw-irc...>
# Sends raw IRC lines onto a server specified by its hostname.
#
# TODO:
# -----
#
# (1) Handle IRC lines broken on the 1024-byte boundary. My code receives blocks
# of 1024 bytes, therefore something like this might happen:
# ... ][ ...
# blah blah\r\nPRIVMSG user :!link blah blah\r\n001 :Welcome to IRC
# Neither PRIVMSG alone or "user :!link blah blah" are valid IRC lines.
#
# (2) Reduce amount of lookup tables :P
import select
import socket
import ssl
import re
import os
import time
import threading
import queue
from relaybox_conf import *
# Bot version! :D
VERSION = "2.1.2"
def log(text, msg_type="~~~", fmt="{time} {msg_type} {text}"):
print(fmt.format(**{
"time": time.strftime("%H:%M:%S", time.gmtime()),
"msg_type": msg_type,
"text": text
}))
# just a boring container object
class QueuedMessage():
"""
Initialize a QueuedMessage object.
A QueuedMessage is a combination of the raw message in text as well as the
socket it needs to be sent across.
@constructor
@param sl (ssl.SSLSocket, threading.Lock) a 2-tuple with both the socket
and the lock corresponding to that socket
@param raw_message str the raw text that has to be sent across the socket
"""
def __init__(self, sl, raw_message):
self.socket, self.lock = sl
self.raw_message = raw_message
class SendQueueHandler(threading.Thread):
"""
Handles messages from a global send queue (`sendq`) and rate-limits messages
over sockets.
"""
def __init__(self):
super(SendQueueHandler, self).__init__()
self.last_msgs = {}
def run(self):
while True:
message = sendq.get()
# skip garbage non-QueuedMessage messages
if not isinstance(message, QueuedMessage):
continue
to_send = message.raw_message
# todo: blocks until any last message sent was 0.25s away
# make it so it handles other servers' messages during that
# :P
if (hash(message.socket) in self.last_msgs and
time.time() - 0.1 < self.last_msgs[hash(message.socket)]):
time.sleep(0.1)
self.last_msgs[hash(message.socket)] = time.time()
# if to_send is empty or whitespace
if to_send == '' or to_send.strip() == '':
log("send queue ignored empty message!", " ")
continue
# auto-add \r\n as needed
if not to_send.endswith("\r\n") or not to_send.endswith("\n"):
to_send = to_send + "\r\n"
to_send = to_send.encode('utf8')
# take a lock for the socket, and send the message
with message.lock:
message.socket.send(to_send)
def parse_irc(line):
"""
Parse IRC according to the specification (RFC 1459)
@param line str the IRC line, in plain text (no newline assumed)
@returns dict with prefilled values according to IRC line, or None if failed
"""
if not line or line == '':
log("parse_irc failed, empty line received")
return
if line == 'DONE':
log("server sent DONE line, shutting down", "!!!")
return
# If we get an error parsing, it's very likely we hit a buffer boundary.
# Fix in TODO (1)
try:
ircline = {}
words = line.split(' ')
if line[0] == ':':
ircline['prefix'] = words.pop(0)[1:]
hostparse = hostrx.match(ircline['prefix'])
if hostparse is not None:
hostmask, nick, user, host = hostparse.group(0,1,2,3)
ircline['hostmask'] = hostmask
ircline['nick'] = nick
ircline['user'] = user
ircline['hostname'] = host
ircline['numeric'] = words.pop(0)
ircline['params'] = []
for i, word in enumerate(words):
if word[0] != ':':
ircline['params'].append(word)
else:
ircline['params'].append(' '.join(words[i:])[1:])
break
# specially-named parameters
if len(ircline['params']) > 0: ircline['channel'] = ircline['params'][0]
if len(ircline['params']) > 1: ircline['message'] = ircline['params'][1]
return ircline
except:
return
def send_raw_irc(sock, line):
"""
Given a string of text and a socket, sends the line as UTF-8 down that socket.
Additionally, checks that each IRC line ends with a \r\n, and adds the newline
if it doesn't.
@param sock SSLSocket the SSL socket to send data down
@param line str the raw IRC line you want to send
@returns None
"""
sendq.put(QueuedMessage(sock, line))
def matches_hostmask(pattern, hostmask):
"""
Given a pattern that looks like a hostmask with optional wildcards marked "*",
and generic regex otherwise, test if the hostmask matches the pattern.
Example patterns are jacob*!*@*, me!*@*, me!derp@totally\\.cool (regexes!)
@param pattern str the pattern to look for
@param hostmask str the hostmask to test
@returns bool True if the hostmask can be fit to the pattern
"""
pattern_rx = re.compile("^" + pattern.replace("*", "(.+?)") + "$")
return pattern_rx.match(hostmask) is not None
def get_server(sock):
"""
Given a socket object, returns the server it's connected to, if it was assigned
to the servers_by_socket back during initialization.
@param sock SSLSocket the SSL socket you want to know the corresponding server for
@returns dict Server configuration that created this socket
"""
return servers_by_socket[hash(sock)]
def send_privmsg(sock, target, text):
"""
Send a PRIVMSG message to the target (be it a channel or user) on the socket.
@param sock SSLSocket the SS socket you want to send data down
@param target str the target channel or nickname to send the message to
@param text str the text you want to send
@returns the return value from send_raw_irc()
"""
return send_raw_irc(sock, "PRIVMSG {} :{}".format(target, text[0:400]))
def get_perm_level(sock, line):
"""
Return the "permission level" of the user that sent this line. This depends
on the server that the command was sent from. This is based on user
configuration.
@param sock SSLSocket the socket corresponding to the IRC server
@param line dict The parsed IRC line
@see parse_irc_line
@returns int 0 when no permissions, 1 when the user can change links and 2
when the user is the owner.
"""
current_host = get_server(sock)['host']
# print("checking perms for " + line['nick'])
if current_host in PERMISSIONS:
if 'administrator' in PERMISSIONS[current_host]:
for adm_pat in PERMISSIONS[current_host]['administrator']:
# print("matching pattern with hostmask", adm_pat, line['hostmask'])
if matches_hostmask(adm_pat, line['hostmask']):
# print("matched! returning 2")
return 2
if 'links' in PERMISSIONS[current_host]:
for lnx_pat in PERMISSIONS[current_host]['links']:
# print("matching pattern with hostmask", lnx_pat, line['hostmask'])
if matches_hostmask(lnx_pat, line['hostmask']):
# print("matched! returning 1")
return 1
return 0
def handle_real_irc(sock, server, line):
"""
Handle IRC lines. Send responses to servers. Everything.
@param sock SSLSocket the SSL socket that the IRC line originates from.
@param server dict the server configuration respective to the SSL socket.
@param line str the IRC line that the server sent to us
@returns None
"""
if not line.startswith("PING"):
log(repr(line), "<<<")
line = parse_irc(line)
if not line or len(line) == 0:
log("empty IRC line received into handle_real_irc", "!!!")
return
# the good old ping-pong
if line['numeric'] == 'PING':
send_raw_irc(sock, "PONG :{}\r\n".format(
line['params'][0] if
len(line['params']) > 0 else ''))
# Sign-on
elif line['numeric'] == '464':
log("Authenticating to server {}".format(server['host']), "***")
send_raw_irc(sock, "PASS {}\r\n".format(server['pass']))
# Post-sign-on, we are through!
elif line['numeric'] == '001':
log("Joining relayed channels...", "***")
send_raw_irc(sock, "JOIN {}\r\n".format(','.join(server['channels'])))
# PRIVMSGs
elif line['numeric'] == 'PRIVMSG':
# make sure to respond right to private messages
if line['channel'] == server['nick']:
line['channel'] = line['nick']
print('~~~ ', server['host'], line['channel'], "<" + line['nick'] + ">", line['message'])
msg_words = line['message'].split(' ')
#
# Commands
#
# Create unidirectional links between channels
if (len(msg_words) == 3 and msg_words[0] == '!link' and get_perm_level(sock, line) >= 1):
link_from = msg_words[1]
link_to = msg_words[2]
log("Adding link between two channels: {} {}".format(link_from, link_to), "***")
if (link_from, link_to) not in links:
links.append((link_from, link_to))
else:
log("Link already exists. Doing nothing.", "***")
send_privmsg(sock, line['channel'], "link already exists!")
elif msg_words[0] == '!link':
send_privmsg(sock, line['channel'], STRINGS['!link usage'])
# Delete links between channels
elif (len(msg_words) == 3 and msg_words[0] == '!dellink' and get_perm_level(sock, line) >= 1):
link_from = msg_words[1]
link_to = msg_words[2]
log("Removing link between two channels: ", link_from, link_to, "***")
pair = (link_from, link_to)
if pair in links:
links.pop(links.index(pair))
else:
log("No such link, doing nothing.", "***")
send_privmsg(sock, line['channel'], STRINGS['!dellink error-link-doesnt-exist'])
elif msg_words[0] == '!dellink':
send_privmsg(sock, line['channel'], STRINGS['!dellink usage'])
# Raw send, because every bot needs it
elif (msg_words[0] == '!raw' and get_perm_level(sock, line) == 2):
to_server = msg_words[1]
if to_server in sockets_by_host and len(msg_words) > 2:
send_raw_irc(sockets_by_host[to_server], ' '.join(msg_words[2:]))
else:
send_privmsg(sock, line['channel'], STRINGS['!raw usage'])
# Who's online?
elif (msg_words[0] == '!online'):
target = ''
if len(msg_words) == 2:
target = msg_words[1]
else:
our_server_def = server['host'] + ":" + line['channel']
for link in links:
if link[0] == our_server_def:
target = link[1]
break
if target == '':
log("didn't find the channel to list users in...", "???")
send_privmsg(sock, line['channel'], STRINGS['!online usage'])
else:
about_def = target.split(':')
if len(about_def) != 2:
send_privmsg(sock, line['channel'], STRINGS['!online usage'])
else:
sserv = about_def[0]
schan = about_def[1]
if sserv in susers and schan in susers[sserv]:
send_privmsg(sock, line['channel'], ', '.join( list(susers[sserv][schan]) [0:MAX_ONLINE]))
else:
send_privmsg(sock, line['channel'], STRINGS['!online usage'])
# Some arbitrary commands
elif msg_words[0] == '!moo':
# moo with 2 + (how many links do we have?) o-s
send_privmsg(sock, line['channel'], "moo" + 'o' * min(100, len(links)) )
elif msg_words[0] == '!?' or msg_words[0] == '!help':
send_privmsg(sock, line['channel'], STRINGS['!help'])
elif msg_words[0] == '!version':
send_privmsg(sock, line['channel'], "relaybox: version " + VERSION + ", updating from " + UPDATE_URL)
# Updating
elif msg_words[0] == '!update' and get_perm_level(sock, line) == 2:
send_privmsg(sock, line['channel'], "Updating relaybox from the URL " + UPDATE_URL)
update_and_reboot()
#
# Channel links
#
for link in links:
from_server, from_channel = link[0].split(':')
to_server, to_channel = link[1].split(':')
# don't forward messages that have !local as the first word
if msg_words[0] == "!local": continue
if (to_server in sockets_by_host and
server['host'] == from_server and
line['channel'] == from_channel):
msg_format = "PRIVMSG {to_channel} :"
if (line['message'].startswith("\x01ACTION") and
line['message'].endswith("\x01")):
msg_format += ACTION_FORMAT
line['message'] = ' '.join(msg_words[1:])[:-1]
else:
msg_format += PRIVMSG_FORMAT
if line['message'] == '':
continue
full_msg = msg_format.format(**{
"to_channel": to_channel,
"from_server": from_server,
"from_channel": from_channel,
"nick": line['nick'],
"message": line['message']
})
send_raw_irc(sockets_by_host[to_server], full_msg)
#
# ZNC User Watching
#
# Useful in ZNC, this feature collects the nicks of all voiced users in a
# channel upon join, and keeps track of the voiced users. This is used in
# ZNC in partyline channels to mark users that are online.
elif line['numeric'] == '353':
# Create the initial NAMES list when we join a channel
# Collect all 353s and filter users with @ or + status.
shost = server['host']
lchan = line['params'][2]
if shost not in susers: susers[shost] = {}
if lchan not in susers[shost]: susers[shost][lchan] = set()
for user in line['params'][3].split():
if user[0] == "@" or user[0] == "+":
susers[shost][lchan].add(user)
# Someone just entered a channel!
elif line['numeric'] == 'MODE' and line['params'][2] in ['+v', '+ov', '+vo']:
shost = server['host']
lchan = line['channel']
user = line['params'][3]
if shost not in susers: susers[shost] = {}
if lchan not in susers[shost]: susers[shost][lchan] = set()
susers[shost][lchan].add(user)
# Someone just left a channel!
elif line['numeric'] == 'MODE' and line['params'][2] in ['-v', '-ov', '-vo']:
shost = server['host']
lchan = line['channel']
user = line['params'][3]
if shost not in susers: susers[shost] = {}
if lchan not in susers[shost]: susers[shost][lchan] = set()
susers[shost][lchan].discard(user)
def handle_raw_irc(sock, buf):
"""
Decodes bytes received from the socket, splits them into lines and sends all
of the lines onto handle_real_irc.
@param sock SSLSocket the socket that the IRC data originated from
@param buf bytes the bytes received from the socket
@returns None
"""
text = buf.decode('utf8', errors='ignore')
server = get_server(sock)
for line in text.split('\r\n'):
if line and line != '':
handle_real_irc(sock, server, line)
def update_and_reboot():
"""
Downloads new source code for this IRC bot and runs it. Make sure you're
sending new code over HTTPS from a trusted vendor. The URL of the source is
specified inside the relaybox configuration. Also make sure that the config
you have right now will match the config that the new code supports.
"""
import urllib.request
from os import execl
from sys import argv
log("Retrieving fresh code for relaybox from URL: {}".format(UPDATE_URL), "!!!")
urllib.request.urlretrieve(UPDATE_URL, 'relaybox.py')
cli_args = argv
if len(cli_args) == 1:
cli_args.append("moo")
print("Executing `" + ' '.join(argv) + "`...")
os.execl(*(cli_args))
#
# Entry point
#
sendq = queue.Queue()
# Match data in a hostmask (user!ident@host)
hostrx = re.compile("^(.+)!(.+)@(.+)$")
# bot state
# sockets = (ssl.SSLSocket, threading.Lock)
sockets = []
links = [] + initial_links
# max amount of users in one listing
MAX_ONLINE = 20
# users in channels
# users get added when they receive voice or ops
# and removed otherwise
# { server: { channel: [user] }}
susers = {}
# create three lookup tables for fastness
servers_by_socket = {}
sockets_by_host = {}
# servers_by_host = {}
# setup and connect to the hosts
for s in servers:
sock = socket.socket()
ssock = ssl.wrap_socket(sock)
addrinfo = socket.getaddrinfo(s['host'], s['port'])
ssock.connect(addrinfo[0][-1])
ssock = (ssock, threading.Lock())
sockets.append(ssock)
log("connecting to " + str(addrinfo[0][-1]) + " with socket " + str(hash(ssock)), "***")
# populate lookup tables
servers_by_socket[hash(ssock)] = s
sockets_by_host[s['host']] = ssock
# servers_by_host[s['host']] = s
send_raw_irc(ssock, "NICK {nick}\r\nUSER {ident} beep boop :{realname}\r\n".format(**s))
# start the send queue thread
send_thread = SendQueueHandler()
send_thread.daemon = True
send_thread.start()
# start read loop
try:
while True:
readable_socks, beep, boop = select.select([x[0] for x in sockets], [], [], 256)
# convert socket objects back to socket/lock pairs
readable_socks = [x for x in sockets if x[0] in readable_socks]
if len(readable_socks) == 0:
log("no readable sockets selected, will die!", "!!!")
for s in sockets:
with s[1]:
s[0].close()
exit(0)
for sock in readable_socks:
buf = sock[0].recv(1024)
handle_raw_irc(sock, buf)
send_thread.join()
except KeyboardInterrupt:
log("KeyboardInterrupt, cleaning up after ourselves...", "!!!")
try:
for s in sockets:
with s[1]:
s[0].send("QUIT :KeybordInterrupt\r\n".encode('utf8'))
s[0].close()
except KeyboardInterrupt:
# I ain't gonna do another layer but if I did you'd be screaming
# TRIPLE FAULT
log("cleanup interrupted. closing.", "!!!")
exit(0)
log("cleanup completed. closing.", "!!!")
exit(0)
# The MIT License (MIT)
# Copyright (c) 2015 boxmein
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
# relaybox configuration!
#
if __name__=='__main__': exit(0)
#
# Configuration for all the servers you want to use with this bot.
# a list of dicts, where each dict has to have the following keys defined:
#
# name type desc
# ----------------------------------------------------------------------------
# host string the hostname (IPv4, IPv6 or domain name) of the IRC server
# port int the port on which the IRC server is listening with SSL
# pass string the server password, if any
# channels list[string] a list of channels to join on connection
# nick string the bot's nickname
# ident string the bot's ident/username (first argument of the USER line)
# realname string the bot's realname (last argument of the USER line)
#
servers = [
{
"host": "boxmein.net",
"port": 6667,
"pass": "",
"channels": [""],
"nick": "relaybox",
"ident": "relaybox",
"realname": ""
},
{
"host": "irc.freenode.net",
"port": 6697,
"pass": "",
"channels": [""],
"nick": "relaybox",
"ident": "boxmein",
"realname": ""
}
]
#
# Configuration of links between channels.
#
# A channel link will send all PRIVMSG-s from the left side onto the right side
# (links are uni-directional). Channel links are represented as a 2-tuple in the
# following format:
#
# (<from-host> + ":" + <from-channel>, <to-host> + ":" + <to-channel>)
# for example:
#
# ("starcatcher.us:~#party", "irc.freenode.net:#powder")
# This link will send all chats from the ~#party channnel (ZNC partyline) on the
# starcatcher.us server to the #powder channel on the irc.freenode.net server.
#
# The names "starcatcher.us" and "irc.freenode.net" must be the same as the
# "host" parameters in the server list above.
#
# Note, you can do all this configuration later too, with the bot's commands.
#
initial_links = [
("irc.freenode.net:##channel", "boxmein.net:~#party")
]
# Permissions of users and hostmasks on specific servers.
# For example, the user "derp" on server "irc.freenode.net" will get to
# administer the bot.
# The permissions of an administrator are the !raw and !update commands, and
# also commands related to links.
# The permissions of a links are the !link and !dellink commands only.
PERMISSIONS = {
"irc.freenode.net": {
"administrator": ["example2!example2@*"],
"links": ["\\?boxmein!*@znc.in"]
},
}
# how much the bot waits until sending consecutive messages
# in seconds
SEND_DELAY = 0.1
# Change your PRIVMSG and ACTION message formats here
# Note: removing these values will mean that the bot will crash :D
PRIVMSG_FORMAT = "[{from_server}:{from_channel}] <{nick}> {message}"
ACTION_FORMAT = "[{from_server}:{from_channel}] * {nick} {message}"
# This is the URL from where to fetch new code when !update is run.
UPDATE_URL = "https://gist.githubusercontent.com/boxmein/d67844d1e3d064d80570/raw"
# Strings used inside the IRC bot.
STRINGS = {
"!link usage": "usage: !link <fromserv>:<fromchan> <toserv>:<tochan>. sends all channel messages from <fromchan> to <tochan>.",
"!dellink error-link-doesnt-exist": "this link does not exist!",
"!dellink usage": "usage: !dellink <fromserv>:<fromchan> <toserv>:<tochan>. deletes a link from one channel to another.",
"!raw usage": "usage: !raw <server> <raw irc...>. sends raw IRC lines to a server.",
"!online usage": "usage: !online [<server>:<channel>]. lists all voiced-or-above users in the specified channel, or the first one this one is linked to. ",
"!help": "relaybox - this bot relays messages between different IRC servers and channels.",
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment