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
4 changes: 1 addition & 3 deletions .github/PULL_REQUEST_TEMPLATE
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,15 @@
- [ ] Documentation
- [ ] CI/CD or build configuration
- [ ] Dependencies update
- [ ] Other

## Testing
- [ ] Unit tests added/updated
- [ ] Integration tests added/updated
- [ ] All existing tests pass
- [ ] Manual testing performed

## Checklist
- [ ] Code follows the project's style and conventions
- [ ] Documentation updated (if applicable)
- [ ] No new warnings or linter errors introduced
- [ ] I have considered how this change may affect other services

## Reviewer
Expand Down
22 changes: 22 additions & 0 deletions docker-compose-localdb.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,28 @@ services:
retries: 5
start_period: 30s

discordbot_alembic:
build:
context: .
dockerfile: Dockerfile
container_name: discordbot_alembic
restart: always
env_file: .env
environment:
DOCKER_BUILDKIT: 1
networks:
- postgres_network
command: ["sh", "-c", "uv run --frozen --no-sync alembic upgrade head && touch /tmp/alembic_done && sleep infinity"]
deploy:
restart_policy:
delay: 60s
healthcheck:
test: ["CMD", "sh", "-c", "test -f /tmp/alembic_done"]
interval: 5s
timeout: 5s
retries: 12
start_period: 120s

