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.
@@ -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
+ ]