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.
Files changed (72) hide show
  1. scurrypy/__init__.py +429 -0
  2. scurrypy/client.py +335 -0
  3. {discord → scurrypy}/client_like.py +8 -1
  4. scurrypy/dispatch/command_dispatcher.py +205 -0
  5. {discord → scurrypy}/dispatch/event_dispatcher.py +21 -21
  6. {discord → scurrypy}/dispatch/prefix_dispatcher.py +31 -12
  7. {discord → scurrypy}/error.py +6 -18
  8. {discord → scurrypy}/events/channel_events.py +2 -1
  9. scurrypy/events/gateway_events.py +31 -0
  10. {discord → scurrypy}/events/guild_events.py +2 -1
  11. {discord → scurrypy}/events/interaction_events.py +28 -13
  12. {discord → scurrypy}/events/message_events.py +8 -5
  13. {discord → scurrypy}/events/reaction_events.py +1 -2
  14. {discord → scurrypy}/events/ready_event.py +1 -3
  15. scurrypy/gateway.py +183 -0
  16. scurrypy/http.py +310 -0
  17. {discord → scurrypy}/intents.py +5 -7
  18. {discord → scurrypy}/logger.py +14 -61
  19. scurrypy/model.py +71 -0
  20. scurrypy/models.py +258 -0
  21. scurrypy/parts/channel.py +42 -0
  22. scurrypy/parts/command.py +90 -0
  23. scurrypy/parts/components.py +224 -0
  24. scurrypy/parts/components_v2.py +144 -0
  25. scurrypy/parts/embed.py +83 -0
  26. scurrypy/parts/message.py +134 -0
  27. scurrypy/parts/modal.py +16 -0
  28. {discord → scurrypy}/parts/role.py +2 -14
  29. {discord → scurrypy}/resources/application.py +1 -2
  30. {discord → scurrypy}/resources/bot_emojis.py +1 -1
  31. {discord → scurrypy}/resources/channel.py +9 -8
  32. {discord → scurrypy}/resources/guild.py +14 -16
  33. {discord → scurrypy}/resources/interaction.py +50 -43
  34. {discord → scurrypy}/resources/message.py +15 -16
  35. {discord → scurrypy}/resources/user.py +3 -4
  36. scurrypy-0.6.6.dist-info/METADATA +108 -0
  37. scurrypy-0.6.6.dist-info/RECORD +47 -0
  38. {scurrypy-0.4.dist-info → scurrypy-0.6.6.dist-info}/licenses/LICENSE +1 -1
  39. scurrypy-0.6.6.dist-info/top_level.txt +1 -0
  40. discord/__init__.py +0 -223
  41. discord/client.py +0 -375
  42. discord/dispatch/command_dispatcher.py +0 -163
  43. discord/gateway.py +0 -155
  44. discord/http.py +0 -280
  45. discord/model.py +0 -90
  46. discord/models/__init__.py +0 -1
  47. discord/models/application.py +0 -37
  48. discord/models/emoji.py +0 -34
  49. discord/models/guild.py +0 -35
  50. discord/models/integration.py +0 -23
  51. discord/models/interaction.py +0 -26
  52. discord/models/member.py +0 -27
  53. discord/models/role.py +0 -53
  54. discord/models/user.py +0 -15
  55. discord/parts/action_row.py +0 -208
  56. discord/parts/channel.py +0 -20
  57. discord/parts/command.py +0 -102
  58. discord/parts/components_v2.py +0 -353
  59. discord/parts/embed.py +0 -154
  60. discord/parts/message.py +0 -194
  61. discord/parts/modal.py +0 -21
  62. scurrypy-0.4.dist-info/METADATA +0 -130
  63. scurrypy-0.4.dist-info/RECORD +0 -54
  64. scurrypy-0.4.dist-info/top_level.txt +0 -1
  65. {discord → scurrypy}/config.py +0 -0
  66. {discord → scurrypy}/dispatch/__init__.py +0 -0
  67. {discord → scurrypy}/events/__init__.py +0 -0
  68. {discord → scurrypy}/events/hello_event.py +0 -0
  69. {discord → scurrypy}/parts/__init__.py +0 -0
  70. {discord → scurrypy}/parts/component_types.py +0 -0
  71. {discord → scurrypy}/resources/__init__.py +0 -0
  72. {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.member import MemberModel
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 (case-insensitive)
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.lower()] = handler
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
- if event.message._has_prefix(self.prefix):
52
- command, *args = event.message._extract_args(self.prefix)
53
- handler = self._handlers.get(command)
54
- if handler:
55
- try:
56
- await handler(self.bot, event, *args)
57
- self._logger.log_info(f"Prefix Event '{command}' Acknowledged.")
58
- except Exception as e:
59
- self._logger.log_error(f"Error in prefix command '{command}': {e}")
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}")
@@ -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
- """Raw error data."""
19
-
12
+ self.status = status
20
13
  self.reason = data.get('message', 'Unknown Error')
21
- """Discord-generated reason for error."""
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.fatal = status in (401, 403)
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.member import MemberModel
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`][discord.parts.command.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 option_name != option.name:
56
- continue
57
-
58
- return option.value
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 name '{option_name}' could not be found.")
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 [3,5,6,7,8]: # select menus (w. possibly many option selects!)
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 id '{custom_id}' not found.")
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`][discord.resources.interaction.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.member import MemberModel
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`][discord.resources.message.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`][discord.models.member.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`][discord.resources.message.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`][discord.models.member.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.member import MemberModel
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.user import UserModel
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