SurfSense/surfsense_backend/app/routes/circleback_webhook_route.py

344 lines
11 KiB
Python

"""
Circleback Webhook Route
This module provides a webhook endpoint for receiving meeting data from Circleback.
It processes the incoming webhook payload and saves it as a document in the specified search space.
"""
import logging
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import SearchSourceConnector, SearchSourceConnectorType, get_async_session
logger = logging.getLogger(__name__)
router = APIRouter()
# Pydantic models for Circleback webhook payload
class CirclebackAttendee(BaseModel):
"""Attendee model for Circleback meeting."""
name: str | None = None
email: str | None = None
class CirclebackActionItemAssignee(BaseModel):
"""Assignee model for action items."""
name: str | None = None
email: str | None = None
class CirclebackActionItem(BaseModel):
"""Action item model for Circleback meeting."""
id: int
title: str
description: str = ""
assignee: CirclebackActionItemAssignee | None = None
status: str = "PENDING"
class CirclebackTranscriptSegment(BaseModel):
"""Transcript segment model for Circleback meeting."""
speaker: str
text: str
timestamp: float
class CirclebackInsightItem(BaseModel):
"""Individual insight item."""
insight: str | dict[str, Any]
speaker: str | None = None
timestamp: float | None = None
class CirclebackWebhookPayload(BaseModel):
"""
Circleback webhook payload model.
This model represents the data sent by Circleback when a meeting is processed.
"""
model_config = {"populate_by_name": True}
id: int = Field(..., description="Circleback meeting ID")
name: str = Field(..., description="Meeting name")
created_at: str = Field(
..., alias="createdAt", description="Meeting creation date in ISO format"
)
duration: float = Field(..., description="Meeting duration in seconds")
url: str | None = Field(None, description="URL of the virtual meeting")
recording_url: str | None = Field(
None,
alias="recordingUrl",
description="URL of the meeting recording (valid for 24 hours)",
)
tags: list[str] = Field(default_factory=list, description="Meeting tags")
ical_uid: str | None = Field(
None, alias="icalUid", description="Unique identifier of the calendar event"
)
attendees: list[CirclebackAttendee] = Field(
default_factory=list, description="Meeting attendees"
)
notes: str = Field("", description="Meeting notes in Markdown format")
action_items: list[CirclebackActionItem] = Field(
default_factory=list,
alias="actionItems",
description="Action items from the meeting",
)
transcript: list[CirclebackTranscriptSegment] = Field(
default_factory=list, description="Meeting transcript segments"
)
insights: dict[str, list[CirclebackInsightItem]] = Field(
default_factory=dict, description="Custom insights from the meeting"
)
def format_circleback_meeting_to_markdown(payload: CirclebackWebhookPayload) -> str:
"""
Convert Circleback webhook payload to a well-formatted Markdown document.
Args:
payload: The Circleback webhook payload
Returns:
Markdown string representation of the meeting
"""
lines = []
# Title
lines.append(f"# {payload.name}")
lines.append("")
# Meeting metadata
lines.append("## Meeting Details")
lines.append("")
# Parse and format date
try:
created_dt = datetime.fromisoformat(payload.created_at.replace("Z", "+00:00"))
formatted_date = created_dt.strftime("%Y-%m-%d %H:%M:%S UTC")
except (ValueError, AttributeError):
formatted_date = payload.created_at
lines.append(f"- **Date:** {formatted_date}")
lines.append(f"- **Duration:** {int(payload.duration // 60)} minutes")
if payload.url:
lines.append(f"- **Meeting URL:** {payload.url}")
if payload.tags:
lines.append(f"- **Tags:** {', '.join(payload.tags)}")
lines.append(
f"- **Circleback Link:** [View on Circleback](https://app.circleback.ai/meetings/{payload.id})"
)
lines.append("")
# Attendees
if payload.attendees:
lines.append("## Attendees")
lines.append("")
for attendee in payload.attendees:
name = attendee.name or "Unknown"
if attendee.email:
lines.append(f"- **{name}** ({attendee.email})")
else:
lines.append(f"- **{name}**")
lines.append("")
# Notes (if provided)
if payload.notes:
lines.append("## Meeting Notes")
lines.append("")
lines.append(payload.notes)
lines.append("")
# Action Items
if payload.action_items:
lines.append("## Action Items")
lines.append("")
for item in payload.action_items:
status_emoji = "" if item.status == "DONE" else ""
assignee_text = ""
if item.assignee and item.assignee.name:
assignee_text = f" (Assigned to: {item.assignee.name})"
lines.append(f"{status_emoji} **{item.title}**{assignee_text}")
if item.description:
lines.append(f" {item.description}")
lines.append("")
# Insights
if payload.insights:
lines.append("## Insights")
lines.append("")
for insight_name, insight_items in payload.insights.items():
lines.append(f"### {insight_name}")
lines.append("")
for insight_item in insight_items:
if isinstance(insight_item.insight, dict):
for key, value in insight_item.insight.items():
lines.append(f"- **{key}:** {value}")
else:
speaker_info = (
f" _{insight_item.speaker}_" if insight_item.speaker else ""
)
lines.append(f"- {insight_item.insight}{speaker_info}")
lines.append("")
# Transcript
if payload.transcript:
lines.append("## Transcript")
lines.append("")
for segment in payload.transcript:
# Format timestamp as MM:SS
minutes = int(segment.timestamp // 60)
seconds = int(segment.timestamp % 60)
timestamp_str = f"[{minutes:02d}:{seconds:02d}]"
lines.append(f"**{segment.speaker}** {timestamp_str}: {segment.text}")
lines.append("")
return "\n".join(lines)
@router.post("/webhooks/circleback/{search_space_id}")
async def receive_circleback_webhook(
search_space_id: int,
payload: CirclebackWebhookPayload,
session: AsyncSession = Depends(get_async_session),
):
"""
Receive and process a Circleback webhook.
This endpoint receives meeting data from Circleback and saves it as a document
in the specified search space. The meeting data is converted to Markdown format
and processed asynchronously.
Args:
search_space_id: The ID of the search space to save the document to
payload: The Circleback webhook payload containing meeting data
session: Database session for looking up the connector
Returns:
Success message with document details
Note:
This endpoint does not require authentication as it's designed to receive
webhooks from Circleback. Signature verification can be added later for security.
"""
try:
logger.info(
f"Received Circleback webhook for meeting {payload.id} in search space {search_space_id}"
)
# Look up the Circleback connector for this search space
connector_result = await session.execute(
select(SearchSourceConnector.id).where(
SearchSourceConnector.search_space_id == search_space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.CIRCLEBACK_CONNECTOR,
)
)
connector_id = connector_result.scalar_one_or_none()
if connector_id:
logger.info(
f"Found Circleback connector {connector_id} for search space {search_space_id}"
)
else:
logger.warning(
f"No Circleback connector found for search space {search_space_id}. "
"Document will be created without connector_id."
)
# Convert to markdown
markdown_content = format_circleback_meeting_to_markdown(payload)
# Trigger async document processing
from app.tasks.celery_tasks.document_tasks import (
process_circleback_meeting_task,
)
# Prepare meeting metadata for the task
meeting_metadata = {
"circleback_meeting_id": payload.id,
"meeting_name": payload.name,
"meeting_date": payload.created_at,
"duration_seconds": payload.duration,
"meeting_url": payload.url,
"tags": payload.tags,
"attendees_count": len(payload.attendees),
"action_items_count": len(payload.action_items),
"has_transcript": len(payload.transcript) > 0,
}
# Queue the processing task
process_circleback_meeting_task.delay(
meeting_id=payload.id,
meeting_name=payload.name,
markdown_content=markdown_content,
metadata=meeting_metadata,
search_space_id=search_space_id,
connector_id=connector_id,
)
logger.info(
f"Queued Circleback meeting {payload.id} for processing in search space {search_space_id}"
)
return {
"status": "accepted",
"message": f"Meeting '{payload.name}' queued for processing",
"meeting_id": payload.id,
"search_space_id": search_space_id,
}
except Exception as e:
logger.error(f"Error processing Circleback webhook: {e!s}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to process Circleback webhook: {e!s}",
) from e
@router.get("/webhooks/circleback/{search_space_id}/info")
async def get_circleback_webhook_info(
search_space_id: int,
):
"""
Get information about the Circleback webhook endpoint.
This endpoint provides information about how to configure the Circleback
webhook integration.
Args:
search_space_id: The ID of the search space
Returns:
Webhook configuration information
"""
from app.config import config
# Construct the webhook URL
base_url = getattr(config, "API_BASE_URL", "http://localhost:8000")
webhook_url = f"{base_url}/api/v1/webhooks/circleback/{search_space_id}"
return {
"webhook_url": webhook_url,
"search_space_id": search_space_id,
"method": "POST",
"content_type": "application/json",
"description": "Use this URL in your Circleback automation to send meeting data to SurfSense",
"note": "Configure this URL in Circleback Settings → Automations → Create automation → Send webhook request",
}