mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
merge
This commit is contained in:
29
CHANGELOG.md
29
CHANGELOG.md
@@ -1,5 +1,34 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.8.2](https://github.com/Monadical-SAS/reflector/compare/v0.8.1...v0.8.2) (2025-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* search-logspam ([#593](https://github.com/Monadical-SAS/reflector/issues/593)) ([695d1a9](https://github.com/Monadical-SAS/reflector/commit/695d1a957d4cd862753049f9beed88836cabd5ab))
|
||||||
|
|
||||||
|
## [0.8.1](https://github.com/Monadical-SAS/reflector/compare/v0.8.0...v0.8.1) (2025-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* make webhook secret/url allowing null ([#590](https://github.com/Monadical-SAS/reflector/issues/590)) ([84a3812](https://github.com/Monadical-SAS/reflector/commit/84a381220bc606231d08d6f71d4babc818fa3c75))
|
||||||
|
|
||||||
|
## [0.8.0](https://github.com/Monadical-SAS/reflector/compare/v0.7.3...v0.8.0) (2025-08-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **cleanup:** add automatic data retention for public instances ([#574](https://github.com/Monadical-SAS/reflector/issues/574)) ([6f0c7c1](https://github.com/Monadical-SAS/reflector/commit/6f0c7c1a5e751713366886c8e764c2009e12ba72))
|
||||||
|
* **rooms:** add webhook for transcript completion ([#578](https://github.com/Monadical-SAS/reflector/issues/578)) ([88ed7cf](https://github.com/Monadical-SAS/reflector/commit/88ed7cfa7804794b9b54cad4c3facc8a98cf85fd))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* file pipeline status reporting and websocket updates ([#589](https://github.com/Monadical-SAS/reflector/issues/589)) ([9dfd769](https://github.com/Monadical-SAS/reflector/commit/9dfd76996f851cc52be54feea078adbc0816dc57))
|
||||||
|
* Igor/evaluation ([#575](https://github.com/Monadical-SAS/reflector/issues/575)) ([124ce03](https://github.com/Monadical-SAS/reflector/commit/124ce03bf86044c18313d27228a25da4bc20c9c5))
|
||||||
|
* optimize parakeet transcription batching algorithm ([#577](https://github.com/Monadical-SAS/reflector/issues/577)) ([7030e0f](https://github.com/Monadical-SAS/reflector/commit/7030e0f23649a8cf6c1eb6d5889684a41ce849ec))
|
||||||
|
|
||||||
## [0.7.3](https://github.com/Monadical-SAS/reflector/compare/v0.7.2...v0.7.3) (2025-08-22)
|
## [0.7.3](https://github.com/Monadical-SAS/reflector/compare/v0.7.2...v0.7.3) (2025-08-22)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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 ###
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""webhook url and secret null by default
|
||||||
|
|
||||||
|
|
||||||
|
Revision ID: 61882a919591
|
||||||
|
Revises: 0194f65cd6d3
|
||||||
|
Create Date: 2025-08-29 11:46:36.738091
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "61882a919591"
|
||||||
|
down_revision: Union[str, None] = "0194f65cd6d3"
|
||||||
|
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! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlite3 import IntegrityError
|
from sqlite3 import IntegrityError
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -40,6 +41,8 @@ rooms = sqlalchemy.Table(
|
|||||||
sqlalchemy.Column(
|
sqlalchemy.Column(
|
||||||
"is_shared", sqlalchemy.Boolean, nullable=False, server_default=false()
|
"is_shared", sqlalchemy.Boolean, nullable=False, server_default=false()
|
||||||
),
|
),
|
||||||
|
sqlalchemy.Column("webhook_url", sqlalchemy.String, nullable=True),
|
||||||
|
sqlalchemy.Column("webhook_secret", sqlalchemy.String, nullable=True),
|
||||||
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
|
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +62,8 @@ class Room(BaseModel):
|
|||||||
"none", "prompt", "automatic", "automatic-2nd-participant"
|
"none", "prompt", "automatic", "automatic-2nd-participant"
|
||||||
] = "automatic-2nd-participant"
|
] = "automatic-2nd-participant"
|
||||||
is_shared: bool = False
|
is_shared: bool = False
|
||||||
|
webhook_url: str | None = None
|
||||||
|
webhook_secret: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class RoomController:
|
class RoomController:
|
||||||
@@ -107,10 +112,15 @@ class RoomController:
|
|||||||
recording_type: str,
|
recording_type: str,
|
||||||
recording_trigger: str,
|
recording_trigger: str,
|
||||||
is_shared: bool,
|
is_shared: bool,
|
||||||
|
webhook_url: str = "",
|
||||||
|
webhook_secret: str = "",
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Add a new room
|
Add a new room
|
||||||
"""
|
"""
|
||||||
|
if webhook_url and not webhook_secret:
|
||||||
|
webhook_secret = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
room = Room(
|
room = Room(
|
||||||
name=name,
|
name=name,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -122,6 +132,8 @@ class RoomController:
|
|||||||
recording_type=recording_type,
|
recording_type=recording_type,
|
||||||
recording_trigger=recording_trigger,
|
recording_trigger=recording_trigger,
|
||||||
is_shared=is_shared,
|
is_shared=is_shared,
|
||||||
|
webhook_url=webhook_url,
|
||||||
|
webhook_secret=webhook_secret,
|
||||||
)
|
)
|
||||||
query = rooms.insert().values(**room.model_dump())
|
query = rooms.insert().values(**room.model_dump())
|
||||||
try:
|
try:
|
||||||
@@ -134,6 +146,9 @@ class RoomController:
|
|||||||
"""
|
"""
|
||||||
Update a room fields with key/values in values
|
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)
|
query = rooms.update().where(rooms.c.id == room.id).values(**values)
|
||||||
try:
|
try:
|
||||||
await get_database().execute(query)
|
await get_database().execute(query)
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ from typing import Annotated, Any, Dict, Iterator
|
|||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import webvtt
|
import webvtt
|
||||||
|
from databases.interfaces import Record as DbRecord
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
BaseModel,
|
BaseModel,
|
||||||
Field,
|
Field,
|
||||||
NonNegativeFloat,
|
NonNegativeFloat,
|
||||||
NonNegativeInt,
|
NonNegativeInt,
|
||||||
|
TypeAdapter,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
constr,
|
constr,
|
||||||
field_serializer,
|
field_serializer,
|
||||||
@@ -24,6 +26,7 @@ from reflector.db.rooms import rooms
|
|||||||
from reflector.db.transcripts import SourceKind, transcripts
|
from reflector.db.transcripts import SourceKind, transcripts
|
||||||
from reflector.db.utils import is_postgresql
|
from reflector.db.utils import is_postgresql
|
||||||
from reflector.logger import logger
|
from reflector.logger import logger
|
||||||
|
from reflector.utils.string import NonEmptyString, try_parse_non_empty_string
|
||||||
|
|
||||||
DEFAULT_SEARCH_LIMIT = 20
|
DEFAULT_SEARCH_LIMIT = 20
|
||||||
SNIPPET_CONTEXT_LENGTH = 50 # Characters before/after match to include
|
SNIPPET_CONTEXT_LENGTH = 50 # Characters before/after match to include
|
||||||
@@ -31,12 +34,13 @@ DEFAULT_SNIPPET_MAX_LENGTH = NonNegativeInt(150)
|
|||||||
DEFAULT_MAX_SNIPPETS = NonNegativeInt(3)
|
DEFAULT_MAX_SNIPPETS = NonNegativeInt(3)
|
||||||
LONG_SUMMARY_MAX_SNIPPETS = 2
|
LONG_SUMMARY_MAX_SNIPPETS = 2
|
||||||
|
|
||||||
SearchQueryBase = constr(min_length=0, strip_whitespace=True)
|
SearchQueryBase = constr(min_length=1, strip_whitespace=True)
|
||||||
SearchLimitBase = Annotated[int, Field(ge=1, le=100)]
|
SearchLimitBase = Annotated[int, Field(ge=1, le=100)]
|
||||||
SearchOffsetBase = Annotated[int, Field(ge=0)]
|
SearchOffsetBase = Annotated[int, Field(ge=0)]
|
||||||
SearchTotalBase = Annotated[int, Field(ge=0)]
|
SearchTotalBase = Annotated[int, Field(ge=0)]
|
||||||
|
|
||||||
SearchQuery = Annotated[SearchQueryBase, Field(description="Search query text")]
|
SearchQuery = Annotated[SearchQueryBase, Field(description="Search query text")]
|
||||||
|
search_query_adapter = TypeAdapter(SearchQuery)
|
||||||
SearchLimit = Annotated[SearchLimitBase, Field(description="Results per page")]
|
SearchLimit = Annotated[SearchLimitBase, Field(description="Results per page")]
|
||||||
SearchOffset = Annotated[
|
SearchOffset = Annotated[
|
||||||
SearchOffsetBase, Field(description="Number of results to skip")
|
SearchOffsetBase, Field(description="Number of results to skip")
|
||||||
@@ -88,7 +92,7 @@ class WebVTTProcessor:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_snippets(
|
def generate_snippets(
|
||||||
webvtt_content: WebVTTContent,
|
webvtt_content: WebVTTContent,
|
||||||
query: str,
|
query: SearchQuery,
|
||||||
max_snippets: NonNegativeInt = DEFAULT_MAX_SNIPPETS,
|
max_snippets: NonNegativeInt = DEFAULT_MAX_SNIPPETS,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Generate snippets from WebVTT content."""
|
"""Generate snippets from WebVTT content."""
|
||||||
@@ -125,7 +129,7 @@ class SnippetCandidate:
|
|||||||
class SearchParameters(BaseModel):
|
class SearchParameters(BaseModel):
|
||||||
"""Validated search parameters for full-text search."""
|
"""Validated search parameters for full-text search."""
|
||||||
|
|
||||||
query_text: SearchQuery
|
query_text: SearchQuery | None = None
|
||||||
limit: SearchLimit = DEFAULT_SEARCH_LIMIT
|
limit: SearchLimit = DEFAULT_SEARCH_LIMIT
|
||||||
offset: SearchOffset = 0
|
offset: SearchOffset = 0
|
||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
@@ -199,15 +203,13 @@ class SnippetGenerator:
|
|||||||
prev_start = start
|
prev_start = start
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def count_matches(text: str, query: str) -> NonNegativeInt:
|
def count_matches(text: str, query: SearchQuery) -> NonNegativeInt:
|
||||||
"""Count total number of matches for a query in text."""
|
"""Count total number of matches for a query in text."""
|
||||||
ZERO = NonNegativeInt(0)
|
ZERO = NonNegativeInt(0)
|
||||||
if not text:
|
if not text:
|
||||||
logger.warning("Empty text for search query in count_matches")
|
logger.warning("Empty text for search query in count_matches")
|
||||||
return ZERO
|
return ZERO
|
||||||
if not query:
|
assert query is not None
|
||||||
logger.warning("Empty query for search text in count_matches")
|
|
||||||
return ZERO
|
|
||||||
return NonNegativeInt(
|
return NonNegativeInt(
|
||||||
sum(1 for _ in SnippetGenerator.find_all_matches(text, query))
|
sum(1 for _ in SnippetGenerator.find_all_matches(text, query))
|
||||||
)
|
)
|
||||||
@@ -243,13 +245,14 @@ class SnippetGenerator:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def generate(
|
def generate(
|
||||||
text: str,
|
text: str,
|
||||||
query: str,
|
query: SearchQuery,
|
||||||
max_length: NonNegativeInt = DEFAULT_SNIPPET_MAX_LENGTH,
|
max_length: NonNegativeInt = DEFAULT_SNIPPET_MAX_LENGTH,
|
||||||
max_snippets: NonNegativeInt = DEFAULT_MAX_SNIPPETS,
|
max_snippets: NonNegativeInt = DEFAULT_MAX_SNIPPETS,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Generate snippets from text."""
|
"""Generate snippets from text."""
|
||||||
if not text or not query:
|
assert query is not None
|
||||||
logger.warning("Empty text or query for generate_snippets")
|
if not text:
|
||||||
|
logger.warning("Empty text for generate_snippets")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
candidates = (
|
candidates = (
|
||||||
@@ -270,7 +273,7 @@ class SnippetGenerator:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def from_summary(
|
def from_summary(
|
||||||
summary: str,
|
summary: str,
|
||||||
query: str,
|
query: SearchQuery,
|
||||||
max_snippets: NonNegativeInt = LONG_SUMMARY_MAX_SNIPPETS,
|
max_snippets: NonNegativeInt = LONG_SUMMARY_MAX_SNIPPETS,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Generate snippets from summary text."""
|
"""Generate snippets from summary text."""
|
||||||
@@ -278,9 +281,9 @@ class SnippetGenerator:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def combine_sources(
|
def combine_sources(
|
||||||
summary: str | None,
|
summary: NonEmptyString | None,
|
||||||
webvtt: WebVTTContent | None,
|
webvtt: WebVTTContent | None,
|
||||||
query: str,
|
query: SearchQuery,
|
||||||
max_total: NonNegativeInt = DEFAULT_MAX_SNIPPETS,
|
max_total: NonNegativeInt = DEFAULT_MAX_SNIPPETS,
|
||||||
) -> tuple[list[str], NonNegativeInt]:
|
) -> tuple[list[str], NonNegativeInt]:
|
||||||
"""Combine snippets from multiple sources and return total match count.
|
"""Combine snippets from multiple sources and return total match count.
|
||||||
@@ -289,6 +292,11 @@ class SnippetGenerator:
|
|||||||
|
|
||||||
snippets can be empty for real in case of e.g. title match
|
snippets can be empty for real in case of e.g. title match
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
assert (
|
||||||
|
summary is not None or webvtt is not None
|
||||||
|
), "At least one source must be present"
|
||||||
|
|
||||||
webvtt_matches = 0
|
webvtt_matches = 0
|
||||||
summary_matches = 0
|
summary_matches = 0
|
||||||
|
|
||||||
@@ -355,8 +363,8 @@ class SearchController:
|
|||||||
else_=rooms.c.name,
|
else_=rooms.c.name,
|
||||||
).label("room_name"),
|
).label("room_name"),
|
||||||
]
|
]
|
||||||
|
search_query = None
|
||||||
if params.query_text:
|
if params.query_text is not None:
|
||||||
search_query = sqlalchemy.func.websearch_to_tsquery(
|
search_query = sqlalchemy.func.websearch_to_tsquery(
|
||||||
"english", params.query_text
|
"english", params.query_text
|
||||||
)
|
)
|
||||||
@@ -373,7 +381,9 @@ class SearchController:
|
|||||||
transcripts.join(rooms, transcripts.c.room_id == rooms.c.id, isouter=True)
|
transcripts.join(rooms, transcripts.c.room_id == rooms.c.id, isouter=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
if params.query_text:
|
if params.query_text is not None:
|
||||||
|
# because already initialized based on params.query_text presence above
|
||||||
|
assert search_query is not None
|
||||||
base_query = base_query.where(
|
base_query = base_query.where(
|
||||||
transcripts.c.search_vector_en.op("@@")(search_query)
|
transcripts.c.search_vector_en.op("@@")(search_query)
|
||||||
)
|
)
|
||||||
@@ -393,7 +403,7 @@ class SearchController:
|
|||||||
transcripts.c.source_kind == params.source_kind
|
transcripts.c.source_kind == params.source_kind
|
||||||
)
|
)
|
||||||
|
|
||||||
if params.query_text:
|
if params.query_text is not None:
|
||||||
order_by = sqlalchemy.desc(sqlalchemy.text("rank"))
|
order_by = sqlalchemy.desc(sqlalchemy.text("rank"))
|
||||||
else:
|
else:
|
||||||
order_by = sqlalchemy.desc(transcripts.c.created_at)
|
order_by = sqlalchemy.desc(transcripts.c.created_at)
|
||||||
@@ -407,19 +417,29 @@ class SearchController:
|
|||||||
)
|
)
|
||||||
total = await get_database().fetch_val(count_query)
|
total = await get_database().fetch_val(count_query)
|
||||||
|
|
||||||
def _process_result(r) -> SearchResult:
|
def _process_result(r: DbRecord) -> SearchResult:
|
||||||
r_dict: Dict[str, Any] = dict(r)
|
r_dict: Dict[str, Any] = dict(r)
|
||||||
|
|
||||||
webvtt_raw: str | None = r_dict.pop("webvtt", None)
|
webvtt_raw: str | None = r_dict.pop("webvtt", None)
|
||||||
|
webvtt: WebVTTContent | None
|
||||||
if webvtt_raw:
|
if webvtt_raw:
|
||||||
webvtt = WebVTTProcessor.parse(webvtt_raw)
|
webvtt = WebVTTProcessor.parse(webvtt_raw)
|
||||||
else:
|
else:
|
||||||
webvtt = None
|
webvtt = None
|
||||||
long_summary: str | None = r_dict.pop("long_summary", None)
|
|
||||||
|
long_summary_r: str | None = r_dict.pop("long_summary", None)
|
||||||
|
long_summary: NonEmptyString = try_parse_non_empty_string(long_summary_r)
|
||||||
room_name: str | None = r_dict.pop("room_name", None)
|
room_name: str | None = r_dict.pop("room_name", None)
|
||||||
db_result = SearchResultDB.model_validate(r_dict)
|
db_result = SearchResultDB.model_validate(r_dict)
|
||||||
|
|
||||||
snippets, total_match_count = SnippetGenerator.combine_sources(
|
at_least_one_source = webvtt is not None or long_summary is not None
|
||||||
long_summary, webvtt, params.query_text, DEFAULT_MAX_SNIPPETS
|
has_query = params.query_text is not None
|
||||||
|
snippets, total_match_count = (
|
||||||
|
SnippetGenerator.combine_sources(
|
||||||
|
long_summary, webvtt, params.query_text, DEFAULT_MAX_SNIPPETS
|
||||||
|
)
|
||||||
|
if has_query and at_least_one_source
|
||||||
|
else ([], 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
return SearchResult(
|
return SearchResult(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Uses parallel processing for transcription, diarization, and waveform generation
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import av
|
import av
|
||||||
@@ -14,7 +15,9 @@ import structlog
|
|||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
||||||
from reflector.asynctask import asynctask
|
from reflector.asynctask import asynctask
|
||||||
|
from reflector.db.rooms import rooms_controller
|
||||||
from reflector.db.transcripts import (
|
from reflector.db.transcripts import (
|
||||||
|
SourceKind,
|
||||||
Transcript,
|
Transcript,
|
||||||
TranscriptStatus,
|
TranscriptStatus,
|
||||||
transcripts_controller,
|
transcripts_controller,
|
||||||
@@ -48,6 +51,7 @@ from reflector.processors.types import (
|
|||||||
)
|
)
|
||||||
from reflector.settings import settings
|
from reflector.settings import settings
|
||||||
from reflector.storage import get_transcripts_storage
|
from reflector.storage import get_transcripts_storage
|
||||||
|
from reflector.worker.webhook import send_transcript_webhook
|
||||||
|
|
||||||
|
|
||||||
class EmptyPipeline:
|
class EmptyPipeline:
|
||||||
@@ -385,7 +389,6 @@ async def task_pipeline_file_process(*, transcript_id: str):
|
|||||||
raise Exception(f"Transcript {transcript_id} not found")
|
raise Exception(f"Transcript {transcript_id} not found")
|
||||||
|
|
||||||
pipeline = PipelineMainFile(transcript_id=transcript_id)
|
pipeline = PipelineMainFile(transcript_id=transcript_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await pipeline.set_status(transcript_id, "processing")
|
await pipeline.set_status(transcript_id, "processing")
|
||||||
|
|
||||||
@@ -402,3 +405,17 @@ async def task_pipeline_file_process(*, transcript_id: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
await pipeline.set_status(transcript_id, "error")
|
await pipeline.set_status(transcript_id, "error")
|
||||||
raise
|
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
|
||||||
|
)
|
||||||
|
|||||||
20
server/reflector/utils/string.py
Normal file
20
server/reflector/utils/string.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from pydantic import Field, TypeAdapter, constr
|
||||||
|
|
||||||
|
NonEmptyStringBase = constr(min_length=1, strip_whitespace=False)
|
||||||
|
NonEmptyString = Annotated[
|
||||||
|
NonEmptyStringBase,
|
||||||
|
Field(description="A non-empty string", min_length=1),
|
||||||
|
]
|
||||||
|
non_empty_string_adapter = TypeAdapter(NonEmptyString)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_non_empty_string(s: str) -> NonEmptyString:
|
||||||
|
return non_empty_string_adapter.validate_python(s)
|
||||||
|
|
||||||
|
|
||||||
|
def try_parse_non_empty_string(s: str) -> NonEmptyString | None:
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
return parse_non_empty_string(s)
|
||||||
@@ -15,6 +15,7 @@ from reflector.db.meetings import meetings_controller
|
|||||||
from reflector.db.rooms import rooms_controller
|
from reflector.db.rooms import rooms_controller
|
||||||
from reflector.settings import settings
|
from reflector.settings import settings
|
||||||
from reflector.whereby import create_meeting, upload_logo
|
from reflector.whereby import create_meeting, upload_logo
|
||||||
|
from reflector.worker.webhook import test_webhook
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,6 +45,11 @@ class Room(BaseModel):
|
|||||||
is_shared: bool
|
is_shared: bool
|
||||||
|
|
||||||
|
|
||||||
|
class RoomDetails(Room):
|
||||||
|
webhook_url: str | None
|
||||||
|
webhook_secret: str | None
|
||||||
|
|
||||||
|
|
||||||
class Meeting(BaseModel):
|
class Meeting(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
room_name: str
|
room_name: str
|
||||||
@@ -64,6 +70,8 @@ class CreateRoom(BaseModel):
|
|||||||
recording_type: str
|
recording_type: str
|
||||||
recording_trigger: str
|
recording_trigger: str
|
||||||
is_shared: bool
|
is_shared: bool
|
||||||
|
webhook_url: str
|
||||||
|
webhook_secret: str
|
||||||
|
|
||||||
|
|
||||||
class UpdateRoom(BaseModel):
|
class UpdateRoom(BaseModel):
|
||||||
@@ -76,16 +84,26 @@ class UpdateRoom(BaseModel):
|
|||||||
recording_type: str
|
recording_type: str
|
||||||
recording_trigger: str
|
recording_trigger: str
|
||||||
is_shared: bool
|
is_shared: bool
|
||||||
|
webhook_url: str
|
||||||
|
webhook_secret: str
|
||||||
|
|
||||||
|
|
||||||
class DeletionStatus(BaseModel):
|
class DeletionStatus(BaseModel):
|
||||||
status: str
|
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(
|
async def rooms_list(
|
||||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||||
) -> list[Room]:
|
) -> list[RoomDetails]:
|
||||||
if not user and not settings.PUBLIC_MODE:
|
if not user and not settings.PUBLIC_MODE:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
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)
|
@router.post("/rooms", response_model=Room)
|
||||||
async def rooms_create(
|
async def rooms_create(
|
||||||
room: CreateRoom,
|
room: CreateRoom,
|
||||||
@@ -117,10 +147,12 @@ async def rooms_create(
|
|||||||
recording_type=room.recording_type,
|
recording_type=room.recording_type,
|
||||||
recording_trigger=room.recording_trigger,
|
recording_trigger=room.recording_trigger,
|
||||||
is_shared=room.is_shared,
|
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(
|
async def rooms_update(
|
||||||
room_id: str,
|
room_id: str,
|
||||||
info: UpdateRoom,
|
info: UpdateRoom,
|
||||||
@@ -209,3 +241,24 @@ async def rooms_create_meeting(
|
|||||||
meeting.host_room_url = ""
|
meeting.host_room_url = ""
|
||||||
|
|
||||||
return meeting
|
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)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
from fastapi_pagination import Page
|
from fastapi_pagination import Page
|
||||||
from fastapi_pagination.ext.databases import apaginate
|
from fastapi_pagination.ext.databases import apaginate
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
from pydantic import BaseModel, Field, field_serializer
|
from pydantic import BaseModel, Field, constr, field_serializer
|
||||||
|
|
||||||
import reflector.auth as auth
|
import reflector.auth as auth
|
||||||
from reflector.db import get_database
|
from reflector.db import get_database
|
||||||
@@ -19,10 +19,10 @@ from reflector.db.search import (
|
|||||||
SearchOffsetBase,
|
SearchOffsetBase,
|
||||||
SearchParameters,
|
SearchParameters,
|
||||||
SearchQuery,
|
SearchQuery,
|
||||||
SearchQueryBase,
|
|
||||||
SearchResult,
|
SearchResult,
|
||||||
SearchTotal,
|
SearchTotal,
|
||||||
search_controller,
|
search_controller,
|
||||||
|
search_query_adapter,
|
||||||
)
|
)
|
||||||
from reflector.db.transcripts import (
|
from reflector.db.transcripts import (
|
||||||
SourceKind,
|
SourceKind,
|
||||||
@@ -114,7 +114,19 @@ class DeletionStatus(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
|
|
||||||
|
|
||||||
SearchQueryParam = Annotated[SearchQueryBase, Query(description="Search query text")]
|
SearchQueryParamBase = constr(min_length=0, strip_whitespace=True)
|
||||||
|
SearchQueryParam = Annotated[
|
||||||
|
SearchQueryParamBase, Query(description="Search query text")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# http and api standards accept "q="; we would like to handle it as the absence of query, not as "empty string query"
|
||||||
|
def parse_search_query_param(q: SearchQueryParam) -> SearchQuery | None:
|
||||||
|
if q == "":
|
||||||
|
return None
|
||||||
|
return search_query_adapter.validate_python(q)
|
||||||
|
|
||||||
|
|
||||||
SearchLimitParam = Annotated[SearchLimitBase, Query(description="Results per page")]
|
SearchLimitParam = Annotated[SearchLimitBase, Query(description="Results per page")]
|
||||||
SearchOffsetParam = Annotated[
|
SearchOffsetParam = Annotated[
|
||||||
SearchOffsetBase, Query(description="Number of results to skip")
|
SearchOffsetBase, Query(description="Number of results to skip")
|
||||||
@@ -124,7 +136,7 @@ SearchOffsetParam = Annotated[
|
|||||||
class SearchResponse(BaseModel):
|
class SearchResponse(BaseModel):
|
||||||
results: list[SearchResult]
|
results: list[SearchResult]
|
||||||
total: SearchTotal
|
total: SearchTotal
|
||||||
query: SearchQuery
|
query: SearchQuery | None = None
|
||||||
limit: SearchLimit
|
limit: SearchLimit
|
||||||
offset: SearchOffset
|
offset: SearchOffset
|
||||||
|
|
||||||
@@ -174,7 +186,7 @@ async def transcripts_search(
|
|||||||
user_id = user["sub"] if user else None
|
user_id = user["sub"] if user else None
|
||||||
|
|
||||||
search_params = SearchParameters(
|
search_params = SearchParameters(
|
||||||
query_text=q,
|
query_text=parse_search_query_param(q),
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
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)}",
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ async def test_search_postgresql_only():
|
|||||||
assert results == []
|
assert results == []
|
||||||
assert total == 0
|
assert total == 0
|
||||||
|
|
||||||
params_empty = SearchParameters(query_text="")
|
params_empty = SearchParameters(query_text=None)
|
||||||
results_empty, total_empty = await search_controller.search_transcripts(
|
results_empty, total_empty = await search_controller.search_transcripts(
|
||||||
params_empty
|
params_empty
|
||||||
)
|
)
|
||||||
@@ -34,7 +34,7 @@ async def test_search_postgresql_only():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_search_with_empty_query():
|
async def test_search_with_empty_query():
|
||||||
"""Test that empty query returns all transcripts."""
|
"""Test that empty query returns all transcripts."""
|
||||||
params = SearchParameters(query_text="")
|
params = SearchParameters(query_text=None)
|
||||||
results, total = await search_controller.search_transcripts(params)
|
results, total = await search_controller.search_transcripts(params)
|
||||||
|
|
||||||
assert isinstance(results, list)
|
assert isinstance(results, list)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Unit tests for search snippet generation."""
|
"""Unit tests for search snippet generation."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from reflector.db.search import (
|
from reflector.db.search import (
|
||||||
SnippetCandidate,
|
SnippetCandidate,
|
||||||
SnippetGenerator,
|
SnippetGenerator,
|
||||||
@@ -512,11 +514,9 @@ data visualization and data storage"""
|
|||||||
)
|
)
|
||||||
assert webvtt_count == 3
|
assert webvtt_count == 3
|
||||||
|
|
||||||
snippets_empty, count_empty = SnippetGenerator.combine_sources(
|
# combine_sources requires at least one source to be present
|
||||||
None, None, "data", max_total=3
|
with pytest.raises(AssertionError, match="At least one source must be present"):
|
||||||
)
|
SnippetGenerator.combine_sources(None, None, "data", max_total=3)
|
||||||
assert snippets_empty == []
|
|
||||||
assert count_empty == 0
|
|
||||||
|
|
||||||
def test_edge_cases(self):
|
def test_edge_cases(self):
|
||||||
"""Test edge cases for the pure functions."""
|
"""Test edge cases for the pure functions."""
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
IconButton,
|
||||||
createListCollection,
|
createListCollection,
|
||||||
useDisclosure,
|
useDisclosure,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||||
|
import useApi from "../../lib/useApi";
|
||||||
import useRoomList from "./useRoomList";
|
import useRoomList from "./useRoomList";
|
||||||
|
import { ApiError, RoomDetails } from "../../api";
|
||||||
import type { components } from "../../reflector-api";
|
import type { components } from "../../reflector-api";
|
||||||
import {
|
import {
|
||||||
useRoomCreate,
|
useRoomCreate,
|
||||||
@@ -63,6 +67,8 @@ const roomInitialState = {
|
|||||||
recordingType: "cloud",
|
recordingType: "cloud",
|
||||||
recordingTrigger: "automatic-2nd-participant",
|
recordingTrigger: "automatic-2nd-participant",
|
||||||
isShared: false,
|
isShared: false,
|
||||||
|
webhookUrl: "",
|
||||||
|
webhookSecret: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RoomsList() {
|
export default function RoomsList() {
|
||||||
@@ -89,6 +95,19 @@ export default function RoomsList() {
|
|||||||
const [nameError, setNameError] = useState("");
|
const [nameError, setNameError] = useState("");
|
||||||
const [linkCopied, setLinkCopied] = useState("");
|
const [linkCopied, setLinkCopied] = useState("");
|
||||||
const [selectedStreamId, setSelectedStreamId] = useState<number | null>(null);
|
const [selectedStreamId, setSelectedStreamId] = useState<number | null>(null);
|
||||||
|
const [testingWebhook, setTestingWebhook] = useState(false);
|
||||||
|
const [webhookTestResult, setWebhookTestResult] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
|
||||||
|
interface Stream {
|
||||||
|
stream_id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Topic {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
const createRoomMutation = useRoomCreate();
|
const createRoomMutation = useRoomCreate();
|
||||||
const updateRoomMutation = useRoomUpdate();
|
const updateRoomMutation = useRoomUpdate();
|
||||||
@@ -135,6 +154,69 @@ export default function RoomsList() {
|
|||||||
}, 2000);
|
}, 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 () => {
|
const handleSaveRoom = async () => {
|
||||||
try {
|
try {
|
||||||
if (RESERVED_PATHS.includes(room.name)) {
|
if (RESERVED_PATHS.includes(room.name)) {
|
||||||
@@ -152,6 +234,8 @@ export default function RoomsList() {
|
|||||||
recording_type: room.recordingType,
|
recording_type: room.recordingType,
|
||||||
recording_trigger: room.recordingTrigger,
|
recording_trigger: room.recordingTrigger,
|
||||||
is_shared: room.isShared,
|
is_shared: room.isShared,
|
||||||
|
webhook_url: room.webhookUrl,
|
||||||
|
webhook_secret: room.webhookSecret,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
@@ -173,6 +257,7 @@ export default function RoomsList() {
|
|||||||
setNameError("");
|
setNameError("");
|
||||||
refetch();
|
refetch();
|
||||||
onClose();
|
onClose();
|
||||||
|
handleCloseDialog();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (
|
if (
|
||||||
err?.status === 400 &&
|
err?.status === 400 &&
|
||||||
@@ -187,18 +272,46 @@ export default function RoomsList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditRoom = (roomId, roomData) => {
|
const handleEditRoom = async (roomId, roomData) => {
|
||||||
setRoom({
|
// Reset states
|
||||||
name: roomData.name,
|
setShowWebhookSecret(false);
|
||||||
zulipAutoPost: roomData.zulip_auto_post,
|
setWebhookTestResult(null);
|
||||||
zulipStream: roomData.zulip_stream,
|
|
||||||
zulipTopic: roomData.zulip_topic,
|
// Fetch full room details to get webhook fields
|
||||||
isLocked: roomData.is_locked,
|
try {
|
||||||
roomMode: roomData.room_mode,
|
const detailedRoom = await api?.v1RoomsGet({ roomId });
|
||||||
recordingType: roomData.recording_type,
|
if (detailedRoom) {
|
||||||
recordingTrigger: roomData.recording_trigger,
|
setRoom({
|
||||||
isShared: roomData.is_shared,
|
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);
|
setEditRoomId(roomId);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
setNameError("");
|
setNameError("");
|
||||||
@@ -233,9 +346,9 @@ export default function RoomsList() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const myRooms: Room[] =
|
const myRooms: RoomDetails[] =
|
||||||
response?.items.filter((roomData) => !roomData.is_shared) || [];
|
response?.items.filter((roomData) => !roomData.is_shared) || [];
|
||||||
const sharedRooms: Room[] =
|
const sharedRooms: RoomDetails[] =
|
||||||
response?.items.filter((roomData) => roomData.is_shared) || [];
|
response?.items.filter((roomData) => roomData.is_shared) || [];
|
||||||
|
|
||||||
if (loading && !response)
|
if (loading && !response)
|
||||||
@@ -270,6 +383,8 @@ export default function RoomsList() {
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setRoom(roomInitialState);
|
setRoom(roomInitialState);
|
||||||
setNameError("");
|
setNameError("");
|
||||||
|
setShowWebhookSecret(false);
|
||||||
|
setWebhookTestResult(null);
|
||||||
onOpen();
|
onOpen();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -279,7 +394,7 @@ export default function RoomsList() {
|
|||||||
|
|
||||||
<Dialog.Root
|
<Dialog.Root
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(e) => (e.open ? onOpen() : onClose())}
|
onOpenChange={(e) => (e.open ? onOpen() : handleCloseDialog())}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<Dialog.Backdrop />
|
<Dialog.Backdrop />
|
||||||
@@ -516,6 +631,109 @@ export default function RoomsList() {
|
|||||||
</Select.Positioner>
|
</Select.Positioner>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
</Field.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}>
|
<Field.Root mt={4}>
|
||||||
<Checkbox.Root
|
<Checkbox.Root
|
||||||
name="isShared"
|
name="isShared"
|
||||||
@@ -540,7 +758,7 @@ export default function RoomsList() {
|
|||||||
</Field.Root>
|
</Field.Root>
|
||||||
</Dialog.Body>
|
</Dialog.Body>
|
||||||
<Dialog.Footer>
|
<Dialog.Footer>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="ghost" onClick={handleCloseDialog}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
0
www/app/api/schemas.gen.ts
Normal file
0
www/app/api/schemas.gen.ts
Normal file
0
www/app/api/services.gen.ts
Normal file
0
www/app/api/services.gen.ts
Normal file
0
www/app/api/types.gen.ts
Normal file
0
www/app/api/types.gen.ts
Normal file
Reference in New Issue
Block a user