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.

Files changed (52) hide show
  1. discord/__init__.py +9 -0
  2. discord/client.py +312 -0
  3. discord/dispatch/__init__.py +1 -0
  4. discord/dispatch/command_dispatcher.py +156 -0
  5. discord/dispatch/event_dispatcher.py +85 -0
  6. discord/dispatch/prefix_dispatcher.py +53 -0
  7. discord/error.py +63 -0
  8. discord/events/__init__.py +33 -0
  9. discord/events/channel_events.py +52 -0
  10. discord/events/guild_events.py +38 -0
  11. discord/events/hello_event.py +9 -0
  12. discord/events/interaction_events.py +145 -0
  13. discord/events/message_events.py +43 -0
  14. discord/events/reaction_events.py +99 -0
  15. discord/events/ready_event.py +30 -0
  16. discord/gateway.py +175 -0
  17. discord/http.py +292 -0
  18. discord/intents.py +87 -0
  19. discord/logger.py +147 -0
  20. discord/model.py +88 -0
  21. discord/models/__init__.py +8 -0
  22. discord/models/application.py +37 -0
  23. discord/models/emoji.py +34 -0
  24. discord/models/guild.py +35 -0
  25. discord/models/integration.py +23 -0
  26. discord/models/member.py +27 -0
  27. discord/models/role.py +53 -0
  28. discord/models/user.py +15 -0
  29. discord/parts/__init__.py +28 -0
  30. discord/parts/action_row.py +258 -0
  31. discord/parts/attachment.py +18 -0
  32. discord/parts/channel.py +20 -0
  33. discord/parts/command.py +102 -0
  34. discord/parts/component_types.py +5 -0
  35. discord/parts/components_v2.py +270 -0
  36. discord/parts/embed.py +154 -0
  37. discord/parts/message.py +179 -0
  38. discord/parts/modal.py +21 -0
  39. discord/parts/role.py +39 -0
  40. discord/resources/__init__.py +10 -0
  41. discord/resources/application.py +94 -0
  42. discord/resources/bot_emojis.py +49 -0
  43. discord/resources/channel.py +192 -0
  44. discord/resources/guild.py +265 -0
  45. discord/resources/interaction.py +155 -0
  46. discord/resources/message.py +223 -0
  47. discord/resources/user.py +111 -0
  48. scurrypy-0.1.0.dist-info/METADATA +8 -0
  49. scurrypy-0.1.0.dist-info/RECORD +52 -0
  50. scurrypy-0.1.0.dist-info/WHEEL +5 -0
  51. scurrypy-0.1.0.dist-info/licenses/LICENSE +5 -0
  52. 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,9 @@
1
+ from dataclasses import dataclass
2
+ from ..model import DataModel
3
+
4
+ @dataclass
5
+ class HelloEvent(DataModel):
6
+ """Heartbeat interval event."""
7
+
8
+ heartbeat_interval: int
9
+ """Heartbeat interval in milliseconds."""
@@ -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