Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Database and tags #22

Merged
merged 9 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
8 changes: 2 additions & 6 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,10 @@ on:
push:
branches:
- main
paths:
- dynamo/**/*.py
- tests/**/*.py
pull_request:
types: [opened, reopened, synchronize]
paths:
- dynamo/**/*.py
- tests/**/*.py
branches:
- main

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand Down
31 changes: 18 additions & 13 deletions dynamo/_types.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from collections.abc import Coroutine, Mapping
from collections.abc import Callable, Coroutine, Mapping
from typing import Any, ParamSpec, Protocol, TypeVar

from discord import Interaction as DInter
from discord import app_commands
from discord.ext import commands

Expand All @@ -18,18 +19,9 @@
ContextT = TypeVar("ContextT", bound=commands.Context[Any], covariant=True)


class WrappedCoroutine[**P, T](Protocol):
"""A coroutine that has been wrapped by a decorator."""

__name__: str

def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, T]: ...


class DecoratedCoroutine[**P, T](Protocol):
"""A decorator that has been applied to a coroutine."""

def __call__(self, wrapped: WrappedCoroutine[P, T]) -> T: ...
type Coro[T] = Coroutine[Any, Any, T]
type WrappedCoro[**P, T] = Callable[P, Coro[T]]
type DecoratedCoro[**P, T] = Callable[[WrappedCoro[P, T]], T]


class NotFoundWithHelp(commands.CommandError): ...
Expand Down Expand Up @@ -84,3 +76,16 @@ def __repr__(self):


MISSING: Any = _MissingSentinel()


class RawSubmittableCls(Protocol):
@classmethod
async def raw_submit(cls: type["RawSubmittableCls"], interaction: DInter, data: str) -> Any: ...


class RawSubmittableStatic(Protocol):
@staticmethod
async def raw_submit(interaction: DInter, data: str) -> Any: ...


type RawSubmittable = RawSubmittableCls | RawSubmittableStatic
12 changes: 11 additions & 1 deletion dynamo/core/base_cog.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from discord.ext import commands

from dynamo._types import RawSubmittable
from dynamo.utils.helper import get_cog

if TYPE_CHECKING:
Expand All @@ -14,9 +15,18 @@
class DynamoCog(commands.Cog):
__slots__ = ("bot", "log")

def __init__(self, bot: Dynamo, case_insensitive: bool = True) -> None:
def __init__(
self,
bot: Dynamo,
raw_modal_submits: dict[str, type[RawSubmittable]] | None = None,
raw_button_submits: dict[str, type[RawSubmittable]] | None = None,
) -> None:
self.bot: Dynamo = bot
self.log = logging.getLogger(get_cog(self.__class__.__name__))
if raw_modal_submits is not None:
self.bot.raw_modal_submits.update(raw_modal_submits)
if raw_button_submits is not None:
self.bot.raw_button_submits.update(raw_button_submits)

async def cog_load(self) -> None:
self.log.debug("%s cog loaded", self.__class__.__name__)
39 changes: 30 additions & 9 deletions dynamo/core/bot.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
from __future__ import annotations

import logging
import re
from collections.abc import AsyncGenerator, Generator
from typing import Any, cast
from typing import Any, Self, cast

import aiohttp
import apsw
import discord
import msgspec
import xxhash
from discord import app_commands
from discord.ext import commands

from dynamo._types import RawSubmittable
from dynamo.utils.context import Context
from dynamo.utils.emoji import Emojis
from dynamo.utils.helper import get_cog, platformdir, resolve_path_with_links
Expand All @@ -24,12 +27,16 @@
get_cog("events"),
get_cog("general"),
get_cog("info"),
get_cog("tags"),
)

description = """
Quantum entanglement.
"""

modal_regex = re.compile(r"^m:(.{1,10}):(.*)$", flags=re.DOTALL)
button_regex = re.compile(r"^b:(.{1,10}):(.*)$", flags=re.DOTALL)


class VersionableTree(app_commands.CommandTree["Dynamo"]):
application_commands: dict[int | None, list[app_commands.AppCommand]]
Expand Down Expand Up @@ -148,23 +155,22 @@ def _prefix_callable(bot: Dynamo, msg: discord.Message) -> list[str]:
return base


type Interaction = discord.Interaction[Dynamo]


