]> git.aero2k.de Git - urlbot-v3.git/commitdiff
migrate to PEP-621, add some tests, refactor some things
authorThorsten <mail@aero2k.de>
Fri, 19 Apr 2024 14:27:52 +0000 (16:27 +0200)
committerThorsten <mail@aero2k.de>
Fri, 19 Apr 2024 14:27:52 +0000 (16:27 +0200)
45 files changed:
LICENSE [new file with mode: 0644]
distbot/plugins/openai.py [deleted file]
pyproject.toml [new file with mode: 0644]
setup.py [deleted file]
src/distbot/__init__.py [moved from distbot/__init__.py with 100% similarity]
src/distbot/bot/__init__.py [moved from distbot/bot/__init__.py with 100% similarity]
src/distbot/bot/action_worker.py [moved from distbot/bot/action_worker.py with 100% similarity]
src/distbot/bot/bot.py [moved from distbot/bot/bot.py with 92% similarity]
src/distbot/bot/worker.py [moved from distbot/bot/worker.py with 95% similarity]
src/distbot/common/__init__.py [moved from distbot/common/__init__.py with 100% similarity]
src/distbot/common/action.py [moved from distbot/common/action.py with 100% similarity]
src/distbot/common/config.py [moved from distbot/common/config.py with 98% similarity]
src/distbot/common/config/local_config.ini.spec [moved from distbot/common/config/local_config.ini.spec with 100% similarity]
src/distbot/common/config/persistent_config.ini.spec [moved from distbot/common/config/persistent_config.ini.spec with 100% similarity]
src/distbot/common/message.py [moved from distbot/common/message.py with 97% similarity]
src/distbot/common/utils.py [moved from distbot/common/utils.py with 100% similarity]
src/distbot/doc/README.md [moved from distbot/doc/README.md with 100% similarity]
src/distbot/minijobber/__init__.py [moved from distbot/minijobber/__init__.py with 100% similarity]
src/distbot/minijobber/run.py [moved from distbot/minijobber/run.py with 98% similarity]
src/distbot/plugins/__init__.py [moved from distbot/plugins/__init__.py with 100% similarity]
src/distbot/plugins/basic.py [moved from distbot/plugins/basic.py with 88% similarity]
src/distbot/plugins/bofh.py [moved from distbot/plugins/bofh.py with 100% similarity]
src/distbot/plugins/bots.py [moved from distbot/plugins/bots.py with 100% similarity]
src/distbot/plugins/bugtracker.py [moved from distbot/plugins/bugtracker.py with 100% similarity]
src/distbot/plugins/debug.py [moved from distbot/plugins/debug.py with 100% similarity]
src/distbot/plugins/didyouknow.py [moved from distbot/plugins/didyouknow.py with 100% similarity]
src/distbot/plugins/extended.py [moved from distbot/plugins/extended.py with 100% similarity]
src/distbot/plugins/feeds.py [moved from distbot/plugins/feeds.py with 100% similarity]
src/distbot/plugins/fun.py [moved from distbot/plugins/fun.py with 97% similarity]
src/distbot/plugins/insults.txt [moved from distbot/plugins/insults.txt with 100% similarity]
src/distbot/plugins/lookup.py [moved from distbot/plugins/lookup.py with 100% similarity]
src/distbot/plugins/meta.py [moved from distbot/plugins/meta.py with 100% similarity]
src/distbot/plugins/morse.py [moved from distbot/plugins/morse.py with 100% similarity]
src/distbot/plugins/muc.py [moved from distbot/plugins/muc.py with 96% similarity]
src/distbot/plugins/openai.py [new file with mode: 0644]
src/distbot/plugins/plugin_help.py [moved from distbot/plugins/plugin_help.py with 100% similarity]
src/distbot/plugins/queue_management.py [moved from distbot/plugins/queue_management.py with 100% similarity]
src/distbot/plugins/searx.py [moved from distbot/plugins/searx.py with 100% similarity]
src/distbot/plugins/translation.py [moved from distbot/plugins/translation.py with 100% similarity]
src/distbot/plugins/url.py [moved from distbot/plugins/url.py with 100% similarity]
src/distbot/plugins/youtube.py [moved from distbot/plugins/youtube.py with 100% similarity]
tests/test_integration/bot_test_utils.py [new file with mode: 0644]
tests/test_integration/conftest.py [new file with mode: 0644]
tests/test_integration/test_plugins.py
tests/test_integration/test_translation.py

diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
index 0000000..425e7b5
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,7 @@
+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
diff --git a/distbot/plugins/openai.py b/distbot/plugins/openai.py
deleted file mode 100644 (file)
index a7d2bbc..0000000
+++ /dev/null
@@ -1,57 +0,0 @@
-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()
-
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644 (file)
index 0000000..78f73c1
--- /dev/null
@@ -0,0 +1,30 @@
+[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"
diff --git a/setup.py b/setup.py
deleted file mode 100644 (file)
index fd7d3c9..0000000
--- a/setup.py
+++ /dev/null
@@ -1,20 +0,0 @@
-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']),
-
-)
similarity index 100%
rename from distbot/__init__.py
rename to src/distbot/__init__.py
similarity index 92%
rename from distbot/bot/bot.py
rename to src/distbot/bot/bot.py
index e337e1360ed6e1f1df827d105a41bfd8599f93da..61c454774f6421819263dc313347a1b6ea4bf4b1 100644 (file)
@@ -8,6 +8,8 @@ import pika
 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
