cortexdb-connectors 0.2.0__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,672 @@
1
+ """Webhook receivers for real-time event ingestion from Slack and GitHub.
2
+
3
+ Provides a Starlette-based HTTP server that accepts webhook payloads from
4
+ Slack (Events API) and GitHub, normalizes them into CortexDB Episodes using
5
+ the existing connector classes, and ingests them via the CortexDB HTTP API.
6
+
7
+ Environment variables:
8
+ CORTEXDB_URL Base URL of the CortexDB API (default: http://localhost:8080)
9
+ CORTEXDB_API_KEY Bearer token for CortexDB authentication
10
+ SLACK_SIGNING_SECRET Used to verify Slack request signatures
11
+ GITHUB_WEBHOOK_SECRET Used to verify GitHub X-Hub-Signature-256 headers
12
+ WEBHOOK_PORT Port to listen on (default: 8081)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import hmac
19
+ import json
20
+ import logging
21
+ import os
22
+ import time
23
+ from datetime import datetime, timezone
24
+ from typing import Any
25
+
26
+ import httpx
27
+ from starlette.applications import Starlette
28
+ from starlette.requests import Request
29
+ from starlette.responses import JSONResponse, PlainTextResponse
30
+ from starlette.routing import Route
31
+
32
+ from cortexdb_connectors.base import (
33
+ Actor,
34
+ EntityRef,
35
+ Episode,
36
+ EpisodeType,
37
+ RawEvent,
38
+ Source,
39
+ Visibility,
40
+ VisibilityLevel,
41
+ )
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ _SLACK_SOURCE = Source(system="slack")
46
+ _GITHUB_SOURCE = Source(system="github")
47
+
48
+ # Map GitHub webhook event types to CortexDB episode types.
49
+ _GITHUB_WEBHOOK_TYPE_MAP: dict[str, EpisodeType] = {
50
+ "push": EpisodeType.CODE_CHANGE,
51
+ "pull_request": EpisodeType.CODE_CHANGE,
52
+ "issues": EpisodeType.ISSUE,
53
+ "issue_comment": EpisodeType.COMMENT,
54
+ "pull_request_review": EpisodeType.REVIEW,
55
+ "deployment_status": EpisodeType.DEPLOYMENT,
56
+ }
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Slack webhook handler
61
+ # ---------------------------------------------------------------------------
62
+
63
+ class SlackWebhookHandler:
64
+ """Handles incoming Slack Events API webhook requests.
65
+
66
+ Responsibilities:
67
+ - Respond to Slack URL verification challenges.
68
+ - Verify request authenticity using the Slack signing secret.
69
+ - Normalize incoming event_callback events into CortexDB Episodes.
70
+ - Forward episodes to the CortexDB ingestion API.
71
+ """
72
+
73
+ SUPPORTED_EVENT_TYPES = {"message", "message_changed", "reaction_added"}
74
+
75
+ def __init__(
76
+ self,
77
+ signing_secret: str,
78
+ cortexdb_url: str,
79
+ cortexdb_api_key: str,
80
+ tenant_id: str = "default",
81
+ ) -> None:
82
+ self.signing_secret = signing_secret
83
+ self.cortexdb_url = cortexdb_url.rstrip("/")
84
+ self.cortexdb_api_key = cortexdb_api_key
85
+ self.tenant_id = tenant_id
86
+
87
+ def verify_signature(
88
+ self,
89
+ body: bytes,
90
+ timestamp: str,
91
+ signature: str,
92
+ ) -> bool:
93
+ """Verify the Slack request signature.
94
+
95
+ Uses HMAC-SHA256 over ``v0:<timestamp>:<body>`` and compares
96
+ against the ``X-Slack-Signature`` header value.
97
+
98
+ Returns True if the signature is valid.
99
+ """
100
+ if not timestamp or not signature:
101
+ return False
102
+
103
+ # Reject requests older than 5 minutes to prevent replay attacks.
104
+ try:
105
+ ts = int(timestamp)
106
+ except (ValueError, TypeError):
107
+ return False
108
+
109
+ if abs(time.time() - ts) > 300:
110
+ return False
111
+
112
+ basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
113
+ computed = (
114
+ "v0="
115
+ + hmac.new(
116
+ self.signing_secret.encode("utf-8"),
117
+ basestring.encode("utf-8"),
118
+ hashlib.sha256,
119
+ ).hexdigest()
120
+ )
121
+ return hmac.compare_digest(computed, signature)
122
+
123
+ async def handle(self, request: Request) -> JSONResponse:
124
+ """Process an incoming Slack Events API request."""
125
+ body = await request.body()
126
+
127
+ # -- URL verification challenge ---------------------------------------
128
+ try:
129
+ payload = json.loads(body)
130
+ except json.JSONDecodeError:
131
+ return JSONResponse({"error": "invalid json"}, status_code=400)
132
+
133
+ if payload.get("type") == "url_verification":
134
+ return JSONResponse({"challenge": payload.get("challenge", "")})
135
+
136
+ # -- Signature verification -------------------------------------------
137
+ timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
138
+ signature = request.headers.get("X-Slack-Signature", "")
139
+
140
+ if not self.verify_signature(body, timestamp, signature):
141
+ logger.warning("Slack signature verification failed")
142
+ return JSONResponse({"error": "invalid signature"}, status_code=401)
143
+
144
+ # -- Event dispatch ---------------------------------------------------
145
+ if payload.get("type") != "event_callback":
146
+ return JSONResponse({"ok": True})
147
+
148
+ event = payload.get("event", {})
149
+ event_type = event.get("type", "")
150
+
151
+ if event_type not in self.SUPPORTED_EVENT_TYPES:
152
+ logger.debug("Ignoring unsupported Slack event type: %s", event_type)
153
+ return JSONResponse({"ok": True})
154
+
155
+ try:
156
+ episode = self.normalize(payload, event)
157
+ await self._ingest(episode)
158
+ except Exception:
159
+ logger.exception("Failed to process Slack event %s", event_type)
160
+ return JSONResponse(
161
+ {"error": "processing failed"}, status_code=500
162
+ )
163
+
164
+ return JSONResponse({"ok": True})
165
+
166
+ def normalize(
167
+ self,
168
+ payload: dict[str, Any],
169
+ event: dict[str, Any],
170
+ ) -> Episode:
171
+ """Normalize a Slack Events API event_callback into an Episode.
172
+
173
+ This mirrors SlackConnector.normalize() but operates on the
174
+ webhook payload structure rather than a RawEvent fetched via polling.
175
+ """
176
+ event_type = event.get("type", "")
177
+ user_id = event.get("user", event.get("user_id", "unknown"))
178
+ channel_id = event.get("channel", "")
179
+ ts = event.get("ts", event.get("event_ts", "0"))
180
+ thread_ts = event.get("thread_ts")
181
+
182
+ # Determine content based on event type.
183
+ if event_type == "message" or event_type == "message_changed":
184
+ text = event.get("text", "")
185
+ if event_type == "message_changed":
186
+ message = event.get("message", {})
187
+ text = message.get("text", text)
188
+ user_id = message.get("user", user_id)
189
+ ts = message.get("ts", ts)
190
+ episode_type = EpisodeType.MESSAGE
191
+ elif event_type == "reaction_added":
192
+ reaction = event.get("reaction", "")
193
+ text = f"Reaction :{reaction}: added"
194
+ item = event.get("item", {})
195
+ channel_id = item.get("channel", channel_id)
196
+ ts = item.get("ts", ts)
197
+ episode_type = EpisodeType.MESSAGE
198
+ else:
199
+ text = ""
200
+ episode_type = EpisodeType.CUSTOM
201
+
202
+ actor = Actor(id=user_id, name=user_id)
203
+
204
+ parent_id = None
205
+ if thread_ts and thread_ts != ts:
206
+ parent_id = f"{channel_id}:{thread_ts}"
207
+
208
+ return Episode(
209
+ actor=actor,
210
+ source=_SLACK_SOURCE,
211
+ episode_type=episode_type,
212
+ occurred_at=datetime.fromtimestamp(float(ts), tz=timezone.utc),
213
+ content=text,
214
+ raw_payload=payload,
215
+ tenant_id=self.tenant_id,
216
+ namespace="slack",
217
+ entities=[
218
+ EntityRef(
219
+ entity_type="channel",
220
+ entity_id=channel_id,
221
+ display_name=channel_id,
222
+ ),
223
+ ],
224
+ tags=["slack", f"slack:{event_type}"],
225
+ metadata={
226
+ "event_type": event_type,
227
+ "team_id": payload.get("team_id", ""),
228
+ },
229
+ thread_id=(
230
+ f"{channel_id}:{thread_ts}" if thread_ts else None
231
+ ),
232
+ parent_id=parent_id,
233
+ idempotency_key=f"{channel_id}:{ts}",
234
+ )
235
+
236
+ async def _ingest(self, episode: Episode) -> None:
237
+ """Send a normalized episode to CortexDB."""
238
+ payload = _episode_to_dict(episode)
239
+ async with httpx.AsyncClient() as client:
240
+ resp = await client.post(
241
+ f"{self.cortexdb_url}/api/v1/episodes",
242
+ json=payload,
243
+ headers={
244
+ "Authorization": f"Bearer {self.cortexdb_api_key}",
245
+ "Content-Type": "application/json",
246
+ },
247
+ timeout=30.0,
248
+ )
249
+ resp.raise_for_status()
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # GitHub webhook handler
254
+ # ---------------------------------------------------------------------------
255
+
256
+ class GitHubWebhookHandler:
257
+ """Handles incoming GitHub webhook requests.
258
+
259
+ Responsibilities:
260
+ - Verify request authenticity using the GitHub webhook secret.
261
+ - Normalize incoming events into CortexDB Episodes.
262
+ - Forward episodes to the CortexDB ingestion API.
263
+ """
264
+
265
+ SUPPORTED_EVENTS = {
266
+ "push",
267
+ "pull_request",
268
+ "issues",
269
+ "issue_comment",
270
+ "pull_request_review",
271
+ "deployment_status",
272
+ }
273
+
274
+ def __init__(
275
+ self,
276
+ webhook_secret: str,
277
+ cortexdb_url: str,
278
+ cortexdb_api_key: str,
279
+ tenant_id: str = "default",
280
+ ) -> None:
281
+ self.webhook_secret = webhook_secret
282
+ self.cortexdb_url = cortexdb_url.rstrip("/")
283
+ self.cortexdb_api_key = cortexdb_api_key
284
+ self.tenant_id = tenant_id
285
+
286
+ def verify_signature(self, body: bytes, signature: str) -> bool:
287
+ """Verify the GitHub X-Hub-Signature-256 header.
288
+
289
+ Computes HMAC-SHA256 of the request body using the webhook secret
290
+ and compares it against the provided signature.
291
+
292
+ Returns True if the signature is valid.
293
+ """
294
+ if not signature or not signature.startswith("sha256="):
295
+ return False
296
+
297
+ expected = (
298
+ "sha256="
299
+ + hmac.new(
300
+ self.webhook_secret.encode("utf-8"),
301
+ body,
302
+ hashlib.sha256,
303
+ ).hexdigest()
304
+ )
305
+ return hmac.compare_digest(expected, signature)
306
+
307
+ async def handle(self, request: Request) -> JSONResponse:
308
+ """Process an incoming GitHub webhook request."""
309
+ body = await request.body()
310
+
311
+ # -- Signature verification -------------------------------------------
312
+ signature = request.headers.get("X-Hub-Signature-256", "")
313
+ if not self.verify_signature(body, signature):
314
+ logger.warning("GitHub signature verification failed")
315
+ return JSONResponse({"error": "invalid signature"}, status_code=401)
316
+
317
+ # -- Parse payload ----------------------------------------------------
318
+ try:
319
+ payload = json.loads(body)
320
+ except json.JSONDecodeError:
321
+ return JSONResponse({"error": "invalid json"}, status_code=400)
322
+
323
+ event_type = request.headers.get("X-GitHub-Event", "")
324
+ delivery_id = request.headers.get("X-GitHub-Delivery", "")
325
+
326
+ if event_type == "ping":
327
+ return JSONResponse({"ok": True, "msg": "pong"})
328
+
329
+ if event_type not in self.SUPPORTED_EVENTS:
330
+ logger.debug("Ignoring unsupported GitHub event: %s", event_type)
331
+ return JSONResponse({"ok": True})
332
+
333
+ try:
334
+ episode = self.normalize(event_type, delivery_id, payload)
335
+ await self._ingest(episode)
336
+ except Exception:
337
+ logger.exception(
338
+ "Failed to process GitHub event %s (%s)", event_type, delivery_id
339
+ )
340
+ return JSONResponse(
341
+ {"error": "processing failed"}, status_code=500
342
+ )
343
+
344
+ return JSONResponse({"ok": True})
345
+
346
+ def normalize(
347
+ self,
348
+ event_type: str,
349
+ delivery_id: str,
350
+ payload: dict[str, Any],
351
+ ) -> Episode:
352
+ """Normalize a GitHub webhook event into a CortexDB Episode.
353
+
354
+ This mirrors GitHubConnector.normalize() but operates on the
355
+ raw webhook payload rather than a RawEvent from the polling API.
356
+ """
357
+ episode_type = _GITHUB_WEBHOOK_TYPE_MAP.get(event_type, EpisodeType.CUSTOM)
358
+
359
+ # Extract actor.
360
+ sender = payload.get("sender", {})
361
+ actor = Actor(
362
+ id=str(sender.get("id", "")),
363
+ name=sender.get("login", "unknown"),
364
+ avatar_url=sender.get("avatar_url"),
365
+ )
366
+
367
+ # Extract repository.
368
+ repo_data = payload.get("repository", {})
369
+ repo_name = repo_data.get("full_name", "")
370
+ is_private = repo_data.get("private", False)
371
+
372
+ content = self._build_content(event_type, payload)
373
+ entities = self._extract_entities(repo_name, payload)
374
+ thread_id = self._derive_thread_id(event_type, payload, repo_name)
375
+ action = payload.get("action", "")
376
+
377
+ tags = ["github", event_type]
378
+ if action:
379
+ tags.append(f"github:{action}")
380
+
381
+ metadata: dict[str, Any] = {"github_event_type": event_type}
382
+ if action:
383
+ metadata["action"] = action
384
+ if delivery_id:
385
+ metadata["delivery_id"] = delivery_id
386
+
387
+ visibility = Visibility(
388
+ level=VisibilityLevel.RESTRICTED if is_private
389
+ else VisibilityLevel.ORGANIZATION
390
+ )
391
+
392
+ return Episode(
393
+ actor=actor,
394
+ source=_GITHUB_SOURCE,
395
+ episode_type=episode_type,
396
+ occurred_at=datetime.now(timezone.utc),
397
+ content=content,
398
+ raw_payload=payload,
399
+ tenant_id=self.tenant_id,
400
+ namespace="github",
401
+ entities=entities,
402
+ tags=tags,
403
+ metadata=metadata,
404
+ thread_id=thread_id,
405
+ visibility=visibility,
406
+ idempotency_key=delivery_id or None,
407
+ )
408
+
409
+ @staticmethod
410
+ def _build_content(event_type: str, payload: dict[str, Any]) -> str:
411
+ """Produce a human-readable summary of the GitHub webhook event."""
412
+ action = payload.get("action", "")
413
+
414
+ if event_type == "push":
415
+ commits = payload.get("commits", [])
416
+ ref = payload.get("ref", "")
417
+ messages = "; ".join(
418
+ c.get("message", "").split("\n")[0] for c in commits[:5]
419
+ )
420
+ return f"Pushed {len(commits)} commit(s) to {ref}: {messages}"
421
+
422
+ if event_type == "pull_request":
423
+ pr = payload.get("pull_request", {})
424
+ title = pr.get("title", "")
425
+ return f"PR {action}: {title}"
426
+
427
+ if event_type == "issues":
428
+ issue = payload.get("issue", {})
429
+ title = issue.get("title", "")
430
+ return f"Issue {action}: {title}"
431
+
432
+ if event_type == "issue_comment":
433
+ comment = payload.get("comment", {})
434
+ body = (comment.get("body") or "")[:200]
435
+ return f"Comment: {body}"
436
+
437
+ if event_type == "pull_request_review":
438
+ review = payload.get("review", {})
439
+ state = review.get("state", "")
440
+ pr = payload.get("pull_request", {})
441
+ title = pr.get("title", "")
442
+ return f"Review ({state}) on PR: {title}"
443
+
444
+ if event_type == "deployment_status":
445
+ deployment = payload.get("deployment_status", {})
446
+ state = deployment.get("state", "")
447
+ env = deployment.get("environment", "")
448
+ return f"Deployment to {env}: {state}"
449
+
450
+ return f"GitHub event: {event_type}"
451
+
452
+ @staticmethod
453
+ def _extract_entities(
454
+ repo_name: str, payload: dict[str, Any]
455
+ ) -> list[EntityRef]:
456
+ """Extract entity references from the webhook payload."""
457
+ entities: list[EntityRef] = []
458
+
459
+ if repo_name:
460
+ entities.append(
461
+ EntityRef(
462
+ entity_type="repository",
463
+ entity_id=repo_name,
464
+ display_name=repo_name,
465
+ )
466
+ )
467
+
468
+ pr = payload.get("pull_request", {})
469
+ if pr:
470
+ pr_user = pr.get("user", {})
471
+ if pr_user:
472
+ entities.append(
473
+ EntityRef(
474
+ entity_type="user",
475
+ entity_id=str(pr_user.get("id", "")),
476
+ display_name=pr_user.get("login", ""),
477
+ )
478
+ )
479
+
480
+ issue = payload.get("issue", {})
481
+ if issue:
482
+ entities.append(
483
+ EntityRef(
484
+ entity_type="issue",
485
+ entity_id=str(issue.get("number", "")),
486
+ display_name=issue.get("title", ""),
487
+ )
488
+ )
489
+
490
+ return entities
491
+
492
+ @staticmethod
493
+ def _derive_thread_id(
494
+ event_type: str, payload: dict[str, Any], repo_name: str
495
+ ) -> str | None:
496
+ """Derive a thread_id for grouping related events."""
497
+ pr = payload.get("pull_request", {})
498
+ if pr and pr.get("number"):
499
+ return f"{repo_name}:pr:{pr['number']}"
500
+ issue = payload.get("issue", {})
501
+ if issue and issue.get("number"):
502
+ return f"{repo_name}:issue:{issue['number']}"
503
+ return None
504
+
505
+ async def _ingest(self, episode: Episode) -> None:
506
+ """Send a normalized episode to CortexDB."""
507
+ payload = _episode_to_dict(episode)
508
+ async with httpx.AsyncClient() as client:
509
+ resp = await client.post(
510
+ f"{self.cortexdb_url}/api/v1/episodes",
511
+ json=payload,
512
+ headers={
513
+ "Authorization": f"Bearer {self.cortexdb_api_key}",
514
+ "Content-Type": "application/json",
515
+ },
516
+ timeout=30.0,
517
+ )
518
+ resp.raise_for_status()
519
+
520
+
521
+ # ---------------------------------------------------------------------------
522
+ # Shared utilities
523
+ # ---------------------------------------------------------------------------
524
+
525
+ def _episode_to_dict(episode: Episode) -> dict[str, Any]:
526
+ """Serialize an Episode to a dictionary suitable for JSON ingestion."""
527
+ payload: dict[str, Any] = {
528
+ "id": episode.id,
529
+ "episode_type": episode.episode_type.value,
530
+ "content": episode.content,
531
+ "occurred_at": episode.occurred_at.isoformat(),
532
+ "tenant_id": episode.tenant_id,
533
+ "namespace": episode.namespace,
534
+ "tags": episode.tags,
535
+ "metadata": episode.metadata,
536
+ "raw_payload": episode.raw_payload,
537
+ "visibility": {
538
+ "level": episode.visibility.level.value,
539
+ "allowed_principals": list(episode.visibility.allowed_principals),
540
+ },
541
+ "idempotency_key": episode.idempotency_key,
542
+ }
543
+
544
+ if episode.actor:
545
+ payload["actor"] = {
546
+ "id": episode.actor.id,
547
+ "name": episode.actor.name,
548
+ "email": episode.actor.email,
549
+ }
550
+
551
+ if episode.source:
552
+ payload["source"] = {
553
+ "system": episode.source.system,
554
+ "connector_version": episode.source.connector_version,
555
+ }
556
+
557
+ if episode.entities:
558
+ payload["entities"] = [
559
+ {
560
+ "entity_type": e.entity_type,
561
+ "entity_id": e.entity_id,
562
+ "display_name": e.display_name,
563
+ }
564
+ for e in episode.entities
565
+ ]
566
+
567
+ if episode.thread_id:
568
+ payload["thread_id"] = episode.thread_id
569
+ if episode.parent_id:
570
+ payload["parent_id"] = episode.parent_id
571
+ if episode.ttl_seconds is not None:
572
+ payload["ttl_seconds"] = episode.ttl_seconds
573
+
574
+ return payload
575
+
576
+
577
+ # ---------------------------------------------------------------------------
578
+ # Webhook server
579
+ # ---------------------------------------------------------------------------
580
+
581
+ class WebhookServer:
582
+ """Configurable Starlette application serving Slack and GitHub webhooks.
583
+
584
+ Reads configuration from environment variables and wires up the
585
+ handler classes with appropriate routes.
586
+
587
+ Parameters
588
+ ----------
589
+ cortexdb_url:
590
+ Override for CORTEXDB_URL env var.
591
+ cortexdb_api_key:
592
+ Override for CORTEXDB_API_KEY env var.
593
+ slack_signing_secret:
594
+ Override for SLACK_SIGNING_SECRET env var.
595
+ github_webhook_secret:
596
+ Override for GITHUB_WEBHOOK_SECRET env var.
597
+ tenant_id:
598
+ CortexDB tenant identifier.
599
+ """
600
+
601
+ def __init__(
602
+ self,
603
+ cortexdb_url: str | None = None,
604
+ cortexdb_api_key: str | None = None,
605
+ slack_signing_secret: str | None = None,
606
+ github_webhook_secret: str | None = None,
607
+ tenant_id: str = "default",
608
+ ) -> None:
609
+ self.cortexdb_url = cortexdb_url or os.environ.get(
610
+ "CORTEXDB_URL", "http://localhost:8080"
611
+ )
612
+ self.cortexdb_api_key = cortexdb_api_key or os.environ.get(
613
+ "CORTEXDB_API_KEY", ""
614
+ )
615
+ self.slack_signing_secret = slack_signing_secret or os.environ.get(
616
+ "SLACK_SIGNING_SECRET", ""
617
+ )
618
+ self.github_webhook_secret = github_webhook_secret or os.environ.get(
619
+ "GITHUB_WEBHOOK_SECRET", ""
620
+ )
621
+ self.tenant_id = tenant_id
622
+
623
+ self.slack_handler = SlackWebhookHandler(
624
+ signing_secret=self.slack_signing_secret,
625
+ cortexdb_url=self.cortexdb_url,
626
+ cortexdb_api_key=self.cortexdb_api_key,
627
+ tenant_id=self.tenant_id,
628
+ )
629
+ self.github_handler = GitHubWebhookHandler(
630
+ webhook_secret=self.github_webhook_secret,
631
+ cortexdb_url=self.cortexdb_url,
632
+ cortexdb_api_key=self.cortexdb_api_key,
633
+ tenant_id=self.tenant_id,
634
+ )
635
+
636
+ def build_app(self) -> Starlette:
637
+ """Construct and return the Starlette ASGI application."""
638
+ routes = [
639
+ Route("/health", self._health, methods=["GET"]),
640
+ Route(
641
+ "/webhooks/slack/events",
642
+ self.slack_handler.handle,
643
+ methods=["POST"],
644
+ ),
645
+ Route(
646
+ "/webhooks/github/events",
647
+ self.github_handler.handle,
648
+ methods=["POST"],
649
+ ),
650
+ ]
651
+ return Starlette(routes=routes)
652
+
653
+ @staticmethod
654
+ async def _health(request: Request) -> JSONResponse:
655
+ """Health check endpoint."""
656
+ return JSONResponse({"status": "ok"})
657
+
658
+
659
+ def main() -> None:
660
+ """Entry point for running the webhook server via ``python -m`` or CLI."""
661
+ import uvicorn
662
+
663
+ port = int(os.environ.get("WEBHOOK_PORT", "8081"))
664
+ server = WebhookServer()
665
+ app = server.build_app()
666
+
667
+ logger.info("Starting CortexDB webhook server on port %d", port)
668
+ uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
669
+
670
+
671
+ if __name__ == "__main__":
672
+ main()