1+ import asyncio
2+ import json
13import re
24import textwrap
5+ from os import environ
36from pathlib import Path
47
5- from discord import Message
8+ import discord
9+ from discord import Message , ui
610from discord .ext import commands
11+ from pydis_core .utils import scheduling
712from pydis_core .utils .logging import get_logger
813
14+ from bot import constants
915from bot .bot import SirRobin
1016
1117log = get_logger (__name__ )
2026""" )
2127
2228PIXELS_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
2440class 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 :
0 commit comments