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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cytube": {
|
|
3
|
+
"domain": "cytu.be",
|
|
4
|
+
"channel": "your-channel-name",
|
|
5
|
+
"channel_password": null,
|
|
6
|
+
"user": "YourBotName",
|
|
7
|
+
"password": null
|
|
8
|
+
},
|
|
9
|
+
"nats": {
|
|
10
|
+
"servers": ["nats://localhost:4222"],
|
|
11
|
+
"user": null,
|
|
12
|
+
"password": null,
|
|
13
|
+
"connect_timeout": 10,
|
|
14
|
+
"reconnect_time_wait": 2,
|
|
15
|
+
"max_reconnect_attempts": 10,
|
|
16
|
+
"allow_reconnect": true
|
|
17
|
+
},
|
|
18
|
+
"health": {
|
|
19
|
+
"enabled": true,
|
|
20
|
+
"host": "0.0.0.0",
|
|
21
|
+
"port": 8080
|
|
22
|
+
},
|
|
23
|
+
"commands": {
|
|
24
|
+
"enabled": false
|
|
25
|
+
},
|
|
26
|
+
"logging": {
|
|
27
|
+
"base_path": "./logs",
|
|
28
|
+
"_comment": "For systemd service, use: /opt/kryten/logs",
|
|
29
|
+
"admin_operations": "admin-operations.log",
|
|
30
|
+
"playlist_operations": "playlist-operations.log",
|
|
31
|
+
"chat_messages": "chat-messages.log",
|
|
32
|
+
"command_audit": "command-audit.log"
|
|
33
|
+
},
|
|
34
|
+
"log_level": "INFO"
|
|
35
|
+
}
|
kryten/config.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""Dataclass-Based Configuration Loader for Kryten.
|
|
2
|
+
|
|
3
|
+
This module provides strongly typed configuration objects for CyTube and NATS
|
|
4
|
+
settings loaded from JSON, with environment variable override support.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# Valid log levels (mirrors Python logging module)
|
|
13
|
+
VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
|
14
|
+
|
|
15
|
+
# Environment variable names for overrides
|
|
16
|
+
ENV_CYTUBE_USER = "KRYTEN_CYTUBE_USER"
|
|
17
|
+
ENV_CYTUBE_PASSWORD = "KRYTEN_CYTUBE_PASSWORD"
|
|
18
|
+
ENV_NATS_URL = "KRYTEN_NATS_URL"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class CytubeConfig:
|
|
23
|
+
"""Configuration for CyTube connection.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
domain: CyTube server domain (e.g., "cytu.be").
|
|
27
|
+
channel: Channel name to join.
|
|
28
|
+
channel_password: Optional password for password-protected channels.
|
|
29
|
+
user: Optional username for authentication.
|
|
30
|
+
password: Optional password for user authentication.
|
|
31
|
+
aggressive_reconnect: If True, attempt to reconnect when kicked instead
|
|
32
|
+
of shutting down. Default: False.
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
>>> cfg = CytubeConfig(domain="cytu.be", channel="test")
|
|
36
|
+
>>> print(cfg.domain)
|
|
37
|
+
cytu.be
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
domain: str
|
|
41
|
+
channel: str
|
|
42
|
+
channel_password: str | None = None
|
|
43
|
+
user: str | None = None
|
|
44
|
+
password: str | None = None
|
|
45
|
+
aggressive_reconnect: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class NatsConfig:
|
|
50
|
+
"""Configuration for NATS connection.
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
servers: List of NATS server URLs (e.g., ["nats://localhost:4222"]).
|
|
54
|
+
user: Optional username for authentication.
|
|
55
|
+
password: Optional password for authentication.
|
|
56
|
+
connect_timeout: Connection timeout in seconds. Default: 10.
|
|
57
|
+
reconnect_time_wait: Wait time between reconnects in seconds. Default: 2.
|
|
58
|
+
max_reconnect_attempts: Maximum reconnection attempts. Default: 10.
|
|
59
|
+
allow_reconnect: Enable automatic reconnection. Default: True.
|
|
60
|
+
|
|
61
|
+
Examples:
|
|
62
|
+
>>> cfg = NatsConfig(servers=["nats://localhost:4222"])
|
|
63
|
+
>>> print(cfg.connect_timeout)
|
|
64
|
+
10
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
servers: list[str]
|
|
68
|
+
user: str | None = None
|
|
69
|
+
password: str | None = None
|
|
70
|
+
connect_timeout: int = 10
|
|
71
|
+
reconnect_time_wait: int = 2
|
|
72
|
+
max_reconnect_attempts: int = 10
|
|
73
|
+
allow_reconnect: bool = True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class CommandsConfig:
|
|
78
|
+
"""Configuration for bidirectional command execution.
|
|
79
|
+
|
|
80
|
+
Attributes:
|
|
81
|
+
enabled: Whether command subscriptions are enabled. Default: False.
|
|
82
|
+
|
|
83
|
+
Examples:
|
|
84
|
+
>>> cfg = CommandsConfig(enabled=True)
|
|
85
|
+
>>> print(cfg.enabled)
|
|
86
|
+
True
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
enabled: bool = False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class HealthConfig:
|
|
94
|
+
"""Configuration for health monitoring endpoint.
|
|
95
|
+
|
|
96
|
+
Attributes:
|
|
97
|
+
enabled: Whether health endpoint is enabled. Default: True.
|
|
98
|
+
host: Host to bind health server. Default: "0.0.0.0".
|
|
99
|
+
port: Port for health server. Default: 8080.
|
|
100
|
+
|
|
101
|
+
Examples:
|
|
102
|
+
>>> cfg = HealthConfig(enabled=True, port=8080)
|
|
103
|
+
>>> print(cfg.port)
|
|
104
|
+
8080
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
enabled: bool = True
|
|
108
|
+
host: str = "0.0.0.0"
|
|
109
|
+
port: int = 8080
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class LoggingConfig:
|
|
114
|
+
"""Configuration for specialized logging.
|
|
115
|
+
|
|
116
|
+
Attributes:
|
|
117
|
+
base_path: Base directory for all log files. Default: "./logs".
|
|
118
|
+
admin_operations: Filename for admin operations log. Default: "admin-operations.log".
|
|
119
|
+
playlist_operations: Filename for playlist operations log. Default: "playlist-operations.log".
|
|
120
|
+
chat_messages: Filename for chat message log. Default: "chat-messages.log".
|
|
121
|
+
command_audit: Filename for command audit log. Default: "command-audit.log".
|
|
122
|
+
|
|
123
|
+
Examples:
|
|
124
|
+
>>> cfg = LoggingConfig(base_path="/var/log/kryten")
|
|
125
|
+
>>> print(cfg.admin_operations)
|
|
126
|
+
admin-operations.log
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
base_path: str = "./logs"
|
|
130
|
+
admin_operations: str = "admin-operations.log"
|
|
131
|
+
playlist_operations: str = "playlist-operations.log"
|
|
132
|
+
chat_messages: str = "chat-messages.log"
|
|
133
|
+
command_audit: str = "command-audit.log"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class StateCountingConfig:
|
|
138
|
+
"""Configuration for state counting and filtering.
|
|
139
|
+
|
|
140
|
+
Attributes:
|
|
141
|
+
users_exclude_afk: Exclude AFK users from count. Default: False.
|
|
142
|
+
users_min_rank: Minimum rank to include in count (0=all). Default: 0.
|
|
143
|
+
playlist_exclude_temp: Exclude temporary items from count. Default: False.
|
|
144
|
+
playlist_max_duration: Maximum duration (seconds) to include (0=no limit). Default: 0.
|
|
145
|
+
emotes_only_enabled: Only count enabled emotes. Default: False.
|
|
146
|
+
|
|
147
|
+
Examples:
|
|
148
|
+
>>> cfg = StateCountingConfig(users_exclude_afk=True, users_min_rank=1)
|
|
149
|
+
>>> print(cfg.users_exclude_afk)
|
|
150
|
+
True
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
users_exclude_afk: bool = False
|
|
154
|
+
users_min_rank: int = 0
|
|
155
|
+
playlist_exclude_temp: bool = False
|
|
156
|
+
playlist_max_duration: int = 0
|
|
157
|
+
emotes_only_enabled: bool = False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class KrytenConfig:
|
|
162
|
+
"""Top-level configuration for Kryten connector.
|
|
163
|
+
|
|
164
|
+
Attributes:
|
|
165
|
+
cytube: CyTube connection configuration.
|
|
166
|
+
nats: NATS connection configuration.
|
|
167
|
+
commands: Command subscription configuration.
|
|
168
|
+
health: Health monitoring configuration.
|
|
169
|
+
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
|
|
170
|
+
Default: "INFO".
|
|
171
|
+
logging: Specialized logging configuration.
|
|
172
|
+
state_counting: State counting and filtering configuration.
|
|
173
|
+
|
|
174
|
+
Examples:
|
|
175
|
+
>>> cytube = CytubeConfig(domain="cytu.be", channel="test")
|
|
176
|
+
>>> nats = NatsConfig(url="nats://localhost:4222")
|
|
177
|
+
>>> cfg = KrytenConfig(cytube=cytube, nats=nats)
|
|
178
|
+
>>> print(cfg.log_level)
|
|
179
|
+
INFO
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
cytube: CytubeConfig
|
|
183
|
+
nats: NatsConfig
|
|
184
|
+
commands: CommandsConfig = field(default_factory=CommandsConfig)
|
|
185
|
+
health: HealthConfig = field(default_factory=HealthConfig)
|
|
186
|
+
log_level: str = "INFO"
|
|
187
|
+
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
|
188
|
+
state_counting: StateCountingConfig = field(default_factory=StateCountingConfig)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _normalize_string(value: str) -> str:
|
|
192
|
+
"""Normalize string by stripping whitespace.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
value: String to normalize.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Normalized string with leading/trailing whitespace removed.
|
|
199
|
+
"""
|
|
200
|
+
return value.strip()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _validate_log_level(level: str) -> None:
|
|
204
|
+
"""Validate log level is one of the accepted values.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
level: Log level to validate.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
ValueError: If log level is not valid.
|
|
211
|
+
"""
|
|
212
|
+
if level not in VALID_LOG_LEVELS:
|
|
213
|
+
valid_str = ", ".join(sorted(VALID_LOG_LEVELS))
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"Invalid log_level: '{level}'. Expected one of: {valid_str}"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _load_cytube_config(data: dict) -> CytubeConfig:
|
|
220
|
+
"""Load and validate CyTube configuration from dict.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
data: Dictionary containing cytube configuration.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Validated CytubeConfig instance.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
ValueError: If required fields are missing or invalid.
|
|
230
|
+
"""
|
|
231
|
+
if "cytube" not in data:
|
|
232
|
+
raise ValueError("Missing required section: cytube")
|
|
233
|
+
|
|
234
|
+
cytube_data = data["cytube"]
|
|
235
|
+
if not isinstance(cytube_data, dict):
|
|
236
|
+
raise ValueError("Section 'cytube' must be a dictionary")
|
|
237
|
+
|
|
238
|
+
# Check required fields
|
|
239
|
+
required_fields = ["domain", "channel"]
|
|
240
|
+
for required_field in required_fields:
|
|
241
|
+
if required_field not in cytube_data:
|
|
242
|
+
raise ValueError(f"Missing required field: cytube.{required_field}")
|
|
243
|
+
|
|
244
|
+
# Extract and normalize required fields
|
|
245
|
+
domain = _normalize_string(str(cytube_data["domain"]))
|
|
246
|
+
channel = _normalize_string(str(cytube_data["channel"]))
|
|
247
|
+
|
|
248
|
+
# Extract optional fields with environment overrides
|
|
249
|
+
channel_password = cytube_data.get("channel_password")
|
|
250
|
+
if channel_password is not None:
|
|
251
|
+
channel_password = _normalize_string(str(channel_password))
|
|
252
|
+
|
|
253
|
+
user = cytube_data.get("user")
|
|
254
|
+
if user is not None:
|
|
255
|
+
user = _normalize_string(str(user))
|
|
256
|
+
# Environment override for user
|
|
257
|
+
if ENV_CYTUBE_USER in os.environ:
|
|
258
|
+
user = os.environ[ENV_CYTUBE_USER]
|
|
259
|
+
|
|
260
|
+
password = cytube_data.get("password")
|
|
261
|
+
if password is not None:
|
|
262
|
+
password = _normalize_string(str(password))
|
|
263
|
+
# Environment override for password
|
|
264
|
+
if ENV_CYTUBE_PASSWORD in os.environ:
|
|
265
|
+
password = os.environ[ENV_CYTUBE_PASSWORD]
|
|
266
|
+
|
|
267
|
+
# Extract aggressive_reconnect option (default: False)
|
|
268
|
+
aggressive_reconnect = bool(cytube_data.get("aggressive_reconnect", False))
|
|
269
|
+
|
|
270
|
+
return CytubeConfig(
|
|
271
|
+
domain=domain,
|
|
272
|
+
channel=channel,
|
|
273
|
+
channel_password=channel_password,
|
|
274
|
+
user=user,
|
|
275
|
+
password=password,
|
|
276
|
+
aggressive_reconnect=aggressive_reconnect,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _load_nats_config(data: dict) -> NatsConfig:
|
|
281
|
+
"""Load and validate NATS configuration from dict.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
data: Dictionary containing nats configuration.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Validated NatsConfig instance.
|
|
288
|
+
|
|
289
|
+
Raises:
|
|
290
|
+
ValueError: If required fields are missing or invalid.
|
|
291
|
+
"""
|
|
292
|
+
if "nats" not in data:
|
|
293
|
+
raise ValueError("Missing required section: nats")
|
|
294
|
+
|
|
295
|
+
nats_data = data["nats"]
|
|
296
|
+
if not isinstance(nats_data, dict):
|
|
297
|
+
raise ValueError("Section 'nats' must be a dictionary")
|
|
298
|
+
|
|
299
|
+
# Check required fields - support both 'servers' and legacy 'url'
|
|
300
|
+
servers = []
|
|
301
|
+
if "servers" in nats_data:
|
|
302
|
+
servers_value = nats_data["servers"]
|
|
303
|
+
if isinstance(servers_value, list):
|
|
304
|
+
servers = [_normalize_string(str(s)) for s in servers_value]
|
|
305
|
+
elif isinstance(servers_value, str):
|
|
306
|
+
servers = [_normalize_string(servers_value)]
|
|
307
|
+
else:
|
|
308
|
+
raise ValueError("Field 'nats.servers' must be a string or list of strings")
|
|
309
|
+
elif "url" in nats_data:
|
|
310
|
+
# Legacy support for single 'url' field
|
|
311
|
+
servers = [_normalize_string(str(nats_data["url"]))]
|
|
312
|
+
else:
|
|
313
|
+
raise ValueError("Missing required field: nats.servers (or nats.url)")
|
|
314
|
+
|
|
315
|
+
if not servers:
|
|
316
|
+
raise ValueError("Field 'nats.servers' cannot be empty")
|
|
317
|
+
|
|
318
|
+
# Environment override for servers (first server only for backward compatibility)
|
|
319
|
+
if ENV_NATS_URL in os.environ:
|
|
320
|
+
servers = [os.environ[ENV_NATS_URL]]
|
|
321
|
+
|
|
322
|
+
# Extract optional fields
|
|
323
|
+
user = nats_data.get("user")
|
|
324
|
+
if user is not None:
|
|
325
|
+
user = _normalize_string(str(user))
|
|
326
|
+
|
|
327
|
+
password = nats_data.get("password")
|
|
328
|
+
if password is not None:
|
|
329
|
+
password = _normalize_string(str(password))
|
|
330
|
+
|
|
331
|
+
connect_timeout = int(nats_data.get("connect_timeout", 10))
|
|
332
|
+
reconnect_time_wait = int(nats_data.get("reconnect_time_wait", 2))
|
|
333
|
+
max_reconnect_attempts = int(nats_data.get("max_reconnect_attempts", 10))
|
|
334
|
+
allow_reconnect = bool(nats_data.get("allow_reconnect", True))
|
|
335
|
+
|
|
336
|
+
return NatsConfig(
|
|
337
|
+
servers=servers,
|
|
338
|
+
user=user,
|
|
339
|
+
password=password,
|
|
340
|
+
connect_timeout=connect_timeout,
|
|
341
|
+
reconnect_time_wait=reconnect_time_wait,
|
|
342
|
+
max_reconnect_attempts=max_reconnect_attempts,
|
|
343
|
+
allow_reconnect=allow_reconnect,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _load_commands_config(data: dict) -> CommandsConfig:
|
|
348
|
+
"""Load and validate commands configuration from dict.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
data: Dictionary containing commands configuration.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Validated CommandsConfig instance.
|
|
355
|
+
|
|
356
|
+
Raises:
|
|
357
|
+
ValueError: If configuration is invalid.
|
|
358
|
+
"""
|
|
359
|
+
if "commands" not in data:
|
|
360
|
+
# Commands section is optional, return defaults
|
|
361
|
+
return CommandsConfig()
|
|
362
|
+
|
|
363
|
+
commands_data = data["commands"]
|
|
364
|
+
if not isinstance(commands_data, dict):
|
|
365
|
+
raise ValueError("Section 'commands' must be a dictionary")
|
|
366
|
+
|
|
367
|
+
enabled = bool(commands_data.get("enabled", False))
|
|
368
|
+
|
|
369
|
+
return CommandsConfig(enabled=enabled)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _load_health_config(data: dict) -> HealthConfig:
|
|
373
|
+
"""Load and validate health configuration from dict.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
data: Dictionary containing health configuration.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Validated HealthConfig instance.
|
|
380
|
+
|
|
381
|
+
Raises:
|
|
382
|
+
ValueError: If configuration is invalid.
|
|
383
|
+
"""
|
|
384
|
+
if "health" not in data:
|
|
385
|
+
# Health section is optional, return defaults
|
|
386
|
+
return HealthConfig()
|
|
387
|
+
|
|
388
|
+
health_data = data["health"]
|
|
389
|
+
if not isinstance(health_data, dict):
|
|
390
|
+
raise ValueError("Section 'health' must be a dictionary")
|
|
391
|
+
|
|
392
|
+
enabled = bool(health_data.get("enabled", True))
|
|
393
|
+
host = str(health_data.get("host", "0.0.0.0"))
|
|
394
|
+
port = int(health_data.get("port", 8080))
|
|
395
|
+
|
|
396
|
+
return HealthConfig(enabled=enabled, host=host, port=port)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def load_config(path: Path | str) -> KrytenConfig:
|
|
400
|
+
"""Load and validate configuration from JSON file.
|
|
401
|
+
|
|
402
|
+
Reads JSON configuration file, validates structure and types, applies
|
|
403
|
+
environment variable overrides, and returns strongly-typed configuration
|
|
404
|
+
object.
|
|
405
|
+
|
|
406
|
+
Environment Variables:
|
|
407
|
+
KRYTEN_CYTUBE_USER: Override cytube.user
|
|
408
|
+
KRYTEN_CYTUBE_PASSWORD: Override cytube.password
|
|
409
|
+
KRYTEN_NATS_URL: Override nats.url
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
path: Path to JSON configuration file (string or Path object).
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
Validated KrytenConfig instance with normalized values.
|
|
416
|
+
|
|
417
|
+
Raises:
|
|
418
|
+
FileNotFoundError: If configuration file does not exist.
|
|
419
|
+
ValueError: If configuration is invalid or missing required fields.
|
|
420
|
+
json.JSONDecodeError: If file contains malformed JSON.
|
|
421
|
+
|
|
422
|
+
Examples:
|
|
423
|
+
>>> cfg = load_config('config.json')
|
|
424
|
+
>>> print(cfg.cytube.domain)
|
|
425
|
+
cytu.be
|
|
426
|
+
|
|
427
|
+
>>> # With environment override
|
|
428
|
+
>>> os.environ['KRYTEN_CYTUBE_PASSWORD'] = 'secret'
|
|
429
|
+
>>> cfg = load_config('config.json')
|
|
430
|
+
>>> print(cfg.cytube.password)
|
|
431
|
+
secret
|
|
432
|
+
"""
|
|
433
|
+
# Convert to Path if string
|
|
434
|
+
config_path = Path(path) if isinstance(path, str) else path
|
|
435
|
+
|
|
436
|
+
# Check file exists
|
|
437
|
+
if not config_path.exists():
|
|
438
|
+
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
|
439
|
+
|
|
440
|
+
# Load JSON
|
|
441
|
+
try:
|
|
442
|
+
with config_path.open("r", encoding="utf-8") as f:
|
|
443
|
+
data = json.load(f)
|
|
444
|
+
except json.JSONDecodeError as e:
|
|
445
|
+
raise ValueError(f"Invalid JSON in configuration file: {e}") from e
|
|
446
|
+
|
|
447
|
+
if not isinstance(data, dict):
|
|
448
|
+
raise ValueError("Configuration file must contain a JSON object")
|
|
449
|
+
|
|
450
|
+
# Load subsections
|
|
451
|
+
cytube = _load_cytube_config(data)
|
|
452
|
+
nats = _load_nats_config(data)
|
|
453
|
+
commands = _load_commands_config(data)
|
|
454
|
+
health = _load_health_config(data)
|
|
455
|
+
|
|
456
|
+
# Load log level with validation
|
|
457
|
+
log_level = data.get("log_level", "INFO")
|
|
458
|
+
if not isinstance(log_level, str):
|
|
459
|
+
raise ValueError("Field 'log_level' must be a string")
|
|
460
|
+
|
|
461
|
+
log_level = log_level.upper() # Normalize to uppercase
|
|
462
|
+
_validate_log_level(log_level)
|
|
463
|
+
|
|
464
|
+
# Load logging configuration
|
|
465
|
+
logging_config = LoggingConfig()
|
|
466
|
+
if "logging" in data:
|
|
467
|
+
logging_data = data["logging"]
|
|
468
|
+
if isinstance(logging_data, dict):
|
|
469
|
+
logging_config = LoggingConfig(
|
|
470
|
+
base_path=logging_data.get("base_path", "./logs"),
|
|
471
|
+
admin_operations=logging_data.get("admin_operations", "admin-operations.log"),
|
|
472
|
+
playlist_operations=logging_data.get("playlist_operations", "playlist-operations.log"),
|
|
473
|
+
chat_messages=logging_data.get("chat_messages", "chat-messages.log"),
|
|
474
|
+
command_audit=logging_data.get("command_audit", "command-audit.log"),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Load state counting configuration
|
|
478
|
+
state_counting_config = StateCountingConfig()
|
|
479
|
+
if "state_counting" in data:
|
|
480
|
+
state_data = data["state_counting"]
|
|
481
|
+
if isinstance(state_data, dict):
|
|
482
|
+
state_counting_config = StateCountingConfig(
|
|
483
|
+
users_exclude_afk=state_data.get("users_exclude_afk", False),
|
|
484
|
+
users_min_rank=state_data.get("users_min_rank", 0),
|
|
485
|
+
playlist_exclude_temp=state_data.get("playlist_exclude_temp", False),
|
|
486
|
+
playlist_max_duration=state_data.get("playlist_max_duration", 0),
|
|
487
|
+
emotes_only_enabled=state_data.get("emotes_only_enabled", False),
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
return KrytenConfig(
|
|
491
|
+
cytube=cytube,
|
|
492
|
+
nats=nats,
|
|
493
|
+
commands=commands,
|
|
494
|
+
health=health,
|
|
495
|
+
log_level=log_level,
|
|
496
|
+
logging=logging_config,
|
|
497
|
+
state_counting=state_counting_config,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
__all__ = [
|
|
502
|
+
"CytubeConfig",
|
|
503
|
+
"NatsConfig",
|
|
504
|
+
"CommandsConfig",
|
|
505
|
+
"HealthConfig",
|
|
506
|
+
"LoggingConfig",
|
|
507
|
+
"StateCountingConfig",
|
|
508
|
+
"KrytenConfig",
|
|
509
|
+
"load_config",
|
|
510
|
+
]
|