disagreement 0.3.0b1__py3-none-any.whl → 0.4.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.
- disagreement/__init__.py +2 -4
- disagreement/audio.py +25 -5
- disagreement/cache.py +12 -3
- disagreement/caching.py +15 -14
- disagreement/client.py +86 -52
- disagreement/enums.py +10 -3
- disagreement/error_handler.py +5 -1
- disagreement/errors.py +1341 -3
- disagreement/event_dispatcher.py +1 -3
- disagreement/ext/__init__.py +1 -0
- disagreement/ext/app_commands/__init__.py +0 -2
- disagreement/ext/app_commands/commands.py +0 -2
- disagreement/ext/app_commands/context.py +0 -2
- disagreement/ext/app_commands/converters.py +2 -4
- disagreement/ext/app_commands/decorators.py +5 -7
- disagreement/ext/app_commands/handler.py +1 -3
- disagreement/ext/app_commands/hybrid.py +0 -2
- disagreement/ext/commands/__init__.py +0 -2
- disagreement/ext/commands/cog.py +0 -2
- disagreement/ext/commands/converters.py +16 -5
- disagreement/ext/commands/core.py +52 -14
- disagreement/ext/commands/decorators.py +3 -7
- disagreement/ext/commands/errors.py +0 -2
- disagreement/ext/commands/help.py +0 -2
- disagreement/ext/commands/view.py +1 -3
- disagreement/gateway.py +27 -25
- disagreement/http.py +264 -22
- disagreement/interactions.py +0 -2
- disagreement/models.py +199 -105
- disagreement/shard_manager.py +0 -2
- disagreement/ui/view.py +2 -2
- disagreement/voice_client.py +20 -1
- {disagreement-0.3.0b1.dist-info → disagreement-0.4.0.dist-info}/METADATA +32 -6
- disagreement-0.4.0.dist-info/RECORD +55 -0
- disagreement-0.3.0b1.dist-info/RECORD +0 -55
- {disagreement-0.3.0b1.dist-info → disagreement-0.4.0.dist-info}/WHEEL +0 -0
- {disagreement-0.3.0b1.dist-info → disagreement-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {disagreement-0.3.0b1.dist-info → disagreement-0.4.0.dist-info}/top_level.txt +0 -0
disagreement/event_dispatcher.py
CHANGED
@@ -1,5 +1,3 @@
|
|
1
|
-
# disagreement/event_dispatcher.py
|
2
|
-
|
3
1
|
"""
|
4
2
|
Event dispatcher for handling Discord Gateway events.
|
5
3
|
"""
|
@@ -198,7 +196,7 @@ class EventDispatcher:
|
|
198
196
|
try:
|
199
197
|
self._listeners[event_name_upper].remove(coro)
|
200
198
|
except ValueError:
|
201
|
-
pass
|
199
|
+
pass
|
202
200
|
|
203
201
|
def add_waiter(
|
204
202
|
self,
|
disagreement/ext/__init__.py
CHANGED
@@ -0,0 +1 @@
|
|
1
|
+
|
@@ -1,5 +1,3 @@
|
|
1
|
-
# disagreement/ext/app_commands/converters.py
|
2
|
-
|
3
1
|
"""
|
4
2
|
Converters for transforming application command option values.
|
5
3
|
"""
|
@@ -466,8 +464,8 @@ async def run_converters(
|
|
466
464
|
|
467
465
|
# If no specific converter, and it's not a basic type match, raise error or return raw
|
468
466
|
# For now, let's raise if no converter found for a specific option type
|
469
|
-
if option_type in DEFAULT_CONVERTERS:
|
470
|
-
pass
|
467
|
+
if option_type in DEFAULT_CONVERTERS:
|
468
|
+
pass
|
471
469
|
|
472
470
|
# If it's a model type but no converter yet, this will need to be handled
|
473
471
|
# e.g. if param_type is User and option_type is ApplicationCommandOptionType.USER
|
@@ -1,5 +1,3 @@
|
|
1
|
-
# disagreement/ext/app_commands/decorators.py
|
2
|
-
|
3
1
|
import inspect
|
4
2
|
import asyncio
|
5
3
|
from dataclasses import dataclass
|
@@ -134,7 +132,7 @@ def _extract_options_from_signature(
|
|
134
132
|
first_param = next(param_iter, None) # Consume 'self', get next
|
135
133
|
|
136
134
|
if first_param and first_param.name == "ctx": # Consume 'ctx'
|
137
|
-
pass
|
135
|
+
pass
|
138
136
|
elif (
|
139
137
|
first_param
|
140
138
|
): # If first_param was not 'self' and not 'ctx', it's a command option
|
@@ -147,7 +145,7 @@ def _extract_options_from_signature(
|
|
147
145
|
if param.kind == param.VAR_POSITIONAL or param.kind == param.VAR_KEYWORD:
|
148
146
|
# *args and **kwargs are not directly supported by slash command options structure.
|
149
147
|
# Could raise an error or ignore. For now, ignore.
|
150
|
-
|
148
|
+
|
151
149
|
continue
|
152
150
|
|
153
151
|
option_name = param.name
|
@@ -190,7 +188,7 @@ def _extract_options_from_signature(
|
|
190
188
|
# More complex Unions are not directly supported by a single option type.
|
191
189
|
# Could default to STRING or raise.
|
192
190
|
# For now, let's assume simple Optional[T] or direct types.
|
193
|
-
|
191
|
+
|
194
192
|
actual_type_for_mapping = str
|
195
193
|
|
196
194
|
elif origin is list and len(args) == 1:
|
@@ -198,7 +196,7 @@ def _extract_options_from_signature(
|
|
198
196
|
# via repeated options or specific component interactions, not directly in slash command options.
|
199
197
|
# This might indicate a need for a different interaction pattern or custom parsing.
|
200
198
|
# For now, treat List[str] as a string, others might error or default.
|
201
|
-
|
199
|
+
|
202
200
|
actual_type_for_mapping = args[
|
203
201
|
0
|
204
202
|
] # Use the inner type for mapping, but this is a simplification.
|
@@ -247,7 +245,7 @@ def _extract_options_from_signature(
|
|
247
245
|
|
248
246
|
if not option_type:
|
249
247
|
# Fallback or error if type couldn't be mapped
|
250
|
-
|
248
|
+
|
251
249
|
option_type = ApplicationCommandOptionType.STRING # Default fallback
|
252
250
|
|
253
251
|
required = (param.default == inspect.Parameter.empty) and not is_optional
|
@@ -1,5 +1,3 @@
|
|
1
|
-
# disagreement/ext/app_commands/handler.py
|
2
|
-
|
3
1
|
import inspect
|
4
2
|
import json
|
5
3
|
import logging
|
@@ -341,7 +339,7 @@ class AppCommandHandler:
|
|
341
339
|
return value.lower() == "true"
|
342
340
|
return bool(value)
|
343
341
|
except (ValueError, TypeError):
|
344
|
-
pass
|
342
|
+
pass
|
345
343
|
return value # Return as is if no specific resolution or conversion applied
|
346
344
|
|
347
345
|
async def _resolve_value(
|
disagreement/ext/commands/cog.py
CHANGED
@@ -1,8 +1,9 @@
|
|
1
|
-
#
|
1
|
+
# pyright: reportIncompatibleMethodOverride=false
|
2
2
|
|
3
3
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar, Generic
|
4
4
|
from abc import ABC, abstractmethod
|
5
5
|
import re
|
6
|
+
import inspect
|
6
7
|
|
7
8
|
from .errors import BadArgument
|
8
9
|
from disagreement.models import Member, Guild, Role
|
@@ -36,6 +37,20 @@ class Converter(ABC, Generic[T]):
|
|
36
37
|
raise NotImplementedError("Converter subclass must implement convert method.")
|
37
38
|
|
38
39
|
|
40
|
+
class Greedy(list):
|
41
|
+
"""Type hint helper to greedily consume arguments."""
|
42
|
+
|
43
|
+
converter: Any = None
|
44
|
+
|
45
|
+
def __class_getitem__(cls, param: Any) -> type: # pyright: ignore[override]
|
46
|
+
if isinstance(param, tuple):
|
47
|
+
if len(param) != 1:
|
48
|
+
raise TypeError("Greedy[...] expects a single parameter")
|
49
|
+
param = param[0]
|
50
|
+
name = f"Greedy[{getattr(param, '__name__', str(param))}]"
|
51
|
+
return type(name, (Greedy,), {"converter": param})
|
52
|
+
|
53
|
+
|
39
54
|
# --- Built-in Type Converters ---
|
40
55
|
|
41
56
|
|
@@ -169,7 +184,3 @@ async def run_converters(ctx: "CommandContext", annotation: Any, argument: str)
|
|
169
184
|
raise BadArgument(f"No converter found for type annotation '{annotation}'.")
|
170
185
|
|
171
186
|
return argument # Default to string if no annotation or annotation is str
|
172
|
-
|
173
|
-
|
174
|
-
# Need to import inspect for the run_converters function
|
175
|
-
import inspect
|
@@ -1,5 +1,3 @@
|
|
1
|
-
# disagreement/ext/commands/core.py
|
2
|
-
|
3
1
|
from __future__ import annotations
|
4
2
|
|
5
3
|
import asyncio
|
@@ -29,7 +27,7 @@ from .errors import (
|
|
29
27
|
CheckFailure,
|
30
28
|
CommandInvokeError,
|
31
29
|
)
|
32
|
-
from .converters import run_converters, DEFAULT_CONVERTERS, Converter
|
30
|
+
from .converters import Greedy, run_converters, DEFAULT_CONVERTERS, Converter
|
33
31
|
from disagreement.typing import Typing
|
34
32
|
|
35
33
|
logger = logging.getLogger(__name__)
|
@@ -46,29 +44,39 @@ class GroupMixin:
|
|
46
44
|
self.commands: Dict[str, "Command"] = {}
|
47
45
|
self.name: str = ""
|
48
46
|
|
49
|
-
def command(
|
47
|
+
def command(
|
48
|
+
self, **attrs: Any
|
49
|
+
) -> Callable[[Callable[..., Awaitable[None]]], "Command"]:
|
50
50
|
def decorator(func: Callable[..., Awaitable[None]]) -> "Command":
|
51
51
|
cmd = Command(func, **attrs)
|
52
52
|
cmd.cog = getattr(self, "cog", None)
|
53
53
|
self.add_command(cmd)
|
54
54
|
return cmd
|
55
|
+
|
55
56
|
return decorator
|
56
57
|
|
57
|
-
def group(
|
58
|
+
def group(
|
59
|
+
self, **attrs: Any
|
60
|
+
) -> Callable[[Callable[..., Awaitable[None]]], "Group"]:
|
58
61
|
def decorator(func: Callable[..., Awaitable[None]]) -> "Group":
|
59
62
|
cmd = Group(func, **attrs)
|
60
63
|
cmd.cog = getattr(self, "cog", None)
|
61
64
|
self.add_command(cmd)
|
62
65
|
return cmd
|
66
|
+
|
63
67
|
return decorator
|
64
68
|
|
65
69
|
def add_command(self, command: "Command") -> None:
|
66
70
|
if command.name in self.commands:
|
67
|
-
raise ValueError(
|
71
|
+
raise ValueError(
|
72
|
+
f"Command '{command.name}' is already registered in group '{self.name}'."
|
73
|
+
)
|
68
74
|
self.commands[command.name.lower()] = command
|
69
75
|
for alias in command.aliases:
|
70
76
|
if alias in self.commands:
|
71
|
-
logger.warning(
|
77
|
+
logger.warning(
|
78
|
+
f"Alias '{alias}' for command '{command.name}' in group '{self.name}' conflicts with an existing command or alias."
|
79
|
+
)
|
72
80
|
self.commands[alias.lower()] = command
|
73
81
|
|
74
82
|
def get_command(self, name: str) -> Optional["Command"]:
|
@@ -181,6 +189,7 @@ class Command(GroupMixin):
|
|
181
189
|
|
182
190
|
class Group(Command):
|
183
191
|
"""A command that can have subcommands."""
|
192
|
+
|
184
193
|
def __init__(self, callback: Callable[..., Awaitable[None]], **attrs: Any):
|
185
194
|
super().__init__(callback, **attrs)
|
186
195
|
|
@@ -494,7 +503,34 @@ class CommandHandler:
|
|
494
503
|
None # Holds the raw string for current param
|
495
504
|
)
|
496
505
|
|
497
|
-
|
506
|
+
annotation = param.annotation
|
507
|
+
if inspect.isclass(annotation) and issubclass(annotation, Greedy):
|
508
|
+
greedy_values = []
|
509
|
+
converter_type = annotation.converter
|
510
|
+
while not view.eof:
|
511
|
+
view.skip_whitespace()
|
512
|
+
if view.eof:
|
513
|
+
break
|
514
|
+
start = view.index
|
515
|
+
if view.buffer[view.index] == '"':
|
516
|
+
arg_str_value = view.get_quoted_string()
|
517
|
+
if arg_str_value == "" and view.buffer[view.index] == '"':
|
518
|
+
raise BadArgument(
|
519
|
+
f"Unterminated quoted string for argument '{param.name}'."
|
520
|
+
)
|
521
|
+
else:
|
522
|
+
arg_str_value = view.get_word()
|
523
|
+
try:
|
524
|
+
converted = await run_converters(
|
525
|
+
ctx, converter_type, arg_str_value
|
526
|
+
)
|
527
|
+
except BadArgument:
|
528
|
+
view.index = start
|
529
|
+
break
|
530
|
+
greedy_values.append(converted)
|
531
|
+
final_value_for_param = greedy_values
|
532
|
+
arg_str_value = None
|
533
|
+
elif view.eof: # No more input string
|
498
534
|
if param.default is not inspect.Parameter.empty:
|
499
535
|
final_value_for_param = param.default
|
500
536
|
elif param.kind != inspect.Parameter.VAR_KEYWORD:
|
@@ -529,9 +565,7 @@ class CommandHandler:
|
|
529
565
|
|
530
566
|
# If final_value_for_param was not set by greedy logic, try conversion
|
531
567
|
if final_value_for_param is inspect.Parameter.empty:
|
532
|
-
if
|
533
|
-
arg_str_value is None
|
534
|
-
): # Should not happen if view.get_word/get_quoted_string is robust
|
568
|
+
if arg_str_value is None:
|
535
569
|
if param.default is not inspect.Parameter.empty:
|
536
570
|
final_value_for_param = param.default
|
537
571
|
else:
|
@@ -571,7 +605,7 @@ class CommandHandler:
|
|
571
605
|
final_value_for_param = None
|
572
606
|
elif last_err_union:
|
573
607
|
raise last_err_union
|
574
|
-
else:
|
608
|
+
else:
|
575
609
|
raise BadArgument(
|
576
610
|
f"Could not convert '{arg_str_value}' to any of {union_args} for param '{param.name}'."
|
577
611
|
)
|
@@ -656,7 +690,9 @@ class CommandHandler:
|
|
656
690
|
elif command.invoke_without_command:
|
657
691
|
view.index -= len(potential_subcommand) + view.previous
|
658
692
|
else:
|
659
|
-
raise CommandNotFound(
|
693
|
+
raise CommandNotFound(
|
694
|
+
f"Subcommand '{potential_subcommand}' not found."
|
695
|
+
)
|
660
696
|
|
661
697
|
ctx = CommandContext(
|
662
698
|
message=message,
|
@@ -681,7 +717,9 @@ class CommandHandler:
|
|
681
717
|
if hasattr(self.client, "on_command_error"):
|
682
718
|
await self.client.on_command_error(ctx, e)
|
683
719
|
except Exception as e:
|
684
|
-
logger.error(
|
720
|
+
logger.error(
|
721
|
+
"Unexpected error invoking command '%s': %s", original_command.name, e
|
722
|
+
)
|
685
723
|
exc = CommandInvokeError(e)
|
686
724
|
if hasattr(self.client, "on_command_error"):
|
687
725
|
await self.client.on_command_error(ctx, exc)
|
@@ -1,4 +1,3 @@
|
|
1
|
-
# disagreement/ext/commands/decorators.py
|
2
1
|
from __future__ import annotations
|
3
2
|
|
4
3
|
import asyncio
|
@@ -218,6 +217,7 @@ def requires_permissions(
|
|
218
217
|
|
219
218
|
return check(predicate)
|
220
219
|
|
220
|
+
|
221
221
|
def has_role(
|
222
222
|
name_or_id: str | int,
|
223
223
|
) -> Callable[[Callable[..., Awaitable[None]]], Callable[..., Awaitable[None]]]:
|
@@ -241,9 +241,7 @@ def has_role(
|
|
241
241
|
raise CheckFailure("Could not resolve author to a guild member.")
|
242
242
|
|
243
243
|
# Create a list of the member's role objects by looking them up in the guild's roles list
|
244
|
-
member_roles = [
|
245
|
-
role for role in ctx.guild.roles if role.id in author.roles
|
246
|
-
]
|
244
|
+
member_roles = [role for role in ctx.guild.roles if role.id in author.roles]
|
247
245
|
|
248
246
|
if any(
|
249
247
|
role.id == str(name_or_id) or role.name == name_or_id
|
@@ -278,9 +276,7 @@ def has_any_role(
|
|
278
276
|
if not author:
|
279
277
|
raise CheckFailure("Could not resolve author to a guild member.")
|
280
278
|
|
281
|
-
member_roles = [
|
282
|
-
role for role in ctx.guild.roles if role.id in author.roles
|
283
|
-
]
|
279
|
+
member_roles = [role for role in ctx.guild.roles if role.id in author.roles]
|
284
280
|
# Convert names_or_ids to a set for efficient lookup
|
285
281
|
names_or_ids_set = set(map(str, names_or_ids))
|
286
282
|
|
@@ -1,5 +1,3 @@
|
|
1
|
-
# disagreement/ext/commands/view.py
|
2
|
-
|
3
1
|
import re
|
4
2
|
|
5
3
|
|
@@ -47,7 +45,7 @@ class StringView:
|
|
47
45
|
word = match.group(0)
|
48
46
|
self.index += len(word)
|
49
47
|
return word
|
50
|
-
return ""
|
48
|
+
return ""
|
51
49
|
|
52
50
|
def get_quoted_string(self) -> str:
|
53
51
|
"""
|
disagreement/gateway.py
CHANGED
@@ -1,5 +1,3 @@
|
|
1
|
-
# disagreement/gateway.py
|
2
|
-
|
3
1
|
"""
|
4
2
|
Manages the WebSocket connection to the Discord Gateway.
|
5
3
|
"""
|
@@ -14,6 +12,8 @@ import time
|
|
14
12
|
import random
|
15
13
|
from typing import Optional, TYPE_CHECKING, Any, Dict
|
16
14
|
|
15
|
+
from .models import Activity
|
16
|
+
|
17
17
|
from .enums import GatewayOpcode, GatewayIntent
|
18
18
|
from .errors import GatewayException, DisagreementException, AuthenticationError
|
19
19
|
from .interactions import Interaction
|
@@ -63,7 +63,11 @@ class GatewayClient:
|
|
63
63
|
self._max_backoff: float = max_backoff
|
64
64
|
|
65
65
|
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
66
|
-
|
66
|
+
try:
|
67
|
+
self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
|
68
|
+
except RuntimeError:
|
69
|
+
self._loop = asyncio.new_event_loop()
|
70
|
+
asyncio.set_event_loop(self._loop)
|
67
71
|
self._heartbeat_interval: Optional[float] = None
|
68
72
|
self._last_sequence: Optional[int] = None
|
69
73
|
self._session_id: Optional[str] = None
|
@@ -146,12 +150,11 @@ class GatewayClient:
|
|
146
150
|
self._last_heartbeat_sent = time.monotonic()
|
147
151
|
payload = {"op": GatewayOpcode.HEARTBEAT, "d": self._last_sequence}
|
148
152
|
await self._send_json(payload)
|
149
|
-
# print("Sent heartbeat.")
|
150
153
|
|
151
154
|
async def _keep_alive(self):
|
152
155
|
"""Manages the heartbeating loop."""
|
153
156
|
if self._heartbeat_interval is None:
|
154
|
-
|
157
|
+
|
155
158
|
logger.error("Heartbeat interval not set. Cannot start keep_alive.")
|
156
159
|
return
|
157
160
|
|
@@ -213,26 +216,17 @@ class GatewayClient:
|
|
213
216
|
async def update_presence(
|
214
217
|
self,
|
215
218
|
status: str,
|
216
|
-
|
217
|
-
|
219
|
+
activity: Optional[Activity] = None,
|
220
|
+
*,
|
218
221
|
since: int = 0,
|
219
222
|
afk: bool = False,
|
220
|
-
):
|
223
|
+
) -> None:
|
221
224
|
"""Sends the presence update payload to the Gateway."""
|
222
225
|
payload = {
|
223
226
|
"op": GatewayOpcode.PRESENCE_UPDATE,
|
224
227
|
"d": {
|
225
228
|
"since": since,
|
226
|
-
"activities": (
|
227
|
-
[
|
228
|
-
{
|
229
|
-
"name": activity_name,
|
230
|
-
"type": activity_type,
|
231
|
-
}
|
232
|
-
]
|
233
|
-
if activity_name
|
234
|
-
else []
|
235
|
-
),
|
229
|
+
"activities": [activity.to_dict()] if activity else [],
|
236
230
|
"status": status,
|
237
231
|
"afk": afk,
|
238
232
|
},
|
@@ -353,12 +347,15 @@ class GatewayClient:
|
|
353
347
|
future._members.extend(raw_event_d_payload.get("members", [])) # type: ignore
|
354
348
|
|
355
349
|
# If this is the last chunk, resolve the future
|
356
|
-
if
|
350
|
+
if (
|
351
|
+
raw_event_d_payload.get("chunk_index")
|
352
|
+
== raw_event_d_payload.get("chunk_count", 1) - 1
|
353
|
+
):
|
357
354
|
future.set_result(future._members) # type: ignore
|
358
355
|
del self._member_chunk_requests[nonce]
|
359
356
|
|
360
357
|
elif event_name == "INTERACTION_CREATE":
|
361
|
-
|
358
|
+
|
362
359
|
if isinstance(raw_event_d_payload, dict):
|
363
360
|
interaction = Interaction(
|
364
361
|
data=raw_event_d_payload, client_instance=self._client_instance
|
@@ -397,7 +394,7 @@ class GatewayClient:
|
|
397
394
|
event_data_to_dispatch = (
|
398
395
|
raw_event_d_payload if isinstance(raw_event_d_payload, dict) else {}
|
399
396
|
)
|
400
|
-
|
397
|
+
|
401
398
|
await self._dispatcher.dispatch(event_name, event_data_to_dispatch)
|
402
399
|
else:
|
403
400
|
logger.warning("Received dispatch with no event name: %s", data)
|
@@ -496,8 +493,6 @@ class GatewayClient:
|
|
496
493
|
await self._identify()
|
497
494
|
elif op == GatewayOpcode.HEARTBEAT_ACK:
|
498
495
|
self._last_heartbeat_ack = time.monotonic()
|
499
|
-
# print("Received heartbeat ACK.")
|
500
|
-
pass # Good, connection is alive
|
501
496
|
else:
|
502
497
|
logger.warning(
|
503
498
|
"Received unhandled Gateway Opcode: %s with data: %s", op, data
|
@@ -584,7 +579,7 @@ class GatewayClient:
|
|
584
579
|
try:
|
585
580
|
await self._keep_alive_task
|
586
581
|
except asyncio.CancelledError:
|
587
|
-
pass
|
582
|
+
pass
|
588
583
|
|
589
584
|
if self._receive_task and not self._receive_task.done():
|
590
585
|
current = asyncio.current_task(loop=self._loop)
|
@@ -593,7 +588,7 @@ class GatewayClient:
|
|
593
588
|
try:
|
594
589
|
await self._receive_task
|
595
590
|
except asyncio.CancelledError:
|
596
|
-
pass
|
591
|
+
pass
|
597
592
|
|
598
593
|
if self._ws and not self._ws.closed:
|
599
594
|
await self._ws.close(code=code)
|
@@ -621,6 +616,13 @@ class GatewayClient:
|
|
621
616
|
return None
|
622
617
|
return self._last_heartbeat_ack - self._last_heartbeat_sent
|
623
618
|
|
619
|
+
@property
|
620
|
+
def latency_ms(self) -> Optional[float]:
|
621
|
+
"""Returns the latency between heartbeat and ACK in milliseconds."""
|
622
|
+
if self._last_heartbeat_sent is None or self._last_heartbeat_ack is None:
|
623
|
+
return None
|
624
|
+
return (self._last_heartbeat_ack - self._last_heartbeat_sent) * 1000
|
625
|
+
|
624
626
|
@property
|
625
627
|
def last_heartbeat_sent(self) -> Optional[float]:
|
626
628
|
return self._last_heartbeat_sent
|