+ Coverage for bot.py: + 57% +
+ ++ 23 statements + + + +
++ « prev + ^ index + » next + + coverage.py v7.6.1, + created at 2024-10-03 20:19 +0200 +
+ +diff --git a/docs/coverage/.gitignore b/docs/coverage/.gitignore new file mode 100644 index 0000000..ccccf14 --- /dev/null +++ b/docs/coverage/.gitignore @@ -0,0 +1,2 @@ +# Created by coverage.py +* diff --git a/docs/coverage/bot_py.html b/docs/coverage/bot_py.html new file mode 100644 index 0000000..6c05f59 --- /dev/null +++ b/docs/coverage/bot_py.html @@ -0,0 +1,139 @@ + + +
+ ++ « prev + ^ index + » next + + coverage.py v7.6.1, + created at 2024-10-03 20:19 +0200 +
+ +1#! /usr/bin/env python3
+2# -*- coding: utf-8 -*-
+ +4"""
+5Module for the common base class for all Bots
+6"""
+ +8import re
+ +10class Bot():
+11 """Base class for things common between different protocols"""
+12 def __init__(self):
+13 self.CONFIG = {}
+14 self.ACTIONS = []
+15 self.GENERAL_ACTIONS = []
+ +17 def getConfig(self):
+18 """Return the current configuration"""
+19 return self.CONFIG
+ +21 def setConfig(self, config):
+22 """Set the current configuration"""
+23 self.CONFIG = config
+ +25 def registerActions(self, actions):
+26 """Register actions to use"""
+27 print("Adding actions:")
+28 for action in actions:
+29 print(" - " + action.__name__)
+30 self.ACTIONS.extend(actions)
+ +32 def registerGeneralActions(self, actions):
+33 """Register general actions to use"""
+34 print("Adding general actions:")
+35 for action in actions:
+36 print(" - " + action.__name__)
+37 self.GENERAL_ACTIONS.extend(actions)
+ +39 @staticmethod
+40 def tokenize(message):
+41 """Split a message into normalized tokens"""
+42 return re.sub("[,.?:]", " ", message).strip().lower().split()
++ coverage.py v7.6.1, + created at 2024-10-03 20:36 +0200 +
+File | +class | +statements | +missing | +excluded | +coverage | +
---|---|---|---|---|---|
bot.py | +Bot | +14 | +10 | +0 | +29% | +
bot.py | +(no class) | +9 | +0 | +0 | +100% | +
discord_bot.py | +DiscordBot | +20 | +15 | +0 | +25% | +
discord_bot.py | +(no class) | +7 | +0 | +0 | +100% | +
irc_bot.py | +IrcBot | +111 | +107 | +0 | +4% | +
irc_bot.py | +(no class) | +23 | +0 | +0 | +100% | +
main.py | +(no class) | +72 | +16 | +0 | +78% | +
marvin_actions.py | +(no class) | +303 | +64 | +0 | +79% | +
marvin_general_actions.py | +(no class) | +34 | +14 | +0 | +59% | +
test_main.py | +ConfigMergeTest | +13 | +0 | +0 | +100% | +
test_main.py | +ConfigParseTest | +23 | +0 | +0 | +100% | +
test_main.py | +FormattingTest | +28 | +0 | +0 | +100% | +
test_main.py | +TestArgumentParsing | +13 | +0 | +0 | +100% | +
test_main.py | +TestBotFactoryMethod | +7 | +0 | +0 | +100% | +
test_main.py | +(no class) | +45 | +0 | +0 | +100% | +
test_marvin_actions.py | +ActionTest | +160 | +0 | +0 | +100% | +
test_marvin_actions.py | +(no class) | +45 | +0 | +0 | +100% | +
Total | ++ | 927 | +226 | +0 | +76% | +
+ No items found using the specified filter. +
++ « prev + ^ index + » next + + coverage.py v7.6.1, + created at 2024-10-03 20:19 +0200 +
+ +1#! /usr/bin/env python3
+2# -*- coding: utf-8 -*-
+ +4"""
+5Module for the Discord bot.
+ +7Connecting, sending and receiving messages and doing custom actions.
+8"""
+ +10import discord
+ +12from bot import Bot
+ +14class DiscordBot(discord.Client, Bot):
+15 """Bot implementing the discord protocol"""
+16 def __init__(self):
+17 Bot.__init__(self)
+18 self.CONFIG = {
+19 "token": ""
+20 }
+21 intents = discord.Intents.default()
+22 intents.message_content = True
+23 discord.Client.__init__(self, intents=intents)
+ +25 def begin(self):
+26 """Start the bot"""
+27 self.run(self.CONFIG.get("token"))
+ +29 async def checkMarvinActions(self, message):
+30 """Check if Marvin should perform any actions"""
+31 words = self.tokenize(message.content)
+32 if self.user.name.lower() in words:
+33 for action in self.ACTIONS:
+34 response = action(words)
+35 if response:
+36 await message.channel.send(response)
+37 else:
+38 for action in self.GENERAL_ACTIONS:
+39 response = action(words)
+40 if response:
+41 await message.channel.send(response)
+ +43 async def on_message(self, message):
+44 """Hook run on every message"""
+45 print(f"#{message.channel.name} <{message.author}> {message.content}")
+46 if message.author.name == self.user.name:
+47 # don't react to own messages
+48 return
+49 await self.checkMarvinActions(message)
++ coverage.py v7.6.1, + created at 2024-10-03 20:36 +0200 +
++ No items found using the specified filter. +
++ coverage.py v7.6.1, + created at 2024-10-03 20:36 +0200 +
+File | +statements | +missing | +excluded | +coverage | +
---|---|---|---|---|
bot.py | +23 | +10 | +0 | +57% | +
discord_bot.py | +27 | +15 | +0 | +44% | +
irc_bot.py | +134 | +107 | +0 | +20% | +
main.py | +72 | +16 | +0 | +78% | +
marvin_actions.py | +303 | +64 | +0 | +79% | +
marvin_general_actions.py | +34 | +14 | +0 | +59% | +
test_main.py | +129 | +0 | +0 | +100% | +
test_marvin_actions.py | +205 | +0 | +0 | +100% | +
Total | +927 | +226 | +0 | +76% | +
+ No items found using the specified filter. +
++ « prev + ^ index + » next + + coverage.py v7.6.1, + created at 2024-10-03 20:19 +0200 +
+ +1#! /usr/bin/env python3
+2# -*- coding: utf-8 -*-
+ +4"""
+5Module for the IRC bot.
+ +7Connecting, sending and receiving messages and doing custom actions.
+ +9Keeping a log and reading incoming material.
+10"""
+11from collections import deque
+12from datetime import datetime
+13import json
+14import os
+15import re
+16import shutil
+17import socket
+ +19import chardet
+ +21from bot import Bot
+ +23class IrcBot(Bot):
+24 """Bot implementing the IRC protocol"""
+25 def __init__(self):
+26 super().__init__()
+27 self.CONFIG = {
+28 "server": None,
+29 "port": 6667,
+30 "channel": None,
+31 "nick": "marvin",
+32 "realname": "Marvin The All Mighty dbwebb-bot",
+33 "ident": None,
+34 "irclogfile": "irclog.txt",
+35 "irclogmax": 20,
+36 "dirIncoming": "incoming",
+37 "dirDone": "done",
+38 "lastfm": None,
+39 }
+ +41 # Socket for IRC server
+42 self.SOCKET = None
+ +44 # Keep a log of the latest messages
+45 self.IRCLOG = None
+ + +48 def connectToServer(self):
+49 """Connect to the IRC Server"""
+ +51 # Create the socket & Connect to the server
+52 server = self.CONFIG["server"]
+53 port = self.CONFIG["port"]
+ +55 if server and port:
+56 self.SOCKET = socket.socket()
+57 print("Connecting: {SERVER}:{PORT}".format(SERVER=server, PORT=port))
+58 self.SOCKET.connect((server, port))
+59 else:
+60 print("Failed to connect, missing server or port in configuration.")
+61 return
+ +63 # Send the nick to server
+64 nick = self.CONFIG["nick"]
+65 if nick:
+66 msg = 'NICK {NICK}\r\n'.format(NICK=nick)
+67 self.sendMsg(msg)
+68 else:
+69 print("Ignore sending nick, missing nick in configuration.")
+ +71 # Present yourself
+72 realname = self.CONFIG["realname"]
+73 self.sendMsg('USER {NICK} 0 * :{REALNAME}\r\n'.format(NICK=nick, REALNAME=realname))
+ +75 # This is my nick, i promise!
+76 ident = self.CONFIG["ident"]
+77 if ident:
+78 self.sendMsg('PRIVMSG nick IDENTIFY {IDENT}\r\n'.format(IDENT=ident))
+79 else:
+80 print("Ignore identifying with password, ident is not set.")
+ +82 # Join a channel
+83 channel = self.CONFIG["channel"]
+84 if channel:
+85 self.sendMsg('JOIN {CHANNEL}\r\n'.format(CHANNEL=channel))
+86 else:
+87 print("Ignore joining channel, missing channel name in configuration.")
+ +89 def sendPrivMsg(self, message, channel):
+90 """Send and log a PRIV message"""
+91 if channel == self.CONFIG["channel"]:
+92 self.ircLogAppend(user=self.CONFIG["nick"].ljust(8), message=message)
+ +94 msg = "PRIVMSG {CHANNEL} :{MSG}\r\n".format(CHANNEL=channel, MSG=message)
+95 self.sendMsg(msg)
+ +97 def sendMsg(self, msg):
+98 """Send and occasionally print the message sent"""
+99 print("SEND: " + msg.rstrip('\r\n'))
+100 self.SOCKET.send(msg.encode())
+ +102 def decode_irc(self, raw, preferred_encs=None):
+103 """
+104 Do character detection.
+105 You can send preferred encodings as a list through preferred_encs.
+106 http://stackoverflow.com/questions/938870/python-irc-bot-and-encoding-issue
+107 """
+108 if preferred_encs is None:
+109 preferred_encs = ["UTF-8", "CP1252", "ISO-8859-1"]
+ +111 changed = False
+112 enc = None
+113 for enc in preferred_encs:
+114 try:
+115 res = raw.decode(enc)
+116 changed = True
+117 break
+118 except Exception:
+119 pass
+ +121 if not changed:
+122 try:
+123 enc = chardet.detect(raw)['encoding']
+124 res = raw.decode(enc)
+125 except Exception:
+126 res = raw.decode(enc, 'ignore')
+ +128 return res
+ +130 def receive(self):
+131 """Read incoming message and guess encoding"""
+132 try:
+133 buf = self.SOCKET.recv(2048)
+134 lines = self.decode_irc(buf)
+135 lines = lines.split("\n")
+136 buf = lines.pop()
+137 except Exception as err:
+138 print("Error reading incoming message. " + err)
+ +140 return lines
+ +142 def ircLogAppend(self, line=None, user=None, message=None):
+143 """Read incoming message and guess encoding"""
+144 if not user:
+145 user = re.search(r"(?<=:)\w+", line[0]).group(0)
+ +147 if not message:
+148 message = ' '.join(line[3:]).lstrip(':')
+ +150 self.IRCLOG.append({
+151 'time': datetime.now().strftime("%H:%M").rjust(5),
+152 'user': user,
+153 'msg': message
+154 })
+ +156 def ircLogWriteToFile(self):
+157 """Write IRClog to file"""
+158 with open(self.CONFIG["irclogfile"], 'w', encoding="UTF-8") as f:
+159 json.dump(list(self.IRCLOG), f, indent=2)
+ +161 def readincoming(self):
+162 """
+163 Read all files in the directory incoming, send them as a message if
+164 they exists and then move the file to directory done.
+165 """
+166 if not os.path.isdir(self.CONFIG["dirIncoming"]):
+167 return
+ +169 listing = os.listdir(self.CONFIG["dirIncoming"])
+ +171 for infile in listing:
+172 filename = os.path.join(self.CONFIG["dirIncoming"], infile)
+ +174 with open(filename, "r", encoding="UTF-8") as f:
+175 for msg in f:
+176 self.sendPrivMsg(msg, self.CONFIG["channel"])
+ +178 try:
+179 shutil.move(filename, self.CONFIG["dirDone"])
+180 except Exception:
+181 os.remove(filename)
+ +183 def mainLoop(self):
+184 """For ever, listen and answer to incoming chats"""
+185 self.IRCLOG = deque([], self.CONFIG["irclogmax"])
+ +187 while 1:
+188 # Write irclog
+189 self.ircLogWriteToFile()
+ +191 # Check in any in the incoming directory
+192 self.readincoming()
+ +194 for line in self.receive():
+195 print(line)
+196 words = line.strip().split()
+ +198 if not words:
+199 continue
+ +201 self.checkIrcActions(words)
+202 self.checkMarvinActions(words)
+ +204 def begin(self):
+205 """Start the bot"""
+206 self.connectToServer()
+207 self.mainLoop()
+ +209 def checkIrcActions(self, words):
+210 """
+211 Check if Marvin should take action on any messages defined in the
+212 IRC protocol.
+213 """
+214 if words[0] == "PING":
+215 self.sendMsg("PONG {ARG}\r\n".format(ARG=words[1]))
+ +217 if words[1] == 'INVITE':
+218 self.sendMsg('JOIN {CHANNEL}\r\n'.format(CHANNEL=words[3]))
+ +220 def checkMarvinActions(self, words):
+221 """Check if Marvin should perform any actions"""
+222 if words[1] == 'PRIVMSG' and words[2] == self.CONFIG["channel"]:
+223 self.ircLogAppend(words)
+ +225 if words[1] == 'PRIVMSG':
+226 raw = ' '.join(words[3:])
+227 row = self.tokenize(raw)
+ +229 if self.CONFIG["nick"] in row:
+230 for action in self.ACTIONS:
+231 msg = action(row)
+232 if msg:
+233 self.sendPrivMsg(msg, words[2])
+234 break
+235 else:
+236 for action in self.GENERAL_ACTIONS:
+237 msg = action(row)
+238 if msg:
+239 self.sendPrivMsg(msg, words[2])
+240 break
++ « prev + ^ index + » next + + coverage.py v7.6.1, + created at 2024-10-03 20:19 +0200 +
+ +1#! /usr/bin/env python3
+2# -*- coding: utf-8 -*-
+ +4"""
+5An IRC bot that answers random questions, keeps a log from the IRC-chat,
+6easy to integrate in a webpage and montores a phpBB forum for latest topics
+7by loggin in to the forum and checking the RSS-feed.
+ +9You need to install additional modules.
+ +11# Install needed modules in local directory
+12pip3 install --target modules/ feedparser beautifulsoup4 chardet
+ +14Modules in modules/ will be loaded automatically. If you want to use a
+15different directory you can start the program like this instead:
+ +17PYTHONPATH=modules python3 main.py
+ +19# To get help
+20PYTHONPATH=modules python3 main.py --help
+ +22# Example of using options
+23--server=irc.bsnet.se --channel=#db-o-webb
+24--server=irc.bsnet.se --port=6667 --channel=#db-o-webb
+25--nick=marvin --ident=secret
+ +27# Configuration
+28Check out the file 'marvin_config_default.json' on how to configure, instead
+29of using cli-options. The default configfile is 'marvin_config.json' but you
+30can change that using cli-options.
+ +32# Make own actions
+33Check the file 'marvin_strings.json' for the file where most of the strings
+34are defined and check out 'marvin_actions.py' to see how to write your own
+35actions. Its just a small function.
+ +37# Read from incoming
+38Marvin reads messages from the incoming/ directory, if it exists, and writes
+39it out the the irc channel.
+40"""
+ +42import argparse
+43import json
+44import os
+45import sys
+ +47from discord_bot import DiscordBot
+48from irc_bot import IrcBot
+ +50import marvin_actions
+51import marvin_general_actions
+ +53#
+54# General stuff about this program
+55#
+56PROGRAM = "marvin"
+57AUTHOR = "Mikael Roos"
+58EMAIL = "mikael.t.h.roos@gmail.com"
+59VERSION = "0.3.0"
+60MSG_VERSION = "{program} version {version}.".format(program=PROGRAM, version=VERSION)
+ + + +64def printVersion():
+65 """
+66 Print version information and exit.
+67 """
+68 print(MSG_VERSION)
+69 sys.exit(0)
+ + +72def mergeOptionsWithConfigFile(options, configFile):
+73 """
+74 Read information from config file.
+75 """
+76 if os.path.isfile(configFile):
+77 with open(configFile, encoding="UTF-8") as f:
+78 data = json.load(f)
+ +80 options.update(data)
+81 res = json.dumps(options, sort_keys=True, indent=4, separators=(',', ': '))
+ +83 msg = "Read configuration from config file '{file}'. Current configuration is:\n{config}"
+84 print(msg.format(config=res, file=configFile))
+ +86 else:
+87 print("Config file '{file}' is not readable, skipping.".format(file=configFile))
+ +89 return options
+ + +92def parseOptions(options):
+93 """
+94 Merge default options with incoming options and arguments and return them as a dictionary.
+95 """
+ +97 parser = argparse.ArgumentParser()
+98 parser.add_argument("protocol", choices=["irc", "discord"], nargs="?", default="irc")
+99 parser.add_argument("-v", "--version", action="store_true")
+100 parser.add_argument("--config")
+ +102 for key, value in options.items():
+103 parser.add_argument(f"--{key}", type=type(value))
+ +105 args = vars(parser.parse_args())
+106 if args["version"]:
+107 printVersion()
+108 if args["config"]:
+109 mergeOptionsWithConfigFile(options, args["config"])
+ +111 for parameter in options:
+112 if args[parameter]:
+113 options[parameter] = args[parameter]
+ +115 res = json.dumps(options, sort_keys=True, indent=4, separators=(',', ': '))
+116 print("Configuration updated after cli options:\n{config}".format(config=res))
+ +118 return options
+ + +121def determineProtocol():
+122 """Parse the argument to determine what protocol to use"""
+123 parser = argparse.ArgumentParser()
+124 parser.add_argument("protocol", choices=["irc", "discord"], nargs="?", default="irc")
+125 arg, _ = parser.parse_known_args()
+126 return arg.protocol
+ + +129def createBot(protocol):
+130 """Return an instance of a bot with the requested implementation"""
+131 if protocol == "irc":
+132 return IrcBot()
+133 if protocol == "discord":
+134 return DiscordBot()
+135 raise ValueError(f"Unsupported protocol: {protocol}")
+ + +138def main():
+139 """
+140 Main function to carry out the work.
+141 """
+142 protocol = determineProtocol()
+143 bot = createBot(protocol)
+144 options = bot.getConfig()
+145 options.update(mergeOptionsWithConfigFile(options, "marvin_config.json"))
+146 config = parseOptions(options)
+147 bot.setConfig(config)
+148 marvin_actions.setConfig(options)
+149 marvin_general_actions.setConfig(options)
+150 actions = marvin_actions.getAllActions()
+151 general_actions = marvin_general_actions.getAllGeneralActions()
+152 bot.registerActions(actions)
+153 bot.registerGeneralActions(general_actions)
+154 bot.begin()
+ +156 sys.exit(0)
+ + +159if __name__ == "__main__":
+160 main()
++ « prev + ^ index + » next + + coverage.py v7.6.1, + created at 2024-10-03 20:19 +0200 +
+ +1#! /usr/bin/env python3
+2# -*- coding: utf-8 -*-
+ +4"""
+5Make actions for Marvin, one function for each action.
+6"""
+7from urllib.parse import quote_plus
+8from urllib.request import urlopen
+9import calendar
+10import datetime
+11import json
+12import random
+13import requests
+ +15from bs4 import BeautifulSoup
+ + +18def getAllActions():
+19 """
+20 Return all actions in an array.
+21 """
+22 return [
+23 marvinExplainShell,
+24 marvinGoogle,
+25 marvinLunch,
+26 marvinVideoOfToday,
+27 marvinWhoIs,
+28 marvinHelp,
+29 marvinSource,
+30 marvinBudord,
+31 marvinQuote,
+32 marvinStats,
+33 marvinIrcLog,
+34 marvinListen,
+35 marvinWeather,
+36 marvinSun,
+37 marvinSayHi,
+38 marvinSmile,
+39 marvinStrip,
+40 marvinTimeToBBQ,
+41 marvinBirthday,
+42 marvinNameday,
+43 marvinUptime,
+44 marvinStream,
+45 marvinPrinciple,
+46 marvinJoke,
+47 marvinCommit
+48 ]
+ + +51# Load all strings from file
+52with open("marvin_strings.json", encoding="utf-8") as f:
+53 STRINGS = json.load(f)
+ +55# Configuration loaded
+56CONFIG = None
+ +58def setConfig(config):
+59 """
+60 Keep reference to the loaded configuration.
+61 """
+62 global CONFIG
+63 CONFIG = config
+ + +66def getString(key, key1=None):
+67 """
+68 Get a string from the string database.
+69 """
+70 data = STRINGS[key]
+71 if isinstance(data, list):
+72 res = data[random.randint(0, len(data) - 1)]
+73 elif isinstance(data, dict):
+74 if key1 is None:
+75 res = data
+76 else:
+77 res = data[key1]
+78 if isinstance(res, list):
+79 res = res[random.randint(0, len(res) - 1)]
+80 elif isinstance(data, str):
+81 res = data
+ +83 return res
+ + +86def marvinSmile(row):
+87 """
+88 Make Marvin smile.
+89 """
+90 msg = None
+91 if any(r in row for r in ["smile", "le", "skratta", "smilies"]):
+92 smilie = getString("smile")
+93 msg = "{SMILE}".format(SMILE=smilie)
+94 return msg
+ + +97def wordsAfterKeyWords(words, keyWords):
+98 """
+99 Return all items in the words list after the first occurence
+100 of an item in the keyWords list.
+101 """
+102 kwIndex = []
+103 for kw in keyWords:
+104 if kw in words:
+105 kwIndex.append(words.index(kw))
+ +107 if not kwIndex:
+108 return None
+ +110 return words[min(kwIndex)+1:]
+ + +113def marvinGoogle(row):
+114 """
+115 Let Marvin present an url to google.
+116 """
+117 query = wordsAfterKeyWords(row, ["google", "googla"])
+118 if not query:
+119 return None
+ +121 searchStr = " ".join(query)
+122 url = "https://www.google.se/search?q="
+123 url += quote_plus(searchStr)
+124 msg = getString("google")
+125 return msg.format(url)
+ + +128def marvinExplainShell(row):
+129 """
+130 Let Marvin present an url to the service explain shell to
+131 explain a shell command.
+132 """
+133 query = wordsAfterKeyWords(row, ["explain", "förklara"])
+134 if not query:
+135 return None
+136 cmd = " ".join(query)
+137 url = "http://explainshell.com/explain?cmd="
+138 url += quote_plus(cmd, "/:")
+139 msg = getString("explainShell")
+140 return msg.format(url)
+ + +143def marvinSource(row):
+144 """
+145 State message about sourcecode.
+146 """
+147 msg = None
+148 if any(r in row for r in ["källkod", "source"]):
+149 msg = getString("source")
+ +151 return msg
+ + +154def marvinBudord(row):
+155 """
+156 What are the budord for Marvin?
+157 """
+158 msg = None
+159 if any(r in row for r in ["budord", "stentavla"]):
+160 if any(r in row for r in ["#1", "1"]):
+161 msg = getString("budord", "#1")
+162 elif any(r in row for r in ["#2", "2"]):
+163 msg = getString("budord", "#2")
+164 elif any(r in row for r in ["#3", "3"]):
+165 msg = getString("budord", "#3")
+166 elif any(r in row for r in ["#4", "4"]):
+167 msg = getString("budord", "#4")
+168 elif any(r in row for r in ["#5", "5"]):
+169 msg = getString("budord", "#5")
+ +171 return msg
+ + +174def marvinQuote(row):
+175 """
+176 Make a quote.
+177 """
+178 msg = None
+179 if any(r in row for r in ["quote", "citat", "filosofi", "filosofera"]):
+180 msg = getString("hitchhiker")
+ +182 return msg
+ + +185def videoOfToday():
+186 """
+187 Check what day it is and provide a url to a suitable video together with a greeting.
+188 """
+189 dayNum = datetime.date.weekday(datetime.date.today()) + 1
+190 msg = getString("weekdays", str(dayNum))
+191 video = getString("video-of-today", str(dayNum))
+ +193 if video:
+194 msg += " En passande video är " + video
+195 else:
+196 msg += " Jag har ännu ingen passande video för denna dagen."
+ +198 return msg
+ + +201def marvinVideoOfToday(row):
+202 """
+203 Show the video of today.
+204 """
+205 msg = None
+206 if any(r in row for r in ["idag", "dagens"]):
+207 if any(r in row for r in ["video", "youtube", "tube"]):
+208 msg = videoOfToday()
+ +210 return msg
+ + +213def marvinWhoIs(row):
+214 """
+215 Who is Marvin.
+216 """
+217 msg = None
+218 if all(r in row for r in ["vem", "är"]):
+219 msg = getString("whois")
+ +221 return msg
+ + +224def marvinHelp(row):
+225 """
+226 Provide a menu.
+227 """
+228 msg = None
+229 if any(r in row for r in ["hjälp", "help", "menu", "meny"]):
+230 msg = getString("menu")
+ +232 return msg
+ + +235def marvinStats(row):
+236 """
+237 Provide a link to the stats.
+238 """
+239 msg = None
+240 if any(r in row for r in ["stats", "statistik", "ircstats"]):
+241 msg = getString("ircstats")
+ +243 return msg
+ + +246def marvinIrcLog(row):
+247 """
+248 Provide a link to the irclog
+249 """
+250 msg = None
+251 if any(r in row for r in ["irc", "irclog", "log", "irclogg", "logg", "historik"]):
+252 msg = getString("irclog")
+ +254 return msg
+ + +257def marvinSayHi(row):
+258 """
+259 Say hi with a nice message.
+260 """
+261 msg = None
+262 if any(r in row for r in [
+263 "snälla", "hej", "tjena", "morsning", "morrn", "mår", "hallå",
+264 "halloj", "läget", "snäll", "duktig", "träna", "träning",
+265 "utbildning", "tack", "tacka", "tackar", "tacksam"
+266 ]):
+267 smile = getString("smile")
+268 hello = getString("hello")
+269 friendly = getString("friendly")
+270 msg = "{} {} {}".format(smile, hello, friendly)
+ +272 return msg
+ + +275def marvinLunch(row):
+276 """
+277 Help decide where to eat.
+278 """
+279 lunchOptions = {
+280 'stan centrum karlskrona kna': 'lunch-karlskrona',
+281 'ängelholm angelholm engelholm': 'lunch-angelholm',
+282 'hässleholm hassleholm': 'lunch-hassleholm',
+283 'malmö malmo malmoe': 'lunch-malmo',
+284 'göteborg goteborg gbg': 'lunch-goteborg'
+285 }
+ +287 if any(r in row for r in ["lunch", "mat", "äta", "luncha"]):
+288 lunchStr = getString('lunch-message')
+ +290 for keys, value in lunchOptions.items():
+291 if any(r in row for r in keys.split(" ")):
+292 return lunchStr.format(getString(value))
+ +294 return lunchStr.format(getString('lunch-bth'))
+ +296 return None
+ + +299def marvinListen(row):
+300 """
+301 Return music last listened to.
+302 """
+303 msg = None
+304 if any(r in row for r in ["lyssna", "lyssnar", "musik"]):
+ +306 if not CONFIG["lastfm"]:
+307 return getString("listen", "disabled")
+ +309 url = "http://ws.audioscrobbler.com/2.0/"
+ +311 try:
+312 params = dict(
+313 method="user.getrecenttracks",
+314 user=CONFIG["lastfm"]["user"],
+315 api_key=CONFIG["lastfm"]["apikey"],
+316 format="json",
+317 limit="1"
+318 )
+ +320 resp = requests.get(url=url, params=params, timeout=5)
+321 data = json.loads(resp.text)
+ +323 artist = data["recenttracks"]["track"][0]["artist"]["#text"]
+324 title = data["recenttracks"]["track"][0]["name"]
+325 link = data["recenttracks"]["track"][0]["url"]
+ +327 msg = getString("listen", "success").format(artist=artist, title=title, link=link)
+ +329 except Exception:
+330 msg = getString("listen", "failed")
+ +332 return msg
+ + +335def marvinSun(row):
+336 """
+337 Check when the sun goes up and down.
+338 """
+339 msg = None
+340 if any(r in row for r in ["sol", "solen", "solnedgång", "soluppgång"]):
+341 try:
+342 soup = BeautifulSoup(urlopen('http://www.timeanddate.com/sun/sweden/jonkoping'))
+343 spans = soup.find_all("span", {"class": "three"})
+344 sunrise = spans[0].text
+345 sunset = spans[1].text
+346 msg = getString("sun").format(sunrise, sunset)
+ +348 except Exception:
+349 msg = getString("sun-no")
+ +351 return msg
+ + +354def marvinWeather(row):
+355 """
+356 Check what the weather prognosis looks like.
+357 """
+358 msg = None
+359 if any(r in row for r in ["väder", "vädret", "prognos", "prognosen", "smhi"]):
+360 url = getString("smhi", "url")
+361 try:
+362 soup = BeautifulSoup(urlopen(url))
+363 msg = "{}. {}. {}".format(
+364 soup.h1.text,
+365 soup.h4.text,
+366 soup.h4.findNextSibling("p").text
+367 )
+ +369 except Exception:
+370 msg = getString("smhi", "failed")
+ +372 return msg
+ + +375def marvinStrip(row):
+376 """
+377 Get a comic strip.
+378 """
+379 msg = None
+380 if any(r in row for r in ["strip", "comic", "nöje", "paus"]):
+381 msg = commitStrip(randomize=any(r in row for r in ["rand", "random", "slump", "lucky"]))
+382 return msg
+ + +385def commitStrip(randomize=False):
+386 """
+387 Latest or random comic strip from CommitStrip.
+388 """
+389 msg = getString("commitstrip", "message")
+ +391 if randomize:
+392 first = getString("commitstrip", "first")
+393 last = getString("commitstrip", "last")
+394 rand = random.randint(first, last)
+395 url = getString("commitstrip", "urlPage") + str(rand)
+396 else:
+397 url = getString("commitstrip", "url")
+ +399 return msg.format(url=url)
+ + +402def marvinTimeToBBQ(row):
+403 """
+404 Calcuate the time to next barbecue and print a appropriate msg
+405 """
+406 msg = None
+407 if any(r in row for r in ["grilla", "grill", "grillcon", "bbq"]):
+408 url = getString("barbecue", "url")
+409 nextDate = nextBBQ()
+410 today = datetime.date.today()
+411 daysRemaining = (nextDate - today).days
+ +413 if daysRemaining == 0:
+414 msg = getString("barbecue", "today")
+415 elif daysRemaining == 1:
+416 msg = getString("barbecue", "tomorrow")
+417 elif 1 < daysRemaining < 14:
+418 msg = getString("barbecue", "week") % nextDate
+419 elif 14 < daysRemaining < 200:
+420 msg = getString("barbecue", "base") % nextDate
+421 else:
+422 msg = getString("barbecue", "eternity") % nextDate
+ +424 msg = url + ". " + msg
+425 return msg
+ +427def nextBBQ():
+428 """
+429 Calculate the next grillcon date after today
+430 """
+ +432 MAY = 5
+433 SEPTEMBER = 9
+ +435 after = datetime.date.today()
+436 spring = thirdFridayIn(after.year, MAY)
+437 if after <= spring:
+438 return spring
+ +440 autumn = thirdFridayIn(after.year, SEPTEMBER)
+441 if after <= autumn:
+442 return autumn
+ +444 return thirdFridayIn(after.year + 1, MAY)
+ + +447def thirdFridayIn(y, m):
+448 """
+449 Get the third Friday in a given month and year
+450 """
+451 THIRD = 2
+452 FRIDAY = -1
+ +454 # Start the weeks on saturday to prevent fridays from previous month
+455 cal = calendar.Calendar(firstweekday=calendar.SATURDAY)
+ +457 # Return the friday in the third week
+458 return cal.monthdatescalendar(y, m)[THIRD][FRIDAY]
+ + +461def marvinBirthday(row):
+462 """
+463 Check birthday info
+464 """
+465 msg = None
+466 if any(r in row for r in ["birthday", "födelsedag"]):
+467 try:
+468 url = getString("birthday", "url")
+469 soup = BeautifulSoup(urlopen(url), "html.parser")
+470 my_list = list()
+ +472 for ana in soup.findAll('a'):
+473 if ana.parent.name == 'strong':
+474 my_list.append(ana.getText())
+ +476 my_list.pop()
+477 my_strings = ', '.join(my_list)
+478 if not my_strings:
+479 msg = getString("birthday", "nobody")
+480 else:
+481 msg = getString("birthday", "somebody").format(my_strings)
+ +483 except Exception:
+484 msg = getString("birthday", "error")
+ +486 return msg
+ +488def marvinNameday(row):
+489 """
+490 Check current nameday
+491 """
+492 msg = None
+493 if any(r in row for r in ["nameday", "namnsdag"]):
+494 try:
+495 now = datetime.datetime.now()
+496 raw_url = "http://api.dryg.net/dagar/v2.1/{year}/{month}/{day}"
+497 url = raw_url.format(year=now.year, month=now.month, day=now.day)
+498 r = requests.get(url, timeout=5)
+499 nameday_data = r.json()
+500 names = nameday_data["dagar"][0]["namnsdag"]
+501 if names:
+502 msg = getString("nameday", "somebody").format(",".join(names))
+503 else:
+504 msg = getString("nameday", "nobody")
+505 except Exception:
+506 msg = getString("nameday", "error")
+507 return msg
+ +509def marvinUptime(row):
+510 """
+511 Display info about uptime tournament
+512 """
+513 msg = None
+514 if "uptime" in row:
+515 msg = getString("uptime", "info")
+516 return msg
+ +518def marvinStream(row):
+519 """
+520 Display info about stream
+521 """
+522 msg = None
+523 if any(r in row for r in ["stream", "streama", "ström", "strömma"]):
+524 msg = getString("stream", "info")
+525 return msg
+ +527def marvinPrinciple(row):
+528 """
+529 Display one selected software principle, or provide one as random
+530 """
+531 msg = None
+532 if any(r in row for r in ["principle", "princip", "principer"]):
+533 principles = getString("principle")
+534 principleKeys = list(principles.keys())
+535 matchedKeys = [k for k in row if k in principleKeys]
+536 if matchedKeys:
+537 msg = principles[matchedKeys.pop()]
+538 else:
+539 msg = principles[random.choice(principleKeys)]
+540 return msg
+ +542def getJoke():
+543 """
+544 Retrieves joke from api.chucknorris.io/jokes/random?category=dev
+545 """
+546 try:
+547 url = getString("joke", "url")
+548 r = requests.get(url, timeout=5)
+549 joke_data = r.json()
+550 return joke_data["value"]
+551 except Exception:
+552 return getString("joke", "error")
+ +554def marvinJoke(row):
+555 """
+556 Display a random Chuck Norris joke
+557 """
+558 msg = None
+559 if any(r in row for r in ["joke", "skämt", "chuck norris", "chuck", "norris"]):
+560 msg = getJoke()
+561 return msg
+ +563def getCommit():
+564 """
+565 Retrieves random commit message from whatthecommit.com/index.html
+566 """
+567 try:
+568 url = getString("commit", "url")
+569 r = requests.get(url, timeout=5)
+570 res = r.text.strip()
+571 return res
+572 except Exception:
+573 return getString("commit", "error")
+ +575def marvinCommit(row):
+576 """
+577 Display a random commit message
+578 """
+579 msg = None
+580 if any(r in row for r in ["commit", "-m"]):
+581 commitMsg = getCommit()
+582 msg = "Använd detta meddelandet: '{}'".format(commitMsg)
+583 return msg
++ « prev + ^ index + » next + + coverage.py v7.6.1, + created at 2024-10-03 20:19 +0200 +
+ +1#! /usr/bin/env python3
+2# -*- coding: utf-8 -*-
+ +4"""
+5Make general actions for Marvin, one function for each action.
+6"""
+7import datetime
+8import json
+9import random
+ +11# Load all strings from file
+12with open("marvin_strings.json", encoding="utf-8") as f:
+13 STRINGS = json.load(f)
+ +15# Configuration loaded
+16CONFIG = None
+ +18lastDateGreeted = None
+ +20def setConfig(config):
+21 """
+22 Keep reference to the loaded configuration.
+23 """
+24 global CONFIG
+25 CONFIG = config
+ + +28def getString(key, key1=None):
+29 """
+30 Get a string from the string database.
+31 """
+32 data = STRINGS[key]
+33 if isinstance(data, list):
+34 res = data[random.randint(0, len(data) - 1)]
+35 elif isinstance(data, dict):
+36 if key1 is None:
+37 res = data
+38 else:
+39 res = data[key1]
+40 if isinstance(res, list):
+41 res = res[random.randint(0, len(res) - 1)]
+42 elif isinstance(data, str):
+43 res = data
+ +45 return res
+ + +48def getAllGeneralActions():
+49 """
+50 Return all general actions as an array.
+51 """
+52 return [
+53 marvinMorning
+54 ]
+ + +57def marvinMorning(row):
+58 """
+59 Marvin says Good morning after someone else says it
+60 """
+61 msg = None
+62 phrases = [
+63 "morgon",
+64 "godmorgon",
+65 "god morgon",
+66 "morrn",
+67 "morn"
+68 ]
+ +70 morning_phrases = [
+71 "Godmorgon! :-)",
+72 "Morgon allesammans",
+73 "Morgon gott folk",
+74 "Guten morgen",
+75 "Morgon"
+76 ]
+ +78 global lastDateGreeted
+ +80 for phrase in phrases:
+81 if phrase in row:
+82 if lastDateGreeted != datetime.date.today():
+83 lastDateGreeted = datetime.date.today()
+84 msg = random.choice(morning_phrases)
+85 return msg
++ « prev + ^ index + » next + + coverage.py v7.6.1, + created at 2024-10-03 20:19 +0200 +
+ +1#! /usr/bin/env python3
+2# -*- coding: utf-8 -*-
+ +4"""
+5Tests for the main launcher
+6"""
+ +8import argparse
+9import contextlib
+10import io
+11import os
+12import sys
+13from unittest import TestCase
+ +15from main import mergeOptionsWithConfigFile, parseOptions, determineProtocol, MSG_VERSION, createBot
+16from irc_bot import IrcBot
+17from discord_bot import DiscordBot
+ + +20class ConfigMergeTest(TestCase):
+21 """Test merging a config file with a dict"""
+ +23 def assertMergedConfig(self, config, fileName, expected):
+24 """Merge dict with file and assert the result matches expected"""
+25 configFile = os.path.join("testConfigs", f"{fileName}.json")
+26 actualConfig = mergeOptionsWithConfigFile(config, configFile)
+27 self.assertEqual(actualConfig, expected)
+ + +30 def testEmpty(self):
+31 """Empty into empty should equal empty"""
+32 self.assertMergedConfig({}, "empty", {})
+ +34 def testAddSingleParameter(self):
+35 """Add a single parameter to an empty config"""
+36 new = {
+37 "single": "test"
+38 }
+39 expected = {
+40 "single": "test"
+41 }
+42 self.assertMergedConfig(new, "empty", expected)
+ +44 def testAddSingleParameterOverwrites(self):
+45 """Add a single parameter to a config that contains it already"""
+46 new = {
+47 "single": "test"
+48 }
+49 expected = {
+50 "single": "original"
+51 }
+52 self.assertMergedConfig(new, "single", expected)
+ +54 def testAddSingleParameterMerges(self):
+55 """Add a single parameter to a config that contains a different one"""
+56 new = {
+57 "new": "test"
+58 }
+59 expected = {
+60 "new" : "test",
+61 "single" : "original"
+62 }
+63 self.assertMergedConfig(new, "single", expected)
+ +65class ConfigParseTest(TestCase):
+66 """Test parsing options into a config"""
+ +68 SAMPLE_CONFIG = {
+69 "server": "localhost",
+70 "port": 6667,
+71 "channel": "#dbwebb",
+72 "nick": "marvin",
+73 "realname": "Marvin The All Mighty dbwebb-bot",
+74 "ident": "password"
+75 }
+ +77 CHANGED_CONFIG = {
+78 "server": "remotehost",
+79 "port": 1234,
+80 "channel": "#db-o-webb",
+81 "nick": "imposter",
+82 "realname": "where is marvin?",
+83 "ident": "identify"
+84 }
+ +86 def testOverrideHardcodedParameters(self):
+87 """Test that all the hard coded parameters can be overridden from commandline"""
+88 for parameter in ["server", "port", "channel", "nick", "realname", "ident"]:
+89 sys.argv = ["./main.py", f"--{parameter}", str(self.CHANGED_CONFIG.get(parameter))]
+90 actual = parseOptions(self.SAMPLE_CONFIG)
+91 self.assertEqual(actual.get(parameter), self.CHANGED_CONFIG.get(parameter))
+ +93 def testOverrideMultipleParameters(self):
+94 """Test that multiple parameters can be overridden from commandline"""
+95 sys.argv = ["./main.py", "--server", "dbwebb.se", "--port", "5432"]
+96 actual = parseOptions(self.SAMPLE_CONFIG)
+97 self.assertEqual(actual.get("server"), "dbwebb.se")
+98 self.assertEqual(actual.get("port"), 5432)
+ +100 def testOverrideWithFile(self):
+101 """Test that parameters can be overridden with the --config option"""
+102 configFile = os.path.join("testConfigs", "server.json")
+103 sys.argv = ["./main.py", "--config", configFile]
+104 actual = parseOptions(self.SAMPLE_CONFIG)
+105 self.assertEqual(actual.get("server"), "irc.dbwebb.se")
+ +107 def testOverridePrecedenceConfigFirst(self):
+108 """Test that proper precedence is considered. From most to least significant it should be:
+109 explicit parameter -> parameter in --config file -> default """
+ +111 configFile = os.path.join("testConfigs", "server.json")
+112 sys.argv = ["./main.py", "--config", configFile, "--server", "important.com"]
+113 actual = parseOptions(self.SAMPLE_CONFIG)
+114 self.assertEqual(actual.get("server"), "important.com")
+ +116 def testOverridePrecedenceParameterFirst(self):
+117 """Test that proper precedence is considered. From most to least significant it should be:
+118 explicit parameter -> parameter in --config file -> default """
+ +120 configFile = os.path.join("testConfigs", "server.json")
+121 sys.argv = ["./main.py", "--server", "important.com", "--config", configFile]
+122 actual = parseOptions(self.SAMPLE_CONFIG)
+123 self.assertEqual(actual.get("server"), "important.com")
+ +125 def testBannedParameters(self):
+126 """Don't allow config, help and version as parameters, as those options are special"""
+127 for bannedParameter in ["config", "help", "version"]:
+128 with self.assertRaises(argparse.ArgumentError):
+129 parseOptions({bannedParameter: "test"})
+ + +132class FormattingTest(TestCase):
+133 """Test the parameters that cause printouts"""
+ +135 USAGE = ("usage: main.py [-h] [-v] [--config CONFIG] [--server SERVER] [--port PORT] "
+136 "[--channel CHANNEL] [--nick NICK] [--realname REALNAME] [--ident IDENT]\n"
+137 " [{irc,discord}]\n")
+ +139 OPTIONS = ("positional arguments:\n {irc,discord}\n\n"
+140 "options:\n"
+141 " -h, --help show this help message and exit\n"
+142 " -v, --version\n"
+143 " --config CONFIG\n"
+144 " --server SERVER\n"
+145 " --port PORT\n"
+146 " --channel CHANNEL\n"
+147 " --nick NICK\n"
+148 " --realname REALNAME\n"
+149 " --ident IDENT")
+ + +152 @classmethod
+153 def setUpClass(cls):
+154 """Set the terminal width to 160 to prevent the tests from failing on small terminals"""
+155 os.environ["COLUMNS"] = "160"
+ + +158 def assertPrintOption(self, options, returnCode, output):
+159 """Assert that parseOptions returns a certain code and prints a certain output"""
+160 with self.assertRaises(SystemExit) as e:
+161 s = io.StringIO()
+162 with contextlib.redirect_stdout(s):
+163 sys.argv = ["./main.py"] + [options]
+164 parseOptions(ConfigParseTest.SAMPLE_CONFIG)
+165 self.assertEqual(e.exception.code, returnCode)
+166 self.assertEqual(s.getvalue(), output+"\n") # extra newline added by print()
+ + +169 def testHelpPrintout(self):
+170 """Test that a help is printed when providing the --help flag"""
+171 self.assertPrintOption("--help", 0, f"{self.USAGE}\n{self.OPTIONS}")
+ +173 def testHelpPrintoutShort(self):
+174 """Test that a help is printed when providing the -h flag"""
+175 self.assertPrintOption("-h", 0, f"{self.USAGE}\n{self.OPTIONS}")
+ +177 def testVersionPrintout(self):
+178 """Test that the version is printed when provided the --version flag"""
+179 self.assertPrintOption("--version", 0, MSG_VERSION)
+ +181 def testVersionPrintoutShort(self):
+182 """Test that the version is printed when provided the -v flag"""
+183 self.assertPrintOption("-v", 0, MSG_VERSION)
+ +185 def testUnhandledOption(self):
+186 """Test that unknown options gives an error"""
+187 with self.assertRaises(SystemExit) as e:
+188 s = io.StringIO()
+189 expectedError = f"{self.USAGE}main.py: error: unrecognized arguments: -g\n"
+190 with contextlib.redirect_stderr(s):
+191 sys.argv = ["./main.py", "-g"]
+192 parseOptions(ConfigParseTest.SAMPLE_CONFIG)
+193 self.assertEqual(e.exception.code, 2)
+194 self.assertEqual(s.getvalue(), expectedError)
+ +196 def testUnhandledArgument(self):
+197 """Test that any argument gives an error"""
+198 with self.assertRaises(SystemExit) as e:
+199 s = io.StringIO()
+200 expectedError = (f"{self.USAGE}main.py: error: argument protocol: "
+201 "invalid choice: 'arg' (choose from 'irc', 'discord')\n")
+202 with contextlib.redirect_stderr(s):
+203 sys.argv = ["./main.py", "arg"]
+204 parseOptions(ConfigParseTest.SAMPLE_CONFIG)
+205 self.assertEqual(e.exception.code, 2)
+206 self.assertEqual(s.getvalue(), expectedError)
+ +208class TestArgumentParsing(TestCase):
+209 """Test parsing argument to determine whether to launch as irc or discord bot """
+210 def testDetermineDiscordProtocol(self):
+211 """Test that the it's possible to give argument to start the bot as a discord bot"""
+212 sys.argv = ["main.py", "discord"]
+213 protocol = determineProtocol()
+214 self.assertEqual(protocol, "discord")
+ +216 def testDetermineIRCProtocol(self):
+217 """Test that the it's possible to give argument to start the bot as an irc bot"""
+218 sys.argv = ["main.py", "irc"]
+219 protocol = determineProtocol()
+220 self.assertEqual(protocol, "irc")
+ +222 def testDetermineIRCProtocolisDefault(self):
+223 """Test that if no argument is given, irc is the default"""
+224 sys.argv = ["main.py"]
+225 protocol = determineProtocol()
+226 self.assertEqual(protocol, "irc")
+ +228 def testDetermineConfigThrowsOnInvalidProto(self):
+229 """Test that determineProtocol throws error on unsupported protocols"""
+230 sys.argv = ["main.py", "gopher"]
+231 with self.assertRaises(SystemExit) as e:
+232 determineProtocol()
+233 self.assertEqual(e.exception.code, 2)
+ +235class TestBotFactoryMethod(TestCase):
+236 """Test that createBot returns expected instances of Bots"""
+237 def testCreateIRCBot(self):
+238 """Test that an irc bot can be created"""
+239 bot = createBot("irc")
+240 self.assertIsInstance(bot, IrcBot)
+ +242 def testCreateDiscordBot(self):
+243 """Test that a discord bot can be created"""
+244 bot = createBot("discord")
+245 self.assertIsInstance(bot, DiscordBot)
+ +247 def testCreateUnsupportedProtocolThrows(self):
+248 """Test that trying to create a bot with an unsupported protocol will throw exception"""
+249 with self.assertRaises(ValueError) as e:
+250 createBot("gopher")
+251 self.assertEqual(str(e.exception), "Unsupported protocol: gopher")
++ « prev + ^ index + » next + + coverage.py v7.6.1, + created at 2024-10-03 20:19 +0200 +
+ +1#! /usr/bin/env python3
+2# -*- coding: utf-8 -*-
+ +4"""
+5Tests for all Marvin actions
+6"""
+ +8import json
+ +10from datetime import date
+11from unittest import mock, TestCase
+ +13import requests
+ +15from bot import Bot
+16import marvin_actions
+17import marvin_general_actions
+ +19class ActionTest(TestCase):
+20 """Test Marvin actions"""
+21 strings = {}
+ +23 @classmethod
+24 def setUpClass(cls):
+25 with open("marvin_strings.json", encoding="utf-8") as f:
+26 cls.strings = json.load(f)
+ + +29 def executeAction(self, action, message):
+30 """Execute an action for a message and return the response"""
+31 return action(Bot.tokenize(message))
+ + +34 def assertActionOutput(self, action, message, expectedOutput):
+35 """Call an action on message and assert expected output"""
+36 actualOutput = self.executeAction(action, message)
+ +38 self.assertEqual(actualOutput, expectedOutput)
+ + +41 def assertActionSilent(self, action, message):
+42 """Call an action with provided message and assert no output"""
+43 self.assertActionOutput(action, message, None)
+ + +46 def assertStringsOutput(self, action, message, expectedoutputKey, subkey=None):
+47 """Call an action with provided message and assert the output is equal to DB"""
+48 expectedOutput = self.strings.get(expectedoutputKey)
+49 if subkey is not None:
+50 if isinstance(expectedOutput, list):
+51 expectedOutput = expectedOutput[subkey]
+52 else:
+53 expectedOutput = expectedOutput.get(subkey)
+54 self.assertActionOutput(action, message, expectedOutput)
+ + +57 def assertBBQResponse(self, todaysDate, bbqDate, expectedMessageKey):
+58 """Assert that the proper bbq message is returned, given a date"""
+59 url = self.strings.get("barbecue").get("url")
+60 message = self.strings.get("barbecue").get(expectedMessageKey)
+61 if isinstance(message, list):
+62 message = message[1]
+63 if expectedMessageKey in ["base", "week", "eternity"]:
+64 message = message % bbqDate
+ +66 with mock.patch("marvin_actions.datetime") as d:
+67 d.date.today.return_value = todaysDate
+68 with mock.patch("marvin_actions.random") as r:
+69 r.randint.return_value = 1
+70 expected = f"{url}. {message}"
+71 self.assertActionOutput(marvin_actions.marvinTimeToBBQ, "dags att grilla", expected)
+ + +74 def assertNameDayOutput(self, exampleFile, expectedOutput):
+75 """Assert that the proper nameday message is returned, given an inputfile"""
+76 with open(f"namedayFiles/{exampleFile}.json", "r", encoding="UTF-8") as f:
+77 response = requests.models.Response()
+78 response._content = str.encode(json.dumps(json.load(f)))
+79 with mock.patch("marvin_actions.requests") as r:
+80 r.get.return_value = response
+81 self.assertActionOutput(marvin_actions.marvinNameday, "nameday", expectedOutput)
+ +83 def assertJokeOutput(self, exampleFile, expectedOutput):
+84 """Assert that a joke is returned, given an input file"""
+85 with open(f"jokeFiles/{exampleFile}.json", "r", encoding="UTF-8") as f:
+86 response = requests.models.Response()
+87 response._content = str.encode(json.dumps(json.load(f)))
+88 with mock.patch("marvin_actions.requests") as r:
+89 r.get.return_value = response
+90 self.assertActionOutput(marvin_actions.marvinJoke, "joke", expectedOutput)
+ +92 def testSmile(self):
+93 """Test that marvin can smile"""
+94 with mock.patch("marvin_actions.random") as r:
+95 r.randint.return_value = 1
+96 self.assertStringsOutput(marvin_actions.marvinSmile, "le lite?", "smile", 1)
+97 self.assertActionSilent(marvin_actions.marvinSmile, "sur idag?")
+ +99 def testWhois(self):
+100 """Test that marvin responds to whois"""
+101 self.assertStringsOutput(marvin_actions.marvinWhoIs, "vem är marvin?", "whois")
+102 self.assertActionSilent(marvin_actions.marvinWhoIs, "vemär")
+ +104 def testGoogle(self):
+105 """Test that marvin can help google stuff"""
+106 with mock.patch("marvin_actions.random") as r:
+107 r.randint.return_value = 1
+108 self.assertActionOutput(
+109 marvin_actions.marvinGoogle,
+110 "kan du googla mos",
+111 "LMGTFY https://www.google.se/search?q=mos")
+112 self.assertActionOutput(
+113 marvin_actions.marvinGoogle,
+114 "kan du googla google mos",
+115 "LMGTFY https://www.google.se/search?q=google+mos")
+116 self.assertActionSilent(marvin_actions.marvinGoogle, "du kan googla")
+117 self.assertActionSilent(marvin_actions.marvinGoogle, "gogool")
+ +119 def testExplainShell(self):
+120 """Test that marvin can explain shell commands"""
+121 url = "http://explainshell.com/explain?cmd=pwd"
+122 self.assertActionOutput(marvin_actions.marvinExplainShell, "explain pwd", url)
+123 self.assertActionOutput(marvin_actions.marvinExplainShell, "can you explain pwd", url)
+124 self.assertActionOutput(
+125 marvin_actions.marvinExplainShell,
+126 "förklara pwd|grep -o $user",
+127 f"{url}%7Cgrep+-o+%24user")
+ +129 self.assertActionSilent(marvin_actions.marvinExplainShell, "explains")
+ +131 def testSource(self):
+132 """Test that marvin responds to questions about source code"""
+133 self.assertStringsOutput(marvin_actions.marvinSource, "source", "source")
+134 self.assertStringsOutput(marvin_actions.marvinSource, "källkod", "source")
+135 self.assertActionSilent(marvin_actions.marvinSource, "opensource")
+ +137 def testBudord(self):
+138 """Test that marvin knows all the commandments"""
+139 for n in range(1, 5):
+140 self.assertStringsOutput(marvin_actions.marvinBudord, f"budord #{n}", "budord", f"#{n}")
+ +142 self.assertStringsOutput(marvin_actions.marvinBudord,"visa stentavla 1", "budord", "#1")
+143 self.assertActionSilent(marvin_actions.marvinBudord, "var är stentavlan?")
+ +145 def testQuote(self):
+146 """Test that marvin can quote The Hitchhikers Guide to the Galaxy"""
+147 with mock.patch("marvin_actions.random") as r:
+148 r.randint.return_value = 1
+149 self.assertStringsOutput(marvin_actions.marvinQuote, "ge os ett citat", "hitchhiker", 1)
+150 self.assertStringsOutput(marvin_actions.marvinQuote, "filosofi", "hitchhiker", 1)
+151 self.assertStringsOutput(marvin_actions.marvinQuote, "filosofera", "hitchhiker", 1)
+152 self.assertActionSilent(marvin_actions.marvinQuote, "noquote")
+ +154 for i,_ in enumerate(self.strings.get("hitchhiker")):
+155 r.randint.return_value = i
+156 self.assertStringsOutput(marvin_actions.marvinQuote, "quote", "hitchhiker", i)
+ +158 def testVideoOfToday(self):
+159 """Test that marvin can link to a different video each day of the week"""
+160 with mock.patch("marvin_actions.datetime") as dt:
+161 for d in range(1, 8):
+162 dt.date.weekday.return_value = d - 1
+163 day = self.strings.get("weekdays").get(str(d))
+164 video = self.strings.get("video-of-today").get(str(d))
+165 response = f"{day} En passande video är {video}"
+166 self.assertActionOutput(marvin_actions.marvinVideoOfToday, "dagens video", response)
+167 self.assertActionSilent(marvin_actions.marvinVideoOfToday, "videoidag")
+ +169 def testHelp(self):
+170 """Test that marvin can provide a help menu"""
+171 self.assertStringsOutput(marvin_actions.marvinHelp, "help", "menu")
+172 self.assertActionSilent(marvin_actions.marvinHelp, "halp")
+ +174 def testStats(self):
+175 """Test that marvin can provide a link to the IRC stats page"""
+176 self.assertStringsOutput(marvin_actions.marvinStats, "stats", "ircstats")
+177 self.assertActionSilent(marvin_actions.marvinStats, "statistics")
+ +179 def testIRCLog(self):
+180 """Test that marvin can provide a link to the IRC log"""
+181 self.assertStringsOutput(marvin_actions.marvinIrcLog, "irc", "irclog")
+182 self.assertActionSilent(marvin_actions.marvinIrcLog, "ircstats")
+ +184 def testSayHi(self):
+185 """Test that marvin responds to greetings"""
+186 with mock.patch("marvin_actions.random") as r:
+187 for skey, s in enumerate(self.strings.get("smile")):
+188 for hkey, h in enumerate(self.strings.get("hello")):
+189 for fkey, f in enumerate(self.strings.get("friendly")):
+190 r.randint.side_effect = [skey, hkey, fkey]
+191 self.assertActionOutput(marvin_actions.marvinSayHi, "hej", f"{s} {h} {f}")
+192 self.assertActionSilent(marvin_actions.marvinSayHi, "korsning")
+ +194 def testLunchLocations(self):
+195 """Test that marvin can provide lunch suggestions for certain places"""
+196 locations = ["karlskrona", "goteborg", "angelholm", "hassleholm", "malmo"]
+197 with mock.patch("marvin_actions.random") as r:
+198 for location in locations:
+199 for index, place in enumerate(self.strings.get(f"lunch-{location}")):
+200 r.randint.side_effect = [0, index]
+201 self.assertActionOutput(
+202 marvin_actions.marvinLunch, f"mat {location}", f"Ska vi ta {place}?")
+203 r.randint.side_effect = [1, 2]
+204 self.assertActionOutput(
+205 marvin_actions.marvinLunch, "dags att luncha", "Jag är lite sugen på Indiska?")
+206 self.assertActionSilent(marvin_actions.marvinLunch, "matdags")
+ +208 def testStrip(self):
+209 """Test that marvin can recommend comics"""
+210 messageFormat = self.strings.get("commitstrip").get("message")
+211 expected = messageFormat.format(url=self.strings.get("commitstrip").get("url"))
+212 self.assertActionOutput(marvin_actions.marvinStrip, "lite strip kanske?", expected)
+213 self.assertActionSilent(marvin_actions.marvinStrip, "nostrip")
+ +215 def testRandomStrip(self):
+216 """Test that marvin can recommend random comics"""
+217 messageFormat = self.strings.get("commitstrip").get("message")
+218 expected = messageFormat.format(url=self.strings.get("commitstrip").get("urlPage") + "123")
+219 with mock.patch("marvin_actions.random") as r:
+220 r.randint.return_value = 123
+221 self.assertActionOutput(marvin_actions.marvinStrip, "random strip kanske?", expected)
+ +223 def testTimeToBBQ(self):
+224 """Test that marvin knows when the next BBQ is"""
+225 self.assertBBQResponse(date(2024, 5, 17), date(2024, 5, 17), "today")
+226 self.assertBBQResponse(date(2024, 5, 16), date(2024, 5, 17), "tomorrow")
+227 self.assertBBQResponse(date(2024, 5, 10), date(2024, 5, 17), "week")
+228 self.assertBBQResponse(date(2024, 5, 1), date(2024, 5, 17), "base")
+229 self.assertBBQResponse(date(2023, 10, 17), date(2024, 5, 17), "eternity")
+ +231 self.assertBBQResponse(date(2024, 9, 20), date(2024, 9, 20), "today")
+232 self.assertBBQResponse(date(2024, 9, 19), date(2024, 9, 20), "tomorrow")
+233 self.assertBBQResponse(date(2024, 9, 13), date(2024, 9, 20), "week")
+234 self.assertBBQResponse(date(2024, 9, 4), date(2024, 9, 20), "base")
+ +236 def testNameDayReaction(self):
+237 """Test that marvin only responds to nameday when asked"""
+238 self.assertActionSilent(marvin_actions.marvinNameday, "anything")
+ +240 def testNameDayRequest(self):
+241 """Test that marvin sends a proper request for nameday info"""
+242 with mock.patch("marvin_actions.requests") as r:
+243 with mock.patch("marvin_actions.datetime") as d:
+244 d.datetime.now.return_value = date(2024, 1, 2)
+245 self.executeAction(marvin_actions.marvinNameday, "namnsdag")
+246 self.assertEqual(r.get.call_args.args[0], "http://api.dryg.net/dagar/v2.1/2024/1/2")
+ +248 def testNameDayResponse(self):
+249 """Test that marvin properly parses nameday responses"""
+250 self.assertNameDayOutput("single", "Idag har Svea namnsdag")
+251 self.assertNameDayOutput("double", "Idag har Alfred,Alfrida namnsdag")
+252 self.assertNameDayOutput("nobody", "Ingen har namnsdag idag")
+ +254 def testJokeRequest(self):
+255 """Test that marvin sends a proper request for a joke"""
+256 with mock.patch("marvin_actions.requests") as r:
+257 self.executeAction(marvin_actions.marvinJoke, "joke")
+258 self.assertEqual(r.get.call_args.args[0], "https://api.chucknorris.io/jokes/random?category=dev")
+ +260 def testJoke(self):
+261 """Test that marvin sends a joke when requested"""
+262 self.assertJokeOutput("joke", "There is no Esc key on Chuck Norris' keyboard, because no one escapes Chuck Norris.")
+ +264 def testUptime(self):
+265 """Test that marvin can provide the link to the uptime tournament"""
+266 self.assertStringsOutput(marvin_actions.marvinUptime, "visa lite uptime", "uptime", "info")
+267 self.assertActionSilent(marvin_actions.marvinUptime, "uptimetävling")
+ +269 def testStream(self):
+270 """Test that marvin can provide the link to the stream"""
+271 self.assertStringsOutput(marvin_actions.marvinStream, "ska mos streama?", "stream", "info")
+272 self.assertActionSilent(marvin_actions.marvinStream, "är mos en streamer?")
+ +274 def testPrinciple(self):
+275 """Test that marvin can recite some software principles"""
+276 principles = self.strings.get("principle")
+277 for key, value in principles.items():
+278 self.assertActionOutput(marvin_actions.marvinPrinciple, f"princip {key}", value)
+279 with mock.patch("marvin_actions.random") as r:
+280 r.choice.return_value = "dry"
+281 self.assertStringsOutput(marvin_actions.marvinPrinciple, "princip", "principle", "dry")
+282 self.assertActionSilent(marvin_actions.marvinPrinciple, "principlös")
+ +284 def testCommitRequest(self):
+285 """Test that marvin sends proper requests when generating commit messages"""
+286 with mock.patch("marvin_actions.requests") as r:
+287 self.executeAction(marvin_actions.marvinCommit, "vad skriver man efter commit -m?")
+288 self.assertEqual(r.get.call_args.args[0], "http://whatthecommit.com/index.txt")
+ +290 def testCommitResponse(self):
+291 """Test that marvin properly handles responses when generating commit messages"""
+292 message = "Secret sauce #9"
+293 response = requests.models.Response()
+294 response._content = str.encode(message)
+295 with mock.patch("marvin_actions.requests") as r:
+296 r.get.return_value = response
+297 expected = f"Använd detta meddelandet: '{message}'"
+298 self.assertActionOutput(marvin_actions.marvinCommit, "commit", expected)
+ +300 def testMorning(self):
+301 """Test that marvin wishes good morning, at most once per day"""
+302 marvin_general_actions.lastDateGreeted = None
+303 with mock.patch("marvin_general_actions.datetime") as d:
+304 d.date.today.return_value = date(2024, 5, 17)
+305 with mock.patch("marvin_general_actions.random") as r:
+306 r.choice.return_value = "Morgon"
+307 self.assertActionOutput(marvin_general_actions.marvinMorning, "morrn", "Morgon")
+308 # Should only greet once per day
+309 self.assertActionSilent(marvin_general_actions.marvinMorning, "morgon")
+310 # Should greet again tomorrow
+311 d.date.today.return_value = date(2024, 5, 18)
+312 self.assertActionOutput(marvin_general_actions.marvinMorning, "godmorgon", "Morgon")
+