kryten-robot 0.6.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kryten/CONFIG.md +504 -0
- kryten/__init__.py +127 -0
- kryten/__main__.py +882 -0
- kryten/application_state.py +98 -0
- kryten/audit_logger.py +237 -0
- kryten/command_subscriber.py +341 -0
- kryten/config.example.json +35 -0
- kryten/config.py +510 -0
- kryten/connection_watchdog.py +209 -0
- kryten/correlation.py +241 -0
- kryten/cytube_connector.py +754 -0
- kryten/cytube_event_sender.py +1476 -0
- kryten/errors.py +161 -0
- kryten/event_publisher.py +416 -0
- kryten/health_monitor.py +482 -0
- kryten/lifecycle_events.py +274 -0
- kryten/logging_config.py +314 -0
- kryten/nats_client.py +468 -0
- kryten/raw_event.py +165 -0
- kryten/service_registry.py +371 -0
- kryten/shutdown_handler.py +383 -0
- kryten/socket_io.py +903 -0
- kryten/state_manager.py +711 -0
- kryten/state_query_handler.py +698 -0
- kryten/state_updater.py +314 -0
- kryten/stats_tracker.py +108 -0
- kryten/subject_builder.py +330 -0
- kryten_robot-0.6.9.dist-info/METADATA +469 -0
- kryten_robot-0.6.9.dist-info/RECORD +32 -0
- kryten_robot-0.6.9.dist-info/WHEEL +4 -0
- kryten_robot-0.6.9.dist-info/entry_points.txt +3 -0
- kryten_robot-0.6.9.dist-info/licenses/LICENSE +21 -0
kryten/state_manager.py
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"""State Manager - Persist CyTube channel state to NATS KV stores.
|
|
2
|
+
|
|
3
|
+
This module tracks and persists channel state (emotes, playlist, userlist)
|
|
4
|
+
to NATS key-value stores, allowing downstream applications to query state
|
|
5
|
+
without directly connecting to the CyTube instance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from nats.errors import NoRespondersError
|
|
13
|
+
from nats.js import api
|
|
14
|
+
from nats.js.errors import ServiceUnavailableError
|
|
15
|
+
from nats.js.kv import KeyValue
|
|
16
|
+
|
|
17
|
+
from .nats_client import NatsClient
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StateManager:
|
|
21
|
+
"""Manage CyTube channel state in NATS key-value stores.
|
|
22
|
+
|
|
23
|
+
Maintains three KV buckets for channel state:
|
|
24
|
+
- emotes: Channel emote list
|
|
25
|
+
- playlist: Current playlist items
|
|
26
|
+
- userlist: Connected users
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
nats_client: NATS client for KV operations.
|
|
30
|
+
channel: CyTube channel name.
|
|
31
|
+
logger: Logger instance.
|
|
32
|
+
is_running: Whether state manager is active.
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
>>> manager = StateManager(nats_client, "mychannel", logger)
|
|
36
|
+
>>> await manager.start()
|
|
37
|
+
>>> await manager.update_emotes(emote_list)
|
|
38
|
+
>>> await manager.stop()
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
nats_client: NatsClient,
|
|
44
|
+
channel: str,
|
|
45
|
+
logger: logging.Logger,
|
|
46
|
+
counting_config=None,
|
|
47
|
+
):
|
|
48
|
+
"""Initialize state manager.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
nats_client: NATS client instance.
|
|
52
|
+
channel: CyTube channel name.
|
|
53
|
+
logger: Logger for structured output.
|
|
54
|
+
counting_config: Optional StateCountingConfig for filtering counts.
|
|
55
|
+
"""
|
|
56
|
+
self._nats = nats_client
|
|
57
|
+
self._channel = channel
|
|
58
|
+
self._logger = logger
|
|
59
|
+
self._counting_config = counting_config
|
|
60
|
+
self._running = False
|
|
61
|
+
|
|
62
|
+
# KV bucket handles
|
|
63
|
+
self._kv_emotes: KeyValue | None = None
|
|
64
|
+
self._kv_playlist: KeyValue | None = None
|
|
65
|
+
self._kv_userlist: KeyValue | None = None
|
|
66
|
+
|
|
67
|
+
# State tracking
|
|
68
|
+
self._emotes: list[dict[str, Any]] = []
|
|
69
|
+
self._playlist: list[dict[str, Any]] = []
|
|
70
|
+
self._users: dict[str, dict[str, Any]] = {} # username -> user data
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_running(self) -> bool:
|
|
74
|
+
"""Check if state manager is running.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True if started and managing state, False otherwise.
|
|
78
|
+
"""
|
|
79
|
+
return self._running
|
|
80
|
+
|
|
81
|
+
def users_count(self) -> int:
|
|
82
|
+
"""Get count of users with optional filtering.
|
|
83
|
+
|
|
84
|
+
Applies filters from counting_config:
|
|
85
|
+
- users_exclude_afk: Exclude AFK users
|
|
86
|
+
- users_min_rank: Minimum rank to include
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Filtered count of users.
|
|
90
|
+
|
|
91
|
+
Examples:
|
|
92
|
+
>>> count = manager.users_count()
|
|
93
|
+
>>> print(f"Active users: {count}")
|
|
94
|
+
"""
|
|
95
|
+
if not self._counting_config:
|
|
96
|
+
return len(self._users)
|
|
97
|
+
|
|
98
|
+
count = 0
|
|
99
|
+
for user in self._users.values():
|
|
100
|
+
# Check rank filter
|
|
101
|
+
user_rank = user.get("rank", 0)
|
|
102
|
+
if user_rank < self._counting_config.users_min_rank:
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
# Check AFK filter
|
|
106
|
+
if self._counting_config.users_exclude_afk:
|
|
107
|
+
meta = user.get("meta", {})
|
|
108
|
+
if meta.get("afk", False):
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
count += 1
|
|
112
|
+
|
|
113
|
+
return count
|
|
114
|
+
|
|
115
|
+
def playlist_count(self) -> int:
|
|
116
|
+
"""Get count of playlist items with optional filtering.
|
|
117
|
+
|
|
118
|
+
Applies filters from counting_config:
|
|
119
|
+
- playlist_exclude_temp: Exclude temporary items
|
|
120
|
+
- playlist_max_duration: Maximum duration in seconds (0=no limit)
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Filtered count of playlist items.
|
|
124
|
+
|
|
125
|
+
Examples:
|
|
126
|
+
>>> count = manager.playlist_count()
|
|
127
|
+
>>> print(f"Playlist items: {count}")
|
|
128
|
+
"""
|
|
129
|
+
if not self._counting_config:
|
|
130
|
+
return len(self._playlist)
|
|
131
|
+
|
|
132
|
+
count = 0
|
|
133
|
+
for item in self._playlist:
|
|
134
|
+
# Check temp filter
|
|
135
|
+
if self._counting_config.playlist_exclude_temp:
|
|
136
|
+
if item.get("temp", False):
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
# Check duration filter
|
|
140
|
+
if self._counting_config.playlist_max_duration > 0:
|
|
141
|
+
media = item.get("media", {})
|
|
142
|
+
duration = media.get("seconds", 0)
|
|
143
|
+
if duration > self._counting_config.playlist_max_duration:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
count += 1
|
|
147
|
+
|
|
148
|
+
return count
|
|
149
|
+
|
|
150
|
+
def emotes_count(self) -> int:
|
|
151
|
+
"""Get count of emotes with optional filtering.
|
|
152
|
+
|
|
153
|
+
Applies filters from counting_config:
|
|
154
|
+
- emotes_only_enabled: Only count enabled emotes
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Filtered count of emotes.
|
|
158
|
+
|
|
159
|
+
Examples:
|
|
160
|
+
>>> count = manager.emotes_count()
|
|
161
|
+
>>> print(f"Emotes: {count}")
|
|
162
|
+
"""
|
|
163
|
+
if not self._counting_config:
|
|
164
|
+
return len(self._emotes)
|
|
165
|
+
|
|
166
|
+
if not self._counting_config.emotes_only_enabled:
|
|
167
|
+
return len(self._emotes)
|
|
168
|
+
|
|
169
|
+
# Count only enabled emotes
|
|
170
|
+
count = 0
|
|
171
|
+
for emote in self._emotes:
|
|
172
|
+
if not emote.get("disabled", False):
|
|
173
|
+
count += 1
|
|
174
|
+
|
|
175
|
+
return count
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def stats(self) -> dict[str, int]:
|
|
179
|
+
"""Get state statistics using configured counting filters.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Dictionary with emote_count, playlist_count, user_count.
|
|
183
|
+
"""
|
|
184
|
+
return {
|
|
185
|
+
"emote_count": self.emotes_count(),
|
|
186
|
+
"playlist_count": self.playlist_count(),
|
|
187
|
+
"user_count": self.users_count(),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async def start(self) -> None:
|
|
191
|
+
"""Start state manager and create KV buckets.
|
|
192
|
+
|
|
193
|
+
Creates or binds to NATS JetStream KV buckets for state storage.
|
|
194
|
+
Buckets are named: cytube_{channel}_emotes, cytube_{channel}_playlist,
|
|
195
|
+
cytube_{channel}_userlist.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
RuntimeError: If NATS is not connected or JetStream unavailable.
|
|
199
|
+
"""
|
|
200
|
+
if self._running:
|
|
201
|
+
self._logger.debug("State manager already running")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
if not self._nats.is_connected:
|
|
205
|
+
raise RuntimeError("NATS client not connected")
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
self._logger.info(f"Starting state manager for channel: {self._channel}")
|
|
209
|
+
|
|
210
|
+
# Get JetStream context
|
|
211
|
+
js = self._nats._nc.jetstream()
|
|
212
|
+
|
|
213
|
+
# Create or bind KV buckets
|
|
214
|
+
# Format: kryten_{channel}_{type}
|
|
215
|
+
bucket_prefix = f"kryten_{self._channel}"
|
|
216
|
+
|
|
217
|
+
# Emotes bucket
|
|
218
|
+
try:
|
|
219
|
+
self._kv_emotes = await js.key_value(bucket=f"{bucket_prefix}_emotes")
|
|
220
|
+
self._logger.debug("Bound to existing emotes KV bucket")
|
|
221
|
+
except Exception:
|
|
222
|
+
self._kv_emotes = await js.create_key_value(
|
|
223
|
+
config=api.KeyValueConfig(
|
|
224
|
+
bucket=f"{bucket_prefix}_emotes",
|
|
225
|
+
description=f"Kryten {self._channel} emotes",
|
|
226
|
+
max_value_size=1024 * 1024, # 1MB max
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
self._logger.info("Created emotes KV bucket")
|
|
230
|
+
|
|
231
|
+
# Playlist bucket
|
|
232
|
+
try:
|
|
233
|
+
self._kv_playlist = await js.key_value(bucket=f"{bucket_prefix}_playlist")
|
|
234
|
+
self._logger.debug("Bound to existing playlist KV bucket")
|
|
235
|
+
except Exception:
|
|
236
|
+
self._kv_playlist = await js.create_key_value(
|
|
237
|
+
config=api.KeyValueConfig(
|
|
238
|
+
bucket=f"{bucket_prefix}_playlist",
|
|
239
|
+
description=f"Kryten {self._channel} playlist",
|
|
240
|
+
max_value_size=10 * 1024 * 1024, # 10MB max
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
self._logger.info("Created playlist KV bucket")
|
|
244
|
+
|
|
245
|
+
# Userlist bucket
|
|
246
|
+
try:
|
|
247
|
+
self._kv_userlist = await js.key_value(bucket=f"{bucket_prefix}_userlist")
|
|
248
|
+
self._logger.debug("Bound to existing userlist KV bucket")
|
|
249
|
+
except Exception:
|
|
250
|
+
self._kv_userlist = await js.create_key_value(
|
|
251
|
+
config=api.KeyValueConfig(
|
|
252
|
+
bucket=f"{bucket_prefix}_userlist",
|
|
253
|
+
description=f"Kryten {self._channel} users",
|
|
254
|
+
max_value_size=1024 * 1024, # 1MB max
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
self._logger.info("Created userlist KV bucket")
|
|
258
|
+
|
|
259
|
+
self._running = True
|
|
260
|
+
self._logger.info("State manager started")
|
|
261
|
+
|
|
262
|
+
except (ServiceUnavailableError, NoRespondersError) as e:
|
|
263
|
+
self._logger.error(
|
|
264
|
+
"JetStream not available - state persistence disabled. "
|
|
265
|
+
"Ensure NATS server is running with JetStream enabled (use -js flag)."
|
|
266
|
+
)
|
|
267
|
+
raise RuntimeError(
|
|
268
|
+
"JetStream not available. NATS server must be started with JetStream enabled. "
|
|
269
|
+
"Run 'nats-server -js' or configure JetStream in nats-server.conf"
|
|
270
|
+
) from e
|
|
271
|
+
|
|
272
|
+
except Exception as e:
|
|
273
|
+
self._logger.error(f"Failed to start state manager: {e}", exc_info=True)
|
|
274
|
+
raise
|
|
275
|
+
|
|
276
|
+
async def stop(self) -> None:
|
|
277
|
+
"""Stop state manager.
|
|
278
|
+
|
|
279
|
+
Does not delete KV buckets - state persists for downstream consumers.
|
|
280
|
+
"""
|
|
281
|
+
if not self._running:
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
self._logger.info("Stopping state manager")
|
|
285
|
+
|
|
286
|
+
self._kv_emotes = None
|
|
287
|
+
self._kv_playlist = None
|
|
288
|
+
self._kv_userlist = None
|
|
289
|
+
self._running = False
|
|
290
|
+
|
|
291
|
+
self._logger.info("State manager stopped")
|
|
292
|
+
|
|
293
|
+
# ========================================================================
|
|
294
|
+
# Emote Management
|
|
295
|
+
# ========================================================================
|
|
296
|
+
|
|
297
|
+
async def update_emotes(self, emotes: list[dict[str, Any]]) -> None:
|
|
298
|
+
"""Update full emote list.
|
|
299
|
+
|
|
300
|
+
Called when 'emoteList' event received from CyTube.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
emotes: List of emote objects with 'name', 'image', etc.
|
|
304
|
+
|
|
305
|
+
Examples:
|
|
306
|
+
>>> emotes = [{"name": "Kappa", "image": "..."}]
|
|
307
|
+
>>> await manager.update_emotes(emotes)
|
|
308
|
+
"""
|
|
309
|
+
if not self._running:
|
|
310
|
+
self._logger.warning("Cannot update emotes: state manager not running")
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
self._emotes = emotes
|
|
315
|
+
|
|
316
|
+
# Store as JSON
|
|
317
|
+
emotes_json = json.dumps(emotes).encode()
|
|
318
|
+
await self._kv_emotes.put("list", emotes_json)
|
|
319
|
+
|
|
320
|
+
self._logger.info(f"Updated emotes: {len(emotes)} emotes")
|
|
321
|
+
|
|
322
|
+
except Exception as e:
|
|
323
|
+
self._logger.error(f"Failed to update emotes: {e}", exc_info=True)
|
|
324
|
+
|
|
325
|
+
# ========================================================================
|
|
326
|
+
# Playlist Management
|
|
327
|
+
# ========================================================================
|
|
328
|
+
|
|
329
|
+
async def set_playlist(self, playlist: list[dict[str, Any]]) -> None:
|
|
330
|
+
"""Set entire playlist.
|
|
331
|
+
|
|
332
|
+
Called when 'playlist' event received (initial load).
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
playlist: List of media items with 'uid', 'title', 'duration', etc.
|
|
336
|
+
|
|
337
|
+
Examples:
|
|
338
|
+
>>> items = [{"uid": "abc", "title": "Video 1"}]
|
|
339
|
+
>>> await manager.set_playlist(items)
|
|
340
|
+
"""
|
|
341
|
+
if not self._running:
|
|
342
|
+
self._logger.warning("Cannot set playlist: state manager not running")
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
self._playlist = playlist
|
|
347
|
+
|
|
348
|
+
# Store as JSON
|
|
349
|
+
playlist_json = json.dumps(playlist).encode()
|
|
350
|
+
await self._kv_playlist.put("items", playlist_json)
|
|
351
|
+
|
|
352
|
+
self._logger.info(f"Set playlist: {len(playlist)} items")
|
|
353
|
+
|
|
354
|
+
except Exception as e:
|
|
355
|
+
self._logger.error(f"Failed to set playlist: {e}", exc_info=True)
|
|
356
|
+
|
|
357
|
+
async def add_playlist_item(self, item: dict[str, Any], after: str | None = None) -> None:
|
|
358
|
+
"""Add item to playlist.
|
|
359
|
+
|
|
360
|
+
Called when 'queue' event received.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
item: Media item to add.
|
|
364
|
+
after: UID of item to insert after, or None for end.
|
|
365
|
+
|
|
366
|
+
Examples:
|
|
367
|
+
>>> item = {"uid": "xyz", "title": "New Video"}
|
|
368
|
+
>>> await manager.add_playlist_item(item)
|
|
369
|
+
"""
|
|
370
|
+
if not self._running:
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
if after is None:
|
|
375
|
+
# Append to end
|
|
376
|
+
self._playlist.append(item)
|
|
377
|
+
else:
|
|
378
|
+
# Insert after specified UID
|
|
379
|
+
for i, existing in enumerate(self._playlist):
|
|
380
|
+
if existing.get("uid") == after:
|
|
381
|
+
self._playlist.insert(i + 1, item)
|
|
382
|
+
break
|
|
383
|
+
else:
|
|
384
|
+
# UID not found, append
|
|
385
|
+
self._playlist.append(item)
|
|
386
|
+
|
|
387
|
+
# Update KV store
|
|
388
|
+
playlist_json = json.dumps(self._playlist).encode()
|
|
389
|
+
await self._kv_playlist.put("items", playlist_json)
|
|
390
|
+
|
|
391
|
+
self._logger.debug(f"Added playlist item: {item.get('uid')} ({item.get('title', 'Unknown')})")
|
|
392
|
+
|
|
393
|
+
except Exception as e:
|
|
394
|
+
self._logger.error(f"Failed to add playlist item: {e}", exc_info=True)
|
|
395
|
+
|
|
396
|
+
async def remove_playlist_item(self, uid: str) -> None:
|
|
397
|
+
"""Remove item from playlist.
|
|
398
|
+
|
|
399
|
+
Called when 'delete' event received.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
uid: UID of item to remove.
|
|
403
|
+
|
|
404
|
+
Examples:
|
|
405
|
+
>>> await manager.remove_playlist_item("xyz")
|
|
406
|
+
"""
|
|
407
|
+
if not self._running:
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
self._playlist = [item for item in self._playlist if item.get("uid") != uid]
|
|
412
|
+
|
|
413
|
+
# Update KV store
|
|
414
|
+
playlist_json = json.dumps(self._playlist).encode()
|
|
415
|
+
await self._kv_playlist.put("items", playlist_json)
|
|
416
|
+
|
|
417
|
+
self._logger.debug(f"Removed playlist item: {uid}")
|
|
418
|
+
|
|
419
|
+
except Exception as e:
|
|
420
|
+
self._logger.error(f"Failed to remove playlist item: {e}", exc_info=True)
|
|
421
|
+
|
|
422
|
+
async def move_playlist_item(self, uid: str, after: str) -> None:
|
|
423
|
+
"""Move item in playlist.
|
|
424
|
+
|
|
425
|
+
Called when 'moveMedia' event received.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
uid: UID of item to move.
|
|
429
|
+
after: UID to place after, or "prepend"/"append".
|
|
430
|
+
|
|
431
|
+
Examples:
|
|
432
|
+
>>> await manager.move_playlist_item("xyz", "abc")
|
|
433
|
+
"""
|
|
434
|
+
if not self._running:
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
# Find and remove item
|
|
439
|
+
item = None
|
|
440
|
+
for i, existing in enumerate(self._playlist):
|
|
441
|
+
if existing.get("uid") == uid:
|
|
442
|
+
item = self._playlist.pop(i)
|
|
443
|
+
break
|
|
444
|
+
|
|
445
|
+
if item is None:
|
|
446
|
+
self._logger.warning(f"Cannot move item {uid}: not found")
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
# Insert at new position
|
|
450
|
+
if after == "prepend":
|
|
451
|
+
self._playlist.insert(0, item)
|
|
452
|
+
elif after == "append":
|
|
453
|
+
self._playlist.append(item)
|
|
454
|
+
else:
|
|
455
|
+
# Insert after specified UID
|
|
456
|
+
for i, existing in enumerate(self._playlist):
|
|
457
|
+
if existing.get("uid") == after:
|
|
458
|
+
self._playlist.insert(i + 1, item)
|
|
459
|
+
break
|
|
460
|
+
else:
|
|
461
|
+
# UID not found, append
|
|
462
|
+
self._playlist.append(item)
|
|
463
|
+
|
|
464
|
+
# Update KV store
|
|
465
|
+
playlist_json = json.dumps(self._playlist).encode()
|
|
466
|
+
await self._kv_playlist.put("items", playlist_json)
|
|
467
|
+
|
|
468
|
+
self._logger.debug(f"Moved playlist item {uid} after {after}")
|
|
469
|
+
|
|
470
|
+
except Exception as e:
|
|
471
|
+
self._logger.error(f"Failed to move playlist item: {e}", exc_info=True)
|
|
472
|
+
|
|
473
|
+
async def clear_playlist(self) -> None:
|
|
474
|
+
"""Clear entire playlist.
|
|
475
|
+
|
|
476
|
+
Called when 'playlist' event with empty list received.
|
|
477
|
+
"""
|
|
478
|
+
if not self._running:
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
self._playlist = []
|
|
483
|
+
|
|
484
|
+
# Update KV store
|
|
485
|
+
playlist_json = json.dumps([]).encode()
|
|
486
|
+
await self._kv_playlist.put("items", playlist_json)
|
|
487
|
+
|
|
488
|
+
self._logger.debug("Cleared playlist")
|
|
489
|
+
|
|
490
|
+
except Exception as e:
|
|
491
|
+
self._logger.error(f"Failed to clear playlist: {e}", exc_info=True)
|
|
492
|
+
|
|
493
|
+
# ========================================================================
|
|
494
|
+
# Userlist Management
|
|
495
|
+
# ========================================================================
|
|
496
|
+
|
|
497
|
+
async def set_userlist(self, users: list[dict[str, Any]]) -> None:
|
|
498
|
+
"""Set entire userlist.
|
|
499
|
+
|
|
500
|
+
Called when 'userlist' event received (initial load).
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
users: List of user objects with 'name', 'rank', etc.
|
|
504
|
+
|
|
505
|
+
Examples:
|
|
506
|
+
>>> users = [{"name": "Alice", "rank": 2}]
|
|
507
|
+
>>> await manager.set_userlist(users)
|
|
508
|
+
"""
|
|
509
|
+
if not self._running:
|
|
510
|
+
self._logger.warning("Cannot set userlist: state manager not running")
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
self._users = {user.get("name"): user for user in users if user.get("name")}
|
|
515
|
+
|
|
516
|
+
# Store as JSON
|
|
517
|
+
userlist_json = json.dumps(list(self._users.values())).encode()
|
|
518
|
+
await self._kv_userlist.put("users", userlist_json)
|
|
519
|
+
|
|
520
|
+
self._logger.info(f"Set userlist: {len(self._users)} users")
|
|
521
|
+
|
|
522
|
+
except Exception as e:
|
|
523
|
+
self._logger.error(f"Failed to set userlist: {e}", exc_info=True)
|
|
524
|
+
|
|
525
|
+
async def add_user(self, user: dict[str, Any]) -> None:
|
|
526
|
+
"""Add user to userlist.
|
|
527
|
+
|
|
528
|
+
Called when 'addUser' event received.
|
|
529
|
+
|
|
530
|
+
Args:
|
|
531
|
+
user: User object with 'name', 'rank', etc.
|
|
532
|
+
|
|
533
|
+
Examples:
|
|
534
|
+
>>> user = {"name": "Bob", "rank": 1}
|
|
535
|
+
>>> await manager.add_user(user)
|
|
536
|
+
"""
|
|
537
|
+
if not self._running:
|
|
538
|
+
return
|
|
539
|
+
|
|
540
|
+
try:
|
|
541
|
+
username = user.get("name")
|
|
542
|
+
if not username:
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
self._users[username] = user
|
|
546
|
+
|
|
547
|
+
# Update KV store
|
|
548
|
+
userlist_json = json.dumps(list(self._users.values())).encode()
|
|
549
|
+
await self._kv_userlist.put("users", userlist_json)
|
|
550
|
+
|
|
551
|
+
self._logger.debug(f"Added user: {username}")
|
|
552
|
+
|
|
553
|
+
except Exception as e:
|
|
554
|
+
self._logger.error(f"Failed to add user: {e}", exc_info=True)
|
|
555
|
+
|
|
556
|
+
async def remove_user(self, username: str) -> None:
|
|
557
|
+
"""Remove user from userlist.
|
|
558
|
+
|
|
559
|
+
Called when 'userLeave' event received.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
username: Username to remove.
|
|
563
|
+
|
|
564
|
+
Examples:
|
|
565
|
+
>>> await manager.remove_user("Bob")
|
|
566
|
+
"""
|
|
567
|
+
if not self._running:
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
try:
|
|
571
|
+
if username in self._users:
|
|
572
|
+
del self._users[username]
|
|
573
|
+
|
|
574
|
+
# Update KV store
|
|
575
|
+
userlist_json = json.dumps(list(self._users.values())).encode()
|
|
576
|
+
await self._kv_userlist.put("users", userlist_json)
|
|
577
|
+
|
|
578
|
+
self._logger.debug(f"Removed user: {username}")
|
|
579
|
+
|
|
580
|
+
except Exception as e:
|
|
581
|
+
self._logger.error(f"Failed to remove user: {e}", exc_info=True)
|
|
582
|
+
|
|
583
|
+
async def update_user(self, user: dict[str, Any]) -> None:
|
|
584
|
+
"""Update user data.
|
|
585
|
+
|
|
586
|
+
Called when user properties change (rank, meta, etc).
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
user: Updated user object.
|
|
590
|
+
|
|
591
|
+
Examples:
|
|
592
|
+
>>> user = {"name": "Bob", "rank": 3}
|
|
593
|
+
>>> await manager.update_user(user)
|
|
594
|
+
"""
|
|
595
|
+
if not self._running:
|
|
596
|
+
return
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
username = user.get("name")
|
|
600
|
+
if not username:
|
|
601
|
+
return
|
|
602
|
+
|
|
603
|
+
self._users[username] = user
|
|
604
|
+
|
|
605
|
+
# Update KV store
|
|
606
|
+
userlist_json = json.dumps(list(self._users.values())).encode()
|
|
607
|
+
await self._kv_userlist.put("users", userlist_json)
|
|
608
|
+
|
|
609
|
+
self._logger.debug(f"Updated user: {username}")
|
|
610
|
+
|
|
611
|
+
except Exception as e:
|
|
612
|
+
self._logger.error(f"Failed to update user: {e}", exc_info=True)
|
|
613
|
+
|
|
614
|
+
# ========================================================================
|
|
615
|
+
# State Retrieval
|
|
616
|
+
# ========================================================================
|
|
617
|
+
|
|
618
|
+
def get_emotes(self) -> list[dict[str, Any]]:
|
|
619
|
+
"""Get current emote list.
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
List of emote dictionaries.
|
|
623
|
+
"""
|
|
624
|
+
return self._emotes.copy()
|
|
625
|
+
|
|
626
|
+
def get_playlist(self) -> list[dict[str, Any]]:
|
|
627
|
+
"""Get current playlist.
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
List of playlist item dictionaries.
|
|
631
|
+
"""
|
|
632
|
+
return self._playlist.copy()
|
|
633
|
+
|
|
634
|
+
def get_userlist(self) -> list[dict[str, Any]]:
|
|
635
|
+
"""Get current userlist.
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
List of user dictionaries.
|
|
639
|
+
"""
|
|
640
|
+
return list(self._users.values())
|
|
641
|
+
|
|
642
|
+
def get_user(self, username: str) -> dict[str, Any] | None:
|
|
643
|
+
"""Get specific user by username.
|
|
644
|
+
|
|
645
|
+
Args:
|
|
646
|
+
username: Username to look up.
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
User dictionary if found, None otherwise.
|
|
650
|
+
|
|
651
|
+
Examples:
|
|
652
|
+
>>> user = manager.get_user("Alice")
|
|
653
|
+
>>> if user:
|
|
654
|
+
... print(f"Rank: {user['rank']}")
|
|
655
|
+
"""
|
|
656
|
+
return self._users.get(username)
|
|
657
|
+
|
|
658
|
+
def get_user_profile(self, username: str) -> dict[str, Any] | None:
|
|
659
|
+
"""Get user's profile (avatar and bio).
|
|
660
|
+
|
|
661
|
+
Args:
|
|
662
|
+
username: Username to look up.
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
Profile dictionary with 'image' and 'text' keys, or None if not found.
|
|
666
|
+
|
|
667
|
+
Examples:
|
|
668
|
+
>>> profile = manager.get_user_profile("Alice")
|
|
669
|
+
>>> if profile:
|
|
670
|
+
... print(f"Avatar: {profile.get('image')}")
|
|
671
|
+
... print(f"Bio: {profile.get('text')}")
|
|
672
|
+
"""
|
|
673
|
+
user = self._users.get(username)
|
|
674
|
+
if user:
|
|
675
|
+
return user.get("profile", {})
|
|
676
|
+
return None
|
|
677
|
+
|
|
678
|
+
def get_all_profiles(self) -> dict[str, dict[str, Any]]:
|
|
679
|
+
"""Get all user profiles.
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
Dictionary mapping username to profile dict.
|
|
683
|
+
|
|
684
|
+
Examples:
|
|
685
|
+
>>> profiles = manager.get_all_profiles()
|
|
686
|
+
>>> for username, profile in profiles.items():
|
|
687
|
+
... print(f"{username}: {profile.get('image')}")
|
|
688
|
+
"""
|
|
689
|
+
profiles = {}
|
|
690
|
+
for username, user in self._users.items():
|
|
691
|
+
profile = user.get("profile")
|
|
692
|
+
if profile:
|
|
693
|
+
profiles[username] = profile
|
|
694
|
+
return profiles
|
|
695
|
+
|
|
696
|
+
def get_all_state(self) -> dict[str, list[dict[str, Any]]]:
|
|
697
|
+
"""Get all channel state.
|
|
698
|
+
|
|
699
|
+
Returns:
|
|
700
|
+
Dictionary with emotes, playlist, and userlist.
|
|
701
|
+
"""
|
|
702
|
+
return {
|
|
703
|
+
"emotes": self.get_emotes(),
|
|
704
|
+
"playlist": self.get_playlist(),
|
|
705
|
+
"userlist": self.get_userlist()
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
__all__ = ["StateManager"]
|
|
710
|
+
|
|
711
|
+
|