import threading import time from collections import namedtuple import channel import gameloop import random import re nickserv_pass = None irc_chan = None game_channel = None HelpEntry = namedtuple('HelpEntry', ['synopsis', 'desc', 'is_sub']) def chunk(l, n): assert 0 < n chunked = [] item = [] for i in l: if len(item) >= n: chunked.append(item) item = [] item.append(i) if len(item) > 0: chunked.append(item) return chunked class GameLoop(threading.Thread): def __init__(self, irc, chan, irc_chan): self.irc = irc self.chan = chan self.irc_chan = irc_chan threading.Thread.__init__(self) def send(self, message): message_parts = message.encode().split(b' ') line = [] line_len = 0 for part in message_parts: if len(part) + line_len > 440: self.irc.bot_response(self.irc_chan, b' '.join(line)) line = [] line_len = 0 line.append(part) line_len += len(part) + 1 if len(line) > 0: self.irc.bot_response(self.irc_chan, b' '.join(line)) def notice(self, recipient, message): recipient = recipient.encode() message_parts = message.encode().split(b' ') line = [] line_len = 0 for part in message_parts: if len(part) + line_len > 440: self.irc.send_raw(b'NOTICE %s :%s %s' % (recipient, self.irc_chan, b' '.join(line))) line = [] line_len = 0 line.append(part) line_len += len(part) + 1 if len(line) > 0: self.irc.send_raw(b'NOTICE %s :%s %s' % (recipient, self.irc_chan, b' '.join(line))) def get_event(self): event = self.chan.recv() return event def voice(self, nicks): if type(nicks) == str: nicks = [nicks] for nicks in chunk(nicks, 4): self.irc.send_raw(b'MODE %s +%s %s' % (self.irc_chan, b'v'*len(nicks), b' '.join(i.encode() for i in nicks))) def devoice(self, nicks): if type(nicks) == str: nicks = [nicks] for nicks in chunk(nicks, 4): self.irc.send_raw(b'MODE %s -%s %s' % (self.irc_chan, b'v'*len(nicks), b' '.join(i.encode() for i in nicks))) def run(self): try: gameloop.game(self.send, self.notice, self.voice, self.devoice, self.get_event) except Exception as err: self.send('Crash! (%s, %s)' % (type(err), repr(err))) finally: self.chan.close() def start_gameloop(irc): global game_channel, irc_chan if game_channel is not None: return chan = channel.Channel() GameLoop(irc, chan, irc_chan).start() game_channel = chan def stop_gameloop(): global game_channel if game_channel is None: return game_channel.send((gameloop.events.quit,)) game_channel = None def send_event(event): global game_channel game_channel.send(event) def usage(command): entries = { ('status',) : HelpEntry('!status', 'Show the game status.', False), ('ready',) : HelpEntry('!ready', 'Mark yourself as ready.', False), ('unready',) : HelpEntry('!unready', 'Mark yourself as not ready.', False), ('kill',) : HelpEntry('!kill', 'Stop the game.', False), ('leave',) : HelpEntry('!leave', 'Leave the game.', False), ('players',) : HelpEntry('!players', 'Show a list of players.', False), ('cards',) : HelpEntry('!cards', 'Show the cards in your hand.', False), ('origins',) : HelpEntry('!origins', 'Show the cardcast codes of the decks containing the cards that can be picked currently.', False), ('redeal',) : HelpEntry('!redeal', 'Remove all cards from your hand and redeal.', False), ('start',) : HelpEntry('!start []', 'Start a game with the specified preset. If no preset is given, use "default". Available presets are: default, empty, offtopia, offtopia-random', False), ('join',) : HelpEntry('!join []', 'Join the game with the specified message.', False), ('kick',) : HelpEntry('!kick ', 'Kick the specified player from the game.', False), ('card',) : HelpEntry('[!card] ...', 'Pick the specified cards. If multiple numbers are specified for a single pick, play one of them at random.', False), ('jape',) : HelpEntry('[!jape] ...', 'See !card.', False), ('deck',) : HelpEntry('!deck add | remove | list', 'Manage decks.', True), ('bot',) : HelpEntry('!bot add | remove', 'Manage bots.', True), ('limit',) : HelpEntry('!limit [ []]', 'Show or adjust the win limit. Type can be "r" for rounds and "p" for points.', False), ('help',) : HelpEntry('!help [command [subcommand]]', 'Show a synopsis and description for the specified command.', False), ('deck', 'add') : HelpEntry('!deck add | random', 'Add the deck with the specified namespace and code (or pick one randomly).', False), ('deck', 'remove') : HelpEntry('!deck remove ', 'Remove the deck with the namespace and code.', False), ('deck', 'list') : HelpEntry('!deck list', 'List selected decks.', False), ('bot', 'add') : HelpEntry('!bot add []', 'Add a bot of the specified type and name. If the name is omitted, name the bot after its type.', False), ('bot', 'remove') : HelpEntry('!bot remove ', 'Remove the specified bot.', False), } if type(command) == str: command = [command] if len(command) > 0: if command[0][0] == '!': command[0] = command[0][1:] if len(command) == 0: return ' '.join(sorted(set('!' + cmd[0] for cmd in entries.keys()))) elif len(command) > 2: return 'Uh, how did we get %i args?' % len(command) key = tuple(command) if key in entries: e = entries[key] return '%s: %s %s' % ('Subcommands' if e.is_sub else 'Usage', e.synopsis, e.desc) else: return 'No such command !%s' % ' '.join(command) def parse_command(message, nick, irc): def send(m): global irc_chan irc.bot_response(irc_chan, m) def arg(num, index = 1, at_least = False): nonlocal message if not at_least: if type(num) == int: num = [num] if len(message) - index not in num: send(usage(message[:index])) return None return message[index:] else: if len(message) - index < num: send(usage(message[:index])) return None return message[index:] def valid_choice(c): return re.fullmatch(r'\d+(,\d+)*', c) events = gameloop.events message = message.split() if len(message) == 0: return c = message[0] if c == '!status': if arg(0) is not None: send_event((events.status,)) elif c == '!start': args = arg([0, 1]) if args is not None: if len(args) == 0: send_event((events.start, nick)) else: send_event((events.start, nick, args[0])) elif c == '!ready': if arg(0) is not None: send_event((events.ready, nick)) elif c == '!unready': if arg(0) is not None: send_event((events.unready, nick)) elif c == '!kill': if arg(0) is not None: send_event((events.kill,)) elif c == '!join': if len(message) > 1: send_event((events.join, nick, ' '.join(message[1:]))) else: send_event((events.join, nick)) elif c == '!leave': if arg(0) is not None: send_event((events.leave, nick)) elif c == '!players': if arg(0) is not None: send_event((events.players,)) elif c == '!kick': args = arg(1) if args is not None: kickee, = args send_event((events.kick, nick, kickee)) elif c == '!deck': if len(message) < 2: send(usage('!deck')) return subc = message[1] if subc == 'add': args = arg(2, 2) if args is not None: namespace, code, = args if code == 'random': send_event((events.deck_add_random, namespace)) else: send_event((events.deck_add, namespace, code)) elif subc == 'remove': args = arg(2, 2) if args is not None: namespace, code, = args send_event((events.deck_remove, namespace, code)) elif subc == 'list': if arg(0, 2) is not None: send_event((events.deck_list,)) else: send(usage('!deck')) elif c == '!bot': if len(message) < 2: send(usage('!bot')) return subc = message[1] if subc == 'add': args = arg(1, 2, at_least = True) if args is not None: if len(args) > 1: bot_type, *name = args name = ' '.join(name) else: bot_type, = args name = bot_type if bot_type == 'rando': send_event((events.bot_add_rando, name)) else: send('Allowed bot types: rando') elif subc == 'remove': args = arg(1, 2, at_least = True) if args is not None: name = ' '.join(args) send_event((events.bot_remove, name)) else: send(usage('!bot')) elif c == '!limit': args = arg([0, 1, 2]) if args is None: return if len(args) == 0: send_event((events.limit,)) else: num = args[0] if not num.isdecimal(): send(usage('!limit')) return num = int(num) if len(args) == 2: limit_type = args[1] if limit_type == 'p' or limit_type == 'points': send_event((events.limit, gameloop.limit_types.points, num)) elif limit_type == 'r' or limit_type == 'rounds': send_event((events.limit, gameloop.limit_types.rounds, num)) else: send('Allowed limit types: p(oints), r(ounds)') else: send_event((events.limit, gameloop.limit_types.points, num)) elif c == '!card' or c == '!jape' or all(valid_choice(i) for i in message): if c == '!card' or c == '!jape': args = message[1:] else: args = message if not all(valid_choice(i) for i in args): send(usage('!card')) return def pick(c): if c.isdecimal(): return c else: return random.choice(c.split(',')) choices = [int(pick(i)) for i in args] send_event((events.card, nick, choices)) elif c == '!cards': if arg(0) is not None: send_event((events.cards, nick)) elif c == '!origins': if arg(0) is not None: send_event((events.origins, nick)) elif c == '!redeal': if arg(0) is not None: send_event((events.redeal, nick)) elif c == '!help': args = arg([0, 1, 2]) if args is not None: send(usage(args)) # initialize(*, config) # Called to initialize the IRC bot # Runs before even logger is brought up, and blocks further bringup until it's done # config is a configpatser.ConfigParser object containig contents of bot.conf def initialize(*, config): global nickserv_pass, irc_chan nickserv_pass = config['nickserv']['password'] irc_chan = config['server']['channels'].split()[0].encode() # on_connect(*, irc) # Called after IRC bot has connected and sent the USER/NICk commands but not yet attempted anything else # Called for every reconnect # Blocks the bot until it's done, including PING/PONG handling # irc is the IRC API object def on_connect(*, irc): global nickserv_pass if nickserv_pass != '': irc.msg(b'nickserv', b'IDENTIFY ' + nickserv_pass.encode()) time.sleep(30) # One day I will do this correctly. Today is not the day stop_gameloop() start_gameloop(irc) # on_quit(*, irc) # Called just before IRC bot sends QUIT # Blocks the bot until it's done, including PING/PONG handling # irc is the IRC API object def on_quit(*, irc): stop_gameloop() # handle_message(*, prefix, message, nick, channel, irc) # Called for PRIVMSGs. # prefix is the prefix at the start of the message, without the leading ':' # message is the contents of the message # nick is who sent the message # channel is where you should send the response (note: in queries nick == channel) # irc is the IRC API object # All strings are bytestrings def handle_message(*, prefix, message, nick, channel, irc): global irc_chan if channel == irc_chan: parse_command(message.decode(), nick.decode(), irc) # handle_nonmessage(*, prefix, command, arguments, irc) # Called for all other commands than PINGs and PRIVMSGs. # prefix is the prefix at the start of the message, without the leading ':' # command is the command or number code # arguments is rest of the arguments of the command, represented as a list. ':'-arguments are handled automatically # irc is the IRC API object # All strings are bytestrings def handle_nonmessage(*, prefix, command, arguments, irc): if command == b'NICK': old = prefix.split(b'!')[0].decode() new = arguments[0].decode() send_event((gameloop.events.nick_change, old, new)) elif command == b'PART' or command == b'QUIT': nick = prefix.split(b'!')[0].decode() send_event((gameloop.events.leave, nick)) elif command == b'KICK': nick = arguments[1].decode() send_event((gameloop.events.leave, nick))