mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9bdc57616 | ||
|
|
e7b4da2689 | ||
|
|
aa0101c666 | ||
|
|
c939f8a76e | ||
|
|
0b19ad0013 | ||
|
|
3d5141d852 | ||
|
|
75832ca007 | ||
|
|
97f78c60b4 | ||
|
|
9295499405 | ||
|
|
6bcbd15ee6 | ||
|
|
36612ae42a | ||
|
|
68d207df94 | ||
|
|
d5502d602b | ||
|
|
37d524bb0a |
@@ -1979,6 +1979,24 @@ export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function FathomIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000' fill='none'>
|
||||
<path
|
||||
d='M0,668.7v205.78c0,53.97,34.24,102.88,85.8,119.08,87.48,27.49,167.88-36.99,167.88-120.22v-77.45L0,668.7Z'
|
||||
fill='#007299'
|
||||
/>
|
||||
<path
|
||||
d='M873.72,626.07c-19.05,0-38.38-4.3-56.58-13.38L72.78,241.43C11.15,210.69-17.51,136.6,11.18,74.05,41.2,8.59,119.26-18.53,183.23,13.38l744.25,371.21c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
|
||||
fill='#00beff'
|
||||
/>
|
||||
<path
|
||||
d='M500.09,813.66c-19.05,0-38.38-4.3-56.58-13.38l-370.72-184.9c-61.63-30.74-90.29-104.82-61.61-167.37,30.02-65.46,108.08-92.59,172.06-60.68l370.62,184.85c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
|
||||
fill='#00beff'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export function LinkupIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 154 107' fill='none'>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
EvernoteIcon,
|
||||
ExaAIIcon,
|
||||
EyeIcon,
|
||||
FathomIcon,
|
||||
FirecrawlIcon,
|
||||
FirefliesIcon,
|
||||
GammaIcon,
|
||||
@@ -206,6 +207,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
enrich: EnrichSoIcon,
|
||||
evernote: EvernoteIcon,
|
||||
exa: ExaAIIcon,
|
||||
fathom: FathomIcon,
|
||||
file_v3: DocumentIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
fireflies_v2: FirefliesIcon,
|
||||
|
||||
135
apps/docs/content/docs/en/tools/fathom.mdx
Normal file
135
apps/docs/content/docs/en/tools/fathom.mdx
Normal file
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: Fathom
|
||||
description: Access meeting recordings, transcripts, and summaries
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="fathom"
|
||||
color="#181C1E"
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Fathom AI Notetaker into your workflow. List meetings, get transcripts and summaries, and manage team members and teams. Can also trigger workflows when new meeting content is ready.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `fathom_list_meetings`
|
||||
|
||||
List recent meetings recorded by the user or shared to their team.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Fathom API Key |
|
||||
| `includeSummary` | string | No | Include meeting summary \(true/false\) |
|
||||
| `includeTranscript` | string | No | Include meeting transcript \(true/false\) |
|
||||
| `includeActionItems` | string | No | Include action items \(true/false\) |
|
||||
| `includeCrmMatches` | string | No | Include linked CRM matches \(true/false\) |
|
||||
| `createdAfter` | string | No | Filter meetings created after this ISO 8601 timestamp |
|
||||
| `createdBefore` | string | No | Filter meetings created before this ISO 8601 timestamp |
|
||||
| `recordedBy` | string | No | Filter by recorder email address |
|
||||
| `teams` | string | No | Filter by team name |
|
||||
| `cursor` | string | No | Pagination cursor from a previous response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `meetings` | array | List of meetings |
|
||||
| ↳ `title` | string | Meeting title |
|
||||
| ↳ `recording_id` | number | Unique recording ID |
|
||||
| ↳ `url` | string | URL to view the meeting |
|
||||
| ↳ `share_url` | string | Shareable URL |
|
||||
| ↳ `created_at` | string | Creation timestamp |
|
||||
| ↳ `transcript_language` | string | Transcript language |
|
||||
| `next_cursor` | string | Pagination cursor for next page |
|
||||
|
||||
### `fathom_get_summary`
|
||||
|
||||
Get the call summary for a specific meeting recording.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Fathom API Key |
|
||||
| `recordingId` | string | Yes | The recording ID of the meeting |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `template_name` | string | Name of the summary template used |
|
||||
| `markdown_formatted` | string | Markdown-formatted summary text |
|
||||
|
||||
### `fathom_get_transcript`
|
||||
|
||||
Get the full transcript for a specific meeting recording.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Fathom API Key |
|
||||
| `recordingId` | string | Yes | The recording ID of the meeting |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `transcript` | array | Array of transcript entries with speaker, text, and timestamp |
|
||||
| ↳ `speaker` | object | Speaker information |
|
||||
| ↳ `display_name` | string | Speaker display name |
|
||||
| ↳ `matched_calendar_invitee_email` | string | Matched calendar invitee email |
|
||||
| ↳ `text` | string | Transcript text |
|
||||
| ↳ `timestamp` | string | Timestamp \(HH:MM:SS\) |
|
||||
|
||||
### `fathom_list_team_members`
|
||||
|
||||
List team members in your Fathom organization.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Fathom API Key |
|
||||
| `teams` | string | No | Team name to filter by |
|
||||
| `cursor` | string | No | Pagination cursor from a previous response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `members` | array | List of team members |
|
||||
| ↳ `name` | string | Team member name |
|
||||
| ↳ `email` | string | Team member email |
|
||||
| ↳ `created_at` | string | Date the member was added |
|
||||
| `next_cursor` | string | Pagination cursor for next page |
|
||||
|
||||
### `fathom_list_teams`
|
||||
|
||||
List teams in your Fathom organization.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Fathom API Key |
|
||||
| `cursor` | string | No | Pagination cursor from a previous response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `teams` | array | List of teams |
|
||||
| ↳ `name` | string | Team name |
|
||||
| ↳ `created_at` | string | Date the team was created |
|
||||
| `next_cursor` | string | Pagination cursor for next page |
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"enrich",
|
||||
"evernote",
|
||||
"exa",
|
||||
"fathom",
|
||||
"file",
|
||||
"firecrawl",
|
||||
"fireflies",
|
||||
|
||||
@@ -590,6 +590,7 @@ List all users in a Slack workspace. Returns user profiles with names and avatar
|
||||
| ↳ `name` | string | Username \(handle\) |
|
||||
| ↳ `real_name` | string | Full real name |
|
||||
| ↳ `display_name` | string | Display name shown in Slack |
|
||||
| ↳ `email` | string | Email address \(requires users:read.email scope\) |
|
||||
| ↳ `is_bot` | boolean | Whether the user is a bot |
|
||||
| ↳ `is_admin` | boolean | Whether the user is a workspace admin |
|
||||
| ↳ `is_owner` | boolean | Whether the user is the workspace owner |
|
||||
@@ -629,6 +630,7 @@ Get detailed information about a specific Slack user by their user ID.
|
||||
| ↳ `title` | string | Job title |
|
||||
| ↳ `phone` | string | Phone number |
|
||||
| ↳ `skype` | string | Skype handle |
|
||||
| ↳ `email` | string | Email address \(requires users:read.email scope\) |
|
||||
| ↳ `is_bot` | boolean | Whether the user is a bot |
|
||||
| ↳ `is_admin` | boolean | Whether the user is a workspace admin |
|
||||
| ↳ `is_owner` | boolean | Whether the user is the workspace owner |
|
||||
|
||||
@@ -367,9 +367,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Configure each new webhook (for providers that need configuration)
|
||||
const pollingProviders = ['gmail', 'outlook']
|
||||
const needsConfiguration = pollingProviders.includes(provider)
|
||||
const needsConfiguration = provider === 'gmail' || provider === 'outlook'
|
||||
|
||||
if (needsConfiguration) {
|
||||
const configureFunc =
|
||||
|
||||
@@ -12,7 +12,7 @@ interface UseShiftSelectionLockResult {
|
||||
/** Computed ReactFlow props based on current selection state */
|
||||
selectionProps: {
|
||||
selectionOnDrag: boolean
|
||||
panOnDrag: [number, number] | false
|
||||
panOnDrag: number[]
|
||||
selectionKeyCode: string | null
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function useShiftSelectionLock({
|
||||
|
||||
const selectionProps = {
|
||||
selectionOnDrag: !isHandMode || isShiftSelecting,
|
||||
panOnDrag: (isHandMode && !isShiftSelecting ? [0, 1] : false) as [number, number] | false,
|
||||
panOnDrag: isHandMode && !isShiftSelecting ? [0, 1] : [1],
|
||||
selectionKeyCode: isShiftSelecting ? null : 'Shift',
|
||||
}
|
||||
|
||||
|
||||
211
apps/sim/blocks/blocks/fathom.ts
Normal file
211
apps/sim/blocks/blocks/fathom.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { FathomIcon } from '@/components/icons'
|
||||
import { AuthMode, type BlockConfig } from '@/blocks/types'
|
||||
import type { FathomResponse } from '@/tools/fathom/types'
|
||||
import { getTrigger } from '@/triggers'
|
||||
import { fathomTriggerOptions } from '@/triggers/fathom/utils'
|
||||
|
||||
export const FathomBlock: BlockConfig<FathomResponse> = {
|
||||
type: 'fathom',
|
||||
name: 'Fathom',
|
||||
description: 'Access meeting recordings, transcripts, and summaries',
|
||||
authMode: AuthMode.ApiKey,
|
||||
triggerAllowed: true,
|
||||
longDescription:
|
||||
'Integrate Fathom AI Notetaker into your workflow. List meetings, get transcripts and summaries, and manage team members and teams. Can also trigger workflows when new meeting content is ready.',
|
||||
docsLink: 'https://docs.sim.ai/tools/fathom',
|
||||
category: 'tools',
|
||||
bgColor: '#181C1E',
|
||||
icon: FathomIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'List Meetings', id: 'fathom_list_meetings' },
|
||||
{ label: 'Get Summary', id: 'fathom_get_summary' },
|
||||
{ label: 'Get Transcript', id: 'fathom_get_transcript' },
|
||||
{ label: 'List Team Members', id: 'fathom_list_team_members' },
|
||||
{ label: 'List Teams', id: 'fathom_list_teams' },
|
||||
],
|
||||
value: () => 'fathom_list_meetings',
|
||||
},
|
||||
{
|
||||
id: 'recordingId',
|
||||
title: 'Recording ID',
|
||||
type: 'short-input',
|
||||
required: { field: 'operation', value: ['fathom_get_summary', 'fathom_get_transcript'] },
|
||||
placeholder: 'Enter the recording ID',
|
||||
condition: { field: 'operation', value: ['fathom_get_summary', 'fathom_get_transcript'] },
|
||||
},
|
||||
{
|
||||
id: 'includeSummary',
|
||||
title: 'Include Summary',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'No', id: 'false' },
|
||||
{ label: 'Yes', id: 'true' },
|
||||
],
|
||||
value: () => 'false',
|
||||
condition: { field: 'operation', value: 'fathom_list_meetings' },
|
||||
},
|
||||
{
|
||||
id: 'includeTranscript',
|
||||
title: 'Include Transcript',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'No', id: 'false' },
|
||||
{ label: 'Yes', id: 'true' },
|
||||
],
|
||||
value: () => 'false',
|
||||
condition: { field: 'operation', value: 'fathom_list_meetings' },
|
||||
},
|
||||
{
|
||||
id: 'includeActionItems',
|
||||
title: 'Include Action Items',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'No', id: 'false' },
|
||||
{ label: 'Yes', id: 'true' },
|
||||
],
|
||||
value: () => 'false',
|
||||
condition: { field: 'operation', value: 'fathom_list_meetings' },
|
||||
},
|
||||
{
|
||||
id: 'includeCrmMatches',
|
||||
title: 'Include CRM Matches',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'No', id: 'false' },
|
||||
{ label: 'Yes', id: 'true' },
|
||||
],
|
||||
value: () => 'false',
|
||||
condition: { field: 'operation', value: 'fathom_list_meetings' },
|
||||
},
|
||||
{
|
||||
id: 'createdAfter',
|
||||
title: 'Created After',
|
||||
type: 'short-input',
|
||||
placeholder: 'ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)',
|
||||
condition: { field: 'operation', value: 'fathom_list_meetings' },
|
||||
mode: 'advanced',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'createdBefore',
|
||||
title: 'Created Before',
|
||||
type: 'short-input',
|
||||
placeholder: 'ISO 8601 timestamp (e.g., 2025-12-31T23:59:59Z)',
|
||||
condition: { field: 'operation', value: 'fathom_list_meetings' },
|
||||
mode: 'advanced',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'recordedBy',
|
||||
title: 'Recorded By',
|
||||
type: 'short-input',
|
||||
placeholder: 'Filter by recorder email',
|
||||
condition: { field: 'operation', value: 'fathom_list_meetings' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'teams',
|
||||
title: 'Team',
|
||||
type: 'short-input',
|
||||
placeholder: 'Filter by team name',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['fathom_list_meetings', 'fathom_list_team_members'],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
title: 'Pagination Cursor',
|
||||
type: 'short-input',
|
||||
placeholder: 'Cursor from a previous response',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['fathom_list_meetings', 'fathom_list_team_members', 'fathom_list_teams'],
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
required: true,
|
||||
placeholder: 'Enter your Fathom API key',
|
||||
password: true,
|
||||
},
|
||||
{
|
||||
id: 'selectedTriggerId',
|
||||
title: 'Trigger Type',
|
||||
type: 'dropdown',
|
||||
mode: 'trigger',
|
||||
options: fathomTriggerOptions,
|
||||
value: () => 'fathom_new_meeting',
|
||||
required: true,
|
||||
},
|
||||
...getTrigger('fathom_new_meeting').subBlocks,
|
||||
...getTrigger('fathom_webhook').subBlocks,
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'fathom_list_meetings',
|
||||
'fathom_get_summary',
|
||||
'fathom_get_transcript',
|
||||
'fathom_list_team_members',
|
||||
'fathom_list_teams',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
return params.operation || 'fathom_list_meetings'
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
apiKey: { type: 'string', description: 'Fathom API key' },
|
||||
recordingId: { type: 'string', description: 'Recording ID for summary or transcript' },
|
||||
includeSummary: { type: 'string', description: 'Include summary in meetings response' },
|
||||
includeTranscript: { type: 'string', description: 'Include transcript in meetings response' },
|
||||
includeActionItems: {
|
||||
type: 'string',
|
||||
description: 'Include action items in meetings response',
|
||||
},
|
||||
includeCrmMatches: {
|
||||
type: 'string',
|
||||
description: 'Include linked CRM matches in meetings response',
|
||||
},
|
||||
createdAfter: { type: 'string', description: 'Filter meetings created after this timestamp' },
|
||||
createdBefore: {
|
||||
type: 'string',
|
||||
description: 'Filter meetings created before this timestamp',
|
||||
},
|
||||
recordedBy: { type: 'string', description: 'Filter by recorder email' },
|
||||
teams: { type: 'string', description: 'Filter by team name' },
|
||||
cursor: { type: 'string', description: 'Pagination cursor for next page' },
|
||||
},
|
||||
outputs: {
|
||||
meetings: { type: 'json', description: 'List of meetings' },
|
||||
template_name: { type: 'string', description: 'Summary template name' },
|
||||
markdown_formatted: { type: 'string', description: 'Markdown-formatted summary' },
|
||||
transcript: { type: 'json', description: 'Meeting transcript entries' },
|
||||
members: { type: 'json', description: 'List of team members' },
|
||||
teams: { type: 'json', description: 'List of teams' },
|
||||
next_cursor: { type: 'string', description: 'Pagination cursor' },
|
||||
},
|
||||
triggers: {
|
||||
enabled: true,
|
||||
available: ['fathom_new_meeting', 'fathom_webhook'],
|
||||
},
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export const GenericWebhookBlock: BlockConfig = {
|
||||
bestPractices: `
|
||||
- You can test the webhook by sending a request to the webhook URL. E.g. depending on authorization: curl -X POST http://localhost:3000/api/webhooks/trigger/d8abcf0d-1ee5-4b77-bb07-b1e8142ea4e9 -H "Content-Type: application/json" -H "X-Sim-Secret: 1234" -d '{"message": "Test webhook trigger", "data": {"key": "v"}}'
|
||||
- Continuing example above, the body can be accessed in downstream block using dot notation. E.g. <webhook1.message> and <webhook1.data.key>
|
||||
- To deduplicate incoming events, set the Deduplication Field to a dot-notation path of a unique field in the payload (e.g. "event.id"). Duplicate values within 7 days will be skipped.
|
||||
- Only use when there's no existing integration for the service with triggerAllowed flag set to true.
|
||||
`,
|
||||
subBlocks: [...getTrigger('generic_webhook').subBlocks],
|
||||
|
||||
@@ -40,6 +40,7 @@ import { EnrichBlock } from '@/blocks/blocks/enrich'
|
||||
import { EvaluatorBlock } from '@/blocks/blocks/evaluator'
|
||||
import { EvernoteBlock } from '@/blocks/blocks/evernote'
|
||||
import { ExaBlock } from '@/blocks/blocks/exa'
|
||||
import { FathomBlock } from '@/blocks/blocks/fathom'
|
||||
import { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file'
|
||||
import { FirecrawlBlock } from '@/blocks/blocks/firecrawl'
|
||||
import { FirefliesBlock, FirefliesV2Block } from '@/blocks/blocks/fireflies'
|
||||
@@ -235,6 +236,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
dynamodb: DynamoDBBlock,
|
||||
elasticsearch: ElasticsearchBlock,
|
||||
elevenlabs: ElevenLabsBlock,
|
||||
fathom: FathomBlock,
|
||||
enrich: EnrichBlock,
|
||||
evernote: EvernoteBlock,
|
||||
evaluator: EvaluatorBlock,
|
||||
|
||||
@@ -1979,6 +1979,24 @@ export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function FathomIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000' fill='none'>
|
||||
<path
|
||||
d='M0,668.7v205.78c0,53.97,34.24,102.88,85.8,119.08,87.48,27.49,167.88-36.99,167.88-120.22v-77.45L0,668.7Z'
|
||||
fill='#007299'
|
||||
/>
|
||||
<path
|
||||
d='M873.72,626.07c-19.05,0-38.38-4.3-56.58-13.38L72.78,241.43C11.15,210.69-17.51,136.6,11.18,74.05,41.2,8.59,119.26-18.53,183.23,13.38l744.25,371.21c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
|
||||
fill='#00beff'
|
||||
/>
|
||||
<path
|
||||
d='M500.09,813.66c-19.05,0-38.38-4.3-56.58-13.38l-370.72-184.9c-61.63-30.74-90.29-104.82-61.61-167.37,30.02-65.46,108.08-92.59,172.06-60.68l370.62,184.85c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
|
||||
fill='#00beff'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export function LinkupIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 154 107' fill='none'>
|
||||
|
||||
@@ -166,7 +166,8 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
if (!output || typeof output !== 'object') {
|
||||
return output
|
||||
}
|
||||
const { _pauseMetadata, error, ...rest } = output
|
||||
const { _pauseMetadata, error, providerTiming, tokens, toolCalls, model, cost, ...rest } =
|
||||
output
|
||||
return rest
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export class TriggerBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
const existingState = ctx.blockStates.get(block.id)
|
||||
if (existingState?.output && Object.keys(existingState.output).length > 0) {
|
||||
if (existingState?.output) {
|
||||
return existingState.output
|
||||
}
|
||||
|
||||
|
||||
@@ -492,7 +492,7 @@ export const auth = betterAuth({
|
||||
'google-meet',
|
||||
'google-tasks',
|
||||
'vertex-ai',
|
||||
'github-repo',
|
||||
|
||||
'microsoft-dataverse',
|
||||
'microsoft-teams',
|
||||
'microsoft-excel',
|
||||
@@ -754,83 +754,6 @@ export const auth = betterAuth({
|
||||
}),
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: 'github-repo',
|
||||
clientId: env.GITHUB_REPO_CLIENT_ID as string,
|
||||
clientSecret: env.GITHUB_REPO_CLIENT_SECRET as string,
|
||||
authorizationUrl: 'https://github.com/login/oauth/authorize',
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
tokenUrl: 'https://github.com/login/oauth/access_token',
|
||||
userInfoUrl: 'https://api.github.com/user',
|
||||
scopes: getCanonicalScopesForProvider('github-repo'),
|
||||
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/github-repo`,
|
||||
getUserInfo: async (tokens) => {
|
||||
try {
|
||||
const profileResponse = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
'User-Agent': 'sim-studio',
|
||||
},
|
||||
})
|
||||
|
||||
if (!profileResponse.ok) {
|
||||
await profileResponse.text().catch(() => {})
|
||||
logger.error('Failed to fetch GitHub profile', {
|
||||
status: profileResponse.status,
|
||||
statusText: profileResponse.statusText,
|
||||
})
|
||||
throw new Error(`Failed to fetch GitHub profile: ${profileResponse.statusText}`)
|
||||
}
|
||||
|
||||
const profile = await profileResponse.json()
|
||||
|
||||
if (!profile.email) {
|
||||
const emailsResponse = await fetch('https://api.github.com/user/emails', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
'User-Agent': 'sim-studio',
|
||||
},
|
||||
})
|
||||
|
||||
if (emailsResponse.ok) {
|
||||
const emails = await emailsResponse.json()
|
||||
|
||||
const primaryEmail =
|
||||
emails.find(
|
||||
(email: { primary: boolean; email: string; verified: boolean }) =>
|
||||
email.primary
|
||||
) || emails[0]
|
||||
if (primaryEmail) {
|
||||
profile.email = primaryEmail.email
|
||||
profile.emailVerified = primaryEmail.verified || false
|
||||
}
|
||||
} else {
|
||||
logger.warn('Failed to fetch GitHub emails', {
|
||||
status: emailsResponse.status,
|
||||
statusText: emailsResponse.statusText,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: `${profile.id.toString()}-${crypto.randomUUID()}`,
|
||||
name: profile.name || profile.login,
|
||||
email: profile.email,
|
||||
image: profile.avatar_url,
|
||||
emailVerified: profile.emailVerified || false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in GitHub getUserInfo', { error })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Google providers
|
||||
{
|
||||
providerId: 'google-email',
|
||||
|
||||
@@ -7,6 +7,7 @@ const logger = createLogger('AsyncJobsConfig')
|
||||
|
||||
let cachedBackend: JobQueueBackend | null = null
|
||||
let cachedBackendType: AsyncBackendType | null = null
|
||||
let cachedInlineBackend: JobQueueBackend | null = null
|
||||
|
||||
/**
|
||||
* Determines which async backend to use based on environment configuration.
|
||||
@@ -71,6 +72,31 @@ export function getCurrentBackendType(): AsyncBackendType | null {
|
||||
return cachedBackendType
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a job queue backend that bypasses Trigger.dev (Redis -> Database).
|
||||
* Used for non-polling webhooks that should always execute inline.
|
||||
*/
|
||||
export async function getInlineJobQueue(): Promise<JobQueueBackend> {
|
||||
if (cachedInlineBackend) {
|
||||
return cachedInlineBackend
|
||||
}
|
||||
|
||||
const redis = getRedisClient()
|
||||
let type: string
|
||||
if (redis) {
|
||||
const { RedisJobQueue } = await import('@/lib/core/async-jobs/backends/redis')
|
||||
cachedInlineBackend = new RedisJobQueue(redis)
|
||||
type = 'redis'
|
||||
} else {
|
||||
const { DatabaseJobQueue } = await import('@/lib/core/async-jobs/backends/database')
|
||||
cachedInlineBackend = new DatabaseJobQueue()
|
||||
type = 'database'
|
||||
}
|
||||
|
||||
logger.info(`Inline job backend initialized: ${type}`)
|
||||
return cachedInlineBackend
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if jobs should be executed inline (fire-and-forget).
|
||||
* For Redis/DB backends, we execute inline. Trigger.dev handles execution itself.
|
||||
@@ -85,4 +111,5 @@ export function shouldExecuteInline(): boolean {
|
||||
export function resetJobQueueCache(): void {
|
||||
cachedBackend = null
|
||||
cachedBackendType = null
|
||||
cachedInlineBackend = null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
getAsyncBackendType,
|
||||
getCurrentBackendType,
|
||||
getInlineJobQueue,
|
||||
getJobQueue,
|
||||
resetJobQueueCache,
|
||||
shouldExecuteInline,
|
||||
|
||||
@@ -230,8 +230,7 @@ export const env = createEnv({
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(), // Google OAuth client secret
|
||||
GITHUB_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for GitHub integration
|
||||
GITHUB_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret
|
||||
GITHUB_REPO_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for repo access
|
||||
GITHUB_REPO_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret for repo access
|
||||
|
||||
X_CLIENT_ID: z.string().optional(), // X (Twitter) OAuth client ID
|
||||
X_CLIENT_SECRET: z.string().optional(), // X (Twitter) OAuth client secret
|
||||
CONFLUENCE_CLIENT_ID: z.string().optional(), // Atlassian Confluence OAuth client ID
|
||||
|
||||
@@ -413,6 +413,7 @@ export class IdempotencyService {
|
||||
: undefined
|
||||
|
||||
const webhookIdHeader =
|
||||
normalizedHeaders?.['x-sim-idempotency-key'] ||
|
||||
normalizedHeaders?.['webhook-id'] ||
|
||||
normalizedHeaders?.['x-webhook-id'] ||
|
||||
normalizedHeaders?.['x-shopify-webhook-id'] ||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { ToolCall, TraceSpan } from '@/lib/logs/types'
|
||||
import { isWorkflowBlockType, stripCustomToolPrefix } from '@/executor/constants'
|
||||
import {
|
||||
isConditionBlockType,
|
||||
isWorkflowBlockType,
|
||||
stripCustomToolPrefix,
|
||||
} from '@/executor/constants'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
import { stripCloneSuffixes } from '@/executor/utils/subflow-utils'
|
||||
|
||||
@@ -109,6 +113,7 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
if (!log.blockId || !log.blockType) return
|
||||
|
||||
const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}`
|
||||
const isCondition = isConditionBlockType(log.blockType)
|
||||
|
||||
const duration = log.durationMs || 0
|
||||
|
||||
@@ -164,7 +169,7 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
...(log.parentIterations?.length && { parentIterations: log.parentIterations }),
|
||||
}
|
||||
|
||||
if (log.output?.providerTiming) {
|
||||
if (!isCondition && log.output?.providerTiming) {
|
||||
const providerTiming = log.output.providerTiming as {
|
||||
duration: number
|
||||
startTime: string
|
||||
@@ -186,7 +191,7 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
}
|
||||
}
|
||||
|
||||
if (log.output?.cost) {
|
||||
if (!isCondition && log.output?.cost) {
|
||||
span.cost = log.output.cost as {
|
||||
input?: number
|
||||
output?: number
|
||||
@@ -194,7 +199,7 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
}
|
||||
}
|
||||
|
||||
if (log.output?.tokens) {
|
||||
if (!isCondition && log.output?.tokens) {
|
||||
const t = log.output.tokens as
|
||||
| number
|
||||
| {
|
||||
@@ -224,12 +229,13 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
}
|
||||
}
|
||||
|
||||
if (log.output?.model) {
|
||||
if (!isCondition && log.output?.model) {
|
||||
span.model = log.output.model as string
|
||||
}
|
||||
|
||||
if (
|
||||
!isWorkflowBlockType(log.blockType) &&
|
||||
!isCondition &&
|
||||
log.output?.providerTiming?.timeSegments &&
|
||||
Array.isArray(log.output.providerTiming.timeSegments)
|
||||
) {
|
||||
@@ -317,7 +323,7 @@ export function buildTraceSpans(result: ExecutionResult): {
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
} else if (!isCondition) {
|
||||
let toolCallsList = null
|
||||
|
||||
try {
|
||||
|
||||
@@ -170,11 +170,6 @@ describe('OAuth Token Refresh', () => {
|
||||
describe('Body Credential Providers', () => {
|
||||
const bodyCredentialProviders = [
|
||||
{ name: 'Google', providerId: 'google', endpoint: 'https://oauth2.googleapis.com/token' },
|
||||
{
|
||||
name: 'GitHub',
|
||||
providerId: 'github',
|
||||
endpoint: 'https://github.com/login/oauth/access_token',
|
||||
},
|
||||
{
|
||||
name: 'Microsoft',
|
||||
providerId: 'microsoft',
|
||||
@@ -279,19 +274,6 @@ describe('OAuth Token Refresh', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should include Accept header for GitHub requests', async () => {
|
||||
const mockFetch = createMockFetch(defaultOAuthResponse)
|
||||
const refreshToken = 'test_refresh_token'
|
||||
|
||||
await withMockFetch(mockFetch, () => refreshOAuthToken('github', refreshToken))
|
||||
|
||||
const [, requestOptions] = mockFetch.mock.calls[0] as [
|
||||
string,
|
||||
{ headers: Record<string, string>; body: string },
|
||||
]
|
||||
expect(requestOptions.headers.Accept).toBe('application/json')
|
||||
})
|
||||
|
||||
it.concurrent('should include User-Agent header for Reddit requests', async () => {
|
||||
const mockFetch = createMockFetch(defaultOAuthResponse)
|
||||
const refreshToken = 'test_refresh_token'
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
CalComIcon,
|
||||
ConfluenceIcon,
|
||||
DropboxIcon,
|
||||
GithubIcon,
|
||||
GmailIcon,
|
||||
GoogleBigQueryIcon,
|
||||
GoogleCalendarIcon,
|
||||
@@ -340,21 +339,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
},
|
||||
defaultService: 'outlook',
|
||||
},
|
||||
github: {
|
||||
name: 'GitHub',
|
||||
icon: GithubIcon,
|
||||
services: {
|
||||
github: {
|
||||
name: 'GitHub',
|
||||
description: 'Manage repositories, issues, and pull requests.',
|
||||
providerId: 'github-repo',
|
||||
icon: GithubIcon,
|
||||
baseProviderIcon: GithubIcon,
|
||||
scopes: ['repo', 'user:email', 'read:user', 'workflow'],
|
||||
},
|
||||
},
|
||||
defaultService: 'github',
|
||||
},
|
||||
x: {
|
||||
name: 'X',
|
||||
icon: xIcon,
|
||||
@@ -474,6 +458,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'read:comment:jira',
|
||||
'delete:comment:jira',
|
||||
'read:attachment:jira',
|
||||
'write:attachment:jira',
|
||||
'delete:attachment:jira',
|
||||
'write:issue-worklog:jira',
|
||||
'read:issue-worklog:jira',
|
||||
@@ -639,6 +624,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'im:history',
|
||||
'im:read',
|
||||
'users:read',
|
||||
// TODO: Add 'users:read.email' once Slack app review is approved
|
||||
'files:write',
|
||||
'files:read',
|
||||
'canvases:write',
|
||||
@@ -987,19 +973,6 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
||||
useBasicAuth: false,
|
||||
}
|
||||
}
|
||||
case 'github': {
|
||||
const { clientId, clientSecret } = getCredentials(
|
||||
env.GITHUB_CLIENT_ID,
|
||||
env.GITHUB_CLIENT_SECRET
|
||||
)
|
||||
return {
|
||||
tokenEndpoint: 'https://github.com/login/oauth/access_token',
|
||||
clientId,
|
||||
clientSecret,
|
||||
useBasicAuth: false,
|
||||
additionalHeaders: { Accept: 'application/json' },
|
||||
}
|
||||
}
|
||||
case 'x': {
|
||||
const { clientId, clientSecret } = getCredentials(env.X_CLIENT_ID, env.X_CLIENT_SECRET)
|
||||
return {
|
||||
|
||||
@@ -15,8 +15,6 @@ export type OAuthProvider =
|
||||
| 'google-groups'
|
||||
| 'google-meet'
|
||||
| 'vertex-ai'
|
||||
| 'github'
|
||||
| 'github-repo'
|
||||
| 'x'
|
||||
| 'confluence'
|
||||
| 'airtable'
|
||||
@@ -64,7 +62,6 @@ export type OAuthService =
|
||||
| 'google-groups'
|
||||
| 'google-meet'
|
||||
| 'vertex-ai'
|
||||
| 'github'
|
||||
| 'x'
|
||||
| 'confluence'
|
||||
| 'airtable'
|
||||
|
||||
@@ -66,11 +66,6 @@ describe('getAllOAuthServices', () => {
|
||||
it.concurrent('should include single-service providers', () => {
|
||||
const services = getAllOAuthServices()
|
||||
|
||||
const githubService = services.find((s) => s.providerId === 'github-repo')
|
||||
expect(githubService).toBeDefined()
|
||||
expect(githubService?.name).toBe('GitHub')
|
||||
expect(githubService?.baseProvider).toBe('github')
|
||||
|
||||
const slackService = services.find((s) => s.providerId === 'slack')
|
||||
expect(slackService).toBeDefined()
|
||||
expect(slackService?.name).toBe('Slack')
|
||||
@@ -145,14 +140,6 @@ describe('getServiceByProviderAndId', () => {
|
||||
expect(service.name).toBe('Microsoft Excel')
|
||||
})
|
||||
|
||||
it.concurrent('should work with single-service providers', () => {
|
||||
const service = getServiceByProviderAndId('github')
|
||||
|
||||
expect(service).toBeDefined()
|
||||
expect(service.providerId).toBe('github-repo')
|
||||
expect(service.name).toBe('GitHub')
|
||||
})
|
||||
|
||||
it.concurrent('should include scopes in returned service config', () => {
|
||||
const service = getServiceByProviderAndId('google', 'gmail')
|
||||
|
||||
@@ -182,12 +169,6 @@ describe('getProviderIdFromServiceId', () => {
|
||||
expect(providerId).toBe('outlook')
|
||||
})
|
||||
|
||||
it.concurrent('should return correct providerId for GitHub', () => {
|
||||
const providerId = getProviderIdFromServiceId('github')
|
||||
|
||||
expect(providerId).toBe('github-repo')
|
||||
})
|
||||
|
||||
it.concurrent('should return correct providerId for Microsoft Excel', () => {
|
||||
const providerId = getProviderIdFromServiceId('microsoft-excel')
|
||||
|
||||
@@ -262,14 +243,6 @@ describe('getServiceConfigByProviderId', () => {
|
||||
expect(excelService?.name).toBe('Microsoft Excel')
|
||||
})
|
||||
|
||||
it.concurrent('should work for GitHub', () => {
|
||||
const service = getServiceConfigByProviderId('github-repo')
|
||||
|
||||
expect(service).toBeDefined()
|
||||
expect(service?.providerId).toBe('github-repo')
|
||||
expect(service?.name).toBe('GitHub')
|
||||
})
|
||||
|
||||
it.concurrent('should work for Slack', () => {
|
||||
const service = getServiceConfigByProviderId('slack')
|
||||
|
||||
@@ -338,14 +311,6 @@ describe('getCanonicalScopesForProvider', () => {
|
||||
expect(excelScopes).toContain('Files.Read')
|
||||
})
|
||||
|
||||
it.concurrent('should return scopes for GitHub', () => {
|
||||
const scopes = getCanonicalScopesForProvider('github-repo')
|
||||
|
||||
expect(scopes.length).toBeGreaterThan(0)
|
||||
expect(scopes).toContain('repo')
|
||||
expect(scopes).toContain('user:email')
|
||||
})
|
||||
|
||||
it.concurrent('should handle providers with empty scopes array', () => {
|
||||
const scopes = getCanonicalScopesForProvider('notion')
|
||||
|
||||
@@ -397,13 +362,6 @@ describe('parseProvider', () => {
|
||||
expect(teamsConfig.featureType).toBe('microsoft-teams')
|
||||
})
|
||||
|
||||
it.concurrent('should parse GitHub provider', () => {
|
||||
const config = parseProvider('github-repo' as OAuthProvider)
|
||||
|
||||
expect(config.baseProvider).toBe('github')
|
||||
expect(config.featureType).toBe('github')
|
||||
})
|
||||
|
||||
it.concurrent('should parse Slack provider', () => {
|
||||
const config = parseProvider('slack' as OAuthProvider)
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'read:comment:jira': 'Read comments on Jira issues',
|
||||
'delete:comment:jira': 'Delete comments from Jira issues',
|
||||
'read:attachment:jira': 'Read attachments from Jira issues',
|
||||
'write:attachment:jira': 'Add attachments to Jira issues',
|
||||
'delete:attachment:jira': 'Delete attachments from Jira issues',
|
||||
'write:issue-worklog:jira': 'Add and update worklog entries on Jira issues',
|
||||
'read:issue-worklog:jira': 'Read worklog entries from Jira issues',
|
||||
@@ -269,6 +270,7 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'im:history': 'Read direct message history',
|
||||
'im:read': 'View direct message channels',
|
||||
'users:read': 'View workspace users',
|
||||
'users:read.email': 'View user email addresses',
|
||||
'files:write': 'Upload files',
|
||||
'files:read': 'Download and read files',
|
||||
'canvases:write': 'Create canvas documents',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { and, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { checkEnterprisePlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
|
||||
import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { executeWebhookJob } from '@/background/webhook-execution'
|
||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||
import { isConfluencePayloadMatch } from '@/triggers/confluence/utils'
|
||||
import { isPollingWebhookProvider } from '@/triggers/constants'
|
||||
import { isGitHubEventMatch } from '@/triggers/github/utils'
|
||||
import { isHubSpotContactEventMatch } from '@/triggers/hubspot/utils'
|
||||
import { isJiraEventMatch } from '@/triggers/jira/utils'
|
||||
@@ -1049,7 +1050,7 @@ export async function queueWebhookExecution(
|
||||
}
|
||||
}
|
||||
|
||||
const headers = Object.fromEntries(request.headers.entries())
|
||||
const { 'x-sim-idempotency-key': _, ...headers } = Object.fromEntries(request.headers.entries())
|
||||
|
||||
// For Microsoft Teams Graph notifications, extract unique identifiers for idempotency
|
||||
if (
|
||||
@@ -1067,9 +1068,20 @@ export async function queueWebhookExecution(
|
||||
}
|
||||
}
|
||||
|
||||
// Extract credentialId from webhook config
|
||||
// Note: Each webhook now has its own credentialId (credential sets are fanned out at save time)
|
||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||
|
||||
if (foundWebhook.provider === 'generic') {
|
||||
const idempotencyField = providerConfig.idempotencyField as string | undefined
|
||||
if (idempotencyField && body) {
|
||||
const value = idempotencyField
|
||||
.split('.')
|
||||
.reduce((acc: any, key: string) => acc?.[key], body)
|
||||
if (value !== undefined && value !== null && typeof value !== 'object') {
|
||||
headers['x-sim-idempotency-key'] = String(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const credentialId = providerConfig.credentialId as string | undefined
|
||||
|
||||
// credentialSetId is a direct field on webhook table, not in providerConfig
|
||||
@@ -1105,15 +1117,24 @@ export async function queueWebhookExecution(
|
||||
...(credentialId ? { credentialId } : {}),
|
||||
}
|
||||
|
||||
const jobQueue = await getJobQueue()
|
||||
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
|
||||
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
|
||||
})
|
||||
logger.info(
|
||||
`[${options.requestId}] Queued webhook execution task ${jobId} for ${foundWebhook.provider} webhook`
|
||||
)
|
||||
const isPolling = isPollingWebhookProvider(payload.provider)
|
||||
|
||||
if (shouldExecuteInline()) {
|
||||
if (isPolling && !shouldExecuteInline()) {
|
||||
const jobQueue = await getJobQueue()
|
||||
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
|
||||
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
|
||||
})
|
||||
logger.info(
|
||||
`[${options.requestId}] Queued polling webhook execution task ${jobId} for ${foundWebhook.provider} webhook via job queue`
|
||||
)
|
||||
} else {
|
||||
const jobQueue = await getInlineJobQueue()
|
||||
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
|
||||
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
|
||||
})
|
||||
logger.info(
|
||||
`[${options.requestId}] Executing ${foundWebhook.provider} webhook ${jobId} inline`
|
||||
)
|
||||
void (async () => {
|
||||
try {
|
||||
await jobQueue.startJob(jobId)
|
||||
@@ -1193,6 +1214,26 @@ export async function queueWebhookExecution(
|
||||
})
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'generic' && providerConfig.responseMode === 'custom') {
|
||||
const rawCode = Number(providerConfig.responseStatusCode) || 200
|
||||
const statusCode = rawCode >= 100 && rawCode <= 599 ? rawCode : 200
|
||||
const responseBody = (providerConfig.responseBody as string | undefined)?.trim()
|
||||
|
||||
if (!responseBody) {
|
||||
return new NextResponse(null, { status: statusCode })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(responseBody)
|
||||
return NextResponse.json(parsed, { status: statusCode })
|
||||
} catch {
|
||||
return new NextResponse(responseBody, {
|
||||
status: statusCode,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Webhook processed' })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error)
|
||||
|
||||
@@ -17,6 +17,7 @@ const airtableLogger = createLogger('AirtableWebhook')
|
||||
const typeformLogger = createLogger('TypeformWebhook')
|
||||
const calendlyLogger = createLogger('CalendlyWebhook')
|
||||
const grainLogger = createLogger('GrainWebhook')
|
||||
const fathomLogger = createLogger('FathomWebhook')
|
||||
const lemlistLogger = createLogger('LemlistWebhook')
|
||||
const webflowLogger = createLogger('WebflowWebhook')
|
||||
const attioLogger = createLogger('AttioWebhook')
|
||||
@@ -792,6 +793,60 @@ export async function deleteGrainWebhook(webhook: any, requestId: string): Promi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Fathom webhook
|
||||
* Don't fail webhook deletion if cleanup fails
|
||||
*/
|
||||
export async function deleteFathomWebhook(webhook: any, requestId: string): Promise<void> {
|
||||
try {
|
||||
const config = getProviderConfig(webhook)
|
||||
const apiKey = config.apiKey as string | undefined
|
||||
const externalId = config.externalId as string | undefined
|
||||
|
||||
if (!apiKey) {
|
||||
fathomLogger.warn(
|
||||
`[${requestId}] Missing apiKey for Fathom webhook deletion ${webhook.id}, skipping cleanup`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!externalId) {
|
||||
fathomLogger.warn(
|
||||
`[${requestId}] Missing externalId for Fathom webhook deletion ${webhook.id}, skipping cleanup`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const idValidation = validateAlphanumericId(externalId, 'Fathom webhook ID', 100)
|
||||
if (!idValidation.isValid) {
|
||||
fathomLogger.warn(
|
||||
`[${requestId}] Invalid externalId format for Fathom webhook deletion ${webhook.id}, skipping cleanup`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const fathomApiUrl = `https://api.fathom.ai/external/v1/webhooks/${externalId}`
|
||||
|
||||
const fathomResponse = await fetch(fathomApiUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Api-Key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!fathomResponse.ok && fathomResponse.status !== 404) {
|
||||
fathomLogger.warn(
|
||||
`[${requestId}] Failed to delete Fathom webhook (non-fatal): ${fathomResponse.status}`
|
||||
)
|
||||
} else {
|
||||
fathomLogger.info(`[${requestId}] Successfully deleted Fathom webhook ${externalId}`)
|
||||
}
|
||||
} catch (error) {
|
||||
fathomLogger.warn(`[${requestId}] Error deleting Fathom webhook (non-fatal)`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Lemlist webhook
|
||||
* Don't fail webhook deletion if cleanup fails
|
||||
@@ -1314,6 +1369,116 @@ export async function createGrainWebhookSubscription(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFathomWebhookSubscription(
|
||||
_request: NextRequest,
|
||||
webhookData: any,
|
||||
requestId: string
|
||||
): Promise<{ id: string } | undefined> {
|
||||
try {
|
||||
const { path, providerConfig } = webhookData
|
||||
const {
|
||||
apiKey,
|
||||
triggerId,
|
||||
triggeredFor,
|
||||
includeSummary,
|
||||
includeTranscript,
|
||||
includeActionItems,
|
||||
includeCrmMatches,
|
||||
} = providerConfig || {}
|
||||
|
||||
if (!apiKey) {
|
||||
fathomLogger.warn(`[${requestId}] Missing apiKey for Fathom webhook creation.`, {
|
||||
webhookId: webhookData.id,
|
||||
})
|
||||
throw new Error(
|
||||
'Fathom API Key is required. Please provide your API key in the trigger configuration.'
|
||||
)
|
||||
}
|
||||
|
||||
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
|
||||
|
||||
const triggeredForValue = triggeredFor || 'my_recordings'
|
||||
|
||||
const toBool = (val: unknown, fallback: boolean): boolean => {
|
||||
if (val === undefined) return fallback
|
||||
return val === true || val === 'true'
|
||||
}
|
||||
|
||||
const requestBody: Record<string, any> = {
|
||||
destination_url: notificationUrl,
|
||||
triggered_for: [triggeredForValue],
|
||||
include_summary: toBool(includeSummary, true),
|
||||
include_transcript: toBool(includeTranscript, false),
|
||||
include_action_items: toBool(includeActionItems, false),
|
||||
include_crm_matches: toBool(includeCrmMatches, false),
|
||||
}
|
||||
|
||||
fathomLogger.info(`[${requestId}] Creating Fathom webhook`, {
|
||||
triggerId,
|
||||
triggeredFor: triggeredForValue,
|
||||
webhookId: webhookData.id,
|
||||
})
|
||||
|
||||
const fathomResponse = await fetch('https://api.fathom.ai/external/v1/webhooks', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Api-Key': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
const responseBody = await fathomResponse.json().catch(() => ({}))
|
||||
|
||||
if (!fathomResponse.ok) {
|
||||
const errorMessage =
|
||||
(responseBody as Record<string, string>).message ||
|
||||
(responseBody as Record<string, string>).error ||
|
||||
'Unknown Fathom API error'
|
||||
fathomLogger.error(
|
||||
`[${requestId}] Failed to create webhook in Fathom for webhook ${webhookData.id}. Status: ${fathomResponse.status}`,
|
||||
{ message: errorMessage, response: responseBody }
|
||||
)
|
||||
|
||||
let userFriendlyMessage = 'Failed to create webhook subscription in Fathom'
|
||||
if (fathomResponse.status === 401) {
|
||||
userFriendlyMessage = 'Invalid Fathom API Key. Please verify your key is correct.'
|
||||
} else if (fathomResponse.status === 400) {
|
||||
userFriendlyMessage = `Fathom error: ${errorMessage}`
|
||||
} else if (errorMessage && errorMessage !== 'Unknown Fathom API error') {
|
||||
userFriendlyMessage = `Fathom error: ${errorMessage}`
|
||||
}
|
||||
|
||||
throw new Error(userFriendlyMessage)
|
||||
}
|
||||
|
||||
if (!responseBody.id) {
|
||||
fathomLogger.error(
|
||||
`[${requestId}] Fathom webhook creation returned success but no webhook ID for ${webhookData.id}.`
|
||||
)
|
||||
throw new Error('Fathom webhook created but no ID returned. Please try again.')
|
||||
}
|
||||
|
||||
fathomLogger.info(
|
||||
`[${requestId}] Successfully created webhook in Fathom for webhook ${webhookData.id}.`,
|
||||
{
|
||||
fathomWebhookId: responseBody.id,
|
||||
}
|
||||
)
|
||||
|
||||
return { id: responseBody.id }
|
||||
} catch (error: any) {
|
||||
fathomLogger.error(
|
||||
`[${requestId}] Exception during Fathom webhook creation for webhook ${webhookData.id}.`,
|
||||
{
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLemlistWebhookSubscription(
|
||||
webhookData: any,
|
||||
requestId: string
|
||||
@@ -1811,6 +1976,7 @@ const PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS = new Set([
|
||||
'airtable',
|
||||
'attio',
|
||||
'calendly',
|
||||
'fathom',
|
||||
'webflow',
|
||||
'typeform',
|
||||
'grain',
|
||||
@@ -1923,6 +2089,12 @@ export async function createExternalWebhookSubscription(
|
||||
updatedProviderConfig = { ...updatedProviderConfig, webhookTag: usedTag }
|
||||
}
|
||||
externalSubscriptionCreated = true
|
||||
} else if (provider === 'fathom') {
|
||||
const result = await createFathomWebhookSubscription(request, webhookData, requestId)
|
||||
if (result) {
|
||||
updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id }
|
||||
externalSubscriptionCreated = true
|
||||
}
|
||||
} else if (provider === 'grain') {
|
||||
const result = await createGrainWebhookSubscription(request, webhookData, requestId)
|
||||
if (result) {
|
||||
@@ -1968,6 +2140,8 @@ export async function cleanupExternalWebhook(
|
||||
await deleteCalendlyWebhook(webhook, requestId)
|
||||
} else if (webhook.provider === 'webflow') {
|
||||
await deleteWebflowWebhook(webhook, workflow, requestId)
|
||||
} else if (webhook.provider === 'fathom') {
|
||||
await deleteFathomWebhook(webhook, requestId)
|
||||
} else if (webhook.provider === 'grain') {
|
||||
await deleteGrainWebhook(webhook, requestId)
|
||||
} else if (webhook.provider === 'lemlist') {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
refreshAccessTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
import { isPollingWebhookProvider } from '@/triggers/constants'
|
||||
|
||||
const logger = createLogger('WebhookUtils')
|
||||
|
||||
@@ -2222,10 +2223,7 @@ export async function syncWebhooksForCredentialSet(params: {
|
||||
`[${requestId}] Syncing webhooks for credential set ${credentialSetId}, provider ${provider}`
|
||||
)
|
||||
|
||||
// Polling providers get unique paths per credential (for independent state)
|
||||
// External webhook providers share the same path (external service sends to one URL)
|
||||
const pollingProviders = ['gmail', 'outlook', 'rss', 'imap']
|
||||
const useUniquePaths = pollingProviders.includes(provider)
|
||||
const useUniquePaths = isPollingWebhookProvider(provider)
|
||||
|
||||
const credentials = await getCredentialsForCredentialSet(credentialSetId, oauthProviderId)
|
||||
|
||||
|
||||
@@ -433,7 +433,7 @@ describe('hasWorkflowChanged', () => {
|
||||
expect(hasWorkflowChanged(state1, state2)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should detect subBlock type changes', () => {
|
||||
it.concurrent('should ignore subBlock type changes', () => {
|
||||
const state1 = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1', {
|
||||
@@ -448,7 +448,7 @@ describe('hasWorkflowChanged', () => {
|
||||
}),
|
||||
},
|
||||
})
|
||||
expect(hasWorkflowChanged(state1, state2)).toBe(true)
|
||||
expect(hasWorkflowChanged(state1, state2)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should handle null/undefined subBlock values consistently', () => {
|
||||
|
||||
@@ -496,7 +496,14 @@ export function normalizeSubBlockValue(subBlockId: string, value: unknown): unkn
|
||||
* @returns SubBlock fields excluding value and is_diff
|
||||
*/
|
||||
export function extractSubBlockRest(subBlock: Record<string, unknown>): Record<string, unknown> {
|
||||
const { value: _v, is_diff: _sd, ...rest } = subBlock as SubBlockWithDiffMarker
|
||||
const {
|
||||
value: _v,
|
||||
is_diff: _sd,
|
||||
type: _type,
|
||||
...rest
|
||||
} = subBlock as SubBlockWithDiffMarker & {
|
||||
type?: unknown
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
|
||||
57
apps/sim/lib/workflows/condition-ids.ts
Normal file
57
apps/sim/lib/workflows/condition-ids.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { EDGE } from '@/executor/constants'
|
||||
|
||||
/**
|
||||
* Remaps condition/router block IDs in a parsed conditions array.
|
||||
* Condition IDs use the format `{blockId}-{suffix}` and must be updated
|
||||
* when a block is duplicated to reference the new block ID.
|
||||
*
|
||||
* @param conditions - Parsed array of condition block objects with `id` fields
|
||||
* @param oldBlockId - The original block ID prefix to replace
|
||||
* @param newBlockId - The new block ID prefix
|
||||
* @returns Whether any IDs were changed (mutates in place)
|
||||
*/
|
||||
export function remapConditionBlockIds(
|
||||
conditions: Array<{ id: string; [key: string]: unknown }>,
|
||||
oldBlockId: string,
|
||||
newBlockId: string
|
||||
): boolean {
|
||||
let changed = false
|
||||
const prefix = `${oldBlockId}-`
|
||||
for (const condition of conditions) {
|
||||
if (typeof condition.id === 'string' && condition.id.startsWith(prefix)) {
|
||||
const suffix = condition.id.slice(prefix.length)
|
||||
condition.id = `${newBlockId}-${suffix}`
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/** Handle prefixes that embed block-scoped condition/route IDs */
|
||||
const HANDLE_PREFIXES = [EDGE.CONDITION_PREFIX, EDGE.ROUTER_PREFIX] as const
|
||||
|
||||
/**
|
||||
* Remaps a condition or router edge sourceHandle from the old block ID to the new one.
|
||||
* Handle formats:
|
||||
* - Condition: `condition-{blockId}-{suffix}`
|
||||
* - Router V2: `router-{blockId}-{suffix}`
|
||||
*
|
||||
* @returns The remapped handle string, or the original if no remapping needed
|
||||
*/
|
||||
export function remapConditionEdgeHandle(
|
||||
sourceHandle: string,
|
||||
oldBlockId: string,
|
||||
newBlockId: string
|
||||
): string {
|
||||
for (const handlePrefix of HANDLE_PREFIXES) {
|
||||
if (!sourceHandle.startsWith(handlePrefix)) continue
|
||||
|
||||
const innerId = sourceHandle.slice(handlePrefix.length)
|
||||
if (!innerId.startsWith(`${oldBlockId}-`)) continue
|
||||
|
||||
const suffix = innerId.slice(oldBlockId.length + 1)
|
||||
return `${handlePrefix}${newBlockId}-${suffix}`
|
||||
}
|
||||
|
||||
return sourceHandle
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull, min } from 'drizzle-orm'
|
||||
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import type { Variable } from '@/stores/panel/variables/types'
|
||||
@@ -77,6 +78,40 @@ function remapVariableIdsInSubBlocks(
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps condition/router block IDs within subBlocks when a block is duplicated.
|
||||
* Returns a new object without mutating the input.
|
||||
*/
|
||||
function remapConditionIdsInSubBlocks(
|
||||
subBlocks: Record<string, any>,
|
||||
oldBlockId: string,
|
||||
newBlockId: string
|
||||
): Record<string, any> {
|
||||
const updated: Record<string, any> = {}
|
||||
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
if (
|
||||
subBlock &&
|
||||
typeof subBlock === 'object' &&
|
||||
(subBlock.type === 'condition-input' || subBlock.type === 'router-input') &&
|
||||
typeof subBlock.value === 'string'
|
||||
) {
|
||||
try {
|
||||
const parsed = JSON.parse(subBlock.value)
|
||||
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
|
||||
updated[key] = { ...subBlock, value: JSON.stringify(parsed) }
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
updated[key] = subBlock
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a workflow with all its blocks, edges, and subflows
|
||||
* This is a shared helper used by both the workflow duplicate API and folder duplicate API
|
||||
@@ -259,6 +294,15 @@ export async function duplicateWorkflow(
|
||||
)
|
||||
}
|
||||
|
||||
// Remap condition/router IDs to use the new block ID
|
||||
if (updatedSubBlocks && typeof updatedSubBlocks === 'object') {
|
||||
updatedSubBlocks = remapConditionIdsInSubBlocks(
|
||||
updatedSubBlocks as Record<string, any>,
|
||||
block.id,
|
||||
newBlockId
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...block,
|
||||
id: newBlockId,
|
||||
@@ -286,15 +330,24 @@ export async function duplicateWorkflow(
|
||||
.where(eq(workflowEdges.workflowId, sourceWorkflowId))
|
||||
|
||||
if (sourceEdges.length > 0) {
|
||||
const newEdges = sourceEdges.map((edge) => ({
|
||||
...edge,
|
||||
id: crypto.randomUUID(), // Generate new edge ID
|
||||
workflowId: newWorkflowId,
|
||||
sourceBlockId: blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId,
|
||||
targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
const newEdges = sourceEdges.map((edge) => {
|
||||
const newSourceBlockId = blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId
|
||||
const newSourceHandle =
|
||||
edge.sourceHandle && blockIdMapping.has(edge.sourceBlockId)
|
||||
? remapConditionEdgeHandle(edge.sourceHandle, edge.sourceBlockId, newSourceBlockId)
|
||||
: edge.sourceHandle
|
||||
|
||||
return {
|
||||
...edge,
|
||||
id: crypto.randomUUID(),
|
||||
workflowId: newWorkflowId,
|
||||
sourceBlockId: newSourceBlockId,
|
||||
targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId,
|
||||
sourceHandle: newSourceHandle,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
})
|
||||
|
||||
await tx.insert(workflowEdges).values(newEdges)
|
||||
logger.info(`[${requestId}] Copied ${sourceEdges.length} edges with updated block references`)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { and, desc, eq, inArray, sql } from 'drizzle-orm'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
|
||||
import {
|
||||
backfillCanonicalModes,
|
||||
migrateSubblockIds,
|
||||
@@ -833,7 +834,12 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
|
||||
Object.entries(state.blocks || {}).forEach(([oldId, block]) => {
|
||||
const newId = blockIdMapping.get(oldId)!
|
||||
// Duplicated blocks are always unlocked so users can edit them
|
||||
const newBlock: BlockState = { ...block, id: newId, locked: false }
|
||||
const newBlock: BlockState = {
|
||||
...block,
|
||||
id: newId,
|
||||
subBlocks: JSON.parse(JSON.stringify(block.subBlocks)),
|
||||
locked: false,
|
||||
}
|
||||
|
||||
// Update parentId reference if it exists
|
||||
if (newBlock.data?.parentId) {
|
||||
@@ -857,6 +863,21 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
|
||||
updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value) ?? updatedSubBlock.value
|
||||
}
|
||||
|
||||
// Remap condition/router IDs embedded in condition-input/router-input subBlocks
|
||||
if (
|
||||
(updatedSubBlock.type === 'condition-input' || updatedSubBlock.type === 'router-input') &&
|
||||
typeof updatedSubBlock.value === 'string'
|
||||
) {
|
||||
try {
|
||||
const parsed = JSON.parse(updatedSubBlock.value)
|
||||
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldId, newId)) {
|
||||
updatedSubBlock.value = JSON.stringify(parsed)
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
|
||||
updatedSubBlocks[subId] = updatedSubBlock
|
||||
})
|
||||
newBlock.subBlocks = updatedSubBlocks
|
||||
@@ -871,12 +892,17 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
|
||||
const newId = edgeIdMapping.get(edge.id)!
|
||||
const newSource = blockIdMapping.get(edge.source) || edge.source
|
||||
const newTarget = blockIdMapping.get(edge.target) || edge.target
|
||||
const newSourceHandle =
|
||||
edge.sourceHandle && blockIdMapping.has(edge.source)
|
||||
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
|
||||
: edge.sourceHandle
|
||||
|
||||
newEdges.push({
|
||||
...edge,
|
||||
id: newId,
|
||||
source: newSource,
|
||||
target: newTarget,
|
||||
sourceHandle: newSourceHandle,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Edge } from 'reactflow'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
|
||||
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
|
||||
import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
@@ -363,13 +364,15 @@ export function regenerateWorkflowIds(
|
||||
const nameMap = new Map<string, string>()
|
||||
const newBlocks: Record<string, BlockState> = {}
|
||||
|
||||
// First pass: generate new IDs
|
||||
// First pass: generate new IDs and remap condition/router IDs in subBlocks
|
||||
Object.entries(workflowState.blocks).forEach(([oldId, block]) => {
|
||||
const newId = uuidv4()
|
||||
blockIdMap.set(oldId, newId)
|
||||
const oldNormalizedName = normalizeName(block.name)
|
||||
nameMap.set(oldNormalizedName, oldNormalizedName)
|
||||
newBlocks[newId] = { ...block, id: newId }
|
||||
const newBlock = { ...block, id: newId, subBlocks: JSON.parse(JSON.stringify(block.subBlocks)) }
|
||||
remapConditionIds(newBlock.subBlocks, {}, oldId, newId)
|
||||
newBlocks[newId] = newBlock
|
||||
})
|
||||
|
||||
// Second pass: update parentId references
|
||||
@@ -385,12 +388,21 @@ export function regenerateWorkflowIds(
|
||||
}
|
||||
})
|
||||
|
||||
const newEdges = workflowState.edges.map((edge) => ({
|
||||
...edge,
|
||||
id: uuidv4(),
|
||||
source: blockIdMap.get(edge.source) || edge.source,
|
||||
target: blockIdMap.get(edge.target) || edge.target,
|
||||
}))
|
||||
const newEdges = workflowState.edges.map((edge) => {
|
||||
const newSource = blockIdMap.get(edge.source) || edge.source
|
||||
const newSourceHandle =
|
||||
edge.sourceHandle && blockIdMap.has(edge.source)
|
||||
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
|
||||
: edge.sourceHandle
|
||||
|
||||
return {
|
||||
...edge,
|
||||
id: uuidv4(),
|
||||
source: newSource,
|
||||
target: blockIdMap.get(edge.target) || edge.target,
|
||||
sourceHandle: newSourceHandle,
|
||||
}
|
||||
})
|
||||
|
||||
const newLoops: Record<string, Loop> = {}
|
||||
if (workflowState.loops) {
|
||||
@@ -429,6 +441,37 @@ export function regenerateWorkflowIds(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps condition/router block IDs within subBlock values when a block is duplicated.
|
||||
* Mutates both `subBlocks` and `subBlockValues` in place (callers must pass cloned data).
|
||||
*/
|
||||
export function remapConditionIds(
|
||||
subBlocks: Record<string, SubBlockState>,
|
||||
subBlockValues: Record<string, unknown>,
|
||||
oldBlockId: string,
|
||||
newBlockId: string
|
||||
): void {
|
||||
for (const [subBlockId, subBlock] of Object.entries(subBlocks)) {
|
||||
if (subBlock.type !== 'condition-input' && subBlock.type !== 'router-input') continue
|
||||
|
||||
const value = subBlockValues[subBlockId] ?? subBlock.value
|
||||
if (typeof value !== 'string') continue
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed)) continue
|
||||
|
||||
if (remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
|
||||
const newValue = JSON.stringify(parsed)
|
||||
subBlock.value = newValue
|
||||
subBlockValues[subBlockId] = newValue
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function regenerateBlockIds(
|
||||
blocks: Record<string, BlockState>,
|
||||
edges: Edge[],
|
||||
@@ -497,6 +540,7 @@ export function regenerateBlockIds(
|
||||
id: newId,
|
||||
name: newName,
|
||||
position: newPosition,
|
||||
subBlocks: JSON.parse(JSON.stringify(block.subBlocks)),
|
||||
// Temporarily keep data as-is, we'll fix parentId in second pass
|
||||
data: block.data ? { ...block.data } : block.data,
|
||||
// Duplicated blocks are always unlocked so users can edit them
|
||||
@@ -510,6 +554,9 @@ export function regenerateBlockIds(
|
||||
if (subBlockValues[oldId]) {
|
||||
newSubBlockValues[newId] = JSON.parse(JSON.stringify(subBlockValues[oldId]))
|
||||
}
|
||||
|
||||
// Remap condition/router IDs in the duplicated block
|
||||
remapConditionIds(newBlock.subBlocks, newSubBlockValues[newId] || {}, oldId, newId)
|
||||
})
|
||||
|
||||
// Second pass: update parentId references for nested blocks
|
||||
@@ -542,12 +589,21 @@ export function regenerateBlockIds(
|
||||
}
|
||||
})
|
||||
|
||||
const newEdges = edges.map((edge) => ({
|
||||
...edge,
|
||||
id: uuidv4(),
|
||||
source: blockIdMap.get(edge.source) || edge.source,
|
||||
target: blockIdMap.get(edge.target) || edge.target,
|
||||
}))
|
||||
const newEdges = edges.map((edge) => {
|
||||
const newSource = blockIdMap.get(edge.source) || edge.source
|
||||
const newSourceHandle =
|
||||
edge.sourceHandle && blockIdMap.has(edge.source)
|
||||
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
|
||||
: edge.sourceHandle
|
||||
|
||||
return {
|
||||
...edge,
|
||||
id: uuidv4(),
|
||||
source: newSource,
|
||||
target: blockIdMap.get(edge.target) || edge.target,
|
||||
sourceHandle: newSourceHandle,
|
||||
}
|
||||
})
|
||||
|
||||
const newLoops: Record<string, Loop> = {}
|
||||
Object.entries(loops).forEach(([oldLoopId, loop]) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
filterValidEdges,
|
||||
getUniqueBlockName,
|
||||
mergeSubblockState,
|
||||
remapConditionIds,
|
||||
} from '@/stores/workflows/utils'
|
||||
import type {
|
||||
Position,
|
||||
@@ -611,6 +612,21 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
{}
|
||||
)
|
||||
|
||||
// Remap condition/router IDs in the duplicated subBlocks
|
||||
const clonedSubBlockValues = activeWorkflowId
|
||||
? JSON.parse(
|
||||
JSON.stringify(
|
||||
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
|
||||
)
|
||||
)
|
||||
: {}
|
||||
remapConditionIds(
|
||||
newSubBlocks as Record<string, SubBlockState>,
|
||||
clonedSubBlockValues,
|
||||
id,
|
||||
newId
|
||||
)
|
||||
|
||||
const newState = {
|
||||
blocks: {
|
||||
...get().blocks,
|
||||
@@ -630,14 +646,12 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
}
|
||||
|
||||
if (activeWorkflowId) {
|
||||
const subBlockValues =
|
||||
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId]: {
|
||||
...state.workflowValues[activeWorkflowId],
|
||||
[newId]: JSON.parse(JSON.stringify(subBlockValues)),
|
||||
[newId]: clonedSubBlockValues,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
74
apps/sim/tools/fathom/get_summary.ts
Normal file
74
apps/sim/tools/fathom/get_summary.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { FathomGetSummaryParams, FathomGetSummaryResponse } from '@/tools/fathom/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getSummaryTool: ToolConfig<FathomGetSummaryParams, FathomGetSummaryResponse> = {
|
||||
id: 'fathom_get_summary',
|
||||
name: 'Fathom Get Summary',
|
||||
description: 'Get the call summary for a specific meeting recording.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Fathom API Key',
|
||||
},
|
||||
recordingId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The recording ID of the meeting',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
`https://api.fathom.ai/external/v1/recordings/${encodeURIComponent(params.recordingId.trim())}/summary`,
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
'X-Api-Key': params.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
(errorData as Record<string, string>).message ||
|
||||
`Fathom API error: ${response.status} ${response.statusText}`,
|
||||
output: {
|
||||
template_name: null,
|
||||
markdown_formatted: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const summary = data.summary ?? data
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
template_name: summary.template_name ?? null,
|
||||
markdown_formatted: summary.markdown_formatted ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
template_name: {
|
||||
type: 'string',
|
||||
description: 'Name of the summary template used',
|
||||
optional: true,
|
||||
},
|
||||
markdown_formatted: {
|
||||
type: 'string',
|
||||
description: 'Markdown-formatted summary text',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
95
apps/sim/tools/fathom/get_transcript.ts
Normal file
95
apps/sim/tools/fathom/get_transcript.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { FathomGetTranscriptParams, FathomGetTranscriptResponse } from '@/tools/fathom/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getTranscriptTool: ToolConfig<FathomGetTranscriptParams, FathomGetTranscriptResponse> =
|
||||
{
|
||||
id: 'fathom_get_transcript',
|
||||
name: 'Fathom Get Transcript',
|
||||
description: 'Get the full transcript for a specific meeting recording.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Fathom API Key',
|
||||
},
|
||||
recordingId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The recording ID of the meeting',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
`https://api.fathom.ai/external/v1/recordings/${encodeURIComponent(params.recordingId.trim())}/transcript`,
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
'X-Api-Key': params.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
(errorData as Record<string, string>).message ||
|
||||
`Fathom API error: ${response.status} ${response.statusText}`,
|
||||
output: {
|
||||
transcript: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const transcript = (data.transcript ?? []).map(
|
||||
(entry: { speaker?: Record<string, unknown>; text?: string; timestamp?: string }) => ({
|
||||
speaker: {
|
||||
display_name: entry.speaker?.display_name ?? '',
|
||||
matched_calendar_invitee_email: entry.speaker?.matched_calendar_invitee_email ?? null,
|
||||
},
|
||||
text: entry.text ?? '',
|
||||
timestamp: entry.timestamp ?? '',
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
transcript,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
transcript: {
|
||||
type: 'array',
|
||||
description: 'Array of transcript entries with speaker, text, and timestamp',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
speaker: {
|
||||
type: 'object',
|
||||
description: 'Speaker information',
|
||||
properties: {
|
||||
display_name: { type: 'string', description: 'Speaker display name' },
|
||||
matched_calendar_invitee_email: {
|
||||
type: 'string',
|
||||
description: 'Matched calendar invitee email',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
text: { type: 'string', description: 'Transcript text' },
|
||||
timestamp: { type: 'string', description: 'Timestamp (HH:MM:SS)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
13
apps/sim/tools/fathom/index.ts
Normal file
13
apps/sim/tools/fathom/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getSummaryTool } from '@/tools/fathom/get_summary'
|
||||
import { getTranscriptTool } from '@/tools/fathom/get_transcript'
|
||||
import { listMeetingsTool } from '@/tools/fathom/list_meetings'
|
||||
import { listTeamMembersTool } from '@/tools/fathom/list_team_members'
|
||||
import { listTeamsTool } from '@/tools/fathom/list_teams'
|
||||
|
||||
export const fathomGetSummaryTool = getSummaryTool
|
||||
export const fathomGetTranscriptTool = getTranscriptTool
|
||||
export const fathomListMeetingsTool = listMeetingsTool
|
||||
export const fathomListTeamMembersTool = listTeamMembersTool
|
||||
export const fathomListTeamsTool = listTeamsTool
|
||||
|
||||
export * from './types'
|
||||
174
apps/sim/tools/fathom/list_meetings.ts
Normal file
174
apps/sim/tools/fathom/list_meetings.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { FathomListMeetingsParams, FathomListMeetingsResponse } from '@/tools/fathom/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listMeetingsTool: ToolConfig<FathomListMeetingsParams, FathomListMeetingsResponse> = {
|
||||
id: 'fathom_list_meetings',
|
||||
name: 'Fathom List Meetings',
|
||||
description: 'List recent meetings recorded by the user or shared to their team.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Fathom API Key',
|
||||
},
|
||||
includeSummary: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Include meeting summary (true/false)',
|
||||
},
|
||||
includeTranscript: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Include meeting transcript (true/false)',
|
||||
},
|
||||
includeActionItems: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Include action items (true/false)',
|
||||
},
|
||||
includeCrmMatches: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Include linked CRM matches (true/false)',
|
||||
},
|
||||
createdAfter: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter meetings created after this ISO 8601 timestamp',
|
||||
},
|
||||
createdBefore: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter meetings created before this ISO 8601 timestamp',
|
||||
},
|
||||
recordedBy: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by recorder email address',
|
||||
},
|
||||
teams: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by team name',
|
||||
},
|
||||
cursor: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Pagination cursor from a previous response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const url = new URL('https://api.fathom.ai/external/v1/meetings')
|
||||
if (params.includeSummary === 'true') url.searchParams.append('include_summary', 'true')
|
||||
if (params.includeTranscript === 'true') url.searchParams.append('include_transcript', 'true')
|
||||
if (params.includeActionItems === 'true')
|
||||
url.searchParams.append('include_action_items', 'true')
|
||||
if (params.includeCrmMatches === 'true')
|
||||
url.searchParams.append('include_crm_matches', 'true')
|
||||
if (params.createdAfter) url.searchParams.append('created_after', params.createdAfter)
|
||||
if (params.createdBefore) url.searchParams.append('created_before', params.createdBefore)
|
||||
if (params.recordedBy) url.searchParams.append('recorded_by[]', params.recordedBy)
|
||||
if (params.teams) url.searchParams.append('teams[]', params.teams)
|
||||
if (params.cursor) url.searchParams.append('cursor', params.cursor)
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
'X-Api-Key': params.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
(errorData as Record<string, string>).message ||
|
||||
`Fathom API error: ${response.status} ${response.statusText}`,
|
||||
output: {
|
||||
meetings: [],
|
||||
next_cursor: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const meetings = (data.items ?? []).map(
|
||||
(meeting: Record<string, unknown> & { recorded_by?: Record<string, unknown> }) => ({
|
||||
title: meeting.title ?? '',
|
||||
meeting_title: meeting.meeting_title ?? null,
|
||||
recording_id: meeting.recording_id ?? null,
|
||||
url: meeting.url ?? '',
|
||||
share_url: meeting.share_url ?? '',
|
||||
created_at: meeting.created_at ?? '',
|
||||
scheduled_start_time: meeting.scheduled_start_time ?? null,
|
||||
scheduled_end_time: meeting.scheduled_end_time ?? null,
|
||||
recording_start_time: meeting.recording_start_time ?? null,
|
||||
recording_end_time: meeting.recording_end_time ?? null,
|
||||
transcript_language: meeting.transcript_language ?? '',
|
||||
calendar_invitees_domains_type: meeting.calendar_invitees_domains_type ?? null,
|
||||
recorded_by: meeting.recorded_by
|
||||
? {
|
||||
name: meeting.recorded_by.name ?? '',
|
||||
email: meeting.recorded_by.email ?? '',
|
||||
email_domain: meeting.recorded_by.email_domain ?? '',
|
||||
team: meeting.recorded_by.team ?? null,
|
||||
}
|
||||
: null,
|
||||
calendar_invitees: (meeting.calendar_invitees as Array<Record<string, unknown>>) ?? [],
|
||||
default_summary: meeting.default_summary ?? null,
|
||||
transcript: meeting.transcript ?? null,
|
||||
action_items: meeting.action_items ?? null,
|
||||
crm_matches: meeting.crm_matches ?? null,
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
meetings,
|
||||
next_cursor: data.next_cursor ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
meetings: {
|
||||
type: 'array',
|
||||
description: 'List of meetings',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Meeting title' },
|
||||
recording_id: { type: 'number', description: 'Unique recording ID' },
|
||||
url: { type: 'string', description: 'URL to view the meeting' },
|
||||
share_url: { type: 'string', description: 'Shareable URL' },
|
||||
created_at: { type: 'string', description: 'Creation timestamp' },
|
||||
transcript_language: { type: 'string', description: 'Transcript language' },
|
||||
},
|
||||
},
|
||||
},
|
||||
next_cursor: {
|
||||
type: 'string',
|
||||
description: 'Pagination cursor for next page',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
103
apps/sim/tools/fathom/list_team_members.ts
Normal file
103
apps/sim/tools/fathom/list_team_members.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type {
|
||||
FathomListTeamMembersParams,
|
||||
FathomListTeamMembersResponse,
|
||||
} from '@/tools/fathom/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listTeamMembersTool: ToolConfig<
|
||||
FathomListTeamMembersParams,
|
||||
FathomListTeamMembersResponse
|
||||
> = {
|
||||
id: 'fathom_list_team_members',
|
||||
name: 'Fathom List Team Members',
|
||||
description: 'List team members in your Fathom organization.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Fathom API Key',
|
||||
},
|
||||
teams: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Team name to filter by',
|
||||
},
|
||||
cursor: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Pagination cursor from a previous response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const url = new URL('https://api.fathom.ai/external/v1/team_members')
|
||||
if (params.teams) url.searchParams.append('team', params.teams)
|
||||
if (params.cursor) url.searchParams.append('cursor', params.cursor)
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
'X-Api-Key': params.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
(errorData as Record<string, string>).message ||
|
||||
`Fathom API error: ${response.status} ${response.statusText}`,
|
||||
output: {
|
||||
members: [],
|
||||
next_cursor: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const members = (data.items ?? []).map(
|
||||
(member: { name?: string; email?: string; created_at?: string }) => ({
|
||||
name: member.name ?? '',
|
||||
email: member.email ?? '',
|
||||
created_at: member.created_at ?? '',
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
members,
|
||||
next_cursor: data.next_cursor ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
members: {
|
||||
type: 'array',
|
||||
description: 'List of team members',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Team member name' },
|
||||
email: { type: 'string', description: 'Team member email' },
|
||||
created_at: { type: 'string', description: 'Date the member was added' },
|
||||
},
|
||||
},
|
||||
},
|
||||
next_cursor: {
|
||||
type: 'string',
|
||||
description: 'Pagination cursor for next page',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
86
apps/sim/tools/fathom/list_teams.ts
Normal file
86
apps/sim/tools/fathom/list_teams.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { FathomListTeamsParams, FathomListTeamsResponse } from '@/tools/fathom/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listTeamsTool: ToolConfig<FathomListTeamsParams, FathomListTeamsResponse> = {
|
||||
id: 'fathom_list_teams',
|
||||
name: 'Fathom List Teams',
|
||||
description: 'List teams in your Fathom organization.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Fathom API Key',
|
||||
},
|
||||
cursor: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Pagination cursor from a previous response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const url = new URL('https://api.fathom.ai/external/v1/teams')
|
||||
if (params.cursor) url.searchParams.append('cursor', params.cursor)
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
'X-Api-Key': params.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
(errorData as Record<string, string>).message ||
|
||||
`Fathom API error: ${response.status} ${response.statusText}`,
|
||||
output: {
|
||||
teams: [],
|
||||
next_cursor: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const teams = (data.items ?? []).map((team: { name?: string; created_at?: string }) => ({
|
||||
name: team.name ?? '',
|
||||
created_at: team.created_at ?? '',
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
teams,
|
||||
next_cursor: data.next_cursor ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
teams: {
|
||||
type: 'array',
|
||||
description: 'List of teams',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Team name' },
|
||||
created_at: { type: 'string', description: 'Date the team was created' },
|
||||
},
|
||||
},
|
||||
},
|
||||
next_cursor: {
|
||||
type: 'string',
|
||||
description: 'Pagination cursor for next page',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
127
apps/sim/tools/fathom/types.ts
Normal file
127
apps/sim/tools/fathom/types.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface FathomBaseParams {
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export interface FathomListMeetingsParams extends FathomBaseParams {
|
||||
includeSummary?: string
|
||||
includeTranscript?: string
|
||||
includeActionItems?: string
|
||||
includeCrmMatches?: string
|
||||
createdAfter?: string
|
||||
createdBefore?: string
|
||||
recordedBy?: string
|
||||
teams?: string
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export interface FathomListMeetingsResponse extends ToolResponse {
|
||||
output: {
|
||||
meetings: Array<{
|
||||
title: string
|
||||
meeting_title: string | null
|
||||
recording_id: number | null
|
||||
url: string
|
||||
share_url: string
|
||||
created_at: string
|
||||
scheduled_start_time: string | null
|
||||
scheduled_end_time: string | null
|
||||
recording_start_time: string | null
|
||||
recording_end_time: string | null
|
||||
transcript_language: string
|
||||
calendar_invitees_domains_type: string | null
|
||||
recorded_by: { name: string; email: string; email_domain: string; team: string | null } | null
|
||||
calendar_invitees: Array<{
|
||||
name: string | null
|
||||
email: string
|
||||
email_domain: string | null
|
||||
is_external: boolean
|
||||
matched_speaker_display_name: string | null
|
||||
}>
|
||||
default_summary: { template_name: string | null; markdown_formatted: string | null } | null
|
||||
transcript: Array<{
|
||||
speaker: { display_name: string; matched_calendar_invitee_email: string | null }
|
||||
text: string
|
||||
timestamp: string
|
||||
}> | null
|
||||
action_items: Array<{
|
||||
description: string
|
||||
user_generated: boolean
|
||||
completed: boolean
|
||||
recording_timestamp: string
|
||||
recording_playback_url: string
|
||||
assignee: { name: string | null; email: string | null; team: string | null }
|
||||
}> | null
|
||||
crm_matches: {
|
||||
contacts: Array<{ name: string; email: string; record_url: string }>
|
||||
companies: Array<{ name: string; record_url: string }>
|
||||
deals: Array<{ name: string; amount: number; record_url: string }>
|
||||
error: string | null
|
||||
} | null
|
||||
}>
|
||||
next_cursor: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface FathomGetSummaryParams extends FathomBaseParams {
|
||||
recordingId: string
|
||||
}
|
||||
|
||||
export interface FathomGetSummaryResponse extends ToolResponse {
|
||||
output: {
|
||||
template_name: string | null
|
||||
markdown_formatted: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface FathomGetTranscriptParams extends FathomBaseParams {
|
||||
recordingId: string
|
||||
}
|
||||
|
||||
export interface FathomGetTranscriptResponse extends ToolResponse {
|
||||
output: {
|
||||
transcript: Array<{
|
||||
speaker: { display_name: string; matched_calendar_invitee_email: string | null }
|
||||
text: string
|
||||
timestamp: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export interface FathomListTeamMembersParams extends FathomBaseParams {
|
||||
teams?: string
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export interface FathomListTeamMembersResponse extends ToolResponse {
|
||||
output: {
|
||||
members: Array<{
|
||||
name: string
|
||||
email: string
|
||||
created_at: string
|
||||
}>
|
||||
next_cursor: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface FathomListTeamsParams extends FathomBaseParams {
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
export interface FathomListTeamsResponse extends ToolResponse {
|
||||
output: {
|
||||
teams: Array<{
|
||||
name: string
|
||||
created_at: string
|
||||
}>
|
||||
next_cursor: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export type FathomResponse =
|
||||
| FathomListMeetingsResponse
|
||||
| FathomGetSummaryResponse
|
||||
| FathomGetTranscriptResponse
|
||||
| FathomListTeamMembersResponse
|
||||
| FathomListTeamsResponse
|
||||
36
apps/sim/tools/gmail/utils.test.ts
Normal file
36
apps/sim/tools/gmail/utils.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodeRfc2047 } from './utils'
|
||||
|
||||
describe('encodeRfc2047', () => {
|
||||
it('returns ASCII text unchanged', () => {
|
||||
expect(encodeRfc2047('Simple ASCII Subject')).toBe('Simple ASCII Subject')
|
||||
})
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(encodeRfc2047('')).toBe('')
|
||||
})
|
||||
|
||||
it('encodes emojis as RFC 2047 base64', () => {
|
||||
const result = encodeRfc2047('Time to Stretch! 🧘')
|
||||
expect(result).toBe('=?UTF-8?B?VGltZSB0byBTdHJldGNoISDwn6eY?=')
|
||||
})
|
||||
|
||||
it('round-trips non-ASCII subjects correctly', () => {
|
||||
const subjects = ['Hello 世界', 'Café résumé', '🎉🎊🎈 Party!', '今週のミーティング']
|
||||
for (const subject of subjects) {
|
||||
const encoded = encodeRfc2047(subject)
|
||||
const match = encoded.match(/^=\?UTF-8\?B\?(.+)\?=$/)
|
||||
expect(match).not.toBeNull()
|
||||
const decoded = Buffer.from(match![1], 'base64').toString('utf-8')
|
||||
expect(decoded).toBe(subject)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not double-encode already-encoded subjects', () => {
|
||||
const alreadyEncoded = '=?UTF-8?B?VGltZSB0byBTdHJldGNoISDwn6eY?='
|
||||
expect(encodeRfc2047(alreadyEncoded)).toBe(alreadyEncoded)
|
||||
})
|
||||
})
|
||||
@@ -294,6 +294,19 @@ function generateBoundary(): string {
|
||||
return `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a header value using RFC 2047 Base64 encoding if it contains non-ASCII characters.
|
||||
* This matches Google's own Gmail API sample: `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=`
|
||||
* @see https://github.com/googleapis/google-api-nodejs-client/blob/main/samples/gmail/send.js
|
||||
*/
|
||||
export function encodeRfc2047(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/^[\x00-\x7F]*$/.test(value)) {
|
||||
return value
|
||||
}
|
||||
return `=?UTF-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=`
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode string or buffer to base64url format (URL-safe base64)
|
||||
* Gmail API requires base64url encoding for the raw message field
|
||||
@@ -333,7 +346,7 @@ export function buildSimpleEmailMessage(params: {
|
||||
emailHeaders.push(`Bcc: ${bcc}`)
|
||||
}
|
||||
|
||||
emailHeaders.push(`Subject: ${subject || ''}`)
|
||||
emailHeaders.push(`Subject: ${encodeRfc2047(subject || '')}`)
|
||||
|
||||
if (inReplyTo) {
|
||||
emailHeaders.push(`In-Reply-To: ${inReplyTo}`)
|
||||
@@ -380,7 +393,7 @@ export function buildMimeMessage(params: BuildMimeMessageParams): string {
|
||||
if (bcc) {
|
||||
messageParts.push(`Bcc: ${bcc}`)
|
||||
}
|
||||
messageParts.push(`Subject: ${subject || ''}`)
|
||||
messageParts.push(`Subject: ${encodeRfc2047(subject || '')}`)
|
||||
|
||||
if (inReplyTo) {
|
||||
messageParts.push(`In-Reply-To: ${inReplyTo}`)
|
||||
|
||||
@@ -137,8 +137,11 @@ export const jiraSearchIssuesTool: ToolConfig<JiraSearchIssuesParams, JiraSearch
|
||||
if (params.nextPageToken) query.set('nextPageToken', params.nextPageToken)
|
||||
if (typeof params.maxResults === 'number')
|
||||
query.set('maxResults', String(params.maxResults))
|
||||
if (Array.isArray(params.fields) && params.fields.length > 0)
|
||||
if (Array.isArray(params.fields) && params.fields.length > 0) {
|
||||
query.set('fields', params.fields.join(','))
|
||||
} else {
|
||||
query.set('fields', '*all')
|
||||
}
|
||||
const qs = query.toString()
|
||||
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/search/jql${qs ? `?${qs}` : ''}`
|
||||
}
|
||||
@@ -159,8 +162,11 @@ export const jiraSearchIssuesTool: ToolConfig<JiraSearchIssuesParams, JiraSearch
|
||||
if (params?.jql) query.set('jql', params.jql)
|
||||
if (params?.nextPageToken) query.set('nextPageToken', params.nextPageToken)
|
||||
if (typeof params?.maxResults === 'number') query.set('maxResults', String(params.maxResults))
|
||||
if (Array.isArray(params?.fields) && params.fields.length > 0)
|
||||
if (Array.isArray(params?.fields) && params.fields.length > 0) {
|
||||
query.set('fields', params.fields.join(','))
|
||||
} else {
|
||||
query.set('fields', '*all')
|
||||
}
|
||||
const searchUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${query.toString()}`
|
||||
const searchResponse = await fetch(searchUrl, {
|
||||
method: 'GET',
|
||||
|
||||
@@ -446,6 +446,13 @@ import {
|
||||
exaResearchTool,
|
||||
exaSearchTool,
|
||||
} from '@/tools/exa'
|
||||
import {
|
||||
fathomGetSummaryTool,
|
||||
fathomGetTranscriptTool,
|
||||
fathomListMeetingsTool,
|
||||
fathomListTeamMembersTool,
|
||||
fathomListTeamsTool,
|
||||
} from '@/tools/fathom'
|
||||
import { fileParserV2Tool, fileParserV3Tool, fileParseTool } from '@/tools/file'
|
||||
import {
|
||||
firecrawlAgentTool,
|
||||
@@ -3666,6 +3673,11 @@ export const tools: Record<string, ToolConfig> = {
|
||||
knowledge_create_document: knowledgeCreateDocumentTool,
|
||||
search_tool: searchTool,
|
||||
elevenlabs_tts: elevenLabsTtsTool,
|
||||
fathom_list_meetings: fathomListMeetingsTool,
|
||||
fathom_get_summary: fathomGetSummaryTool,
|
||||
fathom_get_transcript: fathomGetTranscriptTool,
|
||||
fathom_list_team_members: fathomListTeamMembersTool,
|
||||
fathom_list_teams: fathomListTeamsTool,
|
||||
stt_whisper: whisperSttTool,
|
||||
stt_whisper_v2: whisperSttV2Tool,
|
||||
stt_deepgram: deepgramSttTool,
|
||||
|
||||
@@ -85,6 +85,7 @@ export const slackGetUserTool: ToolConfig<SlackGetUserParams, SlackGetUserRespon
|
||||
first_name: profile.first_name || '',
|
||||
last_name: profile.last_name || '',
|
||||
title: profile.title || '',
|
||||
email: profile.email || '',
|
||||
phone: profile.phone || '',
|
||||
skype: profile.skype || '',
|
||||
is_bot: user.is_bot || false,
|
||||
|
||||
@@ -93,6 +93,7 @@ export const slackListUsersTool: ToolConfig<SlackListUsersParams, SlackListUsers
|
||||
name: user.name,
|
||||
real_name: user.real_name || user.profile?.real_name || '',
|
||||
display_name: user.profile?.display_name || '',
|
||||
email: user.profile?.email || '',
|
||||
is_bot: user.is_bot || false,
|
||||
is_admin: user.is_admin || false,
|
||||
is_owner: user.is_owner || false,
|
||||
|
||||
@@ -376,6 +376,11 @@ export const USER_OUTPUT_PROPERTIES = {
|
||||
title: { type: 'string', description: 'Job title', optional: true },
|
||||
phone: { type: 'string', description: 'Phone number', optional: true },
|
||||
skype: { type: 'string', description: 'Skype handle', optional: true },
|
||||
email: {
|
||||
type: 'string',
|
||||
description: 'Email address (requires users:read.email scope)',
|
||||
optional: true,
|
||||
},
|
||||
is_bot: { type: 'boolean', description: 'Whether the user is a bot' },
|
||||
is_admin: { type: 'boolean', description: 'Whether the user is a workspace admin' },
|
||||
is_owner: { type: 'boolean', description: 'Whether the user is the workspace owner' },
|
||||
@@ -438,6 +443,11 @@ export const USER_SUMMARY_OUTPUT_PROPERTIES = {
|
||||
name: { type: 'string', description: 'Username (handle)' },
|
||||
real_name: { type: 'string', description: 'Full real name' },
|
||||
display_name: { type: 'string', description: 'Display name shown in Slack' },
|
||||
email: {
|
||||
type: 'string',
|
||||
description: 'Email address (requires users:read.email scope)',
|
||||
optional: true,
|
||||
},
|
||||
is_bot: { type: 'boolean', description: 'Whether the user is a bot' },
|
||||
is_admin: { type: 'boolean', description: 'Whether the user is a workspace admin' },
|
||||
is_owner: { type: 'boolean', description: 'Whether the user is the workspace owner' },
|
||||
@@ -953,6 +963,7 @@ export interface SlackUser {
|
||||
title?: string
|
||||
phone?: string
|
||||
skype?: string
|
||||
email: string
|
||||
is_bot: boolean
|
||||
is_admin: boolean
|
||||
is_owner: boolean
|
||||
|
||||
41
apps/sim/triggers/constants.test.ts
Normal file
41
apps/sim/triggers/constants.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { POLLING_PROVIDERS } from '@/triggers/constants'
|
||||
import { TRIGGER_REGISTRY } from '@/triggers/registry'
|
||||
|
||||
describe('POLLING_PROVIDERS sync with TriggerConfig.polling', () => {
|
||||
it('matches every trigger with polling: true in the registry', () => {
|
||||
const registryPollingProviders = new Set(
|
||||
Object.values(TRIGGER_REGISTRY)
|
||||
.filter((t) => t.polling === true)
|
||||
.map((t) => t.provider)
|
||||
)
|
||||
|
||||
expect(POLLING_PROVIDERS).toEqual(registryPollingProviders)
|
||||
})
|
||||
|
||||
it('no trigger with polling: true is missing from POLLING_PROVIDERS', () => {
|
||||
const missing: string[] = []
|
||||
for (const trigger of Object.values(TRIGGER_REGISTRY)) {
|
||||
if (trigger.polling && !POLLING_PROVIDERS.has(trigger.provider)) {
|
||||
missing.push(`${trigger.id} (provider: ${trigger.provider})`)
|
||||
}
|
||||
}
|
||||
expect(missing, `Triggers with polling: true missing from POLLING_PROVIDERS`).toEqual([])
|
||||
})
|
||||
|
||||
it('no POLLING_PROVIDERS entry lacks a polling: true trigger in the registry', () => {
|
||||
const extra: string[] = []
|
||||
for (const provider of POLLING_PROVIDERS) {
|
||||
const hasTrigger = Object.values(TRIGGER_REGISTRY).some(
|
||||
(t) => t.provider === provider && t.polling === true
|
||||
)
|
||||
if (!hasTrigger) {
|
||||
extra.push(provider)
|
||||
}
|
||||
}
|
||||
expect(extra, `POLLING_PROVIDERS entries with no matching polling trigger`).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -35,3 +35,15 @@ export const TRIGGER_RUNTIME_SUBBLOCK_IDS: string[] = [
|
||||
* This prevents runaway errors from continuously executing failing workflows.
|
||||
*/
|
||||
export const MAX_CONSECUTIVE_FAILURES = 100
|
||||
|
||||
/**
|
||||
* Set of webhook provider names that use polling-based triggers.
|
||||
* Mirrors the `polling: true` flag on TriggerConfig entries.
|
||||
* Used to route execution: polling providers use the full job queue
|
||||
* (Trigger.dev), non-polling providers execute inline.
|
||||
*/
|
||||
export const POLLING_PROVIDERS = new Set(['gmail', 'outlook', 'rss', 'imap'])
|
||||
|
||||
export function isPollingWebhookProvider(provider: string): boolean {
|
||||
return POLLING_PROVIDERS.has(provider)
|
||||
}
|
||||
|
||||
2
apps/sim/triggers/fathom/index.ts
Normal file
2
apps/sim/triggers/fathom/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { fathomNewMeetingTrigger } from './new_meeting'
|
||||
export { fathomWebhookTrigger } from './webhook'
|
||||
128
apps/sim/triggers/fathom/new_meeting.ts
Normal file
128
apps/sim/triggers/fathom/new_meeting.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { FathomIcon } from '@/components/icons'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
import { buildMeetingOutputs, fathomSetupInstructions } from './utils'
|
||||
|
||||
export const fathomNewMeetingTrigger: TriggerConfig = {
|
||||
id: 'fathom_new_meeting',
|
||||
name: 'Fathom New Meeting Content',
|
||||
provider: 'fathom',
|
||||
description: 'Trigger workflow when new meeting content is ready in Fathom',
|
||||
version: '1.0.0',
|
||||
icon: FathomIcon,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your Fathom API key',
|
||||
description: 'Required to create the webhook in Fathom.',
|
||||
password: true,
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_new_meeting',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'triggeredFor',
|
||||
title: 'Trigger For',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'My Recordings', id: 'my_recordings' },
|
||||
{ label: 'Shared External Recordings', id: 'shared_external_recordings' },
|
||||
{ label: 'My Shared With Team Recordings', id: 'my_shared_with_team_recordings' },
|
||||
{ label: 'Shared Team Recordings', id: 'shared_team_recordings' },
|
||||
],
|
||||
value: () => 'my_recordings',
|
||||
description: 'Which recording types should trigger this webhook.',
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_new_meeting',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeSummary',
|
||||
title: 'Include Summary',
|
||||
type: 'switch',
|
||||
description: 'Include the meeting summary in the webhook payload.',
|
||||
defaultValue: true,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_new_meeting',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeTranscript',
|
||||
title: 'Include Transcript',
|
||||
type: 'switch',
|
||||
description: 'Include the full transcript in the webhook payload.',
|
||||
defaultValue: false,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_new_meeting',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeActionItems',
|
||||
title: 'Include Action Items',
|
||||
type: 'switch',
|
||||
description: 'Include action items extracted from the meeting.',
|
||||
defaultValue: false,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_new_meeting',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeCrmMatches',
|
||||
title: 'Include CRM Matches',
|
||||
type: 'switch',
|
||||
description: 'Include matched CRM contacts, companies, and deals from your linked CRM.',
|
||||
defaultValue: false,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_new_meeting',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'triggerSave',
|
||||
title: '',
|
||||
type: 'trigger-save',
|
||||
hideFromPreview: true,
|
||||
mode: 'trigger',
|
||||
triggerId: 'fathom_new_meeting',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_new_meeting',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'triggerInstructions',
|
||||
title: 'Setup Instructions',
|
||||
hideFromPreview: true,
|
||||
type: 'text',
|
||||
defaultValue: fathomSetupInstructions('New Meeting Content'),
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_new_meeting',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
outputs: buildMeetingOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
123
apps/sim/triggers/fathom/utils.ts
Normal file
123
apps/sim/triggers/fathom/utils.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { TriggerOutput } from '@/triggers/types'
|
||||
|
||||
/**
|
||||
* Shared trigger dropdown options for all Fathom triggers
|
||||
*/
|
||||
export const fathomTriggerOptions = [
|
||||
{ label: 'New Meeting Content', id: 'fathom_new_meeting' },
|
||||
{ label: 'General Webhook (All Events)', id: 'fathom_webhook' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Generate setup instructions for a specific Fathom event type
|
||||
*/
|
||||
export function fathomSetupInstructions(eventType: string): string {
|
||||
const instructions = [
|
||||
'Enter your Fathom API Key above.',
|
||||
'You can find or create your API key in Fathom at <strong>Settings > Integrations > API</strong>. See the <a href="https://developers.fathom.ai/" target="_blank" rel="noopener noreferrer">Fathom API documentation</a> for details.',
|
||||
`Click <strong>"Save Configuration"</strong> to automatically create the webhook in Fathom for <strong>${eventType}</strong> events.`,
|
||||
'The webhook will be automatically deleted when you remove this trigger.',
|
||||
]
|
||||
|
||||
return instructions
|
||||
.map(
|
||||
(instruction, index) =>
|
||||
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build output schema for meeting content events.
|
||||
* Fathom webhook payload delivers meeting data including summary, transcript, and action items
|
||||
* based on the include flags set during webhook creation.
|
||||
*/
|
||||
export function buildMeetingOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Meeting title',
|
||||
},
|
||||
meeting_title: {
|
||||
type: 'string',
|
||||
description: 'Calendar event title',
|
||||
},
|
||||
recording_id: {
|
||||
type: 'number',
|
||||
description: 'Unique recording ID',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'URL to view the meeting in Fathom',
|
||||
},
|
||||
share_url: {
|
||||
type: 'string',
|
||||
description: 'Shareable URL for the meeting',
|
||||
},
|
||||
created_at: {
|
||||
type: 'string',
|
||||
description: 'ISO 8601 creation timestamp',
|
||||
},
|
||||
scheduled_start_time: {
|
||||
type: 'string',
|
||||
description: 'Scheduled start time',
|
||||
},
|
||||
scheduled_end_time: {
|
||||
type: 'string',
|
||||
description: 'Scheduled end time',
|
||||
},
|
||||
recording_start_time: {
|
||||
type: 'string',
|
||||
description: 'Recording start time',
|
||||
},
|
||||
recording_end_time: {
|
||||
type: 'string',
|
||||
description: 'Recording end time',
|
||||
},
|
||||
transcript_language: {
|
||||
type: 'string',
|
||||
description: 'Language of the transcript',
|
||||
},
|
||||
calendar_invitees_domains_type: {
|
||||
type: 'string',
|
||||
description: 'Domain type: only_internal or one_or_more_external',
|
||||
},
|
||||
recorded_by: {
|
||||
type: 'object',
|
||||
description: 'Recorder details',
|
||||
name: { type: 'string', description: 'Name of the recorder' },
|
||||
email: { type: 'string', description: 'Email of the recorder' },
|
||||
},
|
||||
calendar_invitees: {
|
||||
type: 'array',
|
||||
description: 'Array of calendar invitees with name and email',
|
||||
},
|
||||
default_summary: {
|
||||
type: 'object',
|
||||
description: 'Meeting summary',
|
||||
template_name: { type: 'string', description: 'Summary template name' },
|
||||
markdown_formatted: { type: 'string', description: 'Markdown-formatted summary' },
|
||||
},
|
||||
transcript: {
|
||||
type: 'array',
|
||||
description: 'Array of transcript entries with speaker, text, and timestamp',
|
||||
},
|
||||
action_items: {
|
||||
type: 'array',
|
||||
description: 'Array of action items extracted from the meeting',
|
||||
},
|
||||
crm_matches: {
|
||||
type: 'json',
|
||||
description: 'Matched CRM contacts, companies, and deals from linked CRM',
|
||||
},
|
||||
} as Record<string, TriggerOutput>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build output schema for generic webhook events.
|
||||
* Fathom only has one webhook event type (new meeting content ready) and the payload
|
||||
* is the Meeting object directly (no wrapping), so outputs match buildMeetingOutputs.
|
||||
*/
|
||||
export function buildGenericOutputs(): Record<string, TriggerOutput> {
|
||||
return buildMeetingOutputs()
|
||||
}
|
||||
128
apps/sim/triggers/fathom/webhook.ts
Normal file
128
apps/sim/triggers/fathom/webhook.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { FathomIcon } from '@/components/icons'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
import { buildGenericOutputs, fathomSetupInstructions } from './utils'
|
||||
|
||||
export const fathomWebhookTrigger: TriggerConfig = {
|
||||
id: 'fathom_webhook',
|
||||
name: 'Fathom Webhook',
|
||||
provider: 'fathom',
|
||||
description: 'Generic webhook trigger for all Fathom events',
|
||||
version: '1.0.0',
|
||||
icon: FathomIcon,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your Fathom API key',
|
||||
description: 'Required to create the webhook in Fathom.',
|
||||
password: true,
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_webhook',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'triggeredFor',
|
||||
title: 'Trigger For',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'My Recordings', id: 'my_recordings' },
|
||||
{ label: 'Shared External Recordings', id: 'shared_external_recordings' },
|
||||
{ label: 'My Shared With Team Recordings', id: 'my_shared_with_team_recordings' },
|
||||
{ label: 'Shared Team Recordings', id: 'shared_team_recordings' },
|
||||
],
|
||||
value: () => 'my_recordings',
|
||||
description: 'Which recording types should trigger this webhook.',
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_webhook',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeSummary',
|
||||
title: 'Include Summary',
|
||||
type: 'switch',
|
||||
description: 'Include the meeting summary in the webhook payload.',
|
||||
defaultValue: true,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_webhook',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeTranscript',
|
||||
title: 'Include Transcript',
|
||||
type: 'switch',
|
||||
description: 'Include the full transcript in the webhook payload.',
|
||||
defaultValue: false,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_webhook',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeActionItems',
|
||||
title: 'Include Action Items',
|
||||
type: 'switch',
|
||||
description: 'Include action items extracted from the meeting.',
|
||||
defaultValue: false,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_webhook',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'includeCrmMatches',
|
||||
title: 'Include CRM Matches',
|
||||
type: 'switch',
|
||||
description: 'Include matched CRM contacts, companies, and deals from your linked CRM.',
|
||||
defaultValue: false,
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_webhook',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'triggerSave',
|
||||
title: '',
|
||||
type: 'trigger-save',
|
||||
hideFromPreview: true,
|
||||
mode: 'trigger',
|
||||
triggerId: 'fathom_webhook',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_webhook',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'triggerInstructions',
|
||||
title: 'Setup Instructions',
|
||||
hideFromPreview: true,
|
||||
type: 'text',
|
||||
defaultValue: fathomSetupInstructions('All Events'),
|
||||
mode: 'trigger',
|
||||
condition: {
|
||||
field: 'selectedTriggerId',
|
||||
value: 'fathom_webhook',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
outputs: buildGenericOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -49,6 +49,49 @@ export const genericWebhookTrigger: TriggerConfig = {
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'idempotencyField',
|
||||
title: 'Deduplication Field (Optional)',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g. event.id',
|
||||
description:
|
||||
'Dot-notation path to a unique field in the payload for deduplication. If the same value is seen within 7 days, the duplicate webhook will be skipped.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'responseMode',
|
||||
title: 'Acknowledgement',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Default', id: 'default' },
|
||||
{ label: 'Custom', id: 'custom' },
|
||||
],
|
||||
defaultValue: 'default',
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'responseStatusCode',
|
||||
title: 'Response Status Code',
|
||||
type: 'short-input',
|
||||
placeholder: '200 (default)',
|
||||
description:
|
||||
'HTTP status code (100–599) to return to the webhook caller. Defaults to 200 if empty or invalid.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
condition: { field: 'responseMode', value: 'custom' },
|
||||
},
|
||||
{
|
||||
id: 'responseBody',
|
||||
title: 'Response Body',
|
||||
type: 'code',
|
||||
language: 'json',
|
||||
placeholder: '{"ok": true}',
|
||||
description: 'JSON body to return to the webhook caller. Leave empty for no body.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
condition: { field: 'responseMode', value: 'custom' },
|
||||
},
|
||||
{
|
||||
id: 'inputFormat',
|
||||
title: 'Input Format',
|
||||
@@ -76,7 +119,7 @@ export const genericWebhookTrigger: TriggerConfig = {
|
||||
'The webhook will receive any HTTP method (GET, POST, PUT, DELETE, etc.).',
|
||||
'All request data (headers, body, query parameters) will be available in your workflow.',
|
||||
'If authentication is enabled, include the token in requests using either the custom header or "Authorization: Bearer TOKEN".',
|
||||
'Common fields like "event", "id", and "data" will be automatically extracted from the payload when available.',
|
||||
'To deduplicate incoming events, set the Deduplication Field to the dot-notation path of a unique identifier in the payload (e.g. "event.id"). Duplicate values within 7 days will be skipped.',
|
||||
]
|
||||
.map(
|
||||
(instruction, index) =>
|
||||
|
||||
@@ -30,6 +30,7 @@ export const gmailPollingTrigger: TriggerConfig = {
|
||||
description: 'Triggers when new emails are received in Gmail (requires Gmail credentials)',
|
||||
version: '1.0.0',
|
||||
icon: GmailIcon,
|
||||
polling: true,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@ export const imapPollingTrigger: TriggerConfig = {
|
||||
description: 'Triggers when new emails are received via IMAP (works with any email provider)',
|
||||
version: '1.0.0',
|
||||
icon: MailServerIcon,
|
||||
polling: true,
|
||||
|
||||
subBlocks: [
|
||||
// Connection settings
|
||||
|
||||
@@ -24,6 +24,7 @@ export const outlookPollingTrigger: TriggerConfig = {
|
||||
description: 'Triggers when new emails are received in Outlook (requires Microsoft credentials)',
|
||||
version: '1.0.0',
|
||||
icon: OutlookIcon,
|
||||
polling: true,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
confluenceSpaceUpdatedTrigger,
|
||||
confluenceWebhookTrigger,
|
||||
} from '@/triggers/confluence'
|
||||
import { fathomNewMeetingTrigger, fathomWebhookTrigger } from '@/triggers/fathom'
|
||||
import { firefliesTranscriptionCompleteTrigger } from '@/triggers/fireflies'
|
||||
import { genericWebhookTrigger } from '@/triggers/generic'
|
||||
import {
|
||||
@@ -226,6 +227,8 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
|
||||
github_release_published: githubReleasePublishedTrigger,
|
||||
github_workflow_run: githubWorkflowRunTrigger,
|
||||
fireflies_transcription_complete: firefliesTranscriptionCompleteTrigger,
|
||||
fathom_new_meeting: fathomNewMeetingTrigger,
|
||||
fathom_webhook: fathomWebhookTrigger,
|
||||
gmail_poller: gmailPollingTrigger,
|
||||
grain_webhook: grainWebhookTrigger,
|
||||
grain_recording_created: grainRecordingCreatedTrigger,
|
||||
|
||||
@@ -8,6 +8,7 @@ export const rssPollingTrigger: TriggerConfig = {
|
||||
description: 'Triggers when new items are published to an RSS feed',
|
||||
version: '1.0.0',
|
||||
icon: RssIcon,
|
||||
polling: true,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
|
||||
@@ -25,6 +25,9 @@ export interface TriggerConfig {
|
||||
method?: 'POST' | 'GET' | 'PUT' | 'DELETE'
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
/** When true, this trigger is poll-based (cron-driven) rather than push-based. */
|
||||
polling?: boolean
|
||||
}
|
||||
|
||||
export interface TriggerRegistry {
|
||||
|
||||
Reference in New Issue
Block a user