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
8 changes: 4 additions & 4 deletions .github/PULL_REQUEST_TEMPLATE
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
<!-- List the key changes -->

## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Bug fix
- [ ] Breaking change (existing functionality affected)
- [ ] Refactoring (no functional changes)
- [ ] Documentation
- [ ] Refactoring/Optimization (no functional changes)
- [ ] CI/CD or build configuration
- [ ] Dependencies update
- [ ] Dependency updates
- [ ] Documentation
- [ ] Other

## Testing
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ COPY --chmod=555 .env ${WORKDIR}

RUN set -ex && \
mkdir -p "${LOG_DIRECTORY}" && \
uv sync --frozen --no-dev && \
uv sync --frozen --no-dev --no-build && \
uv cache clean && \
chown -R botuser:botuser "${WORKDIR}"

Expand Down
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "DiscordBot"
version = "3.0.15"
version = "3.0.16"
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 @@ -44,11 +44,11 @@ dependencies = [

[dependency-groups]
dev = [
"coverage>=7.14.0",
"coverage>=7.14.1",
"poethepoet>=0.46.0",
"pytest-asyncio>=1.3.0",
"ruff>=0.15.14",
"testcontainers[postgres]>=4.14.2",
"pytest-asyncio>=1.4.0",
"ruff>=0.15.15",
"testcontainers[postgres]>=4.15.0rc2",
]

[tool.poe.tasks]
Expand Down
47 changes: 37 additions & 10 deletions src/bot/cogs/open_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ def __init__(self, bot: Bot) -> None:
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 = (
self._instructions: str = "You are a helpful AI assistant."
self._instructions_web: 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)."
Expand All @@ -28,26 +29,45 @@ def __init__(self, bot: Bot) -> None:
@commands.command()
@commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user)
async def ai(self, ctx: commands.Context, *, msg_text: str) -> None:
"""Ask OpenAI for assistance with any question or task.
"""Ask OpenAI for a direct answer (no web search).

Usage:
ai What is Python?
ai Write a haiku about programming
ai Explain quantum computing in simple terms
"""
# Reasoning + web search can take a couple of minutes, so show a progress
await self._run_ai(ctx, msg_text, use_web=False)

@commands.command()
@commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user)
async def aiweb(self, ctx: commands.Context, *, msg_text: str) -> None:
"""Ask OpenAI to search the web before answering — best for current/factual info.

Usage:
aiweb What's the latest news about <topic>
aiweb How many support gems does Path of Exile 2 have
"""
await self._run_ai(ctx, msg_text, use_web=True)

async def _run_ai(self, ctx: commands.Context, msg_text: str, use_web: bool) -> None:
"""Shared body for the `ai` and `aiweb` commands."""
# Reasoning (and 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_text = (
"Please wait, I'm thinking and searching the web for an accurate answer..."
if use_web
else "Please wait, I'm thinking..."
)
progress_embed = discord.Embed(
description="🔄 **Please wait, I'm thinking and searching the web for an accurate answer...** "
"(this may take a moment)",
description=f"🔄 **{progress_text}** (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)
response_text = await self._get_ai_response(msg_text, use_web=use_web)
color = discord.Color.green()
description = response_text
except Exception as e:
Expand All @@ -69,14 +89,21 @@ async def ai(self, ctx: commands.Context, *, msg_text: str) -> None:
view = bot_utils.EmbedPaginatorView(embeds, ctx.author.id)
await view.send_and_save(ctx)

async def _get_ai_response(self, message: str) -> str:
"""Get response from OpenAI API."""
async def _get_ai_response(self, message: str, use_web: bool) -> str:
"""Get response from OpenAI API.

