Use Hikari instead of discord.py

This commit is contained in:
2024-12-22 03:43:10 +01:00
parent 50ef8b5a8e
commit 4f53f91e4a
4 changed files with 93 additions and 103 deletions

12
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
}
]
}

17
.vscode/settings.json vendored
View File

@ -1,12 +1,25 @@
{ {
"cSpell.words": [ "cSpell.words": [
"anewdawn",
"asctime",
"audioop",
"docstrings",
"dotenv",
"forgefilip", "forgefilip",
"forgor", "forgor",
"hikari",
"isort",
"killyoy",
"levelname",
"lovibot", "lovibot",
"Lovinator",
"nobot",
"plubplub", "plubplub",
"pycodestyle", "pycodestyle",
"pydocstyle", "pydocstyle",
"PYTHONDONTWRITEBYTECODE", "PYTHONDONTWRITEBYTECODE",
"PYTHONUNBUFFERED" "PYTHONUNBUFFERED",
"testpaths",
"thelovinator"
] ]
} }

146
main.py
View File

@ -5,65 +5,52 @@ import os
import sys import sys
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import discord import hikari
from discord.ext import commands import lightbulb
import openai
from dotenv import load_dotenv
from openai import OpenAI from openai import OpenAI
if TYPE_CHECKING: if TYPE_CHECKING:
from openai.types.chat.chat_completion import ChatCompletion from openai.types.chat.chat_completion import ChatCompletion
from dotenv import load_dotenv
logger: logging.Logger = logging.getLogger(__name__) logger: logging.Logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
# Load the environment variables from the .env file
load_dotenv(verbose=True) load_dotenv(verbose=True)
# Get the Discord token and OpenAI API key from the environment variables
discord_token: str | None = os.getenv("DISCORD_TOKEN") discord_token: str | None = os.getenv("DISCORD_TOKEN")
openai_api_key: str | None = os.getenv("OPENAI_TOKEN") openai_api_key: str | None = os.getenv("OPENAI_TOKEN")
if not discord_token or not openai_api_key: if not discord_token or not openai_api_key:
logger.error("You haven't configured the bot correctly. Please set the environment variables.") logger.error("You haven't configured the bot correctly. Please set the environment variables.")
sys.exit(1) sys.exit(1)
# Use OpenAI for chatting with the bot
bot = lightbulb.BotApp(token=discord_token, intents=hikari.Intents.GUILD_MESSAGES | hikari.Intents.GUILD_MESSAGE_TYPING)
openai_client = OpenAI(api_key=openai_api_key) openai_client = OpenAI(api_key=openai_api_key)
# Create a bot with the necessary intents
# TODO(TheLovinator): We should only enable the intents we need # noqa: TD003
intents: discord.Intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents)
def chat(user_message: str) -> str | None:
@bot.event
async def on_ready() -> None: # noqa: RUF029
"""Print a message when the bot is ready."""
logger.info("Logged on as %s", bot.user)
def chat(msg: str) -> str | None:
"""Chat with the bot using the OpenAI API. """Chat with the bot using the OpenAI API.
Args: Args:
msg: The message to send to the bot. user_message: The message to send to OpenAI.
Returns: Returns:
The response from the bot. The response from the AI model.
""" """
completion: ChatCompletion = openai_client.chat.completions.create( completion: ChatCompletion = openai_client.chat.completions.create(
model="gpt-4o-mini", model="gpt-4o-mini",
messages=[ messages=[
{ {
"role": "system", "role": "developer",
"content": "You are a chatbot. Use Markdown to format your messages if you want.", "content": "You are in a Discord group chat with people above the age of 30. Use Discord Markdown to format messages if needed.", # noqa: E501
}, },
{"role": "user", "content": msg}, {"role": "user", "content": user_message},
], ],
) )
response: str | None = completion.choices[0].message.content response: str | None = completion.choices[0].message.content
logger.info("AI response: %s from message: %s", response, msg) logger.info("AI response: %s", response)
return response return response
@ -84,92 +71,59 @@ def get_allowed_users() -> list[str]:
] ]
def remove_mentions(message_content: str) -> str: @bot.listen(hikari.MessageCreateEvent)
"""Remove mentions of the bot from the message content. async def on_message(event: hikari.MessageCreateEvent) -> None:
Args:
message_content: The message content to process.
Returns:
The message content without the mentions of the bot.
"""
message_content = message_content.removeprefix("lovibot").strip()
message_content = message_content.removeprefix(",").strip()
if bot.user:
message_content = message_content.replace(f"<@!{bot.user.id}>", "").strip()
message_content = message_content.replace(f"<@{bot.user.id}>", "").strip()
return message_content
@bot.event
async def on_message(message: discord.Message) -> None:
"""Respond to a message.""" """Respond to a message."""
logger.info("Message received: %s", message.content) if not event.is_human:
message_content: str = message.content.lower()
# Ignore messages from the bot itself to prevent an infinite loop
if message.author == bot.user:
return return
# Only allow certain users to interact with the bot # Only allow certain users to interact with the bot
allowed_users: list[str] = get_allowed_users() allowed_users: list[str] = get_allowed_users()
if message.author.name not in allowed_users: if event.author.username not in allowed_users:
logger.info("Ignoring message from: %s", message.author.name) logger.info("Ignoring message from: %s", event.author.username)
return return
# Check if the message mentions the bot or starts with the bot's name incoming_message: str | None = event.message.content
things_to_notify_on: list[str] = ["lovibot"] if not incoming_message:
if bot.user: logger.error("No message content found in the event: %s", event)
things_to_notify_on.extend((f"<@!{bot.user.id}>", f"<@{bot.user.id}>")) return
# Only respond to messages that mention the bot lowercase_message: str = incoming_message.lower() if incoming_message else ""
if any(thing.lower() in message_content for thing in things_to_notify_on): trigger_keywords: list[str] = get_trigger_keywords()
if message.reference: if any(trigger in lowercase_message for trigger in trigger_keywords):
# Get the message that the current message is replying to logger.info("Received message: %s from: %s", incoming_message, event.author.username)
message_id: int | None = message.reference.message_id
if message_id is None:
return
async with bot.rest.trigger_typing(event.channel_id):
try: try:
reply_message: discord.Message | None = await message.channel.fetch_message(message_id) response: str | None = chat(incoming_message)
except discord.errors.NotFound: except openai.OpenAIError as e:
logger.exception("An error occurred while chatting with the AI model.")
e.add_note(f"Message: {incoming_message}\nEvent: {event}\nWho: {event.author.username}")
await bot.rest.create_message(
event.channel_id, f"An error occurred while chatting with the AI model. {e}"
)
return return
# Get the message content and author if response:
reply_content: str = reply_message.content logger.info("Responding to message: %s with: %s", incoming_message, response)
reply_author: str = reply_message.author.name await bot.rest.create_message(event.channel_id, response)
else:
logger.warning("No response from the AI model. Message: %s", incoming_message)
await bot.rest.create_message(event.channel_id, "I forgor how to think 💀")
# Add the reply message to the current message
message.content = f"{reply_author}: {reply_content}\n{message.author.name}: {message.content}"
# Remove the mention of the bot from the message def get_trigger_keywords() -> list[str]:
message_content = remove_mentions(message_content) """Get the list of trigger keywords to respond to.
# Grab 10 messages before the current one to provide context Returns:
old_messages: list[str] = [ The list of trigger keywords.
f"{old_message.author.name}: {old_message.content}" """
async for old_message in message.channel.history(limit=10) bot_user: hikari.OwnUser | None = bot.get_me()
] bot_mention_string: str = f"<@{bot_user.id}>" if bot_user else ""
old_messages.reverse() notification_keywords: list[str] = ["lovibot", bot_mention_string]
return notification_keywords
# Get the response from OpenAI
response: str | None = chat("\n".join(old_messages) + "\n" + f"{message.author.name}: {message.content}")
# Remove LoviBot: from the response
if response:
response = response.removeprefix("LoviBot:").strip()
response = response.removeprefix("**LoviBot:**").strip()
if response:
logger.info("Responding to message: %s with: %s", message.content, response)
await message.channel.send(response)
else:
logger.warning("No response from the AI model. Message: %s", message.content)
await message.channel.send("I forgor how to think 💀")
if __name__ == "__main__": if __name__ == "__main__":
logger.info("Starting the bot.") logger.info("Starting the bot.")
bot.run(token=discord_token, root_logger=True) bot.run(asyncio_debug=True, check_for_updates=True)

