Skip to content

Commit 5297825

Browse files
authored
v3.0.8 (#85)
* v3.0.8 * v3.0.8
1 parent eba1a10 commit 5297825

6 files changed

Lines changed: 299 additions & 197 deletions

File tree

.github/PULL_REQUEST_TEMPLATE

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,15 @@
1212
- [ ] Documentation
1313
- [ ] CI/CD or build configuration
1414
- [ ] Dependencies update
15+
- [ ] Other
1516

1617
## Testing
1718
- [ ] Unit tests added/updated
1819
- [ ] Integration tests added/updated
19-
- [ ] All existing tests pass
2020
- [ ] Manual testing performed
2121

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

2826
## Reviewer

docker-compose-localdb.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,28 @@ services:
3333
retries: 5
3434
start_period: 30s
3535

36+
discordbot_alembic:
37+
build:
38+
context: .
39+
dockerfile: Dockerfile
40+
container_name: discordbot_alembic
41+
restart: always
42+
env_file: .env
43+
environment:
44+
DOCKER_BUILDKIT: 1
45+
networks:
46+
- postgres_network
47+
command: ["sh", "-c", "uv run --frozen --no-sync alembic upgrade head && touch /tmp/alembic_done && sleep infinity"]
48+
deploy:
49+
restart_policy:
50+
delay: 60s
51+
healthcheck:
52+
test: ["CMD", "sh", "-c", "test -f /tmp/alembic_done"]
53+
interval: 5s
54+
timeout: 5s
55+
retries: 12
56+
start_period: 120s
57+
3658
volumes:
3759
discordbot_database_data:
3860
name: "discordbot_database_data"

pyproject.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "DiscordBot"
3-
version = "3.0.7"
3+
version = "3.0.8"
44
description = "A simple Discord bot with OpenAI support and server administration tools"
55
urls.Repository = "https://github.com/ddc/DiscordBot"
66
urls.Homepage = "https://github.com/ddc/DiscordBot"
@@ -24,7 +24,7 @@ classifiers = [
2424
"Programming Language :: Python :: 3",
2525
"Programming Language :: Python :: 3.14",
2626
"Operating System :: OS Independent",
27-
"Environment :: Other Environment",
27+
"Environment :: No Input/Output (Daemon)",
2828
"Intended Audience :: Developers",
2929
"Natural Language :: English",
3030
]
@@ -34,9 +34,9 @@ dependencies = [
3434
"beautifulsoup4>=4.14.3",
3535
"better-profanity>=0.7.0",
3636
"ddcdatabases[postgres]>=3.0.11",
37-
"discord-py>=2.7.0",
37+
"discord-py>=2.7.1",
3838
"gTTS>=2.5.4",
39-
"openai>=2.24.0",
39+
"openai>=2.28.0",
4040
"PyNaCl>=1.6.2",
4141
"pythonLogs>=6.0.3",
4242
"uuid-utils>=0.14.1",
@@ -45,9 +45,9 @@ dependencies = [
4545
[dependency-groups]
4646
dev = [
4747
"coverage>=7.13.4",
48-
"poethepoet>=0.42.0",
48+
"poethepoet>=0.42.1",
4949
"pytest-asyncio>=1.3.0",
50-
"ruff>=0.15.2",
50+
"ruff>=0.15.6",
5151
"testcontainers[postgres]>=4.14.1",
5252
]
5353

src/bot/cogs/open_ai.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,13 @@ async def ai(self, ctx: commands.Context, *, msg_text: str) -> None:
3636
color = discord.Color.red()
3737
description = f"Sorry, I encountered an error: {e}"
3838

39-
embed = self._create_ai_embed(ctx, description, color)
40-
await bot_utils.send_embed(ctx, embed, False)
39+
embeds = self._create_ai_embeds(ctx, description, color)
40+
if len(embeds) == 1:
41+
await bot_utils.send_embed(ctx, embeds[0], False)
42+
else:
43+
view = bot_utils.EmbedPaginatorView(embeds, ctx.author.id)
44+
msg = await ctx.send(embed=embeds[0], view=view)
45+
view.message = msg
4146

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

7378
@staticmethod
74-
def _create_ai_embed(ctx: commands.Context, description: str, color: discord.Color) -> discord.Embed:
75-
"""Create formatted embed for AI response."""
76-
# Truncate long responses to fit Discord limits
77-
if len(description) > 2000:
78-
description = description[:1997] + "..."
79-
80-
embed = discord.Embed(color=color, description=description)
81-
embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.avatar.url if ctx.author.avatar else None)
82-
embed.set_footer(
83-
icon_url=ctx.bot.user.avatar.url if ctx.bot.user.avatar else None,
84-
text=f"{bot_utils.get_current_date_time_str_long()} UTC",
85-
)
86-
87-
return embed
79+
def _create_ai_embeds(ctx: commands.Context, description: str, color: discord.Color) -> list[discord.Embed]:
80+
"""Create formatted embed(s) for AI response, paginating if needed."""
81+
max_length = 2000
82+
chunks = []
83+
84+
while description:
85+
if len(description) <= max_length:
86+
chunks.append(description)
87+
break
88+
split_index = description.rfind("\n", 0, max_length)
89+
if split_index == -1:
90+
split_index = description.rfind(" ", 0, max_length)
91+
if split_index == -1:
92+
split_index = max_length
93+
chunks.append(description[:split_index])
94+
description = description[split_index:].lstrip()
95+
96+
pages = []
97+
for i, chunk in enumerate(chunks):
98+
embed = discord.Embed(color=color, description=chunk)
99+
embed.set_author(
100+
name=ctx.author.display_name, icon_url=ctx.author.avatar.url if ctx.author.avatar else None
101+
)
102+
footer_text = bot_utils.get_current_date_time_str_long() + " UTC"
103+
if len(chunks) > 1:
104+
footer_text = f"Page {i + 1}/{len(chunks)} | {footer_text}"
105+
embed.set_footer(
106+
icon_url=ctx.bot.user.avatar.url if ctx.bot.user.avatar else None,
107+
text=footer_text,
108+
)
109+
pages.append(embed)
110+
111+
return pages
88112

89113

90114
async def setup(bot: Bot) -> None:

tests/unit/bot/cogs/test_open_ai.py

Lines changed: 78 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -196,61 +196,66 @@ async def test_get_ai_response_with_leading_trailing_spaces(
196196

197197
assert result == "Response with spaces"
198198

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

204-
embed = openai_cog._create_ai_embed(mock_ctx, description, color)
204+
embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)
205205

206-
assert isinstance(embed, discord.Embed)
207-
assert embed.color == color
208-
assert embed.description == description
209-
assert embed.author.name == "TestUser"
210-
assert embed.author.icon_url == "https://example.com/avatar.png"
206+
assert len(embeds) == 1
207+
assert isinstance(embeds[0], discord.Embed)
208+
assert embeds[0].color == color
209+
assert embeds[0].description == description
210+
assert embeds[0].author.name == "TestUser"
211+
assert embeds[0].author.icon_url == "https://example.com/avatar.png"
211212

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

217-
embed = openai_cog._create_ai_embed(mock_ctx, long_description, color)
218+
embeds = openai_cog._create_ai_embeds(mock_ctx, long_description, color)
218219

219-
assert len(embed.description) <= 2000
220-
assert embed.description.endswith("...")
221-
assert embed.description.startswith("a" * 1997)
220+
assert len(embeds) == 2
221+
assert len(embeds[0].description) <= 2000
222+
assert len(embeds[1].description) <= 2000
223+
assert embeds[0].description + embeds[1].description == long_description
224+
assert "Page 1/2" in embeds[0].footer.text
225+
assert "Page 2/2" in embeds[1].footer.text
222226

223-
def test_create_ai_embed_exactly_2000_chars(self, openai_cog, mock_ctx):
224-
"""Test _create_ai_embed with exactly 2000 characters."""
227+
def test_create_ai_embeds_exactly_2000_chars(self, openai_cog, mock_ctx):
228+
"""Test _create_ai_embeds with exactly 2000 characters returns single page."""
225229
description = "a" * 2000
226230
color = discord.Color.red()
227231

228-
embed = openai_cog._create_ai_embed(mock_ctx, description, color)
232+
embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)
229233

230-
assert embed.description == description
231-
assert len(embed.description) == 2000
234+
assert len(embeds) == 1
235+
assert embeds[0].description == description
236+
assert len(embeds[0].description) == 2000
232237

233-
def test_create_ai_embed_no_author_avatar(self, openai_cog, mock_ctx):
234-
"""Test _create_ai_embed when author has no avatar."""
238+
def test_create_ai_embeds_no_author_avatar(self, openai_cog, mock_ctx):
239+
"""Test _create_ai_embeds when author has no avatar."""
235240
mock_ctx.author.avatar = None
236241
description = "Test response"
237242
color = discord.Color.orange()
238243

239-
embed = openai_cog._create_ai_embed(mock_ctx, description, color)
244+
embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)
240245

