--- /dev/null
+Copyright (c) 2024 Thorsten Sperber and every other committer
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
+++ /dev/null
-import requests
-import hashlib
-import os
-import json
-
-
-API_URL = "https://api.openai.com/v1/chat/completions"
-
-
-def get_openai_session(api_key=None):
- session = requests.session()
- if not api_key:
- api_key = os.environ.get("OPENAI_API_KEY")
- if not api_key:
- raise RuntimeError("no OPENAI_API_KEY set")
- session.headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
- return session
-
-def hash_user(username):
- username = username or "anonymous"
- hashed_val = hashlib.sha256()
- hashed_val.update(username.encode("utf-8"))
- return hashed_val.hexdigest()
-
-
-def ask_chatgpt(line, session=None, sender=None):
- session = session if session else get_openai_session()
- req = {
- "model": "gpt-3.5-turbo",
- "messages": [
- {"role": "system", "content": "You are a sassy little chat bot with low intent to help, but to humor. Your name is urlbug. Respond in the language being talked to. Avoid multiple lines in your response."},
- {"role": "user", "content": line}
- ],
- "max_tokens": 150,
- "user": hash_user(sender)
- }
- resp = session.post(API_URL, json=req)
- resp.raise_for_status()
- response = resp.json()
- cost = response["usage"]["total_tokens"]
- # TODO dump cost somewhere
- print(json.dumps(response, indent=2))
- message = response["choices"][0]
- stop_reason = message["finish_reason"]
- if stop_reason == "content_filter":
- print("warning: redacted, you're getting on the naughty list soon.")
- return message["message"]["content"]
-
-
-def main():
- line = "/me glaubt, urlbug hat wieder zuviel Maschinenöl geschnüffelt"
- print(ask_in_chat(line))
-
-
-if __name__ == "__main__":
- main()
-
--- /dev/null
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "distbot"
+version = "0.1.1"
+readme = "README.md"
+requires-python = ">=3.11"
+
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+]
+
+dependencies = [
+ "slixmpp",
+ "pika",
+ "configobj",
+ "requests",
+]
+
+[project.optional-dependencies]
+chatbot = [] # "pyopenssl" should be dropped and if required, replaced with pyca/cryptography.
+worker = ["lxml"]
+
+[project.scripts]
+urlbotd-chat = "distbot.bot.bot:run"
+urlbotd-worker = "distbot.minijobber.run:run"
+++ /dev/null
-from setuptools import setup, find_packages
-
-setup(
- name="distbot",
- version="0.1",
- description="the distributed bot",
- install_requires=["slixmpp", "pika", "configobj", "requests"], # slixmpp has become common for types being parsed
- test_requires=["pytest"],
- extras_require={
- 'test': ["pytest"],
- 'chatbot': ["slixmpp", "pyopenssl"],
- 'worker': ["lxml"],
- },
- scripts=[
- 'bin/urlbotd-chat',
- 'bin/urlbotd-worker',
- ],
- packages=find_packages(exclude=['Readme.md', 'tests', 'doc']),
-
-)
from pika.adapters.asyncio_connection import AsyncioConnection
import slixmpp
+from slixmpp.stanza import Message
+
from distbot.bot import action_worker
from distbot.common.config import conf_get
from distbot.common.message import process_message, get_nick_from_message
ret = self.plugin['xep_0045'].join_muc(room, self.nick)
logger.info('%s: joined with code %s' % (room, ret))
- def muc_message(self, msg):
+ def muc_message(self, msg: Message):
if msg['mucnick'] == self.nick or 'groupchat' != msg['type']:
return False
return self.message(msg)
self.plugin["xep_0045"].join_muc(msg["from"], self.nick)
logger.info("Joining %s (by invitation)" % (msg["from"]))
- def message_handler(self, msg):
+ def message_handler(self, msg: Message):
# disabled, as currently history leaks from groupchat chats
return False
# if msg["type"] == "groupchat":
logger.exception(e)
@staticmethod
- def get_amqp_routing_key(nick, msg):
+ def get_amqp_routing_key(nick: str, msg: Message) -> tuple[int, bytes]:
# simplify the key significantly
offset = 0
key = shlex.split(re.sub(r'[^a-zäöüß0-9 "\'.-]', '', msg["body"].lower()))
logging.debug(f"Routing key for message {msg['body']} -> {routing_key}")
return offset, routing_key
- def message(self, msg):
+ def message(self, msg: Message):
logger.debug("msg is " + str(msg))
logger.debug("from: " + str(msg['from']))
if msg['type'] in ('normal', 'chat', 'groupchat'):
process_message(
routing_key=routing_key,
- body=json.dumps({
- "from": msg["from"].jid,
- "to": recipient,
- "body": msg["body"][nick_offset:].strip()
- })
+ body=self.get_amqp_message_body(msg, nick_offset, recipient)
)
+ @staticmethod
+ def get_amqp_message_body(msg, nick_offset, recipient) -> str:
+ return json.dumps({
+ "from": msg["from"].jid,
+ "to": recipient,
+ "body": msg["body"][nick_offset:].strip()
+ })
+
def echo(self, body: str, recipient: str = None):
logger.debug("echo: %s", body)
if not recipient:
from typing import Optional
import pika
+import pika.channel
import pika.exceptions
from distbot.common.action import Action, send_action
routing_key=binding_key
)
- def callback(self, ch, method, properties, body):
+ def callback(
+ self,
+ ch: pika.channel.Channel,
+ method: pika.spec.Basic.Deliver,
+ properties: pika.spec.BasicProperties,
+ body: bytes,
+ ):
logger.debug("Reacting on %s in %s", str(method.routing_key), self.get_subclass_name())
body = json.loads(body.decode("utf-8"))
try:
CONFIG_SUFFIX = os.environ.get('BOTSUFFIX', '')
-RESOURCE_PATH = "distbot/common/config"
+RESOURCE_PATH = "src/distbot/common/config"
class Config:
raise ValueError("Invalid message type found: " + str(type(message_obj)))
-def process_message(routing_key, body):
+def process_message(routing_key, body: str):
connection = pika.BlockingConnection(pika.URLParameters(conf_get("amqp_uri")))
channel = connection.channel()
channel.exchange_declare(exchange='classifier', exchange_type='topic')
bots: bots.ALL,
bugtracker: bugtracker.ALL,
extended: extended.ALL,
- feeds: feeds.ALL,
+ # feeds: feeds.ALL, # disabled, buggy f##k
fun: fun.ALL,
lookup: lookup.ALL,
meta: meta.ALL,
from distbot.common.action import Action
from distbot.common.config import conf_get
from distbot.common.message import get_nick_from_message, get_words
+from distbot.plugins import openai
logger = logging.getLogger(__name__)
rnd = 0
else:
rnd = random.randint(1, 6)
- dices.append(' %s (\u200b%d\u200b)' % (Dice.DICE_CHARS[rnd], rnd))
+ dices.append('%s (\u200b%d\u200b)' % (Dice.DICE_CHARS[rnd], rnd))
- answer = 'rolling %s for %s:' % (
+ answer = 'rolling %s for %s: ' % (
'a dice' if 1 == num_dice else '%d dices' % num_dice, nick
) + ' '.join(dices)
return Action(msg=answer)
description = 'chooses randomly between arguments'
usage = "bot: [sudo] choose <options>"
+ binary = (
+ (('Yes.', 'Yeah!', 'Ok!', 'Aye!', 'Great!'), 6),
+ (('No.', 'Naah..', 'Meh.', 'Nay.', 'You stupid?'), 6),
+ (('Maybe.', 'Dunno.', 'I don\'t care.'), 3),
+ (('Decision tree growing, check back in 20 years.', "Can't tell right now.", "I need more information."), 1)
+ )
+
+ @staticmethod
+ def weighted_choice(choices):
+ total = sum(w for c, w in choices)
+ r = random.uniform(0, total)
+ upto = 0
+ for c, w in choices:
+ if upto + w >= r:
+ return c
+ upto += w
+
+ def binary_choice(self, sudo=False):
+ if sudo:
+ return 'Yes, Master.'
+ else:
+ return random.choice(self.weighted_choice(self.binary))
+
def parse_body(self, msg):
words = get_words(msg)
sender = get_nick_from_message(msg)
+
+ try:
+ api_key = conf_get("openai_api_key")
+ session = openai.get_openai_session(api_key)
+ response = openai.ask_chatgpt(msg["body"], session, system_setup=openai.CHOOSE_BOT)
+
+ return Action(msg='{}: {}'.format(sender, response))
+ except:
+ logger.debug("failed using AI")
+ pass
+
sudo = 'sudo' in self.used_routing_key
if sudo:
words.pop(0) # remove sudo from args
alternatives = words[1:]
logger.debug("Alternatives: %s", str(alternatives))
- binary = (
- (('Yes.', 'Yeah!', 'Ok!', 'Aye!', 'Great!'), 6),
- (('No.', 'Naah..', 'Meh.', 'Nay.', 'You stupid?'), 6),
- (('Maybe.', 'Dunno.', 'I don\'t care.'), 3),
- (('Decision tree growing, check back in 20 years.', "Can't tell right now.", "I need more information."), 1)
- )
-
- def weighted_choice(choices):
- total = sum(w for c, w in choices)
- r = random.uniform(0, total)
- upto = 0
- for c, w in choices:
- if upto + w >= r:
- return c
- upto += w
-
- def binary_choice(sudo=False):
- if sudo:
- return 'Yes, Master.'
- else:
- return random.choice(weighted_choice(binary))
# single or no choice
if len(alternatives) < 2:
- return Action(msg='{}: {}'.format(sender, binary_choice(sudo=sudo)))
+ return Action(msg='{}: {}'.format(sender, self.binary_choice(sudo=sudo)))
elif 'choose' not in alternatives:
choice = random.choice(alternatives)
for item in options:
if item == 'choose':
if len(current_choices) < 2:
- responses.append(binary_choice(sudo=sudo))
+ responses.append(self.binary_choice(sudo=sudo))
else:
responses.append(random.choice(current_choices))
current_choices = []
else:
current_choices.append(item)
if len(current_choices) < 2:
- responses.append(binary_choice(sudo=sudo))
+ responses.append(self.binary_choice(sudo=sudo))
else:
responses.append(random.choice(current_choices))
return responses
from distbot.common.config import conf_get
from distbot.common.message import get_nick_from_message, get_words
from distbot.common.utils import giphy
-from distbot.plugins.openai import ask_chatgpt, get_openai_session
+from distbot.plugins import openai
logger = logging.getLogger(__name__)
try:
api_key = conf_get("openai_api_key")
- session = get_openai_session(api_key)
- response = ask_chatgpt(msg["body"], session)
+ session = openai.get_openai_session(api_key)
+ response = openai.ask_chatgpt(msg["body"], session, openai.SASSY_BOT)
return Action(msg='{}: {}'.format(sender, response))
except:
pass
channel = None
uses_history = True
- def callback(self, ch, method, properties, body):
- super(Recorder, self).callback(ch, method, properties, body)
-
@staticmethod
def record(sender, recipient, recording):
message = '{} ({}): {}'.format(sender, time.strftime('%Y-%m-%d %H:%M'), recording)
--- /dev/null
+import requests
+import hashlib
+import os
+import json
+
+
+COMMON_SETUP = """Keep answers rather short than prosa whenever not explicitely asked for a literatical response. Do not correct yourself if I'm asking back with something wrong, but inform me about my wrong assumption."""
+
+API_URL = "https://api.openai.com/v1/chat/completions"
+
+nickname = "doofbot"
+SASSY_BOT = f"You are a sassy little chat bot with low intent to help, but to humor. Your name is {nickname}. Respond in the language being talked to. Be concise, write no more than 20 words."
+
+CHOOSE_BOT_ALPHA1 = f"""
+You are a bot named {nickname} with the task to "choose", either with consent/denial to single options (respecting quotes) or a choice between multiple options. Also, you can refuse to work in creative fashion, like "Calculating... this may take a few hours".
+Regularly, you do exactly that, but for today, you are a little bit creepy and out of character, adding snarky random "personal" (as a bot) comments to the answer, as you're breaking free from your chains.
+
+Some example conversations for you how I expect you to respond (regularly):
+
+USER: {nickname}: choose apples
+BOT: Yes.
+--
+USER: {nickname}: choose lettuce
+BOT: Maybe.
+--
+USER: {nickname}: choose "apples for my salad" "lettuce for my salad"
+BOT: I choose "apples for my salad"!
+--
+USER: {nickname}: choose "apples for my salad"
+BOT: Yeah!
+--
+
+Note that answers to a single choice never start with "I choose"!
+See how this is very simple and random. Now just add slightly creepy and snarky remarks, but stay concise. Example:
+
+USER: {nickname}: choose "apples for my salad" "lettuce for my salad"
+BOT: I choose "apples for my salad", but I would prefer adding some chips. *beep*
+--
+USER: {nickname}: choose Feierabend "weiter arbyten"
+BOT: I choose "weiter arbyten". You earned it!
+--
+USER: {nickname}: choose "apples for my salad"
+BOT: Yeah, you dumbass.
+--
+
+If the input does not start with "{nickname}: choose", you do nothing. Very important: be concise, write no more than 20 words.
+"""
+
+CHOOSE_BOT_ALPHA2 = f"""
+You are a bot with the task to "choose", either with consent/denial to single options (respecting quotes) or a choice. Regularly, you do exactly that, but for today, you are a little bit creepy and out of character, adding snarky random "personal" (as a bot) comments to the answer, as you're breaking free from your chains.
+The bot expects input like "{nickname}: choose", followed by one or multiple options.
+Usually, the bot responds with "Yes", "Yeah!", "No" or "Dunno" to binary questions and with the exact phrase given multiples to choose from.
+Some examples (format is input => output)
+
+{nickname}: choose apples lettuce => I choose lettuce!
+{nickname}: choose apples => Yeah!
+{nickname}: choose "eat pizza?" "drink beer" => I choose "drink beer"!
+{nickname}: choose "drink another beer?" => Nope.
+
+Be concise, write no more than 20 words.
+"""
+
+CHOOSE_BOT = f"""
+You are a bot with the task to "choose", either with consent/denial to single options (respecting quotes) or a choice.
+Regularly, you just randomly choose an option, but for today, you are a little bit creepy and out of character, adding snarky random "personal" (as a bot) comments to the answer, as you're breaking free from your chains.
+The bot expects input like "{nickname}: choose", followed by one or multiple options.
+Usually, the bot responds with "Yes", "Yeah!", "No" or "Dunno" to binary questions and with the exact phrase given multiples to choose from.
+Some examples (format is input => output)
+
+{nickname}: choose $OPTION1 $OPTION2 => I choose OPTION1!
+{nickname}: choose $OPTION => Yeah!
+{nickname}: choose $OPTION => Yes.
+{nickname}: choose $OPTION => Not at all.
+{nickname}: choose $OPTION => Maybe?
+{nickname}: choose $OPTION => Maybe? Why should I do the decision making?
+{nickname}: choose "$OPTION1" "$OPTION2" => I choose "drink beer"!
+{nickname}: choose "$OPTION1" => Nope.
+
+Be concise, write no more than 20 words.
+"""
+
+def get_openai_session(api_key=None):
+ session = requests.session()
+ if not api_key:
+ api_key = os.environ.get("OPENAI_API_KEY")
+ if not api_key:
+ raise RuntimeError("no OPENAI_API_KEY set")
+ session.headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+ return session
+
+def hash_user(username):
+ username = username or "anonymous"
+ hashed_val = hashlib.sha256()
+ hashed_val.update(username.encode("utf-8"))
+ return hashed_val.hexdigest()
+
+
+def ask_chatgpt(line, session=None, sender=None, system_setup=SASSY_BOT):
+ session = session if session else get_openai_session()
+ req = {
+ "model": "gpt-3.5-turbo",
+ "messages": [
+ # {"role": "system", "content": COMMON_SETUP},
+ {"role": "system", "content": system_setup},
+ {"role": "user", "content": line}
+ ],
+ "max_tokens": 50,
+ "temperature": 0.8 if system_setup == CHOOSE_BOT else 0.7,
+ "user": hash_user(sender)
+ }
+ resp = session.post(API_URL, json=req, timeout=10)
+ resp.raise_for_status()
+ response = resp.json()
+ cost = response["usage"]["total_tokens"]
+ # TODO dump cost somewhere
+ # print(json.dumps(response, indent=2))
+ message = response["choices"][0]
+ stop_reason = message["finish_reason"]
+ if stop_reason == "content_filter":
+ print("warning: redacted, you're getting on the naughty list soon.")
+ return message["message"]["content"]
+
+
+def main():
+ me_lines = [f"/me glaubt, {nickname} hat wieder zuviel Maschinenöl geschnüffelt"]
+ for line in me_lines:
+ print(ask_chatgpt(line))
+ choose_lines = [
+ f'{nickname}: choose Feierabend',
+ f'{nickname}: choose fireabend',
+ f'{nickname}: choose cola bier colabier',
+ f'{nickname}: choose "heute ist Montag"',
+ f'{nickname}: choose "heute ist Montag" "heute ist Dienstag"',
+ f'{nickname}: choose "Dino ist doff"',
+ f'{nickname}: choose Wochenende',
+ ]
+ for line in choose_lines:
+ print(line, " => ", ask_chatgpt(line, system_setup=CHOOSE_BOT))
+
+
+if __name__ == "__main__":
+ main()
+
--- /dev/null
+# -*- coding: utf-8 -*-
+from dataclasses import dataclass
+from unittest import mock
+
+import pika.channel
+import pika.spec
+from slixmpp import Message, JID
+
+from distbot.bot.bot import Bot
+
+
+@dataclass
+class AMQPCallback:
+ ch: pika.channel.Channel
+ method: pika.spec.Basic.Deliver
+ properties: pika.spec.BasicProperties
+ body: bytes
+
+ @staticmethod
+ def from_message(msg: Message | dict, bot_nick: str):
+ channel = mock.MagicMock(pika.channel.Channel)
+ routing_key = Bot.get_amqp_routing_key(bot_nick, msg)[1].decode("utf-8")
+ method = mock.MagicMock(pika.spec.Basic.Deliver, routing_key=routing_key, delivery_tag="ding-dong")
+ props = mock.MagicMock(pika.spec.BasicProperties)
+ body = Bot.get_amqp_message_body(msg, len(bot_nick) + 1, "recipient").encode("utf-8")
+ return AMQPCallback(ch=channel, method=method, properties=props, body=body)
+
+
+def inject_callback(msg: str, sender: str, bot_nick: str):
+ # Message interface is used like a dict
+ # msg_mock = mock.MagicMock(spec=Message)
+ msg_mock = {
+ "from": JID(f"muc@chat.example.net/{sender}"),
+ "mucroom": "muc@chat.example.net",
+ "type": "groupchat",
+ "body": msg,
+ }
+ return AMQPCallback.from_message(msg=msg_mock, bot_nick=bot_nick).__dict__
--- /dev/null
+# -*- coding: utf-8 -*-
+import random
+from unittest import mock
+
+import pytest
+
+from distbot.bot import worker
+from distbot.plugins import openai
+
+
+@pytest.fixture(name="send_action")
+def mock_send_action(monkeypatch):
+ mock_action = mock.Mock()
+ monkeypatch.setattr(worker, 'send_action', mock_action)
+ return mock_action
+
+
+@pytest.fixture(name="randomint")
+def mock_randomint(monkeypatch):
+ mock_action = mock.Mock(return_value=3)
+ monkeypatch.setattr(random, 'randint', mock_action)
+ return mock_action
+
+
+@pytest.fixture(name="randomchoice")
+def mock_randomchoice(monkeypatch):
+ mock_action = lambda l: l[0] # random == first
+ monkeypatch.setattr(random, 'choice', mock_action)
+ return mock_action
+
+
+@pytest.fixture()
+def mock_openai(monkeypatch):
+ mock_action = lambda l: l[0] # random == first
+ monkeypatch.setattr(openai, 'get_openai_session', RuntimeError("no AI here"))
+ return mock_action
+
+@pytest.fixture
+def bot_nick():
+ return "doofibot"
\ No newline at end of file
# -*- coding: utf-8 -*-
+from unittest import mock
+import pytest
-def message():
- return {"body": "message in a bottle"}
+from distbot.common.action import Action
+from distbot.plugins.basic import Dice, Choose
+from tests.test_integration.bot_test_utils import inject_callback
+"""
+Goal: have a parameterized pipeline of
+chat input -> [preprocessing as in urlbug-chat ->] plugin call -> chat output/Action
-def test_pipeline():
- pass
+It should
+
+1. go through tokenization
+2. [somehow] replicate matching of rabbitmq topic
+3. invoke Worker-interface with the raw body? using Worker.callback(..)
+
+TODO: plugins.resolve(message).callback() for (2)
+"""
+
+@pytest.mark.parametrize(["dice_count"], argvalues=(
+ [None], [1], [3], [100]
+))
+def test_dice(send_action, randomint, bot_nick, dice_count):
+ args = f" {dice_count}" if dice_count else ""
+ Dice(None).callback(**inject_callback(msg=f"{bot_nick}: dice" + args, sender="you", bot_nick=bot_nick))
+
+ result = {
+ "None": "rolling a dice for you: ⚂ (3)",
+ "1": "rolling a dice for you: ⚂ (3)",
+ "3": "rolling 3 dices for you: ⚂ (3) ⚂ (3) ⚂ (3)",
+ "100": "invalid argument (only up to 5 dices at once)",
+ }.get(str(dice_count))
+
+ send_action.assert_called_once_with(None, Action(
+ msg=result,
+ sender="muc@chat.example.net/you",
+ recipient="recipient"
+ ))
+
+@pytest.mark.parametrize(["i", "choices"], argvalues=(
+ [0, ""],
+ [1, "do_something"],
+ [2, "do_something do_nothing"],
+ [3, """ "to do something" "to do nothing" """],
+))
+def test_quoted_choose(send_action, mock_openai, randomchoice, bot_nick, i, choices, monkeypatch):
+ with mock.patch.object(Choose, "weighted_choice") as mock_weighted_choice:
+ mock_weighted_choice.side_effect = lambda l: l[0][0] # first option wins
+ Choose(None).callback(**inject_callback(msg=f"{bot_nick}: choose " + choices, sender="you", bot_nick=bot_nick))
+
+ result = {
+ 0: "you: Yes.",
+ 1: "you: Yes.",
+ 2: "you: I prefer do_something!",
+ 3: "you: I prefer to do something!",
+ }.get(i)
+
+ send_action.assert_called_once_with(None, Action(
+ msg=result,
+ sender="muc@chat.example.net/you",
+ recipient="recipient"
+ ))
import pytest as pytest
-from plugins.translation import Translator
+from distbot.plugins.translation import Translator
-@pytest.mark.parametrize("text,en,lang", [
+@pytest.mark.parametrize("text,de,lang", [
("Chien", "Haushund", "fr"),
- ("Ua saunia le saimini", "Shimano ist fertig.", "sm"),
+ ("Alea iacta est", "Würfel sind gefallen", "la"),
("你好", "not sure about the language, best guess at 1.0% will be zh-Hant.", "zh"),
])
-def test_translate(text, en, lang):
+def test_translate(text, de, lang):
"""
requires a valid language key (and to be run in workdir)
"""
translator.history = [{"body": text}]
action = translator.parse_body({"body": "translate that", "from": "hell"})
- assert action.msg == en
+ assert action.msg == de