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,330 @@
1
+ """NATS Subject Builder for CyTube Events.
2
+
3
+ This module provides utilities for constructing and parsing hierarchical NATS
4
+ subject strings following the format: kryten.events.cytube.{channel}.{event_name}
5
+
6
+ Subject Format
7
+ --------------
8
+ kryten.events.cytube.{channel}.{event_name}
9
+ ^ ^ ^ ^
10
+ | | | +-- Event type (e.g., chatmsg)
11
+ | | +------------ Channel name (e.g., 420grindhouse)
12
+ | +--------------------- Platform literal (always "cytube")
13
+ +----------------------------- Namespace (always "kryten")
14
+
15
+ All tokens are normalized: lowercase, dots removed, spaces to hyphens.
16
+ This ensures cy.tube, Cy.tube, cytu.be all normalize to "cytube".
17
+ Channels like "420Grindhouse" normalize to "420grindhouse".
18
+
19
+ Wildcard Subscriptions
20
+ ----------------------
21
+ NATS supports wildcard subscriptions for flexible filtering:
22
+
23
+ - Single level wildcard (*):
24
+ kryten.events.cytube.*.chatmsg # All channels, chatmsg events
25
+
26
+ - Multi-level wildcard (>):
27
+ kryten.events.cytube.420grindhouse.> # All events from 420grindhouse channel
28
+
29
+ Examples
30
+ --------
31
+ >>> from .raw_event import RawEvent
32
+ >>> from .subject_builder import build_subject, build_event_subject
33
+ >>>
34
+ >>> # Build subject from components
35
+ >>> subject = build_subject("cytu.be", "420Grindhouse", "chatMsg")
36
+ >>> print(subject)
37
+ 'kryten.events.cytube.420grindhouse.chatmsg'
38
+ >>>
39
+ >>> # Build subject from RawEvent
40
+ >>> event = RawEvent("chatMsg", {"user": "bob"}, "420Grindhouse", "cytu.be")
41
+ >>> subject = build_event_subject(event)
42
+ >>>
43
+ >>> # Parse subject back to components
44
+ >>> from .subject_builder import parse_subject
45
+ >>> components = parse_subject("kryten.events.cytube.420grindhouse.chatmsg")
46
+ >>> print(components['channel'])
47
+ '420grindhouse'
48
+ """
49
+
50
+ from .raw_event import RawEvent
51
+
52
+ SUBJECT_PREFIX = "kryten.events"
53
+ """NATS subject prefix for all CyTube events."""
54
+
55
+ COMMAND_PREFIX = "kryten.commands"
56
+ """NATS subject prefix for all CyTube commands."""
57
+
58
+ MAX_TOKEN_LENGTH = 100
59
+ """Maximum length for individual subject tokens to prevent exceeding NATS limits."""
60
+
61
+
62
+ def normalize_token(token: str) -> str:
63
+ """Normalize token for consistent NATS subject matching.
64
+
65
+ Aggressively normalizes domains and channels to ensure consistent matching:
66
+ - Converts to lowercase
67
+ - Removes ALL dots (cy.tube -> cytube, cytu.be -> cytube)
68
+ - Replaces spaces with hyphens
69
+ - Removes special characters
70
+
71
+ This ensures that variations like "cy.tube", "Cy.tube", "cytu.be" all
72
+ normalize to the same subject token, making NATS routing reliable.
73
+
74
+ Args:
75
+ token: Raw token string to normalize.
76
+
77
+ Returns:
78
+ Normalized token suitable for NATS subject.
79
+
80
+ Examples:
81
+ >>> normalize_token("cy.tube")
82
+ 'cytube'
83
+ >>> normalize_token("Cytu.be")
84
+ 'cytube'
85
+ >>> normalize_token("420Grindhouse")
86
+ '420grindhouse'
87
+ >>> normalize_token("My Channel!")
88
+ 'my-channel'
89
+ """
90
+ if not token:
91
+ return ""
92
+
93
+ # Convert to lowercase first
94
+ token = token.lower()
95
+
96
+ # Remove ALL dots (critical for domain normalization)
97
+ token = token.replace(".", "")
98
+
99
+ # Replace spaces with hyphens
100
+ token = token.replace(" ", "-")
101
+
102
+ # Remove NATS wildcard characters
103
+ token = token.replace("*", "").replace(">", "")
104
+
105
+ # Remove invalid characters for NATS subjects
106
+ # Keep: alphanumeric (ASCII + Unicode), hyphens, underscores only
107
+ # Remove: all other special chars
108
+ invalid_chars = "!@#$%^&*()+=[]{|}\\:;\"'<>,?/"
109
+ for char in invalid_chars:
110
+ token = token.replace(char, "")
111
+
112
+ # Truncate to prevent exceeding NATS subject length limit
113
+ if len(token) > MAX_TOKEN_LENGTH:
114
+ token = token[:MAX_TOKEN_LENGTH]
115
+
116
+ return token
117
+
118
+
119
+ def sanitize_token(token: str) -> str:
120
+ """Legacy alias for normalize_token. Use normalize_token instead.
121
+
122
+ Deprecated: This function exists for backward compatibility only.
123
+ Use normalize_token() for new code.
124
+ """
125
+ return normalize_token(token)
126
+
127
+
128
+ def build_subject(domain: str, channel: str, event_name: str) -> str:
129
+ """Build NATS subject from event components.
130
+
131
+ Constructs hierarchical subject following the format:
132
+ kryten.events.cytube.{channel}.{event_name}
133
+
134
+ All components are aggressively normalized (lowercase, dots removed).
135
+
136
+ Args:
137
+ domain: CyTube server domain (e.g., "cytu.be", "cy.tube").
138
+ channel: Channel name (e.g., "420Grindhouse").
139
+ event_name: Socket.IO event name (e.g., "chatMsg").
140
+
141
+ Returns:
142
+ Formatted NATS subject string.
143
+
144
+ Raises:
145
+ ValueError: If any component is empty after normalization.
146
+
147
+ Examples:
148
+ >>> build_subject("cytu.be", "lounge", "chatMsg")
149
+ 'kryten.events.cytube.lounge.chatmsg'
150
+ >>> build_subject("cy.tube", "420Grindhouse", "chatMsg")
151
+ 'kryten.events.cytube.420grindhouse.chatmsg'
152
+ >>> build_subject("CYTU.BE", "Test Channel", "userJoin")
153
+ 'kryten.events.cytube.test-channel.userjoin'
154
+ """
155
+ # Normalize all components (domain dots removed, everything lowercase)
156
+ normalize_token(domain)
157
+ channel_clean = normalize_token(channel)
158
+ event_clean = normalize_token(event_name)
159
+
160
+ # Validate components are not empty
161
+ if not channel_clean:
162
+ raise ValueError("Channel cannot be empty after normalization")
163
+ if not event_clean:
164
+ raise ValueError("Event name cannot be empty after normalization")
165
+
166
+ # Build subject with "cytube" as literal platform name (domain normalized out)
167
+ subject = f"{SUBJECT_PREFIX}.cytube.{channel_clean}.{event_clean}"
168
+
169
+ # Final validation
170
+ if len(subject) > 255:
171
+ raise ValueError(f"Subject exceeds NATS limit of 255 characters: {len(subject)}")
172
+
173
+ return subject
174
+
175
+
176
+ def build_event_subject(event: RawEvent) -> str:
177
+ """Build NATS subject from RawEvent.
178
+
179
+ Convenience function that extracts domain, channel, and event_name from
180
+ a RawEvent instance and builds the subject string.
181
+
182
+ Args:
183
+ event: RawEvent instance with domain, channel, and event_name fields.
184
+
185
+ Returns:
186
+ Formatted NATS subject string.
187
+
188
+ Examples:
189
+ >>> from kryten import RawEvent
190
+ >>> event = RawEvent(event_name="chatMsg", payload={}, channel="lounge", domain="cytu.be")
191
+ >>> build_event_subject(event)
192
+ 'cytube.events.cytu.be.lounge.chatmsg'
193
+ """
194
+ return build_subject(event.domain, event.channel, event.event_name)
195
+
196
+
197
+ def build_command_subject(domain: str, channel: str, action: str) -> str:
198
+ """Build NATS subject for commands.
199
+
200
+ Constructs hierarchical subject following the format:
201
+ kryten.commands.cytube.{channel}.{action}
202
+
203
+ All components are aggressively normalized (lowercase, dots removed).
204
+
205
+ Args:
206
+ domain: Domain name (e.g., "cytu.be", "cy.tube").
207
+ channel: Channel name (e.g., "420Grindhouse").
208
+ action: Command action (e.g., "chat", "queue").
209
+
210
+ Returns:
211
+ Formatted NATS subject string.
212
+
213
+ Raises:
214
+ ValueError: If any component is empty after normalization.
215
+
216
+ Examples:
217
+ >>> build_command_subject("cytu.be", "lounge", "chat")
218
+ 'kryten.commands.cytube.lounge.chat'
219
+ >>> build_command_subject("cy.tube", "420Grindhouse", "chat")
220
+ 'kryten.commands.cytube.420grindhouse.chat'
221
+ >>> build_command_subject("Cytu.be", "Test Channel", "queue")
222
+ 'kryten.commands.cytube.test-channel.queue'
223
+ """
224
+ # Normalize all components (domain dots removed, everything lowercase)
225
+ normalize_token(domain)
226
+ channel_clean = normalize_token(channel)
227
+ action_clean = normalize_token(action)
228
+
229
+ # Validate components are not empty
230
+ if not channel_clean:
231
+ raise ValueError("Channel cannot be empty after normalization")
232
+ if not action_clean:
233
+ raise ValueError("Action cannot be empty after normalization")
234
+
235
+ # Build subject with "cytube" as literal platform name (domain normalized out)
236
+ subject = f"{COMMAND_PREFIX}.cytube.{channel_clean}.{action_clean}"
237
+
238
+ # Final validation
239
+ if len(subject) > 255:
240
+ raise ValueError(f"Subject exceeds NATS limit of 255 characters: {len(subject)}")
241
+
242
+ return subject
243
+
244
+
245
+ def parse_subject(subject: str) -> dict[str, str]:
246
+ """Parse NATS subject into components.
247
+
248
+ Extracts prefix, domain, channel, and event_name from a hierarchical
249
+ subject string. Expected format: cytube.events.{domain}.{channel}.{event}
250
+
251
+ Domain may contain dots (e.g., cytu.be). Uses TLD detection heuristic.
252
+
253
+ Args:
254
+ subject: NATS subject string to parse.
255
+
256
+ Returns:
257
+ Dictionary with keys: prefix, domain, channel, event_name.
258
+
259
+ Raises:
260
+ ValueError: If subject format is invalid or missing required components.
261
+
262
+ Examples:
263
+ >>> components = parse_subject("cytube.events.cytu.be.lounge.chatMsg")
264
+ >>> components['domain']
265
+ 'cytu.be'
266
+ >>> components['channel']
267
+ 'lounge'
268
+ >>> components['event_name']
269
+ 'chatMsg'
270
+ """
271
+ if not subject:
272
+ raise ValueError("Subject cannot be empty")
273
+
274
+ # Check prefix first
275
+ if not subject.startswith(SUBJECT_PREFIX + "."):
276
+ raise ValueError(
277
+ f"Invalid subject prefix: expected '{SUBJECT_PREFIX}.', " f"got '{subject[:20]}...'"
278
+ )
279
+
280
+ # Remove prefix to get remaining components
281
+ remaining = subject[len(SUBJECT_PREFIX) + 1 :] # +1 for the dot
282
+
283
+ # Split remaining part
284
+ tokens = remaining.split(".")
285
+
286
+ if len(tokens) < 3:
287
+ raise ValueError(
288
+ f"Invalid subject format: expected 'cytube.events.{{domain}}.{{channel}}.{{event}}', "
289
+ f"got '{subject}'"
290
+ )
291
+
292
+ # Heuristic: Check if second token looks like a TLD
293
+ # Common TLDs for CyTube servers
294
+ tld_extensions = {"com", "be", "org", "net", "io", "tv", "gg", "me", "co"}
295
+
296
+ if len(tokens) >= 2 and tokens[1] in tld_extensions:
297
+ # Domain has TLD (e.g., cytu.be)
298
+ domain = f"{tokens[0]}.{tokens[1]}"
299
+ channel = tokens[2] if len(tokens) > 2 else ""
300
+ event_name = ".".join(tokens[3:]) if len(tokens) > 3 else ""
301
+ else:
302
+ # Domain is single token (e.g., localhost)
303
+ domain = tokens[0]
304
+ channel = tokens[1] if len(tokens) > 1 else ""
305
+ event_name = ".".join(tokens[2:]) if len(tokens) > 2 else ""
306
+
307
+ if not channel or not event_name:
308
+ raise ValueError(
309
+ f"Invalid subject format: expected 'cytube.events.{{domain}}.{{channel}}.{{event}}', "
310
+ f"got '{subject}'"
311
+ )
312
+
313
+ return {
314
+ "prefix": SUBJECT_PREFIX,
315
+ "domain": domain,
316
+ "channel": channel,
317
+ "event_name": event_name,
318
+ }
319
+
320
+
321
+ __all__ = [
322
+ "SUBJECT_PREFIX",
323
+ "COMMAND_PREFIX",
324
+ "MAX_TOKEN_LENGTH",
325
+ "sanitize_token",
326
+ "build_subject",
327
+ "build_event_subject",
328
+ "build_command_subject",
329
+ "parse_subject",
330
+ ]