mirror of
https://github.com/simstudioai/sim.git
synced 2026-03-15 03:00:33 -04:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9bdc57616 | ||
|
|
e7b4da2689 | ||
|
|
aa0101c666 | ||
|
|
c939f8a76e | ||
|
|
0b19ad0013 | ||
|
|
3d5141d852 | ||
|
|
75832ca007 | ||
|
|
97f78c60b4 | ||
|
|
9295499405 | ||
|
|
6bcbd15ee6 | ||
|
|
36612ae42a | ||
|
|
68d207df94 | ||
|
|
d5502d602b | ||
|
|
37d524bb0a | ||
|
|
1c2c2c65d4 | ||
|
|
19ef526886 | ||
|
|
ff2a1527ab | ||
|
|
2e1c639a81 | ||
|
|
ecd3536a72 | ||
|
|
635179d696 | ||
|
|
f88926a6a8 | ||
|
|
690b47a0bf | ||
|
|
158d5236bc | ||
|
|
1ba1bc8edb | ||
|
|
53fd92a30a | ||
|
|
8c0a2e04b1 | ||
|
|
0a52b09deb | ||
|
|
1d36b80172 | ||
|
|
e6a5e7f4e4 | ||
|
|
a71304200e | ||
|
|
a4d581c76f | ||
|
|
f1efc598d1 | ||
|
|
244cf4ff7e | ||
|
|
ae887185a1 | ||
|
|
06c88441f8 | ||
|
|
6586c5ce40 | ||
|
|
127968d467 | ||
|
|
2722f0efbf |
@@ -20,6 +20,7 @@ When the user asks you to create a block:
|
||||
import { {ServiceName}Icon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
|
||||
export const {ServiceName}Block: BlockConfig = {
|
||||
type: '{service}', // snake_case identifier
|
||||
@@ -115,12 +116,17 @@ export const {ServiceName}Block: BlockConfig = {
|
||||
id: 'credential',
|
||||
title: 'Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: '{service}', // Must match OAuth provider
|
||||
serviceId: '{service}', // Must match OAuth provider service key
|
||||
requiredScopes: getScopesForService('{service}'), // Import from @/lib/oauth/utils
|
||||
placeholder: 'Select account',
|
||||
required: true,
|
||||
}
|
||||
```
|
||||
|
||||
**Scopes:** Always use `getScopesForService(serviceId)` from `@/lib/oauth/utils` for `requiredScopes`. Never hardcode scope arrays — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`.
|
||||
|
||||
**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`.
|
||||
|
||||
### Selectors (with dynamic options)
|
||||
```typescript
|
||||
// Channel selector (Slack, Discord, etc.)
|
||||
@@ -624,6 +630,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
import { ServiceIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
|
||||
export const ServiceBlock: BlockConfig = {
|
||||
type: 'service',
|
||||
@@ -654,6 +661,7 @@ export const ServiceBlock: BlockConfig = {
|
||||
title: 'Service Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'service',
|
||||
requiredScopes: getScopesForService('service'),
|
||||
placeholder: 'Select account',
|
||||
required: true,
|
||||
},
|
||||
@@ -792,7 +800,8 @@ All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MU
|
||||
- [ ] Conditions use correct syntax (field, value, not, and)
|
||||
- [ ] DependsOn set for fields that need other values
|
||||
- [ ] Required fields marked correctly (boolean or condition)
|
||||
- [ ] OAuth inputs have correct `serviceId`
|
||||
- [ ] OAuth inputs have correct `serviceId` and `requiredScopes: getScopesForService(serviceId)`
|
||||
- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes
|
||||
- [ ] Tools.access lists all tool IDs (snake_case)
|
||||
- [ ] Tools.config.tool returns correct tool ID (snake_case)
|
||||
- [ ] Outputs match tool outputs
|
||||
|
||||
@@ -114,6 +114,7 @@ export const {service}{Action}Tool: ToolConfig<Params, Response> = {
|
||||
import { {Service}Icon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import { getScopesForService } from '@/lib/oauth/utils'
|
||||
|
||||
export const {Service}Block: BlockConfig = {
|
||||
type: '{service}',
|
||||
@@ -144,6 +145,7 @@ export const {Service}Block: BlockConfig = {
|
||||
title: '{Service} Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: '{service}',
|
||||
requiredScopes: getScopesForService('{service}'),
|
||||
required: true,
|
||||
},
|
||||
// Conditional fields per operation
|
||||
@@ -409,7 +411,7 @@ If creating V2 versions (API-aligned outputs):
|
||||
### Block
|
||||
- [ ] Created `blocks/blocks/{service}.ts`
|
||||
- [ ] Defined operation dropdown with all operations
|
||||
- [ ] Added credential field (oauth-input or short-input)
|
||||
- [ ] Added credential field with `requiredScopes: getScopesForService('{service}')`
|
||||
- [ ] Added conditional fields per operation
|
||||
- [ ] Set up dependsOn for cascading selectors
|
||||
- [ ] Configured tools.access with all tool IDs
|
||||
@@ -419,6 +421,12 @@ If creating V2 versions (API-aligned outputs):
|
||||
- [ ] If triggers: set `triggers.enabled` and `triggers.available`
|
||||
- [ ] If triggers: spread trigger subBlocks with `getTrigger()`
|
||||
|
||||
### OAuth Scopes (if OAuth service)
|
||||
- [ ] Defined scopes in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS`
|
||||
- [ ] Added scope descriptions in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
|
||||
- [ ] Used `getCanonicalScopesForProvider()` in `auth.ts` (never hardcode)
|
||||
- [ ] Used `getScopesForService()` in block `requiredScopes` (never hardcode)
|
||||
|
||||
### Icon
|
||||
- [ ] Asked user to provide SVG
|
||||
- [ ] Added icon to `components/icons.tsx`
|
||||
@@ -717,6 +725,25 @@ Use `wandConfig` for fields that are hard to fill out manually:
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth Scopes (Centralized System)
|
||||
|
||||
Scopes are maintained in a single source of truth and reused everywhere:
|
||||
|
||||
1. **Define scopes** in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes`
|
||||
2. **Add descriptions** in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for the OAuth modal UI
|
||||
3. **Reference in auth.ts** using `getCanonicalScopesForProvider(providerId)` from `@/lib/oauth/utils`
|
||||
4. **Reference in blocks** using `getScopesForService(serviceId)` from `@/lib/oauth/utils`
|
||||
|
||||
**Never hardcode scope arrays** in `auth.ts` or block `requiredScopes`. Always import from the centralized source.
|
||||
|
||||
```typescript
|
||||
// In auth.ts (Better Auth config)
|
||||
scopes: getCanonicalScopesForProvider('{service}'),
|
||||
|
||||
// In block credential sub-block
|
||||
requiredScopes: getScopesForService('{service}'),
|
||||
```
|
||||
|
||||
### Common Gotchas
|
||||
|
||||
1. **OAuth serviceId must match** - The `serviceId` in oauth-input must match the OAuth provider configuration
|
||||
@@ -729,3 +756,5 @@ Use `wandConfig` for fields that are hard to fill out manually:
|
||||
8. **Always handle legacy file params** - Keep hidden `fileContent` params for backwards compatibility
|
||||
9. **Optional fields use advanced mode** - Set `mode: 'advanced'` on rarely-used optional fields
|
||||
10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled
|
||||
11. **Never hardcode scopes** - Use `getScopesForService()` in blocks and `getCanonicalScopesForProvider()` in auth.ts
|
||||
12. **Always add scope descriptions** - New scopes must have entries in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
|
||||
|
||||
@@ -26,8 +26,9 @@ apps/sim/blocks/blocks/{service}.ts # Block definition
|
||||
apps/sim/tools/registry.ts # Tool registry entries for this service
|
||||
apps/sim/blocks/registry.ts # Block registry entry for this service
|
||||
apps/sim/components/icons.tsx # Icon definition
|
||||
apps/sim/lib/auth/auth.ts # OAuth scopes (if OAuth service)
|
||||
apps/sim/lib/oauth/oauth.ts # OAuth provider config (if OAuth service)
|
||||
apps/sim/lib/auth/auth.ts # OAuth config — should use getCanonicalScopesForProvider()
|
||||
apps/sim/lib/oauth/oauth.ts # OAuth provider config — single source of truth for scopes
|
||||
apps/sim/lib/oauth/utils.ts # Scope utilities, SCOPE_DESCRIPTIONS for modal UI
|
||||
```
|
||||
|
||||
## Step 2: Pull API Documentation
|
||||
@@ -199,11 +200,14 @@ For **each tool** in `tools.access`:
|
||||
|
||||
## Step 5: Validate OAuth Scopes (if OAuth service)
|
||||
|
||||
- [ ] `auth.ts` scopes include ALL scopes needed by ALL tools in the integration
|
||||
- [ ] `oauth.ts` provider config scopes match `auth.ts` scopes
|
||||
- [ ] Block `requiredScopes` (if defined) matches `auth.ts` scopes
|
||||
Scopes are centralized — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`.
|
||||
|
||||
- [ ] Scopes defined in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes`
|
||||
- [ ] `auth.ts` uses `getCanonicalScopesForProvider(providerId)` — NOT a hardcoded array
|
||||
- [ ] Block `requiredScopes` uses `getScopesForService(serviceId)` — NOT a hardcoded array
|
||||
- [ ] No hardcoded scope arrays in `auth.ts` or block files (should all use utility functions)
|
||||
- [ ] Each scope has a human-readable description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
|
||||
- [ ] No excess scopes that aren't needed by any tool
|
||||
- [ ] Each scope has a human-readable description in `oauth-required-modal.tsx`'s `SCOPE_DESCRIPTIONS`
|
||||
|
||||
## Step 6: Validate Pagination Consistency
|
||||
|
||||
@@ -244,7 +248,8 @@ Group findings by severity:
|
||||
- Missing `.trim()` on ID fields in request URLs
|
||||
- Missing `?? null` on nullable response fields
|
||||
- Block condition array missing an operation that uses that field
|
||||
- Missing scope description in `oauth-required-modal.tsx`
|
||||
- Hardcoded scope arrays instead of using `getScopesForService()` / `getCanonicalScopesForProvider()`
|
||||
- Missing scope description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
|
||||
|
||||
**Suggestion** (minor improvements):
|
||||
- Better description text
|
||||
@@ -273,7 +278,8 @@ After fixing, confirm:
|
||||
- [ ] Validated wandConfig on timestamps and complex inputs
|
||||
- [ ] Validated tools.config mapping, tool selector, and type coercions
|
||||
- [ ] Validated block outputs match what tools return, with typed JSON where possible
|
||||
- [ ] Validated OAuth scopes alignment across auth.ts, oauth.ts, block, and modal (if OAuth)
|
||||
- [ ] Validated OAuth scopes use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays
|
||||
- [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes
|
||||
- [ ] Validated pagination consistency across tools and block
|
||||
- [ ] Validated error handling (error checks, meaningful messages)
|
||||
- [ ] Validated registry entries (tools and block, alphabetical, correct imports)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1.3.9-alpine
|
||||
FROM oven/bun:1.3.10-alpine
|
||||
|
||||
# Install necessary packages for development
|
||||
RUN apk add --no-cache \
|
||||
|
||||
2
.github/workflows/docs-embeddings.yml
vendored
2
.github/workflows/docs-embeddings.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.9
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
4
.github/workflows/i18n.yml
vendored
4
.github/workflows/i18n.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.9
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.9
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
|
||||
2
.github/workflows/migrations.yml
vendored
2
.github/workflows/migrations.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.9
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
|
||||
2
.github/workflows/publish-cli.yml
vendored
2
.github/workflows/publish-cli.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.9
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Setup Node.js for npm publishing
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
2
.github/workflows/publish-ts-sdk.yml
vendored
2
.github/workflows/publish-ts-sdk.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.9
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Setup Node.js for npm publishing
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
2
.github/workflows/test-build.yml
vendored
2
.github/workflows/test-build.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.9
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -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
|
||||
@@ -1822,6 +1979,24 @@ export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function FathomIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000' fill='none'>
|
||||
<path
|
||||
d='M0,668.7v205.78c0,53.97,34.24,102.88,85.8,119.08,87.48,27.49,167.88-36.99,167.88-120.22v-77.45L0,668.7Z'
|
||||
fill='#007299'
|
||||
/>
|
||||
<path
|
||||
d='M873.72,626.07c-19.05,0-38.38-4.3-56.58-13.38L72.78,241.43C11.15,210.69-17.51,136.6,11.18,74.05,41.2,8.59,119.26-18.53,183.23,13.38l744.25,371.21c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
|
||||
fill='#00beff'
|
||||
/>
|
||||
<path
|
||||
d='M500.09,813.66c-19.05,0-38.38-4.3-56.58-13.38l-370.72-184.9c-61.63-30.74-90.29-104.82-61.61-167.37,30.02-65.46,108.08-92.59,172.06-60.68l370.62,184.85c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
|
||||
fill='#00beff'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
export function LinkupIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 154 107' fill='none'>
|
||||
|
||||
@@ -40,8 +40,10 @@ import {
|
||||
ElasticsearchIcon,
|
||||
ElevenLabsIcon,
|
||||
EnrichSoIcon,
|
||||
EvernoteIcon,
|
||||
ExaAIIcon,
|
||||
EyeIcon,
|
||||
FathomIcon,
|
||||
FirecrawlIcon,
|
||||
FirefliesIcon,
|
||||
GammaIcon,
|
||||
@@ -103,6 +105,7 @@ import {
|
||||
MySQLIcon,
|
||||
Neo4jIcon,
|
||||
NotionIcon,
|
||||
ObsidianIcon,
|
||||
OnePasswordIcon,
|
||||
OpenAIIcon,
|
||||
OutlookIcon,
|
||||
@@ -202,7 +205,9 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
elevenlabs: ElevenLabsIcon,
|
||||
enrich: EnrichSoIcon,
|
||||
evernote: EvernoteIcon,
|
||||
exa: ExaAIIcon,
|
||||
fathom: FathomIcon,
|
||||
file_v3: DocumentIcon,
|
||||
firecrawl: FirecrawlIcon,
|
||||
fireflies_v2: FirefliesIcon,
|
||||
@@ -265,6 +270,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 |
|
||||
|
||||
|
||||
135
apps/docs/content/docs/en/tools/fathom.mdx
Normal file
135
apps/docs/content/docs/en/tools/fathom.mdx
Normal file
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: Fathom
|
||||
description: Access meeting recordings, transcripts, and summaries
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="fathom"
|
||||
color="#181C1E"
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Fathom AI Notetaker into your workflow. List meetings, get transcripts and summaries, and manage team members and teams. Can also trigger workflows when new meeting content is ready.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `fathom_list_meetings`
|
||||
|
||||
List recent meetings recorded by the user or shared to their team.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Fathom API Key |
|
||||
| `includeSummary` | string | No | Include meeting summary \(true/false\) |
|
||||
| `includeTranscript` | string | No | Include meeting transcript \(true/false\) |
|
||||
| `includeActionItems` | string | No | Include action items \(true/false\) |
|
||||
| `includeCrmMatches` | string | No | Include linked CRM matches \(true/false\) |
|
||||
| `createdAfter` | string | No | Filter meetings created after this ISO 8601 timestamp |
|
||||
| `createdBefore` | string | No | Filter meetings created before this ISO 8601 timestamp |
|
||||
| `recordedBy` | string | No | Filter by recorder email address |
|
||||
| `teams` | string | No | Filter by team name |
|
||||
| `cursor` | string | No | Pagination cursor from a previous response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `meetings` | array | List of meetings |
|
||||
| ↳ `title` | string | Meeting title |
|
||||
| ↳ `recording_id` | number | Unique recording ID |
|
||||
| ↳ `url` | string | URL to view the meeting |
|
||||
| ↳ `share_url` | string | Shareable URL |
|
||||
| ↳ `created_at` | string | Creation timestamp |
|
||||
| ↳ `transcript_language` | string | Transcript language |
|
||||
| `next_cursor` | string | Pagination cursor for next page |
|
||||
|
||||
### `fathom_get_summary`
|
||||
|
||||
Get the call summary for a specific meeting recording.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Fathom API Key |
|
||||
| `recordingId` | string | Yes | The recording ID of the meeting |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `template_name` | string | Name of the summary template used |
|
||||
| `markdown_formatted` | string | Markdown-formatted summary text |
|
||||
|
||||
### `fathom_get_transcript`
|
||||
|
||||
Get the full transcript for a specific meeting recording.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Fathom API Key |
|
||||
| `recordingId` | string | Yes | The recording ID of the meeting |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `transcript` | array | Array of transcript entries with speaker, text, and timestamp |
|
||||
| ↳ `speaker` | object | Speaker information |
|
||||
| ↳ `display_name` | string | Speaker display name |
|
||||
| ↳ `matched_calendar_invitee_email` | string | Matched calendar invitee email |
|
||||
| ↳ `text` | string | Transcript text |
|
||||
| ↳ `timestamp` | string | Timestamp \(HH:MM:SS\) |
|
||||
|
||||
### `fathom_list_team_members`
|
||||
|
||||
List team members in your Fathom organization.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Fathom API Key |
|
||||
| `teams` | string | No | Team name to filter by |
|
||||
| `cursor` | string | No | Pagination cursor from a previous response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `members` | array | List of team members |
|
||||
| ↳ `name` | string | Team member name |
|
||||
| ↳ `email` | string | Team member email |
|
||||
| ↳ `created_at` | string | Date the member was added |
|
||||
| `next_cursor` | string | Pagination cursor for next page |
|
||||
|
||||
### `fathom_list_teams`
|
||||
|
||||
List teams in your Fathom organization.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | Fathom API Key |
|
||||
| `cursor` | string | No | Pagination cursor from a previous response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `teams` | array | List of teams |
|
||||
| ↳ `name` | string | Team name |
|
||||
| ↳ `created_at` | string | Date the team was created |
|
||||
| `next_cursor` | string | Pagination cursor for next page |
|
||||
|
||||
|
||||
@@ -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,7 +35,9 @@
|
||||
"elasticsearch",
|
||||
"elevenlabs",
|
||||
"enrich",
|
||||
"evernote",
|
||||
"exa",
|
||||
"fathom",
|
||||
"file",
|
||||
"firecrawl",
|
||||
"fireflies",
|
||||
@@ -98,6 +100,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\) |
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ These operations let your agents access and analyze Reddit content as part of yo
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Reddit into workflows. Read posts, comments, and search content. Submit posts, vote, reply, edit, and manage your Reddit account.
|
||||
Integrate Reddit into workflows. Read posts, comments, and search content. Submit posts, vote, reply, edit, manage messages, and access user and subreddit info.
|
||||
|
||||
|
||||
|
||||
@@ -39,14 +39,15 @@ Fetch posts from a subreddit with different sorting options
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `subreddit` | string | Yes | The subreddit to fetch posts from \(e.g., "technology", "news"\) |
|
||||
| `sort` | string | No | Sort method for posts \(e.g., "hot", "new", "top", "rising"\). Default: "hot" |
|
||||
| `sort` | string | No | Sort method for posts \(e.g., "hot", "new", "top", "rising", "controversial"\). Default: "hot" |
|
||||
| `limit` | number | No | Maximum number of posts to return \(e.g., 25\). Default: 10, max: 100 |
|
||||
| `time` | string | No | Time filter for "top" sorted posts: "day", "week", "month", "year", or "all" \(default: "day"\) |
|
||||
| `time` | string | No | Time filter for "top" sorted posts: "day", "week", "month", "year", or "all" \(default: "all"\) |
|
||||
| `after` | string | No | Fullname of a thing to fetch items after \(for pagination\) |
|
||||
| `before` | string | No | Fullname of a thing to fetch items before \(for pagination\) |
|
||||
| `count` | number | No | A count of items already seen in the listing \(used for numbering\) |
|
||||
| `show` | string | No | Show items that would normally be filtered \(e.g., "all"\) |
|
||||
| `sr_detail` | boolean | No | Expand subreddit details in the response |
|
||||
| `g` | string | No | Geo filter for posts \(e.g., "GLOBAL", "US", "AR", etc.\) |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -55,6 +56,7 @@ Fetch posts from a subreddit with different sorting options
|
||||
| `subreddit` | string | Name of the subreddit where posts were fetched from |
|
||||
| `posts` | array | Array of posts with title, author, URL, score, comments count, and metadata |
|
||||
| ↳ `id` | string | Post ID |
|
||||
| ↳ `name` | string | Thing fullname \(t3_xxxxx\) |
|
||||
| ↳ `title` | string | Post title |
|
||||
| ↳ `author` | string | Author username |
|
||||
| ↳ `url` | string | Post URL |
|
||||
@@ -66,6 +68,8 @@ Fetch posts from a subreddit with different sorting options
|
||||
| ↳ `selftext` | string | Text content for self posts |
|
||||
| ↳ `thumbnail` | string | Thumbnail URL |
|
||||
| ↳ `subreddit` | string | Subreddit name |
|
||||
| `after` | string | Fullname of the last item for forward pagination |
|
||||
| `before` | string | Fullname of the first item for backward pagination |
|
||||
|
||||
### `reddit_get_comments`
|
||||
|
||||
@@ -83,12 +87,9 @@ Fetch comments from a specific Reddit post
|
||||
| `context` | number | No | Number of parent comments to include |
|
||||
| `showedits` | boolean | No | Show edit information for comments |
|
||||
| `showmore` | boolean | No | Include "load more comments" elements in the response |
|
||||
| `showtitle` | boolean | No | Include submission title in the response |
|
||||
| `threaded` | boolean | No | Return comments in threaded/nested format |
|
||||
| `truncate` | number | No | Integer to truncate comment depth |
|
||||
| `after` | string | No | Fullname of a thing to fetch items after \(for pagination\) |
|
||||
| `before` | string | No | Fullname of a thing to fetch items before \(for pagination\) |
|
||||
| `count` | number | No | A count of items already seen in the listing \(used for numbering\) |
|
||||
| `comment` | string | No | ID36 of a comment to focus on \(returns that comment thread\) |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -96,6 +97,7 @@ Fetch comments from a specific Reddit post
|
||||
| --------- | ---- | ----------- |
|
||||
| `post` | object | Post information including ID, title, author, content, and metadata |
|
||||
| ↳ `id` | string | Post ID |
|
||||
| ↳ `name` | string | Thing fullname \(t3_xxxxx\) |
|
||||
| ↳ `title` | string | Post title |
|
||||
| ↳ `author` | string | Post author |
|
||||
| ↳ `selftext` | string | Post text content |
|
||||
@@ -104,6 +106,7 @@ Fetch comments from a specific Reddit post
|
||||
| ↳ `permalink` | string | Reddit permalink |
|
||||
| `comments` | array | Nested comments with author, body, score, timestamps, and replies |
|
||||
| ↳ `id` | string | Comment ID |
|
||||
| ↳ `name` | string | Thing fullname \(t1_xxxxx\) |
|
||||
| ↳ `author` | string | Comment author |
|
||||
| ↳ `body` | string | Comment text |
|
||||
| ↳ `score` | number | Comment score |
|
||||
@@ -135,6 +138,7 @@ Fetch controversial posts from a subreddit
|
||||
| `subreddit` | string | Name of the subreddit where posts were fetched from |
|
||||
| `posts` | array | Array of controversial posts with title, author, URL, score, comments count, and metadata |
|
||||
| ↳ `id` | string | Post ID |
|
||||
| ↳ `name` | string | Thing fullname \(t3_xxxxx\) |
|
||||
| ↳ `title` | string | Post title |
|
||||
| ↳ `author` | string | Author username |
|
||||
| ↳ `url` | string | Post URL |
|
||||
@@ -146,6 +150,8 @@ Fetch controversial posts from a subreddit
|
||||
| ↳ `selftext` | string | Text content for self posts |
|
||||
| ↳ `thumbnail` | string | Thumbnail URL |
|
||||
| ↳ `subreddit` | string | Subreddit name |
|
||||
| `after` | string | Fullname of the last item for forward pagination |
|
||||
| `before` | string | Fullname of the first item for backward pagination |
|
||||
|
||||
### `reddit_search`
|
||||
|
||||
@@ -165,6 +171,8 @@ Search for posts within a subreddit
|
||||
| `before` | string | No | Fullname of a thing to fetch items before \(for pagination\) |
|
||||
| `count` | number | No | A count of items already seen in the listing \(used for numbering\) |
|
||||
| `show` | string | No | Show items that would normally be filtered \(e.g., "all"\) |
|
||||
| `type` | string | No | Type of search results: "link" \(posts\), "sr" \(subreddits\), or "user" \(users\). Default: "link" |
|
||||
| `sr_detail` | boolean | No | Expand subreddit details in the response |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -173,6 +181,7 @@ Search for posts within a subreddit
|
||||
| `subreddit` | string | Name of the subreddit where search was performed |
|
||||
| `posts` | array | Array of search result posts with title, author, URL, score, comments count, and metadata |
|
||||
| ↳ `id` | string | Post ID |
|
||||
| ↳ `name` | string | Thing fullname \(t3_xxxxx\) |
|
||||
| ↳ `title` | string | Post title |
|
||||
| ↳ `author` | string | Author username |
|
||||
| ↳ `url` | string | Post URL |
|
||||
@@ -184,6 +193,8 @@ Search for posts within a subreddit
|
||||
| ↳ `selftext` | string | Text content for self posts |
|
||||
| ↳ `thumbnail` | string | Thumbnail URL |
|
||||
| ↳ `subreddit` | string | Subreddit name |
|
||||
| `after` | string | Fullname of the last item for forward pagination |
|
||||
| `before` | string | Fullname of the first item for backward pagination |
|
||||
|
||||
### `reddit_submit_post`
|
||||
|
||||
@@ -200,6 +211,9 @@ Submit a new post to a subreddit (text or link)
|
||||
| `nsfw` | boolean | No | Mark post as NSFW |
|
||||
| `spoiler` | boolean | No | Mark post as spoiler |
|
||||
| `send_replies` | boolean | No | Send reply notifications to inbox \(default: true\) |
|
||||
| `flair_id` | string | No | Flair template UUID for the post \(max 36 characters\) |
|
||||
| `flair_text` | string | No | Flair text to display on the post \(max 64 characters\) |
|
||||
| `collection_id` | string | No | Collection UUID to add the post to |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -264,6 +278,21 @@ Save a Reddit post or comment to your saved items
|
||||
| `posts` | json | Posts data |
|
||||
| `post` | json | Single post data |
|
||||
| `comments` | json | Comments data |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `message` | string | Result message |
|
||||
| `data` | json | Response data |
|
||||
| `after` | string | Pagination cursor \(next page\) |
|
||||
| `before` | string | Pagination cursor \(previous page\) |
|
||||
| `id` | string | Entity ID |
|
||||
| `name` | string | Entity fullname |
|
||||
| `messages` | json | Messages data |
|
||||
| `display_name` | string | Subreddit display name |
|
||||
| `subscribers` | number | Subscriber count |
|
||||
| `description` | string | Description text |
|
||||
| `link_karma` | number | Link karma |
|
||||
| `comment_karma` | number | Comment karma |
|
||||
| `total_karma` | number | Total karma |
|
||||
| `icon_img` | string | Icon image URL |
|
||||
|
||||
### `reddit_reply`
|
||||
|
||||
@@ -275,6 +304,7 @@ Add a comment reply to a Reddit post or comment
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `parent_id` | string | Yes | Thing fullname to reply to \(e.g., "t3_abc123" for post, "t1_def456" for comment\) |
|
||||
| `text` | string | Yes | Comment text in markdown format \(e.g., "Great post! Here is my **reply**"\) |
|
||||
| `return_rtjson` | boolean | No | Return response in Rich Text JSON format |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -345,4 +375,138 @@ Subscribe or unsubscribe from a subreddit
|
||||
| `success` | boolean | Whether the subscription action was successful |
|
||||
| `message` | string | Success or error message |
|
||||
|
||||
### `reddit_get_me`
|
||||
|
||||
Get information about the authenticated Reddit user
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | User ID |
|
||||
| `name` | string | Username |
|
||||
| `created_utc` | number | Account creation time in UTC epoch seconds |
|
||||
| `link_karma` | number | Total link karma |
|
||||
| `comment_karma` | number | Total comment karma |
|
||||
| `total_karma` | number | Combined total karma |
|
||||
| `is_gold` | boolean | Whether user has Reddit Premium |
|
||||
| `is_mod` | boolean | Whether user is a moderator |
|
||||
| `has_verified_email` | boolean | Whether email is verified |
|
||||
| `icon_img` | string | User avatar/icon URL |
|
||||
|
||||
### `reddit_get_user`
|
||||
|
||||
Get public profile information about any Reddit user by username
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `username` | string | Yes | Reddit username to look up \(e.g., "spez", "example_user"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | User ID |
|
||||
| `name` | string | Username |
|
||||
| `created_utc` | number | Account creation time in UTC epoch seconds |
|
||||
| `link_karma` | number | Total link karma |
|
||||
| `comment_karma` | number | Total comment karma |
|
||||
| `total_karma` | number | Combined total karma |
|
||||
| `is_gold` | boolean | Whether user has Reddit Premium |
|
||||
| `is_mod` | boolean | Whether user is a moderator |
|
||||
| `has_verified_email` | boolean | Whether email is verified |
|
||||
| `icon_img` | string | User avatar/icon URL |
|
||||
|
||||
### `reddit_send_message`
|
||||
|
||||
Send a private message to a Reddit user
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `to` | string | Yes | Recipient username \(e.g., "example_user"\) or subreddit \(e.g., "/r/subreddit"\) |
|
||||
| `subject` | string | Yes | Message subject \(max 100 characters\) |
|
||||
| `text` | string | Yes | Message body in markdown format |
|
||||
| `from_sr` | string | No | Subreddit name to send the message from \(requires moderator mail permission\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the message was sent successfully |
|
||||
| `message` | string | Success or error message |
|
||||
|
||||
### `reddit_get_messages`
|
||||
|
||||
Retrieve private messages from your Reddit inbox
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `where` | string | No | Message folder to retrieve: "inbox" \(all\), "unread", "sent", "messages" \(direct messages only\), "comments" \(comment replies\), "selfreply" \(self-post replies\), or "mentions" \(username mentions\). Default: "inbox" |
|
||||
| `limit` | number | No | Maximum number of messages to return \(e.g., 25\). Default: 25, max: 100 |
|
||||
| `after` | string | No | Fullname of a thing to fetch items after \(for pagination\) |
|
||||
| `before` | string | No | Fullname of a thing to fetch items before \(for pagination\) |
|
||||
| `mark` | boolean | No | Whether to mark fetched messages as read |
|
||||
| `count` | number | No | A count of items already seen in the listing \(used for numbering\) |
|
||||
| `show` | string | No | Show items that would normally be filtered \(e.g., "all"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `messages` | array | Array of messages with sender, recipient, subject, body, and metadata |
|
||||
| ↳ `id` | string | Message ID |
|
||||
| ↳ `name` | string | Thing fullname \(t4_xxxxx\) |
|
||||
| ↳ `author` | string | Sender username |
|
||||
| ↳ `dest` | string | Recipient username |
|
||||
| ↳ `subject` | string | Message subject |
|
||||
| ↳ `body` | string | Message body text |
|
||||
| ↳ `created_utc` | number | Creation time in UTC epoch seconds |
|
||||
| ↳ `new` | boolean | Whether the message is unread |
|
||||
| ↳ `was_comment` | boolean | Whether the message is a comment reply |
|
||||
| ↳ `context` | string | Context URL for comment replies |
|
||||
| ↳ `distinguished` | string | Distinction: null/"moderator"/"admin" |
|
||||
| `after` | string | Fullname of the last item for forward pagination |
|
||||
| `before` | string | Fullname of the first item for backward pagination |
|
||||
|
||||
### `reddit_get_subreddit_info`
|
||||
|
||||
Get metadata and information about a subreddit
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `subreddit` | string | Yes | The subreddit to get info about \(e.g., "technology", "programming", "news"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Subreddit ID |
|
||||
| `name` | string | Subreddit fullname \(t5_xxxxx\) |
|
||||
| `display_name` | string | Subreddit name without prefix |
|
||||
| `title` | string | Subreddit title |
|
||||
| `description` | string | Full subreddit description \(markdown\) |
|
||||
| `public_description` | string | Short public description |
|
||||
| `subscribers` | number | Number of subscribers |
|
||||
| `accounts_active` | number | Number of currently active users |
|
||||
| `created_utc` | number | Creation time in UTC epoch seconds |
|
||||
| `over18` | boolean | Whether the subreddit is NSFW |
|
||||
| `lang` | string | Primary language of the subreddit |
|
||||
| `subreddit_type` | string | Subreddit type: public, private, restricted, etc. |
|
||||
| `url` | string | Subreddit URL path \(e.g., /r/technology/\) |
|
||||
| `icon_img` | string | Subreddit icon URL |
|
||||
| `banner_img` | string | Subreddit banner URL |
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Slack
|
||||
description: Send, update, delete messages, add or remove reactions, manage canvases, get channel info and user presence in Slack
|
||||
description: Send, update, delete messages, manage views and modals, add or remove reactions, manage canvases, get channel info and user presence in Slack
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -39,7 +39,7 @@ If you encounter issues with the Slack integration, contact us at [help@sim.ai](
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
|
||||
Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, open/update/push modal views, publish Home tab views, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
|
||||
|
||||
|
||||
|
||||
@@ -590,6 +590,7 @@ List all users in a Slack workspace. Returns user profiles with names and avatar
|
||||
| ↳ `name` | string | Username \(handle\) |
|
||||
| ↳ `real_name` | string | Full real name |
|
||||
| ↳ `display_name` | string | Display name shown in Slack |
|
||||
| ↳ `email` | string | Email address \(requires users:read.email scope\) |
|
||||
| ↳ `is_bot` | boolean | Whether the user is a bot |
|
||||
| ↳ `is_admin` | boolean | Whether the user is a workspace admin |
|
||||
| ↳ `is_owner` | boolean | Whether the user is the workspace owner |
|
||||
@@ -629,6 +630,7 @@ Get detailed information about a specific Slack user by their user ID.
|
||||
| ↳ `title` | string | Job title |
|
||||
| ↳ `phone` | string | Phone number |
|
||||
| ↳ `skype` | string | Skype handle |
|
||||
| ↳ `email` | string | Email address \(requires users:read.email scope\) |
|
||||
| ↳ `is_bot` | boolean | Whether the user is a bot |
|
||||
| ↳ `is_admin` | boolean | Whether the user is a workspace admin |
|
||||
| ↳ `is_owner` | boolean | Whether the user is the workspace owner |
|
||||
@@ -923,4 +925,189 @@ Create a canvas pinned to a Slack channel as its resource hub
|
||||
| --------- | ---- | ----------- |
|
||||
| `canvas_id` | string | ID of the created channel canvas |
|
||||
|
||||
### `slack_open_view`
|
||||
|
||||
Open a modal view in Slack using a trigger_id from an interaction payload. Used to display forms, confirmations, and other interactive modals.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `triggerId` | string | Yes | Exchange a trigger to post to the user. Obtained from an interaction payload \(e.g., slash command, button click\) |
|
||||
| `interactivityPointer` | string | No | Alternative to trigger_id for posting to user |
|
||||
| `view` | json | Yes | A view payload object defining the modal. Must include type \("modal"\), title, and blocks array |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `view` | object | The opened modal view object |
|
||||
| ↳ `id` | string | Unique view identifier |
|
||||
| ↳ `team_id` | string | Workspace/team ID |
|
||||
| ↳ `type` | string | View type \(e.g., "modal"\) |
|
||||
| ↳ `title` | json | Plain text title object with type and text fields |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Title text content |
|
||||
| ↳ `submit` | json | Plain text submit button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Submit button text |
|
||||
| ↳ `close` | json | Plain text close button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Close button text |
|
||||
| ↳ `blocks` | array | Block Kit blocks in the view |
|
||||
| ↳ `type` | string | Block type \(section, divider, image, actions, etc.\) |
|
||||
| ↳ `block_id` | string | Unique block identifier |
|
||||
| ↳ `private_metadata` | string | Private metadata string passed with the view |
|
||||
| ↳ `callback_id` | string | Custom identifier for the view |
|
||||
| ↳ `external_id` | string | Custom external identifier \(max 255 chars, unique per workspace\) |
|
||||
| ↳ `state` | json | Current state of the view with input values |
|
||||
| ↳ `hash` | string | View version hash for updates |
|
||||
| ↳ `clear_on_close` | boolean | Whether to clear all views in the stack when this view is closed |
|
||||
| ↳ `notify_on_close` | boolean | Whether to send a view_closed event when this view is closed |
|
||||
| ↳ `root_view_id` | string | ID of the root view in the view stack |
|
||||
| ↳ `previous_view_id` | string | ID of the previous view in the view stack |
|
||||
| ↳ `app_id` | string | Application identifier |
|
||||
| ↳ `bot_id` | string | Bot identifier |
|
||||
|
||||
### `slack_update_view`
|
||||
|
||||
Update an existing modal view in Slack. Identify the view by view_id or external_id, and provide the updated view payload.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `viewId` | string | No | Unique identifier of the view to update. Either viewId or externalId is required |
|
||||
| `externalId` | string | No | Developer-set unique identifier of the view to update \(max 255 chars\). Either viewId or externalId is required |
|
||||
| `hash` | string | No | View state hash to protect against race conditions. Obtained from a previous views response |
|
||||
| `view` | json | Yes | A view payload object defining the updated modal. Must include type \("modal"\), title, and blocks array. Use identical block_id and action_id values to preserve input data |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `view` | object | The updated modal view object |
|
||||
| ↳ `id` | string | Unique view identifier |
|
||||
| ↳ `team_id` | string | Workspace/team ID |
|
||||
| ↳ `type` | string | View type \(e.g., "modal"\) |
|
||||
| ↳ `title` | json | Plain text title object with type and text fields |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Title text content |
|
||||
| ↳ `submit` | json | Plain text submit button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Submit button text |
|
||||
| ↳ `close` | json | Plain text close button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Close button text |
|
||||
| ↳ `blocks` | array | Block Kit blocks in the view |
|
||||
| ↳ `type` | string | Block type \(section, divider, image, actions, etc.\) |
|
||||
| ↳ `block_id` | string | Unique block identifier |
|
||||
| ↳ `private_metadata` | string | Private metadata string passed with the view |
|
||||
| ↳ `callback_id` | string | Custom identifier for the view |
|
||||
| ↳ `external_id` | string | Custom external identifier \(max 255 chars, unique per workspace\) |
|
||||
| ↳ `state` | json | Current state of the view with input values |
|
||||
| ↳ `hash` | string | View version hash for updates |
|
||||
| ↳ `clear_on_close` | boolean | Whether to clear all views in the stack when this view is closed |
|
||||
| ↳ `notify_on_close` | boolean | Whether to send a view_closed event when this view is closed |
|
||||
| ↳ `root_view_id` | string | ID of the root view in the view stack |
|
||||
| ↳ `previous_view_id` | string | ID of the previous view in the view stack |
|
||||
| ↳ `app_id` | string | Application identifier |
|
||||
| ↳ `bot_id` | string | Bot identifier |
|
||||
|
||||
### `slack_push_view`
|
||||
|
||||
Push a new view onto an existing modal stack in Slack. Limited to 2 additional views after the initial modal is opened.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `triggerId` | string | Yes | Exchange a trigger to post to the user. Obtained from an interaction payload \(e.g., button click within an existing modal\) |
|
||||
| `interactivityPointer` | string | No | Alternative to trigger_id for posting to user |
|
||||
| `view` | json | Yes | A view payload object defining the modal to push. Must include type \("modal"\), title, and blocks array |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `view` | object | The pushed modal view object |
|
||||
| ↳ `id` | string | Unique view identifier |
|
||||
| ↳ `team_id` | string | Workspace/team ID |
|
||||
| ↳ `type` | string | View type \(e.g., "modal"\) |
|
||||
| ↳ `title` | json | Plain text title object with type and text fields |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Title text content |
|
||||
| ↳ `submit` | json | Plain text submit button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Submit button text |
|
||||
| ↳ `close` | json | Plain text close button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Close button text |
|
||||
| ↳ `blocks` | array | Block Kit blocks in the view |
|
||||
| ↳ `type` | string | Block type \(section, divider, image, actions, etc.\) |
|
||||
| ↳ `block_id` | string | Unique block identifier |
|
||||
| ↳ `private_metadata` | string | Private metadata string passed with the view |
|
||||
| ↳ `callback_id` | string | Custom identifier for the view |
|
||||
| ↳ `external_id` | string | Custom external identifier \(max 255 chars, unique per workspace\) |
|
||||
| ↳ `state` | json | Current state of the view with input values |
|
||||
| ↳ `hash` | string | View version hash for updates |
|
||||
| ↳ `clear_on_close` | boolean | Whether to clear all views in the stack when this view is closed |
|
||||
| ↳ `notify_on_close` | boolean | Whether to send a view_closed event when this view is closed |
|
||||
| ↳ `root_view_id` | string | ID of the root view in the view stack |
|
||||
| ↳ `previous_view_id` | string | ID of the previous view in the view stack |
|
||||
| ↳ `app_id` | string | Application identifier |
|
||||
| ↳ `bot_id` | string | Bot identifier |
|
||||
|
||||
### `slack_publish_view`
|
||||
|
||||
Publish a static view to a user
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `authMethod` | string | No | Authentication method: oauth or bot_token |
|
||||
| `botToken` | string | No | Bot token for Custom Bot |
|
||||
| `userId` | string | Yes | The user ID to publish the Home tab view to \(e.g., U0BPQUNTA\) |
|
||||
| `hash` | string | No | View state hash to protect against race conditions. Obtained from a previous views response |
|
||||
| `view` | json | Yes | A view payload object defining the Home tab. Must include type \("home"\) and blocks array |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `view` | object | The published Home tab view object |
|
||||
| ↳ `id` | string | Unique view identifier |
|
||||
| ↳ `team_id` | string | Workspace/team ID |
|
||||
| ↳ `type` | string | View type \(e.g., "modal"\) |
|
||||
| ↳ `title` | json | Plain text title object with type and text fields |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Title text content |
|
||||
| ↳ `submit` | json | Plain text submit button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Submit button text |
|
||||
| ↳ `close` | json | Plain text close button object |
|
||||
| ↳ `type` | string | Text object type \(plain_text\) |
|
||||
| ↳ `text` | string | Close button text |
|
||||
| ↳ `blocks` | array | Block Kit blocks in the view |
|
||||
| ↳ `type` | string | Block type \(section, divider, image, actions, etc.\) |
|
||||
| ↳ `block_id` | string | Unique block identifier |
|
||||
| ↳ `private_metadata` | string | Private metadata string passed with the view |
|
||||
| ↳ `callback_id` | string | Custom identifier for the view |
|
||||
| ↳ `external_id` | string | Custom external identifier \(max 255 chars, unique per workspace\) |
|
||||
| ↳ `state` | json | Current state of the view with input values |
|
||||
| ↳ `hash` | string | View version hash for updates |
|
||||
| ↳ `clear_on_close` | boolean | Whether to clear all views in the stack when this view is closed |
|
||||
| ↳ `notify_on_close` | boolean | Whether to send a view_closed event when this view is closed |
|
||||
| ↳ `root_view_id` | string | ID of the root view in the view stack |
|
||||
| ↳ `previous_view_id` | string | ID of the previous view in the view stack |
|
||||
| ↳ `app_id` | string | Application identifier |
|
||||
| ↳ `bot_id` | string | Bot identifier |
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,40 +6,33 @@
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetSession,
|
||||
mockDb,
|
||||
mockLogger,
|
||||
mockParseProvider,
|
||||
mockEvaluateScopeCoverage,
|
||||
mockJwtDecode,
|
||||
mockEq,
|
||||
} = vi.hoisted(() => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn(),
|
||||
const { mockGetSession, mockDb, mockLogger, mockParseProvider, mockJwtDecode, mockEq } = vi.hoisted(
|
||||
() => {
|
||||
const db = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn(),
|
||||
}
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child: vi.fn(),
|
||||
}
|
||||
return {
|
||||
mockGetSession: vi.fn(),
|
||||
mockDb: db,
|
||||
mockLogger: logger,
|
||||
mockParseProvider: vi.fn(),
|
||||
mockJwtDecode: vi.fn(),
|
||||
mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
}
|
||||
}
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
child: vi.fn(),
|
||||
}
|
||||
return {
|
||||
mockGetSession: vi.fn(),
|
||||
mockDb: db,
|
||||
mockLogger: logger,
|
||||
mockParseProvider: vi.fn(),
|
||||
mockEvaluateScopeCoverage: vi.fn(),
|
||||
mockJwtDecode: vi.fn(),
|
||||
mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
@@ -66,7 +59,6 @@ vi.mock('@sim/logger', () => ({
|
||||
|
||||
vi.mock('@/lib/oauth/utils', () => ({
|
||||
parseProvider: mockParseProvider,
|
||||
evaluateScopeCoverage: mockEvaluateScopeCoverage,
|
||||
}))
|
||||
|
||||
import { GET } from '@/app/api/auth/oauth/connections/route'
|
||||
@@ -83,16 +75,6 @@ describe('OAuth Connections API Route', () => {
|
||||
baseProvider: providerId.split('-')[0] || providerId,
|
||||
featureType: providerId.split('-')[1] || 'default',
|
||||
}))
|
||||
|
||||
mockEvaluateScopeCoverage.mockImplementation(
|
||||
(_providerId: string, _grantedScopes: string[]) => ({
|
||||
canonicalScopes: ['email', 'profile'],
|
||||
grantedScopes: ['email', 'profile'],
|
||||
missingScopes: [],
|
||||
extraScopes: [],
|
||||
requiresReauthorization: false,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should return connections successfully', async () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import type { OAuthProvider } from '@/lib/oauth'
|
||||
import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth'
|
||||
import { parseProvider } from '@/lib/oauth'
|
||||
|
||||
const logger = createLogger('OAuthConnectionsAPI')
|
||||
|
||||
@@ -49,8 +49,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
for (const acc of accounts) {
|
||||
const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider)
|
||||
const grantedScopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
|
||||
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
|
||||
const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
|
||||
|
||||
if (baseProvider) {
|
||||
// Try multiple methods to get a user-friendly display name
|
||||
@@ -96,10 +95,6 @@ export async function GET(request: NextRequest) {
|
||||
const accountSummary = {
|
||||
id: acc.id,
|
||||
name: displayName,
|
||||
scopes: scopeEvaluation.grantedScopes,
|
||||
missingScopes: scopeEvaluation.missingScopes,
|
||||
extraScopes: scopeEvaluation.extraScopes,
|
||||
requiresReauthorization: scopeEvaluation.requiresReauthorization,
|
||||
}
|
||||
|
||||
if (existingConnection) {
|
||||
@@ -108,20 +103,8 @@ export async function GET(request: NextRequest) {
|
||||
existingConnection.accounts.push(accountSummary)
|
||||
|
||||
existingConnection.scopes = Array.from(
|
||||
new Set([...(existingConnection.scopes || []), ...scopeEvaluation.grantedScopes])
|
||||
new Set([...(existingConnection.scopes || []), ...scopes])
|
||||
)
|
||||
existingConnection.missingScopes = Array.from(
|
||||
new Set([...(existingConnection.missingScopes || []), ...scopeEvaluation.missingScopes])
|
||||
)
|
||||
existingConnection.extraScopes = Array.from(
|
||||
new Set([...(existingConnection.extraScopes || []), ...scopeEvaluation.extraScopes])
|
||||
)
|
||||
existingConnection.canonicalScopes =
|
||||
existingConnection.canonicalScopes && existingConnection.canonicalScopes.length > 0
|
||||
? existingConnection.canonicalScopes
|
||||
: scopeEvaluation.canonicalScopes
|
||||
existingConnection.requiresReauthorization =
|
||||
existingConnection.requiresReauthorization || scopeEvaluation.requiresReauthorization
|
||||
|
||||
const existingTimestamp = existingConnection.lastConnected
|
||||
? new Date(existingConnection.lastConnected).getTime()
|
||||
@@ -138,11 +121,7 @@ export async function GET(request: NextRequest) {
|
||||
baseProvider,
|
||||
featureType,
|
||||
isConnected: true,
|
||||
scopes: scopeEvaluation.grantedScopes,
|
||||
canonicalScopes: scopeEvaluation.canonicalScopes,
|
||||
missingScopes: scopeEvaluation.missingScopes,
|
||||
extraScopes: scopeEvaluation.extraScopes,
|
||||
requiresReauthorization: scopeEvaluation.requiresReauthorization,
|
||||
scopes,
|
||||
lastConnected: acc.updatedAt.toISOString(),
|
||||
accounts: [accountSummary],
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger } = vi.hoisted(() => {
|
||||
const { mockCheckSessionOrInternalAuth, mockLogger } = vi.hoisted(() => {
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
@@ -19,7 +19,6 @@ const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger }
|
||||
}
|
||||
return {
|
||||
mockCheckSessionOrInternalAuth: vi.fn(),
|
||||
mockEvaluateScopeCoverage: vi.fn(),
|
||||
mockLogger: logger,
|
||||
}
|
||||
})
|
||||
@@ -28,10 +27,6 @@ vi.mock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/oauth', () => ({
|
||||
evaluateScopeCoverage: mockEvaluateScopeCoverage,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/utils/request', () => ({
|
||||
generateRequestId: vi.fn().mockReturnValue('mock-request-id'),
|
||||
}))
|
||||
@@ -87,16 +82,6 @@ describe('OAuth Credentials API Route', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockEvaluateScopeCoverage.mockImplementation(
|
||||
(_providerId: string, grantedScopes: string[]) => ({
|
||||
canonicalScopes: grantedScopes,
|
||||
grantedScopes,
|
||||
missingScopes: [],
|
||||
extraScopes: [],
|
||||
requiresReauthorization: false,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle unauthenticated user', async () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
|
||||
import { evaluateScopeCoverage } from '@/lib/oauth'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
@@ -39,8 +38,7 @@ function toCredentialResponse(
|
||||
scope: string | null
|
||||
) {
|
||||
const storedScope = scope?.trim()
|
||||
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
|
||||
const scopeEvaluation = evaluateScopeCoverage(providerId, grantedScopes)
|
||||
const scopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
|
||||
const [_, featureType = 'default'] = providerId.split('-')
|
||||
|
||||
return {
|
||||
@@ -49,11 +47,7 @@ function toCredentialResponse(
|
||||
provider: providerId,
|
||||
lastUsed: updatedAt.toISOString(),
|
||||
isDefault: featureType === 'default',
|
||||
scopes: scopeEvaluation.grantedScopes,
|
||||
canonicalScopes: scopeEvaluation.canonicalScopes,
|
||||
missingScopes: scopeEvaluation.missingScopes,
|
||||
extraScopes: scopeEvaluation.extraScopes,
|
||||
requiresReauthorization: scopeEvaluation.requiresReauthorization,
|
||||
scopes,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
79
apps/sim/app/api/tools/airtable/bases/route.ts
Normal file
79
apps/sim/app/api/tools/airtable/bases/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('AirtableBasesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.airtable.com/v0/meta/bases', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Airtable bases', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Airtable bases', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const bases = (data.bases || []).map((base: { id: string; name: string }) => ({
|
||||
id: base.id,
|
||||
name: base.name,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ bases })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Airtable bases request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Airtable bases', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
95
apps/sim/app/api/tools/airtable/tables/route.ts
Normal file
95
apps/sim/app/api/tools/airtable/tables/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAirtableId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('AirtableTablesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, baseId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!baseId) {
|
||||
logger.error('Missing baseId in request')
|
||||
return NextResponse.json({ error: 'Base ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId')
|
||||
if (!baseIdValidation.isValid) {
|
||||
logger.error('Invalid baseId', { error: baseIdValidation.error })
|
||||
return NextResponse.json({ error: baseIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.airtable.com/v0/meta/bases/${baseIdValidation.sanitized}/tables`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Airtable tables', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
baseId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Airtable tables', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const tables = (data.tables || []).map((table: { id: string; name: string }) => ({
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ tables })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Airtable tables request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Airtable tables', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
79
apps/sim/app/api/tools/asana/workspaces/route.ts
Normal file
79
apps/sim/app/api/tools/asana/workspaces/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('AsanaWorkspacesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://app.asana.com/api/1.0/workspaces', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Asana workspaces', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Asana workspaces', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const workspaces = (data.data || []).map((workspace: { gid: string; name: string }) => ({
|
||||
id: workspace.gid,
|
||||
name: workspace.name,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ workspaces })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Asana workspaces request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Asana workspaces', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
79
apps/sim/app/api/tools/attio/lists/route.ts
Normal file
79
apps/sim/app/api/tools/attio/lists/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('AttioListsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.attio.com/v2/lists', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Attio lists', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Attio lists', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const lists = (data.data || []).map((list: { api_slug: string; name: string }) => ({
|
||||
id: list.api_slug,
|
||||
name: list.name,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ lists })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Attio lists request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Attio lists', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
79
apps/sim/app/api/tools/attio/objects/route.ts
Normal file
79
apps/sim/app/api/tools/attio/objects/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('AttioObjectsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.attio.com/v2/objects', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Attio objects', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Attio objects', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const objects = (data.data || []).map((obj: { api_slug: string; singular_noun: string }) => ({
|
||||
id: obj.api_slug,
|
||||
name: obj.singular_noun,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ objects })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Attio objects request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Attio objects', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
83
apps/sim/app/api/tools/calcom/event-types/route.ts
Normal file
83
apps/sim/app/api/tools/calcom/event-types/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('CalcomEventTypesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.cal.com/v2/event-types', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'cal-api-version': '2024-06-14',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Cal.com event types', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Cal.com event types', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const eventTypes = (data.data || []).map(
|
||||
(eventType: { id: number; title: string; slug: string }) => ({
|
||||
id: String(eventType.id),
|
||||
title: eventType.title,
|
||||
slug: eventType.slug,
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ eventTypes })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Cal.com event types request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Cal.com event types', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
80
apps/sim/app/api/tools/calcom/schedules/route.ts
Normal file
80
apps/sim/app/api/tools/calcom/schedules/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('CalcomSchedulesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.cal.com/v2/schedules', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'cal-api-version': '2024-06-11',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Cal.com schedules', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Cal.com schedules', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const schedules = (data.data || []).map((schedule: { id: number; name: string }) => ({
|
||||
id: String(schedule.id),
|
||||
name: schedule.name,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ schedules })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Cal.com schedules request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Cal.com schedules', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
96
apps/sim/app/api/tools/confluence/selector-spaces/route.ts
Normal file
96
apps/sim/app/api/tools/confluence/selector-spaces/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getConfluenceCloudId } from '@/tools/confluence/utils'
|
||||
|
||||
const logger = createLogger('ConfluenceSelectorSpacesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, domain } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const cloudId = await getConfluenceCloudId(domain, accessToken)
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces?limit=250`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
logger.error('Confluence API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorData,
|
||||
})
|
||||
const errorMessage =
|
||||
errorData?.message || `Failed to list Confluence spaces (${response.status})`
|
||||
return NextResponse.json({ error: errorMessage }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const spaces = (data.results || []).map((space: { id: string; name: string; key: string }) => ({
|
||||
id: space.id,
|
||||
name: space.name,
|
||||
key: space.key,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ spaces })
|
||||
} catch (error) {
|
||||
logger.error('Error listing Confluence spaces:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ 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 })
|
||||
}
|
||||
}
|
||||
100
apps/sim/app/api/tools/google_bigquery/datasets/route.ts
Normal file
100
apps/sim/app/api/tools/google_bigquery/datasets/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('GoogleBigQueryDatasetsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* POST /api/tools/google_bigquery/datasets
|
||||
*
|
||||
* Fetches the list of BigQuery datasets for a given project using the caller's OAuth credential.
|
||||
*
|
||||
* @param request - Incoming request containing `credential`, `workflowId`, and `projectId` in the JSON body
|
||||
* @returns JSON response with a `datasets` array, each entry containing `datasetReference` and optional `friendlyName`
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, projectId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
logger.error('Missing project ID in request')
|
||||
return NextResponse.json({ error: 'Project ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets?maxResults=200`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch BigQuery datasets', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch BigQuery datasets', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const datasets = (data.datasets || []).map(
|
||||
(ds: {
|
||||
datasetReference: { datasetId: string; projectId: string }
|
||||
friendlyName?: string
|
||||
}) => ({
|
||||
datasetReference: ds.datasetReference,
|
||||
friendlyName: ds.friendlyName,
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ datasets })
|
||||
} catch (error) {
|
||||
logger.error('Error processing BigQuery datasets request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve BigQuery datasets', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
94
apps/sim/app/api/tools/google_bigquery/tables/route.ts
Normal file
94
apps/sim/app/api/tools/google_bigquery/tables/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('GoogleBigQueryTablesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, projectId, datasetId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
logger.error('Missing project ID in request')
|
||||
return NextResponse.json({ error: 'Project ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!datasetId) {
|
||||
logger.error('Missing dataset ID in request')
|
||||
return NextResponse.json({ error: 'Dataset ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets/${encodeURIComponent(datasetId)}/tables?maxResults=200`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch BigQuery tables', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch BigQuery tables', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const tables = (data.tables || []).map(
|
||||
(t: { tableReference: { tableId: string }; friendlyName?: string }) => ({
|
||||
tableReference: t.tableReference,
|
||||
friendlyName: t.friendlyName,
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ tables })
|
||||
} catch (error) {
|
||||
logger.error('Error processing BigQuery tables request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve BigQuery tables', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
79
apps/sim/app/api/tools/google_tasks/task-lists/route.ts
Normal file
79
apps/sim/app/api/tools/google_tasks/task-lists/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('GoogleTasksTaskListsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://tasks.googleapis.com/tasks/v1/users/@me/lists', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Google Tasks task lists', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Google Tasks task lists', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const taskLists = (data.items || []).map((list: { id: string; title: string }) => ({
|
||||
id: list.id,
|
||||
title: list.title,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ taskLists })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Google Tasks task lists request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Google Tasks task lists', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
103
apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts
Normal file
103
apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
||||
|
||||
const logger = createLogger('JsmSelectorRequestTypesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, domain, serviceDeskId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!serviceDeskId) {
|
||||
return NextResponse.json({ error: 'Service Desk ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
|
||||
if (!serviceDeskIdValidation.isValid) {
|
||||
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const cloudId = await getJiraCloudId(domain, accessToken)
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const baseUrl = getJsmApiBaseUrl(cloudIdValidation.sanitized!)
|
||||
const url = `${baseUrl}/servicedesk/${serviceDeskIdValidation.sanitized}/requesttype?limit=100`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: getJsmHeaders(accessToken),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error('JSM API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: `JSM API error: ${response.status} ${response.statusText}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const requestTypes = (data.values || []).map((rt: { id: string; name: string }) => ({
|
||||
id: rt.id,
|
||||
name: rt.name,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ requestTypes })
|
||||
} catch (error) {
|
||||
logger.error('Error listing JSM request types:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
94
apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts
Normal file
94
apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
|
||||
|
||||
const logger = createLogger('JsmSelectorServiceDesksAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, domain } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const cloudId = await getJiraCloudId(domain, accessToken)
|
||||
|
||||
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
|
||||
if (!cloudIdValidation.isValid) {
|
||||
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const baseUrl = getJsmApiBaseUrl(cloudIdValidation.sanitized!)
|
||||
const url = `${baseUrl}/servicedesk?limit=100`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: getJsmHeaders(accessToken),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error('JSM API error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: `JSM API error: ${response.status} ${response.statusText}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const serviceDesks = (data.values || []).map((sd: { id: string; projectName: string }) => ({
|
||||
id: sd.id,
|
||||
name: sd.projectName,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ serviceDesks })
|
||||
} catch (error) {
|
||||
logger.error('Error listing JSM service desks:', error)
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
72
apps/sim/app/api/tools/microsoft_planner/plans/route.ts
Normal file
72
apps/sim/app/api/tools/microsoft_planner/plans/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('MicrosoftPlannerPlansAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error(`[${requestId}] Missing credential in request`)
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to obtain valid access token`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to obtain valid access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://graph.microsoft.com/v1.0/me/planner/plans', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error(`[${requestId}] Microsoft Graph API error:`, errorText)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch plans from Microsoft Graph' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const plans = data.value || []
|
||||
|
||||
const filteredPlans = plans.map((plan: { id: string; title: string }) => ({
|
||||
id: plan.id,
|
||||
title: plan.title,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ plans: filteredPlans })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching Microsoft Planner plans:`, error)
|
||||
return NextResponse.json({ error: 'Failed to fetch plans' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,29 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import type { PlannerTask } from '@/tools/microsoft_planner/types'
|
||||
|
||||
const logger = createLogger('MicrosoftPlannerTasksAPI')
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, planId } = body
|
||||
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthenticated request rejected`)
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const planId = searchParams.get('planId')
|
||||
|
||||
if (!credentialId) {
|
||||
logger.error(`[${requestId}] Missing credentialId parameter`)
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
if (!credential) {
|
||||
logger.error(`[${requestId}] Missing credential in request`)
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!planId) {
|
||||
logger.error(`[${requestId}] Missing planId parameter`)
|
||||
logger.error(`[${requestId}] Missing planId in request`)
|
||||
return NextResponse.json({ error: 'Plan ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
@@ -42,52 +33,35 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: planIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (resolved.workspaceId) {
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
const perm = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
resolved.workspaceId
|
||||
)
|
||||
if (perm === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const accountRow = credentials[0]
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to obtain valid access token`)
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to obtain valid access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(`https://graph.microsoft.com/v1.0/planner/plans/${planId}/tasks`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
const response = await fetch(
|
||||
`https://graph.microsoft.com/v1.0/planner/plans/${planIdValidation.sanitized}/tasks`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
86
apps/sim/app/api/tools/notion/databases/route.ts
Normal file
86
apps/sim/app/api/tools/notion/databases/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { extractTitleFromItem } from '@/tools/notion/utils'
|
||||
|
||||
const logger = createLogger('NotionDatabasesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.notion.com/v1/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Notion-Version': '2022-06-28',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filter: { value: 'database', property: 'object' },
|
||||
page_size: 100,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Notion databases', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Notion databases', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const databases = (data.results || []).map((db: Record<string, unknown>) => ({
|
||||
id: db.id as string,
|
||||
name: extractTitleFromItem(db),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ databases })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Notion databases request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Notion databases', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
86
apps/sim/app/api/tools/notion/pages/route.ts
Normal file
86
apps/sim/app/api/tools/notion/pages/route.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { extractTitleFromItem } from '@/tools/notion/utils'
|
||||
|
||||
const logger = createLogger('NotionPagesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.notion.com/v1/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Notion-Version': '2022-06-28',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filter: { value: 'page', property: 'object' },
|
||||
page_size: 100,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Notion pages', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Notion pages', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const pages = (data.results || []).map((page: Record<string, unknown>) => ({
|
||||
id: page.id as string,
|
||||
name: extractTitleFromItem(page),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ pages })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Notion pages request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Notion pages', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
79
apps/sim/app/api/tools/pipedrive/pipelines/route.ts
Normal file
79
apps/sim/app/api/tools/pipedrive/pipelines/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('PipedrivePipelinesAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.pipedrive.com/v1/pipelines', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Pipedrive pipelines', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Pipedrive pipelines', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const pipelines = (data.data || []).map((pipeline: { id: number; name: string }) => ({
|
||||
id: String(pipeline.id),
|
||||
name: pipeline.name,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ pipelines })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Pipedrive pipelines request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Pipedrive pipelines', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
91
apps/sim/app/api/tools/sharepoint/lists/route.ts
Normal file
91
apps/sim/app/api/tools/sharepoint/lists/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateSharePointSiteId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SharePointListsAPI')
|
||||
|
||||
interface SharePointList {
|
||||
id: string
|
||||
displayName: string
|
||||
description?: string
|
||||
webUrl?: string
|
||||
list?: {
|
||||
hidden?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, siteId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error(`[${requestId}] Missing credential in request`)
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const siteIdValidation = validateSharePointSiteId(siteId)
|
||||
if (!siteIdValidation.isValid) {
|
||||
logger.error(`[${requestId}] Invalid siteId: ${siteIdValidation.error}`)
|
||||
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error(`[${requestId}] Failed to obtain valid access token`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to obtain valid access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const url = `https://graph.microsoft.com/v1.0/sites/${siteIdValidation.sanitized}/lists?$select=id,displayName,description,webUrl&$expand=list($select=hidden)&$top=100`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
|
||||
return NextResponse.json(
|
||||
{ error: errorData.error?.message || 'Failed to fetch lists from SharePoint' },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const lists = (data.value || [])
|
||||
.filter((list: SharePointList) => list.list?.hidden !== true)
|
||||
.map((list: SharePointList) => ({
|
||||
id: list.id,
|
||||
displayName: list.displayName,
|
||||
}))
|
||||
|
||||
logger.info(`[${requestId}] Successfully fetched ${lists.length} SharePoint lists`)
|
||||
return NextResponse.json({ lists }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching lists from SharePoint`, error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,79 +1,45 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import type { SharepointSite } from '@/tools/sharepoint/types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SharePointSitesAPI')
|
||||
|
||||
/**
|
||||
* Get SharePoint sites from Microsoft Graph API
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
const body = await request.json()
|
||||
const { credential, workflowId, query } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error(`[${requestId}] Missing credential in request`)
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const query = searchParams.get('query') || ''
|
||||
|
||||
if (!credentialId) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255)
|
||||
if (!credentialIdValidation.isValid) {
|
||||
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
|
||||
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
const resolved = await resolveOAuthAccountId(credentialId)
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (resolved.workspaceId) {
|
||||
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
|
||||
const perm = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
resolved.workspaceId
|
||||
)
|
||||
if (perm === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(eq(account.id, resolved.accountId))
|
||||
.limit(1)
|
||||
if (!credentials.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const accountRow = credentials[0]
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
resolved.accountId,
|
||||
accountRow.userId,
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
logger.error(`[${requestId}] Failed to obtain valid access token`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to obtain valid access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const searchQuery = query || '*'
|
||||
|
||||
87
apps/sim/app/api/tools/trello/boards/route.ts
Normal file
87
apps/sim/app/api/tools/trello/boards/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('TrelloBoardsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const apiKey = process.env.TRELLO_API_KEY
|
||||
if (!apiKey) {
|
||||
logger.error('Trello API key not configured')
|
||||
return NextResponse.json({ error: 'Trello API key not configured' }, { status: 500 })
|
||||
}
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.trello.com/1/members/me/boards?key=${apiKey}&token=${accessToken}&fields=id,name,closed`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Trello boards', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Trello boards', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const boards = (data || []).map((board: { id: string; name: string; closed: boolean }) => ({
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
closed: board.closed,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ boards })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Trello boards request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Trello boards', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
82
apps/sim/app/api/tools/zoom/meetings/route.ts
Normal file
82
apps/sim/app/api/tools/zoom/meetings/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('ZoomMeetingsAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const requestId = generateRequestId()
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
if (!credential) {
|
||||
logger.error('Missing credential in request')
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Could not retrieve access token', authRequired: true },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
'https://api.zoom.us/v2/users/me/meetings?page_size=300&type=scheduled',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
logger.error('Failed to fetch Zoom meetings', {
|
||||
status: response.status,
|
||||
error: errorData,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch Zoom meetings', details: errorData },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const meetings = (data.meetings || []).map((meeting: { id: number; topic: string }) => ({
|
||||
id: String(meeting.id),
|
||||
name: meeting.topic,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ meetings })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Zoom meetings request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Zoom meetings', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
getProviderIdFromServiceId,
|
||||
getScopeDescription,
|
||||
OAUTH_PROVIDERS,
|
||||
type OAuthProvider,
|
||||
parseProvider,
|
||||
@@ -33,318 +34,6 @@ export interface OAuthRequiredModalProps {
|
||||
onConnect?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'https://www.googleapis.com/auth/gmail.send': 'Send emails',
|
||||
'https://www.googleapis.com/auth/gmail.labels': 'View and manage email labels',
|
||||
'https://www.googleapis.com/auth/gmail.modify': 'View and manage email messages',
|
||||
'https://www.googleapis.com/auth/drive.file': 'View and manage Google Drive files',
|
||||
'https://www.googleapis.com/auth/drive': 'Access all Google Drive files',
|
||||
'https://www.googleapis.com/auth/calendar': 'View and manage calendar',
|
||||
'https://www.googleapis.com/auth/contacts': 'View and manage Google Contacts',
|
||||
'https://www.googleapis.com/auth/tasks': 'Create, read, update, and delete Google Tasks',
|
||||
'https://www.googleapis.com/auth/userinfo.email': 'View email address',
|
||||
'https://www.googleapis.com/auth/userinfo.profile': 'View basic profile info',
|
||||
'https://www.googleapis.com/auth/forms.body': 'View and manage Google Forms',
|
||||
'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to Google Forms',
|
||||
'https://www.googleapis.com/auth/bigquery': 'View and manage data in Google BigQuery',
|
||||
'https://www.googleapis.com/auth/ediscovery': 'Access Google Vault for eDiscovery',
|
||||
'https://www.googleapis.com/auth/devstorage.read_only': 'Read files from Google Cloud Storage',
|
||||
'https://www.googleapis.com/auth/admin.directory.group': 'Manage Google Workspace groups',
|
||||
'https://www.googleapis.com/auth/admin.directory.group.member':
|
||||
'Manage Google Workspace group memberships',
|
||||
'https://www.googleapis.com/auth/admin.directory.group.readonly': 'View Google Workspace groups',
|
||||
'https://www.googleapis.com/auth/admin.directory.group.member.readonly':
|
||||
'View Google Workspace group memberships',
|
||||
'https://www.googleapis.com/auth/meetings.space.created':
|
||||
'Create and manage Google Meet meeting spaces',
|
||||
'https://www.googleapis.com/auth/meetings.space.readonly':
|
||||
'View Google Meet meeting space details',
|
||||
'https://www.googleapis.com/auth/cloud-platform':
|
||||
'Full access to Google Cloud resources for Vertex AI',
|
||||
'read:confluence-content.all': 'Read all Confluence content',
|
||||
'read:confluence-space.summary': 'Read Confluence space information',
|
||||
'read:space:confluence': 'View Confluence spaces',
|
||||
'read:space-details:confluence': 'View detailed Confluence space information',
|
||||
'write:confluence-content': 'Create and edit Confluence pages',
|
||||
'write:confluence-space': 'Manage Confluence spaces',
|
||||
'write:confluence-file': 'Upload files to Confluence',
|
||||
'read:content:confluence': 'Read Confluence content',
|
||||
'read:page:confluence': 'View Confluence pages',
|
||||
'write:page:confluence': 'Create and update Confluence pages',
|
||||
'read:comment:confluence': 'View comments on Confluence pages',
|
||||
'write:comment:confluence': 'Create and update comments',
|
||||
'delete:comment:confluence': 'Delete comments from Confluence pages',
|
||||
'read:attachment:confluence': 'View attachments on Confluence pages',
|
||||
'write:attachment:confluence': 'Upload and manage attachments',
|
||||
'delete:attachment:confluence': 'Delete attachments from Confluence pages',
|
||||
'delete:page:confluence': 'Delete Confluence pages',
|
||||
'read:label:confluence': 'View labels on Confluence content',
|
||||
'write:label:confluence': 'Add and remove labels',
|
||||
'search:confluence': 'Search Confluence content',
|
||||
'readonly:content.attachment:confluence': 'View attachments',
|
||||
'read:blogpost:confluence': 'View Confluence blog posts',
|
||||
'write:blogpost:confluence': 'Create and update Confluence blog posts',
|
||||
'read:content.property:confluence': 'View properties on Confluence content',
|
||||
'write:content.property:confluence': 'Create and manage content properties',
|
||||
'read:hierarchical-content:confluence': 'View page hierarchy (children and ancestors)',
|
||||
'read:content.metadata:confluence': 'View content metadata (required for ancestors)',
|
||||
'read:user:confluence': 'View Confluence user profiles',
|
||||
'read:task:confluence': 'View Confluence inline tasks',
|
||||
'write:task:confluence': 'Update Confluence inline tasks',
|
||||
'delete:blogpost:confluence': 'Delete Confluence blog posts',
|
||||
'write:space:confluence': 'Create and update Confluence spaces',
|
||||
'delete:space:confluence': 'Delete Confluence spaces',
|
||||
'read:space.property:confluence': 'View Confluence space properties',
|
||||
'write:space.property:confluence': 'Create and manage space properties',
|
||||
'read:space.permission:confluence': 'View Confluence space permissions',
|
||||
'read:me': 'Read profile information',
|
||||
'database.read': 'Read database',
|
||||
'database.write': 'Write to database',
|
||||
'projects.read': 'Read projects',
|
||||
offline_access: 'Access account when not using the application',
|
||||
repo: 'Access repositories',
|
||||
workflow: 'Manage repository workflows',
|
||||
'read:user': 'Read public user information',
|
||||
'user:email': 'Access email address',
|
||||
'tweet.read': 'Read tweets and timeline',
|
||||
'tweet.write': 'Post and delete tweets',
|
||||
'tweet.moderate.write': 'Hide and unhide replies to tweets',
|
||||
'users.read': 'Read user profiles and account information',
|
||||
'follows.read': 'View followers and following lists',
|
||||
'follows.write': 'Follow and unfollow users',
|
||||
'bookmark.read': 'View bookmarked tweets',
|
||||
'bookmark.write': 'Add and remove bookmarks',
|
||||
'like.read': 'View liked tweets and liking users',
|
||||
'like.write': 'Like and unlike tweets',
|
||||
'block.read': 'View blocked users',
|
||||
'block.write': 'Block and unblock users',
|
||||
'mute.read': 'View muted users',
|
||||
'mute.write': 'Mute and unmute users',
|
||||
'offline.access': 'Access account when not using the application',
|
||||
'data.records:read': 'Read records',
|
||||
'data.records:write': 'Write to records',
|
||||
'schema.bases:read': 'View bases and tables',
|
||||
'webhook:manage': 'Manage webhooks',
|
||||
'page.read': 'Read Notion pages',
|
||||
'page.write': 'Write to Notion pages',
|
||||
'workspace.content': 'Read Notion content',
|
||||
'workspace.name': 'Read Notion workspace name',
|
||||
'workspace.read': 'Read Notion workspace',
|
||||
'workspace.write': 'Write to Notion workspace',
|
||||
'user.email:read': 'Read email address',
|
||||
'read:jira-user': 'Read Jira user',
|
||||
'read:jira-work': 'Read Jira work',
|
||||
'write:jira-work': 'Write to Jira work',
|
||||
'manage:jira-webhook': 'Register and manage Jira webhooks',
|
||||
'read:webhook:jira': 'View Jira webhooks',
|
||||
'write:webhook:jira': 'Create and update Jira webhooks',
|
||||
'delete:webhook:jira': 'Delete Jira webhooks',
|
||||
'read:issue-event:jira': 'Read Jira issue events',
|
||||
'write:issue:jira': 'Write to Jira issues',
|
||||
'read:project:jira': 'Read Jira projects',
|
||||
'read:issue-type:jira': 'Read Jira issue types',
|
||||
'read:issue-meta:jira': 'Read Jira issue meta',
|
||||
'read:issue-security-level:jira': 'Read Jira issue security level',
|
||||
'read:issue.vote:jira': 'Read Jira issue votes',
|
||||
'read:issue.changelog:jira': 'Read Jira issue changelog',
|
||||
'read:avatar:jira': 'Read Jira avatar',
|
||||
'read:issue:jira': 'Read Jira issues',
|
||||
'read:status:jira': 'Read Jira status',
|
||||
'read:user:jira': 'Read Jira user',
|
||||
'read:field-configuration:jira': 'Read Jira field configuration',
|
||||
'read:issue-details:jira': 'Read Jira issue details',
|
||||
'read:field:jira': 'Read Jira field configurations',
|
||||
'read:jql:jira': 'Use JQL to filter Jira issues',
|
||||
'read:comment.property:jira': 'Read Jira comment properties',
|
||||
'read:issue.property:jira': 'Read Jira issue properties',
|
||||
'delete:issue:jira': 'Delete Jira issues',
|
||||
'write:comment:jira': 'Add and update comments on Jira issues',
|
||||
'read:comment:jira': 'Read comments on Jira issues',
|
||||
'delete:comment:jira': 'Delete comments from Jira issues',
|
||||
'read:attachment:jira': 'Read attachments from Jira issues',
|
||||
'delete:attachment:jira': 'Delete attachments from Jira issues',
|
||||
'write:issue-worklog:jira': 'Add and update worklog entries on Jira issues',
|
||||
'read:issue-worklog:jira': 'Read worklog entries from Jira issues',
|
||||
'delete:issue-worklog:jira': 'Delete worklog entries from Jira issues',
|
||||
'write:issue-link:jira': 'Create links between Jira issues',
|
||||
'delete:issue-link:jira': 'Delete links between Jira issues',
|
||||
'User.Read': 'Read Microsoft user',
|
||||
'Chat.Read': 'Read Microsoft chats',
|
||||
'Chat.ReadWrite': 'Write to Microsoft chats',
|
||||
'Chat.ReadBasic': 'Read Microsoft chats',
|
||||
'ChatMessage.Send': 'Send chat messages',
|
||||
'Channel.ReadBasic.All': 'Read Microsoft channels',
|
||||
'ChannelMessage.Send': 'Write to Microsoft channels',
|
||||
'ChannelMessage.Read.All': 'Read Microsoft channels',
|
||||
'ChannelMessage.ReadWrite': 'Read and write to Microsoft channels',
|
||||
'ChannelMember.Read.All': 'Read team channel members',
|
||||
'Group.Read.All': 'Read Microsoft groups',
|
||||
'Group.ReadWrite.All': 'Write to Microsoft groups',
|
||||
'Team.ReadBasic.All': 'Read Microsoft teams',
|
||||
'TeamMember.Read.All': 'Read team members',
|
||||
'Mail.ReadWrite': 'Write to Microsoft emails',
|
||||
'Mail.ReadBasic': 'Read Microsoft emails',
|
||||
'Mail.Read': 'Read Microsoft emails',
|
||||
'Mail.Send': 'Send emails',
|
||||
'Files.Read': 'Read OneDrive files',
|
||||
'Files.ReadWrite': 'Read and write OneDrive files',
|
||||
'Tasks.ReadWrite': 'Read and manage Planner tasks',
|
||||
'Sites.Read.All': 'Read Sharepoint sites',
|
||||
'Sites.ReadWrite.All': 'Read and write Sharepoint sites',
|
||||
'Sites.Manage.All': 'Manage Sharepoint sites',
|
||||
openid: 'Standard authentication',
|
||||
profile: 'Access profile information',
|
||||
email: 'Access email address',
|
||||
identify: 'Read Discord user',
|
||||
bot: 'Read Discord bot',
|
||||
'messages.read': 'Read Discord messages',
|
||||
guilds: 'Read Discord guilds',
|
||||
'guilds.members.read': 'Read Discord guild members',
|
||||
identity: 'Access Reddit identity',
|
||||
submit: 'Submit posts and comments',
|
||||
vote: 'Vote on posts and comments',
|
||||
save: 'Save and unsave posts and comments',
|
||||
edit: 'Edit posts and comments',
|
||||
subscribe: 'Subscribe and unsubscribe from subreddits',
|
||||
history: 'Access Reddit history',
|
||||
privatemessages: 'Access inbox and send private messages',
|
||||
account: 'Update account preferences and settings',
|
||||
mysubreddits: 'Access subscribed and moderated subreddits',
|
||||
flair: 'Manage user and post flair',
|
||||
report: 'Report posts and comments for rule violations',
|
||||
modposts: 'Approve, remove, and moderate posts in moderated subreddits',
|
||||
modflair: 'Manage flair in moderated subreddits',
|
||||
modmail: 'Access and respond to moderator mail',
|
||||
login: 'Access Wealthbox account',
|
||||
data: 'Access Wealthbox data',
|
||||
read: 'Read access to workspace',
|
||||
write: 'Write access to Linear workspace',
|
||||
'channels:read': 'View public channels',
|
||||
'channels:history': 'Read channel messages',
|
||||
'groups:read': 'View private channels',
|
||||
'groups:history': 'Read private messages',
|
||||
'chat:write': 'Send messages',
|
||||
'chat:write.public': 'Post to public channels',
|
||||
'im:write': 'Send direct messages',
|
||||
'im:history': 'Read direct message history',
|
||||
'im:read': 'View direct message channels',
|
||||
'users:read': 'View workspace users',
|
||||
'files:write': 'Upload files',
|
||||
'files:read': 'Download and read files',
|
||||
'canvases:write': 'Create canvas documents',
|
||||
'reactions:write': 'Add emoji reactions to messages',
|
||||
'sites:read': 'View Webflow sites',
|
||||
'sites:write': 'Manage webhooks and site settings',
|
||||
'cms:read': 'View CMS content',
|
||||
'cms:write': 'Manage CMS content',
|
||||
'crm.objects.contacts.read': 'Read HubSpot contacts',
|
||||
'crm.objects.contacts.write': 'Create and update HubSpot contacts',
|
||||
'crm.objects.companies.read': 'Read HubSpot companies',
|
||||
'crm.objects.companies.write': 'Create and update HubSpot companies',
|
||||
'crm.objects.deals.read': 'Read HubSpot deals',
|
||||
'crm.objects.deals.write': 'Create and update HubSpot deals',
|
||||
'crm.objects.owners.read': 'Read HubSpot object owners',
|
||||
'crm.objects.users.read': 'Read HubSpot users',
|
||||
'crm.objects.users.write': 'Create and update HubSpot users',
|
||||
'crm.objects.marketing_events.read': 'Read HubSpot marketing events',
|
||||
'crm.objects.marketing_events.write': 'Create and update HubSpot marketing events',
|
||||
'crm.objects.line_items.read': 'Read HubSpot line items',
|
||||
'crm.objects.line_items.write': 'Create and update HubSpot line items',
|
||||
'crm.objects.quotes.read': 'Read HubSpot quotes',
|
||||
'crm.objects.quotes.write': 'Create and update HubSpot quotes',
|
||||
'crm.objects.appointments.read': 'Read HubSpot appointments',
|
||||
'crm.objects.appointments.write': 'Create and update HubSpot appointments',
|
||||
'crm.objects.carts.read': 'Read HubSpot shopping carts',
|
||||
'crm.objects.carts.write': 'Create and update HubSpot shopping carts',
|
||||
'crm.import': 'Import data into HubSpot',
|
||||
'crm.lists.read': 'Read HubSpot lists',
|
||||
'crm.lists.write': 'Create and update HubSpot lists',
|
||||
tickets: 'Manage HubSpot tickets',
|
||||
api: 'Access Salesforce API',
|
||||
refresh_token: 'Maintain long-term access to Salesforce account',
|
||||
default: 'Access Asana workspace',
|
||||
base: 'Basic access to Pipedrive account',
|
||||
'deals:read': 'Read Pipedrive deals',
|
||||
'deals:full': 'Full access to manage Pipedrive deals',
|
||||
'contacts:read': 'Read Pipedrive contacts',
|
||||
'contacts:full': 'Full access to manage Pipedrive contacts',
|
||||
'leads:read': 'Read Pipedrive leads',
|
||||
'leads:full': 'Full access to manage Pipedrive leads',
|
||||
'activities:read': 'Read Pipedrive activities',
|
||||
'activities:full': 'Full access to manage Pipedrive activities',
|
||||
'mail:read': 'Read Pipedrive emails',
|
||||
'mail:full': 'Full access to manage Pipedrive emails',
|
||||
'projects:read': 'Read Pipedrive projects',
|
||||
'projects:full': 'Full access to manage Pipedrive projects',
|
||||
'webhooks:read': 'Read Pipedrive webhooks',
|
||||
'webhooks:full': 'Full access to manage Pipedrive webhooks',
|
||||
w_member_social: 'Access LinkedIn profile',
|
||||
// Box scopes
|
||||
root_readwrite: 'Read and write all files and folders in Box account',
|
||||
root_readonly: 'Read all files and folders in Box account',
|
||||
// Shopify scopes (write_* implicitly includes read access)
|
||||
write_products: 'Read and manage Shopify products',
|
||||
write_orders: 'Read and manage Shopify orders',
|
||||
write_customers: 'Read and manage Shopify customers',
|
||||
write_inventory: 'Read and manage Shopify inventory levels',
|
||||
read_locations: 'View store locations',
|
||||
write_merchant_managed_fulfillment_orders: 'Create fulfillments for orders',
|
||||
// Zoom scopes
|
||||
'user:read:user': 'View Zoom profile information',
|
||||
'meeting:write:meeting': 'Create Zoom meetings',
|
||||
'meeting:read:meeting': 'View Zoom meeting details',
|
||||
'meeting:read:list_meetings': 'List Zoom meetings',
|
||||
'meeting:update:meeting': 'Update Zoom meetings',
|
||||
'meeting:delete:meeting': 'Delete Zoom meetings',
|
||||
'meeting:read:invitation': 'View Zoom meeting invitations',
|
||||
'meeting:read:list_past_participants': 'View past meeting participants',
|
||||
'cloud_recording:read:list_user_recordings': 'List Zoom cloud recordings',
|
||||
'cloud_recording:read:list_recording_files': 'View recording files',
|
||||
'cloud_recording:delete:recording_file': 'Delete cloud recordings',
|
||||
// Dropbox scopes
|
||||
'account_info.read': 'View Dropbox account information',
|
||||
'files.metadata.read': 'View file and folder names, sizes, and dates',
|
||||
'files.metadata.write': 'Modify file and folder metadata',
|
||||
'files.content.read': 'Download and read Dropbox files',
|
||||
'files.content.write': 'Upload, copy, move, and delete files in Dropbox',
|
||||
'sharing.read': 'View shared files and folders',
|
||||
'sharing.write': 'Share files and folders with others',
|
||||
// WordPress.com scopes
|
||||
global: 'Full access to manage WordPress.com sites, posts, pages, media, and settings',
|
||||
// Spotify scopes
|
||||
'user-read-private': 'View Spotify account details',
|
||||
'user-read-email': 'View email address on Spotify',
|
||||
'user-library-read': 'View saved tracks and albums',
|
||||
'user-library-modify': 'Save and remove tracks and albums from library',
|
||||
'playlist-read-private': 'View private playlists',
|
||||
'playlist-read-collaborative': 'View collaborative playlists',
|
||||
'playlist-modify-public': 'Create and manage public playlists',
|
||||
'playlist-modify-private': 'Create and manage private playlists',
|
||||
'user-read-playback-state': 'View current playback state',
|
||||
'user-modify-playback-state': 'Control playback on Spotify devices',
|
||||
'user-read-currently-playing': 'View currently playing track',
|
||||
'user-read-recently-played': 'View recently played tracks',
|
||||
'user-top-read': 'View top artists and tracks',
|
||||
'user-follow-read': 'View followed artists and users',
|
||||
'user-follow-modify': 'Follow and unfollow artists and users',
|
||||
'user-read-playback-position': 'View playback position in podcasts',
|
||||
'ugc-image-upload': 'Upload images to Spotify playlists',
|
||||
// Attio
|
||||
'record_permission:read-write': 'Read and write CRM records',
|
||||
'object_configuration:read-write': 'Read and manage object schemas',
|
||||
'list_configuration:read-write': 'Read and manage list configurations',
|
||||
'list_entry:read-write': 'Read and write list entries',
|
||||
'note:read-write': 'Read and write notes',
|
||||
'task:read-write': 'Read and write tasks',
|
||||
'comment:read-write': 'Read and write comments and threads',
|
||||
'user_management:read': 'View workspace members',
|
||||
'webhook:read-write': 'Manage webhooks',
|
||||
}
|
||||
|
||||
function getScopeDescription(scope: string): string {
|
||||
return SCOPE_DESCRIPTIONS[scope] || scope
|
||||
}
|
||||
|
||||
export function OAuthRequiredModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type OAuthProvider,
|
||||
parseProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
@@ -25,7 +26,6 @@ import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
||||
import { useOrganizations } from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
||||
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
type OAuthService,
|
||||
parseProvider,
|
||||
} from '@/lib/oauth'
|
||||
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
|
||||
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
|
||||
import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
|
||||
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
||||
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const getProviderIcon = (providerName: OAuthProvider) => {
|
||||
|
||||
@@ -1969,9 +1969,8 @@ export const ToolInput = memo(function ToolInput({
|
||||
}
|
||||
|
||||
if (useSubBlocks && displaySubBlocks.length > 0) {
|
||||
const allBlockSubBlocks = toolBlock?.subBlocks || []
|
||||
const coveredParamIds = new Set(
|
||||
allBlockSubBlocks.flatMap((sb) => {
|
||||
displaySubBlocks.flatMap((sb) => {
|
||||
const ids = [sb.id]
|
||||
if (sb.canonicalParamId) ids.push(sb.canonicalParamId)
|
||||
const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
|
||||
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useDependsOnGate } from './use-depends-on-gate'
|
||||
|
||||
@@ -12,8 +15,7 @@ import { useDependsOnGate } from './use-depends-on-gate'
|
||||
*
|
||||
* Builds a `SelectorContext` by mapping each `dependsOn` entry through the
|
||||
* canonical index to its `canonicalParamId`, which maps directly to
|
||||
* `SelectorContext` field names (e.g. `siteId`, `teamId`, `collectionId`).
|
||||
* The one special case is `oauthCredential` which maps to `credentialId`.
|
||||
* `SelectorContext` field names (e.g. `siteId`, `teamId`, `oauthCredential`).
|
||||
*
|
||||
* @param blockId - The block containing the selector sub-block
|
||||
* @param subBlock - The sub-block config (must have `selectorKey` set)
|
||||
@@ -29,53 +31,58 @@ export function useSelectorSetup(
|
||||
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
|
||||
const workflowId = (params?.workflowId as string) || activeWorkflowId || ''
|
||||
|
||||
const envVariables = useEnvironmentStore((s) => s.variables)
|
||||
|
||||
const { finalDisabled, dependencyValues, canonicalIndex } = useDependsOnGate(
|
||||
blockId,
|
||||
subBlock,
|
||||
opts
|
||||
)
|
||||
|
||||
const resolvedDependencyValues = useMemo(() => {
|
||||
const resolved: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(dependencyValues)) {
|
||||
if (value === null || value === undefined) {
|
||||
resolved[key] = value
|
||||
continue
|
||||
}
|
||||
const str = String(value)
|
||||
if (isEnvVarReference(str)) {
|
||||
const varName = extractEnvVarName(str)
|
||||
resolved[key] = envVariables[varName]?.value || undefined
|
||||
} else {
|
||||
resolved[key] = value
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}, [dependencyValues, envVariables])
|
||||
|
||||
const selectorContext = useMemo<SelectorContext>(() => {
|
||||
const context: SelectorContext = {
|
||||
workflowId,
|
||||
mimeType: subBlock.mimeType,
|
||||
}
|
||||
|
||||
for (const [depKey, value] of Object.entries(dependencyValues)) {
|
||||
for (const [depKey, value] of Object.entries(resolvedDependencyValues)) {
|
||||
if (value === null || value === undefined) continue
|
||||
const strValue = String(value)
|
||||
if (!strValue) continue
|
||||
if (isReference(strValue)) continue
|
||||
|
||||
const canonicalParamId = canonicalIndex.canonicalIdBySubBlockId[depKey] ?? depKey
|
||||
|
||||
if (canonicalParamId === 'oauthCredential') {
|
||||
context.credentialId = strValue
|
||||
} else if (canonicalParamId in CONTEXT_FIELD_SET) {
|
||||
;(context as Record<string, unknown>)[canonicalParamId] = strValue
|
||||
if (SELECTOR_CONTEXT_FIELDS.has(canonicalParamId as keyof SelectorContext)) {
|
||||
context[canonicalParamId as keyof SelectorContext] = strValue
|
||||
}
|
||||
}
|
||||
|
||||
return context
|
||||
}, [dependencyValues, canonicalIndex, workflowId, subBlock.mimeType])
|
||||
}, [resolvedDependencyValues, canonicalIndex, workflowId, subBlock.mimeType])
|
||||
|
||||
return {
|
||||
selectorKey: (subBlock.selectorKey ?? null) as SelectorKey | null,
|
||||
selectorContext,
|
||||
allowSearch: subBlock.selectorAllowSearch ?? true,
|
||||
disabled: finalDisabled || !subBlock.selectorKey,
|
||||
dependencyValues,
|
||||
dependencyValues: resolvedDependencyValues,
|
||||
}
|
||||
}
|
||||
|
||||
const CONTEXT_FIELD_SET: Record<string, true> = {
|
||||
credentialId: true,
|
||||
domain: true,
|
||||
teamId: true,
|
||||
projectId: true,
|
||||
knowledgeBaseId: true,
|
||||
planId: true,
|
||||
siteId: true,
|
||||
collectionId: true,
|
||||
spreadsheetId: true,
|
||||
fileId: true,
|
||||
}
|
||||
|
||||
@@ -57,9 +57,9 @@ import { useWebhookManagement } from '@/hooks/use-webhook-management'
|
||||
const SLACK_OVERRIDES: SelectorOverrides = {
|
||||
transformContext: (context, deps) => {
|
||||
const authMethod = deps.authMethod as string
|
||||
const credentialId =
|
||||
const oauthCredential =
|
||||
authMethod === 'bot_token' ? String(deps.botToken ?? '') : String(deps.credential ?? '')
|
||||
return { ...context, credentialId }
|
||||
return { ...context, oauthCredential }
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -549,21 +549,48 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
return typeof option === 'string' ? option : option.label
|
||||
}, [subBlock, rawValue])
|
||||
|
||||
const domainValue = getStringValue('domain')
|
||||
const teamIdValue = getStringValue('teamId')
|
||||
const projectIdValue = getStringValue('projectId')
|
||||
const planIdValue = getStringValue('planId')
|
||||
const resolveContextValue = useCallback(
|
||||
(key: string): string | undefined => {
|
||||
const resolved = resolveDependencyValue(
|
||||
key,
|
||||
rawValues,
|
||||
canonicalIndex || buildCanonicalIndex([]),
|
||||
canonicalModeOverrides
|
||||
)
|
||||
return typeof resolved === 'string' && resolved.length > 0 ? resolved : undefined
|
||||
},
|
||||
[rawValues, canonicalIndex, canonicalModeOverrides]
|
||||
)
|
||||
|
||||
const domainValue = resolveContextValue('domain')
|
||||
const teamIdValue = resolveContextValue('teamId')
|
||||
const projectIdValue = resolveContextValue('projectId')
|
||||
const planIdValue = resolveContextValue('planId')
|
||||
const baseIdValue = resolveContextValue('baseId')
|
||||
const datasetIdValue = resolveContextValue('datasetId')
|
||||
const serviceDeskIdValue = resolveContextValue('serviceDeskId')
|
||||
const siteIdValue = resolveContextValue('siteId')
|
||||
const collectionIdValue = resolveContextValue('collectionId')
|
||||
const spreadsheetIdValue = resolveContextValue('spreadsheetId')
|
||||
const fileIdValue = resolveContextValue('fileId')
|
||||
|
||||
const { displayName: selectorDisplayName } = useSelectorDisplayName({
|
||||
subBlock,
|
||||
value: rawValue,
|
||||
workflowId,
|
||||
credentialId: typeof credentialId === 'string' ? credentialId : undefined,
|
||||
oauthCredential: typeof credentialId === 'string' ? credentialId : undefined,
|
||||
knowledgeBaseId: typeof knowledgeBaseId === 'string' ? knowledgeBaseId : undefined,
|
||||
domain: domainValue,
|
||||
teamId: teamIdValue,
|
||||
projectId: projectIdValue,
|
||||
planId: planIdValue,
|
||||
baseId: baseIdValue,
|
||||
datasetId: datasetIdValue,
|
||||
serviceDeskId: serviceDeskIdValue,
|
||||
siteId: siteIdValue,
|
||||
collectionId: collectionIdValue,
|
||||
spreadsheetId: spreadsheetIdValue,
|
||||
fileId: fileIdValue,
|
||||
})
|
||||
|
||||
const { knowledgeBase: kbForDisplayName } = useKnowledgeBase(
|
||||
|
||||
@@ -12,7 +12,7 @@ interface UseShiftSelectionLockResult {
|
||||
/** Computed ReactFlow props based on current selection state */
|
||||
selectionProps: {
|
||||
selectionOnDrag: boolean
|
||||
panOnDrag: [number, number] | false
|
||||
panOnDrag: number[]
|
||||
selectionKeyCode: string | null
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function useShiftSelectionLock({
|
||||
|
||||
const selectionProps = {
|
||||
selectionOnDrag: !isHandMode || isShiftSelecting,
|
||||
panOnDrag: (isHandMode && !isShiftSelecting ? [0, 1] : false) as [number, number] | false,
|
||||
panOnDrag: isHandMode && !isShiftSelecting ? [0, 1] : [1],
|
||||
selectionKeyCode: isShiftSelecting ? null : 'Shift',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user