mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 12:19:06 +00:00
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
This commit is contained in:
212
server/docs/webhook.md
Normal file
212
server/docs/webhook.md
Normal file
@@ -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\n<v Speaker 1>Welcome 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\n<v Speaker 1>Welcome 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\n<v Speaker 2>Let'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)"
|
||||
}
|
||||
```
|
||||
@@ -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 ###
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
258
server/reflector/worker/webhook.py
Normal file
258
server/reflector/worker/webhook.py
Normal file
@@ -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)}",
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Topic[]>([]);
|
||||
const [nameError, setNameError] = useState("");
|
||||
const [linkCopied, setLinkCopied] = useState("");
|
||||
const [testingWebhook, setTestingWebhook] = useState(false);
|
||||
const [webhookTestResult, setWebhookTestResult] = useState<string | null>(
|
||||
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,7 +280,32 @@ export default function RoomsList() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditRoom = (roomId, roomData) => {
|
||||
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,
|
||||
@@ -217,7 +316,10 @@ export default function RoomsList() {
|
||||
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() {
|
||||
|
||||
<Dialog.Root
|
||||
open={open}
|
||||
onOpenChange={(e) => (e.open ? onOpen() : onClose())}
|
||||
onOpenChange={(e) => (e.open ? onOpen() : handleCloseDialog())}
|
||||
size="lg"
|
||||
>
|
||||
<Dialog.Backdrop />
|
||||
@@ -533,6 +637,109 @@ export default function RoomsList() {
|
||||
</Select.Positioner>
|
||||
</Select.Root>
|
||||
</Field.Root>
|
||||
|
||||
{/* Webhook Configuration Section */}
|
||||
<Field.Root mt={8}>
|
||||
<Field.Label>Webhook URL</Field.Label>
|
||||
<Input
|
||||
name="webhookUrl"
|
||||
type="url"
|
||||
placeholder="https://example.com/webhook"
|
||||
value={room.webhookUrl}
|
||||
onChange={handleRoomChange}
|
||||
/>
|
||||
<Field.HelperText>
|
||||
Optional: URL to receive notifications when transcripts are
|
||||
ready
|
||||
</Field.HelperText>
|
||||
</Field.Root>
|
||||
|
||||
{room.webhookUrl && (
|
||||
<>
|
||||
<Field.Root mt={4}>
|
||||
<Field.Label>Webhook Secret</Field.Label>
|
||||
<Flex gap={2}>
|
||||
<Input
|
||||
name="webhookSecret"
|
||||
type={showWebhookSecret ? "text" : "password"}
|
||||
value={room.webhookSecret}
|
||||
onChange={handleRoomChange}
|
||||
placeholder={
|
||||
isEditing && room.webhookSecret
|
||||
? "••••••••"
|
||||
: "Leave empty to auto-generate"
|
||||
}
|
||||
flex="1"
|
||||
/>
|
||||
{isEditing && room.webhookSecret && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={
|
||||
showWebhookSecret ? "Hide secret" : "Show secret"
|
||||
}
|
||||
onClick={() =>
|
||||
setShowWebhookSecret(!showWebhookSecret)
|
||||
}
|
||||
>
|
||||
{showWebhookSecret ? <LuEyeOff /> : <LuEye />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Flex>
|
||||
<Field.HelperText>
|
||||
Used for HMAC signature verification (auto-generated if
|
||||
left empty)
|
||||
</Field.HelperText>
|
||||
</Field.Root>
|
||||
|
||||
{isEditing && (
|
||||
<>
|
||||
<Flex
|
||||
mt={2}
|
||||
gap={2}
|
||||
alignItems="flex-start"
|
||||
direction="column"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTestWebhook}
|
||||
disabled={testingWebhook || !room.webhookUrl}
|
||||
>
|
||||
{testingWebhook ? (
|
||||
<>
|
||||
<Spinner size="xs" mr={2} />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
"Test Webhook"
|
||||
)}
|
||||
</Button>
|
||||
{webhookTestResult && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
wordBreak: "break-word",
|
||||
maxWidth: "100%",
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: webhookTestResult.startsWith(
|
||||
"✅",
|
||||
)
|
||||
? "#f0fdf4"
|
||||
: "#fef2f2",
|
||||
border: `1px solid ${webhookTestResult.startsWith("✅") ? "#86efac" : "#fca5a5"}`,
|
||||
}}
|
||||
>
|
||||
{webhookTestResult}
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field.Root mt={4}>
|
||||
<Checkbox.Root
|
||||
name="isShared"
|
||||
@@ -557,7 +764,7 @@ export default function RoomsList() {
|
||||
</Field.Root>
|
||||
</Dialog.Body>
|
||||
<Dialog.Footer>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
<Button variant="ghost" onClick={handleCloseDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useError } from "../../(errors)/errorContext";
|
||||
import useApi from "../../lib/useApi";
|
||||
import { Page_Room_ } from "../../api";
|
||||
import { Page_RoomDetails_ } from "../../api";
|
||||
import { PaginationPage } from "../browse/_components/Pagination";
|
||||
|
||||
type RoomList = {
|
||||
response: Page_Room_ | null;
|
||||
response: Page_RoomDetails_ | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
@@ -13,7 +13,7 @@ type RoomList = {
|
||||
|
||||
//always protected
|
||||
const useRoomList = (page: PaginationPage): RoomList => {
|
||||
const [response, setResponse] = useState<Page_Room_ | null>(null);
|
||||
const [response, setResponse] = useState<Page_RoomDetails_ | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setErrorState] = useState<Error | null>(null);
|
||||
const { setError } = useError();
|
||||
|
||||
@@ -91,6 +91,14 @@ export const $CreateRoom = {
|
||||
type: "boolean",
|
||||
title: "Is Shared",
|
||||
},
|
||||
webhook_url: {
|
||||
type: "string",
|
||||
title: "Webhook Url",
|
||||
},
|
||||
webhook_secret: {
|
||||
type: "string",
|
||||
title: "Webhook Secret",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: [
|
||||
@@ -103,6 +111,8 @@ export const $CreateRoom = {
|
||||
"recording_type",
|
||||
"recording_trigger",
|
||||
"is_shared",
|
||||
"webhook_url",
|
||||
"webhook_secret",
|
||||
],
|
||||
title: "CreateRoom",
|
||||
} as const;
|
||||
@@ -809,11 +819,11 @@ export const $Page_GetTranscriptMinimal_ = {
|
||||
title: "Page[GetTranscriptMinimal]",
|
||||
} as const;
|
||||
|
||||
export const $Page_Room_ = {
|
||||
export const $Page_RoomDetails_ = {
|
||||
properties: {
|
||||
items: {
|
||||
items: {
|
||||
$ref: "#/components/schemas/Room",
|
||||
$ref: "#/components/schemas/RoomDetails",
|
||||
},
|
||||
type: "array",
|
||||
title: "Items",
|
||||
@@ -869,7 +879,7 @@ export const $Page_Room_ = {
|
||||
},
|
||||
type: "object",
|
||||
required: ["items", "page", "size"],
|
||||
title: "Page[Room]",
|
||||
title: "Page[RoomDetails]",
|
||||
} as const;
|
||||
|
||||
export const $Participant = {
|
||||
@@ -969,6 +979,86 @@ export const $Room = {
|
||||
title: "Room",
|
||||
} as const;
|
||||
|
||||
export const $RoomDetails = {
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
title: "Id",
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
title: "Name",
|
||||
},
|
||||
user_id: {
|
||||
type: "string",
|
||||
title: "User Id",
|
||||
},
|
||||
created_at: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
title: "Created At",
|
||||
},
|
||||
zulip_auto_post: {
|
||||
type: "boolean",
|
||||
title: "Zulip Auto Post",
|
||||
},
|
||||
zulip_stream: {
|
||||
type: "string",
|
||||
title: "Zulip Stream",
|
||||
},
|
||||
zulip_topic: {
|
||||
type: "string",
|
||||
title: "Zulip Topic",
|
||||
},
|
||||
is_locked: {
|
||||
type: "boolean",
|
||||
title: "Is Locked",
|
||||
},
|
||||
room_mode: {
|
||||
type: "string",
|
||||
title: "Room Mode",
|
||||
},
|
||||
recording_type: {
|
||||
type: "string",
|
||||
title: "Recording Type",
|
||||
},
|
||||
recording_trigger: {
|
||||
type: "string",
|
||||
title: "Recording Trigger",
|
||||
},
|
||||
is_shared: {
|
||||
type: "boolean",
|
||||
title: "Is Shared",
|
||||
},
|
||||
webhook_url: {
|
||||
type: "string",
|
||||
title: "Webhook Url",
|
||||
},
|
||||
webhook_secret: {
|
||||
type: "string",
|
||||
title: "Webhook Secret",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: [
|
||||
"id",
|
||||
"name",
|
||||
"user_id",
|
||||
"created_at",
|
||||
"zulip_auto_post",
|
||||
"zulip_stream",
|
||||
"zulip_topic",
|
||||
"is_locked",
|
||||
"room_mode",
|
||||
"recording_type",
|
||||
"recording_trigger",
|
||||
"is_shared",
|
||||
"webhook_url",
|
||||
"webhook_secret",
|
||||
],
|
||||
title: "RoomDetails",
|
||||
} as const;
|
||||
|
||||
export const $RtcOffer = {
|
||||
properties: {
|
||||
sdp: {
|
||||
@@ -1351,6 +1441,14 @@ export const $UpdateRoom = {
|
||||
type: "boolean",
|
||||
title: "Is Shared",
|
||||
},
|
||||
webhook_url: {
|
||||
type: "string",
|
||||
title: "Webhook Url",
|
||||
},
|
||||
webhook_secret: {
|
||||
type: "string",
|
||||
title: "Webhook Secret",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: [
|
||||
@@ -1363,6 +1461,8 @@ export const $UpdateRoom = {
|
||||
"recording_type",
|
||||
"recording_trigger",
|
||||
"is_shared",
|
||||
"webhook_url",
|
||||
"webhook_secret",
|
||||
],
|
||||
title: "UpdateRoom",
|
||||
} as const;
|
||||
@@ -1541,6 +1641,50 @@ export const $ValidationError = {
|
||||
title: "ValidationError",
|
||||
} as const;
|
||||
|
||||
export const $WebhookTestResult = {
|
||||
properties: {
|
||||
success: {
|
||||
type: "boolean",
|
||||
title: "Success",
|
||||
},
|
||||
message: {
|
||||
type: "string",
|
||||
title: "Message",
|
||||
default: "",
|
||||
},
|
||||
error: {
|
||||
type: "string",
|
||||
title: "Error",
|
||||
default: "",
|
||||
},
|
||||
status_code: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "integer",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Status Code",
|
||||
},
|
||||
response_preview: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Response Preview",
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: ["success"],
|
||||
title: "WebhookTestResult",
|
||||
} as const;
|
||||
|
||||
export const $WherebyWebhookEvent = {
|
||||
properties: {
|
||||
apiVersion: {
|
||||
|
||||
@@ -10,12 +10,16 @@ import type {
|
||||
V1RoomsListResponse,
|
||||
V1RoomsCreateData,
|
||||
V1RoomsCreateResponse,
|
||||
V1RoomsGetData,
|
||||
V1RoomsGetResponse,
|
||||
V1RoomsUpdateData,
|
||||
V1RoomsUpdateResponse,
|
||||
V1RoomsDeleteData,
|
||||
V1RoomsDeleteResponse,
|
||||
V1RoomsCreateMeetingData,
|
||||
V1RoomsCreateMeetingResponse,
|
||||
V1RoomsTestWebhookData,
|
||||
V1RoomsTestWebhookResponse,
|
||||
V1TranscriptsListData,
|
||||
V1TranscriptsListResponse,
|
||||
V1TranscriptsCreateData,
|
||||
@@ -118,7 +122,7 @@ export class DefaultService {
|
||||
* @param data The data for the request.
|
||||
* @param data.page Page number
|
||||
* @param data.size Page size
|
||||
* @returns Page_Room_ Successful Response
|
||||
* @returns Page_RoomDetails_ Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public v1RoomsList(
|
||||
@@ -158,12 +162,34 @@ export class DefaultService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rooms Get
|
||||
* @param data The data for the request.
|
||||
* @param data.roomId
|
||||
* @returns RoomDetails Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public v1RoomsGet(
|
||||
data: V1RoomsGetData,
|
||||
): CancelablePromise<V1RoomsGetResponse> {
|
||||
return this.httpRequest.request({
|
||||
method: "GET",
|
||||
url: "/v1/rooms/{room_id}",
|
||||
path: {
|
||||
room_id: data.roomId,
|
||||
},
|
||||
errors: {
|
||||
422: "Validation Error",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rooms Update
|
||||
* @param data The data for the request.
|
||||
* @param data.roomId
|
||||
* @param data.requestBody
|
||||
* @returns Room Successful Response
|
||||
* @returns RoomDetails Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public v1RoomsUpdate(
|
||||
@@ -227,6 +253,29 @@ export class DefaultService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rooms Test Webhook
|
||||
* Test webhook configuration by sending a sample payload.
|
||||
* @param data The data for the request.
|
||||
* @param data.roomId
|
||||
* @returns WebhookTestResult Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public v1RoomsTestWebhook(
|
||||
data: V1RoomsTestWebhookData,
|
||||
): CancelablePromise<V1RoomsTestWebhookResponse> {
|
||||
return this.httpRequest.request({
|
||||
method: "POST",
|
||||
url: "/v1/rooms/{room_id}/webhook/test",
|
||||
path: {
|
||||
room_id: data.roomId,
|
||||
},
|
||||
errors: {
|
||||
422: "Validation Error",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcripts List
|
||||
* @param data The data for the request.
|
||||
|
||||
@@ -24,6 +24,8 @@ export type CreateRoom = {
|
||||
recording_type: string;
|
||||
recording_trigger: string;
|
||||
is_shared: boolean;
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
};
|
||||
|
||||
export type CreateTranscript = {
|
||||
@@ -147,8 +149,8 @@ export type Page_GetTranscriptMinimal_ = {
|
||||
pages?: number | null;
|
||||
};
|
||||
|
||||
export type Page_Room_ = {
|
||||
items: Array<Room>;
|
||||
export type Page_RoomDetails_ = {
|
||||
items: Array<RoomDetails>;
|
||||
total?: number | null;
|
||||
page: number | null;
|
||||
size: number | null;
|
||||
@@ -176,6 +178,23 @@ export type Room = {
|
||||
is_shared: boolean;
|
||||
};
|
||||
|
||||
export type RoomDetails = {
|
||||
id: string;
|
||||
name: string;
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
zulip_auto_post: boolean;
|
||||
zulip_stream: string;
|
||||
zulip_topic: string;
|
||||
is_locked: boolean;
|
||||
room_mode: string;
|
||||
recording_type: string;
|
||||
recording_trigger: string;
|
||||
is_shared: boolean;
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
};
|
||||
|
||||
export type RtcOffer = {
|
||||
sdp: string;
|
||||
type: string;
|
||||
@@ -281,6 +300,8 @@ export type UpdateRoom = {
|
||||
recording_type: string;
|
||||
recording_trigger: string;
|
||||
is_shared: boolean;
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
};
|
||||
|
||||
export type UpdateTranscript = {
|
||||
@@ -307,6 +328,14 @@ export type ValidationError = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type WebhookTestResult = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
status_code?: number | null;
|
||||
response_preview?: string | null;
|
||||
};
|
||||
|
||||
export type WherebyWebhookEvent = {
|
||||
apiVersion: string;
|
||||
id: string;
|
||||
@@ -350,7 +379,7 @@ export type V1RoomsListData = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export type V1RoomsListResponse = Page_Room_;
|
||||
export type V1RoomsListResponse = Page_RoomDetails_;
|
||||
|
||||
export type V1RoomsCreateData = {
|
||||
requestBody: CreateRoom;
|
||||
@@ -358,12 +387,18 @@ export type V1RoomsCreateData = {
|
||||
|
||||
export type V1RoomsCreateResponse = Room;
|
||||
|
||||
export type V1RoomsGetData = {
|
||||
roomId: string;
|
||||
};
|
||||
|
||||
export type V1RoomsGetResponse = RoomDetails;
|
||||
|
||||
export type V1RoomsUpdateData = {
|
||||
requestBody: UpdateRoom;
|
||||
roomId: string;
|
||||
};
|
||||
|
||||
export type V1RoomsUpdateResponse = Room;
|
||||
export type V1RoomsUpdateResponse = RoomDetails;
|
||||
|
||||
export type V1RoomsDeleteData = {
|
||||
roomId: string;
|
||||
@@ -377,6 +412,12 @@ export type V1RoomsCreateMeetingData = {
|
||||
|
||||
export type V1RoomsCreateMeetingResponse = Meeting;
|
||||
|
||||
export type V1RoomsTestWebhookData = {
|
||||
roomId: string;
|
||||
};
|
||||
|
||||
export type V1RoomsTestWebhookResponse = WebhookTestResult;
|
||||
|
||||
export type V1TranscriptsListData = {
|
||||
/**
|
||||
* Page number
|
||||
@@ -613,7 +654,7 @@ export type $OpenApiTs = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: Page_Room_;
|
||||
200: Page_RoomDetails_;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
@@ -635,13 +676,26 @@ export type $OpenApiTs = {
|
||||
};
|
||||
};
|
||||
"/v1/rooms/{room_id}": {
|
||||
get: {
|
||||
req: V1RoomsGetData;
|
||||
res: {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: RoomDetails;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HTTPValidationError;
|
||||
};
|
||||
};
|
||||
patch: {
|
||||
req: V1RoomsUpdateData;
|
||||
res: {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: Room;
|
||||
200: RoomDetails;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
@@ -677,6 +731,21 @@ export type $OpenApiTs = {
|
||||
};
|
||||
};
|
||||
};
|
||||
"/v1/rooms/{room_id}/webhook/test": {
|
||||
post: {
|
||||
req: V1RoomsTestWebhookData;
|
||||
res: {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: WebhookTestResult;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HTTPValidationError;
|
||||
};
|
||||
};
|
||||
};
|
||||
"/v1/transcripts": {
|
||||
get: {
|
||||
req: V1TranscriptsListData;
|
||||
|
||||
Reference in New Issue
Block a user