diff --git a/.requirements.txt b/.requirements.txt index 2a325a6..febea1a 100644 --- a/.requirements.txt +++ b/.requirements.txt @@ -3,6 +3,7 @@ feedparser beautifulsoup4 chardet requests +discord # For development pylint >= 1.7.1 diff --git a/README.md b/README.md index 5cd542a..8c33ce2 100755 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ Marvin, an IRC bot ================== -[![Join the chat at https://gitter.im/mosbth/irc2phpbb](https://badges.gitter.im/mosbth/irc2phpbb.svg)](https://gitter.im/mosbth/irc2phpbb?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[![Join the chat at https://discord.gg/6qQATZjv](https://dcbadge.limes.pink/api/server/https://discord.gg/6qQATZjv?theme=default-inverted&compact=true)](https://discord.gg/6qQATZjv) [![Build Status](https://github.com/mosbth/irc2phpbb/actions/workflows/main.yml/badge.svg)](https://github.com/mosbth/irc2phpbb/actions) +======= Get a quick start by checking out the main script `main.py` and read on how to contribute. diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..ff14540 --- /dev/null +++ b/bot.py @@ -0,0 +1,42 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Module for the common base class for all Bots +""" + +import re + +class Bot(): + """Base class for things common between different protocols""" + def __init__(self): + self.CONFIG = {} + self.ACTIONS = [] + self.GENERAL_ACTIONS = [] + + def getConfig(self): + """Return the current configuration""" + return self.CONFIG + + def setConfig(self, config): + """Set the current configuration""" + self.CONFIG = config + + def registerActions(self, actions): + """Register actions to use""" + print("Adding actions:") + for action in actions: + print(" - " + action.__name__) + self.ACTIONS.extend(actions) + + def registerGeneralActions(self, actions): + """Register general actions to use""" + print("Adding general actions:") + for action in actions: + print(" - " + action.__name__) + self.GENERAL_ACTIONS.extend(actions) + + @staticmethod + def tokenize(message): + """Split a message into normalized tokens""" + return re.sub("[,.?:]", " ", message).strip().lower().split() diff --git a/discord_bot.py b/discord_bot.py new file mode 100644 index 0000000..83f6814 --- /dev/null +++ b/discord_bot.py @@ -0,0 +1,49 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Module for the Discord bot. + +Connecting, sending and receiving messages and doing custom actions. +""" + +import discord + +from bot import Bot + +class DiscordBot(discord.Client, Bot): + """Bot implementing the discord protocol""" + def __init__(self): + Bot.__init__(self) + self.CONFIG = { + "token": "" + } + intents = discord.Intents.default() + intents.message_content = True + discord.Client.__init__(self, intents=intents) + + def begin(self): + """Start the bot""" + self.run(self.CONFIG.get("token")) + + async def checkMarvinActions(self, message): + """Check if Marvin should perform any actions""" + words = self.tokenize(message.content) + if self.user.name.lower() in words: + for action in self.ACTIONS: + response = action(words) + if response: + await message.channel.send(response) + else: + for action in self.GENERAL_ACTIONS: + response = action(words) + if response: + await message.channel.send(response) + + async def on_message(self, message): + """Hook run on every message""" + print(f"#{message.channel.name} <{message.author}> {message.content}") + if message.author.name == self.user.name: + # don't react to own messages + return + await self.checkMarvinActions(message) diff --git a/irc_bot.py b/irc_bot.py new file mode 100755 index 0000000..65c9c1b --- /dev/null +++ b/irc_bot.py @@ -0,0 +1,240 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Module for the IRC bot. + +Connecting, sending and receiving messages and doing custom actions. + +Keeping a log and reading incoming material. +""" +from collections import deque +from datetime import datetime +import json +import os +import re +import shutil +import socket + +import chardet + +from bot import Bot + +class IrcBot(Bot): + """Bot implementing the IRC protocol""" + def __init__(self): + super().__init__() + self.CONFIG = { + "server": None, + "port": 6667, + "channel": None, + "nick": "marvin", + "realname": "Marvin The All Mighty dbwebb-bot", + "ident": None, + "irclogfile": "irclog.txt", + "irclogmax": 20, + "dirIncoming": "incoming", + "dirDone": "done", + "lastfm": None, + } + + # Socket for IRC server + self.SOCKET = None + + # Keep a log of the latest messages + self.IRCLOG = None + + + def connectToServer(self): + """Connect to the IRC Server""" + + # Create the socket & Connect to the server + server = self.CONFIG["server"] + port = self.CONFIG["port"] + + if server and port: + self.SOCKET = socket.socket() + print("Connecting: {SERVER}:{PORT}".format(SERVER=server, PORT=port)) + self.SOCKET.connect((server, port)) + else: + print("Failed to connect, missing server or port in configuration.") + return + + # Send the nick to server + nick = self.CONFIG["nick"] + if nick: + msg = 'NICK {NICK}\r\n'.format(NICK=nick) + self.sendMsg(msg) + else: + print("Ignore sending nick, missing nick in configuration.") + + # Present yourself + realname = self.CONFIG["realname"] + self.sendMsg('USER {NICK} 0 * :{REALNAME}\r\n'.format(NICK=nick, REALNAME=realname)) + + # This is my nick, i promise! + ident = self.CONFIG["ident"] + if ident: + self.sendMsg('PRIVMSG nick IDENTIFY {IDENT}\r\n'.format(IDENT=ident)) + else: + print("Ignore identifying with password, ident is not set.") + + # Join a channel + channel = self.CONFIG["channel"] + if channel: + self.sendMsg('JOIN {CHANNEL}\r\n'.format(CHANNEL=channel)) + else: + print("Ignore joining channel, missing channel name in configuration.") + + def sendPrivMsg(self, message, channel): + """Send and log a PRIV message""" + if channel == self.CONFIG["channel"]: + self.ircLogAppend(user=self.CONFIG["nick"].ljust(8), message=message) + + msg = "PRIVMSG {CHANNEL} :{MSG}\r\n".format(CHANNEL=channel, MSG=message) + self.sendMsg(msg) + + def sendMsg(self, msg): + """Send and occasionally print the message sent""" + print("SEND: " + msg.rstrip('\r\n')) + self.SOCKET.send(msg.encode()) + + def decode_irc(self, raw, preferred_encs=None): + """ + Do character detection. + You can send preferred encodings as a list through preferred_encs. + http://stackoverflow.com/questions/938870/python-irc-bot-and-encoding-issue + """ + if preferred_encs is None: + preferred_encs = ["UTF-8", "CP1252", "ISO-8859-1"] + + changed = False + enc = None + for enc in preferred_encs: + try: + res = raw.decode(enc) + changed = True + break + except Exception: + pass + + if not changed: + try: + enc = chardet.detect(raw)['encoding'] + res = raw.decode(enc) + except Exception: + res = raw.decode(enc, 'ignore') + + return res + + def receive(self): + """Read incoming message and guess encoding""" + try: + buf = self.SOCKET.recv(2048) + lines = self.decode_irc(buf) + lines = lines.split("\n") + buf = lines.pop() + except Exception as err: + print("Error reading incoming message. " + err) + + return lines + + def ircLogAppend(self, line=None, user=None, message=None): + """Read incoming message and guess encoding""" + if not user: + user = re.search(r"(?<=:)\w+", line[0]).group(0) + + if not message: + message = ' '.join(line[3:]).lstrip(':') + + self.IRCLOG.append({ + 'time': datetime.now().strftime("%H:%M").rjust(5), + 'user': user, + 'msg': message + }) + + def ircLogWriteToFile(self): + """Write IRClog to file""" + with open(self.CONFIG["irclogfile"], 'w', encoding="UTF-8") as f: + json.dump(list(self.IRCLOG), f, indent=2) + + def readincoming(self): + """ + Read all files in the directory incoming, send them as a message if + they exists and then move the file to directory done. + """ + if not os.path.isdir(self.CONFIG["dirIncoming"]): + return + + listing = os.listdir(self.CONFIG["dirIncoming"]) + + for infile in listing: + filename = os.path.join(self.CONFIG["dirIncoming"], infile) + + with open(filename, "r", encoding="UTF-8") as f: + for msg in f: + self.sendPrivMsg(msg, self.CONFIG["channel"]) + + try: + shutil.move(filename, self.CONFIG["dirDone"]) + except Exception: + os.remove(filename) + + def mainLoop(self): + """For ever, listen and answer to incoming chats""" + self.IRCLOG = deque([], self.CONFIG["irclogmax"]) + + while 1: + # Write irclog + self.ircLogWriteToFile() + + # Check in any in the incoming directory + self.readincoming() + + for line in self.receive(): + print(line) + words = line.strip().split() + + if not words: + continue + + self.checkIrcActions(words) + self.checkMarvinActions(words) + + def begin(self): + """Start the bot""" + self.connectToServer() + self.mainLoop() + + def checkIrcActions(self, words): + """ + Check if Marvin should take action on any messages defined in the + IRC protocol. + """ + if words[0] == "PING": + self.sendMsg("PONG {ARG}\r\n".format(ARG=words[1])) + + if words[1] == 'INVITE': + self.sendMsg('JOIN {CHANNEL}\r\n'.format(CHANNEL=words[3])) + + def checkMarvinActions(self, words): + """Check if Marvin should perform any actions""" + if words[1] == 'PRIVMSG' and words[2] == self.CONFIG["channel"]: + self.ircLogAppend(words) + + if words[1] == 'PRIVMSG': + raw = ' '.join(words[3:]) + row = self.tokenize(raw) + + if self.CONFIG["nick"] in row: + for action in self.ACTIONS: + msg = action(row) + if msg: + self.sendPrivMsg(msg, words[2]) + break + else: + for action in self.GENERAL_ACTIONS: + msg = action(row) + if msg: + self.sendPrivMsg(msg, words[2]) + break diff --git a/main.py b/main.py index f73d3c0..76d0e26 100755 --- a/main.py +++ b/main.py @@ -44,7 +44,9 @@ import os import sys -import marvin +from discord_bot import DiscordBot +from irc_bot import IrcBot + import marvin_actions import marvin_general_actions @@ -93,6 +95,7 @@ def parseOptions(options): """ parser = argparse.ArgumentParser() + parser.add_argument("protocol", choices=["irc", "discord"], nargs="?", default="irc") parser.add_argument("-v", "--version", action="store_true") parser.add_argument("--config") @@ -115,22 +118,40 @@ def parseOptions(options): return options +def determineProtocol(): + """Parse the argument to determine what protocol to use""" + parser = argparse.ArgumentParser() + parser.add_argument("protocol", choices=["irc", "discord"], nargs="?", default="irc") + arg, _ = parser.parse_known_args() + return arg.protocol + + +def createBot(protocol): + """Return an instance of a bot with the requested implementation""" + if protocol == "irc": + return IrcBot() + if protocol == "discord": + return DiscordBot() + raise ValueError(f"Unsupported protocol: {protocol}") + + def main(): """ Main function to carry out the work. """ - options = marvin.getConfig() + protocol = determineProtocol() + bot = createBot(protocol) + options = bot.getConfig() options.update(mergeOptionsWithConfigFile(options, "marvin_config.json")) config = parseOptions(options) - marvin.setConfig(config) + bot.setConfig(config) marvin_actions.setConfig(options) marvin_general_actions.setConfig(options) actions = marvin_actions.getAllActions() general_actions = marvin_general_actions.getAllGeneralActions() - marvin.registerActions(actions) - marvin.registerGeneralActions(general_actions) - marvin.connectToServer() - marvin.mainLoop() + bot.registerActions(actions) + bot.registerGeneralActions(general_actions) + bot.begin() sys.exit(0) diff --git a/marvin.py b/marvin.py deleted file mode 100755 index 4270e40..0000000 --- a/marvin.py +++ /dev/null @@ -1,300 +0,0 @@ -#! /usr/bin/env python3 -# -*- coding: utf-8 -*- - -""" -Module for the IRC bot. - -Connecting, sending and receiving messages and doing custom actions. - -Keeping a log and reading incoming material. -""" -from collections import deque -from datetime import datetime -import json -import os -import re -import shutil -import socket - -import chardet - - -# -# Settings -# -CONFIG = { - "server": None, - "port": 6667, - "channel": None, - "nick": "marvin", - "realname": "Marvin The All Mighty dbwebb-bot", - "ident": None, - "irclogfile": "irclog.txt", - "irclogmax": 20, - "dirIncoming": "incoming", - "dirDone": "done", - "lastfm": None, -} - - -# Socket for IRC server -SOCKET = None - -# All actions to check for incoming messages -ACTIONS = [] -GENERAL_ACTIONS = [] - -# Keep a log of the latest messages -IRCLOG = None - - -def getConfig(): - """ - Return the current configuration - """ - return CONFIG - - -def setConfig(config): - """ - Set the current configuration - """ - global CONFIG - CONFIG = config - - -def registerActions(actions): - """ - Register actions to use. - """ - print("Adding actions:") - for action in actions: - print(" - " + action.__name__) - ACTIONS.extend(actions) - -def registerGeneralActions(actions): - """ - Register general actions to use. - """ - print("Adding general actions:") - for action in actions: - print(" - " + action.__name__) - GENERAL_ACTIONS.extend(actions) - -def connectToServer(): - """ - Connect to the IRC Server - """ - global SOCKET - - # Create the socket & Connect to the server - server = CONFIG["server"] - port = CONFIG["port"] - - if server and port: - SOCKET = socket.socket() - print("Connecting: {SERVER}:{PORT}".format(SERVER=server, PORT=port)) - SOCKET.connect((server, port)) - else: - print("Failed to connect, missing server or port in configuration.") - return - - # Send the nick to server - nick = CONFIG["nick"] - if nick: - msg = 'NICK {NICK}\r\n'.format(NICK=nick) - sendMsg(msg) - else: - print("Ignore sending nick, missing nick in configuration.") - - # Present yourself - realname = CONFIG["realname"] - sendMsg('USER {NICK} 0 * :{REALNAME}\r\n'.format(NICK=nick, REALNAME=realname)) - - # This is my nick, i promise! - ident = CONFIG["ident"] - if ident: - sendMsg('PRIVMSG nick IDENTIFY {IDENT}\r\n'.format(IDENT=ident)) - else: - print("Ignore identifying with password, ident is not set.") - - # Join a channel - channel = CONFIG["channel"] - if channel: - sendMsg('JOIN {CHANNEL}\r\n'.format(CHANNEL=channel)) - else: - print("Ignore joining channel, missing channel name in configuration.") - - -def sendPrivMsg(message, channel): - """ - Send and log a PRIV message - """ - if channel == CONFIG["channel"]: - ircLogAppend(user=CONFIG["nick"].ljust(8), message=message) - - msg = "PRIVMSG {CHANNEL} :{MSG}\r\n".format(CHANNEL=channel, MSG=message) - sendMsg(msg) - - -def sendMsg(msg): - """ - Send and occasionally print the message sent. - """ - print("SEND: " + msg.rstrip('\r\n')) - SOCKET.send(msg.encode()) - - -def decode_irc(raw, preferred_encs=None): - """ - Do character detection. - You can send preferred encodings as a list through preferred_encs. - http://stackoverflow.com/questions/938870/python-irc-bot-and-encoding-issue - """ - if preferred_encs is None: - preferred_encs = ["UTF-8", "CP1252", "ISO-8859-1"] - - changed = False - enc = None - for enc in preferred_encs: - try: - res = raw.decode(enc) - changed = True - break - except Exception: - pass - - if not changed: - try: - enc = chardet.detect(raw)['encoding'] - res = raw.decode(enc) - except Exception: - res = raw.decode(enc, 'ignore') - - return res - - -def receive(): - """ - Read incoming message and guess encoding. - """ - try: - buf = SOCKET.recv(2048) - lines = decode_irc(buf) - lines = lines.split("\n") - buf = lines.pop() - except Exception as err: - print("Error reading incoming message. " + err) - - return lines - - -def ircLogAppend(line=None, user=None, message=None): - """ - Read incoming message and guess encoding. - """ - if not user: - user = re.search(r"(?<=:)\w+", line[0]).group(0) - - if not message: - message = ' '.join(line[3:]).lstrip(':') - - IRCLOG.append({ - 'time': datetime.now().strftime("%H:%M").rjust(5), - 'user': user, - 'msg': message - }) - - -def ircLogWriteToFile(): - """ - Write IRClog to file. - """ - with open(CONFIG["irclogfile"], 'w', encoding="UTF-8") as f: - #json.dump(list(IRCLOG), f, False, False, False, False, indent=2) - json.dump(list(IRCLOG), f, indent=2) - - -def readincoming(): - """ - Read all files in the directory incoming, send them as a message if - they exists and then move the file to directory done. - """ - if not os.path.isdir(CONFIG["dirIncoming"]): - return - - listing = os.listdir(CONFIG["dirIncoming"]) - - for infile in listing: - filename = os.path.join(CONFIG["dirIncoming"], infile) - - with open(filename, "r", encoding="UTF-8") as f: - for msg in f: - sendPrivMsg(msg, CONFIG["channel"]) - - try: - shutil.move(filename, CONFIG["dirDone"]) - except Exception: - os.remove(filename) - - -def mainLoop(): - """ - For ever, listen and answer to incoming chats. - """ - global IRCLOG - IRCLOG = deque([], CONFIG["irclogmax"]) - - while 1: - # Write irclog - ircLogWriteToFile() - - # Check in any in the incoming directory - readincoming() - - for line in receive(): - print(line) - words = line.strip().split() - - if not words: - continue - - checkIrcActions(words) - checkMarvinActions(words) - - -def checkIrcActions(words): - """ - Check if Marvin should take action on any messages defined in the - IRC protocol. - """ - if words[0] == "PING": - sendMsg("PONG {ARG}\r\n".format(ARG=words[1])) - - if words[1] == 'INVITE': - sendMsg('JOIN {CHANNEL}\r\n'.format(CHANNEL=words[3])) - - -def checkMarvinActions(words): - """ - Check if Marvin should perform any actions - """ - if words[1] == 'PRIVMSG' and words[2] == CONFIG["channel"]: - ircLogAppend(words) - - if words[1] == 'PRIVMSG': - raw = ' '.join(words[3:]) - row = re.sub('[,.?:]', ' ', raw).strip().lower().split() - - if CONFIG["nick"] in row: - for action in ACTIONS: - msg = action(row) - if msg: - sendPrivMsg(msg, words[2]) - break - else: - for action in GENERAL_ACTIONS: - msg = action(row) - if msg: - sendPrivMsg(msg, words[2]) - break diff --git a/marvin_actions.py b/marvin_actions.py index d4d528c..a565652 100644 --- a/marvin_actions.py +++ b/marvin_actions.py @@ -399,11 +399,6 @@ def commitStrip(randomize=False): return msg.format(url=url) -# elif ('latest' in row or 'senaste' in row or 'senast' in row) -# and ('forum' in row or 'forumet' in row): -# feed=feedparser.parse(FEED_FORUM) - - def marvinTimeToBBQ(row): """ Calcuate the time to next barbecue and print a appropriate msg @@ -494,7 +489,7 @@ def marvinNameday(row): """ Check current nameday """ - msg = getString("nameday", "nobody") + msg = None if any(r in row for r in ["nameday", "namnsdag"]): try: now = datetime.datetime.now() @@ -505,6 +500,8 @@ def marvinNameday(row): names = nameday_data["dagar"][0]["namnsdag"] if names: msg = getString("nameday", "somebody").format(",".join(names)) + else: + msg = getString("nameday", "nobody") except Exception: msg = getString("nameday", "error") return msg diff --git a/marvin_general_actions.py b/marvin_general_actions.py index 0731b09..0955859 100644 --- a/marvin_general_actions.py +++ b/marvin_general_actions.py @@ -15,7 +15,7 @@ # Configuration loaded CONFIG = None -lastDateGreeted = datetime.date.today() +lastDateGreeted = None def setConfig(config): """ diff --git a/test_config.py b/test_main.py similarity index 75% rename from test_config.py rename to test_main.py index 2341be2..ed8c0bb 100644 --- a/test_config.py +++ b/test_main.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- """ -Tests for reading, merging and parsing config +Tests for the main launcher """ import argparse @@ -12,7 +12,9 @@ import sys from unittest import TestCase -from main import mergeOptionsWithConfigFile, parseOptions, MSG_VERSION +from main import mergeOptionsWithConfigFile, parseOptions, determineProtocol, MSG_VERSION, createBot +from irc_bot import IrcBot +from discord_bot import DiscordBot class ConfigMergeTest(TestCase): @@ -131,8 +133,11 @@ class FormattingTest(TestCase): """Test the parameters that cause printouts""" USAGE = ("usage: main.py [-h] [-v] [--config CONFIG] [--server SERVER] [--port PORT] " - "[--channel CHANNEL] [--nick NICK] [--realname REALNAME] [--ident IDENT]\n") - OPTIONS = ("options:\n" + "[--channel CHANNEL] [--nick NICK] [--realname REALNAME] [--ident IDENT]\n" + " [{irc,discord}]\n") + + OPTIONS = ("positional arguments:\n {irc,discord}\n\n" + "options:\n" " -h, --help show this help message and exit\n" " -v, --version\n" " --config CONFIG\n" @@ -143,6 +148,7 @@ class FormattingTest(TestCase): " --realname REALNAME\n" " --ident IDENT") + @classmethod def setUpClass(cls): """Set the terminal width to 160 to prevent the tests from failing on small terminals""" @@ -191,9 +197,55 @@ def testUnhandledArgument(self): """Test that any argument gives an error""" with self.assertRaises(SystemExit) as e: s = io.StringIO() - expectedError = f"{self.USAGE}main.py: error: unrecognized arguments: arg\n" + expectedError = (f"{self.USAGE}main.py: error: argument protocol: " + "invalid choice: 'arg' (choose from 'irc', 'discord')\n") with contextlib.redirect_stderr(s): sys.argv = ["./main.py", "arg"] parseOptions(ConfigParseTest.SAMPLE_CONFIG) self.assertEqual(e.exception.code, 2) self.assertEqual(s.getvalue(), expectedError) + +class TestArgumentParsing(TestCase): + """Test parsing argument to determine whether to launch as irc or discord bot """ + def testDetermineDiscordProtocol(self): + """Test that the it's possible to give argument to start the bot as a discord bot""" + sys.argv = ["main.py", "discord"] + protocol = determineProtocol() + self.assertEqual(protocol, "discord") + + def testDetermineIRCProtocol(self): + """Test that the it's possible to give argument to start the bot as an irc bot""" + sys.argv = ["main.py", "irc"] + protocol = determineProtocol() + self.assertEqual(protocol, "irc") + + def testDetermineIRCProtocolisDefault(self): + """Test that if no argument is given, irc is the default""" + sys.argv = ["main.py"] + protocol = determineProtocol() + self.assertEqual(protocol, "irc") + + def testDetermineConfigThrowsOnInvalidProto(self): + """Test that determineProtocol throws error on unsupported protocols""" + sys.argv = ["main.py", "gopher"] + with self.assertRaises(SystemExit) as e: + determineProtocol() + self.assertEqual(e.exception.code, 2) + +class TestBotFactoryMethod(TestCase): + """Test that createBot returns expected instances of Bots""" + def testCreateIRCBot(self): + """Test that an irc bot can be created""" + bot = createBot("irc") + self.assertIsInstance(bot, IrcBot) + + def testCreateDiscordBot(self): + """Test that a discord bot can be created""" + bot = createBot("discord") + self.assertIsInstance(bot, DiscordBot) + + def testCreateUnsupportedProtocolThrows(self): + """Test that trying to create a bot with an unsupported protocol will throw exception""" + with self.assertRaises(ValueError) as e: + createBot("gopher") + self.assertEqual(str(e.exception), "Unsupported protocol: gopher") diff --git a/test_marvin_actions.py b/test_marvin_actions.py index 54fc5d6..3a63b9a 100644 --- a/test_marvin_actions.py +++ b/test_marvin_actions.py @@ -6,13 +6,13 @@ """ import json -import re from datetime import date from unittest import mock, TestCase import requests +from bot import Bot import marvin_actions import marvin_general_actions @@ -28,7 +28,7 @@ def setUpClass(cls): def executeAction(self, action, message): """Execute an action for a message and return the response""" - return action(re.sub('[,.?:]', ' ', message).strip().lower().split()) + return action(Bot.tokenize(message)) def assertActionOutput(self, action, message, expectedOutput): @@ -225,6 +225,10 @@ def testTimeToBBQ(self): self.assertBBQResponse(date(2024, 9, 13), date(2024, 9, 20), "week") self.assertBBQResponse(date(2024, 9, 4), date(2024, 9, 20), "base") + def testNameDayReaction(self): + """Test that marvin only responds to nameday when asked""" + self.assertActionSilent(marvin_actions.marvinNameday, "anything") + def testNameDayRequest(self): """Test that marvin sends a proper request for nameday info""" with mock.patch("marvin_actions.requests") as r: