Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.14.4-alpine3.23 AS python-base
FROM python:3.14.5-alpine3.23 AS python-base

LABEL Description="DiscordBot"

Expand Down
12 changes: 6 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "DiscordBot"
version = "3.0.14"
version = "3.0.15"
description = "A simple Discord bot with OpenAI support and server administration tools"
urls.Repository = "https://github.com/ddc/DiscordBot"
urls.Homepage = "https://ddc.github.io/DiscordBot"
Expand Down Expand Up @@ -36,18 +36,18 @@ dependencies = [
"ddcdatabases[postgres]>=4.0.1",
"discord-py>=2.7.1",
"gTTS>=2.5.4",
"openai>=2.36.0",
"openai>=2.38.0",
"PyNaCl>=1.6.2",
"pythonLogs>=7.0.1",
"uuid-utils>=0.14.1",
"uuid-utils>=0.16.0",
]

[dependency-groups]
dev = [
"coverage>=7.13.5",
"poethepoet>=0.45.0",
"coverage>=7.14.0",
"poethepoet>=0.46.0",
"pytest-asyncio>=1.3.0",
"ruff>=0.15.12",
"ruff>=0.15.14",
"testcontainers[postgres]>=4.14.2",
]

Expand Down
75 changes: 51 additions & 24 deletions src/bot/cogs/open_ai.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import discord
import time
from discord.ext import commands
from openai import AsyncOpenAI
from openai.types.chat import ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam
from src.bot.constants.settings import get_bot_settings
from openai.types.responses import WebSearchToolParam
from openai.types.shared import ReasoningEffort
from openai.types.shared_params import Reasoning
from src.bot.constants.settings import BotSettings, get_bot_settings
from src.bot.discord_bot import Bot
from src.bot.tools import bot_utils
from src.bot.tools.cooldowns import CoolDowns
Expand All @@ -12,9 +15,15 @@ class OpenAi(commands.Cog):
"""OpenAI-powered commands for AI assistance and text generation."""

def __init__(self, bot: Bot) -> None:
self.bot = bot
self._bot_settings = get_bot_settings()
self.bot: Bot = bot
self._bot_settings: BotSettings = get_bot_settings()
self._openai_client: AsyncOpenAI = AsyncOpenAI(api_key=self._bot_settings.openai_api_key)
self._effort: ReasoningEffort = "xhigh"
self._instructions: str = (
"You are a helpful AI assistant. When answering factual questions, use web search and base your "
"answer only on information directly supported by the sources. Do not invent or extrapolate specific "
"numbers, statistics, or breakdowns that the sources do not explicitly state. Cite the source URL(s)."
)

@commands.command()
@commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user)
Expand All @@ -26,8 +35,17 @@ async def ai(self, ctx: commands.Context, *, msg_text: str) -> None:
ai Write a haiku about programming
ai Explain quantum computing in simple terms
"""
await ctx.message.channel.typing()
# Reasoning + web search can take a couple of minutes, so show a progress
# message immediately so the user knows the bot is working (not stuck).
progress_embed = discord.Embed(
description="🔄 **Please wait, I'm thinking and searching the web for an accurate answer...** "
"(this may take a moment)",
color=discord.Color.blurple(),
)
progress_embed.set_author(name=ctx.author.display_name, icon_url=getattr(ctx.author.avatar, "url", None))
progress_msg = await bot_utils.send_with_retry(ctx, ctx.send, embed=progress_embed)

start = time.monotonic()
try:
response_text = await self._get_ai_response(msg_text)
color = discord.Color.green()
Expand All @@ -36,8 +54,15 @@ async def ai(self, ctx: commands.Context, *, msg_text: str) -> None:
self.bot.log.error(f"OpenAI API error: {e}")
color = discord.Color.red()
description = f"Sorry, I encountered an error: {e}"
elapsed = time.monotonic() - start

# Remove the progress message before sending the final answer.
try:
await progress_msg.delete()
except discord.HTTPException:
pass

embeds = self._create_ai_embeds(ctx, description, color)
embeds = self._create_ai_embeds(ctx, description, color, elapsed)
if len(embeds) == 1:
await bot_utils.send_embed(ctx, embeds[0], False)
else:
Expand All @@ -46,30 +71,32 @@ async def ai(self, ctx: commands.Context, *, msg_text: str) -> None:

async def _get_ai_response(self, message: str) -> str:
"""Get response from OpenAI API."""
model = self._bot_settings.openai_model

# Create properly typed messages for OpenAI API
messages: list[ChatCompletionSystemMessageParam | ChatCompletionUserMessageParam] = [
ChatCompletionSystemMessageParam(
role="system",
content="You are a helpful AI assistant. Provide clear, concise, and accurate responses.",
),
ChatCompletionUserMessageParam(role="user", content=message),
]

# Use the correct OpenAI API endpoint (async — does not block the event loop)
response = await self._openai_client.chat.completions.create(
model=model,
messages=messages,
max_completion_tokens=1000,
response = await self._openai_client.responses.create(
instructions=self._instructions,
model=self._bot_settings.openai_model,
reasoning=Reasoning(effort=self._effort),
tools=[WebSearchToolParam(type="web_search")],
max_output_tokens=None,
input=message,
)

content = response.choices[0].message.content
content = response.output_text
return content.strip() if content else ""

def _create_ai_embeds(self, ctx: commands.Context, description: str, color: discord.Color) -> list[discord.Embed]:
@staticmethod
def _format_duration(seconds: float) -> str:
"""Format an elapsed duration as e.g. '5ms' (sub-second) or '20s'."""
if seconds < 1:
return f"{round(seconds * 1000)}ms"
return f"{round(seconds)}s"

def _create_ai_embeds(
self, ctx: commands.Context, description: str, color: discord.Color, elapsed: float = 0.0
) -> list[discord.Embed]:
"""Create formatted embed(s) for AI response, paginating if needed."""
model = self._bot_settings.openai_model
duration = self._format_duration(elapsed)
max_length = 2000
chunks = []

Expand All @@ -92,7 +119,7 @@ def _create_ai_embeds(self, ctx: commands.Context, description: str, color: disc
name=ctx.author.display_name,
icon_url=getattr(ctx.author.avatar, "url", None),
)
footer_text = f"{model} | {bot_utils.get_current_date_time_str_long()} UTC"
footer_text = f"{model} | {duration} | {bot_utils.get_current_date_time_str_long()} UTC"
if len(chunks) > 1:
footer_text = f"Page {i + 1}/{len(chunks)} | {footer_text}"
embed.set_footer(
Expand Down
86 changes: 46 additions & 40 deletions tests/unit/bot/cogs/test_open_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,9 @@ def mock_ctx():

@pytest.fixture
def mock_openai_response():
"""Create a mock OpenAI response."""
"""Create a mock OpenAI Responses API response."""
response = MagicMock()
response.choices = [MagicMock()]
response.choices[0].message = MagicMock()
response.choices[0].message.content = "This is a mock AI response from OpenAI."
response.output_text = "This is a mock AI response from OpenAI."
return response


Expand Down Expand Up @@ -98,7 +96,7 @@ async def test_ai_command_success(
with patch.object(openai_cog, "_get_ai_response", return_value="AI response here"):
await openai_cog.ai.callback(openai_cog, mock_ctx, msg_text="What is Python?")

mock_ctx.message.channel.typing.assert_called_once()
mock_ctx.send.assert_called_once() # progress message was sent
mock_send_embed.assert_called_once()

# Check embed properties
Expand All @@ -118,7 +116,7 @@ async def test_ai_command_error(self, mock_send_embed, mock_get_settings, openai
with patch.object(openai_cog, "_get_ai_response", side_effect=Exception("API Error")):
await openai_cog.ai.callback(openai_cog, mock_ctx, msg_text="What is Python?")

mock_ctx.message.channel.typing.assert_called_once()
mock_ctx.send.assert_called_once() # progress message was sent
mock_send_embed.assert_called_once()

# Check error embed properties
Expand All @@ -140,27 +138,24 @@ async def test_get_ai_response_success(

# Mock the client instance directly
mock_client = MagicMock()
mock_client.chat.completions.create = AsyncMock(return_value=mock_openai_response)
mock_client.responses.create = AsyncMock(return_value=mock_openai_response)
openai_cog._openai_client = mock_client

result = await openai_cog._get_ai_response("What is Python?")

assert result == "This is a mock AI response from OpenAI."

# Verify OpenAI API call
mock_client.chat.completions.create.assert_called_once()
call_args = mock_client.chat.completions.create.call_args
mock_client.responses.create.assert_called_once()
call_args = mock_client.responses.create.call_args

assert call_args[1]["model"] == "gpt-3.5-turbo"
assert call_args[1]["max_completion_tokens"] == 1000
assert "temperature" not in call_args[1]

# Verify message types and content
messages = call_args[1]["messages"]
assert len(messages) == 2
assert messages[0]["role"] == "system"
assert messages[1]["role"] == "user"
assert messages[1]["content"] == "What is Python?"
assert call_args[1]["max_output_tokens"] is None
assert call_args[1]["instructions"] == openai_cog._instructions
assert call_args[1]["input"] == "What is Python?"
# Reasoning effort and web search are enabled
assert call_args[1]["reasoning"]["effort"] == "xhigh"
assert call_args[1]["tools"][0]["type"] == "web_search"

@pytest.mark.asyncio
@patch("src.bot.cogs.open_ai.get_bot_settings")
Expand All @@ -169,11 +164,11 @@ async def test_get_ai_response_with_leading_trailing_spaces(
):
"""Test _get_ai_response strips leading/trailing spaces."""
mock_get_settings.return_value = mock_bot_settings
mock_openai_response.choices[0].message.content = " Response with spaces "
mock_openai_response.output_text = " Response with spaces "

# Mock the client instance directly
mock_client = MagicMock()
mock_client.chat.completions.create = AsyncMock(return_value=mock_openai_response)
mock_client.responses.create = AsyncMock(return_value=mock_openai_response)
openai_cog._openai_client = mock_client

result = await openai_cog._get_ai_response("Test message")
Expand Down Expand Up @@ -250,13 +245,13 @@ async def test_ai_command_with_different_models(self, mock_send_embed, openai_co

# Mock the client instance directly
mock_client = MagicMock()
mock_client.chat.completions.create = AsyncMock(return_value=mock_openai_response)
mock_client.responses.create = AsyncMock(return_value=mock_openai_response)
openai_cog._openai_client = mock_client

await openai_cog.ai.callback(openai_cog, mock_ctx, msg_text="Test question")

# Verify correct model was used
call_args = mock_client.chat.completions.create.call_args
call_args = mock_client.responses.create.call_args
assert call_args[1]["model"] == "gpt-4"

@pytest.mark.asyncio
Expand Down Expand Up @@ -303,15 +298,14 @@ async def test_get_ai_response_system_message_content(

# Mock the client instance directly
mock_client = MagicMock()
mock_client.chat.completions.create = AsyncMock(return_value=mock_openai_response)
mock_client.responses.create = AsyncMock(return_value=mock_openai_response)
openai_cog._openai_client = mock_client

await openai_cog._get_ai_response("Test message")

messages = mock_client.chat.completions.create.call_args[1]["messages"]
system_message = messages[0]
expected_content = "You are a helpful AI assistant. Provide clear, concise, and accurate responses."
assert system_message["content"] == expected_content
call_args = mock_client.responses.create.call_args[1]
assert call_args["instructions"] == openai_cog._instructions
assert call_args["input"] == "Test message"

@pytest.mark.asyncio
@patch("src.bot.cogs.open_ai.get_bot_settings")
Expand All @@ -323,26 +317,39 @@ async def test_get_ai_response_api_parameters(

# Mock the client instance directly
mock_client = MagicMock()
mock_client.chat.completions.create = AsyncMock(return_value=mock_openai_response)
mock_client.responses.create = AsyncMock(return_value=mock_openai_response)
openai_cog._openai_client = mock_client

await openai_cog._get_ai_response("Test message")

call_args = mock_client.chat.completions.create.call_args[1]
assert call_args["max_completion_tokens"] == 1000
call_args = mock_client.responses.create.call_args[1]
assert call_args["max_output_tokens"] is None
assert call_args["reasoning"]["effort"] == "xhigh"
assert call_args["tools"][0]["type"] == "web_search"
assert "temperature" not in call_args
assert call_args["model"] == "gpt-3.5-turbo"

@patch("src.bot.cogs.open_ai.bot_utils.get_current_date_time_str_long")
def test_create_ai_embeds_footer(self, mock_get_datetime, openai_cog, mock_ctx):
"""Test that embed footer contains correct timestamp and model name."""
"""Test that embed footer contains model, duration, and timestamp."""
mock_get_datetime.return_value = "2023-01-01 12:00:00"

embeds = openai_cog._create_ai_embeds(mock_ctx, "Test", discord.Color.blue())
embeds = openai_cog._create_ai_embeds(mock_ctx, "Test", discord.Color.blue(), elapsed=20.0)

assert embeds[0].footer.text == "gpt-3.5-turbo | 2023-01-01 12:00:00 UTC"
assert embeds[0].footer.text == "gpt-3.5-turbo | 20s | 2023-01-01 12:00:00 UTC"
mock_get_datetime.assert_called_once()

def test_format_duration_milliseconds(self, openai_cog):
"""Sub-second durations are shown in milliseconds."""
assert openai_cog._format_duration(0.005) == "5ms"
assert openai_cog._format_duration(0.5) == "500ms"

def test_format_duration_seconds(self, openai_cog):
"""Durations of one second or more are shown in whole seconds."""
assert openai_cog._format_duration(1.0) == "1s"
assert openai_cog._format_duration(20.4) == "20s"
assert openai_cog._format_duration(119.6) == "120s"

@pytest.mark.asyncio
async def test_setup_function(self, mock_bot):
"""Test the setup function."""
Expand Down Expand Up @@ -406,15 +413,13 @@ async def test_get_ai_response_empty_response(self, mock_get_settings, openai_co
"""Test _get_ai_response with empty response from OpenAI."""
mock_get_settings.return_value = mock_bot_settings

# Mock empty response
# Mock empty response (whitespace only)
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message = MagicMock()
mock_response.choices[0].message.content = " " # Only whitespace
mock_response.output_text = " "

# Mock the client instance directly
mock_client = MagicMock()
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
mock_client.responses.create = AsyncMock(return_value=mock_response)
openai_cog._openai_client = mock_client

result = await openai_cog._get_ai_response("Test message")
Expand Down Expand Up @@ -473,7 +478,8 @@ async def test_ai_command_pagination(
with patch.object(openai_cog, "_get_ai_response", return_value=long_response):
await openai_cog.ai.callback(openai_cog, mock_ctx, msg_text="Long question")

mock_ctx.send.assert_called_once()
call_kwargs = mock_ctx.send.call_args[1]
# ctx.send called twice: progress message, then the paginated first page
assert mock_ctx.send.call_count == 2
call_kwargs = mock_ctx.send.call_args[1] # last call == paginated send
assert "embed" in call_kwargs
assert "view" in call_kwargs
Loading
Loading