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
17 changes: 8 additions & 9 deletions .github/workflows/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:
needs: lint
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Install uv
uses: astral-sh/setup-uv@v7
Expand All @@ -37,20 +39,17 @@ jobs:
run: uv sync --locked --all-extras --all-groups

- name: Run tests with coverage
uses: nick-fields/retry@v3
uses: nick-fields/retry@v4
with:
timeout_minutes: 2
max_attempts: 3
command: uv run --no-sync poe test-unit
shell: bash

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5

- name: Upload test results to Codecov
uses: codecov/codecov-action@v5
with:
report_type: test_results
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v7
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

integration-test:
name: Integration Tests
Expand All @@ -71,7 +70,7 @@ jobs:
run: uv sync --locked --all-extras --all-groups

- name: Run integration tests
uses: nick-fields/retry@v3
uses: nick-fields/retry@v4
with:
timeout_minutes: 3
max_attempts: 3
Expand Down
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.5-alpine3.23 AS python-base
FROM python:3.14.6-alpine3.24 AS python-base

LABEL Description="DiscordBot"

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<a href="https://github.com/ddc/DiscordBot/releases/latest"><img src="https://img.shields.io/github/v/release/ddc/DiscordBot?style=plastic&logo=github&logoColor=white" alt="Release"/></a>
<br>
<a href="https://github.com/ddc/DiscordBot/issues"><img src="https://img.shields.io/github/issues/ddc/DiscordBot?style=plastic&logo=github&logoColor=white" alt="issues"/></a>
<a href="https://codecov.io/gh/ddc/DiscordBot"><img src="https://img.shields.io/codecov/c/github/ddc/DiscordBot?token=E942EZII4Q&style=plastic&logo=codecov" alt="codecov"/></a>
<a href="https://sonarcloud.io/component_measures?id=ddc_DiscordBot&metric=coverage"><img src="https://img.shields.io/sonar/coverage/ddc_DiscordBot?server=https%3A%2F%2Fsonarcloud.io&style=plastic&logo=sonarqubecloud&logoColor=white" alt="SonarCloud Coverage"/></a>
<a href="https://sonarcloud.io/dashboard?id=ddc_DiscordBot"><img src="https://img.shields.io/sonar/quality_gate/ddc_DiscordBot?server=https%3A%2F%2Fsonarcloud.io&style=plastic&logo=sonarqubecloud&logoColor=white" alt="Quality Gate Status"/></a>
<a href="https://github.com/ddc/DiscordBot/actions/workflows/workflow.yml"><img src="https://img.shields.io/github/actions/workflow/status/ddc/DiscordBot/workflow.yml?style=plastic&logo=github&logoColor=white&label=CI%2FCD%20Pipeline" alt="CI/CD Pipeline"/></a>
<a href="https://actions-badge.atrox.dev/ddc/DiscordBot/goto?ref=master"><img src="https://img.shields.io/endpoint.svg?url=https%3A//actions-badge.atrox.dev/ddc/DiscordBot/badge?ref=master&label=build&logo=github&style=plastic" alt="Build Status"/></a>
Expand Down
27 changes: 13 additions & 14 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "DiscordBot"
version = "3.0.17"
version = "3.0.18"
description = "A simple Discord bot with multi-provider AI commands (OpenAI / Claude / Gemini) and server administration tools"
urls.Repository = "https://github.com/ddc/DiscordBot"
urls.Homepage = "https://ddc.github.io/DiscordBot"
Expand Down Expand Up @@ -30,29 +30,29 @@ classifiers = [
"Intended Audience :: Developers",
"Natural Language :: English",
]
requires-python = ">=3.14"
requires-python = ">=3.14.6"
dependencies = [
"alembic>=1.18.4",
"anthropic>=0.105.2",
"beautifulsoup4>=4.14.3",
"alembic>=1.18.5",
"anthropic>=0.117.0",
"beautifulsoup4>=4.15.0",
"better-profanity>=0.7.0",
"ddcdatabases[postgres]>=4.0.1",
"discord-py>=2.7.1",
"google-genai>=2.7.0",
"google-genai>=2.12.1",
"gTTS>=2.5.4",
"openai>=2.40.0",
"openai>=2.46.0",
"PyNaCl>=1.6.2",
"pythonLogs>=7.0.1",
"uuid-utils>=0.16.0",
"uuid-utils>=0.17.0",
]

