Revert "Revert "[hacktoberfest] feat: add fireflies"" (#15595)

Reverts twentyhq/twenty#15589

Add back without the breaking change
This commit is contained in:
martmull
2025-11-04 15:07:30 +01:00
committed by GitHub
parent 186dbb8aca
commit 89c8d89330
28 changed files with 9016 additions and 0 deletions
@@ -0,0 +1,442 @@
import type { FirefliesMeetingData, FirefliesParticipant, SummaryFetchConfig } from './types';
export class FirefliesApiClient {
private apiKey: string;
constructor(apiKey: string) {
if (!apiKey) {
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') {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] fetching meeting ${meetingId} (strategy: immediate_only)`);
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000 });
const ready = this.isSummaryReady(meetingData);
// eslint-disable-next-line no-console
console.log(`[fireflies-api] summary ready: ${ready}`);
return { data: meetingData, summaryReady: ready };
}
// immediate_with_retry: retry with exponential backoff
// eslint-disable-next-line no-console
console.log(`[fireflies-api] 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);
// eslint-disable-next-line no-console
console.log(`[fireflies-api] attempt ${attempt}/${config.retryAttempts}: summary ready=${ready}`);
if (ready) {
return { data: meetingData, summaryReady: true };
}
if (attempt < config.retryAttempts) {
const delayMs = config.retryDelay * attempt;
// eslint-disable-next-line no-console
console.log(`[fireflies-api] summary not ready, waiting ${delayMs}ms before retry ${attempt + 1}`);
await new Promise(resolve => setTimeout(resolve, delayMs));
} else {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] max retries reached, returning partial data`);
return { data: meetingData, summaryReady: false };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
// eslint-disable-next-line no-console
console.error(`[fireflies-api] attempt ${attempt}/${config.retryAttempts} failed: ${errorMsg}`);
if (attempt === config.retryAttempts) {
throw error;
}
const delayMs = config.retryDelay * attempt;
// eslint-disable-next-line no-console
console.log(`[fireflies-api] 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[] = [];
// eslint-disable-next-line no-console
console.log('[fireflies-api] === PARTICIPANT EXTRACTION DEBUG ===');
// eslint-disable-next-line no-console
console.log('[fireflies-api] participants field:', JSON.stringify(transcript.participants));
// eslint-disable-next-line no-console
console.log('[fireflies-api] meeting_attendees field:', JSON.stringify(transcript.meeting_attendees));
// eslint-disable-next-line no-console
console.log('[fireflies-api] speakers field:', transcript.speakers?.map((s: any) => s.name));
// eslint-disable-next-line no-console
console.log('[fireflies-api] meeting_attendance field:', transcript.meeting_attendance?.map((a: any) => a.name));
// eslint-disable-next-line no-console
console.log('[fireflies-api] 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] : '';
const name = part.replace(/[<>]/g, '').trim();
// Skip if the "name" is actually an email address
if (isEmail(name)) {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] Skipping participant with email as name: "${name}"`);
return;
}
// Skip if empty name
if (!name) {
return;
}
// Skip duplicates
if (isDuplicate(name, email)) {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] 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)) {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] 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)) {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] 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(',')) {
// eslint-disable-next-line no-console
console.log(`[fireflies-api] 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];
// eslint-disable-next-line no-console
console.log('[fireflies-api] === EXTRACTED PARTICIPANTS ===');
// eslint-disable-next-line no-console
console.log('[fireflies-api] With emails:', participantsWithEmails.length, JSON.stringify(participantsWithEmails));
// eslint-disable-next-line no-console
console.log('[fireflies-api] Name only:', participantsNameOnly.length, JSON.stringify(participantsNameOnly));
// eslint-disable-next-line no-console
console.log('[fireflies-api] 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,
};
}
}
@@ -0,0 +1,227 @@
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,
};
}
}
@@ -0,0 +1,10 @@
export { config, main } from './receive-fireflies-notes';
export type {
FirefliesMeetingData,
FirefliesParticipant,
FirefliesWebhookPayload,
ProcessResult,
SummaryFetchConfig,
SummaryStrategy
} from './types';
@@ -0,0 +1,28 @@
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
import type { ProcessResult } from './types';
import { WebhookHandler } from './webhook-handler';
export const main = async (
params: unknown,
headers?: Record<string, string>
): Promise<ProcessResult> => {
const handler = new WebhookHandler();
return handler.handle(params, headers);
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: '2d3ea303-667c-4bbe-9e3d-db6ffb9d6c74',
name: 'receive-fireflies-notes',
description:
'Receives Fireflies webhooks, fetches meeting summaries, and stores them in Twenty.',
timeoutSeconds: 30,
triggers: [
{
universalIdentifier: 'a2117dc1-7674-4c7e-9d70-9feb9820e9e8',
type: 'route',
path: '/webhook/fireflies',
httpMethod: 'POST',
isAuthRequired: true,
},
],
};
@@ -0,0 +1,575 @@
import type {
Contact,
CreateMeetingResponse,
CreateNoteResponse,
CreatePersonResponse,
FindMeetingResponse,
FindPeopleResponse,
FirefliesParticipant,
GraphQLResponse,
IdNode,
MeetingCreateInput,
} from './types';
export class TwentyCrmService {
private apiKey: string;
private apiUrl: string;
private isTestEnvironment: boolean;
constructor(apiKey: string, apiUrl: string) {
if (!apiKey) {
throw new Error('TWENTY_API_KEY is required');
}
this.apiKey = apiKey;
this.apiUrl = apiUrl;
this.isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
}
async findExistingMeeting(title: string): Promise<IdNode | undefined> {
const query = `
query FindMeeting($title: String!) {
meetings(filter: { name: { eq: $title } }) {
edges { node { id } }
}
}
`;
const variables = { title };
const response = await this.gqlRequest<FindMeetingResponse>(query, variables);
return response.data?.meetings?.edges?.[0]?.node;
}
async matchParticipantsToContacts(
participants: FirefliesParticipant[],
): Promise<{
matchedContacts: Contact[];
unmatchedParticipants: FirefliesParticipant[];
}> {
if (participants.length === 0) {
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);
unmatchedParticipants.push(...nameMatches.unmatchedParticipants);
}
return { matchedContacts, unmatchedParticipants };
}
private async matchByEmail(participants: FirefliesParticipant[]): Promise<{
matchedContacts: Contact[];
unmatchedParticipants: FirefliesParticipant[];
}> {
const emails = participants.map(({ email }) => email).filter(Boolean);
const query = `
query FindPeople($emails: [String!]!) {
people(filter: { emails: { primaryEmail: { in: $emails } } }) {
edges { node { id emails { primaryEmail } } }
}
}
`;
const variables = { emails };
const response = await this.gqlRequest<FindPeopleResponse>(query, variables);
const people = response.data?.people;
if (!people?.edges) {
return { matchedContacts: [], unmatchedParticipants: participants };
}
const matchedContacts = people.edges.map(({ node }) => ({
id: node.id,
email: node.emails?.primaryEmail || ''
}));
const matchedEmails = new Set(
matchedContacts
.map(({ email }) => email)
.filter((email) => Boolean(email)),
);
const unmatchedParticipants = participants.filter(
({ email }) => !matchedEmails.has(email)
);
return { matchedContacts, unmatchedParticipants };
}
private async matchByName(
participants: FirefliesParticipant[],
alreadyMatchedContacts: Contact[]
): Promise<{
matchedContacts: Contact[];
unmatchedParticipants: FirefliesParticipant[];
}> {
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) {
const nameMatch = await this.findContactByName(participant.name);
if (nameMatch && !alreadyMatchedIds.has(nameMatch.id)) {
matchedContacts.push(nameMatch);
alreadyMatchedIds.add(nameMatch.id);
} else {
unmatchedParticipants.push(participant);
}
}
return { matchedContacts, unmatchedParticipants };
}
private async findContactByName(name: string): Promise<Contact | null> {
if (!name || !name.trim()) {
return null;
}
const nameParts = name.trim().split(/\s+/);
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 } } }
}
}
`;
let variables: any = { firstName };
if (lastName) {
variables.lastName = lastName;
}
try {
const response = await this.gqlRequest<any>(query, variables);
const people = response.data?.people?.edges;
if (people && people.length > 0) {
const person = people[0].node;
return {
id: person.id,
email: person.emails?.primaryEmail || ''
};
}
// If no exact match and we have a last name, try fuzzy matching
if (lastName) {
query = `
query FindPeopleByNameFuzzy($firstName: String!) {
people(filter: { name: { firstName: { ilike: $firstName } } }) {
edges { node { id emails { primaryEmail } name { firstName lastName } } }
}
}
`;
const fuzzyResponse = await this.gqlRequest<any>(query, { 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 personLastName = edge.node.name?.lastName || '';
return personLastName.toLowerCase().includes(lastName.toLowerCase());
});
if (bestMatch) {
const person = bestMatch.node;
return {
id: person.id,
email: person.emails?.primaryEmail || ''
};
}
}
}
return null;
} catch {
// Silently fail - don't break the entire process for a single contact lookup
return null;
}
}
async createContactsForUnmatched(
participants: FirefliesParticipant[],
): 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 (original logic)
if (participantsWithEmails.length > 0) {
const emailContactIds = await this.createContactsWithEmails(participantsWithEmails);
newContactIds.push(...emailContactIds);
}
// Process participants with names only (new logic)
if (participantsNameOnly.length > 0) {
const nameContactIds = await this.createContactsNameOnly(participantsNameOnly);
newContactIds.push(...nameContactIds);
}
return newContactIds;
}
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) {
unique.push(participant);
} else {
// eslint-disable-next-line no-console
console.warn(`[fireflies] Duplicate participant email detected: ${participant.email}. Using first occurrence.`);
}
return unique;
}, []);
for (const participant of uniqueParticipants) {
const [firstName, ...lastNameParts] = participant.name.trim().split(/\s+/);
const lastName = lastNameParts.join(' ');
const mutation = `
mutation CreatePerson($data: PersonCreateInput!) {
createPerson(data: $data) { id }
}
`;
const variables = {
data: {
name: { firstName, lastName },
emails: { primaryEmail: participant.email },
},
};
try {
const response = await this.gqlRequest<CreatePersonResponse>(mutation, variables);
if (!response.data?.createPerson?.id) {
throw new Error(`Failed to create contact for ${participant.email}`);
}
newContactIds.push(response.data.createPerson.id);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (errorMessage.includes('Duplicate Emails') || errorMessage.includes('BAD_USER_INPUT')) {
// eslint-disable-next-line no-console
console.warn(`[fireflies] Skipping contact creation for ${participant.email} due to duplicate email constraint: ${errorMessage}`);
continue;
}
throw error;
}
}
return newContactIds;
}
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()
);
if (!existing) {
unique.push(participant);
} else {
// eslint-disable-next-line no-console
console.warn(`[fireflies] Duplicate participant name detected: ${participant.name}. Using first occurrence.`);
}
return unique;
}, []);
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) {
// eslint-disable-next-line no-console
console.warn(`[fireflies] Contact with name "${participant.name}" already exists. Skipping creation.`);
continue;
}
const [firstName, ...lastNameParts] = participant.name.trim().split(/\s+/);
const lastName = lastNameParts.join(' ');
const mutation = `
mutation CreatePerson($data: PersonCreateInput!) {
createPerson(data: $data) { id }
}
`;
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
},
};
try {
const response = await this.gqlRequest<CreatePersonResponse>(mutation, variables);
if (!response.data?.createPerson?.id) {
throw new Error(`Failed to create contact for ${participant.name}`);
}
newContactIds.push(response.data.createPerson.id);
// eslint-disable-next-line no-console
console.log(`[fireflies] Created contact for name-only participant: ${participant.name}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// eslint-disable-next-line no-console
console.warn(`[fireflies] Failed to create contact for ${participant.name}: ${errorMessage}`);
// Continue processing other participants instead of failing completely
continue;
}
}
return newContactIds;
}
async createNote(
contactId: string,
title: string,
body: string
): Promise<string> {
const noteId = await this.createNoteOnly(title, body);
await this.createNoteTarget(noteId, contactId);
return noteId;
}
async createNoteOnly(
title: string,
body: string
): Promise<string> {
const mutation = `
mutation CreateNote($data: NoteCreateInput!) {
createNote(data: $data) { id }
}
`;
const variables = {
data: {
title,
bodyV2: {
markdown: body.trim()
},
},
};
const response = await this.gqlRequest<CreateNoteResponse>(mutation, variables);
if (!response.data?.createNote?.id) {
throw new Error(`Failed to create note`);
}
return response.data.createNote.id;
}
async createNoteTarget(noteId: string, contactId: string): Promise<void> {
const mutation = `
mutation CreateNoteTarget($data: NoteTargetCreateInput!) {
createNoteTarget(data: $data) {
id
noteId
personId
}
}
`;
const variables = {
data: {
noteId,
personId: contactId,
},
};
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);
}
async createMeeting(meetingData: MeetingCreateInput): Promise<string> {
const mutation = `
mutation CreateMeeting($data: MeetingCreateInput!) {
createMeeting(data: $data) { id }
}
`;
const variables = { data: meetingData };
// Debug: log the variables being sent
if (!this.isTestEnvironment) {
// eslint-disable-next-line no-console
console.log('[fireflies] createMeeting variables:', JSON.stringify(variables, null, 2));
}
const response = await this.gqlRequest<CreateMeetingResponse>(mutation, variables);
if (!response.data?.createMeeting?.id) {
throw new Error('Failed to create meeting: Invalid response from server');
}
return response.data.createMeeting.id;
}
private async gqlRequest<T>(
query: string,
variables?: Record<string, unknown>
): Promise<GraphQLResponse<T>> {
const url = `${this.apiUrl}/graphql`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) {
let errorMessage = `GraphQL request failed with status ${res.status}`;
try {
const errorText = await res.text();
if (errorText) {
errorMessage += `: ${errorText}`;
}
} catch {
// Ignore error when reading response body
}
throw new Error(errorMessage);
}
const json = await res.json() as GraphQLResponse<T> & {
errors?: Array<{ message?: string; extensions?: Record<string, unknown> }>
};
if (json?.errors && Array.isArray(json.errors) && json.errors.length > 0) {
const firstError = json.errors[0];
const errorMessage = firstError?.message || 'GraphQL error';
const errorCode = firstError?.extensions?.code as string | undefined;
if (errorCode) {
throw new Error(`${errorMessage} (Code: ${errorCode})`);
}
throw new Error(errorMessage);
}
return json;
} catch (error) {
// eslint-disable-next-line no-console
console.error('[twenty-crm] GraphQL request error:', error);
throw error;
}
}
async createFailedMeeting(meetingData: MeetingCreateInput): Promise<string> {
const mutation = `
mutation CreateMeeting($data: MeetingCreateInput!) {
createMeeting(data: $data) { id }
}
`;
const variables = { data: meetingData };
if (!this.isTestEnvironment) {
// eslint-disable-next-line no-console
console.log('[fireflies] createFailedMeeting variables:', JSON.stringify(variables, null, 2));
}
const response = await this.gqlRequest<CreateMeetingResponse>(mutation, variables);
if (!response.data?.createMeeting?.id) {
throw new Error('Failed to create failed meeting record: Invalid response from server');
}
return response.data.createMeeting.id;
}
async findFailedMeetings(): Promise<any[]> {
const query = `
query FindFailedMeetings {
meetings(filter: { importStatus: { eq: "FAILED" } }) {
edges {
node {
id
name
firefliesMeetingId
importError
lastImportAttempt
importAttempts
createdAt
}
}
}
}
`;
const response = await this.gqlRequest<any>(query);
return response.data?.meetings?.edges?.map((edge: any) => edge.node) || [];
}
async retryFailedMeeting(meetingId: string, updatedData: Partial<MeetingCreateInput>): Promise<void> {
const mutation = `
mutation UpdateMeeting($where: MeetingWhereUniqueInput!, $data: MeetingUpdateInput!) {
updateMeeting(where: $where, data: $data) { id }
}
`;
const variables = {
where: { id: meetingId },
data: {
...updatedData,
lastImportAttempt: new Date().toISOString(),
importAttempts: { increment: 1 }
}
};
await this.gqlRequest<any>(mutation, variables);
}
}
@@ -0,0 +1,130 @@
// 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;
};
@@ -0,0 +1,30 @@
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';
};
export const getSummaryFetchConfig = (): SummaryFetchConfig => {
const strategy = (process.env.FIREFLIES_SUMMARY_STRATEGY as SummaryStrategy) || 'immediate_with_retry';
// Ultra-conservative defaults to respect 50 requests/day API limit
// With 3 attempts at 15-minute intervals, max 3 API calls per webhook (45 minutes total)
return {
strategy,
retryAttempts: parseInt(process.env.FIREFLIES_RETRY_ATTEMPTS || '3', 10),
retryDelay: parseInt(process.env.FIREFLIES_RETRY_DELAY || '120000', 10), // 2 minutes
pollInterval: parseInt(process.env.FIREFLIES_POLL_INTERVAL || '120000', 10), // 2 minutes
maxPolls: parseInt(process.env.FIREFLIES_MAX_POLLS || '3', 10),
};
};
export const shouldAutoCreateContacts = (): boolean => {
return toBoolean(process.env.AUTO_CREATE_CONTACTS, true);
};
@@ -0,0 +1,356 @@
/* eslint-disable no-console */
import { FirefliesApiClient } from './fireflies-api-client';
import { MeetingFormatter } from './formatters';
import { TwentyCrmService } from './twenty-crm-service';
import type { FirefliesWebhookPayload, ProcessResult } from './types';
import { getApiUrl, getSummaryFetchConfig, shouldAutoCreateContacts } from './utils';
import {
getWebhookSecretFingerprint,
isValidFirefliesPayload,
verifyWebhookSignature
} from './webhook-validator';
declare const process: { env: Record<string, string | undefined> };
export class WebhookHandler {
private debug: string[] = [];
private isTestEnvironment: boolean;
constructor() {
this.isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
}
async handle(params: unknown, headers?: Record<string, string>): Promise<ProcessResult> {
const result: ProcessResult = {
success: false,
noteIds: [],
newContacts: [],
errors: [],
};
try {
this.logDebug('[fireflies] invoked');
this.logDebug(`[fireflies] apiUrl=${getApiUrl()}`);
// 0) Validate environment configuration
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!firefliesApiKey) {
this.logError('[fireflies] FIREFLIES_API_KEY not configured');
throw new Error('FIREFLIES_API_KEY environment variable is required');
}
if (!twentyApiKey) {
this.logError('[fireflies] TWENTY_API_KEY not configured');
throw new Error('TWENTY_API_KEY environment variable is required');
}
// 1) Parse and validate webhook payload and extract headers if wrapped together
const { payload, extractedHeaders } = this.parsePayload(params);
const finalHeaders = extractedHeaders || headers;
this.logDebug(`[fireflies] payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
// 2) Verify webhook signature
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
const secretFingerprint = getWebhookSecretFingerprint(webhookSecret);
this.logDebug(`[fireflies] webhook secret fingerprint=${secretFingerprint}`);
this.verifySignature(payload, finalHeaders, webhookSecret);
this.logDebug('[fireflies] signature verification: ok');
// 3) Fetch meeting data from Fireflies
const summaryConfig = getSummaryFetchConfig();
this.logDebug(`[fireflies] summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
this.logDebug(`[fireflies] fetching meeting data from Fireflies API`);
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
const { data: meetingData, summaryReady } = await firefliesClient.fetchMeetingDataWithRetry(
payload.meetingId,
summaryConfig
);
this.logDebug(`[fireflies] meeting data fetched: title="${meetingData.title}" summaryReady=${summaryReady}`);
result.summaryReady = summaryReady;
result.summaryPending = !summaryReady;
// Extract business intelligence
if (summaryReady) {
result.actionItemsCount = meetingData.summary.action_items.length;
result.keyTopics = meetingData.summary.topics_discussed;
result.meetingType = meetingData.summary.meeting_type;
if (meetingData.analytics?.sentiments) {
const sentiments = meetingData.analytics.sentiments;
result.sentimentScore = sentiments.positive_pct / 100;
}
}
// 4) Check for duplicate meetings
const twentyService = new TwentyCrmService(
twentyApiKey,
getApiUrl()
);
const existingMeeting = await twentyService.findExistingMeeting(meetingData.title);
if (existingMeeting) {
this.logDebug(`[fireflies] meeting already exists id=${existingMeeting.id}`);
result.success = true;
result.meetingId = existingMeeting.id;
result.debug = this.debug;
return result;
}
this.logDebug('[fireflies] no existing meeting found, proceeding');
// 5) Match participants to existing contacts
this.logDebug(`[fireflies] total participants from API: ${meetingData.participants.length}`);
meetingData.participants.forEach((p, idx) => {
this.logDebug(`[fireflies] participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
});
const { matchedContacts, unmatchedParticipants } = await twentyService.matchParticipantsToContacts(
meetingData.participants
);
this.logDebug(`[fireflies] matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
unmatchedParticipants.forEach((p, idx) => {
this.logDebug(`[fireflies] unmatched ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
});
// 6) Optionally create contacts
const autoCreate = shouldAutoCreateContacts();
const newContactIds = autoCreate
? await twentyService.createContactsForUnmatched(unmatchedParticipants)
: [];
result.newContacts = newContactIds;
this.logDebug(`[fireflies] 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];
const noteBody = MeetingFormatter.formatNoteBody(meetingData);
const noteId = await twentyService.createNoteOnly(
`Meeting: ${meetingData.title}`,
noteBody
);
result.noteIds = [noteId];
this.logDebug(`[fireflies] created note id=${noteId}`);
// 8) Create meeting with direct relationship to the note
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
this.logDebug(`[fireflies] meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
result.meetingId = await twentyService.createMeeting(meetingInput);
this.logDebug(`[fireflies] created meeting id=${result.meetingId} with noteId=${noteId}`);
// 9) Link note to participants (Meeting link is handled via the relation field)
await this.linkNoteToParticipants(
twentyService,
noteId,
allContactIds
);
this.logDebug(`[fireflies] linked note to ${allContactIds.length} participants`);
result.success = true;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logError(`[fireflies] 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;
}
private parsePayload(params: unknown): { payload: FirefliesWebhookPayload; extractedHeaders?: Record<string, string> } {
let normalizedParams = params;
let extractedHeaders: Record<string, string> | undefined;
// Handle string-encoded params
if (typeof normalizedParams === 'string') {
this.logDebug(`[fireflies] 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>);
this.logDebug(`[fireflies] parsed params keys: ${parsedKeys.join(',') || 'none'}`);
}
} catch (parseError) {
this.logError(`[fireflies] error parsing string params: ${String(parseError)}`);
throw new Error('Invalid or missing webhook payload');
}
}
// Handle wrapped payloads and extract headers if present
let payload: FirefliesWebhookPayload | undefined;
if (isValidFirefliesPayload(normalizedParams)) {
payload = normalizedParams as FirefliesWebhookPayload;
} else if (normalizedParams && typeof normalizedParams === 'object') {
const wrapper = normalizedParams as Record<string, unknown>;
// Extract headers if present in wrapper
if (wrapper.headers && typeof wrapper.headers === 'object' && !Array.isArray(wrapper.headers)) {
extractedHeaders = wrapper.headers as Record<string, string>;
const headerKeys = Object.keys(extractedHeaders);
this.logDebug(`[fireflies] 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)) {
this.logDebug(`[fireflies] detected payload under wrapper key "${key}"`);
payload = candidate as FirefliesWebhookPayload;
break;
}
}
}
if (!payload) {
this.logError('[fireflies] error: Invalid or missing webhook payload');
throw new Error('Invalid or missing webhook payload');
}
// Log payload keys for debugging
const payloadRecord = payload as Record<string, unknown>;
const payloadKeys = Object.keys(payloadRecord);
if (payloadKeys.length > 0) {
this.logDebug(`[fireflies] payload keys: ${payloadKeys.join(',')}`);
}
return { payload, extractedHeaders };
}
private verifySignature(
payload: FirefliesWebhookPayload,
headers: Record<string, string> | undefined,
webhookSecret: string
): void {
// Extract headers
const normalizedHeaders = headers || {};
const headerKeys = Object.keys(normalizedHeaders);
if (headerKeys.length > 0) {
this.logDebug(`[fireflies] header keys: ${headerKeys.join(',')}`);
}
const headerSignature = Object.entries(normalizedHeaders).find(
([key]) => key.toLowerCase() === 'x-hub-signature',
)?.[1];
const payloadRecord = payload as Record<string, unknown>;
const payloadSignature =
typeof payloadRecord['x-hub-signature'] === 'string'
? (payloadRecord['x-hub-signature'] as string)
: undefined;
if (payloadSignature) {
this.logDebug('[fireflies] found signature inside payload');
}
const signature =
(typeof headerSignature === 'string' ? headerSignature : undefined) || payloadSignature;
const body = typeof normalizedHeaders['body'] === 'string'
? normalizedHeaders['body']
: JSON.stringify(payloadRecord);
const signatureCheck = verifyWebhookSignature(body, signature, webhookSecret);
if (!signatureCheck.isValid) {
this.logDebug(
`[fireflies] signature check failed. headerPresent=${Boolean(
headerSignature,
)} payloadSignaturePresent=${Boolean(payloadSignature)}`,
);
if (signature) {
this.logDebug(`[fireflies] provided signature=${signature}`);
} else {
this.logDebug('[fireflies] provided signature=undefined');
}
this.logDebug(
`[fireflies] computed signature=${signatureCheck.computedSignature ?? 'unavailable'}`,
);
this.logError('[fireflies] error: Invalid webhook signature');
throw new Error('Invalid webhook signature');
}
}
private async linkNoteToParticipants(
twentyService: TwentyCrmService,
noteId: string,
contactIds: string[]
): Promise<void> {
// Create Note-Person links for each participant
for (const contactId of contactIds) {
try {
await twentyService.createNoteTarget(noteId, contactId);
this.logDebug(`[fireflies] linked note ${noteId} to person ${contactId}`);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logError(`[fireflies] failed to link note to person ${contactId}: ${message}`);
// Continue with other participants
}
}
}
private logDebug(message: string): void {
this.debug.push(message);
if (!this.isTestEnvironment) {
console.log(message);
}
}
private logError(message: string): void {
this.debug.push(message);
if (!this.isTestEnvironment) {
console.error(message);
}
}
private async createFailedMeetingRecord(params: unknown, error: string): Promise<void> {
try {
const twentyApiKey = process.env.TWENTY_API_KEY || '';
if (!twentyApiKey) {
this.logDebug('[fireflies] 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';
const { payload } = this.parsePayload(params);
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 {
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
const meetingData = await firefliesClient.fetchMeetingData(meetingId);
meetingTitle = meetingData.title || meetingTitle;
} catch (fetchError) {
this.logDebug(`[fireflies] Could not fetch meeting title: ${fetchError instanceof Error ? fetchError.message : 'Unknown error'}`);
}
}
}
const twentyService = new TwentyCrmService(twentyApiKey, getApiUrl());
const failedMeetingData = MeetingFormatter.toFailedMeetingCreateInput(
meetingId,
meetingTitle,
error
);
const failedMeetingId = await twentyService.createFailedMeeting(failedMeetingData);
this.logDebug(`[fireflies] Created failed meeting record: ${failedMeetingId}`);
} catch (recordError) {
// Don't throw here - we don't want to break the original error handling
this.logError(`[fireflies] Failed to create failed meeting record: ${recordError instanceof Error ? recordError.message : 'Unknown error'}`);
}
}
}
@@ -0,0 +1,54 @@
import { createHash, createHmac } from 'crypto';
import type { FirefliesWebhookPayload } from './types';
export type SignatureVerificationResult = {
isValid: boolean;
computedSignature?: string;
};
export const verifyWebhookSignature = (
body: string,
signature: string | undefined,
secret: string
): SignatureVerificationResult => {
if (!signature) {
return { isValid: false };
}
try {
const hmac = createHmac('sha256', secret);
hmac.update(body, 'utf8');
const computed = hmac.digest('hex');
const computedSignature = `sha256=${computed}`;
const isValid = signature === computedSignature;
return { isValid, computedSignature };
} catch {
return { isValid: false };
}
};
export const getWebhookSecretFingerprint = (secret: string): string => {
return createHash('sha256').update(secret).digest('hex').substring(0, 8);
};
export const isValidFirefliesPayload = (
params: unknown
): params is FirefliesWebhookPayload => {
if (!params || typeof params !== 'object') {
return false;
}
const payload = params as Record<string, unknown>;
return (
typeof payload['meetingId'] === 'string' &&
payload['meetingId'].length > 0 &&
typeof payload['eventType'] === 'string' &&
payload['eventType'].length > 0 &&
(payload['clientReferenceId'] === undefined ||
typeof payload['clientReferenceId'] === 'string')
);
};