From 14198d47fb4febb26d21d7dbd3b6fa29d80c6794 Mon Sep 17 00:00:00 2001 From: Thorsten Date: Fri, 19 Apr 2024 16:27:52 +0200 Subject: [PATCH] migrate to PEP-621, add some tests, refactor some things --- LICENSE | 7 + distbot/plugins/openai.py | 57 ------- pyproject.toml | 30 ++++ setup.py | 20 --- {distbot => src/distbot}/__init__.py | 0 {distbot => src/distbot}/bot/__init__.py | 0 {distbot => src/distbot}/bot/action_worker.py | 0 {distbot => src/distbot}/bot/bot.py | 24 +-- {distbot => src/distbot}/bot/worker.py | 9 +- {distbot => src/distbot}/common/__init__.py | 0 {distbot => src/distbot}/common/action.py | 0 {distbot => src/distbot}/common/config.py | 2 +- .../common/config/local_config.ini.spec | 0 .../common/config/persistent_config.ini.spec | 0 {distbot => src/distbot}/common/message.py | 2 +- {distbot => src/distbot}/common/utils.py | 0 {distbot => src/distbot}/doc/README.md | 0 .../distbot}/minijobber/__init__.py | 0 {distbot => src/distbot}/minijobber/run.py | 2 +- {distbot => src/distbot}/plugins/__init__.py | 0 {distbot => src/distbot}/plugins/basic.py | 66 ++++---- {distbot => src/distbot}/plugins/bofh.py | 0 {distbot => src/distbot}/plugins/bots.py | 0 .../distbot}/plugins/bugtracker.py | 0 {distbot => src/distbot}/plugins/debug.py | 0 .../distbot}/plugins/didyouknow.py | 0 {distbot => src/distbot}/plugins/extended.py | 0 {distbot => src/distbot}/plugins/feeds.py | 0 {distbot => src/distbot}/plugins/fun.py | 6 +- {distbot => src/distbot}/plugins/insults.txt | 0 {distbot => src/distbot}/plugins/lookup.py | 0 {distbot => src/distbot}/plugins/meta.py | 0 {distbot => src/distbot}/plugins/morse.py | 0 {distbot => src/distbot}/plugins/muc.py | 3 - src/distbot/plugins/openai.py | 143 ++++++++++++++++++ .../distbot}/plugins/plugin_help.py | 0 .../distbot}/plugins/queue_management.py | 0 {distbot => src/distbot}/plugins/searx.py | 0 .../distbot}/plugins/translation.py | 0 {distbot => src/distbot}/plugins/url.py | 0 {distbot => src/distbot}/plugins/youtube.py | 0 tests/test_integration/bot_test_utils.py | 38 +++++ tests/test_integration/conftest.py | 40 +++++ tests/test_integration/test_plugins.py | 64 +++++++- tests/test_integration/test_translation.py | 10 +- 45 files changed, 392 insertions(+), 131 deletions(-) create mode 100644 LICENSE delete mode 100644 distbot/plugins/openai.py create mode 100644 pyproject.toml delete mode 100644 setup.py rename {distbot => src/distbot}/__init__.py (100%) rename {distbot => src/distbot}/bot/__init__.py (100%) rename {distbot => src/distbot}/bot/action_worker.py (100%) rename {distbot => src/distbot}/bot/bot.py (92%) rename {distbot => src/distbot}/bot/worker.py (95%) rename {distbot => src/distbot}/common/__init__.py (100%) rename {distbot => src/distbot}/common/action.py (100%) rename {distbot => src/distbot}/common/config.py (98%) rename {distbot => src/distbot}/common/config/local_config.ini.spec (100%) rename {distbot => src/distbot}/common/config/persistent_config.ini.spec (100%) rename {distbot => src/distbot}/common/message.py (97%) rename {distbot => src/distbot}/common/utils.py (100%) rename {distbot => src/distbot}/doc/README.md (100%) rename {distbot => src/distbot}/minijobber/__init__.py (100%) rename {distbot => src/distbot}/minijobber/run.py (98%) rename {distbot => src/distbot}/plugins/__init__.py (100%) rename {distbot => src/distbot}/plugins/basic.py (88%) rename {distbot => src/distbot}/plugins/bofh.py (100%) rename {distbot => src/distbot}/plugins/bots.py (100%) rename {distbot => src/distbot}/plugins/bugtracker.py (100%) rename {distbot => src/distbot}/plugins/debug.py (100%) rename {distbot => src/distbot}/plugins/didyouknow.py (100%) rename {distbot => src/distbot}/plugins/extended.py (100%) rename {distbot => src/distbot}/plugins/feeds.py (100%) rename {distbot => src/distbot}/plugins/fun.py (97%) rename {distbot => src/distbot}/plugins/insults.txt (100%) rename {distbot => src/distbot}/plugins/lookup.py (100%) rename {distbot => src/distbot}/plugins/meta.py (100%) rename {distbot => src/distbot}/plugins/morse.py (100%) rename {distbot => src/distbot}/plugins/muc.py (96%) create mode 100644 src/distbot/plugins/openai.py rename {distbot => src/distbot}/plugins/plugin_help.py (100%) rename {distbot => src/distbot}/plugins/queue_management.py (100%) rename {distbot => src/distbot}/plugins/searx.py (100%) rename {distbot => src/distbot}/plugins/translation.py (100%) rename {distbot => src/distbot}/plugins/url.py (100%) rename {distbot => src/distbot}/plugins/youtube.py (100%) create mode 100644 tests/test_integration/bot_test_utils.py create mode 100644 tests/test_integration/conftest.py diff --git a/LICENSE b/LICENSE new file mode 100644 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 index a7d2bbc..0000000 --- a/distbot/plugins/openai.py +++ /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 index 0000000..78f73c1 --- /dev/null +++ b/pyproject.toml @@ -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 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']), - -) diff --git a/distbot/__init__.py b/src/distbot/__init__.py similarity index 100% rename from distbot/__init__.py rename to src/distbot/__init__.py diff --git a/distbot/bot/__init__.py b/src/distbot/bot/__init__.py similarity index 100% rename from distbot/bot/__init__.py rename to src/distbot/bot/__init__.py diff --git a/distbot/bot/action_worker.py b/src/distbot/bot/action_worker.py similarity index 100% rename from distbot/bot/action_worker.py rename to src/distbot/bot/action_worker.py diff --git a/distbot/bot/bot.py b/src/distbot/bot/bot.py similarity index 92% rename from distbot/bot/bot.py rename to src/distbot/bot/bot.py index e337e13..61c4547 100644 --- a/distbot/bot/bot.py +++ b/src/distbot/bot/bot.py @@ -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: diff --git a/distbot/bot/worker.py b/src/distbot/bot/worker.py similarity index 95% rename from distbot/bot/worker.py rename to src/distbot/bot/worker.py index 2a3043d..ec69805 100644 --- a/distbot/bot/worker.py +++ b/src/distbot/bot/worker.py @@ -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: diff --git a/distbot/common/__init__.py b/src/distbot/common/__init__.py similarity index 100% rename from distbot/common/__init__.py rename to src/distbot/common/__init__.py diff --git a/distbot/common/action.py b/src/distbot/common/action.py similarity index 100% rename from distbot/common/action.py rename to src/distbot/common/action.py diff --git a/distbot/common/config.py b/src/distbot/common/config.py similarity index 98% rename from distbot/common/config.py rename to src/distbot/common/config.py index d2b9b82..89e1aeb 100644 --- a/distbot/common/config.py +++ b/src/distbot/common/config.py @@ -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: diff --git a/distbot/common/config/local_config.ini.spec b/src/distbot/common/config/local_config.ini.spec similarity index 100% rename from distbot/common/config/local_config.ini.spec rename to src/distbot/common/config/local_config.ini.spec diff --git a/distbot/common/config/persistent_config.ini.spec b/src/distbot/common/config/persistent_config.ini.spec similarity index 100% rename from distbot/common/config/persistent_config.ini.spec rename to src/distbot/common/config/persistent_config.ini.spec diff --git a/distbot/common/message.py b/src/distbot/common/message.py similarity index 97% rename from distbot/common/message.py rename to src/distbot/common/message.py index eb30d26..a218f0a 100644 --- a/distbot/common/message.py +++ b/src/distbot/common/message.py @@ -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') diff --git a/distbot/common/utils.py b/src/distbot/common/utils.py similarity index 100% rename from distbot/common/utils.py rename to src/distbot/common/utils.py diff --git a/distbot/doc/README.md b/src/distbot/doc/README.md similarity index 100% rename from distbot/doc/README.md rename to src/distbot/doc/README.md diff --git a/distbot/minijobber/__init__.py b/src/distbot/minijobber/__init__.py similarity index 100% rename from distbot/minijobber/__init__.py rename to src/distbot/minijobber/__init__.py diff --git a/distbot/minijobber/run.py b/src/distbot/minijobber/run.py similarity index 98% rename from distbot/minijobber/run.py rename to src/distbot/minijobber/run.py index 86ef4ab..5044a20 100644 --- a/distbot/minijobber/run.py +++ b/src/distbot/minijobber/run.py @@ -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, diff --git a/distbot/plugins/__init__.py b/src/distbot/plugins/__init__.py similarity index 100% rename from distbot/plugins/__init__.py rename to src/distbot/plugins/__init__.py diff --git a/distbot/plugins/basic.py b/src/distbot/plugins/basic.py similarity index 88% rename from distbot/plugins/basic.py rename to src/distbot/plugins/basic.py index 98c2c88..36c9d1d 100644 --- a/distbot/plugins/basic.py +++ b/src/distbot/plugins/basic.py @@ -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 " + 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 diff --git a/distbot/plugins/bofh.py b/src/distbot/plugins/bofh.py similarity index 100% rename from distbot/plugins/bofh.py rename to src/distbot/plugins/bofh.py diff --git a/distbot/plugins/bots.py b/src/distbot/plugins/bots.py similarity index 100% rename from distbot/plugins/bots.py rename to src/distbot/plugins/bots.py diff --git a/distbot/plugins/bugtracker.py b/src/distbot/plugins/bugtracker.py similarity index 100% rename from distbot/plugins/bugtracker.py rename to src/distbot/plugins/bugtracker.py diff --git a/distbot/plugins/debug.py b/src/distbot/plugins/debug.py similarity index 100% rename from distbot/plugins/debug.py rename to src/distbot/plugins/debug.py diff --git a/distbot/plugins/didyouknow.py b/src/distbot/plugins/didyouknow.py similarity index 100% rename from distbot/plugins/didyouknow.py rename to src/distbot/plugins/didyouknow.py diff --git a/distbot/plugins/extended.py b/src/distbot/plugins/extended.py similarity index 100% rename from distbot/plugins/extended.py rename to src/distbot/plugins/extended.py diff --git a/distbot/plugins/feeds.py b/src/distbot/plugins/feeds.py similarity index 100% rename from distbot/plugins/feeds.py rename to src/distbot/plugins/feeds.py diff --git a/distbot/plugins/fun.py b/src/distbot/plugins/fun.py similarity index 97% rename from distbot/plugins/fun.py rename to src/distbot/plugins/fun.py index 72cd02b..7b468b2 100644 --- a/distbot/plugins/fun.py +++ b/src/distbot/plugins/fun.py @@ -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 diff --git a/distbot/plugins/insults.txt b/src/distbot/plugins/insults.txt similarity index 100% rename from distbot/plugins/insults.txt rename to src/distbot/plugins/insults.txt diff --git a/distbot/plugins/lookup.py b/src/distbot/plugins/lookup.py similarity index 100% rename from distbot/plugins/lookup.py rename to src/distbot/plugins/lookup.py diff --git a/distbot/plugins/meta.py b/src/distbot/plugins/meta.py similarity index 100% rename from distbot/plugins/meta.py rename to src/distbot/plugins/meta.py diff --git a/distbot/plugins/morse.py b/src/distbot/plugins/morse.py similarity index 100% rename from distbot/plugins/morse.py rename to src/distbot/plugins/morse.py diff --git a/distbot/plugins/muc.py b/src/distbot/plugins/muc.py similarity index 96% rename from distbot/plugins/muc.py rename to src/distbot/plugins/muc.py index f67d171..0cb1557 100644 --- a/distbot/plugins/muc.py +++ b/src/distbot/plugins/muc.py @@ -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 index 0000000..12e5caf --- /dev/null +++ b/src/distbot/plugins/openai.py @@ -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/distbot/plugins/plugin_help.py b/src/distbot/plugins/plugin_help.py similarity index 100% rename from distbot/plugins/plugin_help.py rename to src/distbot/plugins/plugin_help.py diff --git a/distbot/plugins/queue_management.py b/src/distbot/plugins/queue_management.py similarity index 100% rename from distbot/plugins/queue_management.py rename to src/distbot/plugins/queue_management.py diff --git a/distbot/plugins/searx.py b/src/distbot/plugins/searx.py similarity index 100% rename from distbot/plugins/searx.py rename to src/distbot/plugins/searx.py diff --git a/distbot/plugins/translation.py b/src/distbot/plugins/translation.py similarity index 100% rename from distbot/plugins/translation.py rename to src/distbot/plugins/translation.py diff --git a/distbot/plugins/url.py b/src/distbot/plugins/url.py similarity index 100% rename from distbot/plugins/url.py rename to src/distbot/plugins/url.py diff --git a/distbot/plugins/youtube.py b/src/distbot/plugins/youtube.py similarity index 100% rename from distbot/plugins/youtube.py rename to src/distbot/plugins/youtube.py diff --git a/tests/test_integration/bot_test_utils.py b/tests/test_integration/bot_test_utils.py new file mode 100644 index 0000000..e102f6e --- /dev/null +++ b/tests/test_integration/bot_test_utils.py @@ -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 index 0000000..015e2ce --- /dev/null +++ b/tests/test_integration/conftest.py @@ -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 diff --git a/tests/test_integration/test_plugins.py b/tests/test_integration/test_plugins.py index 3227ca2..1d1955d 100644 --- a/tests/test_integration/test_plugins.py +++ b/tests/test_integration/test_plugins.py @@ -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" + )) diff --git a/tests/test_integration/test_translation.py b/tests/test_integration/test_translation.py index ec38cbb..74435df 100644 --- a/tests/test_integration/test_translation.py +++ b/tests/test_integration/test_translation.py @@ -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 -- 2.39.2