From 88ed7cfa7804794b9b54cad4c3facc8a98cf85fd Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Fri, 29 Aug 2025 10:07:49 -0600 Subject: [PATCH] feat(rooms): add webhook for transcript completion (#578) * feat(rooms): add webhook notifications for transcript completion - Add webhook_url and webhook_secret fields to rooms table - Create Celery task with 24-hour retry window using exponential backoff - Send transcript metadata, diarized text, topics, and summaries via webhook - Add HMAC signature verification for webhook security - Add test endpoint POST /v1/rooms/{room_id}/webhook/test - Update frontend with webhook configuration UI and test button - Auto-generate webhook secret if not provided - Trigger webhook after successful file pipeline processing for room recordings * style: linting * fix: remove unwanted files * fix: update openapi gen * fix: self-review * docs: add comprehensive webhook documentation - Document webhook configuration, events, and payloads - Include transcript.completed and test event examples - Add security considerations and best practices - Provide example webhook receiver implementation - Document retry policy and signature verification * fix: remove audio_mp3_url from webhook payload - Remove audio download URL generation from webhook - Update documentation to reflect the change - Keep only frontend_url for accessing transcripts * docs: remove unwanted section * fix: correct API method name and type imports for rooms - Fix v1RoomsRetrieve to v1RoomsGet - Update Room type to RoomDetails throughout frontend - Fix type imports in useRoomList, RoomList, RoomTable, and RoomCards * feat: add show/hide toggle for webhook secret field - Add eye icon button to reveal/hide webhook secret when editing - Show password dots when webhook secret is hidden - Reset visibility state when opening/closing dialog - Only show toggle button when editing existing room with secret * fix: resolve event loop conflict in webhook test endpoint - Extract webhook test logic into shared async function - Call async function directly from FastAPI endpoint - Keep Celery task wrapper for background processing - Fixes RuntimeError: event loop already running * refactor: remove unnecessary Celery task for webhook testing - Webhook testing is synchronous and provides immediate feedback - No need for background processing via Celery - Keep only the async function called directly from API endpoint * feat: improve webhook test error messages and display - Show HTTP status code in error messages - Parse JSON error responses to extract meaningful messages - Improved UI layout for webhook test results - Added colored background for success/error states - Better text wrapping for long error messages * docs: adjust doc * fix: review * fix: update attempts to match close 24h * fix: add event_id * fix: changed to uuid, to have new event_id when reprocess. * style: linting * fix: alembic revision --- server/docs/webhook.md | 212 ++++++++++++++ ...194f65cd6d3_add_webhook_fields_to_rooms.py | 36 +++ server/reflector/db/rooms.py | 15 + .../reflector/pipelines/main_file_pipeline.py | 19 +- server/reflector/views/rooms.py | 59 +++- server/reflector/worker/webhook.py | 258 ++++++++++++++++++ www/app/(app)/rooms/_components/RoomCards.tsx | 4 +- www/app/(app)/rooms/_components/RoomList.tsx | 4 +- www/app/(app)/rooms/_components/RoomTable.tsx | 4 +- www/app/(app)/rooms/page.tsx | 243 +++++++++++++++-- www/app/(app)/rooms/useRoomList.tsx | 6 +- www/app/api/schemas.gen.ts | 150 +++++++++- www/app/api/services.gen.ts | 53 +++- www/app/api/types.gen.ts | 81 +++++- 14 files changed, 1102 insertions(+), 42 deletions(-) create mode 100644 server/docs/webhook.md create mode 100644 server/migrations/versions/0194f65cd6d3_add_webhook_fields_to_rooms.py create mode 100644 server/reflector/worker/webhook.py diff --git a/server/docs/webhook.md b/server/docs/webhook.md new file mode 100644 index 00000000..9fe88fb9 --- /dev/null +++ b/server/docs/webhook.md @@ -0,0 +1,212 @@ +# Reflector Webhook Documentation + +## Overview + +Reflector supports webhook notifications to notify external systems when transcript processing is completed. Webhooks can be configured per room and are triggered automatically after a transcript is successfully processed. + +## Configuration + +Webhooks are configured at the room level with two fields: +- `webhook_url`: The HTTPS endpoint to receive webhook notifications +- `webhook_secret`: Optional secret key for HMAC signature verification (auto-generated if not provided) + +## Events + +### `transcript.completed` + +Triggered when a transcript has been fully processed, including transcription, diarization, summarization, and topic detection. + +### `test` + +A test event that can be triggered manually to verify webhook configuration. + +## Webhook Request Format + +### Headers + +All webhook requests include the following headers: + +| Header | Description | Example | +|--------|-------------|---------| +| `Content-Type` | Always `application/json` | `application/json` | +| `User-Agent` | Identifies Reflector as the source | `Reflector-Webhook/1.0` | +| `X-Webhook-Event` | The event type | `transcript.completed` or `test` | +| `X-Webhook-Retry` | Current retry attempt number | `0`, `1`, `2`... | +| `X-Webhook-Signature` | HMAC signature (if secret configured) | `t=1735306800,v1=abc123...` | + +### Signature Verification + +If a webhook secret is configured, Reflector includes an HMAC-SHA256 signature in the `X-Webhook-Signature` header to verify the webhook authenticity. + +The signature format is: `t={timestamp},v1={signature}` + +To verify the signature: +1. Extract the timestamp and signature from the header +2. Create the signed payload: `{timestamp}.{request_body}` +3. Compute HMAC-SHA256 of the signed payload using your webhook secret +4. Compare the computed signature with the received signature + +Example verification (Python): +```python +import hmac +import hashlib + +def verify_webhook_signature(payload: bytes, signature_header: str, secret: str) -> bool: + # Parse header: "t=1735306800,v1=abc123..." + parts = dict(part.split("=") for part in signature_header.split(",")) + timestamp = parts["t"] + received_signature = parts["v1"] + + # Create signed payload + signed_payload = f"{timestamp}.{payload.decode('utf-8')}" + + # Compute expected signature + expected_signature = hmac.new( + secret.encode("utf-8"), + signed_payload.encode("utf-8"), + hashlib.sha256 + ).hexdigest() + + # Compare signatures + return hmac.compare_digest(expected_signature, received_signature) +``` + +## Event Payloads + +### `transcript.completed` Event + +This event includes a convenient URL for accessing the transcript: +- `frontend_url`: Direct link to view the transcript in the web interface + +```json +{ + "event": "transcript.completed", + "event_id": "transcript.completed-abc-123-def-456", + "timestamp": "2025-08-27T12:34:56.789012Z", + "transcript": { + "id": "abc-123-def-456", + "room_id": "room-789", + "created_at": "2025-08-27T12:00:00Z", + "duration": 1800.5, + "title": "Q3 Product Planning Meeting", + "short_summary": "Team discussed Q3 product roadmap, prioritizing mobile app features and API improvements.", + "long_summary": "The product team met to finalize the Q3 roadmap. Key decisions included...", + "webvtt": "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nWelcome everyone to today's meeting...", + "topics": [ + { + "title": "Introduction and Agenda", + "summary": "Meeting kickoff with agenda review", + "timestamp": 0.0, + "duration": 120.0, + "webvtt": "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nWelcome everyone..." + }, + { + "title": "Mobile App Features Discussion", + "summary": "Team reviewed proposed mobile app features for Q3", + "timestamp": 120.0, + "duration": 600.0, + "webvtt": "WEBVTT\n\n00:02:00.000 --> 00:02:10.000\nLet's talk about the mobile app..." + } + ], + "participants": [ + { + "id": "participant-1", + "name": "John Doe", + "speaker": "Speaker 1" + }, + { + "id": "participant-2", + "name": "Jane Smith", + "speaker": "Speaker 2" + } + ], + "source_language": "en", + "target_language": "en", + "status": "completed", + "frontend_url": "https://app.reflector.com/transcripts/abc-123-def-456" + }, + "room": { + "id": "room-789", + "name": "Product Team Room" + } +} +``` + +### `test` Event + +```json +{ + "event": "test", + "event_id": "test.2025-08-27T12:34:56.789012Z", + "timestamp": "2025-08-27T12:34:56.789012Z", + "message": "This is a test webhook from Reflector", + "room": { + "id": "room-789", + "name": "Product Team Room" + } +} +``` + +## Retry Policy + +Webhooks are delivered with automatic retry logic to handle transient failures. When a webhook delivery fails due to server errors or network issues, Reflector will automatically retry the delivery multiple times over an extended period. + +### Retry Mechanism + +Reflector implements an exponential backoff strategy for webhook retries: + +- **Initial retry delay**: 60 seconds after the first failure +- **Exponential backoff**: Each subsequent retry waits approximately twice as long as the previous one +- **Maximum retry interval**: 1 hour (backoff is capped at this duration) +- **Maximum retry attempts**: 30 attempts total +- **Total retry duration**: Retries continue for approximately 24 hours + +### How Retries Work + +When a webhook fails, Reflector will: +1. Wait 60 seconds, then retry (attempt #1) +2. If it fails again, wait ~2 minutes, then retry (attempt #2) +3. Continue doubling the wait time up to a maximum of 1 hour between attempts +4. Keep retrying at 1-hour intervals until successful or 30 attempts are exhausted + +The `X-Webhook-Retry` header indicates the current retry attempt number (0 for the initial attempt, 1 for first retry, etc.), allowing your endpoint to track retry attempts. + +### Retry Behavior by HTTP Status Code + +| Status Code | Behavior | +|-------------|----------| +| 2xx (Success) | No retry, webhook marked as delivered | +| 4xx (Client Error) | No retry, request is considered permanently failed | +| 5xx (Server Error) | Automatic retry with exponential backoff | +| Network/Timeout Error | Automatic retry with exponential backoff | + +**Important Notes:** +- Webhooks timeout after 30 seconds. If your endpoint takes longer to respond, it will be considered a timeout error and retried. +- During the retry period (~24 hours), you may receive the same webhook multiple times if your endpoint experiences intermittent failures. +- There is no mechanism to manually retry failed webhooks after the retry period expires. + +## Testing Webhooks + +You can test your webhook configuration before processing transcripts: + +```http +POST /v1/rooms/{room_id}/webhook/test +``` + +Response: +```json +{ + "success": true, + "status_code": 200, + "message": "Webhook test successful", + "response_preview": "OK" +} +``` + +Or in case of failure: +```json +{ + "success": false, + "error": "Webhook request timed out (10 seconds)" +} +``` diff --git a/server/migrations/versions/0194f65cd6d3_add_webhook_fields_to_rooms.py b/server/migrations/versions/0194f65cd6d3_add_webhook_fields_to_rooms.py new file mode 100644 index 00000000..21dc1260 --- /dev/null +++ b/server/migrations/versions/0194f65cd6d3_add_webhook_fields_to_rooms.py @@ -0,0 +1,36 @@ +"""Add webhook fields to rooms + +Revision ID: 0194f65cd6d3 +Revises: 5a8907fd1d78 +Create Date: 2025-08-27 09:03:19.610995 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0194f65cd6d3" +down_revision: Union[str, None] = "5a8907fd1d78" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.add_column(sa.Column("webhook_url", sa.String(), nullable=True)) + batch_op.add_column(sa.Column("webhook_secret", sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.drop_column("webhook_secret") + batch_op.drop_column("webhook_url") + + # ### end Alembic commands ### diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py index a38e6b7f..08a6748d 100644 --- a/server/reflector/db/rooms.py +++ b/server/reflector/db/rooms.py @@ -1,3 +1,4 @@ +import secrets from datetime import datetime, timezone from sqlite3 import IntegrityError from typing import Literal @@ -40,6 +41,8 @@ rooms = sqlalchemy.Table( sqlalchemy.Column( "is_shared", sqlalchemy.Boolean, nullable=False, server_default=false() ), + sqlalchemy.Column("webhook_url", sqlalchemy.String), + sqlalchemy.Column("webhook_secret", sqlalchemy.String), sqlalchemy.Index("idx_room_is_shared", "is_shared"), ) @@ -59,6 +62,8 @@ class Room(BaseModel): "none", "prompt", "automatic", "automatic-2nd-participant" ] = "automatic-2nd-participant" is_shared: bool = False + webhook_url: str = "" + webhook_secret: str = "" class RoomController: @@ -107,10 +112,15 @@ class RoomController: recording_type: str, recording_trigger: str, is_shared: bool, + webhook_url: str = "", + webhook_secret: str = "", ): """ Add a new room """ + if webhook_url and not webhook_secret: + webhook_secret = secrets.token_urlsafe(32) + room = Room( name=name, user_id=user_id, @@ -122,6 +132,8 @@ class RoomController: recording_type=recording_type, recording_trigger=recording_trigger, is_shared=is_shared, + webhook_url=webhook_url, + webhook_secret=webhook_secret, ) query = rooms.insert().values(**room.model_dump()) try: @@ -134,6 +146,9 @@ class RoomController: """ Update a room fields with key/values in values """ + if values.get("webhook_url") and not values.get("webhook_secret"): + values["webhook_secret"] = secrets.token_urlsafe(32) + query = rooms.update().where(rooms.c.id == room.id).values(**values) try: await get_database().execute(query) diff --git a/server/reflector/pipelines/main_file_pipeline.py b/server/reflector/pipelines/main_file_pipeline.py index 42333aa9..5c57dddb 100644 --- a/server/reflector/pipelines/main_file_pipeline.py +++ b/server/reflector/pipelines/main_file_pipeline.py @@ -7,6 +7,7 @@ Uses parallel processing for transcription, diarization, and waveform generation """ import asyncio +import uuid from pathlib import Path import av @@ -14,7 +15,9 @@ import structlog from celery import shared_task from reflector.asynctask import asynctask +from reflector.db.rooms import rooms_controller from reflector.db.transcripts import ( + SourceKind, Transcript, TranscriptStatus, transcripts_controller, @@ -48,6 +51,7 @@ from reflector.processors.types import ( ) from reflector.settings import settings from reflector.storage import get_transcripts_storage +from reflector.worker.webhook import send_transcript_webhook class EmptyPipeline: @@ -385,7 +389,6 @@ async def task_pipeline_file_process(*, transcript_id: str): raise Exception(f"Transcript {transcript_id} not found") pipeline = PipelineMainFile(transcript_id=transcript_id) - try: await pipeline.set_status(transcript_id, "processing") @@ -402,3 +405,17 @@ async def task_pipeline_file_process(*, transcript_id: str): except Exception: await pipeline.set_status(transcript_id, "error") raise + + # Trigger webhook if this is a room recording with webhook configured + if transcript.source_kind == SourceKind.ROOM and transcript.room_id: + room = await rooms_controller.get_by_id(transcript.room_id) + if room and room.webhook_url: + logger.info( + "Dispatching webhook task", + transcript_id=transcript_id, + room_id=room.id, + webhook_url=room.webhook_url, + ) + send_transcript_webhook.delay( + transcript_id, room.id, event_id=uuid.uuid4().hex + ) diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index d4278e1f..82c172f2 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -15,6 +15,7 @@ from reflector.db.meetings import meetings_controller from reflector.db.rooms import rooms_controller from reflector.settings import settings from reflector.whereby import create_meeting, upload_logo +from reflector.worker.webhook import test_webhook logger = logging.getLogger(__name__) @@ -44,6 +45,11 @@ class Room(BaseModel): is_shared: bool +class RoomDetails(Room): + webhook_url: str + webhook_secret: str + + class Meeting(BaseModel): id: str room_name: str @@ -64,6 +70,8 @@ class CreateRoom(BaseModel): recording_type: str recording_trigger: str is_shared: bool + webhook_url: str + webhook_secret: str class UpdateRoom(BaseModel): @@ -76,16 +84,26 @@ class UpdateRoom(BaseModel): recording_type: str recording_trigger: str is_shared: bool + webhook_url: str + webhook_secret: str class DeletionStatus(BaseModel): status: str -@router.get("/rooms", response_model=Page[Room]) +class WebhookTestResult(BaseModel): + success: bool + message: str = "" + error: str = "" + status_code: int | None = None + response_preview: str | None = None + + +@router.get("/rooms", response_model=Page[RoomDetails]) async def rooms_list( user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], -) -> list[Room]: +) -> list[RoomDetails]: if not user and not settings.PUBLIC_MODE: raise HTTPException(status_code=401, detail="Not authenticated") @@ -99,6 +117,18 @@ async def rooms_list( ) +@router.get("/rooms/{room_id}", response_model=RoomDetails) +async def rooms_get( + room_id: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + return room + + @router.post("/rooms", response_model=Room) async def rooms_create( room: CreateRoom, @@ -117,10 +147,12 @@ async def rooms_create( recording_type=room.recording_type, recording_trigger=room.recording_trigger, is_shared=room.is_shared, + webhook_url=room.webhook_url, + webhook_secret=room.webhook_secret, ) -@router.patch("/rooms/{room_id}", response_model=Room) +@router.patch("/rooms/{room_id}", response_model=RoomDetails) async def rooms_update( room_id: str, info: UpdateRoom, @@ -209,3 +241,24 @@ async def rooms_create_meeting( meeting.host_room_url = "" return meeting + + +@router.post("/rooms/{room_id}/webhook/test", response_model=WebhookTestResult) +async def rooms_test_webhook( + room_id: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + """Test webhook configuration by sending a sample payload.""" + user_id = user["sub"] if user else None + + room = await rooms_controller.get_by_id(room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + if user_id and room.user_id != user_id: + raise HTTPException( + status_code=403, detail="Not authorized to test this room's webhook" + ) + + result = await test_webhook(room_id) + return WebhookTestResult(**result) diff --git a/server/reflector/worker/webhook.py b/server/reflector/worker/webhook.py new file mode 100644 index 00000000..64368b2e --- /dev/null +++ b/server/reflector/worker/webhook.py @@ -0,0 +1,258 @@ +"""Webhook task for sending transcript notifications.""" + +import hashlib +import hmac +import json +import uuid +from datetime import datetime, timezone + +import httpx +import structlog +from celery import shared_task +from celery.utils.log import get_task_logger + +from reflector.db.rooms import rooms_controller +from reflector.db.transcripts import transcripts_controller +from reflector.pipelines.main_live_pipeline import asynctask +from reflector.settings import settings +from reflector.utils.webvtt import topics_to_webvtt + +logger = structlog.wrap_logger(get_task_logger(__name__)) + + +def generate_webhook_signature(payload: bytes, secret: str, timestamp: str) -> str: + """Generate HMAC signature for webhook payload.""" + signed_payload = f"{timestamp}.{payload.decode('utf-8')}" + hmac_obj = hmac.new( + secret.encode("utf-8"), + signed_payload.encode("utf-8"), + hashlib.sha256, + ) + return hmac_obj.hexdigest() + + +@shared_task( + bind=True, + max_retries=30, + default_retry_delay=60, + retry_backoff=True, + retry_backoff_max=3600, # Max 1 hour between retries +) +@asynctask +async def send_transcript_webhook( + self, + transcript_id: str, + room_id: str, + event_id: str, +): + log = logger.bind( + transcript_id=transcript_id, + room_id=room_id, + retry_count=self.request.retries, + ) + + try: + # Fetch transcript and room + transcript = await transcripts_controller.get_by_id(transcript_id) + if not transcript: + log.error("Transcript not found, skipping webhook") + return + + room = await rooms_controller.get_by_id(room_id) + if not room: + log.error("Room not found, skipping webhook") + return + + if not room.webhook_url: + log.info("No webhook URL configured for room, skipping") + return + + # Generate WebVTT content from topics + topics_data = [] + + if transcript.topics: + # Build topics data with diarized content per topic + for topic in transcript.topics: + topic_webvtt = topics_to_webvtt([topic]) if topic.words else "" + topics_data.append( + { + "title": topic.title, + "summary": topic.summary, + "timestamp": topic.timestamp, + "duration": topic.duration, + "webvtt": topic_webvtt, + } + ) + + # Build webhook payload + frontend_url = f"{settings.UI_BASE_URL}/transcripts/{transcript.id}" + participants = [ + {"id": p.id, "name": p.name, "speaker": p.speaker} + for p in (transcript.participants or []) + ] + payload_data = { + "event": "transcript.completed", + "event_id": event_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "transcript": { + "id": transcript.id, + "room_id": transcript.room_id, + "created_at": transcript.created_at.isoformat(), + "duration": transcript.duration, + "title": transcript.title, + "short_summary": transcript.short_summary, + "long_summary": transcript.long_summary, + "webvtt": transcript.webvtt, + "topics": topics_data, + "participants": participants, + "source_language": transcript.source_language, + "target_language": transcript.target_language, + "status": transcript.status, + "frontend_url": frontend_url, + }, + "room": { + "id": room.id, + "name": room.name, + }, + } + + # Convert to JSON + payload_json = json.dumps(payload_data, separators=(",", ":")) + payload_bytes = payload_json.encode("utf-8") + + # Generate signature if secret is configured + headers = { + "Content-Type": "application/json", + "User-Agent": "Reflector-Webhook/1.0", + "X-Webhook-Event": "transcript.completed", + "X-Webhook-Retry": str(self.request.retries), + } + + if room.webhook_secret: + timestamp = str(int(datetime.now(timezone.utc).timestamp())) + signature = generate_webhook_signature( + payload_bytes, room.webhook_secret, timestamp + ) + headers["X-Webhook-Signature"] = f"t={timestamp},v1={signature}" + + # Send webhook with timeout + async with httpx.AsyncClient(timeout=30.0) as client: + log.info( + "Sending webhook", + url=room.webhook_url, + payload_size=len(payload_bytes), + ) + + response = await client.post( + room.webhook_url, + content=payload_bytes, + headers=headers, + ) + + response.raise_for_status() + + log.info( + "Webhook sent successfully", + status_code=response.status_code, + response_size=len(response.content), + ) + + except httpx.HTTPStatusError as e: + log.error( + "Webhook failed with HTTP error", + status_code=e.response.status_code, + response_text=e.response.text[:500], # First 500 chars + ) + + # Don't retry on client errors (4xx) + if 400 <= e.response.status_code < 500: + log.error("Client error, not retrying") + return + + # Retry on server errors (5xx) + raise self.retry(exc=e) + + except (httpx.ConnectError, httpx.TimeoutException) as e: + # Retry on network errors + log.error("Webhook failed with connection error", error=str(e)) + raise self.retry(exc=e) + + except Exception as e: + # Retry on unexpected errors + log.exception("Unexpected error in webhook task", error=str(e)) + raise self.retry(exc=e) + + +async def test_webhook(room_id: str) -> dict: + """ + Test webhook configuration by sending a sample payload. + Returns immediately with success/failure status. + This is the shared implementation used by both the API endpoint and Celery task. + """ + try: + room = await rooms_controller.get_by_id(room_id) + if not room: + return {"success": False, "error": "Room not found"} + + if not room.webhook_url: + return {"success": False, "error": "No webhook URL configured"} + + now = (datetime.now(timezone.utc).isoformat(),) + payload_data = { + "event": "test", + "event_id": uuid.uuid4().hex, + "timestamp": now, + "message": "This is a test webhook from Reflector", + "room": { + "id": room.id, + "name": room.name, + }, + } + + payload_json = json.dumps(payload_data, separators=(",", ":")) + payload_bytes = payload_json.encode("utf-8") + + # Generate headers with signature + headers = { + "Content-Type": "application/json", + "User-Agent": "Reflector-Webhook/1.0", + "X-Webhook-Event": "test", + } + + if room.webhook_secret: + timestamp = str(int(datetime.now(timezone.utc).timestamp())) + signature = generate_webhook_signature( + payload_bytes, room.webhook_secret, timestamp + ) + headers["X-Webhook-Signature"] = f"t={timestamp},v1={signature}" + + # Send test webhook with short timeout + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + room.webhook_url, + content=payload_bytes, + headers=headers, + ) + + return { + "success": response.is_success, + "status_code": response.status_code, + "message": f"Webhook test {'successful' if response.is_success else 'failed'}", + "response_preview": response.text if response.text else None, + } + + except httpx.TimeoutException: + return { + "success": False, + "error": "Webhook request timed out (10 seconds)", + } + except httpx.ConnectError as e: + return { + "success": False, + "error": f"Could not connect to webhook URL: {str(e)}", + } + except Exception as e: + return { + "success": False, + "error": f"Unexpected error: {str(e)}", + } diff --git a/www/app/(app)/rooms/_components/RoomCards.tsx b/www/app/(app)/rooms/_components/RoomCards.tsx index 15079a7a..16748d90 100644 --- a/www/app/(app)/rooms/_components/RoomCards.tsx +++ b/www/app/(app)/rooms/_components/RoomCards.tsx @@ -12,11 +12,11 @@ import { HStack, } from "@chakra-ui/react"; import { LuLink } from "react-icons/lu"; -import { Room } from "../../../api"; +import { RoomDetails } from "../../../api"; import { RoomActionsMenu } from "./RoomActionsMenu"; interface RoomCardsProps { - rooms: Room[]; + rooms: RoomDetails[]; linkCopied: string; onCopyUrl: (roomName: string) => void; onEdit: (roomId: string, roomData: any) => void; diff --git a/www/app/(app)/rooms/_components/RoomList.tsx b/www/app/(app)/rooms/_components/RoomList.tsx index 17cd5fc5..73fe8a5c 100644 --- a/www/app/(app)/rooms/_components/RoomList.tsx +++ b/www/app/(app)/rooms/_components/RoomList.tsx @@ -1,11 +1,11 @@ import { Box, Heading, Text, VStack } from "@chakra-ui/react"; -import { Room } from "../../../api"; +import { RoomDetails } from "../../../api"; import { RoomTable } from "./RoomTable"; import { RoomCards } from "./RoomCards"; interface RoomListProps { title: string; - rooms: Room[]; + rooms: RoomDetails[]; linkCopied: string; onCopyUrl: (roomName: string) => void; onEdit: (roomId: string, roomData: any) => void; diff --git a/www/app/(app)/rooms/_components/RoomTable.tsx b/www/app/(app)/rooms/_components/RoomTable.tsx index 092fccdc..93d05b61 100644 --- a/www/app/(app)/rooms/_components/RoomTable.tsx +++ b/www/app/(app)/rooms/_components/RoomTable.tsx @@ -9,11 +9,11 @@ import { Spinner, } from "@chakra-ui/react"; import { LuLink } from "react-icons/lu"; -import { Room } from "../../../api"; +import { RoomDetails } from "../../../api"; import { RoomActionsMenu } from "./RoomActionsMenu"; interface RoomTableProps { - rooms: Room[]; + rooms: RoomDetails[]; linkCopied: string; onCopyUrl: (roomName: string) => void; onEdit: (roomId: string, roomData: any) => void; diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx index 305087f9..33cfa6b3 100644 --- a/www/app/(app)/rooms/page.tsx +++ b/www/app/(app)/rooms/page.tsx @@ -11,13 +11,15 @@ import { Input, Select, Spinner, + IconButton, createListCollection, useDisclosure, } from "@chakra-ui/react"; import { useEffect, useState } from "react"; +import { LuEye, LuEyeOff } from "react-icons/lu"; import useApi from "../../lib/useApi"; import useRoomList from "./useRoomList"; -import { ApiError, Room } from "../../api"; +import { ApiError, RoomDetails } from "../../api"; import { RoomList } from "./_components/RoomList"; import { PaginationPage } from "../browse/_components/Pagination"; @@ -55,6 +57,8 @@ const roomInitialState = { recordingType: "cloud", recordingTrigger: "automatic-2nd-participant", isShared: false, + webhookUrl: "", + webhookSecret: "", }; export default function RoomsList() { @@ -83,6 +87,11 @@ export default function RoomsList() { const [topics, setTopics] = useState([]); const [nameError, setNameError] = useState(""); const [linkCopied, setLinkCopied] = useState(""); + const [testingWebhook, setTestingWebhook] = useState(false); + const [webhookTestResult, setWebhookTestResult] = useState( + null, + ); + const [showWebhookSecret, setShowWebhookSecret] = useState(false); interface Stream { stream_id: number; name: string; @@ -155,6 +164,69 @@ export default function RoomsList() { }, 2000); }; + const handleCloseDialog = () => { + setShowWebhookSecret(false); + setWebhookTestResult(null); + onClose(); + }; + + const handleTestWebhook = async () => { + if (!room.webhookUrl || !editRoomId) { + setWebhookTestResult("Please enter a webhook URL first"); + return; + } + + setTestingWebhook(true); + setWebhookTestResult(null); + + try { + const response = await api?.v1RoomsTestWebhook({ + roomId: editRoomId, + }); + + if (response?.success) { + setWebhookTestResult( + `✅ Webhook test successful! Status: ${response.status_code}`, + ); + } else { + let errorMsg = `❌ Webhook test failed`; + if (response?.status_code) { + errorMsg += ` (Status: ${response.status_code})`; + } + if (response?.error) { + errorMsg += `: ${response.error}`; + } else if (response?.response_preview) { + // Try to parse and extract meaningful error from response + // Specific to N8N at the moment, as there is no specification for that + // We could just display as is, but decided here to dig a little bit more. + try { + const preview = JSON.parse(response.response_preview); + if (preview.message) { + errorMsg += `: ${preview.message}`; + } + } catch { + // If not JSON, just show the preview text (truncated) + const previewText = response.response_preview.substring(0, 150); + errorMsg += `: ${previewText}`; + } + } else if (response?.message) { + errorMsg += `: ${response.message}`; + } + setWebhookTestResult(errorMsg); + } + } catch (error) { + console.error("Error testing webhook:", error); + setWebhookTestResult("❌ Failed to test webhook. Please check your URL."); + } finally { + setTestingWebhook(false); + } + + // Clear result after 5 seconds + setTimeout(() => { + setWebhookTestResult(null); + }, 5000); + }; + const handleSaveRoom = async () => { try { if (RESERVED_PATHS.includes(room.name)) { @@ -172,6 +244,8 @@ export default function RoomsList() { recording_type: room.recordingType, recording_trigger: room.recordingTrigger, is_shared: room.isShared, + webhook_url: room.webhookUrl, + webhook_secret: room.webhookSecret, }; if (isEditing) { @@ -190,7 +264,7 @@ export default function RoomsList() { setEditRoomId(""); setNameError(""); refetch(); - onClose(); + handleCloseDialog(); } catch (err) { if ( err instanceof ApiError && @@ -206,18 +280,46 @@ export default function RoomsList() { } }; - const handleEditRoom = (roomId, roomData) => { - setRoom({ - name: roomData.name, - zulipAutoPost: roomData.zulip_auto_post, - zulipStream: roomData.zulip_stream, - zulipTopic: roomData.zulip_topic, - isLocked: roomData.is_locked, - roomMode: roomData.room_mode, - recordingType: roomData.recording_type, - recordingTrigger: roomData.recording_trigger, - isShared: roomData.is_shared, - }); + const handleEditRoom = async (roomId, roomData) => { + // Reset states + setShowWebhookSecret(false); + setWebhookTestResult(null); + + // Fetch full room details to get webhook fields + try { + const detailedRoom = await api?.v1RoomsGet({ roomId }); + if (detailedRoom) { + setRoom({ + name: detailedRoom.name, + zulipAutoPost: detailedRoom.zulip_auto_post, + zulipStream: detailedRoom.zulip_stream, + zulipTopic: detailedRoom.zulip_topic, + isLocked: detailedRoom.is_locked, + roomMode: detailedRoom.room_mode, + recordingType: detailedRoom.recording_type, + recordingTrigger: detailedRoom.recording_trigger, + isShared: detailedRoom.is_shared, + webhookUrl: detailedRoom.webhook_url || "", + webhookSecret: detailedRoom.webhook_secret || "", + }); + } + } catch (error) { + console.error("Failed to fetch room details, using list data:", error); + // Fallback to using the data from the list + setRoom({ + name: roomData.name, + zulipAutoPost: roomData.zulip_auto_post, + zulipStream: roomData.zulip_stream, + zulipTopic: roomData.zulip_topic, + isLocked: roomData.is_locked, + roomMode: roomData.room_mode, + recordingType: roomData.recording_type, + recordingTrigger: roomData.recording_trigger, + isShared: roomData.is_shared, + webhookUrl: roomData.webhook_url || "", + webhookSecret: roomData.webhook_secret || "", + }); + } setEditRoomId(roomId); setIsEditing(true); setNameError(""); @@ -250,9 +352,9 @@ export default function RoomsList() { }); }; - const myRooms: Room[] = + const myRooms: RoomDetails[] = response?.items.filter((roomData) => !roomData.is_shared) || []; - const sharedRooms: Room[] = + const sharedRooms: RoomDetails[] = response?.items.filter((roomData) => roomData.is_shared) || []; if (loading && !response) @@ -287,6 +389,8 @@ export default function RoomsList() { setIsEditing(false); setRoom(roomInitialState); setNameError(""); + setShowWebhookSecret(false); + setWebhookTestResult(null); onOpen(); }} > @@ -296,7 +400,7 @@ export default function RoomsList() { (e.open ? onOpen() : onClose())} + onOpenChange={(e) => (e.open ? onOpen() : handleCloseDialog())} size="lg" > @@ -533,6 +637,109 @@ export default function RoomsList() { + + {/* Webhook Configuration Section */} + + Webhook URL + + + Optional: URL to receive notifications when transcripts are + ready + + + + {room.webhookUrl && ( + <> + + Webhook Secret + + + {isEditing && room.webhookSecret && ( + + setShowWebhookSecret(!showWebhookSecret) + } + > + {showWebhookSecret ? : } + + )} + + + Used for HMAC signature verification (auto-generated if + left empty) + + + + {isEditing && ( + <> + + + {webhookTestResult && ( +
+ {webhookTestResult} +
+ )} +
+ + )} + + )} + -