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_updater.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""State Updater - Subscribe to CyTube events and update StateManager.
|
|
2
|
+
|
|
3
|
+
This module bridges the EventPublisher and StateManager by subscribing to
|
|
4
|
+
relevant CyTube events and updating the state KV stores accordingly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .nats_client import NatsClient
|
|
11
|
+
from .state_manager import StateManager
|
|
12
|
+
from .subject_builder import build_subject
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class StateUpdater:
|
|
16
|
+
"""Subscribe to CyTube events and update state KV stores.
|
|
17
|
+
|
|
18
|
+
Listens for events like playlist, userlist, emotes, queue, delete, etc.
|
|
19
|
+
and calls appropriate StateManager methods to keep KV stores synchronized.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
nats_client: NATS client for subscriptions.
|
|
23
|
+
state_manager: StateManager to update.
|
|
24
|
+
channel: CyTube channel name.
|
|
25
|
+
domain: CyTube domain.
|
|
26
|
+
logger: Logger instance.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
>>> updater = StateUpdater(nats_client, state_manager, "mychannel", "cytu.be", logger)
|
|
30
|
+
>>> await updater.start()
|
|
31
|
+
>>> # Receives events and updates state automatically
|
|
32
|
+
>>> await updater.stop()
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
nats_client: NatsClient,
|
|
38
|
+
state_manager: StateManager,
|
|
39
|
+
channel: str,
|
|
40
|
+
domain: str,
|
|
41
|
+
logger: logging.Logger,
|
|
42
|
+
):
|
|
43
|
+
"""Initialize state updater.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
nats_client: NATS client instance.
|
|
47
|
+
state_manager: StateManager to update.
|
|
48
|
+
channel: CyTube channel name.
|
|
49
|
+
domain: CyTube domain.
|
|
50
|
+
logger: Logger for structured output.
|
|
51
|
+
"""
|
|
52
|
+
self._nats = nats_client
|
|
53
|
+
self._state = state_manager
|
|
54
|
+
self._channel = channel
|
|
55
|
+
self._domain = domain
|
|
56
|
+
self._logger = logger
|
|
57
|
+
self._running = False
|
|
58
|
+
self._subscriptions: list[Any] = []
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def is_running(self) -> bool:
|
|
62
|
+
"""Check if updater is running.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
True if started and processing events, False otherwise.
|
|
66
|
+
"""
|
|
67
|
+
return self._running
|
|
68
|
+
|
|
69
|
+
async def start(self) -> None:
|
|
70
|
+
"""Start state updater and subscribe to events.
|
|
71
|
+
|
|
72
|
+
Subscribes to relevant CyTube events for the configured channel/domain.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
RuntimeError: If NATS is not connected or StateManager not started.
|
|
76
|
+
"""
|
|
77
|
+
if self._running:
|
|
78
|
+
self._logger.debug("State updater already running")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if not self._nats.is_connected:
|
|
82
|
+
raise RuntimeError("NATS client not connected")
|
|
83
|
+
|
|
84
|
+
if not self._state.is_running:
|
|
85
|
+
raise RuntimeError("StateManager not started")
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
self._logger.info(f"Starting state updater for {self._domain}/{self._channel}")
|
|
89
|
+
|
|
90
|
+
# Subscribe to playlist events
|
|
91
|
+
subject = build_subject(self._domain, self._channel, "playlist")
|
|
92
|
+
sub = await self._nats._nc.subscribe(subject, cb=self._handle_playlist)
|
|
93
|
+
self._subscriptions.append(sub)
|
|
94
|
+
self._logger.debug(f"Subscribed to {subject}")
|
|
95
|
+
|
|
96
|
+
subject = build_subject(self._domain, self._channel, "queue")
|
|
97
|
+
sub = await self._nats._nc.subscribe(subject, cb=self._handle_queue)
|
|
98
|
+
self._subscriptions.append(sub)
|
|
99
|
+
self._logger.debug(f"Subscribed to {subject}")
|
|
100
|
+
|
|
101
|
+
subject = build_subject(self._domain, self._channel, "delete")
|
|
102
|
+
sub = await self._nats._nc.subscribe(subject, cb=self._handle_delete)
|
|
103
|
+
self._subscriptions.append(sub)
|
|
104
|
+
self._logger.debug(f"Subscribed to {subject}")
|
|
105
|
+
|
|
106
|
+
subject = build_subject(self._domain, self._channel, "moveMedia")
|
|
107
|
+
sub = await self._nats._nc.subscribe(subject, cb=self._handle_move_media)
|
|
108
|
+
self._subscriptions.append(sub)
|
|
109
|
+
self._logger.debug(f"Subscribed to {subject}")
|
|
110
|
+
|
|
111
|
+
# Subscribe to userlist events
|
|
112
|
+
subject = build_subject(self._domain, self._channel, "userlist")
|
|
113
|
+
sub = await self._nats._nc.subscribe(subject, cb=self._handle_userlist)
|
|
114
|
+
self._subscriptions.append(sub)
|
|
115
|
+
self._logger.debug(f"Subscribed to {subject}")
|
|
116
|
+
|
|
117
|
+
subject = build_subject(self._domain, self._channel, "addUser")
|
|
118
|
+
sub = await self._nats._nc.subscribe(subject, cb=self._handle_add_user)
|
|
119
|
+
self._subscriptions.append(sub)
|
|
120
|
+
self._logger.debug(f"Subscribed to {subject}")
|
|
121
|
+
|
|
122
|
+
subject = build_subject(self._domain, self._channel, "userLeave")
|
|
123
|
+
sub = await self._nats._nc.subscribe(subject, cb=self._handle_user_leave)
|
|
124
|
+
self._subscriptions.append(sub)
|
|
125
|
+
self._logger.debug(f"Subscribed to {subject}")
|
|
126
|
+
|
|
127
|
+
# Subscribe to emotes event
|
|
128
|
+
subject = build_subject(self._domain, self._channel, "emoteList")
|
|
129
|
+
sub = await self._nats._nc.subscribe(subject, cb=self._handle_emote_list)
|
|
130
|
+
self._subscriptions.append(sub)
|
|
131
|
+
self._logger.debug(f"Subscribed to {subject}")
|
|
132
|
+
|
|
133
|
+
self._running = True
|
|
134
|
+
self._logger.info("State updater started successfully")
|
|
135
|
+
|
|
136
|
+
except Exception as e:
|
|
137
|
+
self._logger.error(f"Failed to start state updater: {e}", exc_info=True)
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
async def stop(self) -> None:
|
|
141
|
+
"""Stop state updater and unsubscribe from events.
|
|
142
|
+
|
|
143
|
+
Gracefully unsubscribes from all event subscriptions.
|
|
144
|
+
Safe to call multiple times.
|
|
145
|
+
"""
|
|
146
|
+
if not self._running:
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
self._logger.info("Stopping state updater")
|
|
151
|
+
|
|
152
|
+
# Unsubscribe from all subscriptions
|
|
153
|
+
for sub in self._subscriptions:
|
|
154
|
+
try:
|
|
155
|
+
await sub.unsubscribe()
|
|
156
|
+
except Exception as e:
|
|
157
|
+
self._logger.warning(f"Error unsubscribing: {e}")
|
|
158
|
+
|
|
159
|
+
self._subscriptions.clear()
|
|
160
|
+
self._running = False
|
|
161
|
+
|
|
162
|
+
self._logger.info("State updater stopped")
|
|
163
|
+
|
|
164
|
+
except Exception as e:
|
|
165
|
+
self._logger.error(f"Error stopping state updater: {e}", exc_info=True)
|
|
166
|
+
|
|
167
|
+
# ========================================================================
|
|
168
|
+
# Event Handlers
|
|
169
|
+
# ========================================================================
|
|
170
|
+
|
|
171
|
+
async def _handle_playlist(self, msg) -> None:
|
|
172
|
+
"""Handle 'playlist' event (initial playlist load).
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
msg: NATS message with playlist data.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
import json
|
|
179
|
+
data = json.loads(msg.data.decode())
|
|
180
|
+
|
|
181
|
+
# Extract playlist items from event payload
|
|
182
|
+
playlist = data.get("payload", [])
|
|
183
|
+
|
|
184
|
+
await self._state.set_playlist(playlist)
|
|
185
|
+
|
|
186
|
+
except Exception as e:
|
|
187
|
+
self._logger.error(f"Error handling playlist event: {e}", exc_info=True)
|
|
188
|
+
|
|
189
|
+
async def _handle_queue(self, msg) -> None:
|
|
190
|
+
"""Handle 'queue' event (video added to playlist).
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
msg: NATS message with queue data.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
import json
|
|
197
|
+
data = json.loads(msg.data.decode())
|
|
198
|
+
payload = data.get("payload", {})
|
|
199
|
+
|
|
200
|
+
item = payload.get("item", {})
|
|
201
|
+
after = payload.get("after")
|
|
202
|
+
|
|
203
|
+
await self._state.add_playlist_item(item, after)
|
|
204
|
+
|
|
205
|
+
except Exception as e:
|
|
206
|
+
self._logger.error(f"Error handling queue event: {e}", exc_info=True)
|
|
207
|
+
|
|
208
|
+
async def _handle_delete(self, msg) -> None:
|
|
209
|
+
"""Handle 'delete' event (video removed from playlist).
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
msg: NATS message with delete data.
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
import json
|
|
216
|
+
data = json.loads(msg.data.decode())
|
|
217
|
+
payload = data.get("payload", {})
|
|
218
|
+
|
|
219
|
+
uid = payload.get("uid")
|
|
220
|
+
if uid:
|
|
221
|
+
await self._state.remove_playlist_item(uid)
|
|
222
|
+
|
|
223
|
+
except Exception as e:
|
|
224
|
+
self._logger.error(f"Error handling delete event: {e}", exc_info=True)
|
|
225
|
+
|
|
226
|
+
async def _handle_move_media(self, msg) -> None:
|
|
227
|
+
"""Handle 'moveMedia' event (video moved in playlist).
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
msg: NATS message with move data.
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
import json
|
|
234
|
+
data = json.loads(msg.data.decode())
|
|
235
|
+
payload = data.get("payload", {})
|
|
236
|
+
|
|
237
|
+
from_uid = payload.get("from")
|
|
238
|
+
after = payload.get("after")
|
|
239
|
+
|
|
240
|
+
if from_uid is not None and after is not None:
|
|
241
|
+
await self._state.move_playlist_item(from_uid, after)
|
|
242
|
+
|
|
243
|
+
except Exception as e:
|
|
244
|
+
self._logger.error(f"Error handling moveMedia event: {e}", exc_info=True)
|
|
245
|
+
|
|
246
|
+
async def _handle_userlist(self, msg) -> None:
|
|
247
|
+
"""Handle 'userlist' event (initial user list load).
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
msg: NATS message with userlist data.
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
import json
|
|
254
|
+
data = json.loads(msg.data.decode())
|
|
255
|
+
|
|
256
|
+
# Extract users from event payload
|
|
257
|
+
users = data.get("payload", [])
|
|
258
|
+
|
|
259
|
+
await self._state.set_userlist(users)
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
self._logger.error(f"Error handling userlist event: {e}", exc_info=True)
|
|
263
|
+
|
|
264
|
+
async def _handle_add_user(self, msg) -> None:
|
|
265
|
+
"""Handle 'addUser' event (user joined channel).
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
msg: NATS message with user data.
|
|
269
|
+
"""
|
|
270
|
+
try:
|
|
271
|
+
import json
|
|
272
|
+
data = json.loads(msg.data.decode())
|
|
273
|
+
payload = data.get("payload", {})
|
|
274
|
+
|
|
275
|
+
await self._state.add_user(payload)
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
self._logger.error(f"Error handling addUser event: {e}", exc_info=True)
|
|
279
|
+
|
|
280
|
+
async def _handle_user_leave(self, msg) -> None:
|
|
281
|
+
"""Handle 'userLeave' event (user left channel).
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
msg: NATS message with username data.
|
|
285
|
+
"""
|
|
286
|
+
try:
|
|
287
|
+
import json
|
|
288
|
+
data = json.loads(msg.data.decode())
|
|
289
|
+
payload = data.get("payload", {})
|
|
290
|
+
|
|
291
|
+
username = payload.get("name")
|
|
292
|
+
if username:
|
|
293
|
+
await self._state.remove_user(username)
|
|
294
|
+
|
|
295
|
+
except Exception as e:
|
|
296
|
+
self._logger.error(f"Error handling userLeave event: {e}", exc_info=True)
|
|
297
|
+
|
|
298
|
+
async def _handle_emote_list(self, msg) -> None:
|
|
299
|
+
"""Handle 'emoteList' event (channel emotes loaded).
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
msg: NATS message with emote list data.
|
|
303
|
+
"""
|
|
304
|
+
try:
|
|
305
|
+
import json
|
|
306
|
+
data = json.loads(msg.data.decode())
|
|
307
|
+
|
|
308
|
+
# Extract emotes from event payload
|
|
309
|
+
emotes = data.get("payload", [])
|
|
310
|
+
|
|
311
|
+
await self._state.update_emotes(emotes)
|
|
312
|
+
|
|
313
|
+
except Exception as e:
|
|
314
|
+
self._logger.error(f"Error handling emoteList event: {e}", exc_info=True)
|
kryten/stats_tracker.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Statistics Tracker - Track rates and statistics over time windows.
|
|
2
|
+
|
|
3
|
+
This module provides the StatsTracker class for monitoring event rates and
|
|
4
|
+
maintaining time-windowed statistics for system management and monitoring.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StatsTracker:
|
|
12
|
+
"""Track rates and statistics over time windows.
|
|
13
|
+
|
|
14
|
+
Maintains a rolling window of events with timestamps and types,
|
|
15
|
+
enabling rate calculation over various time periods and access to
|
|
16
|
+
the most recent event information.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
window_size: Maximum number of events to track (default 300 for 5 min at 1/sec).
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
>>> tracker = StatsTracker()
|
|
23
|
+
>>> tracker.record("chatMsg")
|
|
24
|
+
>>> tracker.record("userJoin")
|
|
25
|
+
>>> print(f"Rate: {tracker.get_rate(60):.2f}/sec")
|
|
26
|
+
>>> last_time, last_type = tracker.get_last()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, window_size: int = 300):
|
|
30
|
+
"""Initialize statistics tracker.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
window_size: Maximum events to track (default 300 = 5 min at 1/sec).
|
|
34
|
+
"""
|
|
35
|
+
self._events: deque[tuple[float, str | None]] = deque(maxlen=window_size)
|
|
36
|
+
self._start_time = time.time()
|
|
37
|
+
|
|
38
|
+
def record(self, event_type: str | None = None) -> None:
|
|
39
|
+
"""Record an event occurrence.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
event_type: Optional event type/name for tracking.
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
>>> tracker.record("chatMsg")
|
|
46
|
+
>>> tracker.record() # Anonymous event
|
|
47
|
+
"""
|
|
48
|
+
self._events.append((time.time(), event_type))
|
|
49
|
+
|
|
50
|
+
def get_rate(self, window_seconds: int) -> float:
|
|
51
|
+
"""Get events per second over specified time window.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
window_seconds: Time window in seconds to calculate rate over.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Events per second over the window, or 0.0 if window is invalid.
|
|
58
|
+
|
|
59
|
+
Examples:
|
|
60
|
+
>>> rate_1min = tracker.get_rate(60)
|
|
61
|
+
>>> rate_5min = tracker.get_rate(300)
|
|
62
|
+
"""
|
|
63
|
+
if window_seconds <= 0:
|
|
64
|
+
return 0.0
|
|
65
|
+
|
|
66
|
+
cutoff = time.time() - window_seconds
|
|
67
|
+
count = sum(1 for t, _ in self._events if t > cutoff)
|
|
68
|
+
return count / window_seconds
|
|
69
|
+
|
|
70
|
+
def get_last(self) -> tuple[float | None, str | None]:
|
|
71
|
+
"""Get last event time and type.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Tuple of (timestamp, event_type) for most recent event,
|
|
75
|
+
or (None, None) if no events recorded.
|
|
76
|
+
|
|
77
|
+
Examples:
|
|
78
|
+
>>> timestamp, event_type = tracker.get_last()
|
|
79
|
+
>>> if timestamp:
|
|
80
|
+
... print(f"Last: {event_type} at {timestamp}")
|
|
81
|
+
"""
|
|
82
|
+
if self._events:
|
|
83
|
+
return self._events[-1]
|
|
84
|
+
return (None, None)
|
|
85
|
+
|
|
86
|
+
def get_total(self) -> int:
|
|
87
|
+
"""Get total events recorded (within window).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Number of events currently in the tracking window.
|
|
91
|
+
|
|
92
|
+
Examples:
|
|
93
|
+
>>> total = tracker.get_total()
|
|
94
|
+
>>> print(f"Tracked events: {total}")
|
|
95
|
+
"""
|
|
96
|
+
return len(self._events)
|
|
97
|
+
|
|
98
|
+
def get_uptime(self) -> float:
|
|
99
|
+
"""Get tracker uptime in seconds.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Seconds since tracker was created.
|
|
103
|
+
|
|
104
|
+
Examples:
|
|
105
|
+
>>> uptime = tracker.get_uptime()
|
|
106
|
+
>>> print(f"Uptime: {uptime / 3600:.1f} hours")
|
|
107
|
+
"""
|
|
108
|
+
return time.time() - self._start_time
|