Revert "Revert "[hacktoberfest] feat: add fireflies"" (#15595)
Reverts twentyhq/twenty#15589 Add back without the breaking change
This commit is contained in:
+442
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+227
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export { config, main } from './receive-fireflies-notes';
|
||||
export type {
|
||||
FirefliesMeetingData,
|
||||
FirefliesParticipant,
|
||||
FirefliesWebhookPayload,
|
||||
ProcessResult,
|
||||
SummaryFetchConfig,
|
||||
SummaryStrategy
|
||||
} from './types';
|
||||
|
||||
+28
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
+575
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+130
@@ -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;
|
||||
};
|
||||
|
||||
+30
@@ -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);
|
||||
};
|
||||
|
||||
+356
@@ -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'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -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')
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user