diff --git a/.pylintrc b/.pylintrc
index 00ac6ae2..70585b04 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -151,7 +151,8 @@ spelling-store-unknown-words=no
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
- TODO
+ TODO,
+ BUG
[TYPECHECK]
@@ -289,7 +290,8 @@ indent-after-paren=4
indent-string=' '
# Maximum number of characters on a single line.
-max-line-length=100
+# Following the standards set forth by black, this will be removed
+max-line-length=100000000
# Maximum number of lines in a module.
max-module-lines=1000
diff --git a/changelog.md b/changelog.md
index f76e612c..605785d9 100644
--- a/changelog.md
+++ b/changelog.md
@@ -28,8 +28,16 @@ Changes since 2026.06.15
## Operation
### Factoid
-- Make /factoid call work with factoids with spaces
-- Fix permissions on /factoid add
+- Complete migration to application commands
+- Factoids are now allowed to use spaces
+- /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
+- /factoid dealias now shows the remaining aliases on success
### Relay
- Make relay only ping users with words starting with an @
diff --git a/configuration/config.default.json b/configuration/config.default.json
index b66b1361..46971980 100644
--- a/configuration/config.default.json
+++ b/configuration/config.default.json
@@ -40,6 +40,7 @@
"embed_embed_roles": [],
"factoids_admin_roles": [],
"factoids_disable_embeds": false,
+ "factoids_disable_prefix": true,
"factoids_manage_roles": [],
"factoids_prefix": "?",
"factoids_restricted_list": [],
diff --git a/configuration/config.meta.json b/configuration/config.meta.json
index 27f158bf..6cf5a136 100644
--- a/configuration/config.meta.json
+++ b/configuration/config.meta.json
@@ -163,6 +163,10 @@
"datatype": "bool",
"description": "This will force all factoids to not use embeds."
},
+ "factoids_disable_prefix": {
+ "datatype": "bool",
+ "description": "Whether or not to disable calling factoids by a prefix command."
+ },
"factoids_manage_roles": {
"datatype": "list[discord.Role]",
"description": "The roles required to manage factoids"
diff --git a/core/databases.py b/core/databases.py
index 98a16a90..6362f165 100644
--- a/core/databases.py
+++ b/core/databases.py
@@ -127,57 +127,90 @@ 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):
+ """
+ FactoidData is the data of the factoid, content displayed and how its displayed
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): The data ID of the factoid
+ guild (str): The ID of the guild this factoid belongs to
+ message (str): The plaintext message of this factoid
+ create_time (datetime): The time this factoid was created in the database
+ edit_time (datetime): The last time this factoid was edited in any way
+ json_string (str): The JSON of an embed, parsed into a string
+ flags (int): A bitwise value of the property flags of this factoid
+ timed_called (int): The amount of times this factoid was called
"""
- __tablename__ = "factoids"
+ __tablename__ = "factoid_data"
- factoid_id: int = bot.db.Column(bot.db.Integer, primary_key=True)
- name: str = bot.db.Column(bot.db.String)
- guild: str = bot.db.Column(bot.db.String)
+ 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)
- time: datetime.datetime = bot.db.Column(
+ create_time: datetime.datetime = bot.db.Column(
bot.db.DateTime, default=datetime.datetime.utcnow
)
- 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)
+ edit_time: datetime.datetime = bot.db.Column(
+ bot.db.DateTime,
+ default=datetime.datetime.utcnow,
+ )
+ json_string: str = bot.db.Column(bot.db.String, default=None)
+ flags: int = bot.db.Column(bot.db.Integer, default=0)
+ times_called: int = bot.db.Column(bot.db.Integer, default=0)
+
+ class FactoidCall(bot.db.Model):
+ """
+ FactoidCall is the name of a factoid. All FactoidCall's are tied to a FactoidData
+
+ Attributes:
+ factoid_call_id (int): This is the ID of the call
+ guild (str): This is the ID of the guild this factoid belongs to
+ name (str): This is the name, used and displayed to users
+ factoid_data_id (object): The linked foreign ID for the related FactoidData
+ """
+
+ __tablename__ = "factoid_calls"
+
+ __table_args__ = (
+ bot.db.UniqueConstraint(
+ "guild", "name", name="uq_factoid_calls_guild_name"
+ ),
+ )
+
+ 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, index=True)
+
+ factoid_data_id = bot.db.Column(
+ bot.db.Integer,
+ bot.db.ForeignKey("factoid_data.factoid_data_id"),
+ nullable=False,
+ index=True,
+ )
class FactoidJob(bot.db.Model):
- """The postgres table for factoid loops
- Currently used in factoid.py
+ """
+ FactoidJob is a database entry that represents loop jobs for factoids
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
+ factoid_job_id (int): This is the ID of the job
+ guild (str): This is the ID of the guild this job belongs to
+ factoid_data_id (object): The linked foreign ID for the related FactoidData
+ channel (str): This is the ID of the channel the loop should execute in
+ cron (str): This is the cron syntax for the job
"""
__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 +420,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/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..b340b201 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,19 @@ 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
+ ) -> None:
+ """This processes a factoid into a valid IRC message, attributed to the correct autho
+
+ Args:
+ channel (str): The IRC channel to send the message to
+ factoid (factoids.FactoidView): The factoid that was sent
+ author (discord.Member): The author who sent the message
+ """
+ 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 587a7895..57668a57 100644
--- a/modules/operation/factoids.py
+++ b/modules/operation/factoids.py
@@ -1,80 +1,46 @@
-"""
-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
+"""This holds all commands, database helpers, functions, listeners and more to run the factoids system
+This module is almost entirely all application command
+
+A legacy prefix way to call factoids is maintained here for the time being
+Barring discord forcing this to be shut down, this will be maintained until at least summer 2027
"""
from __future__ import annotations
import asyncio
import datetime
+import hashlib
import io
import json
import re
from dataclasses import dataclass
-from enum import Enum
+from enum import IntFlag
from socket import gaierror
from typing import TYPE_CHECKING, Self
+from urllib.parse import urlsplit, urlunsplit
-import aiocron
import discord
import expiringdict
-import munch
import yaml
from aiohttp.client_exceptions import InvalidURL
+from apscheduler.triggers.cron import CronTrigger
from discord import app_commands
-from discord.ext import commands
+from discord.ext import commands # Added only to support legacy prefix calls
import configuration
import ui
from botlogging import LogContext, LogLevel
-from core import auxiliary, cogs, custom_errors
+from core import auxiliary, cogs
from modules.moderation import logger as function_logger
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 +55,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"),
)
@@ -117,7 +83,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:
@@ -125,2860 +91,3063 @@ async def has_given_factoids_role(
"""
factoid_roles = []
# Gets permitted roles
- for name in check_roles:
- factoid_role = discord.utils.get(guild.roles, name=name)
+ for id in check_roles:
+ factoid_role = guild.get_role(int(id))
if not factoid_role:
continue
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
+async def setup(bot: bot.TechSupportBot) -> None:
+ """Loading the Factoid plugin into the bot
- 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
+ Args:
+ bot (bot.TechSupportBot): The bot to register the cog to
+ """
+ await bot.add_cog(FactoidManager(bot=bot))
+
+
+@dataclass
+class FactoidView:
+ """The FactoidView is a parsed combination of the FactoidData and FactoidCall databases
+ The FactoidView contains all the calls, in a list, as well as all the data
"""
- original_call_str: str
- factoid_db_entry: bot.models.Factoid
+ 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):
+class Properties(IntFlag):
"""
This enum is for the new factoid all to be able to handle dynamic properties
Attributes:
- HIDDEN (str): Representation of hidden
- DISABLED (str): Representation of disabled
- RESTRICTED (str): Representation of restricted
- PROTECTED (str): Representation of protected
+ DISABLED (int): Representation of disabled
+ HIDDEN (int): Representation of hidden
+ PROTECTED (int): Representation of protected
+ RESTRICTED (int): Representation of restricted
"""
- HIDDEN: str = "hidden"
- DISABLED: str = "disabled"
- RESTRICTED: str = "restricted"
- PROTECTED: str = "protected"
+ DISABLED: int = 0b1000
+ HIDDEN: int = 0b0100
+ PROTECTED: int = 0b0010
+ RESTRICTED: int = 0b0001
-class FactoidManager(cogs.MatchCog):
+class FactoidManager(cogs.BaseCog):
+ """This is the cog for factoids
+ It is what is registered into the bot and has access to self.bot
"""
- 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
- """
-
- 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_app_group: app_commands.Group = app_commands.Group(
+ name="factoid",
+ description="Commands to create, manage and use the factoids system",
)
- factoid_app_group: app_commands.Group = app_commands.Group(
- name="factoid", description="Command Group for the Factoids Extension"
+ 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:
- """Preconfig for factoid jobs"""
+ """This sets up cache and job loop calls"""
self.factoid_cache = expiringdict.ExpiringDict(
- max_len=100, max_age_seconds=1200
+ max_len=500,
+ max_age_seconds=82800,
)
- # set a hard time limit on repeated cronjob DB calls
- self.running_jobs = {}
+
+ # Factoid all cache setup to save links for 23 hours, to avoid links that are close to expiring from being presented
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,
+ max_len=50,
+ max_age_seconds=82800,
)
- await self.kickoff_jobs()
- # -- DB calls --
- async def delete_factoid_call(
- self: Self, factoid: bot.models.Factoid, guild: str
- ) -> None:
- """Calls the db to delete a factoid
+ # Autocomplete cache to avoid constant DB calls
+ # guild ID: list[(name, flags)]
+ self.factoid_autocomplete_cache: dict[int, list[tuple[str, int]]] = {}
- 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]
+ # Register the loop callback into APScheduler
+ self.bot.scheduler.register_task(
+ "factoid_loop",
+ self.execute_job,
+ )
- # 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()
+ # On bot startup, start all jobs
+ await self.startup_jobs()
- # Removes it from the cache
- del self.running_jobs[job_id]
+ # LOOP STUFF
- # Removes the DB entry
- await job.delete()
+ async def startup_jobs(self: Self) -> None:
+ """This registers all jobs at once, ideally run when the bot restarts
+ All jobs in all guilds are read with this
+ """
+ all_jobs = await self.bot.models.FactoidJob.query.gino.all()
+ for job in all_jobs:
+ await self.register_job(job)
- await self.handle_cache(guild, factoid.name)
- await factoid.delete()
+ async def register_job(self: Self, job: bot.models.FactoidJob) -> None:
+ """This registers a FactoidJob with apscheduler, to allow it to be automatically called
- async def create_factoid_call(
+ Args:
+ job (bot.models.FactoidJob): The job database entry to schedule
+ """
+ guild = self.bot.get_guild(int(job.guild))
+ 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,
- factoid_name: str,
- guild: str,
- message: str,
- embed_config: str,
- alias: str = None,
- properties: list[bool] = None,
+ payload: dict,
) -> None:
- """Calls the DB to create a factoid
+ """Called when its time to run a job
+ The payload in this case contains the guild and the job_id
+ This is called by apscheduler
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
-
- Raises:
- TooLongFactoidMessageError:
- When the message argument is over 2k chars, discords limit
+ payload (dict): The payload of the job to execute, stored by apscheduler
"""
- if not properties:
- properties = [False, False, False, False]
+ # Expand payload
+ guild: discord.Guild = payload["guild"]
+ factoid_job_id: int = payload["job_id"]
+ job_data = 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()
+ if not job_data:
+ return
+ factoid = await self.get_factoid_view_by_id(guild, job_data.factoid_data_id)
+ if not factoid:
+ return
- if len(message) > 2000:
- raise custom_errors.TooLongFactoidMessageError
+ # If the FactoidJob and FactoidData entry both exist, the job is valid. We reschedule here to prevent a different error causing the job to be shadow cancelled
+ await self.register_job(job_data)
- # Removes the `factoid all` cache since it has become outdated
- if guild in self.factoid_all_cache:
- del self.factoid_all_cache[guild]
+ # If factoids has been disabled, don't execute the job
+ # We have already rescheduled it, so it will check again later
+ if not self.extension_enabled(guild=guild):
+ return
- 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],
- )
+ # If for some reason we cannot find the channel, don't bother trying to execute
+ channel = self.bot.get_channel(int(job_data.channel))
+ if not channel:
+ return
- await factoid.create()
+ # Check if factoid is disabled. If so, don't send it
+ if factoid.flags & Properties.DISABLED:
+ return
- async def modify_factoid_call(
- self: Self,
- factoid: bot.models.Factoid,
- ) -> None:
- """Makes a DB call to modify a factoid
+ # Check if factoid is restricted. If so, check if we can call it
+ if (
+ factoid.flags & Properties.RESTRICTED
+ and not self.can_channel_send_restricted(channel)
+ ):
+ return
- Args:
- factoid (bot.models.Factoid): Factoid to modify.
+ embed, plaintext_content = await self.generate_sendable_factoid(guild, factoid)
- Raises:
- TooLongFactoidMessageError:
- When the message argument is over 2k chars, discords limit
- """
- if len(factoid.message) > 2000:
- raise custom_errors.TooLongFactoidMessageError
+ last_message = await channel.fetch_message(channel.last_message_id)
+ embed_sent = False
+ sent_message = None
+ if embed:
+ try:
+ # If the bot wrote the last message, and its the same as the current job, do nothing
+ if last_message.embeds:
+ old_embed_hash = self.compute_embed_hash(last_message.embeds[0])
+ new_embed_hash = self.compute_embed_hash(embed)
+ if (
+ last_message.author == guild.me
+ and new_embed_hash == old_embed_hash
+ ):
+ return
+
+ # 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:
+ asyncio.create_task(
+ self.log_embed_fallback_exception(
+ factoid=factoid,
+ exception=exception,
+ guild=guild,
+ channel=channel,
+ )
+ )
+
+ # 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
- # 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]
+ # If the bot wrote the last message, and its the same as the current job, do nothing
+ if last_message.author == guild.me and last_message.content == content:
+ return
- 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()
+ sent_message = await channel.send(content=content)
- await self.handle_cache(factoid.guild, factoid.name)
+ # Log in the background
+ asyncio.create_task(
+ self.log_factoid_send(
+ guild=guild,
+ channel=channel,
+ sender=guild.me,
+ factoid=factoid,
+ )
+ )
- # -- Utility --
- async def confirm_factoid_deletion(
+ if not sent_message:
+ # This is a major error, and should never happen
+ await self.logger.send_log(
+ message="Factoid sent_message not found. Critical failure",
+ level=LogLevel.ERROR,
+ context=LogContext(guild=guild, channel=channel),
+ )
+ return
+
+ # 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)
+
+ async def unschedule_job(
self: Self,
- factoid_name: str,
- channel: discord.abc.GuildChannel,
- author: discord.Member,
- fmt: str,
- ) -> bool:
- """Confirms if a factoid should be deleted/modified
+ job: bot.models.FactoidJob,
+ ) -> None:
+ """This removes the passed FactoidJob from the APScheduler queue
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
-
- Returns:
- bool: Whether the factoid was deleted/modified
+ job (bot.models.FactoidJob): The job to remove from the queue
"""
- view = ui.Confirm()
- await view.send(
- message=(
- f"The factoid `{factoid_name}` already exists. Should I overwrite it?"
- ),
- channel=channel,
- author=author,
- )
+ for scheduled_job in await self.bot.scheduler.get_upcoming_tasks():
+ payload = scheduled_job["payload"]
- await view.wait()
- if view.value is ui.ConfirmResponse.TIMEOUT:
- return False
+ job_id = scheduled_job["job_id"]
- 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
+ # Extract task name from APScheduler job ID
+ task_name = job_id.split(":", 1)[0]
- return True
+ # Ignore unrelated scheduled tasks
+ if task_name != "factoid_loop":
+ continue
- async def check_valid_factoid_contents(
- self: Self, ctx: commands.Context, factoid_name: str, message: str
- ) -> str:
- """Makes sure the factoid contents are valid
+ if (
+ payload.get("job_id") == job.factoid_job_id
+ and str(payload.get("guild").id) == job.guild
+ ):
+ self.bot.scheduler.scheduler.remove_job(job_id)
+
+ # DATABASE CALLS
+
+ 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:
- 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
+ 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
Returns:
- str: The error message
+ bot.models.FactoidCall: The newly created database entry
"""
- # 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!"
-
- # Prevents factoids being created with spaces
- if " " in factoid_name:
- return "Cannot create factoids with names that contain spaces!"
-
- return None
+ return await self.bot.models.FactoidCall.create(
+ guild=str(guild.id),
+ name=name,
+ factoid_data_id=factoid_data_id,
+ )
- 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 read_factoid_call(
+ self: Self,
+ guild: discord.Guild,
+ name: str,
+ ) -> bot.models.FactoidCall:
+ """Searches the database for a factoid call for the passed guild
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
+ guild (discord.Guild): The guild to find the factoid call of
+ name (str): The name of the factoid to search for
+
+ Returns:
+ bot.models.FactoidCall: The database entry for the factoid call
"""
- 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)
+ 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 check_alias_recursion(
+ async def read_factoid_data(
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,
+ factoid_data_id: int,
+ ) -> bot.models.FactoidData:
+ """Searches the database for a factoid data for the passed guild
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 find the factoid data of
+ factoid_data_id (int): The ID of the factoid to search for
Returns:
- bool: Whether the alias recurses
+ bot.models.FactoidData: The database entry for the factoid data
"""
- # 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()
- )
+ 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()
- # 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
+ 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,
+ )
- # 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
+ update_values = {}
- return False
+ if message is not None:
+ update_values["message"] = message
- def get_embed_from_factoid(
- self: Self, factoid: bot.models.Factoid
- ) -> discord.Embed:
- """Gets the factoid embed from its message.
+ if edit_time is not None:
+ update_values["edit_time"] = edit_time
- Args:
- factoid (bot.models.Factoid): The factoid to get the json of
+ if flags is not None:
+ update_values["flags"] = flags
- Returns:
- discord.Embed: The embed of the factoid
- """
- if not factoid.embed_config:
- return None
+ if times_called is not None:
+ update_values["times_called"] = times_called
- embed_config = json.loads(factoid.embed_config)
+ if json_string is not None:
+ update_values["json_string"] = json_string
- return discord.Embed.from_dict(embed_config)
+ if update_values:
+ await db_entry.update(**update_values).apply()
- # -- Cache functions --
- async def handle_cache(self: Self, guild: str, factoid_name: str) -> None:
- """Deletes factoid from the factoid cache
+ return db_entry
- 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)
+ async def delete_factoid_call(
+ self: Self,
+ guild: discord.Guild,
+ name: str,
+ ) -> None:
+ """Deletes a factoid call by name."""
- if key in self.factoid_cache:
- del self.factoid_cache[key]
+ await self.bot.models.FactoidCall.delete.where(
+ (self.bot.models.FactoidCall.guild == str(guild.id))
+ & (self.bot.models.FactoidCall.name == name)
+ ).gino.status()
- def get_cache_key(self: Self, guild: str, factoid_name: str) -> str:
- """Gets the cache key for a guild
+ 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 (str): The ID of the guild
- factoid_name (str): The name of the factoid
+ guild (discord.Guild): The guild to find the factoid job of
+ factoid_job_id (int): The ID of the factoid to search for
Returns:
- str: The cache key
+ bot.models.FactoidJob: The database entry for the factoid job
"""
- 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
+ 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_jobs_for_guild(
+ self: Self, guild: discord.Guild
+ ) -> list[bot.models.FactoidJob]:
+ """This returns raw database entries for all jobs for a given guild
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.
+ self (Self): _description_
+ guild (discord.Guild): The guild to search for
Returns:
- list: List of factoids
- """
- # 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()
- )
+ list[bot.models.FactoidJob]: The list of all database entries of factoid jobs
+ """
+ return await self.bot.models.FactoidJob.query.where(
+ (self.bot.models.FactoidJob.guild == str(guild.id))
+ ).gino.all()
- # Gets ALL factoids for ALL guilds
- else:
- factoids = await self.bot.db.all(self.bot.models.Factoid.query)
+ async def get_factoid_calls_by_factoid_id(
+ self: Self,
+ guild: discord.Guild,
+ factoid_data_id: int,
+ ) -> list[bot.models.FactoidCall]:
+ """Returns all calls pointing to a factoid."""
- # Sorts them alphabetically
- if factoids:
- factoids.sort(key=lambda factoid: factoid.name)
+ 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()
+
+ async def get_factoid_jobs_by_factoid_id(
+ self: Self,
+ guild: discord.Guild,
+ factoid_data_id: int,
+ ) -> list[bot.models.FactoidJob]:
+ """Returns all jobs pointing to a factoid."""
- return factoids
+ 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)
+ ).gino.all()
- 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
+ # DATABASE HELPERS
- Args:
- factoid_name (str): The name of the factoid to get
- guild (str): The id of the guild for the factoid
+ async def get_factoid_view_by_name(
+ self: Self,
+ guild: discord.Guild,
+ name: str,
+ ) -> FactoidView | None:
+ """Searches for the factoid associated with a given factoid name.
- Raises:
- FactoidNotFoundError: Raised when the provided factoid doesn't exist
+ Args:
+ guild (discord.Guild): The guild to look for the factoid in
+ name (str): The name of the factoid to lookup
Returns:
- bot.models.Factoid: The factoid
+ FactoidView | None: The factoid view, if found
"""
- 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 the factoid doesn't exist
- if not factoid:
- raise custom_errors.FactoidNotFoundError(factoid=factoid_name)
+ call = await self.read_factoid_call(
+ guild=guild,
+ name=name,
+ )
- # Caches it
- self.factoid_cache[cache_key] = factoid
+ if call is None:
+ return None
- return factoid
+ return await self.get_factoid_view_by_id(guild, call.factoid_data_id)
- async def get_factoid(
- self: Self, factoid_name: str, guild: str
- ) -> bot.models.Factoid:
- """Gets the factoid from the DB, follows aliases
+ async def get_factoid_view_by_id(
+ self: Self, guild: discord.Guild, factoid_data_id: int
+ ) -> FactoidView | None:
+ """This gets a factoid view for a given factoid ID
+ This attempts to fetch the factoid from cache, but will query the database if needed
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
+ guild (discord.Guild): The guild the factoid belongs in
+ factoid_data_id (int): The ID of the factoid to fetch
Returns:
- bot.models.Factoid: The factoid
+ FactoidView | None: The factoid view of the factoid, if the factoid exists
"""
- factoid = await self.get_raw_factoid_entry(factoid_name, guild)
+ 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=factoid_data_id,
+ )
- # 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 factoid_data is None:
+ return None
- if not factoid:
- raise custom_errors.FactoidNotFoundError(factoid=factoid_name)
+ factoid_calls = await self.get_factoid_calls_by_factoid_id(
+ guild=guild,
+ factoid_data_id=factoid_data.factoid_data_id,
+ )
+
+ factoid = 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),
+ )
+
+ self.add_to_cache(guild, factoid)
return factoid
- 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
+ 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:
- factoid_to_search (str): The name of the factoid to search for aliases of
- guild (str): The guild to search for factoids in
+ guild (discord.Guild): The guild the factoid to delete is in
+ id (int): The ID of the factoid to delete
Returns:
- list[str]: The list of all ways to call the factoid, including what was passed
+ bool: Whether or not this was successful
"""
- 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)
+ data = await self.read_factoid_data(guild, id)
+ calls = await self.get_factoid_calls_by_factoid_id(guild, id)
+ jobs = await self.get_factoid_jobs_by_factoid_id(guild, id)
+
+ if not data:
+ return False
+
+ for call in calls:
+ await call.delete()
- # -- Adding and removing factoids --
+ for job in jobs:
+ # We need to clear the job from both the database and APScheduler
+ await self.unschedule_job(job)
+ await job.delete()
+
+ await data.delete()
+ return True
- async def add_factoid(
+ async def move_factoid_call(
self: Self,
- channel: discord.abc.Messageable,
- author: discord.Member,
- 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
+ guild: discord.Guild,
+ existing_name: str,
+ new_factoid_data_id: int,
+ ) -> bool:
+ """
+ Moves a FactoidCall to a different FactoidData entry.
- 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.
+ If the old FactoidData loses all calls, it is deleted.
+ Returns True if the move succeeded.
"""
- fmt = "added" # Changes to modified, used for the returned message
- name = factoid_name # Name if the factoid doesn't exist
+ call = await self.read_factoid_call(
+ guild=guild,
+ name=existing_name,
+ )
- # 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
+ if call is None:
+ return False
- await self.create_factoid_call(
- factoid_name=name,
- guild=guild,
- message=message,
- embed_config=embed_config,
- alias=alias,
- )
+ old_factoid_data_id = call.factoid_data_id
- # 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
+ # 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()
- # 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,
+ # 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,
)
- async def delete_factoid(
- self: Self, ctx: commands.Context, called_factoid: CalledFactoid
- ) -> bool:
- """Deletes a factoid with confirmation
+ # If there aren't any calls, prevent having orphaned factoids in the database at all
+ if not remaining_calls:
+ await self.delete_factoid_data_by_id(
+ guild=guild,
+ id=old_factoid_data_id,
+ )
+
+ return True
+
+ async def get_all_factoids_for_guild(
+ self: Self,
+ guild: discord.Guild,
+ ) -> list[FactoidView]:
+ """Generates a list of every factoid in the guild
+ This converts the Data/Call entries into the FactoidView objects
Args:
- ctx (commands.Context): Context to send the confirmation message to
- called_factoid (CalledFactoid): The factoid to remove
+ guild (discord.Guild): The guild to fetch the factoids for
Returns:
- bool: Whether the factoid was deleted
+ list[FactoidView]: The list of all the processed factoids as view objects
"""
- 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)
+ factoid_data = await self.bot.models.FactoidData.query.where(
+ self.bot.models.FactoidData.guild == str(guild.id)
+ ).gino.all()
+ factoid_calls = await self.bot.models.FactoidCall.query.where(
+ self.bot.models.FactoidCall.guild == str(guild.id)
+ ).gino.all()
- 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"
+ 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,
+ [],
+ )
+ ),
+ )
+ )
- send_message += ". Are you sure?"
+ return views
- view = ui.Confirm()
- await view.send(
- message=send_message,
- channel=ctx.channel,
- author=ctx.author,
+ async def increment_times_called_by_view(
+ self: Self, guild: discord.Guild, factoid: FactoidView
+ ) -> None:
+ """This will update the times called for a factoid
+ This writes to the database and updates the cache
+
+ Args:
+ self (Self): _description_
+ guild (discord.Guild): The guild the factoid belongs to
+ factoid (FactoidView): The factoid view fo the factoid that was called to increment
+ """
+ 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)
- await view.wait()
- if view.value is ui.ConfirmResponse.TIMEOUT:
- return False
+ async def handle_factoid_edit(
+ self: Self, guild: discord.Guild, factoid: FactoidView
+ ) -> None:
+ """Sets the edit time of the factoid to now
+ This also clears the factoid and factoid all cache after the edits
- 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
+ Args:
+ guild (discord.Guild): The guild this factoid is in
+ factoid (FactoidView): The factoid to edit
+ """
+ await self.update_factoid_data(
+ guild=guild,
+ factoid_data_id=factoid.factoid_data_id,
+ edit_time=datetime.datetime.utcnow(),
+ )
- await self.delete_factoid_call(factoid, str(ctx.guild.id))
+ # Make sure the edited factoid is not in the cache
+ self.remove_from_cache(guild, factoid)
- # 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})`"
+ # Clear factoid all and factoid autocomplete caches
+ self.clear_guild_caches(guild)
- await auxiliary.send_confirm_embed(message=confirm_message, channel=ctx.channel)
- return True
+ # CACHE HELPERS
- # -- 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
+ def add_to_cache(self: Self, guild: discord.Guild, factoid: FactoidView) -> None:
+ """This adds a factoid to the cache, if it isn't already in the cache
Args:
- ctx (commands.Context): The context of which the message was sent
- message_contents (str): The message to check
-
- Returns:
- bool: Whether the message starts with the prefix or not
+ self (Self): _description_
+ guild (discord.Guild): The guild the factoid is in
+ factoid (FactoidView): The factoid view of the factoid to add to the cache
"""
- if not ctx.guild:
- return
- return message_contents.startswith(
- configuration.get_config_entry(ctx.guild.id, "factoids_prefix")
- )
+ 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
- async def response(
- self: Self,
- ctx: commands.Context,
- message_content: str,
- _: bool,
+ def remove_from_cache(
+ self: Self, guild: discord.Guild, factoid: FactoidView
) -> None:
- """Responds to a factoid call
+ """This removes the factoid from cache, if it exists in the cache
Args:
- ctx (commands.Context): Context of the call
- message_content (str): Content of the call
+ guild (discord.Guild): The guild the factoid belongs to
+ factoid (FactoidView): The factoid view object of the factoid to remove from the cache
+ """
+ cache_key = self.generate_cache_key(guild, factoid.factoid_data_id)
+ 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
+ ) -> FactoidView | None:
+ """This attempts to fetch a factoid from the cache
- Raises:
- TooLongFactoidMessageError:
- Raised when the raw message content is over discords 2000 char limit
+ Args:
+ guild (discord.Guild): The guild to search for factoids in
+ factoid_id (int): The ID of the factoid to search
+
+ Returns:
+ FactoidView | None: The view object, if the factoid was cached
"""
- 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))
+ cache_key = self.generate_cache_key(guild, factoid_id)
+ if cache_key in self.factoid_cache:
+ return self.factoid_cache[cache_key]
+ return None
- 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),
- )
- return
+ def generate_cache_key(self: Self, guild: discord.Guild, factoid_id: int) -> str:
+ """This generates a key to use to determine if a specific factoid is cached
- # Checking for disabled or restricted
- if factoid.disabled:
- return
+ Args:
+ guild (discord.Guild): The guild the factoid is in
+ factoid_id (int): The factoid ID from the FactoidData database
- if factoid.restricted:
- channel = ctx.channel
- restricted_list = configuration.get_config_entry(
- ctx.guild.id, "factoids_restricted_list"
- )
- 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
+ Returns:
+ str: The string to use for the cache system
+ """
+ return f"{guild.id}:{factoid_id}"
- 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=ctx.guild, channel=ctx.channel),
- exception=exception,
- )
- embed = None
+ def clear_guild_caches(self: Self, guild: discord.Guild) -> None:
+ """This clears the guild wide caches, being factoid all and factoid autocomplete
- # 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)
+ Args:
+ guild (discord.Guild): The guild to clear the cache for
+ """
+ # This theoretically could be made better by being more targetted and efficient
+ # There are a lot of cases where only some of this cache should be deleted
+ # We delete it all anyway to avoid bugs
- 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
+ # Clearing factoid all cache for this guild
+ for entry in list(self.factoid_all_cache.keys()):
+ if entry[0] == guild.id:
+ del self.factoid_all_cache[entry]
- 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,
- )
+ # clearing factoid autocomplete cache for this guild
+ if guild.id in self.factoid_autocomplete_cache:
+ del self.factoid_autocomplete_cache[guild.id]
- await self.send_to_irc(ctx.channel, ctx.message, factoid.message)
- await self.send_to_logger(
- sent_message, ctx.author, ctx.channel, factoid.message
- )
+ # OTHER HELPERS
- 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
+ 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:
- 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
+ self (Self): _description_
+ channel (discord.abc.GuildChannel): The channel trying to see the factoid
+
+ Returns:
+ bool: Whether the restricted factoid can be sent
"""
- # 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
+ if isinstance(channel, discord.Thread):
+ channel = channel.parent
- await self.bot.irc.irc_cog.handle_factoid(
- channel=channel,
- discord_message=message,
- factoid_message=factoid_message,
+ restricted_channel_list = configuration.get_config_entry(
+ channel.guild.id, "factoids_restricted_list"
)
- async def send_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
+ if str(channel.id) in restricted_channel_list:
+ return True
+ return False
+
+ def get_embed_from_factoid(
+ self: Self, factoid: bot.models.FactoidData
+ ) -> discord.Embed:
+ """Gets the factoid embed from its database entry
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
+ factoid (bot.models.FactoidData): The factoid to get the json of
+
+ Returns:
+ discord.Embed: The embed 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
+ if not factoid.json_string:
+ return None
- target_logging_channel = await function_logger.pre_log_checks(self.bot, channel)
- if not target_logging_channel:
- return
+ embed_config = json.loads(factoid.json_string)
- await function_logger.send_message(
- self.bot,
- factoid_message_object,
- factoid_caller,
- channel,
- target_logging_channel,
- content_override=factoid_message,
- special_flags=["Factoid call"],
- )
+ return discord.Embed.from_dict(embed_config)
- @factoid_app_group.command(
- name="call",
- description="Calls a factoid from the database and sends it publicy in the channel.",
- )
- async def factoid_call_command(
+ async def confirm_factoid_deletion(
self: Self,
interaction: discord.Interaction,
- factoid_name: str,
- member_to_ping: discord.Member = None,
- ) -> None:
- """This is an app command version of typing {prefix}call
+ display_message: str,
+ channel: discord.abc.GuildChannel,
+ author: discord.Member,
+ ) -> ui.ConfirmResponse:
+ """Confirms if a factoid should be deleted/modified
Args:
- 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
+ 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
- Raises:
- TooLongFactoidMessageError: If the plaintext exceed 2000 characters
+ Returns:
+ bool: Whether the factoid was deleted/modified
"""
- query = factoid_name.replace("\n", " ").lower()
- try:
- factoid = await self.get_factoid(query, str(interaction.guild.id))
-
- except custom_errors.FactoidNotFoundError:
- embed = auxiliary.prepare_deny_embed(
- 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
+ view = ui.Confirm()
+ await view.send(
+ message=display_message,
+ channel=channel,
+ author=author,
+ interaction=interaction,
+ )
- # Checking for disabled or restricted
- if factoid.disabled:
- embed = auxiliary.prepare_deny_embed(
- message=f"The factoid {factoid_name} is disabled."
- )
- await interaction.response.send_message(embed=embed, ephemeral=True)
- return
+ await view.wait()
+ return view.value
- if factoid.restricted and str(
- interaction.channel.id
- ) not in configuration.get_config_entry(
- interaction.guild.id, "factoids_restricted_list"
- ):
- embed = auxiliary.prepare_deny_embed(
- 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(
- 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.",
- level=LogLevel.ERROR,
- channel=log_channel,
- context=LogContext(
- 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
+ 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
- if member_to_ping:
- if not content:
- content = ""
- content = f"{member_to_ping.mention} {content}".strip()
+ 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
- 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)
+ Returns:
+ discord.File | str: The final formatted factoid all
+ """
- raise custom_errors.TooLongFactoidMessageError
+ if use_file:
+ return self.generate_factoid_all_file(guild, factoids)
try:
- # define the message and send it
- view = DeleteView(interaction.user.id)
+ html = await self.generate_factoid_all_html(guild, factoids)
- await interaction.response.send_message(
- content=content,
- embed=embed,
- view=view,
- )
+ if html is None:
+ return None
- 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,
+ 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,
)
- # If something breaks, also log it
- except discord.errors.HTTPException as exception:
+
+ 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(
- interaction.guild.id, "core_logging_channel"
+ guild.id,
+ "core_logging_channel",
)
+
await self.bot.logger.send_log(
- message="Could not send factoid",
+ message="Could not render/send all-factoid HTML",
level=LogLevel.ERROR,
- context=LogContext(
- guild=interaction.guild, channel=interaction.channel
- ),
+ context=LogContext(guild=guild),
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
- )
+ return self.generate_factoid_all_file(guild, factoids)
- @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
+ async def generate_factoid_all_html(
+ self: Self,
+ guild: discord.Guild,
+ factoids: list[FactoidView],
+ ) -> str:
+ """Method to generate the html file contents
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
+ guild (discord.Guild): The guild the factoids are being pulled from
+ factoids (list[FactoidView]): List of all factoids
- except custom_errors.FactoidNotFoundError:
- ...
+ Returns:
+ str: The result html file
+ """
- form = NewFactoid(factoid_name)
- await interaction.response.send_modal(form)
- await form.wait()
+ # Should never hit this, but double check
+ if not factoids:
+ return None
- embed_json_string = ""
+ body_contents = ""
- 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
+ for factoid in factoids:
+ embed_text = " (embed)" if factoid.json_string else ""
- 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
+ calls = sorted(factoid.calls)
- selected = set(form.properties.component.values)
- properties = [
- "disabled" in selected,
- "hidden" in selected,
- "protected" in selected,
- "restricted" in selected,
- ]
+ calls_text = f" [{', '.join(calls)}]"
- 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)
+ body_contents += (
+ f"
{calls_text}{embed_text}"
+ f" - {factoid.message}"
+ )
- # -- 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] = {}
+ body_contents = f""
- # 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
+ return f"""
+
- 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)
+
+
+ Factoids for {guild.name}
+ {body_contents}
+
- try:
- await aiocron.crontab(job.cron).next()
+
+
+ """
- except ValueError as exception:
- log_channel = None
- log_context = None
+ 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
- if ctx:
- channel = configuration.get_config_entry(
- ctx.guild.id, "core_logging_channel"
- )
- log_context = LogContext(guild=ctx.guild, channel=ctx.channel)
+ Args:
+ guild (discord.Guild): The guild the factoids are from
+ factoids (list[FactoidView]): List of all factoids
- await self.bot.logger.send_log(
- message="Could not await cron completion",
- level=LogLevel.ERROR,
- channel=log_channel,
- context=log_context,
- exception=exception,
- )
+ Returns:
+ discord.File: The file, ready to upload to discord
+ """
+ # We should never be here, but just in case
+ if not factoids:
+ return None
- await asyncio.sleep(300)
+ output_data = []
- 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
+ for index, factoid in enumerate(factoids):
- if ctx:
- channel = configuration.get_config_entry(
- ctx.guild.id, "core_logging_channel"
- )
- log_context = LogContext(guild=ctx.guild, channel=ctx.channel)
+ calls = factoid.calls
- await self.bot.logger.send_log(
- message=(
- "Could not find factoid referenced by job - will retry after"
- " waiting"
- ),
- level=LogLevel.WARNING,
- channel=log_channel,
- context=log_context,
+ properties_str = (
+ ", ".join(
+ prop.name.lower() for prop in Properties if factoid.flags & prop
)
- 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)
+ or "None"
+ )
- await self.bot.logger.send_log(
- message=(
- "Could not find channel to send factoid cronjob - will retry"
- " after waiting"
- ),
- level=LogLevel.WARNING,
- channel=log_channel,
- context=log_context,
- )
- continue
- # Checking for disabled or restricted
- if factoid.disabled:
- return
+ data = {
+ "calls": calls,
+ "message": factoid.message,
+ "embed": bool(factoid.json_string),
+ "properties": properties_str,
+ }
- if factoid.restricted and str(
- channel.id
- ) not in configuration.get_config_entry(
- ctx.guild.id, "factoids_restricted_list"
- ):
- return
+ output_data.append(
+ {
+ index: data,
+ }
+ )
- # 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
+ return discord.File(
+ io.StringIO(yaml.dump(output_data)),
+ filename=(
+ f"factoids-for-server-{guild.id}-{datetime.datetime.utcnow()}.yaml"
+ ),
+ )
- 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
+ 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
- try:
- message = await channel.send(content=content, embed=embed)
+ Args:
+ guild (discord.Guild): The guild the factoid exists in
+ factoid (FactoidView): The factoid to send
- 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 looped factoid",
- level=LogLevel.ERROR,
- context=LogContext(guild=ctx.guild, channel=ctx.channel),
- channel=log_channel,
+ 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:
+ asyncio.create_task(
+ self.log_embed_fallback_exception(
+ factoid=factoid,
exception=exception,
+ guild=guild,
)
- # 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)
+ return (embed, plaintext)
- @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
+ 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:
- ctx (commands.Context): The context in which the command was run in
+ 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
"""
- 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 = ""
+ 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:
- """Command to add a factoid
+ """If relevant, will send a factoid to the bridged IRC channel
Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): Name of the factoid to add
- message (str): The message of the factoid
+ 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
"""
- # 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)
+ irc_config = self.bot.file_config.api.irc
+ if not irc_config.enable_irc:
return
- embed_config = await auxiliary.get_json_from_attachments(
- ctx.message, as_string=True
+ self.bot.irc.irc_cog.handle_factoid(
+ channel=channel,
+ factoid=factoid,
+ author=author,
)
- if not embed_config and not message:
- await auxiliary.send_deny_embed(
- message="You did not provide the factoid message!", channel=ctx.channel
- )
+ 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
- if embed_config and message == "":
- message = None
+ target_logging_channel = await function_logger.pre_log_checks(self.bot, channel)
+ if not target_logging_channel:
+ return
- 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,
+ await function_logger.send_message(
+ self.bot,
+ factoid_message_object,
+ factoid_caller,
+ channel,
+ target_logging_channel,
+ content_override=factoid_message,
+ special_flags=["Factoid call"],
)
- @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
+ def check_valid_name(self: Self, name: str) -> bool:
+ """This checks if the name of a factoid is valid or not
Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): Name of the factoid to remove
+ 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
+ if "," in name:
+ return False
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
+ # Factoid name passed all the rules
+ return True
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid.name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
- return
+ def check_valid_message(self: Self, message: str) -> bool:
+ """This checks if the message of a factoid is valid or not
- factoid_called = CalledFactoid(
- original_call_str=factoid_name, factoid_db_entry=factoid
- )
+ Args:
+ message (str): The message of the factoid to check
- if not await self.delete_factoid(ctx, factoid_called):
- return
+ 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
+ if len(message) > 2000:
+ return False
- # 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()
+ # 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"
+ ),
)
- for alias in aliases:
- await self.delete_factoid_call(alias, str(ctx.guild.id))
+ return json_file
- @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(
+ async def log_embed_fallback_exception(
self: Self,
- ctx: commands.Context,
- factoid_name: str,
- channel: discord.TextChannel,
- *,
- cron_config: str,
+ factoid: FactoidView,
+ exception: Exception,
+ guild: discord.Guild,
+ channel: discord.abc.GuildChannel = None,
) -> None:
- """Command to loop a factoid in a channel
+ """This logs an error log if a factoid embed failed, causing a fallback to be sent
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 (FactoidView): The factoid that had the problem
+ exception (Exception): The exception generated when making or sending the embed
+ guild (discord.Guild): The guild this happened in
+ channel (discord.abc.GuildChannel, optional): The channel the factoid was going to be sent in. Defaults to None.
"""
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
+ 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,
+ )
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
- return
+ async def generate_json_string_from_file(
+ self: Self, interaction: discord.Interaction, uploaded_file: discord.Attachment
+ ) -> str:
+ """This turns a json file discord attachement into a single string, ready to be inserted into a database
- if factoid.disabled:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is disabled and new loops cannot be made",
- channel=ctx.channel,
- )
- return
+ Args:
+ interaction (discord.Interaction): The interaction that called this command
+ uploaded_file (discord.Attachment): The file that was uploaded to parse
- 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
+ Returns:
+ str: The parsed string representation of the json file
+ """
- # 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,
+ if not uploaded_file.filename.endswith(".json"):
+ await self.respond_error_embed(
+ interaction, "I don't recognize your upload as a JSON file."
)
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,
+ try:
+ json_bytes = await uploaded_file.read()
+ attachment_json = json.loads(json_bytes.decode("UTF-8"))
+ embed_json_string = json.dumps(attachment_json)
+
+ except Exception:
+ await self.respond_error_embed(
+ interaction, message="I couldn't parse the uploaded JSON file."
)
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] = {}
+ return embed_json_string
- # 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
+ def compute_embed_hash(self: Self, embed: discord.Embed) -> str:
+ """This generates a hash from a discords embed content, for comparison purposes
+ This allows us to normalize the embed we are about to send and the embed we previously sent
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
+ embed (discord.Embed): The embed we want to normalize
+
+ Returns:
+ str: The hash of the embed to compare
"""
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
+ # I hate everything about this
+ # This is all because of discord CDN links
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already protected",
- channel=ctx.channel,
- )
- return
+ # Do some parsing to attempt to ensure embeds are the same
+ def normalize_discord_url(url: str | None) -> str | None:
+ if not url:
+ return None
- job = (
- await self.bot.models.FactoidJob.query.where(
- self.bot.models.FactoidJob.channel == str(channel.id)
+ parsed_url = urlsplit(url)
+ return urlunsplit(
+ (parsed_url.scheme, parsed_url.netloc, parsed_url.path, "", "")
)
- .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()
+ def normalize_inline(value):
+ if value is None:
+ return False
+ return bool(value)
- await auxiliary.send_confirm_embed(
- message="Loop job deleted",
- channel=ctx.channel,
- )
+ embed_data = {
+ "author": (
+ {
+ "name": embed.author.name if embed.author else None,
+ "icon_url": normalize_discord_url(embed.author.icon_url),
+ "url": normalize_discord_url(embed.author.url),
+ }
+ if embed.author
+ else None
+ ),
+ "footer": (
+ {
+ "text": embed.footer.text if embed.footer else None,
+ "icon_url": normalize_discord_url(embed.footer.icon_url),
+ }
+ if embed.footer
+ else None
+ ),
+ "color": embed.color.value if embed.color else None,
+ "fields": [
+ {
+ "name": field.name,
+ "value": field.value,
+ "inline": normalize_inline(field.inline),
+ }
+ for field in embed.fields
+ ],
+ "image": normalize_discord_url(embed.image.url),
+ "thumbnail": normalize_discord_url(embed.thumbnail.url),
+ "title": embed.title,
+ "description": embed.description,
+ "url": normalize_discord_url(embed.url),
+ "timestamp": embed.timestamp.isoformat() if embed.timestamp else None,
+ }
- @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,
+ normalized_json = json.dumps(embed_data, sort_keys=True, ensure_ascii=False)
+ return hashlib.sha256(normalized_json.encode("utf-8")).hexdigest()
+
+ # INTERACTION RESPONSE BOILERPLATE
+
+ async def check_protected(
+ self: Self, interaction: discord.Interaction, factoid: FactoidView
+ ) -> bool:
+ """This takes a factoid and ensures its not protected
+ If the factoid is protected, this will respond to the interaction
+
+ Args:
+ interaction (discord.Interaction): The interaction calling the command
+ factoid (FactoidView): The factoid attempting to be edited
+
+ Returns:
+ bool: True if protected, False if unprotected
+ """
+ if factoid.flags & Properties.PROTECTED:
+ await self.respond_error_embed(
+ interaction,
+ f"The factoid `[{', '.join(factoid.calls)}]` is protected and cannot be edited.",
+ )
+ return True
+ return False
+
+ async def respond_error_embed(
+ self: Self, interaction: discord.Interaction, message: str
) -> None:
- """Command to list info about a loop
+ """This formats a denial embed and responds to the interaction with it.
+ Will always respond ephemerally, will handle followup if needed
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
+ interaction (discord.Interaction): The interaction to respond to
+ message (str): The message to include
"""
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
+ embed = auxiliary.prepare_deny_embed(message=message)
- # 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
+ if interaction.response.is_done():
+ await interaction.followup.send(
+ embed=embed,
+ ephemeral=True,
)
return
- embed_label = ""
- if job.embed_config:
- embed_label = "(embed)"
+ await interaction.response.send_message(
+ embed=embed,
+ ephemeral=True,
+ )
+
+ async def get_valid_factoid(
+ self: Self, interaction: discord.Interaction, factoid_name: str
+ ) -> FactoidView | None:
+ """This gets a factoid by factoid name
+ If the factoid does not exist, the interaction is responded to
+
+ Args:
+ interaction (discord.Interaction): The interaction that called for lookup
+ factoid_name (str): The factoid name to lookup
- embed = auxiliary.generate_basic_embed(
- color=discord.Color.blurple(),
- title=f"Loop config for `{factoid_name}` {embed_label}",
- description=f'"{job.message}"',
+ Returns:
+ FactoidView | None: The factoid, if it exists. None if no factoid exists
+ """
+ factoid = await self.get_factoid_view_by_name(
+ guild=interaction.guild, name=factoid_name
)
- embed.add_field(name="Channel", value=f"#{channel.name}")
- embed.add_field(name="Cron config", value=f"`{job.cron}`")
+ if not factoid:
+ await self.respond_error_embed(
+ interaction, f"The factoid `{factoid_name}` doesn't exist!"
+ )
- await ctx.send(embed=embed)
+ return factoid
- @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
+ # AUTOFILL
+ async def setup_autocomplete_cache(self: Self, guild: discord.Guild) -> None:
+ """This calls the database and creates a cache for the passed guild for the autocomplete
+ This cache contains a mapping of names to flags
Args:
- ctx (commands.Context): Context of the invocation
+ guild (discord.Guild): The guild to build the cache for
"""
- # 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))
+ factoids = (
+ await self.bot.db.select(
+ [
+ self.bot.models.FactoidCall.name,
+ self.bot.models.FactoidData.flags,
+ ]
+ )
+ .select_from(
+ self.bot.models.FactoidCall.join(
+ self.bot.models.FactoidData,
+ self.bot.models.FactoidCall.factoid_data_id
+ == self.bot.models.FactoidData.factoid_data_id,
+ )
+ )
+ .where(self.bot.models.FactoidCall.guild == str(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,
+ cache = [
+ (
+ factoid.name.lower(),
+ factoid.flags,
)
+ for factoid in factoids
+ ]
- await ctx.send(embed=embed)
+ cache.sort(key=lambda x: x[0])
- @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
+ self.factoid_autocomplete_cache[guild.id] = cache
+
+ async def generate_factoid_autocomplete_list(
+ self: Self,
+ interaction: discord.Interaction,
+ current: str,
+ hidden_flags: Properties = Properties(0),
+ ) -> list[app_commands.Choice[str]]:
+ """This autocomplete list is capable of returning all factoids, filtering by no properties
+ It is setup in a way where it can be called with a list of flags to hide
+ This is not designed to be used as a direct call
Args:
- ctx (commands.Context): Context of the invocation
- factoid_name (str): The name of the factoid
+ interaction (discord.Interaction): The interaction calling for autocomplete
+ current (str): The current text in the factoid name field
+ hidden_flags (Properties, optional): The properties to exclude from the list.
+ Defaults to Properties(0).
+
+ Returns:
+ list[app_commands.Choice[str]]: The list of choices matching the user input and flag filter
"""
+ guild = interaction.guild
+ if guild is None:
+ return []
+
+ if guild.id not in self.factoid_autocomplete_cache:
+ await self.setup_autocomplete_cache(guild)
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
+ current = current.lower()
+ cached = self.factoid_autocomplete_cache.get(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,
+ matches: list[app_commands.Choice[str]] = []
+
+ for name, flags in cached:
+ if not name.startswith(current):
+ continue
+
+ if flags & hidden_flags:
+ continue
+
+ matches.append(
+ app_commands.Choice(
+ name=name,
+ value=name,
+ )
)
- 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"
- ),
- )
+ if len(matches) >= 10:
+ break
- await ctx.send(file=json_file)
+ return matches
- @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(
+ async def property_factoid_autocomplete(
self: Self,
- ctx: commands.Context,
- query: str,
- ) -> None:
- """Command to list info about a factoid
+ interaction: discord.Interaction,
+ current: str,
+ ) -> list[app_commands.Choice[str]]:
+ """This function is designed to return ALL factoids, regardless of property flags
+ Designed for use exclusively in the /factoid property command
Args:
- ctx (commands.Context): Context of the invocation
- query (str): The factoid name to query
+ interaction (discord.Interaction): The interaction calling for autocomplete
+ current (str): The current text in the factoid name field
+
+ Returns:
+ list[app_commands.Choice[str]]: The list of choices matching to show to the user
"""
+ return await self.generate_factoid_autocomplete_list(
+ interaction,
+ current,
+ )
- # Gets the factoid if it exists
- factoid = await self.get_factoid(query, str(ctx.guild.id))
+ async def editing_factoid_autocomplete(
+ self: Self,
+ interaction: discord.Interaction,
+ current: str,
+ ) -> list[app_commands.Choice[str]]:
+ """This autocomplete list will hide hidden and protected factoids from the autocomplete list
+ This is designed for use when editing factoids
- embed = discord.Embed(title=f"Info about `{query}`")
+ Args:
+ interaction (discord.Interaction): The interaction calling for autocomplete
+ current (str): The current text in the factoid name field
- # 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()
+ Returns:
+ list[app_commands.Choice[str]]: The list of choices matching to show to the user
+ """
+ return await self.generate_factoid_autocomplete_list(
+ interaction,
+ current,
+ hidden_flags=(Properties.HIDDEN | Properties.PROTECTED),
)
- # 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]))
- )
+ async def filtered_factoid_autocomplete(
+ self: Self,
+ interaction: discord.Interaction,
+ current: str,
+ ) -> list[app_commands.Choice[str]]:
+ """This hides hidden and disabled facotids, and restricted factoids if they cannot be used in the current channel
+ This is designed for user forward commands, like /factoid call and /factoid info
- # Gets the factoids loop jobs
- jobs = await self.bot.models.FactoidJob.query.where(
- self.bot.models.FactoidJob.factoid == factoid.factoid_id
- ).gino.all()
+ Args:
+ interaction (discord.Interaction): The interaction calling for autocomplete
+ current (str): The current text in the factoid name field
+
+ Returns:
+ list[app_commands.Choice[str]]: The list of choices matching to show to the user
+ """
+ hidden_flags = Properties.HIDDEN | Properties.DISABLED
- # 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)
+ if not self.can_channel_send_restricted(interaction.channel):
+ hidden_flags |= Properties.RESTRICTED
- # 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)
+ return await self.generate_factoid_autocomplete_list(
+ interaction,
+ current,
+ hidden_flags=hidden_flags,
)
- 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)
+ # COMMANDS
+ @app_commands.check(has_manage_factoids_role)
@factoid_app_group.command(
- name="all",
- description="Sends a configurable list of all factoids.",
+ name="alias",
+ description="Creates an alias for an existing factoid call",
)
- async def app_command_all(
+ @app_commands.autocomplete(existing_factoid=editing_factoid_autocomplete)
+ async def factoid_alias_command(
self: Self,
interaction: discord.Interaction,
- force_file: bool = False,
- property: Properties = "",
- true_all: bool = False,
- show_hidden: bool = False,
+ existing_factoid: str,
+ new_factoid: str,
) -> None:
- """This is the more feature full version of factoid all
- This is an application command
+ """Creates an alias to a factoid without creating new data
+ This will create only a FactoidCall entry
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.
+ interaction (discord.Interaction): The interaction that called this command
+ existing_factoid (str): The pre-existing factoid name
+ new_factoid (str): The new name to use
"""
- 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
+ existing_factoid = existing_factoid.lower()
+ new_factoid = new_factoid.lower()
+ if not self.check_valid_name(new_factoid):
+ await self.respond_error_embed(
+ interaction,
+ f"The factoid name `{new_factoid}` is invalid and cannot be used!",
)
+ return
- if not factoids:
- embed = auxiliary.prepare_deny_embed(
- "No factoids could be found matching your filter"
+ if new_factoid == existing_factoid:
+ await self.respond_error_embed(
+ interaction, "You cannot alias a factoid to itself!"
)
- 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
+ # Make sure the factoid is valid
+ factoid = await self.get_valid_factoid(
+ interaction=interaction, factoid_name=existing_factoid
)
+ if not factoid:
+ return
- 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)
+ # No aliases on protected factoids
+ if await self.check_protected(interaction, factoid):
return
- factoid_all = await self.build_factoid_all(
- interaction.guild, factoids, aliases, force_file, cachable
+ new_factoid_db = await self.get_factoid_view_by_name(
+ guild=interaction.guild, name=new_factoid
)
- if not factoid_all:
- embed = auxiliary.prepare_deny_embed(
- "No factoids could be found matching your filter"
+ # 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:
+ await self.respond_error_embed(
+ interaction,
+ f"The factoid `{new_factoid}` is already an alias of `{existing_factoid}`.",
)
- 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.
+ # If the new_factoid already exists but point elsewhere, we need to ask the user for confirmation
+ if new_factoid_db:
- 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
+ # No aliases on protected factoids
+ if await self.check_protected(interaction, new_factoid_db):
+ return
- 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
+ await interaction.response.defer()
+ confirmation_response = await self.confirm_factoid_deletion(
+ interaction=interaction,
+ display_message=f"The factoid `{new_factoid}` already exists. Should I overwrite it?",
+ channel=interaction.channel,
+ author=interaction.user,
+ )
+ if confirmation_response == ui.ConfirmResponse.TIMEOUT:
+ return
+ if confirmation_response == ui.ConfirmResponse.DENIED:
+ await self.respond_error_embed(
+ interaction,
+ message=f"The factoid `{new_factoid}` was not replaced.",
+ )
+ return
- Args:
- factoids (list[munch.Munch]): The factoid list to find aliases for
+ 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_confirm_embed(
+ message=f"Successfully added the alias `{new_factoid}` for `{existing_factoid}`",
+ )
- 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
+ # Update the factoid edit time
+ # This will also remove the factoid from the cache
+ await self.handle_factoid_edit(interaction.guild, factoid)
+ if new_factoid_db:
+ await self.handle_factoid_edit(interaction.guild, new_factoid_db)
- aliases[factoid.alias] = [factoid.name]
- return aliases
+ # 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)
- async def build_factoid_all(
+ @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,
- 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
+ interaction: discord.Interaction,
+ factoid_property: Properties = "",
+ force_file: bool = False,
+ show_all: bool = False,
+ ) -> None:
+ """This displays to the user a configurable list of all factoids
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
+ interaction (discord.Interaction): The interaction that called this command
+ factoid_property (Properties, optional): A property to force the search to display. Defaults to "".
+ force_file (bool, optional): Whether a file is generated, even if a linx server is configured. Defaults to False.
+ show_all (bool, optional): To force all factoids, including hidden ones, to be in the output. Defaults to False.
"""
+ all_factoids = await self.get_all_factoids_for_guild(guild=interaction.guild)
- 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
+ if not all_factoids:
+ await self.respond_error_embed(
+ interaction, "No factoids have been created for this guild"
+ )
+ return
- 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,
+ # 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"
+ ),
)
- 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
+ # Determine whether restricted factoids should be visible here
+ should_show_restricted = self.can_channel_send_restricted(
+ interaction.channel,
+ )
+
+ # 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
+ ]
+ else:
+ filtered_factoids = [
+ factoid
+ for factoid in all_factoids
+ if (
+ # Never show hidden factoids normally
+ not (factoid.flags & Properties.HIDDEN)
+ # Never show disabled factoids normally
+ and not (factoid.flags & Properties.DISABLED)
+ # Restricted factoids depend on channel
+ and (
+ should_show_restricted
+ or not (factoid.flags & Properties.RESTRICTED)
+ )
+ )
+ ]
+
+ # Bulding a cache key to cache factoid all links
+ if show_all:
+ cache_mode = "all"
+ property_value = 0
+ elif factoid_property:
+ cache_mode = "property"
+ property_value = factoid_property
+ elif should_show_restricted:
+ cache_mode = "default_with_restricted"
+ property_value = 0
+ else:
+ cache_mode = "default"
+ property_value = 0
- return url
+ cache_key = (
+ interaction.guild.id,
+ cache_mode,
+ property_value,
+ )
- # 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,
+ filtered_factoids.sort(key=lambda factoid: factoid.calls[0])
+ if not filtered_factoids:
+ await self.respond_error_embed(
+ interaction, "No factoids could be found matching your filter"
)
+ return
- return await self.send_factoids_as_file(guild, factoids, aliases)
+ # 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
- 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
+ await interaction.response.defer(ephemeral=True)
- 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
+ cached_factoid_all = self.factoid_all_cache.get(cache_key)
- 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
+ if cached_factoid_all and not force_file:
+ factoid_all = cached_factoid_all
+ else:
+ factoid_all = await self.build_factoid_all(
+ guild=interaction.guild, factoids=filtered_factoids, use_file=force_file
+ )
- # 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 not factoid_all:
+ await self.respond_error_embed(
+ interaction, "Something went wrong generating the list of factoids"
+ )
+ return
- # If aliased
- else:
- data = {"message": factoid.message, "embed": bool(factoid.embed_config)}
+ # If we got a file, send it.
+ if isinstance(factoid_all, discord.File):
+ await interaction.followup.send(file=factoid_all, ephemeral=True)
+ return
- output_data.append({name: data})
+ # If we didn't pull factoid all from the cache, add it to the cache
+ if not cached_factoid_all:
+ self.factoid_all_cache[cache_key] = factoid_all
- # Sort output alphabetically
- output_data = sorted(output_data, key=lambda x: list(x.keys())[0])
- return output_data
+ embed = auxiliary.prepare_confirm_embed(factoid_all)
+ await interaction.followup.send(embed=embed, ephemeral=True)
- async def generate_html(
+ @factoid_app_group.command(
+ name="call",
+ description="Calls a factoid from the database and sends it publicy in the channel.",
+ extras={"ephemeral_error": True},
+ )
+ @app_commands.autocomplete(factoid_name=filtered_factoid_autocomplete)
+ async def factoid_call_command(
self: Self,
- guild: discord.Guild,
- factoids: list[munch.Munch],
- aliases: dict[str, list[str]],
- ) -> str:
- """Method to generate the html file contents
+ interaction: discord.Interaction,
+ factoid_name: str,
+ 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:
- 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
+ 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
"""
+ factoid_name = factoid_name.lower()
+ # Make sure the factoid is valid
+ factoid = await self.get_valid_factoid(
+ interaction=interaction, factoid_name=factoid_name
+ )
+ if not factoid:
+ return
- 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
+ # Check if factoid is disabled. If so, don't send it
+ if factoid.flags & Properties.DISABLED:
+ await self.respond_error_embed(
+ interaction, f"The factoid `{factoid_name}` is disabled."
+ )
+ return
- body_contents = f""
- output = (
- f"""
-
-
-
- Factoids for {guild.name}
- {body_contents}
-
-
-
- """
+ # Log in the background
+ asyncio.create_task(
+ self.log_factoid_send(
+ guild=interaction.guild,
+ channel=interaction.channel,
+ sender=interaction.user,
+ factoid=factoid,
+ )
)
- 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
+ content = ""
+ if member_to_ping:
+ content = member_to_ping.mention
- 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
+ embed_sent = False
+ view = ButtonView(interaction.user.id, factoid)
+ if embed:
+ try:
+ # Attempt to send the message with the embed in it
+ await interaction.response.send_message(
+ content=content,
+ embed=embed,
+ view=view,
+ )
+ view.message = await interaction.original_response()
+ embed_sent = True
+ # If something breaks, also log it
+ except discord.errors.HTTPException as exception:
+ asyncio.create_task(
+ self.log_embed_fallback_exception(
+ factoid=factoid,
+ exception=exception,
+ guild=interaction.guild,
+ channel=interaction.channel,
+ )
+ )
- Returns:
- discord.File: The file, ready to upload to discord
- """
+ # 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:
+ await self.respond_error_embed(
+ interaction,
+ f"The factoid `{factoid_name}` is too long and cannot be sent on discord.",
+ )
+ return
- output_data = self.build_formatted_factoid_data(factoids, aliases)
+ # 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()
- if not output_data:
- # Something is wrong with the database if we are ever here
- return None
+ # IRC connection
+ self.send_factoid_to_irc(interaction.channel, factoid, interaction.user)
- yaml_file = discord.File(
- io.StringIO(yaml.dump(output_data)),
- filename=(
- f"factoids-for-server-{guild.id}-{datetime.datetime.utcnow()}.yaml"
- ),
+ # Logger connection
+ sent_message = await interaction.original_response()
+ await self.send_factoid_to_logger(
+ sent_message, interaction.user, interaction.channel, factoid.message
)
- # Returns the file
- return yaml_file
+ # Increase times called
+ await self.increment_times_called_by_view(
+ guild=interaction.guild, factoid=factoid
+ )
- 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.
+ @app_commands.check(auxiliary.bot_admin_check_interaction)
+ @app_commands.check(has_admin_factoids_role)
+ @factoid_app_group.command(
+ name="cleanup",
+ description="Deletes any broken database entries for the current guild",
+ )
+ async def factoid_cleanup_command(
+ self: Self, interaction: discord.Interaction
+ ) -> None:
+ """This command cleans up any broken database entries
+ Hopefully this never needs to be run
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.
+ self (Self): _description_
+ interaction (discord.Interaction): The interaction that called this command
"""
+ await interaction.response.defer()
- original = original.replace(search_string, f"**{search_string}**")
+ factoid_data = await self.bot.models.FactoidData.query.where(
+ self.bot.models.FactoidData.guild == str(interaction.guild.id)
+ ).gino.all()
+ factoid_calls = await self.bot.models.FactoidCall.query.where(
+ self.bot.models.FactoidCall.guild == str(interaction.guild.id)
+ ).gino.all()
+ factoid_jobs = await self.get_all_jobs_for_guild(interaction.guild)
- show_range = 20
+ # A master list of valid factoid IDs, pulled from the database
+ factoid_data_ids = {data.factoid_data_id for data in factoid_data}
- 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)
+ # A mast list of factoid IDs that have at least a single valid guild
+ called_factoid_data_ids = set()
- if len(indices) == 0:
- return None
+ factoid_data_removed = 0
+ factoid_calls_removed = 0
+ factoid_jobs_removed = 0
- # 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),
- )
- )
+ # Find any calls that point to an invalid factoid
+ for call in factoid_calls:
+ if call.factoid_data_id in factoid_data_ids:
+ called_factoid_data_ids.add(call.factoid_data_id)
+ continue
- # 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))
+ await call.delete()
+ factoid_calls_removed += 1
+
+ # Find any jobs that point to an invalid factoid
+ for job in factoid_jobs:
+ if job.factoid_data_id in factoid_data_ids:
+ continue
+
+ await self.unschedule_job(job)
+ await job.delete()
+ factoid_jobs_removed += 1
- ranges_to_strs = []
+ # Find any data that has no calls
+ for data in factoid_data:
+ if data.factoid_data_id in called_factoid_data_ids:
+ continue
- if minimized_ranges[0][0] != 0:
- ranges_to_strs.append("")
+ factoid_jobs_removed += len(
+ [
+ job
+ for job in factoid_jobs
+ if job.factoid_data_id == data.factoid_data_id
+ ]
+ )
+ if await self.delete_factoid_data_by_id(
+ guild=interaction.guild,
+ id=data.factoid_data_id,
+ ):
+ factoid_data_removed += 1
- for include_range in minimized_ranges:
- ranges_to_strs.append(original[include_range[0] : include_range[1]])
+ self.clear_guild_caches(interaction.guild)
- if minimized_ranges[len(minimized_ranges) - 1][1] != len(original):
- ranges_to_strs.append("")
+ embed = auxiliary.prepare_confirm_embed(
+ (
+ f"FactoidData entries removed: {factoid_data_removed}\n"
+ f"FactoidCall entries removed: {factoid_calls_removed}\n"
+ f"FactoidJob entries removed: {factoid_jobs_removed}"
+ )
+ )
- return "...".join(ranges_to_strs)
+ await interaction.followup.send(embed=embed)
- @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]",
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_app_group.command(
+ name="create",
+ description="Creates a new factoid by name",
+ extras={"ephemeral_error": True},
)
- async def search(self: Self, ctx: commands.Context, *, query: str) -> None:
- """Commands to search a factoid
+ async def factoid_create_command(
+ self: Self, interaction: discord.Interaction, factoid_name: str
+ ) -> None:
+ """Create a new factoid, using a Modal popup
Args:
- ctx (commands.Context): Context of the invokation
- query (str): The querry to look for
+ interaction (discord.Interaction): The interaction that called this command
+ factoid_name (str): The name of the factoid to create
"""
- query = query.lower()
- guild = str(ctx.guild.id)
+ factoid_name = factoid_name.lower()
+ # Only ever attempt to add a factoid if it doesn't exist
+ existing_factoid = await self.get_factoid_view_by_name(
+ guild=interaction.guild, name=factoid_name
+ )
+ if existing_factoid:
+ await self.respond_error_embed(
+ interaction, f"The factoid `{factoid_name}` already exists"
+ )
+ return
- if len(query) < 3:
- await auxiliary.send_deny_embed(
- message="Please enter at least 3 characters for the search query!",
- channel=ctx.channel,
+ if not self.check_valid_name(factoid_name):
+ await self.respond_error_embed(
+ interaction,
+ f"The factoid name `{factoid_name}` is invalid and cannot be used!",
)
return
- factoids = await self.get_all_factoids(guild, list_hidden=False)
- matches = {}
- for factoid in factoids:
- if factoid.alias:
- continue
+ form = FactoidModal(factoid_name, edit_mode=False)
+ await interaction.response.send_modal(form)
+ await form.wait()
- factoid_key = ", ".join(await self.get_list_of_aliases(factoid.name, guild))
+ if not self.check_valid_message(form.plaintext.component.value):
+ await self.respond_error_embed(
+ interaction, "The message content is invalid and cannot be used!"
+ )
+ return
- # 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}"]
+ embed_json_string = ""
+ if form.embed.component.values:
+ embed_json_string = self.generate_json_string_from_file(
+ interaction, form.embed.component.values[0]
+ )
+ if not embed_json_string:
+ return
+
+ selected = set(form.properties.component.values)
- # Content
- content_highlight = self.search_content_and_bold(
- factoid.message.lower(), query
+ property_binary = sum(int(value) for value in selected)
+
+ factoid = await self.bot.models.FactoidData.create(
+ guild=str(interaction.guild.id),
+ message=form.plaintext.component.value,
+ json_string=embed_json_string,
+ flags=property_binary,
+ )
+ try:
+ await self.create_factoid_call(
+ guild=interaction.guild,
+ name=factoid_name,
+ factoid_data_id=factoid.factoid_data_id,
)
- if content_highlight:
- if factoid_key in matches:
- matches[factoid_key].append(f"Content: {content_highlight}")
- else:
- matches[factoid_key] = [f"Content: {content_highlight}"]
+ except Exception as exc:
+ await factoid.delete()
+ raise exc
+
+ # We must update the factoid all and autocomplete list
+ self.clear_guild_caches(interaction.guild)
+
+ embed = auxiliary.prepare_confirm_embed(
+ message=f"Your factoid `{factoid_name}` was successfully created!",
+ )
+ await interaction.followup.send(embed=embed)
- # Embed
- if factoid.embed_config is not None:
- embed_highlight = self.search_content_and_bold(
- factoid.embed_config.lower(), query
+ # 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 self.respond_error_embed(
+ interaction, f"The embed you uploaded failed: {exc}"
)
- 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]",
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_app_group.command(
+ name="dealias",
+ description="Deletes an alias for an existing factoid call",
)
- async def alias(
+ @app_commands.autocomplete(factoid_name=editing_factoid_autocomplete)
+ async def factoid_dealias_command(
self: Self,
- ctx: commands.Context,
- alias_name: str,
+ interaction: discord.Interaction,
factoid_name: str,
) -> None:
- """Command to add an alternate way of calling a factoid
+ """This deletes an alias from an existing factoid
+ This will not delete the FactoidData entry
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
-
+ interaction (discord.Interaction): The interaction that triggered this command
+ factoid_name (str): The factoid to dealias
"""
- # Makes factoids caps insensitive
+ factoid_name = factoid_name.lower()
+ # Make sure the factoid is valid
+ factoid = await self.get_valid_factoid(
+ interaction=interaction, factoid_name=factoid_name
+ )
+ if not factoid:
+ return
- # Gets the parent factoid
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
+ # No edits on protected factoids
+ if await self.check_protected(interaction, factoid):
+ return
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid.name}` is protected and cannot be modified",
- channel=ctx.channel,
+ # Only allowed to dealias if this wouldn't require deleting the entire factoid
+ if len(factoid.calls) == 1:
+ await self.respond_error_embed(
+ interaction, f"The factoid `{factoid_name}` has no other aliases."
)
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
- ):
+ await self.bot.models.FactoidCall.delete.where(
+ (self.bot.models.FactoidCall.guild == str(interaction.guild.id))
+ & (self.bot.models.FactoidCall.name == factoid_name)
+ ).gino.status()
+ 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}`"
+ )
+
+ # Update the factoid edit time
+ # This will also remove the factoid from the cache
+ await self.handle_factoid_edit(interaction.guild, factoid)
+
+ 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=editing_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()
+ # Make sure the factoid is valid
+ factoid = await self.get_valid_factoid(
+ interaction=interaction, factoid_name=factoid_name
+ )
+ if not factoid:
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
- )
+ # No edits on protected factoids
+ if await self.check_protected(interaction, factoid):
return
- try:
- # Firstly check if the new entry already exists
- target_entry = await self.get_raw_factoid_entry(
- alias_name, str(ctx.guild.id)
+ 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
+ if confirmation_response == ui.ConfirmResponse.DENIED:
+ await self.respond_error_embed(
+ interaction, f"The factoid `{factoid_name}` was not deleted."
)
+ return
- # 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
+ await self.delete_factoid_data_by_id(interaction.guild, factoid.factoid_data_id)
- # Confirms deletion of old entry
- if not await self.confirm_factoid_deletion(
- alias_name, ctx.channel, ctx.author, "replaced"
- ):
- return
+ # We must update the factoid all and autocomplete list
+ self.clear_guild_caches(interaction.guild)
- # 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()
- )
+ # Remove factoid from cache after deleting
+ self.remove_from_cache(interaction.guild, factoid)
- # 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)
- )
+ embed = auxiliary.prepare_confirm_embed(
+ f"The factoid `[{', '.join(factoid.calls)}]` was deleted"
+ )
+ await interaction.followup.send(embed=embed)
- 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]",
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_app_group.command(
+ name="edit",
+ description="Edits an existing factoids message or embed",
)
- async def dealias(
+ @app_commands.autocomplete(factoid_name=editing_factoid_autocomplete)
+ async def factoid_edit_command(
self: Self,
- ctx: commands.Context,
+ interaction: discord.Interaction,
factoid_name: str,
- replacement_name: str = None,
) -> None:
- """Command to remove an alias from the group, but never delete the parent
+ """This edits an existing factoid, allowing changes to the properties, message, and embed
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.
+ interaction (discord.Interaction): The interaction that triggered this command
+ factoid_name (str): The factoid to edit
"""
+ factoid_name = factoid_name.lower()
+ # Make sure the factoid is valid
+ factoid = await self.get_valid_factoid(
+ interaction=interaction, factoid_name=factoid_name
+ )
+ if not factoid:
+ return
+
+ # No edits on protected factoids
+ if await self.check_protected(interaction, factoid):
+ return
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
+ form = FactoidModal(factoid_name, edit_mode=True, factoid=factoid)
+ await interaction.response.send_modal(form)
+ await form.wait()
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid.name}` is protected and cannot be modified",
- channel=ctx.channel,
+ if not self.check_valid_message(form.plaintext.component.value):
+ await self.respond_error_embed(
+ interaction, "The message content is invalid and cannot be used!"
)
return
- # -- Handling for aliases --
- # (They just get deleted, no parent handling needs to be done)
+ 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:
+ await self.respond_error_embed(
+ interaction,
+ "The json file was requested to be replaced, but no file was uploaded. No edits were made.",
+ )
+ return
- 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),
+ embed_json_string = self.generate_json_string_from_file(
+ interaction, form.embed.component.values[0]
)
- await auxiliary.send_confirm_embed(
- message=f"Deleted the alias `{factoid_name}`",
- channel=ctx.channel,
+ if not embed_json_string:
+ return
+
+ # If the factoid was not edited, do nothing
+ if not show_embed and not show_plaintext:
+ await self.respond_error_embed(
+ interaction,
+ "It doesn't appear any edits were made to this factoid. No edits were made.",
)
return
- # -- Handling for parents --
+ # Update the factoid edit time
+ # This will also remove the factoid from the cache
+ await self.handle_factoid_edit(interaction.guild, factoid)
- # 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()
+ factoid = await self.update_factoid_data(
+ guild=interaction.guild,
+ factoid_data_id=factoid.factoid_data_id,
+ message=form.plaintext.component.value,
+ json_string=embed_json_string,
)
- # 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)
+ 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 self.respond_error_embed(
+ interaction, f"The embed you uploaded failed: {exc}"
+ )
+
+ @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
+ """
+ guild_id = interaction.guild.id
+
+ factoid_cache_removed = 0
+ factoid_all_cache_removed = 0
+ autocomplete_cache_removed = 0
- # 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,
+ # Clear factoid cache
+ for entry in list(self.factoid_cache.keys()):
+ if entry.startswith(str(guild_id)):
+ del self.factoid_cache[entry]
+ factoid_cache_removed += 1
+
+ # Clear factoid all cache
+ for entry in list(self.factoid_all_cache.keys()):
+ if entry[0] == guild_id:
+ del self.factoid_all_cache[entry]
+ factoid_all_cache_removed += 1
+
+ # Clear autocomplete cache
+ if guild_id in self.factoid_autocomplete_cache:
+ autocomplete_cache_removed = 1
+ del self.factoid_autocomplete_cache[guild_id]
+
+ embed = auxiliary.prepare_confirm_embed(
+ "\n".join(
+ [
+ f"FactoidView cache cleared: {factoid_cache_removed}",
+ f"FactoidAll cache cleared: {factoid_all_cache_removed}",
+ f"Autocomplete cache cleared: {autocomplete_cache_removed}",
+ ]
)
+ )
+
+ await interaction.response.send_message(embed=embed)
- 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)
+ @factoid_app_group.command(
+ name="info",
+ description="Gets information about a factoid and displays it to the user.",
+ extras={"ephemeral_error": True},
+ )
+ @app_commands.autocomplete(factoid_name=filtered_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
- # 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,
+ 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()
+ # Make sure the factoid is valid
+ factoid = await self.get_valid_factoid(
+ interaction=interaction, factoid_name=factoid_name
)
+ if not factoid:
+ return
- # Logs the new parent change
- log_channel = configuration.get_config_entry(
- ctx.guild.id, "core_logging_channel"
+ has_embed = bool(factoid.json_string)
+
+ embed = discord.Embed(
+ title=f"Info about `{factoid_name}`", description=factoid.message
)
- 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,
+ embed.color = discord.Color.blue()
+ 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=has_embed)
+
+ # Handle properties different to convert from into to string
+ properties_str = (
+ ", ".join(prop.name.lower() for prop in Properties if factoid.flags & prop)
+ or "None"
)
+ embed.add_field(name="Properties", value=properties_str)
- 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()
+ embed.add_field(
+ name="Date of creation", value=f""
+ )
+ embed.add_field(
+ name="Last edit", value=f""
)
- # 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
+ jobs = await self.get_factoid_jobs_by_factoid_id(
+ interaction.guild, factoid.factoid_data_id
+ )
if jobs:
+ job_lines = []
+
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",
+ channel = interaction.guild.get_channel(int(job.channel))
+
+ if channel:
+ job_lines.append(f"{channel.mention} - `{job.cron}`")
+ else:
+ job_lines.append(f"Unknown channel ({job.channel}) - `{job.cron}`")
+
+ embed.add_field(
+ name=f"Jobs ({len(jobs)})",
+ value="\n".join(job_lines),
+ )
+
+ if has_embed:
+ view = InfoEmbedButtons(interaction.user.id, factoid, self)
+ await interaction.response.send_message(
+ embed=embed, view=view, ephemeral=True
+ )
+ view.message = interaction.original_response()
+ else:
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+
+ @factoid_app_group.command(
+ name="json",
+ description="Gets the json file for the embed of this factoid",
)
- async def flush(self: Self, ctx: commands.Context) -> None:
- """Command to flush all factoid caches
+ @app_commands.autocomplete(factoid_name=editing_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:
- ctx (commands.Context): Context of the invokation
+ interaction (discord.Interaction): The interaction that triggered this command
+ factoid_name (str): The factoid name to display information for
"""
- 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,
+ factoid_name = factoid_name.lower()
+ # Make sure the factoid is valid
+ factoid = await self.get_valid_factoid(
+ interaction=interaction, factoid_name=factoid_name
)
+ if not factoid:
+ return
+
+ if not factoid.json_string:
+ await self.respond_error_embed(
+ interaction,
+ f"The factoid `{factoid_name}` doesn't have any embed configured!",
+ )
+ return
+
+ json_file = self.create_json_file(factoid)
+
+ await interaction.response.send_message(file=json_file)
+
+ @factoid_loop_commands.command(
+ name="all",
+ description="Displays all active factoid loops for this guild",
+ )
+ async def factoid_loop_all_command(
+ self: Self,
+ interaction: discord.Interaction,
+ ) -> None:
+ """If any jobs exist, this will get and display all factoid jobs to the use
+
+ Args:
+ interaction (discord.Interaction): The interaction that triggered this command
+ """
+ jobs = await self.get_all_jobs_for_guild(interaction.guild)
+ if not jobs:
+ await self.respond_error_embed(
+ interaction, "There are no configured jobs for this guild"
+ )
+ return
+
+ embed = discord.Embed(title=f"Factoid loop for {interaction.guild.name}")
+ embed.color = discord.Color.blue()
+ job_lines = []
+
+ for job in jobs:
+ factoid = await self.get_factoid_view_by_id(
+ interaction.guild, job.factoid_data_id
+ )
+ channel = interaction.guild.get_channel(int(job.channel))
- # -- Property Commands --
+ if channel:
+ job_lines.append(
+ f"`[{', '.join(factoid.calls)}]` - {channel.mention} - `{job.cron}`"
+ )
+ else:
+ job_lines.append(
+ f"`[{', '.join(factoid.calls)}]` - Unknown channel ({job.channel}) - `{job.cron}`"
+ )
- # Hiding
+ embed.description = "\n".join(job_lines)
+ await interaction.response.send_message(embed=embed)
- @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]",
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_loop_commands.command(
+ name="create",
+ description="Creates a new factoid loop job in the specified channel",
)
- async def hide(
+ @app_commands.autocomplete(factoid_name=editing_factoid_autocomplete)
+ async def factoid_loop_create_command(
self: Self,
- ctx: commands.Context,
+ interaction: discord.Interaction,
factoid_name: str,
+ channel: discord.abc.GuildChannel,
+ cron: str,
) -> None:
- """Command to hide a factoid from the .factoid all command
+ """This edits an existing factoid, allowing changes to the properties, message, and embed
Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): Name of the factoid to hide
+ interaction (discord.Interaction): The interaction that triggered this command
+ factoid_name (str): The factoid to edit
+ channel (discord.abc.GuildChannel): The channel to put this loop in
+ cron (str): The crontab syntax to use for this job
"""
+ factoid_name = factoid_name.lower()
+ # Make sure the factoid is valid
+ factoid = await self.get_valid_factoid(
+ interaction=interaction, factoid_name=factoid_name
+ )
+ if not factoid:
+ return
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
+ if await self.check_protected(interaction, factoid):
+ return
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is protected and cannot be modified",
- channel=ctx.channel,
+ # We can only have 1 factoid have a job per channel
+ existing_job = await self.bot.models.FactoidJob.query.where(
+ (self.bot.models.FactoidJob.guild == str(interaction.guild.id))
+ & (self.bot.models.FactoidJob.factoid_data_id == factoid.factoid_data_id)
+ & (self.bot.models.FactoidJob.channel == str(channel.id))
+ ).gino.all()
+ if existing_job:
+ await self.respond_error_embed(
+ interaction,
+ f"The factoid `{factoid_name}` already has a job in {channel.mention}.",
)
return
- if factoid.hidden:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already hidden",
- channel=ctx.channel,
+ await interaction.response.defer()
+
+ # Use APSchduler to determine if the cron syntax is valid before scheduling
+ trigger = CronTrigger.from_crontab(cron)
+ now = datetime.datetime.utcnow()
+ run_at = trigger.get_next_fire_time(None, now)
+
+ if run_at is None:
+ await self.respond_error_embed(
+ interaction, f"The cron expression: `{cron}` is invalid."
)
- 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
+ job_data = await self.bot.models.FactoidJob.create(
+ guild=str(interaction.guild.id),
+ factoid_data_id=factoid.factoid_data_id,
+ channel=str(channel.id),
+ cron=cron,
+ )
+ await self.register_job(job_data)
+
+ # Update the factoid edit time
+ # This will also remove the factoid from the cache
+ await self.handle_factoid_edit(interaction.guild, factoid)
+
+ embed = auxiliary.prepare_confirm_embed(
+ f"The loop in {channel.mention} for factoid `{factoid_name}` was created successfully"
)
+ await interaction.followup.send(embed=embed)
- @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]",
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_loop_commands.command(
+ name="delete",
+ description="Deletes an existing factoid loop job based on name and channel",
)
- async def unhide(
+ @app_commands.autocomplete(factoid_name=editing_factoid_autocomplete)
+ async def factoid_loop_delete_command(
self: Self,
- ctx: commands.Context,
+ interaction: discord.Interaction,
factoid_name: str,
+ channel: discord.abc.GuildChannel,
) -> None:
- """Command to unhide a factoid from the .factoid all list
+ """This edits an existing factoid, allowing changes to the properties, message, and embed
Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): The name of the factoid to unhide
+ interaction (discord.Interaction): The interaction that triggered this command
+ factoid_name (str): The factoid to edit
+ channel (discord.abc.GuildChannel): The channel to put this loop in
+ cron (str): The crontab syntax to use for this job
"""
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
+ factoid_name = factoid_name.lower()
+ # Make sure the factoid is valid
+ factoid = await self.get_valid_factoid(
+ interaction=interaction, factoid_name=factoid_name
+ )
+ if not factoid:
+ return
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
+ # No edits on protected factoids
+ if await self.check_protected(interaction, factoid):
return
- if not factoid.hidden:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already unhidden",
- channel=ctx.channel,
+ factoid_job = await self.read_factoid_job_by_channel(
+ guild=interaction.guild,
+ factoid_data_id=factoid.factoid_data_id,
+ channel=channel,
+ )
+
+ if not factoid_job:
+ await self.respond_error_embed(
+ interaction,
+ f"The factoid `{factoid_name}` doesn't have a job in {channel.mention}!",
)
return
- factoid.hidden = False
- await self.modify_factoid_call(factoid=factoid)
+ await interaction.response.defer()
+ # We need to cancel the job in APScheduler and delete the database entry
+ await self.unschedule_job(factoid_job)
+ await factoid_job.delete()
- await auxiliary.send_confirm_embed(
- message=f"`{factoid_name}` is now unhidden", channel=ctx.channel
- )
+ # Update the factoid edit time
+ # This will also remove the factoid from the cache
+ await self.handle_factoid_edit(interaction.guild, factoid)
- # Protecting
+ embed = auxiliary.prepare_confirm_embed(
+ f"The loop in {channel.mention} for factoid `{factoid_name}` was deleted successfully"
+ )
+ await interaction.followup.send(embed=embed)
- @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]",
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_loop_commands.command(
+ name="edit",
+ description="Edits an existing factoid loop job based on name and channel",
)
- async def protect(
+ @app_commands.autocomplete(factoid_name=editing_factoid_autocomplete)
+ async def factoid_loop_edit_command(
self: Self,
- ctx: commands.Context,
+ interaction: discord.Interaction,
factoid_name: str,
+ channel: discord.abc.GuildChannel,
+ cron: str,
) -> None:
- """Command to protect a factoid from being deleted or modified
+ """This edits an existing job, changing the cron syntax
Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): Name of the factoid to hide
+ interaction (discord.Interaction): The interaction that triggered this command
+ factoid_name (str): The factoid to edit
+ channel (discord.abc.GuildChannel): The channel the job to edit is in
+ cron (str): The crontab syntax to use for this job
"""
+ factoid_name = factoid_name.lower()
+ # Make sure the factoid is valid
+ factoid = await self.get_valid_factoid(
+ interaction=interaction, factoid_name=factoid_name
+ )
+ if not factoid:
+ return
+
+ # No edits on protected factoids
+ if await self.check_protected(interaction, factoid):
+ return
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
+ factoid_job = await self.read_factoid_job_by_channel(
+ guild=interaction.guild,
+ factoid_data_id=factoid.factoid_data_id,
+ channel=channel,
+ )
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already protected",
- channel=ctx.channel,
+ if not factoid_job:
+ await self.respond_error_embed(
+ interaction,
+ f"The factoid `{factoid_name}` doesn't have a job in {channel.mention}!",
)
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
+ await interaction.response.defer()
+ # We need to update the database entry and reschedule the job
+ await self.unschedule_job(factoid_job)
+ await factoid_job.update(cron=cron).apply()
+ await self.register_job(factoid_job)
+
+ # Update the factoid edit time
+ # This will also remove the factoid from the cache
+ await self.handle_factoid_edit(interaction.guild, factoid)
+
+ embed = auxiliary.prepare_confirm_embed(
+ f"The loop in {channel.mention} for factoid `{factoid_name}` was edited successfully"
)
+ await interaction.followup.send(embed=embed)
- @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]",
+ @app_commands.checks.has_permissions(administrator=True)
+ @app_commands.check(has_admin_factoids_role)
+ @factoid_loop_commands.command(
+ name="refresh",
+ description="Refreshes all the scheduled factoid loops for this guild",
)
- async def unprotect(
+ async def factoid_loop_refresh_command(
self: Self,
- ctx: commands.Context,
- factoid_name: str,
+ interaction: discord.Interaction,
) -> None:
- """Command to unprotect a factoid and allow it to be deleted or modified
+ """This is designed to cancel and reschedule all jobs in the guild, for debug purposes
Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): The name of the factoid to unhide
+ interaction (discord.Interaction): The interaction that triggered this command
"""
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
+ jobs = await self.get_all_jobs_for_guild(interaction.guild)
+ if not jobs:
+ await self.respond_error_embed(
+ interaction, "There are no configured jobs for this guild"
+ )
+ return
- factoid.protected = False
- await self.modify_factoid_call(factoid=factoid)
+ await interaction.response.defer()
+ for job in jobs:
+ await self.unschedule_job(job)
+ await self.register_job(job)
- await auxiliary.send_confirm_embed(
- message=f"`{factoid_name}` is now unprotected", channel=ctx.channel
+ embed = auxiliary.prepare_confirm_embed(
+ f"Refreshed {len(jobs)} job{"s" if len(jobs)>1 else ""} in this guild"
)
+ await interaction.followup.send(embed=embed)
- # 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]",
+ @app_commands.check(has_manage_factoids_role)
+ @factoid_app_group.command(
+ name="property",
+ description="Modifies properites of the given factoid",
)
- async def restrict(
+ @app_commands.autocomplete(factoid_name=property_factoid_autocomplete)
+ async def factoid_property_command(
self: Self,
- ctx: commands.Context,
+ interaction: discord.Interaction,
factoid_name: str,
+ property: Properties,
+ set_value: bool,
) -> None:
- """Command to restrict a factoid to only certain channels
+ """This command modifies a given factoids property
+ This will flip the relevant bit if allowed
Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): Name of the factoid to hide
+ interaction (discord.Interaction): The interaction that called the command
+ factoid_name (str): The name of the factoid to edit
+ property (Properties): The property to edit
+ set_value (bool): What to set the property to
"""
+ factoid_name = factoid_name.lower()
+ # Make sure the factoid is valid
+ factoid = await self.get_valid_factoid(
+ interaction=interaction, factoid_name=factoid_name
+ )
+ if not factoid:
+ return
- 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,
- )
+ # No edits on protected factoids, unless we are modifying the protected flag
+ if property != Properties.PROTECTED and await self.check_protected(
+ interaction, factoid
+ ):
return
- if factoid.restricted:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already restricted",
- channel=ctx.channel,
+ # Check if the property is already set to the requested value
+ currently_set = bool(factoid.flags & property)
+
+ if currently_set == set_value:
+ state = "enabled" if set_value else "disabled"
+
+ await self.respond_error_embed(
+ interaction,
+ f"The property `{property.name.lower()}` is already {state} for `{factoid_name}`!",
)
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
+ # Apply the property change
+ if set_value:
+ new_flags = factoid.flags | property
+ else:
+ new_flags = factoid.flags & ~property
+
+ # Update the factoid edit time
+ # This will also remove the factoid from the cache
+ await self.handle_factoid_edit(
+ interaction.guild,
+ factoid,
+ )
+
+ factoid = await self.update_factoid_data(
+ guild=interaction.guild,
+ factoid_data_id=factoid.factoid_data_id,
+ flags=new_flags,
+ )
+
+ state = "enabled" if set_value else "disabled"
+
+ embed = auxiliary.prepare_confirm_embed(
+ message=(
+ f"The property `{property.name.lower()}` was successfully "
+ f"{state} for `{factoid_name}`!"
+ )
)
- @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]",
+ await interaction.response.send_message(embed=embed)
+
+ @factoid_app_group.command(
+ name="search",
+ description="Searches for factoids where the message or json match the query",
+ extras={"ephemeral_error": True},
)
- async def unrestrict(
+ async def factoid_search_command(
self: Self,
- ctx: commands.Context,
- factoid_name: str,
+ interaction: discord.Interaction,
+ query: str,
) -> None:
- """Command to allow a factoid to be called anywhere
+ """This will search all facatoids in the guild and display any that match the search query
+ This will filter out hidden factoids
Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): The name of the factoid to unhide
+ interaction (discord.Interaction): The interaction that triggered this command
"""
- 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,
+ query = query.lower()
+ if len(query) <= 3:
+ await self.respond_error_embed(
+ interaction, "The minimum search query length is 4 characters"
)
return
- if not factoid.restricted:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already unrestricted",
- channel=ctx.channel,
+ await interaction.response.defer(ephemeral=True)
+ all_factoids = await self.get_all_factoids_for_guild(guild=interaction.guild)
+
+ matching_factoids: list[FactoidView] = []
+
+ for factoid in all_factoids:
+ # Filter hidden factoids
+ if factoid.flags & Properties.HIDDEN:
+ continue
+
+ if query in factoid.message.lower() or query in factoid.json_string.lower():
+ matching_factoids.append(factoid)
+
+ if not matching_factoids:
+ await self.respond_error_embed(
+ interaction,
+ f"No factoids matched the query `{query}`!",
)
return
- factoid.restricted = False
- await self.modify_factoid_call(factoid=factoid)
+ title = f"Found {len(matching_factoids)} matching factoid{'s' if len(matching_factoids) != 1 else ''}"
- await auxiliary.send_confirm_embed(
- message=f"`{factoid_name}` is now unrestricted", channel=ctx.channel
- )
+ factoids = matching_factoids[:50]
+ embeds: list[discord.Embed] = []
+
+ for i in range(0, len(factoids), 5):
+ chunk = factoids[i : i + 5]
+
+ lines = [
+ f"`[{', '.join(factoid.calls)}]`: {factoid.message[:30]}"
+ for factoid in chunk
+ ]
+
+ embed = discord.Embed(
+ title=title, description="\n".join(lines), color=discord.Color.green()
+ )
+
+ embeds.append(embed)
- # Disabling
+ view = ui.PaginateView()
+ await view.send(
+ interaction.channel, interaction.user, embeds, interaction, True
+ )
- @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]",
+ @factoid_app_group.command(
+ name="top",
+ description="Displays the top 10 factoids by number of times called",
)
- async def disable(
+ async def factoid_top_command(
self: Self,
- ctx: commands.Context,
- factoid_name: str,
+ interaction: discord.Interaction,
) -> None:
- """Command to completely prevent a factoid from being called
+ """This will display the most commonly called factoids in this guild
+ It will ignore hidden factoids
Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): Name of the factoid to hide
+ interaction (discord.Interaction): The interaction that triggered this command
"""
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
+ all_factoids = await self.get_all_factoids_for_guild(guild=interaction.guild)
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is protected and cannot be modified",
- channel=ctx.channel,
+ visible_factoids: list[FactoidView] = []
+
+ for factoid in all_factoids:
+ # Ignore hidden factoids
+ if factoid.flags & Properties.HIDDEN:
+ continue
+
+ visible_factoids.append(factoid)
+
+ if not visible_factoids:
+ await self.respond_error_embed(
+ interaction,
+ "There are no factoids in this guild!",
)
return
- if factoid.disabled:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already disabled",
- channel=ctx.channel,
+ sorted_factoids = sorted(
+ visible_factoids,
+ key=lambda factoid: factoid.times_called,
+ reverse=True,
+ )
+
+ top_factoids = sorted_factoids[:10]
+
+ lines: list[str] = []
+
+ for index, factoid in enumerate(top_factoids, start=1):
+ lines.append(
+ f"{index}. `[{', '.join(factoid.calls)}]` - {factoid.times_called} call{'s' if factoid.times_called != 1 else ''}"
)
- 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
+ embed = discord.Embed(
+ title=f"Top {len(top_factoids)} factoid{'s' if len(top_factoids) != 1 else ''}",
+ description="\n".join(lines),
)
+ embed.color = discord.Color.blue()
- @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
+ await interaction.response.send_message(embed=embed)
+
+ # LEGACY - Prefix Calls
+ @commands.Cog.listener()
+ async def on_message(self: Self, message: discord.Message) -> None:
+ """This listens for any messages sent the bot can read
Args:
- ctx (commands.Context): Context of the invokation
- factoid_name (str): The name of the factoid to unhide
+ message (discord.Message): The message object sent
"""
- factoid = await self.get_factoid(factoid_name, str(ctx.guild.id))
+ # Do nothing if the extension is disabled
+ if not self.extension_enabled(message.guild):
+ return
- if factoid.protected:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is protected and cannot be modified",
- channel=ctx.channel,
- )
+ # Do nothing if the prefix calls are disabled
+ if configuration.get_config_entry(message.guild.id, "factoids_disable_prefix"):
+ return
+
+ # Do nothing if the message does not start with a factoid prefix
+ raw_content = message.clean_content
+ factoid_prefix = configuration.get_config_entry(
+ message.guild.id, "factoids_prefix"
+ )
+ if not message.clean_content.startswith(factoid_prefix):
+ return
+
+ factoid_name = (
+ raw_content[len(factoid_prefix) :].replace("\n", " ").split(" ")[0].lower()
+ )
+ factoid = factoid = await self.get_factoid_view_by_name(
+ guild=message.guild, name=factoid_name
+ )
+ if not factoid:
+ return
+
+ # Check if factoid is disabled. If so, don't send it
+ if factoid.flags & Properties.DISABLED:
+ return
+
+ # Check if factoid is restricted. If so, check if we can call it
+ if (
+ factoid.flags & Properties.RESTRICTED
+ and not self.can_channel_send_restricted(message.channel)
+ ):
return
- if not factoid.disabled:
- await auxiliary.send_deny_embed(
- message=f"`{factoid_name}` is already enabled",
- channel=ctx.channel,
+ # At this point we know factoids is enabled, as is prefix factoids
+ # We know the message is trying to call a factoid, and that the factoid exists
+ # We also know we should be able to send the factoid in this channel
+ embed, plaintext_content = await self.generate_sendable_factoid(
+ message.guild, factoid
+ )
+
+ # Log in the background
+ asyncio.create_task(
+ self.log_factoid_send(
+ guild=message.guild,
+ channel=message.channel,
+ sender=message.author,
+ factoid=factoid,
+ )
+ )
+ mentions = auxiliary.construct_mention_string(message.mentions)
+
+ embed_sent = False
+ sent_message = None
+ if embed:
+ try:
+ # Attempt to send the message with the embed in it
+ sent_message = await message.reply(
+ content=mentions,
+ embed=embed,
+ )
+ embed_sent = True
+ # If something breaks, also log it
+ except discord.errors.HTTPException as exception:
+ asyncio.create_task(
+ self.log_embed_fallback_exception(
+ factoid=factoid,
+ exception=exception,
+ guild=message.guild,
+ channel=message.channel,
+ )
+ )
+
+ # 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"{mentions if mentions else ""}{plaintext_content}"
+ content = content.strip()[:2000]
+ # The can't see button is not needed in plaintext cases
+ sent_message = await message.reply(content=content)
+
+ if not sent_message:
+ # This is a major error, and should never happen
+ await self.logger.send_log(
+ message="Factoid sent_message not found. Critical failure",
+ level=LogLevel.ERROR,
+ context=LogContext(guild=message.guild, channel=message.channel),
)
return
- factoid.disabled = False
- await self.modify_factoid_call(factoid=factoid)
+ # IRC connection
+ self.send_factoid_to_irc(message.channel, factoid, message.author)
- await auxiliary.send_confirm_embed(
- message=f"`{factoid_name}` is now enabled", channel=ctx.channel
+ # Logger connection
+ await self.send_factoid_to_logger(
+ sent_message, message.author, message.channel, factoid.message
)
+ # Increase times called
+ await self.increment_times_called_by_view(guild=message.guild, factoid=factoid)
+
-class DeleteView(discord.ui.View):
+class ButtonView(discord.ui.View):
"""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=60)
+ # At a point in which we migrate to components, this view should take over sending and processing the embed/plaintext factiods
+ # A new view entirely designed around components should exist to send those factoids
+
+ 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)
+
+ # Be memory safe and clear these objects
+ self.factoid = None
+ self.message = None
@discord.ui.button(label="Delete", style=discord.ButtonStyle.danger, emoji="🗑️")
async def delete_button(
@@ -3003,9 +3172,60 @@ 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(
+ "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
+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
@@ -3018,37 +3238,84 @@ 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}")
+ 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",
+ ),
+ ],
),
- discord.CheckboxGroupOption(
- default=False, label="Restricted", value="restricted"
+ )
+
+ 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)
+
+ if not edit_mode:
+ property_options = []
+
+ for prop in Properties:
+ property_options.append(
+ discord.CheckboxGroupOption(
+ label=prop.name.title(),
+ value=str(prop),
+ default=(bool(factoid.flags & prop) 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
@@ -3058,3 +3325,74 @@ async def on_submit(self: Self, interaction: discord.Interaction) -> None:
"""
await interaction.response.defer()
return
+
+
+class InfoEmbedButtons(discord.ui.View):
+ """This adds up to 3 buttons for relevant factoids
+ This also handles the pressing of these buttons
+ """
+
+ def __init__(
+ self: Self, author_id: int, factoid: FactoidView, cog: FactoidManager
+ ) -> None:
+ super().__init__(timeout=600)
+ self.author_id = author_id
+ self.factoid: FactoidView = factoid
+ self.cog = cog
+ self.message: discord.Message | None = None
+
+ async def on_timeout(self: Self) -> None:
+ """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=self)
+
+ # Be memory safe and clear these objects
+ self.factoid = None
+ self.message = None
+ self.cog = None
+
+ @discord.ui.button(label="Show JSON file", style=discord.ButtonStyle.blurple)
+ async def show_json(
+ self: Self,
+ interaction: discord.Interaction,
+ button: discord.ui.Button,
+ ) -> None:
+ """The function called when the show json file button is pressed
+
+ Args:
+ interaction (discord.Interaction): The interaction that pressed the button
+ button (discord.ui.Button): The button object itself
+ """
+ json_file = self.cog.create_json_file(self.factoid)
+ await interaction.response.send_message(file=json_file, ephemeral=True)
+
+ @discord.ui.button(label="Show embed", style=discord.ButtonStyle.blurple)
+ async def show_embed(
+ self: Self,
+ interaction: discord.Interaction,
+ button: discord.ui.Button,
+ ) -> None:
+ """The function called when the show embed is pressed
+
+ Args:
+ interaction (discord.Interaction): The interaction that pressed the button
+ button (discord.ui.Button): The button object itself
+ """
+ embed, _ = await self.cog.generate_sendable_factoid(
+ interaction.guild, self.factoid
+ )
+ if not embed:
+ await self.cog.respond_error_embed(
+ "The embed for this factoid could not be generated"
+ )
+
+ try:
+ await interaction.response.send_message(embed=embed, ephemeral=True)
+ except discord.HTTPException:
+ await self.cog.respond_error_embed(
+ "The embed for this factoid could not be sent"
+ )
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(