scurrypy 0.4__py3-none-any.whl → 0.6.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scurrypy/__init__.py +429 -0
- scurrypy/client.py +335 -0
- {discord → scurrypy}/client_like.py +8 -1
- scurrypy/dispatch/command_dispatcher.py +205 -0
- {discord → scurrypy}/dispatch/event_dispatcher.py +21 -21
- {discord → scurrypy}/dispatch/prefix_dispatcher.py +31 -12
- {discord → scurrypy}/error.py +6 -18
- {discord → scurrypy}/events/channel_events.py +2 -1
- scurrypy/events/gateway_events.py +31 -0
- {discord → scurrypy}/events/guild_events.py +2 -1
- {discord → scurrypy}/events/interaction_events.py +28 -13
- {discord → scurrypy}/events/message_events.py +8 -5
- {discord → scurrypy}/events/reaction_events.py +1 -2
- {discord → scurrypy}/events/ready_event.py +1 -3
- scurrypy/gateway.py +183 -0
- scurrypy/http.py +310 -0
- {discord → scurrypy}/intents.py +5 -7
- {discord → scurrypy}/logger.py +14 -61
- scurrypy/model.py +71 -0
- scurrypy/models.py +258 -0
- scurrypy/parts/channel.py +42 -0
- scurrypy/parts/command.py +90 -0
- scurrypy/parts/components.py +224 -0
- scurrypy/parts/components_v2.py +144 -0
- scurrypy/parts/embed.py +83 -0
- scurrypy/parts/message.py +134 -0
- scurrypy/parts/modal.py +16 -0
- {discord → scurrypy}/parts/role.py +2 -14
- {discord → scurrypy}/resources/application.py +1 -2
- {discord → scurrypy}/resources/bot_emojis.py +1 -1
- {discord → scurrypy}/resources/channel.py +9 -8
- {discord → scurrypy}/resources/guild.py +14 -16
- {discord → scurrypy}/resources/interaction.py +50 -43
- {discord → scurrypy}/resources/message.py +15 -16
- {discord → scurrypy}/resources/user.py +3 -4
- scurrypy-0.6.6.dist-info/METADATA +108 -0
- scurrypy-0.6.6.dist-info/RECORD +47 -0
- {scurrypy-0.4.dist-info → scurrypy-0.6.6.dist-info}/licenses/LICENSE +1 -1
- scurrypy-0.6.6.dist-info/top_level.txt +1 -0
- discord/__init__.py +0 -223
- discord/client.py +0 -375
- discord/dispatch/command_dispatcher.py +0 -163
- discord/gateway.py +0 -155
- discord/http.py +0 -280
- discord/model.py +0 -90
- discord/models/__init__.py +0 -1
- discord/models/application.py +0 -37
- discord/models/emoji.py +0 -34
- discord/models/guild.py +0 -35
- discord/models/integration.py +0 -23
- discord/models/interaction.py +0 -26
- discord/models/member.py +0 -27
- discord/models/role.py +0 -53
- discord/models/user.py +0 -15
- discord/parts/action_row.py +0 -208
- discord/parts/channel.py +0 -20
- discord/parts/command.py +0 -102
- discord/parts/components_v2.py +0 -353
- discord/parts/embed.py +0 -154
- discord/parts/message.py +0 -194
- discord/parts/modal.py +0 -21
- scurrypy-0.4.dist-info/METADATA +0 -130
- scurrypy-0.4.dist-info/RECORD +0 -54
- scurrypy-0.4.dist-info/top_level.txt +0 -1
- {discord → scurrypy}/config.py +0 -0
- {discord → scurrypy}/dispatch/__init__.py +0 -0
- {discord → scurrypy}/events/__init__.py +0 -0
- {discord → scurrypy}/events/hello_event.py +0 -0
- {discord → scurrypy}/parts/__init__.py +0 -0
- {discord → scurrypy}/parts/component_types.py +0 -0
- {discord → scurrypy}/resources/__init__.py +0 -0
- {scurrypy-0.4.dist-info → scurrypy-0.6.6.dist-info}/WHEEL +0 -0
|
@@ -3,10 +3,11 @@ from ..client_like import ClientLike
|
|
|
3
3
|
from ..events.message_events import MessageCreateEvent
|
|
4
4
|
|
|
5
5
|
from ..resources.message import Message
|
|
6
|
-
from ..models
|
|
6
|
+
from ..models import MemberModel
|
|
7
7
|
|
|
8
8
|
class PrefixDispatcher:
|
|
9
9
|
"""Handles text-based command messages that start with a specific prefix."""
|
|
10
|
+
|
|
10
11
|
def __init__(self, client: ClientLike, prefix: str):
|
|
11
12
|
|
|
12
13
|
self.bot = client
|
|
@@ -18,6 +19,9 @@ class PrefixDispatcher:
|
|
|
18
19
|
self._logger = client._logger
|
|
19
20
|
"""Logger instance to log events."""
|
|
20
21
|
|
|
22
|
+
self.application_id = client.application_id
|
|
23
|
+
"""The client's application ID."""
|
|
24
|
+
|
|
21
25
|
self.prefix = prefix
|
|
22
26
|
"""User-defined command prefix."""
|
|
23
27
|
|
|
@@ -28,13 +32,13 @@ class PrefixDispatcher:
|
|
|
28
32
|
"""Mapping of command prefix names to handler"""
|
|
29
33
|
|
|
30
34
|
def register(self, name: str, handler):
|
|
31
|
-
"""Registers a handler for a command name
|
|
35
|
+
"""Registers a handler for a command name
|
|
32
36
|
|
|
33
37
|
Args:
|
|
34
38
|
name (str): name of handler (and command)
|
|
35
39
|
handler (callable): handler callback
|
|
36
40
|
"""
|
|
37
|
-
self._handlers[name
|
|
41
|
+
self._handlers[name] = handler
|
|
38
42
|
|
|
39
43
|
async def dispatch(self, data: dict):
|
|
40
44
|
"""Hydrate the corresponding dataclass and call the handler.
|
|
@@ -48,12 +52,27 @@ class PrefixDispatcher:
|
|
|
48
52
|
member=MemberModel.from_dict(data.get('member'))
|
|
49
53
|
)
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
# ignore bot responding to itself
|
|
56
|
+
if event.message.author.id == self.application_id:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# ignore messages without prefix
|
|
60
|
+
if not event.message._has_prefix(self.prefix):
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
command, *args = event.message._extract_args(self.prefix)
|
|
64
|
+
handler = self._handlers.get(command)
|
|
65
|
+
|
|
66
|
+
# warn if this command doesnt have a known handler
|
|
67
|
+
if not handler:
|
|
68
|
+
self._logger.log_warn(f"Prefix Event '{command}' not found.")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# now prefix info can be confidently set
|
|
72
|
+
try:
|
|
73
|
+
event.prefix_args = list(args)
|
|
74
|
+
await handler(self.bot, event)
|
|
75
|
+
|
|
76
|
+
self._logger.log_info(f"Prefix Event '{command}' acknowledged with args: {event.prefix_args or 'No args'}")
|
|
77
|
+
except Exception as e:
|
|
78
|
+
self._logger.log_error(f"Error in prefix command '{command}': {e}")
|
{discord → scurrypy}/error.py
RENAMED
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
METHOD_SUCCESS_CODES = {
|
|
2
|
-
"GET": (200),
|
|
3
|
-
"POST": (200, 201),
|
|
4
|
-
"PATCH": (200, 201),
|
|
5
|
-
"DELETE": (200, 204)
|
|
6
|
-
}
|
|
7
|
-
|
|
8
1
|
class DiscordError(Exception):
|
|
9
2
|
"""Represents a Discord API error."""
|
|
3
|
+
|
|
10
4
|
def __init__(self, status: int, data: dict):
|
|
11
5
|
"""Initialize the error with Discord's response.
|
|
12
6
|
Extracts reason, code, and walks the nested errors.
|
|
@@ -15,22 +9,14 @@ class DiscordError(Exception):
|
|
|
15
9
|
data (dict): Discord's error JSON
|
|
16
10
|
"""
|
|
17
11
|
self.data = data
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
self.status = status
|
|
20
13
|
self.reason = data.get('message', 'Unknown Error')
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
self.code = data.get('code', '???')
|
|
24
|
-
"""Discord-generated code of error."""
|
|
14
|
+
self.code = data.get('code', 'Unknown Code')
|
|
25
15
|
|
|
26
16
|
self.error_data = data.get('errors', {})
|
|
27
|
-
"""Error-specific data."""
|
|
28
|
-
|
|
29
17
|
self.details = self.walk(self.error_data)
|
|
30
|
-
"""Error details."""
|
|
31
18
|
|
|
32
|
-
self.
|
|
33
|
-
"""If this error is considered fatal."""
|
|
19
|
+
self.is_fatal = status in (401, 403)
|
|
34
20
|
|
|
35
21
|
errors = [f"→ {path}: {reason}" for path, reason in self.details]
|
|
36
22
|
full_message = f"{self.reason} ({self.code})"
|
|
@@ -58,6 +44,8 @@ class DiscordError(Exception):
|
|
|
58
44
|
if key == '_errors' and isinstance(value, list):
|
|
59
45
|
msg = value[0].get('message', 'Unknown error')
|
|
60
46
|
result.append(('.'.join(path), msg))
|
|
47
|
+
|
|
48
|
+
# the value should not be a dict -- keep going
|
|
61
49
|
elif isinstance(value, dict):
|
|
62
50
|
result.extend(self.walk(value, path + [key]))
|
|
63
51
|
return result
|
|
@@ -46,8 +46,9 @@ class GuildChannelDeleteEvent(GuildChannelEvent):
|
|
|
46
46
|
pass
|
|
47
47
|
|
|
48
48
|
@dataclass
|
|
49
|
-
class ChannelPinsUpdateEvent:
|
|
49
|
+
class ChannelPinsUpdateEvent(DataModel):
|
|
50
50
|
"""Pin update event."""
|
|
51
|
+
|
|
51
52
|
channel_id: int
|
|
52
53
|
"""ID of channel where the pins were updated."""
|
|
53
54
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from ..model import DataModel
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class SessionStartLimit(DataModel):
|
|
6
|
+
"""Represents the Session Start Limit object."""
|
|
7
|
+
|
|
8
|
+
total: int
|
|
9
|
+
"""Total remaining shards."""
|
|
10
|
+
|
|
11
|
+
remaining: int
|
|
12
|
+
"""Shards left to connect."""
|
|
13
|
+
|
|
14
|
+
reset_after: int
|
|
15
|
+
"""When `remaining` resets from now (in ms)."""
|
|
16
|
+
|
|
17
|
+
max_concurrency: int
|
|
18
|
+
"""How many shards can be started at once."""
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class GatewayEvent(DataModel):
|
|
22
|
+
"""Represents the Gateway Event object."""
|
|
23
|
+
|
|
24
|
+
url: str
|
|
25
|
+
"""Gateway URL to connect."""
|
|
26
|
+
|
|
27
|
+
shards: int
|
|
28
|
+
"""Recommended shard count for the aaplication."""
|
|
29
|
+
|
|
30
|
+
session_start_limit: SessionStartLimit
|
|
31
|
+
"""Session start info."""
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
2
|
from ..model import DataModel
|
|
3
3
|
|
|
4
|
-
from ..models
|
|
4
|
+
from ..models import MemberModel
|
|
5
5
|
from ..resources.channel import Channel
|
|
6
6
|
|
|
7
7
|
@dataclass
|
|
8
8
|
class GuildEvent(DataModel):
|
|
9
9
|
"""Base guild event."""
|
|
10
|
+
|
|
10
11
|
joined_at: str
|
|
11
12
|
"""ISO8601 timestamp of when app joined the guild."""
|
|
12
13
|
|
|
@@ -3,17 +3,20 @@ from typing import Optional
|
|
|
3
3
|
from ..model import DataModel
|
|
4
4
|
|
|
5
5
|
from ..resources.interaction import Interaction
|
|
6
|
+
from ..parts.components import ComponentTypes
|
|
7
|
+
|
|
6
8
|
|
|
7
9
|
# ----- Command Interaction -----
|
|
8
10
|
|
|
9
11
|
@dataclass
|
|
10
12
|
class ApplicationCommandOptionData(DataModel):
|
|
11
13
|
"""Represents the response options from a slash command."""
|
|
14
|
+
|
|
12
15
|
name: str
|
|
13
16
|
"""Name of the command option."""
|
|
14
17
|
|
|
15
18
|
type: int
|
|
16
|
-
"""Type of command option. See [`CommandOptionTypes`][
|
|
19
|
+
"""Type of command option. See [`CommandOptionTypes`][scurrypy.parts.command.CommandOptionTypes]."""
|
|
17
20
|
|
|
18
21
|
value: str | int | float | bool
|
|
19
22
|
"""Input value for option."""
|
|
@@ -21,6 +24,7 @@ class ApplicationCommandOptionData(DataModel):
|
|
|
21
24
|
@dataclass
|
|
22
25
|
class ApplicationCommandData(DataModel):
|
|
23
26
|
"""Represents the response from a command."""
|
|
27
|
+
|
|
24
28
|
id: int
|
|
25
29
|
"""ID of the command."""
|
|
26
30
|
|
|
@@ -39,7 +43,7 @@ class ApplicationCommandData(DataModel):
|
|
|
39
43
|
options: Optional[list[ApplicationCommandOptionData]] = field(default_factory=list)
|
|
40
44
|
"""Options of the command (slash command only)."""
|
|
41
45
|
|
|
42
|
-
def get_command_option_value(self, option_name: str):
|
|
46
|
+
def get_command_option_value(self, option_name: str, default = None):
|
|
43
47
|
"""Get the input for a command option by name.
|
|
44
48
|
|
|
45
49
|
Args:
|
|
@@ -52,12 +56,14 @@ class ApplicationCommandData(DataModel):
|
|
|
52
56
|
(str | int | float | bool): input data of specified option
|
|
53
57
|
"""
|
|
54
58
|
for option in self.options:
|
|
55
|
-
if
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
if option.name == option_name:
|
|
60
|
+
return option.value
|
|
61
|
+
|
|
62
|
+
if default is not None:
|
|
63
|
+
return default
|
|
59
64
|
|
|
60
|
-
raise ValueError(f"Option
|
|
65
|
+
raise ValueError(f"Option '{option_name}' not found")
|
|
66
|
+
|
|
61
67
|
|
|
62
68
|
# ----- Component Interaction -----
|
|
63
69
|
|
|
@@ -74,26 +80,29 @@ class MessageComponentData(DataModel):
|
|
|
74
80
|
values: Optional[list[str]] = field(default_factory=list)
|
|
75
81
|
"""Select values (if any)."""
|
|
76
82
|
|
|
83
|
+
|
|
77
84
|
# ----- Modal Interaction -----
|
|
78
85
|
|
|
79
86
|
@dataclass
|
|
80
87
|
class ModalComponentData(DataModel):
|
|
81
88
|
"""Represents the modal field response from a modal."""
|
|
89
|
+
|
|
82
90
|
type: int
|
|
83
91
|
"""Type of component."""
|
|
84
92
|
|
|
85
93
|
value: Optional[str]
|
|
86
94
|
"""Text input value (Text Input component only)."""
|
|
87
95
|
|
|
88
|
-
values: Optional[list[str]]
|
|
89
|
-
"""String select values (String Select component only)."""
|
|
90
|
-
|
|
91
96
|
custom_id: str
|
|
92
97
|
"""Unique ID associated with the component."""
|
|
93
98
|
|
|
99
|
+
values: Optional[list[str]] = field(default_factory=list)
|
|
100
|
+
"""String select values (String Select component only)."""
|
|
101
|
+
|
|
94
102
|
@dataclass
|
|
95
103
|
class ModalComponent(DataModel):
|
|
96
104
|
"""Represents the modal component response from a modal."""
|
|
105
|
+
|
|
97
106
|
type: int
|
|
98
107
|
"""Type of component."""
|
|
99
108
|
|
|
@@ -128,20 +137,26 @@ class ModalData(DataModel):
|
|
|
128
137
|
|
|
129
138
|
t = component.component.type
|
|
130
139
|
|
|
131
|
-
if t in [
|
|
140
|
+
if t in [
|
|
141
|
+
ComponentTypes.STRING_SELECT,
|
|
142
|
+
ComponentTypes.USER_SELECT,
|
|
143
|
+
ComponentTypes.ROLE_SELECT,
|
|
144
|
+
ComponentTypes.MENTIONABLE_SELECT,
|
|
145
|
+
ComponentTypes.CHANNEL_SELECT # select menus (w. possibly many option selects!)
|
|
146
|
+
]:
|
|
132
147
|
return component.component.values
|
|
133
148
|
|
|
134
149
|
# text input
|
|
135
150
|
return component.component.value
|
|
136
151
|
|
|
137
|
-
raise ValueError(f"Component custom
|
|
152
|
+
raise ValueError(f"Component custom ID '{custom_id}' not found.")
|
|
138
153
|
|
|
139
154
|
@dataclass
|
|
140
155
|
class InteractionEvent(DataModel):
|
|
141
156
|
"""Represents the interaction response."""
|
|
142
157
|
|
|
143
158
|
interaction: Interaction
|
|
144
|
-
"""Interaction resource object. See [`Interaction`][
|
|
159
|
+
"""Interaction resource object. See [`Interaction`][scurrypy.resources.interaction.Interaction]."""
|
|
145
160
|
|
|
146
161
|
data: Optional[ApplicationCommandData | MessageComponentData | ModalData] = None
|
|
147
162
|
"""Interaction response data."""
|
|
@@ -3,31 +3,34 @@ from typing import Optional
|
|
|
3
3
|
from ..model import DataModel
|
|
4
4
|
|
|
5
5
|
from ..resources.message import Message
|
|
6
|
-
from ..models
|
|
6
|
+
from ..models import MemberModel
|
|
7
7
|
|
|
8
8
|
@dataclass
|
|
9
9
|
class MessageCreateEvent(DataModel):
|
|
10
10
|
"""Received when a message is created."""
|
|
11
11
|
message: Message
|
|
12
|
-
"""Message resource object. See [`Resource.Message`][
|
|
12
|
+
"""Message resource object. See [`Resource.Message`][scurrypy.resources.message.Message]."""
|
|
13
13
|
|
|
14
14
|
guild_id: Optional[int]
|
|
15
15
|
"""Guild ID of the updated message (if in a guild channel)."""
|
|
16
16
|
|
|
17
17
|
member: Optional[MemberModel] # guild-only author info
|
|
18
|
-
"""Partial Member object of the author of the message. See [`MemberModel`][
|
|
18
|
+
"""Partial Member object of the author of the message. See [`MemberModel`][scurrypy.models.MemberModel]."""
|
|
19
|
+
|
|
20
|
+
prefix_args: Optional[list[str]] = None
|
|
21
|
+
"""Prefix args as a list of string if a prefix command was made."""
|
|
19
22
|
|
|
20
23
|
@dataclass
|
|
21
24
|
class MessageUpdateEvent(DataModel):
|
|
22
25
|
"""Received when a message is updated."""
|
|
23
26
|
message: Message
|
|
24
|
-
"""Message resource object. See [`Resource.Message`][
|
|
27
|
+
"""Message resource object. See [`Resource.Message`][scurrypy.resources.message.Message]."""
|
|
25
28
|
|
|
26
29
|
guild_id: Optional[int]
|
|
27
30
|
"""Guild ID of the updated message (if in a guild channel)."""
|
|
28
31
|
|
|
29
32
|
member: Optional[MemberModel]
|
|
30
|
-
"""Partial Member object of the author of the message. See [`MemberModel`][
|
|
33
|
+
"""Partial Member object of the author of the message. See [`MemberModel`][scurrypy.models.MemberModel]."""
|
|
31
34
|
|
|
32
35
|
@dataclass
|
|
33
36
|
class MessageDeleteEvent(DataModel):
|
|
@@ -2,8 +2,7 @@ from dataclasses import dataclass
|
|
|
2
2
|
from typing import Optional
|
|
3
3
|
from ..model import DataModel
|
|
4
4
|
|
|
5
|
-
from ..models
|
|
6
|
-
from ..models.emoji import EmojiModel
|
|
5
|
+
from ..models import MemberModel, EmojiModel
|
|
7
6
|
|
|
8
7
|
class ReactionType:
|
|
9
8
|
"""Reaction types."""
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
2
|
from ..model import DataModel
|
|
3
3
|
|
|
4
|
-
from ..models
|
|
5
|
-
from ..models.guild import ReadyGuildModel
|
|
6
|
-
from ..models.application import ApplicationModel
|
|
4
|
+
from ..models import UserModel, ReadyGuildModel, ApplicationModel
|
|
7
5
|
|
|
8
6
|
@dataclass
|
|
9
7
|
class ReadyEvent(DataModel):
|
scurrypy/gateway.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import websockets
|
|
4
|
+
|
|
5
|
+
from .client_like import ClientLike
|
|
6
|
+
|
|
7
|
+
class GatewayClient:
|
|
8
|
+
def __init__(self, client: ClientLike, gateway_url: str, shard_id: int, total_shards: int):
|
|
9
|
+
"""Initialize this websocket.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
client (ClientLike): the bot client
|
|
13
|
+
gateway_url (str): gateway URL provided by GET /gateway/bot endpoint
|
|
14
|
+
shard_id (int): assigned shard ID
|
|
15
|
+
total_shards (int): total shard count provided by GET /gateway/bot endpoint
|
|
16
|
+
"""
|
|
17
|
+
self.token = client.token
|
|
18
|
+
self.intents = client.intents
|
|
19
|
+
self.logger = client._logger
|
|
20
|
+
self.shard_id = shard_id
|
|
21
|
+
self.total_shards = total_shards
|
|
22
|
+
self.ws = None
|
|
23
|
+
self.seq = None
|
|
24
|
+
self.session_id = None
|
|
25
|
+
self.heartbeat_task = None
|
|
26
|
+
self.heartbeat_interval = None
|
|
27
|
+
self.event_queue = asyncio.Queue()
|
|
28
|
+
|
|
29
|
+
self.base_url = gateway_url
|
|
30
|
+
self.url_params = "?v=10&encoding=json"
|
|
31
|
+
|
|
32
|
+
async def connect_ws(self):
|
|
33
|
+
"""Connect to Discord's Gateway (websocket)."""
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
# connect to websocket
|
|
37
|
+
self.ws = await websockets.connect(self.base_url + self.url_params)
|
|
38
|
+
self.logger.log_high_priority(f"SHARD ID {self.shard_id}: Connected to Discord!")
|
|
39
|
+
|
|
40
|
+
# wait to recv HELLO
|
|
41
|
+
hello = await self.receive()
|
|
42
|
+
|
|
43
|
+
# extra info from recv'd HELLO
|
|
44
|
+
self.heartbeat_interval = hello["d"]["heartbeat_interval"] / 1000
|
|
45
|
+
self.seq = hello.get('s')
|
|
46
|
+
|
|
47
|
+
# start heartbeat in background
|
|
48
|
+
self.heartbeat_task = asyncio.create_task(self.heartbeat())
|
|
49
|
+
|
|
50
|
+
except Exception as e:
|
|
51
|
+
self.logger.log_error(f"SHARD ID {self.shard_id}: Connection Error - {e}")
|
|
52
|
+
|
|
53
|
+
async def start(self):
|
|
54
|
+
"""Starts the websocket and handles reconnections."""
|
|
55
|
+
|
|
56
|
+
backoff = 1
|
|
57
|
+
while True:
|
|
58
|
+
try:
|
|
59
|
+
await self.connect_ws()
|
|
60
|
+
|
|
61
|
+
if self.session_id and self.seq:
|
|
62
|
+
await self.resume()
|
|
63
|
+
else:
|
|
64
|
+
await self.identify()
|
|
65
|
+
|
|
66
|
+
await self._listen() # blocks until disconnect
|
|
67
|
+
|
|
68
|
+
except (ConnectionError, websockets.exceptions.ConnectionClosedError) as e:
|
|
69
|
+
self.logger.log_error(f"SHARD ID {self.shard_id}: Disconnected: {e}, reconnecting in {backoff}s...")
|
|
70
|
+
await self.close_ws()
|
|
71
|
+
await asyncio.sleep(backoff)
|
|
72
|
+
backoff = min(backoff * 2, 30)
|
|
73
|
+
except asyncio.CancelledError:
|
|
74
|
+
# only break; don't close here — let Client handle shutdown
|
|
75
|
+
break
|
|
76
|
+
except Exception as e:
|
|
77
|
+
self.logger.log_error(f"SHARD ID {self.shard_id}: Unhandled error - {e}")
|
|
78
|
+
await self.close_ws()
|
|
79
|
+
await asyncio.sleep(backoff)
|
|
80
|
+
|
|
81
|
+
async def send(self, data: dict):
|
|
82
|
+
"""Send data through the websocket."""
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
await self.ws.send(json.dumps(data))
|
|
86
|
+
except Exception:
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
async def receive(self):
|
|
90
|
+
"""Receive data through the websocket."""
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
return json.loads(await self.ws.recv())
|
|
94
|
+
except Exception:
|
|
95
|
+
raise
|
|
96
|
+
|
|
97
|
+
async def heartbeat(self):
|
|
98
|
+
"""Heartbeat task to keep connection alive."""
|
|
99
|
+
while self.ws:
|
|
100
|
+
await asyncio.sleep(self.heartbeat_interval)
|
|
101
|
+
await self.send({"op": 1, "d": self.seq})
|
|
102
|
+
self.logger.log_info(f"SHARD ID {self.shard_id} Heartbeat sent")
|
|
103
|
+
|
|
104
|
+
async def identify(self):
|
|
105
|
+
"""Send an IDENTIFY payload to handshake for bot."""
|
|
106
|
+
|
|
107
|
+
await self.send({
|
|
108
|
+
'op': 2,
|
|
109
|
+
'd': {
|
|
110
|
+
'token': f"Bot {self.token}",
|
|
111
|
+
'intents': self.intents,
|
|
112
|
+
'properties': {
|
|
113
|
+
'os': 'my_os',
|
|
114
|
+
'browser': 'my_browser',
|
|
115
|
+
'device': 'my_device'
|
|
116
|
+
},
|
|
117
|
+
'shards': [self.shard_id, self.total_shards]
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
self.logger.log_info(f"SHARD ID {self.shard_id}: IDENIFY Sent.")
|
|
121
|
+
|
|
122
|
+
async def resume(self):
|
|
123
|
+
"""Send a RESUME payload to resume a connection."""
|
|
124
|
+
|
|
125
|
+
await self.send({
|
|
126
|
+
'op': 6,
|
|
127
|
+
'd': {
|
|
128
|
+
'token': f"Bot {self.token}",
|
|
129
|
+
'session_id': self.session_id,
|
|
130
|
+
'seq': self.seq
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
self.logger.log_info("Resume Sent.")
|
|
134
|
+
|
|
135
|
+
async def _listen(self):
|
|
136
|
+
"""Listen for events and queue them to be picked up by Client."""
|
|
137
|
+
|
|
138
|
+
while self.ws:
|
|
139
|
+
try:
|
|
140
|
+
data = await self.receive()
|
|
141
|
+
|
|
142
|
+
op_code = data.get('op')
|
|
143
|
+
|
|
144
|
+
match op_code:
|
|
145
|
+
case 0:
|
|
146
|
+
dispatcher_type = data.get('t')
|
|
147
|
+
self.logger.log_info(f"SHARD ID {self.shard_id} DISPATCH -> {dispatcher_type}")
|
|
148
|
+
event_data = data.get('d')
|
|
149
|
+
self.seq = data.get('s') or self.seq
|
|
150
|
+
|
|
151
|
+
if dispatcher_type == 'READY':
|
|
152
|
+
self.session_id = event_data.get('session_id')
|
|
153
|
+
self.base_url = event_data.get('resume_gateway_url') or self.base_url
|
|
154
|
+
|
|
155
|
+
await self.event_queue.put((dispatcher_type, event_data))
|
|
156
|
+
case 7:
|
|
157
|
+
raise ConnectionError("Reconnect requested by server.")
|
|
158
|
+
case 9:
|
|
159
|
+
self.session_id = self.seq = None
|
|
160
|
+
raise ConnectionError("Invalid session.")
|
|
161
|
+
case 11:
|
|
162
|
+
self.logger.log_info(f"SHARD ID {self.shard_id}: Heartbeat ACK")
|
|
163
|
+
|
|
164
|
+
except websockets.exceptions.ConnectionClosedOK:
|
|
165
|
+
break
|
|
166
|
+
except Exception as e:
|
|
167
|
+
self.logger.log_error(f"SHARD ID {self.shard_id}: Listen Error - {e}")
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
async def close_ws(self):
|
|
171
|
+
"""Close the websocket connection if one is still open."""
|
|
172
|
+
|
|
173
|
+
self.logger.log_high_priority(f"Closing Shard ID {self.shard_id}...")
|
|
174
|
+
if self.ws:
|
|
175
|
+
if self.heartbeat_task:
|
|
176
|
+
self.heartbeat_task.cancel()
|
|
177
|
+
try:
|
|
178
|
+
await self.heartbeat_task
|
|
179
|
+
except asyncio.CancelledError:
|
|
180
|
+
pass
|
|
181
|
+
await self.ws.close()
|
|
182
|
+
|
|
183
|
+
self.ws = None
|