class Dynamo(commands.AutoShardedBot):
session: aiohttp.ClientSession
connector: aiohttp.TCPConnector
conn: apsw.Connection
context: Context
logging_handler: Any
bot_app_info: discord.AppInfo

def __init__(self, connector: aiohttp.TCPConnector, session: aiohttp.ClientSession) -> None:
def __init__(self, connector: aiohttp.TCPConnector, conn: apsw.Connection, session: aiohttp.ClientSession) -> None:
self.session = session
self.conn = conn
allowed_mentions = discord.AllowedMentions(roles=False, everyone=False, users=True)
intents = discord.Intents(
guilds=True,
members=True,
messages=True,
message_content=True,
presences=True,
)
intents = discord.Intents(guilds=True, members=True, messages=True, message_content=True, presences=True)
super().__init__(
connector=connector,
command_prefix=_prefix_callable,
Expand All @@ -179,6 +185,8 @@ def __init__(self, connector: aiohttp.TCPConnector, session: aiohttp.ClientSessi
tree_cls=VersionableTree,
activity=discord.Activity(name="The Cursed Apple", type=discord.ActivityType.watching),
)
self.raw_modal_submits: dict[str, RawSubmittable] = {}
self.raw_button_submits: dict[str, RawSubmittable] = {}

async def setup_hook(self) -> None:
self.prefixes: dict[int, list[str]] = {}
Expand Down Expand Up @@ -234,6 +242,19 @@ async def on_ready(self) -> None:

log.info("Ready: %s (ID: %s)", self.user, self.user.id)

async def on_interaction(self, interaction: discord.Interaction[Self]) -> None:
for typ, regex, mapping in (
(discord.InteractionType.modal_submit, modal_regex, self.raw_modal_submits),
(discord.InteractionType.component, button_regex, self.raw_button_submits),
):
if interaction.type is typ:
assert interaction.data is not None
custom_id = interaction.data.get("custom_id", "")
if match := regex.match(custom_id):
modal_name, data = match.groups()
if rs := mapping.get(modal_name):
await rs.raw_submit(interaction, data)

async def get_context[ContextT: commands.Context[Any]](
self,
origin: discord.Message | discord.Interaction,
Expand Down
97 changes: 52 additions & 45 deletions dynamo/extensions/cogs/dev.py
Original file line number Diff line number Diff line change
@@ -1,73 +1,80 @@
import importlib
import sys
from typing import Literal

import discord
from discord.ext import commands

from dynamo.core import Dynamo, DynamoCog
from dynamo.utils.checks import is_owner
from dynamo.utils.context import Context
from dynamo.utils.converter import GuildConverter
from dynamo.utils.emoji import Emojis
from dynamo.utils.format import code_block
from dynamo.utils.helper import get_cog

type SyncSpec = Literal["~", "*", "^"]


class Dev(DynamoCog):
"""Dev-only commands"""

def __init__(self, bot: Dynamo) -> None:
super().__init__(bot)

@commands.hybrid_group(invoke_without_command=True, name="sync", aliases=("s",))
@commands.hybrid_command(name="sync", aliases=("s",))
@commands.guild_only()
@is_owner()
async def sync(
self,
ctx: Context,
guild: discord.Guild = commands.param(converter=GuildConverter, displayed_name="guild_id"),
copy: bool = False,
) -> None:
"""Sync slash commands
async def sync(self, ctx: Context, guilds: commands.Greedy[discord.Object], spec: SyncSpec | None = None) -> None:
"""Sync application commands globally or with guilds

Parameters
----------
guild_id: int | None
The ID of the guild to sync commands to. Current guild by default.
copy: bool
Copy global commands to the specified guild. (Default: False)
guilds: commands.Greedy[discord.Object]
The guilds to sync the commands to
spec: SyncSpec | None, optional
The sync specification, by default None

See
---
- https://about.abstractumbra.dev/discord.py/2023/01/29/sync-command-example.html
"""
if copy:
self.bot.tree.copy_global_to(guild=guild)

commands = await self.bot.tree.sync(guild=guild)
await ctx.send(f"Successfully synced {len(commands)} commands")

@sync.command(name="global", aliases=("g",))
@is_owner()
async def sync_global(self, ctx: Context) -> None:
"""Sync global slash commands"""
commands = await self.bot.tree.sync(guild=None)
await ctx.send(f"Successfully synced {len(commands)} commands")

@sync.command(name="clear", aliases=("c",))
@is_owner()
async def clear_commands(
self,
ctx: Context,
guild: discord.Guild = commands.param(converter=GuildConverter, displayed_name="guild_id"),
) -> None:
"""Clear all slash commands
if not ctx.guild:
return

Parameters
----------
guild_id: int | None
The ID of the guild to clear commands from. Current guild by default.
"""
if not await ctx.prompt("Are you sure you want to clear all commands?"):
if not guilds:
synced = await self._sync_commands(ctx.guild, spec)
scope = "globally" if spec is None else "to the current guild"
await ctx.send(f"Synced {len(synced)} commands {scope}.")
return

self.bot.tree.clear_commands(guild=guild)
await ctx.send("Successfully cleared all commands")
success = await self._sync_to_guilds(guilds)
await ctx.send(f"Synced the tree to {success}/{len(guilds)} guilds.")

async def _sync_commands(
self, guild: discord.Guild, spec: SyncSpec | None
) -> list[discord.app_commands.AppCommand]:
# This will sync all guild commands for the current context's guild.
if spec == "~":
return await self.bot.tree.sync(guild=guild)
# This will copy all global commands to the current guild (within the CommandTree) and syncs.
if spec == "*":
self.bot.tree.copy_global_to(guild=guild)
return await self.bot.tree.sync(guild=guild)
# This command will remove all guild commands from the CommandTree and syncs,
# which effectively removes all commands from the guild.
if spec == "^":
self.bot.tree.clear_commands(guild=guild)
await self.bot.tree.sync(guild=guild)
return []
# This takes all global commands within the CommandTree and sends them to Discord
return await self.bot.tree.sync()

async def _sync_to_guilds(self, guilds: commands.Greedy[discord.Object]) -> int:
success = 0
for guild in guilds:
try:
await self.bot.tree.sync(guild=guild)
success += 1
except discord.HTTPException:
self.log.exception("Failed to sync guild %s", guild.id)
return success

@commands.hybrid_command(name="load", aliases=("l",))
@is_owner()
Expand Down
7 changes: 3 additions & 4 deletions dynamo/extensions/cogs/errors.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
from collections.abc import Callable, Coroutine
from typing import Any
from collections.abc import Callable

import discord
from discord import Interaction, app_commands
from discord.ext import commands
from rapidfuzz import fuzz

from dynamo._types import NotFoundWithHelp, app_command_error_messages, command_error_messages
from dynamo._types import Coro, NotFoundWithHelp, app_command_error_messages, command_error_messages
from dynamo.core import Dynamo, DynamoCog
from dynamo.utils.context import Context

AppCommandErrorMethod = Callable[[Interaction[Dynamo], app_commands.AppCommandError], Coroutine[Any, Any, None]]
type AppCommandErrorMethod = Callable[[Interaction[Dynamo], app_commands.AppCommandError], Coro[None]]


class Errors(DynamoCog):
Expand Down
9 changes: 3 additions & 6 deletions dynamo/extensions/cogs/general.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,13 @@
from dynamo.core import Dynamo, DynamoCog
from dynamo.utils import spotify
from dynamo.utils.context import Context
from dynamo.utils.converter import SeedConverter
from dynamo.utils.converter import MemberLikeConverter
from dynamo.utils.identicon import Identicon, derive_seed, get_colors, get_identicon, seed_from_time


class General(DynamoCog):
"""Generic commands"""

def __init__(self, bot: Dynamo) -> None:
super().__init__(bot)

async def generate_identicon(
self, seed: discord.Member | str | int, guild: discord.Guild | None
) -> tuple[discord.Embed, discord.File]:
Expand Down Expand Up @@ -66,7 +63,7 @@ async def ping(self, ctx: Context) -> None:
async def identicon(
self,
ctx: Context,
seed: discord.Member | str | int = commands.param(converter=SeedConverter, default=""),
seed: discord.Member | str | int = commands.param(converter=MemberLikeConverter, default=""),
) -> None:
"""Generate an identicon from a user or string

Expand All @@ -82,7 +79,7 @@ async def identicon(
async def spotify(
self,
ctx: Context,
user: discord.User | discord.Member | None = commands.param(default=None, converter=commands.MemberConverter),
user: discord.User | discord.Member | None = commands.param(default=None, converter=MemberLikeConverter),
) -> None:
"""Get the currently playing Spotify track for a user.

Expand Down
Loading