Skip to content
This repository has been archived by the owner on Mar 13, 2023. It is now read-only.

Commit

Permalink
Merge pull request #617 from NAFTeam/dev
Browse files Browse the repository at this point in the history
Dev
  • Loading branch information
LordOfPolls authored Aug 28, 2022
2 parents db1ff7d + c093059 commit 0081ecb
Show file tree
Hide file tree
Showing 27 changed files with 1,156 additions and 53 deletions.
1 change: 1 addition & 0 deletions docs/src/API Reference/models/Naff/hybrid_commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: naff.models.naff.hybrid_commands
1 change: 1 addition & 0 deletions docs/src/API Reference/models/Naff/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- [Converters](converters)
- [Cooldowns](cooldowns)
- [Extension](extension)
- [Hybrid Commands](hybrid_commands)
- [Listeners](listener)
- [Localisation](localisation)
- [Prefixed Commands](prefixed_commands)
Expand Down
19 changes: 19 additions & 0 deletions docs/src/Guides/03 Creating Commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,22 @@ There also is `on_command` which you can overwrite too. That fires on every inte
If your bot is complex enough, you might find yourself wanting to use custom models in your commands.

To do this, you'll want to use a string option, and define a converter. Information on how to use converters can be found [on the converter page](/Guides/08 Converters).

## I Want To Make A Prefixed Command Too

You're in luck! You can use a hybrid command, which is a slash command that also gets converted to an equivalent prefixed command under the hood.

To use it, simply replace `@slash_command` with `@hybrid_command`, and `InteractionContext` with `HybridContext`, like so:

```python
@hybrid_command(name="my_command", description="My hybrid command!")
async def my_command_function(ctx: HybridContext):
await ctx.send("Hello World")
```

Suggesting you are using the default mention settings for your bot, you should be able to run this command by `@BotPing my_command`.

As you can see, the only difference between hybrid commands and slash commands, from a developer perspective, is that they use `HybridContext`, which attempts
to seamlessly allow using the same context for slash and prefixed commands. You can always get the underlying context via `inner_context`, though.

There are only two limitations with them: they only support one attachment option, and they do not support autocomplete.
30 changes: 30 additions & 0 deletions docs/src/Guides/24 Error Tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Error Tracking

So, you've finally got your bot running on a server somewhere. Chances are, you're not checking the console output 24/7, looking for exceptions.

You're going to want to have some way of tracking if errors occur.

# The simple and dirty method

!!! Please don't actually do this.

The most obvious solution is to think "Well, I'm writing a Discord Bot. Why not send my errors to a discord channel?"

```python

@listen()
async def on_error(error):
await bot.get_channel(LOGGING_CHANNEL_ID).send(f"```\n{error.source}\n{error.error}\n```)
```
And this is great when debugging. But it consumes your rate limit, can run into the 2000 character message limit, and won't work on shards that don't contain your personal server. It's also very hard to notice patterns and can be noisy.

# So what should I do instead?

NAFF contains built-in support for Sentry.io, a cloud error tracking platform.

To enable it, call `bot.load_extension('naff.ext.sentry', token=SENTRY_TOKEN)` as early as possible in your startup. (Load it before your own extensions, so it can catch intitialization errors in those extensions)

# What does this do that vanilla Sentry doesn't?