241-
assert embed.author.name == "TestUser"
242-
assert embed.author.icon_url is None
246+
assert embeds[0].author.name == "TestUser"
247+
assert embeds[0].author.icon_url is None
243248

244-
def test_create_ai_embed_no_bot_avatar(self, openai_cog, mock_ctx):
245-
"""Test _create_ai_embed when bot has no avatar."""
249+
def test_create_ai_embeds_no_bot_avatar(self, openai_cog, mock_ctx):
250+
"""Test _create_ai_embeds when bot has no avatar."""
246251
mock_ctx.bot.user.avatar = None
247252
description = "Test response"
248253
color = discord.Color.purple()
249254

250-
embed = openai_cog._create_ai_embed(mock_ctx, description, color)
255+
embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)
251256

252-
assert embed.footer.icon_url is None
253-
assert "UTC" in embed.footer.text
257+
assert embeds[0].footer.icon_url is None
258+
assert "UTC" in embeds[0].footer.text
254259

255260
@pytest.mark.asyncio
256261
@patch("src.bot.cogs.open_ai.get_bot_settings")
@@ -350,13 +355,13 @@ async def test_get_ai_response_api_parameters(
350355
assert call_args["model"] == "gpt-3.5-turbo"
351356

352357
@patch("src.bot.cogs.open_ai.bot_utils.get_current_date_time_str_long")
353-
def test_create_ai_embed_footer(self, mock_get_datetime, openai_cog, mock_ctx):
358+
def test_create_ai_embeds_footer(self, mock_get_datetime, openai_cog, mock_ctx):
354359
"""Test that embed footer contains correct timestamp."""
355360
mock_get_datetime.return_value = "2023-01-01 12:00:00"
356361

357-
embed = openai_cog._create_ai_embed(mock_ctx, "Test", discord.Color.blue())
362+
embeds = openai_cog._create_ai_embeds(mock_ctx, "Test", discord.Color.blue())
358363

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

362367
@pytest.mark.asyncio
@@ -435,24 +440,53 @@ async def test_get_ai_response_empty_response(self, mock_get_settings, openai_co
435440

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

438-
def test_create_ai_embed_edge_case_1997_chars(self, openai_cog, mock_ctx):
439-
"""Test _create_ai_embed with exactly 1997 characters (edge case)."""
443+
def test_create_ai_embeds_edge_case_1997_chars(self, openai_cog, mock_ctx):
444+
"""Test _create_ai_embeds with exactly 1997 characters returns single page."""
440445
description = "a" * 1997
441446
color = discord.Color.teal()
442447

443-
embed = openai_cog._create_ai_embed(mock_ctx, description, color)
448+
embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)
444449

445-
# Should not be truncated
446-
assert embed.description == description
447-
assert len(embed.description) == 1997
450+
assert len(embeds) == 1
451+
assert embeds[0].description == description
452+
assert len(embeds[0].description) == 1997
448453

449-
def test_create_ai_embed_edge_case_1998_chars(self, openai_cog, mock_ctx):
450-
"""Test _create_ai_embed with 1998 characters (should NOT be truncated)."""
454+
def test_create_ai_embeds_edge_case_1998_chars(self, openai_cog, mock_ctx):
455+
"""Test _create_ai_embeds with 1998 characters returns single page."""
451456
description = "a" * 1998
452457
color = discord.Color.magenta()
453458

454-
embed = openai_cog._create_ai_embed(mock_ctx, description, color)
459+
embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)
455460

