Last active
February 20, 2023 05:21
-
-
Save TerrorBite/8ae23a576545b4f86ad3 to your computer and use it in GitHub Desktop.
Python IRC chat bridge for Vanilla Minecraft
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#=== 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