@@ -75,7 +77,7 @@ class Bot(slixmpp.ClientXMPP):
             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)
@@ -84,7 +86,7 @@ class Bot(slixmpp.ClientXMPP):
         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":
@@ -110,7 +112,7 @@ class Bot(slixmpp.ClientXMPP):
             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()))
@@ -131,7 +133,7 @@ class Bot(slixmpp.ClientXMPP):
         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'):
@@ -170,13 +172,17 @@ class Bot(slixmpp.ClientXMPP):
 
             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:
similarity index 95%
rename from distbot/bot/worker.py
rename to src/distbot/bot/worker.py
index 2a3043d72bda4c4f750ce7532b35cf8ffefe8b9e..ec69805dcfdce6cd7b598db2902b185418480aae 100644 (file)
@@ -7,6 +7,7 @@ from functools import partial
 from typing import Optional
 
 import pika
+import pika.channel
 import pika.exceptions
 
 from distbot.common.action import Action, send_action
@@ -70,7 +71,13 @@ class Worker(threading.Thread):
                 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:
similarity index 98%
rename from distbot/common/config.py
rename to src/distbot/common/config.py
index d2b9b82f799573daabcf152fc1026857c2456cc9..89e1aebe60e542f8b0eb54a05c132e5b2ed3498b 100644 (file)
@@ -18,7 +18,7 @@ def conf_set(key, value):
 
 
 CONFIG_SUFFIX = os.environ.get('BOTSUFFIX', '')
-RESOURCE_PATH = "distbot/common/config"
+RESOURCE_PATH = "src/distbot/common/config"
 
 
 class Config:
similarity index 97%
rename from distbot/common/message.py
rename to src/distbot/common/message.py
index eb30d2696c0c7d777cbf6caab96419f049ffd262..a218f0ad4762915584b83b4edb598954a2ab7d65 100644 (file)
@@ -43,7 +43,7 @@ def get_nick_from_message(message_obj) -> str:
         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')
similarity index 98%
rename from distbot/minijobber/run.py
rename to src/distbot/minijobber/run.py
index 86ef4ab6d93ef2712c5e6abfd3bb240fbbacac25..5044a20a22fb316df51345b7617896e59991660b 100644 (file)
@@ -26,7 +26,7 @@ PLUGIN_MODULES = {
     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,
similarity index 88%
rename from distbot/plugins/basic.py
rename to src/distbot/plugins/basic.py
index 98c2c8852a41fe96f40c96c95c51633314e6cc54..36c9d1d088bde1c4c292526e5d7bb5d1dbd2bb3e 100644 (file)
@@ -9,6 +9,7 @@ from distbot.common import config
 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__)
 
@@ -38,9 +39,9 @@ class Dice(Worker):
                 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)
@@ -240,9 +241,43 @@ class Choose(Worker):
     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
@@ -252,31 +287,10 @@ class Choose(Worker):
 
         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)
@@ -289,14 +303,14 @@ class Choose(Worker):
             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
similarity index 97%
rename from distbot/plugins/fun.py
rename to src/distbot/plugins/fun.py
index 72cd02b94e544b667e452f3e466f93a766946542..7b468b2d6f400a88d7e9350e69904db3872cd9d3 100644 (file)
@@ -9,7 +9,7 @@ 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.common.utils import giphy
-from distbot.plugins.openai import ask_chatgpt, get_openai_session
+from distbot.plugins import openai
 
 logger = logging.getLogger(__name__)
 
@@ -167,8 +167,8 @@ class Selfreaction(Worker):
 
         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
similarity index 96%
rename from distbot/plugins/muc.py
rename to src/distbot/plugins/muc.py
index f67d171052e92fce7f0ff16a649d6a83446ef0fb..0cb15572e19508b8c5d2802722e092db9544f323 100644 (file)
@@ -31,9 +31,6 @@ class Recorder(Worker):
     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)
diff --git a/src/distbot/plugins/openai.py b/src/distbot/plugins/openai.py
new file mode 100644 (file)
index 0000000..12e5caf
--- /dev/null
@@ -0,0 +1,143 @@
+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()
+
diff --git a/tests/test_integration/bot_test_utils.py b/tests/test_integration/bot_test_utils.py
new file mode 100644 (file)
index 0000000..e102f6e
--- /dev/null
@@ -0,0 +1,38 @@
+# -*- 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__
diff --git a/tests/test_integration/conftest.py b/tests/test_integration/conftest.py
new file mode 100644 (file)
index 0000000..015e2ce
--- /dev/null
@@ -0,0 +1,40 @@
+# -*- 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
index 3227ca275bb675b1ce6c7b4865858c81e71e7257..1d1955d58bb581b32dd7e04002218b788bf4e9c9 100644 (file)
@@ -1,9 +1,65 @@
 # -*- 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"
+    ))
index ec38cbb1bebb66cc9cddad8aa71bc626a5b6f424..74435df22fc00d2953cc9c2f8d0d4128c1ef9c2a 100644 (file)
@@ -2,15 +2,15 @@
 
 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)
     """
@@ -22,4 +22,4 @@ def test_translate(text, en, lang):
 
     translator.history = [{"body": text}]
     action = translator.parse_body({"body": "translate that", "from": "hell"})
-    assert action.msg == en
+    assert action.msg == de