Fireflies app reached beta : utilities to ingest one or many meetings - No production webhook without headers forwarding (#16410)

# Fireflies
Automatically (with cli util : webhook not working and no cron added)
captures meeting notes with AI-generated summaries and insights from
Fireflies.ai into your Twenty CRM.

## Current Status
- Doesn't work with Fireflies webhook yet due to missing headers
forwarding in twenty serverless func
- Meeting ingestion utility script are available for individual meeting
insertion and historical meetings with filters with yarn meeting:all
- You have to push the secrets as application.config.ts values (despite
env variables in .env in docker compose or container I couldn't push the
secrets with the cli)

## Current Platform Limitation (Headers)

- Twenty serverless route triggers currently do **not forward HTTP
headers** to functions. Fireflies signatures sent in headers are
stripped, so header-based verification does not work in production.
- Workaround: the provided test script also includes the signature
inside the payload; the handler falls back to that payload signature.
Use this only for testing until header forwarding is supported.

## Utilities for meeting insertion (workarounds)

- Ingest a specific Fireflies meeting into Twenty:
`yarn meeting:ingest <meetingId>` or `MEETING_ID=... yarn
meeting:ingest`

- Fetch all/historical Fireflies meetings into Twenty:
`yarn meeting:all [--from 2024-01-01] [--to 2024-02-01] [--organizer
a@x.com] [--participant b@x.com] [--channel <channelId>] [--mine]
[--dry-run]`

  - Filters (combine as needed):
    - `--from` / `--to`: ISO or date string range filter
    - `--organizer` / `--participant`: comma-separated emails
    - `--channel`: Fireflies channel id
    - `--mine`: only meetings for the current Fireflies user
  - Controls:
    - `--dry-run`: list and transform without writing to Twenty
    - `--page-size`: pagination size (default 50)
    - `--max-records`: stop after N transcripts (default 500)
    
    
    I am closing previous #16378  as this one includes it all
This commit is contained in:
Alex Galey
2025-12-09 09:27:01 +01:00
committed by GitHub
parent 95c69507aa
commit b578ea5951
38 changed files with 4266 additions and 1180 deletions
@@ -17,7 +17,7 @@ FIREFLIES_API_KEY=your_fireflies_api_key_here
# Fireflies plan level - affects which fields are available
# Options: free, pro, business, enterprise
# This controls which GraphQL fields are requested to avoid 403 errors
FIREFLIES_PLAN_LEVEL=pro
FIREFLIES_PLAN=free
# Twenty CRM API key for authentication
# Generate this from your Twenty instance: Settings > Developers > API Keys
@@ -0,0 +1 @@
.yarn
@@ -1,5 +1,76 @@
# Changelog
## [0.3.1] - 2025-12-08
Import all
### Added
- Historical import CLI: `yarn meeting:all` to fetch and insert historical Fireflies meetings with filters (date range, organizers, participants, channel, mine) and dry-run support.
- Fireflies transcripts listing with pagination and date filtering to support bulk imports.
### Changed
- Deduplication now checks `firefliesMeetingId` before creating meetings (webhook + bulk).
- Shared historical importer pipeline reusing existing note/meeting formatting.
## [0.3.0] - 2025-12-08
Subscription-based query / Full transcript and AI notes for Pro+ / More
### Added
- **Full transcript capture**: Meeting object now stores the complete meeting transcript with speaker names and timestamps (`transcript` field)
- **Rich AI meeting notes**: Captures detailed AI-generated meeting notes from Fireflies (`notes` field with 7,000+ char summaries)
- **Expanded summary fields**: Now fetches all available Fireflies summary data:
- `notes` - Detailed AI-generated meeting notes with timestamps and section headers
- `bullet_gist` - Emoji-enhanced bullet point summaries
- `outline` / `shorthand_bullet` - Timestamped meeting outline
- `gist` - One-sentence meeting summary
- `short_summary` - Single paragraph summary
- `short_overview` - Brief overview
- **New Meeting fields**:
- `transcript` - Full meeting transcript with speaker attribution
- `notes` - AI-generated detailed notes
- `audioUrl` - Link to audio recording (Pro+)
- `videoUrl` - Link to video recording (Business+)
- `meetingLink` - Original meeting link
- `neutralPercent` - Neutral sentiment percentage
- **Meeting delete utility**: New `yarn meeting:delete <meetingId>` script for cleanup and re-import
- **Debug meeting utility**: New `scripts/debug-meeting.ts` to inspect raw Fireflies API responses
### Changed
- **Plan-based GraphQL queries**: Completely redesigned query system with three tiers:
- **Free**: Basic fields only (title, date, duration, participants, transcript_url, meeting_link)
- **Pro**: Adds full transcript (`sentences`), summary fields, speakers, audio_url
- **Business+**: Adds analytics, video_url, speaker stats, meeting metrics
- **Action items parsing**: Fixed parsing of `action_items` which Fireflies returns as newline-separated string, not array
- **Note body format**: Enhanced with Meeting Notes, Outline, Key Points sections from rich Fireflies data
- **Import status**: Added `PARTIAL` status for imports missing summary/analytics data
### Fixed
- Missing `notes` and `bullet_gist` fields in data transform (were fetched but not passed through)
- Proper fallback: Uses `shorthand_bullet` when `outline` is empty (Fireflies stores outline content there)
- Summary readiness detection now checks `notes` field in addition to `overview` and `action_items`
### Documentation
- Updated README with complete API access comparison table by subscription plan
- Documented all available Fireflies summary fields and their plan requirements
## [0.2.3] - 2025-12-06
### Added
- **Meeting ingest utility**: New `yarn meeting:ingest <meetingId>` script to manually fetch and import specific Fireflies meetings into Twenty
- **Plan-based field selection**: Added `FIREFLIES_PLAN` configuration to control which GraphQL fields are requested based on subscription level (free, pro, business, enterprise)
- **Main entry point**: New `src/index.ts` centralizing all exports for cleaner imports
### Changed
- **Auth configuration**: Disabled authentication requirement for webhook route (`isAuthRequired: false`) to support serverless deployments
- **Signature verification fallback**: Webhook handler now supports signature in payload body as fallback when HTTP headers aren't forwarded to serverless functions (production doesn't work for Fireflies webhook)
- **Improved type safety**: Replaced `any` types with proper TypeScript types throughout codebase
### Enhanced
- **Webhook debugging**: Added detailed debug output including param keys, header info, and signature comparison details
- **Test webhook script**: Includes signature in both header and payload, with diagnostic output for header forwarding status
- **Documentation**: Added README sections on current twenty headers forward limitations and utility scripts
## [0.2.2] - 2025-11-04
### Added
@@ -2,6 +2,10 @@
Automatically captures meeting notes with AI-generated summaries and insights from Fireflies.ai into your Twenty CRM.
### Current Status
- Doesn't work with Fireflies webhook yet due to missing headers forwarding in twenty serverless func
- Meeting ingestion utility scripts are available for individual meeting insertion and historical meetings with filters with yarn meeting:all
## Integration Overview
**Fireflies webhook → Fireflies API → Twenty CRM with summary-focused insights**
@@ -19,19 +23,54 @@ Automatically captures meeting notes with AI-generated summaries and insights fr
## API Access by Subscription Plan
Fireflies API access varies significantly by subscription tier:
Fireflies API access varies by subscription tier. This integration automatically adapts queries based on your plan and falls back gracefully if restrictions are encountered.
### Plan Comparison
| Feature | Free | Pro | Business | Enterprise |
|---------|------|-----|----------|------------|
| **API Rate Limit** | 50 requests/day | 50 requests/day | 60 requests/minute | 60 requests/minute |
| **Storage** | 800 mins/seat | 8,000 mins/seat | Unlimited | Unlimited |
| **AI Summaries** | Limited (20 credits) | Unlimited | Unlimited | Unlimited |
| **Video Upload** | 100MB max | 1.5GB max | 1.5GB max | 1.5GB max |
| **Advanced Features** | Basic transcription | AI apps, analytics | Team analytics, CI | Full API, SSO, compliance |
|---------|:----:|:---:|:--------:|:----------:|
| **API Rate Limit** | 50/day | 50/day | 60/min | 60/min |
| **Basic Data** (title, date, duration) | ✅ | ✅ | ✅ | ✅ |
| **Participants List** | ✅ | ✅ | ✅ | ✅ |
| **Transcript URL** | ✅ | ✅ | ✅ | ✅ |
| **Speakers** | ❌ | ✅ | ✅ | ✅ |
| **Summary** (overview, keywords) | ❌ | ✅ | ✅ | ✅ |
| **Audio URL** | ❌ | ✅ | ✅ | ✅ |
| **Action Items** | ❌ | ❌ | ✅ | ✅ |
| **Topics Discussed** | ❌ | ❌ | ✅ | ✅ |
| **Video URL** | ❌ | ❌ | ✅ | ✅ |
| **Sentiment Analytics** | ❌ | ❌ | ✅ | ✅ |
| **Meeting Attendees (detailed)** | ❌ | ❌ | ✅ | ✅ |
**Key Design Pattern:** Subscription-based API access uses **tiered rate limiting** rather than feature gating. Lower tiers get severely restricted throughput (50/day vs 60/minute = 1,700x difference), making production integrations effectively require Business+ plans.
### What You'll Get Per Plan
**Pro Plan Limitation:** Despite "unlimited" AI summaries, the 50 requests/day limit severely constrains production usage for meeting-heavy organizations.
**Free Plan:**
- Meeting title, date, duration
- Participant names/emails (basic)
- Link to transcript
**Pro Plan:**
- Everything in Free, plus:
- Speaker identification
- AI summary (overview + keywords)
- Audio recording URL
**Business Plan:**
- Everything in Pro, plus:
- Action items extraction
- Topics discussed
- Sentiment analysis (positive/negative/neutral %)
- Video recording URL
- Detailed meeting attendee info
### Configuration
Set your plan in `.env`:
```bash
FIREFLIES_PLAN=free # Options: free, pro, business, enterprise
```
**Rate Limiting:** Free/Pro plans are limited to 50 API calls/day. The integration uses conservative retry settings by default to stay within limits.
## What Gets Captured
@@ -114,11 +153,6 @@ The `setup:fields` script adds 13 custom fields to store rich Fireflies data:
| `firefliesMeetingId` | TEXT | Fireflies Meeting ID | Unique identifier from Fireflies |
| `organizerEmail` | TEXT | Organizer Email | Email address of the meeting organizer |
Then re-sync:
```bash
npx twenty-cli app sync
```
**Note:** Without custom fields, meetings will be created with just the title. The rich summary data will only be stored in Notes for 1-on-1 meetings.
## Configuration
@@ -160,6 +194,29 @@ The integration uses **HMAC SHA-256 signature verification**:
- Twenty verifies signature using your webhook secret
- Invalid signatures are rejected immediately
### Current Platform Limitation (Headers)
- Twenty serverless route triggers currently do **not forward HTTP headers** to functions. Fireflies signatures sent in headers are stripped, so header-based verification does not work in production.
- Workaround: the provided test script also includes the signature inside the payload; the handler falls back to that payload signature. Use this only for testing until header forwarding is supported.
## Utilities for meeting insertion (workarounds)
- Ingest a specific Fireflies meeting into Twenty:
`yarn meeting:ingest <meetingId>` or `MEETING_ID=... yarn meeting:ingest`
- Fetch all/historical Fireflies meetings into Twenty:
`yarn meeting:all [--from 2024-01-01] [--to 2024-02-01] [--organizer a@x.com] [--participant b@x.com] [--channel <channelId>] [--mine] [--dry-run]`
- Filters (combine as needed):
- `--from` / `--to`: ISO or date string range filter
- `--organizer` / `--participant`: comma-separated emails
- `--channel`: Fireflies channel id
- `--mine`: only meetings for the current Fireflies user
- Controls:
- `--dry-run`: list and transform without writing to Twenty
- `--page-size`: pagination size (default 50)
- `--max-records`: stop after N transcripts (default 500)
## Development
```bash
@@ -247,10 +304,7 @@ Client expressed strong interest in the enterprise plan.
## Future Implementation Opportunities
### Past Meetings Retrieval
- **New trigger to retrieve past meetings from a contact** - Enable users to fetch historical meeting data from Fireflies for specific contacts, allowing retrospective capture and analysis of past interactions.
Next iteration would enhance the **intelligence layer** to:
Next iterations would enhance the **intelligence layer** to:
### AI-Powered Insights
- **Extract pain points, objections & buying signals** automatically from transcripts
@@ -1,4 +1,4 @@
import { type ApplicationConfig } from 'twenty-sdk/application';
import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = {
universalIdentifier: 'a4df0c0f-c65e-44e5-8436-24814182d4ac',
@@ -9,17 +9,25 @@ const config: ApplicationConfig = {
FIREFLIES_WEBHOOK_SECRET: {
universalIdentifier: 'f51f7646-be9f-4ba9-9b75-160dd288cd0c',
description: 'Secret key for verifying Fireflies webhook signatures',
isSecret: true,
//isSecret: true,
value: '',
},
FIREFLIES_API_KEY: {
universalIdentifier: 'faa41f07-b28e-4500-b1c0-ce4b3d27924c',
description: 'Fireflies GraphQL API key used to fetch meeting summaries',
isSecret: true,
//isSecret: true,
value: '',
},
FIREFLIES_PLAN: {
universalIdentifier: '57dbb73c-aac5-4247-9fcc-a070bb669f16',
description: 'Fireflies plan: free, pro, business, enterprise',
value: 'free',
},
TWENTY_API_KEY: {
universalIdentifier: '02756551-5bf7-4fb2-8e08-1f622008d305',
description: 'Twenty API key used when running scripts locally',
isSecret: true,
//isSecret: true,
value: '',
},
SERVER_URL: {
universalIdentifier: '9b3a5e8e-5973-4e6b-a059-2966075652aa',
@@ -36,6 +44,11 @@ const config: ApplicationConfig = {
description: 'Log level: silent, error, warn, info, debug (default: error)',
value: 'error',
},
CAPTURE_LOGS: {
universalIdentifier: 'adbcc267-309d-49b2-af71-76f1299d863e',
description: 'Capture logs in webhook response for debugging (true/false)',
value: 'true',
},
FIREFLIES_SUMMARY_STRATEGY: {
universalIdentifier: '562b43d9-cd47-4ec1-ae16-5cc7ebc9729b',
description: 'Summary fetch strategy: immediate_only, immediate_with_retry, delayed_polling, or basic_only',
@@ -1,6 +1,6 @@
{
"name": "fireflies",
"version": "0.2.2",
"version": "0.3.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -11,12 +11,15 @@
"scripts": {
"test": "jest",
"setup:fields": "tsx scripts/add-meeting-fields.ts",
"test:webhook": "tsx scripts/test-webhook.ts"
"test:webhook": "tsx scripts/test-webhook.ts",
"meeting:ingest": "tsx scripts/ingest-meeting.ts",
"meeting:delete": "tsx scripts/delete-meeting.ts",
"meeting:all": "tsx scripts/fetch-all-meetings.ts"
},
"dependencies": {
"axios": "^1.13.1",
"dotenv": "^16.3.1",
"twenty-sdk": "0.0.3"
"dotenv": "^17.2.3",
"twenty-sdk": "0.1.3"
},
"devDependencies": {
"@types/jest": "^29.5.5",
@@ -1,7 +1,7 @@
{
"name": "fireflies",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-apps/fireflies/src",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/twenty-apps/community/fireflies/src",
"projectType": "application",
"tags": [
"scope:apps"
@@ -13,7 +13,7 @@
"{workspaceRoot}/coverage/{projectRoot}"
],
"options": {
"jestConfig": "packages/twenty-apps/fireflies/jest.config.mjs",
"jestConfig": "packages/twenty-apps/community/fireflies/jest.config.mjs",
"passWithNoTests": true
},
"configurations": {
@@ -36,7 +36,7 @@
],
"options": {
"lintFilePatterns": [
"packages/twenty-apps/fireflies/**/*.{ts,tsx,js,jsx}"
"packages/twenty-apps/community/fireflies/**/*.{ts,tsx,js,jsx}"
]
},
"configurations": {
@@ -50,7 +50,11 @@ interface FieldDefinition {
options?: FieldOption[];
}
// Meeting fields based on Fireflies GraphQL API transcript schema
// See: https://docs.fireflies.ai/graphql-api/query/transcript
// Note: Some fields require higher plans (Pro, Business, Enterprise)
const MEETING_FIELDS: FieldDefinition[] = [
// === Internal Twenty Relations ===
{
type: 'RELATION',
name: 'note',
@@ -59,11 +63,21 @@ const MEETING_FIELDS: FieldDefinition[] = [
icon: 'IconNotes',
isNullable: true,
},
// === Basic Fields (All Plans) ===
{
type: 'TEXT',
name: 'firefliesMeetingId',
label: 'Fireflies ID',
description: 'Unique transcript ID from Fireflies (maps to: id)',
icon: 'IconKey',
isNullable: true,
},
{
type: 'DATE_TIME',
name: 'meetingDate',
label: 'Meeting Date',
description: 'Date and time when the meeting occurred',
description: 'When the meeting occurred (maps to: date)',
icon: 'IconCalendar',
isNullable: true,
},
@@ -71,39 +85,107 @@ const MEETING_FIELDS: FieldDefinition[] = [
type: 'NUMBER',
name: 'duration',
label: 'Duration (minutes)',
description: 'Meeting duration in minutes',
description: 'Meeting duration in minutes (maps to: duration)',
icon: 'IconClock',
isNullable: true,
},
{
type: 'TEXT',
name: 'meetingType',
label: 'Meeting Type',
description: 'Type of meeting (e.g., Sales Call, Sprint Planning, 1:1)',
icon: 'IconTag',
name: 'organizerEmail',
label: 'Organizer Email',
description: 'Meeting organizer email (maps to: organizer_email)',
icon: 'IconMail',
isNullable: true,
},
{
type: 'LINKS',
name: 'transcriptUrl',
label: 'Transcript URL',
description: 'Link to full transcript (maps to: transcript_url)',
icon: 'IconFileText',
isNullable: true,
},
{
type: 'LINKS',
name: 'meetingLink',
label: 'Meeting Link',
description: 'Original meeting link (maps to: meeting_link)',
icon: 'IconLink',
isNullable: true,
},
// === Pro+ Fields (summary, speakers, audio_url, transcript) ===
{
type: 'TEXT',
name: 'transcript',
label: 'Full Transcript',
description: 'Full meeting transcript with speaker names and timestamps [Pro+]',
icon: 'IconFileText',
isNullable: true,
},
{
type: 'TEXT',
name: 'overview',
label: 'Overview',
description: 'AI-generated meeting summary (maps to: summary.overview) [Pro+]',
icon: 'IconFileDescription',
isNullable: true,
},
{
type: 'TEXT',
name: 'notes',
label: 'AI Notes',
description: 'Detailed AI-generated meeting notes (maps to: summary.notes) [Pro+]',
icon: 'IconNotes',
isNullable: true,
},
{
type: 'TEXT',
name: 'keywords',
label: 'Keywords',
description: 'Key topics and themes discussed (comma-separated)',
description: 'Key topics extracted (maps to: summary.keywords) [Pro+]',
icon: 'IconTags',
isNullable: true,
},
{
type: 'LINKS',
name: 'audioUrl',
label: 'Audio URL',
description: 'Link to audio recording (maps to: audio_url) [Pro+]',
icon: 'IconHeadphones',
isNullable: true,
},
// === Business+ Fields (analytics, video_url, full summary) ===
{
type: 'TEXT',
name: 'meetingType',
label: 'Meeting Type',
description: 'AI-detected meeting type (maps to: summary.meeting_type) [Business+]',
icon: 'IconTag',
isNullable: true,
},
{
type: 'TEXT',
name: 'topics',
label: 'Topics Discussed',
description: 'Topics covered in meeting (maps to: summary.topics_discussed) [Business+]',
icon: 'IconListDetails',
isNullable: true,
},
{
type: 'NUMBER',
name: 'sentimentScore',
label: 'Sentiment Score',
description: 'Overall meeting sentiment (0-1 scale, where 1 is most positive)',
icon: 'IconMoodSmile',
name: 'actionItemsCount',
label: 'Action Items',
description: 'Number of action items (count of: summary.action_items) [Business+]',
icon: 'IconCheckbox',
isNullable: true,
},
{
type: 'NUMBER',
name: 'positivePercent',
label: 'Positive %',
description: 'Percentage of positive sentiment in conversation',
description: 'Positive sentiment % (maps to: analytics.sentiments.positive_pct) [Business+]',
icon: 'IconThumbUp',
isNullable: true,
},
@@ -111,69 +193,48 @@ const MEETING_FIELDS: FieldDefinition[] = [
type: 'NUMBER',
name: 'negativePercent',
label: 'Negative %',
description: 'Percentage of negative sentiment in conversation',
description: 'Negative sentiment % (maps to: analytics.sentiments.negative_pct) [Business+]',
icon: 'IconThumbDown',
isNullable: true,
},
{
type: 'NUMBER',
name: 'actionItemsCount',
label: 'Action Items',
description: 'Number of action items identified',
icon: 'IconCheckbox',
name: 'neutralPercent',
label: 'Neutral %',
description: 'Neutral sentiment % (maps to: analytics.sentiments.neutral_pct) [Business+]',
icon: 'IconMoodNeutral',
isNullable: true,
},
{
type: 'LINKS',
name: 'transcriptUrl',
label: 'Transcript URL',
description: 'Link to full transcript in Fireflies',
icon: 'IconFileText',
isNullable: true,
},
{
type: 'LINKS',
name: 'recordingUrl',
label: 'Recording URL',
description: 'Link to video/audio recording in Fireflies',
name: 'videoUrl',
label: 'Video URL',
description: 'Link to video recording (maps to: video_url) [Business+]',
icon: 'IconVideo',
isNullable: true,
},
{
type: 'TEXT',
name: 'firefliesMeetingId',
label: 'Fireflies Meeting ID',
description: 'Unique identifier from Fireflies',
icon: 'IconKey',
isNullable: true,
},
{
type: 'TEXT',
name: 'organizerEmail',
label: 'Organizer Email',
description: 'Email address of the meeting organizer',
icon: 'IconMail',
isNullable: true,
},
// === Import Tracking Fields (Internal) ===
{
type: 'SELECT',
name: 'importStatus',
label: 'Import Status',
description: 'Status of the meeting import from Fireflies',
description: 'Status of the Fireflies import',
icon: 'IconCheck',
isNullable: true,
options: [
{ value: 'SUCCESS', label: 'Success', position: 0, color: 'green' },
{ value: 'FAILED', label: 'Failed', position: 1, color: 'red' },
{ value: 'PENDING', label: 'Pending', position: 2, color: 'yellow' },
{ value: 'RETRYING', label: 'Retrying', position: 3, color: 'orange' },
{ value: 'PARTIAL', label: 'Partial', position: 1, color: 'blue' },
{ value: 'FAILED', label: 'Failed', position: 2, color: 'red' },
{ value: 'PENDING', label: 'Pending', position: 3, color: 'yellow' },
{ value: 'RETRYING', label: 'Retrying', position: 4, color: 'orange' },
],
},
{
type: 'TEXT',
name: 'importError',
label: 'Import Error',
description: 'Error message if meeting import failed',
description: 'Error message if import failed',
icon: 'IconAlertTriangle',
isNullable: true,
},
@@ -181,7 +242,7 @@ const MEETING_FIELDS: FieldDefinition[] = [
type: 'DATE_TIME',
name: 'lastImportAttempt',
label: 'Last Import Attempt',
description: 'Date and time of the last import attempt',
description: 'When import was last attempted',
icon: 'IconClock',
isNullable: true,
},
@@ -189,7 +250,7 @@ const MEETING_FIELDS: FieldDefinition[] = [
type: 'NUMBER',
name: 'importAttempts',
label: 'Import Attempts',
description: 'Number of times import has been attempted',
description: 'Number of import attempts',
icon: 'IconRepeat',
isNullable: true,
},
@@ -414,13 +475,9 @@ const main = async () => {
}
if (createdCount === 0 && skippedCount === MEETING_FIELDS.length) {
console.log('\n✨ All fields already exist. Nothing to do!');
console.log('\n✨ All fields already exist. Nothing to do!\n');
} else if (createdCount > 0) {
console.log('\n✨ Custom fields added successfully!');
console.log('\n📝 Next steps:');
console.log(' 1. Re-sync your app: npx twenty-cli app sync');
console.log(' 2. Update the createMeetingRecord function to use these fields');
console.log(' 3. Test the integration with a real meeting');
console.log('\n✨ Custom fields added successfully!\n');
}
} catch (error) {
@@ -0,0 +1,81 @@
/* eslint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
dotenv.config({ path: path.join(__dirname, '../.env') });
const FIREFLIES_API_KEY = process.env.FIREFLIES_API_KEY;
const meetingId = process.argv[2] || '01KBMR1ZYQ34YP8D2KB4B16QPH';
const main = async (): Promise<void> => {
if (!FIREFLIES_API_KEY) {
console.error('❌ FIREFLIES_API_KEY is required');
process.exit(1);
}
const query = `
query GetTranscript($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
summary {
overview
notes
gist
bullet_gist
short_summary
short_overview
outline
shorthand_bullet
action_items
keywords
topics_discussed
meeting_type
transcript_chapters
}
}
}
`;
const response = await fetch('https://api.fireflies.ai/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${FIREFLIES_API_KEY}`,
},
body: JSON.stringify({ query, variables: { transcriptId: meetingId } }),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ API request failed with status ${response.status}`);
console.error(errorText);
process.exit(1);
}
const json = await response.json();
console.log('=== Fireflies API Response ===\n');
console.log(JSON.stringify(json, null, 2));
if (json.data?.transcript?.summary) {
const s = json.data.transcript.summary;
console.log('\n=== Summary Fields Status ===');
console.log('overview:', s.overview ? `✓ (${s.overview.length} chars)` : '✗ empty');
console.log('notes:', s.notes ? `✓ (${s.notes.length} chars)` : '✗ empty');
console.log('gist:', s.gist ? `✓ (${s.gist.length} chars)` : '✗ empty');
console.log('bullet_gist:', s.bullet_gist ? `✓ (${s.bullet_gist.length} chars)` : '✗ empty');
console.log('outline:', s.outline ? `✓ (${s.outline.length} chars)` : '✗ empty');
console.log('action_items:', s.action_items?.length || 0, 'items');
console.log('topics_discussed:', s.topics_discussed?.length || 0, 'topics');
console.log('keywords:', s.keywords?.length || 0, 'keywords');
}
};
main().catch((error) => {
console.error('❌ Failed to fetch meeting');
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,59 @@
/* eslint-disable no-console */
import * as dotenv from 'dotenv';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
dotenv.config({ path: path.join(__dirname, '../.env') });
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
const API_KEY = process.env.TWENTY_API_KEY;
const meetingId = process.argv[2];
const main = async (): Promise<void> => {
if (!API_KEY) {
console.error('❌ TWENTY_API_KEY is required');
process.exit(1);
}
if (!meetingId) {
console.error('Usage: yarn delete:meeting <meetingId>');
process.exit(1);
}
const response = await fetch(`${SERVER_URL}/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({
query: `mutation DeleteMeeting($id: UUID!) { deleteMeeting(id: $id) { id } }`,
variables: { id: meetingId },
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Delete failed (status ${response.status})`);
console.error(errorText);
process.exit(1);
}
const result = await response.json();
const deletedId = result.data?.deleteMeeting?.id;
if (result.errors || !deletedId) {
const message = result.errors?.[0]?.message || 'deleteMeeting returned null';
console.error('❌ Error:', message);
process.exit(1);
}
console.log('✅ Deleted meeting:', deletedId);
};
main().catch((error) => {
console.error('❌ Failed to delete meeting');
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,222 @@
/* eslint-disable no-console */
/**
* Fetch historical Fireflies meetings and insert into Twenty.
*
* Usage:
* yarn meeting:all [--from 2024-01-01] [--to 2024-02-01] [--organizer alice@x.com] [--participant bob@x.com] [--channel <channelId>] [--mine] [--dry-run] [--page-size 50] [--max-records 200]
*
* Required env:
* FIREFLIES_API_KEY
* TWENTY_API_KEY
*
* Optional env:
* SERVER_URL (defaults to http://localhost:3000)
* FIREFLIES_PLAN (free|pro|business|enterprise)
* AUTO_CREATE_CONTACTS (true|false)
* FIREFLIES_* retry settings (see README)
*/
import * as dotenv from 'dotenv';
import { existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { FirefliesApiClient } from '../src/fireflies-api-client';
import { type HistoricalImportFilters, HistoricalImporter } from '../src/historical-importer';
import { createLogger } from '../src/logger';
import { TwentyCrmService } from '../src/twenty-crm-service';
import {
getApiUrl,
getFirefliesPlan,
getSummaryFetchConfig,
shouldAutoCreateContacts,
} from '../src/utils';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const envPath = join(__dirname, '..', '.env');
if (existsSync(envPath)) {
dotenv.config({ path: envPath });
}
const logger = createLogger('cli:meeting:all');
type CliArgs = {
from?: string;
to?: string;
organizer?: string[];
participant?: string[];
channel?: string;
host?: string;
mine?: boolean;
dryRun?: boolean;
pageSize?: number;
maxRecords?: number;
limit?: number;
};
const parseArgs = (argv: string[]): CliArgs => {
const args: CliArgs = {};
const parseNumberArg = (value?: string): number | undefined => {
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? undefined : parsed;
};
for (let i = 0; i < argv.length; i += 1) {
const current = argv[i];
const next = argv[i + 1];
switch (current) {
case '--from':
args.from = next;
i += 1;
break;
case '--to':
args.to = next;
i += 1;
break;
case '--organizer':
args.organizer = next ? next.split(',') : [];
i += 1;
break;
case '--participant':
args.participant = next ? next.split(',') : [];
i += 1;
break;
case '--channel':
args.channel = next;
i += 1;
break;
case '--host':
args.host = next;
i += 1;
break;
case '--mine':
args.mine = true;
break;
case '--dry-run':
args.dryRun = true;
break;
case '--page-size':
args.pageSize = parseNumberArg(next);
i += 1;
break;
case '--max-records':
args.maxRecords = parseNumberArg(next);
i += 1;
break;
case '--limit':
args.limit = parseNumberArg(next);
i += 1;
break;
default:
break;
}
}
return args;
};
const parseDate = (value?: string): number | undefined => {
if (!value) {
return undefined;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? undefined : parsed;
};
const main = async (): Promise<void> => {
const args = parseArgs(process.argv.slice(2));
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!firefliesApiKey) {
console.error('❌ FIREFLIES_API_KEY is required');
process.exit(1);
}
if (!twentyApiKey) {
console.error('❌ TWENTY_API_KEY is required');
process.exit(1);
}
const fromDate = parseDate(args.from);
const toDate = parseDate(args.to);
const filters: HistoricalImportFilters = {
fromDate,
toDate,
organizers: args.organizer,
participants: args.participant,
channelId: args.channel,
hostEmail: args.host,
mine: args.mine,
limit: args.limit,
pageSize: args.pageSize,
maxRecords: args.maxRecords,
};
const summaryConfig = getSummaryFetchConfig();
const plan = getFirefliesPlan();
const autoCreateContacts = shouldAutoCreateContacts();
logger.info(
`Starting historical import (dryRun=${Boolean(args.dryRun)}, plan=${plan}, pageSize=${filters.pageSize ?? 50})`,
);
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
const twentyService = new TwentyCrmService(twentyApiKey, getApiUrl());
const importer = new HistoricalImporter(firefliesClient, twentyService);
const result = await importer.run(filters, {
dryRun: args.dryRun,
autoCreateContacts,
summaryConfig,
plan,
});
console.log('✅ Historical import summary:');
const summary = {
dryRun: result.dryRun,
totalListed: result.totalListed,
imported: result.imported,
skippedExisting: result.skippedExisting,
summaryPending: result.summaryPending,
failed: result.failed,
};
console.log(JSON.stringify(summary, null, 2));
if (result.statuses.length > 0) {
console.log('Status by meeting:');
console.table(
result.statuses.map((s) => ({
meetingId: s.meetingId,
title: s.title ?? '',
status: s.status,
reason: s.reason ?? '',
})),
);
}
if (result.failed.length > 0) {
process.exitCode = 1;
}
};
main().catch((error) => {
console.error('❌ Failed to import historical meetings');
if (error instanceof Error) {
console.error(error.message);
if (error.stack) {
console.error(error.stack);
}
} else {
console.error(String(error));
}
process.exit(1);
});
@@ -0,0 +1,93 @@
/* eslint-disable no-console */
/**
* Fetch a Fireflies meeting by ID and insert it into Twenty using the same path
* as the webhook handler.
*
* Usage:
* yarn meeting:ingest <meetingId>
* Or
* MEETING_ID=... yarn meeting:ingest
*
* Required env:
* FIREFLIES_API_KEY
* FIREFLIES_WEBHOOK_SECRET
* TWENTY_API_KEY
*
* Optional env:
* SERVER_URL (defaults to http://localhost:3000)
* FIREFLIES_PLAN (free|pro|business|enterprise)
*/
import { createHmac } from 'crypto';
import * as dotenv from 'dotenv';
import { existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { WebhookHandler } from '../src/webhook-handler';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const envPath = join(__dirname, '..', '.env');
if (existsSync(envPath)) {
dotenv.config({ path: envPath });
}
const args = process.argv.slice(2);
const meetingId = args[0] || process.env.MEETING_ID;
if (!meetingId) {
console.error('❌ meetingId is required (arg or MEETING_ID env)');
process.exit(1);
}
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
const twentyApiKey = process.env.TWENTY_API_KEY || '';
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
if (!firefliesApiKey) {
console.error('❌ FIREFLIES_API_KEY is required');
process.exit(1);
}
if (!twentyApiKey) {
console.error('❌ TWENTY_API_KEY is required');
process.exit(1);
}
if (!webhookSecret) {
console.error('❌ FIREFLIES_WEBHOOK_SECRET is required to generate signature');
process.exit(1);
}
const payload = {
meetingId,
eventType: 'Transcription completed',
};
const body = JSON.stringify(payload);
const signature = `sha256=${createHmac('sha256', webhookSecret)
.update(body, 'utf8')
.digest('hex')}`;
const main = async (): Promise<void> => {
console.log(`🚀 Ingesting meeting ${meetingId} via webhook handler`);
const handler = new WebhookHandler();
const result = await handler.handle(payload, {
'x-hub-signature': signature,
body,
});
console.log('✅ Result:');
console.log(JSON.stringify(result, null, 2));
if (result.errors && result.errors.length > 0) {
process.exitCode = 1;
}
};
main().catch((error) => {
console.error('❌ Failed to ingest meeting');
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
@@ -40,11 +40,13 @@ const FIREFLIES_WEBHOOK_SECRET = process.env.FIREFLIES_WEBHOOK_SECRET || 'test_s
const _FIREFLIES_API_KEY = process.env.FIREFLIES_API_KEY || 'test_api_key';
// Test meeting data (simulating Fireflies API response)
const TEST_MEETING_ID = 'test-meeting-local-' + Date.now();
const TEST_MEETING_ID = process.env.MEETING_ID || 'test-meeting-local-' + Date.now();
const CLIENT_REFERENCE_ID = process.env.CLIENT_REFERENCE_ID;
const TEST_WEBHOOK_PAYLOAD = {
meetingId: TEST_MEETING_ID,
eventType: 'Transcription completed',
clientReferenceId: 'test-client-ref',
...(CLIENT_REFERENCE_ID ? { clientReferenceId: CLIENT_REFERENCE_ID } : {}),
};
// Mock Fireflies GraphQL API response
@@ -119,11 +121,17 @@ const main = async () => {
}
// Prepare webhook payload
const body = JSON.stringify(TEST_WEBHOOK_PAYLOAD);
const signature = generateHMACSignature(body, FIREFLIES_WEBHOOK_SECRET);
const unsignedBody = JSON.stringify(TEST_WEBHOOK_PAYLOAD);
const signature = generateHMACSignature(unsignedBody, FIREFLIES_WEBHOOK_SECRET);
const payloadWithSignature = {
...TEST_WEBHOOK_PAYLOAD,
'x-hub-signature': signature,
};
const body = JSON.stringify(payloadWithSignature);
console.log('📤 Sending webhook payload:');
console.log(JSON.stringify(TEST_WEBHOOK_PAYLOAD, null, 2));
console.log(JSON.stringify(payloadWithSignature, null, 2));
console.log('\n️ Signature is sent both as header (preferred) and in payload as fallback (headers are not passed to serverless functions)\n');
console.log(`\n🔐 HMAC Signature: ${signature}\n`);
// Check if server is reachable
@@ -178,6 +186,31 @@ const main = async () => {
console.log('📥 Response Body:');
console.log(JSON.stringify(responseData, null, 2));
// Report whether the server appears to have received the header signature
const debugMessages = Array.isArray((responseData as any)?.debug)
? ((responseData as any).debug as string[])
: [];
const headerMissing =
debugMessages.some((msg) => msg.includes('headerKeys=none')) ||
debugMessages.some((msg) => msg.includes('providedSignature=undefined'));
const signatureErrors =
Array.isArray((responseData as any)?.errors) &&
((responseData as any).errors as unknown[]).some(
(err) => typeof err === 'string' && err.toLowerCase().includes('signature'),
);
if (headerMissing) {
console.log(
'\n⚠️ Server did not report any received headers; it may be using payload fallback for signature verification.',
);
} else {
console.log('\n✅ Server reported headers present (header-based signature should be used).');
}
if (signatureErrors) {
console.log('⚠️ Signature was rejected by the server (check webhook secret / payload).');
}
if (response.ok) {
console.log('\n✅ Webhook test completed successfully!');
console.log('\n📋 Next steps:');
@@ -1,426 +0,0 @@
import { createLogger } from './logger';
import type { FirefliesMeetingData, FirefliesParticipant, SummaryFetchConfig } from './types';
const logger = createLogger('fireflies-api');
export class FirefliesApiClient {
private apiKey: string;
constructor(apiKey: string) {
if (!apiKey) {
logger.critical('FIREFLIES_API_KEY is required but not provided - this is a critical configuration error');
throw new Error('FIREFLIES_API_KEY is required');
}
this.apiKey = apiKey;
}
async fetchMeetingData(
meetingId: string,
options?: { timeout?: number }
): Promise<FirefliesMeetingData> {
const query = `
query GetTranscript($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
date
duration
participants
organizer_email
meeting_attendees {
displayName
email
phoneNumber
name
location
}
meeting_attendance {
name
join_time
leave_time
}
speakers {
name
}
summary {
action_items
overview
}
transcript_url
}
}
`;
const controller = new AbortController();
const timeoutId = options?.timeout
? setTimeout(() => controller.abort(), options.timeout)
: null;
try {
const response = await fetch('https://api.fireflies.ai/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
query,
variables: { transcriptId: meetingId },
}),
signal: controller.signal,
});
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
let errorDetails = `Fireflies API request failed with status ${response.status}`;
try {
const errorBody = await response.text();
if (errorBody) {
errorDetails += `: ${errorBody}`;
}
} catch {
// Ignore if we can't read the response body
}
throw new Error(errorDetails);
}
const json = await response.json() as {
data?: { transcript?: any };
errors?: Array<{ message?: string }>;
};
if (json.errors && json.errors.length > 0) {
throw new Error(`Fireflies API error: ${json.errors[0]?.message || 'Unknown error'}`);
}
const transcript = json.data?.transcript;
if (!transcript) {
throw new Error('Invalid response from Fireflies API: missing transcript data');
}
return this.transformMeetingData(transcript, meetingId);
} catch (error) {
if (timeoutId) clearTimeout(timeoutId);
throw error;
}
}
async fetchMeetingDataWithRetry(
meetingId: string,
config: SummaryFetchConfig
): Promise<{ data: FirefliesMeetingData; summaryReady: boolean }> {
// immediate_only: single attempt, no retries
if (config.strategy === 'immediate_only') {
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_only)`);
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000 });
const ready = this.isSummaryReady(meetingData);
logger.debug(`summary ready: ${ready}`);
return { data: meetingData, summaryReady: ready };
}
// immediate_with_retry: retry with exponential backoff
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_with_retry, maxAttempts: ${config.retryAttempts})`);
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
try {
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000 });
const ready = this.isSummaryReady(meetingData);
logger.debug(`attempt ${attempt}/${config.retryAttempts}: summary ready=${ready}`);
if (ready) {
return { data: meetingData, summaryReady: true };
}
if (attempt < config.retryAttempts) {
const delayMs = config.retryDelay * attempt;
logger.debug(`summary not ready, waiting ${delayMs}ms before retry ${attempt + 1}`);
await new Promise(resolve => setTimeout(resolve, delayMs));
} else {
logger.debug(`max retries reached, returning partial data`);
return { data: meetingData, summaryReady: false };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`attempt ${attempt}/${config.retryAttempts} failed: ${errorMsg}`);
if (attempt === config.retryAttempts) {
throw error;
}
const delayMs = config.retryDelay * attempt;
logger.debug(`retrying in ${delayMs}ms...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw new Error('Failed to fetch meeting data after retries');
}
private isSummaryReady(meetingData: FirefliesMeetingData): boolean {
return (
(meetingData.summary?.action_items?.length > 0) ||
(meetingData.summary?.overview?.length > 0) ||
meetingData.summary_status === 'completed'
);
}
private extractAllParticipants(transcript: any): FirefliesParticipant[] {
const participantsWithEmails: FirefliesParticipant[] = [];
const participantsNameOnly: FirefliesParticipant[] = [];
logger.debug('=== PARTICIPANT EXTRACTION DEBUG ===');
logger.debug('participants field:', JSON.stringify(transcript.participants));
logger.debug('meeting_attendees field:', JSON.stringify(transcript.meeting_attendees));
logger.debug('speakers field:', transcript.speakers?.map((s: any) => s.name));
logger.debug('meeting_attendance field:', transcript.meeting_attendance?.map((a: any) => a.name));
logger.debug('organizer_email:', transcript.organizer_email);
// Helper function to check if a string is an email
const isEmail = (str: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str.trim());
};
// Helper function to check if already exists
const isDuplicate = (name: string, email: string): boolean => {
const nameLower = name.toLowerCase().trim();
const emailLower = email.toLowerCase().trim();
return participantsWithEmails.some(p =>
p.name.toLowerCase().trim() === nameLower ||
(email && p.email.toLowerCase() === emailLower)
) || participantsNameOnly.some(p =>
p.name.toLowerCase().trim() === nameLower
);
};
// 1. Extract from legacy participants field (with emails)
if (transcript.participants && Array.isArray(transcript.participants)) {
transcript.participants.forEach((participant: string) => {
// Handle comma-separated emails or names
const parts = participant.split(',').map(p => p.trim());
parts.forEach(part => {
const emailMatch = part.match(/<([^>]+)>/);
const email = emailMatch ? emailMatch[1] : '';
// Extract name properly: if there's an email in angle brackets, get the part before it
const name = emailMatch
? part.substring(0, part.indexOf('<')).trim()
: part.trim();
// Skip if the "name" is actually an email address
if (isEmail(name)) {
logger.debug(`Skipping participant with email as name: "${name}"`);
return;
}
// Skip if empty name
if (!name) {
return;
}
// Skip duplicates
if (isDuplicate(name, email)) {
logger.debug(`Skipping duplicate participant: "${name}" <${email}>`);
return;
}
if (name && email) {
participantsWithEmails.push({ name, email });
} else if (name) {
participantsNameOnly.push({ name, email: '' });
}
});
});
}
// 2. Extract from meeting_attendees field (structured)
if (transcript.meeting_attendees && Array.isArray(transcript.meeting_attendees)) {
transcript.meeting_attendees.forEach((attendee: any) => {
const name = attendee.displayName || attendee.name || '';
const email = attendee.email || '';
// Skip if name is actually an email
if (isEmail(name)) {
logger.debug(`Skipping attendee with email as name: "${name}"`);
return;
}
if (name && !isDuplicate(name, email)) {
if (email) {
participantsWithEmails.push({ name, email });
} else {
participantsNameOnly.push({ name, email: '' });
}
}
});
}
// 3. Extract from speakers field (name only)
if (transcript.speakers && Array.isArray(transcript.speakers)) {
transcript.speakers.forEach((speaker: any) => {
const name = speaker.name || '';
// Skip if name is actually an email
if (isEmail(name)) {
logger.debug(`Skipping speaker with email as name: "${name}"`);
return;
}
if (name && !isDuplicate(name, '')) {
participantsNameOnly.push({ name, email: '' });
}
});
}
// 4. Extract from meeting_attendance field (name only)
if (transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
transcript.meeting_attendance.forEach((attendance: any) => {
const name = attendance.name || '';
// Skip if name is actually an email or contains comma-separated emails
if (isEmail(name) || name.includes(',')) {
logger.debug(`Skipping attendance with email/list as name: "${name}"`);
return;
}
if (name && !isDuplicate(name, '')) {
participantsNameOnly.push({ name, email: '' });
}
});
}
// 5. Add organizer email if available and not already included
const organizerEmail = transcript.organizer_email;
if (organizerEmail) {
// Check if organizer email is already in the participants
const existsWithEmail = participantsWithEmails.some(p =>
p.email.toLowerCase() === organizerEmail.toLowerCase()
);
if (!existsWithEmail) {
// Try to find organizer name from speakers/attendance and match with email
let organizerName = '';
// Extract username from organizer email for matching
const emailUsername = organizerEmail.split('@')[0].toLowerCase();
const emailNameVariations = [emailUsername];
// Add common name variations based on email username
if (emailUsername === 'alex') {
emailNameVariations.push('alexander', 'alexandre', 'alex');
}
// Look for organizer in speakers by matching email username to speaker names
if (transcript.speakers && Array.isArray(transcript.speakers)) {
const potentialOrganizerSpeaker = transcript.speakers.find((speaker: any) => {
const name = (speaker.name || '').toLowerCase();
return emailNameVariations.some(variation =>
name.includes(variation) || variation.includes(name)
);
});
if (potentialOrganizerSpeaker) {
organizerName = potentialOrganizerSpeaker.name;
}
}
// Look for organizer in attendance
if (!organizerName && transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
const potentialOrganizerAttendance = transcript.meeting_attendance.find((attendance: any) => {
const name = (attendance.name || '').toLowerCase();
return emailNameVariations.some(variation =>
name.includes(variation) || variation.includes(name)
);
});
if (potentialOrganizerAttendance) {
organizerName = potentialOrganizerAttendance.name;
}
}
// If we found a name match, add as participant with email
if (organizerName) {
participantsWithEmails.push({ name: organizerName, email: organizerEmail });
// Remove from name-only participants to avoid duplicates
const nameIndex = participantsNameOnly.findIndex(p =>
p.name.toLowerCase().includes(organizerName.toLowerCase()) ||
organizerName.toLowerCase().includes(p.name.toLowerCase())
);
if (nameIndex !== -1) {
participantsNameOnly.splice(nameIndex, 1);
}
} else {
// If no name found, add with generic organizer name
participantsWithEmails.push({ name: 'Meeting Organizer', email: organizerEmail });
}
}
}
// Return participants with emails first, then name-only participants
const allParticipants = [...participantsWithEmails, ...participantsNameOnly];
logger.debug('=== EXTRACTED PARTICIPANTS ===');
logger.debug('With emails:', participantsWithEmails.length, JSON.stringify(participantsWithEmails));
logger.debug('Name only:', participantsNameOnly.length, JSON.stringify(participantsNameOnly));
logger.debug('Total:', allParticipants.length);
return allParticipants;
}
private transformMeetingData(transcript: any, meetingId: string): FirefliesMeetingData {
// Convert date to ISO string - handle both timestamp and ISO string formats
let dateString: string;
if (transcript.date) {
if (typeof transcript.date === 'number') {
// Unix timestamp in milliseconds
dateString = new Date(transcript.date).toISOString();
} else if (typeof transcript.date === 'string') {
// Could be ISO string or timestamp string
const parsed = Number(transcript.date);
if (!isNaN(parsed)) {
// It's a numeric string (timestamp)
dateString = new Date(parsed).toISOString();
} else {
// It's already an ISO string
dateString = transcript.date;
}
} else {
dateString = new Date().toISOString();
}
} else {
dateString = new Date().toISOString();
}
return {
id: transcript.id || meetingId,
title: transcript.title || 'Untitled Meeting',
date: dateString,
duration: transcript.duration || 0,
participants: this.extractAllParticipants(transcript),
organizer_email: transcript.organizer_email,
summary: {
action_items: Array.isArray(transcript.summary?.action_items)
? transcript.summary.action_items
: (typeof transcript.summary?.action_items === 'string'
? [transcript.summary.action_items]
: []),
overview: transcript.summary?.overview || '',
keywords: transcript.summary?.keywords,
topics_discussed: transcript.summary?.topics_discussed,
meeting_type: transcript.summary?.meeting_type,
},
analytics: transcript.sentiments ? {
sentiments: {
positive_pct: transcript.sentiments.positive_pct || 0,
negative_pct: transcript.sentiments.negative_pct || 0,
neutral_pct: transcript.sentiments.neutral_pct || 0,
}
} : undefined,
transcript_url: transcript.transcript_url || `https://app.fireflies.ai/view/${meetingId}`,
recording_url: transcript.video_url || undefined,
summary_status: transcript.summary_status,
};
}
}
@@ -1,227 +0,0 @@
import type { FirefliesMeetingData, MeetingCreateInput } from './types';
export class MeetingFormatter {
static formatNoteBody(meetingData: FirefliesMeetingData): string {
const meetingDate = new Date(meetingData.date);
const formattedDate = meetingDate.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const durationMinutes = Math.round(meetingData.duration);
let noteBody = `**Date:** ${formattedDate}\n`;
noteBody += `**Duration:** ${durationMinutes} minutes\n`;
if (meetingData.participants.length > 0) {
const participantNames = meetingData.participants.map(p => p.name).join(', ');
noteBody += `**Participants:** ${participantNames}\n`;
}
// Overview section
if (meetingData.summary?.overview) {
noteBody += `\n## Overview\n${meetingData.summary.overview}\n`;
}
// Key topics
if (meetingData.summary?.topics_discussed && Array.isArray(meetingData.summary.topics_discussed) && meetingData.summary.topics_discussed.length > 0) {
noteBody += `\n## Key Topics\n`;
meetingData.summary.topics_discussed.forEach(topic => {
noteBody += `- ${topic}\n`;
});
}
// Action items
if (meetingData.summary?.action_items && Array.isArray(meetingData.summary.action_items) && meetingData.summary.action_items.length > 0) {
noteBody += `\n## Action Items\n`;
meetingData.summary.action_items.forEach(item => {
noteBody += `- ${item}\n`;
});
}
// Insights section
noteBody += `\n## Insights\n`;
if (meetingData.summary?.keywords && Array.isArray(meetingData.summary.keywords) && meetingData.summary.keywords.length > 0) {
noteBody += `**Keywords:** ${meetingData.summary.keywords.join(', ')}\n`;
}
if (meetingData.analytics?.sentiments) {
const sentiments = meetingData.analytics.sentiments;
noteBody += `**Sentiment:** ${sentiments.positive_pct}% positive, ${sentiments.negative_pct}% negative, ${sentiments.neutral_pct}% neutral\n`;
}
if (meetingData.summary?.meeting_type) {
noteBody += `**Meeting Type:** ${meetingData.summary.meeting_type}\n`;
}
// Resources section
noteBody += `\n## Resources\n`;
noteBody += `[View Full Transcript](${meetingData.transcript_url})\n`;
if (meetingData.recording_url) {
noteBody += `[Watch Recording](${meetingData.recording_url})\n`;
}
return noteBody;
}
static formatMeetingNotes(meetingData: FirefliesMeetingData): string {
const meetingDate = new Date(meetingData.date);
const formattedDate = meetingDate.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const durationMinutes = Math.round(meetingData.duration);
let meetingNotes = `**Date:** ${formattedDate}\n`;
meetingNotes += `**Duration:** ${durationMinutes} minutes\n`;
if (meetingData.participants.length > 0) {
const participantNames = meetingData.participants.map(p => p.name).join(', ');
meetingNotes += `**Participants:** ${participantNames}\n`;
}
// Overview section
if (meetingData.summary?.overview) {
meetingNotes += `\n## Overview\n${meetingData.summary.overview}\n`;
}
// Key topics
if (meetingData.summary?.topics_discussed && Array.isArray(meetingData.summary.topics_discussed) && meetingData.summary.topics_discussed.length > 0) {
meetingNotes += `\n## Key Topics\n`;
meetingData.summary.topics_discussed.forEach(topic => {
meetingNotes += `- ${topic}\n`;
});
}
// Action items
if (meetingData.summary?.action_items && Array.isArray(meetingData.summary.action_items) && meetingData.summary.action_items.length > 0) {
meetingNotes += `\n## Action Items\n`;
meetingData.summary.action_items.forEach(item => {
meetingNotes += `- ${item}\n`;
});
}
// Insights section
meetingNotes += `\n## Insights\n`;
if (meetingData.summary?.keywords && Array.isArray(meetingData.summary.keywords) && meetingData.summary.keywords.length > 0) {
meetingNotes += `**Keywords:** ${meetingData.summary.keywords.join(', ')}\n`;
}
if (meetingData.analytics?.sentiments) {
const sentiments = meetingData.analytics.sentiments;
meetingNotes += `**Sentiment:** ${sentiments.positive_pct}% positive, ${sentiments.negative_pct}% negative, ${sentiments.neutral_pct}% neutral\n`;
}
if (meetingData.summary?.meeting_type) {
meetingNotes += `**Meeting Type:** ${meetingData.summary.meeting_type}\n`;
}
// Resources section
meetingNotes += `\n## Resources\n`;
meetingNotes += `[View Full Transcript](${meetingData.transcript_url})\n`;
if (meetingData.recording_url) {
meetingNotes += `[Watch Recording](${meetingData.recording_url})\n`;
}
return meetingNotes;
}
static toMeetingCreateInput(
meetingData: FirefliesMeetingData,
noteId?: string
): MeetingCreateInput {
const durationMinutes = Math.round(meetingData.duration);
// Build input object with only defined values (omit null fields)
const input: MeetingCreateInput = {
name: meetingData.title,
meetingDate: meetingData.date,
duration: durationMinutes,
actionItemsCount: meetingData.summary?.action_items?.length || 0,
firefliesMeetingId: meetingData.id,
};
// Add direct relationship to note if noteId is provided
if (noteId) {
input.noteId = noteId;
}
// Only add optional fields if they have values
if (meetingData.summary?.meeting_type) {
input.meetingType = meetingData.summary.meeting_type;
}
if (meetingData.summary?.keywords && Array.isArray(meetingData.summary.keywords) && meetingData.summary.keywords.length > 0) {
input.keywords = meetingData.summary.keywords.join(', ');
}
if (meetingData.analytics?.sentiments?.positive_pct) {
input.sentimentScore = meetingData.analytics.sentiments.positive_pct / 100;
input.positivePercent = meetingData.analytics.sentiments.positive_pct;
}
if (meetingData.analytics?.sentiments?.negative_pct) {
input.negativePercent = meetingData.analytics.sentiments.negative_pct;
}
// Only add URLs if they are valid (not empty strings)
if (meetingData.transcript_url && meetingData.transcript_url.trim()) {
input.transcriptUrl = {
primaryLinkUrl: meetingData.transcript_url,
primaryLinkLabel: 'View Transcript'
};
}
if (meetingData.recording_url && meetingData.recording_url.trim()) {
input.recordingUrl = {
primaryLinkUrl: meetingData.recording_url,
primaryLinkLabel: 'Watch Recording'
};
}
if (meetingData.organizer_email) {
input.organizerEmail = meetingData.organizer_email;
}
// Set success status and timestamps
input.importStatus = 'SUCCESS';
input.lastImportAttempt = new Date().toISOString();
input.importAttempts = 1;
return input;
}
static toFailedMeetingCreateInput(
meetingId: string,
title: string,
error: string,
attempts: number = 1
): MeetingCreateInput {
const currentDate = new Date().toISOString();
return {
name: title || `Failed Meeting Import - ${meetingId}`,
meetingDate: currentDate,
duration: 0,
actionItemsCount: 0,
firefliesMeetingId: meetingId,
importStatus: 'FAILED',
importError: error,
lastImportAttempt: currentDate,
importAttempts: attempts,
};
}
}
@@ -1,4 +1,5 @@
export { config, main } from './receive-fireflies-notes';
// Serverless function entry point - re-exports from src/lib
export { config, main } from '../../../src';
export type {
FirefliesMeetingData,
FirefliesParticipant,
@@ -6,5 +7,5 @@ export type {
ProcessResult,
SummaryFetchConfig,
SummaryStrategy
} from './types';
} from '../../../src';
@@ -1,95 +0,0 @@
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
export interface LoggerConfig {
logLevel: LogLevel;
isTestEnvironment: boolean;
}
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
silent: 4,
};
/**
* App-level Fireflies application logger with configurable log levels.
*/
export class AppLogger {
private config: LoggerConfig;
private context: string;
constructor(context: string) {
this.context = context;
this.config = {
logLevel: this.parseLogLevel(process.env.LOG_LEVEL || 'error'),
isTestEnvironment: process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined,
};
}
private parseLogLevel(level: string): LogLevel {
const normalizedLevel = level.toLowerCase() as LogLevel;
return Object.keys(LOG_LEVELS).includes(normalizedLevel) ? normalizedLevel : 'error';
}
private shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel];
}
/**
* Log debug information (LOG_LEVEL=debug)
*/
debug(message: string, ...args: any[]): void {
if (this.shouldLog('debug')) {
// eslint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
/**
* Log informational messages (LOG_LEVEL=info or lower)
*/
info(message: string, ...args: any[]): void {
if (this.shouldLog('info')) {
// eslint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
/**
* Log warnings (LOG_LEVEL=warn or lower)
*/
warn(message: string, ...args: any[]): void {
if (this.shouldLog('warn')) {
// eslint-disable-next-line no-console
console.warn(`[${this.context}] ${message}`, ...args);
}
}
/**
* Log errors (LOG_LEVEL=error or lower)
*/
error(message: string, ...args: any[]): void {
if (this.shouldLog('error')) {
// eslint-disable-next-line no-console
console.error(`[${this.context}] ${message}`, ...args);
}
}
/**
* Log critical errors that should ALWAYS be visible regardless of log level
* Use sparingly - only for fatal errors, security issues, or data corruption
*/
critical(message: string, ...args: any[]): void {
// eslint-disable-next-line no-console
console.error(`[${this.context}] CRITICAL: ${message}`, ...args);
}
}
/**
* Factory function to create loggers with automatic context detection
*/
export const createLogger = (context: string): AppLogger => {
return new AppLogger(context);
};
@@ -1,130 +0,0 @@
// Fireflies API Types
export type FirefliesParticipant = {
email: string;
name: string;
};
export type FirefliesWebhookPayload = {
meetingId: string;
eventType: string;
clientReferenceId?: string;
};
export type FirefliesMeetingData = {
id: string;
title: string;
date: string;
duration: number;
participants: FirefliesParticipant[];
organizer_email?: string;
summary: {
action_items: string[];
keywords?: string[];
overview: string;
gist?: string;
topics_discussed?: string[];
meeting_type?: string;
bullet_gist?: string;
};
analytics?: {
sentiments?: {
positive_pct: number;
negative_pct: number;
neutral_pct: number;
};
};
transcript_url: string;
recording_url?: string;
summary_status?: string;
};
// Configuration Types
export type SummaryStrategy = 'immediate_only' | 'immediate_with_retry' | 'delayed_polling' | 'basic_only';
export type SummaryFetchConfig = {
strategy: SummaryStrategy;
retryAttempts: number;
retryDelay: number;
pollInterval: number;
maxPolls: number;
};
export type WebhookConfig = {
secret: string;
apiUrl: string;
};
// Processing Result Types
export type ProcessResult = {
success: boolean;
meetingId?: string;
noteIds?: string[];
newContacts?: string[];
errors?: string[];
debug?: string[];
summaryReady?: boolean;
summaryPending?: boolean;
enhancementScheduled?: boolean;
actionItemsCount?: number;
sentimentScore?: number;
meetingType?: string;
keyTopics?: string[];
};
// Twenty CRM Types
export type GraphQLResponse<T> = {
data: T;
errors?: Array<{
message?: string;
extensions?: { code?: string }
}>;
};
export type IdNode = { id: string };
export type FindMeetingResponse = {
meetings: { edges: Array<{ node: IdNode }> };
};
export type FindPeopleResponse = {
people: { edges: Array<{ node: { id: string; emails: { primaryEmail: string } } }> };
};
export type CreatePersonResponse = {
createPerson: { id: string }
};
export type CreateNoteResponse = {
createNote: { id: string }
};
export type CreateMeetingResponse = {
createMeeting: { id: string }
};
export type Contact = {
id: string;
email: string;
};
export type MeetingCreateInput = {
name: string;
noteId?: string | null; // This is the relation field
meetingDate: string;
duration: number;
meetingType?: string | null;
keywords?: string | null;
sentimentScore?: number | null;
positivePercent?: number | null;
negativePercent?: number | null;
actionItemsCount: number;
transcriptUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
recordingUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
firefliesMeetingId: string;
organizerEmail?: string | null;
importStatus?: 'SUCCESS' | 'FAILED' | 'PENDING' | 'RETRYING' | null;
importError?: string | null;
lastImportAttempt?: string | null;
importAttempts?: number | null;
};
@@ -4,7 +4,7 @@ import {
main,
type FirefliesMeetingData,
type FirefliesWebhookPayload,
} from '../actions/receive-fireflies-notes';
} from '../';
// Helper to generate HMAC signature
const generateHMACSignature = (body: string, secret: string): string => {
@@ -39,10 +39,12 @@ const mockFirefliesApiResponseWithSummary = {
meeting_type: 'Sales Call',
bullet_gist: '• Demonstrated core product features\n• Discussed enterprise pricing\n• Addressed integration questions',
},
sentiments: { // Note: Raw API has sentiments at top level, not in analytics
positive_pct: 75,
negative_pct: 10,
neutral_pct: 15,
analytics: {
sentiments: {
positive_pct: 75,
negative_pct: 10,
neutral_pct: 15,
},
},
transcript_url: 'https://app.fireflies.ai/transcript/test-001',
video_url: 'https://app.fireflies.ai/recording/test-001',
@@ -81,7 +83,7 @@ const mockMeetingWithFullSummary: FirefliesMeetingData = {
},
},
transcript_url: 'https://app.fireflies.ai/transcript/test-001',
recording_url: 'https://app.fireflies.ai/recording/test-001',
video_url: 'https://app.fireflies.ai/recording/test-001',
summary_status: 'completed',
};
@@ -404,7 +406,11 @@ describe('Fireflies Webhook Integration v2', () => {
expect(result.success).toBe(true);
expect(result.summaryReady).toBe(true);
expect(result.actionItemsCount).toBe(3);
expect(result.sentimentScore).toBeCloseTo(0.75, 2); // Use toBeCloseTo for floating point comparison
expect(result.sentimentAnalysis).toEqual({
positive_pct: 75,
negative_pct: 10,
neutral_pct: 15,
});
expect(result.meetingType).toBe('Sales Call');
expect(result.keyTopics).toEqual(['product features', 'pricing discussion', 'integration capabilities', 'support options']);
});
@@ -565,7 +571,7 @@ describe('Fireflies Webhook Integration v2', () => {
const createNoteMock = jest.fn();
global.fetch = jest.fn().mockImplementation((url: string, options?: any) => {
global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
@@ -576,9 +582,9 @@ describe('Fireflies Webhook Integration v2', () => {
}
// Twenty API
const body = options?.body ? JSON.parse(options.body) : {};
if (body.query?.includes('createNote')) {
createNoteMock(body.variables);
const requestBody = options?.body ? JSON.parse(options.body as string) : {};
if (requestBody.query?.includes('createNote')) {
createNoteMock(requestBody.variables);
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
@@ -610,7 +616,7 @@ describe('Fireflies Webhook Integration v2', () => {
expect(noteData.data.bodyV2.markdown).toContain('## Overview'); // Markdown header, not bold
expect(noteData.data.bodyV2.markdown).toContain('## Action Items'); // Markdown header, not bold
expect(noteData.data.bodyV2.markdown).toContain('**Sentiment:**'); // This is bold
expect(noteData.data.bodyV2.markdown).toContain('[View Full Transcript]');
expect(noteData.data.bodyV2.markdown).toContain('View Full Transcript on Fireflies');
});
it('should create meeting records for multi-party meetings', async () => {
@@ -624,7 +630,7 @@ describe('Fireflies Webhook Integration v2', () => {
const createMeetingMock = jest.fn();
global.fetch = jest.fn().mockImplementation((url: string, options?: any) => {
global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => {
if (url === 'https://api.fireflies.ai/graphql') {
return Promise.resolve({
ok: true,
@@ -635,9 +641,9 @@ describe('Fireflies Webhook Integration v2', () => {
}
// Twenty API
const body = options?.body ? JSON.parse(options.body) : {};
if (body.query?.includes('createMeeting')) {
createMeetingMock(body.variables);
const requestBody = options?.body ? JSON.parse(options.body as string) : {};
if (requestBody.query?.includes('createMeeting')) {
createMeetingMock(requestBody.variables);
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
@@ -645,7 +651,7 @@ describe('Fireflies Webhook Integration v2', () => {
}),
});
}
if (body.query?.includes('createNote')) {
if (requestBody.query?.includes('createNote')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
@@ -694,7 +700,7 @@ describe('Fireflies Webhook Integration v2', () => {
});
it('should handle missing payload gracefully', async () => {
const result = await main(null as any, {});
const result = await main(null as unknown, {});
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
@@ -703,10 +709,10 @@ describe('Fireflies Webhook Integration v2', () => {
it('should handle invalid payload structure', async () => {
const invalidPayload = { invalid: 'data' };
const result = await main(invalidPayload as any, {});
const result = await main(invalidPayload as unknown, {});
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
});
});
});
@@ -0,0 +1,84 @@
import { HistoricalImporter } from '../historical-importer';
import type { FirefliesMeetingData, SummaryFetchConfig } from '../types';
const summaryConfig: SummaryFetchConfig = {
strategy: 'immediate_with_retry',
retryAttempts: 1,
retryDelay: 0,
pollInterval: 0,
maxPolls: 0,
};
const sampleMeeting: FirefliesMeetingData = {
id: 'm-1',
title: 'Sample',
date: new Date().toISOString(),
duration: 30,
participants: [],
summary: { action_items: [], overview: '' },
transcript_url: 'https://example.com',
};
describe('HistoricalImporter', () => {
const buildImporter = () => {
const firefliesClient = {
listTranscripts: jest.fn(),
fetchMeetingDataWithRetry: jest.fn(),
} as unknown as jest.Mocked<any>;
const twentyService = {
findMeetingByFirefliesId: jest.fn(),
matchParticipantsToContacts: jest.fn(),
createContactsForUnmatched: jest.fn(),
createNoteOnly: jest.fn(),
createMeeting: jest.fn(),
createNoteTarget: jest.fn(),
} as unknown as jest.Mocked<any>;
return { firefliesClient, twentyService };
};
it('skips meetings that already exist by firefliesMeetingId', async () => {
const { firefliesClient, twentyService } = buildImporter();
firefliesClient.listTranscripts.mockResolvedValue([{ id: 'existing' }]);
twentyService.findMeetingByFirefliesId.mockResolvedValue({ id: 'twenty-id' });
const importer = new HistoricalImporter(firefliesClient, twentyService);
const result = await importer.run(
{},
{ dryRun: false, autoCreateContacts: true, summaryConfig, plan: 'free' },
);
expect(result.skippedExisting).toBe(1);
expect(result.imported).toBe(0);
expect(twentyService.createMeeting).not.toHaveBeenCalled();
expect(result.statuses[0].status).toBe('skipped_existing');
});
it('supports dry-run without writing to Twenty', async () => {
const { firefliesClient, twentyService } = buildImporter();
firefliesClient.listTranscripts.mockResolvedValue([{ id: 'm-2' }]);
firefliesClient.fetchMeetingDataWithRetry.mockResolvedValue({
data: sampleMeeting,
summaryReady: false,
});
twentyService.findMeetingByFirefliesId.mockResolvedValue(undefined);
const importer = new HistoricalImporter(firefliesClient, twentyService);
const result = await importer.run(
{},
{ dryRun: true, autoCreateContacts: false, summaryConfig, plan: 'free' },
);
expect(result.imported).toBe(1);
expect(result.summaryPending).toBe(1);
expect(twentyService.createMeeting).not.toHaveBeenCalled();
expect(twentyService.createNoteOnly).not.toHaveBeenCalled();
expect(result.statuses).toHaveLength(1);
expect(result.statuses[0].status).toBe('pending_summary');
});
});
@@ -9,6 +9,7 @@ process.env.AUTO_CREATE_CONTACTS = 'true';
process.env.SERVER_URL = 'http://localhost:3000';
process.env.TWENTY_API_KEY = 'test-api-key';
process.env.LOG_LEVEL = 'silent';
process.env.CAPTURE_LOGS = 'false';
// Reset mocks before each test
beforeEach(() => {
@@ -0,0 +1,40 @@
import { WebhookHandler } from '../webhook-handler';
describe('WebhookHandler log capture', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = {
...originalEnv,
FIREFLIES_WEBHOOK_SECRET: 'testsecret',
FIREFLIES_API_KEY: '',
TWENTY_API_KEY: '',
CAPTURE_LOGS: 'false',
LOG_LEVEL: 'silent',
};
});
afterEach(() => {
process.env = originalEnv;
});
it('includes debug logs in response when CAPTURE_LOGS is true', async () => {
process.env.CAPTURE_LOGS = 'true';
const handler = new WebhookHandler();
const result = await handler.handle(null);
expect(result.success).toBe(false);
expect(Array.isArray(result.debug)).toBe(true);
});
it('omits debug logs when CAPTURE_LOGS is false', async () => {
process.env.CAPTURE_LOGS = 'false';
const handler = new WebhookHandler();
const result = await handler.handle(null);
expect(result.debug).toBeUndefined();
});
});
@@ -1,11 +0,0 @@
export {
config,
main,
type FirefliesMeetingData,
type FirefliesParticipant,
type FirefliesWebhookPayload,
type ProcessResult,
type SummaryFetchConfig,
type SummaryStrategy
} from '../../serverlessFunctions/receive-fireflies-notes/src/index';
@@ -0,0 +1,823 @@
import { createLogger } from './logger';
import {
FIREFLIES_PLANS,
type FirefliesMeetingData,
type FirefliesParticipant,
type FirefliesPlan,
type FirefliesTranscriptListItem,
type FirefliesTranscriptListOptions,
type SummaryFetchConfig
} from './types';
const logger = createLogger('fireflies-api');
export class FirefliesApiClient {
private apiKey: string;
constructor(apiKey: string) {
if (!apiKey) {
logger.critical('FIREFLIES_API_KEY is required but not provided - this is a critical configuration error');
throw new Error('FIREFLIES_API_KEY is required');
}
this.apiKey = apiKey;
}
async listTranscripts(options: FirefliesTranscriptListOptions = {}): Promise<FirefliesTranscriptListItem[]> {
const {
organizers,
participants,
hostEmail,
participantEmail,
userId,
channelId,
mine,
fromDate,
toDate,
pageSize = 50,
maxRecords = 500,
} = options;
const sanitizedOrganizers = organizers?.filter(Boolean);
const sanitizedParticipants = participants?.filter(Boolean);
const transcripts: FirefliesTranscriptListItem[] = [];
let skip = options.skip ?? 0;
const limit = options.limit ?? pageSize;
const baseQuery = `
query Transcripts(
$limit: Int
$skip: Int
$hostEmail: String
$participantEmail: String
$organizers: [String!]
$participants: [String!]
$userId: String
$channelId: String
$mine: Boolean
$date: Float
) {
transcripts(
limit: $limit
skip: $skip
host_email: $hostEmail
participant_email: $participantEmail
organizers: $organizers
participants: $participants
user_id: $userId
channel_id: $channelId
mine: $mine
date: $date
) {
id
title
date
duration
organizer_email
participants
transcript_url
meeting_link
meeting_info { summary_status }
}
}
`;
while (transcripts.length < maxRecords) {
const pageVariables = {
limit,
skip,
hostEmail,
participantEmail,
organizers: sanitizedOrganizers,
participants: sanitizedParticipants,
userId,
channelId,
mine,
date: fromDate,
};
const page = await this.executeTranscriptListQuery(baseQuery, pageVariables);
const normalized = page
.map((item) => {
const normalizedDate = this.normalizeDate(item.date);
return {
id: (item.id as string) || '',
title: (item.title as string) || 'Untitled Meeting',
date: normalizedDate,
duration: (item.duration as number) || 0,
organizer_email: item.organizer_email as string | undefined,
participants: Array.isArray(item.participants)
? (item.participants as string[])
: undefined,
transcript_url: item.transcript_url as string | undefined,
meeting_link: item.meeting_link as string | undefined,
summary_status: (item.meeting_info as { summary_status?: string } | undefined)?.summary_status,
};
})
.filter((item) => {
if (toDate && item.date) {
const itemTime = Date.parse(item.date);
if (!Number.isNaN(itemTime) && itemTime > toDate) {
return false;
}
}
return true;
});
transcripts.push(...normalized);
if (page.length < limit) {
break;
}
skip += limit;
}
if (transcripts.length > maxRecords) {
return transcripts.slice(0, maxRecords);
}
return transcripts;
}
async fetchMeetingData(
meetingId: string,
options?: { timeout?: number; plan?: FirefliesPlan }
): Promise<FirefliesMeetingData> {
const plan = options?.plan ?? FIREFLIES_PLANS.FREE;
const isPremiumPlan =
plan === FIREFLIES_PLANS.BUSINESS || plan === FIREFLIES_PLANS.ENTERPRISE;
// Minimal query for free plans - only basic fields available on all plans
// Note: audio_url requires Pro+, video_url requires Business+
const freeQuery = `
query GetTranscriptMinimal($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
date
duration
participants
organizer_email
transcript_url
meeting_link
}
}
`;
// Standard query for pro plans - adds speakers, summary, sentences, and audio_url (Pro+)
// Note: video_url requires Business+
const proQuery = `
query GetTranscriptBasic($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
date
duration
participants
organizer_email
speakers {
name
}
sentences {
index
speaker_name
text
start_time
end_time
}
summary {
overview
keywords
action_items
notes
gist
bullet_gist
short_summary
short_overview
outline
shorthand_bullet
}
meeting_info {
summary_status
}
transcript_url
audio_url
meeting_link
}
}
`;
// Full query for business/enterprise - includes all fields
const businessQuery = `
query GetTranscriptFull($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
date
duration
participants
organizer_email
analytics {
sentiments {
positive_pct
negative_pct
neutral_pct
}
categories {
questions
tasks
metrics
date_times
}
speakers {
speaker_id
name
duration
word_count
longest_monologue
filler_words
questions
words_per_minute
}
}
meeting_attendees {
displayName
email
phoneNumber
name
location
}
meeting_attendance {
name
join_time
leave_time
}
speakers {
name
}
sentences {
index
speaker_name
text
start_time
end_time
ai_filters {
task
question
sentiment
}
}
summary {
action_items
overview
keywords
notes
gist
bullet_gist
short_summary
short_overview
outline
shorthand_bullet
topics_discussed
meeting_type
transcript_chapters
}
meeting_info {
summary_status
}
transcript_url
audio_url
video_url
meeting_link
}
}
`;
// Select query based on plan
const queryToUse = isPremiumPlan ? businessQuery :
(plan === FIREFLIES_PLANS.PRO ? proQuery : freeQuery);
const planFeatures = {
[FIREFLIES_PLANS.FREE]: 'basic fields only (no summary, no audio/video)',
[FIREFLIES_PLANS.PRO]: 'summary, speakers, audio_url',
[FIREFLIES_PLANS.BUSINESS]: 'full access including analytics, video_url',
[FIREFLIES_PLANS.ENTERPRISE]: 'full access including analytics, video_url',
};
logger.debug(`using ${plan} plan query (${planFeatures[plan]})`);
try {
return await this.executeTranscriptQuery({
meetingId,
query: queryToUse,
timeout: options?.timeout,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// Detect plan-specific errors
const requiresBusiness = message.toLowerCase().includes('business or higher');
const requiresPro = message.toLowerCase().includes('pro or higher');
const planError = requiresBusiness || requiresPro ||
message.toLowerCase().includes('higher plan') ||
message.includes('Cannot query field');
// Fallback cascade: business -> pro -> free
if (planError) {
if (isPremiumPlan) {
logger.warn(`Plan limitation detected (configured: ${plan}), falling back to pro query`);
try {
return await this.executeTranscriptQuery({
meetingId,
query: proQuery,
timeout: options?.timeout,
});
} catch (proError) {
const proMessage = proError instanceof Error ? proError.message : String(proError);
if (proMessage.toLowerCase().includes('plan') || proMessage.includes('Cannot query field')) {
logger.warn('Pro query also failed, falling back to minimal free query');
return this.executeTranscriptQuery({
meetingId,
query: freeQuery,
timeout: options?.timeout,
});
}
throw proError;
}
} else if (plan === FIREFLIES_PLANS.PRO) {
logger.warn(`Pro plan query failed (${requiresBusiness ? 'requires Business+' : 'unknown restriction'}), falling back to free query`);
return this.executeTranscriptQuery({
meetingId,
query: freeQuery,
timeout: options?.timeout,
});
} else {
// Already using free query - some field might still be restricted
logger.error(
'Fireflies API rejected the minimal free query. This may indicate: ' +
'1) The transcript ID is invalid, or ' +
'2) Your API key does not have access to this transcript, or ' +
'3) An unexpected API restriction : open an issue'
);
}
}
throw error;
}
}
async fetchMeetingDataWithRetry(
meetingId: string,
config: SummaryFetchConfig,
plan: FirefliesPlan = FIREFLIES_PLANS.FREE
): Promise<{ data: FirefliesMeetingData; summaryReady: boolean }> {
// immediate_only: single attempt, no retries
if (config.strategy === 'immediate_only') {
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_only)`);
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000, plan });
const ready = this.isSummaryReady(meetingData);
logger.debug(`summary ready: ${ready}`);
return { data: meetingData, summaryReady: ready };
}
// immediate_with_retry: retry with linear backoff
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_with_retry, maxAttempts: ${config.retryAttempts})`);
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
try {
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000, plan });
const ready = this.isSummaryReady(meetingData);
logger.debug(`attempt ${attempt}/${config.retryAttempts}: summary ready=${ready}`);
if (ready) {
return { data: meetingData, summaryReady: true };
}
if (attempt < config.retryAttempts) {
const delayMs = config.retryDelay * attempt;
logger.debug(`summary not ready, waiting ${delayMs}ms before retry ${attempt + 1}`);
await new Promise(resolve => setTimeout(resolve, delayMs));
} else {
logger.debug(`max retries reached, returning partial data`);
return { data: meetingData, summaryReady: false };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`attempt ${attempt}/${config.retryAttempts} failed: ${errorMsg}`);
if (attempt === config.retryAttempts) {
throw error;
}
const delayMs = config.retryDelay * attempt;
logger.debug(`retrying in ${delayMs}ms...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
throw new Error('Failed to fetch meeting data after retries');
}
private async executeTranscriptQuery({
meetingId,
query,
timeout,
}: {
meetingId: string;
query: string;
timeout?: number;
}): Promise<FirefliesMeetingData> {
const controller = new AbortController();
const timeoutId = timeout ? setTimeout(() => controller.abort(), timeout) : null;
try {
const response = await fetch('https://api.fireflies.ai/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
query,
variables: { transcriptId: meetingId },
}),
signal: controller.signal,
});
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
let errorDetails = `Fireflies API request failed with status ${response.status}`;
try {
const errorBody = await response.text();
if (errorBody) {
errorDetails += `: ${errorBody}`;
}
} catch {
// Ignore if we can't read the response body
}
throw new Error(errorDetails);
}
const json = await response.json() as {
data?: { transcript?: Record<string, unknown> };
errors?: Array<{ message?: string }>;
};
if (json.errors && json.errors.length > 0) {
throw new Error(`Fireflies API error: ${json.errors[0]?.message || 'Unknown error'}`);
}
const transcript = json.data?.transcript;
if (!transcript) {
throw new Error('Invalid response from Fireflies API: missing transcript data');
}
return this.transformMeetingData(transcript, meetingId);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
private isSummaryReady(meetingData: FirefliesMeetingData): boolean {
return (
(meetingData.summary?.action_items?.length > 0) ||
(meetingData.summary?.overview?.length > 0) ||
meetingData.summary_status === 'completed'
);
}
private extractAllParticipants(transcript: Record<string, unknown>): FirefliesParticipant[] {
const participantsWithEmails: FirefliesParticipant[] = [];
const participantsNameOnly: FirefliesParticipant[] = [];
logger.debug('=== PARTICIPANT EXTRACTION DEBUG ===');
logger.debug('participants field:', JSON.stringify(transcript.participants));
logger.debug('meeting_attendees field:', JSON.stringify(transcript.meeting_attendees));
logger.debug('speakers field:', (transcript.speakers as Array<{ name: string }>)?.map((s) => s.name));
logger.debug('meeting_attendance field:', (transcript.meeting_attendance as Array<{ name: string }>)?.map((a) => a.name));
logger.debug('organizer_email:', transcript.organizer_email);
const isEmail = (str: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str.trim());
};
const isDuplicate = (name: string, email: string): boolean => {
const nameLower = name.toLowerCase().trim();
const emailLower = email.toLowerCase().trim();
return participantsWithEmails.some(p =>
p.name.toLowerCase().trim() === nameLower ||
(email && p.email.toLowerCase() === emailLower)
) || participantsNameOnly.some(p =>
p.name.toLowerCase().trim() === nameLower
);
};
// 1. Extract from legacy participants field (with emails)
if (transcript.participants && Array.isArray(transcript.participants)) {
transcript.participants.forEach((participant: string) => {
const parts = participant.split(',').map(p => p.trim());
parts.forEach(part => {
const emailMatch = part.match(/<([^>]+)>/);
const email = emailMatch ? emailMatch[1] : '';
const name = emailMatch
? part.substring(0, part.indexOf('<')).trim()
: part.trim();
if (isEmail(name)) {
logger.debug(`Skipping participant with email as name: "${name}"`);
return;
}
if (!name) {
return;
}
if (isDuplicate(name, email)) {
logger.debug(`Skipping duplicate participant: "${name}" <${email}>`);
return;
}
if (name && email) {
participantsWithEmails.push({ name, email });
} else if (name) {
participantsNameOnly.push({ name, email: '' });
}
});
});
}
// 2. Extract from meeting_attendees field (structured)
if (transcript.meeting_attendees && Array.isArray(transcript.meeting_attendees)) {
transcript.meeting_attendees.forEach((attendee: Record<string, unknown>) => {
const name = (attendee.displayName || attendee.name || '') as string;
const email = (attendee.email || '') as string;
if (isEmail(name)) {
logger.debug(`Skipping attendee with email as name: "${name}"`);
return;
}
if (name && !isDuplicate(name, email)) {
if (email) {
participantsWithEmails.push({ name, email });
} else {
participantsNameOnly.push({ name, email: '' });
}
}
});
}
// 3. Extract from speakers field (name only)
if (transcript.speakers && Array.isArray(transcript.speakers)) {
transcript.speakers.forEach((speaker: Record<string, unknown>) => {
const name = (speaker.name || '') as string;
if (isEmail(name)) {
logger.debug(`Skipping speaker with email as name: "${name}"`);
return;
}
if (name && !isDuplicate(name, '')) {
participantsNameOnly.push({ name, email: '' });
}
});
}
// 4. Extract from meeting_attendance field (name only)
if (transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
transcript.meeting_attendance.forEach((attendance: Record<string, unknown>) => {
const name = (attendance.name || '') as string;
if (isEmail(name) || name.includes(',')) {
logger.debug(`Skipping attendance with email/list as name: "${name}"`);
return;
}
if (name && !isDuplicate(name, '')) {
participantsNameOnly.push({ name, email: '' });
}
});
}
// 5. Add organizer email if available and not already included
const organizerEmail = transcript.organizer_email as string | undefined;
if (organizerEmail) {
const existsWithEmail = participantsWithEmails.some(p =>
p.email.toLowerCase() === organizerEmail.toLowerCase()
);
if (!existsWithEmail) {
let organizerName = '';
const emailUsername = organizerEmail.split('@')[0].toLowerCase();
const emailNameVariations = [emailUsername];
if (transcript.speakers && Array.isArray(transcript.speakers)) {
const potentialOrganizerSpeaker = transcript.speakers.find((speaker: Record<string, unknown>) => {
const name = ((speaker.name || '') as string).toLowerCase();
return emailNameVariations.some(variation =>
name.includes(variation) || variation.includes(name)
);
}) as Record<string, unknown> | undefined;
if (potentialOrganizerSpeaker) {
organizerName = potentialOrganizerSpeaker.name as string;
}
}
if (!organizerName && transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
const potentialOrganizerAttendance = transcript.meeting_attendance.find((attendance: Record<string, unknown>) => {
const name = ((attendance.name || '') as string).toLowerCase();
return emailNameVariations.some(variation =>
name.includes(variation) || variation.includes(name)
);
}) as Record<string, unknown> | undefined;
if (potentialOrganizerAttendance) {
organizerName = potentialOrganizerAttendance.name as string;
}
}
if (organizerName) {
participantsWithEmails.push({ name: organizerName, email: organizerEmail });
const nameIndex = participantsNameOnly.findIndex(p =>
p.name.toLowerCase().includes(organizerName.toLowerCase()) ||
organizerName.toLowerCase().includes(p.name.toLowerCase())
);
if (nameIndex !== -1) {
participantsNameOnly.splice(nameIndex, 1);
}
} else {
participantsWithEmails.push({ name: 'Meeting Organizer', email: organizerEmail });
}
}
}
const allParticipants = [...participantsWithEmails, ...participantsNameOnly];
logger.debug('=== EXTRACTED PARTICIPANTS ===');
logger.debug('With emails:', participantsWithEmails.length, JSON.stringify(participantsWithEmails));
logger.debug('Name only:', participantsNameOnly.length, JSON.stringify(participantsNameOnly));
logger.debug('Total:', allParticipants.length);
return allParticipants;
}
private transformMeetingData(transcript: Record<string, unknown>, meetingId: string): FirefliesMeetingData {
let dateString: string;
if (transcript.date) {
if (typeof transcript.date === 'number') {
dateString = new Date(transcript.date).toISOString();
} else if (typeof transcript.date === 'string') {
const parsed = Number(transcript.date);
if (!isNaN(parsed)) {
dateString = new Date(parsed).toISOString();
} else {
dateString = transcript.date;
}
} else {
dateString = new Date().toISOString();
}
} else {
dateString = new Date().toISOString();
}
const summary = transcript.summary as Record<string, unknown> | undefined;
const analytics = transcript.analytics as Record<string, unknown> | undefined;
const sentiments = analytics?.sentiments as Record<string, number> | undefined;
const categories = analytics?.categories as Record<string, number> | undefined;
const speakersAnalytics = analytics?.speakers as Array<Record<string, unknown>> | undefined;
const meetingInfo = transcript.meeting_info as Record<string, unknown> | undefined;
// Transform sentences array
const rawSentences = transcript.sentences as Array<Record<string, unknown>> | undefined;
const sentences = rawSentences?.map(s => ({
index: (s.index as number) || 0,
speaker_name: (s.speaker_name as string) || 'Unknown',
text: (s.text as string) || '',
start_time: (s.start_time as string) || '0',
end_time: (s.end_time as string) || '0',
ai_filters: s.ai_filters as { task?: boolean; question?: boolean; sentiment?: string } | undefined,
}));
// Transform speaker analytics
const speakers = speakersAnalytics?.map(sp => ({
speaker_id: (sp.speaker_id as string) || '',
name: (sp.name as string) || 'Unknown',
duration: (sp.duration as number) || 0,
word_count: (sp.word_count as number) || 0,
longest_monologue: (sp.longest_monologue as number) || 0,
filler_words: (sp.filler_words as number) || 0,
questions: (sp.questions as number) || 0,
words_per_minute: (sp.words_per_minute as number) || 0,
}));
return {
id: (transcript.id as string) || meetingId,
title: (transcript.title as string) || 'Untitled Meeting',
date: dateString,
duration: (transcript.duration as number) || 0,
participants: this.extractAllParticipants(transcript),
organizer_email: transcript.organizer_email as string | undefined,
sentences,
summary: {
// action_items can be string or array - normalize to array
action_items: Array.isArray(summary?.action_items)
? summary.action_items as string[]
: (typeof summary?.action_items === 'string' && summary.action_items.trim()
? summary.action_items.split('\n').filter((item: string) => item.trim())
: []),
overview: (summary?.overview as string) || '',
notes: summary?.notes as string | undefined,
gist: summary?.gist as string | undefined,
bullet_gist: summary?.bullet_gist as string | undefined,
short_summary: summary?.short_summary as string | undefined,
short_overview: summary?.short_overview as string | undefined,
outline: summary?.outline as string | undefined,
shorthand_bullet: summary?.shorthand_bullet as string | undefined,
keywords: summary?.keywords as string[] | undefined,
topics_discussed: summary?.topics_discussed as string[] | undefined,
meeting_type: summary?.meeting_type as string | undefined,
transcript_chapters: summary?.transcript_chapters as string[] | undefined,
},
analytics: (sentiments || categories || speakers) ? {
sentiments: sentiments ? {
positive_pct: sentiments.positive_pct || 0,
negative_pct: sentiments.negative_pct || 0,
neutral_pct: sentiments.neutral_pct || 0,
} : undefined,
categories: categories ? {
questions: categories.questions || 0,
tasks: categories.tasks || 0,
metrics: categories.metrics || 0,
date_times: categories.date_times || 0,
} : undefined,
speakers,
} : undefined,
meeting_info: meetingInfo ? {
summary_status: meetingInfo.summary_status as string | undefined,
} : undefined,
// URLs by plan availability:
transcript_url: (transcript.transcript_url as string) || `https://app.fireflies.ai/view/${meetingId}`,
audio_url: transcript.audio_url as string | undefined, // Pro+
video_url: transcript.video_url as string | undefined, // Business+
meeting_link: transcript.meeting_link as string | undefined, // All plans
summary_status: (meetingInfo?.summary_status as string) || (transcript.summary_status as string) || undefined,
};
}
private async executeTranscriptListQuery(
query: string,
variables: Record<string, unknown>,
): Promise<Array<Record<string, unknown>>> {
const response = await fetch('https://api.fireflies.ai/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Fireflies transcripts request failed: ${response.status} ${errorBody}`);
}
const json = await response.json() as {
data?: { transcripts?: Array<Record<string, unknown>> };
errors?: Array<{ message?: string }>;
};
if (json.errors && json.errors.length > 0) {
const message = json.errors[0]?.message || 'Unknown error';
throw new Error(`Fireflies API error: ${message}`);
}
return json.data?.transcripts ?? [];
}
private normalizeDate(dateValue: unknown): string | undefined {
if (!dateValue) {
return undefined;
}
if (typeof dateValue === 'number') {
return new Date(dateValue).toISOString();
}
if (typeof dateValue === 'string') {
const parsed = Number(dateValue);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toISOString();
}
return dateValue;
}
return undefined;
}
}
@@ -0,0 +1,259 @@
import type { FirefliesMeetingData, FirefliesSentence, MeetingCreateInput } from './types';
export class MeetingFormatter {
// Format timestamp from seconds to MM:SS
private static formatTimestamp(timeStr: string): string {
const seconds = parseFloat(timeStr);
if (isNaN(seconds)) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// Format full transcript from sentences
private static formatTranscript(sentences: FirefliesSentence[]): string {
if (!sentences || sentences.length === 0) return '';
let transcript = '';
let currentSpeaker = '';
for (const sentence of sentences) {
const timestamp = this.formatTimestamp(sentence.start_time);
const speaker = sentence.speaker_name || 'Unknown';
// Add speaker header when speaker changes
if (speaker !== currentSpeaker) {
currentSpeaker = speaker;
transcript += `\n**${speaker}** [${timestamp}]\n`;
}
transcript += `${sentence.text} `;
}
return transcript.trim();
}
static formatNoteBody(meetingData: FirefliesMeetingData): string {
const meetingDate = meetingData.date ? new Date(meetingData.date) : null;
const hasValidDate = meetingDate instanceof Date && !Number.isNaN(meetingDate.getTime());
const formattedDate = hasValidDate
? meetingDate.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: 'Unknown date';
const durationMinutes = Math.round(meetingData.duration);
let noteBody = `**Date:** ${formattedDate}\n`;
noteBody += `**Duration:** ${durationMinutes} minutes\n`;
if (meetingData.participants.length > 0) {
const participantNames = meetingData.participants.map(p => p.name).join(', ');
noteBody += `**Participants:** ${participantNames}\n`;
}
// Overview section
if (meetingData.summary?.overview) {
noteBody += `\n## Overview\n${meetingData.summary.overview}\n`;
}
// Detailed AI Notes (the rich content from Fireflies)
if (meetingData.summary?.notes) {
noteBody += `\n## Meeting Notes\n${meetingData.summary.notes}\n`;
}
// Bullet gist with emojis (if available and different from notes)
if (meetingData.summary?.bullet_gist && !meetingData.summary?.notes) {
noteBody += `\n## Key Points\n${meetingData.summary.bullet_gist}\n`;
}
// Meeting outline with timestamps (shorthand_bullet contains the timestamped outline)
const outline = meetingData.summary?.outline || meetingData.summary?.shorthand_bullet;
if (outline) {
noteBody += `\n## Outline\n${outline}\n`;
}
// Key topics
if (meetingData.summary?.topics_discussed?.length) {
noteBody += `\n## Key Topics\n`;
meetingData.summary.topics_discussed.forEach(topic => {
noteBody += `- ${topic}\n`;
});
}
// Action items
if (meetingData.summary?.action_items?.length) {
noteBody += `\n## Action Items\n`;
meetingData.summary.action_items.forEach(item => {
noteBody += `- [ ] ${item}\n`;
});
}
// Insights section
noteBody += `\n## Insights\n`;
if (meetingData.summary?.keywords?.length) {
noteBody += `**Keywords:** ${meetingData.summary.keywords.join(', ')}\n`;
}
if (meetingData.analytics?.sentiments) {
const sentiments = meetingData.analytics.sentiments;
noteBody += `**Sentiment:** ${sentiments.positive_pct}% positive, ${sentiments.negative_pct}% negative, ${sentiments.neutral_pct}% neutral\n`;
}
if (meetingData.summary?.meeting_type) {
noteBody += `**Meeting Type:** ${meetingData.summary.meeting_type}\n`;
}
// Speaker analytics (Business+)
if (meetingData.analytics?.speakers?.length) {
noteBody += `\n### Speaker Stats\n`;
for (const speaker of meetingData.analytics.speakers) {
const talkTime = Math.round(speaker.duration / 60);
noteBody += `- **${speaker.name}**: ${talkTime} min talk time, ${speaker.word_count} words, ${speaker.questions} questions\n`;
}
}
// Meeting metrics (Business+)
if (meetingData.analytics?.categories) {
const cats = meetingData.analytics.categories;
noteBody += `\n### Meeting Metrics\n`;
noteBody += `- Questions asked: ${cats.questions}\n`;
noteBody += `- Tasks identified: ${cats.tasks}\n`;
if (cats.metrics > 0) noteBody += `- Metrics mentioned: ${cats.metrics}\n`;
if (cats.date_times > 0) noteBody += `- Dates/times discussed: ${cats.date_times}\n`;
}
// Resources section
noteBody += `\n## Resources\n`;
noteBody += `[View Full Transcript on Fireflies](${meetingData.transcript_url})\n`;
if (meetingData.video_url) {
noteBody += `[Watch Video Recording](${meetingData.video_url})\n`;
}
if (meetingData.audio_url) {
noteBody += `[Listen to Audio](${meetingData.audio_url})\n`;
}
if (meetingData.meeting_link) {
noteBody += `[Original Meeting Link](${meetingData.meeting_link})\n`;
}
return noteBody;
}
static toMeetingCreateInput(
meetingData: FirefliesMeetingData,
noteId?: string
): MeetingCreateInput {
const durationMinutes = Math.round(meetingData.duration);
const hasSummary = Boolean(meetingData.summary?.overview || meetingData.summary?.action_items?.length);
const hasAnalytics = Boolean(meetingData.analytics?.sentiments);
// Build input object with only defined values (omit null fields)
const input: MeetingCreateInput = {
name: meetingData.title,
meetingDate: meetingData.date,
duration: durationMinutes,
actionItemsCount: meetingData.summary?.action_items?.length || 0,
firefliesMeetingId: meetingData.id,
};
// Add direct relationship to note if noteId is provided
if (noteId) {
input.noteId = noteId;
}
// Basic fields (All plans)
if (meetingData.organizer_email) {
input.organizerEmail = meetingData.organizer_email;
}
if (meetingData.transcript_url?.trim()) {
input.transcriptUrl = {
primaryLinkUrl: meetingData.transcript_url,
primaryLinkLabel: 'View Transcript'
};
}
if (meetingData.meeting_link?.trim()) {
input.meetingLink = {
primaryLinkUrl: meetingData.meeting_link,
primaryLinkLabel: 'Join Meeting'
};
}
// Pro+ fields (transcript, summary, notes, keywords, audio)
if (meetingData.sentences?.length) {
input.transcript = this.formatTranscript(meetingData.sentences);
}
if (meetingData.summary?.overview) {
input.overview = meetingData.summary.overview;
}
if (meetingData.summary?.notes) {
input.notes = meetingData.summary.notes;
}
if (meetingData.summary?.keywords?.length) {
input.keywords = meetingData.summary.keywords.join(', ');
}
if (meetingData.audio_url?.trim()) {
input.audioUrl = {
primaryLinkUrl: meetingData.audio_url,
primaryLinkLabel: 'Listen to Audio'
};
}
// Business+ fields (analytics, video, detailed summary)
if (meetingData.summary?.meeting_type) {
input.meetingType = meetingData.summary.meeting_type;
}
if (meetingData.summary?.topics_discussed?.length) {
input.topics = meetingData.summary.topics_discussed.join(', ');
}
if (meetingData.analytics?.sentiments) {
const sentiments = meetingData.analytics.sentiments;
input.positivePercent = sentiments.positive_pct;
input.negativePercent = sentiments.negative_pct;
input.neutralPercent = sentiments.neutral_pct;
}
if (meetingData.video_url?.trim()) {
input.videoUrl = {
primaryLinkUrl: meetingData.video_url,
primaryLinkLabel: 'Watch Video'
};
}
// Import status based on data completeness
const isPartial = !hasSummary && !hasAnalytics;
input.importStatus = isPartial ? 'PARTIAL' : 'SUCCESS';
input.lastImportAttempt = new Date().toISOString();
input.importAttempts = 1;
return input;
}
static toFailedMeetingCreateInput(
meetingId: string,
title: string,
error: string,
attempts: number = 1
): MeetingCreateInput {
const currentDate = new Date().toISOString();
return {
name: title || `Failed Meeting Import - ${meetingId}`,
meetingDate: currentDate,
duration: 0,
actionItemsCount: 0,
firefliesMeetingId: meetingId,
importStatus: 'FAILED',
importError: error,
lastImportAttempt: currentDate,
importAttempts: attempts,
};
}
}
@@ -0,0 +1,161 @@
import { type FirefliesApiClient } from './fireflies-api-client';
import { MeetingFormatter } from './formatters';
import { createLogger } from './logger';
import { type TwentyCrmService } from './twenty-crm-service';
import type {
FirefliesPlan,
FirefliesTranscriptListOptions,
SummaryFetchConfig,
} from './types';
const logger = createLogger('historical-importer');
export type HistoricalImportFilters = FirefliesTranscriptListOptions;
export type HistoricalImportOptions = {
dryRun?: boolean;
autoCreateContacts: boolean;
summaryConfig: SummaryFetchConfig;
plan: FirefliesPlan;
};
export type HistoricalImportResult = {
dryRun: boolean;
totalListed: number;
imported: number;
skippedExisting: number;
failed: Array<{ meetingId: string; reason: string }>;
summaryPending: number;
statuses: Array<{
meetingId: string;
title?: string;
status: 'imported' | 'skipped_existing' | 'failed' | 'dry_run' | 'pending_summary';
reason?: string;
}>;
};
export class HistoricalImporter {
private firefliesClient: FirefliesApiClient;
private twentyService: TwentyCrmService;
constructor(firefliesClient: FirefliesApiClient, twentyService: TwentyCrmService) {
this.firefliesClient = firefliesClient;
this.twentyService = twentyService;
}
async run(
filters: HistoricalImportFilters,
options: HistoricalImportOptions,
): Promise<HistoricalImportResult> {
const { dryRun = false, autoCreateContacts, summaryConfig, plan } = options;
logger.info('Listing Fireflies transcripts for historical import...');
const transcripts = await this.firefliesClient.listTranscripts(filters);
logger.info(`Found ${transcripts.length} transcript(s) to process`);
let imported = 0;
let skippedExisting = 0;
let summaryPending = 0;
const failed: Array<{ meetingId: string; reason: string }> = [];
const statuses: HistoricalImportResult['statuses'] = [];
for (const transcript of transcripts) {
const meetingId = transcript.id;
try {
const existing = await this.twentyService.findMeetingByFirefliesId(meetingId);
if (existing) {
logger.debug(`Skipping ${meetingId}: already exists in Twenty (${existing.id})`);
skippedExisting += 1;
statuses.push({
meetingId,
title: transcript.title,
status: 'skipped_existing',
});
continue;
}
logger.info(`Fetching meeting ${meetingId} details`);
const { data: meetingData, summaryReady } =
await this.firefliesClient.fetchMeetingDataWithRetry(
meetingId,
summaryConfig,
plan,
);
const isPendingSummary = summaryReady === false;
if (isPendingSummary) {
summaryPending += 1;
}
if (dryRun) {
logger.info(`[dry-run] Would import meeting "${meetingData.title}" (${meetingId})`);
imported += 1;
statuses.push({
meetingId,
title: meetingData.title,
status: isPendingSummary ? 'pending_summary' : 'dry_run',
reason: isPendingSummary ? 'summary not ready' : undefined,
});
continue;
}
const { matchedContacts, unmatchedParticipants } =
await this.twentyService.matchParticipantsToContacts(
meetingData.participants,
);
const newContactIds = autoCreateContacts
? await this.twentyService.createContactsForUnmatched(unmatchedParticipants)
: [];
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
const noteBody = MeetingFormatter.formatNoteBody(meetingData);
const noteId = await this.twentyService.createNoteOnly(
`Meeting: ${meetingData.title}`,
noteBody,
);
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
const createdMeetingId = await this.twentyService.createMeeting(meetingInput);
for (const contactId of allContactIds) {
await this.twentyService.createNoteTarget(noteId, contactId);
}
logger.info(
`Imported meeting "${meetingData.title}" (${meetingId}) as ${createdMeetingId}`,
);
imported += 1;
statuses.push({
meetingId,
title: meetingData.title,
status: isPendingSummary ? 'pending_summary' : 'imported',
reason: isPendingSummary ? 'summary not ready' : undefined,
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
logger.error(`Failed to import meeting ${meetingId}: ${reason}`);
failed.push({ meetingId, reason });
statuses.push({
meetingId,
title: transcript.title,
status: 'failed',
reason,
});
}
}
return {
dryRun,
totalListed: transcripts.length,
imported,
skippedExisting,
failed,
summaryPending,
statuses,
};
}
}
@@ -0,0 +1,31 @@
// Main exports for the Fireflies integration
export { config, main } from './receive-fireflies-notes';
// Types
export type {
FirefliesMeetingData,
FirefliesParticipant,
FirefliesWebhookPayload,
ProcessResult,
SummaryFetchConfig,
SummaryStrategy
} from './types';
// Services (for advanced usage)
export { FirefliesApiClient } from './fireflies-api-client';
export { MeetingFormatter } from './formatters';
export { TwentyCrmService } from './twenty-crm-service';
export { WebhookHandler } from './webhook-handler';
// Objects
export { Meeting } from './objects';
// Utilities
export { createLogger } from './logger';
export { getApiUrl, getSummaryFetchConfig, shouldAutoCreateContacts, toBoolean } from './utils';
export {
getWebhookSecretFingerprint,
isValidFirefliesPayload,
verifyWebhookSignature
} from './webhook-validator';
@@ -0,0 +1,142 @@
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
export type LoggerConfig = {
logLevel: LogLevel;
isTestEnvironment: boolean;
captureForResponse: boolean;
};
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
silent: 4,
};
const loggerRegistry = new Set<AppLogger>();
export class AppLogger {
private config: LoggerConfig;
private context: string;
private capturedLogs: string[] = [];
constructor(context: string) {
this.context = context;
this.config = {
logLevel: this.parseLogLevel(process.env.LOG_LEVEL || 'error'),
isTestEnvironment: process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined,
captureForResponse: process.env.CAPTURE_LOGS === 'true',
};
// Silence logs in test environment unless explicitly overridden
if (this.config.isTestEnvironment && process.env.LOG_LEVEL === undefined) {
this.config.logLevel = 'silent';
}
}
private parseLogLevel(level: string): LogLevel {
const normalizedLevel = level.toLowerCase() as LogLevel;
return Object.keys(LOG_LEVELS).includes(normalizedLevel) ? normalizedLevel : 'error';
}
private shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel];
}
private safeStringify(value: unknown): string {
try {
if (typeof value === 'string') return value;
return JSON.stringify(value);
} catch {
return '[unserializable]';
}
}
private captureLog(level: LogLevel, message: string, ...args: unknown[]): void {
if (!this.config.captureForResponse) {
return;
}
const timestamp = new Date().toISOString();
const formattedMessage =
args.length > 0
? `${message} ${args.map((arg) => this.safeStringify(arg)).join(' ')}`
: message;
this.capturedLogs.push(
`[${timestamp}] [${level.toUpperCase()}] [${this.context}] ${formattedMessage}`,
);
}
debug(message: string, ...args: unknown[]): void {
this.captureLog('debug', message, ...args);
if (this.shouldLog('debug')) {
// eslint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
info(message: string, ...args: unknown[]): void {
this.captureLog('info', message, ...args);
if (this.shouldLog('info')) {
// eslint-disable-next-line no-console
console.log(`[${this.context}] ${message}`, ...args);
}
}
warn(message: string, ...args: unknown[]): void {
this.captureLog('warn', message, ...args);
if (this.shouldLog('warn')) {
// eslint-disable-next-line no-console
console.warn(`[${this.context}] ${message}`, ...args);
}
}
error(message: string, ...args: unknown[]): void {
this.captureLog('error', message, ...args);
if (this.shouldLog('error')) {
// eslint-disable-next-line no-console
console.error(`[${this.context}] ${message}`, ...args);
}
}
// For fatal errors, security issues, or data corruption - always visible
critical(message: string, ...args: unknown[]): void {
this.captureLog('error', `CRITICAL: ${message}`, ...args);
// eslint-disable-next-line no-console
console.error(`[${this.context}] CRITICAL: ${message}`, ...args);
}
getCapturedLogs(): string[] {
return [...this.capturedLogs];
}
clearCapturedLogs(): void {
this.capturedLogs = [];
}
}
export const createLogger = (context: string): AppLogger => {
const logger = new AppLogger(context);
loggerRegistry.add(logger);
return logger;
};
export const removeLogger = (logger: AppLogger): void => {
loggerRegistry.delete(logger);
};
export const getAllCapturedLogs = (): string[] => {
const allLogs: string[] = [];
for (const logger of loggerRegistry) {
allLogs.push(...logger.getCapturedLogs());
}
return allLogs.sort();
};
export const clearAllCapturedLogs = (): void => {
for (const logger of loggerRegistry) {
logger.clearCapturedLogs();
}
};
@@ -0,0 +1,3 @@
export { Meeting } from './meeting';
@@ -1,6 +1,6 @@
import { ObjectMetadata } from 'twenty-sdk/application';
import { Object } from 'twenty-sdk';
@ObjectMetadata({
@Object({
universalIdentifier: 'd1831348-b4a4-4426-9c0b-0af19e7a9c27',
nameSingular: 'meeting',
namePlural: 'meetings',
@@ -1,4 +1,4 @@
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
import { type FunctionConfig } from 'twenty-sdk';
import type { ProcessResult } from './types';
import { WebhookHandler } from './webhook-handler';
@@ -10,7 +10,7 @@ export const main = async (
return handler.handle(params, headers);
};
export const config: ServerlessFunctionConfig = {
export const config: FunctionConfig = {
universalIdentifier: '2d3ea303-667c-4bbe-9e3d-db6ffb9d6c74',
name: 'receive-fireflies-notes',
description:
@@ -22,7 +22,8 @@ export const config: ServerlessFunctionConfig = {
type: 'route',
path: '/webhook/fireflies',
httpMethod: 'POST',
isAuthRequired: true,
isAuthRequired: false,
},
],
};
@@ -43,6 +43,20 @@ export class TwentyCrmService {
return response.data?.meetings?.edges?.[0]?.node;
}
async findMeetingByFirefliesId(meetingId: string): Promise<IdNode | undefined> {
const query = `
query FindMeetingByFirefliesId($meetingId: String!) {
meetings(filter: { firefliesMeetingId: { eq: $meetingId } }) {
edges { node { id } }
}
}
`;
const variables = { meetingId };
const response = await this.gqlRequest<FindMeetingResponse>(query, variables);
return response.data?.meetings?.edges?.[0]?.node;
}
async matchParticipantsToContacts(
participants: FirefliesParticipant[],
): Promise<{
@@ -53,21 +67,18 @@ export class TwentyCrmService {
return { matchedContacts: [], unmatchedParticipants: [] };
}
// Split participants into those with emails and those with names only
const participantsWithEmails = participants.filter(p => p.email && p.email.trim());
const participantsNameOnly = participants.filter(p => !p.email || !p.email.trim());
let matchedContacts: Contact[] = [];
let unmatchedParticipants: FirefliesParticipant[] = [];
// 1. Match by email first
if (participantsWithEmails.length > 0) {
const emailMatches = await this.matchByEmail(participantsWithEmails);
matchedContacts.push(...emailMatches.matchedContacts);
unmatchedParticipants.push(...emailMatches.unmatchedParticipants);
}
// 2. For participants without emails, try name-based matching
if (participantsNameOnly.length > 0) {
const nameMatches = await this.matchByName(participantsNameOnly, matchedContacts);
matchedContacts.push(...nameMatches.matchedContacts);
@@ -126,7 +137,6 @@ export class TwentyCrmService {
const matchedContacts: Contact[] = [];
const unmatchedParticipants: FirefliesParticipant[] = [];
// Get set of already matched contact IDs to avoid duplicates
const alreadyMatchedIds = new Set(alreadyMatchedContacts.map(c => c.id));
for (const participant of participants) {
@@ -152,27 +162,37 @@ export class TwentyCrmService {
const firstName = nameParts[0];
const lastName = nameParts.slice(1).join(' ');
// Try exact name match first
let query = `
query FindPeopleByName($firstName: String!, $lastName: String) {
people(filter: {
and: [
{ name: { firstName: { eq: $firstName } } }
${lastName ? '{ name: { lastName: { eq: $lastName } } }' : ''}
]
}) {
edges { node { id emails { primaryEmail } name { firstName lastName } } }
}
}
`;
const hasLastName = Boolean(lastName);
let variables: any = { firstName };
if (lastName) {
variables.lastName = lastName;
}
const query = hasLastName
? `
query FindPeopleByName($firstName: String!, $lastName: String!) {
people(filter: {
and: [
{ name: { firstName: { eq: $firstName } } }
{ name: { lastName: { eq: $lastName } } }
]
}) {
edges { node { id emails { primaryEmail } name { firstName lastName } } }
}
}
`
: `
query FindPeopleByName($firstName: String!) {
people(filter: {
name: { firstName: { eq: $firstName } }
}) {
edges { node { id emails { primaryEmail } name { firstName lastName } } }
}
}
`;
const variables: Record<string, unknown> = hasLastName
? { firstName, lastName }
: { firstName };
try {
const response = await this.gqlRequest<any>(query, variables);
const response = await this.gqlRequest<{ people: { edges: Array<{ node: { id: string; emails?: { primaryEmail?: string }; name?: { firstName?: string; lastName?: string } } }> } }>(query, variables);
const people = response.data?.people?.edges;
if (people && people.length > 0) {
@@ -183,9 +203,8 @@ export class TwentyCrmService {
};
}
// If no exact match and we have a last name, try fuzzy matching
if (lastName) {
query = `
if (hasLastName) {
const fuzzyQuery = `
query FindPeopleByNameFuzzy($firstName: String!) {
people(filter: { name: { firstName: { ilike: $firstName } } }) {
edges { node { id emails { primaryEmail } name { firstName lastName } } }
@@ -193,12 +212,11 @@ export class TwentyCrmService {
}
`;
const fuzzyResponse = await this.gqlRequest<any>(query, { firstName: `%${firstName}%` });
const fuzzyResponse = await this.gqlRequest<{ people: { edges: Array<{ node: { id: string; emails?: { primaryEmail?: string }; name?: { firstName?: string; lastName?: string } } }> } }>(fuzzyQuery, { firstName: `%${firstName}%` });
const fuzzyPeople = fuzzyResponse.data?.people?.edges;
if (fuzzyPeople && fuzzyPeople.length > 0) {
// Find best match by checking if last name contains our target
const bestMatch = fuzzyPeople.find((edge: any) => {
const bestMatch = fuzzyPeople.find((edge) => {
const personLastName = edge.node.name?.lastName || '';
return personLastName.toLowerCase().includes(lastName.toLowerCase());
});
@@ -215,7 +233,6 @@ export class TwentyCrmService {
return null;
} catch {
// Silently fail - don't break the entire process for a single contact lookup
return null;
}
}
@@ -225,17 +242,14 @@ export class TwentyCrmService {
): Promise<string[]> {
const newContactIds: string[] = [];
// Split participants into those with emails and those with names only
const participantsWithEmails = participants.filter(p => p.email && p.email.trim());
const participantsNameOnly = participants.filter(p => !p.email || !p.email.trim());
// Process participants with emails
if (participantsWithEmails.length > 0) {
const emailContactIds = await this.createContactsWithEmails(participantsWithEmails);
newContactIds.push(...emailContactIds);
}
// Process participants with names only
if (participantsNameOnly.length > 0) {
const nameContactIds = await this.createContactsNameOnly(participantsNameOnly);
newContactIds.push(...nameContactIds);
@@ -247,7 +261,6 @@ export class TwentyCrmService {
private async createContactsWithEmails(participants: FirefliesParticipant[]): Promise<string[]> {
const newContactIds: string[] = [];
// Deduplicate participants by email to prevent duplicate contact creation
const uniqueParticipants = participants.reduce<FirefliesParticipant[]>((unique, participant) => {
const existing = unique.find(p => p.email === participant.email);
if (!existing) {
@@ -276,7 +289,10 @@ export class TwentyCrmService {
};
try {
const response = await this.gqlRequest<CreatePersonResponse>(mutation, variables);
const response = await this.gqlRequest<CreatePersonResponse>(mutation, variables, {
suppressErrorCodes: ['BAD_USER_INPUT'],
suppressErrorMessageIncludes: ['Duplicate Emails', 'duplicate entry'],
});
if (!response.data?.createPerson?.id) {
throw new Error(`Failed to create contact for ${participant.email}`);
}
@@ -297,7 +313,6 @@ export class TwentyCrmService {
private async createContactsNameOnly(participants: FirefliesParticipant[]): Promise<string[]> {
const newContactIds: string[] = [];
// Deduplicate participants by name to prevent duplicate contact creation
const uniqueParticipants = participants.reduce<FirefliesParticipant[]>((unique, participant) => {
const existing = unique.find(p =>
p.name.toLowerCase().trim() === participant.name.toLowerCase().trim()
@@ -311,7 +326,6 @@ export class TwentyCrmService {
}, []);
for (const participant of uniqueParticipants) {
// Check if we already have a contact with this exact name to avoid duplicates
const existingContact = await this.findContactByName(participant.name);
if (existingContact) {
logger.warn(`Contact with name "${participant.name}" already exists. Skipping creation.`);
@@ -330,8 +344,6 @@ export class TwentyCrmService {
const variables = {
data: {
name: { firstName, lastName },
// Note: We don't set emails for name-only participants
// This will create a contact without an email address
},
};
@@ -346,7 +358,6 @@ export class TwentyCrmService {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.warn(`Failed to create contact for ${participant.name}: ${errorMessage}`);
// Continue processing other participants instead of failing completely
continue;
}
}
@@ -409,28 +420,7 @@ export class TwentyCrmService {
},
};
await this.gqlRequest<any>(mutation, variables);
}
async createMeetingTarget(meetingId: string, contactId: string): Promise<void> {
const mutation = `
mutation CreateMeetingTarget($data: NoteTargetCreateInput!) {
createNoteTarget(data: $data) {
id
meetingId
personId
}
}
`;
const variables = {
data: {
meetingId,
personId: contactId,
},
};
await this.gqlRequest<any>(mutation, variables);
await this.gqlRequest<{ createNoteTarget: { id: string; noteId: string; personId: string } }>(mutation, variables);
}
async createMeeting(meetingData: MeetingCreateInput): Promise<string> {
@@ -442,7 +432,6 @@ export class TwentyCrmService {
const variables = { data: meetingData };
// Debug: log the variables being sent
if (!this.isTestEnvironment) {
logger.debug('createMeeting variables:', JSON.stringify(variables, null, 2));
}
@@ -456,7 +445,8 @@ export class TwentyCrmService {
private async gqlRequest<T>(
query: string,
variables?: Record<string, unknown>
variables?: Record<string, unknown>,
options?: { suppressErrorCodes?: string[]; suppressErrorMessageIncludes?: string[] }
): Promise<GraphQLResponse<T>> {
const url = `${this.apiUrl}/graphql`;
@@ -500,7 +490,16 @@ export class TwentyCrmService {
return json;
} catch (error) {
logger.error('GraphQL request error:', error);
const message = error instanceof Error ? error.message : String(error);
const suppressByCode = options?.suppressErrorCodes?.some((code) =>
message.includes(code),
);
const suppressByMessage = options?.suppressErrorMessageIncludes?.some((substring) =>
message.includes(substring),
);
if (!suppressByCode && !suppressByMessage) {
logger.error('GraphQL request error:', error);
}
throw error;
}
}
@@ -525,7 +524,15 @@ export class TwentyCrmService {
return response.data.createMeeting.id;
}
async findFailedMeetings(): Promise<any[]> {
async findFailedMeetings(): Promise<Array<{
id: string;
name: string;
firefliesMeetingId: string;
importError: string;
lastImportAttempt: string;
importAttempts: number;
createdAt: string;
}>> {
const query = `
query FindFailedMeetings {
meetings(filter: { importStatus: { eq: "FAILED" } }) {
@@ -544,8 +551,8 @@ export class TwentyCrmService {
}
`;
const response = await this.gqlRequest<any>(query);
return response.data?.meetings?.edges?.map((edge: any) => edge.node) || [];
const response = await this.gqlRequest<{ meetings: { edges: Array<{ node: { id: string; name: string; firefliesMeetingId: string; importError: string; lastImportAttempt: string; importAttempts: number; createdAt: string } }> } }>(query);
return response.data?.meetings?.edges?.map((edge) => edge.node) || [];
}
async retryFailedMeeting(meetingId: string, updatedData: Partial<MeetingCreateInput>): Promise<void> {
@@ -564,7 +571,7 @@ export class TwentyCrmService {
}
};
await this.gqlRequest<any>(mutation, variables);
await this.gqlRequest<{ updateMeeting: { id: string } }>(mutation, variables);
}
}
@@ -0,0 +1,235 @@
// Fireflies API Types
export type FirefliesParticipant = {
email: string;
name: string;
};
export type FirefliesWebhookPayload = {
meetingId: string;
eventType: string;
clientReferenceId?: string;
};
// Transcript sentence from Fireflies API
export type FirefliesSentence = {
index: number;
speaker_name: string;
text: string;
start_time: string;
end_time: string;
ai_filters?: {
task?: boolean;
question?: boolean;
sentiment?: string;
};
};
// Speaker analytics from Fireflies API (Business+)
export type FirefliesSpeakerAnalytics = {
speaker_id: string;
name: string;
duration: number;
word_count: number;
longest_monologue: number;
filler_words: number;
questions: number;
words_per_minute: number;
};
// Based on Fireflies GraphQL API transcript schema
// See: https://docs.fireflies.ai/graphql-api/query/transcript
export type FirefliesMeetingData = {
id: string;
title: string;
date: string;
duration: number;
participants: FirefliesParticipant[];
organizer_email?: string;
// Full transcript (Pro+)
sentences?: FirefliesSentence[];
summary: {
// Pro+ fields
action_items: string[];
keywords?: string[];
overview: string;
notes?: string; // Detailed AI-generated meeting notes
gist?: string; // 1-sentence summary
bullet_gist?: string; // Bullet point summary with emojis
short_summary?: string; // Single paragraph summary
short_overview?: string; // Brief overview
outline?: string; // Meeting outline with timestamps
shorthand_bullet?: string;
// Business+ fields
topics_discussed?: string[];
meeting_type?: string;
transcript_chapters?: string[];
};
// Business+ analytics
analytics?: {
sentiments?: {
positive_pct: number;
negative_pct: number;
neutral_pct: number;
};
categories?: {
questions: number;
tasks: number;
metrics: number;
date_times: number;
};
speakers?: FirefliesSpeakerAnalytics[];
};
meeting_info?: {
summary_status?: string;
};
// URLs
transcript_url: string;
audio_url?: string; // Pro+
video_url?: string; // Business+
meeting_link?: string; // All plans
summary_status?: string;
};
export type FirefliesTranscriptListItem = {
id: string;
title: string;
date?: string;
duration?: number;
organizer_email?: string;
participants?: string[];
transcript_url?: string;
meeting_link?: string;
summary_status?: string;
};
export type FirefliesTranscriptListOptions = {
limit?: number;
skip?: number;
organizers?: string[];
participants?: string[];
hostEmail?: string;
participantEmail?: string;
userId?: string;
channelId?: string;
mine?: boolean;
fromDate?: number;
toDate?: number;
pageSize?: number;
maxRecords?: number;
};
// Configuration Types
export type SummaryStrategy = 'immediate_only' | 'immediate_with_retry' | 'delayed_polling' | 'basic_only';
export type SummaryFetchConfig = {
strategy: SummaryStrategy;
retryAttempts: number;
retryDelay: number;
pollInterval: number;
maxPolls: number;
};
export const FIREFLIES_PLANS = {
FREE: 'free',
PRO: 'pro',
BUSINESS: 'business',
ENTERPRISE: 'enterprise',
} as const;
export type FirefliesPlan = typeof FIREFLIES_PLANS[keyof typeof FIREFLIES_PLANS];
export type WebhookConfig = {
secret: string;
apiUrl: string;
};
// Processing Result Types
export type ProcessResult = {
success: boolean;
meetingId?: string;
noteIds?: string[];
newContacts?: string[];
errors?: string[];
debug?: string[];
summaryReady?: boolean;
summaryPending?: boolean;
enhancementScheduled?: boolean;
actionItemsCount?: number;
sentimentAnalysis?: {
positive_pct: number;
negative_pct: number;
neutral_pct: number;
};
meetingType?: string;
keyTopics?: string[];
};
// Twenty CRM Types
export type GraphQLResponse<T> = {
data: T;
errors?: Array<{
message?: string;
extensions?: { code?: string }
}>;
};
export type IdNode = { id: string };
export type FindMeetingResponse = {
meetings: { edges: Array<{ node: IdNode }> };
};
export type FindPeopleResponse = {
people: { edges: Array<{ node: { id: string; emails: { primaryEmail: string } } }> };
};
export type CreatePersonResponse = {
createPerson: { id: string }
};
export type CreateNoteResponse = {
createNote: { id: string }
};
export type CreateMeetingResponse = {
createMeeting: { id: string }
};
export type Contact = {
id: string;
email: string;
};
// Twenty CRM Meeting custom field input
// Maps to fields defined in add-meeting-fields.ts
export type MeetingCreateInput = {
name: string;
noteId?: string | null;
// Basic fields (All plans)
meetingDate: string;
duration: number;
firefliesMeetingId: string;
organizerEmail?: string | null;
transcriptUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
meetingLink?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
// Pro+ fields
transcript?: string | null;
overview?: string | null;
notes?: string | null;
keywords?: string | null;
audioUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
// Business+ fields
meetingType?: string | null;
topics?: string | null;
actionItemsCount: number;
positivePercent?: number | null;
negativePercent?: number | null;
neutralPercent?: number | null;
videoUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
// Import tracking
importStatus?: 'SUCCESS' | 'PARTIAL' | 'FAILED' | 'PENDING' | 'RETRYING' | null;
importError?: string | null;
lastImportAttempt?: string | null;
importAttempts?: number | null;
};
@@ -1,11 +1,11 @@
import { FIREFLIES_PLANS, type FirefliesPlan, type SummaryFetchConfig, type SummaryStrategy } from './types';
export const toBoolean = (value: string | undefined, defaultValue: boolean): boolean => {
if (value === undefined) return defaultValue;
const normalized = value.trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes';
};
import type { SummaryFetchConfig, SummaryStrategy } from './types';
export const getApiUrl = (): string => {
return process.env.SERVER_URL || 'http://localhost:3000';
};
@@ -28,3 +28,14 @@ export const shouldAutoCreateContacts = (): boolean => {
return toBoolean(process.env.AUTO_CREATE_CONTACTS, true);
};
export const getFirefliesPlan = (): FirefliesPlan => {
const plan = (process.env.FIREFLIES_PLAN || FIREFLIES_PLANS.FREE).toLowerCase();
if (plan === FIREFLIES_PLANS.BUSINESS || plan === FIREFLIES_PLANS.ENTERPRISE) {
return plan as FirefliesPlan;
}
if (plan === FIREFLIES_PLANS.PRO) {
return FIREFLIES_PLANS.PRO;
}
return FIREFLIES_PLANS.FREE;
};
@@ -1,9 +1,9 @@
import { FirefliesApiClient } from './fireflies-api-client';
import { MeetingFormatter } from './formatters';
import { createLogger } from './logger';
import { clearAllCapturedLogs, createLogger, getAllCapturedLogs } from './logger';
import { TwentyCrmService } from './twenty-crm-service';
import type { FirefliesWebhookPayload, ProcessResult } from './types';
import { getApiUrl, getSummaryFetchConfig, shouldAutoCreateContacts } from './utils';
import { getApiUrl, getFirefliesPlan, getSummaryFetchConfig, shouldAutoCreateContacts } from './utils';
import {
getWebhookSecretFingerprint,
isValidFirefliesPayload,
@@ -12,17 +12,28 @@ import {
declare const process: { env: Record<string, string | undefined> };
const logger = createLogger('fireflies');
export class WebhookHandler {
private debug: string[] = [];
private isTestEnvironment: boolean;
private logger: ReturnType<typeof createLogger>;
constructor() {
this.isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
this.logger = createLogger('fireflies');
}
private addDebugLogs(result: ProcessResult): ProcessResult {
if (process.env.CAPTURE_LOGS === 'true') {
const captured = getAllCapturedLogs();
result.debug = [...this.debug, ...captured];
} else {
delete result.debug;
}
return result;
}
async handle(params: unknown, headers?: Record<string, string>): Promise<ProcessResult> {
clearAllCapturedLogs();
this.debug = [];
const result: ProcessResult = {
success: false,
noteIds: [],
@@ -31,19 +42,27 @@ export class WebhookHandler {
};
try {
logger.debug('invoked');
logger.debug(`apiUrl=${getApiUrl()}`);
this.logger.debug('invoked');
this.logger.debug(`apiUrl=${getApiUrl()}`);
const paramKeys =
params && typeof params === 'object'
? Object.keys(params as Record<string, unknown>)
: [];
this.debug.push(
`paramsType=${typeof params}`,
`paramKeys=${paramKeys.join(',') || 'none'}`
);
// 0) Validate environment configuration
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!firefliesApiKey) {
logger.critical('FIREFLIES_API_KEY not configured - this is a critical configuration error');
this.logger.critical('FIREFLIES_API_KEY not configured - this is a critical configuration error');
throw new Error('FIREFLIES_API_KEY environment variable is required');
}
if (!twentyApiKey) {
logger.critical('TWENTY_API_KEY not configured - this is a critical configuration error');
this.logger.critical('TWENTY_API_KEY not configured - this is a critical configuration error');
throw new Error('TWENTY_API_KEY environment variable is required');
}
@@ -51,28 +70,30 @@ export class WebhookHandler {
const { payload, extractedHeaders } = this.parsePayload(params);
const finalHeaders = extractedHeaders || headers;
logger.debug(`payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
this.logger.debug(`payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
// 2) Verify webhook signature
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
const secretFingerprint = getWebhookSecretFingerprint(webhookSecret);
logger.debug(`webhook secret fingerprint=${secretFingerprint}`);
this.logger.debug(`webhook secret fingerprint=${secretFingerprint}`);
this.verifySignature(payload, finalHeaders, webhookSecret);
logger.debug('signature verification: ok');
this.logger.debug('signature verification: ok');
// 3) Fetch meeting data from Fireflies
const summaryConfig = getSummaryFetchConfig();
logger.debug(`summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
logger.debug(`fetching meeting data from Fireflies API`);
const firefliesPlan = getFirefliesPlan();
this.logger.debug(`summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
this.logger.debug(`fetching meeting data from Fireflies API`);
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
const { data: meetingData, summaryReady } = await firefliesClient.fetchMeetingDataWithRetry(
payload.meetingId,
summaryConfig
summaryConfig,
firefliesPlan
);
logger.debug(`meeting data fetched: title="${meetingData.title}" summaryReady=${summaryReady}`);
this.logger.debug(`meeting data fetched: title="${meetingData.title}" summaryReady=${summaryReady}`);
result.summaryReady = summaryReady;
result.summaryPending = !summaryReady;
@@ -84,8 +105,7 @@ export class WebhookHandler {
result.meetingType = meetingData.summary.meeting_type;
if (meetingData.analytics?.sentiments) {
const sentiments = meetingData.analytics.sentiments;
result.sentimentScore = sentiments.positive_pct / 100;
result.sentimentAnalysis = meetingData.analytics.sentiments;
}
}
@@ -95,29 +115,36 @@ export class WebhookHandler {
getApiUrl()
);
const existingMeeting = await twentyService.findExistingMeeting(meetingData.title);
if (existingMeeting) {
logger.debug(`meeting already exists id=${existingMeeting.id}`);
const existingMeetingById = await twentyService.findMeetingByFirefliesId(meetingData.id);
if (existingMeetingById) {
this.logger.debug(`meeting already exists by firefliesMeetingId id=${existingMeetingById.id}`);
result.success = true;
result.meetingId = existingMeeting.id;
result.debug = this.debug;
return result;
result.meetingId = existingMeetingById.id;
return this.addDebugLogs(result);
}
logger.debug('no existing meeting found, proceeding');
const existingMeetingByTitle = await twentyService.findExistingMeeting(meetingData.title);
if (existingMeetingByTitle) {
this.logger.debug(`meeting already exists by title id=${existingMeetingByTitle.id}`);
result.success = true;
result.meetingId = existingMeetingByTitle.id;
return this.addDebugLogs(result);
}
this.logger.debug('no existing meeting found, proceeding');
// 5) Match participants to existing contacts
logger.debug(`total participants from API: ${meetingData.participants.length}`);
this.logger.debug(`total participants from API: ${meetingData.participants.length}`);
meetingData.participants.forEach((p, idx) => {
logger.debug(`participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
this.logger.debug(`participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
});
const { matchedContacts, unmatchedParticipants } = await twentyService.matchParticipantsToContacts(
meetingData.participants
);
logger.debug(`matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
this.logger.debug(`matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
unmatchedParticipants.forEach((p, idx) => {
logger.debug(`unmatched ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
this.logger.debug(`unmatched ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
});
// 6) Optionally create contacts
@@ -126,7 +153,7 @@ export class WebhookHandler {
? await twentyService.createContactsForUnmatched(unmatchedParticipants)
: [];
result.newContacts = newContactIds;
logger.debug(`autoCreate=${autoCreate} createdContacts=${newContactIds.length}`);
this.logger.debug(`autoCreate=${autoCreate} createdContacts=${newContactIds.length}`);
// 7) Create note first (so we can link to it from the meeting)
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
@@ -136,13 +163,13 @@ export class WebhookHandler {
noteBody
);
result.noteIds = [noteId];
logger.debug(`created note id=${noteId}`);
this.logger.debug(`created note id=${noteId}`);
// 8) Create meeting with direct relationship to the note
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
logger.debug(`meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
this.logger.debug(`meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
result.meetingId = await twentyService.createMeeting(meetingInput);
logger.debug(`created meeting id=${result.meetingId} with noteId=${noteId}`);
this.logger.debug(`created meeting id=${result.meetingId} with noteId=${noteId}`);
// 9) Link note to participants (Meeting link is handled via the relation field)
await this.linkNoteToParticipants(
@@ -150,20 +177,19 @@ export class WebhookHandler {
noteId,
allContactIds
);
logger.debug(`linked note to ${allContactIds.length} participants`);
this.logger.debug(`linked note to ${allContactIds.length} participants`);
result.success = true;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error(`error: ${message}`);
this.logger.error(`error: ${message}`);
result.errors?.push(message);
// Try to create a failed meeting record for tracking
await this.createFailedMeetingRecord(params, message);
}
result.debug = this.debug;
return result;
return this.addDebugLogs(result);
}
private parsePayload(params: unknown): { payload: FirefliesWebhookPayload; extractedHeaders?: Record<string, string> } {
@@ -172,16 +198,16 @@ export class WebhookHandler {
// Handle string-encoded params
if (typeof normalizedParams === 'string') {
logger.debug(`received params as string length=${normalizedParams.length}`);
this.logger.debug(`received params as string length=${normalizedParams.length}`);
try {
const parsed = JSON.parse(normalizedParams);
normalizedParams = parsed;
if (parsed && typeof parsed === 'object') {
const parsedKeys = Object.keys(parsed as Record<string, unknown>);
logger.debug(`parsed params keys: ${parsedKeys.join(',') || 'none'}`);
this.logger.debug(`parsed params keys: ${parsedKeys.join(',') || 'none'}`);
}
} catch (parseError) {
logger.error(`error parsing string params: ${String(parseError)}`);
this.logger.error(`error parsing string params: ${String(parseError)}`);
throw new Error('Invalid or missing webhook payload');
}
}
@@ -197,14 +223,14 @@ export class WebhookHandler {
if (wrapper.headers && typeof wrapper.headers === 'object' && !Array.isArray(wrapper.headers)) {
extractedHeaders = wrapper.headers as Record<string, string>;
const headerKeys = Object.keys(extractedHeaders);
logger.debug(`extracted headers from wrapper: ${headerKeys.join(',')}`);
this.logger.debug(`extracted headers from wrapper: ${headerKeys.join(',')}`);
}
const wrapperKeys = ['params', 'payload', 'body', 'data', 'event'];
for (const key of wrapperKeys) {
const candidate = wrapper[key];
if (isValidFirefliesPayload(candidate)) {
logger.debug(`detected payload under wrapper key "${key}"`);
this.logger.debug(`detected payload under wrapper key "${key}"`);
payload = candidate as FirefliesWebhookPayload;
break;
}
@@ -212,7 +238,7 @@ export class WebhookHandler {
}
if (!payload) {
logger.error('error: Invalid or missing webhook payload');
this.logger.error('error: Invalid or missing webhook payload');
throw new Error('Invalid or missing webhook payload');
}
@@ -220,7 +246,7 @@ export class WebhookHandler {
const payloadRecord = payload as Record<string, unknown>;
const payloadKeys = Object.keys(payloadRecord);
if (payloadKeys.length > 0) {
logger.debug(`payload keys: ${payloadKeys.join(',')}`);
this.logger.debug(`payload keys: ${payloadKeys.join(',')}`);
}
return { payload, extractedHeaders };
@@ -235,8 +261,9 @@ export class WebhookHandler {
const normalizedHeaders = headers || {};
const headerKeys = Object.keys(normalizedHeaders);
if (headerKeys.length > 0) {
logger.debug(`header keys: ${headerKeys.join(',')}`);
this.logger.debug(`header keys: ${headerKeys.join(',')}`);
}
this.debug.push(`headerKeys=${headerKeys.join(',') || 'none'}`);
const headerSignature = Object.entries(normalizedHeaders).find(
([key]) => key.toLowerCase() === 'x-hub-signature',
@@ -249,32 +276,40 @@ export class WebhookHandler {
: undefined;
if (payloadSignature) {
logger.debug('found signature inside payload');
this.logger.debug('found signature inside payload');
}
const signature =
(typeof headerSignature === 'string' ? headerSignature : undefined) || payloadSignature;
const body = typeof normalizedHeaders['body'] === 'string'
? normalizedHeaders['body']
: JSON.stringify(payloadRecord);
const payloadForSignature =
payloadSignature && 'x-hub-signature' in payloadRecord
? Object.fromEntries(
Object.entries(payloadRecord).filter(
([key]) => key.toLowerCase() !== 'x-hub-signature',
),
)
: payloadRecord;
const body =
typeof normalizedHeaders['body'] === 'string'
? normalizedHeaders['body']
: JSON.stringify(payloadForSignature);
const signatureCheck = verifyWebhookSignature(body, signature, webhookSecret);
if (!signatureCheck.isValid) {
logger.debug(
this.debug.push(
`signatureProvided=${Boolean(signature)}`,
`signatureMatched=${signatureCheck.isValid}`,
`webhookSecretFingerprint=${getWebhookSecretFingerprint(webhookSecret)}`
);
this.logger.debug(
`signature check failed. headerPresent=${Boolean(
headerSignature,
)} payloadSignaturePresent=${Boolean(payloadSignature)}`,
);
if (signature) {
logger.debug(`provided signature=${signature}`);
} else {
logger.debug('provided signature=undefined');
}
logger.debug(
`computed signature=${signatureCheck.computedSignature ?? 'unavailable'}`,
);
logger.critical('Invalid webhook signature - potential security threat detected in production');
this.logger.debug(`provided signature present=${Boolean(signature)}`);
this.logger.critical('Invalid webhook signature - potential security threat detected in production');
throw new Error('Invalid webhook signature');
}
}
@@ -284,15 +319,13 @@ export class WebhookHandler {
noteId: string,
contactIds: string[]
): Promise<void> {
// Create Note-Person links for each participant
for (const contactId of contactIds) {
try {
await twentyService.createNoteTarget(noteId, contactId);
logger.debug(`linked note ${noteId} to person ${contactId}`);
this.logger.debug(`linked note ${noteId} to person ${contactId}`);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error(`failed to link note to person ${contactId}: ${message}`);
// Continue with other participants
this.logger.error(`failed to link note to person ${contactId}: ${message}`);
}
}
}
@@ -302,11 +335,10 @@ export class WebhookHandler {
try {
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!twentyApiKey) {
logger.debug('Cannot create failed meeting record: TWENTY_API_KEY not configured');
this.logger.debug('Cannot create failed meeting record: TWENTY_API_KEY not configured');
return;
}
// Try to extract meeting ID and title from the params
let meetingId = 'unknown';
let meetingTitle = 'Unknown Meeting';
@@ -314,7 +346,6 @@ export class WebhookHandler {
if (payload?.meetingId) {
meetingId = payload.meetingId;
// Try to get meeting title from Fireflies API if possible
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
if (firefliesApiKey) {
try {
@@ -322,7 +353,11 @@ export class WebhookHandler {
const meetingData = await firefliesClient.fetchMeetingData(meetingId);
meetingTitle = meetingData.title || meetingTitle;
} catch (fetchError) {
logger.debug(`Could not fetch meeting title: ${fetchError instanceof Error ? fetchError.message : 'Unknown error'}`);
this.logger.debug(
`Could not fetch meeting title: ${
fetchError instanceof Error ? fetchError.message : 'Unknown error'
}`,
);
}
}
}
@@ -335,10 +370,13 @@ export class WebhookHandler {
);
const failedMeetingId = await twentyService.createFailedMeeting(failedMeetingData);
logger.debug(`Created failed meeting record: ${failedMeetingId}`);
this.logger.debug(`Created failed meeting record: ${failedMeetingId}`);
} catch (recordError) {
// Don't throw here - we don't want to break the original error handling
logger.error(`Failed to create failed meeting record: ${recordError instanceof Error ? recordError.message : 'Unknown error'}`);
this.logger.error(
`Failed to create failed meeting record: ${
recordError instanceof Error ? recordError.message : 'Unknown error'
}`,
);
}
}
}
File diff suppressed because it is too large Load Diff