Skip to content

Commit 1e85c0f

Browse files
Merge: pull request #1404 from interactions-py/unstable
5.4.0
2 parents 3655951 + 79e9b97 commit 1e85c0f

File tree

13 files changed

+182
-34
lines changed

13 files changed

+182
-34
lines changed

Diff for: docs/src/Guides/99 2.x Migration_NAFF.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ from interactions.ext import prefixed_commands
1717
client = Client(..., intents=Intents.GUILD_MESSAGES | ...)
1818
prefixed_commands.setup(client)
1919
```
20-
From here it's more or less the same as before. You can find a guide on how to use prefixed commands [here](/Guides/26 Prefixed Commands.md).
20+
From here it's more or less the same as before. You can find a guide on how to use prefixed commands [here](/Guides/26 Prefixed Commands/).
2121

2222
## Hybrid Commands
2323
For now, hybrid commands are not supported, but they will be in the future.

Diff for: interactions/api/http/http_client.py

+27-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import aiohttp
1212
import discord_typings
13-
from aiohttp import BaseConnector, ClientSession, ClientWebSocketResponse, FormData
13+
from aiohttp import BaseConnector, ClientSession, ClientWebSocketResponse, FormData, BasicAuth
1414
from multidict import CIMultiDictProxy
1515

1616
import interactions.client.const as constants
@@ -214,7 +214,11 @@ class HTTPClient(
214214
"""A http client for sending requests to the Discord API."""
215215

216216
def __init__(
217-
self, connector: BaseConnector | None = None, logger: Logger = MISSING, show_ratelimit_tracebacks: bool = False
217+
self,
218+
connector: BaseConnector | None = None,
219+
logger: Logger = MISSING,
220+
show_ratelimit_tracebacks: bool = False,
221+
proxy: tuple[str, BasicAuth] | None = None,
218222
) -> None:
219223
self.connector: BaseConnector | None = connector
220224
self.__session: ClientSession | None = None
@@ -229,6 +233,8 @@ def __init__(
229233
self.user_agent: str = (
230234
f"DiscordBot ({__repo_url__} {__version__} Python/{__py_version__}) aiohttp/{aiohttp.__version__}"
231235
)
236+
self.proxy: tuple[str, BasicAuth] | None = proxy
237+
self.__proxy_validated: bool = False
232238

233239
if logger is MISSING:
234240
logger = constants.get_logger()
@@ -384,6 +390,10 @@ async def request( # noqa: C901
384390
kwargs["json"] = processed_data # pyright: ignore
385391
await self.global_lock.wait()
386392

393+
if self.proxy:
394+
kwargs["proxy"] = self.proxy[0]
395+
kwargs["proxy_auth"] = self.proxy[1]
396+
387397
async with self.__session.request(route.method, route.url, **kwargs) as response:
388398
result = await response_decode(response)
389399
self.ingest_ratelimit(route, response.headers, lock)
@@ -505,6 +515,19 @@ async def login(self, token: str) -> dict[str, Any]:
505515
connector=self.connector or aiohttp.TCPConnector(limit=self.global_lock.max_requests),
506516
json_serialize=FastJson.dumps,
507517
)
518+
if not self.__proxy_validated and self.proxy:
519+
try:
520+
self.logger.info(f"Validating Proxy @ {self.proxy[0]}")
521+
async with self.__session.get(
522+
"http://icanhazip.com/", proxy=self.proxy[0], proxy_auth=self.proxy[1]
523+
) as response:
524+
if response.status != 200:
525+
raise RuntimeError("Proxy configuration is invalid")
526+
self.logger.info(f"Proxy Connected @ {(await response.text()).strip()}")
527+
self.__proxy_validated = True
528+
except Exception as e:
529+
raise RuntimeError("Proxy configuration is invalid") from e
530+
508531
self.token = token
509532
try:
510533
result = await self.request(Route("GET", "/users/@me"))
@@ -556,4 +579,6 @@ async def websocket_connect(self, url: str) -> ClientWebSocketResponse:
556579
autoclose=False,
557580
headers={"User-Agent": self.user_agent},
558581
compress=0,
582+
proxy=self.proxy[0] if self.proxy else None,
583+
proxy_auth=self.proxy[1] if self.proxy else None,
559584
)

Diff for: interactions/api/http/http_requests/guild.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ async def get_guild(self, guild_id: "Snowflake_Type", with_counts: bool = True)
5454
a guild object
5555
5656
"""
57-
params = {"guild_id": guild_id, "with_counts": int(with_counts)}
58-
result = await self.request(Route("GET", "/guilds/{guild_id}"), params=params)
57+
params = {"with_counts": int(with_counts)}
58+
result = await self.request(Route("GET", "/guilds/{guild_id}", guild_id=guild_id), params=params)
5959
return cast(discord_typings.GuildData, result)
6060

6161
async def get_guild_preview(self, guild_id: "Snowflake_Type") -> discord_typings.GuildPreviewData:

Diff for: interactions/api/voice/audio.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ def __init__(self, src: Union[str, Path]) -> None:
207207

208208
self.ffmpeg_before_args = ""
209209
self.ffmpeg_args = ""
210-
self.probe: bool = True
210+
self.probe: bool = False
211211

212212
def __repr__(self) -> str:
213213
return f"<{type(self).__name__}: {self.source}>"

Diff for: interactions/client/client.py

+74-12
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
Tuple,
2727
)
2828

29+
from aiohttp import BasicAuth
30+
2931
import interactions.api.events as events
3032
import interactions.client.const as constants
3133
from interactions.api.events import BaseEvent, RawGatewayEvent, processors
@@ -88,11 +90,17 @@
8890
Intents,
8991
InteractionType,
9092
Status,
93+
MessageFlags,
9194
)
9295
from interactions.models.discord.file import UPLOADABLE_TYPE
9396
from interactions.models.discord.snowflake import Snowflake, to_snowflake_list
9497
from interactions.models.internal.active_voice_state import ActiveVoiceState
95-
from interactions.models.internal.application_commands import ContextMenu, ModalCommand, GlobalAutoComplete
98+
from interactions.models.internal.application_commands import (
99+
ContextMenu,
100+
ModalCommand,
101+
GlobalAutoComplete,
102+
CallbackType,
103+
)
96104
from interactions.models.internal.auto_defer import AutoDefer
97105
from interactions.models.internal.callback import CallbackObject
98106
from interactions.models.internal.command import BaseCommand
@@ -111,10 +119,8 @@
111119
if TYPE_CHECKING:
112120
from interactions.models import Snowflake_Type, TYPE_ALL_CHANNEL
113121

114-
115122
__all__ = ("Client",)
116123

117-
118124
# see https://discord.com/developers/docs/topics/gateway#list-of-intents
119125
_INTENT_EVENTS: dict[BaseEvent, list[Intents]] = {
120126
# Intents.GUILDS
@@ -225,6 +231,7 @@ class Client(
225231
enforce_interaction_perms: Enforce discord application command permissions, locally
226232
fetch_members: Should the client fetch members from guilds upon startup (this will delay the client being ready)
227233
send_command_tracebacks: Automatically send uncaught tracebacks if a command throws an exception
234+
send_not_ready_messages: Send a message to the user if they try to use a command before the client is ready
228235
229236
auto_defer: AutoDefer: A system to automatically defer commands after a set duration
230237
interaction_context: Type[InteractionContext]: InteractionContext: The object to instantiate for Interaction Context
@@ -241,6 +248,9 @@ class Client(
241248
logging_level: The level of logging to use for basic_logging. Do not use in combination with `Client.logger`
242249
logger: The logger interactions.py should use. Do not use in combination with `Client.basic_logging` and `Client.logging_level`. Note: Different loggers with multiple clients are not supported
243250
251+
proxy: A http/https proxy to use for all requests
252+
proxy_auth: The auth to use for the proxy - must be either a tuple of (username, password) or aiohttp.BasicAuth
253+
244254
Optionally, you can configure the caches here, by specifying the name of the cache, followed by a dict-style object to use.
245255
It is recommended to use `smart_cache.create_cache` to configure the cache here.
246256
as an example, this is a recommended attribute `message_cache=create_cache(250, 50)`,
@@ -277,12 +287,15 @@ def __init__(
277287
modal_context: Type[BaseContext] = ModalContext,
278288
owner_ids: Iterable["Snowflake_Type"] = (),
279289
send_command_tracebacks: bool = True,
290+
send_not_ready_messages: bool = False,
280291
shard_id: int = 0,
281292
show_ratelimit_tracebacks: bool = False,
282293
slash_context: Type[BaseContext] = SlashContext,
283294
status: Status = Status.ONLINE,
284295
sync_ext: bool = True,
285296
sync_interactions: bool = True,
297+
proxy_url: str | None = None,
298+
proxy_auth: BasicAuth | tuple[str, str] | None = None,
286299
token: str | None = None,
287300
total_shards: int = 1,
288301
**kwargs,
@@ -312,6 +325,8 @@ def __init__(
312325
"""Sync global commands as guild for quicker command updates during debug"""
313326
self.send_command_tracebacks: bool = send_command_tracebacks
314327
"""Should the traceback of command errors be sent in reply to the command invocation"""
328+
self.send_not_ready_messages: bool = send_not_ready_messages
329+
"""Should the bot send a message when it is not ready yet in response to a command invocation"""
315330
if auto_defer is True:
316331
auto_defer = AutoDefer(enabled=True)
317332
else:
@@ -321,8 +336,12 @@ def __init__(
321336
self.intents = intents if isinstance(intents, Intents) else Intents(intents)
322337

323338
# resources
339+
if isinstance(proxy_auth, tuple):
340+
proxy_auth = BasicAuth(*proxy_auth)
324341

325-
self.http: HTTPClient = HTTPClient(logger=self.logger, show_ratelimit_tracebacks=show_ratelimit_tracebacks)
342+
self.http: HTTPClient = HTTPClient(
343+
logger=self.logger, show_ratelimit_tracebacks=show_ratelimit_tracebacks, proxy=(proxy_url, proxy_auth)
344+
)
326345
"""The HTTP client to use when interacting with discord endpoints"""
327346

328347
# context factories
@@ -386,6 +405,7 @@ def __init__(
386405
self._component_callbacks: Dict[str, Callable[..., Coroutine]] = {}
387406
self._regex_component_callbacks: Dict[re.Pattern, Callable[..., Coroutine]] = {}
388407
self._modal_callbacks: Dict[str, Callable[..., Coroutine]] = {}
408+
self._regex_modal_callbacks: Dict[re.Pattern, Callable[..., Coroutine]] = {}
389409
self._global_autocompletes: Dict[str, GlobalAutoComplete] = {}
390410
self.processors: Dict[str, Callable[..., Coroutine]] = {}
391411
self.__modules = {}
@@ -684,7 +704,7 @@ async def on_command_error(self, event: events.CommandError) -> None:
684704
embeds=Embed(
685705
title=f"Error: {type(event.error).__name__}",
686706
color=BrandColors.RED,
687-
description=f"```\n{out[:EMBED_MAX_DESC_LENGTH-8]}```",
707+
description=f"```\n{out[:EMBED_MAX_DESC_LENGTH - 8]}```",
688708
)
689709
)
690710

@@ -1305,9 +1325,14 @@ def add_modal_callback(self, command: ModalCommand) -> None:
13051325
command: The command to add
13061326
"""
13071327
for listener in command.listeners:
1308-
if listener in self._modal_callbacks.keys():
1309-
raise ValueError(f"Duplicate Component! Multiple modal callbacks for `{listener}`")
1310-
self._modal_callbacks[listener] = command
1328+
if isinstance(listener, re.Pattern):
1329+
if listener in self._regex_component_callbacks.keys():
1330+
raise ValueError(f"Duplicate Component! Multiple modal callbacks for `{listener}`")
1331+
self._regex_modal_callbacks[listener] = command
1332+
else:
1333+
if listener in self._modal_callbacks.keys():
1334+
raise ValueError(f"Duplicate Component! Multiple modal callbacks for `{listener}`")
1335+
self._modal_callbacks[listener] = command
13111336
continue
13121337

13131338
def add_global_autocomplete(self, callback: GlobalAutoComplete) -> None:
@@ -1559,8 +1584,7 @@ def _build_sync_payload(
15591584

15601585
for local_cmd in self.interactions_by_scope.get(cmd_scope, {}).values():
15611586
remote_cmd_json = next(
1562-
(v for v in remote_commands if int(v["id"]) == local_cmd.cmd_id.get(cmd_scope)),
1563-
None,
1587+
(c for c in remote_commands if int(c["id"]) == int(local_cmd.cmd_id.get(cmd_scope, 0))), None
15641588
)
15651589
local_cmd_json = next((c for c in local_cmds_json[cmd_scope] if c["name"] == str(local_cmd.name)))
15661590

@@ -1696,6 +1720,32 @@ async def get_context(self, data: dict) -> InteractionContext:
16961720
self.logger.debug(f"Failed to fetch channel data for {data['channel_id']}")
16971721
return cls
16981722

1723+
async def handle_pre_ready_response(self, data: dict) -> None:
1724+
"""
1725+
Respond to an interaction that was received before the bot was ready.
1726+
1727+
Args:
1728+
data: The interaction data
1729+
1730+
"""
1731+
if data["type"] == InteractionType.AUTOCOMPLETE:
1732+
# we do not want to respond to autocompletes as discord will cache the response,
1733+
# so we just ignore them
1734+
return
1735+
1736+
with contextlib.suppress(HTTPException):
1737+
await self.http.post_initial_response(
1738+
{
1739+
"type": CallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
1740+
"data": {
1741+
"content": f"{self.user.display_name} is starting up. Please try again in a few seconds",
1742+
"flags": MessageFlags.EPHEMERAL,
1743+
},
1744+
},
1745+
token=data["token"],
1746+
interaction_id=data["id"],
1747+
)
1748+
16991749
async def _run_slash_command(self, command: SlashCommand, ctx: "InteractionContext") -> Any:
17001750
"""Overrideable method that executes slash commands, can be used to wrap callback execution"""
17011751
return await command(ctx, **ctx.kwargs)
@@ -1713,6 +1763,8 @@ async def _dispatch_interaction(self, event: RawGatewayEvent) -> None: # noqa:
17131763

17141764
if not self._startup:
17151765
self.logger.warning("Received interaction before startup completed, ignoring")
1766+
if self.send_not_ready_messages:
1767+
await self.handle_pre_ready_response(interaction_data)
17161768
return
17171769

17181770
if interaction_data["type"] in (
@@ -1792,8 +1844,18 @@ async def _dispatch_interaction(self, event: RawGatewayEvent) -> None: # noqa:
17921844
ctx = await self.get_context(interaction_data)
17931845
self.dispatch(events.ModalCompletion(ctx=ctx))
17941846

1795-
if callback := self._modal_callbacks.get(ctx.custom_id):
1796-
await self.__dispatch_interaction(ctx=ctx, callback=callback(ctx), error_callback=events.ModalError)
1847+
modal_callback = self._modal_callbacks.get(ctx.custom_id)
1848+
if not modal_callback:
1849+
# evaluate regex component callbacks
1850+
for regex, callback in self._regex_modal_callbacks.items():
1851+
if regex.match(ctx.custom_id):
1852+
modal_callback = callback
1853+
break
1854+
1855+
if modal_callback:
1856+
await self.__dispatch_interaction(
1857+
ctx=ctx, callback=modal_callback(ctx), error_callback=events.ModalError
1858+
)
17971859

17981860
else:
17991861
raise NotImplementedError(f"Unknown Interaction Received: {interaction_data['type']}")

Diff for: interactions/client/const.py

+46-2
Original file line numberDiff line numberDiff line change
@@ -220,5 +220,49 @@ class MentionPrefix(Sentinel):
220220
LIB_PATH = os.sep.join(__file__.split(os.sep)[:-2])
221221
"""The path to the library folder."""
222222

223-
RECOVERABLE_WEBSOCKET_CLOSE_CODES = (4000, 4001, 4002, 4003, 4005, 4007, 4008, 4009)
224-
NON_RESUMABLE_WEBSOCKET_CLOSE_CODES = (1000, 4007)
223+
# fmt: off
224+
RECOVERABLE_WEBSOCKET_CLOSE_CODES = ( # Codes that are recoverable, and the bot will reconnect
225+
1000, # Normal closure
226+
1001, # Server going away
227+
1003, # Unsupported Data
228+
1005, # No status code
229+
1006, # Abnormal closure
230+
1008, # Policy Violation
231+
1009, # Message too big
232+
1011, # Server error
233+
1012, # Server is restarting
234+
1014, # Handshake failed
235+
1015, # TLS error
236+
4000, # Unknown error
237+
4001, # Unknown opcode
238+
4002, # Decode error
239+
4003, # Not authenticated
240+
4005, # Already authenticated
241+
4007, # Invalid seq
242+
4008, # Rate limited
243+
4009, # Session timed out
244+
)
245+
NON_RESUMABLE_WEBSOCKET_CLOSE_CODES = ( # Codes that are recoverable, but the bot won't be able to resume the session
246+
1000, # Normal closure
247+
1003, # Unsupported Data
248+
1008, # Policy Violation
249+
1009, # Message too big
250+
1011, # Server error
251+
1012, # Server is restarting
252+
1014, # Handshake failed
253+
1015, # TLS error
254+
4007, # Invalid seq
255+
)
256+
# Any close code not in the above two tuples is a non-recoverable close code, and will result in the bot shutting down
257+
# fmt: on
258+
259+
260+
# Sanity check the above constants - only useful during development, but doesn't hurt to leave in
261+
try:
262+
assert set(NON_RESUMABLE_WEBSOCKET_CLOSE_CODES).issubset(set(RECOVERABLE_WEBSOCKET_CLOSE_CODES))
263+
except AssertionError as e:
264+
# find the difference between the two sets
265+
diff = set(NON_RESUMABLE_WEBSOCKET_CLOSE_CODES) - set(RECOVERABLE_WEBSOCKET_CLOSE_CODES)
266+
raise RuntimeError(
267+
f"NON_RESUMABLE_WEBSOCKET_CLOSE_CODES contains codes that are not in RECOVERABLE_WEBSOCKET_CLOSE_CODES: {diff}"
268+
) from e

Diff for: interactions/models/discord/channel.py

+7-5
Original file line numberDiff line numberDiff line change
@@ -404,11 +404,12 @@ async def purge(
404404
search_limit: int = 100,
405405
predicate: Callable[["models.Message"], bool] = MISSING,
406406
avoid_loading_msg: bool = True,
407+
return_messages: bool = False,
407408
before: Optional[Snowflake_Type] = MISSING,
408409
after: Optional[Snowflake_Type] = MISSING,
409410
around: Optional[Snowflake_Type] = MISSING,
410411
reason: Absent[Optional[str]] = MISSING,
411-
) -> int:
412+
) -> int | List["models.Message"]:
412413
"""
413414
Bulk delete messages within a channel. If a `predicate` is provided, it will be used to determine which messages to delete, otherwise all messages will be deleted within the `deletion_limit`.
414415
@@ -424,6 +425,7 @@ async def purge(
424425
search_limit: How many messages to search through
425426
predicate: A function that returns True or False, and takes a message as an argument
426427
avoid_loading_msg: Should the bot attempt to avoid deleting its own loading messages (recommended enabled)
428+
return_messages: Should the bot return the messages that were deleted
427429
before: Search messages before this ID
428430
after: Search messages after this ID
429431
around: Search messages around this ID
@@ -461,13 +463,13 @@ def predicate(m) -> bool:
461463
# message is too old to be purged
462464
continue
463465

464-
to_delete.append(message.id)
466+
to_delete.append(message)
465467

466-
count = len(to_delete)
468+
out = to_delete.copy()
467469
while len(to_delete):
468-
iteration = [to_delete.pop() for i in range(min(100, len(to_delete)))]
470+
iteration = [to_delete.pop().id for i in range(min(100, len(to_delete)))]
469471
await self.delete_messages(iteration, reason=reason)
470-
return count
472+
return out if return_messages else len(out)
471473

472474
async def trigger_typing(self) -> None:
473475
"""Trigger a typing animation in this channel."""

0 commit comments

Comments
 (0)