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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
- Guild Wars 2 API integration (accounts, WvW, sessions, wiki)
- Server administration and moderation tools
- Custom commands, profanity filtering, and text-to-speech
- Persistent embed pagination that survives bot restarts
- PostgreSQL database with Alembic migrations
- Docker deployment with automatic database migrations

Expand Down Expand Up @@ -143,7 +144,7 @@ All configuration is done through environment variables in the `.env` file.
| Variable | Default | Description |
|:-------------------|:--------------|:--------------------|
| `OPENAI_API_KEY` | | OpenAI API key |
| `BOT_OPENAI_MODEL` | `gpt-4o-mini` | OpenAI model to use |
| `BOT_OPENAI_MODEL` | `gpt-5.4` | OpenAI model to use |

## PostgreSQL Settings
| Variable | Default | Description |
Expand Down
3 changes: 3 additions & 0 deletions docker-compose-localdb.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ services:
DOCKER_BUILDKIT: 1
networks:
- postgres_network
depends_on:
discordbot_database:
condition: service_healthy
command: ["sh", "-c", "uv run --frozen --no-sync alembic upgrade head && touch /tmp/alembic_done && sleep infinity"]
deploy:
restart_policy:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "DiscordBot"
version = "3.0.11"
version = "3.0.12"
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
32 changes: 13 additions & 19 deletions src/bot/cogs/open_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ class OpenAi(commands.Cog):

def __init__(self, bot: Bot) -> None:
self.bot = bot
self._openai_client: OpenAI | None = None
self._bot_settings = get_bot_settings()
self._openai_client: OpenAI = OpenAI(api_key=self._bot_settings.openai_api_key)

@commands.command()
@commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user)
Expand Down Expand Up @@ -41,20 +42,11 @@ async def ai(self, ctx: commands.Context, *, msg_text: str) -> None:
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:
"""Get or create OpenAI client instance."""
if self._openai_client is None:
api_key = get_bot_settings().openai_api_key
self._openai_client = OpenAI(api_key=api_key)
return self._openai_client
await view.send_and_save(ctx)

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

# Create properly typed messages for OpenAI API
messages: list[ChatCompletionSystemMessageParam | ChatCompletionUserMessageParam] = [
Expand All @@ -66,18 +58,19 @@ async def _get_ai_response(self, message: str) -> str:
]

# Use the correct OpenAI API endpoint
response = self.openai_client.chat.completions.create(
response = self._openai_client.chat.completions.create(
model=model,
messages=messages,
max_completion_tokens=1000,
temperature=0.7,
)

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

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

Expand All @@ -97,13 +90,14 @@ def _create_ai_embeds(ctx: commands.Context, description: str, color: discord.Co
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
name=ctx.author.display_name,
icon_url=getattr(ctx.author.avatar, "url", None),
)
footer_text = bot_utils.get_current_date_time_str_long() + " UTC"
footer_text = f"{model} | {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,
icon_url=ctx.bot.user.avatar.url if ctx.bot.user and ctx.bot.user.avatar else None,
text=footer_text,
)
pages.append(embed)
Expand Down
3 changes: 2 additions & 1 deletion src/bot/discord_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ def __init__(self, *args, **kwargs):
self._load_settings()

async def setup_hook(self) -> None:
"""Called after login - loads all cogs."""
"""Called after login - loads all cogs and registers persistent views."""
try:
await bot_utils.load_cogs(self)
self.add_view(bot_utils.EmbedPaginatorView())
self.log.info(messages.BOT_LOADED_ALL_COGS_SUCCESS)
except Exception as e:
self.log.error(f"{messages.BOT_LOAD_COGS_FAILED}: {e}")
Expand Down
72 changes: 62 additions & 10 deletions src/bot/tools/bot_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,44 +172,97 @@ async def send_embed(ctx, embed, dm=False):


class EmbedPaginatorView(discord.ui.View):
"""Interactive pagination view for embed pages with Previous/Next buttons."""
"""Persistent pagination view for embed pages with Previous/Next buttons.

def __init__(self, pages: list[discord.Embed], author_id: int):
Pages are stored in the database so pagination survives bot restarts.
"""

