iflow-mcp_enuno-unifi-mcp-server 0.2.1__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.
Files changed (81) hide show
  1. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/METADATA +1282 -0
  2. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/RECORD +81 -0
  3. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/WHEEL +4 -0
  4. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/licenses/LICENSE +201 -0
  6. src/__init__.py +3 -0
  7. src/__main__.py +6 -0
  8. src/api/__init__.py +5 -0
  9. src/api/client.py +727 -0
  10. src/api/site_manager_client.py +176 -0
  11. src/cache.py +483 -0
  12. src/config/__init__.py +5 -0
  13. src/config/config.py +321 -0
  14. src/main.py +2234 -0
  15. src/models/__init__.py +126 -0
  16. src/models/acl.py +41 -0
  17. src/models/backup.py +272 -0
  18. src/models/client.py +74 -0
  19. src/models/device.py +53 -0
  20. src/models/dpi.py +50 -0
  21. src/models/firewall_policy.py +123 -0
  22. src/models/firewall_zone.py +28 -0
  23. src/models/network.py +62 -0
  24. src/models/qos_profile.py +458 -0
  25. src/models/radius.py +141 -0
  26. src/models/reference_data.py +34 -0
  27. src/models/site.py +59 -0
  28. src/models/site_manager.py +120 -0
  29. src/models/topology.py +138 -0
  30. src/models/traffic_flow.py +137 -0
  31. src/models/traffic_matching_list.py +56 -0
  32. src/models/voucher.py +42 -0
  33. src/models/vpn.py +73 -0
  34. src/models/wan.py +48 -0
  35. src/models/zbf_matrix.py +49 -0
  36. src/resources/__init__.py +8 -0
  37. src/resources/clients.py +111 -0
  38. src/resources/devices.py +102 -0
  39. src/resources/networks.py +93 -0
  40. src/resources/site_manager.py +64 -0
  41. src/resources/sites.py +86 -0
  42. src/tools/__init__.py +25 -0
  43. src/tools/acls.py +328 -0
  44. src/tools/application.py +42 -0
  45. src/tools/backups.py +1173 -0
  46. src/tools/client_management.py +505 -0
  47. src/tools/clients.py +203 -0
  48. src/tools/device_control.py +325 -0
  49. src/tools/devices.py +354 -0
  50. src/tools/dpi.py +241 -0
  51. src/tools/dpi_tools.py +89 -0
  52. src/tools/firewall.py +417 -0
  53. src/tools/firewall_policies.py +430 -0
  54. src/tools/firewall_zones.py +515 -0
  55. src/tools/network_config.py +388 -0
  56. src/tools/networks.py +190 -0
  57. src/tools/port_forwarding.py +263 -0
  58. src/tools/qos.py +1070 -0
  59. src/tools/radius.py +763 -0
  60. src/tools/reference_data.py +107 -0
  61. src/tools/site_manager.py +466 -0
  62. src/tools/site_vpn.py +95 -0
  63. src/tools/sites.py +187 -0
  64. src/tools/topology.py +406 -0
  65. src/tools/traffic_flows.py +1062 -0
  66. src/tools/traffic_matching_lists.py +371 -0
  67. src/tools/vouchers.py +249 -0
  68. src/tools/vpn.py +76 -0
  69. src/tools/wans.py +30 -0
  70. src/tools/wifi.py +498 -0
  71. src/tools/zbf_matrix.py +326 -0
  72. src/utils/__init__.py +88 -0
  73. src/utils/audit.py +213 -0
  74. src/utils/exceptions.py +114 -0
  75. src/utils/helpers.py +159 -0
  76. src/utils/logger.py +105 -0
  77. src/utils/sanitize.py +244 -0
  78. src/utils/validators.py +160 -0
  79. src/webhooks/__init__.py +6 -0
  80. src/webhooks/handlers.py +196 -0
  81. src/webhooks/receiver.py +290 -0
