mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c12914d35 | ||
|
|
d90f828e88 | ||
|
|
a8bbab2d21 | ||
|
|
72bb7e6945 | ||
|
|
4cb0f4a2b0 | ||
|
|
fdd587d6af | ||
|
|
e9bdc57616 | ||
|
|
e7b4da2689 | ||
|
|
aa0101c666 | ||
|
|
c939f8a76e | ||
|
|
0b19ad0013 | ||
|
|
3d5141d852 | ||
|
|
75832ca007 | ||
|
|
97f78c60b4 | ||
|
|
9295499405 | ||
|
|
6bcbd15ee6 |
@@ -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'>
|
||||
@@ -3554,6 +3572,27 @@ export const ResendIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const GoogleAdsIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
|
||||
<g transform='matrix(.257748 0 0 .257745 -.361416 2.515516)'>
|
||||
<path
|
||||
d='M85.9 28.6c2.4-6.3 5.7-12.1 10.6-16.8 19.6-19.1 52-14.3 65.3 9.7 10 18.2 20.6 36 30.9 54l51.6 89.8c14.3 25.1-1.2 56.8-29.6 61.1-17.4 2.6-33.7-5.4-42.7-21l-45.4-78.8c-.3-.6-.7-1.1-1.1-1.6-1.6-1.3-2.3-3.2-3.3-4.9L88.8 62.2c-3.9-6.8-5.7-14.2-5.5-22 .3-4 .8-8 2.6-11.6'
|
||||
fill='#3c8bd9'
|
||||
/>
|
||||
<path
|
||||
d='M85.9 28.6c-.9 3.6-1.7 7.2-1.9 11-.3 8.4 1.8 16.2 6 23.5l32.9 56.9c1 1.7 1.8 3.4 2.8 5l-18.1 31.1-25.3 43.6c-.4 0-.5-.2-.6-.5-.1-.8.2-1.5.4-2.3 4.1-15 .7-28.3-9.6-39.7-6.3-6.9-14.3-10.8-23.5-12.1-12-1.7-22.6 1.4-32.1 8.9-1.7 1.3-2.8 3.2-4.8 4.2-.4 0-.6-.2-.7-.5l14.3-24.9L85.2 29.7c.2-.4.5-.7.7-1.1'
|
||||
fill='#fabc04'
|
||||
/>
|
||||
<path
|
||||
d='M11.8 158l5.7-5.1c24.3-19.2 60.8-5.3 66.1 25.1 1.3 7.3.6 14.3-1.6 21.3-.1.6-.2 1.1-.4 1.7-.9 1.6-1.7 3.3-2.7 4.9-8.9 14.7-22 22-39.2 20.9C20 225.4 4.5 210.6 1.8 191c-1.3-9.5.6-18.4 5.5-26.6 1-1.8 2.2-3.4 3.3-5.2.5-.4.3-1.2 1.2-1.2'
|
||||
fill='#34a852'
|
||||
/>
|
||||
<path d='M11.8 158c-.4.4-.4 1.1-1.1 1.2-.1-.7.3-1.1.7-1.6l.4.4' fill='#fabc04' />
|
||||
<path d='M81.6 201c-.4-.7 0-1.2.4-1.7l.4.4-.8 1.3' fill='#e1c025' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const GoogleBigQueryIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
|
||||
<path
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
EvernoteIcon,
|
||||
ExaAIIcon,
|
||||
EyeIcon,
|
||||
FathomIcon,
|
||||
FirecrawlIcon,
|
||||
FirefliesIcon,
|
||||
GammaIcon,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
GitLabIcon,
|
||||
GmailIcon,
|
||||
GongIcon,
|
||||
GoogleAdsIcon,
|
||||
GoogleBigQueryIcon,
|
||||
GoogleBooksIcon,
|
||||
GoogleCalendarIcon,
|
||||
@@ -206,6 +208,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
enrich: EnrichSoIcon,
|
||||
evernote: EvernoteIcon,
|
||||
exa: ExaAIIcon,
|
||||
fathom: FathomIcon,
|
||||
file_v3: DocumentIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
fireflies_v2: FirefliesIcon,
|
||||
@@ -214,6 +217,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
gitlab: GitLabIcon,
|
||||
gmail_v2: GmailIcon,
|
||||
gong: GongIcon,
|
||||
google_ads: GoogleAdsIcon,
|
||||
google_bigquery: GoogleBigQueryIcon,
|
||||
google_books: GoogleBooksIcon,
|
||||
google_calendar_v2: GoogleCalendarIcon,
|
||||
|
||||
@@ -22,6 +22,8 @@ With Ashby, you can:
|
||||
- **List and view jobs**: Browse all open, closed, and archived job postings with location and department info
|
||||
- **List applications**: View all applications across your organization with candidate and job details, status tracking, and pagination
|
||||
|
||||
The Ashby block also supports **webhook triggers** that automatically start workflows in response to Ashby events. Available triggers include Application Submitted, Candidate Stage Change, Candidate Hired, Candidate Deleted, Job Created, and Offer Created. Webhooks are fully managed — Sim automatically creates the webhook in Ashby when you save the trigger and deletes it when you remove it, so there's no manual webhook configuration needed. Just provide your Ashby API key (with `apiKeysWrite` permission) and select the event type.
|
||||
|
||||
In Sim, the Ashby integration enables your agents to programmatically manage your recruiting pipeline. Agents can search for candidates, create new candidate records, add notes after interviews, and monitor applications across jobs. This allows you to automate recruiting workflows like candidate intake, interview follow-ups, pipeline reporting, and cross-referencing candidates across roles.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
@@ -10,6 +10,21 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
color="#E0E0E0"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Evernote](https://evernote.com/) is a note-taking and organization platform that helps individuals and teams capture ideas, manage projects, and store information across devices. With notebooks, tags, and powerful search, Evernote serves as a central hub for knowledge management.
|
||||
|
||||
With the Sim Evernote integration, you can:
|
||||
|
||||
- **Create and update notes**: Programmatically create new notes with content and tags, or update existing notes in any notebook.
|
||||
- **Search and retrieve notes**: Use Evernote's search grammar to find notes by keyword, tag, notebook, or other criteria, and retrieve full note content.
|
||||
- **Organize with notebooks and tags**: Create notebooks and tags, list existing ones, and move or copy notes between notebooks.
|
||||
- **Delete and manage notes**: Move notes to trash or copy them to different notebooks as part of automated workflows.
|
||||
|
||||
**How it works in Sim:**
|
||||
Add an Evernote block to your workflow and select an operation (e.g., create note, search notes, list notebooks). Provide your Evernote developer token and any required parameters. The block calls the Evernote API and returns structured data you can pass to downstream blocks — for example, searching for meeting notes and sending summaries to Slack, or creating notes from AI-generated content.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate with Evernote to manage notes, notebooks, and tags. Create, read, update, copy, search, and delete notes. Create and list notebooks and tags.
|
||||
|
||||
150
apps/docs/content/docs/en/tools/fathom.mdx
Normal file
150
apps/docs/content/docs/en/tools/fathom.mdx
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
title: Fathom
|
||||
description: Access meeting recordings, transcripts, and summaries
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="fathom"
|
||||
color="#181C1E"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Fathom](https://fathom.video/) is an AI meeting assistant that automatically records, transcribes, and summarizes your video calls. It works across platforms like Zoom, Google Meet, and Microsoft Teams, generating highlights and action items so your team can stay focused during meetings and catch up quickly afterward.
|
||||
|
||||
With the Sim Fathom integration, you can:
|
||||
|
||||
- **List and filter meetings**: Retrieve recent meetings recorded by you or shared with your team, with optional filters by date range, recorder, or team.
|
||||
- **Get meeting summaries**: Pull structured, markdown-formatted summaries for any recorded meeting to quickly review key discussion points.
|
||||
- **Access full transcripts**: Retrieve complete transcripts with speaker attribution and timestamps for detailed review or downstream processing.
|
||||
- **Manage teams and members**: List teams in your Fathom organization and view team member details to coordinate meeting workflows.
|
||||
|
||||
**How it works in Sim:**
|
||||
Add a Fathom block to your workflow and select an operation. Provide your Fathom API key and any required parameters (such as a recording ID for summaries and transcripts). The block calls the Fathom API and returns structured data you can pass to downstream blocks — for example, sending a summary to Slack or extracting action items with an AI agent.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## 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 |
|
||||
|
||||
|
||||
192
apps/docs/content/docs/en/tools/google_ads.mdx
Normal file
192
apps/docs/content/docs/en/tools/google_ads.mdx
Normal file
@@ -0,0 +1,192 @@
|
||||
---
|
||||
title: Google Ads
|
||||
description: Query campaigns, ad groups, and performance metrics
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="google_ads"
|
||||
color="#E0E0E0"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Google Ads](https://ads.google.com) is Google's online advertising platform that lets businesses create ads to reach customers across Google Search, YouTube, Gmail, and millions of partner websites. It supports campaign types including Search, Display, Video, Shopping, and Performance Max, with detailed targeting, bidding strategies, and performance analytics.
|
||||
|
||||
In Sim, the Google Ads integration enables your agents to query campaign data, monitor ad group performance, and pull detailed metrics using the Google Ads Query Language (GAQL). This supports use cases such as automated performance reporting, budget monitoring, campaign health checks, and data-driven optimization workflows. By connecting Sim with Google Ads, your agents can retrieve real-time advertising data and act on insights without manual dashboard navigation.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect to Google Ads to list accessible accounts, list campaigns, view ad group details, get performance metrics, and run custom GAQL queries.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `google_ads_list_customers`
|
||||
|
||||
List all Google Ads customer accounts accessible by the authenticated user
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `developerToken` | string | Yes | Google Ads API developer token |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `customerIds` | array | List of accessible customer IDs |
|
||||
| `totalCount` | number | Total number of accessible customer accounts |
|
||||
|
||||
### `google_ads_search`
|
||||
|
||||
Run a custom Google Ads Query Language (GAQL) query
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `customerId` | string | Yes | Google Ads customer ID \(numeric, no dashes\) |
|
||||
| `developerToken` | string | Yes | Google Ads API developer token |
|
||||
| `managerCustomerId` | string | No | Manager account customer ID \(if accessing via manager account\) |
|
||||
| `query` | string | Yes | GAQL query to execute |
|
||||
| `pageToken` | string | No | Page token for pagination |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `results` | json | Array of result objects from the GAQL query |
|
||||
| `totalResultsCount` | number | Total number of matching results |
|
||||
| `nextPageToken` | string | Token for the next page of results |
|
||||
|
||||
### `google_ads_list_campaigns`
|
||||
|
||||
List campaigns in a Google Ads account with optional status filtering
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `customerId` | string | Yes | Google Ads customer ID \(numeric, no dashes\) |
|
||||
| `developerToken` | string | Yes | Google Ads API developer token |
|
||||
| `managerCustomerId` | string | No | Manager account customer ID \(if accessing via manager account\) |
|
||||
| `status` | string | No | Filter by campaign status \(ENABLED, PAUSED, REMOVED\) |
|
||||
| `limit` | number | No | Maximum number of campaigns to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `campaigns` | array | List of campaigns in the account |
|
||||
| ↳ `id` | string | Campaign ID |
|
||||
| ↳ `name` | string | Campaign name |
|
||||
| ↳ `status` | string | Campaign status \(ENABLED, PAUSED, REMOVED\) |
|
||||
| ↳ `channelType` | string | Advertising channel type \(SEARCH, DISPLAY, SHOPPING, VIDEO, PERFORMANCE_MAX\) |
|
||||
| ↳ `startDate` | string | Campaign start date \(YYYY-MM-DD\) |
|
||||
| ↳ `endDate` | string | Campaign end date \(YYYY-MM-DD\) |
|
||||
| ↳ `budgetAmountMicros` | string | Daily budget in micros \(divide by 1,000,000 for currency value\) |
|
||||
| `totalCount` | number | Total number of campaigns returned |
|
||||
|
||||
### `google_ads_campaign_performance`
|
||||
|
||||
Get performance metrics for Google Ads campaigns over a date range
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `customerId` | string | Yes | Google Ads customer ID \(numeric, no dashes\) |
|
||||
| `developerToken` | string | Yes | Google Ads API developer token |
|
||||
| `managerCustomerId` | string | No | Manager account customer ID \(if accessing via manager account\) |
|
||||
| `campaignId` | string | No | Filter by specific campaign ID |
|
||||
| `dateRange` | string | No | Predefined date range \(LAST_7_DAYS, LAST_30_DAYS, THIS_MONTH, LAST_MONTH, TODAY, YESTERDAY\) |
|
||||
| `startDate` | string | No | Custom start date in YYYY-MM-DD format |
|
||||
| `endDate` | string | No | Custom end date in YYYY-MM-DD format |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `campaigns` | array | Campaign performance data broken down by date |
|
||||
| ↳ `id` | string | Campaign ID |
|
||||
| ↳ `name` | string | Campaign name |
|
||||
| ↳ `status` | string | Campaign status |
|
||||
| ↳ `impressions` | string | Number of impressions |
|
||||
| ↳ `clicks` | string | Number of clicks |
|
||||
| ↳ `costMicros` | string | Cost in micros \(divide by 1,000,000 for currency value\) |
|
||||
| ↳ `ctr` | number | Click-through rate \(0.0 to 1.0\) |
|
||||
| ↳ `conversions` | number | Number of conversions |
|
||||
| ↳ `date` | string | Date for this row \(YYYY-MM-DD\) |
|
||||
| `totalCount` | number | Total number of result rows |
|
||||
|
||||
### `google_ads_list_ad_groups`
|
||||
|
||||
List ad groups in a Google Ads campaign
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `customerId` | string | Yes | Google Ads customer ID \(numeric, no dashes\) |
|
||||
| `developerToken` | string | Yes | Google Ads API developer token |
|
||||
| `managerCustomerId` | string | No | Manager account customer ID \(if accessing via manager account\) |
|
||||
| `campaignId` | string | Yes | Campaign ID to list ad groups for |
|
||||
| `status` | string | No | Filter by ad group status \(ENABLED, PAUSED, REMOVED\) |
|
||||
| `limit` | number | No | Maximum number of ad groups to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `adGroups` | array | List of ad groups in the campaign |
|
||||
| ↳ `id` | string | Ad group ID |
|
||||
| ↳ `name` | string | Ad group name |
|
||||
| ↳ `status` | string | Ad group status \(ENABLED, PAUSED, REMOVED\) |
|
||||
| ↳ `type` | string | Ad group type \(SEARCH_STANDARD, DISPLAY_STANDARD, SHOPPING_PRODUCT_ADS\) |
|
||||
| ↳ `campaignId` | string | Parent campaign ID |
|
||||
| ↳ `campaignName` | string | Parent campaign name |
|
||||
| `totalCount` | number | Total number of ad groups returned |
|
||||
|
||||
### `google_ads_ad_performance`
|
||||
|
||||
Get performance metrics for individual ads over a date range
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `customerId` | string | Yes | Google Ads customer ID \(numeric, no dashes\) |
|
||||
| `developerToken` | string | Yes | Google Ads API developer token |
|
||||
| `managerCustomerId` | string | No | Manager account customer ID \(if accessing via manager account\) |
|
||||
| `campaignId` | string | No | Filter by campaign ID |
|
||||
| `adGroupId` | string | No | Filter by ad group ID |
|
||||
| `dateRange` | string | No | Predefined date range \(LAST_7_DAYS, LAST_30_DAYS, THIS_MONTH, LAST_MONTH, TODAY, YESTERDAY\) |
|
||||
| `startDate` | string | No | Custom start date in YYYY-MM-DD format |
|
||||
| `endDate` | string | No | Custom end date in YYYY-MM-DD format |
|
||||
| `limit` | number | No | Maximum number of results to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ads` | array | Ad performance data broken down by date |
|
||||
| ↳ `adId` | string | Ad ID |
|
||||
| ↳ `adGroupId` | string | Parent ad group ID |
|
||||
| ↳ `adGroupName` | string | Parent ad group name |
|
||||
| ↳ `campaignId` | string | Parent campaign ID |
|
||||
| ↳ `campaignName` | string | Parent campaign name |
|
||||
| ↳ `adType` | string | Ad type \(RESPONSIVE_SEARCH_AD, EXPANDED_TEXT_AD, etc.\) |
|
||||
| ↳ `impressions` | string | Number of impressions |
|
||||
| ↳ `clicks` | string | Number of clicks |
|
||||
| ↳ `costMicros` | string | Cost in micros \(divide by 1,000,000 for currency value\) |
|
||||
| ↳ `ctr` | number | Click-through rate \(0.0 to 1.0\) |
|
||||
| ↳ `conversions` | number | Number of conversions |
|
||||
| ↳ `date` | string | Date for this row \(YYYY-MM-DD\) |
|
||||
| `totalCount` | number | Total number of result rows |
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"enrich",
|
||||
"evernote",
|
||||
"exa",
|
||||
"fathom",
|
||||
"file",
|
||||
"firecrawl",
|
||||
"fireflies",
|
||||
@@ -45,6 +46,7 @@
|
||||
"gitlab",
|
||||
"gmail",
|
||||
"gong",
|
||||
"google_ads",
|
||||
"google_bigquery",
|
||||
"google_books",
|
||||
"google_calendar",
|
||||
|
||||
@@ -10,6 +10,22 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
color="#0F0F0F"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Obsidian](https://obsidian.md/) is a powerful knowledge base and note-taking application that works on top of a local folder of plain-text Markdown files. With features like bidirectional linking, graph views, and a rich plugin ecosystem, Obsidian is widely used for personal knowledge management, research, and documentation.
|
||||
|
||||
With the Sim Obsidian integration, you can:
|
||||
|
||||
- **Read and create notes**: Retrieve note content from your vault or create new notes programmatically as part of automated workflows.
|
||||
- **Update and patch notes**: Modify existing notes in full or patch content at specific locations within a note.
|
||||
- **Search your vault**: Find notes by keyword or content across your entire Obsidian vault.
|
||||
- **Manage periodic notes**: Access and create daily or other periodic notes for journaling and task tracking.
|
||||
- **Execute commands**: Trigger Obsidian commands remotely to automate vault operations.
|
||||
|
||||
**How it works in Sim:**
|
||||
Add an Obsidian block to your workflow and select an operation. This integration requires the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin to be installed and running in your vault. Provide your API key and vault URL, along with any required parameters. The block communicates with your local Obsidian instance and returns structured data you can pass to downstream blocks — for example, searching your vault for research notes and feeding them into an AI agent for summarization.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Read, create, update, search, and delete notes in your Obsidian vault. Manage periodic notes, execute commands, and patch content at specific locations. Requires the Obsidian Local REST API plugin.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
isTerminalState,
|
||||
parseWorkflowSSEChunk,
|
||||
} from '@/lib/a2a/utils'
|
||||
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
|
||||
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
@@ -242,9 +242,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
|
||||
const { id, method, params: rpcParams } = body
|
||||
const requestApiKey = request.headers.get('X-API-Key')
|
||||
const apiKey = authenticatedAuthType === 'api_key' ? requestApiKey : null
|
||||
const apiKey = authenticatedAuthType === AuthType.API_KEY ? requestApiKey : null
|
||||
const isPersonalApiKeyCaller =
|
||||
authenticatedAuthType === 'api_key' && authenticatedApiKeyType === 'personal'
|
||||
authenticatedAuthType === AuthType.API_KEY && authenticatedApiKeyType === 'personal'
|
||||
const billedUserId = await getWorkspaceBilledAccountUserId(agent.workspaceId)
|
||||
if (!billedUserId) {
|
||||
logger.error('Unable to resolve workspace billed account for A2A execution', {
|
||||
|
||||
@@ -24,6 +24,7 @@ const { mockCheckSessionOrInternalAuth, mockLogger } = vi.hoisted(() => {
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
}))
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock('@/lib/auth/credential-access', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkHybridAuth: vi.fn(),
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
checkInternalAuth: vi.fn(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || auth.authType !== 'session' || !auth.userId) {
|
||||
if (!auth.success || auth.authType !== AuthType.SESSION || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized request for credentialAccountUserId path`, {
|
||||
success: auth.success,
|
||||
authType: auth.authType,
|
||||
@@ -202,7 +202,7 @@ export async function GET(request: NextRequest) {
|
||||
credentialId,
|
||||
requireWorkflowIdForInternal: false,
|
||||
})
|
||||
if (!authz.ok || authz.authType !== 'session' || !authz.credentialOwnerUserId) {
|
||||
if (!authz.ok || authz.authType !== AuthType.SESSION || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ vi.mock('@/lib/auth', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkHybridAuth: mocks.mockCheckHybridAuth,
|
||||
checkSessionOrInternalAuth: mocks.mockCheckSessionOrInternalAuth,
|
||||
checkInternalAuth: mocks.mockCheckInternalAuth,
|
||||
|
||||
@@ -106,6 +106,7 @@ vi.mock('@/lib/auth', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkInternalAuth: mockCheckInternalAuth,
|
||||
checkHybridAuth: mockCheckHybridAuth,
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
|
||||
@@ -49,6 +49,7 @@ vi.mock('fs/promises', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
}))
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ vi.mock('@/lib/auth', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkHybridAuth: mocks.mockCheckHybridAuth,
|
||||
checkSessionOrInternalAuth: mocks.mockCheckSessionOrInternalAuth,
|
||||
checkInternalAuth: mocks.mockCheckInternalAuth,
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock('@/lib/execution/isolated-vm', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkInternalAuth: mockCheckInternalAuth,
|
||||
}))
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants'
|
||||
import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
|
||||
@@ -25,7 +25,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
// For session auth, verify KB access. Internal JWT is trusted.
|
||||
if (auth.authType === 'session' && auth.userId) {
|
||||
if (auth.authType === AuthType.SESSION && auth.userId) {
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
@@ -62,7 +62,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
}
|
||||
|
||||
// For session auth, verify KB access. Internal JWT is trusted.
|
||||
if (auth.authType === 'session' && auth.userId) {
|
||||
if (auth.authType === AuthType.SESSION && auth.userId) {
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
@@ -68,6 +68,7 @@ vi.mock('@sim/db', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
}))
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ vi.mock('@sim/db/schema', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkHybridAuth: mockCheckHybridAuth,
|
||||
checkSessionOrInternalAuth: vi.fn(),
|
||||
checkInternalAuth: vi.fn(),
|
||||
|
||||
@@ -19,7 +19,7 @@ import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
|
||||
@@ -137,7 +137,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
executeAuthContext = {
|
||||
authType: auth.authType,
|
||||
userId: auth.userId,
|
||||
apiKey: auth.authType === 'api_key' ? request.headers.get('X-API-Key') : null,
|
||||
apiKey: auth.authType === AuthType.API_KEY ? request.headers.get('X-API-Key') : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ async function handleToolsCall(
|
||||
const internalToken = await generateInternalToken(publicServerOwnerId)
|
||||
headers.Authorization = `Bearer ${internalToken}`
|
||||
} else if (executeAuthContext) {
|
||||
if (executeAuthContext.authType === 'api_key' && executeAuthContext.apiKey) {
|
||||
if (executeAuthContext.authType === AuthType.API_KEY && executeAuthContext.apiKey) {
|
||||
headers['X-API-Key'] = executeAuthContext.apiKey
|
||||
} else {
|
||||
const internalToken = await generateInternalToken(executeAuthContext.userId)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { AuthType } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
@@ -39,7 +40,7 @@ export async function POST(
|
||||
|
||||
const resumeInput = payload?.input ?? payload ?? {}
|
||||
const isPersonalApiKeyCaller =
|
||||
access.auth?.authType === 'api_key' && access.auth?.apiKeyType === 'personal'
|
||||
access.auth?.authType === AuthType.API_KEY && access.auth?.apiKeyType === 'personal'
|
||||
|
||||
let userId: string
|
||||
if (isPersonalApiKeyCaller && access.auth?.userId) {
|
||||
|
||||
@@ -182,6 +182,7 @@ vi.mock('@/lib/auth', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkSessionOrInternalAuth: (...args: unknown[]) => mockCheckSessionOrInternalAuth(...args),
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage'
|
||||
@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
|
||||
const rateLimiter = new RateLimiter()
|
||||
const triggerType = auth.authType === 'api_key' ? 'api' : 'manual'
|
||||
const triggerType = auth.authType === AuthType.API_KEY ? 'api' : 'manual'
|
||||
const [syncStatus, asyncStatus] = await Promise.all([
|
||||
rateLimiter.getRateLimitStatusWithSubscription(
|
||||
authenticatedUserId,
|
||||
|
||||
@@ -268,6 +268,32 @@ vi.mock('@/lib/webhooks/processor', () => ({
|
||||
}
|
||||
}),
|
||||
handleProviderChallenges: vi.fn().mockResolvedValue(null),
|
||||
handlePreLookupWebhookVerification: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async (
|
||||
method: string,
|
||||
body: Record<string, unknown> | undefined,
|
||||
_requestId: string,
|
||||
path: string
|
||||
) => {
|
||||
if (path !== 'pending-verification-path') {
|
||||
return null
|
||||
}
|
||||
|
||||
const isVerificationProbe =
|
||||
method === 'GET' ||
|
||||
method === 'HEAD' ||
|
||||
(method === 'POST' && (!body || Object.keys(body).length === 0 || !body.type))
|
||||
|
||||
if (!isVerificationProbe) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { NextResponse } = require('next/server')
|
||||
return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' })
|
||||
}
|
||||
),
|
||||
handleProviderReachabilityTest: vi.fn().mockReturnValue(null),
|
||||
verifyProviderAuth: vi
|
||||
.fn()
|
||||
@@ -353,7 +379,7 @@ vi.mock('@/lib/core/utils/request', () => requestUtilsMock)
|
||||
|
||||
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
|
||||
|
||||
import { POST } from '@/app/api/webhooks/trigger/[path]/route'
|
||||
import { GET, POST } from '@/app/api/webhooks/trigger/[path]/route'
|
||||
|
||||
describe('Webhook Trigger API Route', () => {
|
||||
beforeEach(() => {
|
||||
@@ -389,7 +415,7 @@ describe('Webhook Trigger API Route', () => {
|
||||
})
|
||||
|
||||
it('should handle 404 for non-existent webhooks', async () => {
|
||||
const req = createMockRequest('POST', { event: 'test' })
|
||||
const req = createMockRequest('POST', { type: 'event.test' })
|
||||
|
||||
const params = Promise.resolve({ path: 'non-existent-path' })
|
||||
|
||||
@@ -401,6 +427,72 @@ describe('Webhook Trigger API Route', () => {
|
||||
expect(text).toMatch(/not found/i)
|
||||
})
|
||||
|
||||
it('should return 405 for GET requests on unknown webhook paths', async () => {
|
||||
const req = createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{},
|
||||
'http://localhost:3000/api/webhooks/trigger/non-existent-path'
|
||||
)
|
||||
|
||||
const params = Promise.resolve({ path: 'non-existent-path' })
|
||||
|
||||
const response = await GET(req as any, { params })
|
||||
|
||||
expect(response.status).toBe(405)
|
||||
})
|
||||
|
||||
it('should return 200 for GET verification probes on registered pending paths', async () => {
|
||||
const req = createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{},
|
||||
'http://localhost:3000/api/webhooks/trigger/pending-verification-path'
|
||||
)
|
||||
|
||||
const params = Promise.resolve({ path: 'pending-verification-path' })
|
||||
|
||||
const response = await GET(req as any, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
message: 'Webhook endpoint verified',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 200 for empty POST verification probes on registered pending paths', async () => {
|
||||
const req = createMockRequest(
|
||||
'POST',
|
||||
undefined,
|
||||
{},
|
||||
'http://localhost:3000/api/webhooks/trigger/pending-verification-path'
|
||||
)
|
||||
|
||||
const params = Promise.resolve({ path: 'pending-verification-path' })
|
||||
|
||||
const response = await POST(req as any, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
message: 'Webhook endpoint verified',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 404 for POST requests without type on unknown webhook paths', async () => {
|
||||
const req = createMockRequest('POST', { event: 'test' })
|
||||
|
||||
const params = Promise.resolve({ path: 'non-existent-path' })
|
||||
|
||||
const response = await POST(req as any, { params })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
|
||||
const text = await response.text()
|
||||
expect(text).toMatch(/not found/i)
|
||||
})
|
||||
|
||||
describe('Generic Webhook Authentication', () => {
|
||||
it('should process generic webhook without authentication', async () => {
|
||||
testData.webhooks.push({
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
checkWebhookPreprocessing,
|
||||
findAllWebhooksForPath,
|
||||
handlePreDeploymentVerification,
|
||||
handlePreLookupWebhookVerification,
|
||||
handleProviderChallenges,
|
||||
handleProviderReachabilityTest,
|
||||
parseWebhookBody,
|
||||
@@ -30,7 +31,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return challengeResponse
|
||||
}
|
||||
|
||||
return new NextResponse('Method not allowed', { status: 405 })
|
||||
return (
|
||||
(await handlePreLookupWebhookVerification(request.method, undefined, requestId, path)) ||
|
||||
new NextResponse('Method not allowed', { status: 405 })
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
@@ -64,6 +68,16 @@ export async function POST(
|
||||
const webhooksForPath = await findAllWebhooksForPath({ requestId, path })
|
||||
|
||||
if (webhooksForPath.length === 0) {
|
||||
const verificationResponse = await handlePreLookupWebhookVerification(
|
||||
request.method,
|
||||
body,
|
||||
requestId,
|
||||
path
|
||||
)
|
||||
if (verificationResponse) {
|
||||
return verificationResponse
|
||||
}
|
||||
|
||||
logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`)
|
||||
return new NextResponse('Not Found', { status: 404 })
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ vi.mock('@sim/db/schema', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
}))
|
||||
|
||||
|
||||
115
apps/sim/app/api/workflows/[id]/execute/response-block.test.ts
Normal file
115
apps/sim/app/api/workflows/[id]/execute/response-block.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Tests that internal JWT callers receive the standard response format
|
||||
* even when the child workflow has a Response block.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { AuthType } from '@/lib/auth/hybrid'
|
||||
import type { ExecutionResult } from '@/lib/workflows/types'
|
||||
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
|
||||
|
||||
function buildExecutionResult(overrides: Partial<ExecutionResult> = {}): ExecutionResult {
|
||||
return {
|
||||
success: true,
|
||||
output: { data: { issues: [] }, status: 200, headers: {} },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'response-1',
|
||||
blockType: 'response',
|
||||
blockName: 'Response',
|
||||
success: true,
|
||||
output: { data: { issues: [] }, status: 200, headers: {} },
|
||||
startedAt: '2026-01-01T00:00:00Z',
|
||||
endedAt: '2026-01-01T00:00:01Z',
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
duration: 500,
|
||||
startTime: '2026-01-01T00:00:00Z',
|
||||
endTime: '2026-01-01T00:00:01Z',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Response block gating by auth type', () => {
|
||||
let resultWithResponseBlock: ExecutionResult
|
||||
|
||||
beforeEach(() => {
|
||||
resultWithResponseBlock = buildExecutionResult()
|
||||
})
|
||||
|
||||
it('should detect a Response block in execution result', () => {
|
||||
expect(workflowHasResponseBlock(resultWithResponseBlock)).toBe(true)
|
||||
})
|
||||
|
||||
it('should not detect a Response block when none exists', () => {
|
||||
const resultWithoutResponseBlock = buildExecutionResult({
|
||||
output: { result: 'hello' },
|
||||
logs: [
|
||||
{
|
||||
blockId: 'agent-1',
|
||||
blockType: 'agent',
|
||||
blockName: 'Agent',
|
||||
success: true,
|
||||
output: { result: 'hello' },
|
||||
startedAt: '2026-01-01T00:00:00Z',
|
||||
endedAt: '2026-01-01T00:00:01Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(workflowHasResponseBlock(resultWithoutResponseBlock)).toBe(false)
|
||||
})
|
||||
|
||||
it('should skip Response block formatting for internal JWT callers', () => {
|
||||
const authType = AuthType.INTERNAL_JWT
|
||||
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
|
||||
|
||||
expect(hasResponseBlock).toBe(true)
|
||||
|
||||
// This mirrors the route.ts condition:
|
||||
// if (auth.authType !== AuthType.INTERNAL_JWT && workflowHasResponseBlock(...))
|
||||
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
|
||||
expect(shouldFormatAsResponseBlock).toBe(false)
|
||||
})
|
||||
|
||||
it('should apply Response block formatting for API key callers', () => {
|
||||
const authType = AuthType.API_KEY
|
||||
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
|
||||
|
||||
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
|
||||
expect(shouldFormatAsResponseBlock).toBe(true)
|
||||
|
||||
const response = createHttpResponseFromBlock(resultWithResponseBlock)
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
it('should apply Response block formatting for session callers', () => {
|
||||
const authType = AuthType.SESSION
|
||||
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
|
||||
|
||||
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
|
||||
expect(shouldFormatAsResponseBlock).toBe(true)
|
||||
})
|
||||
|
||||
it('should return raw user data via createHttpResponseFromBlock', async () => {
|
||||
const response = createHttpResponseFromBlock(resultWithResponseBlock)
|
||||
const body = await response.json()
|
||||
|
||||
// Response block returns the user-defined data directly (no success/executionId wrapper)
|
||||
expect(body).toEqual({ issues: [] })
|
||||
expect(body.success).toBeUndefined()
|
||||
expect(body.executionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should respect custom status codes from Response block', () => {
|
||||
const result = buildExecutionResult({
|
||||
output: { data: { error: 'Not found' }, status: 404, headers: {} },
|
||||
})
|
||||
|
||||
const response = createHttpResponseFromBlock(result)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { validate as uuidValidate, v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
|
||||
import {
|
||||
createTimeoutAbortController,
|
||||
@@ -322,7 +322,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
)
|
||||
}
|
||||
|
||||
const defaultTriggerType = isPublicApiAccess || auth.authType === 'api_key' ? 'api' : 'manual'
|
||||
const defaultTriggerType =
|
||||
isPublicApiAccess || auth.authType === AuthType.API_KEY ? 'api' : 'manual'
|
||||
|
||||
const {
|
||||
selectedOutputs,
|
||||
@@ -381,7 +382,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
|
||||
// For session auth, the input is explicitly provided in the input field
|
||||
const input =
|
||||
isPublicApiAccess || auth.authType === 'api_key' || auth.authType === 'internal_jwt'
|
||||
isPublicApiAccess ||
|
||||
auth.authType === AuthType.API_KEY ||
|
||||
auth.authType === AuthType.INTERNAL_JWT
|
||||
? (() => {
|
||||
const {
|
||||
selectedOutputs,
|
||||
@@ -407,7 +410,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
// Public API callers always execute the deployed state, never the draft.
|
||||
const shouldUseDraftState = isPublicApiAccess
|
||||
? false
|
||||
: (useDraftState ?? auth.authType === 'session')
|
||||
: (useDraftState ?? auth.authType === AuthType.SESSION)
|
||||
const streamHeader = req.headers.get('X-Stream-Response') === 'true'
|
||||
const enableSSE = streamHeader || streamParam === true
|
||||
const executionModeHeader = req.headers.get('X-Execution-Mode')
|
||||
@@ -440,7 +443,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
// Client-side sessions and personal API keys bill/permission-check the
|
||||
// authenticated user, not the workspace billed account.
|
||||
const useAuthenticatedUserAsActor =
|
||||
isClientSession || (auth.authType === 'api_key' && auth.apiKeyType === 'personal')
|
||||
isClientSession || (auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal')
|
||||
|
||||
// Authorization fetches the full workflow record and checks workspace permissions.
|
||||
// Run it first so we can pass the record to preprocessing (eliminates a duplicate DB query).
|
||||
@@ -670,8 +673,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
const resultWithBase64 = { ...result, output: outputWithBase64 }
|
||||
|
||||
const hasResponseBlock = workflowHasResponseBlock(resultWithBase64)
|
||||
if (hasResponseBlock) {
|
||||
if (auth.authType !== AuthType.INTERNAL_JWT && workflowHasResponseBlock(resultWithBase64)) {
|
||||
return createHttpResponseFromBlock(resultWithBase64)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock('@sim/db/schema', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
}))
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ vi.mock('@/lib/auth', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkHybridAuth: (...args: unknown[]) => mockCheckHybridAuth(...args),
|
||||
checkSessionOrInternalAuth: (...args: unknown[]) => mockCheckSessionOrInternalAuth(...args),
|
||||
}))
|
||||
|
||||
@@ -5,7 +5,7 @@ import { and, eq, isNull, ne } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
@@ -39,7 +39,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const isInternalCall = auth.authType === 'internal_jwt'
|
||||
const isInternalCall = auth.authType === AuthType.INTERNAL_JWT
|
||||
const userId = auth.userId || null
|
||||
|
||||
let workflowData = await getWorkflowById(workflowId)
|
||||
|
||||
@@ -18,6 +18,7 @@ const { mockCheckSessionOrInternalAuth, mockAuthorizeWorkflowByWorkspacePermissi
|
||||
vi.mock('@/lib/audit/log', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
}))
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ vi.mock('@/lib/audit/log', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/hybrid', () => ({
|
||||
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
|
||||
checkHybridAuth: vi.fn(),
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
checkInternalAuth: vi.fn(),
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AshbyIcon } from '@/components/icons'
|
||||
import { AuthMode, type BlockConfig } from '@/blocks/types'
|
||||
import { getTrigger } from '@/triggers'
|
||||
|
||||
export const AshbyBlock: BlockConfig = {
|
||||
type: 'ashby',
|
||||
@@ -13,6 +14,18 @@ export const AshbyBlock: BlockConfig = {
|
||||
icon: AshbyIcon,
|
||||
authMode: AuthMode.ApiKey,
|
||||
|
||||
triggers: {
|
||||
enabled: true,
|
||||
available: [
|
||||
'ashby_application_submit',
|
||||
'ashby_candidate_stage_change',
|
||||
'ashby_candidate_hire',
|
||||
'ashby_candidate_delete',
|
||||
'ashby_job_create',
|
||||
'ashby_offer_create',
|
||||
],
|
||||
},
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
@@ -366,6 +379,14 @@ Output only the ISO 8601 timestamp string, nothing else.`,
|
||||
},
|
||||
mode: 'advanced',
|
||||
},
|
||||
|
||||
// Trigger subBlocks
|
||||
...getTrigger('ashby_application_submit').subBlocks,
|
||||
...getTrigger('ashby_candidate_stage_change').subBlocks,
|
||||
...getTrigger('ashby_candidate_hire').subBlocks,
|
||||
...getTrigger('ashby_candidate_delete').subBlocks,
|
||||
...getTrigger('ashby_job_create').subBlocks,
|
||||
...getTrigger('ashby_offer_create').subBlocks,
|
||||
],
|
||||
|
||||
tools: {
|
||||
|
||||
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'],
|
||||
},
|
||||
}
|
||||
294
apps/sim/blocks/blocks/google_ads.ts
Normal file
294
apps/sim/blocks/blocks/google_ads.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { GoogleAdsIcon } from '@/components/icons'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
|
||||
export const GoogleAdsBlock: BlockConfig = {
|
||||
type: 'google_ads',
|
||||
name: 'Google Ads',
|
||||
description: 'Query campaigns, ad groups, and performance metrics',
|
||||
longDescription:
|
||||
'Connect to Google Ads to list accessible accounts, list campaigns, view ad group details, get performance metrics, and run custom GAQL queries.',
|
||||
docsLink: 'https://docs.sim.ai/tools/google_ads',
|
||||
category: 'tools',
|
||||
bgColor: '#E0E0E0',
|
||||
icon: GoogleAdsIcon,
|
||||
authMode: AuthMode.OAuth,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'List Customers', id: 'list_customers' },
|
||||
{ label: 'List Campaigns', id: 'list_campaigns' },
|
||||
{ label: 'Campaign Performance', id: 'campaign_performance' },
|
||||
{ label: 'List Ad Groups', id: 'list_ad_groups' },
|
||||
{ label: 'Ad Performance', id: 'ad_performance' },
|
||||
{ label: 'Custom Query (GAQL)', id: 'search' },
|
||||
],
|
||||
value: () => 'list_campaigns',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'Google Ads Account',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'basic',
|
||||
required: true,
|
||||
serviceId: 'google-ads',
|
||||
requiredScopes: getScopesForService('google-ads'),
|
||||
placeholder: 'Select Google Ads account',
|
||||
},
|
||||
{
|
||||
id: 'manualCredential',
|
||||
title: 'Google Ads Account',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'oauthCredential',
|
||||
mode: 'advanced',
|
||||
placeholder: 'Enter credential ID',
|
||||
required: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'developerToken',
|
||||
title: 'Developer Token',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your Google Ads API developer token',
|
||||
required: true,
|
||||
password: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'customerId',
|
||||
title: 'Customer ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Google Ads customer ID (no dashes)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'list_customers',
|
||||
not: true,
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: 'list_customers',
|
||||
not: true,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'managerCustomerId',
|
||||
title: 'Manager Customer ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Manager account ID (optional)',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'list_customers',
|
||||
not: true,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'query',
|
||||
title: 'GAQL Query',
|
||||
type: 'long-input',
|
||||
placeholder:
|
||||
"SELECT campaign.id, campaign.name, metrics.impressions FROM campaign WHERE campaign.status = 'ENABLED'",
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
required: { field: 'operation', value: 'search' },
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Generate a Google Ads Query Language (GAQL) query based on the user's description.
|
||||
The query should:
|
||||
- Use valid GAQL syntax
|
||||
- Include relevant metrics when asking about performance
|
||||
- Include segments.date with a date range when using metrics
|
||||
- Be efficient and well-formatted
|
||||
|
||||
Common resources: campaign, ad_group, ad_group_ad, keyword_view, search_term_view
|
||||
Common metrics: metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.ctr, metrics.conversions
|
||||
Date ranges: LAST_7_DAYS, LAST_30_DAYS, THIS_MONTH, YESTERDAY
|
||||
|
||||
Examples:
|
||||
- "active campaigns" -> SELECT campaign.id, campaign.name, campaign.status FROM campaign WHERE campaign.status = 'ENABLED'
|
||||
- "campaign spend last week" -> SELECT campaign.name, metrics.cost_micros, segments.date FROM campaign WHERE segments.date DURING LAST_7_DAYS AND campaign.status != 'REMOVED'
|
||||
|
||||
Return ONLY the GAQL query - no explanations, no quotes, no extra text.`,
|
||||
placeholder: 'Describe the query you want to run...',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: 'campaignId',
|
||||
title: 'Campaign ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Campaign ID to filter by',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['campaign_performance', 'list_ad_groups', 'ad_performance'],
|
||||
},
|
||||
required: { field: 'operation', value: 'list_ad_groups' },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'adGroupId',
|
||||
title: 'Ad Group ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Ad group ID to filter by',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'ad_performance' },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'status',
|
||||
title: 'Status Filter',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All (except removed)', id: '' },
|
||||
{ label: 'Enabled', id: 'ENABLED' },
|
||||
{ label: 'Paused', id: 'PAUSED' },
|
||||
],
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: ['list_campaigns', 'list_ad_groups'] },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'dateRange',
|
||||
title: 'Date Range',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Last 30 Days', id: 'LAST_30_DAYS' },
|
||||
{ label: 'Last 7 Days', id: 'LAST_7_DAYS' },
|
||||
{ label: 'Today', id: 'TODAY' },
|
||||
{ label: 'Yesterday', id: 'YESTERDAY' },
|
||||
{ label: 'This Month', id: 'THIS_MONTH' },
|
||||
{ label: 'Last Month', id: 'LAST_MONTH' },
|
||||
{ label: 'Custom', id: 'CUSTOM' },
|
||||
],
|
||||
condition: { field: 'operation', value: ['campaign_performance', 'ad_performance'] },
|
||||
value: () => 'LAST_30_DAYS',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'startDate',
|
||||
title: 'Start Date',
|
||||
type: 'short-input',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
condition: { field: 'dateRange', value: 'CUSTOM' },
|
||||
required: { field: 'dateRange', value: 'CUSTOM' },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'endDate',
|
||||
title: 'End Date',
|
||||
type: 'short-input',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
condition: { field: 'dateRange', value: 'CUSTOM' },
|
||||
required: { field: 'dateRange', value: 'CUSTOM' },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'pageToken',
|
||||
title: 'Page Token',
|
||||
type: 'short-input',
|
||||
placeholder: 'Pagination token',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
},
|
||||
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
placeholder: 'Maximum results to return',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['list_campaigns', 'list_ad_groups', 'ad_performance'],
|
||||
},
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'google_ads_list_customers',
|
||||
'google_ads_search',
|
||||
'google_ads_list_campaigns',
|
||||
'google_ads_campaign_performance',
|
||||
'google_ads_list_ad_groups',
|
||||
'google_ads_ad_performance',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => `google_ads_${params.operation}`,
|
||||
params: (params) => {
|
||||
const { oauthCredential, dateRange, limit, ...rest } = params
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
...rest,
|
||||
oauthCredential,
|
||||
}
|
||||
|
||||
if (dateRange && dateRange !== 'CUSTOM') {
|
||||
result.dateRange = dateRange
|
||||
}
|
||||
|
||||
if (limit !== undefined && limit !== '') {
|
||||
result.limit = Number(limit)
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
oauthCredential: { type: 'string', description: 'Google Ads OAuth credential' },
|
||||
developerToken: { type: 'string', description: 'Google Ads API developer token' },
|
||||
customerId: { type: 'string', description: 'Google Ads customer ID (numeric, no dashes)' },
|
||||
managerCustomerId: { type: 'string', description: 'Manager account customer ID' },
|
||||
query: { type: 'string', description: 'GAQL query to execute' },
|
||||
campaignId: { type: 'string', description: 'Campaign ID to filter by' },
|
||||
adGroupId: { type: 'string', description: 'Ad group ID to filter by' },
|
||||
status: { type: 'string', description: 'Status filter (ENABLED, PAUSED)' },
|
||||
dateRange: { type: 'string', description: 'Date range for performance queries' },
|
||||
startDate: { type: 'string', description: 'Custom start date (YYYY-MM-DD)' },
|
||||
endDate: { type: 'string', description: 'Custom end date (YYYY-MM-DD)' },
|
||||
pageToken: { type: 'string', description: 'Pagination token' },
|
||||
limit: { type: 'number', description: 'Maximum results to return' },
|
||||
},
|
||||
outputs: {
|
||||
customerIds: {
|
||||
type: 'json',
|
||||
description: 'List of accessible customer IDs (list_customers)',
|
||||
},
|
||||
results: {
|
||||
type: 'json',
|
||||
description: 'Query results (search)',
|
||||
},
|
||||
campaigns: {
|
||||
type: 'json',
|
||||
description: 'Campaign data (list_campaigns, campaign_performance)',
|
||||
},
|
||||
adGroups: {
|
||||
type: 'json',
|
||||
description: 'Ad group data (list_ad_groups)',
|
||||
},
|
||||
ads: {
|
||||
type: 'json',
|
||||
description: 'Ad performance data (ad_performance)',
|
||||
},
|
||||
totalCount: {
|
||||
type: 'number',
|
||||
description: 'Total number of results',
|
||||
},
|
||||
totalResultsCount: {
|
||||
type: 'number',
|
||||
description: 'Total results count (search)',
|
||||
},
|
||||
nextPageToken: {
|
||||
type: 'string',
|
||||
description: 'Token for next page of results',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export const GrainBlock: BlockConfig = {
|
||||
{ label: 'List Recordings', id: 'grain_list_recordings' },
|
||||
{ label: 'Get Recording', id: 'grain_get_recording' },
|
||||
{ label: 'Get Transcript', id: 'grain_get_transcript' },
|
||||
{ label: 'List Views', id: 'grain_list_views' },
|
||||
{ label: 'List Teams', id: 'grain_list_teams' },
|
||||
{ label: 'List Meeting Types', id: 'grain_list_meeting_types' },
|
||||
{ label: 'Create Webhook', id: 'grain_create_hook' },
|
||||
@@ -72,7 +73,7 @@ export const GrainBlock: BlockConfig = {
|
||||
placeholder: 'ISO8601 timestamp (e.g., 2024-01-01T00:00:00Z)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['grain_list_recordings', 'grain_create_hook'],
|
||||
value: ['grain_list_recordings'],
|
||||
},
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
@@ -96,7 +97,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
placeholder: 'ISO8601 timestamp (e.g., 2024-01-01T00:00:00Z)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['grain_list_recordings', 'grain_create_hook'],
|
||||
value: ['grain_list_recordings'],
|
||||
},
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
@@ -125,7 +126,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
|
||||
value: () => '',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['grain_list_recordings', 'grain_create_hook'],
|
||||
value: ['grain_list_recordings'],
|
||||
},
|
||||
},
|
||||
// Title search
|
||||
@@ -162,7 +163,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
|
||||
placeholder: 'Filter by team UUID (optional)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['grain_list_recordings', 'grain_create_hook'],
|
||||
value: ['grain_list_recordings'],
|
||||
},
|
||||
},
|
||||
// Meeting type ID filter
|
||||
@@ -173,7 +174,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
|
||||
placeholder: 'Filter by meeting type UUID (optional)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['grain_list_recordings', 'grain_create_hook'],
|
||||
value: ['grain_list_recordings'],
|
||||
},
|
||||
},
|
||||
// Include highlights
|
||||
@@ -183,7 +184,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
|
||||
type: 'switch',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['grain_list_recordings', 'grain_get_recording', 'grain_create_hook'],
|
||||
value: ['grain_list_recordings', 'grain_get_recording'],
|
||||
},
|
||||
},
|
||||
// Include participants
|
||||
@@ -193,7 +194,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
|
||||
type: 'switch',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['grain_list_recordings', 'grain_get_recording', 'grain_create_hook'],
|
||||
value: ['grain_list_recordings', 'grain_get_recording'],
|
||||
},
|
||||
},
|
||||
// Include AI summary
|
||||
@@ -203,7 +204,18 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
|
||||
type: 'switch',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['grain_list_recordings', 'grain_get_recording', 'grain_create_hook'],
|
||||
value: ['grain_list_recordings', 'grain_get_recording'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'viewId',
|
||||
title: 'View ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter Grain view UUID',
|
||||
required: true,
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['grain_create_hook'],
|
||||
},
|
||||
},
|
||||
// Include calendar event (get_recording only)
|
||||
@@ -271,6 +283,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
|
||||
'grain_list_recordings',
|
||||
'grain_get_recording',
|
||||
'grain_get_transcript',
|
||||
'grain_list_views',
|
||||
'grain_list_teams',
|
||||
'grain_list_meeting_types',
|
||||
'grain_create_hook',
|
||||
@@ -327,6 +340,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
|
||||
|
||||
case 'grain_list_teams':
|
||||
case 'grain_list_meeting_types':
|
||||
case 'grain_list_views':
|
||||
case 'grain_list_hooks':
|
||||
return baseParams
|
||||
|
||||
@@ -334,17 +348,13 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
|
||||
if (!params.hookUrl?.trim()) {
|
||||
throw new Error('Webhook URL is required.')
|
||||
}
|
||||
if (!params.viewId?.trim()) {
|
||||
throw new Error('View ID is required.')
|
||||
}
|
||||
return {
|
||||
...baseParams,
|
||||
hookUrl: params.hookUrl.trim(),
|
||||
filterBeforeDatetime: params.beforeDatetime || undefined,
|
||||
filterAfterDatetime: params.afterDatetime || undefined,
|
||||
filterParticipantScope: params.participantScope || undefined,
|
||||
filterTeamId: params.teamId || undefined,
|
||||
filterMeetingTypeId: params.meetingTypeId || undefined,
|
||||
includeHighlights: params.includeHighlights || false,
|
||||
includeParticipants: params.includeParticipants || false,
|
||||
includeAiSummary: params.includeAiSummary || false,
|
||||
viewId: params.viewId.trim(),
|
||||
}
|
||||
|
||||
case 'grain_delete_hook':
|
||||
@@ -367,6 +377,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
|
||||
apiKey: { type: 'string', description: 'Grain API key (Personal Access Token)' },
|
||||
recordingId: { type: 'string', description: 'Recording UUID' },
|
||||
cursor: { type: 'string', description: 'Pagination cursor' },
|
||||
viewId: { type: 'string', description: 'Grain view UUID for webhook subscriptions' },
|
||||
beforeDatetime: {
|
||||
type: 'string',
|
||||
description: 'Filter recordings before this ISO8601 timestamp',
|
||||
@@ -416,6 +427,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
|
||||
teamsList: { type: 'json', description: 'Array of team objects' },
|
||||
// Meeting type outputs
|
||||
meetingTypes: { type: 'json', description: 'Array of meeting type objects' },
|
||||
views: { type: 'json', description: 'Array of Grain views' },
|
||||
// Hook outputs
|
||||
hooks: { type: 'json', description: 'Array of webhook objects' },
|
||||
hook: { type: 'json', description: 'Created webhook data' },
|
||||
|
||||
@@ -165,7 +165,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'issueKey',
|
||||
placeholder: 'Enter Jira issue key',
|
||||
dependsOn: ['credential', 'domain', 'projectId', 'manualProjectId'],
|
||||
dependsOn: ['credential', 'domain'],
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
|
||||
@@ -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'
|
||||
@@ -51,6 +52,7 @@ import { GitLabBlock } from '@/blocks/blocks/gitlab'
|
||||
import { GmailBlock, GmailV2Block } from '@/blocks/blocks/gmail'
|
||||
import { GongBlock } from '@/blocks/blocks/gong'
|
||||
import { GoogleSearchBlock } from '@/blocks/blocks/google'
|
||||
import { GoogleAdsBlock } from '@/blocks/blocks/google_ads'
|
||||
import { GoogleBigQueryBlock } from '@/blocks/blocks/google_bigquery'
|
||||
import { GoogleBooksBlock } from '@/blocks/blocks/google_books'
|
||||
import { GoogleCalendarBlock, GoogleCalendarV2Block } from '@/blocks/blocks/google_calendar'
|
||||
@@ -235,6 +237,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
dynamodb: DynamoDBBlock,
|
||||
elasticsearch: ElasticsearchBlock,
|
||||
elevenlabs: ElevenLabsBlock,
|
||||
fathom: FathomBlock,
|
||||
enrich: EnrichBlock,
|
||||
evernote: EvernoteBlock,
|
||||
evaluator: EvaluatorBlock,
|
||||
@@ -255,6 +258,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
gmail_v2: GmailV2Block,
|
||||
google_calendar: GoogleCalendarBlock,
|
||||
google_calendar_v2: GoogleCalendarV2Block,
|
||||
google_ads: GoogleAdsBlock,
|
||||
google_books: GoogleBooksBlock,
|
||||
google_contacts: GoogleContactsBlock,
|
||||
google_docs: GoogleDocsBlock,
|
||||
|
||||
@@ -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'>
|
||||
@@ -3554,6 +3572,27 @@ export const ResendIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const GoogleAdsIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
|
||||
<g transform='matrix(.257748 0 0 .257745 -.361416 2.515516)'>
|
||||
<path
|
||||
d='M85.9 28.6c2.4-6.3 5.7-12.1 10.6-16.8 19.6-19.1 52-14.3 65.3 9.7 10 18.2 20.6 36 30.9 54l51.6 89.8c14.3 25.1-1.2 56.8-29.6 61.1-17.4 2.6-33.7-5.4-42.7-21l-45.4-78.8c-.3-.6-.7-1.1-1.1-1.6-1.6-1.3-2.3-3.2-3.3-4.9L88.8 62.2c-3.9-6.8-5.7-14.2-5.5-22 .3-4 .8-8 2.6-11.6'
|
||||
fill='#3c8bd9'
|
||||
/>
|
||||
<path
|
||||
d='M85.9 28.6c-.9 3.6-1.7 7.2-1.9 11-.3 8.4 1.8 16.2 6 23.5l32.9 56.9c1 1.7 1.8 3.4 2.8 5l-18.1 31.1-25.3 43.6c-.4 0-.5-.2-.6-.5-.1-.8.2-1.5.4-2.3 4.1-15 .7-28.3-9.6-39.7-6.3-6.9-14.3-10.8-23.5-12.1-12-1.7-22.6 1.4-32.1 8.9-1.7 1.3-2.8 3.2-4.8 4.2-.4 0-.6-.2-.7-.5l14.3-24.9L85.2 29.7c.2-.4.5-.7.7-1.1'
|
||||
fill='#fabc04'
|
||||
/>
|
||||
<path
|
||||
d='M11.8 158l5.7-5.1c24.3-19.2 60.8-5.3 66.1 25.1 1.3 7.3.6 14.3-1.6 21.3-.1.6-.2 1.1-.4 1.7-.9 1.6-1.7 3.3-2.7 4.9-8.9 14.7-22 22-39.2 20.9C20 225.4 4.5 210.6 1.8 191c-1.3-9.5.6-18.4 5.5-26.6 1-1.8 2.2-3.4 3.3-5.2.5-.4.3-1.2 1.2-1.2'
|
||||
fill='#34a852'
|
||||
/>
|
||||
<path d='M11.8 158c-.4.4-.4 1.1-1.1 1.2-.1-.7.3-1.1.7-1.6l.4.4' fill='#fabc04' />
|
||||
<path d='M81.6 201c-.4-.7 0-1.2.4-1.7l.4.4-.8 1.3' fill='#e1c025' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const GoogleBigQueryIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
|
||||
<path
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -486,13 +486,14 @@ export const auth = betterAuth({
|
||||
'google-docs',
|
||||
'google-sheets',
|
||||
'google-forms',
|
||||
'google-ads',
|
||||
'google-bigquery',
|
||||
'google-vault',
|
||||
'google-groups',
|
||||
'google-meet',
|
||||
'google-tasks',
|
||||
'vertex-ai',
|
||||
'github-repo',
|
||||
|
||||
'microsoft-dataverse',
|
||||
'microsoft-teams',
|
||||
'microsoft-excel',
|
||||
@@ -754,83 +755,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',
|
||||
@@ -1085,6 +1009,41 @@ export const auth = betterAuth({
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
providerId: 'google-ads',
|
||||
clientId: env.GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
|
||||
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
|
||||
accessType: 'offline',
|
||||
scopes: getCanonicalScopesForProvider('google-ads'),
|
||||
prompt: 'consent',
|
||||
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-ads`,
|
||||
getUserInfo: async (tokens) => {
|
||||
try {
|
||||
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
||||
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||
})
|
||||
if (!response.ok) {
|
||||
logger.error('Failed to fetch Google user info', { status: response.status })
|
||||
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
|
||||
}
|
||||
const profile = await response.json()
|
||||
const now = new Date()
|
||||
return {
|
||||
id: `${profile.sub}-${crypto.randomUUID()}`,
|
||||
name: profile.name || 'Google User',
|
||||
email: profile.email,
|
||||
image: profile.picture || undefined,
|
||||
emailVerified: profile.email_verified || false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in Google getUserInfo', { error })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
providerId: 'google-bigquery',
|
||||
clientId: env.GOOGLE_CLIENT_ID as string,
|
||||
|
||||
@@ -2,13 +2,13 @@ import { db } from '@sim/db'
|
||||
import { account, credential, credentialMember, workflow as workflowTable } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
export interface CredentialAccessResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
authType?: 'session' | 'internal_jwt'
|
||||
authType?: typeof AuthType.SESSION | typeof AuthType.INTERNAL_JWT
|
||||
requesterUserId?: string
|
||||
credentialOwnerUserId?: string
|
||||
workspaceId?: string
|
||||
@@ -39,7 +39,7 @@ export async function authorizeCredentialUse(
|
||||
return { ok: false, error: auth.error || 'Authentication required' }
|
||||
}
|
||||
|
||||
const actingUserId = auth.authType === 'internal_jwt' ? callerUserId : auth.userId
|
||||
const actingUserId = auth.authType === AuthType.INTERNAL_JWT ? callerUserId : auth.userId
|
||||
|
||||
const [workflowContext] = workflowId
|
||||
? await db
|
||||
@@ -217,7 +217,7 @@ export async function authorizeCredentialUse(
|
||||
return { ok: false, error: 'Credential not found' }
|
||||
}
|
||||
|
||||
if (auth.authType === 'internal_jwt') {
|
||||
if (auth.authType === AuthType.INTERNAL_JWT) {
|
||||
return { ok: false, error: 'workflowId is required' }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,20 @@ import { verifyInternalToken } from '@/lib/auth/internal'
|
||||
|
||||
const logger = createLogger('HybridAuth')
|
||||
|
||||
export const AuthType = {
|
||||
SESSION: 'session',
|
||||
API_KEY: 'api_key',
|
||||
INTERNAL_JWT: 'internal_jwt',
|
||||
} as const
|
||||
|
||||
export type AuthTypeValue = (typeof AuthType)[keyof typeof AuthType]
|
||||
|
||||
export interface AuthResult {
|
||||
success: boolean
|
||||
userId?: string
|
||||
userName?: string | null
|
||||
userEmail?: string | null
|
||||
authType?: 'session' | 'api_key' | 'internal_jwt'
|
||||
authType?: AuthTypeValue
|
||||
apiKeyType?: 'personal' | 'workspace'
|
||||
error?: string
|
||||
}
|
||||
@@ -46,14 +54,14 @@ async function resolveUserFromJwt(
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
return { success: true, userId, authType: 'internal_jwt' }
|
||||
return { success: true, userId, authType: AuthType.INTERNAL_JWT }
|
||||
}
|
||||
|
||||
if (options.requireWorkflowId !== false) {
|
||||
return { success: false, error: 'userId required for internal JWT calls' }
|
||||
}
|
||||
|
||||
return { success: true, authType: 'internal_jwt' }
|
||||
return { success: true, authType: AuthType.INTERNAL_JWT }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +154,7 @@ export async function checkSessionOrInternalAuth(
|
||||
userId: session.user.id,
|
||||
userName: session.user.name,
|
||||
userEmail: session.user.email,
|
||||
authType: 'session',
|
||||
authType: AuthType.SESSION,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +203,7 @@ export async function checkHybridAuth(
|
||||
userId: session.user.id,
|
||||
userName: session.user.name,
|
||||
userEmail: session.user.email,
|
||||
authType: 'session',
|
||||
authType: AuthType.SESSION,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +216,7 @@ export async function checkHybridAuth(
|
||||
return {
|
||||
success: true,
|
||||
userId: result.userId!,
|
||||
authType: 'api_key',
|
||||
authType: AuthType.API_KEY,
|
||||
apiKeyType: result.keyType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,8 +6,8 @@ import {
|
||||
CalComIcon,
|
||||
ConfluenceIcon,
|
||||
DropboxIcon,
|
||||
GithubIcon,
|
||||
GmailIcon,
|
||||
GoogleAdsIcon,
|
||||
GoogleBigQueryIcon,
|
||||
GoogleCalendarIcon,
|
||||
GoogleContactsIcon,
|
||||
@@ -147,6 +147,18 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
'https://www.googleapis.com/auth/contacts',
|
||||
],
|
||||
},
|
||||
'google-ads': {
|
||||
name: 'Google Ads',
|
||||
description: 'Query campaigns, ad groups, and performance metrics in Google Ads.',
|
||||
providerId: 'google-ads',
|
||||
icon: GoogleAdsIcon,
|
||||
baseProviderIcon: GoogleIcon,
|
||||
scopes: [
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/adwords',
|
||||
],
|
||||
},
|
||||
'google-bigquery': {
|
||||
name: 'Google BigQuery',
|
||||
description: 'Query, list, and insert data in Google BigQuery.',
|
||||
@@ -340,21 +352,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 +471,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 +637,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 +986,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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type OAuthProvider =
|
||||
| 'google-sheets'
|
||||
| 'google-calendar'
|
||||
| 'google-contacts'
|
||||
| 'google-ads'
|
||||
| 'google-bigquery'
|
||||
| 'google-tasks'
|
||||
| 'google-vault'
|
||||
@@ -15,8 +16,6 @@ export type OAuthProvider =
|
||||
| 'google-groups'
|
||||
| 'google-meet'
|
||||
| 'vertex-ai'
|
||||
| 'github'
|
||||
| 'github-repo'
|
||||
| 'x'
|
||||
| 'confluence'
|
||||
| 'airtable'
|
||||
@@ -57,6 +56,7 @@ export type OAuthService =
|
||||
| 'google-sheets'
|
||||
| 'google-calendar'
|
||||
| 'google-contacts'
|
||||
| 'google-ads'
|
||||
| 'google-bigquery'
|
||||
| 'google-tasks'
|
||||
| 'google-vault'
|
||||
@@ -64,7 +64,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)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'https://www.googleapis.com/auth/userinfo.profile': 'View basic profile info',
|
||||
'https://www.googleapis.com/auth/forms.body': 'View and manage Google Forms',
|
||||
'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to Google Forms',
|
||||
'https://www.googleapis.com/auth/adwords': 'Manage Google Ads campaigns and reporting',
|
||||
'https://www.googleapis.com/auth/bigquery': 'View and manage data in Google BigQuery',
|
||||
'https://www.googleapis.com/auth/ediscovery': 'Access Google Vault for eDiscovery',
|
||||
'https://www.googleapis.com/auth/devstorage.read_only': 'Read files from Google Cloud Storage',
|
||||
@@ -157,6 +158,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 +271,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,6 +5,7 @@ import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { getProviderIdFromServiceId } from '@/lib/oauth'
|
||||
import { PendingWebhookVerificationTracker } from '@/lib/webhooks/pending-verification'
|
||||
import {
|
||||
cleanupExternalWebhook,
|
||||
createExternalWebhookSubscription,
|
||||
@@ -580,6 +581,7 @@ export async function saveTriggerWebhooksForDeploy({
|
||||
updatedProviderConfig: Record<string, unknown>
|
||||
externalSubscriptionCreated: boolean
|
||||
}> = []
|
||||
const pendingVerificationTracker = new PendingWebhookVerificationTracker()
|
||||
|
||||
for (const block of blocksNeedingWebhook) {
|
||||
const config = webhookConfigs.get(block.id)
|
||||
@@ -595,6 +597,14 @@ export async function saveTriggerWebhooksForDeploy({
|
||||
}
|
||||
|
||||
try {
|
||||
await pendingVerificationTracker.register({
|
||||
path: triggerPath,
|
||||
provider,
|
||||
workflowId,
|
||||
blockId: block.id,
|
||||
metadata: providerConfig,
|
||||
})
|
||||
|
||||
const result = await createExternalWebhookSubscription(
|
||||
request,
|
||||
createPayload,
|
||||
@@ -613,6 +623,7 @@ export async function saveTriggerWebhooksForDeploy({
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Failed to create external subscription for ${block.id}`, error)
|
||||
await pendingVerificationTracker.clearAll()
|
||||
for (const sub of createdSubscriptions) {
|
||||
if (sub.externalSubscriptionCreated) {
|
||||
try {
|
||||
@@ -666,6 +677,8 @@ export async function saveTriggerWebhooksForDeploy({
|
||||
}
|
||||
})
|
||||
|
||||
await pendingVerificationTracker.clearAll()
|
||||
|
||||
for (const sub of createdSubscriptions) {
|
||||
const pollingError = await configurePollingIfNeeded(
|
||||
sub.provider,
|
||||
@@ -710,6 +723,7 @@ export async function saveTriggerWebhooksForDeploy({
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
await pendingVerificationTracker.clearAll()
|
||||
logger.error(`[${requestId}] Failed to insert webhook records`, error)
|
||||
for (const sub of createdSubscriptions) {
|
||||
if (sub.externalSubscriptionCreated) {
|
||||
|
||||
142
apps/sim/lib/webhooks/pending-verification.test.ts
Normal file
142
apps/sim/lib/webhooks/pending-verification.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/core/config/redis', () => ({
|
||||
getRedisClient: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import {
|
||||
clearPendingWebhookVerification,
|
||||
getPendingWebhookVerification,
|
||||
matchesPendingWebhookVerificationProbe,
|
||||
PendingWebhookVerificationTracker,
|
||||
registerPendingWebhookVerification,
|
||||
} from '@/lib/webhooks/pending-verification'
|
||||
|
||||
describe('pending webhook verification', () => {
|
||||
afterEach(async () => {
|
||||
await clearPendingWebhookVerification('grain-path-1')
|
||||
await clearPendingWebhookVerification('grain-path-2')
|
||||
await clearPendingWebhookVerification('grain-path-3')
|
||||
await clearPendingWebhookVerification('grain-path-4')
|
||||
})
|
||||
|
||||
it('stores and retrieves pending Grain verification entries', async () => {
|
||||
await registerPendingWebhookVerification({
|
||||
path: 'grain-path-1',
|
||||
provider: 'grain',
|
||||
workflowId: 'workflow-1',
|
||||
blockId: 'block-1',
|
||||
})
|
||||
|
||||
const entry = await getPendingWebhookVerification('grain-path-1')
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
path: 'grain-path-1',
|
||||
provider: 'grain',
|
||||
workflowId: 'workflow-1',
|
||||
blockId: 'block-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('matches Grain verification probe shapes only for registered paths', async () => {
|
||||
await registerPendingWebhookVerification({
|
||||
path: 'grain-path-2',
|
||||
provider: 'grain',
|
||||
})
|
||||
|
||||
const entry = await getPendingWebhookVerification('grain-path-2')
|
||||
|
||||
expect(entry).not.toBeNull()
|
||||
expect(
|
||||
matchesPendingWebhookVerificationProbe(entry!, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
matchesPendingWebhookVerificationProbe(entry!, {
|
||||
method: 'POST',
|
||||
body: { type: 'recording_added' },
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not register generic pending verification unless verifyTestEvents is enabled', async () => {
|
||||
await registerPendingWebhookVerification({
|
||||
path: 'grain-path-3',
|
||||
provider: 'generic',
|
||||
metadata: { verifyTestEvents: false },
|
||||
})
|
||||
|
||||
expect(await getPendingWebhookVerification('grain-path-3')).toBeNull()
|
||||
})
|
||||
|
||||
it('registers generic pending verification when verifyTestEvents is enabled', async () => {
|
||||
await registerPendingWebhookVerification({
|
||||
path: 'grain-path-3',
|
||||
provider: 'generic',
|
||||
metadata: { verifyTestEvents: true },
|
||||
})
|
||||
|
||||
const entry = await getPendingWebhookVerification('grain-path-3')
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
path: 'grain-path-3',
|
||||
provider: 'generic',
|
||||
metadata: { verifyTestEvents: true },
|
||||
})
|
||||
expect(
|
||||
matchesPendingWebhookVerificationProbe(entry!, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
matchesPendingWebhookVerificationProbe(entry!, {
|
||||
method: 'POST',
|
||||
body: { message: 'real event' },
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('clears tracked pending verifications after a successful lifecycle', async () => {
|
||||
const tracker = new PendingWebhookVerificationTracker()
|
||||
|
||||
await tracker.register({
|
||||
path: 'grain-path-3',
|
||||
provider: 'grain',
|
||||
})
|
||||
|
||||
expect(await getPendingWebhookVerification('grain-path-3')).not.toBeNull()
|
||||
|
||||
await tracker.clearAll()
|
||||
|
||||
expect(await getPendingWebhookVerification('grain-path-3')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears tracked pending verifications after a failed lifecycle', async () => {
|
||||
const tracker = new PendingWebhookVerificationTracker()
|
||||
|
||||
await tracker.register({
|
||||
path: 'grain-path-4',
|
||||
provider: 'grain',
|
||||
})
|
||||
|
||||
expect(await getPendingWebhookVerification('grain-path-4')).not.toBeNull()
|
||||
|
||||
await tracker.clear('grain-path-4')
|
||||
|
||||
expect(await getPendingWebhookVerification('grain-path-4')).toBeNull()
|
||||
})
|
||||
})
|
||||
218
apps/sim/lib/webhooks/pending-verification.ts
Normal file
218
apps/sim/lib/webhooks/pending-verification.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
|
||||
const logger = createLogger('WebhookPendingVerification')
|
||||
|
||||
const DEFAULT_TTL_SECONDS = 120
|
||||
const REDIS_KEY_PREFIX = 'webhook:pending-verification:'
|
||||
|
||||
const inMemoryPendingVerificationStore = new Map<string, PendingWebhookVerification>()
|
||||
|
||||
export interface PendingWebhookVerification {
|
||||
path: string
|
||||
provider: string
|
||||
workflowId?: string
|
||||
blockId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export interface PendingWebhookVerificationRegistration {
|
||||
path: string
|
||||
provider: string
|
||||
workflowId?: string
|
||||
blockId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
ttlSeconds?: number
|
||||
}
|
||||
|
||||
interface PendingWebhookVerificationProbe {
|
||||
method: string
|
||||
body: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
type PendingWebhookVerificationRegistrationMatcher = (
|
||||
registration: PendingWebhookVerificationRegistration
|
||||
) => boolean
|
||||
|
||||
type PendingWebhookVerificationProbeMatcher = (
|
||||
probe: PendingWebhookVerificationProbe,
|
||||
entry: PendingWebhookVerification
|
||||
) => boolean
|
||||
|
||||
const pendingWebhookVerificationRegistrationMatchers: Record<
|
||||
string,
|
||||
PendingWebhookVerificationRegistrationMatcher
|
||||
> = {
|
||||
grain: () => true,
|
||||
generic: (registration) => registration.metadata?.verifyTestEvents === true,
|
||||
}
|
||||
|
||||
const pendingWebhookVerificationProbeMatchers: Record<
|
||||
string,
|
||||
PendingWebhookVerificationProbeMatcher
|
||||
> = {
|
||||
grain: ({ method, body }) =>
|
||||
method === 'GET' ||
|
||||
method === 'HEAD' ||
|
||||
(method === 'POST' && (!body || Object.keys(body).length === 0 || !body.type)),
|
||||
generic: ({ method, body }) =>
|
||||
method === 'GET' ||
|
||||
method === 'HEAD' ||
|
||||
(method === 'POST' && (!body || Object.keys(body).length === 0)),
|
||||
}
|
||||
|
||||
function getRedisKey(path: string): string {
|
||||
return `${REDIS_KEY_PREFIX}${path}`
|
||||
}
|
||||
|
||||
function isExpired(entry: PendingWebhookVerification): boolean {
|
||||
return entry.expiresAt <= Date.now()
|
||||
}
|
||||
|
||||
function getInMemoryPendingWebhookVerification(path: string): PendingWebhookVerification | null {
|
||||
const entry = inMemoryPendingVerificationStore.get(path)
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isExpired(entry)) {
|
||||
inMemoryPendingVerificationStore.delete(path)
|
||||
return null
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
export function requiresPendingWebhookVerification(
|
||||
provider: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): boolean {
|
||||
const registrationMatcher = pendingWebhookVerificationRegistrationMatchers[provider]
|
||||
if (!registrationMatcher) {
|
||||
return false
|
||||
}
|
||||
|
||||
return registrationMatcher({
|
||||
path: '',
|
||||
provider,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
export async function registerPendingWebhookVerification(
|
||||
registration: PendingWebhookVerificationRegistration
|
||||
): Promise<void> {
|
||||
const registrationMatcher = pendingWebhookVerificationRegistrationMatchers[registration.provider]
|
||||
if (!registrationMatcher || !registrationMatcher(registration)) {
|
||||
return
|
||||
}
|
||||
|
||||
const ttlSeconds = registration.ttlSeconds ?? DEFAULT_TTL_SECONDS
|
||||
const entry: PendingWebhookVerification = {
|
||||
path: registration.path,
|
||||
provider: registration.provider,
|
||||
workflowId: registration.workflowId,
|
||||
blockId: registration.blockId,
|
||||
metadata: registration.metadata,
|
||||
expiresAt: Date.now() + ttlSeconds * 1000,
|
||||
}
|
||||
|
||||
const redis = getRedisClient()
|
||||
if (redis) {
|
||||
await redis.set(getRedisKey(registration.path), JSON.stringify(entry), 'EX', ttlSeconds)
|
||||
} else {
|
||||
inMemoryPendingVerificationStore.set(registration.path, entry)
|
||||
}
|
||||
|
||||
logger.info('Registered pending webhook verification', {
|
||||
provider: registration.provider,
|
||||
path: registration.path,
|
||||
ttlSeconds,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPendingWebhookVerification(
|
||||
path: string
|
||||
): Promise<PendingWebhookVerification | null> {
|
||||
const redis = getRedisClient()
|
||||
if (redis) {
|
||||
const value = await redis.get(getRedisKey(path))
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(value) as PendingWebhookVerification
|
||||
if (isExpired(entry)) {
|
||||
await redis.del(getRedisKey(path))
|
||||
return null
|
||||
}
|
||||
return entry
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse pending webhook verification entry', {
|
||||
path,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
await redis.del(getRedisKey(path))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return getInMemoryPendingWebhookVerification(path)
|
||||
}
|
||||
|
||||
export async function clearPendingWebhookVerification(path: string): Promise<void> {
|
||||
const redis = getRedisClient()
|
||||
if (redis) {
|
||||
await redis.del(getRedisKey(path))
|
||||
} else {
|
||||
inMemoryPendingVerificationStore.delete(path)
|
||||
}
|
||||
|
||||
logger.info('Cleared pending webhook verification', { path })
|
||||
}
|
||||
|
||||
export function matchesPendingWebhookVerificationProbe(
|
||||
entry: PendingWebhookVerification,
|
||||
probe: PendingWebhookVerificationProbe
|
||||
): boolean {
|
||||
const matcher = pendingWebhookVerificationProbeMatchers[entry.provider]
|
||||
if (!matcher) {
|
||||
return false
|
||||
}
|
||||
|
||||
return matcher(probe, entry)
|
||||
}
|
||||
|
||||
export class PendingWebhookVerificationTracker {
|
||||
private readonly registeredPaths = new Set<string>()
|
||||
|
||||
async register(registration: PendingWebhookVerificationRegistration): Promise<void> {
|
||||
const registrationMatcher =
|
||||
pendingWebhookVerificationRegistrationMatchers[registration.provider]
|
||||
if (!registrationMatcher || !registrationMatcher(registration)) {
|
||||
return
|
||||
}
|
||||
|
||||
await registerPendingWebhookVerification(registration)
|
||||
this.registeredPaths.add(registration.path)
|
||||
}
|
||||
|
||||
async clear(path: string): Promise<void> {
|
||||
if (!this.registeredPaths.has(path)) {
|
||||
return
|
||||
}
|
||||
|
||||
await clearPendingWebhookVerification(path)
|
||||
this.registeredPaths.delete(path)
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
for (const path of this.registeredPaths) {
|
||||
await clearPendingWebhookVerification(path)
|
||||
}
|
||||
|
||||
this.registeredPaths.clear()
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import {
|
||||
getPendingWebhookVerification,
|
||||
matchesPendingWebhookVerificationProbe,
|
||||
requiresPendingWebhookVerification,
|
||||
} from '@/lib/webhooks/pending-verification'
|
||||
import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils'
|
||||
import {
|
||||
handleSlackChallenge,
|
||||
@@ -190,6 +195,32 @@ export async function handleProviderChallenges(
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a verification response for provider reachability probes that happen
|
||||
* before a webhook row exists and therefore before provider lookup is possible.
|
||||
*/
|
||||
export async function handlePreLookupWebhookVerification(
|
||||
method: string,
|
||||
body: Record<string, unknown> | undefined,
|
||||
requestId: string,
|
||||
path: string
|
||||
): Promise<NextResponse | null> {
|
||||
const pendingVerification = await getPendingWebhookVerification(path)
|
||||
if (!pendingVerification) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!matchesPendingWebhookVerificationProbe(pendingVerification, { method, body })) {
|
||||
return null
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Returning 200 for pending ${pendingVerification.provider} webhook verification on path: ${path}`
|
||||
)
|
||||
|
||||
return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle provider-specific reachability tests that occur AFTER webhook lookup.
|
||||
*
|
||||
@@ -283,15 +314,12 @@ export function shouldSkipWebhookEvent(webhook: any, body: any, requestId: strin
|
||||
return false
|
||||
}
|
||||
|
||||
/** Providers that validate webhook URLs during creation, before workflow deployment */
|
||||
const PROVIDERS_WITH_PRE_DEPLOYMENT_VERIFICATION = new Set(['grain'])
|
||||
|
||||
/** Returns 200 OK for providers that validate URLs before the workflow is deployed */
|
||||
export function handlePreDeploymentVerification(
|
||||
webhook: any,
|
||||
requestId: string
|
||||
): NextResponse | null {
|
||||
if (PROVIDERS_WITH_PRE_DEPLOYMENT_VERIFICATION.has(webhook.provider)) {
|
||||
if (requiresPendingWebhookVerification(webhook.provider)) {
|
||||
logger.info(
|
||||
`[${requestId}] ${webhook.provider} webhook - block not in deployment, returning 200 OK for URL validation`
|
||||
)
|
||||
|
||||
@@ -16,7 +16,9 @@ const telegramLogger = createLogger('TelegramWebhook')
|
||||
const airtableLogger = createLogger('AirtableWebhook')
|
||||
const typeformLogger = createLogger('TypeformWebhook')
|
||||
const calendlyLogger = createLogger('CalendlyWebhook')
|
||||
const ashbyLogger = createLogger('AshbyWebhook')
|
||||
const grainLogger = createLogger('GrainWebhook')
|
||||
const fathomLogger = createLogger('FathomWebhook')
|
||||
const lemlistLogger = createLogger('LemlistWebhook')
|
||||
const webflowLogger = createLogger('WebflowWebhook')
|
||||
const attioLogger = createLogger('AttioWebhook')
|
||||
@@ -767,14 +769,13 @@ export async function deleteGrainWebhook(webhook: any, requestId: string): Promi
|
||||
return
|
||||
}
|
||||
|
||||
const grainApiUrl = `https://api.grain.com/_/public-api/v2/hooks/${externalId}`
|
||||
const grainApiUrl = `https://api.grain.com/_/public-api/hooks/${externalId}`
|
||||
|
||||
const grainResponse = await fetch(grainApiUrl, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Public-Api-Version': '2025-10-31',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -1181,8 +1236,7 @@ export async function createGrainWebhookSubscription(
|
||||
): Promise<{ id: string; eventTypes: string[] } | undefined> {
|
||||
try {
|
||||
const { path, providerConfig } = webhookData
|
||||
const { apiKey, triggerId, includeHighlights, includeParticipants, includeAiSummary } =
|
||||
providerConfig || {}
|
||||
const { apiKey, triggerId, viewId } = providerConfig || {}
|
||||
|
||||
if (!apiKey) {
|
||||
grainLogger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, {
|
||||
@@ -1193,32 +1247,39 @@ export async function createGrainWebhookSubscription(
|
||||
)
|
||||
}
|
||||
|
||||
const hookTypeMap: Record<string, string> = {
|
||||
grain_webhook: 'recording_added',
|
||||
grain_recording_created: 'recording_added',
|
||||
grain_recording_updated: 'recording_added',
|
||||
grain_highlight_created: 'recording_added',
|
||||
grain_highlight_updated: 'recording_added',
|
||||
grain_story_created: 'recording_added',
|
||||
grain_upload_status: 'upload_status',
|
||||
if (!viewId) {
|
||||
grainLogger.warn(`[${requestId}] Missing viewId for Grain webhook creation.`, {
|
||||
webhookId: webhookData.id,
|
||||
triggerId,
|
||||
})
|
||||
throw new Error(
|
||||
'Grain view ID is required. Please provide the Grain view ID from GET /_/public-api/views in the trigger configuration.'
|
||||
)
|
||||
}
|
||||
|
||||
const actionMap: Record<string, Array<'added' | 'updated' | 'removed'>> = {
|
||||
grain_recording_created: ['added'],
|
||||
grain_recording_updated: ['updated'],
|
||||
grain_highlight_created: ['added'],
|
||||
grain_highlight_updated: ['updated'],
|
||||
grain_story_created: ['added'],
|
||||
}
|
||||
|
||||
const eventTypeMap: Record<string, string[]> = {
|
||||
grain_webhook: [],
|
||||
grain_recording_created: ['recording_added'],
|
||||
grain_recording_updated: ['recording_updated'],
|
||||
grain_highlight_created: ['highlight_created'],
|
||||
grain_highlight_created: ['highlight_added'],
|
||||
grain_highlight_updated: ['highlight_updated'],
|
||||
grain_story_created: ['story_created'],
|
||||
grain_upload_status: ['upload_status'],
|
||||
grain_story_created: ['story_added'],
|
||||
}
|
||||
|
||||
const hookType = hookTypeMap[triggerId] ?? 'recording_added'
|
||||
const actions = actionMap[triggerId] ?? []
|
||||
const eventTypes = eventTypeMap[triggerId] ?? []
|
||||
|
||||
if (!hookTypeMap[triggerId]) {
|
||||
if (!triggerId || (!(triggerId in actionMap) && triggerId !== 'grain_webhook')) {
|
||||
grainLogger.warn(
|
||||
`[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to recording_added`,
|
||||
`[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to all actions`,
|
||||
{
|
||||
webhookId: webhookData.id,
|
||||
}
|
||||
@@ -1227,32 +1288,23 @@ export async function createGrainWebhookSubscription(
|
||||
|
||||
grainLogger.info(`[${requestId}] Creating Grain webhook`, {
|
||||
triggerId,
|
||||
hookType,
|
||||
viewId,
|
||||
actions,
|
||||
eventTypes,
|
||||
webhookId: webhookData.id,
|
||||
})
|
||||
|
||||
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
|
||||
|
||||
const grainApiUrl = 'https://api.grain.com/_/public-api/v2/hooks/create'
|
||||
const grainApiUrl = 'https://api.grain.com/_/public-api/hooks'
|
||||
|
||||
const requestBody: Record<string, any> = {
|
||||
version: 2,
|
||||
hook_url: notificationUrl,
|
||||
hook_type: hookType,
|
||||
view_id: viewId,
|
||||
}
|
||||
|
||||
const include: Record<string, boolean> = {}
|
||||
if (includeHighlights) {
|
||||
include.highlights = true
|
||||
}
|
||||
if (includeParticipants) {
|
||||
include.participants = true
|
||||
}
|
||||
if (includeAiSummary) {
|
||||
include.ai_summary = true
|
||||
}
|
||||
if (Object.keys(include).length > 0) {
|
||||
requestBody.include = include
|
||||
if (actions.length > 0) {
|
||||
requestBody.actions = actions
|
||||
}
|
||||
|
||||
const grainResponse = await fetch(grainApiUrl, {
|
||||
@@ -1260,7 +1312,6 @@ export async function createGrainWebhookSubscription(
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Public-Api-Version': '2025-10-31',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
@@ -1293,15 +1344,29 @@ export async function createGrainWebhookSubscription(
|
||||
throw new Error(userFriendlyMessage)
|
||||
}
|
||||
|
||||
const grainWebhookId = responseBody.id
|
||||
|
||||
if (!grainWebhookId) {
|
||||
grainLogger.error(
|
||||
`[${requestId}] Grain webhook creation response missing id for webhook ${webhookData.id}.`,
|
||||
{
|
||||
response: responseBody,
|
||||
}
|
||||
)
|
||||
throw new Error(
|
||||
'Grain webhook created but no webhook ID was returned in the response. Cannot track subscription.'
|
||||
)
|
||||
}
|
||||
|
||||
grainLogger.info(
|
||||
`[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`,
|
||||
{
|
||||
grainWebhookId: responseBody.id,
|
||||
grainWebhookId,
|
||||
eventTypes,
|
||||
}
|
||||
)
|
||||
|
||||
return { id: responseBody.id, eventTypes }
|
||||
return { id: grainWebhookId, eventTypes }
|
||||
} catch (error: any) {
|
||||
grainLogger.error(
|
||||
`[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`,
|
||||
@@ -1314,6 +1379,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
|
||||
@@ -1809,8 +1984,10 @@ type RecreateCheckInput = {
|
||||
/** Providers that create external webhook subscriptions */
|
||||
const PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS = new Set([
|
||||
'airtable',
|
||||
'ashby',
|
||||
'attio',
|
||||
'calendly',
|
||||
'fathom',
|
||||
'webflow',
|
||||
'typeform',
|
||||
'grain',
|
||||
@@ -1880,7 +2057,13 @@ export async function createExternalWebhookSubscription(
|
||||
let updatedProviderConfig = providerConfig
|
||||
let externalSubscriptionCreated = false
|
||||
|
||||
if (provider === 'airtable') {
|
||||
if (provider === 'ashby') {
|
||||
const result = await createAshbyWebhookSubscription(webhookData, requestId)
|
||||
if (result) {
|
||||
updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id }
|
||||
externalSubscriptionCreated = true
|
||||
}
|
||||
} else if (provider === 'airtable') {
|
||||
const externalId = await createAirtableWebhookSubscription(userId, webhookData, requestId)
|
||||
if (externalId) {
|
||||
updatedProviderConfig = { ...updatedProviderConfig, externalId }
|
||||
@@ -1923,6 +2106,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) {
|
||||
@@ -1954,7 +2143,9 @@ export async function cleanupExternalWebhook(
|
||||
workflow: any,
|
||||
requestId: string
|
||||
): Promise<void> {
|
||||
if (webhook.provider === 'airtable') {
|
||||
if (webhook.provider === 'ashby') {
|
||||
await deleteAshbyWebhook(webhook, requestId)
|
||||
} else if (webhook.provider === 'airtable') {
|
||||
await deleteAirtableWebhook(webhook, workflow, requestId)
|
||||
} else if (webhook.provider === 'attio') {
|
||||
await deleteAttioWebhook(webhook, workflow, requestId)
|
||||
@@ -1968,9 +2159,168 @@ 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') {
|
||||
await deleteLemlistWebhook(webhook, requestId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a webhook subscription in Ashby via webhook.create API.
|
||||
* Ashby uses Basic Auth and one webhook per event type (webhookType).
|
||||
*/
|
||||
export async function createAshbyWebhookSubscription(
|
||||
webhookData: any,
|
||||
requestId: string
|
||||
): Promise<{ id: string } | undefined> {
|
||||
try {
|
||||
const { path, providerConfig } = webhookData
|
||||
const { apiKey, triggerId } = providerConfig || {}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'Ashby API Key is required. Please provide your API Key with apiKeysWrite permission in the trigger configuration.'
|
||||
)
|
||||
}
|
||||
|
||||
if (!triggerId) {
|
||||
throw new Error('Trigger ID is required to create Ashby webhook.')
|
||||
}
|
||||
|
||||
const webhookTypeMap: Record<string, string> = {
|
||||
ashby_application_submit: 'applicationSubmit',
|
||||
ashby_candidate_stage_change: 'candidateStageChange',
|
||||
ashby_candidate_hire: 'candidateHire',
|
||||
ashby_candidate_delete: 'candidateDelete',
|
||||
ashby_job_create: 'jobCreate',
|
||||
ashby_offer_create: 'offerCreate',
|
||||
}
|
||||
|
||||
const webhookType = webhookTypeMap[triggerId]
|
||||
if (!webhookType) {
|
||||
throw new Error(`Unknown Ashby triggerId: ${triggerId}. Add it to webhookTypeMap.`)
|
||||
}
|
||||
|
||||
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
|
||||
const authString = Buffer.from(`${apiKey}:`).toString('base64')
|
||||
|
||||
ashbyLogger.info(`[${requestId}] Creating Ashby webhook`, {
|
||||
triggerId,
|
||||
webhookType,
|
||||
webhookId: webhookData.id,
|
||||
})
|
||||
|
||||
const requestBody: Record<string, unknown> = {
|
||||
requestUrl: notificationUrl,
|
||||
webhookType,
|
||||
}
|
||||
|
||||
const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${authString}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
const responseBody = await ashbyResponse.json().catch(() => ({}))
|
||||
|
||||
if (!ashbyResponse.ok || !responseBody.success) {
|
||||
const errorMessage =
|
||||
responseBody.errorInfo?.message || responseBody.message || 'Unknown Ashby API error'
|
||||
|
||||
let userFriendlyMessage = 'Failed to create webhook subscription in Ashby'
|
||||
if (ashbyResponse.status === 401) {
|
||||
userFriendlyMessage =
|
||||
'Invalid Ashby API Key. Please verify your API Key is correct and has apiKeysWrite permission.'
|
||||
} else if (ashbyResponse.status === 403) {
|
||||
userFriendlyMessage =
|
||||
'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.'
|
||||
} else if (errorMessage && errorMessage !== 'Unknown Ashby API error') {
|
||||
userFriendlyMessage = `Ashby error: ${errorMessage}`
|
||||
}
|
||||
|
||||
throw new Error(userFriendlyMessage)
|
||||
}
|
||||
|
||||
const externalId = responseBody.results?.id
|
||||
if (!externalId) {
|
||||
throw new Error('Ashby webhook creation succeeded but no webhook ID was returned')
|
||||
}
|
||||
|
||||
ashbyLogger.info(
|
||||
`[${requestId}] Successfully created Ashby webhook subscription ${externalId} for webhook ${webhookData.id}`
|
||||
)
|
||||
return { id: externalId }
|
||||
} catch (error: any) {
|
||||
ashbyLogger.error(
|
||||
`[${requestId}] Exception during Ashby webhook creation for webhook ${webhookData.id}.`,
|
||||
{
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an Ashby webhook subscription via webhook.delete API.
|
||||
* Ashby uses POST with webhookId in the body (not DELETE method).
|
||||
*/
|
||||
export async function deleteAshbyWebhook(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) {
|
||||
ashbyLogger.warn(
|
||||
`[${requestId}] Missing apiKey for Ashby webhook deletion ${webhook.id}, skipping cleanup`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!externalId) {
|
||||
ashbyLogger.warn(
|
||||
`[${requestId}] Missing externalId for Ashby webhook deletion ${webhook.id}, skipping cleanup`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const authString = Buffer.from(`${apiKey}:`).toString('base64')
|
||||
|
||||
const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${authString}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ webhookId: externalId }),
|
||||
})
|
||||
|
||||
if (ashbyResponse.ok) {
|
||||
await ashbyResponse.body?.cancel()
|
||||
ashbyLogger.info(
|
||||
`[${requestId}] Successfully deleted Ashby webhook subscription ${externalId}`
|
||||
)
|
||||
} else if (ashbyResponse.status === 404) {
|
||||
await ashbyResponse.body?.cancel()
|
||||
ashbyLogger.info(
|
||||
`[${requestId}] Ashby webhook ${externalId} not found during deletion (already removed)`
|
||||
)
|
||||
} else {
|
||||
const responseBody = await ashbyResponse.json().catch(() => ({}))
|
||||
ashbyLogger.warn(
|
||||
`[${requestId}] Failed to delete Ashby webhook (non-fatal): ${ashbyResponse.status}`,
|
||||
{ response: responseBody }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
ashbyLogger.warn(`[${requestId}] Error deleting Ashby webhook (non-fatal)`, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1244,6 +1244,14 @@ export async function formatWebhookInput(
|
||||
return extractPageData(body)
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'ashby') {
|
||||
return {
|
||||
...(body.data || {}),
|
||||
action: body.action,
|
||||
data: body.data || {},
|
||||
}
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'stripe') {
|
||||
return body
|
||||
}
|
||||
|
||||
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
|
||||
211
apps/sim/tools/google_ads/ad_performance.ts
Normal file
211
apps/sim/tools/google_ads/ad_performance.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type {
|
||||
GoogleAdsAdPerformanceParams,
|
||||
GoogleAdsAdPerformanceResponse,
|
||||
} from '@/tools/google_ads/types'
|
||||
import { validateDate, validateDateRange, validateNumericId } from '@/tools/google_ads/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const googleAdsAdPerformanceTool: ToolConfig<
|
||||
GoogleAdsAdPerformanceParams,
|
||||
GoogleAdsAdPerformanceResponse
|
||||
> = {
|
||||
id: 'google_ads_ad_performance',
|
||||
name: 'Google Ads Ad Performance',
|
||||
description: 'Get performance metrics for individual ads over a date range',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'google-ads',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token for the Google Ads API',
|
||||
},
|
||||
customerId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Google Ads customer ID (numeric, no dashes)',
|
||||
},
|
||||
developerToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Google Ads API developer token',
|
||||
},
|
||||
managerCustomerId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Manager account customer ID (if accessing via manager account)',
|
||||
},
|
||||
campaignId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by campaign ID',
|
||||
},
|
||||
adGroupId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by ad group ID',
|
||||
},
|
||||
dateRange: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Predefined date range (LAST_7_DAYS, LAST_30_DAYS, THIS_MONTH, LAST_MONTH, TODAY, YESTERDAY)',
|
||||
},
|
||||
startDate: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Custom start date in YYYY-MM-DD format',
|
||||
},
|
||||
endDate: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Custom end date in YYYY-MM-DD format',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of results to return',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const customerId = validateNumericId(params.customerId, 'customerId')
|
||||
return `https://googleads.googleapis.com/v19/customers/${customerId}/googleAds:search`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'developer-token': params.developerToken,
|
||||
}
|
||||
if (params.managerCustomerId) {
|
||||
headers['login-customer-id'] = validateNumericId(
|
||||
params.managerCustomerId,
|
||||
'managerCustomerId'
|
||||
)
|
||||
}
|
||||
return headers
|
||||
},
|
||||
body: (params) => {
|
||||
let query =
|
||||
'SELECT ad_group_ad.ad.id, ad_group.id, ad_group.name, campaign.id, campaign.name, ad_group_ad.ad.type, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.ctr, metrics.conversions, segments.date FROM ad_group_ad'
|
||||
|
||||
const conditions: string[] = ["ad_group_ad.status != 'REMOVED'"]
|
||||
|
||||
if (params.campaignId) {
|
||||
conditions.push(`campaign.id = ${validateNumericId(params.campaignId, 'campaignId')}`)
|
||||
}
|
||||
|
||||
if (params.adGroupId) {
|
||||
conditions.push(`ad_group.id = ${validateNumericId(params.adGroupId, 'adGroupId')}`)
|
||||
}
|
||||
|
||||
if (params.startDate && params.endDate) {
|
||||
const start = validateDate(params.startDate, 'startDate')
|
||||
const end = validateDate(params.endDate, 'endDate')
|
||||
conditions.push(`segments.date BETWEEN '${start}' AND '${end}'`)
|
||||
} else {
|
||||
const dateRange = validateDateRange(params.dateRange || 'LAST_30_DAYS')
|
||||
conditions.push(`segments.date DURING ${dateRange}`)
|
||||
}
|
||||
|
||||
query += ` WHERE ${conditions.join(' AND ')}`
|
||||
query += ' ORDER BY metrics.impressions DESC'
|
||||
|
||||
if (params.limit) {
|
||||
query += ` LIMIT ${params.limit}`
|
||||
}
|
||||
|
||||
return { query }
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage =
|
||||
data?.error?.message ?? data?.error?.details?.[0]?.errors?.[0]?.message ?? 'Unknown error'
|
||||
return {
|
||||
success: false,
|
||||
output: { ads: [], totalCount: 0 },
|
||||
error: errorMessage,
|
||||
}
|
||||
}
|
||||
|
||||
const results = data.results ?? []
|
||||
const ads = results.map((r: Record<string, any>) => ({
|
||||
adId: r.adGroupAd?.ad?.id ?? '',
|
||||
adGroupId: r.adGroup?.id ?? '',
|
||||
adGroupName: r.adGroup?.name ?? null,
|
||||
campaignId: r.campaign?.id ?? '',
|
||||
campaignName: r.campaign?.name ?? null,
|
||||
adType: r.adGroupAd?.ad?.type ?? null,
|
||||
impressions: r.metrics?.impressions ?? '0',
|
||||
clicks: r.metrics?.clicks ?? '0',
|
||||
costMicros: r.metrics?.costMicros ?? '0',
|
||||
ctr: r.metrics?.ctr ?? null,
|
||||
conversions: r.metrics?.conversions ?? null,
|
||||
date: r.segments?.date ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
ads,
|
||||
totalCount: ads.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
ads: {
|
||||
type: 'array',
|
||||
description: 'Ad performance data broken down by date',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
adId: { type: 'string', description: 'Ad ID' },
|
||||
adGroupId: { type: 'string', description: 'Parent ad group ID' },
|
||||
adGroupName: { type: 'string', description: 'Parent ad group name' },
|
||||
campaignId: { type: 'string', description: 'Parent campaign ID' },
|
||||
campaignName: { type: 'string', description: 'Parent campaign name' },
|
||||
adType: {
|
||||
type: 'string',
|
||||
description: 'Ad type (RESPONSIVE_SEARCH_AD, EXPANDED_TEXT_AD, etc.)',
|
||||
},
|
||||
impressions: { type: 'string', description: 'Number of impressions' },
|
||||
clicks: { type: 'string', description: 'Number of clicks' },
|
||||
costMicros: {
|
||||
type: 'string',
|
||||
description: 'Cost in micros (divide by 1,000,000 for currency value)',
|
||||
},
|
||||
ctr: { type: 'number', description: 'Click-through rate (0.0 to 1.0)' },
|
||||
conversions: { type: 'number', description: 'Number of conversions' },
|
||||
date: { type: 'string', description: 'Date for this row (YYYY-MM-DD)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
totalCount: {
|
||||
type: 'number',
|
||||
description: 'Total number of result rows',
|
||||
},
|
||||
},
|
||||
}
|
||||
182
apps/sim/tools/google_ads/campaign_performance.ts
Normal file
182
apps/sim/tools/google_ads/campaign_performance.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
GoogleAdsCampaignPerformanceParams,
|
||||
GoogleAdsCampaignPerformanceResponse,
|
||||
} from '@/tools/google_ads/types'
|
||||
import { validateDate, validateDateRange, validateNumericId } from '@/tools/google_ads/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const googleAdsCampaignPerformanceTool: ToolConfig<
|
||||
GoogleAdsCampaignPerformanceParams,
|
||||
GoogleAdsCampaignPerformanceResponse
|
||||
> = {
|
||||
id: 'google_ads_campaign_performance',
|
||||
name: 'Google Ads Campaign Performance',
|
||||
description: 'Get performance metrics for Google Ads campaigns over a date range',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'google-ads',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token for the Google Ads API',
|
||||
},
|
||||
customerId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Google Ads customer ID (numeric, no dashes)',
|
||||
},
|
||||
developerToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Google Ads API developer token',
|
||||
},
|
||||
managerCustomerId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Manager account customer ID (if accessing via manager account)',
|
||||
},
|
||||
campaignId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by specific campaign ID',
|
||||
},
|
||||
dateRange: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Predefined date range (LAST_7_DAYS, LAST_30_DAYS, THIS_MONTH, LAST_MONTH, TODAY, YESTERDAY)',
|
||||
},
|
||||
startDate: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Custom start date in YYYY-MM-DD format',
|
||||
},
|
||||
endDate: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Custom end date in YYYY-MM-DD format',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const customerId = validateNumericId(params.customerId, 'customerId')
|
||||
return `https://googleads.googleapis.com/v19/customers/${customerId}/googleAds:search`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'developer-token': params.developerToken,
|
||||
}
|
||||
if (params.managerCustomerId) {
|
||||
headers['login-customer-id'] = validateNumericId(
|
||||
params.managerCustomerId,
|
||||
'managerCustomerId'
|
||||
)
|
||||
}
|
||||
return headers
|
||||
},
|
||||
body: (params) => {
|
||||
let query =
|
||||
'SELECT campaign.id, campaign.name, campaign.status, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.ctr, metrics.conversions, segments.date FROM campaign'
|
||||
|
||||
const conditions: string[] = ["campaign.status != 'REMOVED'"]
|
||||
|
||||
if (params.campaignId) {
|
||||
conditions.push(`campaign.id = ${validateNumericId(params.campaignId, 'campaignId')}`)
|
||||
}
|
||||
|
||||
if (params.startDate && params.endDate) {
|
||||
const start = validateDate(params.startDate, 'startDate')
|
||||
const end = validateDate(params.endDate, 'endDate')
|
||||
conditions.push(`segments.date BETWEEN '${start}' AND '${end}'`)
|
||||
} else {
|
||||
const dateRange = validateDateRange(params.dateRange || 'LAST_30_DAYS')
|
||||
conditions.push(`segments.date DURING ${dateRange}`)
|
||||
}
|
||||
|
||||
query += ` WHERE ${conditions.join(' AND ')}`
|
||||
query += ' ORDER BY metrics.impressions DESC'
|
||||
|
||||
return { query }
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage =
|
||||
data?.error?.message ?? data?.error?.details?.[0]?.errors?.[0]?.message ?? 'Unknown error'
|
||||
return {
|
||||
success: false,
|
||||
output: { campaigns: [], totalCount: 0 },
|
||||
error: errorMessage,
|
||||
}
|
||||
}
|
||||
|
||||
const results = data.results ?? []
|
||||
const campaigns = results.map((r: Record<string, any>) => ({
|
||||
id: r.campaign?.id ?? '',
|
||||
name: r.campaign?.name ?? '',
|
||||
status: r.campaign?.status ?? '',
|
||||
impressions: r.metrics?.impressions ?? '0',
|
||||
clicks: r.metrics?.clicks ?? '0',
|
||||
costMicros: r.metrics?.costMicros ?? '0',
|
||||
ctr: r.metrics?.ctr ?? null,
|
||||
conversions: r.metrics?.conversions ?? null,
|
||||
date: r.segments?.date ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
campaigns,
|
||||
totalCount: campaigns.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
campaigns: {
|
||||
type: 'array',
|
||||
description: 'Campaign performance data broken down by date',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Campaign ID' },
|
||||
name: { type: 'string', description: 'Campaign name' },
|
||||
status: { type: 'string', description: 'Campaign status' },
|
||||
impressions: { type: 'string', description: 'Number of impressions' },
|
||||
clicks: { type: 'string', description: 'Number of clicks' },
|
||||
costMicros: {
|
||||
type: 'string',
|
||||
description: 'Cost in micros (divide by 1,000,000 for currency value)',
|
||||
},
|
||||
ctr: { type: 'number', description: 'Click-through rate (0.0 to 1.0)' },
|
||||
conversions: { type: 'number', description: 'Number of conversions' },
|
||||
date: { type: 'string', description: 'Date for this row (YYYY-MM-DD)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
totalCount: {
|
||||
type: 'number',
|
||||
description: 'Total number of result rows',
|
||||
},
|
||||
},
|
||||
}
|
||||
17
apps/sim/tools/google_ads/index.ts
Normal file
17
apps/sim/tools/google_ads/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { googleAdsAdPerformanceTool } from '@/tools/google_ads/ad_performance'
|
||||
import { googleAdsCampaignPerformanceTool } from '@/tools/google_ads/campaign_performance'
|
||||
import { googleAdsListAdGroupsTool } from '@/tools/google_ads/list_ad_groups'
|
||||
import { googleAdsListCampaignsTool } from '@/tools/google_ads/list_campaigns'
|
||||
import { googleAdsListCustomersTool } from '@/tools/google_ads/list_customers'
|
||||
import { googleAdsSearchTool } from '@/tools/google_ads/search'
|
||||
|
||||
export {
|
||||
googleAdsAdPerformanceTool,
|
||||
googleAdsCampaignPerformanceTool,
|
||||
googleAdsListAdGroupsTool,
|
||||
googleAdsListCampaignsTool,
|
||||
googleAdsListCustomersTool,
|
||||
googleAdsSearchTool,
|
||||
}
|
||||
|
||||
export * from './types'
|
||||
167
apps/sim/tools/google_ads/list_ad_groups.ts
Normal file
167
apps/sim/tools/google_ads/list_ad_groups.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import type {
|
||||
GoogleAdsListAdGroupsParams,
|
||||
GoogleAdsListAdGroupsResponse,
|
||||
} from '@/tools/google_ads/types'
|
||||
import { validateNumericId, validateStatus } from '@/tools/google_ads/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const googleAdsListAdGroupsTool: ToolConfig<
|
||||
GoogleAdsListAdGroupsParams,
|
||||
GoogleAdsListAdGroupsResponse
|
||||
> = {
|
||||
id: 'google_ads_list_ad_groups',
|
||||
name: 'List Google Ads Ad Groups',
|
||||
description: 'List ad groups in a Google Ads campaign',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'google-ads',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token for the Google Ads API',
|
||||
},
|
||||
customerId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Google Ads customer ID (numeric, no dashes)',
|
||||
},
|
||||
developerToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Google Ads API developer token',
|
||||
},
|
||||
managerCustomerId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Manager account customer ID (if accessing via manager account)',
|
||||
},
|
||||
campaignId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Campaign ID to list ad groups for',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by ad group status (ENABLED, PAUSED, REMOVED)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of ad groups to return',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const customerId = validateNumericId(params.customerId, 'customerId')
|
||||
return `https://googleads.googleapis.com/v19/customers/${customerId}/googleAds:search`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'developer-token': params.developerToken,
|
||||
}
|
||||
if (params.managerCustomerId) {
|
||||
headers['login-customer-id'] = validateNumericId(
|
||||
params.managerCustomerId,
|
||||
'managerCustomerId'
|
||||
)
|
||||
}
|
||||
return headers
|
||||
},
|
||||
body: (params) => {
|
||||
let query =
|
||||
'SELECT ad_group.id, ad_group.name, ad_group.status, ad_group.type, campaign.id, campaign.name FROM ad_group'
|
||||
|
||||
const campaignId = validateNumericId(params.campaignId, 'campaignId')
|
||||
const conditions: string[] = [`campaign.id = ${campaignId}`]
|
||||
|
||||
if (params.status) {
|
||||
conditions.push(`ad_group.status = '${validateStatus(params.status)}'`)
|
||||
} else {
|
||||
conditions.push("ad_group.status != 'REMOVED'")
|
||||
}
|
||||
|
||||
query += ` WHERE ${conditions.join(' AND ')}`
|
||||
query += ' ORDER BY ad_group.name'
|
||||
|
||||
if (params.limit) {
|
||||
query += ` LIMIT ${params.limit}`
|
||||
}
|
||||
|
||||
return { query }
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage =
|
||||
data?.error?.message ?? data?.error?.details?.[0]?.errors?.[0]?.message ?? 'Unknown error'
|
||||
return {
|
||||
success: false,
|
||||
output: { adGroups: [], totalCount: 0 },
|
||||
error: errorMessage,
|
||||
}
|
||||
}
|
||||
|
||||
const results = data.results ?? []
|
||||
const adGroups = results.map((r: Record<string, any>) => ({
|
||||
id: r.adGroup?.id ?? '',
|
||||
name: r.adGroup?.name ?? '',
|
||||
status: r.adGroup?.status ?? '',
|
||||
type: r.adGroup?.type ?? null,
|
||||
campaignId: r.campaign?.id ?? '',
|
||||
campaignName: r.campaign?.name ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
adGroups,
|
||||
totalCount: adGroups.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
adGroups: {
|
||||
type: 'array',
|
||||
description: 'List of ad groups in the campaign',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Ad group ID' },
|
||||
name: { type: 'string', description: 'Ad group name' },
|
||||
status: { type: 'string', description: 'Ad group status (ENABLED, PAUSED, REMOVED)' },
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'Ad group type (SEARCH_STANDARD, DISPLAY_STANDARD, SHOPPING_PRODUCT_ADS)',
|
||||
},
|
||||
campaignId: { type: 'string', description: 'Parent campaign ID' },
|
||||
campaignName: { type: 'string', description: 'Parent campaign name' },
|
||||
},
|
||||
},
|
||||
},
|
||||
totalCount: {
|
||||
type: 'number',
|
||||
description: 'Total number of ad groups returned',
|
||||
},
|
||||
},
|
||||
}
|
||||
168
apps/sim/tools/google_ads/list_campaigns.ts
Normal file
168
apps/sim/tools/google_ads/list_campaigns.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type {
|
||||
GoogleAdsListCampaignsParams,
|
||||
GoogleAdsListCampaignsResponse,
|
||||
} from '@/tools/google_ads/types'
|
||||
import { validateNumericId, validateStatus } from '@/tools/google_ads/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const googleAdsListCampaignsTool: ToolConfig<
|
||||
GoogleAdsListCampaignsParams,
|
||||
GoogleAdsListCampaignsResponse
|
||||
> = {
|
||||
id: 'google_ads_list_campaigns',
|
||||
name: 'List Google Ads Campaigns',
|
||||
description: 'List campaigns in a Google Ads account with optional status filtering',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'google-ads',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token for the Google Ads API',
|
||||
},
|
||||
customerId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Google Ads customer ID (numeric, no dashes)',
|
||||
},
|
||||
developerToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Google Ads API developer token',
|
||||
},
|
||||
managerCustomerId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Manager account customer ID (if accessing via manager account)',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by campaign status (ENABLED, PAUSED, REMOVED)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of campaigns to return',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const customerId = validateNumericId(params.customerId, 'customerId')
|
||||
return `https://googleads.googleapis.com/v19/customers/${customerId}/googleAds:search`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'developer-token': params.developerToken,
|
||||
}
|
||||
if (params.managerCustomerId) {
|
||||
headers['login-customer-id'] = validateNumericId(
|
||||
params.managerCustomerId,
|
||||
'managerCustomerId'
|
||||
)
|
||||
}
|
||||
return headers
|
||||
},
|
||||
body: (params) => {
|
||||
let query =
|
||||
'SELECT campaign.id, campaign.name, campaign.status, campaign.advertising_channel_type, campaign.start_date, campaign.end_date, campaign_budget.amount_micros FROM campaign'
|
||||
|
||||
const conditions: string[] = []
|
||||
if (params.status) {
|
||||
conditions.push(`campaign.status = '${validateStatus(params.status)}'`)
|
||||
} else {
|
||||
conditions.push("campaign.status != 'REMOVED'")
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ` WHERE ${conditions.join(' AND ')}`
|
||||
}
|
||||
|
||||
query += ' ORDER BY campaign.name'
|
||||
|
||||
if (params.limit) {
|
||||
query += ` LIMIT ${params.limit}`
|
||||
}
|
||||
|
||||
return { query }
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage =
|
||||
data?.error?.message ?? data?.error?.details?.[0]?.errors?.[0]?.message ?? 'Unknown error'
|
||||
return {
|
||||
success: false,
|
||||
output: { campaigns: [], totalCount: 0 },
|
||||
error: errorMessage,
|
||||
}
|
||||
}
|
||||
|
||||
const results = data.results ?? []
|
||||
const campaigns = results.map((r: Record<string, any>) => ({
|
||||
id: r.campaign?.id ?? '',
|
||||
name: r.campaign?.name ?? '',
|
||||
status: r.campaign?.status ?? '',
|
||||
channelType: r.campaign?.advertisingChannelType ?? null,
|
||||
startDate: r.campaign?.startDate ?? null,
|
||||
endDate: r.campaign?.endDate ?? null,
|
||||
budgetAmountMicros: r.campaignBudget?.amountMicros ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
campaigns,
|
||||
totalCount: campaigns.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
campaigns: {
|
||||
type: 'array',
|
||||
description: 'List of campaigns in the account',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Campaign ID' },
|
||||
name: { type: 'string', description: 'Campaign name' },
|
||||
status: { type: 'string', description: 'Campaign status (ENABLED, PAUSED, REMOVED)' },
|
||||
channelType: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Advertising channel type (SEARCH, DISPLAY, SHOPPING, VIDEO, PERFORMANCE_MAX)',
|
||||
},
|
||||
startDate: { type: 'string', description: 'Campaign start date (YYYY-MM-DD)' },
|
||||
endDate: { type: 'string', description: 'Campaign end date (YYYY-MM-DD)' },
|
||||
budgetAmountMicros: {
|
||||
type: 'string',
|
||||
description: 'Daily budget in micros (divide by 1,000,000 for currency value)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
totalCount: {
|
||||
type: 'number',
|
||||
description: 'Total number of campaigns returned',
|
||||
},
|
||||
},
|
||||
}
|
||||
84
apps/sim/tools/google_ads/list_customers.ts
Normal file
84
apps/sim/tools/google_ads/list_customers.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type {
|
||||
GoogleAdsListCustomersParams,
|
||||
GoogleAdsListCustomersResponse,
|
||||
} from '@/tools/google_ads/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const googleAdsListCustomersTool: ToolConfig<
|
||||
GoogleAdsListCustomersParams,
|
||||
GoogleAdsListCustomersResponse
|
||||
> = {
|
||||
id: 'google_ads_list_customers',
|
||||
name: 'List Google Ads Customers',
|
||||
description: 'List all Google Ads customer accounts accessible by the authenticated user',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'google-ads',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token for the Google Ads API',
|
||||
},
|
||||
developerToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Google Ads API developer token',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://googleads.googleapis.com/v19/customers:listAccessibleCustomers',
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'developer-token': params.developerToken,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage =
|
||||
data?.error?.message ?? data?.error?.details?.[0]?.errors?.[0]?.message ?? 'Unknown error'
|
||||
return {
|
||||
success: false,
|
||||
output: { customerIds: [], totalCount: 0 },
|
||||
error: errorMessage,
|
||||
}
|
||||
}
|
||||
|
||||
const resourceNames: string[] = data.resourceNames ?? []
|
||||
const customerIds = resourceNames.map((rn: string) => rn.replace('customers/', ''))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
customerIds,
|
||||
totalCount: customerIds.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
customerIds: {
|
||||
type: 'array',
|
||||
description: 'List of accessible customer IDs',
|
||||
items: {
|
||||
type: 'string',
|
||||
description: 'Google Ads customer ID (numeric, no dashes)',
|
||||
},
|
||||
},
|
||||
totalCount: {
|
||||
type: 'number',
|
||||
description: 'Total number of accessible customer accounts',
|
||||
},
|
||||
},
|
||||
}
|
||||
130
apps/sim/tools/google_ads/search.ts
Normal file
130
apps/sim/tools/google_ads/search.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { GoogleAdsSearchParams, GoogleAdsSearchResponse } from '@/tools/google_ads/types'
|
||||
import { validateNumericId } from '@/tools/google_ads/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const googleAdsSearchTool: ToolConfig<GoogleAdsSearchParams, GoogleAdsSearchResponse> = {
|
||||
id: 'google_ads_search',
|
||||
name: 'Google Ads Search (GAQL)',
|
||||
description: 'Run a custom Google Ads Query Language (GAQL) query',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'google-ads',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token for the Google Ads API',
|
||||
},
|
||||
customerId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Google Ads customer ID (numeric, no dashes)',
|
||||
},
|
||||
developerToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Google Ads API developer token',
|
||||
},
|
||||
managerCustomerId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Manager account customer ID (if accessing via manager account)',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'GAQL query to execute',
|
||||
},
|
||||
pageToken: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Page token for pagination',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const customerId = validateNumericId(params.customerId, 'customerId')
|
||||
return `https://googleads.googleapis.com/v19/customers/${customerId}/googleAds:search`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'developer-token': params.developerToken,
|
||||
}
|
||||
if (params.managerCustomerId) {
|
||||
headers['login-customer-id'] = validateNumericId(
|
||||
params.managerCustomerId,
|
||||
'managerCustomerId'
|
||||
)
|
||||
}
|
||||
return headers
|
||||
},
|
||||
body: (params) => {
|
||||
const body: Record<string, unknown> = {
|
||||
query: params.query,
|
||||
searchSettings: {
|
||||
returnTotalResultsCount: true,
|
||||
},
|
||||
}
|
||||
if (params.pageToken) {
|
||||
body.pageToken = params.pageToken
|
||||
}
|
||||
return body
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage =
|
||||
data?.error?.message ?? data?.error?.details?.[0]?.errors?.[0]?.message ?? 'Unknown error'
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
results: [],
|
||||
totalResultsCount: null,
|
||||
nextPageToken: null,
|
||||
},
|
||||
error: errorMessage,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
results: data.results ?? [],
|
||||
totalResultsCount: data.totalResultsCount ? Number(data.totalResultsCount) : null,
|
||||
nextPageToken: data.nextPageToken ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
results: {
|
||||
type: 'json',
|
||||
description: 'Array of result objects from the GAQL query',
|
||||
},
|
||||
totalResultsCount: {
|
||||
type: 'number',
|
||||
description: 'Total number of matching results',
|
||||
},
|
||||
nextPageToken: {
|
||||
type: 'string',
|
||||
description: 'Token for the next page of results',
|
||||
},
|
||||
},
|
||||
}
|
||||
187
apps/sim/tools/google_ads/types.ts
Normal file
187
apps/sim/tools/google_ads/types.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
const NUMERIC_ID_REGEX = /^\d+$/
|
||||
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||
const VALID_STATUSES = new Set(['ENABLED', 'PAUSED', 'REMOVED'])
|
||||
const VALID_DATE_RANGES = new Set([
|
||||
'TODAY',
|
||||
'YESTERDAY',
|
||||
'LAST_7_DAYS',
|
||||
'LAST_14_DAYS',
|
||||
'LAST_30_DAYS',
|
||||
'LAST_BUSINESS_WEEK',
|
||||
'THIS_MONTH',
|
||||
'LAST_MONTH',
|
||||
'THIS_WEEK_SUN_TODAY',
|
||||
'THIS_WEEK_MON_TODAY',
|
||||
'LAST_WEEK_SUN_SAT',
|
||||
'LAST_WEEK_MON_SUN',
|
||||
])
|
||||
|
||||
/** Validates that a value is a numeric ID (digits only). */
|
||||
export function validateNumericId(value: string, fieldName: string): string {
|
||||
const cleaned = value.replace(/-/g, '')
|
||||
if (!NUMERIC_ID_REGEX.test(cleaned)) {
|
||||
throw new Error(`${fieldName} must be numeric (digits only), got: ${value}`)
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/** Validates that a status value is a known Google Ads status. */
|
||||
export function validateStatus(value: string): string {
|
||||
if (!VALID_STATUSES.has(value)) {
|
||||
throw new Error(`Invalid status: ${value}. Must be one of: ${[...VALID_STATUSES].join(', ')}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Validates a date string is in YYYY-MM-DD format. */
|
||||
export function validateDate(value: string, fieldName: string): string {
|
||||
if (!DATE_REGEX.test(value)) {
|
||||
throw new Error(`${fieldName} must be in YYYY-MM-DD format, got: ${value}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Validates a date range is a known Google Ads predefined range. */
|
||||
export function validateDateRange(value: string): string {
|
||||
if (!VALID_DATE_RANGES.has(value)) {
|
||||
throw new Error(
|
||||
`Invalid date range: ${value}. Must be one of: ${[...VALID_DATE_RANGES].join(', ')}`
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export interface GoogleAdsBaseParams {
|
||||
accessToken: string
|
||||
customerId: string
|
||||
developerToken: string
|
||||
managerCustomerId?: string
|
||||
}
|
||||
|
||||
export interface GoogleAdsListCustomersParams {
|
||||
accessToken: string
|
||||
developerToken: string
|
||||
}
|
||||
|
||||
export interface GoogleAdsSearchParams extends GoogleAdsBaseParams {
|
||||
query: string
|
||||
pageToken?: string
|
||||
}
|
||||
|
||||
export interface GoogleAdsListCampaignsParams extends GoogleAdsBaseParams {
|
||||
status?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface GoogleAdsCampaignPerformanceParams extends GoogleAdsBaseParams {
|
||||
campaignId?: string
|
||||
dateRange?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export interface GoogleAdsListAdGroupsParams extends GoogleAdsBaseParams {
|
||||
campaignId: string
|
||||
status?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface GoogleAdsAdPerformanceParams extends GoogleAdsBaseParams {
|
||||
campaignId?: string
|
||||
adGroupId?: string
|
||||
dateRange?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface GoogleAdsListCustomersResponse extends ToolResponse {
|
||||
output: {
|
||||
customerIds: string[]
|
||||
totalCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface GoogleAdsSearchResponse extends ToolResponse {
|
||||
output: {
|
||||
results: Record<string, unknown>[]
|
||||
totalResultsCount: number | null
|
||||
nextPageToken: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface GoogleAdsCampaign {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
channelType: string | null
|
||||
startDate: string | null
|
||||
endDate: string | null
|
||||
budgetAmountMicros: string | null
|
||||
}
|
||||
|
||||
export interface GoogleAdsListCampaignsResponse extends ToolResponse {
|
||||
output: {
|
||||
campaigns: GoogleAdsCampaign[]
|
||||
totalCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface GoogleAdsCampaignPerformance {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
impressions: string
|
||||
clicks: string
|
||||
costMicros: string
|
||||
ctr: number | null
|
||||
conversions: number | null
|
||||
date: string | null
|
||||
}
|
||||
|
||||
export interface GoogleAdsCampaignPerformanceResponse extends ToolResponse {
|
||||
output: {
|
||||
campaigns: GoogleAdsCampaignPerformance[]
|
||||
totalCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface GoogleAdsAdGroup {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
type: string | null
|
||||
campaignId: string
|
||||
campaignName: string | null
|
||||
}
|
||||
|
||||
export interface GoogleAdsListAdGroupsResponse extends ToolResponse {
|
||||
output: {
|
||||
adGroups: GoogleAdsAdGroup[]
|
||||
totalCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface GoogleAdsAdPerformance {
|
||||
adId: string
|
||||
adGroupId: string
|
||||
adGroupName: string | null
|
||||
campaignId: string
|
||||
campaignName: string | null
|
||||
adType: string | null
|
||||
impressions: string
|
||||
clicks: string
|
||||
costMicros: string
|
||||
ctr: number | null
|
||||
conversions: number | null
|
||||
date: string | null
|
||||
}
|
||||
|
||||
export interface GoogleAdsAdPerformanceResponse extends ToolResponse {
|
||||
output: {
|
||||
ads: GoogleAdsAdPerformance[]
|
||||
totalCount: number
|
||||
}
|
||||
}
|
||||
@@ -20,109 +20,38 @@ export const grainCreateHookTool: ToolConfig<GrainCreateHookParams, GrainCreateH
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Webhook endpoint URL (e.g., "https://example.com/webhooks/grain")',
|
||||
},
|
||||
hookType: {
|
||||
viewId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Type of webhook: "recording_added" or "upload_status"',
|
||||
description: 'Grain view ID from GET /_/public-api/views',
|
||||
},
|
||||
filterBeforeDatetime: {
|
||||
type: 'string',
|
||||
actions: {
|
||||
type: 'array',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter: recordings before this ISO8601 date (e.g., "2024-01-15T00:00:00Z")',
|
||||
},
|
||||
filterAfterDatetime: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter: recordings after this ISO8601 date (e.g., "2024-01-01T00:00:00Z")',
|
||||
},
|
||||
filterParticipantScope: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter: "internal" or "external"',
|
||||
},
|
||||
filterTeamId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter: specific team UUID (e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890")',
|
||||
},
|
||||
filterMeetingTypeId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Filter: specific meeting type UUID (e.g., "a1b2c3d4-e5f6-7890-abcd-ef1234567890")',
|
||||
},
|
||||
includeHighlights: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Include highlights in webhook payload',
|
||||
},
|
||||
includeParticipants: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Include participants in webhook payload',
|
||||
},
|
||||
includeAiSummary: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-only',
|
||||
description: 'Include AI summary in webhook payload',
|
||||
description: 'Optional list of actions to subscribe to: added, updated, removed',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.grain.com/_/public-api/v2/hooks/create',
|
||||
url: 'https://api.grain.com/_/public-api/hooks',
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Public-Api-Version': '2025-10-31',
|
||||
}),
|
||||
body: (params) => {
|
||||
const body: Record<string, any> = {
|
||||
version: 2,
|
||||
hook_url: params.hookUrl,
|
||||
hook_type: params.hookType,
|
||||
view_id: params.viewId,
|
||||
}
|
||||
|
||||
const filter: Record<string, any> = {}
|
||||
if (params.filterBeforeDatetime) {
|
||||
filter.before_datetime = params.filterBeforeDatetime
|
||||
}
|
||||
if (params.filterAfterDatetime) {
|
||||
filter.after_datetime = params.filterAfterDatetime
|
||||
}
|
||||
if (params.filterParticipantScope) {
|
||||
filter.participant_scope = params.filterParticipantScope
|
||||
}
|
||||
if (params.filterTeamId) {
|
||||
filter.team = params.filterTeamId
|
||||
}
|
||||
if (params.filterMeetingTypeId) {
|
||||
filter.meeting_type = params.filterMeetingTypeId
|
||||
}
|
||||
if (Object.keys(filter).length > 0) {
|
||||
body.filter = filter
|
||||
}
|
||||
|
||||
const include: Record<string, any> = {}
|
||||
if (params.includeHighlights) {
|
||||
include.highlights = true
|
||||
}
|
||||
if (params.includeParticipants) {
|
||||
include.participants = true
|
||||
}
|
||||
if (params.includeAiSummary) {
|
||||
include.ai_summary = true
|
||||
}
|
||||
if (Object.keys(include).length > 0) {
|
||||
body.include = include
|
||||
if (params.actions && params.actions.length > 0) {
|
||||
body.actions = params.actions
|
||||
}
|
||||
|
||||
return body
|
||||
@@ -136,6 +65,10 @@ export const grainCreateHookTool: ToolConfig<GrainCreateHookParams, GrainCreateH
|
||||
throw new Error(data.error || data.message || 'Failed to create webhook')
|
||||
}
|
||||
|
||||
if (!data?.id) {
|
||||
throw new Error('Grain webhook created but response did not include a webhook id')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: data,
|
||||
@@ -155,17 +88,13 @@ export const grainCreateHookTool: ToolConfig<GrainCreateHookParams, GrainCreateH
|
||||
type: 'string',
|
||||
description: 'The webhook URL',
|
||||
},
|
||||
hook_type: {
|
||||
view_id: {
|
||||
type: 'string',
|
||||
description: 'Type of hook: recording_added or upload_status',
|
||||
description: 'Grain view ID for the webhook',
|
||||
},
|
||||
filter: {
|
||||
type: 'object',
|
||||
description: 'Applied filters',
|
||||
},
|
||||
include: {
|
||||
type: 'object',
|
||||
description: 'Included fields',
|
||||
actions: {
|
||||
type: 'array',
|
||||
description: 'Configured actions for the webhook',
|
||||
},
|
||||
inserted_at: {
|
||||
type: 'string',
|
||||
|
||||
@@ -23,12 +23,11 @@ export const grainDeleteHookTool: ToolConfig<GrainDeleteHookParams, GrainDeleteH
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => `https://api.grain.com/_/public-api/v2/hooks/${params.hookId}`,
|
||||
url: (params) => `https://api.grain.com/_/public-api/hooks/${params.hookId}`,
|
||||
method: 'DELETE',
|
||||
headers: (params) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Public-Api-Version': '2025-10-31',
|
||||
}),
|
||||
},
|
||||
|
||||
|
||||
@@ -6,3 +6,4 @@ export { grainListHooksTool } from './list_hooks'
|
||||
export { grainListMeetingTypesTool } from './list_meeting_types'
|
||||
export { grainListRecordingsTool } from './list_recordings'
|
||||
export { grainListTeamsTool } from './list_teams'
|
||||
export { grainListViewsTool } from './list_views'
|
||||
|
||||
@@ -17,12 +17,11 @@ export const grainListHooksTool: ToolConfig<GrainListHooksParams, GrainListHooks
|
||||
},
|
||||
|
||||
request: {
|
||||
url: 'https://api.grain.com/_/public-api/v2/hooks',
|
||||
method: 'POST',
|
||||
url: 'https://api.grain.com/_/public-api/hooks',
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Public-Api-Version': '2025-10-31',
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -51,9 +50,8 @@ export const grainListHooksTool: ToolConfig<GrainListHooksParams, GrainListHooks
|
||||
id: { type: 'string', description: 'Hook UUID' },
|
||||
enabled: { type: 'boolean', description: 'Whether hook is active' },
|
||||
hook_url: { type: 'string', description: 'Webhook URL' },
|
||||
hook_type: { type: 'string', description: 'Type: recording_added or upload_status' },
|
||||
filter: { type: 'object', description: 'Applied filters' },
|
||||
include: { type: 'object', description: 'Included fields' },
|
||||
view_id: { type: 'string', description: 'Grain view ID' },
|
||||
actions: { type: 'array', description: 'Configured actions' },
|
||||
inserted_at: { type: 'string', description: 'Creation timestamp' },
|
||||
},
|
||||
},
|
||||
|
||||
66
apps/sim/tools/grain/list_views.ts
Normal file
66
apps/sim/tools/grain/list_views.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { GrainListViewsParams, GrainListViewsResponse } from '@/tools/grain/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const grainListViewsTool: ToolConfig<GrainListViewsParams, GrainListViewsResponse> = {
|
||||
id: 'grain_list_views',
|
||||
name: 'Grain List Views',
|
||||
description: 'List available Grain views for webhook subscriptions',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Grain API key (Personal Access Token)',
|
||||
},
|
||||
typeFilter: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional view type filter: recordings, highlights, or stories',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) =>
|
||||
params.typeFilter
|
||||
? `https://api.grain.com/_/public-api/views?type_filter=${encodeURIComponent(params.typeFilter)}`
|
||||
: 'https://api.grain.com/_/public-api/views',
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.message || 'Failed to list views')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
views: data.views || data || [],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
views: {
|
||||
type: 'array',
|
||||
description: 'Array of Grain views',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'View UUID' },
|
||||
name: { type: 'string', description: 'View name' },
|
||||
type: { type: 'string', description: 'View type: recordings, highlights, or stories' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -94,33 +94,27 @@ export interface GrainRecording {
|
||||
export interface GrainHook {
|
||||
id: string
|
||||
enabled: boolean
|
||||
version?: number
|
||||
hook_url: string
|
||||
hook_type: 'recording_added' | 'upload_status'
|
||||
filter: GrainRecordingFilter
|
||||
include: GrainRecordingInclude
|
||||
view_id?: string
|
||||
actions?: Array<'added' | 'updated' | 'removed'>
|
||||
inserted_at: string
|
||||
}
|
||||
|
||||
export interface GrainRecordingFilter {
|
||||
before_datetime?: string
|
||||
after_datetime?: string
|
||||
attendance?: 'hosted' | 'attended'
|
||||
participant_scope?: 'internal' | 'external'
|
||||
title_search?: string
|
||||
team?: string
|
||||
meeting_type?: string
|
||||
export interface GrainView {
|
||||
id: string
|
||||
name?: string
|
||||
type?: 'recordings' | 'highlights' | 'stories'
|
||||
}
|
||||
|
||||
export interface GrainRecordingInclude {
|
||||
highlights?: boolean
|
||||
participants?: boolean
|
||||
ai_summary?: boolean
|
||||
private_notes?: boolean
|
||||
calendar_event?: boolean
|
||||
hubspot?: boolean
|
||||
ai_template_sections?: {
|
||||
format?: 'json' | 'markdown' | 'text'
|
||||
allowed_sections?: string[]
|
||||
export interface GrainListViewsParams {
|
||||
apiKey: string
|
||||
typeFilter?: 'recordings' | 'highlights' | 'stories'
|
||||
}
|
||||
|
||||
export interface GrainListViewsResponse extends ToolResponse {
|
||||
output: {
|
||||
views: GrainView[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,15 +187,8 @@ export interface GrainListMeetingTypesResponse extends ToolResponse {
|
||||
export interface GrainCreateHookParams {
|
||||
apiKey: string
|
||||
hookUrl: string
|
||||
hookType: 'recording_added' | 'upload_status'
|
||||
filterBeforeDatetime?: string
|
||||
filterAfterDatetime?: string
|
||||
filterParticipantScope?: 'internal' | 'external'
|
||||
filterTeamId?: string
|
||||
filterMeetingTypeId?: string
|
||||
includeHighlights?: boolean
|
||||
includeParticipants?: boolean
|
||||
includeAiSummary?: boolean
|
||||
viewId: string
|
||||
actions?: Array<'added' | 'updated' | 'removed'>
|
||||
}
|
||||
|
||||
export interface GrainCreateHookResponse extends ToolResponse {
|
||||
|
||||
@@ -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,
|
||||
@@ -721,6 +728,14 @@ import {
|
||||
gongLookupPhoneTool,
|
||||
} from '@/tools/gong'
|
||||
import { googleSearchTool } from '@/tools/google'
|
||||
import {
|
||||
googleAdsAdPerformanceTool,
|
||||
googleAdsCampaignPerformanceTool,
|
||||
googleAdsListAdGroupsTool,
|
||||
googleAdsListCampaignsTool,
|
||||
googleAdsListCustomersTool,
|
||||
googleAdsSearchTool,
|
||||
} from '@/tools/google_ads'
|
||||
import {
|
||||
googleBigQueryGetTableTool,
|
||||
googleBigQueryInsertRowsTool,
|
||||
@@ -918,6 +933,7 @@ import {
|
||||
grainListMeetingTypesTool,
|
||||
grainListRecordingsTool,
|
||||
grainListTeamsTool,
|
||||
grainListViewsTool,
|
||||
} from '@/tools/grain'
|
||||
import {
|
||||
greenhouseGetApplicationTool,
|
||||
@@ -3147,6 +3163,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
grain_get_transcript: grainGetTranscriptTool,
|
||||
grain_list_teams: grainListTeamsTool,
|
||||
grain_list_meeting_types: grainListMeetingTypesTool,
|
||||
grain_list_views: grainListViewsTool,
|
||||
grain_create_hook: grainCreateHookTool,
|
||||
grain_list_hooks: grainListHooksTool,
|
||||
grain_delete_hook: grainDeleteHookTool,
|
||||
@@ -3666,6 +3683,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,
|
||||
@@ -4026,6 +4048,12 @@ export const tools: Record<string, ToolConfig> = {
|
||||
wordpress_list_users: wordpressListUsersTool,
|
||||
wordpress_get_user: wordpressGetUserTool,
|
||||
wordpress_search_content: wordpressSearchContentTool,
|
||||
google_ads_list_customers: googleAdsListCustomersTool,
|
||||
google_ads_search: googleAdsSearchTool,
|
||||
google_ads_list_campaigns: googleAdsListCampaignsTool,
|
||||
google_ads_campaign_performance: googleAdsCampaignPerformanceTool,
|
||||
google_ads_list_ad_groups: googleAdsListAdGroupsTool,
|
||||
google_ads_ad_performance: googleAdsAdPerformanceTool,
|
||||
google_bigquery_query: googleBigQueryQueryTool,
|
||||
google_bigquery_list_datasets: googleBigQueryListDatasetsTool,
|
||||
google_bigquery_list_tables: googleBigQueryListTablesTool,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -242,6 +242,101 @@ describe('workflowExecutorTool', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('transformResponse', () => {
|
||||
const transformResponse = workflowExecutorTool.transformResponse!
|
||||
|
||||
function mockResponse(body: any, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
it.concurrent('should parse standard format response', async () => {
|
||||
const body = {
|
||||
success: true,
|
||||
executionId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
output: { result: 'hello' },
|
||||
metadata: { duration: 500 },
|
||||
}
|
||||
|
||||
const result = await transformResponse(mockResponse(body))
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toEqual({ result: 'hello' })
|
||||
expect(result.duration).toBe(500)
|
||||
expect(result.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should parse standard format failure', async () => {
|
||||
const body = {
|
||||
success: false,
|
||||
executionId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
output: {},
|
||||
error: 'Something went wrong',
|
||||
}
|
||||
|
||||
const result = await transformResponse(mockResponse(body))
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Something went wrong')
|
||||
})
|
||||
|
||||
it.concurrent('should default success to false when missing', async () => {
|
||||
const body = { output: { data: 'test' } }
|
||||
|
||||
const result = await transformResponse(mockResponse(body))
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.output).toEqual({ data: 'test' })
|
||||
})
|
||||
|
||||
it.concurrent('should default output to empty object when missing', async () => {
|
||||
const body = { success: true }
|
||||
|
||||
const result = await transformResponse(mockResponse(body))
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toEqual({})
|
||||
expect(result.result).toEqual({})
|
||||
})
|
||||
|
||||
it.concurrent('should extract metadata duration', async () => {
|
||||
const body = {
|
||||
success: true,
|
||||
output: {},
|
||||
metadata: { duration: 1234 },
|
||||
}
|
||||
|
||||
const result = await transformResponse(mockResponse(body))
|
||||
|
||||
expect(result.duration).toBe(1234)
|
||||
})
|
||||
|
||||
it.concurrent('should default duration to 0 when metadata is missing', async () => {
|
||||
const body = { success: true, output: {} }
|
||||
|
||||
const result = await transformResponse(mockResponse(body))
|
||||
|
||||
expect(result.duration).toBe(0)
|
||||
})
|
||||
|
||||
it.concurrent('should extract workflowId and workflowName', async () => {
|
||||
const body = {
|
||||
success: true,
|
||||
output: {},
|
||||
workflowId: 'wf-123',
|
||||
workflowName: 'My Workflow',
|
||||
}
|
||||
|
||||
const result = await transformResponse(mockResponse(body))
|
||||
|
||||
expect(result.childWorkflowId).toBe('wf-123')
|
||||
expect(result.childWorkflowName).toBe('My Workflow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool metadata', () => {
|
||||
it.concurrent('should have correct id', () => {
|
||||
expect(workflowExecutorTool.id).toBe('workflow_executor')
|
||||
|
||||
41
apps/sim/triggers/ashby/application_submit.ts
Normal file
41
apps/sim/triggers/ashby/application_submit.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { AshbyIcon } from '@/components/icons'
|
||||
import { buildTriggerSubBlocks } from '@/triggers'
|
||||
import {
|
||||
ashbySetupInstructions,
|
||||
ashbyTriggerOptions,
|
||||
buildApplicationSubmitOutputs,
|
||||
buildAshbyExtraFields,
|
||||
} from '@/triggers/ashby/utils'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
/**
|
||||
* Ashby Application Submitted Trigger
|
||||
*
|
||||
* This is the PRIMARY trigger - it includes the dropdown for selecting trigger type.
|
||||
* Fires when a candidate submits an application or is manually added.
|
||||
*/
|
||||
export const ashbyApplicationSubmitTrigger: TriggerConfig = {
|
||||
id: 'ashby_application_submit',
|
||||
name: 'Ashby Application Submitted',
|
||||
provider: 'ashby',
|
||||
description: 'Trigger workflow when a new application is submitted',
|
||||
version: '1.0.0',
|
||||
icon: AshbyIcon,
|
||||
|
||||
subBlocks: buildTriggerSubBlocks({
|
||||
triggerId: 'ashby_application_submit',
|
||||
triggerOptions: ashbyTriggerOptions,
|
||||
includeDropdown: true,
|
||||
setupInstructions: ashbySetupInstructions('Application Submitted'),
|
||||
extraFields: buildAshbyExtraFields('ashby_application_submit'),
|
||||
}),
|
||||
|
||||
outputs: buildApplicationSubmitOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
39
apps/sim/triggers/ashby/candidate_delete.ts
Normal file
39
apps/sim/triggers/ashby/candidate_delete.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { AshbyIcon } from '@/components/icons'
|
||||
import { buildTriggerSubBlocks } from '@/triggers'
|
||||
import {
|
||||
ashbySetupInstructions,
|
||||
ashbyTriggerOptions,
|
||||
buildAshbyExtraFields,
|
||||
buildCandidateDeleteOutputs,
|
||||
} from '@/triggers/ashby/utils'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
/**
|
||||
* Ashby Candidate Deleted Trigger
|
||||
*
|
||||
* Fires when a candidate record is deleted from Ashby.
|
||||
*/
|
||||
export const ashbyCandidateDeleteTrigger: TriggerConfig = {
|
||||
id: 'ashby_candidate_delete',
|
||||
name: 'Ashby Candidate Deleted',
|
||||
provider: 'ashby',
|
||||
description: 'Trigger workflow when a candidate is deleted',
|
||||
version: '1.0.0',
|
||||
icon: AshbyIcon,
|
||||
|
||||
subBlocks: buildTriggerSubBlocks({
|
||||
triggerId: 'ashby_candidate_delete',
|
||||
triggerOptions: ashbyTriggerOptions,
|
||||
setupInstructions: ashbySetupInstructions('Candidate Deleted'),
|
||||
extraFields: buildAshbyExtraFields('ashby_candidate_delete'),
|
||||
}),
|
||||
|
||||
outputs: buildCandidateDeleteOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
40
apps/sim/triggers/ashby/candidate_hire.ts
Normal file
40
apps/sim/triggers/ashby/candidate_hire.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AshbyIcon } from '@/components/icons'
|
||||
import { buildTriggerSubBlocks } from '@/triggers'
|
||||
import {
|
||||
ashbySetupInstructions,
|
||||
ashbyTriggerOptions,
|
||||
buildAshbyExtraFields,
|
||||
buildCandidateHireOutputs,
|
||||
} from '@/triggers/ashby/utils'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
/**
|
||||
* Ashby Candidate Hired Trigger
|
||||
*
|
||||
* Fires when a candidate is hired. Also triggers applicationUpdate
|
||||
* and candidateStageChange webhooks.
|
||||
*/
|
||||
export const ashbyCandidateHireTrigger: TriggerConfig = {
|
||||
id: 'ashby_candidate_hire',
|
||||
name: 'Ashby Candidate Hired',
|
||||
provider: 'ashby',
|
||||
description: 'Trigger workflow when a candidate is hired',
|
||||
version: '1.0.0',
|
||||
icon: AshbyIcon,
|
||||
|
||||
subBlocks: buildTriggerSubBlocks({
|
||||
triggerId: 'ashby_candidate_hire',
|
||||
triggerOptions: ashbyTriggerOptions,
|
||||
setupInstructions: ashbySetupInstructions('Candidate Hired'),
|
||||
extraFields: buildAshbyExtraFields('ashby_candidate_hire'),
|
||||
}),
|
||||
|
||||
outputs: buildCandidateHireOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
40
apps/sim/triggers/ashby/candidate_stage_change.ts
Normal file
40
apps/sim/triggers/ashby/candidate_stage_change.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { AshbyIcon } from '@/components/icons'
|
||||
import { buildTriggerSubBlocks } from '@/triggers'
|
||||
import {
|
||||
ashbySetupInstructions,
|
||||
ashbyTriggerOptions,
|
||||
buildAshbyExtraFields,
|
||||
buildCandidateStageChangeOutputs,
|
||||
} from '@/triggers/ashby/utils'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
/**
|
||||
* Ashby Candidate Stage Change Trigger
|
||||
*
|
||||
* Fires when a candidate moves to a different interview stage.
|
||||
* Also triggered by candidateHire events.
|
||||
*/
|
||||
export const ashbyCandidateStageChangeTrigger: TriggerConfig = {
|
||||
id: 'ashby_candidate_stage_change',
|
||||
name: 'Ashby Candidate Stage Change',
|
||||
provider: 'ashby',
|
||||
description: 'Trigger workflow when a candidate changes interview stages',
|
||||
version: '1.0.0',
|
||||
icon: AshbyIcon,
|
||||
|
||||
subBlocks: buildTriggerSubBlocks({
|
||||
triggerId: 'ashby_candidate_stage_change',
|
||||
triggerOptions: ashbyTriggerOptions,
|
||||
setupInstructions: ashbySetupInstructions('Candidate Stage Change'),
|
||||
extraFields: buildAshbyExtraFields('ashby_candidate_stage_change'),
|
||||
}),
|
||||
|
||||
outputs: buildCandidateStageChangeOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
6
apps/sim/triggers/ashby/index.ts
Normal file
6
apps/sim/triggers/ashby/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { ashbyApplicationSubmitTrigger } from './application_submit'
|
||||
export { ashbyCandidateDeleteTrigger } from './candidate_delete'
|
||||
export { ashbyCandidateHireTrigger } from './candidate_hire'
|
||||
export { ashbyCandidateStageChangeTrigger } from './candidate_stage_change'
|
||||
export { ashbyJobCreateTrigger } from './job_create'
|
||||
export { ashbyOfferCreateTrigger } from './offer_create'
|
||||
39
apps/sim/triggers/ashby/job_create.ts
Normal file
39
apps/sim/triggers/ashby/job_create.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { AshbyIcon } from '@/components/icons'
|
||||
import { buildTriggerSubBlocks } from '@/triggers'
|
||||
import {
|
||||
ashbySetupInstructions,
|
||||
ashbyTriggerOptions,
|
||||
buildAshbyExtraFields,
|
||||
buildJobCreateOutputs,
|
||||
} from '@/triggers/ashby/utils'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
/**
|
||||
* Ashby Job Created Trigger
|
||||
*
|
||||
* Fires when a new job posting is created in Ashby.
|
||||
*/
|
||||
export const ashbyJobCreateTrigger: TriggerConfig = {
|
||||
id: 'ashby_job_create',
|
||||
name: 'Ashby Job Created',
|
||||
provider: 'ashby',
|
||||
description: 'Trigger workflow when a new job is created',
|
||||
version: '1.0.0',
|
||||
icon: AshbyIcon,
|
||||
|
||||
subBlocks: buildTriggerSubBlocks({
|
||||
triggerId: 'ashby_job_create',
|
||||
triggerOptions: ashbyTriggerOptions,
|
||||
setupInstructions: ashbySetupInstructions('Job Created'),
|
||||
extraFields: buildAshbyExtraFields('ashby_job_create'),
|
||||
}),
|
||||
|
||||
outputs: buildJobCreateOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
39
apps/sim/triggers/ashby/offer_create.ts
Normal file
39
apps/sim/triggers/ashby/offer_create.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { AshbyIcon } from '@/components/icons'
|
||||
import { buildTriggerSubBlocks } from '@/triggers'
|
||||
import {
|
||||
ashbySetupInstructions,
|
||||
ashbyTriggerOptions,
|
||||
buildAshbyExtraFields,
|
||||
buildOfferCreateOutputs,
|
||||
} from '@/triggers/ashby/utils'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
/**
|
||||
* Ashby Offer Created Trigger
|
||||
*
|
||||
* Fires when a new offer is created for a candidate.
|
||||
*/
|
||||
export const ashbyOfferCreateTrigger: TriggerConfig = {
|
||||
id: 'ashby_offer_create',
|
||||
name: 'Ashby Offer Created',
|
||||
provider: 'ashby',
|
||||
description: 'Trigger workflow when a new offer is created',
|
||||
version: '1.0.0',
|
||||
icon: AshbyIcon,
|
||||
|
||||
subBlocks: buildTriggerSubBlocks({
|
||||
triggerId: 'ashby_offer_create',
|
||||
triggerOptions: ashbyTriggerOptions,
|
||||
setupInstructions: ashbySetupInstructions('Offer Created'),
|
||||
extraFields: buildAshbyExtraFields('ashby_offer_create'),
|
||||
}),
|
||||
|
||||
outputs: buildOfferCreateOutputs(),
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
237
apps/sim/triggers/ashby/utils.ts
Normal file
237
apps/sim/triggers/ashby/utils.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { TriggerOutput } from '@/triggers/types'
|
||||
|
||||
/**
|
||||
* Dropdown options for the Ashby trigger type selector.
|
||||
*/
|
||||
export const ashbyTriggerOptions = [
|
||||
{ label: 'Application Submitted', id: 'ashby_application_submit' },
|
||||
{ label: 'Candidate Stage Change', id: 'ashby_candidate_stage_change' },
|
||||
{ label: 'Candidate Hired', id: 'ashby_candidate_hire' },
|
||||
{ label: 'Candidate Deleted', id: 'ashby_candidate_delete' },
|
||||
{ label: 'Job Created', id: 'ashby_job_create' },
|
||||
{ label: 'Offer Created', id: 'ashby_offer_create' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Generates setup instructions for Ashby webhooks.
|
||||
* Webhooks are automatically created/deleted via the Ashby API.
|
||||
*/
|
||||
export function ashbySetupInstructions(eventType: string): string {
|
||||
const instructions = [
|
||||
'Enter your Ashby API Key above.',
|
||||
'You can find your API key in Ashby at <strong>Settings > API Keys</strong>. The key must have the <strong>apiKeysWrite</strong> permission.',
|
||||
`Click <strong>"Save Configuration"</strong> to automatically create the webhook in Ashby 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('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Ashby-specific extra fields for triggers.
|
||||
* Includes API key (required for automatic webhook creation).
|
||||
*/
|
||||
export function buildAshbyExtraFields(triggerId: string): SubBlockConfig[] {
|
||||
return [
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your Ashby API key',
|
||||
description: 'Required to create the webhook in Ashby. Must have apiKeysWrite permission.',
|
||||
password: true,
|
||||
required: true,
|
||||
paramVisibility: 'user-only',
|
||||
mode: 'trigger',
|
||||
condition: { field: 'selectedTriggerId', value: triggerId },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Core fields present in all Ashby webhook payloads.
|
||||
*/
|
||||
const coreOutputs = {
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'The webhook event type (e.g., applicationSubmit, candidateHire)',
|
||||
},
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Build outputs for applicationSubmit events.
|
||||
* Payload: { action, data: { application: { id, createdAt, updatedAt, status,
|
||||
* candidate: { id, name }, currentInterviewStage: { id, title },
|
||||
* job: { id, title } } } }
|
||||
*/
|
||||
export function buildApplicationSubmitOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
...coreOutputs,
|
||||
application: {
|
||||
id: { type: 'string', description: 'Application UUID' },
|
||||
createdAt: { type: 'string', description: 'Application creation timestamp (ISO 8601)' },
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
description: 'Application last update timestamp (ISO 8601)',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Application status (Active, Hired, Archived, Lead)',
|
||||
},
|
||||
candidate: {
|
||||
id: { type: 'string', description: 'Candidate UUID' },
|
||||
name: { type: 'string', description: 'Candidate name' },
|
||||
},
|
||||
currentInterviewStage: {
|
||||
id: { type: 'string', description: 'Current interview stage UUID' },
|
||||
title: { type: 'string', description: 'Current interview stage title' },
|
||||
},
|
||||
job: {
|
||||
id: { type: 'string', description: 'Job UUID' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
},
|
||||
},
|
||||
} as Record<string, TriggerOutput>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build outputs for candidateStageChange events.
|
||||
* Payload matches the application object structure (same as applicationUpdate).
|
||||
* Payload: { action, data: { application: { id, createdAt, updatedAt, status,
|
||||
* candidate: { id, name }, currentInterviewStage: { id, title, type },
|
||||
* job: { id, title } } } }
|
||||
*/
|
||||
export function buildCandidateStageChangeOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
...coreOutputs,
|
||||
application: {
|
||||
id: { type: 'string', description: 'Application UUID' },
|
||||
createdAt: { type: 'string', description: 'Application creation timestamp (ISO 8601)' },
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
description: 'Application last update timestamp (ISO 8601)',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Application status (Active, Hired, Archived, Lead)',
|
||||
},
|
||||
candidate: {
|
||||
id: { type: 'string', description: 'Candidate UUID' },
|
||||
name: { type: 'string', description: 'Candidate name' },
|
||||
},
|
||||
currentInterviewStage: {
|
||||
id: { type: 'string', description: 'Current interview stage UUID' },
|
||||
title: { type: 'string', description: 'Current interview stage title' },
|
||||
},
|
||||
job: {
|
||||
id: { type: 'string', description: 'Job UUID' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
},
|
||||
},
|
||||
} as Record<string, TriggerOutput>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build outputs for candidateHire events.
|
||||
* Payload: { action, data: { application: { id, createdAt, updatedAt, status,
|
||||
* candidate: { id, name }, currentInterviewStage: { id, title },
|
||||
* job: { id, title } } } }
|
||||
*/
|
||||
export function buildCandidateHireOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
...coreOutputs,
|
||||
application: {
|
||||
id: { type: 'string', description: 'Application UUID' },
|
||||
createdAt: { type: 'string', description: 'Application creation timestamp (ISO 8601)' },
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
description: 'Application last update timestamp (ISO 8601)',
|
||||
},
|
||||
status: { type: 'string', description: 'Application status (Hired)' },
|
||||
candidate: {
|
||||
id: { type: 'string', description: 'Candidate UUID' },
|
||||
name: { type: 'string', description: 'Candidate name' },
|
||||
},
|
||||
currentInterviewStage: {
|
||||
id: { type: 'string', description: 'Current interview stage UUID' },
|
||||
title: { type: 'string', description: 'Current interview stage title' },
|
||||
},
|
||||
job: {
|
||||
id: { type: 'string', description: 'Job UUID' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
},
|
||||
},
|
||||
} as Record<string, TriggerOutput>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build outputs for candidateDelete events.
|
||||
* Payload: { action, data: { candidate: { id } } }
|
||||
*/
|
||||
export function buildCandidateDeleteOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
...coreOutputs,
|
||||
candidate: {
|
||||
id: { type: 'string', description: 'Deleted candidate UUID' },
|
||||
},
|
||||
} as Record<string, TriggerOutput>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build outputs for jobCreate events.
|
||||
* Payload: { action, data: { job: { id, title, confidential, status, employmentType } } }
|
||||
*/
|
||||
export function buildJobCreateOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
...coreOutputs,
|
||||
job: {
|
||||
id: { type: 'string', description: 'Job UUID' },
|
||||
title: { type: 'string', description: 'Job title' },
|
||||
confidential: { type: 'boolean', description: 'Whether the job is confidential' },
|
||||
status: { type: 'string', description: 'Job status (Open, Closed, Draft, Archived)' },
|
||||
employmentType: {
|
||||
type: 'string',
|
||||
description: 'Employment type (Full-time, Part-time, etc.)',
|
||||
},
|
||||
},
|
||||
} as Record<string, TriggerOutput>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build outputs for offerCreate events.
|
||||
* Payload: { action, data: { offer: { id, decidedAt, applicationId, acceptanceStatus,
|
||||
* offerStatus, latestVersion: { id } } } }
|
||||
*/
|
||||
export function buildOfferCreateOutputs(): Record<string, TriggerOutput> {
|
||||
return {
|
||||
...coreOutputs,
|
||||
offer: {
|
||||
id: { type: 'string', description: 'Offer UUID' },
|
||||
applicationId: { type: 'string', description: 'Associated application UUID' },
|
||||
acceptanceStatus: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Offer acceptance status (Accepted, Declined, Pending, Created, Cancelled, WaitingOnResponse)',
|
||||
},
|
||||
offerStatus: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Offer process status (WaitingOnApprovalStart, WaitingOnOfferApproval, WaitingOnCandidateResponse, CandidateAccepted, CandidateRejected, OfferCancelled)',
|
||||
},
|
||||
decidedAt: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Offer decision timestamp (ISO 8601). Typically null at creation; populated after candidate responds.',
|
||||
},
|
||||
latestVersion: {
|
||||
id: { type: 'string', description: 'Latest offer version UUID' },
|
||||
},
|
||||
},
|
||||
} as Record<string, TriggerOutput>
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user