def __init__(self, pages: list[discord.Embed] | None = None, author_id: int = 0):
super().__init__(timeout=None)
self.pages = pages
self.pages = pages or []
self.current_page = 0
self.author_id = author_id
self.message: discord.Message | None = None
self._update_buttons()

def _update_buttons(self):
self.previous_button.disabled = self.current_page == 0
self.page_indicator.label = f"{self.current_page + 1}/{len(self.pages)}"
self.next_button.disabled = self.current_page == len(self.pages) - 1
self.page_indicator.label = f"{self.current_page + 1}/{len(self.pages)}" if self.pages else "0/0"
self.next_button.disabled = self.current_page >= len(self.pages) - 1

@staticmethod
def _embed_to_dict(embed: discord.Embed) -> dict:
return embed.to_dict()

@staticmethod
def _dict_to_embed(data: dict) -> discord.Embed:
return discord.Embed.from_dict(data)

async def send_and_save(self, ctx) -> None:
"""Send the first page and save all pages to the database."""
msg = await ctx.send(embed=self.pages[0], view=self)
self.message = msg
from src.database.dal.bot.embed_pages_dal import EmbedPagesDal

dal = EmbedPagesDal(ctx.bot.db_session, ctx.bot.log)
pages_data = [self._embed_to_dict(p) for p in self.pages]
await dal.insert_embed_pages(msg.id, msg.channel.id, self.author_id, pages_data)

async def _load_from_db(self, interaction: discord.Interaction) -> bool:
"""Load pages from database if not in memory. Returns True if loaded."""
if self.pages:
return True
from src.database.dal.bot.embed_pages_dal import EmbedPagesDal

bot = interaction.client
dal = EmbedPagesDal(bot.db_session, bot.log)
record = await dal.get_embed_pages(interaction.message.id)
if not record:
await interaction.response.send_message("This pagination has expired.", ephemeral=True)
return False
self.pages = [self._dict_to_embed(p) for p in record["pages"]]
self.current_page = record["current_page"]
self.author_id = record["author_id"]
self._update_buttons()
return True

async def _save_current_page(self, interaction: discord.Interaction) -> None:
"""Update current page in database."""
from src.database.dal.bot.embed_pages_dal import EmbedPagesDal

bot = interaction.client
dal = EmbedPagesDal(bot.db_session, bot.log)
await dal.update_current_page(interaction.message.id, self.current_page)

@discord.ui.button(label="\u25c0", style=discord.ButtonStyle.secondary)
@discord.ui.button(label="\u25c0", style=discord.ButtonStyle.secondary, custom_id="paginator:prev")
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)
await self._save_current_page(interaction)

@discord.ui.button(label="1/1", style=discord.ButtonStyle.secondary, disabled=True)
@discord.ui.button(label="1/1", style=discord.ButtonStyle.secondary, disabled=True, custom_id="paginator:page")
async def page_indicator(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.defer()

@discord.ui.button(label="\u25b6", style=discord.ButtonStyle.secondary)
@discord.ui.button(label="\u25b6", style=discord.ButtonStyle.secondary, custom_id="paginator:next")
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)
await self._save_current_page(interaction)


async def send_paginated_embed(ctx, embed: discord.Embed, max_fields: int = 25) -> None:
Expand Down Expand Up @@ -246,8 +299,7 @@ async def send_paginated_embed(ctx, embed: discord.Embed, max_fields: int = 25)
return

view = EmbedPaginatorView(pages, ctx.message.author.id)
msg = await ctx.send(embed=pages[0], view=view)
view.message = msg
await view.send_and_save(ctx)


async def delete_message(ctx, warning=False):
Expand Down
33 changes: 33 additions & 0 deletions src/database/dal/bot/embed_pages_dal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import sqlalchemy as sa
from ddcdatabases import DBUtilsAsync
from sqlalchemy.future import select
from src.database.models.bot_models import EmbedPages


class EmbedPagesDal:
def __init__(self, db_session, log):
self.columns = list(EmbedPages.__table__.columns.values())
self.db_utils = DBUtilsAsync(db_session)
self.log = log

async def insert_embed_pages(self, message_id: int, channel_id: int, author_id: int, pages: list[dict]):
stmt = EmbedPages(
message_id=message_id,
channel_id=channel_id,
author_id=author_id,
pages=pages,
)
await self.db_utils.insert(stmt)

