Skip to content

Commit 7c3706d

Browse files
committed
Add pixels discord stream
1 parent c344212 commit 7c3706d

3 files changed

Lines changed: 214 additions & 3 deletions

File tree

‎bot/constants.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class _Channels(EnvConfig, env_prefix="CHANNEL_"):
9898
voice_chat_0: int = 412357430186344448
9999
voice_chat_1: int = 799647045886541885
100100
roles: int = 851270062434156586
101+
pixels_stream: int = 1541576692986941563
101102

102103

103104
Channels = _Channels()

‎bot/exts/pixels.py‎

Lines changed: 159 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1+
import asyncio
2+
import json
13
import re
24
import textwrap
5+
from os import environ
36
from pathlib import Path
47

5-
from discord import Message
8+
import discord
9+
from discord import Message, ui
610
from discord.ext import commands
11+
from pydis_core.utils import scheduling
712
from pydis_core.utils.logging import get_logger
813

14+
from bot import constants
915
from bot.bot import SirRobin
1016

1117
log = get_logger(__name__)
@@ -20,12 +26,38 @@
2026
""")
2127

2228
PIXELS_API_TOKEN_PATH = Path("/var/run/secrets/pixels-api/token")
29+
PIXELS_URL = environ.get("PIXELS_URL", "https://pixels.pythondiscord.com")
30+
31+
class PixelsStreamView(ui.LayoutView):
32+
"""LayoutView containing placement containers."""
33+
34+
def __init__(self, containers: list[ui.Container]) -> None:
35+
super().__init__()
36+
for container in containers:
37+
self.add_item(container)
38+
2339

2440
class Pixels(commands.Cog):
2541
"""Utilities for working with Pixels API."""
2642

2743
def __init__(self, bot: SirRobin):
2844
self.bot = bot
45+
self.stream_task: asyncio.Task | None = None
46+
self.flush_task: asyncio.Task | None = None
47+
self.bucket: list[ui.Container] = []
48+
self.bucket_lock = asyncio.Lock()
49+
50+
async def cog_load(self) -> None:
51+
"""Start background tasks for streaming and flushing placement buckets."""
52+
self.stream_task = scheduling.create_task(self.consume_stream())
53+
self.flush_task = scheduling.create_task(self.flush_bucket_loop())
54+
55+
async def cog_unload(self) -> None:
56+
"""Cancel background tasks on cog unload."""
57+
if self.stream_task:
58+
self.stream_task.cancel()
59+
if self.flush_task:
60+
self.flush_task.cancel()
2961

3062
def get_request_headers(self) -> dict[str, str]:
3163
"""Get the Pixels API request headers from the projected service account file."""
@@ -34,6 +66,130 @@ def get_request_headers(self) -> dict[str, str]:
3466

3567
return {"Authorization": f"Token {token}"}
3668

69+
async def consume_stream(self) -> None:
70+
"""Consume the Pixels API event stream."""
71+
await self.bot.wait_until_ready()
72+
stream_url = f"{PIXELS_URL.rstrip('/')}/api/stream?no_current_state=true"
73+
74+
while True:
75+
try:
76+
headers = self.get_request_headers()
77+
async with self.bot.http_session.get(stream_url, headers=headers) as resp:
78+
if resp.status != 200:
79+
log.error(f"Failed to connect to Pixels stream. Status code: {resp.status}")
80+
await asyncio.sleep(5)
81+
continue
82+
83+
log.info("Connected to Pixels API stream.")
84+
current_event: str | None = None
85+
current_data: list[str] = []
86+
87+
async for line_bytes in resp.content:
88+
line = line_bytes.decode("utf-8").rstrip("\r\n")
89+
if not line:
90+
if current_event and current_data:
91+
data_str = "\n".join(current_data)
92+
await self.handle_stream_event(current_event, data_str)
93+
current_event = None
94+
current_data = []
95+
elif line.startswith("event:"):
96+
current_event = line[6:].strip()
97+
elif line.startswith("data:"):
98+
current_data.append(line[5:].strip())
99+
except asyncio.CancelledError:
100+
log.info("Pixels stream consumer task cancelled.")
101+
break
102+
except Exception:
103+
log.exception("Error consuming Pixels API stream.")
104+
await asyncio.sleep(5)
105+
106+
async def handle_stream_event(self, event_type: str, data_str: str) -> None:
107+
"""Handle an incoming event from the Pixels API stream."""
108+
try:
109+
payload = json.loads(data_str)
110+
except json.JSONDecodeError:
111+
log.warning(f"Failed to decode stream event data for event {event_type}: {data_str}")
112+
return
113+
114+
user_id = payload.get("user_id")
115+
rgb = payload.get("rgb")
116+
x = payload.get("x")
117+
y = payload.get("y")
118+
if user_id is None or not rgb or x is None or y is None:
119+
log.warning(f"Event payload missing required fields: {payload}")
120+
return
121+
122+
user_id_int = int(user_id)
123+
user = self.bot.get_user(user_id_int)
124+
if user is None:
125+
try:
126+
user = await self.bot.fetch_user(user_id_int)
127+
except discord.HTTPException:
128+
user = None
129+
130+
if user:
131+
user_name = user.name
132+
avatar_url = user.display_avatar.url
133+
else:
134+
user_name = "Unknown User"
135+
avatar_url = self.bot.user.display_avatar.url
136+
137+
rgb_hex = str(rgb).lstrip("#")
138+
try:
139+
colour = discord.Colour(int(rgb_hex, 16))
140+
except ValueError:
141+
colour = discord.Colour.default()
142+
143+
container = ui.Container(accent_colour=colour)
144+
section = ui.Section(
145+
f"{user_name} (`{user_id_int}`) at ({x}, {y})",
146+
accessory=ui.Thumbnail(avatar_url),
147+
)
148+
container.add_item(section)
149+
150+
await self.add_container_to_bucket(container)
151+
152+
async def add_container_to_bucket(self, container: ui.Container) -> None:
153+
"""Add a container to the batch bucket. If 10 containers are reached, send the bucket immediately."""
154+
async with self.bucket_lock:
155+
self.bucket.append(container)
156+
if len(self.bucket) >= 10:
157+
await self._send_bucket_locked()
158+
159+
async def flush_bucket_loop(self) -> None:
160+
"""Flush the bucket every 10 seconds if it contains any containers."""
161+
await self.bot.wait_until_ready()
162+
while True:
163+
try:
164+
await asyncio.sleep(10)
165+
async with self.bucket_lock:
166+
if self.bucket:
167+
await self._send_bucket_locked()
168+
except asyncio.CancelledError:
169+
break
170+
except Exception:
171+
log.exception("Error in Pixels flush bucket loop.")
172+
173+
async def _send_bucket_locked(self) -> None:
174+
"""Send containers in the bucket as components v2 LayoutViews in batches of up to 10."""
175+
while self.bucket:
176+
containers_to_send = self.bucket[:10]
177+
self.bucket = self.bucket[10:]
178+
179+
channel = self.bot.get_channel(constants.Channels.pixels_stream)
180+
if channel is None:
181+
try:
182+
channel = await self.bot.fetch_channel(constants.Channels.pixels_stream)
183+
except discord.HTTPException:
184+
log.error(f"Could not fetch pixels stream channel (ID: {constants.Channels.pixels_stream}).")
185+
return
186+
187+
view = PixelsStreamView(containers_to_send)
188+
try:
189+
await channel.send(view=view)
190+
except discord.HTTPException:
191+
log.exception(f"Failed to post Pixels placement bucket to channel {channel.id}.")
192+
37193
@commands.Cog.listener()
38194
async def on_message(self, message: Message) -> None:
39195
"""Check for exposed Pixels API keys and JWTs in messages."""
@@ -51,7 +207,7 @@ async def on_message(self, message: Message) -> None:
51207

52208
for jwt in found_jwts:
53209
async with self.bot.http_session.post(
54-
"https://pixels.pythondiscord.com/api/invalidate_credential",
210+
f"{PIXELS_URL.rstrip('/')}/api/invalidate_credential",
55211
headers=headers,
56212
json={"jwt": jwt},
57213
) as resp:
@@ -67,7 +223,7 @@ async def on_message(self, message: Message) -> None:
67223

68224
for api_key in found_api_keys:
69225
async with self.bot.http_session.post(
70-
"https://pixels.pythondiscord.com/api/invalidate_credential",
226+
f"{PIXELS_URL.rstrip('/')}/api/invalidate_credential",
71227
headers=headers,
72228
json={"api_key": api_key},
73229
) as resp:

‎tests/test_pixels.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import json
2+
import unittest
3+
from unittest.mock import MagicMock
4+
5+
import discord
6+
7+
from bot.exts.pixels import Pixels, PixelsStreamView
8+
from tests.helpers import MockBot, MockTextChannel
9+
10+
11+
class PixelsCogTests(unittest.IsolatedAsyncioTestCase):
12+
"""Tests for Pixels cog streaming and batching functionality."""
13+
14+
def setUp(self) -> None:
15+
self.bot = MockBot()
16+
self.cog = Pixels(self.bot)
17+
18+
async def test_handle_stream_event_creates_container(self) -> None:
19+
"""Placement events should create a container and add it to bucket."""
20+
user = MagicMock(spec=discord.User)
21+
user.name = "TestUser"
22+
user.id = 123456789
23+
user.display_avatar.url = "http://avatar.url/1.png"
24+
self.bot.get_user.return_value = user
25+
26+
event_data = json.dumps({"x": 10, "y": 20, "rgb": "FF0000", "user_id": 123456789})
27+
await self.cog.handle_stream_event("UPDATE", event_data)
28+
29+
self.assertEqual(len(self.cog.bucket), 1)
30+
container = self.cog.bucket[0]
31+
self.assertIsInstance(container, discord.ui.Container)
32+
self.assertEqual(container.accent_colour.value, 0xFF0000)
33+
34+
async def test_bucket_sends_when_10_containers_reached(self) -> None:
35+
"""Bucket should flush automatically when 10 containers are reached."""
36+
channel = MockTextChannel()
37+
self.bot.get_channel.return_value = channel
38+
39+
user = MagicMock(spec=discord.User)
40+
user.name = "TestUser"
41+
user.id = 123456789
42+
user.display_avatar.url = "http://avatar.url/1.png"
43+
self.bot.get_user.return_value = user
44+
45+
event_data = json.dumps({"x": 10, "y": 20, "rgb": "00FF00", "user_id": 123456789})
46+
for _ in range(10):
47+
await self.cog.handle_stream_event("UPDATE", event_data)
48+
49+
self.assertEqual(len(self.cog.bucket), 0)
50+
channel.send.assert_called_once()
51+
_, kwargs = channel.send.call_args
52+
self.assertIn("view", kwargs)
53+
self.assertIsInstance(kwargs["view"], PixelsStreamView)
54+
self.assertEqual(len(kwargs["view"].children), 10)

0 commit comments

Comments
 (0)