mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
36 Commits
improvemen
...
v0.5.111
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36612ae42a | ||
|
|
68d207df94 | ||
|
|
d5502d602b | ||
|
|
37d524bb0a | ||
|
|
1c2c2c65d4 | ||
|
|
19ef526886 | ||
|
|
ff2a1527ab | ||
|
|
2e1c639a81 | ||
|
|
ecd3536a72 | ||
|
|
635179d696 | ||
|
|
f88926a6a8 | ||
|
|
690b47a0bf | ||
|
|
158d5236bc | ||
|
|
1ba1bc8edb | ||
|
|
53fd92a30a | ||
|
|
8c0a2e04b1 | ||
|
|
0a52b09deb | ||
|
|
1d36b80172 | ||
|
|
e6a5e7f4e4 | ||
|
|
6586c5ce40 | ||
|
|
3ce947566d | ||
|
|
70c36cb7aa | ||
|
|
f1ec5fe824 | ||
|
|
e07e3c34cc | ||
|
|
0d2e6ff31d | ||
|
|
4fd0989264 | ||
|
|
67f8a687f6 | ||
|
|
af592349d3 | ||
|
|
0d86ea01f0 | ||
|
|
115f04e989 | ||
|
|
34d92fae89 | ||
|
|
67aa4bb332 | ||
|
|
15ace5e63f | ||
|
|
fdca73679d | ||
|
|
da46a387c9 | ||
|
|
b7e377ec4b |
@@ -710,6 +710,155 @@ export function PerplexityIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ObsidianIcon(props: SVGProps<SVGSVGElement>) {
|
||||
const id = useId()
|
||||
const bl = `${id}-bl`
|
||||
const tr = `${id}-tr`
|
||||
const tl = `${id}-tl`
|
||||
const br = `${id}-br`
|
||||
const te = `${id}-te`
|
||||
const le = `${id}-le`
|
||||
const be = `${id}-be`
|
||||
const me = `${id}-me`
|
||||
const clip = `${id}-clip`
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 512 512' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<radialGradient
|
||||
id={bl}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(-59 -225 150 -39 161.4 470)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.4' />
|
||||
<stop offset='1' stopOpacity='.1' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={tr}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(50 -379 280 37 360 374.2)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.6' />
|
||||
<stop offset='1' stopColor='#fff' stopOpacity='.1' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={tl}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(69 -319 218 47 175.4 307)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.8' />
|
||||
<stop offset='1' stopColor='#fff' stopOpacity='.4' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={br}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(-96 -163 187 -111 335.3 512.2)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.3' />
|
||||
<stop offset='1' stopOpacity='.3' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={te}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(-36 166 -112 -24 310 128.2)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='0' />
|
||||
<stop offset='1' stopColor='#fff' stopOpacity='.2' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={le}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(88 89 -190 187 111 220.2)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.2' />
|
||||
<stop offset='1' stopColor='#fff' stopOpacity='.4' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={be}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(9 130 -276 20 215 284)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.2' />
|
||||
<stop offset='1' stopColor='#fff' stopOpacity='.3' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={me}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(-198 -104 327 -623 400 399.2)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.2' />
|
||||
<stop offset='.5' stopColor='#fff' stopOpacity='.2' />
|
||||
<stop offset='1' stopColor='#fff' stopOpacity='.3' />
|
||||
</radialGradient>
|
||||
<clipPath id={clip}>
|
||||
<path d='M.2.2h512v512H.2z' />
|
||||
</clipPath>
|
||||
<g clipPath={`url(#${clip})`}>
|
||||
<path
|
||||
d='M382.3 475.6c-3.1 23.4-26 41.6-48.7 35.3-32.4-8.9-69.9-22.8-103.6-25.4l-51.7-4a34 34 0 0 1-22-10.2l-89-91.7a34 34 0 0 1-6.7-37.7s55-121 57.1-127.3c2-6.3 9.6-61.2 14-90.6 1.2-7.9 5-15 11-20.3L248 8.9a34.1 34.1 0 0 1 49.6 4.3L386 125.6a37 37 0 0 1 7.6 22.4c0 21.3 1.8 65 13.6 93.2 11.5 27.3 32.5 57 43.5 71.5a17.3 17.3 0 0 1 1.3 19.2 1494 1494 0 0 1-44.8 70.6c-15 22.3-21.9 49.9-25 73.1z'
|
||||
fill='#6c31e3'
|
||||
/>
|
||||
<path
|
||||
d='M165.9 478.3c41.4-84 40.2-144.2 22.6-187-16.2-39.6-46.3-64.5-70-80-.6 2.3-1.3 4.4-2.2 6.5L60.6 342a34 34 0 0 0 6.6 37.7l89.1 91.7a34 34 0 0 0 9.6 7z'
|
||||
fill={`url(#${bl})`}
|
||||
/>
|
||||
<path
|
||||
d='M278.4 307.8c11.2 1.2 22.2 3.6 32.8 7.6 34 12.7 65 41.2 90.5 96.3 1.8-3.1 3.6-6.2 5.6-9.2a1536 1536 0 0 0 44.8-70.6 17 17 0 0 0-1.3-19.2c-11-14.6-32-44.2-43.5-71.5-11.8-28.2-13.5-72-13.6-93.2 0-8.1-2.6-16-7.6-22.4L297.6 13.2a34 34 0 0 0-1.5-1.7 96 96 0 0 1 2 54 198.3 198.3 0 0 1-17.6 41.3l-7.2 14.2a171 171 0 0 0-19.4 71c-1.2 29.4 4.8 66.4 24.5 115.8z'
|
||||
fill={`url(#${tr})`}
|
||||
/>
|
||||
<path
|
||||
d='M278.4 307.8c-19.7-49.4-25.8-86.4-24.5-115.9a171 171 0 0 1 19.4-71c2.3-4.8 4.8-9.5 7.2-14.1 7.1-13.9 14-27 17.6-41.4a96 96 0 0 0-2-54A34.1 34.1 0 0 0 248 9l-105.4 94.8a34.1 34.1 0 0 0-10.9 20.3l-12.8 85-.5 2.3c23.8 15.5 54 40.4 70.1 80a147 147 0 0 1 7.8 24.8c28-6.8 55.7-11 82.1-8.3z'
|
||||
fill={`url(#${tl})`}
|
||||
/>
|
||||
<path
|
||||
d='M333.6 511c22.7 6.2 45.6-12 48.7-35.4a187 187 0 0 1 19.4-63.9c-25.6-55-56.5-83.6-90.4-96.3-36-13.4-75.2-9-115 .7 8.9 40.4 3.6 93.3-30.4 162.2 4 1.8 8.1 3 12.5 3.3 0 0 24.4 2 53.6 4.1 29 2 72.4 17.1 101.6 25.2z'
|
||||
fill={`url(#${br})`}
|
||||
/>
|
||||
<g clipRule='evenodd' fillRule='evenodd'>
|
||||
<path
|
||||
d='M254.1 190c-1.3 29.2 2.4 62.8 22.1 112.1l-6.2-.5c-17.7-51.5-21.5-78-20.2-107.6a174.7 174.7 0 0 1 20.4-72c2.4-4.9 8-14.1 10.5-18.8 7.1-13.7 11.9-21 16-33.6 5.7-17.5 4.5-25.9 3.8-34.1 4.6 29.9-12.7 56-25.7 82.4a177.1 177.1 0 0 0-20.7 72z'
|
||||
fill={`url(#${te})`}
|
||||
/>
|
||||
<path
|
||||
d='M194.3 293.4c2.4 5.4 4.6 9.8 6 16.5L195 311c-2.1-7.8-3.8-13.4-6.8-20-17.8-42-46.3-63.6-69.7-79.5 28.2 15.2 57.2 39 75.7 81.9z'
|
||||
fill={`url(#${le})`}
|
||||
/>
|
||||
<path
|
||||
d='M200.6 315.1c9.8 46-1.2 104.2-33.6 160.9 27.1-56.2 40.2-110.1 29.3-160z'
|
||||
fill={`url(#${be})`}
|
||||
/>
|
||||
<path
|
||||
d='M312.5 311c53.1 19.9 73.6 63.6 88.9 100-19-38.1-45.2-80.3-90.8-96-34.8-11.8-64.1-10.4-114.3 1l-1.1-5c53.2-12.1 81-13.5 117.3 0z'
|
||||
fill={`url(#${me})`}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotionIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50' width='1em' height='1em' {...props}>
|
||||
@@ -1806,6 +1955,14 @@ export function Mem0Icon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function EvernoteIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='#7fce2c'>
|
||||
<path d='M29.343 16.818c.1 1.695-.08 3.368-.305 5.045-.225 1.712-.508 3.416-.964 5.084-.3 1.067-.673 2.1-1.202 3.074-.65 1.192-1.635 1.87-2.992 1.924l-3.832.036c-.636-.017-1.278-.146-1.9-.297-1.192-.3-1.862-1.1-2.06-2.3-.186-1.08-.173-2.187.04-3.264.252-1.23 1-1.96 2.234-2.103.817-.1 1.65-.077 2.476-.1.205-.007.275.098.203.287-.196.53-.236 1.07-.098 1.623.053.207-.023.307-.26.305a7.77 7.77 0 0 0-1.123.053c-.636.086-.96.47-.96 1.112 0 .205.026.416.066.622.103.507.45.78.944.837 1.123.127 2.247.138 3.37-.05.675-.114 1.08-.54 1.16-1.208.152-1.3.155-2.587-.228-3.845-.33-1.092-1.006-1.565-2.134-1.7l-3.36-.54c-1.06-.193-1.7-.887-1.92-1.9-.13-.572-.14-1.17-.214-1.757-.013-.106-.074-.208-.1-.3-.04.1-.106.212-.117.326-.066.68-.053 1.373-.185 2.04-.16.8-.404 1.566-.67 2.33-.185.535-.616.837-1.205.8a37.76 37.76 0 0 1-7.123-1.353l-.64-.207c-.927-.26-1.487-.903-1.74-1.787l-1-3.853-.74-4.3c-.115-.755-.2-1.523-.083-2.293.154-1.112.914-1.903 2.04-1.964l3.558-.062c.127 0 .254.003.373-.026a1.23 1.23 0 0 0 1.01-1.255l-.05-3.036c-.048-1.576.8-2.38 2.156-2.622a10.58 10.58 0 0 1 4.91.26c.933.275 1.467.923 1.715 1.83.058.22.146.3.37.287l2.582.01 3.333.37c.686.095 1.364.25 2.032.42 1.165.298 1.793 1.112 1.962 2.256l.357 3.355.3 5.577.01 2.277zm-4.534-1.155c-.02-.666-.07-1.267-.444-1.784a1.66 1.66 0 0 0-2.469-.15c-.364.4-.494.88-.564 1.4-.008.034.106.126.16.126l.8-.053c.768.007 1.523.113 2.25.393.066.026.136.04.265.077zM8.787 1.154a3.82 3.82 0 0 0-.278 1.592l.05 2.934c.005.357-.075.45-.433.45L5.1 6.156c-.583 0-1.143.1-1.554.278l5.2-5.332c.02.013.04.033.06.053z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
ElasticsearchIcon,
|
||||
ElevenLabsIcon,
|
||||
EnrichSoIcon,
|
||||
EvernoteIcon,
|
||||
ExaAIIcon,
|
||||
EyeIcon,
|
||||
FirecrawlIcon,
|
||||
@@ -103,6 +104,7 @@ import {
|
||||
MySQLIcon,
|
||||
Neo4jIcon,
|
||||
NotionIcon,
|
||||
ObsidianIcon,
|
||||
OnePasswordIcon,
|
||||
OpenAIIcon,
|
||||
OutlookIcon,
|
||||
@@ -202,6 +204,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
enrich: EnrichSoIcon,
|
||||
evernote: EvernoteIcon,
|
||||
exa: ExaAIIcon,
|
||||
file_v3: DocumentIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
@@ -265,6 +268,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
mysql: MySQLIcon,
|
||||
neo4j: Neo4jIcon,
|
||||
notion_v2: NotionIcon,
|
||||
obsidian: ObsidianIcon,
|
||||
onedrive: MicrosoftOneDriveIcon,
|
||||
onepassword: OnePasswordIcon,
|
||||
openai: OpenAIIcon,
|
||||
|
||||
267
apps/docs/content/docs/en/tools/evernote.mdx
Normal file
267
apps/docs/content/docs/en/tools/evernote.mdx
Normal file
@@ -0,0 +1,267 @@
|
||||
---
|
||||
title: Evernote
|
||||
description: Manage notes, notebooks, and tags in Evernote
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="evernote"
|
||||
color="#E0E0E0"
|
||||
/>
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `evernote_copy_note`
|
||||
|
||||
Copy a note to another notebook in Evernote
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Evernote developer token |
|
||||
| `noteGuid` | string | Yes | GUID of the note to copy |
|
||||
| `toNotebookGuid` | string | Yes | GUID of the destination notebook |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `note` | object | The copied note metadata |
|
||||
| ↳ `guid` | string | New note GUID |
|
||||
| ↳ `title` | string | Note title |
|
||||
| ↳ `notebookGuid` | string | GUID of the destination notebook |
|
||||
| ↳ `created` | number | Creation timestamp in milliseconds |
|
||||
| ↳ `updated` | number | Last updated timestamp in milliseconds |
|
||||
|
||||
### `evernote_create_note`
|
||||
|
||||
Create a new note in Evernote
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Evernote developer token |
|
||||
| `title` | string | Yes | Title of the note |
|
||||
| `content` | string | Yes | Content of the note \(plain text or ENML\) |
|
||||
| `notebookGuid` | string | No | GUID of the notebook to create the note in \(defaults to default notebook\) |
|
||||
| `tagNames` | string | No | Comma-separated list of tag names to apply |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `note` | object | The created note |
|
||||
| ↳ `guid` | string | Unique identifier of the note |
|
||||
| ↳ `title` | string | Title of the note |
|
||||
| ↳ `content` | string | ENML content of the note |
|
||||
| ↳ `notebookGuid` | string | GUID of the containing notebook |
|
||||
| ↳ `tagNames` | array | Tag names applied to the note |
|
||||
| ↳ `created` | number | Creation timestamp in milliseconds |
|
||||
| ↳ `updated` | number | Last updated timestamp in milliseconds |
|
||||
|
||||
### `evernote_create_notebook`
|
||||
|
||||
Create a new notebook in Evernote
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Evernote developer token |
|
||||
| `name` | string | Yes | Name for the new notebook |
|
||||
| `stack` | string | No | Stack name to group the notebook under |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `notebook` | object | The created notebook |
|
||||
| ↳ `guid` | string | Notebook GUID |
|
||||
| ↳ `name` | string | Notebook name |
|
||||
| ↳ `defaultNotebook` | boolean | Whether this is the default notebook |
|
||||
| ↳ `serviceCreated` | number | Creation timestamp in milliseconds |
|
||||
| ↳ `serviceUpdated` | number | Last updated timestamp in milliseconds |
|
||||
| ↳ `stack` | string | Notebook stack name |
|
||||
|
||||
### `evernote_create_tag`
|
||||
|
||||
Create a new tag in Evernote
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Evernote developer token |
|
||||
| `name` | string | Yes | Name for the new tag |
|
||||
| `parentGuid` | string | No | GUID of the parent tag for hierarchy |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tag` | object | The created tag |
|
||||
| ↳ `guid` | string | Tag GUID |
|
||||
| ↳ `name` | string | Tag name |
|
||||
| ↳ `parentGuid` | string | Parent tag GUID |
|
||||
| ↳ `updateSequenceNum` | number | Update sequence number |
|
||||
|
||||
### `evernote_delete_note`
|
||||
|
||||
Move a note to the trash in Evernote
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Evernote developer token |
|
||||
| `noteGuid` | string | Yes | GUID of the note to delete |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the note was successfully deleted |
|
||||
| `noteGuid` | string | GUID of the deleted note |
|
||||
|
||||
### `evernote_get_note`
|
||||
|
||||
Retrieve a note from Evernote by its GUID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Evernote developer token |
|
||||
| `noteGuid` | string | Yes | GUID of the note to retrieve |
|
||||
| `withContent` | boolean | No | Whether to include note content \(default: true\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `note` | object | The retrieved note |
|
||||
| ↳ `guid` | string | Unique identifier of the note |
|
||||
| ↳ `title` | string | Title of the note |
|
||||
| ↳ `content` | string | ENML content of the note |
|
||||
| ↳ `contentLength` | number | Length of the note content |
|
||||
| ↳ `notebookGuid` | string | GUID of the containing notebook |
|
||||
| ↳ `tagGuids` | array | GUIDs of tags on the note |
|
||||
| ↳ `tagNames` | array | Names of tags on the note |
|
||||
| ↳ `created` | number | Creation timestamp in milliseconds |
|
||||
| ↳ `updated` | number | Last updated timestamp in milliseconds |
|
||||
| ↳ `active` | boolean | Whether the note is active \(not in trash\) |
|
||||
|
||||
### `evernote_get_notebook`
|
||||
|
||||
Retrieve a notebook from Evernote by its GUID
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Evernote developer token |
|
||||
| `notebookGuid` | string | Yes | GUID of the notebook to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `notebook` | object | The retrieved notebook |
|
||||
| ↳ `guid` | string | Notebook GUID |
|
||||
| ↳ `name` | string | Notebook name |
|
||||
| ↳ `defaultNotebook` | boolean | Whether this is the default notebook |
|
||||
| ↳ `serviceCreated` | number | Creation timestamp in milliseconds |
|
||||
| ↳ `serviceUpdated` | number | Last updated timestamp in milliseconds |
|
||||
| ↳ `stack` | string | Notebook stack name |
|
||||
|
||||
### `evernote_list_notebooks`
|
||||
|
||||
List all notebooks in an Evernote account
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Evernote developer token |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `notebooks` | array | List of notebooks |
|
||||
|
||||
### `evernote_list_tags`
|
||||
|
||||
List all tags in an Evernote account
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Evernote developer token |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tags` | array | List of tags |
|
||||
|
||||
### `evernote_search_notes`
|
||||
|
||||
Search for notes in Evernote using the Evernote search grammar
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Evernote developer token |
|
||||
| `query` | string | Yes | Search query using Evernote search grammar \(e.g., "tag:work intitle:meeting"\) |
|
||||
| `notebookGuid` | string | No | Restrict search to a specific notebook by GUID |
|
||||
| `offset` | number | No | Starting index for results \(default: 0\) |
|
||||
| `maxNotes` | number | No | Maximum number of notes to return \(default: 25\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `totalNotes` | number | Total number of matching notes |
|
||||
| `notes` | array | List of matching note metadata |
|
||||
|
||||
### `evernote_update_note`
|
||||
|
||||
Update an existing note in Evernote
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Evernote developer token |
|
||||
| `noteGuid` | string | Yes | GUID of the note to update |
|
||||
| `title` | string | No | New title for the note |
|
||||
| `content` | string | No | New content for the note \(plain text or ENML\) |
|
||||
| `notebookGuid` | string | No | GUID of the notebook to move the note to |
|
||||
| `tagNames` | string | No | Comma-separated list of tag names \(replaces existing tags\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `note` | object | The updated note |
|
||||
| ↳ `guid` | string | Unique identifier of the note |
|
||||
| ↳ `title` | string | Title of the note |
|
||||
| ↳ `content` | string | ENML content of the note |
|
||||
| ↳ `notebookGuid` | string | GUID of the containing notebook |
|
||||
| ↳ `tagNames` | array | Tag names on the note |
|
||||
| ↳ `created` | number | Creation timestamp in milliseconds |
|
||||
| ↳ `updated` | number | Last updated timestamp in milliseconds |
|
||||
|
||||
|
||||
@@ -1014,4 +1014,36 @@ Get Jira users. If an account ID is provided, returns a single user. Otherwise,
|
||||
| `startAt` | number | Pagination start index |
|
||||
| `maxResults` | number | Maximum results per page |
|
||||
|
||||
### `jira_search_users`
|
||||
|
||||
Search for Jira users by email address or display name. Returns matching users with their accountId, displayName, and emailAddress.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `query` | string | Yes | A query string to search for users. Can be an email address, display name, or partial match. |
|
||||
| `maxResults` | number | No | Maximum number of users to return \(default: 50, max: 1000\) |
|
||||
| `startAt` | number | No | The index of the first user to return \(for pagination, default: 0\) |
|
||||
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `users` | array | Array of matching Jira users |
|
||||
| ↳ `accountId` | string | Atlassian account ID of the user |
|
||||
| ↳ `displayName` | string | Display name of the user |
|
||||
| ↳ `active` | boolean | Whether the user account is active |
|
||||
| ↳ `emailAddress` | string | Email address of the user |
|
||||
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
|
||||
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
|
||||
| ↳ `timeZone` | string | User timezone |
|
||||
| ↳ `self` | string | REST API URL for this user |
|
||||
| `total` | number | Number of users returned in this page \(may be less than total matches\) |
|
||||
| `startAt` | number | Pagination start index |
|
||||
| `maxResults` | number | Maximum results per page |
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"elasticsearch",
|
||||
"elevenlabs",
|
||||
"enrich",
|
||||
"evernote",
|
||||
"exa",
|
||||
"file",
|
||||
"firecrawl",
|
||||
@@ -98,6 +99,7 @@
|
||||
"mysql",
|
||||
"neo4j",
|
||||
"notion",
|
||||
"obsidian",
|
||||
"onedrive",
|
||||
"onepassword",
|
||||
"openai",
|
||||
|
||||
323
apps/docs/content/docs/en/tools/obsidian.mdx
Normal file
323
apps/docs/content/docs/en/tools/obsidian.mdx
Normal file
@@ -0,0 +1,323 @@
|
||||
---
|
||||
title: Obsidian
|
||||
description: Interact with your Obsidian vault via the Local REST API
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="obsidian"
|
||||
color="#0F0F0F"
|
||||
/>
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `obsidian_append_active`
|
||||
|
||||
Append content to the currently active file in Obsidian
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
| `content` | string | Yes | Markdown content to append to the active file |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `appended` | boolean | Whether content was successfully appended |
|
||||
|
||||
### `obsidian_append_note`
|
||||
|
||||
Append content to an existing note in your Obsidian vault
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
| `filename` | string | Yes | Path to the note relative to vault root \(e.g. "folder/note.md"\) |
|
||||
| `content` | string | Yes | Markdown content to append to the note |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `filename` | string | Path of the note |
|
||||
| `appended` | boolean | Whether content was successfully appended |
|
||||
|
||||
### `obsidian_append_periodic_note`
|
||||
|
||||
Append content to the current periodic note (daily, weekly, monthly, quarterly, or yearly). Creates the note if it does not exist.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
| `period` | string | Yes | Period type: daily, weekly, monthly, quarterly, or yearly |
|
||||
| `content` | string | Yes | Markdown content to append to the periodic note |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `period` | string | Period type of the note |
|
||||
| `appended` | boolean | Whether content was successfully appended |
|
||||
|
||||
### `obsidian_create_note`
|
||||
|
||||
Create or replace a note in your Obsidian vault
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
| `filename` | string | Yes | Path for the note relative to vault root \(e.g. "folder/note.md"\) |
|
||||
| `content` | string | Yes | Markdown content for the note |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `filename` | string | Path of the created note |
|
||||
| `created` | boolean | Whether the note was successfully created |
|
||||
|
||||
### `obsidian_delete_note`
|
||||
|
||||
Delete a note from your Obsidian vault
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
| `filename` | string | Yes | Path to the note to delete relative to vault root |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `filename` | string | Path of the deleted note |
|
||||
| `deleted` | boolean | Whether the note was successfully deleted |
|
||||
|
||||
### `obsidian_execute_command`
|
||||
|
||||
Execute a command in Obsidian (e.g. open daily note, toggle sidebar)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
| `commandId` | string | Yes | ID of the command to execute \(use List Commands operation to discover available commands\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `commandId` | string | ID of the executed command |
|
||||
| `executed` | boolean | Whether the command was successfully executed |
|
||||
|
||||
### `obsidian_get_active`
|
||||
|
||||
Retrieve the content of the currently active file in Obsidian
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | Markdown content of the active file |
|
||||
| `filename` | string | Path to the active file |
|
||||
|
||||
### `obsidian_get_note`
|
||||
|
||||
Retrieve the content of a note from your Obsidian vault
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
| `filename` | string | Yes | Path to the note relative to vault root \(e.g. "folder/note.md"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | Markdown content of the note |
|
||||
| `filename` | string | Path to the note |
|
||||
|
||||
### `obsidian_get_periodic_note`
|
||||
|
||||
Retrieve the current periodic note (daily, weekly, monthly, quarterly, or yearly)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
| `period` | string | Yes | Period type: daily, weekly, monthly, quarterly, or yearly |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `content` | string | Markdown content of the periodic note |
|
||||
| `period` | string | Period type of the note |
|
||||
|
||||
### `obsidian_list_commands`
|
||||
|
||||
List all available commands in Obsidian
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `commands` | json | List of available commands with IDs and names |
|
||||
| ↳ `id` | string | Command identifier |
|
||||
| ↳ `name` | string | Human-readable command name |
|
||||
|
||||
### `obsidian_list_files`
|
||||
|
||||
List files and directories in your Obsidian vault
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
| `path` | string | No | Directory path relative to vault root. Leave empty to list root. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `files` | json | List of files and directories |
|
||||
| ↳ `path` | string | File or directory path |
|
||||
| ↳ `type` | string | Whether the entry is a file or directory |
|
||||
|
||||
### `obsidian_open_file`
|
||||
|
||||
Open a file in the Obsidian UI (creates the file if it does not exist)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
| `filename` | string | Yes | Path to the file relative to vault root |
|
||||
| `newLeaf` | boolean | No | Whether to open the file in a new leaf/tab |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `filename` | string | Path of the opened file |
|
||||
| `opened` | boolean | Whether the file was successfully opened |
|
||||
|
||||
### `obsidian_patch_active`
|
||||
|
||||
Insert or replace content at a specific heading, block reference, or frontmatter field in the active file
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
| `content` | string | Yes | Content to insert at the target location |
|
||||
| `operation` | string | Yes | How to insert content: append, prepend, or replace |
|
||||
| `targetType` | string | Yes | Type of target: heading, block, or frontmatter |
|
||||
| `target` | string | Yes | Target identifier \(heading text, block reference ID, or frontmatter field name\) |
|
||||
| `targetDelimiter` | string | No | Delimiter for nested headings \(default: "::"\) |
|
||||
| `trimTargetWhitespace` | boolean | No | Whether to trim whitespace from target before matching \(default: false\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `patched` | boolean | Whether the active file was successfully patched |
|
||||
|
||||
### `obsidian_patch_note`
|
||||
|
||||
Insert or replace content at a specific heading, block reference, or frontmatter field in a note
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
| `filename` | string | Yes | Path to the note relative to vault root \(e.g. "folder/note.md"\) |
|
||||
| `content` | string | Yes | Content to insert at the target location |
|
||||
| `operation` | string | Yes | How to insert content: append, prepend, or replace |
|
||||
| `targetType` | string | Yes | Type of target: heading, block, or frontmatter |
|
||||
| `target` | string | Yes | Target identifier \(heading text, block reference ID, or frontmatter field name\) |
|
||||
| `targetDelimiter` | string | No | Delimiter for nested headings \(default: "::"\) |
|
||||
| `trimTargetWhitespace` | boolean | No | Whether to trim whitespace from target before matching \(default: false\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `filename` | string | Path of the patched note |
|
||||
| `patched` | boolean | Whether the note was successfully patched |
|
||||
|
||||
### `obsidian_search`
|
||||
|
||||
Search for text across notes in your Obsidian vault
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | API key from Obsidian Local REST API plugin settings |
|
||||
| `baseUrl` | string | Yes | Base URL for the Obsidian Local REST API |
|
||||
| `query` | string | Yes | Text to search for across vault notes |
|
||||
| `contextLength` | number | No | Number of characters of context around each match \(default: 100\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `results` | json | Search results with filenames, scores, and matching contexts |
|
||||
| ↳ `filename` | string | Path to the matching note |
|
||||
| ↳ `score` | number | Relevance score |
|
||||
| ↳ `matches` | json | Matching text contexts |
|
||||
| ↳ `context` | string | Text surrounding the match |
|
||||
|
||||
|
||||
@@ -44,20 +44,24 @@ Search the web using Parallel AI. Provides comprehensive search results with int
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `objective` | string | Yes | The search objective or question to answer |
|
||||
| `search_queries` | string | No | Optional comma-separated list of search queries to execute |
|
||||
| `processor` | string | No | Processing method: base or pro \(default: base\) |
|
||||
| `max_results` | number | No | Maximum number of results to return \(default: 5\) |
|
||||
| `max_chars_per_result` | number | No | Maximum characters per result \(default: 1500\) |
|
||||
| `search_queries` | string | No | Comma-separated list of search queries to execute |
|
||||
| `mode` | string | No | Search mode: one-shot, agentic, or fast \(default: one-shot\) |
|
||||
| `max_results` | number | No | Maximum number of results to return \(default: 10\) |
|
||||
| `max_chars_per_result` | number | No | Maximum characters per result excerpt \(minimum: 1000\) |
|
||||
| `include_domains` | string | No | Comma-separated list of domains to restrict search results to |
|
||||
| `exclude_domains` | string | No | Comma-separated list of domains to exclude from search results |
|
||||
| `apiKey` | string | Yes | Parallel AI API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `search_id` | string | Unique identifier for this search request |
|
||||
| `results` | array | Search results with excerpts from relevant pages |
|
||||
| ↳ `url` | string | The URL of the search result |
|
||||
| ↳ `title` | string | The title of the search result |
|
||||
| ↳ `excerpts` | array | Text excerpts from the page |
|
||||
| ↳ `publish_date` | string | Publication date of the page \(YYYY-MM-DD\) |
|
||||
| ↳ `excerpts` | array | LLM-optimized excerpts from the page |
|
||||
|
||||
### `parallel_extract`
|
||||
|
||||
@@ -68,31 +72,33 @@ Extract targeted information from specific URLs using Parallel AI. Processes pro
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `urls` | string | Yes | Comma-separated list of URLs to extract information from |
|
||||
| `objective` | string | Yes | What information to extract from the provided URLs |
|
||||
| `excerpts` | boolean | Yes | Include relevant excerpts from the content |
|
||||
| `full_content` | boolean | Yes | Include full page content |
|
||||
| `objective` | string | No | What information to extract from the provided URLs |
|
||||
| `excerpts` | boolean | No | Include relevant excerpts from the content \(default: true\) |
|
||||
| `full_content` | boolean | No | Include full page content as markdown \(default: false\) |
|
||||
| `apiKey` | string | Yes | Parallel AI API Key |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `extract_id` | string | Unique identifier for this extraction request |
|
||||
| `results` | array | Extracted information from the provided URLs |
|
||||
| ↳ `url` | string | The source URL |
|
||||
| ↳ `title` | string | The title of the page |
|
||||
| ↳ `content` | string | Extracted content |
|
||||
| ↳ `excerpts` | array | Relevant text excerpts |
|
||||
| ↳ `publish_date` | string | Publication date \(YYYY-MM-DD\) |
|
||||
| ↳ `excerpts` | array | Relevant text excerpts in markdown |
|
||||
| ↳ `full_content` | string | Full page content as markdown |
|
||||
|
||||
### `parallel_deep_research`
|
||||
|
||||
Conduct comprehensive deep research across the web using Parallel AI. Synthesizes information from multiple sources with citations. Can take up to 15 minutes to complete.
|
||||
Conduct comprehensive deep research across the web using Parallel AI. Synthesizes information from multiple sources with citations. Can take up to 45 minutes to complete.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `input` | string | Yes | Research query or question \(up to 15,000 characters\) |
|
||||
| `processor` | string | No | Compute level: base, lite, pro, ultra, ultra2x, ultra4x, ultra8x \(default: base\) |
|
||||
| `processor` | string | No | Processing tier: pro, ultra, pro-fast, ultra-fast \(default: pro\) |
|
||||
| `include_domains` | string | No | Comma-separated list of domains to restrict research to \(source policy\) |
|
||||
| `exclude_domains` | string | No | Comma-separated list of domains to exclude from research \(source policy\) |
|
||||
| `apiKey` | string | Yes | Parallel AI API Key |
|
||||
@@ -101,17 +107,17 @@ Conduct comprehensive deep research across the web using Parallel AI. Synthesize
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `status` | string | Task status \(completed, failed\) |
|
||||
| `status` | string | Task status \(completed, failed, running\) |
|
||||
| `run_id` | string | Unique ID for this research task |
|
||||
| `message` | string | Status message |
|
||||
| `content` | object | Research results \(structured based on output_schema\) |
|
||||
| `basis` | array | Citations and sources with reasoning and confidence levels |
|
||||
| ↳ `field` | string | Output field name |
|
||||
| ↳ `field` | string | Output field dot-notation path |
|
||||
| ↳ `reasoning` | string | Explanation for the result |
|
||||
| ↳ `citations` | array | Array of sources |
|
||||
| ↳ `url` | string | Source URL |
|
||||
| ↳ `title` | string | Source title |
|
||||
| ↳ `excerpts` | array | Relevant excerpts from the source |
|
||||
| ↳ `confidence` | string | Confidence level indicator |
|
||||
| ↳ `confidence` | string | Confidence level \(high, medium\) |
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import {
|
||||
@@ -631,11 +630,9 @@ async function handleMessageStream(
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
let messageStreamDecremented = false
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
incrementSSEConnections('a2a-message')
|
||||
const sendEvent = (event: string, data: unknown) => {
|
||||
try {
|
||||
const jsonRpcResponse = {
|
||||
@@ -845,19 +842,10 @@ async function handleMessageStream(
|
||||
})
|
||||
} finally {
|
||||
await releaseLock(lockKey, lockValue)
|
||||
if (!messageStreamDecremented) {
|
||||
messageStreamDecremented = true
|
||||
decrementSSEConnections('a2a-message')
|
||||
}
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
if (!messageStreamDecremented) {
|
||||
messageStreamDecremented = true
|
||||
decrementSSEConnections('a2a-message')
|
||||
}
|
||||
},
|
||||
cancel() {},
|
||||
})
|
||||
|
||||
return new NextResponse(stream, {
|
||||
@@ -1042,22 +1030,16 @@ async function handleTaskResubscribe(
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
let sseDecremented = false
|
||||
const cleanup = () => {
|
||||
isCancelled = true
|
||||
if (pollTimeoutId) {
|
||||
clearTimeout(pollTimeoutId)
|
||||
pollTimeoutId = null
|
||||
}
|
||||
if (!sseDecremented) {
|
||||
sseDecremented = true
|
||||
decrementSSEConnections('a2a-resubscribe')
|
||||
}
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
incrementSSEConnections('a2a-resubscribe')
|
||||
const sendEvent = (event: string, data: unknown): boolean => {
|
||||
if (isCancelled || abortSignal.aborted) return false
|
||||
try {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { getSession } from '@/lib/auth'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { mcpConnectionManager } from '@/lib/mcp/connection-manager'
|
||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||
import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('McpEventsSSE')
|
||||
@@ -50,14 +49,11 @@ export async function GET(request: NextRequest) {
|
||||
for (const unsub of unsubscribers) {
|
||||
unsub()
|
||||
}
|
||||
decrementSSEConnections('mcp-events')
|
||||
logger.info(`SSE connection closed for workspace ${workspaceId}`)
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
incrementSSEConnections('mcp-events')
|
||||
|
||||
const send = (eventName: string, data: Record<string, unknown>) => {
|
||||
if (cleaned) return
|
||||
try {
|
||||
|
||||
@@ -192,7 +192,8 @@ export const POST = withMcpAuth<{ id: string }>('read')(
|
||||
)
|
||||
} catch (error) {
|
||||
connectionStatus = 'error'
|
||||
lastError = error instanceof Error ? error.message : 'Connection test failed'
|
||||
lastError =
|
||||
error instanceof Error ? error.message.split('\n')[0].slice(0, 200) : 'Connection failed'
|
||||
logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,20 @@ interface TestConnectionResult {
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a user-friendly error message from connection errors.
|
||||
* Keeps diagnostic info (timeout, DNS, HTTP status) but strips
|
||||
* verbose internals (Zod details, full response bodies, stack traces).
|
||||
*/
|
||||
function sanitizeConnectionError(error: unknown): string {
|
||||
if (!(error instanceof Error)) {
|
||||
return 'Unknown connection error'
|
||||
}
|
||||
|
||||
const firstLine = error.message.split('\n')[0]
|
||||
return firstLine.length > 200 ? `${firstLine.slice(0, 200)}...` : firstLine
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - Test connection to an MCP server before registering it
|
||||
*/
|
||||
@@ -137,8 +151,7 @@ export const POST = withMcpAuth('write')(
|
||||
} catch (toolError) {
|
||||
logger.warn(`[${requestId}] Connection established but could not list tools:`, toolError)
|
||||
result.success = false
|
||||
const errorMessage = toolError instanceof Error ? toolError.message : 'Unknown error'
|
||||
result.error = `Connection established but could not list tools: ${errorMessage}`
|
||||
result.error = 'Connection established but could not list tools'
|
||||
result.warnings = result.warnings || []
|
||||
result.warnings.push(
|
||||
'Server connected but tool listing failed - connection may be incomplete'
|
||||
@@ -163,11 +176,7 @@ export const POST = withMcpAuth('write')(
|
||||
logger.warn(`[${requestId}] MCP server test failed:`, error)
|
||||
|
||||
result.success = false
|
||||
if (error instanceof Error) {
|
||||
result.error = error.message
|
||||
} else {
|
||||
result.error = 'Unknown connection error'
|
||||
}
|
||||
result.error = sanitizeConnectionError(error)
|
||||
} finally {
|
||||
if (client) {
|
||||
try {
|
||||
|
||||
@@ -89,11 +89,12 @@ export const POST = withMcpAuth('read')(
|
||||
tool = tools.find((t) => t.name === toolName) ?? null
|
||||
|
||||
if (!tool) {
|
||||
logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, {
|
||||
availableTools: tools.map((t) => t.name),
|
||||
})
|
||||
return createMcpErrorResponse(
|
||||
new Error(
|
||||
`Tool ${toolName} not found on server ${serverId}. Available tools: ${tools.map((t) => t.name).join(', ')}`
|
||||
),
|
||||
'Tool not found',
|
||||
new Error('Tool not found'),
|
||||
'Tool not found on the specified server',
|
||||
404
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to cancel task',
|
||||
error: 'Failed to cancel task',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete push notification',
|
||||
error: 'Failed to delete push notification',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch Agent Card',
|
||||
error: 'Failed to fetch Agent Card',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -107,7 +107,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get push notification',
|
||||
error: 'Failed to get push notification',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get task',
|
||||
error: 'Failed to get task',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -111,7 +111,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to resubscribe',
|
||||
error: 'Failed to resubscribe',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Failed to connect to agent: ${clientError instanceof Error ? clientError.message : 'Unknown error'}`,
|
||||
error: 'Failed to connect to agent',
|
||||
},
|
||||
{ status: 502 }
|
||||
)
|
||||
@@ -158,7 +158,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Failed to send message: ${sendError instanceof Error ? sendError.message : 'Unknown error'}`,
|
||||
error: 'Failed to send message to agent',
|
||||
},
|
||||
{ status: 502 }
|
||||
)
|
||||
@@ -218,7 +218,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
error: 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set push notification',
|
||||
error: 'Failed to set push notification',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
||||
38
apps/sim/app/api/tools/evernote/copy-note/route.ts
Normal file
38
apps/sim/app/api/tools/evernote/copy-note/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { copyNote } from '@/app/api/tools/evernote/lib/client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('EvernoteCopyNoteAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { apiKey, noteGuid, toNotebookGuid } = body
|
||||
|
||||
if (!apiKey || !noteGuid || !toNotebookGuid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'apiKey, noteGuid, and toNotebookGuid are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const note = await copyNote(apiKey, noteGuid, toNotebookGuid)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { note },
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Failed to copy note', { error: message })
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
51
apps/sim/app/api/tools/evernote/create-note/route.ts
Normal file
51
apps/sim/app/api/tools/evernote/create-note/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createNote } from '@/app/api/tools/evernote/lib/client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('EvernoteCreateNoteAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { apiKey, title, content, notebookGuid, tagNames } = body
|
||||
|
||||
if (!apiKey || !title || !content) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'apiKey, title, and content are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsedTags = tagNames
|
||||
? (() => {
|
||||
const tags =
|
||||
typeof tagNames === 'string'
|
||||
? tagNames
|
||||
.split(',')
|
||||
.map((t: string) => t.trim())
|
||||
.filter(Boolean)
|
||||
: tagNames
|
||||
return tags.length > 0 ? tags : undefined
|
||||
})()
|
||||
: undefined
|
||||
|
||||
const note = await createNote(apiKey, title, content, notebookGuid || undefined, parsedTags)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { note },
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Failed to create note', { error: message })
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
38
apps/sim/app/api/tools/evernote/create-notebook/route.ts
Normal file
38
apps/sim/app/api/tools/evernote/create-notebook/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createNotebook } from '@/app/api/tools/evernote/lib/client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('EvernoteCreateNotebookAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { apiKey, name, stack } = body
|
||||
|
||||
if (!apiKey || !name) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'apiKey and name are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const notebook = await createNotebook(apiKey, name, stack || undefined)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { notebook },
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Failed to create notebook', { error: message })
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
38
apps/sim/app/api/tools/evernote/create-tag/route.ts
Normal file
38
apps/sim/app/api/tools/evernote/create-tag/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createTag } from '@/app/api/tools/evernote/lib/client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('EvernoteCreateTagAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { apiKey, name, parentGuid } = body
|
||||
|
||||
if (!apiKey || !name) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'apiKey and name are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const tag = await createTag(apiKey, name, parentGuid || undefined)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { tag },
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Failed to create tag', { error: message })
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
41
apps/sim/app/api/tools/evernote/delete-note/route.ts
Normal file
41
apps/sim/app/api/tools/evernote/delete-note/route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { deleteNote } from '@/app/api/tools/evernote/lib/client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('EvernoteDeleteNoteAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { apiKey, noteGuid } = body
|
||||
|
||||
if (!apiKey || !noteGuid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'apiKey and noteGuid are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await deleteNote(apiKey, noteGuid)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
noteGuid,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Failed to delete note', { error: message })
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
38
apps/sim/app/api/tools/evernote/get-note/route.ts
Normal file
38
apps/sim/app/api/tools/evernote/get-note/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { getNote } from '@/app/api/tools/evernote/lib/client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('EvernoteGetNoteAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { apiKey, noteGuid, withContent = true } = body
|
||||
|
||||
if (!apiKey || !noteGuid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'apiKey and noteGuid are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const note = await getNote(apiKey, noteGuid, withContent)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { note },
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Failed to get note', { error: message })
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
38
apps/sim/app/api/tools/evernote/get-notebook/route.ts
Normal file
38
apps/sim/app/api/tools/evernote/get-notebook/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { getNotebook } from '@/app/api/tools/evernote/lib/client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('EvernoteGetNotebookAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { apiKey, notebookGuid } = body
|
||||
|
||||
if (!apiKey || !notebookGuid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'apiKey and notebookGuid are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const notebook = await getNotebook(apiKey, notebookGuid)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { notebook },
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Failed to get notebook', { error: message })
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
799
apps/sim/app/api/tools/evernote/lib/client.ts
Normal file
799
apps/sim/app/api/tools/evernote/lib/client.ts
Normal file
@@ -0,0 +1,799 @@
|
||||
/**
|
||||
* Evernote API client using Thrift binary protocol over HTTP.
|
||||
* Implements only the NoteStore methods needed for the integration.
|
||||
*/
|
||||
|
||||
import {
|
||||
ThriftReader,
|
||||
ThriftWriter,
|
||||
TYPE_BOOL,
|
||||
TYPE_I32,
|
||||
TYPE_I64,
|
||||
TYPE_LIST,
|
||||
TYPE_STRING,
|
||||
TYPE_STRUCT,
|
||||
} from './thrift'
|
||||
|
||||
export interface EvernoteNotebook {
|
||||
guid: string
|
||||
name: string
|
||||
defaultNotebook: boolean
|
||||
serviceCreated: number | null
|
||||
serviceUpdated: number | null
|
||||
stack: string | null
|
||||
}
|
||||
|
||||
export interface EvernoteNote {
|
||||
guid: string
|
||||
title: string
|
||||
content: string | null
|
||||
contentLength: number | null
|
||||
created: number | null
|
||||
updated: number | null
|
||||
deleted: number | null
|
||||
active: boolean
|
||||
notebookGuid: string | null
|
||||
tagGuids: string[]
|
||||
tagNames: string[]
|
||||
}
|
||||
|
||||
export interface EvernoteNoteMetadata {
|
||||
guid: string
|
||||
title: string | null
|
||||
contentLength: number | null
|
||||
created: number | null
|
||||
updated: number | null
|
||||
notebookGuid: string | null
|
||||
tagGuids: string[]
|
||||
}
|
||||
|
||||
export interface EvernoteTag {
|
||||
guid: string
|
||||
name: string
|
||||
parentGuid: string | null
|
||||
updateSequenceNum: number | null
|
||||
}
|
||||
|
||||
export interface EvernoteSearchResult {
|
||||
startIndex: number
|
||||
totalNotes: number
|
||||
notes: EvernoteNoteMetadata[]
|
||||
}
|
||||
|
||||
/** Extract shard ID from an Evernote developer token */
|
||||
function extractShardId(token: string): string {
|
||||
const match = token.match(/S=s(\d+)/)
|
||||
if (!match) {
|
||||
throw new Error('Invalid Evernote token format: cannot extract shard ID')
|
||||
}
|
||||
return `s${match[1]}`
|
||||
}
|
||||
|
||||
/** Get the NoteStore URL for the given token */
|
||||
function getNoteStoreUrl(token: string): string {
|
||||
const shardId = extractShardId(token)
|
||||
const host = token.includes(':Sandbox') ? 'sandbox.evernote.com' : 'www.evernote.com'
|
||||
return `https://${host}/shard/${shardId}/notestore`
|
||||
}
|
||||
|
||||
/** Make a Thrift RPC call to the NoteStore */
|
||||
async function callNoteStore(token: string, writer: ThriftWriter): Promise<ThriftReader> {
|
||||
const url = getNoteStoreUrl(token)
|
||||
const body = writer.toBuffer()
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-thrift',
|
||||
Accept: 'application/x-thrift',
|
||||
},
|
||||
body: new Uint8Array(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Evernote API HTTP error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const reader = new ThriftReader(arrayBuffer)
|
||||
const msg = reader.readMessageBegin()
|
||||
|
||||
if (reader.isException(msg.type)) {
|
||||
const ex = reader.readException()
|
||||
throw new Error(`Evernote API error: ${ex.message}`)
|
||||
}
|
||||
|
||||
return reader
|
||||
}
|
||||
|
||||
/** Check for Evernote-specific exceptions in the response struct. Returns true if handled. */
|
||||
function checkEvernoteException(reader: ThriftReader, fieldId: number, fieldType: number): boolean {
|
||||
if (fieldId === 1 && fieldType === TYPE_STRUCT) {
|
||||
let message = ''
|
||||
let errorCode = 0
|
||||
reader.readStruct((r, fid, ftype) => {
|
||||
if (fid === 1 && ftype === TYPE_I32) {
|
||||
errorCode = r.readI32()
|
||||
} else if (fid === 2 && ftype === TYPE_STRING) {
|
||||
message = r.readString()
|
||||
} else {
|
||||
r.skip(ftype)
|
||||
}
|
||||
})
|
||||
throw new Error(`Evernote error (${errorCode}): ${message}`)
|
||||
}
|
||||
if (fieldId === 2 && fieldType === TYPE_STRUCT) {
|
||||
let message = ''
|
||||
let errorCode = 0
|
||||
reader.readStruct((r, fid, ftype) => {
|
||||
if (fid === 1 && ftype === TYPE_I32) {
|
||||
errorCode = r.readI32()
|
||||
} else if (fid === 2 && ftype === TYPE_STRING) {
|
||||
message = r.readString()
|
||||
} else {
|
||||
r.skip(ftype)
|
||||
}
|
||||
})
|
||||
throw new Error(`Evernote system error (${errorCode}): ${message}`)
|
||||
}
|
||||
if (fieldId === 3 && fieldType === TYPE_STRUCT) {
|
||||
let identifier = ''
|
||||
let key = ''
|
||||
reader.readStruct((r, fid, ftype) => {
|
||||
if (fid === 1 && ftype === TYPE_STRING) {
|
||||
identifier = r.readString()
|
||||
} else if (fid === 2 && ftype === TYPE_STRING) {
|
||||
key = r.readString()
|
||||
} else {
|
||||
r.skip(ftype)
|
||||
}
|
||||
})
|
||||
throw new Error(`Evernote not found: ${identifier}${key ? ` (${key})` : ''}`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function readNotebook(reader: ThriftReader): EvernoteNotebook {
|
||||
const notebook: EvernoteNotebook = {
|
||||
guid: '',
|
||||
name: '',
|
||||
defaultNotebook: false,
|
||||
serviceCreated: null,
|
||||
serviceUpdated: null,
|
||||
stack: null,
|
||||
}
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
switch (fieldId) {
|
||||
case 1:
|
||||
if (fieldType === TYPE_STRING) notebook.guid = r.readString()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 2:
|
||||
if (fieldType === TYPE_STRING) notebook.name = r.readString()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 4:
|
||||
if (fieldType === TYPE_BOOL) notebook.defaultNotebook = r.readBool()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 5:
|
||||
if (fieldType === TYPE_I64) notebook.serviceCreated = Number(r.readI64())
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 6:
|
||||
if (fieldType === TYPE_I64) notebook.serviceUpdated = Number(r.readI64())
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 9:
|
||||
if (fieldType === TYPE_STRING) notebook.stack = r.readString()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
default:
|
||||
r.skip(fieldType)
|
||||
}
|
||||
})
|
||||
|
||||
return notebook
|
||||
}
|
||||
|
||||
function readNote(reader: ThriftReader): EvernoteNote {
|
||||
const note: EvernoteNote = {
|
||||
guid: '',
|
||||
title: '',
|
||||
content: null,
|
||||
contentLength: null,
|
||||
created: null,
|
||||
updated: null,
|
||||
deleted: null,
|
||||
active: true,
|
||||
notebookGuid: null,
|
||||
tagGuids: [],
|
||||
tagNames: [],
|
||||
}
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
switch (fieldId) {
|
||||
case 1:
|
||||
if (fieldType === TYPE_STRING) note.guid = r.readString()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 2:
|
||||
if (fieldType === TYPE_STRING) note.title = r.readString()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 3:
|
||||
if (fieldType === TYPE_STRING) note.content = r.readString()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 5:
|
||||
if (fieldType === TYPE_I32) note.contentLength = r.readI32()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 6:
|
||||
if (fieldType === TYPE_I64) note.created = Number(r.readI64())
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 7:
|
||||
if (fieldType === TYPE_I64) note.updated = Number(r.readI64())
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 8:
|
||||
if (fieldType === TYPE_I64) note.deleted = Number(r.readI64())
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 9:
|
||||
if (fieldType === TYPE_BOOL) note.active = r.readBool()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 11:
|
||||
if (fieldType === TYPE_STRING) note.notebookGuid = r.readString()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 12:
|
||||
if (fieldType === TYPE_LIST) {
|
||||
const { size } = r.readListBegin()
|
||||
for (let i = 0; i < size; i++) {
|
||||
note.tagGuids.push(r.readString())
|
||||
}
|
||||
} else {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
break
|
||||
case 15:
|
||||
if (fieldType === TYPE_LIST) {
|
||||
const { size } = r.readListBegin()
|
||||
for (let i = 0; i < size; i++) {
|
||||
note.tagNames.push(r.readString())
|
||||
}
|
||||
} else {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
break
|
||||
default:
|
||||
r.skip(fieldType)
|
||||
}
|
||||
})
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
function readTag(reader: ThriftReader): EvernoteTag {
|
||||
const tag: EvernoteTag = {
|
||||
guid: '',
|
||||
name: '',
|
||||
parentGuid: null,
|
||||
updateSequenceNum: null,
|
||||
}
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
switch (fieldId) {
|
||||
case 1:
|
||||
if (fieldType === TYPE_STRING) tag.guid = r.readString()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 2:
|
||||
if (fieldType === TYPE_STRING) tag.name = r.readString()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 3:
|
||||
if (fieldType === TYPE_STRING) tag.parentGuid = r.readString()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 4:
|
||||
if (fieldType === TYPE_I32) tag.updateSequenceNum = r.readI32()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
default:
|
||||
r.skip(fieldType)
|
||||
}
|
||||
})
|
||||
|
||||
return tag
|
||||
}
|
||||
|
||||
function readNoteMetadata(reader: ThriftReader): EvernoteNoteMetadata {
|
||||
const meta: EvernoteNoteMetadata = {
|
||||
guid: '',
|
||||
title: null,
|
||||
contentLength: null,
|
||||
created: null,
|
||||
updated: null,
|
||||
notebookGuid: null,
|
||||
tagGuids: [],
|
||||
}
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
switch (fieldId) {
|
||||
case 1:
|
||||
if (fieldType === TYPE_STRING) meta.guid = r.readString()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 2:
|
||||
if (fieldType === TYPE_STRING) meta.title = r.readString()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 5:
|
||||
if (fieldType === TYPE_I32) meta.contentLength = r.readI32()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 6:
|
||||
if (fieldType === TYPE_I64) meta.created = Number(r.readI64())
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 7:
|
||||
if (fieldType === TYPE_I64) meta.updated = Number(r.readI64())
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 11:
|
||||
if (fieldType === TYPE_STRING) meta.notebookGuid = r.readString()
|
||||
else r.skip(fieldType)
|
||||
break
|
||||
case 12:
|
||||
if (fieldType === TYPE_LIST) {
|
||||
const { size } = r.readListBegin()
|
||||
for (let i = 0; i < size; i++) {
|
||||
meta.tagGuids.push(r.readString())
|
||||
}
|
||||
} else {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
break
|
||||
default:
|
||||
r.skip(fieldType)
|
||||
}
|
||||
})
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
export async function listNotebooks(token: string): Promise<EvernoteNotebook[]> {
|
||||
const writer = new ThriftWriter()
|
||||
writer.writeMessageBegin('listNotebooks', 0)
|
||||
writer.writeStringField(1, token)
|
||||
writer.writeFieldStop()
|
||||
|
||||
const reader = await callNoteStore(token, writer)
|
||||
const notebooks: EvernoteNotebook[] = []
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
if (fieldId === 0 && fieldType === TYPE_LIST) {
|
||||
const { size } = r.readListBegin()
|
||||
for (let i = 0; i < size; i++) {
|
||||
notebooks.push(readNotebook(r))
|
||||
}
|
||||
} else {
|
||||
if (!checkEvernoteException(r, fieldId, fieldType)) {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return notebooks
|
||||
}
|
||||
|
||||
export async function getNote(
|
||||
token: string,
|
||||
guid: string,
|
||||
withContent = true
|
||||
): Promise<EvernoteNote> {
|
||||
const writer = new ThriftWriter()
|
||||
writer.writeMessageBegin('getNote', 0)
|
||||
writer.writeStringField(1, token)
|
||||
writer.writeStringField(2, guid)
|
||||
writer.writeBoolField(3, withContent)
|
||||
writer.writeBoolField(4, false)
|
||||
writer.writeBoolField(5, false)
|
||||
writer.writeBoolField(6, false)
|
||||
writer.writeFieldStop()
|
||||
|
||||
const reader = await callNoteStore(token, writer)
|
||||
let note: EvernoteNote | null = null
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
|
||||
note = readNote(r)
|
||||
} else {
|
||||
if (!checkEvernoteException(r, fieldId, fieldType)) {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new Error('No note returned from Evernote API')
|
||||
}
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
/** Wrap content in ENML if it's not already */
|
||||
function wrapInEnml(content: string): string {
|
||||
if (content.includes('<!DOCTYPE en-note')) {
|
||||
return content
|
||||
}
|
||||
const escaped = content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br/>')
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd"><en-note>${escaped}</en-note>`
|
||||
}
|
||||
|
||||
export async function createNote(
|
||||
token: string,
|
||||
title: string,
|
||||
content: string,
|
||||
notebookGuid?: string,
|
||||
tagNames?: string[]
|
||||
): Promise<EvernoteNote> {
|
||||
const writer = new ThriftWriter()
|
||||
writer.writeMessageBegin('createNote', 0)
|
||||
writer.writeStringField(1, token)
|
||||
|
||||
writer.writeFieldBegin(TYPE_STRUCT, 2)
|
||||
writer.writeStringField(2, title)
|
||||
writer.writeStringField(3, wrapInEnml(content))
|
||||
if (notebookGuid) {
|
||||
writer.writeStringField(11, notebookGuid)
|
||||
}
|
||||
if (tagNames && tagNames.length > 0) {
|
||||
writer.writeStringListField(15, tagNames)
|
||||
}
|
||||
writer.writeFieldStop()
|
||||
|
||||
writer.writeFieldStop()
|
||||
|
||||
const reader = await callNoteStore(token, writer)
|
||||
let note: EvernoteNote | null = null
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
|
||||
note = readNote(r)
|
||||
} else {
|
||||
if (!checkEvernoteException(r, fieldId, fieldType)) {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new Error('No note returned from Evernote API')
|
||||
}
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
export async function updateNote(
|
||||
token: string,
|
||||
guid: string,
|
||||
title?: string,
|
||||
content?: string,
|
||||
notebookGuid?: string,
|
||||
tagNames?: string[]
|
||||
): Promise<EvernoteNote> {
|
||||
const writer = new ThriftWriter()
|
||||
writer.writeMessageBegin('updateNote', 0)
|
||||
writer.writeStringField(1, token)
|
||||
|
||||
writer.writeFieldBegin(TYPE_STRUCT, 2)
|
||||
writer.writeStringField(1, guid)
|
||||
if (title !== undefined) {
|
||||
writer.writeStringField(2, title)
|
||||
}
|
||||
if (content !== undefined) {
|
||||
writer.writeStringField(3, wrapInEnml(content))
|
||||
}
|
||||
if (notebookGuid !== undefined) {
|
||||
writer.writeStringField(11, notebookGuid)
|
||||
}
|
||||
if (tagNames !== undefined) {
|
||||
writer.writeStringListField(15, tagNames)
|
||||
}
|
||||
writer.writeFieldStop()
|
||||
|
||||
writer.writeFieldStop()
|
||||
|
||||
const reader = await callNoteStore(token, writer)
|
||||
let note: EvernoteNote | null = null
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
|
||||
note = readNote(r)
|
||||
} else {
|
||||
if (!checkEvernoteException(r, fieldId, fieldType)) {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new Error('No note returned from Evernote API')
|
||||
}
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
export async function deleteNote(token: string, guid: string): Promise<number> {
|
||||
const writer = new ThriftWriter()
|
||||
writer.writeMessageBegin('deleteNote', 0)
|
||||
writer.writeStringField(1, token)
|
||||
writer.writeStringField(2, guid)
|
||||
writer.writeFieldStop()
|
||||
|
||||
const reader = await callNoteStore(token, writer)
|
||||
let usn = 0
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
if (fieldId === 0 && fieldType === TYPE_I32) {
|
||||
usn = r.readI32()
|
||||
} else {
|
||||
if (!checkEvernoteException(r, fieldId, fieldType)) {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return usn
|
||||
}
|
||||
|
||||
export async function searchNotes(
|
||||
token: string,
|
||||
query: string,
|
||||
notebookGuid?: string,
|
||||
offset = 0,
|
||||
maxNotes = 25
|
||||
): Promise<EvernoteSearchResult> {
|
||||
const writer = new ThriftWriter()
|
||||
writer.writeMessageBegin('findNotesMetadata', 0)
|
||||
writer.writeStringField(1, token)
|
||||
|
||||
// NoteFilter (field 2)
|
||||
writer.writeFieldBegin(TYPE_STRUCT, 2)
|
||||
if (query) {
|
||||
writer.writeStringField(3, query)
|
||||
}
|
||||
if (notebookGuid) {
|
||||
writer.writeStringField(4, notebookGuid)
|
||||
}
|
||||
writer.writeFieldStop()
|
||||
|
||||
// offset (field 3)
|
||||
writer.writeI32Field(3, offset)
|
||||
// maxNotes (field 4)
|
||||
writer.writeI32Field(4, maxNotes)
|
||||
|
||||
// NotesMetadataResultSpec (field 5)
|
||||
writer.writeFieldBegin(TYPE_STRUCT, 5)
|
||||
writer.writeBoolField(2, true) // includeTitle
|
||||
writer.writeBoolField(5, true) // includeContentLength
|
||||
writer.writeBoolField(6, true) // includeCreated
|
||||
writer.writeBoolField(7, true) // includeUpdated
|
||||
writer.writeBoolField(11, true) // includeNotebookGuid
|
||||
writer.writeBoolField(12, true) // includeTagGuids
|
||||
writer.writeFieldStop()
|
||||
|
||||
writer.writeFieldStop()
|
||||
|
||||
const reader = await callNoteStore(token, writer)
|
||||
const result: EvernoteSearchResult = {
|
||||
startIndex: 0,
|
||||
totalNotes: 0,
|
||||
notes: [],
|
||||
}
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
|
||||
r.readStruct((r2, fid2, ftype2) => {
|
||||
switch (fid2) {
|
||||
case 1:
|
||||
if (ftype2 === TYPE_I32) result.startIndex = r2.readI32()
|
||||
else r2.skip(ftype2)
|
||||
break
|
||||
case 2:
|
||||
if (ftype2 === TYPE_I32) result.totalNotes = r2.readI32()
|
||||
else r2.skip(ftype2)
|
||||
break
|
||||
case 3:
|
||||
if (ftype2 === TYPE_LIST) {
|
||||
const { size } = r2.readListBegin()
|
||||
for (let i = 0; i < size; i++) {
|
||||
result.notes.push(readNoteMetadata(r2))
|
||||
}
|
||||
} else {
|
||||
r2.skip(ftype2)
|
||||
}
|
||||
break
|
||||
default:
|
||||
r2.skip(ftype2)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
if (!checkEvernoteException(r, fieldId, fieldType)) {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getNotebook(token: string, guid: string): Promise<EvernoteNotebook> {
|
||||
const writer = new ThriftWriter()
|
||||
writer.writeMessageBegin('getNotebook', 0)
|
||||
writer.writeStringField(1, token)
|
||||
writer.writeStringField(2, guid)
|
||||
writer.writeFieldStop()
|
||||
|
||||
const reader = await callNoteStore(token, writer)
|
||||
let notebook: EvernoteNotebook | null = null
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
|
||||
notebook = readNotebook(r)
|
||||
} else {
|
||||
if (!checkEvernoteException(r, fieldId, fieldType)) {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!notebook) {
|
||||
throw new Error('No notebook returned from Evernote API')
|
||||
}
|
||||
|
||||
return notebook
|
||||
}
|
||||
|
||||
export async function createNotebook(
|
||||
token: string,
|
||||
name: string,
|
||||
stack?: string
|
||||
): Promise<EvernoteNotebook> {
|
||||
const writer = new ThriftWriter()
|
||||
writer.writeMessageBegin('createNotebook', 0)
|
||||
writer.writeStringField(1, token)
|
||||
|
||||
writer.writeFieldBegin(TYPE_STRUCT, 2)
|
||||
writer.writeStringField(2, name)
|
||||
if (stack) {
|
||||
writer.writeStringField(9, stack)
|
||||
}
|
||||
writer.writeFieldStop()
|
||||
|
||||
writer.writeFieldStop()
|
||||
|
||||
const reader = await callNoteStore(token, writer)
|
||||
let notebook: EvernoteNotebook | null = null
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
|
||||
notebook = readNotebook(r)
|
||||
} else {
|
||||
if (!checkEvernoteException(r, fieldId, fieldType)) {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!notebook) {
|
||||
throw new Error('No notebook returned from Evernote API')
|
||||
}
|
||||
|
||||
return notebook
|
||||
}
|
||||
|
||||
export async function listTags(token: string): Promise<EvernoteTag[]> {
|
||||
const writer = new ThriftWriter()
|
||||
writer.writeMessageBegin('listTags', 0)
|
||||
writer.writeStringField(1, token)
|
||||
writer.writeFieldStop()
|
||||
|
||||
const reader = await callNoteStore(token, writer)
|
||||
const tags: EvernoteTag[] = []
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
if (fieldId === 0 && fieldType === TYPE_LIST) {
|
||||
const { size } = r.readListBegin()
|
||||
for (let i = 0; i < size; i++) {
|
||||
tags.push(readTag(r))
|
||||
}
|
||||
} else {
|
||||
if (!checkEvernoteException(r, fieldId, fieldType)) {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
export async function createTag(
|
||||
token: string,
|
||||
name: string,
|
||||
parentGuid?: string
|
||||
): Promise<EvernoteTag> {
|
||||
const writer = new ThriftWriter()
|
||||
writer.writeMessageBegin('createTag', 0)
|
||||
writer.writeStringField(1, token)
|
||||
|
||||
writer.writeFieldBegin(TYPE_STRUCT, 2)
|
||||
writer.writeStringField(2, name)
|
||||
if (parentGuid) {
|
||||
writer.writeStringField(3, parentGuid)
|
||||
}
|
||||
writer.writeFieldStop()
|
||||
|
||||
writer.writeFieldStop()
|
||||
|
||||
const reader = await callNoteStore(token, writer)
|
||||
let tag: EvernoteTag | null = null
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
|
||||
tag = readTag(r)
|
||||
} else {
|
||||
if (!checkEvernoteException(r, fieldId, fieldType)) {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!tag) {
|
||||
throw new Error('No tag returned from Evernote API')
|
||||
}
|
||||
|
||||
return tag
|
||||
}
|
||||
|
||||
export async function copyNote(
|
||||
token: string,
|
||||
noteGuid: string,
|
||||
toNotebookGuid: string
|
||||
): Promise<EvernoteNote> {
|
||||
const writer = new ThriftWriter()
|
||||
writer.writeMessageBegin('copyNote', 0)
|
||||
writer.writeStringField(1, token)
|
||||
writer.writeStringField(2, noteGuid)
|
||||
writer.writeStringField(3, toNotebookGuid)
|
||||
writer.writeFieldStop()
|
||||
|
||||
const reader = await callNoteStore(token, writer)
|
||||
let note: EvernoteNote | null = null
|
||||
|
||||
reader.readStruct((r, fieldId, fieldType) => {
|
||||
if (fieldId === 0 && fieldType === TYPE_STRUCT) {
|
||||
note = readNote(r)
|
||||
} else {
|
||||
if (!checkEvernoteException(r, fieldId, fieldType)) {
|
||||
r.skip(fieldType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new Error('No note returned from Evernote API')
|
||||
}
|
||||
|
||||
return note
|
||||
}
|
||||
255
apps/sim/app/api/tools/evernote/lib/thrift.ts
Normal file
255
apps/sim/app/api/tools/evernote/lib/thrift.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Minimal Thrift binary protocol encoder/decoder for Evernote API.
|
||||
* Supports only the types needed for NoteStore operations.
|
||||
*/
|
||||
|
||||
const THRIFT_VERSION_1 = 0x80010000
|
||||
const MESSAGE_CALL = 1
|
||||
const MESSAGE_EXCEPTION = 3
|
||||
|
||||
const TYPE_STOP = 0
|
||||
const TYPE_BOOL = 2
|
||||
const TYPE_I32 = 8
|
||||
const TYPE_I64 = 10
|
||||
const TYPE_STRING = 11
|
||||
const TYPE_STRUCT = 12
|
||||
const TYPE_LIST = 15
|
||||
|
||||
export class ThriftWriter {
|
||||
private buffer: number[] = []
|
||||
|
||||
writeMessageBegin(name: string, seqId: number): void {
|
||||
this.writeI32(THRIFT_VERSION_1 | MESSAGE_CALL)
|
||||
this.writeString(name)
|
||||
this.writeI32(seqId)
|
||||
}
|
||||
|
||||
writeFieldBegin(type: number, id: number): void {
|
||||
this.buffer.push(type)
|
||||
this.writeI16(id)
|
||||
}
|
||||
|
||||
writeFieldStop(): void {
|
||||
this.buffer.push(TYPE_STOP)
|
||||
}
|
||||
|
||||
writeString(value: string): void {
|
||||
const encoded = new TextEncoder().encode(value)
|
||||
this.writeI32(encoded.length)
|
||||
for (const byte of encoded) {
|
||||
this.buffer.push(byte)
|
||||
}
|
||||
}
|
||||
|
||||
writeBool(value: boolean): void {
|
||||
this.buffer.push(value ? 1 : 0)
|
||||
}
|
||||
|
||||
writeI16(value: number): void {
|
||||
this.buffer.push((value >> 8) & 0xff)
|
||||
this.buffer.push(value & 0xff)
|
||||
}
|
||||
|
||||
writeI32(value: number): void {
|
||||
this.buffer.push((value >> 24) & 0xff)
|
||||
this.buffer.push((value >> 16) & 0xff)
|
||||
this.buffer.push((value >> 8) & 0xff)
|
||||
this.buffer.push(value & 0xff)
|
||||
}
|
||||
|
||||
writeI64(value: bigint): void {
|
||||
const buf = new ArrayBuffer(8)
|
||||
const view = new DataView(buf)
|
||||
view.setBigInt64(0, value, false)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
this.buffer.push(view.getUint8(i))
|
||||
}
|
||||
}
|
||||
|
||||
writeStringField(id: number, value: string): void {
|
||||
this.writeFieldBegin(TYPE_STRING, id)
|
||||
this.writeString(value)
|
||||
}
|
||||
|
||||
writeBoolField(id: number, value: boolean): void {
|
||||
this.writeFieldBegin(TYPE_BOOL, id)
|
||||
this.writeBool(value)
|
||||
}
|
||||
|
||||
writeI32Field(id: number, value: number): void {
|
||||
this.writeFieldBegin(TYPE_I32, id)
|
||||
this.writeI32(value)
|
||||
}
|
||||
|
||||
writeStringListField(id: number, values: string[]): void {
|
||||
this.writeFieldBegin(TYPE_LIST, id)
|
||||
this.buffer.push(TYPE_STRING)
|
||||
this.writeI32(values.length)
|
||||
for (const v of values) {
|
||||
this.writeString(v)
|
||||
}
|
||||
}
|
||||
|
||||
toBuffer(): Buffer {
|
||||
return Buffer.from(this.buffer)
|
||||
}
|
||||
}
|
||||
|
||||
export class ThriftReader {
|
||||
private view: DataView
|
||||
private pos = 0
|
||||
|
||||
constructor(buffer: ArrayBuffer) {
|
||||
this.view = new DataView(buffer)
|
||||
}
|
||||
|
||||
readMessageBegin(): { name: string; type: number; seqId: number } {
|
||||
const versionAndType = this.readI32()
|
||||
const version = versionAndType & 0xffff0000
|
||||
if (version !== (THRIFT_VERSION_1 | 0)) {
|
||||
throw new Error(`Unsupported Thrift version: 0x${version.toString(16)}`)
|
||||
}
|
||||
const type = versionAndType & 0x000000ff
|
||||
const name = this.readString()
|
||||
const seqId = this.readI32()
|
||||
return { name, type, seqId }
|
||||
}
|
||||
|
||||
readFieldBegin(): { type: number; id: number } {
|
||||
const type = this.view.getUint8(this.pos++)
|
||||
if (type === TYPE_STOP) {
|
||||
return { type: TYPE_STOP, id: 0 }
|
||||
}
|
||||
const id = this.view.getInt16(this.pos, false)
|
||||
this.pos += 2
|
||||
return { type, id }
|
||||
}
|
||||
|
||||
readString(): string {
|
||||
const length = this.readI32()
|
||||
const bytes = new Uint8Array(this.view.buffer, this.pos, length)
|
||||
this.pos += length
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
readBool(): boolean {
|
||||
return this.view.getUint8(this.pos++) !== 0
|
||||
}
|
||||
|
||||
readI32(): number {
|
||||
const value = this.view.getInt32(this.pos, false)
|
||||
this.pos += 4
|
||||
return value
|
||||
}
|
||||
|
||||
readI64(): bigint {
|
||||
const value = this.view.getBigInt64(this.pos, false)
|
||||
this.pos += 8
|
||||
return value
|
||||
}
|
||||
|
||||
readBinary(): Uint8Array {
|
||||
const length = this.readI32()
|
||||
const bytes = new Uint8Array(this.view.buffer, this.pos, length)
|
||||
this.pos += length
|
||||
return bytes
|
||||
}
|
||||
|
||||
readListBegin(): { elementType: number; size: number } {
|
||||
const elementType = this.view.getUint8(this.pos++)
|
||||
const size = this.readI32()
|
||||
return { elementType, size }
|
||||
}
|
||||
|
||||
/** Skip a value of the given Thrift type */
|
||||
skip(type: number): void {
|
||||
switch (type) {
|
||||
case TYPE_BOOL:
|
||||
this.pos += 1
|
||||
break
|
||||
case 6: // I16
|
||||
this.pos += 2
|
||||
break
|
||||
case 3: // BYTE
|
||||
this.pos += 1
|
||||
break
|
||||
case TYPE_I32:
|
||||
this.pos += 4
|
||||
break
|
||||
case TYPE_I64:
|
||||
case 4: // DOUBLE
|
||||
this.pos += 8
|
||||
break
|
||||
case TYPE_STRING: {
|
||||
const len = this.readI32()
|
||||
this.pos += len
|
||||
break
|
||||
}
|
||||
case TYPE_STRUCT:
|
||||
this.skipStruct()
|
||||
break
|
||||
case TYPE_LIST:
|
||||
case 14: {
|
||||
// SET
|
||||
const { elementType, size } = this.readListBegin()
|
||||
for (let i = 0; i < size; i++) {
|
||||
this.skip(elementType)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 13: {
|
||||
// MAP
|
||||
const keyType = this.view.getUint8(this.pos++)
|
||||
const valueType = this.view.getUint8(this.pos++)
|
||||
const count = this.readI32()
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.skip(keyType)
|
||||
this.skip(valueType)
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error(`Cannot skip unknown Thrift type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
private skipStruct(): void {
|
||||
for (;;) {
|
||||
const { type } = this.readFieldBegin()
|
||||
if (type === TYPE_STOP) break
|
||||
this.skip(type)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read struct fields, calling the handler for each field */
|
||||
readStruct<T>(handler: (reader: ThriftReader, fieldId: number, fieldType: number) => void): void {
|
||||
for (;;) {
|
||||
const { type, id } = this.readFieldBegin()
|
||||
if (type === TYPE_STOP) break
|
||||
handler(this, id, type)
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if this is an exception response */
|
||||
isException(messageType: number): boolean {
|
||||
return messageType === MESSAGE_EXCEPTION
|
||||
}
|
||||
|
||||
/** Read a Thrift application exception */
|
||||
readException(): { message: string; type: number } {
|
||||
let message = ''
|
||||
let type = 0
|
||||
this.readStruct((reader, fieldId, fieldType) => {
|
||||
if (fieldId === 1 && fieldType === TYPE_STRING) {
|
||||
message = reader.readString()
|
||||
} else if (fieldId === 2 && fieldType === TYPE_I32) {
|
||||
type = reader.readI32()
|
||||
} else {
|
||||
reader.skip(fieldType)
|
||||
}
|
||||
})
|
||||
return { message, type }
|
||||
}
|
||||
}
|
||||
|
||||
export { TYPE_BOOL, TYPE_I32, TYPE_I64, TYPE_LIST, TYPE_STOP, TYPE_STRING, TYPE_STRUCT }
|
||||
35
apps/sim/app/api/tools/evernote/list-notebooks/route.ts
Normal file
35
apps/sim/app/api/tools/evernote/list-notebooks/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { listNotebooks } from '@/app/api/tools/evernote/lib/client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('EvernoteListNotebooksAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { apiKey } = body
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ success: false, error: 'apiKey is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const notebooks = await listNotebooks(apiKey)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { notebooks },
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Failed to list notebooks', { error: message })
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
35
apps/sim/app/api/tools/evernote/list-tags/route.ts
Normal file
35
apps/sim/app/api/tools/evernote/list-tags/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { listTags } from '@/app/api/tools/evernote/lib/client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('EvernoteListTagsAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { apiKey } = body
|
||||
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ success: false, error: 'apiKey is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const tags = await listTags(apiKey)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { tags },
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Failed to list tags', { error: message })
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
49
apps/sim/app/api/tools/evernote/search-notes/route.ts
Normal file
49
apps/sim/app/api/tools/evernote/search-notes/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { searchNotes } from '@/app/api/tools/evernote/lib/client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('EvernoteSearchNotesAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { apiKey, query, notebookGuid, offset = 0, maxNotes = 25 } = body
|
||||
|
||||
if (!apiKey || !query) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'apiKey and query are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const clampedMaxNotes = Math.min(Math.max(Number(maxNotes) || 25, 1), 250)
|
||||
|
||||
const result = await searchNotes(
|
||||
apiKey,
|
||||
query,
|
||||
notebookGuid || undefined,
|
||||
Number(offset),
|
||||
clampedMaxNotes
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
totalNotes: result.totalNotes,
|
||||
notes: result.notes,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Failed to search notes', { error: message })
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
58
apps/sim/app/api/tools/evernote/update-note/route.ts
Normal file
58
apps/sim/app/api/tools/evernote/update-note/route.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { updateNote } from '@/app/api/tools/evernote/lib/client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('EvernoteUpdateNoteAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { apiKey, noteGuid, title, content, notebookGuid, tagNames } = body
|
||||
|
||||
if (!apiKey || !noteGuid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'apiKey and noteGuid are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsedTags = tagNames
|
||||
? (() => {
|
||||
const tags =
|
||||
typeof tagNames === 'string'
|
||||
? tagNames
|
||||
.split(',')
|
||||
.map((t: string) => t.trim())
|
||||
.filter(Boolean)
|
||||
: tagNames
|
||||
return tags.length > 0 ? tags : undefined
|
||||
})()
|
||||
: undefined
|
||||
|
||||
const note = await updateNote(
|
||||
apiKey,
|
||||
noteGuid,
|
||||
title || undefined,
|
||||
content || undefined,
|
||||
notebookGuid || undefined,
|
||||
parsedTags
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { note },
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Failed to update note', { error: message })
|
||||
return NextResponse.json({ success: false, error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { MongoClient } from 'mongodb'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
import type { MongoDBCollectionInfo, MongoDBConnectionConfig } from '@/tools/mongodb/types'
|
||||
|
||||
export async function createMongoDBConnection(config: MongoDBConnectionConfig) {
|
||||
const hostValidation = await validateDatabaseHost(config.host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
throw new Error(hostValidation.error)
|
||||
}
|
||||
|
||||
const credentials =
|
||||
config.username && config.password
|
||||
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import mysql from 'mysql2/promise'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
|
||||
export interface MySQLConnectionConfig {
|
||||
host: string
|
||||
@@ -10,6 +11,11 @@ export interface MySQLConnectionConfig {
|
||||
}
|
||||
|
||||
export async function createMySQLConnection(config: MySQLConnectionConfig) {
|
||||
const hostValidation = await validateDatabaseHost(config.host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
throw new Error(hostValidation.error)
|
||||
}
|
||||
|
||||
const connectionConfig: mysql.ConnectionOptions = {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import neo4j from 'neo4j-driver'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
import type { Neo4jConnectionConfig } from '@/tools/neo4j/types'
|
||||
|
||||
export async function createNeo4jDriver(config: Neo4jConnectionConfig) {
|
||||
const hostValidation = await validateDatabaseHost(config.host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
throw new Error(hostValidation.error)
|
||||
}
|
||||
|
||||
const isAuraHost =
|
||||
config.host === 'databases.neo4j.io' || config.host.endsWith('.databases.neo4j.io')
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const sql = createPostgresConnection({
|
||||
const sql = await createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const sql = createPostgresConnection({
|
||||
const sql = await createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const sql = createPostgresConnection({
|
||||
const sql = await createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Introspecting PostgreSQL schema on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const sql = createPostgresConnection({
|
||||
const sql = await createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
|
||||
@@ -34,7 +34,7 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Executing PostgreSQL query on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const sql = createPostgresConnection({
|
||||
const sql = await createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function POST(request: NextRequest) {
|
||||
`[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}`
|
||||
)
|
||||
|
||||
const sql = createPostgresConnection({
|
||||
const sql = await createPostgresConnection({
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
database: params.database,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import postgres from 'postgres'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
import type { PostgresConnectionConfig } from '@/tools/postgresql/types'
|
||||
|
||||
export function createPostgresConnection(config: PostgresConnectionConfig) {
|
||||
export async function createPostgresConnection(config: PostgresConnectionConfig) {
|
||||
const hostValidation = await validateDatabaseHost(config.host, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
throw new Error(hostValidation.error)
|
||||
}
|
||||
|
||||
const sslConfig =
|
||||
config.ssl === 'disabled'
|
||||
? false
|
||||
|
||||
@@ -3,6 +3,7 @@ import Redis from 'ioredis'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
|
||||
|
||||
const logger = createLogger('RedisAPI')
|
||||
|
||||
@@ -24,6 +25,16 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const { url, command, args } = RequestSchema.parse(body)
|
||||
|
||||
const parsedUrl = new URL(url)
|
||||
const hostname =
|
||||
parsedUrl.hostname.startsWith('[') && parsedUrl.hostname.endsWith(']')
|
||||
? parsedUrl.hostname.slice(1, -1)
|
||||
: parsedUrl.hostname
|
||||
const hostValidation = await validateDatabaseHost(hostname, 'host')
|
||||
if (!hostValidation.isValid) {
|
||||
return NextResponse.json({ error: hostValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
client = new Redis(url, {
|
||||
connectTimeout: 10000,
|
||||
commandTimeout: 10000,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections'
|
||||
import { enrichTableSchema } from '@/lib/table/llm/wand'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
import { extractResponseText, parseResponsesUsage } from '@/providers/openai/utils'
|
||||
@@ -331,14 +330,10 @@ export async function POST(req: NextRequest) {
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
let wandStreamClosed = false
|
||||
const readable = new ReadableStream({
|
||||
async start(controller) {
|
||||
incrementSSEConnections('wand')
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
wandStreamClosed = true
|
||||
decrementSSEConnections('wand')
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
@@ -483,18 +478,9 @@ export async function POST(req: NextRequest) {
|
||||
controller.close()
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
if (!wandStreamClosed) {
|
||||
wandStreamClosed = true
|
||||
decrementSSEConnections('wand')
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
if (!wandStreamClosed) {
|
||||
wandStreamClosed = true
|
||||
decrementSSEConnections('wand')
|
||||
}
|
||||
},
|
||||
cancel() {},
|
||||
})
|
||||
|
||||
return new Response(readable, {
|
||||
|
||||
@@ -367,9 +367,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Configure each new webhook (for providers that need configuration)
|
||||
const pollingProviders = ['gmail', 'outlook']
|
||||
const needsConfiguration = pollingProviders.includes(provider)
|
||||
const needsConfiguration = provider === 'gmail' || provider === 'outlook'
|
||||
|
||||
if (needsConfiguration) {
|
||||
const configureFunc =
|
||||
|
||||
@@ -324,7 +324,9 @@ vi.mock('@/lib/webhooks/processor', () => ({
|
||||
return null
|
||||
}
|
||||
),
|
||||
checkWebhookPreprocessing: vi.fn().mockResolvedValue(null),
|
||||
checkWebhookPreprocessing: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ error: null, actorUserId: 'test-user-id' }),
|
||||
formatProviderErrorResponse: vi.fn().mockImplementation((_webhook, error, status) => {
|
||||
const { NextResponse } = require('next/server')
|
||||
return NextResponse.json({ error }, { status })
|
||||
|
||||
@@ -4,7 +4,6 @@ import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import {
|
||||
checkWebhookPreprocessing,
|
||||
findAllWebhooksForPath,
|
||||
formatProviderErrorResponse,
|
||||
handlePreDeploymentVerification,
|
||||
handleProviderChallenges,
|
||||
handleProviderReachabilityTest,
|
||||
@@ -82,7 +81,6 @@ export async function POST(
|
||||
requestId
|
||||
)
|
||||
if (authError) {
|
||||
// For multi-webhook, log and continue to next webhook
|
||||
if (webhooksForPath.length > 1) {
|
||||
logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`)
|
||||
continue
|
||||
@@ -92,39 +90,18 @@ export async function POST(
|
||||
|
||||
const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
|
||||
if (reachabilityResponse) {
|
||||
// Reachability test should return immediately for the first webhook
|
||||
return reachabilityResponse
|
||||
}
|
||||
|
||||
let preprocessError: NextResponse | null = null
|
||||
try {
|
||||
preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
|
||||
if (preprocessError) {
|
||||
if (webhooksForPath.length > 1) {
|
||||
logger.warn(
|
||||
`[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}, continuing to next`
|
||||
)
|
||||
continue
|
||||
}
|
||||
return preprocessError
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Unexpected error during webhook preprocessing`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
webhookId: foundWebhook.id,
|
||||
workflowId: foundWorkflow.id,
|
||||
})
|
||||
|
||||
const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
|
||||
if (preprocessResult.error) {
|
||||
if (webhooksForPath.length > 1) {
|
||||
logger.warn(
|
||||
`[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}, continuing to next`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
return formatProviderErrorResponse(
|
||||
foundWebhook,
|
||||
'An unexpected error occurred during preprocessing',
|
||||
500
|
||||
)
|
||||
return preprocessResult.error
|
||||
}
|
||||
|
||||
if (foundWebhook.blockId) {
|
||||
@@ -152,6 +129,7 @@ export async function POST(
|
||||
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
|
||||
requestId,
|
||||
path,
|
||||
actorUserId: preprocessResult.actorUserId,
|
||||
})
|
||||
responses.push(response)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/ev
|
||||
import { processInputFileFields } from '@/lib/execution/files'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections'
|
||||
import {
|
||||
cleanupExecutionBase64Cache,
|
||||
hydrateUserFilesWithBase64,
|
||||
@@ -764,7 +763,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const encoder = new TextEncoder()
|
||||
const timeoutController = createTimeoutAbortController(preprocessResult.executionTimeout?.sync)
|
||||
let isStreamClosed = false
|
||||
let sseDecremented = false
|
||||
|
||||
const eventWriter = createExecutionEventWriter(executionId)
|
||||
setExecutionMeta(executionId, {
|
||||
@@ -775,7 +773,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
incrementSSEConnections('workflow-execute')
|
||||
let finalMetaStatus: 'complete' | 'error' | 'cancelled' | null = null
|
||||
|
||||
const sendEvent = (event: ExecutionEvent) => {
|
||||
@@ -1159,10 +1156,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
if (executionId) {
|
||||
await cleanupExecutionBase64Cache(executionId)
|
||||
}
|
||||
if (!sseDecremented) {
|
||||
sseDecremented = true
|
||||
decrementSSEConnections('workflow-execute')
|
||||
}
|
||||
if (!isStreamClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
||||
@@ -1174,10 +1167,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
cancel() {
|
||||
isStreamClosed = true
|
||||
logger.info(`[${requestId}] Client disconnected from SSE stream`)
|
||||
if (!sseDecremented) {
|
||||
sseDecremented = true
|
||||
decrementSSEConnections('workflow-execute')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getExecutionMeta,
|
||||
readExecutionEvents,
|
||||
} from '@/lib/execution/event-buffer'
|
||||
import { decrementSSEConnections, incrementSSEConnections } from '@/lib/monitoring/sse-connections'
|
||||
import { formatSSEEvent } from '@/lib/workflows/executor/execution-events'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
@@ -74,10 +73,8 @@ export async function GET(
|
||||
|
||||
let closed = false
|
||||
|
||||
let sseDecremented = false
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
incrementSSEConnections('execution-stream-reconnect')
|
||||
let lastEventId = fromEventId
|
||||
const pollDeadline = Date.now() + MAX_POLL_DURATION_MS
|
||||
|
||||
@@ -145,20 +142,11 @@ export async function GET(
|
||||
controller.close()
|
||||
} catch {}
|
||||
}
|
||||
} finally {
|
||||
if (!sseDecremented) {
|
||||
sseDecremented = true
|
||||
decrementSSEConnections('execution-stream-reconnect')
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
closed = true
|
||||
logger.info('Client disconnected from reconnection stream', { executionId })
|
||||
if (!sseDecremented) {
|
||||
sseDecremented = true
|
||||
decrementSSEConnections('execution-stream-reconnect')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -135,18 +136,18 @@ async function testWebhook(subscription: typeof workspaceNotificationSubscriptio
|
||||
headers['sim-signature'] = `t=${timestamp},v1=${signature}`
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
try {
|
||||
const response = await fetch(webhookConfig.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
const response = await secureFetchWithValidation(
|
||||
webhookConfig.url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
timeout: 10000,
|
||||
allowHttp: true,
|
||||
},
|
||||
'webhookUrl'
|
||||
)
|
||||
const responseBody = await response.text().catch(() => '')
|
||||
|
||||
return {
|
||||
@@ -157,12 +158,10 @@ async function testWebhook(subscription: typeof workspaceNotificationSubscriptio
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
clearTimeout(timeoutId)
|
||||
const err = error as Error & { name?: string }
|
||||
if (err.name === 'AbortError') {
|
||||
return { success: false, error: 'Request timeout after 10 seconds' }
|
||||
}
|
||||
return { success: false, error: err.message }
|
||||
logger.warn('Webhook test failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return { success: false, error: 'Failed to deliver webhook' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,13 +267,15 @@ async function testSlack(
|
||||
|
||||
return {
|
||||
success: result.ok,
|
||||
error: result.error,
|
||||
error: result.ok ? undefined : `Slack error: ${result.error || 'unknown'}`,
|
||||
channel: result.channel,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error
|
||||
return { success: false, error: err.message }
|
||||
logger.warn('Slack test notification failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return { success: false, error: 'Failed to send Slack notification' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { db } from '@sim/db'
|
||||
import { webhook, workflow as workflowTable } from '@sim/db/schema'
|
||||
import { account, webhook } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { task } from '@trigger.dev/sdk'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing'
|
||||
import {
|
||||
createTimeoutAbortController,
|
||||
getExecutionTimeout,
|
||||
getTimeoutErrorMessage,
|
||||
} from '@/lib/core/execution-limits'
|
||||
import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits'
|
||||
import { IdempotencyService, webhookIdempotency } from '@/lib/core/idempotency'
|
||||
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
|
||||
import { processExecutionFiles } from '@/lib/execution/files'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor'
|
||||
@@ -20,7 +15,7 @@ import { fetchAndProcessAirtablePayloads, formatWebhookInput } from '@/lib/webho
|
||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
import type { ExecutionMetadata } from '@/executor/execution/types'
|
||||
@@ -109,8 +104,8 @@ export type WebhookExecutionPayload = {
|
||||
headers: Record<string, string>
|
||||
path: string
|
||||
blockId?: string
|
||||
workspaceId?: string
|
||||
credentialId?: string
|
||||
credentialAccountUserId?: string
|
||||
}
|
||||
|
||||
export async function executeWebhookJob(payload: WebhookExecutionPayload) {
|
||||
@@ -143,6 +138,22 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the account userId for a credential
|
||||
*/
|
||||
async function resolveCredentialAccountUserId(credentialId: string): Promise<string | undefined> {
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return undefined
|
||||
}
|
||||
const [credentialRecord] = await db
|
||||
.select({ userId: account.userId })
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
return credentialRecord?.userId
|
||||
}
|
||||
|
||||
async function executeWebhookJobInternal(
|
||||
payload: WebhookExecutionPayload,
|
||||
executionId: string,
|
||||
@@ -155,17 +166,56 @@ async function executeWebhookJobInternal(
|
||||
requestId
|
||||
)
|
||||
|
||||
const userSubscription = await getHighestPrioritySubscription(payload.userId)
|
||||
const asyncTimeout = getExecutionTimeout(
|
||||
userSubscription?.plan as SubscriptionPlan | undefined,
|
||||
'async'
|
||||
)
|
||||
// Resolve workflow record, billing actor, subscription, and timeout
|
||||
const preprocessResult = await preprocessExecution({
|
||||
workflowId: payload.workflowId,
|
||||
userId: payload.userId,
|
||||
triggerType: 'webhook',
|
||||
executionId,
|
||||
requestId,
|
||||
checkRateLimit: false,
|
||||
checkDeployment: false,
|
||||
skipUsageLimits: true,
|
||||
workspaceId: payload.workspaceId,
|
||||
loggingSession,
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
throw new Error(preprocessResult.error?.message || 'Preprocessing failed in background job')
|
||||
}
|
||||
|
||||
const { workflowRecord, executionTimeout } = preprocessResult
|
||||
if (!workflowRecord) {
|
||||
throw new Error(`Workflow ${payload.workflowId} not found during preprocessing`)
|
||||
}
|
||||
|
||||
const workspaceId = workflowRecord.workspaceId
|
||||
if (!workspaceId) {
|
||||
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
|
||||
}
|
||||
|
||||
const workflowVariables = (workflowRecord.variables as Record<string, any>) || {}
|
||||
const asyncTimeout = executionTimeout?.async ?? 120_000
|
||||
const timeoutController = createTimeoutAbortController(asyncTimeout)
|
||||
|
||||
let deploymentVersionId: string | undefined
|
||||
|
||||
try {
|
||||
const workflowData = await loadDeployedWorkflowState(payload.workflowId)
|
||||
// Parallelize workflow state, webhook record, and credential resolution
|
||||
const [workflowData, webhookRows, resolvedCredentialUserId] = await Promise.all([
|
||||
loadDeployedWorkflowState(payload.workflowId, workspaceId),
|
||||
db.select().from(webhook).where(eq(webhook.id, payload.webhookId)).limit(1),
|
||||
payload.credentialId
|
||||
? resolveCredentialAccountUserId(payload.credentialId)
|
||||
: Promise.resolve(undefined),
|
||||
])
|
||||
const credentialAccountUserId = resolvedCredentialUserId
|
||||
if (payload.credentialId && !credentialAccountUserId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Failed to resolve credential account for credential ${payload.credentialId}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!workflowData) {
|
||||
throw new Error(
|
||||
'Workflow state not found. The workflow may not be deployed or the deployment data may be corrupted.'
|
||||
@@ -178,28 +228,11 @@ async function executeWebhookJobInternal(
|
||||
? (workflowData.deploymentVersionId as string)
|
||||
: undefined
|
||||
|
||||
const wfRows = await db
|
||||
.select({ workspaceId: workflowTable.workspaceId, variables: workflowTable.variables })
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, payload.workflowId))
|
||||
.limit(1)
|
||||
const workspaceId = wfRows[0]?.workspaceId
|
||||
if (!workspaceId) {
|
||||
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
|
||||
}
|
||||
const workflowVariables = (wfRows[0]?.variables as Record<string, any>) || {}
|
||||
|
||||
// Handle special Airtable case
|
||||
if (payload.provider === 'airtable') {
|
||||
logger.info(`[${requestId}] Processing Airtable webhook via fetchAndProcessAirtablePayloads`)
|
||||
|
||||
// Load the actual webhook record from database to get providerConfig
|
||||
const [webhookRecord] = await db
|
||||
.select()
|
||||
.from(webhook)
|
||||
.where(eq(webhook.id, payload.webhookId))
|
||||
.limit(1)
|
||||
|
||||
const webhookRecord = webhookRows[0]
|
||||
if (!webhookRecord) {
|
||||
throw new Error(`Webhook record not found: ${payload.webhookId}`)
|
||||
}
|
||||
@@ -210,29 +243,20 @@ async function executeWebhookJobInternal(
|
||||
providerConfig: webhookRecord.providerConfig,
|
||||
}
|
||||
|
||||
// Create a mock workflow object for Airtable processing
|
||||
const mockWorkflow = {
|
||||
id: payload.workflowId,
|
||||
userId: payload.userId,
|
||||
}
|
||||
|
||||
// Get the processed Airtable input
|
||||
const airtableInput = await fetchAndProcessAirtablePayloads(
|
||||
webhookData,
|
||||
mockWorkflow,
|
||||
requestId
|
||||
)
|
||||
|
||||
// If we got input (changes), execute the workflow like other providers
|
||||
if (airtableInput) {
|
||||
logger.info(`[${requestId}] Executing workflow with Airtable changes`)
|
||||
|
||||
// Get workflow for core execution
|
||||
const workflow = await getWorkflowById(payload.workflowId)
|
||||
if (!workflow) {
|
||||
throw new Error(`Workflow ${payload.workflowId} not found`)
|
||||
}
|
||||
|
||||
const metadata: ExecutionMetadata = {
|
||||
requestId,
|
||||
executionId,
|
||||
@@ -240,13 +264,13 @@ async function executeWebhookJobInternal(
|
||||
workspaceId,
|
||||
userId: payload.userId,
|
||||
sessionUserId: undefined,
|
||||
workflowUserId: workflow.userId,
|
||||
workflowUserId: workflowRecord.userId,
|
||||
triggerType: payload.provider || 'webhook',
|
||||
triggerBlockId: payload.blockId,
|
||||
useDraftState: false,
|
||||
startTime: new Date().toISOString(),
|
||||
isClientSession: false,
|
||||
credentialAccountUserId: payload.credentialAccountUserId,
|
||||
credentialAccountUserId,
|
||||
workflowStateOverride: {
|
||||
blocks,
|
||||
edges,
|
||||
@@ -258,7 +282,7 @@ async function executeWebhookJobInternal(
|
||||
|
||||
const snapshot = new ExecutionSnapshot(
|
||||
metadata,
|
||||
workflow,
|
||||
workflowRecord,
|
||||
airtableInput,
|
||||
workflowVariables,
|
||||
[]
|
||||
@@ -329,7 +353,6 @@ async function executeWebhookJobInternal(
|
||||
// No changes to process
|
||||
logger.info(`[${requestId}] No Airtable changes to process`)
|
||||
|
||||
// Start logging session so the complete call has a log entry to update
|
||||
await loggingSession.safeStart({
|
||||
userId: payload.userId,
|
||||
workspaceId,
|
||||
@@ -357,13 +380,6 @@ async function executeWebhookJobInternal(
|
||||
}
|
||||
|
||||
// Format input for standard webhooks
|
||||
// Load the actual webhook to get providerConfig (needed for Teams credentialId)
|
||||
const webhookRows = await db
|
||||
.select()
|
||||
.from(webhook)
|
||||
.where(eq(webhook.id, payload.webhookId))
|
||||
.limit(1)
|
||||
|
||||
const actualWebhook =
|
||||
webhookRows.length > 0
|
||||
? webhookRows[0]
|
||||
@@ -386,7 +402,6 @@ async function executeWebhookJobInternal(
|
||||
if (!input && payload.provider === 'whatsapp') {
|
||||
logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`)
|
||||
|
||||
// Start logging session so the complete call has a log entry to update
|
||||
await loggingSession.safeStart({
|
||||
userId: payload.userId,
|
||||
workspaceId,
|
||||
@@ -452,7 +467,6 @@ async function executeWebhookJobInternal(
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error processing trigger file outputs:`, error)
|
||||
// Continue without processing attachments rather than failing execution
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,18 +513,11 @@ async function executeWebhookJobInternal(
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error processing generic webhook files:`, error)
|
||||
// Continue without processing files rather than failing execution
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Executing workflow for ${payload.provider} webhook`)
|
||||
|
||||
// Get workflow for core execution
|
||||
const workflow = await getWorkflowById(payload.workflowId)
|
||||
if (!workflow) {
|
||||
throw new Error(`Workflow ${payload.workflowId} not found`)
|
||||
}
|
||||
|
||||
const metadata: ExecutionMetadata = {
|
||||
requestId,
|
||||
executionId,
|
||||
@@ -518,13 +525,13 @@ async function executeWebhookJobInternal(
|
||||
workspaceId,
|
||||
userId: payload.userId,
|
||||
sessionUserId: undefined,
|
||||
workflowUserId: workflow.userId,
|
||||
workflowUserId: workflowRecord.userId,
|
||||
triggerType: payload.provider || 'webhook',
|
||||
triggerBlockId: payload.blockId,
|
||||
useDraftState: false,
|
||||
startTime: new Date().toISOString(),
|
||||
isClientSession: false,
|
||||
credentialAccountUserId: payload.credentialAccountUserId,
|
||||
credentialAccountUserId,
|
||||
workflowStateOverride: {
|
||||
blocks,
|
||||
edges,
|
||||
@@ -536,7 +543,13 @@ async function executeWebhookJobInternal(
|
||||
|
||||
const triggerInput = input || {}
|
||||
|
||||
const snapshot = new ExecutionSnapshot(metadata, workflow, triggerInput, workflowVariables, [])
|
||||
const snapshot = new ExecutionSnapshot(
|
||||
metadata,
|
||||
workflowRecord,
|
||||
triggerInput,
|
||||
workflowVariables,
|
||||
[]
|
||||
)
|
||||
|
||||
const executionResult = await executeWorkflowCore({
|
||||
snapshot,
|
||||
@@ -611,23 +624,9 @@ async function executeWebhookJobInternal(
|
||||
})
|
||||
|
||||
try {
|
||||
const wfRow = await db
|
||||
.select({ workspaceId: workflowTable.workspaceId })
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, payload.workflowId))
|
||||
.limit(1)
|
||||
const errorWorkspaceId = wfRow[0]?.workspaceId
|
||||
|
||||
if (!errorWorkspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Cannot log error: workflow ${payload.workflowId} has no workspace`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: payload.userId,
|
||||
workspaceId: errorWorkspaceId,
|
||||
workspaceId,
|
||||
variables: {},
|
||||
triggerData: {
|
||||
isTest: false,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
|
||||
@@ -207,18 +208,18 @@ async function deliverWebhook(
|
||||
headers['sim-signature'] = `t=${payload.timestamp},v1=${signature}`
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000)
|
||||
|
||||
try {
|
||||
const response = await fetch(webhookConfig.url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
const response = await secureFetchWithValidation(
|
||||
webhookConfig.url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
timeout: 30000,
|
||||
allowHttp: true,
|
||||
},
|
||||
'webhookUrl'
|
||||
)
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
@@ -226,11 +227,13 @@ async function deliverWebhook(
|
||||
error: response.ok ? undefined : `HTTP ${response.status}`,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
clearTimeout(timeoutId)
|
||||
const err = error as Error & { name?: string }
|
||||
logger.warn('Webhook delivery failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
webhookUrl: webhookConfig.url,
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: err.name === 'AbortError' ? 'Request timeout' : err.message,
|
||||
error: 'Failed to deliver webhook',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
308
apps/sim/blocks/blocks/evernote.ts
Normal file
308
apps/sim/blocks/blocks/evernote.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { EvernoteIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
|
||||
export const EvernoteBlock: BlockConfig = {
|
||||
type: 'evernote',
|
||||
name: 'Evernote',
|
||||
description: 'Manage notes, notebooks, and tags in Evernote',
|
||||
longDescription:
|
||||
'Integrate with Evernote to manage notes, notebooks, and tags. Create, read, update, copy, search, and delete notes. Create and list notebooks and tags.',
|
||||
docsLink: 'https://docs.sim.ai/tools/evernote',
|
||||
category: 'tools',
|
||||
bgColor: '#E0E0E0',
|
||||
icon: EvernoteIcon,
|
||||
authMode: AuthMode.ApiKey,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Create Note', id: 'create_note' },
|
||||
{ label: 'Get Note', id: 'get_note' },
|
||||
{ label: 'Update Note', id: 'update_note' },
|
||||
{ label: 'Delete Note', id: 'delete_note' },
|
||||
{ label: 'Copy Note', id: 'copy_note' },
|
||||
{ label: 'Search Notes', id: 'search_notes' },
|
||||
{ label: 'Get Notebook', id: 'get_notebook' },
|
||||
{ label: 'Create Notebook', id: 'create_notebook' },
|
||||
{ label: 'List Notebooks', id: 'list_notebooks' },
|
||||
{ label: 'Create Tag', id: 'create_tag' },
|
||||
{ label: 'List Tags', id: 'list_tags' },
|
||||
],
|
||||
value: () => 'create_note',
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'Developer Token',
|
||||
type: 'short-input',
|
||||
password: true,
|
||||
placeholder: 'Enter your Evernote developer token',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'title',
|
||||
title: 'Title',
|
||||
type: 'short-input',
|
||||
placeholder: 'Note title',
|
||||
condition: { field: 'operation', value: 'create_note' },
|
||||
required: { field: 'operation', value: 'create_note' },
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
title: 'Content',
|
||||
type: 'long-input',
|
||||
placeholder: 'Note content (plain text or ENML)',
|
||||
condition: { field: 'operation', value: 'create_note' },
|
||||
required: { field: 'operation', value: 'create_note' },
|
||||
},
|
||||
{
|
||||
id: 'noteGuid',
|
||||
title: 'Note GUID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter the note GUID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['get_note', 'update_note', 'delete_note', 'copy_note'],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: ['get_note', 'update_note', 'delete_note', 'copy_note'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'updateTitle',
|
||||
title: 'New Title',
|
||||
type: 'short-input',
|
||||
placeholder: 'New title (leave empty to keep current)',
|
||||
condition: { field: 'operation', value: 'update_note' },
|
||||
},
|
||||
{
|
||||
id: 'updateContent',
|
||||
title: 'New Content',
|
||||
type: 'long-input',
|
||||
placeholder: 'New content (leave empty to keep current)',
|
||||
condition: { field: 'operation', value: 'update_note' },
|
||||
},
|
||||
{
|
||||
id: 'toNotebookGuid',
|
||||
title: 'Destination Notebook GUID',
|
||||
type: 'short-input',
|
||||
placeholder: 'GUID of the destination notebook',
|
||||
condition: { field: 'operation', value: 'copy_note' },
|
||||
required: { field: 'operation', value: 'copy_note' },
|
||||
},
|
||||
{
|
||||
id: 'query',
|
||||
title: 'Search Query',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., "tag:work intitle:meeting"',
|
||||
condition: { field: 'operation', value: 'search_notes' },
|
||||
required: { field: 'operation', value: 'search_notes' },
|
||||
},
|
||||
{
|
||||
id: 'notebookGuid',
|
||||
title: 'Notebook GUID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Notebook GUID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['create_note', 'update_note', 'search_notes', 'get_notebook'],
|
||||
},
|
||||
required: { field: 'operation', value: 'get_notebook' },
|
||||
},
|
||||
{
|
||||
id: 'notebookName',
|
||||
title: 'Notebook Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Name for the new notebook',
|
||||
condition: { field: 'operation', value: 'create_notebook' },
|
||||
required: { field: 'operation', value: 'create_notebook' },
|
||||
},
|
||||
{
|
||||
id: 'stack',
|
||||
title: 'Stack',
|
||||
type: 'short-input',
|
||||
placeholder: 'Stack name (optional)',
|
||||
condition: { field: 'operation', value: 'create_notebook' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tagName',
|
||||
title: 'Tag Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'Name for the new tag',
|
||||
condition: { field: 'operation', value: 'create_tag' },
|
||||
required: { field: 'operation', value: 'create_tag' },
|
||||
},
|
||||
{
|
||||
id: 'parentGuid',
|
||||
title: 'Parent Tag GUID',
|
||||
type: 'short-input',
|
||||
placeholder: 'Parent tag GUID (optional)',
|
||||
condition: { field: 'operation', value: 'create_tag' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tagNames',
|
||||
title: 'Tags',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated tags (e.g., "work, meeting, urgent")',
|
||||
condition: { field: 'operation', value: ['create_note', 'update_note'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'maxNotes',
|
||||
title: 'Max Results',
|
||||
type: 'short-input',
|
||||
placeholder: '25',
|
||||
condition: { field: 'operation', value: 'search_notes' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'offset',
|
||||
title: 'Offset',
|
||||
type: 'short-input',
|
||||
placeholder: '0',
|
||||
condition: { field: 'operation', value: 'search_notes' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'withContent',
|
||||
title: 'Include Content',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Yes', id: 'true' },
|
||||
{ label: 'No', id: 'false' },
|
||||
],
|
||||
value: () => 'true',
|
||||
condition: { field: 'operation', value: 'get_note' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
],
|
||||
|
||||
tools: {
|
||||
access: [
|
||||
'evernote_copy_note',
|
||||
'evernote_create_note',
|
||||
'evernote_create_notebook',
|
||||
'evernote_create_tag',
|
||||
'evernote_delete_note',
|
||||
'evernote_get_note',
|
||||
'evernote_get_notebook',
|
||||
'evernote_list_notebooks',
|
||||
'evernote_list_tags',
|
||||
'evernote_search_notes',
|
||||
'evernote_update_note',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => `evernote_${params.operation}`,
|
||||
params: (params) => {
|
||||
const { operation, apiKey, ...rest } = params
|
||||
|
||||
switch (operation) {
|
||||
case 'create_note':
|
||||
return {
|
||||
apiKey,
|
||||
title: rest.title,
|
||||
content: rest.content,
|
||||
notebookGuid: rest.notebookGuid || undefined,
|
||||
tagNames: rest.tagNames || undefined,
|
||||
}
|
||||
case 'get_note':
|
||||
return {
|
||||
apiKey,
|
||||
noteGuid: rest.noteGuid,
|
||||
withContent: rest.withContent !== 'false',
|
||||
}
|
||||
case 'update_note':
|
||||
return {
|
||||
apiKey,
|
||||
noteGuid: rest.noteGuid,
|
||||
title: rest.updateTitle || undefined,
|
||||
content: rest.updateContent || undefined,
|
||||
notebookGuid: rest.notebookGuid || undefined,
|
||||
tagNames: rest.tagNames || undefined,
|
||||
}
|
||||
case 'delete_note':
|
||||
return {
|
||||
apiKey,
|
||||
noteGuid: rest.noteGuid,
|
||||
}
|
||||
case 'copy_note':
|
||||
return {
|
||||
apiKey,
|
||||
noteGuid: rest.noteGuid,
|
||||
toNotebookGuid: rest.toNotebookGuid,
|
||||
}
|
||||
case 'search_notes':
|
||||
return {
|
||||
apiKey,
|
||||
query: rest.query,
|
||||
notebookGuid: rest.notebookGuid || undefined,
|
||||
offset: rest.offset ? Number(rest.offset) : 0,
|
||||
maxNotes: rest.maxNotes ? Number(rest.maxNotes) : 25,
|
||||
}
|
||||
case 'get_notebook':
|
||||
return {
|
||||
apiKey,
|
||||
notebookGuid: rest.notebookGuid,
|
||||
}
|
||||
case 'create_notebook':
|
||||
return {
|
||||
apiKey,
|
||||
name: rest.notebookName,
|
||||
stack: rest.stack || undefined,
|
||||
}
|
||||
case 'list_notebooks':
|
||||
return { apiKey }
|
||||
case 'create_tag':
|
||||
return {
|
||||
apiKey,
|
||||
name: rest.tagName,
|
||||
parentGuid: rest.parentGuid || undefined,
|
||||
}
|
||||
case 'list_tags':
|
||||
return { apiKey }
|
||||
default:
|
||||
return { apiKey }
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
inputs: {
|
||||
apiKey: { type: 'string', description: 'Evernote developer token' },
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
title: { type: 'string', description: 'Note title' },
|
||||
content: { type: 'string', description: 'Note content' },
|
||||
noteGuid: { type: 'string', description: 'Note GUID' },
|
||||
updateTitle: { type: 'string', description: 'New note title' },
|
||||
updateContent: { type: 'string', description: 'New note content' },
|
||||
toNotebookGuid: { type: 'string', description: 'Destination notebook GUID' },
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
notebookGuid: { type: 'string', description: 'Notebook GUID' },
|
||||
notebookName: { type: 'string', description: 'Notebook name' },
|
||||
stack: { type: 'string', description: 'Notebook stack name' },
|
||||
tagName: { type: 'string', description: 'Tag name' },
|
||||
parentGuid: { type: 'string', description: 'Parent tag GUID' },
|
||||
tagNames: { type: 'string', description: 'Comma-separated tag names' },
|
||||
maxNotes: { type: 'string', description: 'Maximum number of results' },
|
||||
offset: { type: 'string', description: 'Starting index for results' },
|
||||
withContent: { type: 'string', description: 'Whether to include note content' },
|
||||
},
|
||||
|
||||
outputs: {
|
||||
note: { type: 'json', description: 'Note data' },
|
||||
notebook: { type: 'json', description: 'Notebook data' },
|
||||
notebooks: { type: 'json', description: 'List of notebooks' },
|
||||
tag: { type: 'json', description: 'Tag data' },
|
||||
tags: { type: 'json', description: 'List of tags' },
|
||||
totalNotes: { type: 'number', description: 'Total number of matching notes' },
|
||||
notes: { type: 'json', description: 'List of note metadata' },
|
||||
success: { type: 'boolean', description: 'Whether the operation succeeded' },
|
||||
noteGuid: { type: 'string', description: 'GUID of the affected note' },
|
||||
},
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export const GenericWebhookBlock: BlockConfig = {
|
||||
bestPractices: `
|
||||
- You can test the webhook by sending a request to the webhook URL. E.g. depending on authorization: curl -X POST http://localhost:3000/api/webhooks/trigger/d8abcf0d-1ee5-4b77-bb07-b1e8142ea4e9 -H "Content-Type: application/json" -H "X-Sim-Secret: 1234" -d '{"message": "Test webhook trigger", "data": {"key": "v"}}'
|
||||
- Continuing example above, the body can be accessed in downstream block using dot notation. E.g. <webhook1.message> and <webhook1.data.key>
|
||||
- To deduplicate incoming events, set the Deduplication Field to a dot-notation path of a unique field in the payload (e.g. "event.id"). Duplicate values within 7 days will be skipped.
|
||||
- Only use when there's no existing integration for the service with triggerAllowed flag set to true.
|
||||
`,
|
||||
subBlocks: [...getTrigger('generic_webhook').subBlocks],
|
||||
|
||||
@@ -47,6 +47,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
{ label: 'Add Watcher', id: 'add_watcher' },
|
||||
{ label: 'Remove Watcher', id: 'remove_watcher' },
|
||||
{ label: 'Get Users', id: 'get_users' },
|
||||
{ label: 'Search Users', id: 'search_users' },
|
||||
],
|
||||
value: () => 'read',
|
||||
},
|
||||
@@ -673,6 +674,31 @@ Return ONLY the comment text - no explanations.`,
|
||||
placeholder: 'Maximum users to return (default: 50)',
|
||||
condition: { field: 'operation', value: 'get_users' },
|
||||
},
|
||||
// Search Users fields
|
||||
{
|
||||
id: 'searchUsersQuery',
|
||||
title: 'Search Query',
|
||||
type: 'short-input',
|
||||
required: true,
|
||||
placeholder: 'Enter email address or display name to search',
|
||||
condition: { field: 'operation', value: 'search_users' },
|
||||
},
|
||||
{
|
||||
id: 'searchUsersMaxResults',
|
||||
title: 'Max Results',
|
||||
type: 'short-input',
|
||||
placeholder: 'Maximum users to return (default: 50)',
|
||||
condition: { field: 'operation', value: 'search_users' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'searchUsersStartAt',
|
||||
title: 'Start At',
|
||||
type: 'short-input',
|
||||
placeholder: 'Pagination start index (default: 0)',
|
||||
condition: { field: 'operation', value: 'search_users' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Trigger SubBlocks
|
||||
...getTrigger('jira_issue_created').subBlocks,
|
||||
...getTrigger('jira_issue_updated').subBlocks,
|
||||
@@ -707,6 +733,7 @@ Return ONLY the comment text - no explanations.`,
|
||||
'jira_add_watcher',
|
||||
'jira_remove_watcher',
|
||||
'jira_get_users',
|
||||
'jira_search_users',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
@@ -767,6 +794,8 @@ Return ONLY the comment text - no explanations.`,
|
||||
return 'jira_remove_watcher'
|
||||
case 'get_users':
|
||||
return 'jira_get_users'
|
||||
case 'search_users':
|
||||
return 'jira_search_users'
|
||||
default:
|
||||
return 'jira_retrieve'
|
||||
}
|
||||
@@ -1023,6 +1052,18 @@ Return ONLY the comment text - no explanations.`,
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
case 'search_users': {
|
||||
return {
|
||||
...baseParams,
|
||||
query: params.searchUsersQuery,
|
||||
maxResults: params.searchUsersMaxResults
|
||||
? Number.parseInt(params.searchUsersMaxResults)
|
||||
: undefined,
|
||||
startAt: params.searchUsersStartAt
|
||||
? Number.parseInt(params.searchUsersStartAt)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return baseParams
|
||||
}
|
||||
@@ -1102,6 +1143,13 @@ Return ONLY the comment text - no explanations.`,
|
||||
},
|
||||
usersStartAt: { type: 'string', description: 'Pagination start index for users' },
|
||||
usersMaxResults: { type: 'string', description: 'Maximum users to return' },
|
||||
// Search Users operation inputs
|
||||
searchUsersQuery: {
|
||||
type: 'string',
|
||||
description: 'Search query (email address or display name)',
|
||||
},
|
||||
searchUsersMaxResults: { type: 'string', description: 'Maximum users to return from search' },
|
||||
searchUsersStartAt: { type: 'string', description: 'Pagination start index for user search' },
|
||||
},
|
||||
outputs: {
|
||||
// Common outputs across all Jira operations
|
||||
|
||||
270
apps/sim/blocks/blocks/obsidian.ts
Normal file
270
apps/sim/blocks/blocks/obsidian.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { ObsidianIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
|
||||
export const ObsidianBlock: BlockConfig = {
|
||||
type: 'obsidian',
|
||||
name: 'Obsidian',
|
||||
description: 'Interact with your Obsidian vault via the Local REST API',
|
||||
longDescription:
|
||||
'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.',
|
||||
docsLink: 'https://docs.sim.ai/tools/obsidian',
|
||||
category: 'tools',
|
||||
bgColor: '#0F0F0F',
|
||||
icon: ObsidianIcon,
|
||||
authMode: AuthMode.ApiKey,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'List Files', id: 'list_files' },
|
||||
{ label: 'Get Note', id: 'get_note' },
|
||||
{ label: 'Create Note', id: 'create_note' },
|
||||
{ label: 'Append to Note', id: 'append_note' },
|
||||
{ label: 'Patch Note', id: 'patch_note' },
|
||||
{ label: 'Delete Note', id: 'delete_note' },
|
||||
{ label: 'Search', id: 'search' },
|
||||
{ label: 'Get Active File', id: 'get_active' },
|
||||
{ label: 'Append to Active File', id: 'append_active' },
|
||||
{ label: 'Patch Active File', id: 'patch_active' },
|
||||
{ label: 'Open File', id: 'open_file' },
|
||||
{ label: 'List Commands', id: 'list_commands' },
|
||||
{ label: 'Execute Command', id: 'execute_command' },
|
||||
{ label: 'Get Periodic Note', id: 'get_periodic_note' },
|
||||
{ label: 'Append to Periodic Note', id: 'append_periodic_note' },
|
||||
],
|
||||
value: () => 'get_note',
|
||||
},
|
||||
{
|
||||
id: 'baseUrl',
|
||||
title: 'Base URL',
|
||||
type: 'short-input',
|
||||
placeholder: 'https://127.0.0.1:27124',
|
||||
value: () => 'https://127.0.0.1:27124',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your Obsidian Local REST API key',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'path',
|
||||
title: 'Directory Path',
|
||||
type: 'short-input',
|
||||
placeholder: 'Leave empty for vault root (e.g. "Projects/notes")',
|
||||
condition: { field: 'operation', value: 'list_files' },
|
||||
},
|
||||
{
|
||||
id: 'filename',
|
||||
title: 'Note Path',
|
||||
type: 'short-input',
|
||||
placeholder: 'folder/note.md',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['get_note', 'create_note', 'append_note', 'patch_note', 'delete_note', 'open_file'],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: ['get_note', 'create_note', 'append_note', 'patch_note', 'delete_note', 'open_file'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
title: 'Content',
|
||||
type: 'long-input',
|
||||
placeholder: 'Markdown content',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'create_note',
|
||||
'append_note',
|
||||
'patch_note',
|
||||
'append_active',
|
||||
'patch_active',
|
||||
'append_periodic_note',
|
||||
],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'create_note',
|
||||
'append_note',
|
||||
'patch_note',
|
||||
'append_active',
|
||||
'patch_active',
|
||||
'append_periodic_note',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'patchOperation',
|
||||
title: 'Patch Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Append', id: 'append' },
|
||||
{ label: 'Prepend', id: 'prepend' },
|
||||
{ label: 'Replace', id: 'replace' },
|
||||
],
|
||||
value: () => 'append',
|
||||
condition: { field: 'operation', value: ['patch_note', 'patch_active'] },
|
||||
required: { field: 'operation', value: ['patch_note', 'patch_active'] },
|
||||
},
|
||||
{
|
||||
id: 'targetType',
|
||||
title: 'Target Type',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Heading', id: 'heading' },
|
||||
{ label: 'Block Reference', id: 'block' },
|
||||
{ label: 'Frontmatter', id: 'frontmatter' },
|
||||
],
|
||||
value: () => 'heading',
|
||||
condition: { field: 'operation', value: ['patch_note', 'patch_active'] },
|
||||
required: { field: 'operation', value: ['patch_note', 'patch_active'] },
|
||||
},
|
||||
{
|
||||
id: 'target',
|
||||
title: 'Target',
|
||||
type: 'short-input',
|
||||
placeholder: 'Heading text, block ID, or frontmatter field',
|
||||
condition: { field: 'operation', value: ['patch_note', 'patch_active'] },
|
||||
required: { field: 'operation', value: ['patch_note', 'patch_active'] },
|
||||
},
|
||||
{
|
||||
id: 'targetDelimiter',
|
||||
title: 'Target Delimiter',
|
||||
type: 'short-input',
|
||||
placeholder: ':: (default)',
|
||||
condition: { field: 'operation', value: ['patch_note', 'patch_active'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'trimTargetWhitespace',
|
||||
title: 'Trim Target Whitespace',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: ['patch_note', 'patch_active'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'query',
|
||||
title: 'Search Query',
|
||||
type: 'short-input',
|
||||
placeholder: 'Text to search for',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
required: { field: 'operation', value: 'search' },
|
||||
},
|
||||
{
|
||||
id: 'contextLength',
|
||||
title: 'Context Length',
|
||||
type: 'short-input',
|
||||
placeholder: '100',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'commandId',
|
||||
title: 'Command ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g. daily-notes:open-today',
|
||||
condition: { field: 'operation', value: 'execute_command' },
|
||||
required: { field: 'operation', value: 'execute_command' },
|
||||
},
|
||||
{
|
||||
id: 'newLeaf',
|
||||
title: 'Open in New Tab',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'open_file' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'period',
|
||||
title: 'Period',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Daily', id: 'daily' },
|
||||
{ label: 'Weekly', id: 'weekly' },
|
||||
{ label: 'Monthly', id: 'monthly' },
|
||||
{ label: 'Quarterly', id: 'quarterly' },
|
||||
{ label: 'Yearly', id: 'yearly' },
|
||||
],
|
||||
value: () => 'daily',
|
||||
condition: { field: 'operation', value: ['get_periodic_note', 'append_periodic_note'] },
|
||||
required: { field: 'operation', value: ['get_periodic_note', 'append_periodic_note'] },
|
||||
},
|
||||
],
|
||||
|
||||
tools: {
|
||||
access: [
|
||||
'obsidian_append_active',
|
||||
'obsidian_append_note',
|
||||
'obsidian_append_periodic_note',
|
||||
'obsidian_create_note',
|
||||
'obsidian_delete_note',
|
||||
'obsidian_execute_command',
|
||||
'obsidian_get_active',
|
||||
'obsidian_get_note',
|
||||
'obsidian_get_periodic_note',
|
||||
'obsidian_list_commands',
|
||||
'obsidian_list_files',
|
||||
'obsidian_open_file',
|
||||
'obsidian_patch_active',
|
||||
'obsidian_patch_note',
|
||||
'obsidian_search',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => `obsidian_${params.operation}`,
|
||||
params: (params) => {
|
||||
const result: Record<string, unknown> = {}
|
||||
if (params.contextLength) {
|
||||
result.contextLength = Number(params.contextLength)
|
||||
}
|
||||
if (params.patchOperation) {
|
||||
result.operation = params.patchOperation
|
||||
}
|
||||
return result
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
baseUrl: { type: 'string', description: 'Base URL for the Obsidian Local REST API' },
|
||||
apiKey: { type: 'string', description: 'API key for authentication' },
|
||||
filename: { type: 'string', description: 'Path to the note relative to vault root' },
|
||||
content: { type: 'string', description: 'Markdown content for the note' },
|
||||
path: { type: 'string', description: 'Directory path to list' },
|
||||
query: { type: 'string', description: 'Text to search for' },
|
||||
contextLength: { type: 'number', description: 'Characters of context around matches' },
|
||||
commandId: { type: 'string', description: 'ID of the command to execute' },
|
||||
patchOperation: { type: 'string', description: 'Patch operation: append, prepend, or replace' },
|
||||
targetType: { type: 'string', description: 'Target type: heading, block, or frontmatter' },
|
||||
target: { type: 'string', description: 'Target identifier for patch operations' },
|
||||
targetDelimiter: { type: 'string', description: 'Delimiter for nested headings' },
|
||||
trimTargetWhitespace: { type: 'boolean', description: 'Trim whitespace from target' },
|
||||
newLeaf: { type: 'boolean', description: 'Open file in new tab' },
|
||||
period: { type: 'string', description: 'Periodic note period type' },
|
||||
},
|
||||
|
||||
outputs: {
|
||||
content: { type: 'string', description: 'Markdown content of the note' },
|
||||
filename: { type: 'string', description: 'Path to the note' },
|
||||
files: { type: 'json', description: 'List of files and directories (path, type)' },
|
||||
results: { type: 'json', description: 'Search results (filename, score, matches)' },
|
||||
commands: { type: 'json', description: 'List of available commands (id, name)' },
|
||||
created: { type: 'boolean', description: 'Whether the note was created' },
|
||||
appended: { type: 'boolean', description: 'Whether content was appended' },
|
||||
patched: { type: 'boolean', description: 'Whether content was patched' },
|
||||
deleted: { type: 'boolean', description: 'Whether the note was deleted' },
|
||||
executed: { type: 'boolean', description: 'Whether the command was executed' },
|
||||
opened: { type: 'boolean', description: 'Whether the file was opened' },
|
||||
commandId: { type: 'string', description: 'ID of the executed command' },
|
||||
period: { type: 'string', description: 'Period type of the periodic note' },
|
||||
},
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
|
||||
authMode: AuthMode.ApiKey,
|
||||
longDescription:
|
||||
'Integrate Parallel AI into the workflow. Can search the web, extract information from URLs, and conduct deep research.',
|
||||
docsLink: 'https://docs.parallel.ai/',
|
||||
docsLink: 'https://docs.sim.ai/tools/parallel-ai',
|
||||
category: 'tools',
|
||||
bgColor: '#E0E0E0',
|
||||
icon: ParallelIcon,
|
||||
@@ -56,7 +56,7 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
|
||||
title: 'Extract Objective',
|
||||
type: 'long-input',
|
||||
placeholder: 'What information to extract from the URLs?',
|
||||
required: true,
|
||||
required: false,
|
||||
condition: { field: 'operation', value: 'extract' },
|
||||
},
|
||||
{
|
||||
@@ -89,6 +89,37 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
|
||||
required: true,
|
||||
condition: { field: 'operation', value: 'deep_research' },
|
||||
},
|
||||
{
|
||||
id: 'search_mode',
|
||||
title: 'Search Mode',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'One-Shot', id: 'one-shot' },
|
||||
{ label: 'Agentic', id: 'agentic' },
|
||||
{ label: 'Fast', id: 'fast' },
|
||||
],
|
||||
value: () => 'one-shot',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'search_include_domains',
|
||||
title: 'Include Domains',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated domains to include (e.g., .edu, example.com)',
|
||||
required: false,
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'search_exclude_domains',
|
||||
title: 'Exclude Domains',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated domains to exclude',
|
||||
required: false,
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'include_domains',
|
||||
title: 'Include Domains',
|
||||
@@ -96,6 +127,7 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
|
||||
placeholder: 'Comma-separated domains to include',
|
||||
required: false,
|
||||
condition: { field: 'operation', value: 'deep_research' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'exclude_domains',
|
||||
@@ -104,37 +136,37 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
|
||||
placeholder: 'Comma-separated domains to exclude',
|
||||
required: false,
|
||||
condition: { field: 'operation', value: 'deep_research' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'processor',
|
||||
title: 'Processor',
|
||||
title: 'Research Processor',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Lite', id: 'lite' },
|
||||
{ label: 'Base', id: 'base' },
|
||||
{ label: 'Core', id: 'core' },
|
||||
{ label: 'Core 2x', id: 'core2x' },
|
||||
{ label: 'Pro', id: 'pro' },
|
||||
{ label: 'Ultra', id: 'ultra' },
|
||||
{ label: 'Ultra 2x', id: 'ultra2x' },
|
||||
{ label: 'Ultra 4x', id: 'ultra4x' },
|
||||
{ label: 'Pro Fast', id: 'pro-fast' },
|
||||
{ label: 'Ultra Fast', id: 'ultra-fast' },
|
||||
],
|
||||
value: () => 'base',
|
||||
condition: { field: 'operation', value: ['search', 'deep_research'] },
|
||||
value: () => 'pro',
|
||||
condition: { field: 'operation', value: 'deep_research' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'max_results',
|
||||
title: 'Max Results',
|
||||
type: 'short-input',
|
||||
placeholder: '5',
|
||||
placeholder: '10',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'max_chars_per_result',
|
||||
title: 'Max Chars',
|
||||
title: 'Max Chars Per Result',
|
||||
type: 'short-input',
|
||||
placeholder: '1500',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
@@ -149,8 +181,6 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
|
||||
access: ['parallel_search', 'parallel_extract', 'parallel_deep_research'],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
if (params.extract_objective) params.objective = params.extract_objective
|
||||
if (params.research_input) params.input = params.research_input
|
||||
switch (params.operation) {
|
||||
case 'search':
|
||||
return 'parallel_search'
|
||||
@@ -174,21 +204,30 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
|
||||
.filter((query: string) => query.length > 0)
|
||||
if (queries.length > 0) {
|
||||
result.search_queries = queries
|
||||
} else {
|
||||
result.search_queries = undefined
|
||||
}
|
||||
}
|
||||
if (params.search_mode && params.search_mode !== 'one-shot') {
|
||||
result.mode = params.search_mode
|
||||
}
|
||||
if (params.max_results) result.max_results = Number(params.max_results)
|
||||
if (params.max_chars_per_result) {
|
||||
result.max_chars_per_result = Number(params.max_chars_per_result)
|
||||
}
|
||||
result.include_domains = params.search_include_domains || undefined
|
||||
result.exclude_domains = params.search_exclude_domains || undefined
|
||||
}
|
||||
|
||||
if (operation === 'extract') {
|
||||
if (params.extract_objective) result.objective = params.extract_objective
|
||||
result.excerpts = !(params.excerpts === 'false' || params.excerpts === false)
|
||||
result.full_content = params.full_content === 'true' || params.full_content === true
|
||||
}
|
||||
|
||||
if (operation === 'deep_research') {
|
||||
if (params.research_input) result.input = params.research_input
|
||||
if (params.processor) result.processor = params.processor
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
},
|
||||
@@ -202,29 +241,34 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
|
||||
excerpts: { type: 'boolean', description: 'Include excerpts' },
|
||||
full_content: { type: 'boolean', description: 'Include full content' },
|
||||
research_input: { type: 'string', description: 'Deep research query' },
|
||||
include_domains: { type: 'string', description: 'Domains to include' },
|
||||
exclude_domains: { type: 'string', description: 'Domains to exclude' },
|
||||
processor: { type: 'string', description: 'Processing method' },
|
||||
include_domains: { type: 'string', description: 'Domains to include (deep research)' },
|
||||
exclude_domains: { type: 'string', description: 'Domains to exclude (deep research)' },
|
||||
search_include_domains: { type: 'string', description: 'Domains to include (search)' },
|
||||
search_exclude_domains: { type: 'string', description: 'Domains to exclude (search)' },
|
||||
search_mode: { type: 'string', description: 'Search mode (one-shot, agentic, fast)' },
|
||||
processor: { type: 'string', description: 'Research processing tier' },
|
||||
max_results: { type: 'number', description: 'Maximum number of results' },
|
||||
max_chars_per_result: { type: 'number', description: 'Maximum characters per result' },
|
||||
apiKey: { type: 'string', description: 'Parallel AI API key' },
|
||||
},
|
||||
outputs: {
|
||||
results: { type: 'string', description: 'Search or extract results (JSON stringified)' },
|
||||
results: {
|
||||
type: 'json',
|
||||
description: 'Search or extract results (array of url, title, excerpts)',
|
||||
},
|
||||
search_id: { type: 'string', description: 'Search request ID (for search)' },
|
||||
extract_id: { type: 'string', description: 'Extract request ID (for extract)' },
|
||||
status: { type: 'string', description: 'Task status (for deep research)' },
|
||||
run_id: { type: 'string', description: 'Task run ID (for deep research)' },
|
||||
message: { type: 'string', description: 'Status message (for deep research)' },
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'Research content (for deep research, JSON stringified)',
|
||||
type: 'json',
|
||||
description: 'Research content (for deep research, structured based on output_schema)',
|
||||
},
|
||||
basis: {
|
||||
type: 'string',
|
||||
description: 'Citations and sources (for deep research, JSON stringified)',
|
||||
},
|
||||
metadata: {
|
||||
type: 'string',
|
||||
description: 'Task metadata (for deep research, JSON stringified)',
|
||||
type: 'json',
|
||||
description:
|
||||
'Citations and sources with field, reasoning, citations, confidence (for deep research)',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch'
|
||||
import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs'
|
||||
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 { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file'
|
||||
import { FirecrawlBlock } from '@/blocks/blocks/firecrawl'
|
||||
@@ -113,6 +114,7 @@ import { MySQLBlock } from '@/blocks/blocks/mysql'
|
||||
import { Neo4jBlock } from '@/blocks/blocks/neo4j'
|
||||
import { NoteBlock } from '@/blocks/blocks/note'
|
||||
import { NotionBlock, NotionV2Block } from '@/blocks/blocks/notion'
|
||||
import { ObsidianBlock } from '@/blocks/blocks/obsidian'
|
||||
import { OneDriveBlock } from '@/blocks/blocks/onedrive'
|
||||
import { OnePasswordBlock } from '@/blocks/blocks/onepassword'
|
||||
import { OpenAIBlock } from '@/blocks/blocks/openai'
|
||||
@@ -234,6 +236,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
elasticsearch: ElasticsearchBlock,
|
||||
elevenlabs: ElevenLabsBlock,
|
||||
enrich: EnrichBlock,
|
||||
evernote: EvernoteBlock,
|
||||
evaluator: EvaluatorBlock,
|
||||
exa: ExaBlock,
|
||||
file: FileBlock,
|
||||
@@ -320,6 +323,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
note: NoteBlock,
|
||||
notion: NotionBlock,
|
||||
notion_v2: NotionV2Block,
|
||||
obsidian: ObsidianBlock,
|
||||
onepassword: OnePasswordBlock,
|
||||
onedrive: OneDriveBlock,
|
||||
openai: OpenAIBlock,
|
||||
|
||||
@@ -710,6 +710,155 @@ export function PerplexityIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ObsidianIcon(props: SVGProps<SVGSVGElement>) {
|
||||
const id = useId()
|
||||
const bl = `${id}-bl`
|
||||
const tr = `${id}-tr`
|
||||
const tl = `${id}-tl`
|
||||
const br = `${id}-br`
|
||||
const te = `${id}-te`
|
||||
const le = `${id}-le`
|
||||
const be = `${id}-be`
|
||||
const me = `${id}-me`
|
||||
const clip = `${id}-clip`
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 512 512' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<radialGradient
|
||||
id={bl}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(-59 -225 150 -39 161.4 470)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.4' />
|
||||
<stop offset='1' stopOpacity='.1' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={tr}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(50 -379 280 37 360 374.2)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.6' />
|
||||
<stop offset='1' stopColor='#fff' stopOpacity='.1' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={tl}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(69 -319 218 47 175.4 307)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.8' />
|
||||
<stop offset='1' stopColor='#fff' stopOpacity='.4' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={br}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(-96 -163 187 -111 335.3 512.2)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.3' />
|
||||
<stop offset='1' stopOpacity='.3' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={te}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(-36 166 -112 -24 310 128.2)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='0' />
|
||||
<stop offset='1' stopColor='#fff' stopOpacity='.2' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={le}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(88 89 -190 187 111 220.2)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.2' />
|
||||
<stop offset='1' stopColor='#fff' stopOpacity='.4' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={be}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(9 130 -276 20 215 284)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.2' />
|
||||
<stop offset='1' stopColor='#fff' stopOpacity='.3' />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id={me}
|
||||
cx='0'
|
||||
cy='0'
|
||||
gradientTransform='matrix(-198 -104 327 -623 400 399.2)'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
r='1'
|
||||
>
|
||||
<stop offset='0' stopColor='#fff' stopOpacity='.2' />
|
||||
<stop offset='.5' stopColor='#fff' stopOpacity='.2' />
|
||||
<stop offset='1' stopColor='#fff' stopOpacity='.3' />
|
||||
</radialGradient>
|
||||
<clipPath id={clip}>
|
||||
<path d='M.2.2h512v512H.2z' />
|
||||
</clipPath>
|
||||
<g clipPath={`url(#${clip})`}>
|
||||
<path
|
||||
d='M382.3 475.6c-3.1 23.4-26 41.6-48.7 35.3-32.4-8.9-69.9-22.8-103.6-25.4l-51.7-4a34 34 0 0 1-22-10.2l-89-91.7a34 34 0 0 1-6.7-37.7s55-121 57.1-127.3c2-6.3 9.6-61.2 14-90.6 1.2-7.9 5-15 11-20.3L248 8.9a34.1 34.1 0 0 1 49.6 4.3L386 125.6a37 37 0 0 1 7.6 22.4c0 21.3 1.8 65 13.6 93.2 11.5 27.3 32.5 57 43.5 71.5a17.3 17.3 0 0 1 1.3 19.2 1494 1494 0 0 1-44.8 70.6c-15 22.3-21.9 49.9-25 73.1z'
|
||||
fill='#6c31e3'
|
||||
/>
|
||||
<path
|
||||
d='M165.9 478.3c41.4-84 40.2-144.2 22.6-187-16.2-39.6-46.3-64.5-70-80-.6 2.3-1.3 4.4-2.2 6.5L60.6 342a34 34 0 0 0 6.6 37.7l89.1 91.7a34 34 0 0 0 9.6 7z'
|
||||
fill={`url(#${bl})`}
|
||||
/>
|
||||
<path
|
||||
d='M278.4 307.8c11.2 1.2 22.2 3.6 32.8 7.6 34 12.7 65 41.2 90.5 96.3 1.8-3.1 3.6-6.2 5.6-9.2a1536 1536 0 0 0 44.8-70.6 17 17 0 0 0-1.3-19.2c-11-14.6-32-44.2-43.5-71.5-11.8-28.2-13.5-72-13.6-93.2 0-8.1-2.6-16-7.6-22.4L297.6 13.2a34 34 0 0 0-1.5-1.7 96 96 0 0 1 2 54 198.3 198.3 0 0 1-17.6 41.3l-7.2 14.2a171 171 0 0 0-19.4 71c-1.2 29.4 4.8 66.4 24.5 115.8z'
|
||||
fill={`url(#${tr})`}
|
||||
/>
|
||||
<path
|
||||
d='M278.4 307.8c-19.7-49.4-25.8-86.4-24.5-115.9a171 171 0 0 1 19.4-71c2.3-4.8 4.8-9.5 7.2-14.1 7.1-13.9 14-27 17.6-41.4a96 96 0 0 0-2-54A34.1 34.1 0 0 0 248 9l-105.4 94.8a34.1 34.1 0 0 0-10.9 20.3l-12.8 85-.5 2.3c23.8 15.5 54 40.4 70.1 80a147 147 0 0 1 7.8 24.8c28-6.8 55.7-11 82.1-8.3z'
|
||||
fill={`url(#${tl})`}
|
||||
/>
|
||||
<path
|
||||
d='M333.6 511c22.7 6.2 45.6-12 48.7-35.4a187 187 0 0 1 19.4-63.9c-25.6-55-56.5-83.6-90.4-96.3-36-13.4-75.2-9-115 .7 8.9 40.4 3.6 93.3-30.4 162.2 4 1.8 8.1 3 12.5 3.3 0 0 24.4 2 53.6 4.1 29 2 72.4 17.1 101.6 25.2z'
|
||||
fill={`url(#${br})`}
|
||||
/>
|
||||
<g clipRule='evenodd' fillRule='evenodd'>
|
||||
<path
|
||||
d='M254.1 190c-1.3 29.2 2.4 62.8 22.1 112.1l-6.2-.5c-17.7-51.5-21.5-78-20.2-107.6a174.7 174.7 0 0 1 20.4-72c2.4-4.9 8-14.1 10.5-18.8 7.1-13.7 11.9-21 16-33.6 5.7-17.5 4.5-25.9 3.8-34.1 4.6 29.9-12.7 56-25.7 82.4a177.1 177.1 0 0 0-20.7 72z'
|
||||
fill={`url(#${te})`}
|
||||
/>
|
||||
<path
|
||||
d='M194.3 293.4c2.4 5.4 4.6 9.8 6 16.5L195 311c-2.1-7.8-3.8-13.4-6.8-20-17.8-42-46.3-63.6-69.7-79.5 28.2 15.2 57.2 39 75.7 81.9z'
|
||||
fill={`url(#${le})`}
|
||||
/>
|
||||
<path
|
||||
d='M200.6 315.1c9.8 46-1.2 104.2-33.6 160.9 27.1-56.2 40.2-110.1 29.3-160z'
|
||||
fill={`url(#${be})`}
|
||||
/>
|
||||
<path
|
||||
d='M312.5 311c53.1 19.9 73.6 63.6 88.9 100-19-38.1-45.2-80.3-90.8-96-34.8-11.8-64.1-10.4-114.3 1l-1.1-5c53.2-12.1 81-13.5 117.3 0z'
|
||||
fill={`url(#${me})`}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotionIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 50 50' width='1em' height='1em' {...props}>
|
||||
@@ -1806,6 +1955,14 @@ export function Mem0Icon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function EvernoteIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='#7fce2c'>
|
||||
<path d='M29.343 16.818c.1 1.695-.08 3.368-.305 5.045-.225 1.712-.508 3.416-.964 5.084-.3 1.067-.673 2.1-1.202 3.074-.65 1.192-1.635 1.87-2.992 1.924l-3.832.036c-.636-.017-1.278-.146-1.9-.297-1.192-.3-1.862-1.1-2.06-2.3-.186-1.08-.173-2.187.04-3.264.252-1.23 1-1.96 2.234-2.103.817-.1 1.65-.077 2.476-.1.205-.007.275.098.203.287-.196.53-.236 1.07-.098 1.623.053.207-.023.307-.26.305a7.77 7.77 0 0 0-1.123.053c-.636.086-.96.47-.96 1.112 0 .205.026.416.066.622.103.507.45.78.944.837 1.123.127 2.247.138 3.37-.05.675-.114 1.08-.54 1.16-1.208.152-1.3.155-2.587-.228-3.845-.33-1.092-1.006-1.565-2.134-1.7l-3.36-.54c-1.06-.193-1.7-.887-1.92-1.9-.13-.572-.14-1.17-.214-1.757-.013-.106-.074-.208-.1-.3-.04.1-.106.212-.117.326-.066.68-.053 1.373-.185 2.04-.16.8-.404 1.566-.67 2.33-.185.535-.616.837-1.205.8a37.76 37.76 0 0 1-7.123-1.353l-.64-.207c-.927-.26-1.487-.903-1.74-1.787l-1-3.853-.74-4.3c-.115-.755-.2-1.523-.083-2.293.154-1.112.914-1.903 2.04-1.964l3.558-.062c.127 0 .254.003.373-.026a1.23 1.23 0 0 0 1.01-1.255l-.05-3.036c-.048-1.576.8-2.38 2.156-2.622a10.58 10.58 0 0 1 4.91.26c.933.275 1.467.923 1.715 1.83.058.22.146.3.37.287l2.582.01 3.333.37c.686.095 1.364.25 2.032.42 1.165.298 1.793 1.112 1.962 2.256l.357 3.355.3 5.577.01 2.277zm-4.534-1.155c-.02-.666-.07-1.267-.444-1.784a1.66 1.66 0 0 0-2.469-.15c-.364.4-.494.88-.564 1.4-.008.034.106.126.16.126l.8-.053c.768.007 1.523.113 2.25.393.066.026.136.04.265.077zM8.787 1.154a3.82 3.82 0 0 0-.278 1.592l.05 2.934c.005.357-.075.45-.433.45L5.1 6.156c-.583 0-1.143.1-1.554.278l5.2-5.332c.02.013.04.033.06.053z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -22,7 +22,7 @@ export class TriggerBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
const existingState = ctx.blockStates.get(block.id)
|
||||
if (existingState?.output && Object.keys(existingState.output).length > 0) {
|
||||
if (existingState?.output) {
|
||||
return existingState.output
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ClientFactoryOptions,
|
||||
} from '@a2a-js/sdk/client'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
|
||||
import { A2A_TERMINAL_STATES } from './constants'
|
||||
|
||||
@@ -43,6 +44,11 @@ class ApiKeyInterceptor implements CallInterceptor {
|
||||
* Tries standard path first, falls back to root URL for compatibility.
|
||||
*/
|
||||
export async function createA2AClient(agentUrl: string, apiKey?: string): Promise<Client> {
|
||||
const validation = await validateUrlWithDNS(agentUrl, 'agentUrl')
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.error || 'Agent URL validation failed')
|
||||
}
|
||||
|
||||
const factoryOptions = apiKey
|
||||
? ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
|
||||
clientConfig: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isLegacyApiKeyFormat,
|
||||
} from '@/lib/api-key/crypto'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
|
||||
const logger = createLogger('ApiKeyAuth')
|
||||
|
||||
@@ -39,7 +40,7 @@ export async function authenticateApiKey(inputKey: string, storedKey: string): P
|
||||
if (isEncryptedKey(storedKey)) {
|
||||
try {
|
||||
const { decrypted } = await decryptApiKey(storedKey)
|
||||
return inputKey === decrypted
|
||||
return safeCompare(inputKey, decrypted)
|
||||
} catch (decryptError) {
|
||||
logger.error('Failed to decrypt stored API key:', { error: decryptError })
|
||||
return false
|
||||
@@ -54,27 +55,27 @@ export async function authenticateApiKey(inputKey: string, storedKey: string): P
|
||||
if (isEncryptedKey(storedKey)) {
|
||||
try {
|
||||
const { decrypted } = await decryptApiKey(storedKey)
|
||||
return inputKey === decrypted
|
||||
return safeCompare(inputKey, decrypted)
|
||||
} catch (decryptError) {
|
||||
logger.error('Failed to decrypt stored API key:', { error: decryptError })
|
||||
// Fall through to plain text comparison if decryption fails
|
||||
}
|
||||
}
|
||||
// Legacy format can match against plain text storage
|
||||
return inputKey === storedKey
|
||||
return safeCompare(inputKey, storedKey)
|
||||
}
|
||||
|
||||
// If no recognized prefix, fall back to original behavior
|
||||
if (isEncryptedKey(storedKey)) {
|
||||
try {
|
||||
const { decrypted } = await decryptApiKey(storedKey)
|
||||
return inputKey === decrypted
|
||||
return safeCompare(inputKey, decrypted)
|
||||
} catch (decryptError) {
|
||||
logger.error('Failed to decrypt stored API key:', { error: decryptError })
|
||||
}
|
||||
}
|
||||
|
||||
return inputKey === storedKey
|
||||
return safeCompare(inputKey, storedKey)
|
||||
} catch (error) {
|
||||
logger.error('API key authentication error:', { error })
|
||||
return false
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { jwtVerify, SignJWT } from 'jose'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
|
||||
const logger = createLogger('CronAuth')
|
||||
|
||||
@@ -81,7 +82,8 @@ export function verifyCronAuth(request: NextRequest, context?: string): NextResp
|
||||
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const expectedAuth = `Bearer ${env.CRON_SECRET}`
|
||||
if (authHeader !== expectedAuth) {
|
||||
const isValid = authHeader !== null && safeCompare(authHeader, expectedAuth)
|
||||
if (!isValid) {
|
||||
const contextInfo = context ? ` for ${context}` : ''
|
||||
logger.warn(`Unauthorized CRON access attempt${contextInfo}`, {
|
||||
providedAuth: authHeader,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
|
||||
export function checkInternalApiKey(req: NextRequest) {
|
||||
const apiKey = req.headers.get('x-api-key')
|
||||
@@ -13,7 +14,7 @@ export function checkInternalApiKey(req: NextRequest) {
|
||||
return { success: false, error: 'API key required' }
|
||||
}
|
||||
|
||||
if (apiKey !== expectedApiKey) {
|
||||
if (!safeCompare(apiKey, expectedApiKey)) {
|
||||
return { success: false, error: 'Invalid API key' }
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const logger = createLogger('AsyncJobsConfig')
|
||||
|
||||
let cachedBackend: JobQueueBackend | null = null
|
||||
let cachedBackendType: AsyncBackendType | null = null
|
||||
let cachedInlineBackend: JobQueueBackend | null = null
|
||||
|
||||
/**
|
||||
* Determines which async backend to use based on environment configuration.
|
||||
@@ -71,6 +72,31 @@ export function getCurrentBackendType(): AsyncBackendType | null {
|
||||
return cachedBackendType
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a job queue backend that bypasses Trigger.dev (Redis -> Database).
|
||||
* Used for non-polling webhooks that should always execute inline.
|
||||
*/
|
||||
export async function getInlineJobQueue(): Promise<JobQueueBackend> {
|
||||
if (cachedInlineBackend) {
|
||||
return cachedInlineBackend
|
||||
}
|
||||
|
||||
const redis = getRedisClient()
|
||||
let type: string
|
||||
if (redis) {
|
||||
const { RedisJobQueue } = await import('@/lib/core/async-jobs/backends/redis')
|
||||
cachedInlineBackend = new RedisJobQueue(redis)
|
||||
type = 'redis'
|
||||
} else {
|
||||
const { DatabaseJobQueue } = await import('@/lib/core/async-jobs/backends/database')
|
||||
cachedInlineBackend = new DatabaseJobQueue()
|
||||
type = 'database'
|
||||
}
|
||||
|
||||
logger.info(`Inline job backend initialized: ${type}`)
|
||||
return cachedInlineBackend
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if jobs should be executed inline (fire-and-forget).
|
||||
* For Redis/DB backends, we execute inline. Trigger.dev handles execution itself.
|
||||
@@ -85,4 +111,5 @@ export function shouldExecuteInline(): boolean {
|
||||
export function resetJobQueueCache(): void {
|
||||
cachedBackend = null
|
||||
cachedBackendType = null
|
||||
cachedInlineBackend = null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
getAsyncBackendType,
|
||||
getCurrentBackendType,
|
||||
getInlineJobQueue,
|
||||
getJobQueue,
|
||||
resetJobQueueCache,
|
||||
shouldExecuteInline,
|
||||
|
||||
@@ -413,6 +413,7 @@ export class IdempotencyService {
|
||||
: undefined
|
||||
|
||||
const webhookIdHeader =
|
||||
normalizedHeaders?.['x-sim-idempotency-key'] ||
|
||||
normalizedHeaders?.['webhook-id'] ||
|
||||
normalizedHeaders?.['x-webhook-id'] ||
|
||||
normalizedHeaders?.['x-shopify-webhook-id'] ||
|
||||
|
||||
@@ -81,7 +81,9 @@ export function setDeploymentAuthCookie(
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds CORS headers to allow cross-origin requests for embedded deployments
|
||||
* Adds CORS headers to allow cross-origin requests for embedded deployments.
|
||||
* Embedded chat widgets and forms are designed to run on any customer domain,
|
||||
* so we reflect the requesting origin rather than restricting to an allowlist.
|
||||
*/
|
||||
export function addCorsHeaders(response: NextResponse, request: NextRequest): NextResponse {
|
||||
const origin = request.headers.get('origin') || ''
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'crypto'
|
||||
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from 'crypto'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
|
||||
@@ -91,8 +91,8 @@ export function generatePassword(length = 24): string {
|
||||
* @returns True if strings are equal, false otherwise
|
||||
*/
|
||||
export function safeCompare(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
|
||||
const key = 'safeCompare'
|
||||
const ha = createHmac('sha256', key).update(a).digest()
|
||||
const hb = createHmac('sha256', key).update(b).digest()
|
||||
return timingSafeEqual(ha, hb)
|
||||
}
|
||||
|
||||
@@ -54,9 +54,10 @@ function isPrivateOrReservedIP(ip: string): boolean {
|
||||
*/
|
||||
export async function validateUrlWithDNS(
|
||||
url: string | null | undefined,
|
||||
paramName = 'url'
|
||||
paramName = 'url',
|
||||
options: { allowHttp?: boolean } = {}
|
||||
): Promise<AsyncValidationResult> {
|
||||
const basicValidation = validateExternalUrl(url, paramName)
|
||||
const basicValidation = validateExternalUrl(url, paramName, options)
|
||||
if (!basicValidation.isValid) {
|
||||
return basicValidation
|
||||
}
|
||||
@@ -88,7 +89,10 @@ export async function validateUrlWithDNS(
|
||||
return ip === '127.0.0.1' || ip === '::1'
|
||||
})()
|
||||
|
||||
if (isPrivateOrReservedIP(address) && !(isLocalhost && resolvedIsLoopback)) {
|
||||
if (
|
||||
isPrivateOrReservedIP(address) &&
|
||||
!(isLocalhost && resolvedIsLoopback && !options.allowHttp)
|
||||
) {
|
||||
logger.warn('URL resolves to blocked IP address', {
|
||||
paramName,
|
||||
hostname,
|
||||
@@ -118,6 +122,70 @@ export async function validateUrlWithDNS(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a database hostname by resolving DNS and checking the resolved IP
|
||||
* against private/reserved ranges to prevent SSRF via database connections.
|
||||
*
|
||||
* Unlike validateHostname (which enforces strict RFC hostname format), this
|
||||
* function is permissive about hostname format to avoid breaking legitimate
|
||||
* database hostnames (e.g. underscores in Docker/K8s service names). It only
|
||||
* blocks localhost and private/reserved IPs.
|
||||
*
|
||||
* @param host - The database hostname to validate
|
||||
* @param paramName - Name of the parameter for error messages
|
||||
* @returns AsyncValidationResult with resolved IP
|
||||
*/
|
||||
export async function validateDatabaseHost(
|
||||
host: string | null | undefined,
|
||||
paramName = 'host'
|
||||
): Promise<AsyncValidationResult> {
|
||||
if (!host) {
|
||||
return { isValid: false, error: `${paramName} is required` }
|
||||
}
|
||||
|
||||
const lowerHost = host.toLowerCase()
|
||||
|
||||
if (lowerHost === 'localhost') {
|
||||
return { isValid: false, error: `${paramName} cannot be localhost` }
|
||||
}
|
||||
|
||||
if (ipaddr.isValid(lowerHost) && isPrivateOrReservedIP(lowerHost)) {
|
||||
return { isValid: false, error: `${paramName} cannot be a private IP address` }
|
||||
}
|
||||
|
||||
try {
|
||||
const { address } = await dns.lookup(host, { verbatim: true })
|
||||
|
||||
if (isPrivateOrReservedIP(address)) {
|
||||
logger.warn('Database host resolves to blocked IP address', {
|
||||
paramName,
|
||||
hostname: host,
|
||||
resolvedIP: address,
|
||||
})
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} resolves to a blocked IP address`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
resolvedIP: address,
|
||||
originalHostname: host,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('DNS lookup failed for database host', {
|
||||
paramName,
|
||||
hostname: host,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} hostname could not be resolved`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SecureFetchOptions {
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
@@ -183,7 +251,7 @@ function resolveRedirectUrl(baseUrl: string, location: string): string {
|
||||
export async function secureFetchWithPinnedIP(
|
||||
url: string,
|
||||
resolvedIP: string,
|
||||
options: SecureFetchOptions = {},
|
||||
options: SecureFetchOptions & { allowHttp?: boolean } = {},
|
||||
redirectCount = 0
|
||||
): Promise<SecureFetchResponse> {
|
||||
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
|
||||
@@ -231,7 +299,7 @@ export async function secureFetchWithPinnedIP(
|
||||
res.resume()
|
||||
const redirectUrl = resolveRedirectUrl(url, location)
|
||||
|
||||
validateUrlWithDNS(redirectUrl, 'redirectUrl')
|
||||
validateUrlWithDNS(redirectUrl, 'redirectUrl', { allowHttp: options.allowHttp })
|
||||
.then((validation) => {
|
||||
if (!validation.isValid) {
|
||||
reject(new Error(`Redirect blocked: ${validation.error}`))
|
||||
@@ -340,10 +408,12 @@ export async function secureFetchWithPinnedIP(
|
||||
*/
|
||||
export async function secureFetchWithValidation(
|
||||
url: string,
|
||||
options: SecureFetchOptions = {},
|
||||
options: SecureFetchOptions & { allowHttp?: boolean } = {},
|
||||
paramName = 'url'
|
||||
): Promise<SecureFetchResponse> {
|
||||
const validation = await validateUrlWithDNS(url, paramName)
|
||||
const validation = await validateUrlWithDNS(url, paramName, {
|
||||
allowHttp: options.allowHttp,
|
||||
})
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.error)
|
||||
}
|
||||
|
||||
@@ -676,7 +676,8 @@ export function validateJiraIssueKey(
|
||||
*/
|
||||
export function validateExternalUrl(
|
||||
url: string | null | undefined,
|
||||
paramName = 'url'
|
||||
paramName = 'url',
|
||||
options: { allowHttp?: boolean } = {}
|
||||
): ValidationResult {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return {
|
||||
@@ -709,7 +710,20 @@ export function validateExternalUrl(
|
||||
}
|
||||
}
|
||||
|
||||
if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost)) {
|
||||
if (options.allowHttp) {
|
||||
if (protocol !== 'https:' && protocol !== 'http:') {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} must use http:// or https:// protocol`,
|
||||
}
|
||||
}
|
||||
if (isLocalhost) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} cannot point to localhost`,
|
||||
}
|
||||
}
|
||||
} else if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `${paramName} must use https:// protocol`,
|
||||
|
||||
@@ -246,7 +246,7 @@ describe('categorizeError', () => {
|
||||
const error = new Error('Server not accessible')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(404)
|
||||
expect(result.message).toBe('Server not accessible')
|
||||
expect(result.message).toBe('Resource not found')
|
||||
})
|
||||
|
||||
it.concurrent('returns 401 for authentication errors', () => {
|
||||
@@ -267,28 +267,28 @@ describe('categorizeError', () => {
|
||||
const error = new Error('Invalid parameter provided')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(400)
|
||||
expect(result.message).toBe('Invalid parameter provided')
|
||||
expect(result.message).toBe('Invalid request parameters')
|
||||
})
|
||||
|
||||
it.concurrent('returns 400 for missing required errors', () => {
|
||||
const error = new Error('Missing required field: name')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(400)
|
||||
expect(result.message).toBe('Missing required field: name')
|
||||
expect(result.message).toBe('Invalid request parameters')
|
||||
})
|
||||
|
||||
it.concurrent('returns 400 for validation errors', () => {
|
||||
const error = new Error('Validation failed for input')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(400)
|
||||
expect(result.message).toBe('Validation failed for input')
|
||||
expect(result.message).toBe('Invalid request parameters')
|
||||
})
|
||||
|
||||
it.concurrent('returns 500 for generic errors', () => {
|
||||
const error = new Error('Something went wrong')
|
||||
const result = categorizeError(error)
|
||||
expect(result.status).toBe(500)
|
||||
expect(result.message).toBe('Something went wrong')
|
||||
expect(result.message).toBe('Internal server error')
|
||||
})
|
||||
|
||||
it.concurrent('returns 500 for non-Error objects', () => {
|
||||
|
||||
@@ -49,18 +49,18 @@ export const MCP_CLIENT_CONSTANTS = {
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Create standardized MCP error response
|
||||
* Create standardized MCP error response.
|
||||
* Always returns the defaultMessage to clients to prevent leaking internal error details.
|
||||
* Callers are responsible for logging the original error before calling this function.
|
||||
*/
|
||||
export function createMcpErrorResponse(
|
||||
error: unknown,
|
||||
_error: unknown,
|
||||
defaultMessage: string,
|
||||
status = 500
|
||||
): NextResponse {
|
||||
const errorMessage = error instanceof Error ? error.message : defaultMessage
|
||||
|
||||
const response: McpApiResponse = {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
error: defaultMessage,
|
||||
}
|
||||
|
||||
return NextResponse.json(response, { status })
|
||||
@@ -115,36 +115,33 @@ export function validateRequiredFields(
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced error categorization for more specific HTTP status codes
|
||||
* Enhanced error categorization for more specific HTTP status codes.
|
||||
* Returns safe, generic messages to prevent leaking internal details.
|
||||
*/
|
||||
export function categorizeError(error: unknown): { message: string; status: number } {
|
||||
if (!(error instanceof Error)) {
|
||||
return { message: 'Unknown error occurred', status: 500 }
|
||||
}
|
||||
|
||||
const message = error.message.toLowerCase()
|
||||
const msg = error.message.toLowerCase()
|
||||
|
||||
if (message.includes('timeout')) {
|
||||
if (msg.includes('timeout')) {
|
||||
return { message: 'Request timed out', status: 408 }
|
||||
}
|
||||
|
||||
if (message.includes('not found') || message.includes('not accessible')) {
|
||||
return { message: error.message, status: 404 }
|
||||
if (msg.includes('not found') || msg.includes('not accessible')) {
|
||||
return { message: 'Resource not found', status: 404 }
|
||||
}
|
||||
|
||||
if (message.includes('authentication') || message.includes('unauthorized')) {
|
||||
if (msg.includes('authentication') || msg.includes('unauthorized')) {
|
||||
return { message: 'Authentication required', status: 401 }
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes('invalid') ||
|
||||
message.includes('missing required') ||
|
||||
message.includes('validation')
|
||||
) {
|
||||
return { message: error.message, status: 400 }
|
||||
if (msg.includes('invalid') || msg.includes('missing required') || msg.includes('validation')) {
|
||||
return { message: 'Invalid request parameters', status: 400 }
|
||||
}
|
||||
|
||||
return { message: error.message, status: 500 }
|
||||
return { message: 'Internal server error', status: 500 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
/**
|
||||
* Periodic memory telemetry for diagnosing heap growth in production.
|
||||
* Logs process.memoryUsage(), V8 heap stats, and active SSE connection
|
||||
* counts every 60s, enabling correlation between connection leaks and
|
||||
* memory spikes.
|
||||
* Periodic memory telemetry for monitoring heap growth in production.
|
||||
* Logs process.memoryUsage() and V8 heap stats every 60s.
|
||||
*/
|
||||
|
||||
import v8 from 'node:v8'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
getActiveSSEConnectionCount,
|
||||
getActiveSSEConnectionsByRoute,
|
||||
} from '@/lib/monitoring/sse-connections'
|
||||
|
||||
const logger = createLogger('MemoryTelemetry', { logLevel: 'INFO' })
|
||||
|
||||
@@ -23,16 +17,6 @@ export function startMemoryTelemetry(intervalMs = 60_000) {
|
||||
started = true
|
||||
|
||||
const timer = setInterval(() => {
|
||||
// Trigger opportunistic (non-blocking) garbage collection if running on Bun.
|
||||
// This signals JSC GC + mimalloc page purge without blocking the event loop,
|
||||
// helping reclaim RSS that mimalloc otherwise retains under sustained load.
|
||||
const bunGlobal = (globalThis as Record<string, unknown>).Bun as
|
||||
| { gc?: (force: boolean) => void }
|
||||
| undefined
|
||||
if (typeof bunGlobal?.gc === 'function') {
|
||||
bunGlobal.gc(false)
|
||||
}
|
||||
|
||||
const mem = process.memoryUsage()
|
||||
const heap = v8.getHeapStatistics()
|
||||
|
||||
@@ -49,8 +33,6 @@ export function startMemoryTelemetry(intervalMs = 60_000) {
|
||||
? process.getActiveResourcesInfo().length
|
||||
: -1,
|
||||
uptimeMin: Math.round(process.uptime() / 60),
|
||||
activeSSEConnections: getActiveSSEConnectionCount(),
|
||||
sseByRoute: getActiveSSEConnectionsByRoute(),
|
||||
})
|
||||
}, intervalMs)
|
||||
timer.unref()
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Tracks active SSE connections by route for memory leak diagnostics.
|
||||
* Logged alongside periodic memory telemetry to correlate connection
|
||||
* counts with heap growth.
|
||||
*/
|
||||
|
||||
const connections = new Map<string, number>()
|
||||
|
||||
export function incrementSSEConnections(route: string) {
|
||||
connections.set(route, (connections.get(route) ?? 0) + 1)
|
||||
}
|
||||
|
||||
export function decrementSSEConnections(route: string) {
|
||||
const count = (connections.get(route) ?? 0) - 1
|
||||
if (count <= 0) connections.delete(route)
|
||||
else connections.set(route, count)
|
||||
}
|
||||
|
||||
export function getActiveSSEConnectionCount(): number {
|
||||
let total = 0
|
||||
for (const count of connections.values()) total += count
|
||||
return total
|
||||
}
|
||||
|
||||
export function getActiveSSEConnectionsByRoute(): Record<string, number> {
|
||||
return Object.fromEntries(connections)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { db, webhook, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { account, credentialSet, subscription } from '@sim/db/schema'
|
||||
import { credentialSet, subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { checkEnterprisePlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
|
||||
import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
|
||||
import { isProd } from '@/lib/core/config/feature-flags'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils'
|
||||
@@ -25,11 +26,10 @@ import {
|
||||
validateTypeformSignature,
|
||||
verifyProviderWebhook,
|
||||
} from '@/lib/webhooks/utils.server'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import { executeWebhookJob } from '@/background/webhook-execution'
|
||||
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
|
||||
import { isConfluencePayloadMatch } from '@/triggers/confluence/utils'
|
||||
import { isPollingWebhookProvider } from '@/triggers/constants'
|
||||
import { isGitHubEventMatch } from '@/triggers/github/utils'
|
||||
import { isHubSpotContactEventMatch } from '@/triggers/hubspot/utils'
|
||||
import { isJiraEventMatch } from '@/triggers/jira/utils'
|
||||
@@ -40,6 +40,12 @@ export interface WebhookProcessorOptions {
|
||||
requestId: string
|
||||
path?: string
|
||||
webhookId?: string
|
||||
actorUserId?: string
|
||||
}
|
||||
|
||||
export interface WebhookPreprocessingResult {
|
||||
error: NextResponse | null
|
||||
actorUserId?: string
|
||||
}
|
||||
|
||||
function getExternalUrl(request: NextRequest): string {
|
||||
@@ -800,14 +806,14 @@ export async function verifyProviderAuth(
|
||||
|
||||
if (secretHeaderName) {
|
||||
const headerValue = request.headers.get(secretHeaderName.toLowerCase())
|
||||
if (headerValue === configToken) {
|
||||
if (headerValue && safeCompare(headerValue, configToken)) {
|
||||
isTokenValid = true
|
||||
}
|
||||
} else {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (authHeader?.toLowerCase().startsWith('bearer ')) {
|
||||
const token = authHeader.substring(7)
|
||||
if (token === configToken) {
|
||||
if (safeCompare(token, configToken)) {
|
||||
isTokenValid = true
|
||||
}
|
||||
}
|
||||
@@ -835,7 +841,7 @@ export async function checkWebhookPreprocessing(
|
||||
foundWorkflow: any,
|
||||
foundWebhook: any,
|
||||
requestId: string
|
||||
): Promise<NextResponse | null> {
|
||||
): Promise<WebhookPreprocessingResult> {
|
||||
try {
|
||||
const executionId = uuidv4()
|
||||
|
||||
@@ -848,6 +854,7 @@ export async function checkWebhookPreprocessing(
|
||||
checkRateLimit: true,
|
||||
checkDeployment: true,
|
||||
workspaceId: foundWorkflow.workspaceId,
|
||||
workflowRecord: foundWorkflow,
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
@@ -859,33 +866,39 @@ export async function checkWebhookPreprocessing(
|
||||
})
|
||||
|
||||
if (foundWebhook.provider === 'microsoft-teams') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
type: 'message',
|
||||
text: error.message,
|
||||
},
|
||||
{ status: error.statusCode }
|
||||
)
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{
|
||||
type: 'message',
|
||||
text: error.message,
|
||||
},
|
||||
{ status: error.statusCode }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: error.message }, { status: error.statusCode })
|
||||
return { error: NextResponse.json({ error: error.message }, { status: error.statusCode }) }
|
||||
}
|
||||
|
||||
return null
|
||||
return { error: null, actorUserId: preprocessResult.actorUserId }
|
||||
} catch (preprocessError) {
|
||||
logger.error(`[${requestId}] Error during webhook preprocessing:`, preprocessError)
|
||||
|
||||
if (foundWebhook.provider === 'microsoft-teams') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
type: 'message',
|
||||
text: 'Internal error during preprocessing',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{
|
||||
type: 'message',
|
||||
text: 'Internal error during preprocessing',
|
||||
},
|
||||
{ status: 500 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Internal error during preprocessing' }, { status: 500 })
|
||||
return {
|
||||
error: NextResponse.json({ error: 'Internal error during preprocessing' }, { status: 500 }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1037,7 +1050,7 @@ export async function queueWebhookExecution(
|
||||
}
|
||||
}
|
||||
|
||||
const headers = Object.fromEntries(request.headers.entries())
|
||||
const { 'x-sim-idempotency-key': _, ...headers } = Object.fromEntries(request.headers.entries())
|
||||
|
||||
// For Microsoft Teams Graph notifications, extract unique identifiers for idempotency
|
||||
if (
|
||||
@@ -1055,26 +1068,22 @@ export async function queueWebhookExecution(
|
||||
}
|
||||
}
|
||||
|
||||
// Extract credentialId from webhook config
|
||||
// Note: Each webhook now has its own credentialId (credential sets are fanned out at save time)
|
||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||
const credentialId = providerConfig.credentialId as string | undefined
|
||||
let credentialAccountUserId: string | undefined
|
||||
if (credentialId) {
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
logger.error(
|
||||
`[${options.requestId}] Failed to resolve OAuth account for credential ${credentialId}`
|
||||
)
|
||||
return formatProviderErrorResponse(foundWebhook, 'Failed to resolve credential', 500)
|
||||
|
||||
if (foundWebhook.provider === 'generic') {
|
||||
const idempotencyField = providerConfig.idempotencyField as string | undefined
|
||||
if (idempotencyField && body) {
|
||||
const value = idempotencyField
|
||||
.split('.')
|
||||
.reduce((acc: any, key: string) => acc?.[key], body)
|
||||
if (value !== undefined && value !== null && typeof value !== 'object') {
|
||||
headers['x-sim-idempotency-key'] = String(value)
|
||||
}
|
||||
}
|
||||
const [credentialRecord] = await db
|
||||
.select({ userId: account.userId })
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
credentialAccountUserId = credentialRecord?.userId
|
||||
}
|
||||
|
||||
const credentialId = providerConfig.credentialId as string | undefined
|
||||
|
||||
// credentialSetId is a direct field on webhook table, not in providerConfig
|
||||
const credentialSetId = foundWebhook.credentialSetId as string | undefined
|
||||
|
||||
@@ -1089,16 +1098,9 @@ export async function queueWebhookExecution(
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundWorkflow.workspaceId) {
|
||||
logger.error(`[${options.requestId}] Workflow ${foundWorkflow.id} has no workspaceId`)
|
||||
return NextResponse.json({ error: 'Workflow has no associated workspace' }, { status: 500 })
|
||||
}
|
||||
|
||||
const actorUserId = await getWorkspaceBilledAccountUserId(foundWorkflow.workspaceId)
|
||||
const actorUserId = options.actorUserId
|
||||
if (!actorUserId) {
|
||||
logger.error(
|
||||
`[${options.requestId}] No billing account for workspace ${foundWorkflow.workspaceId}`
|
||||
)
|
||||
logger.error(`[${options.requestId}] No actorUserId provided for webhook ${foundWebhook.id}`)
|
||||
return NextResponse.json({ error: 'Unable to resolve billing account' }, { status: 500 })
|
||||
}
|
||||
|
||||
@@ -1111,19 +1113,28 @@ export async function queueWebhookExecution(
|
||||
headers,
|
||||
path: options.path || foundWebhook.path,
|
||||
blockId: foundWebhook.blockId,
|
||||
workspaceId: foundWorkflow.workspaceId,
|
||||
...(credentialId ? { credentialId } : {}),
|
||||
...(credentialAccountUserId ? { credentialAccountUserId } : {}),
|
||||
}
|
||||
|
||||
const jobQueue = await getJobQueue()
|
||||
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
|
||||
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
|
||||
})
|
||||
logger.info(
|
||||
`[${options.requestId}] Queued webhook execution task ${jobId} for ${foundWebhook.provider} webhook`
|
||||
)
|
||||
const isPolling = isPollingWebhookProvider(payload.provider)
|
||||
|
||||
if (shouldExecuteInline()) {
|
||||
if (isPolling && !shouldExecuteInline()) {
|
||||
const jobQueue = await getJobQueue()
|
||||
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
|
||||
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
|
||||
})
|
||||
logger.info(
|
||||
`[${options.requestId}] Queued polling webhook execution task ${jobId} for ${foundWebhook.provider} webhook via job queue`
|
||||
)
|
||||
} else {
|
||||
const jobQueue = await getInlineJobQueue()
|
||||
const jobId = await jobQueue.enqueue('webhook-execution', payload, {
|
||||
metadata: { workflowId: foundWorkflow.id, userId: actorUserId },
|
||||
})
|
||||
logger.info(
|
||||
`[${options.requestId}] Executing ${foundWebhook.provider} webhook ${jobId} inline`
|
||||
)
|
||||
void (async () => {
|
||||
try {
|
||||
await jobQueue.startJob(jobId)
|
||||
@@ -1166,6 +1177,12 @@ export async function queueWebhookExecution(
|
||||
})
|
||||
}
|
||||
|
||||
// Slack requires an empty 200 for interactive payloads (view_submission, block_actions, etc.)
|
||||
// A JSON body like {"message":"..."} is not a recognized response format and causes modal errors
|
||||
if (foundWebhook.provider === 'slack') {
|
||||
return new NextResponse(null, { status: 200 })
|
||||
}
|
||||
|
||||
// Twilio Voice requires TwiML XML response
|
||||
if (foundWebhook.provider === 'twilio_voice') {
|
||||
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
|
||||
@@ -1197,6 +1214,26 @@ export async function queueWebhookExecution(
|
||||
})
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'generic' && providerConfig.responseMode === 'custom') {
|
||||
const rawCode = Number(providerConfig.responseStatusCode) || 200
|
||||
const statusCode = rawCode >= 100 && rawCode <= 599 ? rawCode : 200
|
||||
const responseBody = (providerConfig.responseBody as string | undefined)?.trim()
|
||||
|
||||
if (!responseBody) {
|
||||
return new NextResponse(null, { status: statusCode })
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(responseBody)
|
||||
return NextResponse.json(parsed, { status: statusCode })
|
||||
} catch {
|
||||
return new NextResponse(responseBody, {
|
||||
status: statusCode,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Webhook processed' })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error)
|
||||
@@ -1211,6 +1248,12 @@ export async function queueWebhookExecution(
|
||||
)
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'slack') {
|
||||
// Return empty 200 to avoid Slack showing an error dialog to the user,
|
||||
// even though processing failed. The error is already logged above.
|
||||
return new NextResponse(null, { status: 200 })
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'twilio_voice') {
|
||||
const errorTwiml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
refreshAccessTokenIfNeeded,
|
||||
resolveOAuthAccountId,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
import { isPollingWebhookProvider } from '@/triggers/constants'
|
||||
|
||||
const logger = createLogger('WebhookUtils')
|
||||
|
||||
@@ -2222,10 +2223,7 @@ export async function syncWebhooksForCredentialSet(params: {
|
||||
`[${requestId}] Syncing webhooks for credential set ${credentialSetId}, provider ${provider}`
|
||||
)
|
||||
|
||||
// Polling providers get unique paths per credential (for independent state)
|
||||
// External webhook providers share the same path (external service sends to one URL)
|
||||
const pollingProviders = ['gmail', 'outlook', 'rss', 'imap']
|
||||
const useUniquePaths = pollingProviders.includes(provider)
|
||||
const useUniquePaths = isPollingWebhookProvider(provider)
|
||||
|
||||
const credentials = await getCredentialsForCredentialSet(credentialSetId, oauthProviderId)
|
||||
|
||||
|
||||
@@ -433,7 +433,7 @@ describe('hasWorkflowChanged', () => {
|
||||
expect(hasWorkflowChanged(state1, state2)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should detect subBlock type changes', () => {
|
||||
it.concurrent('should ignore subBlock type changes', () => {
|
||||
const state1 = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1', {
|
||||
@@ -448,7 +448,7 @@ describe('hasWorkflowChanged', () => {
|
||||
}),
|
||||
},
|
||||
})
|
||||
expect(hasWorkflowChanged(state1, state2)).toBe(true)
|
||||
expect(hasWorkflowChanged(state1, state2)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should handle null/undefined subBlock values consistently', () => {
|
||||
|
||||
@@ -496,7 +496,14 @@ export function normalizeSubBlockValue(subBlockId: string, value: unknown): unkn
|
||||
* @returns SubBlock fields excluding value and is_diff
|
||||
*/
|
||||
export function extractSubBlockRest(subBlock: Record<string, unknown>): Record<string, unknown> {
|
||||
const { value: _v, is_diff: _sd, ...rest } = subBlock as SubBlockWithDiffMarker
|
||||
const {
|
||||
value: _v,
|
||||
is_diff: _sd,
|
||||
type: _type,
|
||||
...rest
|
||||
} = subBlock as SubBlockWithDiffMarker & {
|
||||
type?: unknown
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,10 @@ export async function loadDeployedWorkflowState(
|
||||
resolvedWorkspaceId = wfRow?.workspaceId ?? undefined
|
||||
}
|
||||
|
||||
if (!resolvedWorkspaceId) {
|
||||
throw new Error(`Workflow ${workflowId} has no workspace`)
|
||||
}
|
||||
|
||||
const { blocks: migratedBlocks } = await applyBlockMigrations(
|
||||
state.blocks || {},
|
||||
resolvedWorkspaceId
|
||||
@@ -139,7 +143,7 @@ export async function loadDeployedWorkflowState(
|
||||
|
||||
interface MigrationContext {
|
||||
blocks: Record<string, BlockState>
|
||||
workspaceId?: string
|
||||
workspaceId: string
|
||||
migrated: boolean
|
||||
}
|
||||
|
||||
@@ -148,7 +152,7 @@ type BlockMigration = (ctx: MigrationContext) => MigrationContext | Promise<Migr
|
||||
function createMigrationPipeline(migrations: BlockMigration[]) {
|
||||
return async (
|
||||
blocks: Record<string, BlockState>,
|
||||
workspaceId?: string
|
||||
workspaceId: string
|
||||
): Promise<{ blocks: Record<string, BlockState>; migrated: boolean }> => {
|
||||
let ctx: MigrationContext = { blocks, workspaceId, migrated: false }
|
||||
for (const migration of migrations) {
|
||||
@@ -170,7 +174,6 @@ const applyBlockMigrations = createMigrationPipeline([
|
||||
}),
|
||||
|
||||
async (ctx) => {
|
||||
if (!ctx.workspaceId) return ctx
|
||||
const { blocks, migrated } = await migrateCredentialIds(ctx.blocks, ctx.workspaceId)
|
||||
return { ...ctx, blocks, migrated: ctx.migrated || migrated }
|
||||
},
|
||||
@@ -409,9 +412,13 @@ export async function loadWorkflowFromNormalizedTables(
|
||||
blocksMap[block.id] = assembled
|
||||
})
|
||||
|
||||
if (!workflowRow?.workspaceId) {
|
||||
throw new Error(`Workflow ${workflowId} has no workspace`)
|
||||
}
|
||||
|
||||
const { blocks: finalBlocks, migrated } = await applyBlockMigrations(
|
||||
blocksMap,
|
||||
workflowRow?.workspaceId ?? undefined
|
||||
workflowRow.workspaceId
|
||||
)
|
||||
|
||||
if (migrated) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { safeCompare } from '@/lib/core/security/encryption'
|
||||
import type { IRoomManager } from '@/socket/rooms'
|
||||
|
||||
interface Logger {
|
||||
@@ -21,7 +22,8 @@ function checkInternalApiKey(req: IncomingMessage): { success: boolean; error?:
|
||||
return { success: false, error: 'API key required' }
|
||||
}
|
||||
|
||||
if (apiKey !== expectedApiKey) {
|
||||
const apiKeyStr = Array.isArray(apiKey) ? apiKey[0] : apiKey
|
||||
if (!apiKeyStr || !safeCompare(apiKeyStr, expectedApiKey)) {
|
||||
return { success: false, error: 'Invalid API key' }
|
||||
}
|
||||
|
||||
|
||||
78
apps/sim/tools/evernote/copy_note.ts
Normal file
78
apps/sim/tools/evernote/copy_note.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { EvernoteCopyNoteParams, EvernoteCopyNoteResponse } from './types'
|
||||
|
||||
export const evernoteCopyNoteTool: ToolConfig<EvernoteCopyNoteParams, EvernoteCopyNoteResponse> = {
|
||||
id: 'evernote_copy_note',
|
||||
name: 'Evernote Copy Note',
|
||||
description: 'Copy a note to another notebook in Evernote',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Evernote developer token',
|
||||
},
|
||||
noteGuid: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'GUID of the note to copy',
|
||||
},
|
||||
toNotebookGuid: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'GUID of the destination notebook',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/evernote/copy-note',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
apiKey: params.apiKey,
|
||||
noteGuid: params.noteGuid,
|
||||
toNotebookGuid: params.toNotebookGuid,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to copy note')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { note: data.output.note },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
note: {
|
||||
type: 'object',
|
||||
description: 'The copied note metadata',
|
||||
properties: {
|
||||
guid: { type: 'string', description: 'New note GUID' },
|
||||
title: { type: 'string', description: 'Note title' },
|
||||
notebookGuid: {
|
||||
type: 'string',
|
||||
description: 'GUID of the destination notebook',
|
||||
optional: true,
|
||||
},
|
||||
created: {
|
||||
type: 'number',
|
||||
description: 'Creation timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
updated: {
|
||||
type: 'number',
|
||||
description: 'Last updated timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
101
apps/sim/tools/evernote/create_note.ts
Normal file
101
apps/sim/tools/evernote/create_note.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { EvernoteCreateNoteParams, EvernoteCreateNoteResponse } from './types'
|
||||
|
||||
export const evernoteCreateNoteTool: ToolConfig<
|
||||
EvernoteCreateNoteParams,
|
||||
EvernoteCreateNoteResponse
|
||||
> = {
|
||||
id: 'evernote_create_note',
|
||||
name: 'Evernote Create Note',
|
||||
description: 'Create a new note in Evernote',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Evernote developer token',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Title of the note',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Content of the note (plain text or ENML)',
|
||||
},
|
||||
notebookGuid: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'GUID of the notebook to create the note in (defaults to default notebook)',
|
||||
},
|
||||
tagNames: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comma-separated list of tag names to apply',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/evernote/create-note',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
apiKey: params.apiKey,
|
||||
title: params.title,
|
||||
content: params.content,
|
||||
notebookGuid: params.notebookGuid || null,
|
||||
tagNames: params.tagNames || null,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to create note')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { note: data.output.note },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
note: {
|
||||
type: 'object',
|
||||
description: 'The created note',
|
||||
properties: {
|
||||
guid: { type: 'string', description: 'Unique identifier of the note' },
|
||||
title: { type: 'string', description: 'Title of the note' },
|
||||
content: { type: 'string', description: 'ENML content of the note', optional: true },
|
||||
notebookGuid: {
|
||||
type: 'string',
|
||||
description: 'GUID of the containing notebook',
|
||||
optional: true,
|
||||
},
|
||||
tagNames: {
|
||||
type: 'array',
|
||||
description: 'Tag names applied to the note',
|
||||
optional: true,
|
||||
},
|
||||
created: {
|
||||
type: 'number',
|
||||
description: 'Creation timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
updated: {
|
||||
type: 'number',
|
||||
description: 'Last updated timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
78
apps/sim/tools/evernote/create_notebook.ts
Normal file
78
apps/sim/tools/evernote/create_notebook.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { EvernoteCreateNotebookParams, EvernoteCreateNotebookResponse } from './types'
|
||||
|
||||
export const evernoteCreateNotebookTool: ToolConfig<
|
||||
EvernoteCreateNotebookParams,
|
||||
EvernoteCreateNotebookResponse
|
||||
> = {
|
||||
id: 'evernote_create_notebook',
|
||||
name: 'Evernote Create Notebook',
|
||||
description: 'Create a new notebook in Evernote',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Evernote developer token',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Name for the new notebook',
|
||||
},
|
||||
stack: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Stack name to group the notebook under',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/evernote/create-notebook',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
apiKey: params.apiKey,
|
||||
name: params.name,
|
||||
stack: params.stack || null,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to create notebook')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { notebook: data.output.notebook },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
notebook: {
|
||||
type: 'object',
|
||||
description: 'The created notebook',
|
||||
properties: {
|
||||
guid: { type: 'string', description: 'Notebook GUID' },
|
||||
name: { type: 'string', description: 'Notebook name' },
|
||||
defaultNotebook: { type: 'boolean', description: 'Whether this is the default notebook' },
|
||||
serviceCreated: {
|
||||
type: 'number',
|
||||
description: 'Creation timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
serviceUpdated: {
|
||||
type: 'number',
|
||||
description: 'Last updated timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
stack: { type: 'string', description: 'Notebook stack name', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
70
apps/sim/tools/evernote/create_tag.ts
Normal file
70
apps/sim/tools/evernote/create_tag.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { EvernoteCreateTagParams, EvernoteCreateTagResponse } from './types'
|
||||
|
||||
export const evernoteCreateTagTool: ToolConfig<EvernoteCreateTagParams, EvernoteCreateTagResponse> =
|
||||
{
|
||||
id: 'evernote_create_tag',
|
||||
name: 'Evernote Create Tag',
|
||||
description: 'Create a new tag in Evernote',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Evernote developer token',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Name for the new tag',
|
||||
},
|
||||
parentGuid: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'GUID of the parent tag for hierarchy',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/evernote/create-tag',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
apiKey: params.apiKey,
|
||||
name: params.name,
|
||||
parentGuid: params.parentGuid || null,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to create tag')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { tag: data.output.tag },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
tag: {
|
||||
type: 'object',
|
||||
description: 'The created tag',
|
||||
properties: {
|
||||
guid: { type: 'string', description: 'Tag GUID' },
|
||||
name: { type: 'string', description: 'Tag name' },
|
||||
parentGuid: { type: 'string', description: 'Parent tag GUID', optional: true },
|
||||
updateSequenceNum: {
|
||||
type: 'number',
|
||||
description: 'Update sequence number',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
62
apps/sim/tools/evernote/delete_note.ts
Normal file
62
apps/sim/tools/evernote/delete_note.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { EvernoteDeleteNoteParams, EvernoteDeleteNoteResponse } from './types'
|
||||
|
||||
export const evernoteDeleteNoteTool: ToolConfig<
|
||||
EvernoteDeleteNoteParams,
|
||||
EvernoteDeleteNoteResponse
|
||||
> = {
|
||||
id: 'evernote_delete_note',
|
||||
name: 'Evernote Delete Note',
|
||||
description: 'Move a note to the trash in Evernote',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Evernote developer token',
|
||||
},
|
||||
noteGuid: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'GUID of the note to delete',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/evernote/delete-note',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
apiKey: params.apiKey,
|
||||
noteGuid: params.noteGuid,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to delete note')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
noteGuid: data.output.noteGuid,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the note was successfully deleted',
|
||||
},
|
||||
noteGuid: {
|
||||
type: 'string',
|
||||
description: 'GUID of the deleted note',
|
||||
},
|
||||
},
|
||||
}
|
||||
87
apps/sim/tools/evernote/get_note.ts
Normal file
87
apps/sim/tools/evernote/get_note.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { EvernoteGetNoteParams, EvernoteGetNoteResponse } from './types'
|
||||
|
||||
export const evernoteGetNoteTool: ToolConfig<EvernoteGetNoteParams, EvernoteGetNoteResponse> = {
|
||||
id: 'evernote_get_note',
|
||||
name: 'Evernote Get Note',
|
||||
description: 'Retrieve a note from Evernote by its GUID',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Evernote developer token',
|
||||
},
|
||||
noteGuid: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'GUID of the note to retrieve',
|
||||
},
|
||||
withContent: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Whether to include note content (default: true)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/evernote/get-note',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
apiKey: params.apiKey,
|
||||
noteGuid: params.noteGuid,
|
||||
withContent: params.withContent ?? true,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to get note')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { note: data.output.note },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
note: {
|
||||
type: 'object',
|
||||
description: 'The retrieved note',
|
||||
properties: {
|
||||
guid: { type: 'string', description: 'Unique identifier of the note' },
|
||||
title: { type: 'string', description: 'Title of the note' },
|
||||
content: { type: 'string', description: 'ENML content of the note', optional: true },
|
||||
contentLength: {
|
||||
type: 'number',
|
||||
description: 'Length of the note content',
|
||||
optional: true,
|
||||
},
|
||||
notebookGuid: {
|
||||
type: 'string',
|
||||
description: 'GUID of the containing notebook',
|
||||
optional: true,
|
||||
},
|
||||
tagGuids: { type: 'array', description: 'GUIDs of tags on the note', optional: true },
|
||||
tagNames: { type: 'array', description: 'Names of tags on the note', optional: true },
|
||||
created: {
|
||||
type: 'number',
|
||||
description: 'Creation timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
updated: {
|
||||
type: 'number',
|
||||
description: 'Last updated timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
active: { type: 'boolean', description: 'Whether the note is active (not in trash)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
71
apps/sim/tools/evernote/get_notebook.ts
Normal file
71
apps/sim/tools/evernote/get_notebook.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { EvernoteGetNotebookParams, EvernoteGetNotebookResponse } from './types'
|
||||
|
||||
export const evernoteGetNotebookTool: ToolConfig<
|
||||
EvernoteGetNotebookParams,
|
||||
EvernoteGetNotebookResponse
|
||||
> = {
|
||||
id: 'evernote_get_notebook',
|
||||
name: 'Evernote Get Notebook',
|
||||
description: 'Retrieve a notebook from Evernote by its GUID',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Evernote developer token',
|
||||
},
|
||||
notebookGuid: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'GUID of the notebook to retrieve',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/evernote/get-notebook',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
apiKey: params.apiKey,
|
||||
notebookGuid: params.notebookGuid,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to get notebook')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { notebook: data.output.notebook },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
notebook: {
|
||||
type: 'object',
|
||||
description: 'The retrieved notebook',
|
||||
properties: {
|
||||
guid: { type: 'string', description: 'Notebook GUID' },
|
||||
name: { type: 'string', description: 'Notebook name' },
|
||||
defaultNotebook: { type: 'boolean', description: 'Whether this is the default notebook' },
|
||||
serviceCreated: {
|
||||
type: 'number',
|
||||
description: 'Creation timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
serviceUpdated: {
|
||||
type: 'number',
|
||||
description: 'Last updated timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
stack: { type: 'string', description: 'Notebook stack name', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
12
apps/sim/tools/evernote/index.ts
Normal file
12
apps/sim/tools/evernote/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { evernoteCopyNoteTool } from './copy_note'
|
||||
export { evernoteCreateNoteTool } from './create_note'
|
||||
export { evernoteCreateNotebookTool } from './create_notebook'
|
||||
export { evernoteCreateTagTool } from './create_tag'
|
||||
export { evernoteDeleteNoteTool } from './delete_note'
|
||||
export { evernoteGetNoteTool } from './get_note'
|
||||
export { evernoteGetNotebookTool } from './get_notebook'
|
||||
export { evernoteListNotebooksTool } from './list_notebooks'
|
||||
export { evernoteListTagsTool } from './list_tags'
|
||||
export { evernoteSearchNotesTool } from './search_notes'
|
||||
export * from './types'
|
||||
export { evernoteUpdateNoteTool } from './update_note'
|
||||
64
apps/sim/tools/evernote/list_notebooks.ts
Normal file
64
apps/sim/tools/evernote/list_notebooks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { EvernoteListNotebooksParams, EvernoteListNotebooksResponse } from './types'
|
||||
|
||||
export const evernoteListNotebooksTool: ToolConfig<
|
||||
EvernoteListNotebooksParams,
|
||||
EvernoteListNotebooksResponse
|
||||
> = {
|
||||
id: 'evernote_list_notebooks',
|
||||
name: 'Evernote List Notebooks',
|
||||
description: 'List all notebooks in an Evernote account',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Evernote developer token',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/evernote/list-notebooks',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to list notebooks')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { notebooks: data.output.notebooks },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
notebooks: {
|
||||
type: 'array',
|
||||
description: 'List of notebooks',
|
||||
properties: {
|
||||
guid: { type: 'string', description: 'Notebook GUID' },
|
||||
name: { type: 'string', description: 'Notebook name' },
|
||||
defaultNotebook: { type: 'boolean', description: 'Whether this is the default notebook' },
|
||||
serviceCreated: {
|
||||
type: 'number',
|
||||
description: 'Creation timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
serviceUpdated: {
|
||||
type: 'number',
|
||||
description: 'Last updated timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
stack: { type: 'string', description: 'Notebook stack name', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
55
apps/sim/tools/evernote/list_tags.ts
Normal file
55
apps/sim/tools/evernote/list_tags.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { EvernoteListTagsParams, EvernoteListTagsResponse } from './types'
|
||||
|
||||
export const evernoteListTagsTool: ToolConfig<EvernoteListTagsParams, EvernoteListTagsResponse> = {
|
||||
id: 'evernote_list_tags',
|
||||
name: 'Evernote List Tags',
|
||||
description: 'List all tags in an Evernote account',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Evernote developer token',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/evernote/list-tags',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
apiKey: params.apiKey,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to list tags')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { tags: data.output.tags },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
tags: {
|
||||
type: 'array',
|
||||
description: 'List of tags',
|
||||
properties: {
|
||||
guid: { type: 'string', description: 'Tag GUID' },
|
||||
name: { type: 'string', description: 'Tag name' },
|
||||
parentGuid: { type: 'string', description: 'Parent tag GUID', optional: true },
|
||||
updateSequenceNum: {
|
||||
type: 'number',
|
||||
description: 'Update sequence number',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
92
apps/sim/tools/evernote/search_notes.ts
Normal file
92
apps/sim/tools/evernote/search_notes.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { EvernoteSearchNotesParams, EvernoteSearchNotesResponse } from './types'
|
||||
|
||||
export const evernoteSearchNotesTool: ToolConfig<
|
||||
EvernoteSearchNotesParams,
|
||||
EvernoteSearchNotesResponse
|
||||
> = {
|
||||
id: 'evernote_search_notes',
|
||||
name: 'Evernote Search Notes',
|
||||
description: 'Search for notes in Evernote using the Evernote search grammar',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Evernote developer token',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Search query using Evernote search grammar (e.g., "tag:work intitle:meeting")',
|
||||
},
|
||||
notebookGuid: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Restrict search to a specific notebook by GUID',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Starting index for results (default: 0)',
|
||||
},
|
||||
maxNotes: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of notes to return (default: 25)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/evernote/search-notes',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
apiKey: params.apiKey,
|
||||
query: params.query,
|
||||
notebookGuid: params.notebookGuid || null,
|
||||
offset: params.offset ?? 0,
|
||||
maxNotes: params.maxNotes ?? 25,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to search notes')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
totalNotes: data.output.totalNotes,
|
||||
notes: data.output.notes,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
totalNotes: {
|
||||
type: 'number',
|
||||
description: 'Total number of matching notes',
|
||||
},
|
||||
notes: {
|
||||
type: 'array',
|
||||
description: 'List of matching note metadata',
|
||||
properties: {
|
||||
guid: { type: 'string', description: 'Note GUID' },
|
||||
title: { type: 'string', description: 'Note title', optional: true },
|
||||
contentLength: { type: 'number', description: 'Content length in bytes', optional: true },
|
||||
created: { type: 'number', description: 'Creation timestamp', optional: true },
|
||||
updated: { type: 'number', description: 'Last updated timestamp', optional: true },
|
||||
notebookGuid: { type: 'string', description: 'Containing notebook GUID', optional: true },
|
||||
tagGuids: { type: 'array', description: 'Tag GUIDs', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
166
apps/sim/tools/evernote/types.ts
Normal file
166
apps/sim/tools/evernote/types.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface EvernoteBaseParams {
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
export interface EvernoteCreateNoteParams extends EvernoteBaseParams {
|
||||
title: string
|
||||
content: string
|
||||
notebookGuid?: string
|
||||
tagNames?: string
|
||||
}
|
||||
|
||||
export interface EvernoteGetNoteParams extends EvernoteBaseParams {
|
||||
noteGuid: string
|
||||
withContent?: boolean
|
||||
}
|
||||
|
||||
export interface EvernoteUpdateNoteParams extends EvernoteBaseParams {
|
||||
noteGuid: string
|
||||
title?: string
|
||||
content?: string
|
||||
notebookGuid?: string
|
||||
tagNames?: string
|
||||
}
|
||||
|
||||
export interface EvernoteDeleteNoteParams extends EvernoteBaseParams {
|
||||
noteGuid: string
|
||||
}
|
||||
|
||||
export interface EvernoteSearchNotesParams extends EvernoteBaseParams {
|
||||
query: string
|
||||
notebookGuid?: string
|
||||
offset?: number
|
||||
maxNotes?: number
|
||||
}
|
||||
|
||||
export interface EvernoteListNotebooksParams extends EvernoteBaseParams {}
|
||||
|
||||
export interface EvernoteGetNotebookParams extends EvernoteBaseParams {
|
||||
notebookGuid: string
|
||||
}
|
||||
|
||||
export interface EvernoteCreateNotebookParams extends EvernoteBaseParams {
|
||||
name: string
|
||||
stack?: string
|
||||
}
|
||||
|
||||
export interface EvernoteListTagsParams extends EvernoteBaseParams {}
|
||||
|
||||
export interface EvernoteCreateTagParams extends EvernoteBaseParams {
|
||||
name: string
|
||||
parentGuid?: string
|
||||
}
|
||||
|
||||
export interface EvernoteCopyNoteParams extends EvernoteBaseParams {
|
||||
noteGuid: string
|
||||
toNotebookGuid: string
|
||||
}
|
||||
|
||||
export interface EvernoteNoteOutput {
|
||||
guid: string
|
||||
title: string
|
||||
content: string | null
|
||||
contentLength: number | null
|
||||
created: number | null
|
||||
updated: number | null
|
||||
active: boolean
|
||||
notebookGuid: string | null
|
||||
tagGuids: string[]
|
||||
tagNames: string[]
|
||||
}
|
||||
|
||||
export interface EvernoteNotebookOutput {
|
||||
guid: string
|
||||
name: string
|
||||
defaultNotebook: boolean
|
||||
serviceCreated: number | null
|
||||
serviceUpdated: number | null
|
||||
stack: string | null
|
||||
}
|
||||
|
||||
export interface EvernoteNoteMetadataOutput {
|
||||
guid: string
|
||||
title: string | null
|
||||
contentLength: number | null
|
||||
created: number | null
|
||||
updated: number | null
|
||||
notebookGuid: string | null
|
||||
tagGuids: string[]
|
||||
}
|
||||
|
||||
export interface EvernoteTagOutput {
|
||||
guid: string
|
||||
name: string
|
||||
parentGuid: string | null
|
||||
updateSequenceNum: number | null
|
||||
}
|
||||
|
||||
export interface EvernoteCreateNoteResponse extends ToolResponse {
|
||||
output: {
|
||||
note: EvernoteNoteOutput
|
||||
}
|
||||
}
|
||||
|
||||
export interface EvernoteGetNoteResponse extends ToolResponse {
|
||||
output: {
|
||||
note: EvernoteNoteOutput
|
||||
}
|
||||
}
|
||||
|
||||
export interface EvernoteUpdateNoteResponse extends ToolResponse {
|
||||
output: {
|
||||
note: EvernoteNoteOutput
|
||||
}
|
||||
}
|
||||
|
||||
export interface EvernoteDeleteNoteResponse extends ToolResponse {
|
||||
output: {
|
||||
success: boolean
|
||||
noteGuid: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface EvernoteSearchNotesResponse extends ToolResponse {
|
||||
output: {
|
||||
totalNotes: number
|
||||
notes: EvernoteNoteMetadataOutput[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface EvernoteListNotebooksResponse extends ToolResponse {
|
||||
output: {
|
||||
notebooks: EvernoteNotebookOutput[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface EvernoteGetNotebookResponse extends ToolResponse {
|
||||
output: {
|
||||
notebook: EvernoteNotebookOutput
|
||||
}
|
||||
}
|
||||
|
||||
export interface EvernoteCreateNotebookResponse extends ToolResponse {
|
||||
output: {
|
||||
notebook: EvernoteNotebookOutput
|
||||
}
|
||||
}
|
||||
|
||||
export interface EvernoteListTagsResponse extends ToolResponse {
|
||||
output: {
|
||||
tags: EvernoteTagOutput[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface EvernoteCreateTagResponse extends ToolResponse {
|
||||
output: {
|
||||
tag: EvernoteTagOutput
|
||||
}
|
||||
}
|
||||
|
||||
export interface EvernoteCopyNoteResponse extends ToolResponse {
|
||||
output: {
|
||||
note: EvernoteNoteOutput
|
||||
}
|
||||
}
|
||||
104
apps/sim/tools/evernote/update_note.ts
Normal file
104
apps/sim/tools/evernote/update_note.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { EvernoteUpdateNoteParams, EvernoteUpdateNoteResponse } from './types'
|
||||
|
||||
export const evernoteUpdateNoteTool: ToolConfig<
|
||||
EvernoteUpdateNoteParams,
|
||||
EvernoteUpdateNoteResponse
|
||||
> = {
|
||||
id: 'evernote_update_note',
|
||||
name: 'Evernote Update Note',
|
||||
description: 'Update an existing note in Evernote',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Evernote developer token',
|
||||
},
|
||||
noteGuid: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'GUID of the note to update',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'New title for the note',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'New content for the note (plain text or ENML)',
|
||||
},
|
||||
notebookGuid: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'GUID of the notebook to move the note to',
|
||||
},
|
||||
tagNames: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Comma-separated list of tag names (replaces existing tags)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/evernote/update-note',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
apiKey: params.apiKey,
|
||||
noteGuid: params.noteGuid,
|
||||
title: params.title || null,
|
||||
content: params.content || null,
|
||||
notebookGuid: params.notebookGuid || null,
|
||||
tagNames: params.tagNames || null,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
const data = await response.json()
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to update note')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { note: data.output.note },
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
note: {
|
||||
type: 'object',
|
||||
description: 'The updated note',
|
||||
properties: {
|
||||
guid: { type: 'string', description: 'Unique identifier of the note' },
|
||||
title: { type: 'string', description: 'Title of the note' },
|
||||
content: { type: 'string', description: 'ENML content of the note', optional: true },
|
||||
notebookGuid: {
|
||||
type: 'string',
|
||||
description: 'GUID of the containing notebook',
|
||||
optional: true,
|
||||
},
|
||||
tagNames: { type: 'array', description: 'Tag names on the note', optional: true },
|
||||
created: {
|
||||
type: 'number',
|
||||
description: 'Creation timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
updated: {
|
||||
type: 'number',
|
||||
description: 'Last updated timestamp in milliseconds',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
36
apps/sim/tools/gmail/utils.test.ts
Normal file
36
apps/sim/tools/gmail/utils.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodeRfc2047 } from './utils'
|
||||
|
||||
describe('encodeRfc2047', () => {
|
||||
it('returns ASCII text unchanged', () => {
|
||||
expect(encodeRfc2047('Simple ASCII Subject')).toBe('Simple ASCII Subject')
|
||||
})
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(encodeRfc2047('')).toBe('')
|
||||
})
|
||||
|
||||
it('encodes emojis as RFC 2047 base64', () => {
|
||||
const result = encodeRfc2047('Time to Stretch! 🧘')
|
||||
expect(result).toBe('=?UTF-8?B?VGltZSB0byBTdHJldGNoISDwn6eY?=')
|
||||
})
|
||||
|
||||
it('round-trips non-ASCII subjects correctly', () => {
|
||||
const subjects = ['Hello 世界', 'Café résumé', '🎉🎊🎈 Party!', '今週のミーティング']
|
||||
for (const subject of subjects) {
|
||||
const encoded = encodeRfc2047(subject)
|
||||
const match = encoded.match(/^=\?UTF-8\?B\?(.+)\?=$/)
|
||||
expect(match).not.toBeNull()
|
||||
const decoded = Buffer.from(match![1], 'base64').toString('utf-8')
|
||||
expect(decoded).toBe(subject)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not double-encode already-encoded subjects', () => {
|
||||
const alreadyEncoded = '=?UTF-8?B?VGltZSB0byBTdHJldGNoISDwn6eY?='
|
||||
expect(encodeRfc2047(alreadyEncoded)).toBe(alreadyEncoded)
|
||||
})
|
||||
})
|
||||
@@ -294,6 +294,19 @@ function generateBoundary(): string {
|
||||
return `----=_Part_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a header value using RFC 2047 Base64 encoding if it contains non-ASCII characters.
|
||||
* This matches Google's own Gmail API sample: `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=`
|
||||
* @see https://github.com/googleapis/google-api-nodejs-client/blob/main/samples/gmail/send.js
|
||||
*/
|
||||
export function encodeRfc2047(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/^[\x00-\x7F]*$/.test(value)) {
|
||||
return value
|
||||
}
|
||||
return `=?UTF-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=`
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode string or buffer to base64url format (URL-safe base64)
|
||||
* Gmail API requires base64url encoding for the raw message field
|
||||
@@ -333,7 +346,7 @@ export function buildSimpleEmailMessage(params: {
|
||||
emailHeaders.push(`Bcc: ${bcc}`)
|
||||
}
|
||||
|
||||
emailHeaders.push(`Subject: ${subject || ''}`)
|
||||
emailHeaders.push(`Subject: ${encodeRfc2047(subject || '')}`)
|
||||
|
||||
if (inReplyTo) {
|
||||
emailHeaders.push(`In-Reply-To: ${inReplyTo}`)
|
||||
@@ -380,7 +393,7 @@ export function buildMimeMessage(params: BuildMimeMessageParams): string {
|
||||
if (bcc) {
|
||||
messageParts.push(`Bcc: ${bcc}`)
|
||||
}
|
||||
messageParts.push(`Subject: ${subject || ''}`)
|
||||
messageParts.push(`Subject: ${encodeRfc2047(subject || '')}`)
|
||||
|
||||
if (inReplyTo) {
|
||||
messageParts.push(`In-Reply-To: ${inReplyTo}`)
|
||||
|
||||
@@ -17,6 +17,7 @@ import { jiraGetWorklogsTool } from '@/tools/jira/get_worklogs'
|
||||
import { jiraRemoveWatcherTool } from '@/tools/jira/remove_watcher'
|
||||
import { jiraRetrieveTool } from '@/tools/jira/retrieve'
|
||||
import { jiraSearchIssuesTool } from '@/tools/jira/search_issues'
|
||||
import { jiraSearchUsersTool } from '@/tools/jira/search_users'
|
||||
import { jiraTransitionIssueTool } from '@/tools/jira/transition_issue'
|
||||
import { jiraUpdateTool } from '@/tools/jira/update'
|
||||
import { jiraUpdateCommentTool } from '@/tools/jira/update_comment'
|
||||
@@ -48,4 +49,5 @@ export {
|
||||
jiraAddWatcherTool,
|
||||
jiraRemoveWatcherTool,
|
||||
jiraGetUsersTool,
|
||||
jiraSearchUsersTool,
|
||||
}
|
||||
|
||||
166
apps/sim/tools/jira/search_users.ts
Normal file
166
apps/sim/tools/jira/search_users.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { JiraSearchUsersParams, JiraSearchUsersResponse } from '@/tools/jira/types'
|
||||
import { TIMESTAMP_OUTPUT, USER_OUTPUT_PROPERTIES } from '@/tools/jira/types'
|
||||
import { getJiraCloudId, transformUser } from '@/tools/jira/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const jiraSearchUsersTool: ToolConfig<JiraSearchUsersParams, JiraSearchUsersResponse> = {
|
||||
id: 'jira_search_users',
|
||||
name: 'Jira Search Users',
|
||||
description:
|
||||
'Search for Jira users by email address or display name. Returns matching users with their accountId, displayName, and emailAddress.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'jira',
|
||||
},
|
||||
|
||||
params: {
|
||||
accessToken: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'hidden',
|
||||
description: 'OAuth access token for Jira',
|
||||
},
|
||||
domain: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Jira domain (e.g., yourcompany.atlassian.net)',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'A query string to search for users. Can be an email address, display name, or partial match.',
|
||||
},
|
||||
maxResults: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of users to return (default: 50, max: 1000)',
|
||||
},
|
||||
startAt: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The index of the first user to return (for pagination, default: 0)',
|
||||
},
|
||||
cloudId: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'hidden',
|
||||
description:
|
||||
'Jira Cloud ID for the instance. If not provided, it will be fetched using the domain.',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: JiraSearchUsersParams) => {
|
||||
if (params.cloudId) {
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('query', params.query)
|
||||
if (params.maxResults !== undefined)
|
||||
queryParams.append('maxResults', String(params.maxResults))
|
||||
if (params.startAt !== undefined) queryParams.append('startAt', String(params.startAt))
|
||||
return `https://api.atlassian.com/ex/jira/${params.cloudId}/rest/api/3/user/search?${queryParams.toString()}`
|
||||
}
|
||||
return 'https://api.atlassian.com/oauth/token/accessible-resources'
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: JiraSearchUsersParams) => ({
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: JiraSearchUsersParams) => {
|
||||
const fetchUsers = async (cloudId: string) => {
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.append('query', params!.query)
|
||||
if (params!.maxResults !== undefined)
|
||||
queryParams.append('maxResults', String(params!.maxResults))
|
||||
if (params!.startAt !== undefined) queryParams.append('startAt', String(params!.startAt))
|
||||
|
||||
const usersUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/user/search?${queryParams.toString()}`
|
||||
|
||||
const usersResponse = await fetch(usersUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${params!.accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!usersResponse.ok) {
|
||||
let message = `Failed to search Jira users (${usersResponse.status})`
|
||||
try {
|
||||
const err = await usersResponse.json()
|
||||
message = err?.errorMessages?.join(', ') || err?.message || message
|
||||
} catch (_e) {}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return usersResponse.json()
|
||||
}
|
||||
|
||||
let data: any
|
||||
|
||||
if (!params?.cloudId) {
|
||||
const cloudId = await getJiraCloudId(params!.domain, params!.accessToken)
|
||||
data = await fetchUsers(cloudId)
|
||||
} else {
|
||||
if (!response.ok) {
|
||||
let message = `Failed to search Jira users (${response.status})`
|
||||
try {
|
||||
const err = await response.json()
|
||||
message = err?.errorMessages?.join(', ') || err?.message || message
|
||||
} catch (_e) {}
|
||||
throw new Error(message)
|
||||
}
|
||||
data = await response.json()
|
||||
}
|
||||
|
||||
const users = Array.isArray(data) ? data.filter(Boolean) : []
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
ts: new Date().toISOString(),
|
||||
users: users.map((user: any) => ({
|
||||
...(transformUser(user) ?? { accountId: '', displayName: '' }),
|
||||
self: user.self ?? null,
|
||||
})),
|
||||
total: users.length,
|
||||
startAt: params?.startAt ?? 0,
|
||||
maxResults: params?.maxResults ?? 50,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
ts: TIMESTAMP_OUTPUT,
|
||||
users: {
|
||||
type: 'array',
|
||||
description: 'Array of matching Jira users',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...USER_OUTPUT_PROPERTIES,
|
||||
self: {
|
||||
type: 'string',
|
||||
description: 'REST API URL for this user',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
total: {
|
||||
type: 'number',
|
||||
description: 'Number of users returned in this page (may be less than total matches)',
|
||||
},
|
||||
startAt: { type: 'number', description: 'Pagination start index' },
|
||||
maxResults: { type: 'number', description: 'Maximum results per page' },
|
||||
},
|
||||
}
|
||||
@@ -1549,6 +1549,34 @@ export interface JiraGetUsersParams {
|
||||
cloudId?: string
|
||||
}
|
||||
|
||||
export interface JiraSearchUsersParams {
|
||||
accessToken: string
|
||||
domain: string
|
||||
query: string
|
||||
maxResults?: number
|
||||
startAt?: number
|
||||
cloudId?: string
|
||||
}
|
||||
|
||||
export interface JiraSearchUsersResponse extends ToolResponse {
|
||||
output: {
|
||||
ts: string
|
||||
users: Array<{
|
||||
accountId: string
|
||||
accountType?: string | null
|
||||
active?: boolean | null
|
||||
displayName: string
|
||||
emailAddress?: string | null
|
||||
avatarUrl?: string | null
|
||||
timeZone?: string | null
|
||||
self?: string | null
|
||||
}>
|
||||
total: number
|
||||
startAt: number
|
||||
maxResults: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface JiraGetUsersResponse extends ToolResponse {
|
||||
output: {
|
||||
ts: string
|
||||
@@ -1594,3 +1622,4 @@ export type JiraResponse =
|
||||
| JiraAddWatcherResponse
|
||||
| JiraRemoveWatcherResponse
|
||||
| JiraGetUsersResponse
|
||||
| JiraSearchUsersResponse
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user