use_web: when True, enables the built-in web_search tool and uses the
web-grounded instructions. When False, the model answers from its own
knowledge with plain instructions.
"""
instructions = self._instructions_web if use_web else self._instructions
tools: list[WebSearchToolParam] = [WebSearchToolParam(type="web_search")] if use_web else []

response = await self._openai_client.responses.create(
instructions=self._instructions,
instructions=instructions,
model=self._bot_settings.openai_model,
reasoning=Reasoning(effort=self._effort),
tools=[WebSearchToolParam(type="web_search")],
tools=tools,
max_output_tokens=None,
input=message,
)
Expand Down
67 changes: 60 additions & 7 deletions tests/unit/bot/cogs/test_open_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,39 @@ async def test_ai_command_success(
assert embed.author.name == "TestUser"
assert embed.author.icon_url == "https://example.com/avatar.png"

@pytest.mark.asyncio
@patch("src.bot.cogs.open_ai.get_bot_settings")
@patch("src.bot.cogs.open_ai.bot_utils.send_embed")
async def test_aiweb_command_success(
self, mock_send_embed, mock_get_settings, openai_cog, mock_ctx, mock_bot_settings
):
"""`aiweb` runs the shared flow with use_web=True (web search enabled)."""
mock_get_settings.return_value = mock_bot_settings

with patch.object(openai_cog, "_get_ai_response", return_value="web answer") as mock_get_response:
await openai_cog.aiweb.callback(openai_cog, mock_ctx, msg_text="Latest news")

mock_get_response.assert_awaited_once_with("Latest news", use_web=True)
mock_ctx.send.assert_called_once() # progress message
mock_send_embed.assert_called_once()
embed = mock_send_embed.call_args[0][1]
assert embed.description == "web answer"
assert embed.color == discord.Color.green()

@pytest.mark.asyncio
@patch("src.bot.cogs.open_ai.get_bot_settings")
@patch("src.bot.cogs.open_ai.bot_utils.send_embed")
async def test_ai_command_uses_plain_path(
self, mock_send_embed, mock_get_settings, openai_cog, mock_ctx, mock_bot_settings
):
"""`ai` routes through _get_ai_response with use_web=False."""
mock_get_settings.return_value = mock_bot_settings

with patch.object(openai_cog, "_get_ai_response", return_value="plain answer") as mock_get_response:
await openai_cog.ai.callback(openai_cog, mock_ctx, msg_text="What is Python?")

mock_get_response.assert_awaited_once_with("What is Python?", use_web=False)

@pytest.mark.asyncio
@patch("src.bot.cogs.open_ai.get_bot_settings")
@patch("src.bot.cogs.open_ai.bot_utils.send_embed")
Expand Down Expand Up @@ -141,7 +174,7 @@ async def test_get_ai_response_success(
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?")
result = await openai_cog._get_ai_response("What is Python?", use_web=False)

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

Expand All @@ -151,11 +184,31 @@ async def test_get_ai_response_success(

assert call_args[1]["model"] == "gpt-3.5-turbo"
assert call_args[1]["max_output_tokens"] is None
# Plain (no web search) uses the plain instructions and no tools
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"] == []

@pytest.mark.asyncio
@patch("src.bot.cogs.open_ai.get_bot_settings")
async def test_get_ai_response_web_success(
self, mock_get_settings, openai_cog, mock_bot_settings, mock_openai_response
):
"""Web variant: uses web-grounded instructions and the web_search tool."""
mock_get_settings.return_value = mock_bot_settings

mock_client = MagicMock()
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?", use_web=True)

assert result == "This is a mock AI response from OpenAI."
call_args = mock_client.responses.create.call_args
assert call_args[1]["instructions"] == openai_cog._instructions_web
assert call_args[1]["tools"][0]["type"] == "web_search"
assert call_args[1]["reasoning"]["effort"] == "xhigh"

@pytest.mark.asyncio
@patch("src.bot.cogs.open_ai.get_bot_settings")
Expand All @@ -171,7 +224,7 @@ async def test_get_ai_response_with_leading_trailing_spaces(
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")
result = await openai_cog._get_ai_response("Test message", use_web=False)

assert result == "Response with spaces"

Expand Down Expand Up @@ -301,7 +354,7 @@ async def test_get_ai_response_system_message_content(
mock_client.responses.create = AsyncMock(return_value=mock_openai_response)
openai_cog._openai_client = mock_client

await openai_cog._get_ai_response("Test message")
await openai_cog._get_ai_response("Test message", use_web=False)

call_args = mock_client.responses.create.call_args[1]
assert call_args["instructions"] == openai_cog._instructions
Expand All @@ -320,12 +373,12 @@ async def test_get_ai_response_api_parameters(
mock_client.responses.create = AsyncMock(return_value=mock_openai_response)
openai_cog._openai_client = mock_client

await openai_cog._get_ai_response("Test message")
await openai_cog._get_ai_response("Test message", use_web=False)

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 call_args["tools"] == [] # plain path has no tools
assert "temperature" not in call_args
assert call_args["model"] == "gpt-3.5-turbo"

Expand Down Expand Up @@ -422,7 +475,7 @@ async def test_get_ai_response_empty_response(self, mock_get_settings, openai_co
mock_client.responses.create = AsyncMock(return_value=mock_response)
openai_cog._openai_client = mock_client

result = await openai_cog._get_ai_response("Test message")
result = await openai_cog._get_ai_response("Test message", use_web=False)

assert result == "" # Should strip to empty string

Expand Down
Loading
Loading