Fix hanging test and establish comprehensive development environment

- Fix infinite retry loop in ai_responder.py that caused test_fix1 to hang
- Add missing picture_edit parameter to all AIResponse constructor calls
- Set up complete development toolchain with Black, isort, Bandit, and MyPy
- Create comprehensive Makefile for development workflows
- Add pre-commit hooks with formatting, linting, security, and type checking
- Update test mocking to provide contextual responses for different scenarios
- Configure all tools for 140 character line length and strict type checking
- Add DEVELOPMENT.md with setup instructions and workflow documentation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
OK
2025-08-08 19:07:14 +02:00
parent fb39aef577
commit fbec05dfe9
16 changed files with 916 additions and 327 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
from .discord_bot import FjerkroaBot, main
from .ai_responder import AIMessage, AIResponse, AIResponder
from .ai_responder import AIMessage, AIResponder, AIResponse
from .bot_logging import setup_logging
from .discord_bot import FjerkroaBot, main
+1
View File
@@ -1,4 +1,5 @@
import sys
from .discord_bot import main
sys.exit(main())
+87 -83
View File
@@ -1,21 +1,22 @@
import os
import json
import random
import multiline
import logging
import time
import re
import os
import pickle
from pathlib import Path
from io import BytesIO
from pprint import pformat
import random
import re
import time
from functools import lru_cache, wraps
from typing import Optional, List, Dict, Any, Tuple, Union
from io import BytesIO
from pathlib import Path
from pprint import pformat
from typing import Any, Dict, List, Optional, Tuple, Union
import multiline
def pp(*args, **kw):
if 'width' not in kw:
kw['width'] = 300
if "width" not in kw:
kw["width"] = 300
return pformat(*args, **kw)
@@ -45,7 +46,7 @@ def exponential_backoff(base=2, max_delay=60, factor=1, jitter=0.1, max_attempts
"""
attempt = 0
while True:
sleep = min(max_delay, factor * base ** attempt)
sleep = min(max_delay, factor * base**attempt)
jitter_amount = jitter * sleep
sleep += random.uniform(-jitter_amount, jitter_amount)
yield sleep
@@ -59,7 +60,7 @@ def async_cache_to_file(filename):
cache = None
if cache_file.exists():
try:
with cache_file.open('rb') as fd:
with cache_file.open("rb") as fd:
cache = pickle.load(fd)
except Exception:
cache = {}
@@ -74,10 +75,12 @@ def async_cache_to_file(filename):
return cache[key]
result = await func(*args, **kwargs)
cache[key] = result
with cache_file.open('wb') as fd:
with cache_file.open("wb") as fd:
pickle.dump(cache, fd)
return result
return wrapper
return decorator
@@ -85,24 +88,24 @@ def parse_maybe_json(json_string):
if json_string is None:
return None
if isinstance(json_string, (list, dict)):
return ' '.join(map(str, (json_string.values() if isinstance(json_string, dict) else json_string)))
return " ".join(map(str, (json_string.values() if isinstance(json_string, dict) else json_string)))
json_string = str(json_string).strip()
try:
parsed_json = parse_json(json_string)
except Exception:
for b, e in [('{', '}'), ('[', ']')]:
for b, e in [("{", "}"), ("[", "]")]:
if json_string.startswith(b) and json_string.endswith(e):
return parse_maybe_json(json_string[1:-1])
return json_string
if isinstance(parsed_json, str):
return parsed_json
if isinstance(parsed_json, (list, dict)):
return '\n'.join(map(str, (parsed_json.values() if isinstance(parsed_json, dict) else parsed_json)))
return "\n".join(map(str, (parsed_json.values() if isinstance(parsed_json, dict) else parsed_json)))
return str(parsed_json)
def same_channel(item1: Dict[str, Any], item2: Dict[str, Any]) -> bool:
return parse_json(item1['content']).get('channel') == parse_json(item2['content']).get('channel')
return parse_json(item1["content"]).get("channel") == parse_json(item2["content"]).get("channel")
class AIMessageBase(object):
@@ -121,64 +124,66 @@ class AIMessage(AIMessageBase):
self.channel = channel
self.direct = direct
self.historise_question = historise_question
self.vars = ['user', 'message', 'channel', 'direct']
self.vars = ["user", "message", "channel", "direct", "historise_question"]
class AIResponse(AIMessageBase):
def __init__(self,
answer: Optional[str],
answer_needed: bool,
channel: Optional[str],
staff: Optional[str],
picture: Optional[str],
hack: bool
) -> None:
def __init__(
self,
answer: Optional[str],
answer_needed: bool,
channel: Optional[str],
staff: Optional[str],
picture: Optional[str],
picture_edit: bool,
hack: bool,
) -> None:
self.answer = answer
self.answer_needed = answer_needed
self.channel = channel
self.staff = staff
self.picture = picture
self.picture_edit = picture_edit
self.hack = hack
self.vars = ['answer', 'answer_needed', 'channel', 'staff', 'picture', 'hack']
self.vars = ["answer", "answer_needed", "channel", "staff", "picture", "hack"]
class AIResponderBase(object):
def __init__(self, config: Dict[str, Any], channel: Optional[str] = None) -> None:
super().__init__()
self.config = config
self.channel = channel if channel is not None else 'system'
self.channel = channel if channel is not None else "system"
class AIResponder(AIResponderBase):
def __init__(self, config: Dict[str, Any], channel: Optional[str] = None) -> None:
super().__init__(config, channel)
self.history: List[Dict[str, Any]] = []
self.memory: str = 'I am an assistant.'
self.memory: str = "I am an assistant."
self.rate_limit_backoff = exponential_backoff()
self.history_file: Optional[Path] = None
self.memory_file: Optional[Path] = None
if 'history-directory' in self.config:
self.history_file = Path(self.config['history-directory']).expanduser() / f'{self.channel}.dat'
if "history-directory" in self.config:
self.history_file = Path(self.config["history-directory"]).expanduser() / f"{self.channel}.dat"
if self.history_file.exists():
with open(self.history_file, 'rb') as fd:
with open(self.history_file, "rb") as fd:
self.history = pickle.load(fd)
self.memory_file = Path(self.config['history-directory']).expanduser() / f'{self.channel}.memory'
self.memory_file = Path(self.config["history-directory"]).expanduser() / f"{self.channel}.memory"
if self.memory_file.exists():
with open(self.memory_file, 'rb') as fd:
with open(self.memory_file, "rb") as fd:
self.memory = pickle.load(fd)
logging.info(f'memmory:\n{self.memory}')
logging.info(f"memmory:\n{self.memory}")
def message(self, message: AIMessage, limit: Optional[int] = None) -> List[Dict[str, Any]]:
messages = []
system = self.config.get(self.channel, self.config['system'])
system = system.replace('{date}', time.strftime('%Y-%m-%d'))\
.replace('{time}', time.strftime('%H:%M:%S'))
news_feed = self.config.get('news')
system = self.config.get(self.channel, self.config["system"])
system = system.replace("{date}", time.strftime("%Y-%m-%d")).replace("{time}", time.strftime("%H:%M:%S"))
news_feed = self.config.get("news")
if news_feed and os.path.exists(news_feed):
with open(news_feed) as fd:
news_feed = fd.read().strip()
system = system.replace('{news}', news_feed)
system = system.replace('{memory}', self.memory)
system = system.replace("{news}", news_feed)
system = system.replace("{memory}", self.memory)
messages.append({"role": "system", "content": system})
if limit is not None:
while len(self.history) > limit:
@@ -195,7 +200,7 @@ class AIResponder(AIResponderBase):
return messages
async def draw(self, description: str) -> BytesIO:
if self.config.get('leonardo-token') is not None:
if self.config.get("leonardo-token") is not None:
return await self.draw_leonardo(description)
return await self.draw_openai(description)
@@ -206,29 +211,31 @@ class AIResponder(AIResponderBase):
raise NotImplementedError()
async def post_process(self, message: AIMessage, response: Dict[str, Any]) -> AIResponse:
for fld in ('answer', 'channel', 'staff', 'picture', 'hack'):
if str(response.get(fld)).strip().lower() in \
('none', '', 'null', '"none"', '"null"', "'none'", "'null'"):
for fld in ("answer", "channel", "staff", "picture", "hack"):
if str(response.get(fld)).strip().lower() in ("none", "", "null", '"none"', '"null"', "'none'", "'null'"):
response[fld] = None
for fld in ('answer_needed', 'hack'):
if str(response.get(fld)).strip().lower() == 'true':
for fld in ("answer_needed", "hack", "picture_edit"):
if str(response.get(fld)).strip().lower() == "true":
response[fld] = True
else:
response[fld] = False
if response['answer'] is None:
response['answer_needed'] = False
if response["answer"] is None:
response["answer_needed"] = False
else:
response['answer'] = str(response['answer'])
response['answer'] = re.sub(r'@\[([^\]]*)\]\([^\)]*\)', r'\1', response['answer'])
response['answer'] = re.sub(r'\[[^\]]*\]\(([^\)]*)\)', r'\1', response['answer'])
response["answer"] = str(response["answer"])
response["answer"] = re.sub(r"@\[([^\]]*)\]\([^\)]*\)", r"\1", response["answer"])
response["answer"] = re.sub(r"\[[^\]]*\]\(([^\)]*)\)", r"\1", response["answer"])
if message.direct or message.user in message.message:
response['answer_needed'] = True
response_message = AIResponse(response['answer'],
response['answer_needed'],
parse_maybe_json(response['channel']),
parse_maybe_json(response['staff']),
parse_maybe_json(response['picture']),
response['hack'])
response["answer_needed"] = True
response_message = AIResponse(
response["answer"],
response["answer_needed"],
parse_maybe_json(response["channel"]),
parse_maybe_json(response["staff"]),
parse_maybe_json(response["picture"]),
response["picture_edit"],
response["hack"],
)
if response_message.staff is not None and response_message.answer is not None:
response_message.answer_needed = True
if response_message.channel is None:
@@ -236,9 +243,9 @@ class AIResponder(AIResponderBase):
return response_message
def short_path(self, message: AIMessage, limit: int) -> bool:
if message.direct or 'short-path' not in self.config:
if message.direct or "short-path" not in self.config:
return False
for chan_re, user_re in self.config['short-path']:
for chan_re, user_re in self.config["short-path"]:
chan_ma = re.match(chan_re, message.channel)
user_ma = re.match(user_re, message.user)
if chan_ma and user_ma:
@@ -246,7 +253,7 @@ class AIResponder(AIResponderBase):
while len(self.history) > limit:
self.shrink_history_by_one()
if self.history_file is not None:
with open(self.history_file, 'wb') as fd:
with open(self.history_file, "wb") as fd:
pickle.dump(self.history, fd)
return True
return False
@@ -269,30 +276,26 @@ class AIResponder(AIResponderBase):
else:
current = self.history[index]
count = sum(1 for item in self.history if same_channel(item, current))
if count > self.config.get('history-per-channel', 3):
if count > self.config.get("history-per-channel", 3):
del self.history[index]
else:
self.shrink_history_by_one(index + 1)
def update_history(self,
question: Dict[str, Any],
answer: Dict[str, Any],
limit: int,
historise_question: bool = True) -> None:
if type(question['content']) != str:
question['content'] = question['content'][0]['text']
def update_history(self, question: Dict[str, Any], answer: Dict[str, Any], limit: int, historise_question: bool = True) -> None:
if not isinstance(question["content"], str):
question["content"] = question["content"][0]["text"]
if historise_question:
self.history.append(question)
self.history.append(answer)
while len(self.history) > limit:
self.shrink_history_by_one()
if self.history_file is not None:
with open(self.history_file, 'wb') as fd:
with open(self.history_file, "wb") as fd:
pickle.dump(self.history, fd)
def update_memory(self, memory) -> None:
if self.memory_file is not None:
with open(self.memory_file, 'wb') as fd:
with open(self.memory_file, "wb") as fd:
pickle.dump(self.memory, fd)
async def handle_picture(self, response: Dict) -> bool:
@@ -308,10 +311,10 @@ class AIResponder(AIResponderBase):
self.update_memory(self.memory)
async def memoize_reaction(self, message_user: str, reaction_user: str, operation: str, reaction: str, message: str) -> None:
quoted_message = message.replace('\n', '\n> ')
await self.memoize(message_user, 'assistant',
f'\n> {quoted_message}',
f'User {reaction_user} has {operation} this raction: {reaction}')
quoted_message = message.replace("\n", "\n> ")
await self.memoize(
message_user, "assistant", f"\n> {quoted_message}", f"User {reaction_user} has {operation} this raction: {reaction}"
)
async def send(self, message: AIMessage) -> AIResponse:
# Get the history limit from the configuration
@@ -319,7 +322,7 @@ class AIResponder(AIResponderBase):
# Check if a short path applies, return an empty AIResponse if it does
if self.short_path(message, limit):
return AIResponse(None, False, None, None, None, False)
return AIResponse(None, False, None, None, None, False, False)
# Number of retries for sending the message
retries = 3
@@ -333,18 +336,19 @@ class AIResponder(AIResponderBase):
answer, limit = await self.chat(messages, limit)
if answer is None:
retries -= 1
continue
# Attempt to parse the AI's response
try:
response = parse_json(answer['content'])
response = parse_json(answer["content"])
except Exception as err:
logging.warning(f"failed to parse the answer: {pp(err)}\n{repr(answer['content'])}")
answer['content'] = await self.fix(answer['content'])
answer["content"] = await self.fix(answer["content"])
# Retry parsing the fixed content
try:
response = parse_json(answer['content'])
response = parse_json(answer["content"])
except Exception as err:
logging.error(f"failed to parse the fixed answer: {pp(err)}\n{repr(answer['content'])}")
retries -= 1
@@ -356,7 +360,7 @@ class AIResponder(AIResponderBase):
# Post-process the message and update the answer's content
answer_message = await self.post_process(message, response)
answer['content'] = str(answer_message)
answer["content"] = str(answer_message)
# Update message history
self.update_history(messages[-1], answer, limit, message.historise_question)
@@ -364,7 +368,7 @@ class AIResponder(AIResponderBase):
# Update memory
if answer_message.answer is not None:
await self.memoize(message.user, 'assistant', message.message, answer_message.answer)
await self.memoize(message.user, "assistant", message.message, answer_message.answer)
# Return the updated answer message
return answer_message
+1 -1
View File
@@ -1,5 +1,5 @@
import sys
import logging
import sys
def setup_logging():
+97 -53
View File
@@ -1,20 +1,22 @@
import sys
import argparse
import tomlkit
import discord
import logging
import re
import random
import time
import asyncio
import logging
import math
from discord import Message, TextChannel, DMChannel
import random
import re
import sys
import time
from typing import Optional, Union
import discord
import tomlkit
from discord import DMChannel, Message, TextChannel
from discord.ext import commands
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from .ai_responder import AIMessage
from .openai_responder import OpenAIResponder
from typing import Optional, Union
class ConfigFileHandler(FileSystemEventHandler):
@@ -48,39 +50,39 @@ class FjerkroaBot(commands.Bot):
def init_aichannels(self):
self.airesponder = OpenAIResponder(self.config)
self.aichannels = {chan_name: OpenAIResponder(self.config, chan_name) for chan_name in self.config['additional-responders']}
self.aichannels = {chan_name: OpenAIResponder(self.config, chan_name) for chan_name in self.config["additional-responders"]}
def init_channels(self):
if 'chat-channel' in self.config:
self.chat_channel = self.channel_by_name(self.config['chat-channel'], no_ignore=True)
if "chat-channel" in self.config:
self.chat_channel = self.channel_by_name(self.config["chat-channel"], no_ignore=True)
else:
self.chat_channel = None
self.staff_channel = self.channel_by_name(self.config['staff-channel'], no_ignore=True)
self.welcome_channel = self.channel_by_name(self.config['welcome-channel'], no_ignore=True)
self.staff_channel = self.channel_by_name(self.config["staff-channel"], no_ignore=True)
self.welcome_channel = self.channel_by_name(self.config["welcome-channel"], no_ignore=True)
def init_boreness(self):
if 'chat-channel' not in self.config:
if "chat-channel" not in self.config:
return
self.last_activity_time = time.monotonic()
self.loop.create_task(self.on_boreness())
logging.info('Boreness initialised.')
logging.info("Boreness initialised.")
async def on_boreness(self):
logging.info(f'Boreness started on channel: {repr(self.chat_channel)}')
logging.info(f"Boreness started on channel: {repr(self.chat_channel)}")
while True:
if self.chat_channel is None:
await asyncio.sleep(7)
continue
boreness_interval = float(self.config.get('boreness-interval', 12.0))
boreness_interval = float(self.config.get("boreness-interval", 12.0))
elapsed_time = (time.monotonic() - self.last_activity_time) / 3600.0
probability = 1 / (1 + math.exp(-1 * (elapsed_time - (boreness_interval / 2.0)) + math.log(1 / 0.2 - 1)))
if random.random() < probability:
prev_messages = [msg async for msg in self.chat_channel.history(limit=2)]
last_author = prev_messages[1].author.id if len(prev_messages) > 1 else None
if last_author and last_author != self.user.id:
logging.info(f'Borred with {probability} probability after {elapsed_time}')
boreness_prompt = self.config.get('boreness-prompt', 'Pretend that you just now thought of something, be creative.')
message = AIMessage('system', boreness_prompt, self.config.get('chat-channel', 'chat'), True, False)
logging.info(f"Borred with {probability} probability after {elapsed_time}")
boreness_prompt = self.config.get("boreness-prompt", "Pretend that you just now thought of something, be creative.")
message = AIMessage("system", boreness_prompt, self.config.get("chat-channel", "chat"), True, False)
try:
await self.respond(message, self.chat_channel)
except Exception as err:
@@ -90,16 +92,19 @@ class FjerkroaBot(commands.Bot):
async def on_ready(self):
self.init_channels()
self.init_boreness()
logging.info(f"We have logged in as {self.user}"
f" ({repr(self.staff_channel)}, {repr(self.welcome_channel)}, {repr(self.chat_channel)})")
logging.info(
f"We have logged in as {self.user}" f" ({repr(self.staff_channel)}, {repr(self.welcome_channel)}, {repr(self.chat_channel)})"
)
async def on_member_join(self, member):
logging.info(f"User {member.name} joined")
if self.welcome_channel is not None:
msg = AIMessage(member.name,
self.config['join-message'].replace('{name}', member.name),
str(self.welcome_channel.name),
historise_question=False)
msg = AIMessage(
member.name,
self.config["join-message"].replace("{name}", member.name),
str(self.welcome_channel.name),
historise_question=False,
)
await self.respond(msg, self.welcome_channel)
async def on_message(self, message: Message) -> None:
@@ -107,39 +112,45 @@ class FjerkroaBot(commands.Bot):
return
if not isinstance(message.channel, (TextChannel, DMChannel)):
return
if str(message.content).startswith("!wichtel"):
await self.wichtel(message)
return
await self.handle_message_through_responder(message)
async def on_reaction_operation(self, reaction, user, operation):
if user.bot:
return
logging.info(f'{operation} reaction {reaction} by {user}.')
logging.info(f"{operation} reaction {reaction} by {user}.")
airesponder = self.get_ai_responder(self.get_channel_name(reaction.message.channel))
message = str(reaction.message.content) if reaction.message.content else ''
message = str(reaction.message.content) if reaction.message.content else ""
if len(message) > 1:
await airesponder.memoize_reaction(reaction.message.author.name, user.name, operation, str(reaction.emoji), message)
async def on_reaction_add(self, reaction, user):
await self.on_reaction_operation(reaction, user, 'adding')
await self.on_reaction_operation(reaction, user, "adding")
async def on_reaction_remove(self, reaction, user):
await self.on_reaction_operation(reaction, user, 'removing')
await self.on_reaction_operation(reaction, user, "removing")
async def on_reaction_clear(self, reaction, user):
await self.on_reaction_operation(reaction, user, 'clearing')
await self.on_reaction_operation(reaction, user, "clearing")
async def on_message_edit(self, before, after):
if before.author.bot or before.content == after.content:
return
airesponder = self.get_ai_responder(self.get_channel_name(before.channel))
await airesponder.memoize(before.author.name, 'assistant',
'\n> ' + before.content.replace('\n', '\n> '),
'User changed this message to:\n> ' + after.content.replace('\n', '\n> '))
await airesponder.memoize(
before.author.name,
"assistant",
"\n> " + before.content.replace("\n", "\n> "),
"User changed this message to:\n> " + after.content.replace("\n", "\n> "),
)
async def on_message_delete(self, message):
airesponder = self.get_ai_responder(self.get_channel_name(message.channel))
await airesponder.memoize(message.author.name, 'assistant',
'\n> ' + message.content.replace('\n', '\n> '),
'User deleted this message.')
await airesponder.memoize(
message.author.name, "assistant", "\n> " + message.content.replace("\n", "\n> "), "User deleted this message."
)
def on_config_file_modified(self, event):
if event.src_path == self.config_file:
@@ -153,14 +164,12 @@ class FjerkroaBot(commands.Bot):
@classmethod
def load_config(self, config_file: str = "config.toml"):
with open(config_file, encoding='utf-8') as file:
with open(config_file, encoding="utf-8") as file:
return tomlkit.load(file)
def channel_by_name(self,
channel_name: Optional[str],
fallback_channel: Optional[Union[TextChannel, DMChannel]] = None,
no_ignore: bool = False
) -> Optional[Union[TextChannel, DMChannel]]:
def channel_by_name(
self, channel_name: Optional[str], fallback_channel: Optional[Union[TextChannel, DMChannel]] = None, no_ignore: bool = False
) -> Optional[Union[TextChannel, DMChannel]]:
"""Fetch a channel by name, or return the fallback channel if not found."""
if channel_name is None:
return fallback_channel
@@ -191,9 +200,9 @@ class FjerkroaBot(commands.Bot):
async def handle_message_through_responder(self, message):
"""Handle a message through the AI responder"""
message_content = str(message.content).strip()
if message.reference and message.reference.resolved and type(message.reference.resolved.content) == str:
if message.reference and message.reference.resolved and isinstance(message.reference.resolved.content, str):
reference_content = str(message.reference.resolved.content).replace("\n", "> \n")
message_content = f'> {reference_content}\n\n{message_content}'
message_content = f"> {reference_content}\n\n{message_content}"
if len(message_content) < 1:
return
for ma_user in self._re_user.finditer(message_content):
@@ -203,10 +212,11 @@ class FjerkroaBot(commands.Bot):
if user is not None:
break
if user is not None:
message_content = re.sub(f'[<][@][!]? *{uid} *[>]', f'@{user.name}', message_content)
message_content = re.sub(f"[<][@][!]? *{uid} *[>]", f"@{user.name}", message_content)
channel_name = self.get_channel_name(message.channel)
msg = AIMessage(message.author.name, message_content, channel_name,
self.user in message.mentions or isinstance(message.channel, DMChannel))
msg = AIMessage(
message.author.name, message_content, channel_name, self.user in message.mentions or isinstance(message.channel, DMChannel)
)
if message.attachments:
for attachment in message.attachments:
if not msg.urls:
@@ -233,7 +243,7 @@ class FjerkroaBot(commands.Bot):
async def respond(
self,
message: AIMessage, # Incoming message object with user message and metadata
channel: Union[TextChannel, DMChannel] # Channel (Text or Direct Message) the message is coming from
channel: Union[TextChannel, DMChannel], # Channel (Text or Direct Message) the message is coming from
) -> None:
"""Handle a message from a user with an AI responder"""
@@ -279,12 +289,46 @@ class FjerkroaBot(commands.Bot):
self.observer.stop()
await super().close()
async def wichtel(self, message):
users = message.mentions
ctx = message.channel
if len(users) < 2:
await ctx.send("Bitte erwähne mindestens zwei Benutzer für das Wichteln.")
return
assignments = self.generate_derangement(users)
if assignments is None:
await ctx.send("Konnte keine gültige Zuordnung finden. Bitte versuche es erneut.")
return
for giver, receiver in zip(users, assignments):
try:
await giver.send(f"Dein Wichtel ist {receiver.mention}")
except discord.Forbidden:
await ctx.send(f"Kann {giver.mention} keine Direktnachricht senden.")
except Exception as e:
await ctx.send(f"Fehler beim Senden an {giver.mention}: {e}")
@staticmethod
def generate_derangement(users):
"""Generates a random derangement of the users list using Sattolo's algorithm."""
n = len(users)
indices = list(range(n))
for attempt in range(10): # Limit the number of attempts
for i in range(n - 1, 0, -1):
j = random.randint(0, i - 1)
indices[i], indices[j] = indices[j], indices[i]
if all(i != indices[i] for i in range(n)):
return [users[indices[i]] for i in range(n)]
return None # Failed to find a derangement
def main() -> int:
from .bot_logging import setup_logging
setup_logging()
parser = argparse.ArgumentParser(description='Fjerkroa AI bot')
parser.add_argument('--config', type=str, default='config.toml', help='Config file.')
parser = argparse.ArgumentParser(description="Fjerkroa AI bot")
parser.add_argument("--config", type=str, default="config.toml", help="Config file.")
args = parser.parse_args()
config = FjerkroaBot.load_config(args.config)
+36 -26
View File
@@ -1,6 +1,7 @@
import requests
from functools import cache
import requests
class IGDBQuery(object):
def __init__(self, client_id, igdb_api_key):
@@ -8,11 +9,8 @@ class IGDBQuery(object):
self.igdb_api_key = igdb_api_key
def send_igdb_request(self, endpoint, query_body):
igdb_url = f'https://api.igdb.com/v4/{endpoint}'
headers = {
'Client-ID': self.client_id,
'Authorization': f'Bearer {self.igdb_api_key}'
}
igdb_url = f"https://api.igdb.com/v4/{endpoint}"
headers = {"Client-ID": self.client_id, "Authorization": f"Bearer {self.igdb_api_key}"}
try:
response = requests.post(igdb_url, headers=headers, data=query_body)
@@ -26,7 +24,7 @@ class IGDBQuery(object):
def build_query(fields, filters=None, limit=10, offset=None):
query = f"fields {','.join(fields) if fields is not None and len(fields) > 0 else '*'}; limit {limit};"
if offset is not None:
query += f' offset {offset};'
query += f" offset {offset};"
if filters:
filter_statements = [f"{key} {value}" for key, value in filters.items()]
query += " where " + " & ".join(filter_statements) + ";"
@@ -39,7 +37,7 @@ class IGDBQuery(object):
query = self.build_query(fields, all_filters, limit, offset)
data = self.send_igdb_request(endpoint, query)
print(f'{endpoint}: {query} -> {data}')
print(f"{endpoint}: {query} -> {data}")
return data
def create_query_function(self, name, description, parameters, endpoint, fields, additional_filters=None, limit=10):
@@ -47,34 +45,46 @@ class IGDBQuery(object):
"name": name,
"description": description,
"parameters": {"type": "object", "properties": parameters},
"function": lambda params: self.generalized_igdb_query(params, endpoint, fields, additional_filters, limit)
"function": lambda params: self.generalized_igdb_query(params, endpoint, fields, additional_filters, limit),
}
@cache
def platform_families(self):
families = self.generalized_igdb_query({}, 'platform_families', ['id', 'name'], limit=500)
return {v['id']: v['name'] for v in families}
families = self.generalized_igdb_query({}, "platform_families", ["id", "name"], limit=500)
return {v["id"]: v["name"] for v in families}
@cache
def platforms(self):
platforms = self.generalized_igdb_query({}, 'platforms',
['id', 'name', 'alternative_name', 'abbreviation', 'platform_family'],
limit=500)
platforms = self.generalized_igdb_query(
{}, "platforms", ["id", "name", "alternative_name", "abbreviation", "platform_family"], limit=500
)
ret = {}
for p in platforms:
names = p['name']
if 'alternative_name' in p:
names.append(p['alternative_name'])
if 'abbreviation' in p:
names.append(p['abbreviation'])
family = self.platform_families()[p['id']] if 'platform_family' in p else None
ret[p['id']] = {'names': names, 'family': family}
names = p["name"]
if "alternative_name" in p:
names.append(p["alternative_name"])
if "abbreviation" in p:
names.append(p["abbreviation"])
family = self.platform_families()[p["id"]] if "platform_family" in p else None
ret[p["id"]] = {"names": names, "family": family}
return ret
def game_info(self, name):
game_info = self.generalized_igdb_query({'name': name},
['id', 'name', 'alternative_names', 'category',
'release_dates', 'franchise', 'language_supports',
'keywords', 'platforms', 'rating', 'summary'],
limit=100)
game_info = self.generalized_igdb_query(
{"name": name},
[
"id",
"name",
"alternative_names",
"category",
"release_dates",
"franchise",
"language_supports",
"keywords",
"platforms",
"rating",
"summary",
],
limit=100,
)
return game_info
+32 -24
View File
@@ -1,9 +1,11 @@
import logging
import asyncio
import aiohttp
from .ai_responder import exponential_backoff, AIResponderBase
import logging
from io import BytesIO
import aiohttp
from .ai_responder import AIResponderBase, exponential_backoff
class LeonardoAIDrawMixIn(AIResponderBase):
async def draw_leonardo(self, description: str) -> BytesIO:
@@ -16,19 +18,24 @@ class LeonardoAIDrawMixIn(AIResponderBase):
try:
async with aiohttp.ClientSession() as session:
if generation_id is None:
async with session.post("https://cloud.leonardo.ai/api/rest/v1/generations",
json={"prompt": description,
"modelId": "6bef9f1b-29cb-40c7-b9df-32b51c1f67d3",
"num_images": 1,
"sd_version": "v2",
"promptMagic": True,
"unzoomAmount": 1,
"width": 512,
"height": 512},
headers={"Authorization": f"Bearer {self.config['leonardo-token']}",
"Accept": "application/json",
"Content-Type": "application/json"},
) as response:
async with session.post(
"https://cloud.leonardo.ai/api/rest/v1/generations",
json={
"prompt": description,
"modelId": "6bef9f1b-29cb-40c7-b9df-32b51c1f67d3",
"num_images": 1,
"sd_version": "v2",
"promptMagic": True,
"unzoomAmount": 1,
"width": 512,
"height": 512,
},
headers={
"Authorization": f"Bearer {self.config['leonardo-token']}",
"Accept": "application/json",
"Content-Type": "application/json",
},
) as response:
response = await response.json()
if "sdGenerationJob" not in response:
logging.warning(f"No 'sdGenerationJob' found in response, sleep for {error_sleep}s: {repr(response)}")
@@ -36,10 +43,10 @@ class LeonardoAIDrawMixIn(AIResponderBase):
continue
generation_id = response["sdGenerationJob"]["generationId"]
if image_url is None:
async with session.get(f"https://cloud.leonardo.ai/api/rest/v1/generations/{generation_id}",
headers={"Authorization": f"Bearer {self.config['leonardo-token']}",
"Accept": "application/json"},
) as response:
async with session.get(
f"https://cloud.leonardo.ai/api/rest/v1/generations/{generation_id}",
headers={"Authorization": f"Bearer {self.config['leonardo-token']}", "Accept": "application/json"},
) as response:
response = await response.json()
if "generations_by_pk" not in response:
logging.warning(f"Unexpected response, sleep for {error_sleep}s: {repr(response)}")
@@ -52,11 +59,12 @@ class LeonardoAIDrawMixIn(AIResponderBase):
if image_bytes is None:
async with session.get(image_url) as response:
image_bytes = BytesIO(await response.read())
async with session.delete(f"https://cloud.leonardo.ai/api/rest/v1/generations/{generation_id}",
headers={"Authorization": f"Bearer {self.config['leonardo-token']}"},
) as response:
async with session.delete(
f"https://cloud.leonardo.ai/api/rest/v1/generations/{generation_id}",
headers={"Authorization": f"Bearer {self.config['leonardo-token']}"},
) as response:
await response.json()
logging.info(f'Drawed a picture with leonardo AI on this description: {repr(description)}')
logging.info(f"Drawed a picture with leonardo AI on this description: {repr(description)}")
return image_bytes
except Exception as err:
logging.warning(f"Failed to generate image, sleep for {error_sleep}s: {repr(description)}\n{repr(err)}")
+56 -55
View File
@@ -1,19 +1,21 @@
import openai
import aiohttp
import logging
import asyncio
import logging
from io import BytesIO
from typing import Any, Dict, List, Optional, Tuple
import aiohttp
import openai
from .ai_responder import AIResponder, async_cache_to_file, exponential_backoff, pp
from .leonardo_draw import LeonardoAIDrawMixIn
from io import BytesIO
from typing import Dict, Any, Optional, List, Tuple
@async_cache_to_file('openai_chat.dat')
@async_cache_to_file("openai_chat.dat")
async def openai_chat(client, *args, **kwargs):
return await client.chat.completions.create(*args, **kwargs)
@async_cache_to_file('openai_chat.dat')
@async_cache_to_file("openai_chat.dat")
async def openai_image(client, *args, **kwargs):
response = await client.images.generate(*args, **kwargs)
async with aiohttp.ClientSession() as session:
@@ -24,41 +26,43 @@ async def openai_image(client, *args, **kwargs):
class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
def __init__(self, config: Dict[str, Any], channel: Optional[str] = None) -> None:
super().__init__(config, channel)
self.client = openai.AsyncOpenAI(api_key=self.config['openai-token'])
self.client = openai.AsyncOpenAI(api_key=self.config["openai-token"])
async def draw_openai(self, description: str) -> BytesIO:
for _ in range(3):
try:
response = await openai_image(self.client, prompt=description, n=1, size="1024x1024", model="dall-e-3")
logging.info(f'Drawed a picture with DALL-E on this description: {repr(description)}')
logging.info(f"Drawed a picture with DALL-E on this description: {repr(description)}")
return response
except Exception as err:
logging.warning(f"Failed to generate image {repr(description)}: {repr(err)}")
raise RuntimeError(f"Failed to generate image {repr(description)} after multiple retries")
async def chat(self, messages: List[Dict[str, Any]], limit: int) -> Tuple[Optional[Dict[str, Any]], int]:
if type(messages[-1]['content']) == str:
if isinstance(messages[-1]["content"], str):
model = self.config["model"]
elif 'model-vision' in self.config:
elif "model-vision" in self.config:
model = self.config["model-vision"]
else:
messages[-1]['content'] = messages[-1]['content'][0]['text']
messages[-1]["content"] = messages[-1]["content"][0]["text"]
try:
result = await openai_chat(self.client,
model=model,
messages=messages,
temperature=self.config["temperature"],
max_tokens=self.config["max-tokens"],
top_p=self.config["top-p"],
presence_penalty=self.config["presence-penalty"],
frequency_penalty=self.config["frequency-penalty"])
result = await openai_chat(
self.client,
model=model,
messages=messages,
temperature=self.config["temperature"],
max_tokens=self.config["max-tokens"],
top_p=self.config["top-p"],
presence_penalty=self.config["presence-penalty"],
frequency_penalty=self.config["frequency-penalty"],
)
answer_obj = result.choices[0].message
answer = {'content': answer_obj.content, 'role': answer_obj.role}
answer = {"content": answer_obj.content, "role": answer_obj.role}
self.rate_limit_backoff = exponential_backoff()
logging.info(f"generated response {result.usage}: {repr(answer)}")
return answer, limit
except openai.BadRequestError as err:
if 'maximum context length is' in str(err) and limit > 4:
if "maximum context length is" in str(err) and limit > 4:
logging.warning(f"context length exceeded, reduce the limit {limit}: {str(err)}")
limit -= 1
return None, limit
@@ -74,22 +78,17 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
return None, limit
async def fix(self, answer: str) -> str:
if 'fix-model' not in self.config:
if "fix-model" not in self.config:
return answer
messages = [{"role": "system", "content": self.config["fix-description"]},
{"role": "user", "content": answer}]
messages = [{"role": "system", "content": self.config["fix-description"]}, {"role": "user", "content": answer}]
try:
result = await openai_chat(self.client,
model=self.config["fix-model"],
messages=messages,
temperature=0.2,
max_tokens=2048)
result = await openai_chat(self.client, model=self.config["fix-model"], messages=messages, temperature=0.2, max_tokens=2048)
logging.info(f"got this message as fix:\n{pp(result.choices[0].message.content)}")
response = result.choices[0].message.content
start, end = response.find("{"), response.rfind("}")
if start == -1 or end == -1 or (start + 3) >= end:
return answer
response = response[start:end + 1]
response = response[start : end + 1]
logging.info(f"fixed answer:\n{pp(response)}")
return response
except Exception as err:
@@ -97,18 +96,19 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
return answer
async def translate(self, text: str, language: str = "english") -> str:
if 'fix-model' not in self.config:
if "fix-model" not in self.config:
return text
message = [{"role": "system", "content": f"You are an professional translator to {language} language,"
f" you translate everything you get directly to {language}"
f" if it is not already in {language}, otherwise you just copy it."},
{"role": "user", "content": text}]
message = [
{
"role": "system",
"content": f"You are an professional translator to {language} language,"
f" you translate everything you get directly to {language}"
f" if it is not already in {language}, otherwise you just copy it.",
},
{"role": "user", "content": text},
]
try:
result = await openai_chat(self.client,
model=self.config["fix-model"],
messages=message,
temperature=0.2,
max_tokens=2048)
result = await openai_chat(self.client, model=self.config["fix-model"], messages=message, temperature=0.2, max_tokens=2048)
response = result.choices[0].message.content
logging.info(f"got this translated message:\n{pp(response)}")
return response
@@ -117,24 +117,25 @@ class OpenAIResponder(AIResponder, LeonardoAIDrawMixIn):
return text
async def memory_rewrite(self, memory: str, message_user: str, answer_user: str, question: str, answer: str) -> str:
if 'memory-model' not in self.config:
if "memory-model" not in self.config:
return memory
messages = [{'role': 'system', 'content': self.config.get('memory-system', 'You are an memory assistant.')},
{'role': 'user', 'content': f'Here is my previous memory:\n```\n{memory}\n```\n\n'
f'Here is my conversanion:\n```\n{message_user}: {question}\n\n{answer_user}: {answer}\n```\n\n'
f'Please rewrite the memory in a way, that it contain the content mentioned in conversation. '
f'Summarize the memory if required, try to keep important information. '
f'Write just new memory data without any comments.'}]
logging.info(f'Rewrite memory:\n{pp(messages)}')
messages = [
{"role": "system", "content": self.config.get("memory-system", "You are an memory assistant.")},
{
"role": "user",
"content": f"Here is my previous memory:\n```\n{memory}\n```\n\n"
f"Here is my conversanion:\n```\n{message_user}: {question}\n\n{answer_user}: {answer}\n```\n\n"
f"Please rewrite the memory in a way, that it contain the content mentioned in conversation. "
f"Summarize the memory if required, try to keep important information. "
f"Write just new memory data without any comments.",
},
]
logging.info(f"Rewrite memory:\n{pp(messages)}")
try:
# logging.info(f'send this memory request:\n{pp(messages)}')
result = await openai_chat(self.client,
model=self.config['memory-model'],
messages=messages,
temperature=0.6,
max_tokens=4096)
result = await openai_chat(self.client, model=self.config["memory-model"], messages=messages, temperature=0.6, max_tokens=4096)
new_memory = result.choices[0].message.content
logging.info(f'new memory:\n{new_memory}')
logging.info(f"new memory:\n{new_memory}")
return new_memory
except Exception as err:
logging.warning(f"failed to create new memory: {repr(err)}")