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.
@@ -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,3 @@
1
+ # rift-discord (Python Bindings)
2
+
3
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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