From 36a98b97583e1be98b281afcb20ccb581387e821 Mon Sep 17 00:00:00 2001
From: ajax146 <31014239+ajax146@users.noreply.github.com>
Date: Tue, 16 Jun 2026 17:10:17 -0700
Subject: [PATCH 01/33] Start on the fundemental rewrite of factoids. 2.5/17
commands done
---
core/databases.py | 78 +-
modules/operation/factoids.py | 3180 ++++++---------------------------
2 files changed, 543 insertions(+), 2715 deletions(-)
diff --git a/core/databases.py b/core/databases.py
index 98a16a90..b74731e0 100644
--- a/core/databases.py
+++ b/core/databases.py
@@ -127,57 +127,50 @@ class DuckUser(bot.db.Model):
)
speed_record: float = bot.db.Column(bot.db.Float, default=-1.0)
- class Factoid(bot.db.Model):
- """The postgres table for factoids
- Currently used in factoid.py
+ class FactoidData(bot.db.Model):
+ __tablename__ = "factoid_data"
- Attributes:
- factoid_id (int): The primary key of the factoid
- name (str): The name of the factoid
- guild (str): The string guild ID for the guild that the factoid is in
- message (str): The string message of the factoid
- time (datetime.datetime): When the factoid was created NOT edited
- embed_config (str): The json of the factoid
- hidden (bool): If the factoid should be hidden or not
- protected (bool): If the factoid should be protected
- disabled (bool): If the factoid should be disabled
- restricted (bool): If the factoid should be restricted
- alias (str): The string representation of the parent
- """
+ factoid_data_id: int = bot.db.Column(bot.db.Integer, primary_key=True)
+ guild: str = bot.db.Column(bot.db.String, index=True)
+ message: str = bot.db.Column(bot.db.String, index=True)
+ create_time: datetime.datetime = bot.db.Column(
+ bot.db.DateTime, default=datetime.datetime.utcnow
+ )
+ edit_time: datetime.datetime = bot.db.Column(
+ bot.db.DateTime,
+ default=datetime.datetime.utcnow,
+ onupdate=datetime.datetime.utcnow,
+ )
+ json_string: str = bot.db.Column(bot.db.String, default=None)
+ flags: int = bot.db.Column(bot.db.Integer)
+ times_called: int = bot.db.Column(bot.db.Integer)
- __tablename__ = "factoids"
+ class FactoidCall(bot.db.Model):
+ __tablename__ = "factoid_calls"
- factoid_id: int = bot.db.Column(bot.db.Integer, primary_key=True)
+ factoid_call_id: int = bot.db.Column(bot.db.Integer, primary_key=True)
+ guild: str = bot.db.Column(bot.db.String, index=True)
name: str = bot.db.Column(bot.db.String)
- guild: str = bot.db.Column(bot.db.String)
- message: str = bot.db.Column(bot.db.String)
- time: datetime.datetime = bot.db.Column(
- bot.db.DateTime, default=datetime.datetime.utcnow
+
+ factoid_data_id = bot.db.Column(
+ bot.db.Integer,
+ bot.db.ForeignKey("factoid_data.factoid_data_id"),
+ nullable=False,
+ index=True,
)
- embed_config: str = bot.db.Column(bot.db.String, default=None)
- hidden: bool = bot.db.Column(bot.db.Boolean, default=False)
- protected: bool = bot.db.Column(bot.db.Boolean, default=False)
- disabled: bool = bot.db.Column(bot.db.Boolean, default=False)
- restricted: bool = bot.db.Column(bot.db.Boolean, default=False)
- alias: str = bot.db.Column(bot.db.String, default=None)
class FactoidJob(bot.db.Model):
- """The postgres table for factoid loops
- Currently used in factoid.py
-
- Attributes:
- job_id (int): The primary key, ID of the job
- factoid (int): The primary key of the linked factoid
- channel (str): The channel this loop needs to run in
- cron (str): The frequency this job should run
- """
-
__tablename__ = "factoid_jobs"
- job_id: int = bot.db.Column(bot.db.Integer, primary_key=True)
- factoid: int = bot.db.Column(
- bot.db.Integer, bot.db.ForeignKey("factoids.factoid_id")
+ factoid_job_id: int = bot.db.Column(bot.db.Integer, primary_key=True)
+ guild: str = bot.db.Column(bot.db.String, index=True)
+ factoid_data_id = bot.db.Column(
+ bot.db.Integer,
+ bot.db.ForeignKey("factoid_data.factoid_data_id"),
+ nullable=False,
+ index=True,
)
+
channel: str = bot.db.Column(bot.db.String)
cron: str = bot.db.Column(bot.db.String)
@@ -387,7 +380,8 @@ class XP(bot.db.Model):
bot.models.AppBans = ApplicationBans
bot.models.ModLog = ModLog
bot.models.DuckUser = DuckUser
- bot.models.Factoid = Factoid
+ bot.models.FactoidData = FactoidData
+ bot.models.FactoidCall = FactoidCall
bot.models.FactoidJob = FactoidJob
bot.models.Grab = Grab
bot.models.IRCChannelMapping = IRCChannelMapping
diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py
index 587a7895..dbf443fa 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -1,80 +1,23 @@
-"""
-Name: Factoids
-Info: Makes callable slices of text
-Unit tests: No
-Config: manage_roles, prefix
-API: Linx
-Databases: Postgres
-Models: Factoid, FactoidJob
-Subcommands: remember, forget, info, json, all, search, loop, deloop, job, jobs, hide, unhide,
- alias, dealias
-Defines: has_manage_factoids_role
-"""
-
from __future__ import annotations
-import asyncio
-import datetime
-import io
import json
-import re
-from dataclasses import dataclass
-from enum import Enum
-from socket import gaierror
from typing import TYPE_CHECKING, Self
-import aiocron
import discord
-import expiringdict
-import munch
-import yaml
-from aiohttp.client_exceptions import InvalidURL
from discord import app_commands
-from discord.ext import commands
-import configuration
import ui
from botlogging import LogContext, LogLevel
-from core import auxiliary, cogs, custom_errors
-from modules.moderation import logger as function_logger
+from core import auxiliary, cogs, configuration
if TYPE_CHECKING:
import bot
-async def setup(bot: bot.TechSupportBot) -> None:
- """Loading the Factoid plugin into the bot
-
- Args:
- bot (bot.TechSupportBot): The bot object to register the cogs to
- """
- await bot.add_cog(
- FactoidManager(
- bot=bot,
- )
- )
-
-
-async def has_manage_factoids_role(ctx: commands.Context) -> bool:
- """A command check to determine if the invoker is allowed to modify basic factoids
-
- Args:
- ctx (commands.Context): The context the command was run
-
- Returns:
- bool: True if the command can be run, False if it can't
- """
- return await has_given_factoids_role(
- ctx.guild,
- ctx.author,
- configuration.get_config_entry(ctx.guild.id, "factoids_manage_roles"),
- )
-
-
-async def has_manage_factoids_role_interaction(
+async def has_manage_factoids_role(
interaction: discord.Interaction,
) -> bool:
- """A command check to determine if the invoker is allowed to modify basic factoids
+ """A command check to determine if the invoker has a configured manage role
Args:
interaction (discord.Interaction): The context the command was run
@@ -89,19 +32,19 @@ async def has_manage_factoids_role_interaction(
)
-async def has_admin_factoids_role(ctx: commands.Context) -> bool:
- """A command check to determine if the invoker is allowed to modify factoid properties
+async def has_admin_factoids_role(interaction: discord.Interaction) -> bool:
+ """A command check to determine if the invoker has a configured admin role
Args:
- ctx (commands.Context): The context the command was run
+ interaction (discord.Interaction): The context the command was run
Returns:
bool: True if the command can be run, False if it can't
"""
return await has_given_factoids_role(
- ctx.guild,
- ctx.author,
- configuration.get_config_entry(ctx.guild.id, "factoids_admin_roles"),
+ interaction.guild,
+ interaction.user,
+ configuration.get_config_entry(interaction.guild.id, "factoids_admin_roles"),
)
@@ -132,859 +75,585 @@ async def has_given_factoids_role(
factoid_roles.append(factoid_role)
if not factoid_roles:
- raise commands.CommandError(
+ raise app_commands.AppCommandError(
"No factoid management roles found in the config file"
)
# Checking against the user to see if they have the roles specified in the config
if not any(
factoid_role in getattr(invoker, "roles", []) for factoid_role in factoid_roles
):
- raise commands.MissingAnyRole(factoid_roles)
+ raise app_commands.MissingAnyRole(factoid_roles)
return True
-@dataclass
-class CalledFactoid:
- """A class to allow keeping the original factoid name in tact
- Without having to call the database lookup function every time
-
- Attributes:
- original_call_str (str): The original name the user provided for a factoid
- factoid_db_entry (bot.models.Factoid): The database entry for the original factoid
- """
-
- original_call_str: str
- factoid_db_entry: bot.models.Factoid
-
-
-class Properties(Enum):
- """
- This enum is for the new factoid all to be able to handle dynamic properties
+async def setup(bot: bot.TechSupportBot) -> None:
+ """Loading the Factoid plugin into the bot
- Attributes:
- HIDDEN (str): Representation of hidden
- DISABLED (str): Representation of disabled
- RESTRICTED (str): Representation of restricted
- PROTECTED (str): Representation of protected
+ Args:
+ bot (bot.TechSupportBot): The bot to register the cog to
"""
+ await bot.add_cog(FactoidManager(bot=bot))
- HIDDEN: str = "hidden"
- DISABLED: str = "disabled"
- RESTRICTED: str = "restricted"
- PROTECTED: str = "protected"
-
-
-class FactoidManager(cogs.MatchCog):
- """
- Manages all factoid features
- Attributes:
- CRON_REGEX (str): The regex to check if a cronjob is correct
- factoid_app_group (app_commands.Group): Group for /factoid commands
- """
+class FactoidManager(cogs.BaseCog):
- CRON_REGEX: str = (
- r"^((\*|([0-5]?\d|\*\/\d+)(-([0-5]?\d))?)(,\s*(\*|([0-5]?\d|\*\/\d+)(-([0-5]"
- + r"?\d))?)){0,59}\s+){4}(\*|([0-7]?\d|\*(\/[1-9]|[1-5]\d)|mon|tue|wed|thu|fri|sat|sun"
- + r")|\*\/[1-9])$"
- )
+ FACTOID_FLAG_DISABLED = 0b1000
+ FACTOID_FLAG_HIDDEN = 0b0100
+ FACTOID_FLAG_PROTECTED = 0b0010
+ FACTOID_FLAG_RESTRICTED = 0b0001
factoid_app_group: app_commands.Group = app_commands.Group(
name="factoid", description="Command Group for the Factoids Extension"
)
- async def preconfig(self: Self) -> None:
- """Preconfig for factoid jobs"""
- self.factoid_cache = expiringdict.ExpiringDict(
- max_len=100, max_age_seconds=1200
- )
- # set a hard time limit on repeated cronjob DB calls
- self.running_jobs = {}
- self.factoid_all_cache = expiringdict.ExpiringDict(
- max_len=1,
- max_age_seconds=86400, # 24 hours, matches deletion on linx server
- )
- await self.bot.logger.send_log(
- message="Loading factoid jobs",
- level=LogLevel.DEBUG,
- )
- await self.kickoff_jobs()
+ # DATABASE
+ # TODO: Add caching
- # -- DB calls --
- async def delete_factoid_call(
- self: Self, factoid: bot.models.Factoid, guild: str
- ) -> None:
- """Calls the db to delete a factoid
+ async def create_factoid_call(
+ self: Self,
+ guild: discord.Guild,
+ name: str,
+ factoid_data_id: int,
+ ) -> bot.models.FactoidCall:
+ """This creates a new factoid call database entry for the given guild and factoid
Args:
- factoid (bot.models.Factoid): The factoid to delete
- guild (str): The guild ID for cache handling
- """
- # Removes the `factoid all` cache since it has become outdated
- if guild in self.factoid_all_cache:
- del self.factoid_all_cache[guild]
+ self (Self): _description_
+ guild (discord.Guild): The guild to create the factoid in
+ name (str): The name of the factoid call to create
+ factoid_data_id (int): The factoid data entry to associate this call with
- # Deloops the factoid first (if it's looped)
- jobs = await self.bot.models.FactoidJob.query.where(
- self.bot.models.FactoidJob.factoid == factoid.factoid_id
- ).gino.all()
- if jobs:
- for job in jobs:
- job_id = job.job_id
- # Cancels the job
- self.running_jobs[job_id]["task"].cancel()
-
- # Removes it from the cache
- del self.running_jobs[job_id]
-
- # Removes the DB entry
- await job.delete()
+ Returns:
+ bot.models.FactoidCall: The newly created database entry
+ """
- await self.handle_cache(guild, factoid.name)
- await factoid.delete()
+ return await self.bot.models.FactoidCall.create(
+ guild=str(guild.id),
+ name=name,
+ factoid_data_id=factoid_data_id,
+ )
- async def create_factoid_call(
+ async def read_factoid_call(
self: Self,
- factoid_name: str,
- guild: str,
- message: str,
- embed_config: str,
- alias: str = None,
- properties: list[bool] = None,
- ) -> None:
- """Calls the DB to create a factoid
+ guild: discord.Guild,
+ name: str,
+ ) -> bot.models.FactoidCall:
+ """Searches the database for a factoid call for the passed guild
Args:
- factoid_name (str): The name of the factoid
- guild (str): Guild of the factoid
- message (str): Message the factoid should send
- embed_config (str): Whether the factoid has an embed set up
- alias (str, optional): The parent factoid. Defaults to None.
- properties (list[bool]): A list of true/false for properties. Defaults to None.
- 0 Disabled, 1 Hidden, 2 Protected, 3 Restricted
+ guild (discord.Guild): The guild to find the factoid call of
+ name (str): The name of the factoid to search for
- Raises:
- TooLongFactoidMessageError:
- When the message argument is over 2k chars, discords limit
+ Returns:
+ bot.models.FactoidCall: The database entry for the factoid call
"""
- if not properties:
- properties = [False, False, False, False]
-
- if len(message) > 2000:
- raise custom_errors.TooLongFactoidMessageError
-
- # Removes the `factoid all` cache since it has become outdated
- if guild in self.factoid_all_cache:
- del self.factoid_all_cache[guild]
-
- factoid = self.bot.models.Factoid(
- name=factoid_name.lower(),
- guild=guild,
- message=message,
- embed_config=embed_config,
- alias=alias,
- disabled=properties[0],
- hidden=properties[1],
- protected=properties[2],
- restricted=properties[3],
- )
- await factoid.create()
+ return await self.bot.models.FactoidCall.query.where(
+ (self.bot.models.FactoidCall.guild == str(guild.id))
+ & (self.bot.models.FactoidCall.name == name)
+ ).gino.first()
- async def modify_factoid_call(
+ async def delete_factoid_data(
self: Self,
- factoid: bot.models.Factoid,
+ guild: discord.Guild,
+ factoid_data_id: int,
) -> None:
- """Makes a DB call to modify a factoid
+ """Deletes factoid data from the database
+ This does not impact factoid jobs or factoid calls
Args:
- factoid (bot.models.Factoid): Factoid to modify.
-
- Raises:
- TooLongFactoidMessageError:
- When the message argument is over 2k chars, discords limit
+ guild (discord.Guild): The guild the factoid data is stored in
+ factoid_data_id (int): The ID of the data entry to delete
"""
- if len(factoid.message) > 2000:
- raise custom_errors.TooLongFactoidMessageError
-
- # Removes the `factoid all` cache since it has become outdated
- if factoid.guild in self.factoid_all_cache:
- del self.factoid_all_cache[factoid.guild]
-
- await factoid.update(
- name=factoid.name,
- message=factoid.message,
- embed_config=factoid.embed_config,
- hidden=factoid.hidden,
- protected=factoid.protected,
- disabled=factoid.disabled,
- restricted=factoid.restricted,
- alias=factoid.alias,
- ).apply()
-
- await self.handle_cache(factoid.guild, factoid.name)
-
- # -- Utility --
- async def confirm_factoid_deletion(
+
+ await self.bot.models.FactoidData.delete.where(
+ (self.bot.models.FactoidData.guild == str(guild.id))
+ & (self.bot.models.FactoidData.factoid_data_id == factoid_data_id)
+ ).gino.status()
+
+ async def create_factoid_data(
self: Self,
- factoid_name: str,
- channel: discord.abc.GuildChannel,
- author: discord.Member,
- fmt: str,
- ) -> bool:
- """Confirms if a factoid should be deleted/modified
+ guild: discord.Guild,
+ message: str,
+ json_string: str,
+ flags: int,
+ ) -> bot.models.FactoidData:
+ """Creates a new factoid data entry in the table
+ This will not create a call to this factoid
Args:
- factoid_name (str): The factoid that is being prompted for deletion
- channel (discord.abc.GuildChannel): The channel the factoid is being deleted in
- author (discord.Member): The member deleting the factoid
- fmt (str): Formatting for the returned message
+ guild (discord.Guild): The guild to create this factoid for
+ message (str): The plaintext version of the factoid
+ json_string (str): The json for this factoid
+ flags (int): The property binary flags for this factoid
Returns:
- bool: Whether the factoid was deleted/modified
+ bot.models.FactoidData: The newly created database entry
"""
- view = ui.Confirm()
- await view.send(
- message=(
- f"The factoid `{factoid_name}` already exists. Should I overwrite it?"
- ),
- channel=channel,
- author=author,
+ return await self.bot.models.FactoidData.create(
+ guild=str(guild.id),
+ message=message,
+ json_string=json_string,
+ flags=flags,
+ times_called=0,
)
- await view.wait()
- if view.value is ui.ConfirmResponse.TIMEOUT:
- return False
-
- if view.value is ui.ConfirmResponse.DENIED:
- await auxiliary.send_deny_embed(
- message=f"The factoid `{factoid_name}` was not {fmt}.",
- channel=channel,
- )
- return False
-
- return True
-
- async def check_valid_factoid_contents(
- self: Self, ctx: commands.Context, factoid_name: str, message: str
- ) -> str:
- """Makes sure the factoid contents are valid
+ async def read_factoid_data(
+ self: Self,
+ guild: discord.Guild,
+ factoid_data_id: int,
+ ) -> bot.models.FactoidData:
+ """Searches the database for a factoid data for the passed guild
Args:
- ctx (commands.Context): Used to make sure that the .factoid remember invokation message
- didn't include any mentions
- factoid_name (str): The name to check
- message (str): The message to check
+ guild (discord.Guild): The guild to find the factoid call of
+ factoid_data_id (int): The ID of the factoid to search for
Returns:
- str: The error message
+ bot.models.FactoidData: The database entry for the factoid call
"""
- # Prevents factoids from being created with any mentions
- if (
- ctx.message.mention_everyone # @everyone
- or ctx.message.role_mentions # @role
- or ctx.message.mentions # @person
- or ctx.message.channel_mentions # #Channel
- ):
- return "I cannot remember factoids with user/role/channel mentions"
-
- # Prevents factoids being created with html elements
- if re.search(r"<[^>]+>", message) or re.search(r"<[^>]+>", factoid_name):
- return "Cannot create factoids that contain HTML tags!"
+ return await self.bot.models.FactoidData.query.where(
+ (self.bot.models.FactoidData.guild == str(guild.id))
+ & (self.bot.models.FactoidData.factoid_data_id == factoid_data_id)
+ ).gino.first()
- # Prevents factoids being created with spaces
- if " " in factoid_name:
- return "Cannot create factoids with names that contain spaces!"
+ async def delete_factoid_call(
+ self: Self,
+ guild: discord.Guild,
+ name: str,
+ ) -> None:
+ """Deletes a factoid call by name."""
- return None
+ await self.bot.models.FactoidCall.delete.where(
+ (self.bot.models.FactoidCall.guild == str(guild.id))
+ & (self.bot.models.FactoidCall.name == name)
+ ).gino.status()
- async def handle_parent_change(
- self: Self, ctx: commands.Context, aliases: list, new_name: str
- ) -> None:
- """Changes the list of aliases to point to a new name
+ async def get_factoid_calls_by_factoid_id(
+ self: Self,
+ guild: discord.Guild,
+ factoid_data_id: int,
+ ) -> list:
+ """Returns all calls pointing to a factoid."""
- Args:
- ctx (commands.Context): Used for cache handling
- aliases (list): A list of aliases to change
- new_name (str): The name of the new parent
- """
+ return await self.bot.models.FactoidCall.query.where(
+ (self.bot.models.FactoidCall.guild == str(guild.id))
+ & (self.bot.models.FactoidCall.factoid_data_id == factoid_data_id)
+ ).gino.all()
- for alias in aliases:
- # Doesn't handle the initial, changed alias
- if alias.name == new_name:
- continue
- # Updates the existing aliases to point to the new parent
- alias.alias = new_name
- await self.modify_factoid_call(factoid=alias)
- await self.handle_cache(str(ctx.guild.id), alias.name)
+ # DATABASE HELPERS
- async def check_alias_recursion(
+ async def get_factoid_data_by_name(
self: Self,
- channel: discord.TextChannel,
- guild: str,
- factoid_name: str,
- alias_name: str,
- ) -> bool:
- """Makes sure an alias isn't already present in a factoids alias list
+ guild: discord.Guild,
+ name: str,
+ ) -> bot.models.FactoidData:
+ """Searches for the factoid data associated with a given factoid name
Args:
- channel (discord.TextChannel): The channel to send the return message to
- guild (str): The id of the guild from which the command was executed
- factoid_name (str): The name of the parent
- alias_name (str): The alias to check
+ guild (discord.Guild): The guild to look for the factoid in
+ name (str): The name of the factoid to lookup
Returns:
- bool: Whether the alias recurses
+ bot.models.FactoidData: The database entry of the factoid data, if found
"""
- # Get list of aliases of the target factoid
- factoid_aliases = (
- await self.bot.models.Factoid.query.where(
- self.bot.models.Factoid.alias == alias_name
- )
- .where(self.bot.models.Factoid.guild == guild)
- .gino.all()
+ call = await self.read_factoid_call(
+ guild=guild,
+ name=name,
)
- # Returns arue if the factoid and alias name is the same (.factoid alias a a)
- if factoid_name == alias_name:
- await auxiliary.send_deny_embed(
- message="Can't set an alias for itself!", channel=channel
- )
- return True
-
- # Returns True if the target has the alias already
- # (.factoid alias b a, where b has a set already)
- if factoid_name in [alias.name for alias in factoid_aliases]:
- await auxiliary.send_deny_embed(
- message=f"`{alias_name}` already has `{factoid_name}`"
- + "set as an alias!",
- channel=channel,
- )
- return True
-
- return False
-
- def get_embed_from_factoid(
- self: Self, factoid: bot.models.Factoid
- ) -> discord.Embed:
- """Gets the factoid embed from its message.
+ if call is None:
+ return None
- Args:
- factoid (bot.models.Factoid): The factoid to get the json of
+ return await self.read_factoid_data(
+ guild=guild,
+ factoid_data_id=call.factoid_data_id,
+ )
- Returns:
- discord.Embed: The embed of the factoid
+ async def delete_factoid_by_name(
+ self: Self,
+ guild: discord.Guild,
+ name: str,
+ ) -> bool:
+ """
+ Deletes a factoid call.
+ If it was the last call, deletes the underlying factoid data too.
+ Returns True if anything was deleted.
"""
- if not factoid.embed_config:
- return None
- embed_config = json.loads(factoid.embed_config)
+ call = await self.get_factoid_call(
+ guild=guild,
+ name=name,
+ )
- return discord.Embed.from_dict(embed_config)
+ if call is None:
+ return False
- # -- Cache functions --
- async def handle_cache(self: Self, guild: str, factoid_name: str) -> None:
- """Deletes factoid from the factoid cache
+ factoid_data_id = call.factoid_data_id
- Args:
- guild (str): The guild to get the cache key
- factoid_name (str): The name of the factoid to remove from the cache
- """
- key = self.get_cache_key(guild, factoid_name)
+ # delete the call first
+ await self.delete_factoid_call(
+ guild=guild,
+ name=name,
+ )
- if key in self.factoid_cache:
- del self.factoid_cache[key]
+ # check remaining calls
+ remaining_calls = await self.get_factoid_calls_by_factoid_id(
+ guild=guild,
+ factoid_data_id=factoid_data_id,
+ )
- def get_cache_key(self: Self, guild: str, factoid_name: str) -> str:
- """Gets the cache key for a guild
+ if not remaining_calls:
+ await self.delete_factoid_data(
+ guild=guild,
+ factoid_data_id=factoid_data_id,
+ )
- Args:
- guild (str): The ID of the guild
- factoid_name (str): The name of the factoid
+ return True
- Returns:
- str: The cache key
+ async def move_factoid_call(
+ self: Self,
+ guild: discord.Guild,
+ existing_name: str,
+ new_factoid_data_id: int,
+ ) -> bool:
"""
- return f"{guild}_{factoid_name}"
-
- # -- Getting factoids --
- async def get_all_factoids(
- self: Self, guild: str = None, list_hidden: bool = False
- ) -> list:
- """Gets all factoids from a guild
+ Moves a factoid call to a different factoid data entry.
- Args:
- guild (str, optional): The guild to get the factoids from.
- Defaults to None, where all guilds are returned instead.
- list_hidden (bool, optional): Whether to list hidden factoids as well.
- Defaults to False.
-
- Returns:
- list: List of factoids
+ If the old factoid_data loses all calls, it is deleted.
+ Returns True if the move succeeded.
"""
- # Gets factoids for a guild, including those that are hidden
- if guild and list_hidden:
- factoids = await self.bot.models.Factoid.query.where(
- self.bot.models.Factoid.guild == guild
- ).gino.all()
-
- # Gets factoids for a guild excluding the hidden ones
- elif guild and not list_hidden:
- factoids = (
- await self.bot.models.Factoid.query.where(
- self.bot.models.Factoid.guild == guild
- )
- # hiding hidden factoids
- # pylint: disable=C0121
- .where(self.bot.models.Factoid.hidden == False).gino.all()
- )
-
- # Gets ALL factoids for ALL guilds
- else:
- factoids = await self.bot.db.all(self.bot.models.Factoid.query)
- # Sorts them alphabetically
- if factoids:
- factoids.sort(key=lambda factoid: factoid.name)
+ call = await self.read_factoid_call(
+ guild=guild,
+ name=existing_name,
+ )
- return factoids
+ if call is None:
+ return False
- async def get_raw_factoid_entry(
- self: Self, factoid_name: str, guild: str
- ) -> bot.models.Factoid:
- """Searches the db for a factoid by its name, does NOT follow aliases
+ old_factoid_data_id = call.factoid_data_id
- Args:
- factoid_name (str): The name of the factoid to get
- guild (str): The id of the guild for the factoid
+ # Update the call to point to the new factoid
+ await self.bot.models.FactoidCall.update.values(
+ factoid_data_id=new_factoid_data_id
+ ).where(
+ (self.bot.models.FactoidCall.guild == str(guild.id))
+ & (self.bot.models.FactoidCall.name == existing_name)
+ ).gino.status()
- Raises:
- FactoidNotFoundError: Raised when the provided factoid doesn't exist
+ # Check if the old factoid is now orphaned
+ remaining_calls = await self.get_factoid_calls_by_factoid_id(
+ guild=guild,
+ factoid_data_id=old_factoid_data_id,
+ )
- Returns:
- bot.models.Factoid: The factoid
- """
- cache_key = self.get_cache_key(guild, factoid_name.lower())
- factoid = self.factoid_cache.get(cache_key)
- # If the factoid isn't cached
- if not factoid:
- factoid = (
- await self.bot.models.Factoid.query.where(
- self.bot.models.Factoid.name == factoid_name.lower()
- )
- .where(self.bot.models.Factoid.guild == guild)
- .gino.first()
+ # If there aren't any calls, prevent having orphaned factoids in the database at all
+ if not remaining_calls:
+ await self.delete_factoid_data(
+ guild=guild,
+ factoid_data_id=old_factoid_data_id,
)
- # If the factoid doesn't exist
- if not factoid:
- raise custom_errors.FactoidNotFoundError(factoid=factoid_name)
-
- # Caches it
- self.factoid_cache[cache_key] = factoid
+ return True
- return factoid
+ # OTHER HELPERS
- async def get_factoid(
- self: Self, factoid_name: str, guild: str
- ) -> bot.models.Factoid:
- """Gets the factoid from the DB, follows aliases
+ def can_channel_send_restricted(
+ self: Self, channel: discord.abc.GuildChannel
+ ) -> bool:
+ """This checks if the given channel is in the restricted channel list.
+ Can handle parsing threads
Args:
- factoid_name (str): The name of the factoid to get
- guild (str): The id of the guild for the factoid
-
- Raises:
- FactoidNotFoundError: If the factoid wasn't found
+ self (Self): _description_
+ channel (discord.abc.GuildChannel): The channel trying to see the factoid
Returns:
- bot.models.Factoid: The factoid
+ bool: Whether the restricted factoid can be sent
"""
- factoid = await self.get_raw_factoid_entry(factoid_name, guild)
+ if isinstance(channel, discord.Thread):
+ channel = channel.parent
- # Handling if the call is an alias
- if factoid and factoid.alias not in ["", None]:
- factoid = await self.get_raw_factoid_entry(factoid.alias, guild)
- factoid_name = factoid.name
-
- if not factoid:
- raise custom_errors.FactoidNotFoundError(factoid=factoid_name)
+ restricted_channel_list = configuration.get_config_entry(
+ channel.guild.id, "factoids_restricted_list"
+ )
- return factoid
+ if str(channel.id) in restricted_channel_list:
+ return True
+ return False
- async def get_list_of_aliases(
- self: Self, factoid_to_search: str, guild: str
- ) -> list[str]:
- """Gets an alphabetical list of all ways to call a factoid
- This will include the internal parent AND all aliases
+ def get_embed_from_factoid(
+ self: Self, factoid: bot.models.FactoidData
+ ) -> discord.Embed:
+ """Gets the factoid embed from its database entry
Args:
- factoid_to_search (str): The name of the factoid to search for aliases of
- guild (str): The guild to search for factoids in
+ factoid (bot.models.FactoidData): The factoid to get the json of
Returns:
- list[str]: The list of all ways to call the factoid, including what was passed
+ discord.Embed: The embed of the factoid
"""
- factoid = await self.get_factoid(factoid_to_search, guild)
- alias_list = [factoid.name]
- factoids = await self.get_all_factoids(guild)
- for test_factoid in factoids:
- if test_factoid.alias and test_factoid.alias == factoid.name:
- alias_list.append(test_factoid.name)
- return sorted(alias_list)
+ if not factoid.json_string:
+ return None
+
+ embed_config = json.loads(factoid.json_string)
- # -- Adding and removing factoids --
+ return discord.Embed.from_dict(embed_config)
- async def add_factoid(
+ async def confirm_factoid_deletion(
self: Self,
- channel: discord.abc.Messageable,
- author: discord.Member,
+ interaction: discord.Interaction,
factoid_name: str,
- guild: str,
- message: str,
- embed_config: str,
- alias: str = None,
- ) -> None:
- """Adds a factoid with confirmation, modifies it if it already exists
-
- Args:
- channel (discord.abc.Messageable): The channel the factoid was added from
- author (discord.Member): The member who created this factoid
- factoid_name (str): The name of the factoid
- guild (str): The guild of the factoid
- message (str): The message of the factoid
- embed_config (str): The embed config of the factoid
- alias (str, optional): The parent of the factoid. Defaults to None.
- """
- fmt = "added" # Changes to modified, used for the returned message
- name = factoid_name # Name if the factoid doesn't exist
-
- # Checks if the factoid exists already
- try:
- factoid = await self.get_factoid(factoid_name, guild)
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid.name}` is protected and cannot be modified",
- channel=channel,
- )
- return
- name = factoid.name.lower() # Name of the parent
-
- # Adds the factoid if it doesn't exist already
- except custom_errors.FactoidNotFoundError:
- # If remember was called with an embed but not a message and the factoid does not exist
- if not message:
- await auxiliary.send_deny_embed(
- message="You did not provide the factoid message!",
- channel=channel,
- )
- return
-
- await self.create_factoid_call(
- factoid_name=name,
- guild=guild,
- message=message,
- embed_config=embed_config,
- alias=alias,
- )
-
- # Modifies the factoid if it already exists
- else:
- fmt = "modified"
- # Confirms modification
- if (
- await self.confirm_factoid_deletion(factoid_name, channel, author, fmt)
- is False
- ):
- return
-
- # Modifies the old entry
- factoid = await self.get_raw_factoid_entry(name, str(channel.guild.id))
- factoid.name = name
- # if no message was supplied, keep the original factoid's message.
- if message:
- factoid.message = message
- factoid.embed_config = embed_config
- factoid.alias = alias
- await self.modify_factoid_call(factoid=factoid)
-
- # Removes the factoid from the cache
- await self.handle_cache(guild, name)
- await auxiliary.send_confirm_embed(
- message=f"Successfully {fmt} the factoid `{factoid_name}`",
- channel=channel,
- )
-
- async def delete_factoid(
- self: Self, ctx: commands.Context, called_factoid: CalledFactoid
- ) -> bool:
- """Deletes a factoid with confirmation
+ channel: discord.abc.GuildChannel,
+ author: discord.Member,
+ ) -> ui.ConfirmResponse:
+ """Confirms if a factoid should be deleted/modified
Args:
- ctx (commands.Context): Context to send the confirmation message to
- called_factoid (CalledFactoid): The factoid to remove
+ factoid_name (str): The factoid that is being prompted for deletion
+ channel (discord.abc.GuildChannel): The channel the factoid is being deleted in
+ author (discord.Member): The member deleting the factoid
+ fmt (str): Formatting for the returned message
Returns:
- bool: Whether the factoid was deleted
+ bool: Whether the factoid was deleted/modified
"""
- factoid = await self.get_raw_factoid_entry(
- called_factoid.factoid_db_entry.name, str(ctx.guild.id)
- )
- aliases_list = await self.get_list_of_aliases(
- called_factoid.factoid_db_entry.name, str(ctx.guild.id)
- )
- aliases_list.remove(called_factoid.original_call_str)
- print_aliases_list = ", ".join(aliases_list)
-
- send_message = (
- f"This will remove the factoid `{called_factoid.original_call_str}`"
- )
- if print_aliases_list:
- send_message += f" and all of it's aliases `({print_aliases_list})` forever"
-
- send_message += ". Are you sure?"
-
view = ui.Confirm()
await view.send(
- message=send_message,
- channel=ctx.channel,
- author=ctx.author,
+ message=(
+ f"The factoid `{factoid_name}` already exists. Should I overwrite it?"
+ ),
+ channel=channel,
+ author=author,
+ interaction=interaction,
)
await view.wait()
- if view.value is ui.ConfirmResponse.TIMEOUT:
- return False
-
- if view.value is ui.ConfirmResponse.DENIED:
- await auxiliary.send_deny_embed(
- message=f"Factoid `{called_factoid.original_call_str}` was not deleted",
- channel=ctx.channel,
- )
- return False
-
- await self.delete_factoid_call(factoid, str(ctx.guild.id))
-
- # Don't send the confirmation message if this is an alias either
- confirm_message = (
- f"Successfully deleted the factoid `{called_factoid.original_call_str}`"
- )
- if print_aliases_list:
- confirm_message += f" and all of it's aliases `({print_aliases_list})`"
+ return view.value
- await auxiliary.send_confirm_embed(message=confirm_message, channel=ctx.channel)
- return True
+ # AUTOFILL
- # -- Getting and responding with a factoid --
- async def match(self: Self, ctx: commands.Context, message_contents: str) -> bool:
- """Checks if a message started with the prefix from the config
+ async def factoid_autocomplete(
+ self: Self,
+ interaction: discord.Interaction,
+ current: str,
+ ) -> list[app_commands.Choice[str]]:
+ """Suggests factoids for autofill for commands that need autofilled factoids
Args:
- ctx (commands.Context): The context of which the message was sent
- message_contents (str): The message to check
+ interaction (discord.Interaction): The interaction calling the factoids
+ current (str): The current string value of the factoid argument
Returns:
- bool: Whether the message starts with the prefix or not
+ list[app_commands.Choice[str]]: The list of suggestions
"""
- if not ctx.guild:
- return
- return message_contents.startswith(
- configuration.get_config_entry(ctx.guild.id, "factoids_prefix")
- )
- async def response(
- self: Self,
- ctx: commands.Context,
- message_content: str,
- _: bool,
- ) -> None:
- """Responds to a factoid call
+ guild = interaction.guild
+ if guild is None:
+ return []
- Args:
- ctx (commands.Context): Context of the call
- message_content (str): Content of the call
+ current = current.lower()
- Raises:
- TooLongFactoidMessageError:
- Raised when the raw message content is over discords 2000 char limit
- """
- if not ctx.guild:
- return
- # Checks if the first word of the content after the prefix is a valid factoid
- # Replaces \n with spaces so factoid can be called even with newlines
- prefix = configuration.get_config_entry(ctx.guild.id, "factoids_prefix")
- query = message_content[len(prefix) :].replace("\n", " ").split(" ")[0].lower()
- try:
- factoid = await self.get_factoid(query, str(ctx.guild.id))
-
- except custom_errors.FactoidNotFoundError:
- await self.bot.logger.send_log(
- message=f"Invalid factoid call {query} from {ctx.guild.id}",
- level=LogLevel.DEBUG,
- context=LogContext(guild=ctx.guild, channel=ctx.channel),
+ factoids = (
+ await self.bot.models.FactoidCall.query.where(
+ (self.bot.models.FactoidCall.guild == str(guild.id))
+ & (self.bot.models.FactoidCall.name.ilike(f"{current}%"))
)
- return
+ .order_by(self.bot.models.FactoidCall.name)
+ .limit(25)
+ .gino.all()
+ )
- # Checking for disabled or restricted
- if factoid.disabled:
- return
+ return [
+ app_commands.Choice(
+ name=factoid.name,
+ value=factoid.name,
+ )
+ for factoid in factoids
+ ]
+
+ # COMMANDS
+
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_app_group.command(
+ name="add",
+ description="Creates a new factoid by name",
+ )
+ async def factoid_add_command(
+ self: Self, interaction: discord.Interaction, factoid_name: str
+ ) -> None:
+ factoid_name = factoid_name.lower()
- if factoid.restricted:
- channel = ctx.channel
- restricted_list = configuration.get_config_entry(
- ctx.guild.id, "factoids_restricted_list"
+ # Only ever attempt to add a factoid if it doesn't exist
+ if await self.read_factoid_call(guild=interaction.guild, name=factoid_name):
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` already exists"
)
- if isinstance(channel, discord.Thread):
- if str(channel.parent.id) not in restricted_list:
- return
- else:
- if str(channel.id) not in restricted_list:
- return
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
- if configuration.get_config_entry(ctx.guild.id, "factoids_disable_embeds"):
- embed = None
- else:
- try:
- embed = self.get_embed_from_factoid(factoid)
- except TypeError as exception:
- log_channel = configuration.get_config_entry(
- ctx.guild.id, "core_logging_channel"
+ form = NewFactoid(factoid_name)
+ await interaction.response.send_modal(form)
+ await form.wait()
+
+ embed_json_string = ""
+
+ if form.embed.component.values:
+ embed_file: discord.Attachment = form.embed.component.values[0]
+
+ if not embed_file.filename.endswith(".json"):
+ embed = auxiliary.prepare_deny_embed(
+ message="I don't recognize your upload as a JSON file.",
)
- await self.bot.logger.send_log(
- message=f"Unable to make embed for factoid `{factoid.name}`, sending fallback.",
- level=LogLevel.ERROR,
- channel=log_channel,
- context=LogContext(guild=ctx.guild, channel=ctx.channel),
- exception=exception,
+ await interaction.followup.send(embed=embed)
+ return
+
+ try:
+ json_bytes = await embed_file.read()
+ attachment_json = json.loads(json_bytes.decode("UTF-8"))
+ embed_json_string = json.dumps(attachment_json)
+
+ except Exception:
+ embed = auxiliary.prepare_deny_embed(
+ message="I couldn't parse the uploaded JSON file.",
)
- embed = None
-
- # if the json doesn't include non embed argument, then don't send anything
- # otherwise send message text with embed
- try:
- plaintext_content = factoid.message if not embed else None
- except ValueError:
- # The not embed causes a ValueError in certain cases. This ensures fallback works
- plaintext_content = factoid.message
- mentions = auxiliary.construct_mention_string(ctx.message.mentions)
-
- content = " ".join(filter(None, [mentions, plaintext_content])) or None
- if content and len(content) > 2000:
- await auxiliary.send_deny_embed(
- message="I ran into an error sending that factoid: "
- + "The factoid message is longer than the discord size limit (2000)",
- channel=ctx.channel,
- )
- raise custom_errors.TooLongFactoidMessageError
+ await interaction.followup.send(embed=embed)
+ return
- try:
- # define the message and send it
- sent_message = await ctx.reply(
- content=content, embed=embed, mention_author=not mentions
- )
- # log it in the logging channel with type info and generic content
- log_channel = configuration.get_config_entry(
- ctx.guild.id, "core_logging_channel"
- )
- await self.bot.logger.send_log(
- message=(
- f"Sending factoid: {query} (triggered by {ctx.author} in"
- f" #{ctx.channel.name})"
- ),
- level=LogLevel.INFO,
- context=LogContext(guild=ctx.guild, channel=ctx.channel),
- channel=log_channel,
- )
- # If something breaks, also log it
- except discord.errors.HTTPException as exception:
- log_channel = configuration.get_config_entry(
- ctx.guild.id, "core_logging_channel"
- )
- await self.bot.logger.send_log(
- message="Could not send factoid",
- level=LogLevel.ERROR,
- context=LogContext(guild=ctx.guild, channel=ctx.channel),
- channel=log_channel,
- exception=exception,
- )
- # Sends the raw factoid instead of the embed as fallback
- sent_message = await ctx.reply(
- f"{mentions + ' ' if mentions else ''}{factoid.message}",
- mention_author=not mentions,
- )
+ selected = set(form.properties.component.values)
- await self.send_to_irc(ctx.channel, ctx.message, factoid.message)
- await self.send_to_logger(
- sent_message, ctx.author, ctx.channel, factoid.message
+ property_binary = (
+ ("disabled" in selected) << 3
+ | ("hidden" in selected) << 2
+ | ("protected" in selected) << 1
+ | ("restricted" in selected)
)
- async def send_to_irc(
- self: Self,
- channel: discord.abc.Messageable,
- message: discord.Message,
- factoid_message: str,
- ) -> None:
- """Send a factoid to IRC channel, if it was called in a linked channel
+ factoid = await self.create_factoid_data(
+ guild=interaction.guild,
+ message=form.plaintext.component.value,
+ json_string=embed_json_string,
+ flags=property_binary,
+ )
- Args:
- channel (discord.abc.Messageable): The channel the factoid was sent in
- message (discord.Message): The message object of the invocation
- factoid_message (str): The text of the factoid to send
- """
- # Don't attempt to send a message if irc if irc is disabled
- irc_config = self.bot.file_config.api.irc
- if not irc_config.enable_irc:
- return
+ await self.create_factoid_call(
+ guild=interaction.guild,
+ name=factoid_name,
+ factoid_data_id=factoid.factoid_data_id,
+ )
- await self.bot.irc.irc_cog.handle_factoid(
- channel=channel,
- discord_message=message,
- factoid_message=factoid_message,
+ embed = auxiliary.prepare_confirm_embed(
+ message=f"Your factoid `{factoid_name}` was successfully created!",
)
+ await interaction.followup.send(embed=embed)
+
+ # Send the factoid, and embed json if exists, to the user
+ await interaction.followup.send(content=factoid.message, ephemeral=True)
+ if embed_json_string:
+ try:
+ embed = self.get_embed_from_factoid(factoid=factoid)
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ except Exception as exc:
+ await interaction.followup.send(
+ f"The embed you upload failed: {exc}", ephemeral=True
+ )
- async def send_to_logger(
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_app_group.command(
+ name="alias",
+ description="Creates an alias for an existing factoid call",
+ )
+ @app_commands.autocomplete(existing_factoid=factoid_autocomplete)
+ async def factoid_alias_command(
self: Self,
- factoid_message_object: discord.Message,
- factoid_caller: discord.Member,
- channel: discord.abc.GuildChannel | discord.Thread,
- factoid_message: str,
+ interaction: discord.Interaction,
+ existing_factoid: str,
+ new_factoid: str,
) -> None:
- """Send a factoid call to the logger function
+ existing_factoid = existing_factoid.lower()
+ new_factoid = new_factoid.lower()
- Args:
- factoid_message_object (discord.Message): The message that the factoid is sent in
- factoid_caller (discord.Member): The person who called the factoid
- channel (discord.abc.GuildChannel | discord.Thread): The channel the
- factoid was sent in
- factoid_message (str): The plaintext message content of the factoid
- """
- # Don't allow logging if extension is disabled
- if "moderation.logger" not in configuration.get_config_entry(
- factoid_caller.guild.id, "core_enabled_extensions"
- ):
+ factoid = await self.get_factoid_data_by_name(
+ guild=interaction.guild, name=existing_factoid
+ )
+
+ # We can't alias a factoid if it doesn't exist
+ if not factoid:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{existing_factoid}` doesn't exist!"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ # No aliases on protected factoids
+ if factoid.flags & self.FACTOID_FLAG_DISABLED:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{existing_factoid}` is protected and cannot be edited."
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
return
- target_logging_channel = await function_logger.pre_log_checks(self.bot, channel)
- if not target_logging_channel:
+ new_factoid_db = await self.get_factoid_data_by_name(
+ guild=interaction.guild, name=new_factoid
+ )
+
+ # If the existing and new calls already point to the same factoid, there is nothing to do
+ if new_factoid_db and factoid.factoid_data_id == new_factoid_db.factoid_data_id:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{new_factoid}` is already an alias of `{existing_factoid}`."
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
return
- await function_logger.send_message(
- self.bot,
- factoid_message_object,
- factoid_caller,
- channel,
- target_logging_channel,
- content_override=factoid_message,
- special_flags=["Factoid call"],
+ # If the new_factoid already exists but point elsewhere, we need to ask the user for confirmation
+ if new_factoid_db:
+ await interaction.response.defer()
+ confirmation_response = await self.confirm_factoid_deletion(
+ interaction=interaction,
+ factoid_name=new_factoid,
+ channel=interaction.channel,
+ author=interaction.user,
+ )
+ if confirmation_response == ui.ConfirmResponse.TIMEOUT:
+ return
+ elif confirmation_response == ui.ConfirmResponse.DENIED:
+ embed = await auxiliary.prepare_deny_embed(
+ message=f"The factoid `{new_factoid}` was not replaced.",
+ )
+ interaction.followup.send(embed=embed)
+ return
+ else:
+ await self.move_factoid_call(
+ guild=interaction.guild,
+ existing_name=new_factoid,
+ new_factoid_data_id=factoid.factoid_data_id,
+ )
+ else:
+ await self.create_factoid_call(
+ guild=interaction.guild,
+ name=new_factoid,
+ factoid_data_id=factoid.factoid_data_id,
+ )
+
+ embed = auxiliary.prepare_deny_embed(
+ message=f"Successfully added the alias `{new_factoid}` for `{existing_factoid}`",
)
+ # Depending on the path took to get here, we may need to followup
+ if interaction.response.is_done():
+ await interaction.followup.send(embed=embed)
+ else:
+ await interaction.response.send_message(embed=embed)
@factoid_app_group.command(
name="call",
description="Calls a factoid from the database and sends it publicy in the channel.",
)
+ @app_commands.autocomplete(factoid_name=factoid_autocomplete)
async def factoid_call_command(
self: Self,
interaction: discord.Interaction,
@@ -992,6 +661,7 @@ async def factoid_call_command(
member_to_ping: discord.Member = None,
) -> None:
"""This is an app command version of typing {prefix}call
+ This is the preferred method of getting factoids
Args:
interaction (discord.Interaction): The interaction that triggered this command
@@ -1001,1965 +671,129 @@ async def factoid_call_command(
Raises:
TooLongFactoidMessageError: If the plaintext exceed 2000 characters
"""
- query = factoid_name.replace("\n", " ").lower()
- try:
- factoid = await self.get_factoid(query, str(interaction.guild.id))
-
- except custom_errors.FactoidNotFoundError:
+ # TODO: Generic this to support prefix commands?
+ factoid_name = factoid_name.lower()
+ factoid = await self.get_factoid_data_by_name(
+ guild=interaction.guild, name=factoid_name
+ )
+ if not factoid:
embed = auxiliary.prepare_deny_embed(
- message=f"The factoid {factoid_name} couldn't be found"
+ message=f"The factoid `{factoid_name}` couldn't be found"
)
await interaction.response.send_message(embed=embed, ephemeral=True)
- await self.bot.logger.send_log(
- message=f"Invalid factoid call {query} from {interaction.guild.id}",
- level=LogLevel.DEBUG,
- context=LogContext(
- guild=interaction.guild, channel=interaction.channel
- ),
- )
return
- # Checking for disabled or restricted
- if factoid.disabled:
+ # Check if factoid is disabled. If so, don't send it
+ if factoid.flags & self.FACTOID_FLAG_DISABLED:
embed = auxiliary.prepare_deny_embed(
- message=f"The factoid {factoid_name} is disabled."
+ message=f"The factoid `{factoid_name}` is disabled."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
- if factoid.restricted and str(
- interaction.channel.id
- ) not in configuration.get_config_entry(
- interaction.guild.id, "factoids_restricted_list"
+ # Check if factoid is restricted. If so, check if we can call it
+ if (
+ factoid.flags & self.FACTOID_FLAG_RESTRICTED
+ and not self.can_channel_send_restricted(interaction.channel)
):
embed = auxiliary.prepare_deny_embed(
- message=f"The factoid {factoid_name} is restricted and not allowed in this channel."
+ message=f"The factoid `{factoid_name}` is restricted and not allowed in this channel."
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
- if configuration.get_config_entry(
+
+ plaintext_content = factoid.message
+ embed = None
+
+ if not configuration.get_config_entry(
interaction.guild.id, "factoids_disable_embeds"
):
- embed = None
- else:
try:
embed = self.get_embed_from_factoid(factoid)
except TypeError as exception:
- log_channel = configuration.get_config_entry(
- interaction.guild.id, "core_logging_channel"
- )
await self.bot.logger.send_log(
- message=f"Unable to make embed for factoid `{factoid.name}`, sending fallback.",
+ message=(
+ f"Unable to make embed for factoid `{factoid_name}`, "
+ "sending fallback."
+ ),
level=LogLevel.ERROR,
- channel=log_channel,
+ channel=configuration.get_config_entry(
+ interaction.guild.id,
+ "core_logging_channel",
+ ),
context=LogContext(
- guild=interaction.guild, channel=interaction.channel
+ guild=interaction.guild,
+ channel=interaction.channel,
),
exception=exception,
)
- embed = None
- # if the json doesn't include non embed argument, then don't send anything
- # otherwise send message text with embed
- try:
- content = factoid.message if not embed else None
- except ValueError:
- # The not embed causes a ValueError in certain cases. This ensures fallback works
- content = factoid.message
+ content = ""
if member_to_ping:
- if not content:
- content = ""
- content = f"{member_to_ping.mention} {content}".strip()
-
- if content and len(content) > 2000:
- embed = auxiliary.prepare_deny_embed(
- message="I ran into an error sending that factoid: "
- + "The factoid message is longer than the discord size limit (2000)",
- )
- await interaction.response.send_message(embed=embed, ephemeral=True)
-
- raise custom_errors.TooLongFactoidMessageError
-
- try:
- # define the message and send it
- view = DeleteView(interaction.user.id)
-
- await interaction.response.send_message(
- content=content,
- embed=embed,
- view=view,
- )
-
- view.message = await interaction.original_response()
- # log it in the logging channel with type info and generic content
- log_channel = configuration.get_config_entry(
- interaction.guild.id, "core_logging_channel"
- )
- await self.bot.logger.send_log(
- message=(
- f"Sending factoid: {query} (triggered by {interaction.user} in"
- f" #{interaction.channel.name})"
- ),
- level=LogLevel.INFO,
- context=LogContext(
- guild=interaction.guild, channel=interaction.channel
- ),
- channel=log_channel,
- )
- # If something breaks, also log it
- except discord.errors.HTTPException as exception:
- log_channel = configuration.get_config_entry(
- interaction.guild.id, "core_logging_channel"
- )
- await self.bot.logger.send_log(
- message="Could not send factoid",
- level=LogLevel.ERROR,
- context=LogContext(
- guild=interaction.guild, channel=interaction.channel
- ),
- channel=log_channel,
- exception=exception,
- )
- # Sends the raw factoid instead of the embed as fallback
- await interaction.response.send_message(content=factoid.message)
- await self.send_to_irc(
- interaction.channel, interaction.message, factoid.message
- )
-
- sent_message = await interaction.original_response()
- await self.send_to_logger(
- sent_message, interaction.user, interaction.channel, factoid.message
- )
-
- @app_commands.check(has_manage_factoids_role_interaction)
- @factoid_app_group.command(
- name="add",
- description="Creates a new factoid.",
- )
- async def factoid_add_command(
- self: Self, interaction: discord.Interaction, factoid_name: str
- ) -> None:
- """A /factoid add command, to add a factoid using a Modal
-
- Args:
- interaction (discord.Interaction): The interaction that called this command
- factoid_name (str): The name of the factoid to add
- """
- query = factoid_name.replace("\n", " ").split(" ")[0].lower()
- try:
- await self.get_factoid(query, str(interaction.guild.id))
- embed = auxiliary.prepare_deny_embed(
- message=f"The factoid `{factoid_name}` already exists"
- )
- await interaction.response.send_message(embed=embed, ephemeral=True)
- return
-
- except custom_errors.FactoidNotFoundError:
- ...
-
- form = NewFactoid(factoid_name)
- await interaction.response.send_modal(form)
- await form.wait()
-
- embed_json_string = ""
-
- if form.embed.component.values:
- embed_file: discord.Attachment = form.embed.component.values[0]
- if not embed_file.filename.endswith(".json"):
- embed = auxiliary.prepare_deny_embed(
- message="I don't recognize your upload as a json file",
- )
- await interaction.followup.send(embed=embed)
- return
+ content = member_to_ping.mention
+ embed_sent = False
+ if embed:
try:
- json_bytes = await embed_file.read()
- attachment_json = json.loads(json_bytes.decode("UTF-8"))
- embed_json_string = json.dumps(attachment_json)
- except Exception:
- embed = auxiliary.prepare_deny_embed(
- message="I couldn't parse the uploaded JSON file.",
+ # This view allows the caller to delete the factoid
+ view = DeleteView(interaction.user.id)
+
+ # Attempt to send the message with the embed in it
+ await interaction.response.send_message(
+ content=content,
+ embed=embed,
+ view=view,
)
- await interaction.followup.send(embed=embed)
- return
- selected = set(form.properties.component.values)
- properties = [
- "disabled" in selected,
- "hidden" in selected,
- "protected" in selected,
- "restricted" in selected,
- ]
-
- await self.create_factoid_call(
- factoid_name=factoid_name,
- guild=str(interaction.guild.id),
- message=form.plaintext.component.value,
- embed_config=embed_json_string if embed_json_string else "",
- properties=properties,
- )
- embed = auxiliary.prepare_confirm_embed(
- message=f"Your factoid `{factoid_name}` was successfully created!",
- )
- await interaction.followup.send(embed=embed)
-
- # -- Factoid job related functions --
- async def kickoff_jobs(self: Self) -> None:
- """Gets a list of cron jobs and starts them"""
- jobs = await self.bot.models.FactoidJob.query.gino.all()
- for job in jobs:
- job_id = job.job_id
- self.running_jobs[job_id] = {}
-
- # This allows the task to be manually cancelled, preventing one more execution
- task = asyncio.create_task(self.cronjob(job))
- task = self.running_jobs[job_id]["task"] = task
-
- async def cronjob(
- self: Self, job: bot.models.FactoidJob, ctx: commands.Context = None
- ) -> None:
- """Run a cron job for a factoid
-
- Args:
- job (bot.models.FactoidJob): The job to start
- ctx (commands.Context): The context, used for logging
- """
- job_id = job.job_id
- self.running_jobs[job_id]["job"] = job
-
- while True:
- job = self.running_jobs.get(job_id)["job"]
- if not job:
- from_db = await self.bot.models.FactoidJob.query.where(
- self.bot.models.FactoidJob.job_id == job_id
- ).gino.first()
- if not from_db:
- # This factoid job has been deleted from the DB
- log_channel = None
- log_context = None
- channel = None
-
- if ctx:
- channel = configuration.get_config_entry(
- ctx.guild.id, "core_logging_channel"
- )
- log_context = LogContext(guild=ctx.guild, channel=ctx.channel)
-
- await self.bot.logger.send_log(
- message=(
- f"Cron job {job} has failed - factoid has been deleted from"
- " the DB"
- ),
- level=LogLevel.WARNING,
- channel=channel,
- context=log_context,
- )
-
- return
- job = from_db
- self.running_jobs[job_id]["job"] = job
-
- try:
- await aiocron.crontab(job.cron).next()
-
- except ValueError as exception:
- log_channel = None
- log_context = None
-
- if ctx:
- channel = configuration.get_config_entry(
- ctx.guild.id, "core_logging_channel"
- )
- log_context = LogContext(guild=ctx.guild, channel=ctx.channel)
-
- await self.bot.logger.send_log(
- message="Could not await cron completion",
- level=LogLevel.ERROR,
- channel=log_channel,
- context=log_context,
- exception=exception,
+ view.message = await interaction.original_response()
+ # log it in the logging channel with type info and generic content
+ log_channel = configuration.get_config_entry(
+ interaction.guild.id, "core_logging_channel"
)
-
- await asyncio.sleep(300)
-
- factoid = await self.bot.models.Factoid.query.where(
- self.bot.models.Factoid.factoid_id == job.factoid
- ).gino.first()
- if not factoid:
- log_channel = None
- log_context = None
-
- if ctx:
- channel = configuration.get_config_entry(
- ctx.guild.id, "core_logging_channel"
- )
- log_context = LogContext(guild=ctx.guild, channel=ctx.channel)
-
await self.bot.logger.send_log(
message=(
- "Could not find factoid referenced by job - will retry after"
- " waiting"
+ f"Sending factoid: `{factoid_name}` (triggered by {interaction.user} in"
+ f" #{interaction.channel.name})"
),
- level=LogLevel.WARNING,
- channel=log_channel,
- context=log_context,
- )
- continue
-
- channel = self.bot.get_channel(int(job.channel))
- if not channel:
- log_channel = None
- log_context = None
-
- if ctx:
- channel = configuration.get_config_entry(
- ctx.guild.id, "core_logging_channel"
- )
- log_context = LogContext(guild=ctx.guild, channel=ctx.channel)
-
- await self.bot.logger.send_log(
- message=(
- "Could not find channel to send factoid cronjob - will retry"
- " after waiting"
+ level=LogLevel.INFO,
+ context=LogContext(
+ guild=interaction.guild, channel=interaction.channel
),
- level=LogLevel.WARNING,
channel=log_channel,
- context=log_context,
)
- continue
- # Checking for disabled or restricted
- if factoid.disabled:
- return
-
- if factoid.restricted and str(
- channel.id
- ) not in configuration.get_config_entry(
- ctx.guild.id, "factoids_restricted_list"
- ):
- return
-
- # Get_embed accepts job as a factoid object
- if configuration.get_config_entry(ctx.guild.id, "factoids_disable_embeds"):
- embed = None
- else:
- try:
- embed = self.get_embed_from_factoid(factoid)
- except TypeError as exception:
- log_channel = configuration.get_config_entry(
- ctx.guild.id, "core_logging_channel"
- )
- await self.bot.logger.send_log(
- message=(
- f"Unable to make embed for factoid `{factoid.name}`, sending fallback."
- ),
- level=LogLevel.ERROR,
- channel=log_channel,
- context=LogContext(guild=channel.guild, channel=channel),
- exception=exception,
- )
- embed = None
-
- try:
- content = factoid.message if not embed else None
- except ValueError:
- # The not embed causes a ValueError in certian places. This ensures fallback works
- content = factoid.message
-
- try:
- message = await channel.send(content=content, embed=embed)
-
+ embed_sent = True
+ # If something breaks, also log it
except discord.errors.HTTPException as exception:
log_channel = configuration.get_config_entry(
- ctx.guild.id, "core_logging_channel"
+ interaction.guild.id, "core_logging_channel"
)
await self.bot.logger.send_log(
- message="Could not send looped factoid",
+ message="Could not send factoid",
level=LogLevel.ERROR,
- context=LogContext(guild=ctx.guild, channel=ctx.channel),
+ context=LogContext(
+ guild=interaction.guild, channel=interaction.channel
+ ),
channel=log_channel,
exception=exception,
)
- # Sends the raw factoid instead of the embed as fallback
- message = await channel.send(content=factoid.message)
-
- await self.send_to_irc(channel, message, factoid.message)
- await self.send_to_logger(message, ctx.author, ctx.channel, factoid.message)
- @commands.group(
- brief="Executes a factoid command",
- description="Executes a factoid command",
- )
- async def factoid(self: Self, ctx: commands.Context) -> None:
- """The bare .factoid command. This does nothing but generate the help message
+ # Either no embed exists, or the embed failed to send for some reason.
+ # We will send the plaintext content of the factoid in this case
+ if not embed_sent:
+ content += f" {plaintext_content}"
+ content = content.strip()
+ if len(content) > 2000:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` is too long and cannot be sent on discord."
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+ view = DeleteView(interaction.user.id)
+ await interaction.response.send_message(content=content, view=view)
+ view.message = await interaction.original_response()
- Args:
- ctx (commands.Context): The context in which the command was run in
- """
- return
-
- @auxiliary.with_typing
- @commands.check(has_manage_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Creates a factoid",
- aliases=["add"],
- description="Creates a factoid",
- usage="[factoid-name] [factoid-output] |optional-embed-json-upload|",
- )
- async def remember(
- self: Self, ctx: commands.Context, factoid_name: str, *, message: str = ""
- ) -> None:
- """Command to add a factoid
-
- Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): Name of the factoid to add
- message (str): The message of the factoid
- """
- # Checks if contents and name are valid
- error_message = await self.check_valid_factoid_contents(
- ctx, factoid_name, message
- )
- if error_message is not None:
- await auxiliary.send_deny_embed(message=error_message, channel=ctx.channel)
- return
-
- embed_config = await auxiliary.get_json_from_attachments(
- ctx.message, as_string=True
- )
-
- if not embed_config and not message:
- await auxiliary.send_deny_embed(
- message="You did not provide the factoid message!", channel=ctx.channel
- )
- return
-
- if embed_config and message == "":
- message = None
-
- await self.add_factoid(
- ctx.channel,
- ctx.author,
- factoid_name=factoid_name,
- guild=str(ctx.guild.id),
- message=message,
- embed_config=embed_config if embed_config else "",
- alias=None,
- )
-
- @commands.check(has_manage_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Deletes a factoid",
- aliases=["delete", "remove"],
- description="Deletes a factoid permanently, including its aliases",
- usage="[factoid-name]",
- )
- async def forget(self: Self, ctx: commands.Context, factoid_name: str) -> None:
- """Command to remove a factoid
-
- Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): Name of the factoid to remove
- """
-
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid.name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
- return
-
- factoid_called = CalledFactoid(
- original_call_str=factoid_name, factoid_db_entry=factoid
- )
-
- if not await self.delete_factoid(ctx, factoid_called):
- return
-
- # Removes associated aliases as well
- aliases = (
- await self.bot.models.Factoid.query.where(
- self.bot.models.Factoid.alias == factoid.name
- )
- .where(self.bot.models.Factoid.guild == str(ctx.guild.id))
- .gino.all()
- )
- for alias in aliases:
- await self.delete_factoid_call(alias, str(ctx.guild.id))
-
- @auxiliary.with_typing
- @commands.check(has_manage_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Loops a factoid",
- description="Loops a pre-existing factoid",
- usage="[factoid-name] [channel] [cron-config]",
- )
- async def loop(
- self: Self,
- ctx: commands.Context,
- factoid_name: str,
- channel: discord.TextChannel,
- *,
- cron_config: str,
- ) -> None:
- """Command to loop a factoid in a channel
-
- Args:
- ctx (commands.Context): Context of the invocation
- factoid_name (str): The name of the factoid to loop
- channel (discord.TextChannel): The channel to loop the factoid in
- cron_config (str): The cron config of the loop
- """
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
- return
-
- if factoid.disabled:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is disabled and new loops cannot be made",
- channel=ctx.channel,
- )
- return
-
- if factoid.restricted and str(channel.id) not in configuration.get_config_entry(
- ctx.guild.id, "factoids_restricted_list"
- ):
- await auxiliary.send_deny_embed(
- message=(
- f"`{factoid_name}` is restricted "
- f"and cannot be used in {channel.mention}"
- ),
- channel=ctx.channel,
- )
- return
-
- # Check if loop already exists
- job = (
- await self.bot.models.FactoidJob.join(self.bot.models.Factoid)
- .select()
- .where(self.bot.models.FactoidJob.channel == str(channel.id))
- .where(self.bot.models.Factoid.name == factoid.name)
- .gino.first()
- )
- if job:
- await auxiliary.send_deny_embed(
- message="That factoid is already looping in this channel",
- channel=ctx.channel,
- )
- return
-
- # Only matches valid cron syntaxes (including some ugly ones,
- # except @ stuff since that isn't supported by cronitor anyways)
- if not re.match(
- self.CRON_REGEX,
- cron_config,
- ):
- await auxiliary.send_deny_embed(
- message=f"`{cron_config}` is not a valid cron configuration!",
- channel=ctx.channel,
- )
- return
-
- job = self.bot.models.FactoidJob(
- factoid=factoid.factoid_id, channel=str(channel.id), cron=cron_config
- )
- await job.create()
-
- job_id = job.job_id
- self.running_jobs[job_id] = {}
-
- # This allows the task to be manually cancelled, preventing one more execution
- task = asyncio.create_task(self.cronjob(job, ctx))
- self.running_jobs[job_id]["task"] = task
-
- await auxiliary.send_confirm_embed(
- message="Factoid loop created", channel=ctx.channel
- )
-
- @auxiliary.with_typing
- @commands.check(has_manage_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Removes a factoid's loop config",
- description="De-loops a pre-existing factoid",
- usage="[factoid-name] [channel]",
- )
- async def deloop(
- self: Self,
- ctx: commands.Context,
- factoid_name: str,
- channel: discord.TextChannel,
- ) -> None:
- """Command to remove a factoid loop
-
- Args:
- ctx (commands.Context): Context of the invocation
- factoid_name (str): The name of the factoid to deloop
- channel (discord.TextChannel): The channel to deloop the factoid from
- """
-
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already protected",
- channel=ctx.channel,
- )
- return
-
- job = (
- await self.bot.models.FactoidJob.query.where(
- self.bot.models.FactoidJob.channel == str(channel.id)
- )
- .where(self.bot.models.Factoid.name == factoid.name)
- .gino.first()
- )
- if not job:
- await auxiliary.send_deny_embed(
- message="That job does not exist", channel=ctx.channel
- )
- return
-
- job_id = job.job_id
- # Stops the job
- self.running_jobs[job_id]["task"].cancel()
- # Deletes it
- await job.delete()
-
- await auxiliary.send_confirm_embed(
- message="Loop job deleted",
- channel=ctx.channel,
- )
-
- @auxiliary.with_typing
- @commands.check(has_manage_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Displays loop config",
- description="Retrieves and displays the loop config for a specific factoid",
- usage="[factoid-name] [channel]",
- )
- async def job(
- self: Self,
- ctx: commands.Context,
- factoid_name: str,
- channel: discord.TextChannel,
- ) -> None:
- """Command to list info about a loop
-
- Args:
- ctx (commands.Context): Context of the invocation
- factoid_name (str): The name of the factoid
- channel (discord.TextChannel): The channel the factoid is looping in
- """
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- # List jobs > Select jobs that have a matching text and channel
- job = (
- await self.bot.models.FactoidJob.join(self.bot.models.Factoid)
- .select()
- .where(self.bot.models.FactoidJob.channel == str(channel.id))
- .where(self.bot.models.Factoid.name == factoid.name)
- .gino.first()
- )
- if not job:
- await auxiliary.send_deny_embed(
- message="That job does not exist", channel=ctx.channel
- )
- return
-
- embed_label = ""
- if job.embed_config:
- embed_label = "(embed)"
-
- embed = auxiliary.generate_basic_embed(
- color=discord.Color.blurple(),
- title=f"Loop config for `{factoid_name}` {embed_label}",
- description=f'"{job.message}"',
- )
-
- embed.add_field(name="Channel", value=f"#{channel.name}")
- embed.add_field(name="Cron config", value=f"`{job.cron}`")
-
- await ctx.send(embed=embed)
-
- @auxiliary.with_typing
- @commands.guild_only()
- @factoid.command(
- brief="Lists loop jobs",
- description="Lists all the currently registered loop jobs",
- )
- async def jobs(self: Self, ctx: commands.Context) -> None:
- """Command ot list all factoid loop jobs
-
- Args:
- ctx (commands.Context): Context of the invocation
- """
- # Gets jobs for invokers guild
- jobs = (
- await self.bot.models.FactoidJob.join(self.bot.models.Factoid)
- .select()
- .where(self.bot.models.Factoid.guild == str(ctx.guild.id))
- .gino.all()
- )
- if not jobs:
- await auxiliary.send_deny_embed(
- message="There are no registered factoid loop jobs for this guild",
- channel=ctx.channel,
- )
- return
-
- embed = discord.Embed(
- color=discord.Color.blurple(),
- title=f"Factoid loop jobs for {ctx.guild.name}",
- )
- for job in jobs[:10]:
- channel = self.bot.get_channel(int(job.channel))
- if not channel:
- continue
- embed.add_field(
- name=f"{job.name.lower()} - #{channel.name}",
- value=f"`{job.cron}`",
- inline=False,
- )
-
- await ctx.send(embed=embed)
-
- @auxiliary.with_typing
- @commands.check(has_manage_factoids_role)
- @commands.guild_only()
- @factoid.command(
- name="json",
- brief="Gets embed JSON",
- description="Gets embed JSON for a factoid",
- usage="[factoid-name]",
- )
- async def _json(self: Self, ctx: commands.Context, factoid_name: str) -> None:
- """Gets the json of a factoid
-
- Args:
- ctx (commands.Context): Context of the invocation
- factoid_name (str): The name of the factoid
- """
-
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- if not factoid.embed_config:
- await auxiliary.send_deny_embed(
- message=f"There is no embed config for `{factoid_name}`",
- channel=ctx.channel,
- )
- return
-
- # Formats the json to have indents, then sends it to the channel it was called from
- formatted = json.dumps(json.loads(factoid.embed_config), indent=4)
- json_file = discord.File(
- io.StringIO(formatted),
- filename=(
- f"{factoid_name.lower()}-factoid-embed-config-{datetime.datetime.utcnow()}.json"
- ),
- )
-
- await ctx.send(file=json_file)
-
- @auxiliary.with_typing
- @commands.guild_only()
- @factoid.command(
- brief="Gets information about a factoid",
- aliases=["aliases"],
- description=(
- "Returns information about a factoid (or the parent if it's an alias)"
- ),
- usage="[factoid-name]",
- )
- async def info(
- self: Self,
- ctx: commands.Context,
- query: str,
- ) -> None:
- """Command to list info about a factoid
-
- Args:
- ctx (commands.Context): Context of the invocation
- query (str): The factoid name to query
- """
-
- # Gets the factoid if it exists
- factoid = await self.get_factoid(query, str(ctx.guild.id))
-
- embed = discord.Embed(title=f"Info about `{query}`")
-
- # Parses list of aliases into a neat string
- aliases = (
- await self.bot.models.Factoid.query.where(
- self.bot.models.Factoid.alias == factoid.name
- )
- .where(self.bot.models.Factoid.guild == str(ctx.guild.id))
- .gino.all()
- )
-
- # Add and sort all aliases to a comma separated string
- aliases.append(factoid)
- alias_list = (
- "None"
- if not aliases
- else ", ".join(sorted([f"`{alias.name.lower()}`" for alias in aliases]))
- )
-
- # Gets the factoids loop jobs
- jobs = await self.bot.models.FactoidJob.query.where(
- self.bot.models.FactoidJob.factoid == factoid.factoid_id
- ).gino.all()
-
- # Adds all fields to the embed
- embed.add_field(name="Aliases", value=alias_list)
- embed.add_field(name="Embed", value=bool(factoid.embed_config))
- embed.add_field(name="Contents", value=factoid.message[:1020])
- embed.add_field(name="Date of creation", value=factoid.time)
-
- # Get all the special properties of a factoid, if any are set
- factoid_properties = ["hidden", "restricted", "disabled", "protected"]
- factoid_string = ", ".join(
- property
- for property in factoid_properties
- if getattr(factoid, property, False)
- )
- result = factoid_string if factoid_string else "None"
- embed.add_field(name="Properties", value=result)
-
- if jobs:
- for job in jobs[:10]:
- channel = self.bot.get_channel(int(job.channel))
- if not channel:
- continue
- embed.add_field(
- name=f"**Loop:** #{channel.name}",
- value=f"`{job.cron}`\n",
- inline=False,
- )
-
- # Finally, sends the factoid
- await ctx.send(embed=embed)
-
- @factoid_app_group.command(
- name="all",
- description="Sends a configurable list of all factoids.",
- )
- async def app_command_all(
- self: Self,
- interaction: discord.Interaction,
- force_file: bool = False,
- property: Properties = "",
- true_all: bool = False,
- show_hidden: bool = False,
- ) -> None:
- """This is the more feature full version of factoid all
- This is an application command
-
- Args:
- interaction (discord.Interaction): The interaction that started this command
- force_file (bool, optional): Whether this should be forced as a yml file.
- Defaults to False.
- property (Properties, optional): What property to look for. Defaults to "".
- true_all (bool, optional): Whether this should force every factoid. Defaults to False.
- show_hidden (bool, optional): If set to true will show hidden factoids.
- Defaults to False.
- """
- guild = str(interaction.guild.id)
- # Check for admin roles if ignoring hidden
- if true_all or show_hidden:
- await has_given_factoids_role(
- interaction.guild,
- interaction.user,
- configuration.get_config_entry(
- interaction.guild.id, "factoids_admin_roles"
- ),
- )
-
- if true_all:
- factoids = await self.build_list_of_factoids(guild, include_hidden=True)
- else:
- factoids = await self.build_list_of_factoids(
- guild, exclusive_property=property, include_hidden=show_hidden
- )
-
- if not factoids:
- embed = auxiliary.prepare_deny_embed(
- "No factoids could be found matching your filter"
- )
- await interaction.response.send_message(embed=embed)
- return
-
- aliases = self.build_alias_dict_for_given_factoids(factoids)
-
- # If the linx server isn't configured, we must make it a file
- if not self.bot.file_config.api.api_url.linx:
- force_file = True
-
- cachable = bool(
- not force_file and not property and not true_all and not show_hidden
- )
-
- if cachable and guild in self.factoid_all_cache:
- url = self.factoid_all_cache[guild]["url"]
- embed = auxiliary.prepare_confirm_embed(url)
- await interaction.response.send_message(embed=embed)
- return
-
- factoid_all = await self.build_factoid_all(
- interaction.guild, factoids, aliases, force_file, cachable
- )
-
- if not factoid_all:
- embed = auxiliary.prepare_deny_embed(
- "No factoids could be found matching your filter"
- )
- await interaction.response.send_message(embed=embed)
- return
-
- # If we know it's a file, or it's fallen back to a file, send it as a file
- if force_file or isinstance(factoid_all, discord.File):
- await interaction.response.send_message(file=factoid_all)
- return
-
- embed = auxiliary.prepare_confirm_embed(factoid_all)
- await interaction.response.send_message(embed=embed)
-
- async def build_list_of_factoids(
- self: Self,
- guild: discord.Guild,
- exclusive_property: Properties = "",
- include_hidden: bool = False,
- ) -> list[munch.Munch]:
- """This builds a list of database objects that match the factoid all requests
-
- Args:
- guild (discord.Guild): The guild to pull factoids from
- exclusive_property (Properties, optional): What property to exclusivly get.
- Defaults to "".
- include_hidden (bool, optional): Whether this query should ignore the hidden property.
- Defaults to False.
-
- Returns:
- list[munch.Munch]: The filtered list of factoids
- """
- factoids = await self.get_all_factoids(guild, list_hidden=True)
- # If there are no factoids for the guild, return None
- if not factoids:
- return None
- # If exclusive property is set, then that property as the only one
- # This obeys include_hidden
- if exclusive_property:
- filtered_factoids = [
- factoid
- for factoid in factoids
- if getattr(factoid, exclusive_property.value)
- and (include_hidden or not factoid.hidden)
- ]
- return filtered_factoids
- # If no specific property is set, see if we have to filter out hidden factoids
- if not include_hidden:
- filtered_factoids = [factoid for factoid in factoids if not factoid.hidden]
- return filtered_factoids
- # Otherwise just return every factoid
- return factoids
-
- def build_alias_dict_for_given_factoids(
- self: Self, factoids: list[munch.Munch]
- ) -> dict[str, list[str]]:
- """This builds a dict of parent to aliases for a given list of factoids
-
- Args:
- factoids (list[munch.Munch]): The factoid list to find aliases for
-
- Returns:
- dict[str, list[str]]: The dict of parent to list of aliases
- """
- aliases = {}
- for factoid in factoids:
- if factoid.alias not in [None, ""]:
- # Append to aliases
- if factoid.alias in aliases:
- aliases[factoid.alias].append(factoid.name)
- continue
-
- aliases[factoid.alias] = [factoid.name]
- return aliases
-
- async def build_factoid_all(
- self: Self,
- guild: discord.Guild,
- factoids: list[munch.Munch],
- aliases: dict[str, list[str]],
- use_file: bool,
- cachable: bool,
- ) -> discord.File | str:
- """This builds the factoid all url or the yaml file
-
- Args:
- guild (discord.Guild): The guild to build factoid all for
- factoids (list[munch.Munch]): The factoids to include in the all
- aliases (dict[str, list[str]]): Aliases for the given factoids
- use_file (bool): Whether to force the use of a file or not
- cachable (bool): Whether this request is cachable
-
- Returns:
- discord.File | str: The final formatted factoid all
- """
-
- if use_file:
- return await self.send_factoids_as_file(guild, factoids, aliases)
-
- try:
- # -Tries calling the api-
- html = await self.generate_html(guild, factoids, aliases)
- # If there are no applicable factoids
- if html is None:
- # Something must go wrong to get here
- return None
-
- headers = {
- "Content-Type": "text/plain",
- }
- response = await self.bot.http_functions.http_call(
- "put",
- self.bot.file_config.api.api_url.linx,
- headers=headers,
- data=io.StringIO(html),
- get_raw_response=True,
- )
- url = response["text"]
- filename = url.split("/")[-1]
- url = url.replace(filename, f"selif/{filename}")
-
- if cachable:
- self.factoid_all_cache[str(guild.id)] = {}
- self.factoid_all_cache[str(guild.id)]["url"] = url
-
- return url
-
- # If an error happened while calling the api
- except (gaierror, InvalidURL) as exception:
- log_channel = configuration.get_config_entry(
- guild.id, "core_logging_channel"
- )
- await self.bot.logger.send_log(
- message="Could not render/send all-factoid HTML",
- level=LogLevel.ERROR,
- context=LogContext(guild=guild),
- channel=log_channel,
- exception=exception,
- )
-
- return await self.send_factoids_as_file(guild, factoids, aliases)
-
- def build_formatted_factoid_data(
- self: Self, factoids: list[munch.Munch], aliases: dict[str, list[str]]
- ) -> dict[str, dict[str, str]]:
- """This builds a nicely formatted, sorted, and processed dict of factoids
- Ready to be put into factoid all
-
- Args:
- factoids (list[munch.Munch]): The list of all parent factoids to be included
- aliases (dict[str, list[str]]): The list of all aliases, if any,
- for the factoids in the main factoids list
-
- Returns:
- dict[str, dict[str, str]]: The formatted list of factoids with all the information
- """
- output_data = []
- for factoid in factoids:
- # Skips aliases
- if factoid.alias not in [None, ""]:
- continue
-
- # Default name to the actual factoid name
- name = factoid.name
-
- # If not aliased
- if factoid.name in aliases:
- all_aliases = [factoid.name] + aliases[factoid.name]
- all_aliases.sort()
- name = all_aliases[0]
- data = {
- "message": factoid.message,
- "embed": bool(factoid.embed_config),
- "aliases": all_aliases[1:],
- }
-
- # If aliased
- else:
- data = {"message": factoid.message, "embed": bool(factoid.embed_config)}
-
- output_data.append({name: data})
-
- # Sort output alphabetically
- output_data = sorted(output_data, key=lambda x: list(x.keys())[0])
- return output_data
-
- async def generate_html(
- self: Self,
- guild: discord.Guild,
- factoids: list[munch.Munch],
- aliases: dict[str, list[str]],
- ) -> str:
- """Method to generate the html file contents
-
- Args:
- guild (discord.Guild): The guild the factoids are being pulled from
- factoids (list[munch.Munch]): List of all factoids
- aliases (dict[str, list[str]]): A dictionary containing factoids and their aliases
-
- Returns:
- str: The result html file
- """
-
- body_contents = ""
-
- output_data = self.build_formatted_factoid_data(factoids, aliases)
-
- if not output_data:
- # Something is wrong with the database if we are ever here
- return None
-
- for factoid in output_data:
- name, data = next(iter(factoid.items()))
- embed_text = " (embed)" if data["embed"] else ""
-
- if "aliases" in data:
- body_contents += (
- f"
{name} [{', '.join(data['aliases'])}]{embed_text}"
- + f" - {data['message']}"
- )
- else:
- body_contents += (
- f"{name}{embed_text}"
- + f" - {data['message']}"
- )
-
- if body_contents == "":
- return None
-
- body_contents = f""
- output = (
- f"""
-
-
-
- Factoids for {guild.name}
- {body_contents}
-
-
-
- """
- )
- return output
-
- async def send_factoids_as_file(
- self: Self,
- guild: discord.Guild,
- factoids: list[munch.Munch],
- aliases: dict[str, list[str]],
- ) -> discord.File:
- """Method to send the factoid list as a file instead of a paste
-
- Args:
- guild (discord.Guild): The guild the factoids are from
- factoids (list[munch.Munch]): List of all factoids
- aliases (dict[str, list[str]]): A dictionary containing factoids and their aliases
-
- Returns:
- discord.File: The file, ready to upload to discord
- """
-
- output_data = self.build_formatted_factoid_data(factoids, aliases)
-
- if not output_data:
- # Something is wrong with the database if we are ever here
- return None
-
- yaml_file = discord.File(
- io.StringIO(yaml.dump(output_data)),
- filename=(
- f"factoids-for-server-{guild.id}-{datetime.datetime.utcnow()}.yaml"
- ),
- )
-
- # Returns the file
- return yaml_file
-
- def search_content_and_bold(
- self: Self, original: str, search_string: str
- ) -> str | None:
- """Finds all starting indices of the search_string in the original string.
-
- Args:
- original (str): The original content to search through.
- search_string (str): The string we are searching for.
-
- Returns:
- str | None: A single string with bolded matches and surrounding context,
- or None if no matches exist.
- """
-
- original = original.replace(search_string, f"**{search_string}**")
-
- show_range = 20
-
- indices = []
- search_len = len(search_string)
- for i in range(len(original) - search_len + 1):
- if original[i : i + search_len] == search_string:
- indices.append(i)
-
- if len(indices) == 0:
- return None
-
- # Generate ranges to include
- ranges_to_include = []
- for start in indices:
- ranges_to_include.append(
- (
- max(0, start - show_range - 2),
- min(len(original), start + search_len + show_range + 2),
- )
- )
-
- # Minimize ranges by merging overlapping or adjacent ranges
- minimized_ranges = []
- for start, end in sorted(ranges_to_include):
- if minimized_ranges and start <= minimized_ranges[-1][1]:
- minimized_ranges[-1] = (
- min(minimized_ranges[-1][0], start),
- max(minimized_ranges[-1][1], end),
- )
- else:
- minimized_ranges.append((start, end))
-
- ranges_to_strs = []
-
- if minimized_ranges[0][0] != 0:
- ranges_to_strs.append("")
-
- for include_range in minimized_ranges:
- ranges_to_strs.append(original[include_range[0] : include_range[1]])
-
- if minimized_ranges[len(minimized_ranges) - 1][1] != len(original):
- ranges_to_strs.append("")
-
- return "...".join(ranges_to_strs)
-
- @auxiliary.with_typing
- @commands.guild_only()
- @factoid.command(
- aliases=["find"],
- brief="Searches a factoid",
- description="Searches a factoid by name and contents",
- usage="[search-query]",
- )
- async def search(self: Self, ctx: commands.Context, *, query: str) -> None:
- """Commands to search a factoid
-
- Args:
- ctx (commands.Context): Context of the invokation
- query (str): The querry to look for
- """
- query = query.lower()
- guild = str(ctx.guild.id)
-
- if len(query) < 3:
- await auxiliary.send_deny_embed(
- message="Please enter at least 3 characters for the search query!",
- channel=ctx.channel,
- )
- return
-
- factoids = await self.get_all_factoids(guild, list_hidden=False)
- matches = {}
- for factoid in factoids:
- if factoid.alias:
- continue
-
- factoid_key = ", ".join(await self.get_list_of_aliases(factoid.name, guild))
-
- # Name string
- name_highlight = self.search_content_and_bold(factoid_key.lower(), query)
- if name_highlight:
- if factoid_key in matches:
- matches[factoid_key].append(f"Name: {name_highlight}")
- else:
- matches[factoid_key] = [f"Name: {name_highlight}"]
-
- # Content
- content_highlight = self.search_content_and_bold(
- factoid.message.lower(), query
- )
- if content_highlight:
- if factoid_key in matches:
- matches[factoid_key].append(f"Content: {content_highlight}")
- else:
- matches[factoid_key] = [f"Content: {content_highlight}"]
-
- # Embed
- if factoid.embed_config is not None:
- embed_highlight = self.search_content_and_bold(
- factoid.embed_config.lower(), query
- )
- if embed_highlight:
- if factoid_key in matches:
- matches[factoid_key].append(
- f"Embed: {embed_highlight.replace('_', '`_`')}"
- )
- else:
- matches[factoid_key] = [
- f"Embed: {embed_highlight.replace('_', '`_`')}"
- ]
-
- if len(matches) == 0:
- embed = auxiliary.prepare_deny_embed(
- f"No factoids could be found matching `{query}`"
- )
- await ctx.send(embed=embed)
- return
- embeds = []
- embed = discord.Embed(color=discord.Color.green())
- for index, match in enumerate(matches):
- if index > 0 and index % 10 == 0:
- embeds.append(embed)
- embed = discord.Embed(color=discord.Color.green())
- embed.add_field(name=match, value="\n".join(matches.get(match)))
-
- embeds.append(embed)
- await ui.PaginateView().send(ctx.channel, ctx.author, embeds)
-
- @auxiliary.with_typing
- @commands.check(has_manage_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Adds a factoid alias",
- description="Adds an alternate way to call a factoid",
- usage="[new-alias-name] [original-factoid-name]",
- )
- async def alias(
- self: Self,
- ctx: commands.Context,
- alias_name: str,
- factoid_name: str,
- ) -> None:
- """Command to add an alternate way of calling a factoid
-
- Args:
- ctx (commands.Context): Context of the invokation
- alias_name (str): The new alias name to create
- factoid_name (str): The original factoid name to add alias to
-
- """
- # Makes factoids caps insensitive
-
- # Gets the parent factoid
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid.name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
- return
-
- # Stops execution if the target is in the alias list already
- if await self.check_alias_recursion(
- ctx.channel, str(ctx.guild.id), factoid_name, alias_name
- ):
- return
-
- # Prevents recursing aliases because fuck that!
- # This should never be run, a bug exists in get_factoid, or a database error exist
- # if this ever runs
- if factoid.alias not in ["", None]:
- await auxiliary.send_deny_embed(
- message="Can't set an alias for an alias!", channel=ctx.channel
- )
- return
-
- try:
- # Firstly check if the new entry already exists
- target_entry = await self.get_raw_factoid_entry(
- alias_name, str(ctx.guild.id)
- )
-
- # No handling needs to be done if it doesn't exist
- except custom_errors.FactoidNotFoundError:
- pass
-
- # Handling if it does already exist
- else:
- # Alias already present and points to the correct factoid
- if target_entry.alias == factoid.name:
- await auxiliary.send_deny_embed(
- f"`{factoid_name}` already has `{alias_name}` set as an alias!",
- channel=ctx.channel,
- )
- return
-
- # Confirms deletion of old entry
- if not await self.confirm_factoid_deletion(
- alias_name, ctx.channel, ctx.author, "replaced"
- ):
- return
-
- # If the target entry is the parent
- if target_entry.alias in ["", None]:
- # The first alias becomes the new parent
- # A more destructive way to do this would be to have the new parent have
- # the old aliases, but that would delete the previous parent and therefore
- # be more dangerous.
-
- # Gets list of all aliases
- aliases = (
- await self.bot.models.Factoid.query.where(
- self.bot.models.Factoid.alias == target_entry.name
- )
- .where(self.bot.models.Factoid.guild == str(ctx.guild.id))
- .gino.all()
- )
-
- # Don't make new parent if there isn't an alias for it
- if len(aliases) != 0:
- # Modifies previous instance of alias to be the parent
- alias_entry = await self.get_raw_factoid_entry(
- aliases[0].name, str(ctx.guild.id)
- )
-
- alias_entry.name = aliases[0].name
- alias_entry.message = target_entry.message
- alias_entry.embed_config = target_entry.embed_config
- alias_entry.alias = None
-
- await self.modify_factoid_call(factoid=alias_entry)
-
- await self.handle_parent_change(ctx, aliases, aliases[0].name)
-
- # Removes the old alias entry
- await self.delete_factoid_call(target_entry, str(ctx.guild.id))
-
- # Finally, add the new alias
- await self.create_factoid_call(
- factoid_name=alias_name,
- guild=str(ctx.guild.id),
- message="",
- embed_config="",
- alias=factoid.name,
- )
- await auxiliary.send_confirm_embed(
- message=f"Successfully added the alias `{alias_name}` for"
- + f" `{factoid_name}`",
- channel=ctx.channel,
- )
-
- @auxiliary.with_typing
- @commands.check(has_manage_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Deletes only an alias",
- description=(
- "Removes an alias from the group. Will never delete the actual factoid"
- ),
- usage="[factoid-name] [optional-new-parent]",
- )
- async def dealias(
- self: Self,
- ctx: commands.Context,
- factoid_name: str,
- replacement_name: str = None,
- ) -> None:
- """Command to remove an alias from the group, but never delete the parent
-
- Args:
- ctx (commands.Context): Context of the invocation
- factoid_name (str): The name of the factoid to remove
- replacement_name (str, optional): Name of new parent. Defaults to None.
- """
-
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid.name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
- return
-
- # -- Handling for aliases --
- # (They just get deleted, no parent handling needs to be done)
-
- if factoid.name.lower() != factoid_name.lower():
- await self.delete_factoid_call(
- await self.get_raw_factoid_entry(factoid_name, str(ctx.guild.id)),
- str(ctx.guild.id),
- )
- await auxiliary.send_confirm_embed(
- message=f"Deleted the alias `{factoid_name}`",
- channel=ctx.channel,
- )
- return
-
- # -- Handling for parents --
-
- # Gets list of aliases
- aliases = (
- await self.bot.models.Factoid.query.where(
- self.bot.models.Factoid.alias == factoid_name
- )
- .where(self.bot.models.Factoid.guild == str(ctx.guild.id))
- .gino.all()
- )
- # Stop execution if there is no other parent to be assigned
- if len(aliases) == 0:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` has no aliases.", channel=ctx.channel
- )
- return
-
- # Converts the raw alias list to a list of alias names
- alias_list = []
- for alias in aliases:
- alias_list.append(alias.name)
-
- # Firstly checks if the replacement name is in the aliast list, if it wasn't specified
- # it defaults to None, both of which would assign a random value
- new_name = replacement_name if replacement_name in alias_list else alias_list[0]
- # If the value is specified (not None) and doesn't match the name, we know
- # the new entry is randomized
- if replacement_name and replacement_name != new_name:
- await auxiliary.send_deny_embed(
- message=f"I couldn't find the new parent `{replacement_name}`"
- + ", picking new parent at random",
- channel=ctx.channel,
- )
-
- new_entry = await self.get_raw_factoid_entry(new_name, str(ctx.guild.id))
- new_entry.name = new_name
- new_entry.message = factoid.message
- new_entry.embed_config = factoid.embed_config
- new_entry.alias = None
- await self.modify_factoid_call(factoid=new_entry)
-
- # Updates old aliases
- await self.handle_parent_change(ctx, aliases, new_name)
- await auxiliary.send_confirm_embed(
- message=f"Deleted the alias `{factoid_name}`",
- channel=ctx.channel,
- )
-
- # Logs the new parent change
- log_channel = configuration.get_config_entry(
- ctx.guild.id, "core_logging_channel"
- )
- await self.bot.logger.send_log(
- message=(
- f"Factoid dealias: Deleted the alias `{factoid_name}`, new"
- f" parent: `{new_name}`"
- ),
- level=LogLevel.INFO,
- context=LogContext(guild=ctx.guild, channel=ctx.channel),
- channel=log_channel,
- )
-
- jobs = (
- await self.bot.models.FactoidJob.query.where(
- self.bot.models.Factoid.guild == factoid.guild
- )
- .where(self.bot.models.Factoid.factoid_id == factoid.factoid_id)
- .gino.all()
- )
- # Deletes the factoid and deletes all jobs tied to it
- await self.delete_factoid_call(factoid, str(ctx.guild.id))
-
- # If there were jobs tied to it, recreate them with the new factoid
- if jobs:
- for job in jobs:
- new_job = self.bot.models.FactoidJob(
- factoid=new_entry.factoid_id, channel=job.channel, cron=job.cron
- )
- await new_job.create()
-
- job_id = new_job.job_id
- self.running_jobs[job_id] = {}
- self.running_jobs[job_id]["job"] = new_job
-
- # Starts the new job
- task = asyncio.create_task(self.cronjob(new_job, ctx))
- self.running_jobs[job_id]["task"] = task
-
- @auxiliary.with_typing
- @commands.has_permissions(administrator=True)
- @commands.check(has_manage_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Flushes all factoid caches",
- description="Flushes all factoid caches",
- )
- async def flush(self: Self, ctx: commands.Context) -> None:
- """Command to flush all factoid caches
-
- Args:
- ctx (commands.Context): Context of the invokation
- """
- self.factoid_cache.clear() # Factoid execution cache
- self.factoid_all_cache.clear() # Factoid all URL cache
-
- await auxiliary.send_confirm_embed(
- message=f"Factoid caches for `{str(ctx.guild.id)}` succesfully flushed!",
- channel=ctx.channel,
- )
-
- # -- Property Commands --
-
- # Hiding
-
- @auxiliary.with_typing
- @commands.check(has_admin_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Hides a factoid",
- description="Hides a factoid from showing in the all response",
- usage="[factoid-name]",
- )
- async def hide(
- self: Self,
- ctx: commands.Context,
- factoid_name: str,
- ) -> None:
- """Command to hide a factoid from the .factoid all command
-
- Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): Name of the factoid to hide
- """
-
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
- return
-
- if factoid.hidden:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already hidden",
- channel=ctx.channel,
- )
- return
- factoid.hidden = True
- await self.modify_factoid_call(factoid=factoid)
-
- await auxiliary.send_confirm_embed(
- message=f"`{factoid_name}` is now hidden", channel=ctx.channel
- )
-
- @auxiliary.with_typing
- @commands.check(has_admin_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Unhides a factoid",
- description="Unhides a factoid from showing in the all response",
- usage="[factoid-name]",
- )
- async def unhide(
- self: Self,
- ctx: commands.Context,
- factoid_name: str,
- ) -> None:
- """Command to unhide a factoid from the .factoid all list
-
- Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): The name of the factoid to unhide
- """
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
- return
-
- if not factoid.hidden:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already unhidden",
- channel=ctx.channel,
- )
- return
-
- factoid.hidden = False
- await self.modify_factoid_call(factoid=factoid)
-
- await auxiliary.send_confirm_embed(
- message=f"`{factoid_name}` is now unhidden", channel=ctx.channel
- )
-
- # Protecting
-
- @auxiliary.with_typing
- @commands.check(has_admin_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Protects a factoid",
- description="Protects a factoid and prevents modification or deletion",
- usage="[factoid-name]",
- )
- async def protect(
- self: Self,
- ctx: commands.Context,
- factoid_name: str,
- ) -> None:
- """Command to protect a factoid from being deleted or modified
-
- Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): Name of the factoid to hide
- """
-
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already protected",
- channel=ctx.channel,
- )
- return
- factoid.protected = True
- await self.modify_factoid_call(factoid=factoid)
-
- await auxiliary.send_confirm_embed(
- message=f"`{factoid_name}` is now protected", channel=ctx.channel
- )
-
- @auxiliary.with_typing
- @commands.check(has_admin_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Unprotects a factoid",
- description="Allows a protected factoid to be modified or deleted",
- usage="[factoid-name]",
- )
- async def unprotect(
- self: Self,
- ctx: commands.Context,
- factoid_name: str,
- ) -> None:
- """Command to unprotect a factoid and allow it to be deleted or modified
-
- Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): The name of the factoid to unhide
- """
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- factoid.protected = False
- await self.modify_factoid_call(factoid=factoid)
-
- await auxiliary.send_confirm_embed(
- message=f"`{factoid_name}` is now unprotected", channel=ctx.channel
- )
-
- # Restricting
-
- @auxiliary.with_typing
- @commands.check(has_admin_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Restricts a factoid",
- description="Restricts a factoid and only allows it to be called in certain channels",
- usage="[factoid-name]",
- )
- async def restrict(
- self: Self,
- ctx: commands.Context,
- factoid_name: str,
- ) -> None:
- """Command to restrict a factoid to only certain channels
-
- Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): Name of the factoid to hide
- """
-
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
- return
-
- if factoid.restricted:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already restricted",
- channel=ctx.channel,
- )
- return
- factoid.restricted = True
- await self.modify_factoid_call(factoid=factoid)
-
- await auxiliary.send_confirm_embed(
- message=f"`{factoid_name}` is now restricted", channel=ctx.channel
- )
-
- @auxiliary.with_typing
- @commands.check(has_admin_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Unrestricts a factoid",
- description="Unrestricts a factoid and allows it to be called anywhere",
- usage="[factoid-name]",
- )
- async def unrestrict(
- self: Self,
- ctx: commands.Context,
- factoid_name: str,
- ) -> None:
- """Command to allow a factoid to be called anywhere
-
- Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): The name of the factoid to unhide
- """
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
- return
-
- if not factoid.restricted:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already unrestricted",
- channel=ctx.channel,
- )
- return
-
- factoid.restricted = False
- await self.modify_factoid_call(factoid=factoid)
-
- await auxiliary.send_confirm_embed(
- message=f"`{factoid_name}` is now unrestricted", channel=ctx.channel
- )
-
- # Disabling
-
- @auxiliary.with_typing
- @commands.check(has_admin_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Disables a factoid",
- description="Disables a factoid and prevents it from being called anywhere",
- usage="[factoid-name]",
- )
- async def disable(
- self: Self,
- ctx: commands.Context,
- factoid_name: str,
- ) -> None:
- """Command to completely prevent a factoid from being called
-
- Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): Name of the factoid to hide
- """
-
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
- return
-
- if factoid.disabled:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already disabled",
- channel=ctx.channel,
- )
- return
- factoid.disabled = True
- await self.modify_factoid_call(factoid=factoid)
-
- await auxiliary.send_confirm_embed(
- message=f"`{factoid_name}` is now disabled", channel=ctx.channel
- )
-
- @auxiliary.with_typing
- @commands.check(has_admin_factoids_role)
- @commands.guild_only()
- @factoid.command(
- brief="Enables a factoid",
- description="Enables a factoid and allows it to be called",
- usage="[factoid-name]",
- )
- async def enable(
- self: Self,
- ctx: commands.Context,
- factoid_name: str,
- ) -> None:
- """Command to allow a factoid to be called
-
- Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): The name of the factoid to unhide
- """
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
-
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
- return
-
- if not factoid.disabled:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already enabled",
- channel=ctx.channel,
- )
- return
-
- factoid.disabled = False
- await self.modify_factoid_call(factoid=factoid)
-
- await auxiliary.send_confirm_embed(
- message=f"`{factoid_name}` is now enabled", channel=ctx.channel
- )
+ # TODO: Send to IRC
+ # TODO: Send to Logger
class DeleteView(discord.ui.View):
@@ -2970,7 +804,7 @@ class DeleteView(discord.ui.View):
"""
def __init__(self: Self, author_id: int) -> None:
- super().__init__(timeout=60)
+ super().__init__(timeout=300)
self.author_id = author_id
self.message: discord.Message | None = None
From acdfe7ec9c352d3af3ddab1c5c0a5f198f3570ce Mon Sep 17 00:00:00 2001
From: ajax146 <31014239+ajax146@users.noreply.github.com>
Date: Tue, 16 Jun 2026 21:23:36 -0700
Subject: [PATCH 02/33] Migrate factoid all
---
modules/operation/factoids.py | 420 ++++++++++++++++++++++++++++++++--
1 file changed, 403 insertions(+), 17 deletions(-)
diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py
index dbf443fa..48a54a9d 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -1,9 +1,16 @@
from __future__ import annotations
+import datetime
+import io
import json
+from dataclasses import dataclass
+from enum import Enum
+from socket import gaierror
from typing import TYPE_CHECKING, Self
import discord
+import yaml
+from aiohttp.client_exceptions import InvalidURL
from discord import app_commands
import ui
@@ -96,19 +103,44 @@ async def setup(bot: bot.TechSupportBot) -> None:
await bot.add_cog(FactoidManager(bot=bot))
-class FactoidManager(cogs.BaseCog):
+# TODO: Use this more
+@dataclass
+class FactoidView:
+ factoid_data_id: int
+ message: str
+ json_string: str
+ flags: int
+ times_called: int
+ create_time: datetime.datetime
+ edit_time: datetime.datetime
+ calls: list[str]
+
+
+class Properties(Enum):
+ """
+ This enum is for the new factoid all to be able to handle dynamic properties
+
+ Attributes:
+ DISABLED (int): Representation of disabled
+ HIDDEN (int): Representation of hidden
+ PROTECTED (int): Representation of protected
+ RESTRICTED (int): Representation of restricted
+ """
+
+ DISABLED: int = 0b1000
+ HIDDEN: int = 0b0100
+ PROTECTED: int = 0b0010
+ RESTRICTED: int = 0b0001
+
- FACTOID_FLAG_DISABLED = 0b1000
- FACTOID_FLAG_HIDDEN = 0b0100
- FACTOID_FLAG_PROTECTED = 0b0010
- FACTOID_FLAG_RESTRICTED = 0b0001
+class FactoidManager(cogs.BaseCog):
factoid_app_group: app_commands.Group = app_commands.Group(
name="factoid", description="Command Group for the Factoids Extension"
)
# DATABASE
- # TODO: Add caching
+ # TODO: Add caching - MAYBE
async def create_factoid_call(
self: Self,
@@ -172,6 +204,22 @@ async def delete_factoid_data(
& (self.bot.models.FactoidData.factoid_data_id == factoid_data_id)
).gino.status()
+ async def get_all_factoid_calls(
+ self: Self,
+ guild: discord.Guild,
+ ) -> list[bot.models.FactoidCall]:
+ """This gets all FactoidCall database entries for a given guild
+
+ Args:
+ guild (discord.Guild): The guild to search for
+
+ Returns:
+ list[bot.models.FactoidCall]: The list of raw database entries
+ """
+ return await self.bot.models.FactoidCall.query.where(
+ self.bot.models.FactoidCall.guild == str(guild.id)
+ ).gino.all()
+
async def create_factoid_data(
self: Self,
guild: discord.Guild,
@@ -232,11 +280,27 @@ async def delete_factoid_call(
& (self.bot.models.FactoidCall.name == name)
).gino.status()
+ async def get_all_factoid_data(
+ self: Self,
+ guild: discord.Guild,
+ ) -> list[bot.models.FactoidData]:
+ """This gets all FactoidData database entries for a given guild
+
+ Args:
+ guild (discord.Guild): The guild to search for
+
+ Returns:
+ list[bot.models.FactoidData]: The list of raw database entries
+ """
+ return await self.bot.models.FactoidData.query.where(
+ self.bot.models.FactoidData.guild == str(guild.id)
+ ).gino.all()
+
async def get_factoid_calls_by_factoid_id(
self: Self,
guild: discord.Guild,
factoid_data_id: int,
- ) -> list:
+ ) -> list[bot.models.FactoidCall]:
"""Returns all calls pointing to a factoid."""
return await self.bot.models.FactoidCall.query.where(
@@ -250,15 +314,15 @@ async def get_factoid_data_by_name(
self: Self,
guild: discord.Guild,
name: str,
- ) -> bot.models.FactoidData:
- """Searches for the factoid data associated with a given factoid name
+ ) -> FactoidView | None:
+ """Searches for the factoid associated with a given factoid name.
Args:
guild (discord.Guild): The guild to look for the factoid in
name (str): The name of the factoid to lookup
Returns:
- bot.models.FactoidData: The database entry of the factoid data, if found
+ FactoidView | None: The factoid view, if found
"""
call = await self.read_factoid_call(
@@ -269,11 +333,30 @@ async def get_factoid_data_by_name(
if call is None:
return None
- return await self.read_factoid_data(
+ factoid_data = await self.read_factoid_data(
guild=guild,
factoid_data_id=call.factoid_data_id,
)
+ if factoid_data is None:
+ return None
+
+ factoid_calls = await self.get_factoid_calls_by_factoid_id(
+ guild=guild,
+ factoid_data_id=factoid_data.factoid_data_id,
+ )
+
+ return FactoidView(
+ factoid_data_id=factoid_data.factoid_data_id,
+ message=factoid_data.message,
+ json_string=factoid_data.json_string,
+ flags=factoid_data.flags,
+ times_called=factoid_data.times_called,
+ create_time=factoid_data.create_time,
+ edit_time=factoid_data.edit_time,
+ calls=sorted(factoid_call.name for factoid_call in factoid_calls),
+ )
+
async def delete_factoid_by_name(
self: Self,
guild: discord.Guild,
@@ -353,6 +436,7 @@ async def move_factoid_call(
)
# If there aren't any calls, prevent having orphaned factoids in the database at all
+ # TODO: Make sure this support canceling and deleting jobs
if not remaining_calls:
await self.delete_factoid_data(
guild=guild,
@@ -361,6 +445,44 @@ async def move_factoid_call(
return True
+ async def get_all_factoids_for_guild(
+ self: Self,
+ guild: discord.Guild,
+ ) -> list[FactoidView]:
+ factoid_data = await self.get_all_factoid_data(guild)
+ factoid_calls = await self.get_all_factoid_calls(guild)
+
+ calls_by_id: dict[int, list[str]] = {}
+
+ for call in factoid_calls:
+ calls_by_id.setdefault(
+ call.factoid_data_id,
+ [],
+ ).append(call.name)
+
+ views = []
+
+ for factoid in factoid_data:
+ views.append(
+ FactoidView(
+ factoid_data_id=factoid.factoid_data_id,
+ message=factoid.message,
+ json_string=factoid.json_string,
+ flags=factoid.flags,
+ times_called=factoid.times_called,
+ create_time=factoid.create_time,
+ edit_time=factoid.edit_time,
+ calls=sorted(
+ calls_by_id.get(
+ factoid.factoid_data_id,
+ [],
+ )
+ ),
+ )
+ )
+
+ return views
+
# OTHER HELPERS
def can_channel_send_restricted(
@@ -436,6 +558,169 @@ async def confirm_factoid_deletion(
await view.wait()
return view.value
+ async def build_factoid_all(
+ self: Self,
+ guild: discord.Guild,
+ factoids: list[FactoidView],
+ use_file: bool,
+ ) -> discord.File | str:
+ """This builds the factoid all url or the yaml file
+
+ Args:
+ guild (discord.Guild): The guild to build factoid all for
+ factoids (list[FactoidView]): The factoids to include in the all
+ use_file (bool): Whether to force the use of a file or not
+
+ Returns:
+ discord.File | str: The final formatted factoid all
+ """
+
+ if use_file:
+ return await self.generate_factoid_all_file(guild, factoids)
+
+ try:
+ html = await self.generate_factoid_all_html(guild, factoids)
+
+ if html is None:
+ return None
+
+ headers = {
+ "Content-Type": "text/plain",
+ }
+
+ response = await self.bot.http_functions.http_call(
+ "put",
+ self.bot.file_config.api.api_url.linx,
+ headers=headers,
+ data=io.StringIO(html),
+ get_raw_response=True,
+ )
+
+ url = response["text"]
+ filename = url.split("/")[-1]
+
+ return url.replace(filename, f"selif/{filename}")
+
+ except (gaierror, InvalidURL) as exception:
+ log_channel = configuration.get_config_entry(
+ guild.id,
+ "core_logging_channel",
+ )
+
+ await self.bot.logger.send_log(
+ message="Could not render/send all-factoid HTML",
+ level=LogLevel.ERROR,
+ context=LogContext(guild=guild),
+ channel=log_channel,
+ exception=exception,
+ )
+
+ return await self.generate_factoid_all_file(guild, factoids)
+
+ async def generate_factoid_all_html(
+ self: Self,
+ guild: discord.Guild,
+ factoids: list[FactoidView],
+ ) -> str:
+ """Method to generate the html file contents
+
+ Args:
+ guild (discord.Guild): The guild the factoids are being pulled from
+ factoids (list[FactoidView]): List of all factoids
+
+ Returns:
+ str: The result html file
+ """
+
+ # Should never hit this, but double check
+ if not factoids:
+ return None
+
+ body_contents = ""
+
+ for factoid in factoids:
+ embed_text = " (embed)" if factoid.json_string else ""
+
+ calls = sorted(factoid.calls)
+
+ calls_text = f" [{', '.join(calls)}]"
+
+ body_contents += (
+ f"{calls_text}{embed_text}"
+ f" - {factoid.message}"
+ )
+
+ body_contents = f""
+
+ return f"""
+
+
+
+
+ Factoids for {guild.name}
+ {body_contents}
+
+
+
+
+ """
+
+ async def generate_factoid_all_file(
+ self: Self,
+ guild: discord.Guild,
+ factoids: list[FactoidView],
+ ) -> discord.File:
+ """Method to send the factoid list as a file instead of a paste
+
+ Args:
+ guild (discord.Guild): The guild the factoids are from
+ factoids (list[FactoidView]): List of all factoids
+
+ Returns:
+ discord.File: The file, ready to upload to discord
+ """
+
+ # We should never be here, but just in case
+ if not factoids:
+ return None
+
+ output_data = []
+
+ for index, factoid in enumerate(factoids):
+
+ calls = factoid.calls
+
+ data = {
+ "calls": calls,
+ "message": factoid.message,
+ "embed": bool(factoid.json_string),
+ }
+
+ output_data.append(
+ {
+ index: data,
+ }
+ )
+
+ return discord.File(
+ io.StringIO(yaml.dump(output_data)),
+ filename=(
+ f"factoids-for-server-{guild.id}-{datetime.datetime.utcnow()}.yaml"
+ ),
+ )
+
# AUTOFILL
async def factoid_autocomplete(
@@ -452,6 +737,7 @@ async def factoid_autocomplete(
Returns:
list[app_commands.Choice[str]]: The list of suggestions
"""
+ # TODO: Filter disabled/restricted factoids
guild = interaction.guild
if guild is None:
@@ -488,6 +774,7 @@ async def factoid_add_command(
self: Self, interaction: discord.Interaction, factoid_name: str
) -> None:
factoid_name = factoid_name.lower()
+ # TODO: Block mentions in factoid messages
# Only ever attempt to add a factoid if it doesn't exist
if await self.read_factoid_call(guild=interaction.guild, name=factoid_name):
@@ -560,7 +847,7 @@ async def factoid_add_command(
await interaction.followup.send(embed=embed, ephemeral=True)
except Exception as exc:
await interaction.followup.send(
- f"The embed you upload failed: {exc}", ephemeral=True
+ f"The embed you uploaded failed: {exc}", ephemeral=True
)
@app_commands.check(has_manage_factoids_role)
@@ -578,6 +865,10 @@ async def factoid_alias_command(
existing_factoid = existing_factoid.lower()
new_factoid = new_factoid.lower()
+ # TODO: Add check for if existing_factoid == new_factoid
+ # TODO: Add check for ensuring both existing and new factoid aren't empty
+ # TODO: This should update the edit time for the FactoidData
+
factoid = await self.get_factoid_data_by_name(
guild=interaction.guild, name=existing_factoid
)
@@ -591,7 +882,7 @@ async def factoid_alias_command(
return
# No aliases on protected factoids
- if factoid.flags & self.FACTOID_FLAG_DISABLED:
+ if factoid.flags & Properties.PROTECTED.value:
embed = auxiliary.prepare_deny_embed(
message=f"The factoid `{existing_factoid}` is protected and cannot be edited."
)
@@ -640,7 +931,7 @@ async def factoid_alias_command(
factoid_data_id=factoid.factoid_data_id,
)
- embed = auxiliary.prepare_deny_embed(
+ embed = auxiliary.prepare_confirm_embed(
message=f"Successfully added the alias `{new_factoid}` for `{existing_factoid}`",
)
# Depending on the path took to get here, we may need to followup
@@ -649,6 +940,98 @@ async def factoid_alias_command(
else:
await interaction.response.send_message(embed=embed)
+ @factoid_app_group.command(
+ name="all",
+ description="Sends a configurable list of all factoids.",
+ extras={"ephemeral_error": True},
+ )
+ async def factoid_all_command(
+ self: Self,
+ interaction: discord.Interaction,
+ factoid_property: Properties = "",
+ force_file: bool = False,
+ show_all: bool = False,
+ ) -> None:
+ # TODO: Caching - MAYBE
+
+ all_factoids = await self.get_all_factoids_for_guild(guild=interaction.guild)
+
+ # Property filters only avaiable to manage roles
+ if factoid_property or show_all:
+ await has_given_factoids_role(
+ interaction.guild,
+ interaction.user,
+ configuration.get_config_entry(
+ interaction.guild.id, "factoids_manage_roles"
+ ),
+ )
+
+ # Top priority is abiding by show_all
+ # If not but a specific property is requested, show that
+ # Otherwise, show a normal filtered list, no hidden, no disabled, no restricted
+ if show_all:
+ filtered_factoids = all_factoids
+ elif factoid_property:
+ filtered_factoids = [
+ factoid
+ for factoid in all_factoids
+ if factoid.flags & factoid_property.value
+ ]
+ else:
+ # Determine whether restricted factoids should be visible here
+ should_show_restricted = self.can_channel_send_restricted(
+ interaction.channel,
+ )
+
+ filtered_factoids = [
+ factoid
+ for factoid in all_factoids
+ if (
+ # Never show hidden factoids normally
+ not (factoid.flags & Properties.HIDDEN.value)
+ # Never show disabled factoids normally
+ and not (factoid.flags & Properties.DISABLED.value)
+ # Restricted factoids depend on channel
+ and (
+ should_show_restricted
+ or not (factoid.flags & Properties.RESTRICTED.value)
+ )
+ )
+ ]
+
+ filtered_factoids.sort(key=lambda factoid: factoid.calls[0])
+ if not filtered_factoids:
+ embed = auxiliary.prepare_deny_embed(
+ "No factoids could be found matching your filter"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ # If the linx server isn't configured, we must make it a file
+ if not self.bot.file_config.api.api_url.linx:
+ force_file = True
+
+ await interaction.response.defer(ephemeral=True)
+
+ factoid_all = await self.build_factoid_all(
+ guild=interaction.guild, factoids=filtered_factoids, use_file=force_file
+ )
+
+ if not factoid_all:
+ embed = auxiliary.prepare_deny_embed(
+ "Something went wrong generating the list of factoids"
+ )
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+
+ # If we know it's a file, or it's fallen back to a file, send it as a file
+ if isinstance(factoid_all, discord.File):
+ await interaction.followup.send(file=factoid_all, ephemeral=True)
+ return
+
+ embed = auxiliary.prepare_confirm_embed(factoid_all)
+ await interaction.followup.send(embed=embed, ephemeral=True)
+
@factoid_app_group.command(
name="call",
description="Calls a factoid from the database and sends it publicy in the channel.",
@@ -671,7 +1054,10 @@ async def factoid_call_command(
Raises:
TooLongFactoidMessageError: If the plaintext exceed 2000 characters
"""
- # TODO: Generic this to support prefix commands?
+ # TODO: Generic this to support prefix calls and loop calls
+ # TODO: New button: I can't see this (print plaintext)
+ # TODO: New button: Save to my DMs (send a copy of the message to the clickers DMs)
+ # TODO: Interact with times called
factoid_name = factoid_name.lower()
factoid = await self.get_factoid_data_by_name(
guild=interaction.guild, name=factoid_name
@@ -684,7 +1070,7 @@ async def factoid_call_command(
return
# Check if factoid is disabled. If so, don't send it
- if factoid.flags & self.FACTOID_FLAG_DISABLED:
+ if factoid.flags & Properties.DISABLED.value:
embed = auxiliary.prepare_deny_embed(
message=f"The factoid `{factoid_name}` is disabled."
)
@@ -693,7 +1079,7 @@ async def factoid_call_command(
# Check if factoid is restricted. If so, check if we can call it
if (
- factoid.flags & self.FACTOID_FLAG_RESTRICTED
+ factoid.flags & Properties.RESTRICTED.value
and not self.can_channel_send_restricted(interaction.channel)
):
embed = auxiliary.prepare_deny_embed(
From 1be8f9bcb463814dde85bcd73cac20ea34c94ee3 Mon Sep 17 00:00:00 2001
From: ajax146 <31014239+ajax146@users.noreply.github.com>
Date: Tue, 16 Jun 2026 21:38:22 -0700
Subject: [PATCH 03/33] Do some current changelog features
---
changelog.md | 4 +++-
modules/operation/factoids.py | 2 ++
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/changelog.md b/changelog.md
index f76e612c..a2efdddb 100644
--- a/changelog.md
+++ b/changelog.md
@@ -28,8 +28,10 @@ Changes since 2026.06.15
## Operation
### Factoid
-- Make /factoid call work with factoids with spaces
+- Complete migration to application commands
+- Factoids are now allowed to use spaces
- Fix permissions on /factoid add
+- /factoid all has been reworked, is now always ephemeral
### Relay
- Make relay only ping users with words starting with an @
diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py
index 48a54a9d..8adee24f 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -325,6 +325,8 @@ async def get_factoid_data_by_name(
FactoidView | None: The factoid view, if found
"""
+ # TODO: This should 100% have a cache. Cache the entire FactoidView object
+
call = await self.read_factoid_call(
guild=guild,
name=name,
From 7af3a032c957929b3554acd69dec224283b83e29 Mon Sep 17 00:00:00 2001
From: ajax146 <31014239+ajax146@users.noreply.github.com>
Date: Tue, 16 Jun 2026 21:38:33 -0700
Subject: [PATCH 04/33] Add comment
---
modules/operation/factoids.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py
index 8adee24f..14c18b41 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -955,6 +955,7 @@ async def factoid_all_command(
show_all: bool = False,
) -> None:
# TODO: Caching - MAYBE
+ # Caching here would require us to build a guild:properties_flags key
all_factoids = await self.get_all_factoids_for_guild(guild=interaction.guild)
From 36e235ee9c8082f0ebced82f8af6842a3d483d2b Mon Sep 17 00:00:00 2001
From: ajax146 <31014239+ajax146@users.noreply.github.com>
Date: Tue, 16 Jun 2026 22:09:09 -0700
Subject: [PATCH 05/33] Add a few more todos
---
modules/operation/factoids.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py
index 14c18b41..9f3791fc 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -13,9 +13,10 @@
from aiohttp.client_exceptions import InvalidURL
from discord import app_commands
+import configuration
import ui
from botlogging import LogContext, LogLevel
-from core import auxiliary, cogs, configuration
+from core import auxiliary, cogs
if TYPE_CHECKING:
import bot
@@ -693,7 +694,7 @@ async def generate_factoid_all_file(
Returns:
discord.File: The file, ready to upload to discord
"""
-
+ # TODO: Include properties in the yaml file
# We should never be here, but just in case
if not factoids:
return None
@@ -777,6 +778,7 @@ async def factoid_add_command(
) -> None:
factoid_name = factoid_name.lower()
# TODO: Block mentions in factoid messages
+ # TODO: Rename command to /factoid create
# Only ever attempt to add a factoid if it doesn't exist
if await self.read_factoid_call(guild=interaction.guild, name=factoid_name):
From 1dc562d3aae8acbcc40296c9284e68a81c8459da Mon Sep 17 00:00:00 2001
From: ajax146 <31014239+ajax146@users.noreply.github.com>
Date: Wed, 17 Jun 2026 07:31:46 -0700
Subject: [PATCH 06/33] Factoid dealias, caching, rename add->create
---
changelog.md | 5 +-
modules/operation/factoids.py | 295 ++++++++++++++++++++++------------
2 files changed, 200 insertions(+), 100 deletions(-)
diff --git a/changelog.md b/changelog.md
index a2efdddb..0fbfec14 100644
--- a/changelog.md
+++ b/changelog.md
@@ -30,8 +30,11 @@ Changes since 2026.06.15
### Factoid
- Complete migration to application commands
- Factoids are now allowed to use spaces
-- Fix permissions on /factoid add
+- /factoid add was renamed to /factoid create
+- Fix permissions on /factoid create
- /factoid all has been reworked, is now always ephemeral
+- /factoid call now works respects threads and restricted factoids
+- /factoid dealias now shows the remaining aliases on success
### Relay
- Make relay only ping users with words starting with an @
diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py
index 9f3791fc..8c9205ca 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -140,8 +140,12 @@ class FactoidManager(cogs.BaseCog):
name="factoid", description="Command Group for the Factoids Extension"
)
- # DATABASE
- # TODO: Add caching - MAYBE
+ # PRECONFIG
+ async def preconfig(self: Self) -> None:
+ """This sets up cache and job loop calls"""
+ # TODO: Factoid all cache
+ # TODO: Loops
+ self.factoid_cache: dict[str, FactoidView] = {}
async def create_factoid_call(
self: Self,
@@ -311,7 +315,7 @@ async def get_factoid_calls_by_factoid_id(
# DATABASE HELPERS
- async def get_factoid_data_by_name(
+ async def get_factoid_view_by_name(
self: Self,
guild: discord.Guild,
name: str,
@@ -326,8 +330,6 @@ async def get_factoid_data_by_name(
FactoidView | None: The factoid view, if found
"""
- # TODO: This should 100% have a cache. Cache the entire FactoidView object
-
call = await self.read_factoid_call(
guild=guild,
name=name,
@@ -336,6 +338,11 @@ async def get_factoid_data_by_name(
if call is None:
return None
+ cached_data = self.get_from_cache(guild, call.factoid_data_id)
+ if cached_data:
+ print("WOOOO")
+ return cached_data
+
factoid_data = await self.read_factoid_data(
guild=guild,
factoid_data_id=call.factoid_data_id,
@@ -486,6 +493,30 @@ async def get_all_factoids_for_guild(
return views
+ # CACHE HELPERS
+
+ def add_to_cache(self: Self, guild: discord.Guild, factoid: FactoidView) -> None:
+ cache_key = self.generate_cache_key(guild, factoid.factoid_data_id)
+ if cache_key not in self.factoid_cache:
+ self.factoid_cache[cache_key] = factoid
+
+ def remove_from_cache(
+ self: Self, guild: discord.Guild, factoid: FactoidView
+ ) -> None:
+ cache_key = self.generate_cache_key(guild, factoid.factoid_data_id)
+ del self.factoid_cache[cache_key]
+
+ def get_from_cache(
+ self: Self, guild: discord.Guild, factoid_id: int
+ ) -> FactoidView | None:
+ cache_key = self.generate_cache_key(guild, factoid_id)
+ if cache_key in self.factoid_cache:
+ return self.factoid_cache[cache_key]
+ return None
+
+ def generate_cache_key(self: Self, guild: discord.Guild, factoid_id: int) -> str:
+ return f"{guild.id}:{factoid_id}"
+
# OTHER HELPERS
def can_channel_send_restricted(
@@ -768,92 +799,6 @@ async def factoid_autocomplete(
# COMMANDS
- @app_commands.check(has_manage_factoids_role)
- @factoid_app_group.command(
- name="add",
- description="Creates a new factoid by name",
- )
- async def factoid_add_command(
- self: Self, interaction: discord.Interaction, factoid_name: str
- ) -> None:
- factoid_name = factoid_name.lower()
- # TODO: Block mentions in factoid messages
- # TODO: Rename command to /factoid create
-
- # Only ever attempt to add a factoid if it doesn't exist
- if await self.read_factoid_call(guild=interaction.guild, name=factoid_name):
- embed = auxiliary.prepare_deny_embed(
- message=f"The factoid `{factoid_name}` already exists"
- )
- await interaction.response.send_message(embed=embed, ephemeral=True)
- return
-
- form = NewFactoid(factoid_name)
- await interaction.response.send_modal(form)
- await form.wait()
-
- embed_json_string = ""
-
- if form.embed.component.values:
- embed_file: discord.Attachment = form.embed.component.values[0]
-
- if not embed_file.filename.endswith(".json"):
- embed = auxiliary.prepare_deny_embed(
- message="I don't recognize your upload as a JSON file.",
- )
- await interaction.followup.send(embed=embed)
- return
-
- try:
- json_bytes = await embed_file.read()
- attachment_json = json.loads(json_bytes.decode("UTF-8"))
- embed_json_string = json.dumps(attachment_json)
-
- except Exception:
- embed = auxiliary.prepare_deny_embed(
- message="I couldn't parse the uploaded JSON file.",
- )
- await interaction.followup.send(embed=embed)
- return
-
- selected = set(form.properties.component.values)
-
- property_binary = (
- ("disabled" in selected) << 3
- | ("hidden" in selected) << 2
- | ("protected" in selected) << 1
- | ("restricted" in selected)
- )
-
- factoid = await self.create_factoid_data(
- guild=interaction.guild,
- message=form.plaintext.component.value,
- json_string=embed_json_string,
- flags=property_binary,
- )
-
- await self.create_factoid_call(
- guild=interaction.guild,
- name=factoid_name,
- factoid_data_id=factoid.factoid_data_id,
- )
-
- embed = auxiliary.prepare_confirm_embed(
- message=f"Your factoid `{factoid_name}` was successfully created!",
- )
- await interaction.followup.send(embed=embed)
-
- # Send the factoid, and embed json if exists, to the user
- await interaction.followup.send(content=factoid.message, ephemeral=True)
- if embed_json_string:
- try:
- embed = self.get_embed_from_factoid(factoid=factoid)
- await interaction.followup.send(embed=embed, ephemeral=True)
- except Exception as exc:
- await interaction.followup.send(
- f"The embed you uploaded failed: {exc}", ephemeral=True
- )
-
@app_commands.check(has_manage_factoids_role)
@factoid_app_group.command(
name="alias",
@@ -873,7 +818,7 @@ async def factoid_alias_command(
# TODO: Add check for ensuring both existing and new factoid aren't empty
# TODO: This should update the edit time for the FactoidData
- factoid = await self.get_factoid_data_by_name(
+ factoid = await self.get_factoid_view_by_name(
guild=interaction.guild, name=existing_factoid
)
@@ -893,7 +838,7 @@ async def factoid_alias_command(
await interaction.response.send_message(embed=embed, ephemeral=True)
return
- new_factoid_db = await self.get_factoid_data_by_name(
+ new_factoid_db = await self.get_factoid_view_by_name(
guild=interaction.guild, name=new_factoid
)
@@ -938,6 +883,10 @@ async def factoid_alias_command(
embed = auxiliary.prepare_confirm_embed(
message=f"Successfully added the alias `{new_factoid}` for `{existing_factoid}`",
)
+
+ # Remove factoid from cache after editing
+ self.remove_from_cache(interaction.guild, factoid)
+
# Depending on the path took to get here, we may need to followup
if interaction.response.is_done():
await interaction.followup.send(embed=embed)
@@ -1037,6 +986,91 @@ async def factoid_all_command(
embed = auxiliary.prepare_confirm_embed(factoid_all)
await interaction.followup.send(embed=embed, ephemeral=True)
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_app_group.command(
+ name="create",
+ description="Creates a new factoid by name",
+ )
+ async def factoid_create_command(
+ self: Self, interaction: discord.Interaction, factoid_name: str
+ ) -> None:
+ factoid_name = factoid_name.lower()
+ # TODO: Block mentions in factoid messages
+
+ # Only ever attempt to add a factoid if it doesn't exist
+ if await self.read_factoid_call(guild=interaction.guild, name=factoid_name):
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` already exists"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ form = NewFactoid(factoid_name)
+ await interaction.response.send_modal(form)
+ await form.wait()
+
+ embed_json_string = ""
+
+ if form.embed.component.values:
+ embed_file: discord.Attachment = form.embed.component.values[0]
+
+ if not embed_file.filename.endswith(".json"):
+ embed = auxiliary.prepare_deny_embed(
+ message="I don't recognize your upload as a JSON file.",
+ )
+ await interaction.followup.send(embed=embed)
+ return
+
+ try:
+ json_bytes = await embed_file.read()
+ attachment_json = json.loads(json_bytes.decode("UTF-8"))
+ embed_json_string = json.dumps(attachment_json)
+
+ except Exception:
+ embed = auxiliary.prepare_deny_embed(
+ message="I couldn't parse the uploaded JSON file.",
+ )
+ await interaction.followup.send(embed=embed)
+ return
+
+ selected = set(form.properties.component.values)
+
+ property_binary = (
+ ("disabled" in selected) << 3
+ | ("hidden" in selected) << 2
+ | ("protected" in selected) << 1
+ | ("restricted" in selected)
+ )
+
+ factoid = await self.create_factoid_data(
+ guild=interaction.guild,
+ message=form.plaintext.component.value,
+ json_string=embed_json_string,
+ flags=property_binary,
+ )
+
+ await self.create_factoid_call(
+ guild=interaction.guild,
+ name=factoid_name,
+ factoid_data_id=factoid.factoid_data_id,
+ )
+
+ embed = auxiliary.prepare_confirm_embed(
+ message=f"Your factoid `{factoid_name}` was successfully created!",
+ )
+ await interaction.followup.send(embed=embed)
+
+ # Send the factoid, and embed json if exists, to the user
+ await interaction.followup.send(content=factoid.message, ephemeral=True)
+ if embed_json_string:
+ try:
+ embed = self.get_embed_from_factoid(factoid=factoid)
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ except Exception as exc:
+ await interaction.followup.send(
+ f"The embed you uploaded failed: {exc}", ephemeral=True
+ )
+
@factoid_app_group.command(
name="call",
description="Calls a factoid from the database and sends it publicy in the channel.",
@@ -1064,7 +1098,7 @@ async def factoid_call_command(
# TODO: New button: Save to my DMs (send a copy of the message to the clickers DMs)
# TODO: Interact with times called
factoid_name = factoid_name.lower()
- factoid = await self.get_factoid_data_by_name(
+ factoid = await self.get_factoid_view_by_name(
guild=interaction.guild, name=factoid_name
)
if not factoid:
@@ -1074,6 +1108,9 @@ async def factoid_call_command(
await interaction.response.send_message(embed=embed, ephemeral=True)
return
+ # Add factoid to cache after getting it
+ self.add_to_cache(interaction.guild, factoid)
+
# Check if factoid is disabled. If so, don't send it
if factoid.flags & Properties.DISABLED.value:
embed = auxiliary.prepare_deny_embed(
@@ -1124,11 +1161,10 @@ async def factoid_call_command(
content = member_to_ping.mention
embed_sent = False
+ view = DeleteView(interaction.user.id)
+ # TODO: Move factoid logging to background task, and ensure it works for fallback/plaintext
if embed:
try:
- # This view allows the caller to delete the factoid
- view = DeleteView(interaction.user.id)
-
# Attempt to send the message with the embed in it
await interaction.response.send_message(
content=content,
@@ -1159,7 +1195,10 @@ async def factoid_call_command(
interaction.guild.id, "core_logging_channel"
)
await self.bot.logger.send_log(
- message="Could not send factoid",
+ message=(
+ f"Unable to send embed for factoid `{factoid_name}`, "
+ "sending fallback."
+ ),
level=LogLevel.ERROR,
context=LogContext(
guild=interaction.guild, channel=interaction.channel
@@ -1179,13 +1218,71 @@ async def factoid_call_command(
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
- view = DeleteView(interaction.user.id)
await interaction.response.send_message(content=content, view=view)
view.message = await interaction.original_response()
# TODO: Send to IRC
# TODO: Send to Logger
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_app_group.command(
+ name="dealias",
+ description="Deletes an alias for an existing factoid call",
+ )
+ @app_commands.autocomplete(factoid_name=factoid_autocomplete)
+ async def factoid_dealias_command(
+ self: Self,
+ interaction: discord.Interaction,
+ factoid_name: str,
+ ) -> None:
+ """This deletes an alias from an existing factoid
+ This will not delete the FactoidData entry
+
+ Args:
+ interaction (discord.Interaction): The interaction that triggered this command
+ factoid_name (str): The factoid to dealias
+ """
+ factoid_name = factoid_name.lower()
+ factoid = await self.get_factoid_view_by_name(
+ guild=interaction.guild, name=factoid_name
+ )
+
+ # We can't dealias a factoid if it doesn't exist
+ if not factoid:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` doesn't exist!"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ # No edits on protected factoids
+ if factoid.flags & Properties.PROTECTED.value:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` is protected and cannot be edited."
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ # Only allowed to dealias if this wouldn't require deleting the entire factoid
+ if len(factoid.calls) == 1:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` has no other aliases."
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ await self.delete_factoid_call(guild=interaction.guild, name=factoid_name)
+ factoid.calls.remove(factoid_name)
+ remaining_aliases = ", ".join(factoid.calls)
+ embed = auxiliary.prepare_confirm_embed(
+ message=f"The factoid alias `{factoid_name}` was removed. Remaining aliases: `{remaining_aliases}`"
+ )
+
+ # Remove factoid from cache after editing
+ self.remove_from_cache(interaction.guild, factoid)
+
+ await interaction.response.send_message(embed=embed)
+
class DeleteView(discord.ui.View):
"""The class to hold the view for the delete button on /factoid call
From 56dfe9e1e0c8ae76888870f01ca96ee2d71b2ae7 Mon Sep 17 00:00:00 2001
From: ajax146 <31014239+ajax146@users.noreply.github.com>
Date: Wed, 17 Jun 2026 08:43:20 -0700
Subject: [PATCH 07/33] Logger, IRC, name/message validation
---
changelog.md | 1 +
ircrelay/formatting.py | 26 ++++
ircrelay/relay.py | 7 +
modules/operation/__init__.py | 1 +
modules/operation/factoids.py | 266 +++++++++++++++++++++++++++-------
modules/operation/relay.py | 13 +-
6 files changed, 258 insertions(+), 56 deletions(-)
diff --git a/changelog.md b/changelog.md
index 0fbfec14..8cb652e1 100644
--- a/changelog.md
+++ b/changelog.md
@@ -34,6 +34,7 @@ Changes since 2026.06.15
- Fix permissions on /factoid create
- /factoid all has been reworked, is now always ephemeral
- /factoid call now works respects threads and restricted factoids
+- /factoid call now works with IRC
- /factoid dealias now shows the remaining aliases on success
### Relay
diff --git a/ircrelay/formatting.py b/ircrelay/formatting.py
index cacbd52e..494562d1 100644
--- a/ircrelay/formatting.py
+++ b/ircrelay/formatting.py
@@ -5,6 +5,8 @@
import discord
import irc.client
+from modules.operation import factoids
+
def parse_irc_message(event: irc.client.Event) -> dict[str, str]:
"""This turns the irc.client.Event object into a dictionary
@@ -108,6 +110,30 @@ def core_sent_message_format(
return message_str
+def factoid_format(factoid: factoids.FactoidView, author: discord.Member) -> str:
+ """This formats a message, adds a permissions prefix, user prefix, and fixes new lines and
+ file attachements
+
+ Args:
+ message (discord.Message): The discord message object to format
+ content_override (str): If passed, this will changed the content of the message
+
+ Returns:
+ str: The string, with unlimited length, that is ready to be sent to IRC
+ """
+ use_content = factoid.message
+ IRC_BOLD = ""
+ permissions_prefix = get_permissions_prefix_for_discord_user(member=author)
+ message_content = f"{use_content}"
+ if len(message_content.strip()) == 0:
+ return ""
+ message_str = f"{IRC_BOLD}[D]{IRC_BOLD} <{permissions_prefix}"
+ message_str += f"{author.display_name}> {message_content}"
+ message_str = message_str.replace("\n", " ")
+ message_str = message_str.strip()
+ return message_str
+
+
def format_discord_edit_message(message: discord.Message) -> str:
"""This modifies a formatted message to add a message edited flag
diff --git a/ircrelay/relay.py b/ircrelay/relay.py
index 2490e7d2..d93810f8 100644
--- a/ircrelay/relay.py
+++ b/ircrelay/relay.py
@@ -18,6 +18,7 @@
import modules.operation
from ircrelay import formatting
+from modules.operation import factoids
class IRCBot(irc.bot.SingleServerIRCBot):
@@ -270,6 +271,12 @@ def send_reaction_from_discord(
)
self.send_message_to_channel(channel=channel, message=formatted_message)
+ def send_factoid_from_discord(
+ self: Self, channel: str, factoid: factoids.FactoidView, author: discord.Member
+ ) -> str:
+ formatted_message = formatting.factoid_format(factoid=factoid, author=author)
+ self.send_message_to_channel(channel=channel, message=formatted_message)
+
def send_message_from_discord(
self: Self, message: discord.Message, channel: str, content_override: str = None
) -> None:
diff --git a/modules/operation/__init__.py b/modules/operation/__init__.py
index 0daeb9f9..7623525e 100644
--- a/modules/operation/__init__.py
+++ b/modules/operation/__init__.py
@@ -1,5 +1,6 @@
"""Modules designed for operations of the server"""
from .application import *
+from .factoids import *
from .relay import *
from .xp import *
diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py
index 8c9205ca..03aa7a73 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -1,8 +1,10 @@
from __future__ import annotations
+import asyncio
import datetime
import io
import json
+import re
from dataclasses import dataclass
from enum import Enum
from socket import gaierror
@@ -17,6 +19,7 @@
import ui
from botlogging import LogContext, LogLevel
from core import auxiliary, cogs
+from modules.moderation import logger as function_logger
if TYPE_CHECKING:
import bot
@@ -141,12 +144,15 @@ class FactoidManager(cogs.BaseCog):
)
# PRECONFIG
+
async def preconfig(self: Self) -> None:
"""This sets up cache and job loop calls"""
# TODO: Factoid all cache
# TODO: Loops
self.factoid_cache: dict[str, FactoidView] = {}
+ # DATABASE CALLS
+
async def create_factoid_call(
self: Self,
guild: discord.Guild,
@@ -340,7 +346,6 @@ async def get_factoid_view_by_name(
cached_data = self.get_from_cache(guild, call.factoid_data_id)
if cached_data:
- print("WOOOO")
return cached_data
factoid_data = await self.read_factoid_data(
@@ -755,6 +760,167 @@ async def generate_factoid_all_file(
),
)
+ async def generate_sendable_factoid(
+ self: Self, guild: discord.Guild, factoid: FactoidView
+ ) -> tuple[discord.Embed, str]:
+ """This generates the embed and plaintext versions of a factoid, to prepare to be sent
+
+ Args:
+ guild (discord.Guild): The guild the factoid exists in
+ factoid (FactoidView): The factoid to send
+
+ Returns:
+ tuple[discord.Embed, str]: The embed if created (or None), the plaintext version
+ """
+ plaintext = factoid.message
+ if configuration.get_config_entry(guild.id, "factoids_disable_embeds"):
+ return (None, plaintext)
+ embed = None
+ try:
+ embed = self.get_embed_from_factoid(factoid)
+ except TypeError as exception:
+ await self.bot.logger.send_log(
+ message=(
+ f"Unable to make embed for factoid `[{", ".join(factoid.calls)}]`, "
+ "sending fallback."
+ ),
+ level=LogLevel.ERROR,
+ channel=configuration.get_config_entry(
+ guild.id,
+ "core_logging_channel",
+ ),
+ context=LogContext(
+ guild=guild,
+ ),
+ exception=exception,
+ )
+ return (embed, plaintext)
+
+ async def log_factoid_send(
+ self: Self,
+ guild: discord.Guild,
+ channel: discord.abc.GuildChannel,
+ sender: discord.Member,
+ factoid: FactoidView,
+ ) -> None:
+ """This sends a factoid call to the bot log channel
+
+ Args:
+ guild (discord.Guild): The guild the factoid was sent to
+ channel (discord.abc.GuildChannel): The channel the factoid was sent to
+ sender (discord.Member): The member who sent the factoid
+ factoid (FactoidView): The factoid that was sent
+ """
+
+ log_channel = configuration.get_config_entry(guild.id, "core_logging_channel")
+ await self.bot.logger.send_log(
+ message=(
+ f"Sending factoid: `[{", ".join(factoid.calls)}]` (triggered by {sender} in"
+ f" #{channel.name})"
+ ),
+ level=LogLevel.INFO,
+ context=LogContext(guild=guild, channel=channel),
+ channel=log_channel,
+ )
+
+ def send_factoid_to_irc(
+ self: Self,
+ channel: discord.abc.Messageable,
+ factoid: FactoidView,
+ author: discord.Member,
+ ) -> None:
+ """If relevant, will send a factoid to the bridged IRC channel
+
+ Args:
+ channel (discord.abc.Messageable): The discord channel the message was sent in
+ factoid (FactoidView): The factoid that was sent
+ author (discord.Member): The member who sent the factoid. May be the bot
+ """
+ irc_config = self.bot.file_config.api.irc
+ if not irc_config.enable_irc:
+ return
+
+ self.bot.irc.irc_cog.handle_factoid(
+ channel=channel,
+ factoid=factoid,
+ author=author,
+ )
+
+ async def send_factoid_to_logger(
+ self: Self,
+ factoid_message_object: discord.Message,
+ factoid_caller: discord.Member,
+ channel: discord.abc.GuildChannel | discord.Thread,
+ factoid_message: str,
+ ) -> None:
+ """Send a factoid call to the logger function
+
+ Args:
+ factoid_message_object (discord.Message): The message that the factoid is sent in
+ factoid_caller (discord.Member): The person who called the factoid
+ channel (discord.abc.GuildChannel | discord.Thread): The channel the
+ factoid was sent in
+ factoid_message (str): The plaintext message content of the factoid
+ """
+ # Don't allow logging if extension is disabled
+ if "moderation.logger" not in configuration.get_config_entry(
+ factoid_caller.guild.id, "core_enabled_extensions"
+ ):
+ return
+
+ target_logging_channel = await function_logger.pre_log_checks(self.bot, channel)
+ if not target_logging_channel:
+ return
+
+ await function_logger.send_message(
+ self.bot,
+ factoid_message_object,
+ factoid_caller,
+ channel,
+ target_logging_channel,
+ content_override=factoid_message,
+ special_flags=["Factoid call"],
+ )
+
+ def check_valid_name(self: Self, name: str) -> bool:
+ """This checks if the name of a factoid is valid or not
+
+ Args:
+ name (str): The name of the factoid to check
+
+ Returns:
+ bool: Whether this name is allowable
+ """
+ # Rule 1: name must exist
+ if not name:
+ return False
+ # Rule 2: No commas
+ elif "," in name:
+ return False
+
+ # Factoid name passed all the rules
+ return True
+
+ def check_valid_message(self: Self, message: str) -> bool:
+ """This checks if the message of a factoid is valid or not
+
+ Args:
+ message (str): The message of the factoid to check
+
+ Returns:
+ bool: Whether this message is allowable
+ """
+ mention_regex = re.compile(r"(@everyone|@here|<@[!&]?\d+>|<#\d+>)")
+ # Rule 1, no mentions
+ if mention_regex.search(message):
+ return False
+ # Rule 2, ensure length is no longer than discord can handle
+ elif len(message) > 2000:
+ return False
+
+ # Message passes all rules
+ return True
+
# AUTOFILL
async def factoid_autocomplete(
@@ -814,10 +980,22 @@ async def factoid_alias_command(
existing_factoid = existing_factoid.lower()
new_factoid = new_factoid.lower()
- # TODO: Add check for if existing_factoid == new_factoid
- # TODO: Add check for ensuring both existing and new factoid aren't empty
# TODO: This should update the edit time for the FactoidData
+ if not self.check_valid_name(new_factoid):
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid name `{new_factoid}` is invalid and cannot be used!"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ if new_factoid == existing_factoid:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"You cannot alias a factoid to itself!"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
factoid = await self.get_factoid_view_by_name(
guild=interaction.guild, name=existing_factoid
)
@@ -995,8 +1173,6 @@ async def factoid_create_command(
self: Self, interaction: discord.Interaction, factoid_name: str
) -> None:
factoid_name = factoid_name.lower()
- # TODO: Block mentions in factoid messages
-
# Only ever attempt to add a factoid if it doesn't exist
if await self.read_factoid_call(guild=interaction.guild, name=factoid_name):
embed = auxiliary.prepare_deny_embed(
@@ -1005,10 +1181,24 @@ async def factoid_create_command(
await interaction.response.send_message(embed=embed, ephemeral=True)
return
+ if not self.check_valid_name(factoid_name):
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid name `{factoid_name}` is invalid and cannot be used!"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
form = NewFactoid(factoid_name)
await interaction.response.send_modal(form)
await form.wait()
+ if not self.check_valid_message(form.plaintext.component.value):
+ embed = auxiliary.prepare_deny_embed(
+ message="The message content is invalid and cannot be used!"
+ )
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+
embed_json_string = ""
if form.embed.component.values:
@@ -1093,10 +1283,10 @@ async def factoid_call_command(
Raises:
TooLongFactoidMessageError: If the plaintext exceed 2000 characters
"""
- # TODO: Generic this to support prefix calls and loop calls
# TODO: New button: I can't see this (print plaintext)
# TODO: New button: Save to my DMs (send a copy of the message to the clickers DMs)
# TODO: Interact with times called
+
factoid_name = factoid_name.lower()
factoid = await self.get_factoid_view_by_name(
guild=interaction.guild, name=factoid_name
@@ -1130,31 +1320,19 @@ async def factoid_call_command(
await interaction.response.send_message(embed=embed, ephemeral=True)
return
- plaintext_content = factoid.message
- embed = None
+ embed, plaintext_content = await self.generate_sendable_factoid(
+ interaction.guild, factoid
+ )
- if not configuration.get_config_entry(
- interaction.guild.id, "factoids_disable_embeds"
- ):
- try:
- embed = self.get_embed_from_factoid(factoid)
- except TypeError as exception:
- await self.bot.logger.send_log(
- message=(
- f"Unable to make embed for factoid `{factoid_name}`, "
- "sending fallback."
- ),
- level=LogLevel.ERROR,
- channel=configuration.get_config_entry(
- interaction.guild.id,
- "core_logging_channel",
- ),
- context=LogContext(
- guild=interaction.guild,
- channel=interaction.channel,
- ),
- exception=exception,
- )
+ # Log in the background
+ asyncio.create_task(
+ self.log_factoid_send(
+ guild=interaction.guild,
+ channel=interaction.channel,
+ sender=interaction.user,
+ factoid=factoid,
+ )
+ )
content = ""
if member_to_ping:
@@ -1171,23 +1349,7 @@ async def factoid_call_command(
embed=embed,
view=view,
)
-
view.message = await interaction.original_response()
- # log it in the logging channel with type info and generic content
- log_channel = configuration.get_config_entry(
- interaction.guild.id, "core_logging_channel"
- )
- await self.bot.logger.send_log(
- message=(
- f"Sending factoid: `{factoid_name}` (triggered by {interaction.user} in"
- f" #{interaction.channel.name})"
- ),
- level=LogLevel.INFO,
- context=LogContext(
- guild=interaction.guild, channel=interaction.channel
- ),
- channel=log_channel,
- )
embed_sent = True
# If something breaks, also log it
except discord.errors.HTTPException as exception:
@@ -1221,8 +1383,14 @@ async def factoid_call_command(
await interaction.response.send_message(content=content, view=view)
view.message = await interaction.original_response()
- # TODO: Send to IRC
- # TODO: Send to Logger
+ # IRC connection
+ self.send_factoid_to_irc(interaction.channel, factoid, interaction.user)
+
+ # Logger connection
+ sent_message = await interaction.original_response()
+ await self.send_factoid_to_logger(
+ sent_message, interaction.user, interaction.channel, factoid.message
+ )
@app_commands.check(has_manage_factoids_role)
@factoid_app_group.command(
@@ -1341,7 +1509,7 @@ class NewFactoid(discord.ui.Modal):
"""
def __init__(self: Self, factoid: str) -> None:
- super().__init__(title=f"Creating factoid {factoid}")
+ super().__init__(title=f"Creating factoid {factoid}"[:45])
plaintext: discord.ui.Label = discord.ui.Label(
text="Plaintext:",
diff --git a/modules/operation/relay.py b/modules/operation/relay.py
index 7d873151..914efc39 100644
--- a/modules/operation/relay.py
+++ b/modules/operation/relay.py
@@ -14,6 +14,7 @@
from core import auxiliary, cogs
from modules.moderation import automod
from modules.moderation import logger as function_logger
+from modules.operation import factoids
if TYPE_CHECKING:
import bot
@@ -109,11 +110,11 @@ async def response(
if self.bot.irc.ready:
self.bot.irc.send_message_from_discord(message=ctx.message, channel=result)
- async def handle_factoid(
+ def handle_factoid(
self: Self,
channel: discord.abc.Messageable,
- discord_message: discord.Message,
- factoid_message: str,
+ factoid: factoids.FactoidView,
+ author: discord.Member,
) -> None:
"""A method to handle a factoid event and send a message to IRC with the content of
the factoid but the author of the factoid invoker
@@ -134,10 +135,8 @@ async def handle_factoid(
if str(channel.id) not in self.mapping:
return
- self.bot.irc.send_message_from_discord(
- message=discord_message,
- channel=self.mapping[str(channel.id)],
- content_override=factoid_message,
+ self.bot.irc.send_factoid_from_discord(
+ channel=self.mapping[str(channel.id)], factoid=factoid, author=author
)
@commands.group(
From 0cc4e7db93adfe65deadb0c9c392a43e25fb13b4 Mon Sep 17 00:00:00 2001
From: ajax146 <31014239+ajax146@users.noreply.github.com>
Date: Wed, 17 Jun 2026 10:03:36 -0700
Subject: [PATCH 08/33] Add some new buttons, change the view around
---
changelog.md | 1 +
modules/operation/factoids.py | 79 ++++++++++++++++++++++++++++++-----
2 files changed, 69 insertions(+), 11 deletions(-)
diff --git a/changelog.md b/changelog.md
index 8cb652e1..07f316b9 100644
--- a/changelog.md
+++ b/changelog.md
@@ -35,6 +35,7 @@ Changes since 2026.06.15
- /factoid all has been reworked, is now always ephemeral
- /factoid call now works respects threads and restricted factoids
- /factoid call now works with IRC
+- /factoid call now shows a "I see nothing" and "Save to DMs" button on factoids
- /factoid dealias now shows the remaining aliases on success
### Relay
diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py
index 03aa7a73..36a60409 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -420,9 +420,9 @@ async def move_factoid_call(
new_factoid_data_id: int,
) -> bool:
"""
- Moves a factoid call to a different factoid data entry.
+ Moves a FactoidCall to a different FactoidData entry.
- If the old factoid_data loses all calls, it is deleted.
+ If the old FactoidData loses all calls, it is deleted.
Returns True if the move succeeded.
"""
@@ -1283,8 +1283,6 @@ async def factoid_call_command(
Raises:
TooLongFactoidMessageError: If the plaintext exceed 2000 characters
"""
- # TODO: New button: I can't see this (print plaintext)
- # TODO: New button: Save to my DMs (send a copy of the message to the clickers DMs)
# TODO: Interact with times called
factoid_name = factoid_name.lower()
@@ -1339,8 +1337,7 @@ async def factoid_call_command(
content = member_to_ping.mention
embed_sent = False
- view = DeleteView(interaction.user.id)
- # TODO: Move factoid logging to background task, and ensure it works for fallback/plaintext
+ view = ButtonView(interaction.user.id, factoid)
if embed:
try:
# Attempt to send the message with the embed in it
@@ -1380,6 +1377,9 @@ async def factoid_call_command(
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
+
+ # The can't see button is not needed in plaintext cases
+ view.remove_item(view.cant_see_button)
await interaction.response.send_message(content=content, view=view)
view.message = await interaction.original_response()
@@ -1452,23 +1452,29 @@ async def factoid_dealias_command(
await interaction.response.send_message(embed=embed)
-class DeleteView(discord.ui.View):
+class ButtonView(discord.ui.View):
+ # TODO: Migrate to LayoutView
+ # TODO: Make this entirely in charge of displaying factoids for factoid call and factoid loop jobs
"""The class to hold the view for the delete button on /factoid call
Args:
author_id (int): The ID of the author of the factoid
"""
- def __init__(self: Self, author_id: int) -> None:
- super().__init__(timeout=300)
+ def __init__(self: Self, author_id: int, factoid: FactoidView) -> None:
+ super().__init__(timeout=600)
self.author_id = author_id
+ self.factoid: FactoidView = factoid
self.message: discord.Message | None = None
async def on_timeout(self: Self) -> None:
- """Is called after the timeout, with the goal of deleting the buttons from the message"""
+ """Is called after the timeout, with the goal of disabling the buttons from the message"""
+ for child in self.walk_children():
+ if isinstance(child, discord.ui.Button):
+ child.disabled = True
if self.message:
- await self.message.edit(view=None)
+ await self.message.edit(view=self)
@discord.ui.button(label="Delete", style=discord.ButtonStyle.danger, emoji="🗑️")
async def delete_button(
@@ -1493,6 +1499,57 @@ async def delete_button(
if interaction.message:
await interaction.message.delete()
+ @discord.ui.button(
+ label="I see nothing", style=discord.ButtonStyle.blurple, emoji="👁️"
+ )
+ async def cant_see_button(
+ self: Self,
+ interaction: discord.Interaction,
+ button: discord.ui.Button,
+ ) -> None:
+ """The function called when the see nothing button is pressed
+
+ Args:
+ interaction (discord.Interaction): The interaction that pressed the button
+ button (discord.ui.Button): The button object itself
+ """
+ await interaction.response.send_message(
+ content=self.factoid.message, ephemeral=True
+ )
+
+ # Tell user how to enable embeds
+ await interaction.followup.send(
+ f"In order to see these messages in the future, consider enabling embeds: ",
+ ephemeral=True,
+ )
+
+ @discord.ui.button(label="Save to DMs", style=discord.ButtonStyle.green, emoji="💬")
+ async def send_to_dm_button(
+ self: Self,
+ interaction: discord.Interaction,
+ button: discord.ui.Button,
+ ) -> None:
+ """The function called when the save to DMs button is pressed
+
+ Args:
+ interaction (discord.Interaction): The interaction that pressed the button
+ button (discord.ui.Button): The button object itself
+ """
+ try:
+ await interaction.user.send(
+ content=interaction.message.content, embeds=interaction.message.embeds
+ )
+ except discord.Forbidden:
+ embed = auxiliary.prepare_deny_embed(
+ "It appears you have DMs closed. I can't send you this factoid"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ await interaction.response.send_message(
+ "I sent a copy of this factoid to your DMs", ephemeral=True
+ )
+
class NewFactoid(discord.ui.Modal):
"""A Modal that contains information to make a new factoid
From d7dffe3e59c0785546afe0bc1814e36f6072287c Mon Sep 17 00:00:00 2001
From: ajax146 <31014239+ajax146@users.noreply.github.com>
Date: Wed, 17 Jun 2026 10:55:44 -0700
Subject: [PATCH 09/33] Delete command, layout changes
---
changelog.md | 1 +
modules/operation/factoids.py | 301 ++++++++++++++++++++++------------
2 files changed, 196 insertions(+), 106 deletions(-)
diff --git a/changelog.md b/changelog.md
index 07f316b9..605785d9 100644
--- a/changelog.md
+++ b/changelog.md
@@ -33,6 +33,7 @@ Changes since 2026.06.15
- /factoid add was renamed to /factoid create
- Fix permissions on /factoid create
- /factoid all has been reworked, is now always ephemeral
+- /factoid all will now filter to only callable factoids, hiding disabled factoids, and hiding restricted factoids if not in a restricted channel
- /factoid call now works respects threads and restricted factoids
- /factoid call now works with IRC
- /factoid call now shows a "I see nothing" and "Save to DMs" button on factoids
diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py
index 36a60409..42bbbc2c 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -372,7 +372,7 @@ async def get_factoid_view_by_name(
calls=sorted(factoid_call.name for factoid_call in factoid_calls),
)
- async def delete_factoid_by_name(
+ async def delete_factoid_call_by_name(
self: Self,
guild: discord.Guild,
name: str,
@@ -388,7 +388,7 @@ async def delete_factoid_by_name(
name=name,
)
- if call is None:
+ if not call:
return False
factoid_data_id = call.factoid_data_id
@@ -413,6 +413,31 @@ async def delete_factoid_by_name(
return True
+ async def delete_factoid_data_by_id(
+ self: Self, guild: discord.Guild, id: int
+ ) -> bool:
+ """This deletes all FactoidData, FactoidCall and FactoidJob for the factoid ID passed
+
+ Args:
+ guild (discord.Guild): The guild the factoid to delete is in
+ id (int): The ID of the factoid to delete
+
+ Returns:
+ bool: Whether or not this was successful
+ """
+ # TODO: Make sure this support jobs
+ data = await self.read_factoid_data(guild, id)
+ calls = await self.get_factoid_calls_by_factoid_id(guild, id)
+
+ if not data or not calls:
+ return False
+
+ for call in calls:
+ await call.delete()
+
+ await data.delete()
+ return True
+
async def move_factoid_call(
self: Self,
guild: discord.Guild,
@@ -509,7 +534,8 @@ def remove_from_cache(
self: Self, guild: discord.Guild, factoid: FactoidView
) -> None:
cache_key = self.generate_cache_key(guild, factoid.factoid_data_id)
- del self.factoid_cache[cache_key]
+ if cache_key in self.factoid_cache:
+ del self.factoid_cache[cache_key]
def get_from_cache(
self: Self, guild: discord.Guild, factoid_id: int
@@ -569,7 +595,7 @@ def get_embed_from_factoid(
async def confirm_factoid_deletion(
self: Self,
interaction: discord.Interaction,
- factoid_name: str,
+ display_message: str,
channel: discord.abc.GuildChannel,
author: discord.Member,
) -> ui.ConfirmResponse:
@@ -586,9 +612,7 @@ async def confirm_factoid_deletion(
"""
view = ui.Confirm()
await view.send(
- message=(
- f"The factoid `{factoid_name}` already exists. Should I overwrite it?"
- ),
+ message=display_message,
channel=channel,
author=author,
interaction=interaction,
@@ -1033,14 +1057,14 @@ async def factoid_alias_command(
await interaction.response.defer()
confirmation_response = await self.confirm_factoid_deletion(
interaction=interaction,
- factoid_name=new_factoid,
+ factoid_name=f"The factoid `{new_factoid}` already exists. Should I overwrite it?",
channel=interaction.channel,
author=interaction.user,
)
if confirmation_response == ui.ConfirmResponse.TIMEOUT:
return
elif confirmation_response == ui.ConfirmResponse.DENIED:
- embed = await auxiliary.prepare_deny_embed(
+ embed = auxiliary.prepare_deny_embed(
message=f"The factoid `{new_factoid}` was not replaced.",
)
interaction.followup.send(embed=embed)
@@ -1164,103 +1188,6 @@ async def factoid_all_command(
embed = auxiliary.prepare_confirm_embed(factoid_all)
await interaction.followup.send(embed=embed, ephemeral=True)
- @app_commands.check(has_manage_factoids_role)
- @factoid_app_group.command(
- name="create",
- description="Creates a new factoid by name",
- )
- async def factoid_create_command(
- self: Self, interaction: discord.Interaction, factoid_name: str
- ) -> None:
- factoid_name = factoid_name.lower()
- # Only ever attempt to add a factoid if it doesn't exist
- if await self.read_factoid_call(guild=interaction.guild, name=factoid_name):
- embed = auxiliary.prepare_deny_embed(
- message=f"The factoid `{factoid_name}` already exists"
- )
- await interaction.response.send_message(embed=embed, ephemeral=True)
- return
-
- if not self.check_valid_name(factoid_name):
- embed = auxiliary.prepare_deny_embed(
- message=f"The factoid name `{factoid_name}` is invalid and cannot be used!"
- )
- await interaction.response.send_message(embed=embed, ephemeral=True)
- return
-
- form = NewFactoid(factoid_name)
- await interaction.response.send_modal(form)
- await form.wait()
-
- if not self.check_valid_message(form.plaintext.component.value):
- embed = auxiliary.prepare_deny_embed(
- message="The message content is invalid and cannot be used!"
- )
- await interaction.followup.send(embed=embed, ephemeral=True)
- return
-
- embed_json_string = ""
-
- if form.embed.component.values:
- embed_file: discord.Attachment = form.embed.component.values[0]
-
- if not embed_file.filename.endswith(".json"):
- embed = auxiliary.prepare_deny_embed(
- message="I don't recognize your upload as a JSON file.",
- )
- await interaction.followup.send(embed=embed)
- return
-
- try:
- json_bytes = await embed_file.read()
- attachment_json = json.loads(json_bytes.decode("UTF-8"))
- embed_json_string = json.dumps(attachment_json)
-
- except Exception:
- embed = auxiliary.prepare_deny_embed(
- message="I couldn't parse the uploaded JSON file.",
- )
- await interaction.followup.send(embed=embed)
- return
-
- selected = set(form.properties.component.values)
-
- property_binary = (
- ("disabled" in selected) << 3
- | ("hidden" in selected) << 2
- | ("protected" in selected) << 1
- | ("restricted" in selected)
- )
-
- factoid = await self.create_factoid_data(
- guild=interaction.guild,
- message=form.plaintext.component.value,
- json_string=embed_json_string,
- flags=property_binary,
- )
-
- await self.create_factoid_call(
- guild=interaction.guild,
- name=factoid_name,
- factoid_data_id=factoid.factoid_data_id,
- )
-
- embed = auxiliary.prepare_confirm_embed(
- message=f"Your factoid `{factoid_name}` was successfully created!",
- )
- await interaction.followup.send(embed=embed)
-
- # Send the factoid, and embed json if exists, to the user
- await interaction.followup.send(content=factoid.message, ephemeral=True)
- if embed_json_string:
- try:
- embed = self.get_embed_from_factoid(factoid=factoid)
- await interaction.followup.send(embed=embed, ephemeral=True)
- except Exception as exc:
- await interaction.followup.send(
- f"The embed you uploaded failed: {exc}", ephemeral=True
- )
-
@factoid_app_group.command(
name="call",
description="Calls a factoid from the database and sends it publicy in the channel.",
@@ -1392,6 +1319,103 @@ async def factoid_call_command(
sent_message, interaction.user, interaction.channel, factoid.message
)
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_app_group.command(
+ name="create",
+ description="Creates a new factoid by name",
+ )
+ async def factoid_create_command(
+ self: Self, interaction: discord.Interaction, factoid_name: str
+ ) -> None:
+ factoid_name = factoid_name.lower()
+ # Only ever attempt to add a factoid if it doesn't exist
+ if await self.read_factoid_call(guild=interaction.guild, name=factoid_name):
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` already exists"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ if not self.check_valid_name(factoid_name):
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid name `{factoid_name}` is invalid and cannot be used!"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ form = NewFactoid(factoid_name)
+ await interaction.response.send_modal(form)
+ await form.wait()
+
+ if not self.check_valid_message(form.plaintext.component.value):
+ embed = auxiliary.prepare_deny_embed(
+ message="The message content is invalid and cannot be used!"
+ )
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+
+ embed_json_string = ""
+
+ if form.embed.component.values:
+ embed_file: discord.Attachment = form.embed.component.values[0]
+
+ if not embed_file.filename.endswith(".json"):
+ embed = auxiliary.prepare_deny_embed(
+ message="I don't recognize your upload as a JSON file.",
+ )
+ await interaction.followup.send(embed=embed)
+ return
+
+ try:
+ json_bytes = await embed_file.read()
+ attachment_json = json.loads(json_bytes.decode("UTF-8"))
+ embed_json_string = json.dumps(attachment_json)
+
+ except Exception:
+ embed = auxiliary.prepare_deny_embed(
+ message="I couldn't parse the uploaded JSON file.",
+ )
+ await interaction.followup.send(embed=embed)
+ return
+
+ selected = set(form.properties.component.values)
+
+ property_binary = (
+ ("disabled" in selected) << 3
+ | ("hidden" in selected) << 2
+ | ("protected" in selected) << 1
+ | ("restricted" in selected)
+ )
+
+ factoid = await self.create_factoid_data(
+ guild=interaction.guild,
+ message=form.plaintext.component.value,
+ json_string=embed_json_string,
+ flags=property_binary,
+ )
+
+ await self.create_factoid_call(
+ guild=interaction.guild,
+ name=factoid_name,
+ factoid_data_id=factoid.factoid_data_id,
+ )
+
+ embed = auxiliary.prepare_confirm_embed(
+ message=f"Your factoid `{factoid_name}` was successfully created!",
+ )
+ await interaction.followup.send(embed=embed)
+
+ # Send the factoid, and embed json if exists, to the user
+ await interaction.followup.send(content=factoid.message, ephemeral=True)
+ if embed_json_string:
+ try:
+ embed = self.get_embed_from_factoid(factoid=factoid)
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ except Exception as exc:
+ await interaction.followup.send(
+ f"The embed you uploaded failed: {exc}", ephemeral=True
+ )
+
@app_commands.check(has_manage_factoids_role)
@factoid_app_group.command(
name="dealias",
@@ -1451,6 +1475,71 @@ async def factoid_dealias_command(
await interaction.response.send_message(embed=embed)
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_app_group.command(
+ name="delete",
+ description="Deletes a factoid, all aliases and all jobs",
+ )
+ @app_commands.autocomplete(factoid_name=factoid_autocomplete)
+ async def factoid_delete_command(
+ self: Self,
+ interaction: discord.Interaction,
+ factoid_name: str,
+ ) -> None:
+ """This deletes a factoid from the database entirely
+ All FactoidCall and FactoidJob entries will be deleted
+
+ Args:
+ interaction (discord.Interaction): The interaction that triggered this command
+ factoid_name (str): The factoid to dealias
+ """
+ factoid_name = factoid_name.lower()
+ factoid = await self.get_factoid_view_by_name(
+ guild=interaction.guild, name=factoid_name
+ )
+
+ # We can't delete a factoid if it doesn't exist
+ if not factoid:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` doesn't exist!"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ # No edits on protected factoids
+ if factoid.flags & Properties.PROTECTED.value:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` is protected and cannot be edited."
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ await interaction.response.defer()
+ confirmation_response = await self.confirm_factoid_deletion(
+ interaction=interaction,
+ display_message=f"Are you sure you want to delete the factoid `[{", ".join(factoid.calls)}]`?",
+ channel=interaction.channel,
+ author=interaction.user,
+ )
+ if confirmation_response == ui.ConfirmResponse.TIMEOUT:
+ return
+ elif confirmation_response == ui.ConfirmResponse.DENIED:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` was not deleted.",
+ )
+ interaction.followup.send(embed=embed)
+ return
+
+ await self.delete_factoid_data_by_id(interaction.guild, factoid.factoid_data_id)
+
+ # Remove factoid from cache after deleting
+ self.remove_from_cache(interaction.guild, factoid)
+
+ embed = auxiliary.prepare_confirm_embed(
+ f"The factoid `[{", ".join(factoid.calls)}]` was deleted"
+ )
+ await interaction.followup.send(embed=embed)
+
class ButtonView(discord.ui.View):
# TODO: Migrate to LayoutView
From 3147d71e17a39be1c7863404504b18944c3e363a Mon Sep 17 00:00:00 2001
From: ajax146 <31014239+ajax146@users.noreply.github.com>
Date: Wed, 17 Jun 2026 12:22:49 -0700
Subject: [PATCH 10/33] Factoid edit command
---
modules/operation/factoids.py | 281 +++++++++++++++++++++++++++++-----
1 file changed, 244 insertions(+), 37 deletions(-)
diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py
index 42bbbc2c..595f6594 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -137,6 +137,7 @@ class Properties(Enum):
RESTRICTED: int = 0b0001
+# TODO: create/edit need to have duplicate json file to string code generic shared function
class FactoidManager(cogs.BaseCog):
factoid_app_group: app_commands.Group = app_commands.Group(
@@ -279,6 +280,48 @@ async def read_factoid_data(
& (self.bot.models.FactoidData.factoid_data_id == factoid_data_id)
).gino.first()
+ async def update_factoid_data(
+ self: Self,
+ guild: discord.Guild,
+ factoid_data_id: int,
+ message: str = None,
+ edit_time: datetime.datetime = None,
+ flags: int = None,
+ times_called: int = None,
+ json_string: str = None,
+ ) -> bot.models.FactoidData:
+ """Partially updates a factoid data entry."""
+
+ db_entry = await self.read_factoid_data(
+ guild=guild,
+ factoid_data_id=factoid_data_id,
+ )
+
+ update_values = {}
+
+ if message is not None:
+ update_values["message"] = message
+
+ if edit_time is not None:
+ update_values["edit_time"] = edit_time
+
+ if flags is not None:
+ update_values["flags"] = flags
+
+ if times_called is not None:
+ update_values["times_called"] = times_called
+
+ # special case: json_string can be explicitly cleared
+ if json_string is not None:
+ update_values["json_string"] = json_string
+ else:
+ update_values["json_string"] = ""
+
+ if update_values:
+ await db_entry.update(**update_values).apply()
+
+ return db_entry
+
async def delete_factoid_call(
self: Self,
guild: discord.Guild,
@@ -1108,6 +1151,7 @@ async def factoid_all_command(
show_all: bool = False,
) -> None:
# TODO: Caching - MAYBE
+ # TODO: Check if guild has zero factoids, make special error
# Caching here would require us to build a guild:properties_flags key
all_factoids = await self.get_all_factoids_for_guild(guild=interaction.guild)
@@ -1343,7 +1387,7 @@ async def factoid_create_command(
await interaction.response.send_message(embed=embed, ephemeral=True)
return
- form = NewFactoid(factoid_name)
+ form = FactoidModal(factoid_name, edit_mode=False)
await interaction.response.send_modal(form)
await form.wait()
@@ -1380,12 +1424,7 @@ async def factoid_create_command(
selected = set(form.properties.component.values)
- property_binary = (
- ("disabled" in selected) << 3
- | ("hidden" in selected) << 2
- | ("protected" in selected) << 1
- | ("restricted" in selected)
- )
+ property_binary = sum(int(value) for value in selected)
factoid = await self.create_factoid_data(
guild=interaction.guild,
@@ -1540,6 +1579,128 @@ async def factoid_delete_command(
)
await interaction.followup.send(embed=embed)
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_app_group.command(
+ name="edit",
+ description="Edits an existing factoids message, embed or properties",
+ )
+ @app_commands.autocomplete(factoid_name=factoid_autocomplete)
+ async def factoid_edit_command(
+ self: Self,
+ interaction: discord.Interaction,
+ factoid_name: str,
+ ) -> None:
+ """This edits an existing factoid, allowing changes to the properties, message, and embed
+
+ Args:
+ interaction (discord.Interaction): The interaction that triggered this command
+ factoid_name (str): The factoid to edit
+ """
+ factoid_name = factoid_name.lower()
+ factoid = await self.get_factoid_view_by_name(
+ guild=interaction.guild, name=factoid_name
+ )
+
+ # We can't edit a factoid if it doesn't exist
+ if not factoid:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` doesn't exist!"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ # No edits on protected factoids
+ if factoid.flags & Properties.PROTECTED.value:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` is protected and cannot be edited."
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ form = FactoidModal(factoid_name, edit_mode=True, factoid=factoid)
+ await interaction.response.send_modal(form)
+ await form.wait()
+
+ if not self.check_valid_message(form.plaintext.component.value):
+ embed = auxiliary.prepare_deny_embed(
+ message="The message content is invalid and cannot be used!"
+ )
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+
+ show_plaintext = False
+ show_embed = False
+
+ if form.plaintext.component.value != factoid.message:
+ show_plaintext = True
+
+ # Embed handling.
+ embed_json_string = ""
+ embed_choice = form.json_action.component.value
+ if embed_choice == "keep":
+ embed_json_string = factoid.json_string
+ elif embed_choice == "replace":
+ show_embed = True
+ # In order to replace we must have a json file
+ if not form.embed.component.values:
+ embed = auxiliary.prepare_deny_embed(
+ message="The json file was requested to be replaced, but no file was uploaded"
+ )
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ return
+ embed_file: discord.Attachment = form.embed.component.values[0]
+
+ if not embed_file.filename.endswith(".json"):
+ embed = auxiliary.prepare_deny_embed(
+ message="I don't recognize your upload as a JSON file.",
+ )
+ await interaction.followup.send(embed=embed)
+ return
+
+ try:
+ json_bytes = await embed_file.read()
+ attachment_json = json.loads(json_bytes.decode("UTF-8"))
+ embed_json_string = json.dumps(attachment_json)
+
+ except Exception:
+ embed = auxiliary.prepare_deny_embed(
+ message="I couldn't parse the uploaded JSON file.",
+ )
+ await interaction.followup.send(embed=embed)
+ return
+
+ # Get the property changes
+ selected = set(form.properties.component.values)
+ property_binary = sum(int(value) for value in selected)
+
+ # Remove factoid from cache after editing
+ self.remove_from_cache(interaction.guild, factoid)
+
+ factoid = await self.update_factoid_data(
+ guild=interaction.guild,
+ factoid_data_id=factoid.factoid_data_id,
+ message=form.plaintext.component.value,
+ flags=property_binary,
+ json_string=embed_json_string,
+ )
+
+ embed = auxiliary.prepare_confirm_embed(
+ message=f"Your factoid `{factoid_name}` was successfully edited!",
+ )
+ await interaction.followup.send(embed=embed)
+
+ # If plaintext or embed was edited, show the new version to the user
+ if show_plaintext:
+ await interaction.followup.send(content=factoid.message, ephemeral=True)
+ if show_embed:
+ try:
+ embed = self.get_embed_from_factoid(factoid=factoid)
+ await interaction.followup.send(embed=embed, ephemeral=True)
+ except Exception as exc:
+ await interaction.followup.send(
+ f"The embed you uploaded failed: {exc}", ephemeral=True
+ )
+
class ButtonView(discord.ui.View):
# TODO: Migrate to LayoutView
@@ -1640,8 +1801,8 @@ async def send_to_dm_button(
)
-class NewFactoid(discord.ui.Modal):
- """A Modal that contains information to make a new factoid
+class FactoidModal(discord.ui.Modal):
+ """A Modal that contains information to make or edit a factoid
This has the user fill in plaintext content, upload an embed json file
And select default properties for the factoid
@@ -1654,37 +1815,83 @@ class NewFactoid(discord.ui.Modal):
properties (discord.ui.Label): The properties of the factoid, such as hidden or disabled
"""
- def __init__(self: Self, factoid: str) -> None:
- super().__init__(title=f"Creating factoid {factoid}"[:45])
+ def __init__(
+ self,
+ factoid_name: str,
+ edit_mode: bool,
+ factoid: FactoidView | None = None,
+ ) -> None:
+ super().__init__(
+ title=(
+ f"Editing factoid {factoid_name}"
+ if edit_mode
+ else f"Creating factoid {factoid_name}"
+ )[:45]
+ )
- plaintext: discord.ui.Label = discord.ui.Label(
- text="Plaintext:",
- component=discord.ui.TextInput(style=discord.TextStyle.long, required=True),
- )
- embed: discord.ui.Label = discord.ui.Label(
- text="Embed json:", component=discord.ui.FileUpload(required=False)
- )
- properties: discord.ui.Label = discord.ui.Label(
- text="Properties:",
- component=discord.ui.CheckboxGroup(
- max_values=4,
- required=False,
- options=[
- discord.CheckboxGroupOption(
- default=False, label="Disabled", value="disabled"
- ),
- discord.CheckboxGroupOption(
- default=False, label="Hidden", value="hidden"
- ),
- discord.CheckboxGroupOption(
- default=False, label="Protected", value="protected"
+ self.plaintext = discord.ui.Label(
+ text="Plaintext:",
+ component=discord.ui.TextInput(
+ style=discord.TextStyle.long,
+ required=True,
+ default=factoid.message if factoid else None,
+ ),
+ )
+
+ self.add_item(self.plaintext)
+
+ if edit_mode:
+ self.json_action = discord.ui.Label(
+ text="JSON Action:",
+ component=discord.ui.RadioGroup(
+ required=True,
+ options=[
+ discord.RadioGroupOption(
+ label="Keep Existing",
+ value="keep",
+ default=True,
+ ),
+ discord.RadioGroupOption(
+ label="Remove Existing",
+ value="remove",
+ ),
+ discord.RadioGroupOption(
+ label="Replace Existing",
+ value="replace",
+ ),
+ ],
),
+ )
+
+ self.add_item(self.json_action)
+
+ self.embed = discord.ui.Label(
+ text="Embed JSON:",
+ component=discord.ui.FileUpload(required=False),
+ )
+
+ self.add_item(self.embed)
+
+ property_options = []
+
+ for prop in Properties:
+ property_options.append(
discord.CheckboxGroupOption(
- default=False, label="Restricted", value="restricted"
- ),
- ],
- ),
- )
+ label=prop.name.title(),
+ value=str(prop.value),
+ default=(bool(factoid.flags & prop.value) if factoid else False),
+ )
+ )
+ self.properties = discord.ui.Label(
+ text="Properties:",
+ component=discord.ui.CheckboxGroup(
+ max_values=len(property_options),
+ required=False,
+ options=property_options,
+ ),
+ )
+
+ self.add_item(self.properties)
async def on_submit(self: Self, interaction: discord.Interaction) -> None:
"""What happens when the form has been successfully submitted
From bf6709806b5de4d2cc6449943891977253e512ef Mon Sep 17 00:00:00 2001
From: ajax146 <31014239+ajax146@users.noreply.github.com>
Date: Wed, 17 Jun 2026 14:30:06 -0700
Subject: [PATCH 11/33] Factoid flush command
---
modules/operation/factoids.py | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py
index 595f6594..119785c1 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -1701,6 +1701,30 @@ async def factoid_edit_command(
f"The embed you uploaded failed: {exc}", ephemeral=True
)
+ @app_commands.checks.has_permissions(administrator=True)
+ @app_commands.check(has_admin_factoids_role)
+ @factoid_app_group.command(
+ name="flush",
+ description="Flushes cached factoids for the current guild",
+ )
+ async def factoid_flush_command(
+ self: Self,
+ interaction: discord.Interaction,
+ ) -> None:
+ """Command designed for fixing issues and debugging.
+ Will empty the cache for the current guild
+
+ Args:
+ interaction (discord.Interaction): The interaction that triggered this command
+ """
+ # TODO: Will need to clear factoid all cache when it exists
+ for entry in list(self.factoid_cache.keys()):
+ if entry.startswith(str(interaction.guild.id)):
+ del self.factoid_cache[entry]
+
+ embed = auxiliary.prepare_confirm_embed("Factoid cache for this guild cleared")
+ await interaction.response.send_message(embed=embed)
+
class ButtonView(discord.ui.View):
# TODO: Migrate to LayoutView
From cff7e7ca14ee01fe544c0ce5e7ebf9d298867771 Mon Sep 17 00:00:00 2001
From: ajax146 <31014239+ajax146@users.noreply.github.com>
Date: Wed, 17 Jun 2026 15:40:54 -0700
Subject: [PATCH 12/33] Info and json commands
---
core/databases.py | 5 +-
modules/operation/factoids.py | 155 +++++++++++++++++++++++++++++++---
2 files changed, 147 insertions(+), 13 deletions(-)
diff --git a/core/databases.py b/core/databases.py
index b74731e0..4d15ca6e 100644
--- a/core/databases.py
+++ b/core/databases.py
@@ -132,14 +132,13 @@ class FactoidData(bot.db.Model):
factoid_data_id: int = bot.db.Column(bot.db.Integer, primary_key=True)
guild: str = bot.db.Column(bot.db.String, index=True)
- message: str = bot.db.Column(bot.db.String, index=True)
+ message: str = bot.db.Column(bot.db.String)
create_time: datetime.datetime = bot.db.Column(
bot.db.DateTime, default=datetime.datetime.utcnow
)
edit_time: datetime.datetime = bot.db.Column(
bot.db.DateTime,
default=datetime.datetime.utcnow,
- onupdate=datetime.datetime.utcnow,
)
json_string: str = bot.db.Column(bot.db.String, default=None)
flags: int = bot.db.Column(bot.db.Integer)
@@ -150,7 +149,7 @@ class FactoidCall(bot.db.Model):
factoid_call_id: int = bot.db.Column(bot.db.Integer, primary_key=True)
guild: str = bot.db.Column(bot.db.String, index=True)
- name: str = bot.db.Column(bot.db.String)
+ name: str = bot.db.Column(bot.db.String, index=True)
factoid_data_id = bot.db.Column(
bot.db.Integer,
diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py
index 119785c1..44f1f7dc 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -71,7 +71,7 @@ async def has_given_factoids_role(
check_roles (list[str]): The list of string names of roles
Raises:
- CommandError: No management roles assigned in the config
+ AppCommandError: No management roles assigned in the config
MissingAnyRole: Invoker doesn't have a factoid management role
Returns:
@@ -141,7 +141,8 @@ class Properties(Enum):
class FactoidManager(cogs.BaseCog):
factoid_app_group: app_commands.Group = app_commands.Group(
- name="factoid", description="Command Group for the Factoids Extension"
+ name="factoid",
+ description="Commands to create, manage and use the factoids system",
)
# PRECONFIG
@@ -311,11 +312,8 @@ async def update_factoid_data(
if times_called is not None:
update_values["times_called"] = times_called
- # special case: json_string can be explicitly cleared
if json_string is not None:
update_values["json_string"] = json_string
- else:
- update_values["json_string"] = ""
if update_values:
await db_entry.update(**update_values).apply()
@@ -566,6 +564,19 @@ async def get_all_factoids_for_guild(
return views
+ async def increment_times_called_by_view(
+ self: Self, guild: discord.Guild, factoid: FactoidView
+ ) -> None:
+ factoid.times_called += 1
+ await self.update_factoid_data(
+ guild=guild,
+ factoid_data_id=factoid.factoid_data_id,
+ times_called=factoid.times_called,
+ )
+ # Replace the factoid in the cache. No need to require a re-pull every call
+ self.remove_from_cache(guild, factoid)
+ self.add_to_cache(guild, factoid)
+
# CACHE HELPERS
def add_to_cache(self: Self, guild: discord.Guild, factoid: FactoidView) -> None:
@@ -988,6 +999,25 @@ def check_valid_message(self: Self, message: str) -> bool:
# Message passes all rules
return True
+ def create_json_file(self: Self, factoid: FactoidView) -> discord.File:
+ """This takes a factoid and pulls the json string, and turns it into a file
+ Designed to be used to send a json file in a discord message
+
+ Args:
+ factoid (FactoidView): The factoid to make the json file of
+
+ Returns:
+ discord.File: The json file representing the embed of this factoid
+ """
+ formatted = json.dumps(json.loads(factoid.json_string), indent=4)
+ json_file = discord.File(
+ io.StringIO(formatted),
+ filename=(
+ f"factoid-{factoid.factoid_data_id}-embed-config-{datetime.datetime.utcnow()}.json"
+ ),
+ )
+ return json_file
+
# AUTOFILL
async def factoid_autocomplete(
@@ -1250,12 +1280,7 @@ async def factoid_call_command(
interaction (discord.Interaction): The interaction that triggered this command
factoid_name (str): The factoid name to search for and print
member_to_ping (discord.Member): A member to ping in the output
-
- Raises:
- TooLongFactoidMessageError: If the plaintext exceed 2000 characters
"""
- # TODO: Interact with times called
-
factoid_name = factoid_name.lower()
factoid = await self.get_factoid_view_by_name(
guild=interaction.guild, name=factoid_name
@@ -1363,6 +1388,11 @@ async def factoid_call_command(
sent_message, interaction.user, interaction.channel, factoid.message
)
+ # Increase times called
+ await self.increment_times_called_by_view(
+ guild=interaction.guild, factoid=factoid
+ )
+
@app_commands.check(has_manage_factoids_role)
@factoid_app_group.command(
name="create",
@@ -1473,6 +1503,7 @@ async def factoid_dealias_command(
interaction (discord.Interaction): The interaction that triggered this command
factoid_name (str): The factoid to dealias
"""
+ # TODO: Update edit time
factoid_name = factoid_name.lower()
factoid = await self.get_factoid_view_by_name(
guild=interaction.guild, name=factoid_name
@@ -1596,6 +1627,7 @@ async def factoid_edit_command(
interaction (discord.Interaction): The interaction that triggered this command
factoid_name (str): The factoid to edit
"""
+ # TODO: Update edit time
factoid_name = factoid_name.lower()
factoid = await self.get_factoid_view_by_name(
guild=interaction.guild, name=factoid_name
@@ -1725,6 +1757,109 @@ async def factoid_flush_command(
embed = auxiliary.prepare_confirm_embed("Factoid cache for this guild cleared")
await interaction.response.send_message(embed=embed)
+ @factoid_app_group.command(
+ name="info",
+ description="Gets information about a factoid and displays it to the user.",
+ )
+ @app_commands.autocomplete(factoid_name=factoid_autocomplete)
+ async def factoid_info_command(
+ self: Self,
+ interaction: discord.Interaction,
+ factoid_name: str,
+ ) -> None:
+ """This gets information about a given factoid from the database and displays it to the user
+
+ Args:
+ interaction (discord.Interaction): The interaction that triggered this command
+ factoid_name (str): The factoid name to display information for
+ """
+ # TODO: Interact with jobs
+ # TODO: Add embed/json buttons
+
+ factoid_name = factoid_name.lower()
+ factoid = await self.get_factoid_view_by_name(
+ guild=interaction.guild, name=factoid_name
+ )
+
+ # We can't get info from a factoid that doesn't exist
+ if not factoid:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` doesn't exist!"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ # Cache the factoid
+ self.add_to_cache(interaction.guild, factoid)
+
+ embed = discord.Embed(
+ title=f"Info about `{factoid_name}`", description=factoid.message
+ )
+ embed.add_field(name="Calls", value=f"`[{', '.join(factoid.calls)}]`")
+ embed.add_field(name="Time called", value=factoid.times_called)
+ embed.add_field(name="Embed", value=bool(factoid.json_string))
+
+ # Handle properties different to convert from into to string
+ properties_str = (
+ ", ".join(
+ prop.name.lower() for prop in Properties if factoid.flags & prop.value
+ )
+ or "None"
+ )
+ embed.add_field(name="Properties", value=properties_str)
+
+ embed.add_field(
+ name="Date of creation", value=f""
+ )
+ embed.add_field(
+ name="Last edit", value=f""
+ )
+
+ await interaction.response.send_message(embed=embed)
+
+ @factoid_app_group.command(
+ name="json",
+ description="Gets the json file for the embed of this factoid",
+ )
+ @app_commands.autocomplete(factoid_name=factoid_autocomplete)
+ async def factoid_json_command(
+ self: Self,
+ interaction: discord.Interaction,
+ factoid_name: str,
+ ) -> None:
+ """This gets information about a given factoid from the database and displays it to the user
+
+ Args:
+ interaction (discord.Interaction): The interaction that triggered this command
+ factoid_name (str): The factoid name to display information for
+ """
+ factoid_name = factoid_name.lower()
+ factoid = await self.get_factoid_view_by_name(
+ guild=interaction.guild, name=factoid_name
+ )
+
+ # We can't get info from a factoid that doesn't exist
+ if not factoid:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` doesn't exist!"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ # Cache the factoid
+ self.add_to_cache(interaction.guild, factoid)
+
+ if not factoid.json_string:
+ embed = auxiliary.prepare_deny_embed(
+ message=f"The factoid `{factoid_name}` doesn't have any embed configured!"
+ )
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ return
+
+ json_file = self.create_json_file(factoid)
+
+ await interaction.response.send_message(file=json_file)
+
class ButtonView(discord.ui.View):
# TODO: Migrate to LayoutView
From 26868c8a5105ef8a146ac295c9ef79e0704a19a4 Mon Sep 17 00:00:00 2001
From: ajax146 <31014239+ajax146@users.noreply.github.com>
Date: Wed, 17 Jun 2026 18:19:30 -0700
Subject: [PATCH 13/33] Start of loop system, loop create/delete
---
modules/operation/factoids.py | 352 ++++++++++++++++++++++++++++++++--
1 file changed, 333 insertions(+), 19 deletions(-)
diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py
index 44f1f7dc..50688f77 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -107,7 +107,6 @@ async def setup(bot: bot.TechSupportBot) -> None:
await bot.add_cog(FactoidManager(bot=bot))
-# TODO: Use this more
@dataclass
class FactoidView:
factoid_data_id: int
@@ -145,14 +144,137 @@ class FactoidManager(cogs.BaseCog):
description="Commands to create, manage and use the factoids system",
)
+ factoid_loop_commands: app_commands.Group = app_commands.Group(
+ name="loop",
+ description="Commands to create, view and manage the factoid loops system",
+ parent=factoid_app_group,
+ )
+
# PRECONFIG
async def preconfig(self: Self) -> None:
"""This sets up cache and job loop calls"""
# TODO: Factoid all cache
- # TODO: Loops
self.factoid_cache: dict[str, FactoidView] = {}
+ # Register the loop callback into APScheduler
+ self.bot.scheduler.register_task(
+ "factoid_loop",
+ self.execute_job,
+ )
+
+ # On bot startup, start all jobs
+ await self.startup_jobs()
+
+ # LOOP STUFF
+
+ async def startup_jobs(self: Self) -> None:
+ all_jobs = await self.get_all_global_jobs()
+ for job in all_jobs:
+ await self.register_job(job)
+
+ async def register_job(self: Self, job: bot.models.FactoidJob) -> None:
+ guild = self.bot.get_guild(int(job.guild))
+ # Do not register the job if extension is disabled
+ if not self.extension_enabled(guild=guild):
+ return
+
+ await self.bot.scheduler.schedule_cron(
+ task_name="factoid_loop",
+ cron=job.cron,
+ payload={"guild": guild, "job_id": job.factoid_job_id},
+ )
+
+ async def execute_job(
+ self: Self,
+ payload: dict,
+ ) -> None:
+
+ # Expand payload
+ guild: discord.Guild = payload["guild"]
+ factoid_job_id: int = payload["job_id"]
+
+ # Stop execution if factoids has been disabled
+ if not self.extension_enabled(guild=guild):
+ return
+
+ job_data = await self.read_factoid_job_by_id(guild, factoid_job_id)
+ if not job_data:
+ return
+ factoid = await self.get_factoid_view_by_id(guild, job_data.factoid_data_id)
+ if not factoid:
+ return
+
+ await self.register_job(job_data)
+
+ channel = self.bot.get_channel(int(job_data.channel))
+ if not channel:
+ return
+
+ # Check if factoid is disabled. If so, don't send it
+ if factoid.flags & Properties.DISABLED.value:
+ return
+
+ # Check if factoid is restricted. If so, check if we can call it
+ if (
+ factoid.flags & Properties.RESTRICTED.value
+ and not self.can_channel_send_restricted(channel)
+ ):
+ return
+
+ embed, plaintext_content = await self.generate_sendable_factoid(guild, factoid)
+
+ # Log in the background
+ asyncio.create_task(
+ self.log_factoid_send(
+ guild=guild,
+ channel=channel,
+ sender=guild.me,
+ factoid=factoid,
+ )
+ )
+
+ embed_sent = False
+ if embed:
+ try:
+ # Attempt to send the message with the embed in it
+ sent_message = await channel.send(embed=embed)
+ embed_sent = True
+ # If something breaks, also log it
+ except discord.errors.HTTPException as exception:
+ log_channel = configuration.get_config_entry(
+ guild.id, "core_logging_channel"
+ )
+ await self.bot.logger.send_log(
+ message=(
+ f"Unable to send embed for factoid `[{", ".join(factoid.calls)}]`, "
+ "sending fallback."
+ ),
+ level=LogLevel.ERROR,
+ context=LogContext(guild=guild, channel=channel),
+ channel=log_channel,
+ exception=exception,
+ )
+
+ # Either no embed exists, or the embed failed to send for some reason.
+ # We will send the plaintext content of the factoid in this case
+ if not embed_sent:
+ content = plaintext_content.strip()
+ if len(content) > 2000:
+ return
+
+ sent_message = await channel.send(content=content)
+ # IRC connection
+ self.send_factoid_to_irc(channel, factoid, guild.me)
+
+ # Logger connection
+ await self.send_factoid_to_logger(
+ sent_message, guild.me, channel, factoid.message
+ )
+
+ # Increase times called
+ await self.increment_times_called_by_view(guild=guild, factoid=factoid)
+
# DATABASE CALLS
async def create_factoid_call(
@@ -269,11 +391,11 @@ async def read_factoid_data(
"""Searches the database for a factoid data for the passed guild
Args:
- guild (discord.Guild): The guild to find the factoid call of
+ guild (discord.Guild): The guild to find the factoid data of
factoid_data_id (int): The ID of the factoid to search for
Returns:
- bot.models.FactoidData: The database entry for the factoid call
+ bot.models.FactoidData: The database entry for the factoid data
"""
return await self.bot.models.FactoidData.query.where(
@@ -332,6 +454,77 @@ async def delete_factoid_call(
& (self.bot.models.FactoidCall.name == name)
).gino.status()
+ async def create_factoid_job(
+ self: Self,
+ guild: discord.Guild,
+ factoid_data_id: int,
+ channel: discord.abc.GuildChannel,
+ cron: str,
+ ) -> bot.models.FactoidJob:
+ """Creates a new FactoidJob entry in the table
+
+ Args:
+ guild (discord.Guild): The guild to create this factoid for
+ message (str): The plaintext version of the factoid
+ json_string (str): The json for this factoid
+ flags (int): The property binary flags for this factoid
+
+ Returns:
+ bot.models.FactoidJob: The newly created database entry
+ """
+
+ return await self.bot.models.FactoidJob.create(
+ guild=str(guild.id),
+ factoid_data_id=factoid_data_id,
+ channel=str(channel.id),
+ cron=cron,
+ )
+
+ async def read_factoid_job_by_id(
+ self: Self,
+ guild: discord.Guild,
+ factoid_job_id: int,
+ ) -> bot.models.FactoidJob:
+ """Searches the database for a factoid job for the passed guild
+
+ Args:
+ guild (discord.Guild): The guild to find the factoid job of
+ factoid_job_id (int): The ID of the factoid to search for
+
+ Returns:
+ bot.models.FactoidJob: The database entry for the factoid job
+ """
+
+ return await self.bot.models.FactoidJob.query.where(
+ (self.bot.models.FactoidJob.guild == str(guild.id))
+ & (self.bot.models.FactoidJob.factoid_job_id == factoid_job_id)
+ ).gino.first()
+
+ async def read_factoid_job_by_channel(
+ self: Self,
+ guild: discord.Guild,
+ factoid_data_id: int,
+ channel: discord.abc.GuildChannel,
+ ) -> bot.models.FactoidJob:
+ """Searches the database for a factoid job for the passed guild
+
+ Args:
+ guild (discord.Guild): The guild to find the factoid job of
+ factoid_job_id (int): The ID of the factoid to search for
+
+ Returns:
+ bot.models.FactoidJob: The database entry for the factoid job
+ """
+
+ return await self.bot.models.FactoidJob.query.where(
+ (self.bot.models.FactoidJob.guild == str(guild.id))
+ & (self.bot.models.FactoidJob.factoid_data_id == factoid_data_id)
+ & (self.bot.models.FactoidJob.channel == str(channel.id))
+ ).gino.first()
+
+ async def get_all_global_jobs(self: Self) -> list[bot.models.FactoidJob]:
+ return await self.bot.models.FactoidJob.query.gino.all()
+
async def get_all_factoid_data(
self: Self,
guild: discord.Guild,
@@ -385,13 +578,18 @@ async def get_factoid_view_by_name(
if call is None:
return None
- cached_data = self.get_from_cache(guild, call.factoid_data_id)
+ return await self.get_factoid_view_by_id(guild, call.factoid_data_id)
+
+ async def get_factoid_view_by_id(
+ self: Self, guild: discord.Guild, factoid_data_id: int
+ ) -> FactoidView | None:
+ cached_data = self.get_from_cache(guild, factoid_data_id)
if cached_data:
return cached_data
factoid_data = await self.read_factoid_data(
guild=guild,
- factoid_data_id=call.factoid_data_id,
+ factoid_data_id=factoid_data_id,
)
if factoid_data is None:
@@ -402,7 +600,7 @@ async def get_factoid_view_by_name(
factoid_data_id=factoid_data.factoid_data_id,
)
- return FactoidView(
+ factoid = FactoidView(
factoid_data_id=factoid_data.factoid_data_id,
message=factoid_data.message,
json_string=factoid_data.json_string,
@@ -413,6 +611,10 @@ async def get_factoid_view_by_name(
calls=sorted(factoid_call.name for factoid_call in factoid_calls),
)
+ self.add_to_cache(guild, factoid)
+
+ return factoid
+
async def delete_factoid_call_by_name(
self: Self,
guild: discord.Guild,
@@ -693,7 +895,7 @@ async def build_factoid_all(
"""
if use_file:
- return await self.generate_factoid_all_file(guild, factoids)
+ return self.generate_factoid_all_file(guild, factoids)
try:
html = await self.generate_factoid_all_html(guild, factoids)
@@ -732,7 +934,7 @@ async def build_factoid_all(
exception=exception,
)
- return await self.generate_factoid_all_file(guild, factoids)
+ return self.generate_factoid_all_file(guild, factoids)
async def generate_factoid_all_html(
self: Self,
@@ -794,7 +996,7 @@ async def generate_factoid_all_html(