456-
# Should NOT be truncated since 1998 <= 2000
457-
assert embed.description == description
458-
assert len(embed.description) == 1998
461+
assert len(embeds) == 1
462+
assert embeds[0].description == description
463+
assert len(embeds[0].description) == 1998
464+
465+
def test_create_ai_embeds_splits_on_newline(self, openai_cog, mock_ctx):
466+
"""Test _create_ai_embeds splits on newline boundary when possible."""
467+
# Create text with a newline near the 2000 char boundary
468+
first_part = "a" * 1990
469+
second_part = "b" * 100
470+
description = first_part + "\n" + second_part
471+
color = discord.Color.green()
472+
473+
embeds = openai_cog._create_ai_embeds(mock_ctx, description, color)
474+
475+
assert len(embeds) == 2
476+
assert embeds[0].description == first_part
477+
assert embeds[1].description == second_part
478+
479+
@pytest.mark.asyncio
480+
@patch("src.bot.cogs.open_ai.get_bot_settings")
481+
async def test_ai_command_pagination(self, mock_get_settings, openai_cog, mock_ctx, mock_bot_settings):
482+
"""Test AI command uses pagination for long responses."""
483+
mock_get_settings.return_value = mock_bot_settings
484+
long_response = "a" * 3000
485+
486+
with patch.object(openai_cog, "_get_ai_response", return_value=long_response):
487+
await openai_cog.ai.callback(openai_cog, mock_ctx, msg_text="Long question")
488+
489+
mock_ctx.send.assert_called_once()
490+
call_kwargs = mock_ctx.send.call_args[1]
491+
assert "embed" in call_kwargs
492+
assert "view" in call_kwargs

0 commit comments

Comments
 (0)