async def get_embed_pages(self, message_id: int):
stmt = select(*self.columns).where(EmbedPages.message_id == message_id)
results = await self.db_utils.fetchall(stmt, True)
return results[0] if results else None

async def update_current_page(self, message_id: int, current_page: int):
stmt = sa.update(EmbedPages).where(EmbedPages.message_id == message_id).values(current_page=current_page)
await self.db_utils.execute(stmt)

async def delete_embed_pages(self, message_id: int):
stmt = sa.delete(EmbedPages).where(EmbedPages.message_id == message_id)
await self.db_utils.execute(stmt)
48 changes: 48 additions & 0 deletions src/database/migrations/versions/0011_embed_pages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""embed_pages

Revision ID: 0011
Revises: 0010
Create Date: 2026-03-28 18:00:00.000000

"""

import sqlalchemy as sa
from alembic import op
from collections.abc import Sequence
from sqlalchemy.dialects.postgresql import JSONB

# revision identifiers, used by Alembic.
revision: str = "0011"
down_revision: str | None = "0010"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.create_table(
"embed_pages",
sa.Column("id", sa.Uuid(), nullable=False, server_default=sa.text("gen_random_uuid()")),
sa.Column("message_id", sa.BigInteger(), nullable=False),
sa.Column("channel_id", sa.BigInteger(), nullable=False),
sa.Column("author_id", sa.BigInteger(), nullable=False),
sa.Column("current_page", sa.Integer(), server_default="0", nullable=False),
sa.Column("pages", JSONB(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(now() at time zone 'utc')"), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("(now() at time zone 'utc')"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("id"),
sa.UniqueConstraint("message_id"),
)
op.create_index(op.f("ix_embed_pages_message_id"), "embed_pages", ["message_id"], unique=True)
op.execute("""
CREATE TRIGGER before_update_embed_pages_tr
BEFORE UPDATE ON embed_pages
FOR EACH ROW
EXECUTE PROCEDURE updated_at_column_func();
""")


def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS before_update_embed_pages_tr ON embed_pages")
op.drop_index(op.f("ix_embed_pages_message_id"), table_name="embed_pages")
op.drop_table("embed_pages")
14 changes: 13 additions & 1 deletion src/database/models/bot_models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from sqlalchemy import CHAR, BigInteger, Boolean, ForeignKey, Uuid
from sqlalchemy import CHAR, BigInteger, Boolean, ForeignKey, Integer, Uuid
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.bot.constants import variables
from src.database.models import BotBase
from typing import Any
from uuid import UUID
from uuid_utils import uuid7

Expand Down Expand Up @@ -67,3 +69,13 @@ class DiceRolls(BotBase):
roll: Mapped[int] = mapped_column()
dice_size: Mapped[int] = mapped_column()
servers = relationship("Servers", back_populates="dice_rolls")


class EmbedPages(BotBase):
__tablename__ = "embed_pages"
id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid7)
message_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
channel_id: Mapped[int] = mapped_column(BigInteger)
author_id: Mapped[int] = mapped_column(BigInteger)
current_page: Mapped[int] = mapped_column(Integer, server_default="0")
pages: Mapped[list[dict[str, Any]]] = mapped_column(JSONB)
3 changes: 1 addition & 2 deletions src/gw2/cogs/worlds.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,7 @@ async def _send_paginated_worlds_embed(ctx, embed):
return

view = EmbedPaginatorView(pages, ctx.author.id)
msg = await ctx.send(embed=pages[0], view=view)
view.message = msg
await view.send_and_save(ctx)


async def setup(bot):
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/test_alembic_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"custom_commands",
"profanity_filters",
"dice_rolls",
"embed_pages",
]

EXPECTED_GW2_TABLES = [
Expand All @@ -28,6 +29,7 @@
"before_update_custom_commands_tr",
"before_update_profanity_filters_tr",
"before_update_dice_rolls_tr",
"before_update_embed_pages_tr",
]

EXPECTED_GW2_TRIGGERS = [
Expand Down Expand Up @@ -135,7 +137,7 @@ async def test_alembic_version_at_head(db_session):
text("SELECT version_num FROM alembic_version"),
)
assert len(rows) == 1
assert rows[0]["version_num"] == "0010"
assert rows[0]["version_num"] == "0011"


# ──────────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading