RelayBot/relaybot.py

490 lines
18 KiB
Python
Raw Normal View History

from twisted.words.protocols import irc
2015-02-02 01:02:53 +00:00
from twisted.internet import reactor, protocol, ssl
2016-11-10 19:55:25 +00:00
from twisted.internet.protocol import ReconnectingClientFactory, ServerFactory
2015-02-02 01:21:05 +00:00
from twisted.python import log, reflect, util
2016-11-10 19:55:25 +00:00
from twisted.internet.endpoints import clientFromString, serverFromString
2015-02-02 01:21:05 +00:00
from twisted.internet.error import VerifyError, CertificateError
2016-11-10 19:55:25 +00:00
from twisted.internet.defer import Deferred, succeed
2013-01-20 05:31:23 +00:00
from twisted.internet.task import LoopingCall
from twisted.application import service
2016-11-10 19:55:25 +00:00
from twisted.web import client, http_headers, iweb, resource, server
from zope.interface import implementer
from hashlib import md5
2015-02-02 01:21:05 +00:00
from OpenSSL import SSL, crypto
from signal import signal, SIGINT
from ConfigParser import SafeConfigParser
2016-11-10 19:55:25 +00:00
import re, sys, itertools, json
2012-03-03 03:54:28 +00:00
#
# RelayBot is a derivative of http://code.google.com/p/relaybot/
#
log.startLogging(sys.stdout)
2012-03-03 03:56:25 +00:00
__version__ = "0.1"
application = service.Application("RelayBot")
2015-02-02 01:21:05 +00:00
_sessionCounter = itertools.count().next
def main():
config = SafeConfigParser()
config.read("relaybot.config")
defaults = config.defaults()
2012-07-16 22:06:00 +00:00
2016-11-10 19:55:25 +00:00
# Webhook stuff
webhooks = resource.Resource()
pool = client.HTTPConnectionPool(reactor)
agent = client.Agent(reactor, pool=pool)
for section in config.sections():
2012-07-16 22:06:00 +00:00
2012-03-04 19:51:06 +00:00
def get(option):
if option in defaults or config.has_option(section, option):
return config.get(section, option) or defaults[option]
else:
return None
2012-07-16 22:06:00 +00:00
2016-07-01 09:44:13 +00:00
options = {'servername': section}
2016-07-13 03:49:22 +00:00
for option in [ "timeout", "host", "port", "nick", "channel", "channels", "heartbeat", "password", "username", "realname", "mode", "ssl", "fingerprint", "nickcolor", "topicsync" ]:
options[option] = get(option)
2012-07-16 22:06:00 +00:00
mode = get("mode")
2012-07-16 22:06:00 +00:00
#Not using endpoints pending http://twistedmatrix.com/trac/ticket/4735
#(ReconnectingClientFactory equivalent for endpoints.)
factory = None
2012-03-11 07:10:29 +00:00
if mode == "Default":
factory = RelayFactory
2012-03-11 07:10:29 +00:00
elif mode == "FLIP":
factory = FLIPFactory
elif mode == "NickServ":
factory = NickServFactory
options["nickServPassword"] = get("nickServPassword")
2013-01-20 06:16:24 +00:00
elif mode == "ReadOnly":
factory = ReadOnlyFactory
options["nickServPassword"] = get("nickServPassword")
# RelayByCommand: only messages with <nickname>: will be relayed.
elif mode == "RelayByCommand":
factory = CommandFactory
2016-11-10 19:55:25 +00:00
elif mode == "Webhooks":
options['webhookNonce'] = get('webhookNonce')
options['outgoingWebhook'] = get('outgoingWebhook')
webhooks.putChild(options['webhookNonce'], Webhook(agent, options))
continue
2012-07-16 22:06:00 +00:00
factory = factory(options)
2015-02-02 01:02:53 +00:00
if options['ssl'] == "True":
2015-02-02 01:21:05 +00:00
if options['fingerprint']:
ctx = certoptions(fingerprint=options['fingerprint'], verifyDepth=0)
reactor.connectSSL(options['host'], int(options['port']), factory, ctx, int(options['timeout']))
else:
reactor.connectSSL(options['host'], int(options['port']), factory, ssl.ClientContextFactory(), int(options['timeout']))
2015-02-02 01:02:53 +00:00
else:
reactor.connectTCP(options['host'], int(options['port']), factory, int(options['timeout']))
2012-07-16 22:06:00 +00:00
2016-11-10 19:55:25 +00:00
# Start incoming webhook server
if 'webhook' in defaults:
serverFromString(reactor, defaults['webhook']).listen(server.Site(webhooks))
reactor.callWhenRunning(signal, SIGINT, handler)
2015-02-02 01:21:05 +00:00
class certoptions(object):
_context = None
_OP_ALL = getattr(SSL, 'OP_ALL', 0x0000FFFF)
_OP_NO_TICKET = 0x00004000
method = SSL.TLSv1_METHOD
def __init__(self, privateKey=None, certificate=None, method=None, verify=False, caCerts=None, verifyDepth=9, requireCertificate=True, verifyOnce=True, enableSingleUseKeys=True, enableSessions=True, fixBrokenPeers=False, enableSessionTickets=False, fingerprint=True):
assert (privateKey is None) == (certificate is None), "Specify neither or both of privateKey and certificate"
self.privateKey = privateKey
self.certificate = certificate
if method is not None:
self.method = method
self.verify = verify
assert ((verify and caCerts) or
(not verify)), "Specify client CA certificate information if and only if enabling certificate verification"
self.caCerts = caCerts
self.verifyDepth = verifyDepth
self.requireCertificate = requireCertificate
self.verifyOnce = verifyOnce
self.enableSingleUseKeys = enableSingleUseKeys
self.enableSessions = enableSessions
self.fixBrokenPeers = fixBrokenPeers
self.enableSessionTickets = enableSessionTickets
self.fingerprint = fingerprint
def __getstate__(self):
d = self.__dict__.copy()
try:
del d['_context']
except KeyError:
pass
return d
def __setstate__(self, state):
self.__dict__ = state
def getContext(self):
if self._context is None:
self._context = self._makeContext()
return self._context
def _makeContext(self):
ctx = SSL.Context(self.method)
if self.certificate is not None and self.privateKey is not None:
ctx.use_certificate(self.certificate)
ctx.use_privatekey(self.privateKey)
ctx.check_privatekey()
verifyFlags = SSL.VERIFY_NONE
if self.verify or self.fingerprint:
verifyFlags = SSL.VERIFY_PEER
if self.requireCertificate:
verifyFlags |= SSL.VERIFY_FAIL_IF_NO_PEER_CERT
if self.verifyOnce:
verifyFlags |= SSL.VERIFY_CLIENT_ONCE
if self.caCerts:
store = ctx.get_cert_store()
for cert in self.caCerts:
store.add_cert(cert)
def _verifyCallback(conn, cert, errno, depth, preverify_ok):
if self.fingerprint:
digest = cert.digest("sha1")
if digest != self.fingerprint:
log.msg("Remote server fingerprint mismatch. Got: %s Expect: %s" % (digest, self.fingerprint))
return False
else:
log.msg("Remote server fingerprint match: %s " % (digest))
return True
return preverify_ok
ctx.set_verify(verifyFlags, _verifyCallback)
if self.verifyDepth is not None:
ctx.set_verify_depth(self.verifyDepth)
if self.enableSingleUseKeys:
ctx.set_options(SSL.OP_SINGLE_DH_USE)
if self.fixBrokenPeers:
ctx.set_options(self._OP_ALL)
if self.enableSessions:
sessionName = md5("%s-%d" % (reflect.qual(self.__class__), _sessionCounter())).hexdigest()
ctx.set_session_id(sessionName)
if not self.enableSessionTickets:
ctx.set_options(self._OP_NO_TICKET)
return ctx
2012-03-02 18:09:00 +00:00
class Communicator:
def __init__(self):
self.protocolInstances = {}
def register(self, protocol):
self.protocolInstances[protocol.identifier] = protocol
def isRegistered(self, protocol):
return protocol.identifier in self.protocolInstances
2012-03-02 18:09:00 +00:00
def unregister(self, protocol):
if protocol.identifier not in self.protocolInstances:
log.msg("No protocol instance with identifier %s."%protocol.identifier)
return
2012-03-02 18:09:00 +00:00
del self.protocolInstances[protocol.identifier]
2016-07-13 03:49:22 +00:00
def relay(self, protocol, channel, message):
2012-03-02 18:09:00 +00:00
for identifier in self.protocolInstances.keys():
if identifier == protocol.identifier:
continue
instance = self.protocolInstances[identifier]
2016-07-13 03:49:22 +00:00
instance.say(channel, message)
2016-07-13 03:49:22 +00:00
def relayTopic(self, protocol, channel, newTopic):
for identifier in self.protocolInstances.keys():
if identifier == protocol.identifier:
continue
instance = self.protocolInstances[identifier]
2016-07-13 03:49:22 +00:00
instance.topic(channel, newTopic)
2012-03-02 18:09:00 +00:00
#Global scope: all protocol instances will need this.
communicator = Communicator()
class IRCRelayer(irc.IRCClient):
2012-07-16 22:06:00 +00:00
def __init__(self, config):
2016-07-01 09:44:13 +00:00
self.servername = config['servername']
self.network = config['host']
self.password = config['password']
2016-07-13 03:49:22 +00:00
self.channels = config['channels']
self.nickname = config['nick']
self.identifier = config['identifier']
2012-03-14 15:57:29 +00:00
self.heartbeatInterval = float(config['heartbeat'])
self.username = config['username']
self.realname = config['realname']
self.mode = config['mode']
2015-02-04 19:48:55 +00:00
self.nickcolor = config['nickcolor']
2015-02-04 20:20:02 +00:00
self.topicsync = config['topicsync']
2016-07-13 03:49:22 +00:00
# IRC RFC: https://tools.ietf.org/html/rfc2812#page-4
if len(self.nickname) > 9:
log.msg("Nickname %s is %d characters long, which exceeds the RFC maximum of 9 characters. This may cause connection problems."%(self.nickname, len(self.nickname)))
2016-07-13 03:49:22 +00:00
# Backward compatibility with deprecated single-channel setting
if not self.channels:
self.channels = config['channel']
# Read comma separated string as list
self.channels = [channel.strip() for channel in self.channels.split(',')]
log.msg("IRC Relay created. Name: %s | Host: %s | Channels: %s" % (
self.nickname, self.network, ",".join(self.channels)))
def formatUsername(self, username):
return username.split("!")[0]
2016-07-13 03:49:22 +00:00
def relay(self, channel, message):
communicator.relay(self, channel, message)
def relayTopic(self, channel, newTopic):
communicator.relayTopic(self, channel, newTopic)
2016-07-13 03:49:22 +00:00
def joinChannel(self, channel):
self.join(channel, "")
def joinChannels(self):
[self.joinChannel(channel) for channel in self.channels]
def signedOn(self):
log.msg("[%s] Connected to network."%self.network)
2012-03-14 15:57:29 +00:00
self.startHeartbeat()
2016-07-13 03:49:22 +00:00
self.joinChannels()
2012-07-16 22:06:00 +00:00
def connectionLost(self, reason):
log.msg("[%s] Connection lost, unregistering."%self.network)
2012-03-02 18:09:00 +00:00
communicator.unregister(self)
2012-07-16 22:06:00 +00:00
def joined(self, channel):
log.msg("Joined channel %s, registering."%channel)
2012-03-02 18:09:00 +00:00
communicator.register(self)
2012-07-16 22:06:00 +00:00
2015-02-02 12:02:38 +00:00
def formatMessage(self, message):
2015-02-06 19:24:37 +00:00
return message.replace(self.nickname + ": ", "", 1)
2015-02-02 12:02:38 +00:00
2015-02-04 19:48:55 +00:00
def formatNick(self, user):
2016-07-01 09:44:13 +00:00
nick = "[" + self.servername + "/" + self.formatUsername(user) + "]"
2015-02-04 19:48:55 +00:00
if self.nickcolor == "True":
2016-07-01 09:44:13 +00:00
nick = "[" + self.servername + "/\x0303" + self.formatUsername(user) + "\x03]"
2015-02-04 19:48:55 +00:00
return nick
def privmsg(self, user, channel, message):
2015-02-04 19:55:40 +00:00
if self.mode != "RelayByCommand":
2016-07-13 03:49:22 +00:00
self.relay(channel, "%s %s"%(self.formatNick(user), message))
2015-02-04 19:55:40 +00:00
elif message.startswith(self.nickname + ':'):
2016-07-13 03:49:22 +00:00
self.relay(channel, "%s %s"%(self.formatNick(user), self.formatMessage(message)))
2012-07-16 22:06:00 +00:00
def kickedFrom(self, channel, kicker, message):
2012-03-02 18:09:00 +00:00
log.msg("Kicked by %s. Message \"%s\""%(kicker, message))
communicator.unregister(self)
2012-07-16 22:06:00 +00:00
2015-02-04 20:11:04 +00:00
def action(self, user, channel, message):
2015-02-02 20:15:35 +00:00
if self.mode != "RelayByCommand":
2016-07-13 03:49:22 +00:00
self.relay(channel, "%s %s"%(self.formatNick(user), message))
2012-07-16 22:06:00 +00:00
2015-02-04 20:11:04 +00:00
def topicUpdated(self, user, channel, newTopic):
if self.mode != "RelayByCommand":
self.topic(user, channel, newTopic)
def topic(self, user, channel, newTopic):
2015-02-04 20:20:02 +00:00
if self.topicsync == "True":
2016-07-13 03:49:22 +00:00
self.relayTopic(channel, newTopic)
2015-02-04 20:11:04 +00:00
class RelayFactory(ReconnectingClientFactory):
protocol = IRCRelayer
#Log information which includes reconnection status.
noisy = True
2012-07-16 22:06:00 +00:00
def __init__(self, config):
config["identifier"] = "{0}{1}{2}".format(config["host"], config["port"], config["channel"])
self.config = config
2012-07-16 22:06:00 +00:00
def buildProtocol(self, addr):
#Connected - reset reconnect attempt delay.
self.maxDelay = 900
x = self.protocol(self.config)
x.factory = self
return x
#Remove the _<numbers> that FLIP puts on the end of usernames.
2015-02-02 13:19:11 +00:00
class FLIPRelayer(IRCRelayer):
def formatUsername(self, username):
return re.sub("_\d+$", "", IRCRelayer.formatUsername(self, username))
class FLIPFactory(RelayFactory):
protocol = FLIPRelayer
2015-02-02 13:19:11 +00:00
class NickServRelayer(IRCRelayer):
2012-03-11 07:10:29 +00:00
NickServ = "nickserv"
2013-01-20 05:25:31 +00:00
NickPollInterval = 30
2012-03-11 07:10:29 +00:00
def signedOn(self):
log.msg("[%s] Connected to network."%self.network)
2012-03-14 15:57:29 +00:00
self.startHeartbeat()
2016-07-13 03:49:22 +00:00
self.joinChannels()
2013-01-20 05:25:31 +00:00
self.checkDesiredNick()
def checkDesiredNick(self):
"""
Checks that the nick is as desired, and if not attempts to retrieve it with
NickServ GHOST and trying again to change it after a polling interval.
"""
if self.nickname != self.desiredNick:
log.msg("[%s] Using GHOST to reclaim nick %s."%(self.network, self.desiredNick))
self.msg(NickServRelayer.NickServ, "GHOST %s %s"%(self.desiredNick, self.password))
2013-01-20 05:25:31 +00:00
# If NickServ does not respond try to regain nick anyway.
self.nickPoll.start(self.NickPollInterval)
def regainNickPoll(self):
if self.nickname != self.desiredNick:
log.msg("[%s] Reclaiming desired nick in polling."%(self.network))
self.setNick(self.desiredNick)
else:
log.msg("[%s] Have desired nick."%(self.network))
2013-01-20 05:25:31 +00:00
self.nickPoll.stop()
def nickChanged(self, nick):
log.msg("[%s] Nick changed from %s to %s."%(self.network, self.nickname, nick))
self.nickname = nick
self.checkDesiredNick()
2012-03-11 07:10:29 +00:00
def noticed(self, user, channel, message):
log.msg("[%s] Recieved notice \"%s\" from %s."%(self.network, message, user))
#Identify with nickserv if requested
if IRCRelayer.formatUsername(self, user).lower() == NickServRelayer.NickServ:
msg = message.lower()
if msg.startswith("this nickname is registered"):
log.msg("[%s] Password requested; identifying with %s."%(self.network, NickServRelayer.NickServ))
self.msg(NickServRelayer.NickServ, "IDENTIFY %s"%self.password)
elif msg == "ghost with your nickname has been killed." or msg == "ghost with your nick has been killed.":
log.msg("[%s] GHOST successful, reclaiming nick %s."%(self.network,self.desiredNick))
self.setNick(self.desiredNick)
2012-07-18 11:41:37 +00:00
elif msg.endswith("isn't currently in use."):
2012-07-29 04:32:20 +00:00
log.msg("[%s] GHOST not needed, reclaiming nick %s."%(self.network,self.desiredNick))
2012-07-18 11:41:37 +00:00
self.setNick(self.desiredNick)
2012-07-16 22:06:00 +00:00
2012-03-11 07:10:29 +00:00
def __init__(self, config):
IRCRelayer.__init__(self, config)
self.password = config['nickServPassword']
self.desiredNick = config['nick']
2013-01-20 05:25:31 +00:00
self.nickPoll = LoopingCall(self.regainNickPoll)
2012-03-11 07:10:29 +00:00
2013-01-20 06:16:24 +00:00
class ReadOnlyRelayer(NickServRelayer):
2015-02-04 20:00:21 +00:00
pass
2013-01-20 06:16:24 +00:00
2015-02-02 13:19:11 +00:00
class CommandRelayer(IRCRelayer):
pass
2013-01-20 06:16:24 +00:00
class ReadOnlyFactory(RelayFactory):
protocol = ReadOnlyRelayer
2012-03-11 07:10:29 +00:00
class NickServFactory(RelayFactory):
protocol = NickServRelayer
class CommandFactory(RelayFactory):
protocol = CommandRelayer
2016-11-10 19:55:25 +00:00
@implementer(iweb.IBodyProducer)
class StringProducer(object):
def __init__(self, body):
self.body = body
self.length = len(body)
def startProducing(self, consumer):
consumer.write(self.body)
return succeed(None)
def pauseProducing(self):
pass
def stopProducing(self):
pass
class Webhook(resource.Resource):
def __init__(self, agent, config):
self.agent = agent
self.servername = config['servername']
self.identifier = 'Webhook:%s' % config['webhookNonce']
self.outgoingWebhook = config['outgoingWebhook']
self.nickcolor = config['nickcolor']
communicator.register(self)
def render_POST(self, request):
"""
Process the contents of a request (i.e. a post from Rocket.Chat's
webhook service)
"""
action = request.getHeader("X-IRC-Action")
obj = json.loads(request.content.read())
if action == 'say':
user = str(obj['username'])
channel = str(obj['channel'])
message = str(obj['message'])
self.relay(channel, "%s %s"%(self.formatNick(user), message))
def formatUsername(self, username):
return username.split("!")[0]
def formatNick(self, user):
nick = "[" + self.servername + "/" + self.formatUsername(user) + "]"
if self.nickcolor == "True":
nick = "[" + self.servername + "/\x0303" + self.formatUsername(user) + "\x03]"
return nick
def relay(self, channel, message):
communicator.relay(self, channel, message)
def relayTopic(self, channel, newTopic):
communicator.relayTopic(self, channel, newTopic)
def post(self, action, user, channel, message):
obj = {
'username': user,
'channel': channel,
'message': message,
}
d = self.agent.request(
'POST',
self.outgoingWebhook,
http_headers.Headers({
'Content-Type': ['application/json'],
'X-IRC-Action': [action],
}),
StringProducer(json.dumps(obj)))
def cbResponse(response):
log.msg('Outgoing webhook response: %d' % response.code)
d.addCallback(cbResponse)
def say(self, channel, message, length=None):
if self.outgoingWebhook:
user, msg = message.split(' ', 1)
self.post('say', user, channel, msg)
def topic(self, user, channel, newTopic):
pass
def handler(signum, frame):
2012-07-16 22:06:00 +00:00
reactor.stop()
#Main if run as script, builtin for twistd.
if __name__ in ["__main__", "__builtin__"]:
main()