[dependency-groups]
dev = [
"coverage>=7.14.1",
"poethepoet>=0.46.0",
"coverage>=7.15.2",
"poethepoet>=0.48.0",
"pytest-asyncio>=1.4.0",
"ruff>=0.15.15",
"testcontainers[postgres]>=4.15.0rc2",
"ruff>=0.15.22",
"testcontainers[postgres]>=4.15.0rc4",
]

[tool.poe.tasks]
Expand Down Expand Up @@ -86,8 +86,7 @@ updatedev.sequence = [{ shell = "uv lock --upgrade && uv sync --all-extras --all
migration = "uv run --frozen alembic upgrade head"

[tool.pytest.ini_options]
addopts = "-v --import-mode=importlib --junitxml=junit.xml"
junit_family = "legacy"
addopts = "-v --import-mode=importlib"
asyncio_mode = "strict"
asyncio_default_fixture_loop_scope = "function"
markers = [
Expand Down
16 changes: 16 additions & 0 deletions sonar-project.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
sonar.projectKey=ddc_DiscordBot
sonar.organization=ddc1

sonar.sources=src
sonar.tests=tests

sonar.python.version=3.14
sonar.python.coverage.reportPaths=coverage.xml

sonar.exclusions=\
.github/**,\
tests/**

sonar.coverage.exclusions=\
.github/**/*,\
tests/**/*
12 changes: 3 additions & 9 deletions src/bot/cogs/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,8 @@ async def serverinfo(self, ctx: commands.Context) -> None:
stats = self._calculate_server_stats(server)

# Format creation date
now = bot_utils.get_current_date_time()
created_str = bot_utils.convert_datetime_to_str_long(server.created_at)
days_ago = (now - server.created_at).days
created_at = f"Created {created_str}. That's over {days_ago} days ago!"
created_at = f"Created {created_str}."

embed = discord.Embed(description=created_at)
self._add_server_info_fields(embed, server, stats)
Expand Down Expand Up @@ -369,12 +367,8 @@ def _add_server_info_fields(embed: discord.Embed, server: discord.Guild, stats:
@staticmethod
def _get_user_info(guild: discord.Guild, user: discord.Member) -> dict:
"""Get comprehensive user information."""
now = bot_utils.get_current_date_time()
joined_at = user.joined_at

since_created = (now - user.created_at).days
since_joined = (now - joined_at).days

user_joined = joined_at.strftime("%d %b %Y %H:%M")
user_created = user.created_at.strftime("%d %b %Y %H:%M")

Expand All @@ -384,8 +378,8 @@ def _get_user_info(guild: discord.Guild, user: discord.Member) -> dict:
roles_str = ", ".join(roles_list) if roles_list else "None"

return {
"created_on": f"{user_created}\n({since_created} days ago)",
"joined_on": f"{user_joined}\n({since_joined} days ago)",
"created_on": user_created,
"joined_on": user_joined,
"member_number": member_number,
"roles": roles_str,
"user_id": user.id,
Expand Down
22 changes: 21 additions & 1 deletion src/bot/cogs/open_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@ async def geminiweb(self, ctx: commands.Context, *, msg_text: str) -> None:
"""Ask Google's Gemini model with Google Search grounding enabled."""
await self._run_chat(ctx, msg_text, provider="gemini", use_web=True)

@commands.command()
async def ai(self, ctx: commands.Context, *, msg_text: str | None = None) -> None:
"""Deprecated — lists the current AI commands to use instead."""
prefix = ctx.prefix
available = [
(f"{prefix}gpt", "Ask OpenAI's GPT model for a direct answer (no web search)."),
(f"{prefix}gptweb", "Ask OpenAI's GPT model with web search enabled — for current/factual info."),
(f"{prefix}claude", "Ask Anthropic's Claude model for a direct answer (no web search)."),
(f"{prefix}claudeweb", "Ask Anthropic's Claude model with web search enabled — for current/factual info."),
(f"{prefix}gemini", "Ask Google's Gemini model for a direct answer (no web search)."),
(f"{prefix}geminiweb", "Ask Google's Gemini model with Google Search grounding enabled."),
]
embed = discord.Embed(
description="This command has changed, please use the following:",
color=discord.Color.blurple(),
)
for name, desc in available:
embed.add_field(name=name, value=desc, inline=False)
await bot_utils.send_embed(ctx, embed)

# ─────────────────────────── Shared flow ───────────────────────────

async def _run_chat(self, ctx: commands.Context, msg_text: str, provider: str, use_web: bool) -> None:
Expand Down Expand Up @@ -266,7 +286,7 @@ def _create_ai_embeds(
if model is None:
model = self._bot_settings.openai_model
duration = self._format_duration(elapsed)
max_length = 2000
max_length = 1000
chunks: list[str] = []

while description:
Expand Down
20 changes: 9 additions & 11 deletions src/bot/tools/bot_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@
notified = True
except discord.HTTPException:
pass # Notice itself failed; keep retrying the main send
await asyncio.sleep(base_delay * (2 ** (attempt - 1)))

Check warning on line 154 in src/bot/tools/bot_utils.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove those redundant parentheses.

See more on https://sonarcloud.io/project/issues?id=ddc_DiscordBot&issues=AZ92NYwelqCs1FI_FhvQ&open=AZ92NYwelqCs1FI_FhvQ&pullRequest=103

Check warning on line 154 in src/bot/tools/bot_utils.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove those redundant parentheses.

See more on https://sonarcloud.io/project/issues?id=ddc_DiscordBot&issues=AZ92NYwelqCs1FI_FhvP&open=AZ92NYwelqCs1FI_FhvP&pullRequest=103


async def send_embed(ctx, embed, dm=False):
Expand Down Expand Up @@ -281,10 +281,6 @@
async def previous_button(self, interaction: discord.Interaction, button: discord.ui.Button):
if not await self._load_from_db(interaction):
return
if interaction.user.id != self.author_id:
return await interaction.response.send_message(
"Only the command invoker can use these buttons.", ephemeral=True
)
self.current_page -= 1
self._update_buttons()
await interaction.response.edit_message(embed=self.pages[self.current_page], view=self)
Expand All @@ -298,10 +294,6 @@
async def next_button(self, interaction: discord.Interaction, button: discord.ui.Button):
if not await self._load_from_db(interaction):
return
if interaction.user.id != self.author_id:
return await interaction.response.send_message(
"Only the command invoker can use these buttons.", ephemeral=True
)
self.current_page += 1
self._update_buttons()
await interaction.response.edit_message(embed=self.pages[self.current_page], view=self)
Expand All @@ -322,8 +314,9 @@
total_fields = len(embed.fields)
pages = []

total_pages = (total_fields + max_fields - 1) // max_fields
for i in range(0, total_fields, max_fields):
page_embed = discord.Embed(color=color, description=embed.description)
page_embed = discord.Embed(color=color, title=embed.title, description=embed.description)
if embed.author:
page_embed.set_author(name=embed.author.name, icon_url=embed.author.icon_url)
if embed.thumbnail:
Expand All @@ -333,8 +326,13 @@
page_embed.add_field(name=field.name, value=field.value, inline=field.inline)

page_number = (i // max_fields) + 1
total_pages = (total_fields + max_fields - 1) // max_fields
page_embed.set_footer(text=f"Page {page_number}/{total_pages}")
page_text = f"Page {page_number}/{total_pages}"
if embed.footer and embed.footer.text:
page_text = f"{embed.footer.text} | {page_text}"
page_embed.set_footer(
text=page_text,
icon_url=embed.footer.icon_url if embed.footer else None,
)
pages.append(page_embed)

if len(pages) == 1:
Expand Down
4 changes: 2 additions & 2 deletions src/bot/tools/custom_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ class CustomHelpCommand(commands.DefaultHelpCommand):
"""Custom help command that sends DM notifications to the channel."""

def __init__(self, **options):
# Increase page size for bigger window
options.setdefault("paginator", commands.Paginator(prefix="```", suffix="```", max_size=2000))
# Smaller page size so boxes are shorter with more pages
options.setdefault("paginator", commands.Paginator(prefix="```", suffix="```", max_size=1000))
super().__init__(**options)

async def send_bot_help(self, mapping):
Expand Down
2 changes: 1 addition & 1 deletion src/gw2/cogs/characters.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ async def characters(ctx):
icon_url=ctx.bot.user.display_avatar.url, text=f"{bot_utils.get_current_date_time_str_long()} UTC"
)
await progress_msg.delete()
await bot_utils.send_embed(ctx, embed)
await bot_utils.send_paginated_embed(ctx, embed)

except Exception as e:
await progress_msg.delete()
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@

@pytest.fixture(scope="session")
def postgres_container():
from testcontainers.postgres import PostgresContainer
from testcontainers.community.postgres import PostgresContainer

with PostgresContainer("postgres:latest", driver=None) as pg:
yield pg
Expand Down
57 changes: 34 additions & 23 deletions tests/unit/bot/cogs/test_open_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,29 +565,28 @@ def test_create_ai_embeds_normal_length(self, openai_cog, mock_ctx):
assert embeds[0].author.icon_url == "https://example.com/avatar.png"

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
"""Test _create_ai_embeds with description exceeding 1000 characters paginates."""
long_description = "a" * 2010 # Exceeds 1000-character page limit
color = discord.Color.green()

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

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_embeds_exactly_2000_chars(self, openai_cog, mock_ctx):
"""Test _create_ai_embeds with exactly 2000 characters returns single page."""
description = "a" * 2000
assert len(embeds) == 3
assert all(len(e.description) <= 1000 for e in embeds)
assert "".join(e.description for e in embeds) == long_description
assert "Page 1/3" in embeds[0].footer.text
assert "Page 3/3" in embeds[2].footer.text

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

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

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

def test_create_ai_embeds_no_author_avatar(self, openai_cog, mock_ctx):
"""Test _create_ai_embeds when author has no avatar."""
Expand All @@ -600,6 +599,18 @@ def test_create_ai_embeds_no_author_avatar(self, openai_cog, mock_ctx):
assert embeds[0].author.name == "TestUser"
assert embeds[0].author.icon_url is None

@pytest.mark.asyncio
@patch("src.bot.cogs.open_ai.bot_utils.send_embed", new_callable=AsyncMock)
async def test_ai_command_lists_available_commands(self, mock_send_embed, openai_cog, mock_ctx):
"""Test deprecated `ai` command shows the migration message and all AI commands."""
await openai_cog.ai.callback(openai_cog, mock_ctx, msg_text=None)

mock_send_embed.assert_called_once()
embed = mock_send_embed.call_args[0][1]
assert embed.description == "This command has changed, please use the following:"
field_names = [f.name for f in embed.fields]
assert field_names == ["!gpt", "!gptweb", "!claude", "!claudeweb", "!gemini", "!geminiweb"]

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
Expand Down Expand Up @@ -801,32 +812,32 @@ async def test_get_openai_response_empty_response(self, mock_get_settings, opena

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

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
def test_create_ai_embeds_edge_case_997_chars(self, openai_cog, mock_ctx):
"""Test _create_ai_embeds with exactly 997 characters returns single page."""
description = "a" * 997
color = discord.Color.teal()

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

assert len(embeds) == 1
assert embeds[0].description == description
assert len(embeds[0].description) == 1997
assert len(embeds[0].description) == 997

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
def test_create_ai_embeds_edge_case_998_chars(self, openai_cog, mock_ctx):
"""Test _create_ai_embeds with 998 characters returns single page."""
description = "a" * 998
color = discord.Color.magenta()

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

assert len(embeds) == 1
assert embeds[0].description == description
assert len(embeds[0].description) == 1998
assert len(embeds[0].description) == 998

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
# Create text with a newline near the 1000 char boundary
first_part = "a" * 990
second_part = "b" * 100
description = first_part + "\n" + second_part
color = discord.Color.green()
Expand Down
Loading
Loading