@@ -0,0 +1,160 @@
1
+ """Input validation functions for UniFi MCP Server."""
2
+
3
+ import re
4
+
5
+ from .exceptions import ValidationError
6
+
7
+
8
+ def validate_mac_address(mac: str) -> str:
9
+ """Validate and normalize MAC address.
10
+
11
+ Args:
12
+ mac: MAC address string
13
+
14
+ Returns:
15
+ Normalized MAC address (lowercase, colon-separated)
16
+
17
+ Raises:
18
+ ValidationError: If MAC address is invalid
19
+ """
20
+ # Remove common separators
21
+ cleaned = re.sub(r"[:\-\.]", "", mac.lower())
22
+
23
+ # Check if valid hex and correct length
24
+ if not re.match(r"^[0-9a-f]{12}$", cleaned):
25
+ raise ValidationError(f"Invalid MAC address format: {mac}")
26
+
27
+ # Format as colon-separated
28
+ return ":".join([cleaned[i : i + 2] for i in range(0, 12, 2)])
29
+
30
+
31
+ def validate_ip_address(ip: str) -> str:
32
+ """Validate IPv4 address.
33
+
34
+ Args:
35
+ ip: IP address string
36
+
37
+ Returns:
38
+ Validated IP address
39
+
40
+ Raises:
41
+ ValidationError: If IP address is invalid
42
+ """
43
+ parts = ip.split(".")
44
+ if len(parts) != 4:
45
+ raise ValidationError(f"Invalid IP address format: {ip}")
46
+
47
+ try:
48
+ for part in parts:
49
+ num = int(part)
50
+ if num < 0 or num > 255:
51
+ raise ValidationError(f"Invalid IP address octet: {part}")
52
+ except ValueError as e:
53
+ raise ValidationError(f"Invalid IP address format: {ip}") from e
54
+
55
+ return ip
56
+
57
+
58
+ def validate_port(port: int) -> int:
59
+ """Validate port number.
60
+
61
+ Args:
62
+ port: Port number
63
+
64
+ Returns:
65
+ Validated port number
66
+
67
+ Raises:
68
+ ValidationError: If port is invalid
69
+ """
70
+ if not isinstance(port, int) or port < 1 or port > 65535:
71
+ raise ValidationError(f"Invalid port number: {port}")
72
+
73
+ return port
74
+
75
+
76
+ def validate_site_id(site_id: str) -> str:
77
+ """Validate site ID format.
78
+
79
+ Args:
80
+ site_id: Site identifier
81
+
82
+ Returns:
83
+ Validated site ID
84
+
85
+ Raises:
86
+ ValidationError: If site ID is invalid
87
+ """
88
+ if not site_id or not isinstance(site_id, str):
89
+ raise ValidationError("Site ID cannot be empty")
90
+
91
+ # Site IDs should be alphanumeric with hyphens/underscores
92
+ if not re.match(r"^[a-zA-Z0-9_\-]+$", site_id):
93
+ raise ValidationError(f"Invalid site ID format: {site_id}")
94
+
95
+ return site_id
96
+
97
+
98
+ def validate_device_id(device_id: str) -> str:
99
+ """Validate device ID format.
100
+
101
+ Args:
102
+ device_id: Device identifier
103
+
104
+ Returns:
105
+ Validated device ID
106
+
107
+ Raises:
108
+ ValidationError: If device ID is invalid
109
+ """
110
+ if not device_id or not isinstance(device_id, str):
111
+ raise ValidationError("Device ID cannot be empty")
112
+
113
+ # Device IDs are typically 24-character hex strings (MongoDB ObjectId)
114
+ if not re.match(r"^[a-f0-9]{24}$", device_id.lower()):
115
+ raise ValidationError(f"Invalid device ID format: {device_id}")
116
+
117
+ return device_id.lower()
118
+
119
+
120
+ def validate_confirmation(confirm: bool | None, operation: str) -> None:
121
+ """Validate that confirmation is provided for mutating operations.
122
+
123
+ Args:
124
+ confirm: Confirmation flag
125
+ operation: Operation name
126
+
127
+ Raises:
128
+ ValidationError: If confirmation is not provided
129
+ """
130
+ if not confirm:
131
+ raise ValidationError(
132
+ f"Operation '{operation}' requires confirmation. Set confirm=true to proceed."
133
+ )
134
+
135
+
136
+ def validate_limit_offset(limit: int | None = None, offset: int | None = None) -> tuple[int, int]:
137
+ """Validate and normalize pagination parameters.
138
+
139
+ Args:
140
+ limit: Maximum number of items to return
141
+ offset: Number of items to skip
142
+
143
+ Returns:
144
+ Tuple of (limit, offset) with defaults applied
145
+
146
+ Raises:
147
+ ValidationError: If parameters are invalid
148
+ """
149
+ # Set defaults
150
+ final_limit = limit if limit is not None else 100
151
+ final_offset = offset if offset is not None else 0
152
+
153
+ # Validate
154
+ if final_limit < 1 or final_limit > 1000:
155
+ raise ValidationError(f"Limit must be between 1 and 1000: {final_limit}")
156
+
157
+ if final_offset < 0:
158
+ raise ValidationError(f"Offset must be non-negative: {final_offset}")
159
+
160
+ return final_limit, final_offset
@@ -0,0 +1,6 @@
1
+ """Webhook receiver and event handlers for UniFi events."""
2
+
3
+ from .handlers import WebhookEventHandler
4
+ from .receiver import WebhookReceiver
5
+
6
+ __all__ = ["WebhookReceiver", "WebhookEventHandler"]
@@ -0,0 +1,196 @@
1
+ """Webhook event handlers for UniFi events.
2
+
3
+ This module provides example event handlers and a handler management class.
4
+ """
5
+
6
+ from collections.abc import Callable
7
+ from typing import TYPE_CHECKING
8
+
9
+ from ..config import Settings
10
+ from ..utils import get_logger
11
+ from .receiver import WebhookEvent
12
+
13
+ if TYPE_CHECKING:
14
+ from .receiver import WebhookReceiver
15
+
16
+
17
+ class WebhookEventHandler:
18
+ """Manages webhook event handlers and provides common handlers."""
19
+
20
+ def __init__(self, settings: Settings):
21
+ """Initialize event handler.
22
+
23
+ Args:
24
+ settings: Application settings
25
+ """
26
+ self.settings = settings
27
+ self.logger = get_logger(__name__, settings.log_level)
28
+
29
+ async def handle_device_online(self, event: WebhookEvent) -> None:
30
+ """Handle device online event.
31
+
32
+ Args:
33
+ event: Webhook event
34
+ """
35
+ device_mac = event.data.get("mac")
36
+ device_name = event.data.get("name", "Unknown")
37
+
38
+ self.logger.info(
39
+ f"Device came online: {device_name} ({device_mac}) in site {event.site_id}"
40
+ )
41
+
42
+ # Example: Invalidate device cache
43
+ from ..cache import invalidate_cache
44
+
45
+ await invalidate_cache(
46
+ self.settings,
47
+ resource_type="devices",
48
+ site_id=event.site_id,
49
+ )
50
+
51
+ async def handle_device_offline(self, event: WebhookEvent) -> None:
52
+ """Handle device offline event.
53
+
54
+ Args:
55
+ event: Webhook event
56
+ """
57
+ device_mac = event.data.get("mac")
58
+ device_name = event.data.get("name", "Unknown")
59
+
60
+ self.logger.warning(
61
+ f"Device went offline: {device_name} ({device_mac}) in site {event.site_id}"
62
+ )
63
+
64
+ # Example: Invalidate device cache
65
+ from ..cache import invalidate_cache
66
+
67
+ await invalidate_cache(
68
+ self.settings,
69
+ resource_type="devices",
70
+ site_id=event.site_id,
71
+ )
72
+
73
+ async def handle_client_connected(self, event: WebhookEvent) -> None:
74
+ """Handle client connected event.
75
+
76
+ Args:
77
+ event: Webhook event
78
+ """
79
+ client_mac = event.data.get("mac")
80
+ client_name = event.data.get("hostname", "Unknown")
81
+ ssid = event.data.get("essid", "N/A")
82
+
83
+ self.logger.info(
84
+ f"Client connected: {client_name} ({client_mac}) to {ssid} " f"in site {event.site_id}"
85
+ )
86
+
87
+ # Example: Invalidate clients cache
88
+ from ..cache import invalidate_cache
89
+
90
+ await invalidate_cache(
91
+ self.settings,
92
+ resource_type="clients",
93
+ site_id=event.site_id,
94
+ )
95
+
96
+ async def handle_client_disconnected(self, event: WebhookEvent) -> None:
97
+ """Handle client disconnected event.
98
+
99
+ Args:
100
+ event: Webhook event
101
+ """
102
+ client_mac = event.data.get("mac")
103
+ client_name = event.data.get("hostname", "Unknown")
104
+
105
+ self.logger.info(
106
+ f"Client disconnected: {client_name} ({client_mac}) from site {event.site_id}"
107
+ )
108
+
109
+ # Example: Invalidate clients cache
110
+ from ..cache import invalidate_cache
111
+
112
+ await invalidate_cache(
113
+ self.settings,
114
+ resource_type="clients",
115
+ site_id=event.site_id,
116
+ )
117
+
118
+ async def handle_alert_raised(self, event: WebhookEvent) -> None:
119
+ """Handle alert raised event.
120
+
121
+ Args:
122
+ event: Webhook event
123
+ """
124
+ alert_type = event.data.get("type", "Unknown")
125
+ alert_message = event.data.get("message", "")
126
+ severity = event.data.get("severity", "info")
127
+
128
+ self.logger.warning(
129
+ f"Alert raised in site {event.site_id}: [{severity}] " f"{alert_type} - {alert_message}"
130
+ )
131
+
132
+ # Example: Could trigger notifications, update monitoring systems, etc.
133
+
134
+ async def handle_event_occurred(self, event: WebhookEvent) -> None:
135
+ """Handle generic event.
136
+
137
+ Args:
138
+ event: Webhook event
139
+ """
140
+ event_key = event.data.get("key", "unknown")
141
+ event_msg = event.data.get("msg", "")
142
+
143
+ self.logger.info(f"Event occurred in site {event.site_id}: {event_key} - {event_msg}")
144
+
145
+ async def handle_wildcard(self, event: WebhookEvent) -> None:
146
+ """Handle any event (wildcard handler).
147
+
148
+ Args:
149
+ event: Webhook event
150
+ """
151
+ self.logger.debug(
152
+ f"Wildcard handler received event: {event.event_type} from site {event.site_id}"
153
+ )
154
+
155
+ def get_default_handlers(self) -> dict[str, Callable]:
156
+ """Get default event handlers mapping.
157
+
158
+ Returns:
159
+ Dictionary mapping event types to handler functions
160
+ """
161
+ return {
162
+ "device.online": self.handle_device_online,
163
+ "device.offline": self.handle_device_offline,
164
+ "client.connected": self.handle_client_connected,
165
+ "client.disconnected": self.handle_client_disconnected,
166
+ "alert.raised": self.handle_alert_raised,
167
+ "event.occurred": self.handle_event_occurred,
168
+ }
169
+
170
+ def register_default_handlers(self, receiver: "WebhookReceiver") -> None:
171
+ """Register all default handlers with a webhook receiver.
172
+
173
+ Args:
174
+ receiver: WebhookReceiver instance
175
+ """
176
+ handlers = self.get_default_handlers()
177
+
178
+ for event_type, handler in handlers.items():
179
+ receiver.register_handler(event_type, handler)
180
+
181
+ self.logger.info(f"Registered {len(handlers)} default webhook handlers")
182
+
183
+
184
+ # Example custom handler
185
+ async def custom_handler_example(event: WebhookEvent) -> None:
186
+ """Example custom webhook handler.
187
+
188
+ Args:
189
+ event: Webhook event
190
+
191
+ Example:
192
+ receiver = WebhookReceiver(settings)
193
+ receiver.register_handler("device.adopted", custom_handler_example)
194
+ """
195
+ print(f"Custom handler received event: {event.event_type}")
196
+ print(f"Event data: {event.data}")
@@ -0,0 +1,290 @@
1
+ """Webhook receiver for UniFi events.
2
+
3
+ This module provides a webhook receiver that listens for UniFi events
4
+ and processes them asynchronously. It includes signature verification,
5
+ event validation, and rate limiting.
6
+ """
7
+
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ from collections.abc import Callable
12
+ from datetime import datetime, timedelta
13
+ from typing import Any
14
+
15
+ from fastapi import FastAPI, Header, HTTPException, Request, status
16
+ from pydantic import BaseModel, Field, validator
17
+
18
+ from ..config import Settings
19
+ from ..utils import get_logger
20
+
21
+
22
+ class WebhookEvent(BaseModel):
23
+ """UniFi webhook event model."""
24
+
25
+ event_type: str = Field(..., description="Event type (e.g., device.online)")
26
+ timestamp: datetime = Field(..., description="Event timestamp")
27
+ site_id: str = Field(..., description="Site identifier")
28
+ data: dict[str, Any] = Field(..., description="Event data")
29
+ event_id: str | None = Field(None, description="Unique event identifier")
30
+
31
+ @validator("event_type")
32
+ def validate_event_type(cls, v: str) -> str:
33
+ """Validate event type format."""
34
+ if not v or "." not in v:
35
+ raise ValueError("Event type must be in format 'category.action'")
36
+ return v.lower()
37
+
38
+
39
+ class WebhookReceiver:
40
+ """Webhook receiver for UniFi events."""
41
+
42
+ def __init__(
43
+ self,
44
+ settings: Settings,
45
+ app: FastAPI | None = None,
46
+ path: str = "/webhooks/unifi",
47
+ ):
48
+ """Initialize webhook receiver.
49
+
50
+ Args:
51
+ settings: Application settings
52
+ app: Optional FastAPI app instance
53
+ path: Webhook endpoint path
54
+ """
55
+ self.settings = settings
56
+ self.path = path
57
+ self.logger = get_logger(__name__, settings.log_level)
58
+ self.handlers: dict[str, list[Callable]] = {}
59
+ self._event_cache: dict[str, datetime] = {}
60
+ self._rate_limit_cache: dict[str, list[datetime]] = {}
61
+
62
+ # Get webhook secret from settings
63
+ self.webhook_secret = getattr(settings, "webhook_secret", None)
64
+ if not self.webhook_secret:
65
+ self.logger.warning(
66
+ "WEBHOOK_SECRET not configured. Signature verification disabled. "
67
+ "Set WEBHOOK_SECRET environment variable for production use."
68
+ )
69
+
70
+ if app:
71
+ self.register_routes(app)
72
+
73
+ def register_routes(self, app: FastAPI) -> None:
74
+ """Register webhook routes with FastAPI app.
75
+
76
+ Args:
77
+ app: FastAPI application instance
78
+ """
79
+
80
+ @app.post(self.path)
81
+ async def receive_webhook(
82
+ request: Request,
83
+ x_unifi_signature: str | None = Header(None),
84
+ ) -> dict[str, Any]:
85
+ """Receive and process UniFi webhook."""
86
+ try:
87
+ # Get request body
88
+ body = await request.body()
89
+ body_str = body.decode("utf-8")
90
+
91
+ # Verify signature if secret is configured
92
+ if self.webhook_secret and x_unifi_signature:
93
+ if not self._verify_signature(body_str, x_unifi_signature):
94
+ self.logger.warning("Invalid webhook signature")
95
+ raise HTTPException(
96
+ status_code=status.HTTP_401_UNAUTHORIZED,
97
+ detail="Invalid signature",
98
+ )
99
+ elif self.webhook_secret and not x_unifi_signature:
100
+ self.logger.warning("Missing webhook signature")
101
+ raise HTTPException(
102
+ status_code=status.HTTP_401_UNAUTHORIZED,
103
+ detail="Missing signature",
104
+ )
105
+
106
+ # Parse event
107
+ event_data = json.loads(body_str)
108
+ event = WebhookEvent(**event_data)
109
+
110
+ # Check for duplicate events
111
+ if self._is_duplicate(event):
112
+ self.logger.debug(f"Ignoring duplicate event: {event.event_id}")
113
+ return {"status": "duplicate", "event_id": event.event_id}
114
+
115
+ # Rate limiting check
116
+ if not self._check_rate_limit(event.site_id):
117
+ self.logger.warning(f"Rate limit exceeded for site: {event.site_id}")
118
+ raise HTTPException(
119
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
120
+ detail="Rate limit exceeded",
121
+ )
122
+
123
+ # Process event
124
+ await self._process_event(event)
125
+
126
+ return {
127
+ "status": "success",
128
+ "event_id": event.event_id,
129
+ "event_type": event.event_type,
130
+ }
131
+
132
+ except json.JSONDecodeError as e:
133
+ self.logger.error(f"Invalid JSON in webhook payload: {e}")
134
+ raise HTTPException(
135
+ status_code=status.HTTP_400_BAD_REQUEST,
136
+ detail="Invalid JSON payload",
137
+ ) from e
138
+ except ValueError as e:
139
+ self.logger.error(f"Invalid webhook event: {e}")
140
+ raise HTTPException(
141
+ status_code=status.HTTP_400_BAD_REQUEST,
142
+ detail=str(e),
143
+ ) from e
144
+ except Exception as e:
145
+ self.logger.error(f"Error processing webhook: {e}")
146
+ raise HTTPException(
147
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
148
+ detail="Internal server error",
149
+ ) from e
150
+
151
+ self.logger.info(f"Webhook receiver registered at {self.path}")
152
+
153
+ def register_handler(self, event_type: str, handler: Callable) -> None:
154
+ """Register an event handler.
155
+
156
+ Args:
157
+ event_type: Event type to handle (e.g., "device.online")
158
+ handler: Async handler function
159
+ """
160
+ if event_type not in self.handlers:
161
+ self.handlers[event_type] = []
162
+
163
+ self.handlers[event_type].append(handler)
164
+ self.logger.info(f"Registered handler for event type: {event_type}")
165
+
166
+ def unregister_handler(self, event_type: str, handler: Callable) -> None:
167
+ """Unregister an event handler.
168
+
169
+ Args:
170
+ event_type: Event type
171
+ handler: Handler function to remove
172
+ """
173
+ if event_type in self.handlers:
174
+ self.handlers[event_type].remove(handler)
175
+ self.logger.info(f"Unregistered handler for event type: {event_type}")
176
+
177
+ async def _process_event(self, event: WebhookEvent) -> None:
178
+ """Process a webhook event.
179
+
180
+ Args:
181
+ event: Webhook event to process
182
+ """
183
+ self.logger.info(
184
+ f"Processing webhook event: {event.event_type} "
185
+ f"(site: {event.site_id}, id: {event.event_id})"
186
+ )
187
+
188
+ # Get handlers for this event type
189
+ handlers = self.handlers.get(event.event_type, [])
190
+
191
+ # Also get wildcard handlers (e.g., "device.*")
192
+ event_category = event.event_type.split(".")[0]
193
+ wildcard_handlers = self.handlers.get(f"{event_category}.*", [])
194
+
195
+ all_handlers = handlers + wildcard_handlers
196
+
197
+ if not all_handlers:
198
+ self.logger.debug(f"No handlers registered for event type: {event.event_type}")
199
+ return
200
+
201
+ # Execute handlers
202
+ for handler in all_handlers:
203
+ try:
204
+ await handler(event)
205
+ except Exception as e:
206
+ self.logger.error(
207
+ f"Error in handler for {event.event_type}: {e}",
208
+ exc_info=True,
209
+ )
210
+
211
+ def _verify_signature(self, payload: str, signature: str) -> bool:
212
+ """Verify webhook signature.
213
+
214
+ Args:
215
+ payload: Request payload
216
+ signature: Signature from X-UniFi-Signature header
217
+
218
+ Returns:
219
+ True if signature is valid, False otherwise
220
+ """
221
+ if not self.webhook_secret:
222
+ return False
223
+
224
+ expected_signature = hmac.new(
225
+ self.webhook_secret.encode("utf-8"),
226
+ payload.encode("utf-8"),
227
+ hashlib.sha256,
228
+ ).hexdigest()
229
+
230
+ return hmac.compare_digest(signature, expected_signature)
231
+
232
+ def _is_duplicate(self, event: WebhookEvent) -> bool:
233
+ """Check if event is a duplicate.
234
+
235
+ Args:
236
+ event: Webhook event
237
+
238
+ Returns:
239
+ True if duplicate, False otherwise
240
+ """
241
+ if not event.event_id:
242
+ return False
243
+
244
+ # Clean old cache entries (older than 5 minutes)
245
+ cutoff = datetime.now() - timedelta(minutes=5)
246
+ self._event_cache = {eid: ts for eid, ts in self._event_cache.items() if ts > cutoff}
247
+
248
+ # Check if event ID exists
249
+ if event.event_id in self._event_cache:
250
+ return True
251
+
252
+ # Add to cache
253
+ self._event_cache[event.event_id] = datetime.now()
254
+ return False
255
+
256
+ def _check_rate_limit(
257
+ self,
258
+ site_id: str,
259
+ max_requests: int = 100,
260
+ window_seconds: int = 60,
261
+ ) -> bool:
262
+ """Check rate limit for a site.
263
+
264
+ Args:
265
+ site_id: Site identifier
266
+ max_requests: Maximum requests per window
267
+ window_seconds: Time window in seconds
268
+
269
+ Returns:
270
+ True if within rate limit, False otherwise
271
+ """
272
+ now = datetime.now()
273
+ cutoff = now - timedelta(seconds=window_seconds)
274
+
275
+ # Initialize or clean rate limit cache for this site
276
+ if site_id not in self._rate_limit_cache:
277
+ self._rate_limit_cache[site_id] = []
278
+
279
+ # Remove old requests
280
+ self._rate_limit_cache[site_id] = [
281
+ ts for ts in self._rate_limit_cache[site_id] if ts > cutoff
282
+ ]
283
+
284
+ # Check limit
285
+ if len(self._rate_limit_cache[site_id]) >= max_requests:
286
+ return False
287
+
288
+ # Add current request
289
+ self._rate_limit_cache[site_id].append(now)
290
+ return True