From 23edc51dbff8bbe93e276e81f240d3240ad64525 Mon Sep 17 00:00:00 2001 From: stijndcl Date: Tue, 20 Sep 2022 14:47:26 +0200 Subject: [PATCH] Command stats --- .../versions/3c94051821f8_command_stats.py | 37 +++++++++ database/crud/command_stats.py | 38 +++++++++ database/schemas.py | 13 +++ didier/didier.py | 82 ++++++++++++------- 4 files changed, 141 insertions(+), 29 deletions(-) create mode 100644 alembic/versions/3c94051821f8_command_stats.py create mode 100644 database/crud/command_stats.py diff --git a/alembic/versions/3c94051821f8_command_stats.py b/alembic/versions/3c94051821f8_command_stats.py new file mode 100644 index 0000000..3dddc94 --- /dev/null +++ b/alembic/versions/3c94051821f8_command_stats.py @@ -0,0 +1,37 @@ +"""Command stats + +Revision ID: 3c94051821f8 +Revises: b84bb10fb8de +Create Date: 2022-09-20 14:38:41.737628 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "3c94051821f8" +down_revision = "b84bb10fb8de" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "command_stats", + sa.Column("command_stats_id", sa.Integer(), nullable=False), + sa.Column("command", sa.Text(), nullable=False), + sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("slash", sa.Boolean(), nullable=False), + sa.Column("context_menu", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("command_stats_id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("command_stats") + # ### end Alembic commands ### diff --git a/database/crud/command_stats.py b/database/crud/command_stats.py new file mode 100644 index 0000000..91c9636 --- /dev/null +++ b/database/crud/command_stats.py @@ -0,0 +1,38 @@ +from datetime import datetime +from typing import Optional, Union + +from discord import app_commands +from discord.ext import commands +from sqlalchemy.ext.asyncio import AsyncSession + +from database.schemas import CommandStats + +__all__ = ["register_command_invocation"] + + +CommandT = Union[commands.Command, app_commands.Command, app_commands.ContextMenu] + + +async def register_command_invocation( + session: AsyncSession, ctx: commands.Context, command: Optional[CommandT], timestamp: datetime +): + """Create an entry for a command invocation""" + if command is None: + return + + # Check the type of invocation + context_menu = isinstance(command, app_commands.ContextMenu) + + # (This is a bit uglier but it accounts for hybrid commands) + slash = isinstance(command, app_commands.Command) or (ctx.interaction is not None and not context_menu) + + stats = CommandStats( + command=command.qualified_name.lower(), + timestamp=timestamp, + user_id=ctx.author.id, + slash=slash, + context_menu=context_menu, + ) + + session.add(stats) + await session.commit() diff --git a/database/schemas.py b/database/schemas.py index 8b952fd..03637f8 100644 --- a/database/schemas.py +++ b/database/schemas.py @@ -27,6 +27,7 @@ __all__ = [ "Bank", "Birthday", "Bookmark", + "CommandStats", "CustomCommand", "CustomCommandAlias", "DadJoke", @@ -95,6 +96,18 @@ class Bookmark(Base): user: User = relationship("User", back_populates="bookmarks", uselist=False, lazy="selectin") +class CommandStats(Base): + """Metrics on how often commands are used""" + + __tablename__ = "command_stats" + command_stats_id: int = Column(Integer, primary_key=True) + command: str = Column(Text, nullable=False) + timestamp: datetime = Column(DateTime(timezone=True), nullable=False) + user_id: int = Column(BigInteger, nullable=False) + slash: bool = Column(Boolean, nullable=False) + context_menu: bool = Column(Boolean, nullable=False) + + class CustomCommand(Base): """Custom commands to fill the hole Dyno couldn't""" diff --git a/didier/didier.py b/didier/didier.py index a72196e..455284c 100644 --- a/didier/didier.py +++ b/didier/didier.py @@ -2,6 +2,7 @@ import logging import os import pathlib from functools import cached_property +from typing import Union import discord from aiohttp import ClientSession @@ -10,7 +11,7 @@ from discord.ext import commands from sqlalchemy.ext.asyncio import AsyncSession import settings -from database.crud import custom_commands +from database.crud import command_stats, custom_commands from database.engine import DBSession from database.utils.caches import CacheManager from didier.data.embeds.error_embed import create_error_embed @@ -18,6 +19,7 @@ from didier.data.embeds.schedules import Schedule, parse_schedule from didier.exceptions import HTTPException, NoMatch from didier.utils.discord.prefix import get_prefix from didier.utils.easter_eggs import detect_easter_egg +from didier.utils.types.datetime import tz_aware_now __all__ = ["Didier"] @@ -194,30 +196,6 @@ class Didier(commands.Bot): """Log a warning message""" await self._log(logging.WARNING, message, log_to_discord) - async def on_ready(self): - """Event triggered when the bot is ready""" - print(settings.DISCORD_READY_MESSAGE) - - async def on_message(self, message: discord.Message, /) -> None: - """Event triggered when a message is sent""" - # Ignore messages by bots - if message.author.bot: - return - - # Boos react to people that say Dider - if "dider" in message.content.lower() and message.author.id != self.user.id: - await message.add_reaction(settings.DISCORD_BOOS_REACT) - - # Potential custom command - if await self._try_invoke_custom_command(message): - return - - await self.process_commands(message) - - easter_egg = await detect_easter_egg(self, message, self.database_caches.easter_eggs) - if easter_egg is not None: - await message.reply(easter_egg, mention_author=False) - async def _try_invoke_custom_command(self, message: discord.Message) -> bool: """Check if the message tries to invoke a custom command @@ -241,9 +219,16 @@ class Didier(commands.Bot): # Nothing found return False - async def on_thread_create(self, thread: discord.Thread): - """Event triggered when a new thread is created""" - await thread.join() + async def on_app_command_completion( + self, + interaction: discord.Interaction, + command: Union[discord.app_commands.Command, discord.app_commands.ContextMenu], + ): + """Event triggered when an app command completes successfully""" + ctx = await commands.Context.from_interaction(interaction) + + async with self.postgres_session as session: + await command_stats.register_command_invocation(session, ctx, command, tz_aware_now()) async def on_app_command_error(self, interaction: discord.Interaction, exception: AppCommandError): """Event triggered when an application command errors""" @@ -257,8 +242,18 @@ class Didier(commands.Bot): else: return await interaction.followup.send(str(exception.original), ephemeral=True) + async def on_command_completion(self, ctx: commands.Context): + """Event triggered when a message command completes successfully""" + # Hybrid command invocation triggers both this handler and on_app_command_completion + # We handle it in the correct place + if ctx.interaction is not None: + return + + async with self.postgres_session as session: + await command_stats.register_command_invocation(session, ctx, ctx.command, tz_aware_now()) + async def on_command_error(self, ctx: commands.Context, exception: commands.CommandError, /): - """Event triggered when a regular command errors""" + """Event triggered when a message command errors""" # If working locally, print everything to your console if settings.SANDBOX: await super().on_command_error(ctx, exception) @@ -310,3 +305,32 @@ class Didier(commands.Bot): embed = create_error_embed(ctx, exception) channel = self.get_channel(settings.ERRORS_CHANNEL) await channel.send(embed=embed) + + async def on_message(self, message: discord.Message, /) -> None: + """Event triggered when a message is sent""" + # Ignore messages by bots + if message.author.bot: + return + + # Boos react to people that say Dider + if "dider" in message.content.lower() and message.author.id != self.user.id: + await message.add_reaction(settings.DISCORD_BOOS_REACT) + + # Potential custom command + if await self._try_invoke_custom_command(message): + return + + await self.process_commands(message) + + easter_egg = await detect_easter_egg(self, message, self.database_caches.easter_eggs) + if easter_egg is not None: + await message.reply(easter_egg, mention_author=False) + + async def on_ready(self): + """Event triggered when the bot is ready""" + print(settings.DISCORD_READY_MESSAGE) + + async def on_thread_create(self, thread: discord.Thread): + """Event triggered when a new thread is created""" + # Join threads automatically + await thread.join()