We add some [tags](https://docs.sentry.io/platforms/python/enriching-events/tags/) and [contexts](https://docs.sentry.io/platforms/python/enriching-events/context/) that might be useful, and filter out some internal-errors that you probably don't want to see.
29 changes: 28 additions & 1 deletion naff/api/events/internal.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ def on_guild_join(event):
"""
import re
from typing import TYPE_CHECKING, Any, Optional
from typing import TYPE_CHECKING, Any, Optional, Callable, Coroutine

from naff.client.const import MISSING
from naff.models.discord.snowflake import to_snowflake
from naff.client.utils.attr_utils import define, field, docs
import naff.models as models

__all__ = (
"BaseEvent",
Expand Down Expand Up @@ -70,6 +71,32 @@ def resolved_name(self) -> str:
name = self.override_name or self.__class__.__name__
return _event_reg.sub("_", name).lower()

@classmethod
def listen(cls, coro: Callable[..., Coroutine], client: "Client") -> "models.Listener":
"""
A shortcut for creating a listener for this event
Args:
coro: The coroutine to call when the event is triggered.
client: The client instance to listen to.
??? Hint "Example Usage:"
```python
class SomeClass:
def __init__(self, bot: Client):
Ready.listen(self.some_func, bot)
async def some_func(self, event):
print(f"{event.resolved_name} triggered")
```
Returns:
A listener object.
"""
listener = models.Listener.create(cls().resolved_name)(coro)
client.add_listener(listener)
return listener


@define(slots=False, kw_only=False)
class GuildEvent:
Expand Down
2 changes: 1 addition & 1 deletion naff/api/events/processors/guild_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async def _on_raw_guild_create(self, event: "RawGatewayEvent") -> None:

if self.fetch_members: # noqa
# delays events until chunking has completed
await guild.chunk_guild(presences=True)
await guild.chunk()

self.dispatch(events.GuildJoin(guild))

Expand Down
8 changes: 4 additions & 4 deletions naff/api/http/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
)
from naff.client.errors import DiscordError, Forbidden, GatewayNotFound, HTTPException, NotFound, LoginError
from naff.client.utils.input_utils import response_decode, OverriddenJson
from naff.client.utils.serializer import dict_filter_missing
from naff.client.utils.serializer import dict_filter
from naff.models import CooldownSystem
from naff.models.discord.file import UPLOADABLE_TYPE
from .route import Route
Expand Down Expand Up @@ -212,9 +212,9 @@ def _process_payload(payload: dict | list[dict], files: Absent[list[UPLOADABLE_T
return None

if isinstance(payload, dict):
payload = dict_filter_missing(payload)
payload = dict_filter(payload)
else:
payload = [dict_filter_missing(x) if isinstance(x, dict) else x for x in payload]
payload = [dict_filter(x) if isinstance(x, dict) else x for x in payload]

if not files:
return payload
Expand Down Expand Up @@ -262,7 +262,7 @@ async def request(
if isinstance(payload, (list, dict)) and not files:
kwargs["headers"]["Content-Type"] = "application/json"
if isinstance(params, dict):
kwargs["params"] = dict_filter_missing(params)
kwargs["params"] = dict_filter(params)

lock = self.get_ratelimit(route)
# this gets a BucketLock for this route.
Expand Down
4 changes: 2 additions & 2 deletions naff/api/http/http_requests/guild.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import discord_typings

from naff.client.const import Absent, MISSING
from naff.client.utils.serializer import dict_filter_missing, dict_filter_none
from naff.client.utils.serializer import dict_filter, dict_filter_none


from ..route import Route
Expand Down Expand Up @@ -661,7 +661,7 @@ async def create_guild(
) -> dict:
return await self.request(
Route("POST", "/guilds"),
payload=dict_filter_missing(
payload=dict_filter(
{
"name": name,
"icon": icon,
Expand Down
13 changes: 10 additions & 3 deletions naff/api/http/http_requests/interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,29 @@ async def delete_application_command(
)

async def get_application_commands(
self, application_id: "Snowflake_Type", guild_id: "Snowflake_Type"
self, application_id: "Snowflake_Type", guild_id: "Snowflake_Type", with_localisations: bool = True
) -> List[discord_typings.ApplicationCommandData]:
"""
Get all application commands for this application from discord.
Args:
application_id: the what application to query
guild_id: specify a guild to get commands from
with_localisations: whether to include all localisations in the response
Returns:
Application command data
"""
if guild_id == GLOBAL_SCOPE:
return await self.request(Route("GET", f"/applications/{application_id}/commands"))
return await self.request(Route("GET", f"/applications/{application_id}/guilds/{guild_id}/commands"))
return await self.request(
Route("GET", f"/applications/{application_id}/commands"),
params={"with_localizations": int(with_localisations)},
)
return await self.request(
Route("GET", f"/applications/{application_id}/guilds/{guild_id}/commands"),
params={"with_localizations": int(with_localisations)},
)

async def overwrite_application_commands(
self, app_id: "Snowflake_Type", data: List[Dict], guild_id: "Snowflake_Type" = None
Expand Down
54 changes: 47 additions & 7 deletions naff/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
InteractionCommand,
SlashCommand,
OptionTypes,
HybridCommand,
PrefixedCommand,
BaseCommand,
to_snowflake,
Expand All @@ -78,6 +79,7 @@
ModalContext,
PrefixedContext,
AutocompleteContext,
HybridContext,
ComponentCommand,
Context,
application_commands_to_dict,
Expand All @@ -95,6 +97,7 @@
from naff.models.naff.active_voice_state import ActiveVoiceState
from naff.models.naff.application_commands import ModalCommand
from naff.models.naff.auto_defer import AutoDefer
from naff.models.naff.hybrid_commands import _prefixed_from_slash, _base_subcommand_generator
from naff.models.naff.listener import Listener
from naff.models.naff.tasks import Task

Expand Down Expand Up @@ -215,6 +218,7 @@ class Client(
component_context: Type[ComponentContext]: The object to instantiate for Component Context
autocomplete_context: Type[AutocompleteContext]: The object to instantiate for Autocomplete Context
modal_context: Type[ModalContext]: The object to instantiate for Modal Context
hybrid_context: Type[HybridContext]: The object to instantiate for Hybrid Context
global_pre_run_callback: Callable[..., Coroutine]: A coroutine to run before every command is executed
global_post_run_callback: Callable[..., Coroutine]: A coroutine to run after every command is executed
Expand Down Expand Up @@ -259,6 +263,7 @@ def __init__(
owner_ids: Iterable["Snowflake_Type"] = (),
modal_context: Type[ModalContext] = ModalContext,
prefixed_context: Type[PrefixedContext] = PrefixedContext,
hybrid_context: Type[HybridContext] = HybridContext,
send_command_tracebacks: bool = True,
shard_id: int = 0,
status: Status = Status.ONLINE,
Expand Down Expand Up @@ -317,6 +322,8 @@ def __init__(
"""The object to instantiate for Autocomplete Context"""
self.modal_context: Type[ModalContext] = modal_context
"""The object to instantiate for Modal Context"""
self.hybrid_context: Type[HybridContext] = hybrid_context
"""The object to instantiate for Hybrid Context"""

# flags
self._ready = asyncio.Event()
Expand Down Expand Up @@ -488,6 +495,7 @@ def _sanity_check(self) -> None:
self.component_context: ComponentContext,
self.autocomplete_context: AutocompleteContext,
self.modal_context: ModalContext,
self.hybrid_context: HybridContext,
}
for obj, expected in contexts.items():
if not issubclass(obj, expected):
Expand Down Expand Up @@ -729,13 +737,6 @@ async def _on_websocket_ready(self, event: events.RawGatewayEvent) -> None:
while True:
try: # wait to let guilds cache
await asyncio.wait_for(self._guild_event.wait(), self.guild_event_timeout)
if self.fetch_members:
# ensure all guilds have completed chunking
for guild in self.guilds:
if guild and not guild.chunked.is_set():
logger.debug(f"Waiting for {guild.id} to chunk")
await guild.chunked.wait()

except asyncio.TimeoutError:
logger.warning("Timeout waiting for guilds cache: Not all guilds will be in cache")
break
Expand Down Expand Up @@ -1105,6 +1106,43 @@ def add_interaction(self, command: InteractionCommand) -> bool:

return True

def add_hybrid_command(self, command: HybridCommand) -> bool:
if self.debug_scope:
command.scopes = [self.debug_scope]

if command.callback is None:
return False

if command.is_subcommand:
prefixed_base = self.prefixed_commands.get(str(command.name))
if not prefixed_base:
prefixed_base = _base_subcommand_generator(
str(command.name), list(command.name.to_locale_dict().values()), str(command.description)
)
self.add_prefixed_command(prefixed_base)

if command.group_name: # if this is a group command
_prefixed_cmd = prefixed_base
prefixed_base = prefixed_base.subcommands.get(str(command.group_name))

if not prefixed_base:
prefixed_base = _base_subcommand_generator(
str(command.group_name),
list(command.group_name.to_locale_dict().values()),
str(command.group_description),
group=True,
)
_prefixed_cmd.add_command(prefixed_base)

new_command = _prefixed_from_slash(command)
new_command._parse_parameters()
prefixed_base.add_command(new_command)
else:
new_command = _prefixed_from_slash(command)
self.add_prefixed_command(new_command)

return self.add_interaction(command)

def add_prefixed_command(self, command: PrefixedCommand) -> None:
"""
Add a prefixed command to the client.
Expand Down Expand Up @@ -1175,6 +1213,8 @@ def process(_cmds) -> None:
self.add_modal_callback(func)
elif isinstance(func, ComponentCommand):
self.add_component_callback(func)
elif isinstance(func, HybridCommand):
self.add_hybrid_command(func)
elif isinstance(func, InteractionCommand):
self.add_interaction(func)
elif (
Expand Down
14 changes: 10 additions & 4 deletions naff/client/utils/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from naff.client.const import MISSING, T
from naff.models.discord.file import UPLOADABLE_TYPE, File

__all__ = ("no_export_meta", "export_converter", "to_dict", "dict_filter_none", "dict_filter_missing", "to_image_data")
__all__ = ("no_export_meta", "export_converter", "to_dict", "dict_filter_none", "dict_filter", "to_image_data")

no_export_meta = {"no_export": True}

Expand Down Expand Up @@ -95,9 +95,9 @@ def dict_filter_none(data: dict) -> dict:
return {k: v for k, v in data.items() if v is not None}


def dict_filter_missing(data: dict) -> dict:
def dict_filter(data: dict) -> dict:
"""
Filters out all values that are MISSING sentinel.
Filters out all values that are MISSING sentinel and converts all sets to lists.
Args:
data: The dict data to filter.
Expand All @@ -106,7 +106,13 @@ def dict_filter_missing(data: dict) -> dict:
The filtered dict data.
"""
return {k: v for k, v in data.items() if v is not MISSING}
filtered = data.copy()
for k, v in data.items():
if v is MISSING:
filtered.pop(k)
elif isinstance(v, set):
filtered[k] = list(v)
return filtered


def to_image_data(imagefile: Optional[UPLOADABLE_TYPE]) -> Optional[str]:
Expand Down
Loading

0 comments on commit 0081ecb

Please sign in to comment.