volumes:
discordbot_database_data:
name: "discordbot_database_data"
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.7"
version = "3.0.8"
description = "A simple Discord bot with OpenAI support and server administration tools"
urls.Repository = "https://github.com/ddc/DiscordBot"
urls.Homepage = "https://github.com/ddc/DiscordBot"
Expand All @@ -24,7 +24,7 @@ classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.14",
"Operating System :: OS Independent",
"Environment :: Other Environment",
"Environment :: No Input/Output (Daemon)",
"Intended Audience :: Developers",
"Natural Language :: English",
]
Expand All @@ -34,9 +34,9 @@ dependencies = [
"beautifulsoup4>=4.14.3",
"better-profanity>=0.7.0",
"ddcdatabases[postgres]>=3.0.11",
"discord-py>=2.7.0",
"discord-py>=2.7.1",
"gTTS>=2.5.4",
"openai>=2.24.0",
"openai>=2.28.0",
"PyNaCl>=1.6.2",
"pythonLogs>=6.0.3",
"uuid-utils>=0.14.1",
Expand All @@ -45,9 +45,9 @@ dependencies = [
[dependency-groups]
dev = [
"coverage>=7.13.4",
"poethepoet>=0.42.0",
"poethepoet>=0.42.1",
"pytest-asyncio>=1.3.0",
"ruff>=0.15.2",
"ruff>=0.15.6",
"testcontainers[postgres]>=4.14.1",
]

Expand Down
56 changes: 40 additions & 16 deletions src/bot/cogs/open_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,13 @@ async def ai(self, ctx: commands.Context, *, msg_text: str) -> None:
color = discord.Color.red()
description = f"Sorry, I encountered an error: {e}"

embed = self._create_ai_embed(ctx, description, color)
await bot_utils.send_embed(ctx, embed, False)
embeds = self._create_ai_embeds(ctx, description, color)
if len(embeds) == 1:
await bot_utils.send_embed(ctx, embeds[0], False)
else:
view = bot_utils.EmbedPaginatorView(embeds, ctx.author.id)
msg = await ctx.send(embed=embeds[0], view=view)
view.message = msg

@property
def openai_client(self) -> OpenAI:
Expand Down Expand Up @@ -71,20 +76,39 @@ async def _get_ai_response(self, message: str) -> str:
return response.choices[0].message.content.strip()

@staticmethod
def _create_ai_embed(ctx: commands.Context, description: str, color: discord.Color) -> discord.Embed:
"""Create formatted embed for AI response."""
# Truncate long responses to fit Discord limits
if len(description) > 2000:
description = description[:1997] + "..."

embed = discord.Embed(color=color, description=description)
embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.avatar.url if ctx.author.avatar else None)
embed.set_footer(
icon_url=ctx.bot.user.avatar.url if ctx.bot.user.avatar else None,
text=f"{bot_utils.get_current_date_time_str_long()} UTC",
)

return embed
def _create_ai_embeds(ctx: commands.Context, description: str, color: discord.Color) -> list[discord.Embed]:
"""Create formatted embed(s) for AI response, paginating if needed."""
max_length = 2000
chunks = []

while description:
if len(description) <= max_length:
chunks.append(description)
break
split_index = description.rfind("\n", 0, max_length)
if split_index == -1:
split_index = description.rfind(" ", 0, max_length)
if split_index == -1:
split_index = max_length
chunks.append(description[:split_index])
description = description[split_index:].lstrip()

pages = []
for i, chunk in enumerate(chunks):
embed = discord.Embed(color=color, description=chunk)
embed.set_author(
name=ctx.author.display_name, icon_url=ctx.author.avatar.url if ctx.author.avatar else None
)
footer_text = 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(
icon_url=ctx.bot.user.avatar.url if ctx.bot.user.avatar else None,
text=footer_text,
)
pages.append(embed)

return pages


async def setup(bot: Bot) -> None:
Expand Down
122 changes: 78 additions & 44 deletions tests/unit/bot/cogs/test_open_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,61 +196,66 @@ async def test_get_ai_response_with_leading_trailing_spaces(

assert result == "Response with spaces"

def test_create_ai_embed_normal_length(self, openai_cog, mock_ctx):
"""Test _create_ai_embed with normal length description."""
def test_create_ai_embeds_normal_length(self, openai_cog, mock_ctx):
"""Test _create_ai_embeds with normal length description."""
description = "This is a normal length response."
color = discord.Color.blue()

embed = openai_cog._create_ai_embed(mock_ctx, description, color)
embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)

assert isinstance(embed, discord.Embed)
assert embed.color == color
assert embed.description == description
assert embed.author.name == "TestUser"
assert embed.author.icon_url == "https://example.com/avatar.png"
assert len(embeds) == 1
assert isinstance(embeds[0], discord.Embed)
assert embeds[0].color == color
assert embeds[0].description == description
assert embeds[0].author.name == "TestUser"
assert embeds[0].author.icon_url == "https://example.com/avatar.png"

def test_create_ai_embed_long_description(self, openai_cog, mock_ctx):
"""Test _create_ai_embed with description exceeding 2000 characters."""
def test_create_ai_embeds_long_description(self, openai_cog, mock_ctx):
"""Test _create_ai_embeds with description exceeding 2000 characters paginates."""
long_description = "a" * 2010 # Exceeds 2000-character limit
color = discord.Color.green()

embed = openai_cog._create_ai_embed(mock_ctx, long_description, color)
embeds = openai_cog._create_ai_embeds(mock_ctx, long_description, color)

assert len(embed.description) <= 2000
assert embed.description.endswith("...")
assert embed.description.startswith("a" * 1997)
assert len(embeds) == 2
assert len(embeds[0].description) <= 2000
assert len(embeds[1].description) <= 2000
assert embeds[0].description + embeds[1].description == long_description
assert "Page 1/2" in embeds[0].footer.text
assert "Page 2/2" in embeds[1].footer.text

def test_create_ai_embed_exactly_2000_chars(self, openai_cog, mock_ctx):
"""Test _create_ai_embed with exactly 2000 characters."""
def test_create_ai_embeds_exactly_2000_chars(self, openai_cog, mock_ctx):
"""Test _create_ai_embeds with exactly 2000 characters returns single page."""
description = "a" * 2000
color = discord.Color.red()

embed = openai_cog._create_ai_embed(mock_ctx, description, color)
embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)

assert embed.description == description
assert len(embed.description) == 2000
assert len(embeds) == 1
assert embeds[0].description == description
assert len(embeds[0].description) == 2000

def test_create_ai_embed_no_author_avatar(self, openai_cog, mock_ctx):
"""Test _create_ai_embed when author has no avatar."""
def test_create_ai_embeds_no_author_avatar(self, openai_cog, mock_ctx):
"""Test _create_ai_embeds when author has no avatar."""
mock_ctx.author.avatar = None
description = "Test response"
color = discord.Color.orange()

embed = openai_cog._create_ai_embed(mock_ctx, description, color)
embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)

