diff --git a/reginaldCog/demo_bot.py b/reginaldCog/demo_bot.py new file mode 100644 index 0000000..8fd3756 --- /dev/null +++ b/reginaldCog/demo_bot.py @@ -0,0 +1,38 @@ +import os +import discord +from discord.ext import commands +from reginaldCog.messenger_clients.services import MessageService + +TOKEN = os.getenv('SCREAMING_OPOSSUM') # Your Discord bot token goes here +intents = discord.Intents.default() +intents.message_content = True +bot = commands.Bot(command_prefix='!', intents=intents) + + +@bot.event +async def on_ready(): + print(f'Logged in as {bot.user} (ID: {bot.user.id})') + print('------') + try: + synced = await bot.tree.sync() + print(f'Synced {len(synced)} command(s).') + except Exception as e: + print(f'Failed to sync commands: {e}') + + +@bot.event +async def on_message(message: discord.Message): + + if message.author == bot.user: + return + + async with message.channel.typing(): + message_service = MessageService(message) + response = await message_service.get_llm_response() + await message.channel.send(response) + print(response) + +if __name__ == '__main__': + if TOKEN is None: + raise RuntimeError('Discord token is not set') + bot.run(TOKEN) diff --git a/reginaldCog/llm_clients/llm_client.py b/reginaldCog/llm_clients/llm_client.py new file mode 100644 index 0000000..e256bc8 --- /dev/null +++ b/reginaldCog/llm_clients/llm_client.py @@ -0,0 +1,192 @@ +from abc import ABC, abstractmethod +from enum import Enum +from openai import OpenAI +from discord import Message +from reginaldCog.messenger_clients.messenger_client import ClientMessage, DiscordMessageAdapter + + +class ILLMContent(ABC): + pass + + +class OpenAIContent(ILLMContent): + def __init__(self): + self._content_items: list[dict[str, str]] = [] + + @property + def content_items(self) -> list[dict]: + return self._content_items + + @content_items.setter + def content_items(self, value: list[dict[str, str]]): + self._content_items = value + + +class ILLMContentBuilder(ABC): + pass + + +class OpenAIContentBuilder(ILLMContentBuilder): + def __init__(self, content: OpenAIContent): + self.content = content + + def add_output_text(self, text: str): + item = {"type": "output_text", "text": text} + self.content.content_items.append(item) + return self + + def add_input_text(self, text: str): + item = {"type": "input_text", "text": text} + self.content.content_items.append(item) + return self + + def add_input_image(self, image_url: str): + item = {"type": "input_image", "image_url": image_url} + self.content.content_items.append(item) + return self + + def add_from_dict(self, item: dict): + self.content.content_items.append(item) + return self + + +class ILLMMessage(ABC): + pass + + +class OpenAIMessage(ILLMMessage): + def __init__(self): + self.content = OpenAIContent() + self.role = "" + + @property + def to_dict(self): + return {"role": self.role, "content": self.content.content_items} + + +class ILLMMessageBuilder(ABC): + pass + + +class OpenAIMessageBuilder(ILLMMessageBuilder): + def __init__(self, message: OpenAIMessage): + self.message = message + + def set_role(self, role: str): + self.message.role = role + return self + + def set_content(self, content: OpenAIContent): + self.message.content = content + return self + + +class ILLMPrompt(ABC): + pass + + +class OpenAIPrompt(ILLMPrompt): + def __init__(self): + self.messages = [] + + @property + def to_list(self): + return [i_message.to_dict for i_message in self.messages] + + +class ILLMPromptBuilder(ABC): + @abstractmethod + def __init__(self, prompt: ILLMPrompt): + pass + + @abstractmethod + def add_message(self, message: ILLMMessage): + pass + + +class OpenAIPromptBuilder(ILLMPromptBuilder): + def __init__(self, prompt: OpenAIPrompt): + self.prompt = prompt + + def add_message(self, message: OpenAIMessage): + self.prompt.messages.append(message) + + +class ILLMClient(ABC): + @abstractmethod + def get_response(self, prompt: ILLMPrompt): + pass + + +class OpenAIClient(ILLMClient): + content_class = OpenAIContent + content_builder_class = OpenAIContentBuilder + message_class = OpenAIMessage + message_builder_class = OpenAIMessageBuilder + prompt_class = OpenAIPrompt + prompt_builder_class = OpenAIPromptBuilder + + def __init__(self): + self.model = 'gpt-4.1-mini' + self.client = OpenAI() + + def get_response(self, prompt: OpenAIPrompt): + response_input = {"model": self.model, "input": prompt.to_list} + return self.client.responses.create(**response_input) + + +class IMessageAdapter(ABC): + @abstractmethod + def to_message(self) -> ILLMMessage: + pass + + +class OpenAIResponseAdapter(IMessageAdapter): + def __init__(self, response): + self.response = response + self.response_output = response.output[0] + + def to_message(self) -> OpenAIMessage: + content = OpenAIContent() + content_builder = OpenAIContentBuilder(content) + message = OpenAIMessage() + message_builder = OpenAIMessageBuilder(message) + + message_builder.set_role(self.response_output.role)\ + .set_content(content) + + for i_content_item in self.response_output.content: + item = i_content_item.to_dict() + content_builder.add_from_dict(item) + + return message + + +class LMMClientType(Enum): + OPENAI = OpenAIClient + + +class MessengerClientMessageAdapter(IMessageAdapter): + def __init__(self, message: ClientMessage, llm_client: LMMClientType): + self.message = message + self.llm_client = llm_client + + def to_message(self) -> ILLMMessage: + content = self.llm_client.value.content_class() + content_builder = self.llm_client.value.content_builder_class(content) + message = self.llm_client.value.message_class() + message_builder = self.llm_client.value.message_builder_class(message) + + message_builder.set_role("user")\ + .set_content(content) + + if self.message: + content_builder.add_input_text(self.message.content) + for i_image_url in self.message.image_urls: + content_builder.add_input_image(i_image_url) + + return message + + +if __name__ == "__main__": + pass diff --git a/reginaldCog/llm_clients/openai_data_models.py b/reginaldCog/llm_clients/openai_data_models.py index 171e7c4..0d138fd 100644 --- a/reginaldCog/llm_clients/openai_data_models.py +++ b/reginaldCog/llm_clients/openai_data_models.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field, asdict +from dataclasses import dataclass, field from abc import ABC diff --git a/reginaldCog/messenger_clients/messenger_client.py b/reginaldCog/messenger_clients/messenger_client.py index 685c898..7ba27a0 100644 --- a/reginaldCog/messenger_clients/messenger_client.py +++ b/reginaldCog/messenger_clients/messenger_client.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from enum import Enum -from discord import Message +from discord import Message, Attachment class IClientMessage(ABC): @@ -18,56 +18,59 @@ class ClientMessage(IClientMessage): class IMessageBuilder(ABC): @abstractmethod - def __init__(self, raw_message: object): - self.raw_message = raw_message - self.message: IClientMessage = None - - def create_message(self) -> ClientMessage: - self.set_content() \ - .set_author_name() \ - .set_image_urls() - return self.message - - @abstractmethod - def set_content(self): + def __init__(self): pass @abstractmethod - def set_author_name(self): + def set_content(self, value: str): pass @abstractmethod - def set_image_urls(self): + def set_author_name(self, value: str): + pass + + @abstractmethod + def set_image_urls(self, value: list[str]): pass class DiscordMessageBuilder(IMessageBuilder): - def __init__(self, raw_message: Message): - self.raw_message = raw_message - self.message = ClientMessage() + def __init__(self, message: IClientMessage): + self.message = message - def create_message(self) -> ClientMessage: - return super().create_message() - - def set_author_name(self) -> 'DiscordMessageBuilder': - self.message.author_name = self.raw_message.author.name + def set_author_name(self, value: str): + self.message.author_name = value return self - def set_content(self) -> 'DiscordMessageBuilder': - self.message.content = self.raw_message.content + def set_content(self, value: str): + self.message.content = value return self - def set_image_urls(self) -> 'DiscordMessageBuilder': - self.message.image_urls = [ - i.url - for i in self.raw_message.attachments - if i.content_type in ('image/jpeg', 'image/png', 'image/webp', 'image/gif') - ] + def set_image_urls(self, value: list[str]): + self.message.image_urls = value return self -class MessengerTypes(Enum): - DISCORD = DiscordMessageBuilder +class IMessageAdapter(ABC): + @abstractmethod + def create_message(self, message: object) -> IClientMessage: + pass + + @staticmethod + def validate_image_urls(urls_list: list[Attachment]) -> list[str]: + supported_image_formats = ('image/jpeg', 'image/png', 'image/webp', 'image/gif') + return [i_attachment.url for i_attachment in urls_list if i_attachment.content_type in supported_image_formats] + + +class DiscordMessageAdapter(IMessageAdapter): + def create_message(self, message: Message) -> IClientMessage: + client_message = ClientMessage() + message_builder = DiscordMessageBuilder(client_message) + urls_list = self.validate_image_urls(message.attachments) + message_builder.set_content(message.content)\ + .set_author_name(message.author.name)\ + .set_image_urls(urls_list) + return client_message if __name__ == "__main__": diff --git a/reginaldCog/messenger_clients/services.py b/reginaldCog/messenger_clients/services.py new file mode 100644 index 0000000..fb6b9ab --- /dev/null +++ b/reginaldCog/messenger_clients/services.py @@ -0,0 +1,39 @@ +import asyncio +from discord import Message +from reginaldCog.messenger_clients.messenger_client import ClientMessage, DiscordMessageAdapter as MessengerDiscordAdapter +from reginaldCog.llm_clients.llm_client import LMMClientType, MessengerClientMessageAdapter + + +class MessageService: + def __init__(self, message: Message, llm_client: LMMClientType = LMMClientType.OPENAI): + self.message = message + self.llm_client = llm_client + + async def get_llm_response(self) -> str: + # Adapt discord.Message to ClientMessage domain object + client_message: ClientMessage = MessengerDiscordAdapter().create_message(self.message) + + # Create prompt and prompt builder for this LLM client + prompt = self.llm_client.value.prompt_class() + prompt_builder = self.llm_client.value.prompt_builder_class(prompt) + + # Adapt the messenger client message into LLM message and add it to prompt + llm_message = MessengerClientMessageAdapter(client_message, self.llm_client).to_message() + prompt_builder.add_message(llm_message) + + # Call the LLM client; run in executor if method is blocking sync + llm_client_instance = self.llm_client.value() + + loop = asyncio.get_running_loop() + # Assuming get_response is blocking - run in executor: + response = await loop.run_in_executor(None, llm_client_instance.get_response, prompt) + + # Extract plain text from the response (assuming OpenAIResponseAdapter is present) + from reginaldCog.llm_clients.llm_client import OpenAIResponseAdapter + + response_adapter = OpenAIResponseAdapter(response) + message_obj = response_adapter.to_message() + + # Concatenate all textual outputs for sending to Discord + texts = [item.get("text", "") for item in message_obj.content.content_items if item.get("type") == "output_text"] + return "\n".join(texts) if texts else "Sorry, no response generated."