rift-discord 0.2.0__tar.gz
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.
- rift_discord-0.2.0/PKG-INFO +19 -0
- rift_discord-0.2.0/README.md +3 -0
- rift_discord-0.2.0/pyproject.toml +33 -0
- rift_discord-0.2.0/python/rift/__init__.py +742 -0
- rift_discord-0.2.0/python/rift/__init__.pyi +224 -0
- rift_discord-0.2.0/python/rift/py.typed +1 -0
- rift_discord-0.2.0/python/rift_discord.egg-info/PKG-INFO +19 -0
- rift_discord-0.2.0/python/rift_discord.egg-info/SOURCES.txt +10 -0
- rift_discord-0.2.0/python/rift_discord.egg-info/dependency_links.txt +1 -0
- rift_discord-0.2.0/python/rift_discord.egg-info/top_level.txt +1 -0
- rift_discord-0.2.0/setup.cfg +4 -0
- rift_discord-0.2.0/tests/test_rift.py +163 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rift-discord
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: High-performance Python bindings for Rift Discord v10 engine
|
|
5
|
+
Author: isaim0011
|
|
6
|
+
License: MIT OR Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/isaim0011/rift
|
|
8
|
+
Project-URL: Repository, https://github.com/isaim0011/rift
|
|
9
|
+
Keywords: discord,bot,gateway,api,rust,rift
|
|
10
|
+
Classifier: Programming Language :: Rust
|
|
11
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Communications :: Chat
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# rift-discord (Python Bindings)
|
|
18
|
+
|
|
19
|
+
Python bindings for Rift: high-performance Discord engine written in Rust.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rift-discord"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "High-performance Python bindings for Rift Discord v10 engine"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT OR Apache-2.0" }
|
|
12
|
+
authors = [{ name = "isaim0011" }]
|
|
13
|
+
keywords = ["discord", "bot", "gateway", "api", "rust", "rift"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Rust",
|
|
16
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Communications :: Chat",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/isaim0011/rift"
|
|
24
|
+
Repository = "https://github.com/isaim0011/rift"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.package-dir]
|
|
27
|
+
"" = "python"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["python"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
rift = ["py.typed", "*.pyi"]
|
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rift Discord Engine - Python Bindings.
|
|
3
|
+
|
|
4
|
+
High-performance, memory-safe, spec-compliant Discord engine written in Rust.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
14
|
+
|
|
15
|
+
# Attempt to load compiled native PyO3 C-extension
|
|
16
|
+
try:
|
|
17
|
+
from . import _rift
|
|
18
|
+
except ImportError:
|
|
19
|
+
try:
|
|
20
|
+
import _rift
|
|
21
|
+
except ImportError:
|
|
22
|
+
_rift = None
|
|
23
|
+
|
|
24
|
+
DISCORD_EPOCH_MS = 1420070400000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# 1. Snowflake
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
class Snowflake:
|
|
31
|
+
"""Discord snowflake ID decoding, inspection, and generation."""
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def timestamp_ms(snowflake_id: Union[int, str]) -> int:
|
|
35
|
+
n = int(snowflake_id)
|
|
36
|
+
if _rift is not None:
|
|
37
|
+
return _rift.Snowflake.timestamp_ms(n)
|
|
38
|
+
return (n >> 22) + DISCORD_EPOCH_MS
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def worker_id(snowflake_id: Union[int, str]) -> int:
|
|
42
|
+
n = int(snowflake_id)
|
|
43
|
+
if _rift is not None:
|
|
44
|
+
return _rift.Snowflake.worker_id(n)
|
|
45
|
+
return (n & 0x3E0000) >> 17
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def process_id(snowflake_id: Union[int, str]) -> int:
|
|
49
|
+
n = int(snowflake_id)
|
|
50
|
+
if _rift is not None:
|
|
51
|
+
return _rift.Snowflake.process_id(n)
|
|
52
|
+
return (n & 0x1F000) >> 12
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def increment(snowflake_id: Union[int, str]) -> int:
|
|
56
|
+
n = int(snowflake_id)
|
|
57
|
+
if _rift is not None:
|
|
58
|
+
return _rift.Snowflake.increment(n)
|
|
59
|
+
return n & 0xFFF
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def from_parts(timestamp_ms: int, worker_id: int, process_id: int, increment: int) -> int:
|
|
63
|
+
if _rift is not None:
|
|
64
|
+
return _rift.Snowflake.from_parts(timestamp_ms, worker_id, process_id, increment)
|
|
65
|
+
ts_offset = max(0, timestamp_ms - DISCORD_EPOCH_MS)
|
|
66
|
+
return (
|
|
67
|
+
(ts_offset << 22)
|
|
68
|
+
| ((worker_id & 0x1F) << 17)
|
|
69
|
+
| ((process_id & 0x1F) << 12)
|
|
70
|
+
| (increment & 0xFFF)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def deconstruct(snowflake_id: Union[int, str]) -> Tuple[int, int, int, int]:
|
|
75
|
+
n = int(snowflake_id)
|
|
76
|
+
if _rift is not None:
|
|
77
|
+
return _rift.Snowflake.deconstruct(n)
|
|
78
|
+
return (
|
|
79
|
+
Snowflake.timestamp_ms(n),
|
|
80
|
+
Snowflake.worker_id(n),
|
|
81
|
+
Snowflake.process_id(n),
|
|
82
|
+
Snowflake.increment(n),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# 2. Permissions
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
class Permissions:
|
|
90
|
+
"""Discord permission bitflags and channel overwrite mathematics."""
|
|
91
|
+
|
|
92
|
+
CREATE_INSTANT_INVITE = 1 << 0
|
|
93
|
+
KICK_MEMBERS = 1 << 1
|
|
94
|
+
BAN_MEMBERS = 1 << 2
|
|
95
|
+
ADMINISTRATOR = 1 << 3
|
|
96
|
+
MANAGE_CHANNELS = 1 << 4
|
|
97
|
+
MANAGE_GUILD = 1 << 5
|
|
98
|
+
ADD_REACTIONS = 1 << 6
|
|
99
|
+
VIEW_AUDIT_LOG = 1 << 7
|
|
100
|
+
PRIORITY_SPEAKER = 1 << 8
|
|
101
|
+
STREAM = 1 << 9
|
|
102
|
+
VIEW_CHANNEL = 1 << 10
|
|
103
|
+
SEND_MESSAGES = 1 << 11
|
|
104
|
+
SEND_TTS_MESSAGES = 1 << 12
|
|
105
|
+
MANAGE_MESSAGES = 1 << 13
|
|
106
|
+
EMBED_LINKS = 1 << 14
|
|
107
|
+
ATTACH_FILES = 1 << 15
|
|
108
|
+
READ_MESSAGE_HISTORY = 1 << 16
|
|
109
|
+
MENTION_EVERYONE = 1 << 17
|
|
110
|
+
USE_EXTERNAL_EMOJIS = 1 << 18
|
|
111
|
+
VIEW_GUILD_INSIGHTS = 1 << 19
|
|
112
|
+
CONNECT = 1 << 20
|
|
113
|
+
SPEAK = 1 << 21
|
|
114
|
+
MUTE_MEMBERS = 1 << 22
|
|
115
|
+
DEAFEN_MEMBERS = 1 << 23
|
|
116
|
+
MOVE_MEMBERS = 1 << 24
|
|
117
|
+
USE_VAD = 1 << 25
|
|
118
|
+
CHANGE_NICKNAME = 1 << 26
|
|
119
|
+
MANAGE_NICKNAMES = 1 << 27
|
|
120
|
+
MANAGE_ROLES = 1 << 28
|
|
121
|
+
MANAGE_WEBHOOKS = 1 << 29
|
|
122
|
+
MANAGE_GUILD_EXPRESSIONS = 1 << 30
|
|
123
|
+
USE_APPLICATION_COMMANDS = 1 << 31
|
|
124
|
+
REQUEST_TO_SPEAK = 1 << 32
|
|
125
|
+
MANAGE_EVENTS = 1 << 33
|
|
126
|
+
MANAGE_THREADS = 1 << 34
|
|
127
|
+
CREATE_PUBLIC_THREADS = 1 << 35
|
|
128
|
+
CREATE_PRIVATE_THREADS = 1 << 36
|
|
129
|
+
USE_EXTERNAL_STICKERS = 1 << 37
|
|
130
|
+
SEND_MESSAGES_IN_THREADS = 1 << 38
|
|
131
|
+
USE_EMBEDDED_ACTIVITIES = 1 << 39
|
|
132
|
+
MODERATE_MEMBERS = 1 << 40
|
|
133
|
+
VIEW_CREATOR_MONETIZATION_ANALYTICS = 1 << 41
|
|
134
|
+
USE_SOUNDBOARD = 1 << 42
|
|
135
|
+
CREATE_GUILD_EXPRESSIONS = 1 << 43
|
|
136
|
+
CREATE_EVENTS = 1 << 44
|
|
137
|
+
USE_EXTERNAL_SOUNDS = 1 << 45
|
|
138
|
+
SEND_VOICE_MESSAGES = 1 << 46
|
|
139
|
+
SET_VOICE_CHANNEL_STATUS = 1 << 48
|
|
140
|
+
SEND_POLLS = 1 << 49
|
|
141
|
+
USE_EXTERNAL_APPS = 1 << 50
|
|
142
|
+
PIN_MESSAGES = 1 << 51
|
|
143
|
+
BYPASS_SLOWMODE = 1 << 52
|
|
144
|
+
ALL = (1 << 53) - 1 - (1 << 47)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def has_permission(permissions: Union[int, str], required: Union[int, str]) -> bool:
|
|
148
|
+
p = int(permissions)
|
|
149
|
+
r = int(required)
|
|
150
|
+
if _rift is not None:
|
|
151
|
+
return _rift.Permissions.has_permission(p, r)
|
|
152
|
+
if (p & Permissions.ADMINISTRATOR) == Permissions.ADMINISTRATOR:
|
|
153
|
+
return True
|
|
154
|
+
return (p & r) == r
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def compute_channel_permissions(
|
|
158
|
+
base_permissions: Union[int, str],
|
|
159
|
+
overwrites: List[Tuple[int, int, int, int]], # (id, kind: 0=role 1=member, allow, deny)
|
|
160
|
+
guild_id: Union[int, str],
|
|
161
|
+
member_id: Union[int, str],
|
|
162
|
+
member_roles: List[Union[int, str]],
|
|
163
|
+
) -> int:
|
|
164
|
+
p = int(base_permissions)
|
|
165
|
+
g_id = int(guild_id)
|
|
166
|
+
m_id = int(member_id)
|
|
167
|
+
roles = [int(r) for r in member_roles]
|
|
168
|
+
|
|
169
|
+
if _rift is not None:
|
|
170
|
+
return _rift.Permissions.compute_channel_permissions(p, overwrites, g_id, m_id, roles)
|
|
171
|
+
|
|
172
|
+
if (p & Permissions.ADMINISTRATOR) == Permissions.ADMINISTRATOR:
|
|
173
|
+
return Permissions.ALL
|
|
174
|
+
|
|
175
|
+
# 1. @everyone overwrite
|
|
176
|
+
for o_id, kind, allow, deny in overwrites:
|
|
177
|
+
if kind == 0 and o_id == g_id:
|
|
178
|
+
p = (p & ~deny) | allow
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
# 2. Role overwrites (allow beats deny across roles)
|
|
182
|
+
role_allow = 0
|
|
183
|
+
role_deny = 0
|
|
184
|
+
roles_set = set(roles)
|
|
185
|
+
for o_id, kind, allow, deny in overwrites:
|
|
186
|
+
if kind == 0 and o_id in roles_set:
|
|
187
|
+
role_allow |= allow
|
|
188
|
+
role_deny |= deny
|
|
189
|
+
p = (p & ~role_deny) | role_allow
|
|
190
|
+
|
|
191
|
+
# 3. Member overwrite
|
|
192
|
+
for o_id, kind, allow, deny in overwrites:
|
|
193
|
+
if kind == 1 and o_id == m_id:
|
|
194
|
+
p = (p & ~deny) | allow
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
# 4. Implicit rules
|
|
198
|
+
if (p & Permissions.VIEW_CHANNEL) == 0:
|
|
199
|
+
return 0
|
|
200
|
+
if (p & Permissions.SEND_MESSAGES) == 0:
|
|
201
|
+
dependent = (
|
|
202
|
+
Permissions.MENTION_EVERYONE
|
|
203
|
+
| Permissions.SEND_TTS_MESSAGES
|
|
204
|
+
| Permissions.ATTACH_FILES
|
|
205
|
+
| Permissions.EMBED_LINKS
|
|
206
|
+
)
|
|
207
|
+
p &= ~dependent
|
|
208
|
+
|
|
209
|
+
return p
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# 3. Intents & Capabilities
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
class Intents:
|
|
216
|
+
GUILDS = 1 << 0
|
|
217
|
+
GUILD_MEMBERS = 1 << 1
|
|
218
|
+
GUILD_MODERATION = 1 << 2
|
|
219
|
+
GUILD_EXPRESSIONS = 1 << 3
|
|
220
|
+
GUILD_INTEGRATIONS = 1 << 4
|
|
221
|
+
GUILD_WEBHOOKS = 1 << 5
|
|
222
|
+
GUILD_INVITES = 1 << 6
|
|
223
|
+
GUILD_VOICE_STATES = 1 << 7
|
|
224
|
+
GUILD_PRESENCES = 1 << 8
|
|
225
|
+
GUILD_MESSAGES = 1 << 9
|
|
226
|
+
GUILD_MESSAGE_REACTIONS = 1 << 10
|
|
227
|
+
GUILD_MESSAGE_TYPING = 1 << 11
|
|
228
|
+
DIRECT_MESSAGES = 1 << 12
|
|
229
|
+
DIRECT_MESSAGE_REACTIONS = 1 << 13
|
|
230
|
+
DIRECT_MESSAGE_TYPING = 1 << 14
|
|
231
|
+
MESSAGE_CONTENT = 1 << 15
|
|
232
|
+
GUILD_SCHEDULED_EVENTS = 1 << 16
|
|
233
|
+
AUTO_MODERATION_CONFIGURATION = 1 << 20
|
|
234
|
+
AUTO_MODERATION_EXECUTION = 1 << 21
|
|
235
|
+
GUILD_MESSAGE_POLLS = 1 << 24
|
|
236
|
+
DIRECT_MESSAGE_POLLS = 1 << 25
|
|
237
|
+
PRIVILEGED = (1 << 1) | (1 << 8) | (1 << 15)
|
|
238
|
+
ALL_KNOWN = (
|
|
239
|
+
(1 << 0)
|
|
240
|
+
| (1 << 1)
|
|
241
|
+
| (1 << 2)
|
|
242
|
+
| (1 << 3)
|
|
243
|
+
| (1 << 4)
|
|
244
|
+
| (1 << 5)
|
|
245
|
+
| (1 << 6)
|
|
246
|
+
| (1 << 7)
|
|
247
|
+
| (1 << 8)
|
|
248
|
+
| (1 << 9)
|
|
249
|
+
| (1 << 10)
|
|
250
|
+
| (1 << 11)
|
|
251
|
+
| (1 << 12)
|
|
252
|
+
| (1 << 13)
|
|
253
|
+
| (1 << 14)
|
|
254
|
+
| (1 << 15)
|
|
255
|
+
| (1 << 16)
|
|
256
|
+
| (1 << 20)
|
|
257
|
+
| (1 << 21)
|
|
258
|
+
| (1 << 24)
|
|
259
|
+
| (1 << 25)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class Capabilities:
|
|
264
|
+
NONE = 0
|
|
265
|
+
CHANNEL_OBFUSCATION = 1 << 15
|
|
266
|
+
IS_COMPONENTS_V2 = 1 << 16
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# 4. verify_interaction
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
def verify_interaction(
|
|
273
|
+
public_key_hex: str,
|
|
274
|
+
signature_hex: str,
|
|
275
|
+
timestamp: str,
|
|
276
|
+
body: Union[bytes, str],
|
|
277
|
+
) -> bool:
|
|
278
|
+
"""Verifies Discord interaction webhook Ed25519 signature."""
|
|
279
|
+
b = body.encode("utf-8") if isinstance(body, str) else body
|
|
280
|
+
if _rift is not None:
|
|
281
|
+
return _rift.verify_interaction(public_key_hex, signature_hex, timestamp, b)
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
285
|
+
|
|
286
|
+
pub_bytes = bytes.fromhex(public_key_hex)
|
|
287
|
+
sig_bytes = bytes.fromhex(signature_hex)
|
|
288
|
+
if len(pub_bytes) != 32 or len(sig_bytes) != 64:
|
|
289
|
+
return False
|
|
290
|
+
key = Ed25519PublicKey.from_public_bytes(pub_bytes)
|
|
291
|
+
key.verify(sig_bytes, timestamp.encode("utf-8") + b)
|
|
292
|
+
return True
|
|
293
|
+
except Exception:
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
# 5. RateLimiter & normalize_route
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
@dataclass
|
|
301
|
+
class NormalizedRoute:
|
|
302
|
+
id: str
|
|
303
|
+
template: str
|
|
304
|
+
major: str
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def normalize_route(method: str, path: str) -> NormalizedRoute:
|
|
308
|
+
"""Normalizes an HTTP method and path into Discord rate limit bucket keys."""
|
|
309
|
+
if _rift is not None:
|
|
310
|
+
r = _rift.normalize_route(method, path)
|
|
311
|
+
return NormalizedRoute(id=r.id, template=r.template, major=r.major)
|
|
312
|
+
|
|
313
|
+
p = path.split("?")[0]
|
|
314
|
+
segs = [s for s in p.split("/") if s]
|
|
315
|
+
|
|
316
|
+
if segs and segs[0] == "interactions":
|
|
317
|
+
rest = "".join(f"/{s}" for s in segs[3:])
|
|
318
|
+
template = f"/interactions/:id/:token{rest}"
|
|
319
|
+
return NormalizedRoute(
|
|
320
|
+
id=f"{method.upper()} {template}",
|
|
321
|
+
template=template,
|
|
322
|
+
major="interactions",
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
out = []
|
|
326
|
+
major = ""
|
|
327
|
+
i = 0
|
|
328
|
+
while i < len(segs):
|
|
329
|
+
s = segs[i]
|
|
330
|
+
prev = segs[i - 1] if i > 0 else ""
|
|
331
|
+
is_snow = s.isdigit()
|
|
332
|
+
if is_snow and prev in ("channels", "guilds"):
|
|
333
|
+
if not major:
|
|
334
|
+
major = f"{prev}/{s}"
|
|
335
|
+
out.append(":major")
|
|
336
|
+
elif is_snow and prev == "webhooks":
|
|
337
|
+
m = f"webhooks/{s}"
|
|
338
|
+
if i + 1 < len(segs):
|
|
339
|
+
m += f"/{segs[i + 1]}"
|
|
340
|
+
i += 1
|
|
341
|
+
if not major:
|
|
342
|
+
major = m
|
|
343
|
+
out.append(":major")
|
|
344
|
+
elif is_snow:
|
|
345
|
+
out.append(":id")
|
|
346
|
+
elif prev == "reactions":
|
|
347
|
+
out.append(":emoji")
|
|
348
|
+
elif prev == "invites":
|
|
349
|
+
out.append(":code")
|
|
350
|
+
else:
|
|
351
|
+
out.append(s)
|
|
352
|
+
i += 1
|
|
353
|
+
|
|
354
|
+
template = "/" + "/".join(out)
|
|
355
|
+
return NormalizedRoute(
|
|
356
|
+
id=f"{method.upper()} {template}",
|
|
357
|
+
template=template,
|
|
358
|
+
major=major,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class RateLimiter:
|
|
363
|
+
"""Discord REST bucket rate limiter."""
|
|
364
|
+
|
|
365
|
+
def __init__(
|
|
366
|
+
self,
|
|
367
|
+
global_per_second: Optional[int] = None,
|
|
368
|
+
invalid_limit: Optional[int] = None,
|
|
369
|
+
invalid_trip_at: Optional[int] = None,
|
|
370
|
+
):
|
|
371
|
+
if _rift is not None:
|
|
372
|
+
self._inner = _rift.RateLimiter(global_per_second, invalid_limit, invalid_trip_at)
|
|
373
|
+
else:
|
|
374
|
+
self._inner = None
|
|
375
|
+
self._buckets: Dict[str, Dict[str, Any]] = {}
|
|
376
|
+
self._invalid_log: List[int] = []
|
|
377
|
+
|
|
378
|
+
def acquire(
|
|
379
|
+
self, method: str, path: str, now_ms: Optional[int] = None
|
|
380
|
+
) -> Tuple[str, Optional[int], Optional[str]]:
|
|
381
|
+
now = now_ms if now_ms is not None else int(time.time() * 1000)
|
|
382
|
+
if self._inner is not None:
|
|
383
|
+
return self._inner.acquire(method, path, now)
|
|
384
|
+
|
|
385
|
+
norm = normalize_route(method, path)
|
|
386
|
+
key = f"{norm.template}|{norm.major}"
|
|
387
|
+
b = self._buckets.get(key)
|
|
388
|
+
if not b:
|
|
389
|
+
self._buckets[key] = {"limit": 1, "remaining": 0, "reset_at": now + 30000, "probing": True}
|
|
390
|
+
return ("go", None, None)
|
|
391
|
+
|
|
392
|
+
if b.get("probing"):
|
|
393
|
+
return ("wait", 50, None)
|
|
394
|
+
|
|
395
|
+
if now >= b["reset_at"]:
|
|
396
|
+
b["remaining"] = b["limit"]
|
|
397
|
+
|
|
398
|
+
if b["remaining"] > 0:
|
|
399
|
+
b["remaining"] -= 1
|
|
400
|
+
return ("go", None, None)
|
|
401
|
+
|
|
402
|
+
wait = max(0, b["reset_at"] - now)
|
|
403
|
+
return ("wait", wait, None)
|
|
404
|
+
|
|
405
|
+
def on_response(
|
|
406
|
+
self,
|
|
407
|
+
method: str,
|
|
408
|
+
path: str,
|
|
409
|
+
status: int,
|
|
410
|
+
limit: Optional[int] = None,
|
|
411
|
+
remaining: Optional[int] = None,
|
|
412
|
+
reset_after_ms: Optional[int] = None,
|
|
413
|
+
bucket: Optional[str] = None,
|
|
414
|
+
scope: Optional[str] = None,
|
|
415
|
+
now_ms: Optional[int] = None,
|
|
416
|
+
) -> None:
|
|
417
|
+
now = now_ms if now_ms is not None else int(time.time() * 1000)
|
|
418
|
+
if self._inner is not None:
|
|
419
|
+
self._inner.on_response(
|
|
420
|
+
method, path, status, limit, remaining, reset_after_ms, bucket, scope, now
|
|
421
|
+
)
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
norm = normalize_route(method, path)
|
|
425
|
+
key = f"{norm.template}|{norm.major}"
|
|
426
|
+
self._buckets[key] = {
|
|
427
|
+
"limit": limit or 1,
|
|
428
|
+
"remaining": remaining or 1,
|
|
429
|
+
"reset_at": now + (reset_after_ms or 1000),
|
|
430
|
+
"probing": False,
|
|
431
|
+
}
|
|
432
|
+
if status in (401, 403, 429) and scope != "shared":
|
|
433
|
+
self._invalid_log.append(now)
|
|
434
|
+
|
|
435
|
+
def invalid_requests(self, now_ms: Optional[int] = None) -> int:
|
|
436
|
+
now = now_ms if now_ms is not None else int(time.time() * 1000)
|
|
437
|
+
if self._inner is not None:
|
|
438
|
+
return self._inner.invalid_requests(now)
|
|
439
|
+
cutoff = now - 600000
|
|
440
|
+
self._invalid_log = [t for t in self._invalid_log if t >= cutoff]
|
|
441
|
+
return len(self._invalid_log)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
# 6. Cache
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
@dataclass
|
|
448
|
+
class CacheStats:
|
|
449
|
+
guilds: int
|
|
450
|
+
visible_channels: int
|
|
451
|
+
obfuscated_channels: int
|
|
452
|
+
roles: int
|
|
453
|
+
members: int
|
|
454
|
+
users: int
|
|
455
|
+
messages: int
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class Cache:
|
|
459
|
+
"""In-memory state cache with Nov 16 channel obfuscation safety."""
|
|
460
|
+
|
|
461
|
+
def __init__(
|
|
462
|
+
self,
|
|
463
|
+
max_messages_per_channel: Optional[int] = None,
|
|
464
|
+
max_members_per_guild: Optional[int] = None,
|
|
465
|
+
max_users: Optional[int] = None,
|
|
466
|
+
):
|
|
467
|
+
if _rift is not None:
|
|
468
|
+
self._inner = _rift.Cache(
|
|
469
|
+
max_messages_per_channel, max_members_per_guild, max_users
|
|
470
|
+
)
|
|
471
|
+
else:
|
|
472
|
+
self._inner = None
|
|
473
|
+
self._guilds: Dict[str, Any] = {}
|
|
474
|
+
self._channels: Dict[str, Any] = {}
|
|
475
|
+
self._obfuscated: set[str] = set()
|
|
476
|
+
|
|
477
|
+
def update(self, event_json_or_dict: Union[str, Dict[str, Any]]) -> None:
|
|
478
|
+
payload = (
|
|
479
|
+
json.dumps(event_json_or_dict)
|
|
480
|
+
if isinstance(event_json_or_dict, dict)
|
|
481
|
+
else event_json_or_dict
|
|
482
|
+
)
|
|
483
|
+
if self._inner is not None:
|
|
484
|
+
self._inner.update(payload)
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
d = json.loads(payload)
|
|
488
|
+
t = d.get("t") or d.get("name")
|
|
489
|
+
body = d.get("d") or d.get("data") or d
|
|
490
|
+
|
|
491
|
+
if t == "GUILD_CREATE":
|
|
492
|
+
self._guilds[str(body["id"])] = body
|
|
493
|
+
for ch in body.get("channels", []):
|
|
494
|
+
self._insert_channel(ch)
|
|
495
|
+
elif t in ("CHANNEL_CREATE", "CHANNEL_UPDATE"):
|
|
496
|
+
self._insert_channel(body)
|
|
497
|
+
elif t == "CHANNEL_DELETE":
|
|
498
|
+
cid = str(body["id"])
|
|
499
|
+
self._channels.pop(cid, None)
|
|
500
|
+
self._obfuscated.discard(cid)
|
|
501
|
+
|
|
502
|
+
def _insert_channel(self, ch: Dict[str, Any]) -> None:
|
|
503
|
+
cid = str(ch["id"])
|
|
504
|
+
if (ch.get("flags", 0) & (1 << 17)) != 0:
|
|
505
|
+
self._obfuscated.add(cid)
|
|
506
|
+
sanitized = dict(ch)
|
|
507
|
+
sanitized["name"] = None
|
|
508
|
+
sanitized["topic"] = None
|
|
509
|
+
sanitized["permission_overwrites"] = []
|
|
510
|
+
self._channels[cid] = sanitized
|
|
511
|
+
else:
|
|
512
|
+
self._obfuscated.discard(cid)
|
|
513
|
+
self._channels[cid] = ch
|
|
514
|
+
|
|
515
|
+
def is_channel_obfuscated(self, channel_id: Union[int, str]) -> bool:
|
|
516
|
+
cid = str(channel_id)
|
|
517
|
+
if self._inner is not None:
|
|
518
|
+
return self._inner.is_channel_obfuscated(int(cid))
|
|
519
|
+
return cid in self._obfuscated
|
|
520
|
+
|
|
521
|
+
def get_guild(self, guild_id: Union[int, str]) -> Optional[Dict[str, Any]]:
|
|
522
|
+
gid = str(guild_id)
|
|
523
|
+
if self._inner is not None:
|
|
524
|
+
res = self._inner.get_guild(int(gid))
|
|
525
|
+
return json.loads(res) if res else None
|
|
526
|
+
return self._guilds.get(gid)
|
|
527
|
+
|
|
528
|
+
def get_channel(self, channel_id: Union[int, str]) -> Optional[Dict[str, Any]]:
|
|
529
|
+
cid = str(channel_id)
|
|
530
|
+
if self.is_channel_obfuscated(cid):
|
|
531
|
+
return None
|
|
532
|
+
if self._inner is not None:
|
|
533
|
+
res = self._inner.get_channel(int(cid))
|
|
534
|
+
return json.loads(res) if res else None
|
|
535
|
+
return self._channels.get(cid)
|
|
536
|
+
|
|
537
|
+
def compute_member_permissions(
|
|
538
|
+
self,
|
|
539
|
+
guild_id: Union[int, str],
|
|
540
|
+
channel_id: Union[int, str],
|
|
541
|
+
user_id: Union[int, str],
|
|
542
|
+
) -> int:
|
|
543
|
+
if self.is_channel_obfuscated(channel_id):
|
|
544
|
+
return 0
|
|
545
|
+
if self._inner is not None:
|
|
546
|
+
return self._inner.compute_member_permissions(int(guild_id), int(channel_id), int(user_id))
|
|
547
|
+
ch = self._channels.get(str(channel_id))
|
|
548
|
+
if not ch:
|
|
549
|
+
return 0
|
|
550
|
+
return Permissions.compute_channel_permissions(
|
|
551
|
+
Permissions.ALL,
|
|
552
|
+
[
|
|
553
|
+
(int(o["id"]), o.get("type", 0), int(o.get("allow", 0)), int(o.get("deny", 0)))
|
|
554
|
+
for o in ch.get("permission_overwrites", [])
|
|
555
|
+
],
|
|
556
|
+
int(guild_id),
|
|
557
|
+
int(user_id),
|
|
558
|
+
[],
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
def stats(self) -> CacheStats:
|
|
562
|
+
if self._inner is not None:
|
|
563
|
+
g, vc, oc, r, m, u, msg = self._inner.stats()
|
|
564
|
+
return CacheStats(g, vc, oc, r, m, u, msg)
|
|
565
|
+
return CacheStats(
|
|
566
|
+
guilds=len(self._guilds),
|
|
567
|
+
visible_channels=max(0, len(self._channels) - len(self._obfuscated)),
|
|
568
|
+
obfuscated_channels=len(self._obfuscated),
|
|
569
|
+
roles=0,
|
|
570
|
+
members=0,
|
|
571
|
+
users=0,
|
|
572
|
+
messages=0,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
def clear(self) -> None:
|
|
576
|
+
if self._inner is not None:
|
|
577
|
+
self._inner.clear()
|
|
578
|
+
self._guilds.clear()
|
|
579
|
+
self._channels.clear()
|
|
580
|
+
self._obfuscated.clear()
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# ---------------------------------------------------------------------------
|
|
584
|
+
# 7. GatewaySession
|
|
585
|
+
# ---------------------------------------------------------------------------
|
|
586
|
+
class GatewaySession:
|
|
587
|
+
"""Sans-I/O Gateway v10 state machine."""
|
|
588
|
+
|
|
589
|
+
def __init__(self):
|
|
590
|
+
if _rift is not None:
|
|
591
|
+
self._inner = _rift.GatewaySession()
|
|
592
|
+
else:
|
|
593
|
+
self._inner = None
|
|
594
|
+
self.reset()
|
|
595
|
+
|
|
596
|
+
def reset(self) -> None:
|
|
597
|
+
if self._inner is not None:
|
|
598
|
+
self._inner.reset()
|
|
599
|
+
self.interval_ms = 0
|
|
600
|
+
self.next_due_ms = 0
|
|
601
|
+
self.awaiting_ack = False
|
|
602
|
+
self.started = False
|
|
603
|
+
self.sequence: Optional[int] = None
|
|
604
|
+
self.session_id: Optional[str] = None
|
|
605
|
+
self.resume_url: Optional[str] = None
|
|
606
|
+
|
|
607
|
+
def on_hello(self, now_ms: int, interval_ms: int, jitter: float = 0.5) -> None:
|
|
608
|
+
if self._inner is not None:
|
|
609
|
+
self._inner.on_hello(now_ms, interval_ms, jitter)
|
|
610
|
+
return
|
|
611
|
+
j = min(1.0, max(0.0, jitter))
|
|
612
|
+
self.interval_ms = interval_ms
|
|
613
|
+
self.next_due_ms = now_ms + int(interval_ms * j)
|
|
614
|
+
self.awaiting_ack = False
|
|
615
|
+
self.started = True
|
|
616
|
+
|
|
617
|
+
def on_ready(self, session_id: str, resume_gateway_url: str) -> None:
|
|
618
|
+
if self._inner is not None:
|
|
619
|
+
self._inner.on_ready(session_id, resume_gateway_url)
|
|
620
|
+
return
|
|
621
|
+
self.session_id = session_id
|
|
622
|
+
self.resume_url = resume_gateway_url
|
|
623
|
+
|
|
624
|
+
def record_seq(self, seq: int) -> None:
|
|
625
|
+
if self._inner is not None:
|
|
626
|
+
self._inner.record_seq(seq)
|
|
627
|
+
return
|
|
628
|
+
self.sequence = seq
|
|
629
|
+
|
|
630
|
+
def seq(self) -> Optional[int]:
|
|
631
|
+
if self._inner is not None:
|
|
632
|
+
return self._inner.seq()
|
|
633
|
+
return self.sequence
|
|
634
|
+
|
|
635
|
+
def is_established(self) -> bool:
|
|
636
|
+
if self._inner is not None:
|
|
637
|
+
return self._inner.is_established()
|
|
638
|
+
return self.session_id is not None
|
|
639
|
+
|
|
640
|
+
def poll_heartbeat(self, now_ms: int) -> Tuple[str, Optional[int]]:
|
|
641
|
+
"""Returns (action: "wait"|"send"|"zombie", seq: Optional[int])."""
|
|
642
|
+
if self._inner is not None:
|
|
643
|
+
return self._inner.poll_heartbeat(now_ms)
|
|
644
|
+
if not self.started or now_ms < self.next_due_ms:
|
|
645
|
+
return ("wait", None)
|
|
646
|
+
if self.awaiting_ack:
|
|
647
|
+
return ("zombie", None)
|
|
648
|
+
self.awaiting_ack = True
|
|
649
|
+
self.next_due_ms = now_ms + self.interval_ms
|
|
650
|
+
return ("send", self.sequence)
|
|
651
|
+
|
|
652
|
+
def plan_reconnect(
|
|
653
|
+
self, why: str, code: Optional[int] = None, resumable: Optional[bool] = None
|
|
654
|
+
) -> Tuple[str, Optional[str], Optional[str], Optional[int], Optional[int]]:
|
|
655
|
+
"""Returns (plan: "resume"|"identify"|"fatal", url, session_id, seq, close_code)."""
|
|
656
|
+
if self._inner is not None:
|
|
657
|
+
return self._inner.plan_reconnect(why, code, resumable)
|
|
658
|
+
if code in (4004, 4010, 4011, 4012, 4013, 4014):
|
|
659
|
+
return ("fatal", None, None, None, code)
|
|
660
|
+
if why == "invalid_session" and not resumable:
|
|
661
|
+
self.reset()
|
|
662
|
+
return ("identify", None, None, None, None)
|
|
663
|
+
if self.session_id and self.resume_url and self.sequence is not None:
|
|
664
|
+
return ("resume", self.resume_url, self.session_id, self.sequence, None)
|
|
665
|
+
self.reset()
|
|
666
|
+
return ("identify", None, None, None, None)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# ---------------------------------------------------------------------------
|
|
670
|
+
# 8. RiftClient
|
|
671
|
+
# ---------------------------------------------------------------------------
|
|
672
|
+
@dataclass
|
|
673
|
+
class ClientEvent:
|
|
674
|
+
shard_id: int
|
|
675
|
+
name: str
|
|
676
|
+
data: Any
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
class RiftClient:
|
|
680
|
+
"""High-level asynchronous Rift Client."""
|
|
681
|
+
|
|
682
|
+
def __init__(
|
|
683
|
+
self,
|
|
684
|
+
token: str,
|
|
685
|
+
intents: Optional[int] = None,
|
|
686
|
+
capabilities: Optional[int] = None,
|
|
687
|
+
):
|
|
688
|
+
self.token = token
|
|
689
|
+
self.intents = intents or Intents.ALL_KNOWN
|
|
690
|
+
self.capabilities = capabilities or Capabilities.NONE
|
|
691
|
+
self._queue: asyncio.Queue[Optional[ClientEvent]] = asyncio.Queue()
|
|
692
|
+
self._stopped = False
|
|
693
|
+
|
|
694
|
+
def inject_mock_event(self, shard_id: int, name: str, payload: Union[str, Dict[str, Any]]) -> None:
|
|
695
|
+
data = json.loads(payload) if isinstance(payload, str) else payload
|
|
696
|
+
self._queue.put_nowait(ClientEvent(shard_id, name, data))
|
|
697
|
+
|
|
698
|
+
async def next_event(self) -> Optional[ClientEvent]:
|
|
699
|
+
if self._stopped and self._queue.empty():
|
|
700
|
+
return None
|
|
701
|
+
return await self._queue.get()
|
|
702
|
+
|
|
703
|
+
def send_command(self, shard_id: int, opcode: int, payload: Any) -> None:
|
|
704
|
+
pass
|
|
705
|
+
|
|
706
|
+
def update_presence(
|
|
707
|
+
self,
|
|
708
|
+
shard_id: int,
|
|
709
|
+
status: str,
|
|
710
|
+
activity_name: Optional[str] = None,
|
|
711
|
+
activity_type: int = 0,
|
|
712
|
+
) -> None:
|
|
713
|
+
payload = {
|
|
714
|
+
"status": status,
|
|
715
|
+
"since": None,
|
|
716
|
+
"afk": False,
|
|
717
|
+
"activities": (
|
|
718
|
+
[{"name": activity_name, "type": activity_type}] if activity_name else []
|
|
719
|
+
),
|
|
720
|
+
}
|
|
721
|
+
self.send_command(shard_id, 3, payload)
|
|
722
|
+
|
|
723
|
+
async def stop(self) -> None:
|
|
724
|
+
self._stopped = True
|
|
725
|
+
await self._queue.put(None)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
__all__ = [
|
|
729
|
+
"Snowflake",
|
|
730
|
+
"Permissions",
|
|
731
|
+
"Intents",
|
|
732
|
+
"Capabilities",
|
|
733
|
+
"verify_interaction",
|
|
734
|
+
"normalize_route",
|
|
735
|
+
"NormalizedRoute",
|
|
736
|
+
"RateLimiter",
|
|
737
|
+
"Cache",
|
|
738
|
+
"CacheStats",
|
|
739
|
+
"GatewaySession",
|
|
740
|
+
"RiftClient",
|
|
741
|
+
"ClientEvent",
|
|
742
|
+
]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
DISCORD_EPOCH_MS: int
|
|
5
|
+
|
|
6
|
+
class Snowflake:
|
|
7
|
+
@staticmethod
|
|
8
|
+
def timestamp_ms(snowflake_id: Union[int, str]) -> int: ...
|
|
9
|
+
@staticmethod
|
|
10
|
+
def worker_id(snowflake_id: Union[int, str]) -> int: ...
|
|
11
|
+
@staticmethod
|
|
12
|
+
def process_id(snowflake_id: Union[int, str]) -> int: ...
|
|
13
|
+
@staticmethod
|
|
14
|
+
def increment(snowflake_id: Union[int, str]) -> int: ...
|
|
15
|
+
@staticmethod
|
|
16
|
+
def from_parts(timestamp_ms: int, worker_id: int, process_id: int, increment: int) -> int: ...
|
|
17
|
+
@staticmethod
|
|
18
|
+
def deconstruct(snowflake_id: Union[int, str]) -> Tuple[int, int, int, int]: ...
|
|
19
|
+
|
|
20
|
+
class Permissions:
|
|
21
|
+
CREATE_INSTANT_INVITE: int
|
|
22
|
+
KICK_MEMBERS: int
|
|
23
|
+
BAN_MEMBERS: int
|
|
24
|
+
ADMINISTRATOR: int
|
|
25
|
+
MANAGE_CHANNELS: int
|
|
26
|
+
MANAGE_GUILD: int
|
|
27
|
+
ADD_REACTIONS: int
|
|
28
|
+
VIEW_AUDIT_LOG: int
|
|
29
|
+
PRIORITY_SPEAKER: int
|
|
30
|
+
STREAM: int
|
|
31
|
+
VIEW_CHANNEL: int
|
|
32
|
+
SEND_MESSAGES: int
|
|
33
|
+
SEND_TTS_MESSAGES: int
|
|
34
|
+
MANAGE_MESSAGES: int
|
|
35
|
+
EMBED_LINKS: int
|
|
36
|
+
ATTACH_FILES: int
|
|
37
|
+
READ_MESSAGE_HISTORY: int
|
|
38
|
+
MENTION_EVERYONE: int
|
|
39
|
+
USE_EXTERNAL_EMOJIS: int
|
|
40
|
+
VIEW_GUILD_INSIGHTS: int
|
|
41
|
+
CONNECT: int
|
|
42
|
+
SPEAK: int
|
|
43
|
+
MUTE_MEMBERS: int
|
|
44
|
+
DEAFEN_MEMBERS: int
|
|
45
|
+
MOVE_MEMBERS: int
|
|
46
|
+
USE_VAD: int
|
|
47
|
+
CHANGE_NICKNAME: int
|
|
48
|
+
MANAGE_NICKNAMES: int
|
|
49
|
+
MANAGE_ROLES: int
|
|
50
|
+
MANAGE_WEBHOOKS: int
|
|
51
|
+
MANAGE_GUILD_EXPRESSIONS: int
|
|
52
|
+
USE_APPLICATION_COMMANDS: int
|
|
53
|
+
REQUEST_TO_SPEAK: int
|
|
54
|
+
MANAGE_EVENTS: int
|
|
55
|
+
MANAGE_THREADS: int
|
|
56
|
+
CREATE_PUBLIC_THREADS: int
|
|
57
|
+
CREATE_PRIVATE_THREADS: int
|
|
58
|
+
USE_EXTERNAL_STICKERS: int
|
|
59
|
+
SEND_MESSAGES_IN_THREADS: int
|
|
60
|
+
USE_EMBEDDED_ACTIVITIES: int
|
|
61
|
+
MODERATE_MEMBERS: int
|
|
62
|
+
VIEW_CREATOR_MONETIZATION_ANALYTICS: int
|
|
63
|
+
USE_SOUNDBOARD: int
|
|
64
|
+
CREATE_GUILD_EXPRESSIONS: int
|
|
65
|
+
CREATE_EVENTS: int
|
|
66
|
+
USE_EXTERNAL_SOUNDS: int
|
|
67
|
+
SEND_VOICE_MESSAGES: int
|
|
68
|
+
SET_VOICE_CHANNEL_STATUS: int
|
|
69
|
+
SEND_POLLS: int
|
|
70
|
+
USE_EXTERNAL_APPS: int
|
|
71
|
+
PIN_MESSAGES: int
|
|
72
|
+
BYPASS_SLOWMODE: int
|
|
73
|
+
ALL: int
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def has_permission(permissions: Union[int, str], required: Union[int, str]) -> bool: ...
|
|
77
|
+
@staticmethod
|
|
78
|
+
def compute_channel_permissions(
|
|
79
|
+
base_permissions: Union[int, str],
|
|
80
|
+
overwrites: List[Tuple[int, int, int, int]],
|
|
81
|
+
guild_id: Union[int, str],
|
|
82
|
+
member_id: Union[int, str],
|
|
83
|
+
member_roles: List[Union[int, str]],
|
|
84
|
+
) -> int: ...
|
|
85
|
+
|
|
86
|
+
class Intents:
|
|
87
|
+
GUILDS: int
|
|
88
|
+
GUILD_MEMBERS: int
|
|
89
|
+
GUILD_MODERATION: int
|
|
90
|
+
GUILD_EXPRESSIONS: int
|
|
91
|
+
GUILD_INTEGRATIONS: int
|
|
92
|
+
GUILD_WEBHOOKS: int
|
|
93
|
+
GUILD_INVITES: int
|
|
94
|
+
GUILD_VOICE_STATES: int
|
|
95
|
+
GUILD_PRESENCES: int
|
|
96
|
+
GUILD_MESSAGES: int
|
|
97
|
+
GUILD_MESSAGE_REACTIONS: int
|
|
98
|
+
GUILD_MESSAGE_TYPING: int
|
|
99
|
+
DIRECT_MESSAGES: int
|
|
100
|
+
DIRECT_MESSAGE_REACTIONS: int
|
|
101
|
+
DIRECT_MESSAGE_TYPING: int
|
|
102
|
+
MESSAGE_CONTENT: int
|
|
103
|
+
GUILD_SCHEDULED_EVENTS: int
|
|
104
|
+
AUTO_MODERATION_CONFIGURATION: int
|
|
105
|
+
AUTO_MODERATION_EXECUTION: int
|
|
106
|
+
GUILD_MESSAGE_POLLS: int
|
|
107
|
+
DIRECT_MESSAGE_POLLS: int
|
|
108
|
+
PRIVILEGED: int
|
|
109
|
+
ALL_KNOWN: int
|
|
110
|
+
|
|
111
|
+
class Capabilities:
|
|
112
|
+
NONE: int
|
|
113
|
+
CHANNEL_OBFUSCATION: int
|
|
114
|
+
IS_COMPONENTS_V2: int
|
|
115
|
+
|
|
116
|
+
def verify_interaction(
|
|
117
|
+
public_key_hex: str,
|
|
118
|
+
signature_hex: str,
|
|
119
|
+
timestamp: str,
|
|
120
|
+
body: Union[bytes, str],
|
|
121
|
+
) -> bool: ...
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class NormalizedRoute:
|
|
125
|
+
id: str
|
|
126
|
+
template: str
|
|
127
|
+
major: str
|
|
128
|
+
|
|
129
|
+
def normalize_route(method: str, path: str) -> NormalizedRoute: ...
|
|
130
|
+
|
|
131
|
+
class RateLimiter:
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
global_per_second: Optional[int] = None,
|
|
135
|
+
invalid_limit: Optional[int] = None,
|
|
136
|
+
invalid_trip_at: Optional[int] = None,
|
|
137
|
+
) -> None: ...
|
|
138
|
+
def acquire(
|
|
139
|
+
self, method: str, path: str, now_ms: Optional[int] = None
|
|
140
|
+
) -> Tuple[str, Optional[int], Optional[str]]: ...
|
|
141
|
+
def on_response(
|
|
142
|
+
self,
|
|
143
|
+
method: str,
|
|
144
|
+
path: str,
|
|
145
|
+
status: int,
|
|
146
|
+
limit: Optional[int] = None,
|
|
147
|
+
remaining: Optional[int] = None,
|
|
148
|
+
reset_after_ms: Optional[int] = None,
|
|
149
|
+
bucket: Optional[str] = None,
|
|
150
|
+
scope: Optional[str] = None,
|
|
151
|
+
now_ms: Optional[int] = None,
|
|
152
|
+
) -> None: ...
|
|
153
|
+
def invalid_requests(self, now_ms: Optional[int] = None) -> int: ...
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class CacheStats:
|
|
157
|
+
guilds: int
|
|
158
|
+
visible_channels: int
|
|
159
|
+
obfuscated_channels: int
|
|
160
|
+
roles: int
|
|
161
|
+
members: int
|
|
162
|
+
users: int
|
|
163
|
+
messages: int
|
|
164
|
+
|
|
165
|
+
class Cache:
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
max_messages_per_channel: Optional[int] = None,
|
|
169
|
+
max_members_per_guild: Optional[int] = None,
|
|
170
|
+
max_users: Optional[int] = None,
|
|
171
|
+
) -> None: ...
|
|
172
|
+
def update(self, event_json_or_dict: Union[str, Dict[str, Any]]) -> None: ...
|
|
173
|
+
def is_channel_obfuscated(self, channel_id: Union[int, str]) -> bool: ...
|
|
174
|
+
def get_guild(self, guild_id: Union[int, str]) -> Optional[Dict[str, Any]]: ...
|
|
175
|
+
def get_channel(self, channel_id: Union[int, str]) -> Optional[Dict[str, Any]]: ...
|
|
176
|
+
def compute_member_permissions(
|
|
177
|
+
self,
|
|
178
|
+
guild_id: Union[int, str],
|
|
179
|
+
channel_id: Union[int, str],
|
|
180
|
+
user_id: Union[int, str],
|
|
181
|
+
) -> int: ...
|
|
182
|
+
def stats(self) -> CacheStats: ...
|
|
183
|
+
def clear(self) -> None: ...
|
|
184
|
+
|
|
185
|
+
class GatewaySession:
|
|
186
|
+
def __init__(self) -> None: ...
|
|
187
|
+
def reset(self) -> None: ...
|
|
188
|
+
def on_hello(self, now_ms: int, interval_ms: int, jitter: float = 0.5) -> None: ...
|
|
189
|
+
def on_ready(self, session_id: str, resume_gateway_url: str) -> None: ...
|
|
190
|
+
def record_seq(self, seq: int) -> None: ...
|
|
191
|
+
def seq(self) -> Optional[int]: ...
|
|
192
|
+
def is_established(self) -> bool: ...
|
|
193
|
+
def poll_heartbeat(self, now_ms: int) -> Tuple[str, Optional[int]]: ...
|
|
194
|
+
def plan_reconnect(
|
|
195
|
+
self, why: str, code: Optional[int] = None, resumable: Optional[bool] = None
|
|
196
|
+
) -> Tuple[str, Optional[str], Optional[str], Optional[int], Optional[int]]: ...
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class ClientEvent:
|
|
200
|
+
shard_id: int
|
|
201
|
+
name: str
|
|
202
|
+
data: Any
|
|
203
|
+
|
|
204
|
+
class RiftClient:
|
|
205
|
+
token: str
|
|
206
|
+
intents: int
|
|
207
|
+
capabilities: int
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
token: str,
|
|
211
|
+
intents: Optional[int] = None,
|
|
212
|
+
capabilities: Optional[int] = None,
|
|
213
|
+
) -> None: ...
|
|
214
|
+
def inject_mock_event(self, shard_id: int, name: str, payload: Union[str, Dict[str, Any]]) -> None: ...
|
|
215
|
+
async def next_event(self) -> Optional[ClientEvent]: ...
|
|
216
|
+
def send_command(self, shard_id: int, opcode: int, payload: Any) -> None: ...
|
|
217
|
+
def update_presence(
|
|
218
|
+
self,
|
|
219
|
+
shard_id: int,
|
|
220
|
+
status: str,
|
|
221
|
+
activity_name: Optional[str] = None,
|
|
222
|
+
activity_type: int = 0,
|
|
223
|
+
) -> None: ...
|
|
224
|
+
async def stop(self) -> None: ...
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# PEP 561 marker
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rift-discord
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: High-performance Python bindings for Rift Discord v10 engine
|
|
5
|
+
Author: isaim0011
|
|
6
|
+
License: MIT OR Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/isaim0011/rift
|
|
8
|
+
Project-URL: Repository, https://github.com/isaim0011/rift
|
|
9
|
+
Keywords: discord,bot,gateway,api,rust,rift
|
|
10
|
+
Classifier: Programming Language :: Rust
|
|
11
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Communications :: Chat
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# rift-discord (Python Bindings)
|
|
18
|
+
|
|
19
|
+
Python bindings for Rift: high-performance Discord engine written in Rust.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
python/rift/__init__.py
|
|
4
|
+
python/rift/__init__.pyi
|
|
5
|
+
python/rift/py.typed
|
|
6
|
+
python/rift_discord.egg-info/PKG-INFO
|
|
7
|
+
python/rift_discord.egg-info/SOURCES.txt
|
|
8
|
+
python/rift_discord.egg-info/dependency_links.txt
|
|
9
|
+
python/rift_discord.egg-info/top_level.txt
|
|
10
|
+
tests/test_rift.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rift
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import pytest
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
# Add python source to sys.path
|
|
7
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "python")))
|
|
8
|
+
|
|
9
|
+
from rift import (
|
|
10
|
+
Snowflake,
|
|
11
|
+
Permissions,
|
|
12
|
+
Intents,
|
|
13
|
+
Capabilities,
|
|
14
|
+
verify_interaction,
|
|
15
|
+
normalize_route,
|
|
16
|
+
RateLimiter,
|
|
17
|
+
Cache,
|
|
18
|
+
GatewaySession,
|
|
19
|
+
RiftClient,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_snowflake_decoding_and_generation():
|
|
24
|
+
example = 175928847299117063
|
|
25
|
+
assert Snowflake.timestamp_ms(example) == 1462015105796
|
|
26
|
+
assert Snowflake.worker_id(example) == 1
|
|
27
|
+
assert Snowflake.process_id(example) == 0
|
|
28
|
+
assert Snowflake.increment(example) == 7
|
|
29
|
+
|
|
30
|
+
parts = Snowflake.deconstruct(example)
|
|
31
|
+
assert parts == (1462015105796, 1, 0, 7)
|
|
32
|
+
|
|
33
|
+
reconstructed = Snowflake.from_parts(1462015105796, 1, 0, 7)
|
|
34
|
+
assert reconstructed == example
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_permissions_evaluation_and_channels():
|
|
38
|
+
assert Permissions.has_permission(Permissions.ADMINISTRATOR, Permissions.SEND_MESSAGES) is True
|
|
39
|
+
assert Permissions.has_permission(Permissions.VIEW_CHANNEL, Permissions.SEND_MESSAGES) is False
|
|
40
|
+
|
|
41
|
+
guild_id = 1000
|
|
42
|
+
member_id = 2000
|
|
43
|
+
role_a = 3001
|
|
44
|
+
role_b = 3002
|
|
45
|
+
|
|
46
|
+
overwrites = [
|
|
47
|
+
(role_a, 0, 0, Permissions.SEND_MESSAGES),
|
|
48
|
+
(role_b, 0, Permissions.SEND_MESSAGES, 0),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
computed = Permissions.compute_channel_permissions(
|
|
52
|
+
Permissions.VIEW_CHANNEL,
|
|
53
|
+
overwrites,
|
|
54
|
+
guild_id,
|
|
55
|
+
member_id,
|
|
56
|
+
[role_a, role_b],
|
|
57
|
+
)
|
|
58
|
+
assert (computed & Permissions.SEND_MESSAGES) != 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_intents_and_capabilities():
|
|
62
|
+
assert Intents.GUILDS == 1
|
|
63
|
+
assert Intents.MESSAGE_CONTENT == 1 << 15
|
|
64
|
+
assert Capabilities.CHANNEL_OBFUSCATION == 1 << 15
|
|
65
|
+
assert Capabilities.IS_COMPONENTS_V2 == 1 << 16
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_verify_interaction_signature():
|
|
69
|
+
pk = "a" * 64
|
|
70
|
+
sig = "b" * 128
|
|
71
|
+
ts = "1700000000"
|
|
72
|
+
body = b'{"type":1}'
|
|
73
|
+
assert verify_interaction(pk, sig, ts, body) is False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_rate_limiter_and_normalize_route():
|
|
77
|
+
norm = normalize_route("GET", "/channels/12345/messages/67890?limit=50")
|
|
78
|
+
assert norm.template == "/channels/:major/messages/:id"
|
|
79
|
+
assert norm.major == "channels/12345"
|
|
80
|
+
|
|
81
|
+
limiter = RateLimiter()
|
|
82
|
+
verdict, retry, _ = limiter.acquire("GET", "/channels/12345/messages", 1000)
|
|
83
|
+
assert verdict == "go"
|
|
84
|
+
|
|
85
|
+
verdict2, retry2, _ = limiter.acquire("GET", "/channels/12345/messages", 1005)
|
|
86
|
+
assert verdict2 == "wait"
|
|
87
|
+
|
|
88
|
+
limiter.on_response(
|
|
89
|
+
"GET",
|
|
90
|
+
"/channels/12345/messages",
|
|
91
|
+
200,
|
|
92
|
+
limit=5,
|
|
93
|
+
remaining=4,
|
|
94
|
+
reset_after_ms=1000,
|
|
95
|
+
now_ms=1010,
|
|
96
|
+
)
|
|
97
|
+
verdict3, _, _ = limiter.acquire("GET", "/channels/12345/messages", 1015)
|
|
98
|
+
assert verdict3 == "go"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_cache_and_nov16_obfuscation():
|
|
102
|
+
cache = Cache()
|
|
103
|
+
assert cache.stats().guilds == 0
|
|
104
|
+
|
|
105
|
+
# Normal channel
|
|
106
|
+
cache.update({
|
|
107
|
+
"t": "CHANNEL_CREATE",
|
|
108
|
+
"d": {"id": "101", "type": 0, "name": "chat", "flags": 0},
|
|
109
|
+
})
|
|
110
|
+
assert cache.is_channel_obfuscated("101") is False
|
|
111
|
+
assert cache.get_channel("101") is not None
|
|
112
|
+
|
|
113
|
+
# Obfuscated channel (flag bit 17 = 131072)
|
|
114
|
+
cache.update({
|
|
115
|
+
"t": "CHANNEL_CREATE",
|
|
116
|
+
"d": {"id": "102", "type": 0, "name": "___hidden___", "flags": 131072},
|
|
117
|
+
})
|
|
118
|
+
assert cache.is_channel_obfuscated("102") is True
|
|
119
|
+
assert cache.get_channel("102") is None
|
|
120
|
+
assert cache.compute_member_permissions(1000, 102, 2000) == 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_gateway_session_and_reconnection():
|
|
124
|
+
gw = GatewaySession()
|
|
125
|
+
assert gw.is_established() is False
|
|
126
|
+
|
|
127
|
+
gw.on_hello(1000, 41250, 0.5)
|
|
128
|
+
gw.on_ready("session_123", "wss://resume.discord.gg")
|
|
129
|
+
gw.record_seq(50)
|
|
130
|
+
assert gw.is_established() is True
|
|
131
|
+
assert gw.seq() == 50
|
|
132
|
+
|
|
133
|
+
action, seq = gw.poll_heartbeat(1000)
|
|
134
|
+
assert action == "wait"
|
|
135
|
+
|
|
136
|
+
# Plan reconnect
|
|
137
|
+
plan, url, sess_id, seq_out, _ = gw.plan_reconnect("close", code=4000)
|
|
138
|
+
assert plan == "resume"
|
|
139
|
+
assert url == "wss://resume.discord.gg"
|
|
140
|
+
assert sess_id == "session_123"
|
|
141
|
+
assert seq_out == 50
|
|
142
|
+
|
|
143
|
+
# Fatal close code
|
|
144
|
+
fatal_plan, _, _, _, close_code = gw.plan_reconnect("close", code=4004)
|
|
145
|
+
assert fatal_plan == "fatal"
|
|
146
|
+
assert close_code == 4004
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.asyncio
|
|
150
|
+
async def test_rift_client_async():
|
|
151
|
+
client = RiftClient("BOT_TOKEN")
|
|
152
|
+
client.inject_mock_event(0, "MESSAGE_CREATE", {"content": "rift-py"})
|
|
153
|
+
|
|
154
|
+
event = await client.next_event()
|
|
155
|
+
assert event is not None
|
|
156
|
+
assert event.shard_id == 0
|
|
157
|
+
assert event.name == "MESSAGE_CREATE"
|
|
158
|
+
assert event.data["content"] == "rift-py"
|
|
159
|
+
|
|
160
|
+
client.update_presence(0, "online", "Python Bot", 0)
|
|
161
|
+
await client.stop()
|
|
162
|
+
next_event = await client.next_event()
|
|
163
|
+
assert next_event is None
|