scurrypy 0.1.0__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.
Potentially problematic release.
This version of scurrypy might be problematic. Click here for more details.
- discord/__init__.py +9 -0
- discord/client.py +312 -0
- discord/dispatch/__init__.py +1 -0
- discord/dispatch/command_dispatcher.py +156 -0
- discord/dispatch/event_dispatcher.py +85 -0
- discord/dispatch/prefix_dispatcher.py +53 -0
- discord/error.py +63 -0
- discord/events/__init__.py +33 -0
- discord/events/channel_events.py +52 -0
- discord/events/guild_events.py +38 -0
- discord/events/hello_event.py +9 -0
- discord/events/interaction_events.py +145 -0
- discord/events/message_events.py +43 -0
- discord/events/reaction_events.py +99 -0
- discord/events/ready_event.py +30 -0
- discord/gateway.py +175 -0
- discord/http.py +292 -0
- discord/intents.py +87 -0
- discord/logger.py +147 -0
- discord/model.py +88 -0
- discord/models/__init__.py +8 -0
- discord/models/application.py +37 -0
- discord/models/emoji.py +34 -0
- discord/models/guild.py +35 -0
- discord/models/integration.py +23 -0
- discord/models/member.py +27 -0
- discord/models/role.py +53 -0
- discord/models/user.py +15 -0
- discord/parts/__init__.py +28 -0
- discord/parts/action_row.py +258 -0
- discord/parts/attachment.py +18 -0
- discord/parts/channel.py +20 -0
- discord/parts/command.py +102 -0
- discord/parts/component_types.py +5 -0
- discord/parts/components_v2.py +270 -0
- discord/parts/embed.py +154 -0
- discord/parts/message.py +179 -0
- discord/parts/modal.py +21 -0
- discord/parts/role.py +39 -0
- discord/resources/__init__.py +10 -0
- discord/resources/application.py +94 -0
- discord/resources/bot_emojis.py +49 -0
- discord/resources/channel.py +192 -0
- discord/resources/guild.py +265 -0
- discord/resources/interaction.py +155 -0
- discord/resources/message.py +223 -0
- discord/resources/user.py +111 -0
- scurrypy-0.1.0.dist-info/METADATA +8 -0
- scurrypy-0.1.0.dist-info/RECORD +52 -0
- scurrypy-0.1.0.dist-info/WHEEL +5 -0
- scurrypy-0.1.0.dist-info/licenses/LICENSE +5 -0
- scurrypy-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# discord/events
|
|
2
|
+
|
|
3
|
+
from .ready_event import ReadyEvent
|
|
4
|
+
|
|
5
|
+
from .reaction_events import (
|
|
6
|
+
ReactionAddEvent,
|
|
7
|
+
ReactionRemoveEvent,
|
|
8
|
+
ReactionRemoveEmojiEvent,
|
|
9
|
+
ReactionRemoveAllEvent
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from .guild_events import (
|
|
13
|
+
GuildCreateEvent,
|
|
14
|
+
GuildUpdateEvent,
|
|
15
|
+
GuildDeleteEvent
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from .message_events import (
|
|
19
|
+
MessageCreateEvent,
|
|
20
|
+
MessageUpdateEvent,
|
|
21
|
+
MessageDeleteEvent
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .channel_events import (
|
|
25
|
+
GuildChannelCreateEvent,
|
|
26
|
+
GuildChannelUpdateEvent,
|
|
27
|
+
GuildChannelDeleteEvent,
|
|
28
|
+
ChannelPinsUpdateEvent
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from .interaction_events import (
|
|
32
|
+
InteractionEvent
|
|
33
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from ..model import DataModel
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class GuildChannelEvent(DataModel):
|
|
7
|
+
"""Base guild channel event."""
|
|
8
|
+
id: int
|
|
9
|
+
"""ID of the guild channel."""
|
|
10
|
+
|
|
11
|
+
type: int
|
|
12
|
+
"""Type of channel."""
|
|
13
|
+
|
|
14
|
+
guild_id: Optional[int]
|
|
15
|
+
"""Guild ID of the channel."""
|
|
16
|
+
|
|
17
|
+
position: Optional[int]
|
|
18
|
+
"""Position of the channel within a category."""
|
|
19
|
+
|
|
20
|
+
name: Optional[str]
|
|
21
|
+
"""Name of the channel."""
|
|
22
|
+
|
|
23
|
+
topic: Optional[str]
|
|
24
|
+
"""Topic of the channel."""
|
|
25
|
+
|
|
26
|
+
nsfw: Optional[bool]
|
|
27
|
+
"""If this channel is flagged NSFW."""
|
|
28
|
+
|
|
29
|
+
last_message_id: Optional[int]
|
|
30
|
+
"""ID of the last message sent in the channel."""
|
|
31
|
+
|
|
32
|
+
parent_id: Optional[int]
|
|
33
|
+
"""Category ID of the channel."""
|
|
34
|
+
|
|
35
|
+
class GuildChannelCreateEvent(GuildChannelEvent):
|
|
36
|
+
"""Received when a guild channel has been created."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
class GuildChannelUpdateEvent(GuildChannelEvent):
|
|
40
|
+
"""Received when a guild channel has been updated."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
class GuildChannelDeleteEvent(GuildChannelEvent):
|
|
44
|
+
"""Received when a guild channel has been deleted."""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ChannelPinsUpdateEvent(DataModel):
|
|
49
|
+
"""Pin update event."""
|
|
50
|
+
channel_id: int
|
|
51
|
+
guild_id: Optional[int]
|
|
52
|
+
last_pin_timestamp: Optional[str] # ISO8601 timestamp of last pinned message
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from ..model import DataModel
|
|
4
|
+
from ..models import MemberModel
|
|
5
|
+
from ..resources.channel import Channel
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class GuildEvent(DataModel):
|
|
9
|
+
"""Base guild event."""
|
|
10
|
+
joined_at: str
|
|
11
|
+
"""ISO8601 timestamp of when app joined the guild."""
|
|
12
|
+
|
|
13
|
+
large: bool
|
|
14
|
+
"""If the guild is considered large."""
|
|
15
|
+
|
|
16
|
+
member_count: int
|
|
17
|
+
"""Total number of members in the guild."""
|
|
18
|
+
|
|
19
|
+
members: list[MemberModel]
|
|
20
|
+
"""Users in the guild."""
|
|
21
|
+
|
|
22
|
+
channels: list[Channel]
|
|
23
|
+
"""Channels in the guild."""
|
|
24
|
+
|
|
25
|
+
unavailable: Optional[bool]
|
|
26
|
+
"""If the guild is unavailable due to an outage."""
|
|
27
|
+
|
|
28
|
+
class GuildCreateEvent(GuildEvent):
|
|
29
|
+
"""Received when the bot has joined a guild."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
class GuildUpdateEvent(GuildEvent):
|
|
33
|
+
"""Received when a guild has been edited."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
class GuildDeleteEvent(GuildEvent):
|
|
37
|
+
"""Received when the bot has left a guild or the guild was deleted."""
|
|
38
|
+
pass
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from ..model import DataModel
|
|
4
|
+
|
|
5
|
+
from ..resources.interaction import Interaction
|
|
6
|
+
|
|
7
|
+
# ----- Command Interaction -----
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ApplicationCommandOptionData(DataModel):
|
|
11
|
+
"""Represents the response options from a slash command."""
|
|
12
|
+
name: str
|
|
13
|
+
"""Name of the command option."""
|
|
14
|
+
|
|
15
|
+
type: int
|
|
16
|
+
"""Type of command option. See [`CommandOptionTypes`][discord.parts.command.CommandOptionTypes]."""
|
|
17
|
+
|
|
18
|
+
value: str | int | float | bool
|
|
19
|
+
"""Input value for option."""
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ApplicationCommandData(DataModel):
|
|
23
|
+
"""Represents the response from a command."""
|
|
24
|
+
id: int
|
|
25
|
+
"""ID of the command."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
"""Name of the command."""
|
|
29
|
+
|
|
30
|
+
type: int
|
|
31
|
+
"""Type of command (e.g., message, user, slash)."""
|
|
32
|
+
|
|
33
|
+
guild_id: Optional[int]
|
|
34
|
+
"""ID of guild from which the command was invoked."""
|
|
35
|
+
|
|
36
|
+
target_id: Optional[int]
|
|
37
|
+
"""ID of the user or message from which the command was invoked (message/user commands only)."""
|
|
38
|
+
|
|
39
|
+
options: Optional[list[ApplicationCommandOptionData]] = field(default_factory=list)
|
|
40
|
+
"""Options of the command (slash command only)."""
|
|
41
|
+
|
|
42
|
+
def get_command_option_value(self, option_name: str):
|
|
43
|
+
"""Get the input for a command option by name.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
option_name (str): option to fetch input from
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ValueError: invalid option name
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
(str | int | float | bool): input data of specified option
|
|
53
|
+
"""
|
|
54
|
+
for option in self.options:
|
|
55
|
+
if option_name != option.name:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
return option.value
|
|
59
|
+
|
|
60
|
+
raise ValueError(f"Option name '{option_name}' could not be found.")
|
|
61
|
+
|
|
62
|
+
# ----- Component Interaction -----
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class MessageComponentData(DataModel):
|
|
66
|
+
"""Represents the select response from a select component."""
|
|
67
|
+
|
|
68
|
+
custom_id: str
|
|
69
|
+
"""Unique ID associated with the component."""
|
|
70
|
+
|
|
71
|
+
component_type: int
|
|
72
|
+
"""Type of component."""
|
|
73
|
+
|
|
74
|
+
values: Optional[list[str]] = field(default_factory=list)
|
|
75
|
+
"""Select values (if any)."""
|
|
76
|
+
|
|
77
|
+
# ----- Modal Interaction -----
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class ModalComponentData(DataModel):
|
|
81
|
+
"""Represents the modal field response from a modal."""
|
|
82
|
+
type: int
|
|
83
|
+
"""Type of component."""
|
|
84
|
+
|
|
85
|
+
value: Optional[str]
|
|
86
|
+
"""Text input value (Text Input component only)."""
|
|
87
|
+
|
|
88
|
+
values: Optional[list[str]]
|
|
89
|
+
"""String select values (String Select component only)."""
|
|
90
|
+
|
|
91
|
+
custom_id: str
|
|
92
|
+
"""Unique ID associated with the component."""
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class ModalComponent(DataModel):
|
|
96
|
+
"""Represents the modal component response from a modal."""
|
|
97
|
+
type: int
|
|
98
|
+
"""Type of component."""
|
|
99
|
+
|
|
100
|
+
component: ModalComponentData
|
|
101
|
+
"""Data associated with the component."""
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class ModalData(DataModel):
|
|
105
|
+
"""Represents the modal response from a modal."""
|
|
106
|
+
|
|
107
|
+
custom_id: str
|
|
108
|
+
"""Unique ID associated with the modal."""
|
|
109
|
+
|
|
110
|
+
components: list[ModalComponent] = field(default_factory=list)
|
|
111
|
+
"""Components on the modal."""
|
|
112
|
+
|
|
113
|
+
def get_modal_data(self, custom_id: str):
|
|
114
|
+
"""Fetch a modal field's data by its custom ID
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
custom_id (str): custom ID of field to fetch
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ValueError: invalid custom ID
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
(str | list[str]): component values (if string select) or value (if text input)
|
|
124
|
+
"""
|
|
125
|
+
for component in self.components:
|
|
126
|
+
if custom_id != component.component.custom_id:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
match component.component.type:
|
|
130
|
+
case 3: # string select -> values
|
|
131
|
+
return component.component.values
|
|
132
|
+
case 4: # text input -> value
|
|
133
|
+
return component.component.value
|
|
134
|
+
|
|
135
|
+
raise ValueError(f"Component custom id '{custom_id}' not found.")
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class InteractionEvent(DataModel):
|
|
139
|
+
"""Represents the interaction response."""
|
|
140
|
+
|
|
141
|
+
interaction: Interaction
|
|
142
|
+
"""Interaction resource object. See [`Interaction`][discord.resources.interaction.Interaction]."""
|
|
143
|
+
|
|
144
|
+
data: Optional[ApplicationCommandData | MessageComponentData | ModalData] = None
|
|
145
|
+
"""Interaction response data."""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from ..model import DataModel
|
|
4
|
+
|
|
5
|
+
from ..resources.message import Message
|
|
6
|
+
from ..models.member import MemberModel
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class MessageCreateEvent(DataModel):
|
|
10
|
+
"""Received when a message is created."""
|
|
11
|
+
message: Message
|
|
12
|
+
"""Message resource object. See [`Resource.Message`][discord.resources.message.Message]."""
|
|
13
|
+
|
|
14
|
+
guild_id: Optional[int]
|
|
15
|
+
"""Guild ID of the updated message (if in a guild channel)."""
|
|
16
|
+
|
|
17
|
+
member: Optional[MemberModel] # guild-only author info
|
|
18
|
+
"""Partial Member object of the author of the message. See [`MemberModel`][discord.models.member.MemberModel]."""
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class MessageUpdateEvent(DataModel):
|
|
22
|
+
"""Received when a message is updated."""
|
|
23
|
+
message: Message
|
|
24
|
+
"""Message resource object. See [`Resource.Message`][discord.resources.message.Message]."""
|
|
25
|
+
|
|
26
|
+
guild_id: Optional[int]
|
|
27
|
+
"""Guild ID of the updated message (if in a guild channel)."""
|
|
28
|
+
|
|
29
|
+
member: Optional[MemberModel]
|
|
30
|
+
"""Partial Member object of the author of the message. See [`MemberModel`][discord.models.member.MemberModel]."""
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class MessageDeleteEvent(DataModel):
|
|
34
|
+
"""Received when a message is deleted."""
|
|
35
|
+
|
|
36
|
+
id: int
|
|
37
|
+
"""ID of the deleted message."""
|
|
38
|
+
|
|
39
|
+
channel_id: int
|
|
40
|
+
"""Channel ID of the deleted message."""
|
|
41
|
+
|
|
42
|
+
guild_id: Optional[int]
|
|
43
|
+
"""Guild ID of the deleted message (if in a guild channel)."""
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from ..model import DataModel
|
|
4
|
+
|
|
5
|
+
from ..models.member import MemberModel
|
|
6
|
+
from ..models.emoji import EmojiModel
|
|
7
|
+
|
|
8
|
+
class ReactionType:
|
|
9
|
+
"""Reaction types."""
|
|
10
|
+
|
|
11
|
+
NORMAL = 0
|
|
12
|
+
"""A standard emoji."""
|
|
13
|
+
|
|
14
|
+
BURST = 1
|
|
15
|
+
"""A super emoji."""
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ReactionAddEvent(DataModel):
|
|
19
|
+
"""Reaction added event."""
|
|
20
|
+
|
|
21
|
+
type: int
|
|
22
|
+
"""Type of reaction added."""
|
|
23
|
+
|
|
24
|
+
user_id: int
|
|
25
|
+
"""ID of user who added the emoji."""
|
|
26
|
+
|
|
27
|
+
emoji: EmojiModel
|
|
28
|
+
"""Emoji used to react."""
|
|
29
|
+
|
|
30
|
+
channel_id: int
|
|
31
|
+
"""ID of the channel where the reaction took place."""
|
|
32
|
+
|
|
33
|
+
message_id: int
|
|
34
|
+
"""ID of the message where the reaction took place."""
|
|
35
|
+
|
|
36
|
+
guild_id: Optional[int]
|
|
37
|
+
"""ID of the guild where the reaction took place (if in a guild)."""
|
|
38
|
+
|
|
39
|
+
burst: bool
|
|
40
|
+
"""Whether the emoji is super."""
|
|
41
|
+
|
|
42
|
+
member: Optional[MemberModel]
|
|
43
|
+
"""Partial member object of the guild member that added the emoji (if in a guild)."""
|
|
44
|
+
|
|
45
|
+
message_author_id: Optional[int]
|
|
46
|
+
"""ID of the user who sent the message where the reaction was added."""
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ReactionRemoveEvent(DataModel):
|
|
50
|
+
"""Reaction removed event."""
|
|
51
|
+
|
|
52
|
+
type: int
|
|
53
|
+
"""Type of reaction removed."""
|
|
54
|
+
|
|
55
|
+
user_id: int
|
|
56
|
+
"""ID of user who removed their reaction."""
|
|
57
|
+
|
|
58
|
+
emoji: EmojiModel
|
|
59
|
+
"""Emoji data of the emoji where the reaction was removed."""
|
|
60
|
+
|
|
61
|
+
channel_id: int
|
|
62
|
+
"""ID of the channel where the reaction was removed."""
|
|
63
|
+
|
|
64
|
+
message_id: int
|
|
65
|
+
"""ID of the message where the reaction was removed."""
|
|
66
|
+
|
|
67
|
+
guild_id: Optional[int]
|
|
68
|
+
"""ID of the guild where the reaction was removed (if in a guild)."""
|
|
69
|
+
|
|
70
|
+
burst: bool
|
|
71
|
+
"""If the emoji of the removed reaction is super."""
|
|
72
|
+
|
|
73
|
+
class ReactionRemoveAllEvent(DataModel):
|
|
74
|
+
"""Remove all reactions event."""
|
|
75
|
+
|
|
76
|
+
channel_id: int
|
|
77
|
+
"""ID of the channel where all reaction were removed."""
|
|
78
|
+
|
|
79
|
+
message_id: int
|
|
80
|
+
"""ID of the message where all reaction were removed."""
|
|
81
|
+
|
|
82
|
+
guild_id: Optional[int]
|
|
83
|
+
"""ID of the guild where all reaction were removed (if in a guild)."""
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class ReactionRemoveEmojiEvent(DataModel):
|
|
87
|
+
"""All reactions of a specific emoji removed."""
|
|
88
|
+
|
|
89
|
+
emoji: EmojiModel
|
|
90
|
+
"""Emoji data of the removed reaction emoji."""
|
|
91
|
+
|
|
92
|
+
channel_id: int
|
|
93
|
+
"""ID of the channel where the reaction emoji was removed."""
|
|
94
|
+
|
|
95
|
+
message_id: int
|
|
96
|
+
"""ID of the message where the reaction emoji was removed."""
|
|
97
|
+
|
|
98
|
+
guild_id: Optional[int]
|
|
99
|
+
"""ID of the guild where the reaction emoji was removed. (if in a guild)"""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from ..model import DataModel
|
|
3
|
+
from ..models.user import UserModel
|
|
4
|
+
from ..models.guild import ReadyGuildModel
|
|
5
|
+
from ..models.application import ApplicationModel
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ReadyEvent(DataModel):
|
|
9
|
+
"""Received when bot goes online."""
|
|
10
|
+
|
|
11
|
+
v: int
|
|
12
|
+
"""API version number."""
|
|
13
|
+
|
|
14
|
+
user: UserModel
|
|
15
|
+
"""Information about the user."""
|
|
16
|
+
|
|
17
|
+
guilds: list[ReadyGuildModel]
|
|
18
|
+
"""List of guilds bot is in."""
|
|
19
|
+
|
|
20
|
+
session_id: str
|
|
21
|
+
"""Used for resuming connections."""
|
|
22
|
+
|
|
23
|
+
resume_gateway_url: str
|
|
24
|
+
"""Gateway URL for resuming connections."""
|
|
25
|
+
|
|
26
|
+
shard: list[int]
|
|
27
|
+
"""Shard information associated with this session."""
|
|
28
|
+
|
|
29
|
+
application: ApplicationModel
|
|
30
|
+
"""Partial application object. Contains ID and flags."""
|
discord/gateway.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import websockets
|
|
4
|
+
|
|
5
|
+
from .logger import Logger
|
|
6
|
+
from .events.hello_event import HelloEvent
|
|
7
|
+
|
|
8
|
+
DISCORD_OP_CODES = {
|
|
9
|
+
0: "Dispatch",
|
|
10
|
+
1: "Heartbeat",
|
|
11
|
+
2: "Identify",
|
|
12
|
+
3: "Presence Update",
|
|
13
|
+
6: "Resume",
|
|
14
|
+
7: "Reconnect",
|
|
15
|
+
8: "Request Guild Members",
|
|
16
|
+
9: "Invalid Session",
|
|
17
|
+
10: "Hello",
|
|
18
|
+
11: "Heartbeat ACK",
|
|
19
|
+
None: "Unknown"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class GatewayClient:
|
|
23
|
+
"""Handles real-time Websocket (WS) connection to Discord's Gateway.
|
|
24
|
+
Connects to Discord's WS, handles identify/resume logic, and maintains heartbeat.
|
|
25
|
+
"""
|
|
26
|
+
def __init__(self, token: str, intents: int, logger: Logger):
|
|
27
|
+
self.token = token
|
|
28
|
+
"""The bot's token."""
|
|
29
|
+
|
|
30
|
+
self._logger = logger
|
|
31
|
+
"""Logger instance to log events."""
|
|
32
|
+
|
|
33
|
+
self.ws = None
|
|
34
|
+
"""Websocket instance."""
|
|
35
|
+
|
|
36
|
+
self.heartbeat = None
|
|
37
|
+
"""Heartbeat task instance."""
|
|
38
|
+
|
|
39
|
+
self.sequence = None
|
|
40
|
+
"""Discord-generated sequence number for this websocket connection."""
|
|
41
|
+
|
|
42
|
+
self.session_id = None
|
|
43
|
+
"""Discord-generated session ID for this websocket connection."""
|
|
44
|
+
|
|
45
|
+
self.intents = intents
|
|
46
|
+
"""User-defined bot intents (for identify)."""
|
|
47
|
+
|
|
48
|
+
self.url_params = "?v=10&encoding=json"
|
|
49
|
+
"""Discord WS query params."""
|
|
50
|
+
|
|
51
|
+
self.connect_url = f"wss://gateway.discord.gg/"
|
|
52
|
+
"""URL to connect to Discord's gateway."""
|
|
53
|
+
|
|
54
|
+
async def connect(self):
|
|
55
|
+
"""Established websocket connection to Discord."""
|
|
56
|
+
try:
|
|
57
|
+
self.ws = await websockets.connect(self.connect_url + self.url_params)
|
|
58
|
+
self._logger.log_high_priority("Connected to Discord.")
|
|
59
|
+
except Exception as e:
|
|
60
|
+
self._logger.log_error(f"Websocket Connection Error {type(e).__name__} - {e}")
|
|
61
|
+
|
|
62
|
+
async def receive(self):
|
|
63
|
+
"""Receives and logs messages from the gateway.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
(dict): parsed JSON data
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
message = await self.ws.recv()
|
|
70
|
+
if message:
|
|
71
|
+
data: dict = json.loads(message)
|
|
72
|
+
self._logger.log_debug(f"Received: {DISCORD_OP_CODES.get(data.get('op'))} - {json.dumps(data, indent=4)}")
|
|
73
|
+
self._logger.log_info(f"Received: {DISCORD_OP_CODES.get(data.get('op'))}")
|
|
74
|
+
return data
|
|
75
|
+
else:
|
|
76
|
+
return None
|
|
77
|
+
except Exception as e:
|
|
78
|
+
self._logger.log_error(f"Error on receive: {type(e).__name__} - {e}")
|
|
79
|
+
|
|
80
|
+
async def send(self, message: dict):
|
|
81
|
+
"""Sends a JSON-encoded message to the gateway.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
message (dict): the message to send
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
await self.ws.send(json.dumps(message))
|
|
88
|
+
except Exception as e:
|
|
89
|
+
self._logger.log_error(f"Error on send: {type(e).__name__} - {e}")
|
|
90
|
+
|
|
91
|
+
async def send_heartbeat_loop(self):
|
|
92
|
+
"""Background task that sends heartbeat payloads in regular intervals.
|
|
93
|
+
Retries until cancelled.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
while True:
|
|
97
|
+
await asyncio.sleep(self.heartbeat_interval / 1000)
|
|
98
|
+
hb_data = {"op": 1, "d": self.sequence}
|
|
99
|
+
await self.send(hb_data)
|
|
100
|
+
self._logger.log_debug(f"Sending: {hb_data}")
|
|
101
|
+
self._logger.log_info("Heartbeat sent.")
|
|
102
|
+
except asyncio.CancelledError:
|
|
103
|
+
self._logger.log_debug("Heartbeat task cancelled")
|
|
104
|
+
except Exception as e:
|
|
105
|
+
self._logger.log_error(f"Error on heartbeat send: {type(e).__name__} - {e}")
|
|
106
|
+
|
|
107
|
+
async def identify(self):
|
|
108
|
+
"""Sends the IDENIFY payload (token, intents, connection properties).
|
|
109
|
+
Must be sent after connecting to the WS.
|
|
110
|
+
"""
|
|
111
|
+
i = {
|
|
112
|
+
"op": 2,
|
|
113
|
+
"d": {
|
|
114
|
+
"token": f"Bot {self.token}",
|
|
115
|
+
"intents": self.intents,
|
|
116
|
+
"properties": {
|
|
117
|
+
"$os": "my_os",
|
|
118
|
+
"$browser": "my_bot",
|
|
119
|
+
"$device": "my_bot"
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
await self.send(i)
|
|
124
|
+
log_i = self._logger.redact(i)
|
|
125
|
+
self._logger.log_debug(f"Sending: {log_i}")
|
|
126
|
+
self._logger.log_high_priority("Identify sent.")
|
|
127
|
+
|
|
128
|
+
async def start_heartbeat(self):
|
|
129
|
+
"""Waits for initial HELLO event, hydrates the HelloEvent class, and begins the heartbeat."""
|
|
130
|
+
try:
|
|
131
|
+
data = await self.receive()
|
|
132
|
+
hello = HelloEvent.from_dict(data.get('d'))
|
|
133
|
+
self.heartbeat_interval = hello.heartbeat_interval
|
|
134
|
+
self.heartbeat = asyncio.create_task(self.send_heartbeat_loop())
|
|
135
|
+
self._logger.log_high_priority("Heartbeat started.")
|
|
136
|
+
except Exception as e:
|
|
137
|
+
self._logger.log_error(f"Heartbeat Task Error: {type(e).__name__} - {e}")
|
|
138
|
+
|
|
139
|
+
async def reconnect(self):
|
|
140
|
+
"""Sends RESUME payload to reconnect with the same session ID and sequence number
|
|
141
|
+
as provided by Discord.
|
|
142
|
+
"""
|
|
143
|
+
await self.send({
|
|
144
|
+
"op": 6,
|
|
145
|
+
"d": {
|
|
146
|
+
"token": f"Bot {self.token}",
|
|
147
|
+
"session_id": self.session_id,
|
|
148
|
+
"seq": self.sequence
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
self._logger.log_high_priority("RESUME sent")
|
|
152
|
+
|
|
153
|
+
async def close(self):
|
|
154
|
+
"""Cancels heart beat and cleanly closes WS with error handling."""
|
|
155
|
+
# Cancel heartbeat task if it's still running
|
|
156
|
+
if self.heartbeat:
|
|
157
|
+
self._logger.log_high_priority(f"Cancelling heartbeat...")
|
|
158
|
+
self.heartbeat.cancel()
|
|
159
|
+
try:
|
|
160
|
+
await asyncio.wait_for(self.heartbeat, timeout=3) # Add timeout to avoid hanging
|
|
161
|
+
except asyncio.CancelledError:
|
|
162
|
+
self._logger.log_debug("Heartbeat cancelled by CancelledError.")
|
|
163
|
+
except asyncio.TimeoutError:
|
|
164
|
+
self._logger.log_error("Heartbeat cancel timed out.")
|
|
165
|
+
except Exception as e:
|
|
166
|
+
self._logger.log_error(f"Unexpected error cancelling heartbeat: {type(e).__name__} - {e}")
|
|
167
|
+
self.heartbeat = None
|
|
168
|
+
|
|
169
|
+
if self.ws:
|
|
170
|
+
try:
|
|
171
|
+
await self.ws.close()
|
|
172
|
+
self._logger.log_high_priority("WebSocket closed.")
|
|
173
|
+
except Exception as e:
|
|
174
|
+
self._logger.log_error(f"Error while closing websocket: {type(e).__name__} - {e}")
|
|
175
|
+
self.ws = None
|