assert embed.author.name == "TestUser"
assert embed.author.icon_url is None
assert embeds[0].author.name == "TestUser"
assert embeds[0].author.icon_url is None

def test_create_ai_embed_no_bot_avatar(self, openai_cog, mock_ctx):
"""Test _create_ai_embed when bot has no avatar."""
def test_create_ai_embeds_no_bot_avatar(self, openai_cog, mock_ctx):
"""Test _create_ai_embeds when bot has no avatar."""
mock_ctx.bot.user.avatar = None
description = "Test response"
color = discord.Color.purple()

embed = openai_cog._create_ai_embed(mock_ctx, description, color)
embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)

assert embed.footer.icon_url is None
assert "UTC" in embed.footer.text
assert embeds[0].footer.icon_url is None
assert "UTC" in embeds[0].footer.text

@pytest.mark.asyncio
@patch("src.bot.cogs.open_ai.get_bot_settings")
Expand Down Expand Up @@ -350,13 +355,13 @@ async def test_get_ai_response_api_parameters(
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_embed_footer(self, mock_get_datetime, openai_cog, mock_ctx):
def test_create_ai_embeds_footer(self, mock_get_datetime, openai_cog, mock_ctx):
"""Test that embed footer contains correct timestamp."""
mock_get_datetime.return_value = "2023-01-01 12:00:00"

embed = openai_cog._create_ai_embed(mock_ctx, "Test", discord.Color.blue())
embeds = openai_cog._create_ai_embeds(mock_ctx, "Test", discord.Color.blue())

assert embed.footer.text == "2023-01-01 12:00:00 UTC"
assert embeds[0].footer.text == "2023-01-01 12:00:00 UTC"
mock_get_datetime.assert_called_once()

@pytest.mark.asyncio
Expand Down Expand Up @@ -435,24 +440,53 @@ async def test_get_ai_response_empty_response(self, mock_get_settings, openai_co

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

def test_create_ai_embed_edge_case_1997_chars(self, openai_cog, mock_ctx):
"""Test _create_ai_embed with exactly 1997 characters (edge case)."""
def test_create_ai_embeds_edge_case_1997_chars(self, openai_cog, mock_ctx):
"""Test _create_ai_embeds with exactly 1997 characters returns single page."""
description = "a" * 1997
color = discord.Color.teal()

embed = openai_cog._create_ai_embed(mock_ctx, description, color)
embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)

# Should not be truncated
assert embed.description == description
assert len(embed.description) == 1997
assert len(embeds) == 1
assert embeds[0].description == description
assert len(embeds[0].description) == 1997

def test_create_ai_embed_edge_case_1998_chars(self, openai_cog, mock_ctx):
"""Test _create_ai_embed with 1998 characters (should NOT be truncated)."""
def test_create_ai_embeds_edge_case_1998_chars(self, openai_cog, mock_ctx):
"""Test _create_ai_embeds with 1998 characters returns single page."""
description = "a" * 1998
color = discord.Color.magenta()

embed = openai_cog._create_ai_embed(mock_ctx, description, color)
embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)

# Should NOT be truncated since 1998 <= 2000
assert embed.description == description
assert len(embed.description) == 1998
assert len(embeds) == 1
assert embeds[0].description == description
assert len(embeds[0].description) == 1998

def test_create_ai_embeds_splits_on_newline(self, openai_cog, mock_ctx):
"""Test _create_ai_embeds splits on newline boundary when possible."""
# Create text with a newline near the 2000 char boundary
first_part = "a" * 1990
second_part = "b" * 100
description = first_part + "\n" + second_part
color = discord.Color.green()

embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)

assert len(embeds) == 2
assert embeds[0].description == first_part
assert embeds[1].description == second_part

@pytest.mark.asyncio
@patch("src.bot.cogs.open_ai.get_bot_settings")
async def test_ai_command_pagination(self, mock_get_settings, openai_cog, mock_ctx, mock_bot_settings):
"""Test AI command uses pagination for long responses."""
mock_get_settings.return_value = mock_bot_settings
long_response = "a" * 3000

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]
assert "embed" in call_kwargs
assert "view" in call_kwargs
Loading
Loading