mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
[hacktoberfest] feat: add fireflies (#15527)
This commit is contained in:
parent
6065fa61c7
commit
995f5b3b3f
29 changed files with 9023 additions and 0 deletions
105
packages/twenty-apps/fireflies/.env.example
Normal file
105
packages/twenty-apps/fireflies/.env.example
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# Fireflies Integration Environment Variables
|
||||
# Copy this file to .env and fill in your actual values
|
||||
|
||||
# =============================================================================
|
||||
# REQUIRED: Authentication & API Keys
|
||||
# =============================================================================
|
||||
|
||||
# Secret key for HMAC signature verification of Fireflies webhooks
|
||||
# This must match the secret configured in your Fireflies webhook settings
|
||||
# Get this from: https://app.fireflies.ai/settings#DeveloperSettings
|
||||
FIREFLIES_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
|
||||
# Fireflies API key for fetching meeting data from GraphQL API
|
||||
# Get this from: https://app.fireflies.ai/settings#DeveloperSettings
|
||||
FIREFLIES_API_KEY=your_fireflies_api_key_here
|
||||
|
||||
# Fireflies plan level - affects which fields are available
|
||||
# Options: free, pro, business, enterprise
|
||||
# This controls which GraphQL fields are requested to avoid 403 errors
|
||||
FIREFLIES_PLAN_LEVEL=pro
|
||||
|
||||
# Twenty CRM API key for authentication
|
||||
# Generate this from your Twenty instance: Settings > Developers > API Keys
|
||||
TWENTY_API_KEY=your_twenty_api_key_here
|
||||
|
||||
# =============================================================================
|
||||
# Server Configuration
|
||||
# =============================================================================
|
||||
|
||||
# Twenty CRM server URL
|
||||
# Use http://localhost:3000 for local development
|
||||
# Use your production URL for deployed instances
|
||||
SERVER_URL=http://localhost:3000
|
||||
|
||||
# =============================================================================
|
||||
# Contact Management
|
||||
# =============================================================================
|
||||
|
||||
# Automatically create contacts for unknown participants (true/false)
|
||||
# If true, new Person records will be created for meeting participants not found in CRM
|
||||
# If false, only meetings with existing contacts will be fully processed
|
||||
AUTO_CREATE_CONTACTS=true
|
||||
|
||||
# =============================================================================
|
||||
# Summary Processing Configuration
|
||||
# =============================================================================
|
||||
|
||||
# Strategy for handling async summary generation
|
||||
# Options:
|
||||
# - immediate_with_retry: Attempts immediate fetch with retry logic (RECOMMENDED)
|
||||
# - delayed_polling: Schedules background polling for summaries
|
||||
# - basic_only: Creates records without waiting for summaries
|
||||
FIREFLIES_SUMMARY_STRATEGY=immediate_with_retry
|
||||
|
||||
# Number of retry attempts when fetching summary data
|
||||
# Used with immediate_with_retry strategy
|
||||
# Recommended: 3-5 attempts (summaries can take up to 10 minutes but rate limit is low)
|
||||
FIREFLIES_RETRY_ATTEMPTS=5
|
||||
|
||||
# Delay in milliseconds between retry attempts (with exponential backoff)
|
||||
# Each retry will wait: RETRY_DELAY * attempt_number
|
||||
# Example: 120000ms means 2min, 4min, 6min... for extended backoff
|
||||
# Total max time with 5 attempts: ~15 minutes
|
||||
FIREFLIES_RETRY_DELAY=120000
|
||||
|
||||
# Polling interval in milliseconds for delayed_polling strategy
|
||||
# How often to check if summary is ready
|
||||
# Recommended: 60000 (60 seconds) for extended processing
|
||||
FIREFLIES_POLL_INTERVAL=120000
|
||||
|
||||
# Maximum number of polling attempts for delayed_polling strategy
|
||||
# Total max time = POLL_INTERVAL * MAX_POLLS
|
||||
# Example: 2min * 5 = 10 minutes maximum
|
||||
FIREFLIES_MAX_POLLS=5
|
||||
|
||||
# =============================================================================
|
||||
# Debugging & Logging
|
||||
# =============================================================================
|
||||
|
||||
# Enable debug logging (true/false)
|
||||
# When enabled, detailed logs will be output to console
|
||||
# Useful for troubleshooting webhook processing
|
||||
DEBUG_LOGS=false
|
||||
|
||||
# =============================================================================
|
||||
# Configuration Notes
|
||||
# =============================================================================
|
||||
#
|
||||
# Webhook Setup:
|
||||
# 1. Configure your Fireflies webhook at: https://app.fireflies.ai/settings#DeveloperSettings
|
||||
# 2. Webhook URL: https://your-twenty-instance.com/s/webhook/fireflies
|
||||
# 3. Event Type: "Transcription completed"
|
||||
# 4. Secret: Same value as FIREFLIES_WEBHOOK_SECRET above, genereate it there
|
||||
#
|
||||
# Summary Strategy Guide:
|
||||
# - immediate_with_retry: Best for most use cases - fast with reliability
|
||||
# - delayed_polling: Use if your server is heavily loaded
|
||||
# - basic_only: Use if you only need transcript links without AI summaries
|
||||
#
|
||||
# Performance Tuning:
|
||||
# - Fireflies summaries can take 5-15 minutes to generate after transcription
|
||||
# - Use 30+ retry attempts with 30s delay for 15-minute coverage
|
||||
# - Consider delayed_polling strategy for heavily loaded servers
|
||||
# - Monitor DEBUG_LOGS to tune timing for your Fireflies account
|
||||
#
|
||||
942
packages/twenty-apps/fireflies/.yarn/releases/yarn-4.9.2.cjs
vendored
Executable file
942
packages/twenty-apps/fireflies/.yarn/releases/yarn-4.9.2.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
3
packages/twenty-apps/fireflies/.yarnrc.yml
Normal file
3
packages/twenty-apps/fireflies/.yarnrc.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||
|
||||
nodeLinker: node-modules
|
||||
80
packages/twenty-apps/fireflies/CHANGELOG.md
Normal file
80
packages/twenty-apps/fireflies/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# Changelog
|
||||
|
||||
## [0.2.1] - 2025-11-03
|
||||
|
||||
### Added
|
||||
- **Import status tracking**: Added four new meeting fields to track import status and failure handling:
|
||||
- `importStatus` (SELECT) - Tracks SUCCESS, FAILED, PENDING, RETRYING states
|
||||
- `importError` (TEXT) - Stores error messages when imports fail
|
||||
- `lastImportAttempt` (DATE_TIME) - Timestamp of the last import attempt
|
||||
- `importAttempts` (NUMBER) - Counter for number of import attempts
|
||||
- **Automatic failure tracking**: Enhanced webhook handler to automatically create failed meeting records when processing fails
|
||||
- **Failed meeting formatter**: Added `toFailedMeetingCreateInput()` method to create standardized failed meeting records
|
||||
|
||||
### Enhanced
|
||||
- **Meeting type definition**: Extended `MeetingCreateInput` type with import tracking fields
|
||||
- **Success status tracking**: Successful meeting imports now automatically set `importStatus: 'SUCCESS'` and track timestamps
|
||||
- **Error handling**: Webhook processing failures are now captured and stored as meeting records for visibility and potential retry
|
||||
|
||||
## [0.2.0] - 2025-11-03
|
||||
|
||||
### Changed
|
||||
- **Major refactoring**: Split monolithic `receive-fireflies-notes.ts` into modular architecture:
|
||||
- `fireflies-api-client.ts` - Fireflies GraphQL API integration with retry logic
|
||||
- `twenty-crm-service.ts` - Twenty CRM operations (contacts, notes, meetings)
|
||||
- `formatters.ts` - Meeting and note body formatting
|
||||
- `webhook-handler.ts` - Main webhook orchestration
|
||||
- `webhook-validator.ts` - HMAC signature verification
|
||||
- `utils.ts` - Shared utility functions
|
||||
- `types.ts` - Centralized type definitions
|
||||
- **Schema update**: Changed Meeting `notes` field from `RICH_TEXT` to `RELATION` type linking to Note object
|
||||
- Enhanced participant extraction from multiple Fireflies API data sources (participants, meeting_attendees, speakers, meeting_attendance)
|
||||
- Improved organizer email matching with name-based heuristics
|
||||
- Updated note creation to use `bodyV2.markdown` format instead of legacy `body` field
|
||||
- Modernized Meeting object schema with proper link field types for transcriptUrl and recordingUrl
|
||||
- Enhanced test suite with improved mocking for new modular structure
|
||||
- **Configuration optimization**: Reduced default retry attempts from 30 to 5 with increased delay (120s) to better respect Fireflies API rate limits (50 requests/day for free/pro plans)
|
||||
- Updated field setup script to support relation field creation with Note object
|
||||
- Restructured exports: types now exported from `types.ts`, runtime functions from `index.ts`
|
||||
- Updated import paths in action handlers to use centralized index exports
|
||||
- Added TypeScript path mappings for `twenty-sdk` in workspace configuration
|
||||
|
||||
### Added
|
||||
- `createNoteTarget` method for linking notes to multiple participants
|
||||
- Support for extracting participants from extended Fireflies API response formats
|
||||
- Better organizer identification logic matching email usernames to speaker names
|
||||
- `axios` dependency for improved HTTP client capabilities
|
||||
- API subscription plan documentation highlighting rate limit differences (50/day vs 60/minute)
|
||||
- Enhanced README with rate limiting guidance and configuration documentation
|
||||
- Relation field creation support in field provisioning script
|
||||
|
||||
### Fixed
|
||||
- Note linking now properly associates a single note with multiple participants in 1:1 meetings
|
||||
- Participant extraction handles missing email addresses gracefully
|
||||
- Improved handling of various Fireflies participant data structures
|
||||
- Test mocks updated to use string format for participants (`"Name <email>"`) matching Fireflies API response format
|
||||
- Test assertions updated to validate `bodyV2.markdown` instead of deprecated `body` field
|
||||
|
||||
## [0.1.0] - 2025-11-02
|
||||
|
||||
### Added
|
||||
- HMAC SHA-256 signature verification for incoming Fireflies webhooks
|
||||
- Fireflies GraphQL client with retry logic, timeout handling, and summary readiness detection
|
||||
- Summary-focused meeting processing that extracts action items, sentiment, keywords, and transcript/recording links
|
||||
- Scripted custom field provisioning via `yarn setup:fields`
|
||||
- Local webhook testing workflow via `yarn test:webhook`
|
||||
- Comprehensive Jest suite (15 tests) covering authentication, API integration, summary strategies, and error handling
|
||||
|
||||
### Changed
|
||||
- Replaced legacy JSON manifests with TypeScript configuration:
|
||||
- `application.config.ts` now declares app metadata and configuration variables
|
||||
- `src/objects/meeting.ts` defines the Meeting object via `@ObjectMetadata`
|
||||
- `src/actions/receive-fireflies-notes.ts` exports the Fireflies webhook action plus its runtime config
|
||||
- Updated documentation (README, Deployment Guide, Testing) to reflect the new project layout and workflows
|
||||
- Switched utility scripts to `tsx` and aligned package management with the hello-world example
|
||||
|
||||
### Fixed
|
||||
- Resolved real-world Fireflies payload mismatch by adopting the minimal webhook schema
|
||||
- Replaced body-based secrets with header-driven HMAC verification
|
||||
- Ensured graceful degradation when summaries are pending or Fireflies is temporarily unavailable
|
||||
|
||||
277
packages/twenty-apps/fireflies/README.md
Normal file
277
packages/twenty-apps/fireflies/README.md
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
# Fireflies
|
||||
|
||||
Automatically captures meeting notes with AI-generated summaries and insights from Fireflies.ai into your Twenty CRM.
|
||||
|
||||
## Integration Overview
|
||||
|
||||
**Fireflies webhook → Fireflies API → Twenty CRM with summary-focused insights**
|
||||
|
||||
- **Summary-first approach** - Prioritizes action items, keywords, and sentiment over raw transcripts
|
||||
- **HMAC signature verification** - Secure webhook authentication
|
||||
- **Two-phase architecture** - Webhook notification → API data fetch → CRM record creation
|
||||
- **Contact identification** - Matches participants to existing contacts or creates new ones
|
||||
- **One-on-one meetings** (2 people) → Individual notes linked to each contact
|
||||
- **Multi-party meetings** (3+ people) → Meeting records with all attendees
|
||||
- **Business intelligence extraction** - Action items, sentiment scores, topics, meeting types
|
||||
- **Smart retry logic** - Handles async summary generation with exponential backoff
|
||||
- **Links transcripts and recordings** - Easy access to full Fireflies content
|
||||
- **Duplicate prevention** - Checks for existing meetings by title
|
||||
|
||||
## API Access by Subscription Plan
|
||||
|
||||
Fireflies API access varies significantly by subscription tier:
|
||||
|
||||
| Feature | Free | Pro | Business | Enterprise |
|
||||
|---------|------|-----|----------|------------|
|
||||
| **API Rate Limit** | 50 requests/day | 50 requests/day | 60 requests/minute | 60 requests/minute |
|
||||
| **Storage** | 800 mins/seat | 8,000 mins/seat | Unlimited | Unlimited |
|
||||
| **AI Summaries** | Limited (20 credits) | Unlimited | Unlimited | Unlimited |
|
||||
| **Video Upload** | 100MB max | 1.5GB max | 1.5GB max | 1.5GB max |
|
||||
| **Advanced Features** | Basic transcription | AI apps, analytics | Team analytics, CI | Full API, SSO, compliance |
|
||||
|
||||
**Key Design Pattern:** Subscription-based API access uses **tiered rate limiting** rather than feature gating. Lower tiers get severely restricted throughput (50/day vs 60/minute = 1,700x difference), making production integrations effectively require Business+ plans.
|
||||
|
||||
**Pro Plan Limitation:** Despite "unlimited" AI summaries, the 50 requests/day limit severely constrains production usage for meeting-heavy organizations.
|
||||
|
||||
## What Gets Captured
|
||||
|
||||
### Summary & Insights
|
||||
- **Action Items** - Concrete next steps and commitments
|
||||
- **Keywords** - Key topics and themes discussed
|
||||
- **Overview** - Executive summary of the meeting
|
||||
- **Topics Discussed** - Main discussion points
|
||||
- **Meeting Type** - Context (sales call, standup, demo, etc.)
|
||||
|
||||
### Analytics
|
||||
- **Sentiment Analysis** - Positive/negative/neutral percentages for deal health
|
||||
- **Engagement Metrics** - Participation levels (future)
|
||||
|
||||
### Resources
|
||||
- **Transcript Link** - Quick access to full Fireflies transcript
|
||||
- **Recording Link** - Video/audio recording when available
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Step 1: Authenticate with Twenty
|
||||
npx twenty-cli auth login
|
||||
|
||||
# Step 2: Sync the app to create Meeting object
|
||||
npx twenty-cli app sync packages/twenty-apps/fireflies
|
||||
|
||||
# Step 3: Install dependencies
|
||||
yarn install
|
||||
|
||||
# Step 4: Add custom fields
|
||||
yarn setup:fields
|
||||
```
|
||||
|
||||
(TODO: change when fields setup internal support)
|
||||
|
||||
### Configuration
|
||||
|
||||
⚠️ **Important**: The integration uses **conservative retry settings** to respect Fireflies' 50 requests/day API limit with free/pro plans. You may increase for more reactivity with higher plans.
|
||||
|
||||
**Required Environment Variables:**
|
||||
```bash
|
||||
FIREFLIES_API_KEY=your_api_key # From Fireflies settings
|
||||
TWENTY_API_KEY=your_api_key # From Twenty CRM settings
|
||||
SERVER_URL=https://your-domain.twenty.com
|
||||
```
|
||||
|
||||
**Optional (Recommended):**
|
||||
```bash
|
||||
FIREFLIES_WEBHOOK_SECRET=your_secret # For webhook security
|
||||
```
|
||||
|
||||
📖 **For detailed configuration, troubleshooting, and rate limit management**, see [WEBHOOK_CONFIGURATION.md](./WEBHOOK_CONFIGURATION.md)
|
||||
|
||||
### What Gets Created
|
||||
|
||||
#### Basic Installation (Step 2)
|
||||
The `app sync` command creates:
|
||||
- ✅ Meeting object with basic `name` field
|
||||
- ✅ Webhook endpoint at `/s/webhook/fireflies`
|
||||
|
||||
#### After Custom Fields Setup (Step 4)
|
||||
The `setup:fields` script adds 13 custom fields to store rich Fireflies data:
|
||||
|
||||
| Field Name | Type | Label | Description |
|
||||
|------------|------|-------|-------------|
|
||||
| `notes` | RICH_TEXT | Meeting Notes | AI-generated summary with overview, topics, action items, and insights |
|
||||
| `meetingDate` | DATE_TIME | Meeting Date | Date and time when the meeting occurred |
|
||||
| `duration` | NUMBER | Duration (minutes) | Meeting duration in minutes |
|
||||
| `meetingType` | TEXT | Meeting Type | Type of meeting (e.g., Sales Call, Sprint Planning, 1:1) |
|
||||
| `keywords` | TEXT | Keywords | Key topics and themes discussed (comma-separated) |
|
||||
| `sentimentScore` | NUMBER | Sentiment Score | Overall meeting sentiment (0-1 scale, 1 = most positive) |
|
||||
| `positivePercent` | NUMBER | Positive % | Percentage of positive sentiment in conversation |
|
||||
| `negativePercent` | NUMBER | Negative % | Percentage of negative sentiment in conversation |
|
||||
| `actionItemsCount` | NUMBER | Action Items | Number of action items identified |
|
||||
| `transcriptUrl` | LINKS | Transcript URL | Link to full transcript in Fireflies |
|
||||
| `recordingUrl` | LINKS | Recording URL | Link to video/audio recording in Fireflies |
|
||||
| `firefliesMeetingId` | TEXT | Fireflies Meeting ID | Unique identifier from Fireflies |
|
||||
| `organizerEmail` | TEXT | Organizer Email | Email address of the meeting organizer |
|
||||
|
||||
Then re-sync:
|
||||
```bash
|
||||
npx twenty-cli app sync
|
||||
```
|
||||
|
||||
**Note:** Without custom fields, meetings will be created with just the title. The rich summary data will only be stored in Notes for 1-on-1 meetings.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
Check [.env.example](./.env.example)
|
||||
|
||||
### Summary Processing Strategies
|
||||
|
||||
| Strategy | Description | Use Case |
|
||||
|----------|-------------|----------|
|
||||
| `immediate_only` | Single fetch attempt, no retries | Fast processing, accept missing summaries if not ready |
|
||||
| `immediate_with_retry` | Attempts immediate fetch, retries with backoff | **Recommended** - Balances speed and reliability |
|
||||
| `delayed_polling` | Schedules background polling | For heavily loaded systems |
|
||||
| `basic_only` | Creates records without waiting for summaries | For basic transcript archival only |
|
||||
|
||||
## Webhook Setup
|
||||
|
||||
### Step 1: Get Your Webhook URL
|
||||
|
||||
Your webhook endpoint will be:
|
||||
```
|
||||
https://your-twenty-instance.com/s/webhook/fireflies
|
||||
```
|
||||
|
||||
### Step 2: Configure Fireflies Webhook
|
||||
|
||||
1. Log into Fireflies.ai
|
||||
2. https://app.fireflies.ai/settings#DeveloperSettings
|
||||
4. Enter your webhook URL
|
||||
5. Set **Secret**: Generate from there and set value of `FIREFLIES_WEBHOOK_SECRET`
|
||||
6. Save configuration
|
||||
|
||||
### Step 3: Verify Webhook
|
||||
|
||||
The integration uses **HMAC SHA-256 signature verification**:
|
||||
- Fireflies sends `x-hub-signature` header
|
||||
- Twenty verifies signature using your webhook secret
|
||||
- Invalid signatures are rejected immediately
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test -- --watch
|
||||
|
||||
# Development mode with live sync
|
||||
npx twenty-cli app dev
|
||||
|
||||
# Type checking
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The integration includes comprehensive test coverage:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run specific test suite
|
||||
npm test -- fireflies-webhook.spec.ts
|
||||
|
||||
# Run with coverage
|
||||
npm test -- --coverage
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- HMAC signature verification
|
||||
- Fireflies GraphQL API integration
|
||||
- Summary processing with retry logic
|
||||
- Summary-focused CRM record creation
|
||||
- One-on-one vs multi-party meeting detection
|
||||
- Contact matching and creation
|
||||
- Duplicate prevention
|
||||
- Error handling and resilience
|
||||
|
||||
## CRM Record Structure
|
||||
|
||||
### One-on-One Meeting Note Example
|
||||
|
||||
```markdown
|
||||
# Meeting: Product Demo with Client (Sales Call)
|
||||
|
||||
**Date:** Monday, November 2, 2024, 02:00 PM
|
||||
**Duration:** 30 minutes
|
||||
**Participants:** Sarah Sales, John Client
|
||||
|
||||
## Overview
|
||||
Successful product demonstration with positive client feedback.
|
||||
Client expressed strong interest in the enterprise plan.
|
||||
|
||||
## Key Topics
|
||||
- product features
|
||||
- pricing discussion
|
||||
- integration capabilities
|
||||
- support options
|
||||
|
||||
## Action Items
|
||||
- Follow up with pricing proposal by Friday
|
||||
- Schedule technical deep-dive next week
|
||||
- Share case studies from similar clients
|
||||
|
||||
## Insights
|
||||
**Keywords:** product demo, pricing, technical requirements, integration
|
||||
**Sentiment:** 75% positive, 10% negative, 15% neutral
|
||||
**Meeting Type:** Sales Call
|
||||
|
||||
## Resources
|
||||
[View Full Transcript](https://app.fireflies.ai/transcript/xxx)
|
||||
[Watch Recording](https://app.fireflies.ai/recording/xxx)
|
||||
```
|
||||
|
||||
### Multi-Party Meeting Record
|
||||
|
||||
- Meeting object with title, date, and all attendees
|
||||
- Summary stored as meeting notes (structure same as above)
|
||||
- Action items potentially converted to separate tasks (future)
|
||||
- Keywords as tags/categories (future)
|
||||
|
||||
## Future Implementation Opportunities
|
||||
|
||||
### Past Meetings Retrieval
|
||||
- **New trigger to retrieve past meetings from a contact** - Enable users to fetch historical meeting data from Fireflies for specific contacts, allowing retrospective capture and analysis of past interactions.
|
||||
|
||||
Next iteration would enhance the **intelligence layer** to:
|
||||
|
||||
### AI-Powered Insights
|
||||
- **Extract pain points, objections & buying signals** automatically from transcripts
|
||||
- **Calculate deal health scores** based on conversation sentiment trends
|
||||
- **Auto-create contextualized tasks** with AI-suggested next steps and priorities
|
||||
- **Proactively flag at-risk deals** when negative signals appear
|
||||
- **Track conversation patterns** that correlate with deal success
|
||||
|
||||
### Enhanced Analytics
|
||||
- **Action item completion tracking** across deals
|
||||
- **Sentiment trend analysis** over time for account health
|
||||
- **Speaking time analysis** for meeting engagement insights
|
||||
- **Topic clustering** for product/feature interest patterns
|
||||
|
||||
### Workflow Automation
|
||||
- **Auto-assign follow-up tasks** based on action items
|
||||
- **Smart notifications** for urgent follow-ups
|
||||
- **Deal stage progression** based on meeting outcomes
|
||||
- **Competitive intelligence** extraction from conversations
|
||||
|
||||
**Integration**: Fireflies webhook → AI processing layer → Enhanced Twenty records
|
||||
|
||||
*This would require the current MVP to be stabilized and discussions about intelligence layer architecture and data privacy considerations.*
|
||||
|
||||
68
packages/twenty-apps/fireflies/application.config.ts
Normal file
68
packages/twenty-apps/fireflies/application.config.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { type ApplicationConfig } from 'twenty-sdk/application';
|
||||
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: 'a4df0c0f-c65e-44e5-8436-24814182d4ac',
|
||||
displayName: 'Fireflies',
|
||||
description: 'Sync Fireflies meeting summaries, sentiment, and action items into Twenty.',
|
||||
icon: 'IconMicrophone',
|
||||
applicationVariables: {
|
||||
FIREFLIES_WEBHOOK_SECRET: {
|
||||
universalIdentifier: 'f51f7646-be9f-4ba9-9b75-160dd288cd0c',
|
||||
description: 'Secret key for verifying Fireflies webhook signatures',
|
||||
isSecret: true,
|
||||
},
|
||||
FIREFLIES_API_KEY: {
|
||||
universalIdentifier: 'faa41f07-b28e-4500-b1c0-ce4b3d27924c',
|
||||
description: 'Fireflies GraphQL API key used to fetch meeting summaries',
|
||||
isSecret: true,
|
||||
},
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: '02756551-5bf7-4fb2-8e08-1f622008d305',
|
||||
description: 'Twenty API key used when running scripts locally',
|
||||
isSecret: true,
|
||||
},
|
||||
SERVER_URL: {
|
||||
universalIdentifier: '9b3a5e8e-5973-4e6b-a059-2966075652aa',
|
||||
description: 'Base URL for the Twenty workspace (default: http://localhost:3000)',
|
||||
value: 'http://localhost:3000',
|
||||
},
|
||||
AUTO_CREATE_CONTACTS: {
|
||||
universalIdentifier: 'c4fa946e-e06b-4d54-afb6-288b0ac75bdf',
|
||||
description: 'Whether to auto-create contacts for unknown participants',
|
||||
value: 'true',
|
||||
},
|
||||
DEBUG_LOGS: {
|
||||
universalIdentifier: '009510df-5125-4683-941b-cce94b113242',
|
||||
description: 'Enable verbose logging for debugging (true/false)',
|
||||
value: 'false',
|
||||
},
|
||||
FIREFLIES_SUMMARY_STRATEGY: {
|
||||
universalIdentifier: '562b43d9-cd47-4ec1-ae16-5cc7ebc9729b',
|
||||
description: 'Summary fetch strategy: immediate_only, immediate_with_retry, delayed_polling, or basic_only',
|
||||
value: 'immediate_with_retry',
|
||||
},
|
||||
FIREFLIES_RETRY_ATTEMPTS: {
|
||||
universalIdentifier: '670ca203-01ce-4ae8-8294-eb38b29434f2',
|
||||
description: 'Number of retry attempts when fetching summaries',
|
||||
value: '3',
|
||||
},
|
||||
FIREFLIES_RETRY_DELAY: {
|
||||
universalIdentifier: '2e8ccb82-9390-47ba-b628-ca2726931bce',
|
||||
description: 'Delay in milliseconds between retry attempts',
|
||||
value: '5000',
|
||||
},
|
||||
FIREFLIES_POLL_INTERVAL: {
|
||||
universalIdentifier: '904538f7-7bec-4ee6-9bac-5d43c619b667',
|
||||
description: 'Polling interval (ms) when using delayed polling strategy',
|
||||
value: '30000',
|
||||
},
|
||||
FIREFLIES_MAX_POLLS: {
|
||||
universalIdentifier: '84d54c97-5572-4c01-9039-764ab3aa87b8',
|
||||
description: 'Maximum number of polling attempts when waiting for summaries',
|
||||
value: '10',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
23
packages/twenty-apps/fireflies/jest.config.mjs
Normal file
23
packages/twenty-apps/fireflies/jest.config.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
const jestConfig = {
|
||||
displayName: 'fireflies',
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
moduleFileExtensions: ['ts', 'js'],
|
||||
transform: {
|
||||
'^.+\\.ts$': 'ts-jest',
|
||||
},
|
||||
testMatch: [
|
||||
'<rootDir>/src/**/__tests__/**/*.(test|spec).{js,ts}',
|
||||
'<rootDir>/src/**/?(*.)(test|spec).{js,ts}',
|
||||
],
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/__tests__/setup.ts'
|
||||
],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,js}',
|
||||
'!src/**/*.d.ts',
|
||||
],
|
||||
coverageDirectory: './coverage',
|
||||
};
|
||||
|
||||
export default jestConfig;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/object.schema.json",
|
||||
"universalIdentifier": "c8f4d3e1-2a7b-4e9f-8c1d-5a6b7e8f9c2a",
|
||||
"standardId": "c8f4d3e1-2a7b-4e9f-8c1d-5a6b7e8f9c2a",
|
||||
"nameSingular": "meeting",
|
||||
"namePlural": "meetings",
|
||||
"labelSingular": "Meeting",
|
||||
"labelPlural": "Meetings",
|
||||
"description": "Meetings imported from Fireflies with AI-generated summaries, sentiment, and action items.",
|
||||
"icon": "IconVideo"
|
||||
}
|
||||
|
||||
101
packages/twenty-apps/fireflies/package.json
Normal file
101
packages/twenty-apps/fireflies/package.json
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
{
|
||||
"name": "fireflies",
|
||||
"version": "0.2.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/appManifest.schema.json",
|
||||
"universalIdentifier": "af5128d7-192e-4bd3-bcf2-fec1ad767440",
|
||||
"description": "Get fireflies meeting notes in Twenty",
|
||||
"env": {
|
||||
"FIREFLIES_API_KEY": {
|
||||
"description": "Fireflies API key for authentication",
|
||||
"isSecret": true
|
||||
},
|
||||
"FIREFLIES_WEBHOOK_SECRET": {
|
||||
"description": "Secret key for validating Fireflies webhooks",
|
||||
"isSecret": true
|
||||
},
|
||||
"FIREFLIES_PLAN_LEVEL": {
|
||||
"description": "Fireflies plan level: free, pro, enterprise",
|
||||
"value": "pro",
|
||||
"isSecret": false
|
||||
},
|
||||
"TWENTY_API_KEY": {
|
||||
"description": "Twenty CRM API key for authentication",
|
||||
"isSecret": true
|
||||
},
|
||||
"SERVER_URL": {
|
||||
"description": "Twenty CRM server URL",
|
||||
"value": "http://localhost:3000",
|
||||
"isSecret": false
|
||||
},
|
||||
"AUTO_CREATE_CONTACTS": {
|
||||
"description": "Automatically create contacts for unknown participants (true/false)",
|
||||
"value": "true",
|
||||
"isSecret": false
|
||||
},
|
||||
"FIREFLIES_SUMMARY_STRATEGY": {
|
||||
"description": "Summary fetch strategy: immediate_only, immediate_with_retry, delayed_polling, or basic_only",
|
||||
"value": "immediate_with_retry",
|
||||
"isSecret": false
|
||||
},
|
||||
"FIREFLIES_RETRY_ATTEMPTS": {
|
||||
"description": "Number of retry attempts when fetching summaries",
|
||||
"value": "30",
|
||||
"isSecret": false
|
||||
},
|
||||
"FIREFLIES_RETRY_DELAY": {
|
||||
"description": "Delay in milliseconds between retry attempts",
|
||||
"value": "30000",
|
||||
"isSecret": false
|
||||
},
|
||||
"FIREFLIES_POLL_INTERVAL": {
|
||||
"description": "Polling interval (ms) when using delayed polling strategy",
|
||||
"value": "60000",
|
||||
"isSecret": false
|
||||
},
|
||||
"FIREFLIES_MAX_POLLS": {
|
||||
"description": "Maximum number of polling attempts when waiting for summaries",
|
||||
"value": "15",
|
||||
"isSecret": false
|
||||
},
|
||||
"FIREFLIES_MAX_RETRY_ATTEMPTS": {
|
||||
"description": "Maximum number of retry attempts when waiting for summaries",
|
||||
"value": "30",
|
||||
"isSecret": false
|
||||
},
|
||||
"FIREFLIES_MAX_POLL_INTERVAL": {
|
||||
"description": "Maximum polling interval (ms) when waiting for summaries",
|
||||
"value": "600000",
|
||||
"isSecret": false
|
||||
},
|
||||
"DEBUG_LOGS": {
|
||||
"description": "Enable debug logging (true/false)",
|
||||
"value": "false",
|
||||
"isSecret": false
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"setup:fields": "tsx scripts/add-meeting-fields.ts",
|
||||
"test:webhook": "tsx scripts/test-webhook.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"twenty-sdk": "0.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/node": "^24.9.2",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
49
packages/twenty-apps/fireflies/project.json
Normal file
49
packages/twenty-apps/fireflies/project.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "fireflies",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/twenty-apps/fireflies/src",
|
||||
"projectType": "application",
|
||||
"tags": [
|
||||
"scope:apps"
|
||||
],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": [
|
||||
"{workspaceRoot}/coverage/{projectRoot}"
|
||||
],
|
||||
"options": {
|
||||
"jestConfig": "packages/twenty-apps/fireflies/jest.config.mjs",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"ci": true,
|
||||
"coverageReporters": ["text"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "tsc --noEmit --project {projectRoot}/tsconfig.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": [
|
||||
"{options.outputFile}"
|
||||
],
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"packages/twenty-apps/fireflies/**/*.{ts,tsx,js,jsx}"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"fix": {
|
||||
"fix": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
437
packages/twenty-apps/fireflies/scripts/add-meeting-fields.ts
Normal file
437
packages/twenty-apps/fireflies/scripts/add-meeting-fields.ts
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
/**
|
||||
* Migration script to add custom fields to the Meeting object
|
||||
* Run this after: npx twenty-cli app sync packages/twenty-apps/fireflies
|
||||
*
|
||||
* Usage: yarn setup:fields
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
|
||||
const API_KEY = process.env.TWENTY_API_KEY;
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error('❌ Error: TWENTY_API_KEY not found in .env file');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface RelationCreationPayload {
|
||||
targetObjectMetadataId: string;
|
||||
targetFieldLabel: string;
|
||||
targetFieldIcon: string;
|
||||
type: 'ONE_TO_MANY' | 'MANY_TO_ONE';
|
||||
}
|
||||
|
||||
interface FieldOption {
|
||||
value: string;
|
||||
label: string;
|
||||
position: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface FieldDefinition {
|
||||
type: string;
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
isNullable?: boolean;
|
||||
relationCreationPayload?: RelationCreationPayload;
|
||||
options?: FieldOption[];
|
||||
}
|
||||
|
||||
const MEETING_FIELDS: FieldDefinition[] = [
|
||||
{
|
||||
type: 'RELATION',
|
||||
name: 'note',
|
||||
label: 'Meeting Note',
|
||||
description: 'Related note with detailed meeting content',
|
||||
icon: 'IconNotes',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'DATE_TIME',
|
||||
name: 'meetingDate',
|
||||
label: 'Meeting Date',
|
||||
description: 'Date and time when the meeting occurred',
|
||||
icon: 'IconCalendar',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'duration',
|
||||
label: 'Duration (minutes)',
|
||||
description: 'Meeting duration in minutes',
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'meetingType',
|
||||
label: 'Meeting Type',
|
||||
description: 'Type of meeting (e.g., Sales Call, Sprint Planning, 1:1)',
|
||||
icon: 'IconTag',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'keywords',
|
||||
label: 'Keywords',
|
||||
description: 'Key topics and themes discussed (comma-separated)',
|
||||
icon: 'IconTags',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'sentimentScore',
|
||||
label: 'Sentiment Score',
|
||||
description: 'Overall meeting sentiment (0-1 scale, where 1 is most positive)',
|
||||
icon: 'IconMoodSmile',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'positivePercent',
|
||||
label: 'Positive %',
|
||||
description: 'Percentage of positive sentiment in conversation',
|
||||
icon: 'IconThumbUp',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'negativePercent',
|
||||
label: 'Negative %',
|
||||
description: 'Percentage of negative sentiment in conversation',
|
||||
icon: 'IconThumbDown',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'actionItemsCount',
|
||||
label: 'Action Items',
|
||||
description: 'Number of action items identified',
|
||||
icon: 'IconCheckbox',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'transcriptUrl',
|
||||
label: 'Transcript URL',
|
||||
description: 'Link to full transcript in Fireflies',
|
||||
icon: 'IconFileText',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'recordingUrl',
|
||||
label: 'Recording URL',
|
||||
description: 'Link to video/audio recording in Fireflies',
|
||||
icon: 'IconVideo',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'firefliesMeetingId',
|
||||
label: 'Fireflies Meeting ID',
|
||||
description: 'Unique identifier from Fireflies',
|
||||
icon: 'IconKey',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'organizerEmail',
|
||||
label: 'Organizer Email',
|
||||
description: 'Email address of the meeting organizer',
|
||||
icon: 'IconMail',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'SELECT',
|
||||
name: 'importStatus',
|
||||
label: 'Import Status',
|
||||
description: 'Status of the meeting import from Fireflies',
|
||||
icon: 'IconCheck',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ value: 'SUCCESS', label: 'Success', position: 0, color: 'green' },
|
||||
{ value: 'FAILED', label: 'Failed', position: 1, color: 'red' },
|
||||
{ value: 'PENDING', label: 'Pending', position: 2, color: 'yellow' },
|
||||
{ value: 'RETRYING', label: 'Retrying', position: 3, color: 'orange' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'importError',
|
||||
label: 'Import Error',
|
||||
description: 'Error message if meeting import failed',
|
||||
icon: 'IconAlertTriangle',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'DATE_TIME',
|
||||
name: 'lastImportAttempt',
|
||||
label: 'Last Import Attempt',
|
||||
description: 'Date and time of the last import attempt',
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'importAttempts',
|
||||
label: 'Import Attempts',
|
||||
description: 'Number of times import has been attempted',
|
||||
icon: 'IconRepeat',
|
||||
isNullable: true,
|
||||
},
|
||||
];
|
||||
|
||||
const graphqlRequest = async (query: string, variables: Record<string, unknown> = {}) => {
|
||||
const response = await fetch(`${SERVER_URL}/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`GraphQL request failed (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
if (json.errors) {
|
||||
throw new Error(`GraphQL errors: ${JSON.stringify(json.errors, null, 2)}`);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
};
|
||||
|
||||
const findMeetingObject = async () => {
|
||||
const query = `
|
||||
query FindMeetingObject {
|
||||
objects(paging: { first: 200 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
nameSingular
|
||||
labelSingular
|
||||
labelPlural
|
||||
fields {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
label
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await graphqlRequest(query);
|
||||
const edges = data.objects?.edges || [];
|
||||
const meetingEdge = edges.find(
|
||||
(edge: any) => edge?.node?.nameSingular === 'meeting',
|
||||
);
|
||||
|
||||
if (!meetingEdge) {
|
||||
throw new Error('Meeting object not found. Please run "npx twenty-cli app sync" first.');
|
||||
}
|
||||
|
||||
return meetingEdge.node;
|
||||
};
|
||||
|
||||
const findNoteObject = async () => {
|
||||
const query = `
|
||||
query FindObjects {
|
||||
objects(paging: { first: 100 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
nameSingular
|
||||
labelSingular
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await graphqlRequest(query);
|
||||
const edges = data.objects?.edges || [];
|
||||
const noteEdge = edges.find(
|
||||
(edge: any) => edge?.node?.nameSingular === 'note',
|
||||
);
|
||||
|
||||
if (!noteEdge) {
|
||||
throw new Error('Note object not found.');
|
||||
}
|
||||
|
||||
return noteEdge.node;
|
||||
};
|
||||
|
||||
const createField = async (objectId: string, field: FieldDefinition) => {
|
||||
const mutation = `
|
||||
mutation CreateField($input: CreateOneFieldMetadataInput!) {
|
||||
createOneField(input: $input) {
|
||||
id
|
||||
name
|
||||
label
|
||||
type
|
||||
description
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const input = {
|
||||
field: {
|
||||
type: field.type,
|
||||
name: field.name,
|
||||
label: field.label,
|
||||
description: field.description,
|
||||
icon: field.icon || 'IconAbc',
|
||||
isNullable: field.isNullable !== false,
|
||||
isActive: true,
|
||||
isCustom: true,
|
||||
objectMetadataId: objectId,
|
||||
...(field.relationCreationPayload && {
|
||||
relationCreationPayload: field.relationCreationPayload,
|
||||
}),
|
||||
...(field.options && {
|
||||
options: field.options,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const data = await graphqlRequest(mutation, { input });
|
||||
return data.createOneField;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message;
|
||||
if (
|
||||
message.includes('already exists') ||
|
||||
message.includes('not available') ||
|
||||
message.includes('Duplicating')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
console.log('🚀 Adding custom fields to Meeting object...\n');
|
||||
|
||||
try {
|
||||
// Step 1: Find Meeting and Note objects
|
||||
console.log('📋 Finding Meeting object...');
|
||||
const meetingObject = await findMeetingObject();
|
||||
console.log(`✅ Found Meeting object: ${meetingObject.labelSingular ?? meetingObject.nameSingular ?? 'Meeting'} (ID: ${meetingObject.id})\n`);
|
||||
|
||||
console.log('📋 Finding Note object...');
|
||||
const noteObject = await findNoteObject();
|
||||
console.log(`✅ Found Note object: ${noteObject.labelSingular ?? noteObject.nameSingular ?? 'Note'} (ID: ${noteObject.id})\n`);
|
||||
|
||||
// Step 2: Update note field with relationCreationPayload
|
||||
const fieldsToCreate = MEETING_FIELDS.map(field => {
|
||||
if (field.name === 'note' && field.type === 'RELATION') {
|
||||
return {
|
||||
...field,
|
||||
relationCreationPayload: {
|
||||
targetObjectMetadataId: noteObject.id,
|
||||
targetFieldLabel: 'Meeting',
|
||||
targetFieldIcon: 'IconCalendarEvent',
|
||||
type: 'MANY_TO_ONE' as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
return field;
|
||||
});
|
||||
|
||||
// Step 3: Check existing fields
|
||||
const existingFields = meetingObject.fields?.edges?.map((edge: any) => edge.node.name) || [];
|
||||
console.log(`📌 Existing fields: ${existingFields.join(', ')}\n`);
|
||||
|
||||
// Step 4: Create custom fields
|
||||
console.log('➕ Creating custom fields...\n');
|
||||
|
||||
let createdCount = 0;
|
||||
let failedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const field of fieldsToCreate) {
|
||||
try {
|
||||
if (existingFields.includes(field.name)) {
|
||||
console.log(` ⏭️ ${field.name} - already exists`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await createField(meetingObject.id, field);
|
||||
|
||||
if (result) {
|
||||
console.log(` ✅ ${field.name} - created successfully`);
|
||||
createdCount++;
|
||||
} else {
|
||||
console.log(` ⏭️ ${field.name} - skipped (already exists)`);
|
||||
skippedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(` ❌ ${field.name} - failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Summary
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 Summary:');
|
||||
console.log(` ✅ Created: ${createdCount} fields`);
|
||||
console.log(` ⏭️ Skipped: ${skippedCount} fields`);
|
||||
console.log(` ❌ Failed: ${failedCount} fields`);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
if (failedCount > 0) {
|
||||
console.log('\n⚠️ Some fields failed to create. Please check the errors above.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (createdCount === 0 && skippedCount === MEETING_FIELDS.length) {
|
||||
console.log('\n✨ All fields already exist. Nothing to do!');
|
||||
} else if (createdCount > 0) {
|
||||
console.log('\n✨ Custom fields added successfully!');
|
||||
console.log('\n📝 Next steps:');
|
||||
console.log(' 1. Re-sync your app: npx twenty-cli app sync');
|
||||
console.log(' 2. Update the createMeetingRecord function to use these fields');
|
||||
console.log(' 3. Test the integration with a real meeting');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error:', error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
208
packages/twenty-apps/fireflies/scripts/test-webhook.ts
Normal file
208
packages/twenty-apps/fireflies/scripts/test-webhook.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/* eslint-disable no-console */
|
||||
/**
|
||||
* Test script for Fireflies webhook against local Twenty instance
|
||||
*
|
||||
* Usage:
|
||||
* yarn test:webhook
|
||||
* # or
|
||||
* npx tsx scripts/test-webhook.ts
|
||||
*
|
||||
* Prerequisites:
|
||||
* 1. Twenty server running on http://localhost:3000
|
||||
* 2. Fireflies app synced: npx twenty-cli app sync
|
||||
* 3. Custom fields created: yarn setup:fields
|
||||
* 4. API key configured (get from Settings > Developers > API Keys)
|
||||
* 5. Environment variables set (copy .env.example to .env and fill values)
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { existsSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Get __dirname equivalent for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Load environment variables
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
if (existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
} else {
|
||||
console.warn('⚠️ .env file not found, using environment variables');
|
||||
}
|
||||
|
||||
// Configuration
|
||||
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
|
||||
const TWENTY_API_KEY = process.env.TWENTY_API_KEY;
|
||||
const FIREFLIES_WEBHOOK_SECRET = process.env.FIREFLIES_WEBHOOK_SECRET || 'test_secret';
|
||||
const _FIREFLIES_API_KEY = process.env.FIREFLIES_API_KEY || 'test_api_key';
|
||||
|
||||
// Test meeting data (simulating Fireflies API response)
|
||||
const TEST_MEETING_ID = 'test-meeting-local-' + Date.now();
|
||||
const TEST_WEBHOOK_PAYLOAD = {
|
||||
meetingId: TEST_MEETING_ID,
|
||||
eventType: 'Transcription completed',
|
||||
clientReferenceId: 'test-client-ref',
|
||||
};
|
||||
|
||||
// Mock Fireflies GraphQL API response
|
||||
const MOCK_FIREFLIES_RESPONSE = {
|
||||
data: {
|
||||
meeting: {
|
||||
id: TEST_MEETING_ID,
|
||||
title: 'Local Test Meeting',
|
||||
date: new Date().toISOString(),
|
||||
duration: 1800, // 30 minutes
|
||||
participants: [
|
||||
{ email: 'test1@example.com', name: 'Test User One' },
|
||||
{ email: 'test2@example.com', name: 'Test User Two' },
|
||||
],
|
||||
organizer_email: 'organizer@example.com',
|
||||
summary: {
|
||||
action_items: ['Complete integration testing', 'Review webhook logs'],
|
||||
keywords: ['testing', 'integration', 'webhook'],
|
||||
overview: 'This is a test meeting to verify the Fireflies webhook integration.',
|
||||
gist: 'Quick test summary',
|
||||
topics_discussed: ['Webhook testing', 'Integration verification'],
|
||||
meeting_type: 'Test',
|
||||
},
|
||||
analytics: {
|
||||
sentiments: {
|
||||
positive_pct: 75,
|
||||
negative_pct: 5,
|
||||
neutral_pct: 20,
|
||||
},
|
||||
},
|
||||
transcript_url: 'https://app.fireflies.ai/transcript/' + TEST_MEETING_ID,
|
||||
recording_url: 'https://app.fireflies.ai/recording/' + TEST_MEETING_ID,
|
||||
summary_status: 'ready',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Generate HMAC signature
|
||||
const generateHMACSignature = (body: string, secret: string): string => {
|
||||
const signature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(body, 'utf8')
|
||||
.digest('hex');
|
||||
return `sha256=${signature}`;
|
||||
};
|
||||
|
||||
// Mock Fireflies API fetch (currently unused but kept for reference)
|
||||
// In production, you'd need to mock this at the network level
|
||||
const _mockFirefliesFetch = async (url: string, options?: RequestInit) => {
|
||||
if (url.includes('graphql.fireflies.ai')) {
|
||||
// Return mock Fireflies API response
|
||||
return new Response(JSON.stringify(MOCK_FIREFLIES_RESPONSE), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
// For Twenty API calls, use real fetch
|
||||
return fetch(url, options);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
console.log('🧪 Testing Fireflies Webhook Against Local Twenty Instance\n');
|
||||
console.log(`📍 Server URL: ${SERVER_URL}`);
|
||||
console.log(`🔑 API Key: ${TWENTY_API_KEY ? '✅ Configured' : '❌ Missing'}`);
|
||||
console.log(`🔐 Webhook Secret: ${FIREFLIES_WEBHOOK_SECRET ? '✅ Configured' : '⚠️ Using test secret'}\n`);
|
||||
|
||||
// Validation
|
||||
if (!TWENTY_API_KEY) {
|
||||
console.error('❌ Error: TWENTY_API_KEY is required');
|
||||
console.error(' Get your API key from: Settings > Developers > API Keys');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Prepare webhook payload
|
||||
const body = JSON.stringify(TEST_WEBHOOK_PAYLOAD);
|
||||
const signature = generateHMACSignature(body, FIREFLIES_WEBHOOK_SECRET);
|
||||
|
||||
console.log('📤 Sending webhook payload:');
|
||||
console.log(JSON.stringify(TEST_WEBHOOK_PAYLOAD, null, 2));
|
||||
console.log(`\n🔐 HMAC Signature: ${signature}\n`);
|
||||
|
||||
// Check if server is reachable
|
||||
try {
|
||||
const healthCheck = await fetch(`${SERVER_URL}/api/health`);
|
||||
if (!healthCheck.ok) {
|
||||
throw new Error(`Server health check failed: ${healthCheck.status}`);
|
||||
}
|
||||
console.log('✅ Server is reachable\n');
|
||||
} catch {
|
||||
console.error(`❌ Cannot reach server at ${SERVER_URL}`);
|
||||
console.error(' Make sure Twenty is running: cd twenty && yarn dev');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Note: In a real test, we'd intercept fetch calls
|
||||
// For now, we'll make a direct request to the webhook endpoint
|
||||
// The actual serverless function will call Fireflies API
|
||||
// This test validates the endpoint is accessible
|
||||
|
||||
// Webhook endpoint: The route path from manifest is /webhook/fireflies
|
||||
// Routes are matched after removing /s/ prefix
|
||||
// So /s/webhook/fireflies should match the route /webhook/fireflies
|
||||
const webhookUrl = `${SERVER_URL}/s/webhook/fireflies`;
|
||||
console.log(`📡 Calling webhook endpoint: ${webhookUrl}\n`);
|
||||
|
||||
try {
|
||||
// Note: This will fail because the serverless function needs to call
|
||||
// Fireflies API, which we can't easily mock at the endpoint level.
|
||||
// In development, you might want to set FIREFLIES_API_KEY to a test value
|
||||
// and mock the Fireflies API endpoint separately.
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${TWENTY_API_KEY}`,
|
||||
'x-hub-signature': signature,
|
||||
},
|
||||
body: body,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
let responseData;
|
||||
try {
|
||||
responseData = JSON.parse(responseText);
|
||||
} catch {
|
||||
responseData = responseText;
|
||||
}
|
||||
|
||||
console.log(`📥 Response Status: ${response.status} ${response.statusText}`);
|
||||
console.log('📥 Response Body:');
|
||||
console.log(JSON.stringify(responseData, null, 2));
|
||||
|
||||
if (response.ok) {
|
||||
console.log('\n✅ Webhook test completed successfully!');
|
||||
console.log('\n📋 Next steps:');
|
||||
console.log(' 1. Check Twenty CRM for new Meeting/Note records');
|
||||
console.log(' 2. Verify custom fields are populated');
|
||||
console.log(' 3. Check server logs for any errors');
|
||||
} else {
|
||||
console.log('\n⚠️ Webhook returned an error status');
|
||||
console.log(' This might be expected if Fireflies API key is not configured');
|
||||
console.log(' or if the meeting data fetch fails.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error calling webhook:');
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
console.error('\n💡 Troubleshooting:');
|
||||
console.error(' 1. Ensure Twenty server is running');
|
||||
console.error(' 2. Ensure app is synced: npx twenty-cli app sync');
|
||||
console.error(' 3. Check API key is valid');
|
||||
console.error(' 4. Verify webhook endpoint exists');
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/serverlessFunction.schema.json",
|
||||
"universalIdentifier": "0765206a-a58d-4ecf-a5a4-23d1d2095f4e",
|
||||
"name": "receive-fireflies-notes",
|
||||
"description": "Receives Fireflies webhooks, fetches meeting summaries, and stores them in Twenty.",
|
||||
"code": {
|
||||
"src": "serverlessFunctions/receive-fireflies-notes/src/receive-fireflies-notes.ts"
|
||||
},
|
||||
"triggers": [
|
||||
{
|
||||
"universalIdentifier": "7742d477-4057-436d-9298-565f2934cf1a",
|
||||
"type": "route",
|
||||
"path": "/webhook/fireflies",
|
||||
"httpMethod": "POST",
|
||||
"isAuthRequired": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -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(/<[^>]+>/, '').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')
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,712 @@
|
|||
import * as crypto from 'crypto';
|
||||
|
||||
import {
|
||||
main,
|
||||
type FirefliesMeetingData,
|
||||
type FirefliesWebhookPayload,
|
||||
} from '../actions/receive-fireflies-notes';
|
||||
|
||||
// Helper to generate HMAC signature
|
||||
const generateHMACSignature = (body: string, secret: string): string => {
|
||||
const signature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(body, 'utf8')
|
||||
.digest('hex');
|
||||
return `sha256=${signature}`;
|
||||
};
|
||||
|
||||
// Mock raw Fireflies API response with full summary (before transformation)
|
||||
const mockFirefliesApiResponseWithSummary = {
|
||||
id: 'test-meeting-001',
|
||||
title: 'Product Demo with Client',
|
||||
date: '2024-11-02T14:00:00Z',
|
||||
duration: 1800,
|
||||
participants: [
|
||||
'Sarah Sales <sales@company.com>',
|
||||
'John Client <client@customer.com>',
|
||||
],
|
||||
organizer_email: 'sales@company.com',
|
||||
summary: {
|
||||
action_items: [
|
||||
'Follow up with pricing proposal by Friday',
|
||||
'Schedule technical deep-dive next week',
|
||||
'Share case studies from similar clients',
|
||||
],
|
||||
keywords: ['product demo', 'pricing', 'technical requirements', 'integration'],
|
||||
overview: 'Successful product demonstration with positive client feedback. Client expressed strong interest in the enterprise plan and requested technical documentation for their IT team.',
|
||||
gist: 'Product demo went well, client interested in enterprise plan, next steps identified',
|
||||
topics_discussed: ['product features', 'pricing discussion', 'integration capabilities', 'support options'],
|
||||
meeting_type: 'Sales Call',
|
||||
bullet_gist: '• Demonstrated core product features\n• Discussed enterprise pricing\n• Addressed integration questions',
|
||||
},
|
||||
sentiments: { // Note: Raw API has sentiments at top level, not in analytics
|
||||
positive_pct: 75,
|
||||
negative_pct: 10,
|
||||
neutral_pct: 15,
|
||||
},
|
||||
transcript_url: 'https://app.fireflies.ai/transcript/test-001',
|
||||
video_url: 'https://app.fireflies.ai/recording/test-001',
|
||||
summary_status: 'completed',
|
||||
};
|
||||
|
||||
// Transformed meeting data (after fetchFirefliesMeetingData processes it)
|
||||
const mockMeetingWithFullSummary: FirefliesMeetingData = {
|
||||
id: 'test-meeting-001',
|
||||
title: 'Product Demo with Client',
|
||||
date: '2024-11-02T14:00:00Z',
|
||||
duration: 1800,
|
||||
participants: [
|
||||
{ email: 'sales@company.com', name: 'Sarah Sales' },
|
||||
{ email: 'client@customer.com', name: 'John Client' },
|
||||
],
|
||||
organizer_email: 'sales@company.com',
|
||||
summary: {
|
||||
action_items: [
|
||||
'Follow up with pricing proposal by Friday',
|
||||
'Schedule technical deep-dive next week',
|
||||
'Share case studies from similar clients',
|
||||
],
|
||||
keywords: ['product demo', 'pricing', 'technical requirements', 'integration'],
|
||||
overview: 'Successful product demonstration with positive client feedback. Client expressed strong interest in the enterprise plan and requested technical documentation for their IT team.',
|
||||
gist: 'Product demo went well, client interested in enterprise plan, next steps identified',
|
||||
topics_discussed: ['product features', 'pricing discussion', 'integration capabilities', 'support options'],
|
||||
meeting_type: 'Sales Call',
|
||||
bullet_gist: '• Demonstrated core product features\n• Discussed enterprise pricing\n• Addressed integration questions',
|
||||
},
|
||||
analytics: {
|
||||
sentiments: {
|
||||
positive_pct: 75,
|
||||
negative_pct: 10,
|
||||
neutral_pct: 15,
|
||||
},
|
||||
},
|
||||
transcript_url: 'https://app.fireflies.ai/transcript/test-001',
|
||||
recording_url: 'https://app.fireflies.ai/recording/test-001',
|
||||
summary_status: 'completed',
|
||||
};
|
||||
|
||||
// Mock raw API response without summary (processing)
|
||||
const mockFirefliesApiResponseWithoutSummary = {
|
||||
id: 'test-meeting-002',
|
||||
title: 'Team Standup',
|
||||
date: '2024-11-02T15:00:00Z',
|
||||
duration: 900,
|
||||
participants: [
|
||||
'Alice Developer <dev1@company.com>',
|
||||
'Bob Developer <dev2@company.com>',
|
||||
],
|
||||
organizer_email: 'dev1@company.com',
|
||||
summary: {
|
||||
action_items: [],
|
||||
keywords: [],
|
||||
overview: '',
|
||||
gist: '',
|
||||
topics_discussed: [],
|
||||
},
|
||||
transcript_url: 'https://app.fireflies.ai/transcript/test-002',
|
||||
summary_status: 'processing',
|
||||
};
|
||||
|
||||
// Mock meeting data without summary (processing) - currently unused but kept for reference
|
||||
const _mockMeetingWithoutSummary = {
|
||||
id: 'test-meeting-002',
|
||||
title: 'Team Standup',
|
||||
date: '2024-11-02T15:00:00Z',
|
||||
duration: 900,
|
||||
participants: [
|
||||
{ email: 'dev1@company.com', name: 'Alice Developer' },
|
||||
{ email: 'dev2@company.com', name: 'Bob Developer' },
|
||||
],
|
||||
organizer_email: 'dev1@company.com',
|
||||
summary: {
|
||||
action_items: [],
|
||||
keywords: [],
|
||||
overview: '',
|
||||
gist: '',
|
||||
topics_discussed: [],
|
||||
},
|
||||
transcript_url: 'https://app.fireflies.ai/transcript/test-002',
|
||||
summary_status: 'processing',
|
||||
};
|
||||
|
||||
// Mock raw API response for team meeting
|
||||
const mockFirefliesApiResponseTeamMeeting = {
|
||||
...mockFirefliesApiResponseWithSummary,
|
||||
id: 'test-team-003',
|
||||
title: 'Sprint Planning',
|
||||
participants: [
|
||||
'Alice Scrum <scrum@company.com>',
|
||||
'Bob Developer <dev1@company.com>',
|
||||
'Carol Coder <dev2@company.com>',
|
||||
'David QA <qa@company.com>',
|
||||
],
|
||||
summary: {
|
||||
...mockFirefliesApiResponseWithSummary.summary,
|
||||
meeting_type: 'Sprint Planning',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock team meeting with multiple participants (transformed) - currently unused but kept for reference
|
||||
const _mockTeamMeeting = {
|
||||
...mockMeetingWithFullSummary,
|
||||
id: 'test-team-003',
|
||||
title: 'Sprint Planning',
|
||||
participants: [
|
||||
{ email: 'scrum@company.com', name: 'Alice Scrum' },
|
||||
{ email: 'dev1@company.com', name: 'Bob Developer' },
|
||||
{ email: 'dev2@company.com', name: 'Carol Coder' },
|
||||
{ email: 'qa@company.com', name: 'David QA' },
|
||||
],
|
||||
summary: {
|
||||
...mockMeetingWithFullSummary.summary,
|
||||
meeting_type: 'Sprint Planning',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock environment variables
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
FIREFLIES_WEBHOOK_SECRET: 'test_webhook_secret',
|
||||
FIREFLIES_API_KEY: 'test_fireflies_api_key',
|
||||
TWENTY_API_KEY: 'test_twenty_api_key',
|
||||
SERVER_URL: 'http://localhost:3000',
|
||||
AUTO_CREATE_CONTACTS: 'true',
|
||||
DEBUG_LOGS: 'false',
|
||||
FIREFLIES_SUMMARY_STRATEGY: 'immediate_with_retry',
|
||||
FIREFLIES_RETRY_ATTEMPTS: '3',
|
||||
FIREFLIES_RETRY_DELAY: '1000',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Fireflies Webhook Integration v2', () => {
|
||||
describe('Webhook Authentication', () => {
|
||||
it('should verify HMAC SHA-256 signature from x-hub-signature header', async () => {
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-meeting-001',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = generateHMACSignature(body, 'test_webhook_secret');
|
||||
|
||||
// Mock Fireflies API
|
||||
global.fetch = jest.fn().mockImplementation((url: string) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: {
|
||||
transcript: mockFirefliesApiResponseWithSummary, // Use raw API format
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
// Twenty API mocks
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: {
|
||||
meetings: { edges: [] },
|
||||
people: { edges: [] },
|
||||
createPerson: { id: 'new-person-id' },
|
||||
createNote: { id: 'new-note-id' },
|
||||
createMeeting: { id: 'new-meeting-id' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const result = await main(payload, { 'x-hub-signature': signature, body });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject requests with invalid signature', async () => {
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-meeting-001',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const invalidSignature = 'sha256=invalid_signature_here';
|
||||
|
||||
const result = await main(payload, { 'x-hub-signature': invalidSignature, body });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Invalid webhook signature');
|
||||
});
|
||||
|
||||
it('should reject requests without signature header', async () => {
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-meeting-001',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const result = await main(payload, {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Invalid webhook signature');
|
||||
});
|
||||
|
||||
it('should reject requests with missing webhook secret env var', async () => {
|
||||
delete process.env.FIREFLIES_WEBHOOK_SECRET;
|
||||
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-meeting-001',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const result = await main(payload, {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fireflies GraphQL Integration', () => {
|
||||
it('should fetch meeting data from Fireflies API', async () => {
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-meeting-001',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = generateHMACSignature(body, 'test_webhook_secret');
|
||||
|
||||
const firefliesApiMock = jest.fn();
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
firefliesApiMock();
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: {
|
||||
transcript: mockFirefliesApiResponseWithSummary, // Use raw API format
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
// Twenty API mocks
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: {
|
||||
meetings: { edges: [] },
|
||||
people: { edges: [] },
|
||||
createPerson: { id: 'new-person-id' },
|
||||
createNote: { id: 'new-note-id' },
|
||||
createMeeting: { id: 'new-meeting-id' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const result = await main(payload, { 'x-hub-signature': signature, body });
|
||||
|
||||
expect(firefliesApiMock).toHaveBeenCalled();
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle Fireflies API fetch failures gracefully', async () => {
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-meeting-001',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = generateHMACSignature(body, 'test_webhook_secret');
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
return Promise.reject(new Error('Fireflies API unavailable'));
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ data: {} }) });
|
||||
});
|
||||
|
||||
const result = await main(payload, { 'x-hub-signature': signature, body });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors?.[0]).toContain('Fireflies API');
|
||||
});
|
||||
|
||||
it('should handle malformed GraphQL responses', async () => {
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-meeting-001',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = generateHMACSignature(body, 'test_webhook_secret');
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: { malformed: 'response' },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ data: {} }) });
|
||||
});
|
||||
|
||||
const result = await main(payload, { 'x-hub-signature': signature, body });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors?.[0]).toContain('Invalid response from Fireflies API');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Summary Processing', () => {
|
||||
it('should create complete records when summary is ready', async () => {
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-meeting-001',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = generateHMACSignature(body, 'test_webhook_secret');
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: { transcript: mockFirefliesApiResponseWithSummary }, // Use raw API format
|
||||
}),
|
||||
});
|
||||
}
|
||||
// Twenty API mocks
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: {
|
||||
meetings: { edges: [] },
|
||||
people: { edges: [] },
|
||||
createPerson: { id: 'new-person-id' },
|
||||
createNote: { id: 'new-note-id' },
|
||||
createMeeting: { id: 'new-meeting-id' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const result = await main(payload, { 'x-hub-signature': signature, body });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.summaryReady).toBe(true);
|
||||
expect(result.actionItemsCount).toBe(3);
|
||||
expect(result.sentimentScore).toBeCloseTo(0.75, 2); // Use toBeCloseTo for floating point comparison
|
||||
expect(result.meetingType).toBe('Sales Call');
|
||||
expect(result.keyTopics).toEqual(['product features', 'pricing discussion', 'integration capabilities', 'support options']);
|
||||
});
|
||||
|
||||
it('should create basic records when summary is pending', async () => {
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-meeting-002',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = generateHMACSignature(body, 'test_webhook_secret');
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: { transcript: mockFirefliesApiResponseWithoutSummary }, // Use raw API format
|
||||
}),
|
||||
});
|
||||
}
|
||||
// Twenty API mocks
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: {
|
||||
meetings: { edges: [] },
|
||||
people: { edges: [] },
|
||||
createPerson: { id: 'new-person-id' },
|
||||
createNote: { id: 'new-note-id' },
|
||||
createMeeting: { id: 'new-meeting-id' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const result = await main(payload, { 'x-hub-signature': signature, body });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.summaryPending).toBe(true);
|
||||
expect(result.noteIds || result.meetingId).toBeDefined();
|
||||
});
|
||||
|
||||
it('should retry summary fetch with exponential backoff', async () => {
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-meeting-003',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = generateHMACSignature(body, 'test_webhook_secret');
|
||||
|
||||
let attemptCount = 0;
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
attemptCount++;
|
||||
// First two attempts return no summary, third returns full summary
|
||||
if (attemptCount < 3) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: { transcript: mockFirefliesApiResponseWithoutSummary }, // Use raw API format
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: { transcript: mockFirefliesApiResponseWithSummary }, // Use raw API format
|
||||
}),
|
||||
});
|
||||
}
|
||||
// Twenty API mocks
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: {
|
||||
meetings: { edges: [] },
|
||||
people: { edges: [] },
|
||||
createPerson: { id: 'new-person-id' },
|
||||
createNote: { id: 'new-note-id' },
|
||||
createMeeting: { id: 'new-meeting-id' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const result = await main(payload, { 'x-hub-signature': signature, body });
|
||||
|
||||
expect(attemptCount).toBe(3);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.summaryReady).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle immediate_only strategy with single fetch attempt', async () => {
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-meeting-004',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = generateHMACSignature(body, 'test_webhook_secret');
|
||||
|
||||
let fetchCount = 0;
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
fetchCount++;
|
||||
// Return summary not ready
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: { transcript: mockFirefliesApiResponseWithoutSummary },
|
||||
}),
|
||||
});
|
||||
}
|
||||
// Twenty API mocks
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: {
|
||||
meetings: { edges: [] },
|
||||
people: { edges: [] },
|
||||
createPerson: { id: 'new-person-id' },
|
||||
createNote: { id: 'new-note-id' },
|
||||
createMeeting: { id: 'new-meeting-id' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Override strategy for this test
|
||||
process.env.FIREFLIES_SUMMARY_STRATEGY = 'immediate_only';
|
||||
|
||||
const result = await main(payload, { 'x-hub-signature': signature, body });
|
||||
|
||||
// Should only fetch once with immediate_only strategy
|
||||
expect(fetchCount).toBe(1);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.summaryPending).toBe(true);
|
||||
|
||||
// Reset to default
|
||||
process.env.FIREFLIES_SUMMARY_STRATEGY = 'immediate_with_retry';
|
||||
});
|
||||
});
|
||||
|
||||
describe('CRM Record Creation', () => {
|
||||
it('should create summary-focused notes for 1-on-1 meetings', async () => {
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-meeting-001',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = generateHMACSignature(body, 'test_webhook_secret');
|
||||
|
||||
const createNoteMock = jest.fn();
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string, options?: any) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: { transcript: mockFirefliesApiResponseWithSummary }, // Use raw API format
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Twenty API
|
||||
const body = options?.body ? JSON.parse(options.body) : {};
|
||||
if (body.query?.includes('createNote')) {
|
||||
createNoteMock(body.variables);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: { createNote: { id: 'new-note-id' } },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: {
|
||||
meetings: { edges: [] },
|
||||
people: { edges: [] },
|
||||
createPerson: { id: 'new-person-id' },
|
||||
createMeeting: { id: 'new-meeting-id' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const result = await main(payload, { 'x-hub-signature': signature, body });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(createNoteMock).toHaveBeenCalled();
|
||||
|
||||
const noteData = createNoteMock.mock.calls[0][0];
|
||||
expect(noteData.data.title).toContain('Meeting:');
|
||||
expect(noteData.data.bodyV2.markdown).toContain('## Overview'); // Markdown header, not bold
|
||||
expect(noteData.data.bodyV2.markdown).toContain('## Action Items'); // Markdown header, not bold
|
||||
expect(noteData.data.bodyV2.markdown).toContain('**Sentiment:**'); // This is bold
|
||||
expect(noteData.data.bodyV2.markdown).toContain('[View Full Transcript]');
|
||||
});
|
||||
|
||||
it('should create meeting records for multi-party meetings', async () => {
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-team-003',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = generateHMACSignature(body, 'test_webhook_secret');
|
||||
|
||||
const createMeetingMock = jest.fn();
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string, options?: any) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: { transcript: mockFirefliesApiResponseTeamMeeting }, // Use raw API format
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Twenty API
|
||||
const body = options?.body ? JSON.parse(options.body) : {};
|
||||
if (body.query?.includes('createMeeting')) {
|
||||
createMeetingMock(body.variables);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: { createMeeting: { id: 'new-meeting-id' } },
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (body.query?.includes('createNote')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: { createNote: { id: 'new-note-id' } },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
data: {
|
||||
meetings: { edges: [] },
|
||||
people: { edges: [] },
|
||||
createPerson: { id: 'new-person-id' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const result = await main(payload, { 'x-hub-signature': signature, body });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.meetingId).toBeDefined();
|
||||
expect(createMeetingMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling & Resilience', () => {
|
||||
it('should never throw uncaught exceptions', async () => {
|
||||
const payload: FirefliesWebhookPayload = {
|
||||
meetingId: 'test-critical-error',
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = generateHMACSignature(body, 'test_webhook_secret');
|
||||
|
||||
global.fetch = jest.fn().mockImplementation(() => {
|
||||
throw new Error('Critical failure');
|
||||
});
|
||||
|
||||
await expect(main(payload, { 'x-hub-signature': signature, body })).resolves.toEqual(
|
||||
expect.objectContaining({ success: false, errors: expect.any(Array) })
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing payload gracefully', async () => {
|
||||
const result = await main(null as any, {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle invalid payload structure', async () => {
|
||||
const invalidPayload = { invalid: 'data' };
|
||||
|
||||
const result = await main(invalidPayload as any, {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
16
packages/twenty-apps/fireflies/src/__tests__/setup.ts
Normal file
16
packages/twenty-apps/fireflies/src/__tests__/setup.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Test setup for Fireflies app
|
||||
|
||||
// Mock global fetch for all tests
|
||||
global.fetch = jest.fn();
|
||||
|
||||
// Setup test environment variables
|
||||
process.env.FIREFLIES_WEBHOOK_SECRET = 'testsecret';
|
||||
process.env.AUTO_CREATE_CONTACTS = 'true';
|
||||
process.env.SERVER_URL = 'http://localhost:3000';
|
||||
process.env.TWENTY_API_KEY = 'test-api-key';
|
||||
process.env.DEBUG_LOGS = 'true'; // Enable debug logs in tests
|
||||
|
||||
// Reset mocks before each test
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export {
|
||||
config,
|
||||
main,
|
||||
type FirefliesMeetingData,
|
||||
type FirefliesParticipant,
|
||||
type FirefliesWebhookPayload,
|
||||
type ProcessResult,
|
||||
type SummaryFetchConfig,
|
||||
type SummaryStrategy
|
||||
} from '../../serverlessFunctions/receive-fireflies-notes/src/index';
|
||||
|
||||
14
packages/twenty-apps/fireflies/src/objects/meeting.ts
Normal file
14
packages/twenty-apps/fireflies/src/objects/meeting.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { ObjectMetadata } from 'twenty-sdk/application';
|
||||
|
||||
@ObjectMetadata({
|
||||
universalIdentifier: 'd1831348-b4a4-4426-9c0b-0af19e7a9c27',
|
||||
nameSingular: 'meeting',
|
||||
namePlural: 'meetings',
|
||||
labelSingular: 'Meeting',
|
||||
labelPlural: 'Meetings',
|
||||
description:
|
||||
'Meetings imported from Fireflies with AI-generated summaries, sentiment, and action items.',
|
||||
icon: 'IconVideo',
|
||||
})
|
||||
export class Meeting {}
|
||||
|
||||
27
packages/twenty-apps/fireflies/tsconfig.json
Normal file
27
packages/twenty-apps/fireflies/tsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"moduleResolution": "node",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"strict": true,
|
||||
"target": "es2018",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
4060
packages/twenty-apps/fireflies/yarn.lock
Normal file
4060
packages/twenty-apps/fireflies/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,6 +2,13 @@
|
|||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"twenty-sdk": ["packages/twenty-sdk/src/index.ts"],
|
||||
"twenty-sdk/*": ["packages/twenty-sdk/src/*"],
|
||||
"@/application": ["packages/twenty-sdk/src/application/index.ts"],
|
||||
"@/application/*": ["packages/twenty-sdk/src/application/*"]
|
||||
},
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"moduleResolution": "node",
|
||||
|
|
|
|||
Loading…
Reference in a new issue