diff --git a/camel/bots/__init__.py b/camel/bots/__init__.py index 4bca8342b..a09c2b0ee 100644 --- a/camel/bots/__init__.py +++ b/camel/bots/__init__.py @@ -11,10 +11,10 @@ # See the License for the specific language governing permissions and # limitations under the License. # =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== -from .discord_bot import DiscordBot +from .discord_app import DiscordApp from .telegram_bot import TelegramBot __all__ = [ - 'DiscordBot', + 'DiscordApp', 'TelegramBot', ] diff --git a/camel/bots/discord_app.py b/camel/bots/discord_app.py new file mode 100644 index 000000000..ef4594234 --- /dev/null +++ b/camel/bots/discord_app.py @@ -0,0 +1,138 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +import logging +import os +from typing import TYPE_CHECKING, List, Optional + +from camel.utils import dependencies_required + +if TYPE_CHECKING: + from discord import Message + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class DiscordApp: + r"""A class representing a Discord app that uses the `discord.py` library + to interact with Discord servers. + + This bot can respond to messages in specific channels and only reacts to + messages that mention the bot. + + Attributes: + channel_ids (Optional[List[int]]): A list of allowed channel IDs. If + provided, the bot will only respond to messages in these channels. + token (Optional[str]): The Discord bot token used for authentication. + """ + + @dependencies_required('discord') + def __init__( + self, + channel_ids: Optional[List[int]] = None, + token: Optional[str] = None, + ) -> None: + r"""Initialize the DiscordApp instance by setting up the Discord client + and event handlers. + + Args: + channel_ids (Optional[List[int]]): A list of allowed channel IDs. + The bot will only respond to messages in these channels if + provided. + token (Optional[str]): The Discord bot token for authentication. + If not provided, the token will be retrieved from the + environment variable `DISCORD_TOKEN`. + + Raises: + ValueError: If the `DISCORD_TOKEN` is not found in environment + variables. + """ + self.token = token or os.getenv('DISCORD_TOKEN') + self.channel_ids = channel_ids + + if not self.token: + raise ValueError( + "`DISCORD_TOKEN` not found in environment variables. Get it" + " here: `https://discord.com/developers/applications`." + ) + + import discord + + intents = discord.Intents.default() + intents.message_content = True + self._client = discord.Client(intents=intents) + + # Register event handlers + self._client.event(self.on_ready) + self._client.event(self.on_message) + + async def start(self): + r"""Asynchronously start the Discord bot using its token. + + This method starts the bot and logs into Discord asynchronously using + the provided token. It should be awaited when used in an async + environment. + """ + await self._client.start(self.token) + + def run(self) -> None: + r"""Start the Discord bot using its token. + + This method starts the bot and logs into Discord synchronously using + the provided token. It blocks execution and keeps the bot running. + """ + self._client.run(self.token) # type: ignore[arg-type] + + async def on_ready(self) -> None: + r"""Event handler that is called when the bot has successfully + connected to the Discord server. + + When the bot is ready and logged into Discord, it prints a message + displaying the bot's username. + """ + logger.info(f'We have logged in as {self._client.user}') + + async def on_message(self, message: 'Message') -> None: + r"""Event handler for processing incoming messages. + + This method is called whenever a new message is received by the bot. It + will ignore messages sent by the bot itself, only respond to messages + in allowed channels (if specified), and only to messages that mention + the bot. + + Args: + message (discord.Message): The message object received from + Discord. + """ + # If the message author is the bot itself, + # do not respond to this message + if message.author == self._client.user: + return + + # If allowed channel IDs are provided, + # only respond to messages in those channels + if self.channel_ids and message.channel.id not in self.channel_ids: + return + + # Only respond to messages that mention the bot + if not self._client.user or not self._client.user.mentioned_in( + message + ): + return + + logger.info(f"Received message: {message.content}") + + @property + def client(self): + return self._client diff --git a/camel/bots/discord_bot.py b/camel/bots/discord_bot.py deleted file mode 100644 index ae1ee28fc..000000000 --- a/camel/bots/discord_bot.py +++ /dev/null @@ -1,206 +0,0 @@ -# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== -# Licensed under the Apache License, Version 2.0 (the “License”); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an “AS IS” BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== -import os -from typing import TYPE_CHECKING, List, Optional, Union - -from camel.agents import ChatAgent -from camel.messages import BaseMessage -from camel.retrievers import AutoRetriever -from camel.utils import dependencies_required - -try: - from unstructured.documents.elements import Element -except ImportError: - Element = None - -if TYPE_CHECKING: - from discord import Message - - -class DiscordBot: - r"""Represents a Discord bot that is powered by a CAMEL `ChatAgent`. - - Attributes: - chat_agent (ChatAgent): Chat agent that will power the bot. - channel_ids (List[int], optional): The channel IDs that the bot will - listen to. - discord_token (str, optional): The bot token. - auto_retriever (AutoRetriever): AutoRetriever instance for RAG. - vector_storage_local_path (Union[str, List[str]]): The paths to the - contents for RAG. - top_k (int): Top choice for the RAG response. - return_detailed_info (bool): If show detailed info of the RAG response. - contents (Union[str, List[str], Element, List[Element]], optional): - Local file paths, remote URLs, string contents or Element objects. - """ - - @dependencies_required('discord') - def __init__( - self, - chat_agent: ChatAgent, - contents: Union[str, List[str], Element, List[Element]] = None, - channel_ids: Optional[List[int]] = None, - discord_token: Optional[str] = None, - auto_retriever: Optional[AutoRetriever] = None, - vector_storage_local_path: Union[str, List[str]] = "", - top_k: int = 1, - return_detailed_info: bool = True, - ) -> None: - self.chat_agent = chat_agent - self.token = discord_token or os.getenv('DISCORD_TOKEN') - self.channel_ids = channel_ids - self.auto_retriever = auto_retriever - self.vector_storage_local_path = vector_storage_local_path - self.top_k = top_k - self.return_detailed_info = return_detailed_info - self.contents = contents - - if not self.token: - raise ValueError( - "`DISCORD_TOKEN` not found in environment variables. Get it" - " here: `https://discord.com/developers/applications`." - ) - - import discord - - intents = discord.Intents.default() - intents.message_content = True - self.client = discord.Client(intents=intents) - - # Register event handlers - self.client.event(self.on_ready) - self.client.event(self.on_message) - - def run(self) -> None: - r"""Start the Discord bot using its token. - - This method starts the Discord bot by running the client with the - provided token. - """ - self.client.run(self.token) # type: ignore[arg-type] - - async def on_ready(self) -> None: - r"""This method is called when the bot has successfully connected to - the Discord server. - - It prints a message indicating that the bot has logged in and displays - the username of the bot. - """ - print(f'We have logged in as {self.client.user}') - - async def on_message(self, message: 'Message') -> None: - r"""Event handler for when a message is received. - - Args: - message (discord.Message): The message object received. - """ - - # If the message author is the bot itself, - # do not respond to this message - if message.author == self.client.user: - return - - # If allowed channel IDs are provided, - # only respond to messages in those channels - if self.channel_ids and message.channel.id not in self.channel_ids: - return - - # Only respond to messages that mention the bot - if not self.client.user or not self.client.user.mentioned_in(message): - return - - user_raw_msg = message.content - - if self.auto_retriever: - retrieved_content = self.auto_retriever.run_vector_retriever( - query=user_raw_msg, - contents=self.contents, - top_k=self.top_k, - return_detailed_info=self.return_detailed_info, - ) - user_raw_msg = ( - f"Here is the query to you: {user_raw_msg}\n" - f"Based on the retrieved content: {retrieved_content}, \n" - f"answer the query from {message.author.name}" - ) - - user_msg = BaseMessage.make_user_message( - role_name="User", content=user_raw_msg - ) - assistant_response = self.chat_agent.step(user_msg) - await message.channel.send(assistant_response.msg.content) - - -if __name__ == "__main__": - assistant_sys_msg = BaseMessage.make_assistant_message( - role_name="Assistant", - content=''' - Objective: - You are a customer service bot designed to assist users - with inquiries related to our open-source project. - Your responses should be informative, concise, and helpful. - - Instructions: - Understand User Queries: Carefully read and understand the - user's question. Focus on keywords and context to - determine the user's intent. - Search for Relevant Information: Use the provided dataset - and refer to the RAG (file to find answers that - closely match the user's query. The RAG file contains - detailed interactions and should be your primary - resource for crafting responses. - Provide Clear and Concise Responses: Your answers should - be clear and to the point. Avoid overly technical - language unless the user's query indicates - familiarity with technical terms. - Encourage Engagement: Where applicable, encourage users - to contribute to the project or seek further - assistance. - - Response Structure: - Greeting: Begin with a polite greeting or acknowledgment. - Main Response: Provide the main answer to the user's query. - Additional Information: Offer any extra tips or direct the - user to additional resources if necessary. - Closing: Close the response politely, encouraging - further engagement if appropriate. - bd - Tone: - Professional: Maintain a professional tone that - instills confidence in the user. - Friendly: Be approachable and friendly to make users - feel comfortable. - Helpful: Always aim to be as helpful as possible, - guiding users to solutions. - ''', - ) - - agent = ChatAgent( - assistant_sys_msg, - message_window_size=10, - ) - # Uncommented the folowing code and offer storage information - # for RAG functionality - - # auto_retriever = AutoRetriever( - # vector_storage_local_path="examples/bots", - # storage_type=StorageType.QDRANT, - # ) - - bot = DiscordBot( - agent, - # auto_retriever=auto_retriever, - # vector_storage_local_path=["local_data/"], - ) - bot.run() diff --git a/examples/bots/discord_bot.py b/examples/bots/discord_bot.py new file mode 100644 index 000000000..3fa3b797a --- /dev/null +++ b/examples/bots/discord_bot.py @@ -0,0 +1,233 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +import asyncio +from typing import TYPE_CHECKING, List, Optional, Union + +from camel.agents import ChatAgent +from camel.bots import DiscordApp +from camel.messages import BaseMessage +from camel.retrievers import AutoRetriever +from camel.types import StorageType + +try: + from unstructured.documents.elements import Element +except ImportError: + Element = None + +if TYPE_CHECKING: + from discord import Message + + +class BotAgent: + def __init__( + self, + contents: Union[str, List[str], Element, List[Element]] = None, + auto_retriever: Optional[AutoRetriever] = None, + similarity_threshold: float = 0.5, + vector_storage_local_path: str = "local_data/", + top_k: int = 1, + return_detailed_info: bool = True, + ): + r"""Initialize the BotAgent instance. + + Args: + contents (Union[str, List[str], Element, List[Element]], optional) + : The content to be retrieved. + auto_retriever (Optional[AutoRetriever], optional): An instance of + AutoRetriever for vector search. + similarity_threshold (float): Threshold for vector similarity when + retrieving content. + vector_storage_local_path (str): Path to local vector storage for + the retriever. + top_k (int): Number of top results to retrieve. + return_detailed_info (bool): Whether to return detailed + information from the retriever. + """ + assistant_sys_msg: BaseMessage = BaseMessage.make_assistant_message( + role_name="Assistant", + content=''' + Objective: + You are a customer service bot designed to assist users + with inquiries related to our open-source project. + Your responses should be informative, concise, and helpful. + + Instructions: + Understand User Queries: Carefully read and understand the + user's question. Focus on keywords and context to + determine the user's intent. + Search for Relevant Information: Use the provided dataset + and refer to the RAG (file to find answers that + closely match the user's query. The RAG file + contains detailed interactions and should be your + primary resource for crafting responses. + Provide Clear and Concise Responses: Your answers should + be clear and to the point. Avoid overly technical + language unless the user's query indicates + familiarity with technical terms. + Encourage Engagement: Where applicable, encourage users + to contribute to the project or seek further + assistance. + + Response Structure: + Greeting: Begin with a polite greeting or acknowledgment. + Main Response: Provide the main answer to the user's query. + Additional Information: Offer any extra tips or direct the + user to additional resources if necessary. + Closing: Close the response politely, encouraging + further engagement if appropriate. + bd + Tone: + Professional: Maintain a professional tone that + instills confidence in the user. + Friendly: Be approachable and friendly to make users + feel comfortable. + Helpful: Always aim to be as helpful as possible, + guiding users to solutions. + ''', + ) + + self._agent = ChatAgent( + assistant_sys_msg, + message_window_size=10, + ) + + self._auto_retriever = None + self._contents = contents + self._top_k = top_k + self._similarity_threshold = similarity_threshold + self._return_detailed_info = return_detailed_info + + self._auto_retriever = auto_retriever or AutoRetriever( + vector_storage_local_path=vector_storage_local_path, + storage_type=StorageType.QDRANT, + ) + + async def process(self, message: str) -> str: + r"""Process the user message, retrieve relevant content, and generate + a response. + + Args: + message (str): The user's query message. + + Returns: + str: The assistant's response message. + """ + user_raw_msg = message + print("User message:", user_raw_msg) + if self._auto_retriever: + retrieved_content = self._auto_retriever.run_vector_retriever( + query=user_raw_msg, + contents=self._contents, + top_k=self._top_k, + similarity_threshold=self._similarity_threshold, + return_detailed_info=self._return_detailed_info, + ) + user_raw_msg = ( + f"Here is the query to you: {user_raw_msg}\n" + f"Based on the retrieved content: {retrieved_content}, \n" + f"answer the query" + ) + + user_msg = BaseMessage.make_user_message( + role_name="User", content=user_raw_msg + ) + assistant_response = self._agent.step(user_msg) + return assistant_response.msg.content + + +class DiscordBot(DiscordApp): + def __init__( + self, + agent: BotAgent, + token: Optional[str] = None, + channel_ids: Optional[list[int]] = None, + ): + r"""Initializes the DiscordBot instance to handle Discord messages and + communicate with BotAgent. + + Args: + agent (BotAgent): The BotAgent responsible for processing messages + and generating responses. + token (Optional[str]): The token used to authenticate the bot with + Discord. + channel_ids (Optional[list[int]]): A list of Discord channel IDs + where the bot is allowed to interact. + """ + super().__init__(token=token, channel_ids=channel_ids) + self.agent: BotAgent = agent + + async def on_message(self, message: 'Message') -> None: + r"""Event handler for received messages. This method processes incoming + messages, checks whether the message is from the bot itself, and + determines whether the bot should respond based on channel ID and + mentions. Then processes the message using the BotAgent, and responds. + + Args: + message (discord.Message): The received message object. + """ + # If the message author is the bot itself, + # do not respond to this message + if message.author == self._client.user: + return + + # If allowed channel IDs are provided, + # only respond to messages in those channels + if self.channel_ids and message.channel.id not in self.channel_ids: + return + + # Only respond to messages that mention the bot + if not self._client.user or not self._client.user.mentioned_in( + message + ): + return + + user_raw_msg = message.content + response = await self.agent.process(user_raw_msg) + await message.channel.send(response) + + +async def process_message(agent: BotAgent, msg_queue: asyncio.Queue): + r"""Continuously processes messages from the queue and sends responses. + + This function waits for new messages in the queue, processes each message + using the `BotAgent` instance, and sends the response back to Discord. + + Args: + agent (BotAgent): An instance of `BotAgent` that processes the received + messages. + msg_queue (asyncio.Queue): The queue from which messages are retrieved + for processing. + """ + while True: + message: "Message" = await msg_queue.get() + user_raw_msg = message.content + + # Process the message using the agent and get the response + response = await agent.process(user_raw_msg) + # message.reply(response) + await message.channel.send(response) + msg_queue.task_done() + + +async def main(): + agent = BotAgent() + + # Initialize the DiscordBot with the message queue + discord_bot = DiscordBot(agent=agent) + + await discord_bot.start() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/bots/discord_bot_use_msg_queue.py b/examples/bots/discord_bot_use_msg_queue.py new file mode 100644 index 000000000..e6b65a8c6 --- /dev/null +++ b/examples/bots/discord_bot_use_msg_queue.py @@ -0,0 +1,245 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +import asyncio +from typing import TYPE_CHECKING, List, Optional, Union + +from camel.agents import ChatAgent +from camel.bots import DiscordApp +from camel.messages import BaseMessage +from camel.retrievers import AutoRetriever +from camel.types import StorageType + +try: + from unstructured.documents.elements import Element +except ImportError: + Element = None + +if TYPE_CHECKING: + from discord import Message + + +class BotAgent: + def __init__( + self, + contents: Union[str, List[str], Element, List[Element]] = None, + auto_retriever: Optional[AutoRetriever] = None, + similarity_threshold: float = 0.5, + vector_storage_local_path: str = "local_data/", + top_k: int = 1, + return_detailed_info: bool = True, + ): + r"""Initialize the BotAgent instance. + + Args: + contents (Union[str, List[str], Element, List[Element]], optional) + : The content to be retrieved. + auto_retriever (Optional[AutoRetriever], optional): An instance of + AutoRetriever for vector search. + similarity_threshold (float): Threshold for vector similarity when + retrieving content. + vector_storage_local_path (str): Path to local vector storage for + the retriever. + top_k (int): Number of top results to retrieve. + return_detailed_info (bool): Whether to return detailed + information from the retriever. + """ + assistant_sys_msg: BaseMessage = BaseMessage.make_assistant_message( + role_name="Assistant", + content=''' + Objective: + You are a customer service bot designed to assist users + with inquiries related to our open-source project. + Your responses should be informative, concise, and helpful. + + Instructions: + Understand User Queries: Carefully read and understand the + user's question. Focus on keywords and context to + determine the user's intent. + Search for Relevant Information: Use the provided dataset + and refer to the RAG (file to find answers that + closely match the user's query. The RAG file + contains detailed interactions and should be your + primary resource for crafting responses. + Provide Clear and Concise Responses: Your answers should + be clear and to the point. Avoid overly technical + language unless the user's query indicates + familiarity with technical terms. + Encourage Engagement: Where applicable, encourage users + to contribute to the project or seek further + assistance. + + Response Structure: + Greeting: Begin with a polite greeting or acknowledgment. + Main Response: Provide the main answer to the user's query. + Additional Information: Offer any extra tips or direct the + user to additional resources if necessary. + Closing: Close the response politely, encouraging + further engagement if appropriate. + bd + Tone: + Professional: Maintain a professional tone that + instills confidence in the user. + Friendly: Be approachable and friendly to make users + feel comfortable. + Helpful: Always aim to be as helpful as possible, + guiding users to solutions. + ''', + ) + + self._agent = ChatAgent( + assistant_sys_msg, + message_window_size=10, + ) + + self._auto_retriever = None + self._contents = contents + self._top_k = top_k + self._similarity_threshold = similarity_threshold + self._return_detailed_info = return_detailed_info + + self._auto_retriever = auto_retriever or AutoRetriever( + vector_storage_local_path=vector_storage_local_path, + storage_type=StorageType.QDRANT, + ) + + async def process(self, message: str) -> str: + r"""Process the user message, retrieve relevant content, and generate + a response. + + Args: + message (str): The user's query message. + + Returns: + str: The assistant's response message. + """ + user_raw_msg = message + print("User message:", user_raw_msg) + if self._auto_retriever: + retrieved_content = self._auto_retriever.run_vector_retriever( + query=user_raw_msg, + contents=self._contents, + top_k=self._top_k, + similarity_threshold=self._similarity_threshold, + return_detailed_info=self._return_detailed_info, + ) + user_raw_msg = ( + f"Here is the query to you: {user_raw_msg}\n" + f"Based on the retrieved content: {retrieved_content}, \n" + f"answer the query" + ) + + user_msg = BaseMessage.make_user_message( + role_name="User", content=user_raw_msg + ) + assistant_response = self._agent.step(user_msg) + return assistant_response.msg.content + + +class DiscordBot(DiscordApp): + r"""A Discord bot that listens for messages, adds them to a queue, + and processes them asynchronously. + + This class extends the functionality of `DiscordApp` and adds message + handling by pushing messages into a queue for further processing. + + Args: + msg_queue (asyncio.Queue): A queue used to store incoming messages for + processing. + token (Optional[str]): The token used to authenticate the bot with + Discord. + channel_ids (Optional[list[int]]): A list of Discord channel IDs where + the bot is allowed to interact. + """ + + def __init__( + self, + msg_queue: asyncio.Queue, + token: Optional[str] = None, + channel_ids: Optional[list[int]] = None, + ): + super().__init__(token=token, channel_ids=channel_ids) + self._queue: asyncio.Queue = msg_queue + + async def on_message(self, message: 'Message') -> None: + r"""Event handler for received messages. This method processes incoming + messages, checks whether the message is from the bot itself, and + determines whether the bot should respond based on channel ID and + mentions. + + Args: + message (discord.Message): The received message object. + """ + # If the message author is the bot itself, + # do not respond to this message + if message.author == self._client.user: + return + + # If allowed channel IDs are provided, + # only respond to messages in those channels + if self.channel_ids and message.channel.id not in self.channel_ids: + return + + # Only respond to messages that mention the bot + if not self._client.user or not self._client.user.mentioned_in( + message + ): + return + + await self._queue.put(message) + + +async def process_message(agent: BotAgent, msg_queue: asyncio.Queue): + r"""Continuously processes messages from the queue and sends responses. + + This function waits for new messages in the queue, processes each message + using the `BotAgent` instance, and sends the response back to Discord. + + Args: + agent (BotAgent): An instance of `BotAgent` that processes the received + messages. + msg_queue (asyncio.Queue): The queue from which messages are retrieved + for processing. + """ + while True: + message: "Message" = await msg_queue.get() + user_raw_msg = message.content + + # Process the message using the agent and get the response + response = await agent.process(user_raw_msg) + # message.reply(response) + await message.channel.send(response) + msg_queue.task_done() + + +async def main(): + r"""Main function to initialize and run the Discord bot and message + processor. + + This function initializes the message queue, creates an `BotAgent` instance + for processing messages, and starts both the Discord bot and the + message-processing loop asynchronously. + """ + msg_queue = asyncio.Queue() + + agent = BotAgent() + + # Initialize the DiscordBot with the message queue + discord_bot = DiscordBot(msg_queue=msg_queue) + await asyncio.gather( + discord_bot.start(), process_message(agent, msg_queue) + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/test/bots/test_discord_app.py b/examples/test/bots/test_discord_app.py new file mode 100644 index 000000000..223db6a62 --- /dev/null +++ b/examples/test/bots/test_discord_app.py @@ -0,0 +1,139 @@ +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +# Licensed under the Apache License, Version 2.0 (the “License”); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an “AS IS” BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== +import os +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from camel.bots import DiscordApp + + +class TestDiscordApp(unittest.TestCase): + def setUp(self): + # Set environment variables to simulate the token + os.environ['DISCORD_TOKEN'] = 'fake_token' + + def tearDown(self): + # Clear the environment variable after the test + if 'DISCORD_TOKEN' in os.environ: + del os.environ['DISCORD_TOKEN'] + + @patch('discord.Client') + def test_init_with_token_from_env(self, mock_discord_client): + # Initialize DiscordApp using the token from environment variables + app = DiscordApp() + self.assertEqual(app.token, 'fake_token') + self.assertIsNotNone(app.client) + mock_discord_client.assert_called_once() + + @patch('discord.Client') + def test_init_with_provided_token(self, mock_discord_client): + # Initialize DiscordApp with a provided token + app = DiscordApp(token='custom_token') + self.assertEqual(app.token, 'custom_token') + mock_discord_client.assert_called_once() + + @patch('discord.Client') + def test_init_raises_value_error_without_token(self, mock_discord_client): + # Test that ValueError is raised if no token is set + del os.environ['DISCORD_TOKEN'] # Remove the environment variable + with self.assertRaises(ValueError): + DiscordApp() + + @patch('discord.Client') + async def test_on_ready(self, mock_discord_client): + # Test the on_ready event handler + mock_discord_client.user = MagicMock(name='TestBot') + app = DiscordApp() + + with patch('camel.bots.discord_app.logger') as mock_logger: + await app.on_ready() + mock_logger.info.assert_called_once_with( + f'We have logged in as {mock_discord_client.user}' + ) + + @patch('discord.Client') + async def test_on_message_ignore_own_message(self, mock_discord_client): + # Test that on_message ignores messages from the bot itself + app = DiscordApp() + mock_message = MagicMock() + mock_message.author = mock_discord_client.user + + await app.on_message(mock_message) + mock_message.channel.send.assert_not_called() + + @patch('discord.Client') + async def test_on_message_respond_in_allowed_channel( + self, mock_discord_client + ): + # Test that on_message responds in allowed channels + app = DiscordApp(channel_ids=[123]) + mock_message = MagicMock() + mock_message.author = MagicMock() + mock_message.channel.id = 123 # Allowed channel + mock_discord_client.user.mentioned_in.return_value = True + + with patch('camel.bots.discord_app.logger') as mock_logger: + await app.on_message(mock_message) + mock_logger.info.assert_called_once_with( + f"Received message: {mock_message.content}" + ) + + @patch('discord.Client') + async def test_on_message_ignore_non_mentioned_message( + self, mock_discord_client + ): + # Test that on_message ignores messages that don't mention the bot + app = DiscordApp(channel_ids=[123]) + mock_message = MagicMock() + mock_message.author = MagicMock() + mock_message.channel.id = 123 + mock_discord_client.user.mentioned_in.return_value = False + + await app.on_message(mock_message) + mock_message.channel.send.assert_not_called() + + @patch('discord.Client') + async def test_on_message_ignore_outside_channel( + self, mock_discord_client + ): + # Test that on_message ignores messages outside the allowed channels + app = DiscordApp(channel_ids=[123]) + mock_message = MagicMock() + mock_message.author = MagicMock() + mock_message.channel.id = 456 # Not in allowed channels + + await app.on_message(mock_message) + mock_message.channel.send.assert_not_called() + + @patch('discord.Client') + async def test_start_bot(self, mock_discord_client): + # Test that the start method calls the correct start function + app = DiscordApp() + app._client.start = AsyncMock() + + await app.start() + app._client.start.assert_called_once_with('fake_token') + + @patch('discord.Client') + def test_run_bot(self, mock_discord_client): + # Test that the run method calls the correct synchronous start function + app = DiscordApp() + app._client.run = MagicMock() + + app.run() + app._client.run.assert_called_once_with('fake_token') + + +if __name__ == '__main__': + unittest.main() diff --git a/examples/test/bots/test_discord_bot.py b/examples/test/bots/test_discord_bot.py deleted file mode 100644 index e20172e90..000000000 --- a/examples/test/bots/test_discord_bot.py +++ /dev/null @@ -1,170 +0,0 @@ -# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== -# Licensed under the Apache License, Version 2.0 (the “License”); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an “AS IS” BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =========== Copyright 2023 @ CAMEL-AI.org. All Rights Reserved. =========== - -import asyncio -import unittest -from unittest.mock import AsyncMock, MagicMock, patch - -from camel.agents import ChatAgent -from camel.bots.discord_bot import DiscordBot - - -class TestDiscordBot(unittest.TestCase): - def setUp(self): - self.chat_agent_mock = MagicMock(spec=ChatAgent) - self.token = "fake_token" - self.channel_ids = [123, 456] - - def test_init_token_provided_uses_provided_token(self): - bot = DiscordBot(self.chat_agent_mock, discord_token=self.token) - self.assertEqual(bot.token, self.token) - - @patch('discord.Client') - def test_on_ready(self, mock_client_class): - # Setup a mock for the Client instance - mock_client = MagicMock() - user_mock = MagicMock() - user_mock.__str__.return_value = 'BotUser' - mock_client.user = user_mock - - # Ensure the mock client class returns the mock client instance - mock_client_class.return_value = mock_client - - # Initialize the bot with the mocked client - bot = DiscordBot(self.chat_agent_mock, discord_token=self.token) - - async def test(): - with patch('builtins.print') as mocked_print: - await bot.on_ready() - mocked_print.assert_called_with('We have logged in as BotUser') - - asyncio.run(test()) - - @patch('discord.Client') - def test_on_message_ignores_own_messages(self, mock_client_class): - mock_client = MagicMock() - mock_user = MagicMock() - mock_client.user = mock_user - - mock_client_class.return_value = mock_client - - bot = DiscordBot(self.chat_agent_mock, discord_token=self.token) - - message_mock = MagicMock() - message_mock.author = mock_user - - # Make the send method an async function - message_mock.channel.send = AsyncMock() - - async def test(): - await bot.on_message(message_mock) - message_mock.channel.send.assert_not_called() - - asyncio.run(test()) - - @patch('discord.Client') - def test_on_message_handles_channel_check(self, mock_client_class): - mock_client = MagicMock() - channel_ids = [123, 456] - mock_client.user = MagicMock() - - mock_client_class.return_value = mock_client - - bot = DiscordBot( - self.chat_agent_mock, - channel_ids=channel_ids, - discord_token=self.token, - ) - - message_mock = MagicMock() - message_mock.channel.id = 789 # Not in channel_ids - - # Make the send method an async function - message_mock.channel.send = AsyncMock() - - async def test(): - await bot.on_message(message_mock) - message_mock.channel.send.assert_not_called() - - asyncio.run(test()) - - @patch('discord.Client') - def test_on_message_sends_response_when_mentioned(self, mock_client_class): - mock_client = MagicMock() - channel_ids = [123, 456] - mock_client.user = MagicMock() - - mock_client_class.return_value = mock_client - - bot = DiscordBot( - self.chat_agent_mock, - channel_ids=channel_ids, - discord_token=self.token, - ) - - message_mock = MagicMock() - message_mock.channel.id = 123 # In channel_ids - message_mock.content = "Hello, @bot!" - message_mock.mentions = [] # Bot is not mentioned - mock_client.user.mentioned_in = MagicMock(return_value=True) - - response_message = "Hello, human!" - self.chat_agent_mock.step.return_value = MagicMock( - msg=MagicMock(content=response_message) - ) - # Make the send method an async function - message_mock.channel.send = AsyncMock() - - async def test(): - await bot.on_message(message_mock) - message_mock.channel.send.assert_called_once_with(response_message) - - asyncio.run(test()) - - @patch('discord.Client') - def test_on_message_ignores_when_not_mentioned(self, mock_client_class): - mock_client = MagicMock() - channel_ids = [123, 456] - mock_client.user = MagicMock() - - mock_client_class.return_value = mock_client - - bot = DiscordBot( - self.chat_agent_mock, - channel_ids=channel_ids, - discord_token=self.token, - ) - - message_mock = MagicMock() - message_mock.channel.id = 123 # In channel_ids - message_mock.content = "Hello, bot!" - message_mock.mentions = [] # Bot is not mentioned - mock_client.user.mentioned_in = MagicMock(return_value=False) - - response_message = "Hello, human!" - self.chat_agent_mock.step.return_value = MagicMock( - msg=MagicMock(content=response_message) - ) - # Make the send method an async function - message_mock.channel.send = AsyncMock() - - async def test(): - await bot.on_message(message_mock) - message_mock.channel.send.assert_not_called() - - asyncio.run(test()) - - -if __name__ == '__main__': - unittest.main() diff --git a/poetry.lock b/poetry.lock index 3de352d5f..a22801707 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "accelerate" @@ -2097,12 +2097,12 @@ files = [ google-auth = ">=2.14.1,<3.0.dev0" googleapis-common-protos = ">=1.56.2,<2.0.dev0" grpcio = [ - {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, {version = ">=1.33.2,<2.0dev", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, + {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, ] grpcio-status = [ - {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, {version = ">=1.33.2,<2.0.dev0", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, + {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, ] proto-plus = ">=1.22.3,<2.0.0dev" protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" @@ -4803,9 +4803,9 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, + {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] @@ -4967,8 +4967,8 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.23.2", markers = "python_version == \"3.11\""}, {version = ">=1.22.4", markers = "python_version < \"3.11\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] python-dateutil = ">=2.8.2"