diff --git a/botbotbot/__init__.py b/botbotbot/__init__.py index 46d82be..aca06b4 100644 --- a/botbotbot/__init__.py +++ b/botbotbot/__init__.py @@ -6,6 +6,7 @@ import tomllib import discord from botbotbot.ai import AIBot +from botbotbot.text import TextBot from botbotbot.tts import CambAI @@ -50,81 +51,15 @@ def main() -> None: bot = discord.Bot(description=description, intents=intents) + text_bot = TextBot(bot, aibot, word_list) + text_bot.init_events() + shuffle_tasks = set() @bot.listen("on_ready") async def on_ready() -> None: logger.info(f"We have logged in as {bot.user}") - async def reply(message: discord.Message) -> None: - logger.info(f"Reply to {message.author}") - mention = random.choices( - [f"<@{message.author.id}>", "@everyone", "@here"], weights=(98, 1, 1) - )[0] - content = random.choice( - ( - f"{mention}, {random.choice(word_list)}", - f"{random.choice(word_list)}", - ) - ) - - if isinstance(message.channel, discord.TextChannel) and random.random() < 0.1: - await send_as_webhook( - message.channel, - message.author.display_name, - message.author.avatar.url if message.author.avatar else None, - content, - ) - else: - fct = random.choice((message.reply, message.channel.send)) - - await fct(content) - - async def ai_reply(message: discord.Message) -> None: - if aibot is None: - return - - logger.info(f"AI Reply to {message.author}") - prompt = message.clean_content - if prompt == "" and message.embeds and message.embeds[0].description: - prompt = message.embeds[0].description - - answer = aibot.answer(prompt) - if not isinstance(answer, str): - return - - if len(answer) > 2000: - embed = discord.Embed( - description=answer, - thumbnail="https://mistral.ai/images/favicon/favicon-32x32.png", - ) - await message.reply(embed=embed) - else: - await message.reply(answer) - - async def react(message: discord.Message) -> None: - if message.guild is None: - return - await message.add_reaction(random.choice(message.guild.emojis)) - - @bot.listen("on_message") - async def on_message(message: discord.Message) -> None: - if message.flags.ephemeral: - return - - if message.author != bot.user and bot.user in message.mentions: - logger.info(f"{message.author} metion") - await random.choice((reply, react))(message) - return - - probas = [10, 5, 10] - - func = random.choices( - (reply, ai_reply, react, None), weights=probas + [100 - sum(probas)] - )[0] - if func is not None: - await func(message) - @bot.listen("on_reaction_add") async def add_more_reaction( reaction: discord.Reaction, user: discord.Member | discord.User @@ -329,18 +264,4 @@ def main() -> None: await ctx.respond(embed=embed) logger.info("FIN CHAN") - async def send_as_webhook( - channel: discord.TextChannel, - name: str, - avatar_url: str | None, - content: str, - ) -> None: - webhooks = await channel.webhooks() - webhook = discord.utils.get(webhooks, name="BotbotbotHook") - - if webhook is None: - webhook = await channel.create_webhook(name="BotbotbotHook") - - await webhook.send(content=content, username=name, avatar_url=avatar_url) - bot.run(config.get("token")) diff --git a/botbotbot/text.py b/botbotbot/text.py new file mode 100644 index 0000000..a03161f --- /dev/null +++ b/botbotbot/text.py @@ -0,0 +1,146 @@ +import logging +import random +from typing import Any, Callable, Coroutine + +import discord +import emoji + +from botbotbot.ai import AIBot + +logger = logging.getLogger(__name__) + + +class TextBot: + uni_emojis = tuple(k for k in emoji.EMOJI_DATA.keys() if len(k) == 1) + + def __init__( + self, + bot: discord.Bot, + aibot: AIBot | None = None, + wordlist: list[str] = [], + rnd_weights: list[float] = [10, 5, 10], + ) -> None: + self.bot = bot + self.aibot = aibot + self.word_list = wordlist + self._rnd_weights = rnd_weights + + def init_events(self) -> None: + self.bot.add_listener(self.on_message, "on_message") + + @property + def rnd_weights(self) -> list[float]: + return self._rnd_weights + [100 - sum(self._rnd_weights)] + + @property + def rnd_functions( + self, + ) -> list[Callable[[discord.Message], Coroutine[Any, Any, None]]]: + return [self.reply, self.ai_reply, self.react] + + @property + def rnd_functions_or_not( + self, + ) -> list[Callable[[discord.Message], Coroutine[Any, Any, None]] | None]: + return [*self.rnd_functions, None] + + async def on_message(self, message: discord.Message) -> None: + logger.debug( + f"Received message from <{message.author}> on channel <{message.channel}>." + ) + if message.flags.ephemeral: + logger.debug("Ephemeral message, ignoring.") + return + + if message.author != self.bot.user and self.bot.user in message.mentions: + logger.info( + f"Mention from <{message.author}> in channel <{message.channel}>." + ) + await random.choices(self.rnd_functions, weights=self.rnd_weights[:-1])[0]( + message + ) + return + + func = random.choices(self.rnd_functions_or_not, weights=self.rnd_weights)[0] + + if func is None: + logger.debug("No action.") + return + + await func(message) + + async def reply(self, message: discord.Message) -> None: + logger.info(f"Replying to <{message.author}> in channel <{message.channel}>.") + + mention = random.choices( + [f"<@{message.author.id}>", "@everyone", "@here"], weights=(97, 1, 2) + )[0] + content = random.choice( + ( + f"{mention}, {random.choice(self.word_list)}", + f"{random.choice(self.word_list)}", + ) + ) + + if ( + isinstance(message.channel, discord.TextChannel) + and random.random() < 10 / 100 + ): + await self.send_as_webhook( + message.channel, + message.author.display_name, + message.author.avatar.url if message.author.avatar else None, + content, + ) + else: + fct = random.choice((message.reply, message.channel.send)) + + await fct(content) + + async def ai_reply(self, message: discord.Message) -> None: + if self.aibot is None: + logger.debug("No AI bot, ignoring.") + return + + logger.info(f"AI Reply to {message.author}") + prompt = message.clean_content + if prompt == "" and message.embeds and message.embeds[0].description: + prompt = message.embeds[0].description + + answer = self.aibot.answer(prompt) + if not isinstance(answer, str): + logger.error(f"Got unexpected result from AIBot : {answer}") + return + + if len(answer) > 2000: + logger.debug("Answer too long, sending as embed.") + embed = discord.Embed( + description=answer, + thumbnail="https://mistral.ai/images/favicon/favicon-32x32.png", + ) + await message.reply(embed=embed) + else: + await message.reply(answer) + + async def react(self, message: discord.Message) -> None: + emojis: tuple[str | discord.Emoji, ...] = self.uni_emojis + if message.guild is not None and random.random() < 50 / 100: + emojis = message.guild.emojis + + emo: str | discord.Emoji = random.choice(emojis) + await message.add_reaction(emo) + + async def send_as_webhook( + self, + channel: discord.TextChannel, + name: str, + avatar_url: str | None, + content: str, + ) -> None: + webhooks = await channel.webhooks() + webhook = discord.utils.get(webhooks, name="BotbotbotHook") + + if webhook is None: + webhook = await channel.create_webhook(name="BotbotbotHook") + + await webhook.send(content=content, username=name, avatar_url=avatar_url) diff --git a/pyproject.toml b/pyproject.toml index 73588be..0d9c9bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "audioop-lts>=0.2.1", + "emoji>=2.14.1", "mistralai>=1.6.0", "py-cord>=2.6.1", "pynacl>=1.5.0", @@ -21,6 +22,7 @@ dev = [ "mypy>=1.15.0", "pre-commit>=4.2.0", "ruff>=0.11.2", + "types-requests>=2.32.0.20250306", ] [tool.mypy] diff --git a/uv.lock b/uv.lock index 8584e4e..accb65c 100644 --- a/uv.lock +++ b/uv.lock @@ -133,6 +133,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "audioop-lts" }, + { name = "emoji" }, { name = "mistralai" }, { name = "py-cord" }, { name = "pynacl" }, @@ -145,11 +146,13 @@ dev = [ { name = "mypy" }, { name = "pre-commit" }, { name = "ruff" }, + { name = "types-requests" }, ] [package.metadata] requires-dist = [ { name = "audioop-lts", specifier = ">=0.2.1" }, + { name = "emoji", specifier = ">=2.14.1" }, { name = "mistralai", specifier = ">=1.6.0" }, { name = "py-cord", specifier = ">=2.6.1" }, { name = "pynacl", specifier = ">=1.5.0" }, @@ -162,6 +165,7 @@ dev = [ { name = "mypy", specifier = ">=1.15.0" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "ruff", specifier = ">=0.11.2" }, + { name = "types-requests", specifier = ">=2.32.0.20250306" }, ] [[package]] @@ -235,6 +239,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, ] +[[package]] +name = "emoji" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/7d/01cddcbb6f5cc0ba72e00ddf9b1fa206c802d557fd0a20b18e130edf1336/emoji-2.14.1.tar.gz", hash = "sha256:f8c50043d79a2c1410ebfae833ae1868d5941a67a6cd4d18377e2eb0bd79346b", size = 597182 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/db/a0335710caaa6d0aebdaa65ad4df789c15d89b7babd9a30277838a7d9aac/emoji-2.14.1-py3-none-any.whl", hash = "sha256:35a8a486c1460addb1499e3bf7929d3889b2e2841a57401903699fef595e942b", size = 590617 }, +] + [[package]] name = "eval-type-backport" version = "0.2.2" @@ -666,6 +679,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "types-requests" +version = "2.32.0.20250306" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/1a/beaeff79ef9efd186566ba5f0d95b44ae21f6d31e9413bcfbef3489b6ae3/types_requests-2.32.0.20250306.tar.gz", hash = "sha256:0962352694ec5b2f95fda877ee60a159abdf84a0fc6fdace599f20acb41a03d1", size = 23012 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/26/645d89f56004aa0ba3b96fec27793e3c7e62b40982ee069e52568922b6db/types_requests-2.32.0.20250306-py3-none-any.whl", hash = "sha256:25f2cbb5c8710b2022f8bbee7b2b66f319ef14aeea2f35d80f18c9dbf3b60a0b", size = 20673 }, +] + [[package]] name = "typing-extensions" version = "4.12.2"