Skip to content

Instantly share code, notes, and snippets.

@TerrorBite
Last active February 20, 2023 05:21
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save TerrorBite/8ae23a576545b4f86ad3 to your computer and use it in GitHub Desktop.
Save TerrorBite/8ae23a576545b4f86ad3 to your computer and use it in GitHub Desktop.
Python IRC chat bridge for Vanilla Minecraft
import socket
import threading
import re
import time
class ircOutputBuffer:
# Delays consecutive messages by at least 1 second.
# This prevents the bot spamming the IRC server.
def __init__(self, irc):
self.waiting = False
self.irc = irc
self.queue = []
self.error = False
def __pop(self):
if len(self.queue) == 0:
self.waiting = False
else:
self.sendImmediately(self.queue[0])
self.queue = self.queue[1:]
self.__startPopTimer()
def __startPopTimer(self):
self.timer = threading.Timer(1, self.__pop)
self.timer.start()
def sendBuffered(self, string):
# Sends the given string after the rest of the messages in the buffer.
# There is a 1 second gap between each message.
if self.waiting:
self.queue.append(string)
else:
self.waiting = True
self.sendImmediately(string)
self.__startPopTimer()
def sendImmediately(self, string):
# Sends the given string without buffering.
if not self.error:
try:
self.irc.send(bytes(string) + b"\r\n")
except socket.error, msg:
self.error = True
print "Output error", msg
print "Was sending \"" + string + "\""
def isInError(self):
return self.error
class ircInputBuffer:
# Keeps a record of the last line fragment received by the socket which is usually not a complete line.
# It is prepended onto the next block of data to make a complete line.
def __init__(self, irc):
self.buffer = ""
self.irc = irc
self.lines = []
def __recv(self):
# Receives new data from the socket and splits it into lines.
# Last (incomplete) line is kept for buffer purposes.
try:
data = self.buffer + self.irc.recv(4096)
except socket.error, msg:
raise socket.error, msg
self.lines += data.split(b"\r\n")
self.buffer = self.lines[len(self.lines) - 1]
self.lines = self.lines[:len(self.lines) - 1]
def getLine(self):
# Returns the next line of IRC received by the socket.
# Converts the received string to standard string format before returning.
while len(self.lines) == 0:
try:
self.__recv()
except socket.error, msg:
raise socket.error, msg
time.sleep(1);
line = self.lines[0]
self.lines = self.lines[1:]
return str(line)
class ircBot(threading.Thread):
def __init__(self, network, port, name, description):
threading.Thread.__init__(self)
self.keepGoing = True
self.name = name
self.desc = description
self.network = network
self.port = port
self.identifyNickCommands = []
self.identifyLock = False
self.binds = []
self.debug = False
# PRIVATE FUNCTIONS
def __identAccept(self, nick):
""" Executes all the callbacks that have been approved for this nick
"""
i = 0
while i < len(self.identifyNickCommands):
(nickName, accept, acceptParams, reject, rejectParams) = self.identifyNickCommands[i]
if nick == nickName:
accept(*acceptParams)
self.identifyNickCommands.pop(i)
else:
i += 1
def __identReject(self, nick):
# Calls the given "denied" callback for all functions called by that nick.
i = 0
while i < len(self.identifyNickCommands):
(nickName, accept, acceptParams, reject, rejectParams) = self.identifyNickCommands[i]
if nick == nickName:
reject(*rejectParams)
self.identifyNickCommands.pop(i)
else:
i += 1
def __callBind(self, msgtype, sender, headers, message):
# Calls the function associated with the given msgtype.
for (messageType, callback) in self.binds:
if (messageType == msgtype):
callback(sender, headers, message)
def __processLine(self, line):
# If a message comes from another user, it will have an @ symbol
if "@" in line:
# Location of the @ symbol in the line (proceeds sender's domain)
at = line.find("@")
# Location of the first gap, this immediately follows the sender's domain
gap = line[at:].find(" ") + at + 1
lastColon = line[gap+1:].find(":") + 2 + gap
else:
lastColon = line[1:].find(":") + 1
# Does most of the parsing of the line received from the IRC network.
# if there is no message to the line. ie. only one colon at the start of line
if ":" not in line[1:]:
headers = line[1:].strip().split(" ")
message = ""
else:
# Split everything up to the lastColon (ie. the headers)
headers = line[1:lastColon-1].strip().split(" ")
message = line[lastColon:]
sender = headers[0]
if len(headers) < 2:
self.__debugPrint("Unhelpful number of messages in message: \"" + line + "\"")
else:
if "!" in sender:
cut = headers[0].find('!')
if cut != -1:
sender = sender[:cut]
msgtype = headers[1]
if msgtype == "PRIVMSG" and message.startswith("\001ACTION ") and message.endswith("\001"):
msgtype = "ACTION"
message = message[8:-1]
self.__callBind(msgtype, sender, headers[2:], message)
else:
self.__debugPrint("[" + headers[1] + "] " + message)
if (headers[1] == "307" or headers[1] == "330") and len(headers) >= 4:
self.__identAccept(headers[3])
if headers[1] == "318" and len(headers) >= 4:
self.__identReject(headers[3])
#identifies the next user in the nick commands list
if len(self.identifyNickCommands) == 0:
self.identifyLock = False
else:
self.outBuf.sendBuffered("WHOIS " + self.identifyNickCommands[0][0])
self.__callBind(headers[1], sender, headers[2:], message)
def __debugPrint(self, s):
if self.debug:
print s
# PUBLIC FUNCTIONS
def ban(self, banMask, channel, reason):
self.__debugPrint("Banning " + banMask + "...")
self.outBuf.sendBuffered("MODE +b " + channel + " " + banMask)
self.kick(nick, channel, reason)
def bind(self, msgtype, callback):
# Check if the msgtype already exists
for i in xrange(0, len(self.binds)):
# Remove msgtype if it has already been "binded" to
if self.binds[i][0] == msgtype:
self.binds.remove(i)
self.binds.append((msgtype, callback))
def connect(self):
self.__debugPrint("Connecting...")
self.irc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.irc.connect((self.network, self.port))
self.inBuf = ircInputBuffer(self.irc)
self.outBuf = ircOutputBuffer(self.irc)
self.outBuf.sendBuffered("NICK " + self.name)
self.outBuf.sendBuffered("USER " + self.name + " " + self.name + " " + self.name + " :" + self.desc)
def debugging(self, state):
self.debug = state
def disconnect(self, qMessage):
self.__debugPrint("Disconnecting...")
self.outBuf.sendBuffered("QUIT :" + qMessage)
self.irc.close()
def identify(self, nick, approvedFunc, approvedParams, deniedFunc, deniedParams):
self.__debugPrint("Verifying " + nick + "...")
self.identifyNickCommands += [(nick, approvedFunc, approvedParams, deniedFunc, deniedParams)]
if not self.identifyLock:
self.outBuf.sendBuffered("WHOIS " + nick)
self.identifyLock = True
def joinchan(self, channel):
self.__debugPrint("Joining " + channel + "...")
self.outBuf.sendBuffered("JOIN " + channel)
def kick(self, nick, channel, reason):
self.__debugPrint("Kicking " + nick + "...")
self.outBuf.sendBuffered("KICK " + channel + " " + nick + " :" + reason)
def reconnect(self):
self.disconnect("Reconnecting")
self.__debugPrint("Pausing before reconnecting...")
time.sleep(5)
self.connect()
def run(self):
self.__debugPrint("Bot is now running.")
self.connect()
while self.keepGoing:
line = ""
while len(line) == 0:
try:
line = self.inBuf.getLine()
except socket.error, msg:
print "Input error", msg
self.reconnect()
if line.startswith("PING"):
self.outBuf.sendImmediately("PONG " + line.split()[1])
else:
self.__processLine(line)
if self.outBuf.isInError():
self.reconnect()
def say(self, recipient, message):
self.outBuf.sendBuffered("PRIVMSG " + recipient + " :" + message)
def send(self, string):
self.outBuf.sendBuffered(string)
def stop(self):
self.keepGoing = False
def unban(self, banMask, channel):
self.__debugPrint("Unbanning " + banMask + "...")
self.outBuf.sendBuffered("MODE -b " + channel + " " + banMask)
#=== Begin SETTINGS ===#
irc_server = "chat.freenode.net"
irc_port = 6667
irc_channel = '#example'
irc_nick = 'ExampleMCBot'
irc_realname = 'IRC to Minecraft bridge'
nickserv_account = '' #Leave blank on networks that don't support it
nickserv_password = ''
rcon_host = '127.0.0.1'
rcon_port = 25566
rcon_password = 'example'
#=== End SETTINGS ===#
from mcrcon import MCRcon
from ircbotframe import ircBot
import subprocess as sp
import signal, re, time, sys
import struct, socket
import traceback
import time
from logwatcher import LogWatcher
# basic sane defaults - the server should elaborate on this
chanmodes = ('b', 'k', 'l', 'imnpst')
prefix = {'o':'@', 'v':'+'}
ops = []
rcon = None
try:
rcon = MCRcon(rcon_host, rcon_port, rcon_password)
except socket.error as e:
print "Error connecting to RCon... Starting up anyway, will wait for Minecraft server to start."
def sig_proccess(signal, frame):
"Unused"
print("\nLeaving now...")
global running
running = False
#signal.signal(signal.SIGINT, sig_proccess)
# Define regexes
chatre = re.compile(r'(<.+> .+)')
serverre = re.compile(r'(\[(Server|Rcon)\] .+)')
actionre = re.compile(r'\* ([^ ]+ .+)$')
achievementre = re.compile(r'(([^ ]+) has just earned the achievement \[.+\]$)')
joinre = re.compile(r'([^ ]+) joined the game$')
quitre = re.compile(r'([^ ]+) left the game$')
deathre = re.compile(r'([^ ]+) ((blew up|burned to death|died|drowned|starved|suffocated)|whilst trying to escape|fell (from|into|off|out)|got finished off by|walked into a (.+) whilst|hit the ground too hard|tried to swim in lava|withered away|went up in flames|was (blown|burnt|doomed|fireballed|killed|knocked|pricked|pummeled|shot|slain|squashed|struck)).*')
formatre = re.compile(r'\xc2\xa7([0-9a-fk-or])')
ircformatre = re.compile(r'([\x02\x0f\x16\x1f]|\x03[0-9]{0,2})')
infore = re.compile(r'\[[^/]/INFO\]')
def notice(ibot, recipient, message):
ibot.send('NOTICE %s :%s' % (recipient, message))
def sub_format(m):
"""
Converts Minecraft format codes to IRC format codes.
Pass this function to re.sub() and it will use it to conditionally replace formatting codes.
"""
colormap = {
'0': '01', '1': '02', '2': '03', '3': '10',
'4': '05', '5': '06', '6': '07', '7': '14',
'8': '15', '9': '12', 'a': '09', 'b': '11',
'c': '04', 'd': '13', 'e': '08', 'f': '00',
}
formatmap = {'l': '\x02', 'n': '\x1f', 'o': '\x16', 'r': '\x0f'}
code = m.group(1)
if code in '0123456789abcdef':
return '\x03' + colormap[code]
elif code in 'lnor': return formatmap[code]
return ''
def sub_ircformat(m):
"""
Converts IRC format codes to Minecraft format codes.
Pass this function to re.sub() and it will use it to conditionally replace formatting codes.
"""
colormap = ['f', '0', '1', '2', 'c', '4', '5', '6', 'e', 'a', '4', 'b', '9', 'd', '7', '8']
formatmap = {'\x02':'l', '\x1f':'n', '\x16':'o', '\x0f':'r'}
code = m.group(1)
if code[0] == '\x03':
# color code
return '\xc2\xa7%s' % colormap(int(code[1:]) % 16)
else:
return '\xc2\xa7%s' % formatmap[code[0]]
def strip_color(text):
"Doesn't strip, rather converts Minecraft formatting to IRC formatting."
return formatre.sub(sub_format, text)
def strip_irc_color(text):
"Converts IRC formatting to IRC formatting. The 'strip' name is for legacy reasons."
return ircformatre.sub('', text)
def parseline(line):
"Does the heavy work of parsing incoming Minecraft log lines."
global rcon
if not botready: return
try:
head, content = line.strip().split(': ', 1)
head = head.split('] ')[1]
except IndexError:
return
except ValueError:
return
content = strip_color(content)
#if head != '[Server thread/INFO]': return
if not head.endswith('/INFO]'): return
m = chatre.match(content)
if m is not None:
print("Chat: "+m.group(0))
ibot.say(irc_channel,m.group(0))
return
m = serverre.match(content)
if m is not None:
print("Server: "+m.group(0))
ibot.say(irc_channel,m.group(0))
return
m = actionre.match(content)
if m is not None:
print("Action: "+m.group(0))
notice(ibot,irc_channel,m.group(0))
return
m = achievementre.match(content)
if m is not None:
print("Achievement: "+m.group(0))
notice(ibot,irc_channel,m.group(0))
return
m = deathre.match(content)
if m is not None:
print("Death: "+m.group(0))
notice(ibot,irc_channel,"Death: "+m.group(0))
return
m = joinre.match(content)
if m is not None:
print("Join: "+m.group(0))
notice(ibot,irc_channel,m.group(0))
return
m = quitre.match(content)
if m is not None:
print("Quit: "+m.group(0))
notice(ibot,irc_channel,m.group(0))
return
if content == 'Stopping server':
print("Detected server shutdown")
notice(ibot,irc_channel,'The Minecraft server is shutting down.')
return
if content.startswith('Starting minecraft server version'):
print("Detected server startup")
notice(ibot,irc_channel,'The Minecraft server is starting up, please wait...')
return
if content.startswith('RCON running'):
print("Server startup complete")
notice(ibot,irc_channel,'The Minecraft server is now running!')
# If the server just restarted, we need to reopen rcon
if rcon:
rcon.close()
rcon = MCRcon(rcon_host, rcon_port, rcon_password)
return
if content.startswith('[@:'):
# Ignore command block spam
return
print "Unknown: "+content
def try_send(cmd):
global rcon
try:
return rcon.send(cmd)
except socket.error as e:
# rcon isn't connected. Reconnect it and retry
rcon.close()
rcon = MCRcon(rcon_host, rcon_port, rcon_password)
return rcon.send(cmd)
except struct.error as e:
notice(ibot, irc_channel, 'No connection to the Minecraft server.')
traceback.print_exc()
except:
notice(ibot, irc_channel, 'Unknown error %s when trying to send message: "%s"' % (sys.exc_info()[0], repr(cmd)) )
traceback.print_exc()
def onIrcMsg(sender, headers, message):
"Handles PRIVMSG events from IRC (i.e. normal channel messages)."
global rcon
if not rcon: return
lmsg = message.lower()
if message.startswith('.'): return
if lmsg == '!players':
output = try_send("list")
if output is not None:
notice(ibot, irc_channel, output)
else:
notice(ibot, irc_channel, "Error getting online players!")
return
name = "\xc2\xa75%s\xc2\xa7r" % sender if sender in ops else "\xc2\xa73%s\xc2\xa7r" % sender
try_send('tellraw @a {text:"[IRC] ", color: blue, extra:[{text:"<%s> %s", color: white}]}' % (name, strip_irc_color(message)))
if ( lmsg.startswith('!kick') or lmsg.startswith('!ban') or lmsg.startswith('!pardon') ):
if sender in ops:
output = try_send(message[1:])
if output is not None:
notice(ibot, irc_channel, output)
else:
notice(ibot, irc_channel, "Error running command!")
else:
notice(ibot, irc_channel, "I'm sorry %s, I'm afraid I can't do that." % sender)
def onIrcAction(sender, headers, message):
"Handles CTCP ACTION messages embedded in PRIVMSGs: i.e. /me"
if not rcon: return
rcon.send('tellraw @a {text:"[IRC] ", color: blue, extra:[{text:"* %s %s", color: white}]}' % (sender, message))
def onIrcReady(sender, headers, message):
"Handles the end-of-MOTD message, which is received when login to IRC server is complete."
if nickserv_account:
# wait for Nickserv response before joining channel
ibot.say('NickServ', 'identify %s %s' % (nickserv_account, nickserv_password))
print 'Connected to server, identifying...'
else:
ibot.joinchan(irc_channel)
def onIrcNotice(sender, headers, message):
"Handles NOTICE messages, which is how NickServ communicates to us."
if sender.lower() == 'nickserv':
if 'now identified' in message or 'Password accepted' in message:
print 'Successfully identified, joining channel...'
ibot.joinchan(irc_channel)
def onIrcJoin(sender, headers, message):
"Handles JOIN messages, sent when someone (including ourselves) joins a channel."
print repr((sender, headers, message))
headers.append(message) # because UnrealIRCd is retarded
if sender == irc_nick and headers[0].lower() == irc_channel.lower():
global botready
botready = True
notice(ibot,irc_channel,"IRC bot started successfully")
def onIrcNick(sender, headers, message):
"Handles nick change messages. We use this to track changes to our own nick."
global irc_nick
if sender == irc_nick:
irc_nick = headers[0]
print 'My nickname was changed to ' + headers[0]
def onIrcNickInUse(sender, headers, message):
"Handles nick-in-use message, in case the nickname we chose is in use - this lets us choose another."
global irc_nick
# append underscores until we get lucky
irc_nick = irc_nick+'_'
ibot.send('NICK '+irc_nick)
def onIrcNames(sender, headers, message):
"Handles the list of channel inhabitants sent on channel join."
#print 'Names: ' + repr((sender, headers, message))
global ops
ops = []
if headers[2] == irc_channel:
names = message.split()
for name in names:
if name[0] == prefix['o']:
ops.append(name[1:])
if 'a' in prefix and name[0] == prefix['a']:
ops.append(name[1:])
if 'q' in prefix and name[0] == prefix['q']:
ops.append(name[1:])
print 'Set inital ops list: ' + ', '.join(ops)
def onIrcISupport(sender, headers, message):
"""Handles the RPL_ISUPPORT message sent by the server during login.
We need this to store some info about the server so we can properly handle MODE messages."""
global chanmodes, prefix
for item in headers[1:]:
if item.startswith('CHANMODES='):
chanmodes = tuple(item.split('=', 1)[1].split(','))
print "Server supports channel modes %s" % ','.join(chanmodes)
if item.startswith('PREFIX='):
prefix = dict(zip(*item.split('=', 1)[1][1:].split(')')))
print "Server supports prefixes %s" % ', '.join(["%s=%s"%(x,y) for x,y in prefix.items()])
def onIrcMode(sender, headers, message):
"Handle MODE messages, so we can track who is opped in the channel."
if headers[0] != irc_channel: return
plus = False
i = 2
for modechar in headers[1]:
if modechar == '+':
plus = True
continue
if modechar == '-':
plus = False
continue
if plus:
if modechar == 'o' and not headers[i] in ops:
ops.append(headers[i])
print "Added %s to ops" % headers[i]
else:
if modechar == 'o' and headers[i] in ops:
ops.remove(headers[i])
print "Removed %s from ops" % headers[i]
# work out if this mode char has a parameter based on what the server supports
if modechar in (''.join(prefix.keys())+chanmodes[0]+chanmodes[1]): i+=1
elif plus and modechar in chanmodes[2]: i+=1
def onIrcPart(sender, headers, message):
"Handle PART messages when someone leaves, so we can remove them from the ops list if opped."
print 'PART: ' + repr((sender, headers, message))
if headers[0] == irc_channel and sender in ops:
ops.remove(sender)
print "Removed %s from ops" % sender
def onIrcQuit(sender, headers, message):
"Handle QUIT messages when someone quits, so we can remove them from the ops list if opped."
print 'QUIT: ' + repr((sender, headers, message))
if sender in ops:
ops.remove(sender)
print "Removed %s from ops" % sender
botready = False
# Connect to the IRC server
ibot = ircBot(irc_server, irc_port, irc_nick, irc_realname)
# Register event handlers
ibot.bind('PRIVMSG', onIrcMsg)
ibot.bind('ACTION', onIrcAction)
ibot.bind('NOTICE', onIrcNotice)
ibot.bind('JOIN', onIrcJoin)
ibot.bind('NICK', onIrcNick)
ibot.bind('MODE', onIrcMode)
ibot.bind('PART', onIrcPart)
ibot.bind('QUIT', onIrcQuit)
ibot.bind('005', onIrcISupport)
ibot.bind('353', onIrcNames)
ibot.bind('376', onIrcReady)
ibot.bind('433', onIrcNickInUse)
#ibot.connect() # don't do this, ibot.start() does it automatically in a new thread
ibot.debugging(False)
# Launch IRC bot in a separate thread
ibot.start()
def process_log(filename, lines):
if 'latest' in filename:
for line in lines:
parseline(line)
lw = LogWatcher('logs/', process_log)
try:
lw.loop()
except KeyboardInterrupt:
pass
# Clean up log watcher
lw.close()
ibot.disconnect('Terminated by user.')
# Stop the bot, can't stop the rock
ibot.stop()
# Wait for the bot thread to terminate (ensures clean exit)
ibot.join()
# Close rcon connection
rcon.close()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment