""" 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, HTTPException from pydantic import BaseModel, Field 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, ): """ 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 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}" ) # 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, ) 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", }