Add PostgreSQL memory system for persistent user and conversation storage
- Add PostgreSQL database with SQLAlchemy async support - Create models: User, UserFact, UserPreference, Conversation, Message, Guild, GuildMember - Add custom name support so bot knows 'who is who' - Add user facts system for remembering things about users - Add persistent conversation history that survives restarts - Add memory commands cog (!setname, !remember, !whatdoyouknow, !forgetme) - Add admin commands (!setusername, !teachbot) - Set up Alembic for database migrations - Update docker-compose with PostgreSQL service - Gracefully falls back to in-memory storage when DB not configured
This commit is contained in:
86
alembic/env.py
Normal file
86
alembic/env.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Alembic migration environment configuration."""
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
from daemon_boyfriend.config import settings
|
||||
from daemon_boyfriend.models import Base
|
||||
|
||||
# this is the Alembic Config object
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Model metadata for autogenerate support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
"""Get database URL from settings."""
|
||||
url = settings.database_url
|
||||
if not url:
|
||||
raise ValueError("DATABASE_URL must be set for migrations")
|
||||
return url
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL and not an Engine,
|
||||
though an Engine is acceptable here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the script output.
|
||||
"""
|
||||
url = get_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection) -> None:
|
||||
"""Run migrations with the given connection."""
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Run migrations in async mode."""
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = get_url()
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
alembic/script.py.mako
Normal file
26
alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
200
alembic/versions/20250112_0001_initial_schema.py
Normal file
200
alembic/versions/20250112_0001_initial_schema.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""Initial schema with users, conversations, messages, and guilds.
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2025-01-12
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Users table
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("discord_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("discord_username", sa.String(255), nullable=False),
|
||||
sa.Column("discord_display_name", sa.String(255), nullable=True),
|
||||
sa.Column("custom_name", sa.String(255), nullable=True),
|
||||
sa.Column("first_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("last_seen_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_users")),
|
||||
sa.UniqueConstraint("discord_id", name=op.f("uq_users_discord_id")),
|
||||
)
|
||||
op.create_index(op.f("ix_users_discord_id"), "users", ["discord_id"])
|
||||
op.create_index(op.f("ix_users_last_seen_at"), "users", ["last_seen_at"])
|
||||
|
||||
# User preferences table
|
||||
op.create_table(
|
||||
"user_preferences",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("preference_key", sa.String(100), nullable=False),
|
||||
sa.Column("preference_value", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
name=op.f("fk_user_preferences_user_id_users"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_user_preferences")),
|
||||
sa.UniqueConstraint("user_id", "preference_key", name="uq_user_preferences_user_key"),
|
||||
)
|
||||
op.create_index(op.f("ix_user_preferences_user_id"), "user_preferences", ["user_id"])
|
||||
|
||||
# User facts table
|
||||
op.create_table(
|
||||
"user_facts",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("fact_type", sa.String(50), nullable=False),
|
||||
sa.Column("fact_content", sa.Text(), nullable=False),
|
||||
sa.Column("confidence", sa.Float(), server_default="1.0"),
|
||||
sa.Column("source", sa.String(50), server_default="conversation"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("learned_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("last_referenced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
name=op.f("fk_user_facts_user_id_users"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_user_facts")),
|
||||
)
|
||||
op.create_index(op.f("ix_user_facts_user_id"), "user_facts", ["user_id"])
|
||||
op.create_index(op.f("ix_user_facts_fact_type"), "user_facts", ["fact_type"])
|
||||
op.create_index(op.f("ix_user_facts_is_active"), "user_facts", ["is_active"])
|
||||
|
||||
# Guilds table
|
||||
op.create_table(
|
||||
"guilds",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("discord_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("name", sa.String(255), nullable=False),
|
||||
sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("settings", postgresql.JSONB(), server_default="{}"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_guilds")),
|
||||
sa.UniqueConstraint("discord_id", name=op.f("uq_guilds_discord_id")),
|
||||
)
|
||||
op.create_index(op.f("ix_guilds_discord_id"), "guilds", ["discord_id"])
|
||||
|
||||
# Guild members table
|
||||
op.create_table(
|
||||
"guild_members",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("guild_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("guild_nickname", sa.String(255), nullable=True),
|
||||
sa.Column("roles", postgresql.ARRAY(sa.Text()), nullable=True),
|
||||
sa.Column("joined_guild_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(
|
||||
["guild_id"],
|
||||
["guilds.id"],
|
||||
name=op.f("fk_guild_members_guild_id_guilds"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
name=op.f("fk_guild_members_user_id_users"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_guild_members")),
|
||||
sa.UniqueConstraint("guild_id", "user_id", name="uq_guild_members_guild_user"),
|
||||
)
|
||||
op.create_index(op.f("ix_guild_members_guild_id"), "guild_members", ["guild_id"])
|
||||
op.create_index(op.f("ix_guild_members_user_id"), "guild_members", ["user_id"])
|
||||
|
||||
# Conversations table
|
||||
op.create_table(
|
||||
"conversations",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("guild_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("channel_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("last_message_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("message_count", sa.Integer(), server_default="0"),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
name=op.f("fk_conversations_user_id_users"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_conversations")),
|
||||
)
|
||||
op.create_index(op.f("ix_conversations_user_id"), "conversations", ["user_id"])
|
||||
op.create_index(op.f("ix_conversations_channel_id"), "conversations", ["channel_id"])
|
||||
op.create_index(op.f("ix_conversations_last_message_at"), "conversations", ["last_message_at"])
|
||||
op.create_index(op.f("ix_conversations_is_active"), "conversations", ["is_active"])
|
||||
|
||||
# Messages table
|
||||
op.create_table(
|
||||
"messages",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("conversation_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("discord_message_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("role", sa.String(20), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("has_images", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("image_urls", postgresql.ARRAY(sa.Text()), nullable=True),
|
||||
sa.Column("token_count", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(
|
||||
["conversation_id"],
|
||||
["conversations.id"],
|
||||
name=op.f("fk_messages_conversation_id_conversations"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
name=op.f("fk_messages_user_id_users"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_messages")),
|
||||
)
|
||||
op.create_index(op.f("ix_messages_conversation_id"), "messages", ["conversation_id"])
|
||||
op.create_index(op.f("ix_messages_user_id"), "messages", ["user_id"])
|
||||
op.create_index(op.f("ix_messages_created_at"), "messages", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("messages")
|
||||
op.drop_table("conversations")
|
||||
op.drop_table("guild_members")
|
||||
op.drop_table("guilds")
|
||||
op.drop_table("user_facts")
|
||||
op.drop_table("user_preferences")
|
||||
op.drop_table("users")
|
||||
Reference in New Issue
Block a user