diff --git a/idlerpg/ircplugin.py b/idlerpg/ircplugin.py new file mode 100644 index 0000000..ea0484f --- /dev/null +++ b/idlerpg/ircplugin.py @@ -0,0 +1,139 @@ +"""IRC support for managing and executing the IdleRPG game. + +SPDX-FileCopyrightText: © 2024 Brian S. Stephan +SPDX-License-Identifier: AGPL-3.0-or-later +""" +import logging +import threading +import time + +import irc.client +from django.db.utils import IntegrityError +from ircbot.lib import Plugin + +from idlerpg.models import Character, Game + +logger = logging.getLogger(__name__) + + +class IdleRPG(Plugin): + """Run a game of IdleRPG.""" + + SLEEP_BETWEEN_LEVEL_CHECKS = 1 + + LOGIN_COMMAND_PATTERN = r'^LOGIN\s+(?P\S+)\s+(?P\S+)$' + LOGOUT_COMMAND_PATTERN = r'^LOGOUT$' + REGISTER_COMMAND_PATTERN = r'^REGISTER\s+(?P\S+)\s+(?P\S+)\s+(?P.*)$' + REMOVEME_COMMAND_PATTERN = r'^REMOVEME$' + STATUS_COMMAND_PATTERN = r'^(STATUS|WHOAMI)$' + + def __init__(self, bot, connection, event): + """Initialize miscellaneous state stuff.""" + self.seen_hostmasks = set() + super().__init__(bot, connection, event) + + def handle_join(self, connection, event): + """Track a character as online if their player joins the game channel.""" + self.seen_hostmasks.add(event.source) + logger.info("Added %s to the set of seen hostmasks.", event.source) + + def handle_kick_penalty(self, connection, event): + """Penalize characters for their user getting kicked from the channel.""" + self.seen_hostmasks.discard(event.source) + logger.info("Removed %s from the set of seen hostmasks.", event.source) + return self._handle_generic_penalty_and_logout(event.source, event.target, 250, + "getting kicked from the game channel", + 'time_penalized_kicked') + + def handle_nick_penalty(self, connection, event): + """Penalize characters for changing their nick while in the channel.""" + # TODO: figure out how to update the character and seen hostmasks + return self._handle_generic_penalty_and_logout(event.source, event.target, 30, + "changing their nick", + 'time_penalized_nick_change') + + def handle_part_penalty(self, connection, event): + """Penalize characters for their user parting.""" + self.seen_hostmasks.discard(event.source) + logger.info("Removed %s from the set of seen hostmasks.", event.source) + return self._handle_generic_penalty_and_logout(event.source, event.target, 200, + "parting the game channel", + 'time_penalized_part') + + def handle_message_penalty(self, connection, event): + """Pay attention to messages in the game channel, and penalize talkers.""" + hostmask = event.source + + logger.debug("looking for %s to try to penalize them", hostmask) + try: + character = Character.objects.get(enabled=True, hostmask=hostmask) + logger.debug("found character %s", character) + message = event.arguments[0] + penalty = character.penalize(len(message), "sending a privmsg to the game channel") + character.time_penalized_privmsg += penalty + character.save() + except Character.DoesNotExist: + logger.debug("no character found for %s", hostmask) + return + + def handle_quit_penalty(self, connection, event): + """Penalize characters for their user quitting IRC.""" + self.seen_hostmasks.discard(event.source) + logger.info("Removed %s from the set of seen hostmasks.", event.source) + return self._handle_generic_penalty_and_logout(event.source, event.target, 20, + "quitting IRC", + 'time_penalized_quit') + + def handle_login(self, connection, event, match): + """Log in a character when requested, assuming they're online.""" + hostmask = event.source + + nick = irc.client.NickMask(hostmask).nick + try: + character = Character.objects.get(enabled=True, name=match.group('name')) + character.check_password(match.group('password')) + except (Character.DoesNotExist, ValueError): + return self.bot.reply(event, "The requested character does not exist, or has been disabled, " + "or your password does not match.") + + if hostmask not in self.seen_hostmasks: + return self.bot.reply(event, f"Please join {character.game.channel.name} before logging in.") + + if character.status != Character.CHARACTER_STATUS_OFFLINE: + return self.bot.reply(event, f"Cannot log in {character.name}, either they already are, " + "or they are in a state that cannot be modified.") + + character.log_in(match.group('password'), hostmask) + character.save() + self.bot.reply(None, f"{character}, is now online from nickname {nick}. " + f"Next level at {character.next_level_str()}.", + explicit_target=character.game.channel.name) + return self.bot.reply(event, f"{character} has been successfully logged in.") + + def _handle_generic_penalty_and_logout(self, hostmask: str, channel: str, penalty: int, reason: str, + penalty_log_attr: str): + """Penalize a character and log them out, for a provided reason. + + Args: + hostmask: the hostmask for the character in question + channel: the game channel the event occurred in + penalty: the penalty to apply + reason: the reason for the penalty + """ + logger.debug("looking for %s to try to penalize them", hostmask) + try: + character = Character.objects.get(enabled=True, hostmask=hostmask) + logger.debug("character found for %s", hostmask) + seconds = character.penalize(penalty, reason) + log = getattr(character, penalty_log_attr) + setattr(character, penalty_log_attr, log + seconds) + try: + character.log_out() + except ValueError: + logger.debug("tried to log out %s but they already were", character) + character.save() + except Character.DoesNotExist: + logger.debug("no character found for %s", hostmask) + + +plugin = IdleRPG diff --git a/tests/test_idlerpg_ircplugin.py b/tests/test_idlerpg_ircplugin.py new file mode 100644 index 0000000..2f76b1c --- /dev/null +++ b/tests/test_idlerpg_ircplugin.py @@ -0,0 +1,257 @@ +"""Test IdleRPG character operations. + +SPDX-FileCopyrightText: © 2024 Brian S. Stephan +SPDX-License-Identifier: AGPL-3.0-or-later +""" +import logging +import re +import unittest.mock as mock + +from django.test import TestCase +from django.utils import timezone + +from ircbot.models import IrcChannel, IrcServer +from idlerpg.ircplugin import IdleRPG +from idlerpg.models import Character, Game + + +class IrcPluginTest(TestCase): + """Test the IRC integration.""" + + fixtures = ['tests/fixtures/simple_character.json'] + + def setUp(self): + """Create common objects.""" + self.mock_bot = mock.MagicMock() + self.mock_connection = mock.MagicMock() + + self.mock_connection.get_nickname.return_value = 'test_bot' + self.mock_connection.server_config = IrcServer.objects.get(pk=1) + + self.plugin = IdleRPG(self.mock_bot, self.mock_connection, mock.MagicMock()) + self.game = Game.objects.get(pk=1) + + def test_kick_penalty(self): + """Test that if a character is kicked from the game channel, they get penalized.""" + mock_event = mock.MagicMock() + mock_event.source = 'bss!bss@test_kick_penalty' + mock_event.target = '#test' + mock_event.recursing = False + + # make a character so as to not disturb other tests + test_char = Character.objects.register('testkickpen', self.game, 'test', + 'bss!bss@test_kick_penalty', 'tester') + with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: + with mock.patch('idlerpg.models.Character.log_out') as mock_log_out: + self.plugin.handle_kick_penalty(self.mock_connection, mock_event) + + mock_penalize.assert_called_with(250, "getting kicked from the game channel") + mock_log_out.assert_called() + test_char = Character.objects.get(name='testkickpen') + assert test_char.time_penalized_kicked == 5 + + test_char.delete() + + def test_generic_penalty_no_penalty_for_unknown(self): + """Test that a kick from an unknown hostmask is ignored.""" + mock_event = mock.MagicMock() + mock_event.source = 'bss!bss@test_kick_penalty' + mock_event.target = '#test' + mock_event.recursing = False + + with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: + with mock.patch('idlerpg.models.Character.log_out') as mock_log_out: + self.plugin.handle_kick_penalty(self.mock_connection, mock_event) + + mock_penalize.assert_not_called() + mock_log_out.assert_not_called() + + def test_nick_penalty(self): + """Test that if a user changes their nick, they get penalized.""" + mock_event = mock.MagicMock() + mock_event.source = 'bss!bss@test_nick_penalty' + mock_event.target = '#test' + mock_event.recursing = False + + # make a character so as to not disturb other tests + test_char = Character.objects.register('testnickpen', self.game, 'test', + 'bss!bss@test_nick_penalty', 'tester') + with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: + with mock.patch('idlerpg.models.Character.log_out') as mock_log_out: + self.plugin.handle_nick_penalty(self.mock_connection, mock_event) + + mock_penalize.assert_called_with(30, "changing their nick") + mock_log_out.assert_called() + test_char = Character.objects.get(name='testnickpen') + assert test_char.time_penalized_nick_change == 5 + + test_char.delete() + + def test_part_penalty(self): + """Test that if a character parts from the game channel, they get penalized.""" + mock_event = mock.MagicMock() + mock_event.source = 'bss!bss@test_part_penalty' + mock_event.target = '#test' + mock_event.recursing = False + + # make a character so as to not disturb other tests + test_char = Character.objects.register('testpartpen', self.game, 'test', + 'bss!bss@test_part_penalty', 'tester') + with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: + with mock.patch('idlerpg.models.Character.log_out') as mock_log_out: + self.plugin.handle_part_penalty(self.mock_connection, mock_event) + + mock_penalize.assert_called_with(200, "parting the game channel") + mock_log_out.assert_called() + test_char = Character.objects.get(name='testpartpen') + assert test_char.time_penalized_part == 5 + + test_char.delete() + + def test_pubmsg_penalty(self): + """Test that if a character speaks in the game channel, they get penalized.""" + mock_event = mock.MagicMock() + mock_event.arguments = ['this is a test message'] + mock_event.source = 'bss!bss@test_pubmsg_penalty' + mock_event.target = '#test' + mock_event.recursing = False + + # make a character so as to not disturb other tests + test_char = Character.objects.register('testpubmsgpen', self.game, 'test', + 'bss!bss@test_pubmsg_penalty', 'tester') + with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: + self.plugin.handle_message_penalty(self.mock_connection, mock_event) + + # 22 is the len of the message + mock_penalize.assert_called_with(22, "sending a privmsg to the game channel") + test_char = Character.objects.get(name='testpubmsgpen') + assert test_char.time_penalized_privmsg == 5 + + test_char.delete() + + def test_pubmsg_no_penalty_for_unknown(self): + """Test that a speakerwith an unknown hostmask is ignored.""" + mock_event = mock.MagicMock() + mock_event.arguments = ['this is a test message'] + mock_event.source = 'bss!bss@some_user' + mock_event.target = '#test' + mock_event.recursing = False + + with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: + self.plugin.handle_message_penalty(self.mock_connection, mock_event) + + mock_penalize.assert_not_called() + + def test_join(self): + """Test that joiners update the seen hostmasks.""" + mock_event = mock.MagicMock() + mock_event.arguments = ['this is a test message'] + mock_event.source = 'test!test@test_join' + mock_event.target = '#test' + mock_event.recursing = False + + assert 'test!test@test_join' not in self.plugin.seen_hostmasks + self.plugin.handle_join(self.mock_connection, mock_event) + assert 'test!test@test_join' in self.plugin.seen_hostmasks + + def test_quit_penalty(self): + """Test that if a character quits from the game channel, they get penalized.""" + mock_event = mock.MagicMock() + mock_event.source = 'bss!bss@test_quit_penalty' + mock_event.target = '#test' + mock_event.recursing = False + + # make a character so as to not disturb other tests + test_char = Character.objects.register('testquitpen', self.game, 'test', + 'bss!bss@test_quit_penalty', 'tester') + with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: + with mock.patch('idlerpg.models.Character.log_out') as mock_log_out: + self.plugin.handle_quit_penalty(self.mock_connection, mock_event) + + mock_penalize.assert_called_with(20, "quitting IRC") + mock_log_out.assert_called() + test_char = Character.objects.get(name='testquitpen') + assert test_char.time_penalized_quit == 5 + + test_char.delete() + + def test_quit_penalty_even_if_logged_out(self): + """Test that the penalty applies even if the character is already logged out.""" + mock_event = mock.MagicMock() + mock_event.source = 'bss!bss@test_quit_penalty' + mock_event.target = '#test' + mock_event.recursing = False + + # make a character so as to not disturb other tests + test_char = Character.objects.register('testquitpen', self.game, 'test', + 'bss!bss@test_quit_penalty', 'tester') + with mock.patch('idlerpg.models.Character.penalize', return_value=5) as mock_penalize: + with mock.patch('idlerpg.models.Character.log_out', side_effect=ValueError) as mock_log_out: + self.plugin.handle_quit_penalty(self.mock_connection, mock_event) + + mock_penalize.assert_called_with(20, "quitting IRC") + mock_log_out.assert_called() + test_char = Character.objects.get(name='testquitpen') + assert test_char.time_penalized_quit == 5 + + test_char.delete() + + def test_login_character_not_found(self): + """Test the LOGIN command sent to the bot.""" + mock_event = mock.MagicMock() + mock_event.source = 'bss!bss@test_login' + mock_event.target = '#test' + mock_event.recursing = False + + match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'LOGIN nope bss') + self.plugin.handle_login(self.mock_connection, mock_event, match) + + self.mock_bot.reply.assert_called_once_with( + mock_event, + "The requested character does not exist, or has been disabled, or your password does not match." + ) + + def test_login_bad_password(self): + """Test the LOGIN command sent to the bot.""" + mock_event = mock.MagicMock() + mock_event.source = 'bss!bss@test_login' + mock_event.target = '#test' + mock_event.recursing = False + + match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'LOGIN bss nope') + self.plugin.handle_login(self.mock_connection, mock_event, match) + + self.mock_bot.reply.assert_called_once_with( + mock_event, + "The requested character does not exist, or has been disabled, or your password does not match." + ) + + def test_login_not_seen(self): + """Test the LOGIN command sent to the bot.""" + mock_event = mock.MagicMock() + mock_event.source = 'bss!bss@test_login' + mock_event.target = '#test' + mock_event.recursing = False + + match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'LOGIN bss bss') + self.plugin.handle_login(self.mock_connection, mock_event, match) + + self.mock_bot.reply.assert_called_once_with(mock_event, "Please join #test before logging in.") + + def test_login_not_logged_off(self): + """Test the LOGIN command sent to the bot.""" + mock_event = mock.MagicMock() + mock_event.source = 'bss!bss@test_login' + mock_event.target = '#test' + mock_event.recursing = False + + self.plugin.seen_hostmasks.add('bss!bss@test_login') + match = re.match(IdleRPG.LOGIN_COMMAND_PATTERN, 'LOGIN bss bss') + self.plugin.handle_login(self.mock_connection, mock_event, match) + + self.mock_bot.reply.assert_called_once_with( + mock_event, + "Cannot log in bss, either they already are, or they are in a state that cannot be modified." + ) + + self.plugin.seen_hostmasks.remove('bss!bss@test_login')