View File

@ -4,10 +4,10 @@ version = "0.1.0"
description = "My shit bot" description = "My shit bot"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = ["discord-py", "openai", "python-dotenv"] dependencies = ["hikari-lightbulb", "hikari", "openai", "python-dotenv"]
[dependency-groups] [dependency-groups]
dev = ["ruff"] dev = ["pytest-asyncio", "pytest", "ruff"]
[tool.ruff] [tool.ruff]
# https://docs.astral.sh/ruff/linter/ # https://docs.astral.sh/ruff/linter/
@ -43,15 +43,15 @@ lint.ignore = [
"W191", # Checks for indentation that uses tabs. "W191", # Checks for indentation that uses tabs.
] ]
# Default is 88 characters
line-length = 120
# https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html # https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html
lint.pydocstyle.convention = "google" lint.pydocstyle.convention = "google"
# Add "from __future__ import annotations" to all files # Add "from __future__ import annotations" to all files
lint.isort.required-imports = ["from __future__ import annotations"] lint.isort.required-imports = ["from __future__ import annotations"]
# Default is 88 characters
line-length = 120
pycodestyle.ignore-overlong-task-comments = true pycodestyle.ignore-overlong-task-comments = true
[tool.ruff.format] [tool.ruff.format]
@ -67,3 +67,14 @@ docstring-code-line-length = 20
"S101", # asserts allowed in tests... "S101", # asserts allowed in tests...
"S311", # Standard pseudo-random generators are not suitable for cryptographic purposes "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes
] ]
# https://pytest-django.readthedocs.io/en/latest/
[tool.pytest.ini_options]
# Enable logging in the console.
log_cli = true
log_cli_level = "INFO"
log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
# Only check /tests/ directory for tests.
testpaths = ["tests"]