mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-11 16:08:04 -05:00
Compare commits
24 Commits
fix/slack-
...
v0.5.22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52edbea659 | ||
|
|
aa1d896b38 | ||
|
|
2fcd07e82d | ||
|
|
0db5ba1b27 | ||
|
|
e390ba0491 | ||
|
|
2f0509adaf | ||
|
|
9f0584a818 | ||
|
|
d480057fd3 | ||
|
|
c27c233da0 | ||
|
|
ebef5f3a27 | ||
|
|
12c4c2d44f | ||
|
|
929a352edb | ||
|
|
6cd078b0fe | ||
|
|
31874939ee | ||
|
|
e157ce5fbc | ||
|
|
774e5d585c | ||
|
|
54cc93743f | ||
|
|
8c32ad4c0d | ||
|
|
1d08796853 | ||
|
|
ebcd243942 | ||
|
|
b7e814b721 | ||
|
|
842ef27ed9 | ||
|
|
31c34b2ea3 | ||
|
|
8f0ef58056 |
@@ -4170,3 +4170,32 @@ export function DuckDuckGoIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function RssIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M4 11C6.38695 11 8.67613 11.9482 10.364 13.636C12.0518 15.3239 13 17.6131 13 20'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M4 4C8.24346 4 12.3131 5.68571 15.3137 8.68629C18.3143 11.6869 20 15.7565 20 20'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<circle cx='5' cy='19' r='1' fill='currentColor' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,15 +30,19 @@ Verwende den Start-Block für alles, was aus dem Editor, deploy-to-API oder depl
|
||||
<Card title="Schedule" href="/triggers/schedule">
|
||||
Cron- oder intervallbasierte Ausführung
|
||||
</Card>
|
||||
<Card title="RSS Feed" href="/triggers/rss">
|
||||
RSS- und Atom-Feeds auf neue Inhalte überwachen
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Schneller Vergleich
|
||||
|
||||
| Trigger | Startbedingung |
|
||||
|---------|-----------------|
|
||||
| **Start** | Editor-Ausführungen, deploy-to-API Anfragen oder Chat-Nachrichten |
|
||||
| **Start** | Editor-Ausführungen, Deploy-to-API-Anfragen oder Chat-Nachrichten |
|
||||
| **Schedule** | Timer, der im Schedule-Block verwaltet wird |
|
||||
| **Webhook** | Bei eingehender HTTP-Anfrage |
|
||||
| **RSS Feed** | Neues Element im Feed veröffentlicht |
|
||||
|
||||
> Der Start-Block stellt immer `input`, `conversationId` und `files` Felder bereit. Füge benutzerdefinierte Felder zum Eingabeformat für zusätzliche strukturierte Daten hinzu.
|
||||
|
||||
|
||||
49
apps/docs/content/docs/de/triggers/rss.mdx
Normal file
49
apps/docs/content/docs/de/triggers/rss.mdx
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: RSS-Feed
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
Der RSS-Feed-Block überwacht RSS- und Atom-Feeds – wenn neue Einträge veröffentlicht werden, wird Ihr Workflow automatisch ausgelöst.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/rss.png"
|
||||
alt="RSS-Feed-Block"
|
||||
width={500}
|
||||
height={400}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
## Konfiguration
|
||||
|
||||
1. **RSS-Feed-Block hinzufügen** - Ziehen Sie den RSS-Feed-Block, um Ihren Workflow zu starten
|
||||
2. **Feed-URL eingeben** - Fügen Sie die URL eines beliebigen RSS- oder Atom-Feeds ein
|
||||
3. **Bereitstellen** - Stellen Sie Ihren Workflow bereit, um das Polling zu aktivieren
|
||||
|
||||
Nach der Bereitstellung wird der Feed jede Minute auf neue Einträge überprüft.
|
||||
|
||||
## Ausgabefelder
|
||||
|
||||
| Feld | Typ | Beschreibung |
|
||||
|-------|------|-------------|
|
||||
| `title` | string | Titel des Eintrags |
|
||||
| `link` | string | Link des Eintrags |
|
||||
| `pubDate` | string | Veröffentlichungsdatum |
|
||||
| `item` | object | Rohdaten des Eintrags mit allen Feldern |
|
||||
| `feed` | object | Rohdaten der Feed-Metadaten |
|
||||
|
||||
Greifen Sie direkt auf zugeordnete Felder zu (`<rss.title>`) oder verwenden Sie die Rohobjekte für beliebige Felder (`<rss.item.author>`, `<rss.feed.language>`).
|
||||
|
||||
## Anwendungsfälle
|
||||
|
||||
- **Inhaltsüberwachung** - Verfolgen Sie Blogs, Nachrichtenseiten oder Updates von Wettbewerbern
|
||||
- **Podcast-Automatisierung** - Lösen Sie Workflows aus, wenn neue Episoden erscheinen
|
||||
- **Release-Tracking** - Überwachen Sie GitHub-Releases, Changelogs oder Produkt-Updates
|
||||
- **Social-Media-Aggregation** - Sammeln Sie Inhalte von Plattformen, die RSS-Feeds anbieten
|
||||
|
||||
<Callout>
|
||||
RSS-Trigger werden nur für Einträge ausgelöst, die nach dem Speichern des Triggers veröffentlicht wurden. Bestehende Feed-Einträge werden nicht verarbeitet.
|
||||
</Callout>
|
||||
@@ -30,6 +30,9 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
|
||||
<Card title="Schedule" href="/triggers/schedule">
|
||||
Cron or interval based execution
|
||||
</Card>
|
||||
<Card title="RSS Feed" href="/triggers/rss">
|
||||
Monitor RSS and Atom feeds for new content
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Quick Comparison
|
||||
@@ -39,6 +42,7 @@ Use the Start block for everything originating from the editor, deploy-to-API, o
|
||||
| **Start** | Editor runs, deploy-to-API requests, or chat messages |
|
||||
| **Schedule** | Timer managed in schedule block |
|
||||
| **Webhook** | On inbound HTTP request |
|
||||
| **RSS Feed** | New item published to feed |
|
||||
|
||||
> The Start block always exposes `input`, `conversationId`, and `files` fields. Add custom fields to the input format for additional structured data.
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"pages": ["index", "start", "schedule", "webhook"]
|
||||
"pages": ["index", "start", "schedule", "webhook", "rss"]
|
||||
}
|
||||
|
||||
49
apps/docs/content/docs/en/triggers/rss.mdx
Normal file
49
apps/docs/content/docs/en/triggers/rss.mdx
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: RSS Feed
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
The RSS Feed block monitors RSS and Atom feeds – when new items are published, your workflow triggers automatically.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/rss.png"
|
||||
alt="RSS Feed Block"
|
||||
width={500}
|
||||
height={400}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
## Configuration
|
||||
|
||||
1. **Add RSS Feed Block** - Drag the RSS Feed block to start your workflow
|
||||
2. **Enter Feed URL** - Paste the URL of any RSS or Atom feed
|
||||
3. **Deploy** - Deploy your workflow to activate polling
|
||||
|
||||
Once deployed, the feed is checked every minute for new items.
|
||||
|
||||
## Output Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `title` | string | Item title |
|
||||
| `link` | string | Item link |
|
||||
| `pubDate` | string | Publication date |
|
||||
| `item` | object | Raw item with all fields |
|
||||
| `feed` | object | Raw feed metadata |
|
||||
|
||||
Access mapped fields directly (`<rss.title>`) or use the raw objects for any field (`<rss.item.author>`, `<rss.feed.language>`).
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Content monitoring** - Track blogs, news sites, or competitor updates
|
||||
- **Podcast automation** - Trigger workflows when new episodes drop
|
||||
- **Release tracking** - Monitor GitHub releases, changelogs, or product updates
|
||||
- **Social aggregation** - Collect content from platforms that expose RSS feeds
|
||||
|
||||
<Callout>
|
||||
RSS triggers only fire for items published after you save the trigger. Existing feed items are not processed.
|
||||
</Callout>
|
||||
184
apps/docs/content/docs/es/tools/sftp.mdx
Normal file
184
apps/docs/content/docs/es/tools/sftp.mdx
Normal file
@@ -0,0 +1,184 @@
|
||||
---
|
||||
title: SFTP
|
||||
description: Transferir archivos a través de SFTP (Protocolo de transferencia de
|
||||
archivos SSH)
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="sftp"
|
||||
color="#2D3748"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[SFTP (Protocolo de transferencia de archivos SSH)](https://en.wikipedia.org/wiki/SSH_File_Transfer_Protocol) es un protocolo de red seguro que te permite subir, descargar y gestionar archivos en servidores remotos. SFTP opera sobre SSH, lo que lo hace ideal para transferencias de archivos automatizadas y cifradas, así como para la gestión remota de archivos dentro de flujos de trabajo modernos.
|
||||
|
||||
Con las herramientas SFTP integradas en Sim, puedes automatizar fácilmente el movimiento de archivos entre tus agentes de IA y sistemas o servidores externos. Esto permite a tus agentes gestionar intercambios críticos de datos, copias de seguridad, generación de documentos y orquestación de sistemas remotos, todo con una seguridad robusta.
|
||||
|
||||
**Funcionalidades clave disponibles a través de las herramientas SFTP:**
|
||||
|
||||
- **Subir archivos:** Transfiere sin problemas archivos de cualquier tipo desde tu flujo de trabajo a un servidor remoto, con soporte tanto para autenticación por contraseña como por clave privada SSH.
|
||||
- **Descargar archivos:** Recupera archivos de servidores SFTP remotos directamente para su procesamiento, archivo o automatización adicional.
|
||||
- **Listar y gestionar archivos:** Enumera directorios, elimina o crea archivos y carpetas, y gestiona permisos del sistema de archivos de forma remota.
|
||||
- **Autenticación flexible:** Conéctate usando contraseñas tradicionales o claves SSH, con soporte para frases de contraseña y control de permisos.
|
||||
- **Soporte para archivos grandes:** Gestiona programáticamente cargas y descargas de archivos grandes, con límites de tamaño incorporados para mayor seguridad.
|
||||
|
||||
Al integrar SFTP en Sim, puedes automatizar operaciones seguras de archivos como parte de cualquier flujo de trabajo, ya sea recopilación de datos, informes, mantenimiento de sistemas remotos o intercambio dinámico de contenido entre plataformas.
|
||||
|
||||
Las secciones a continuación describen las principales herramientas SFTP disponibles:
|
||||
|
||||
- **sftp_upload:** Sube uno o más archivos a un servidor remoto.
|
||||
- **sftp_download:** Descarga archivos desde un servidor remoto a tu flujo de trabajo.
|
||||
- **sftp_list:** Lista el contenido de directorios en un servidor SFTP remoto.
|
||||
- **sftp_delete:** Elimina archivos o directorios de un servidor remoto.
|
||||
- **sftp_create:** Crea nuevos archivos en un servidor SFTP remoto.
|
||||
- **sftp_mkdir:** Crea nuevos directorios de forma remota.
|
||||
|
||||
Consulta la documentación de la herramienta a continuación para conocer los parámetros detallados de entrada y salida para cada operación.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Instrucciones de uso
|
||||
|
||||
Sube, descarga, lista y gestiona archivos en servidores remotos a través de SFTP. Compatible con autenticación por contraseña y clave privada para transferencias seguras de archivos.
|
||||
|
||||
## Herramientas
|
||||
|
||||
### `sftp_upload`
|
||||
|
||||
Subir archivos a un servidor SFTP remoto
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
|
||||
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
|
||||
| `username` | string | Sí | Nombre de usuario SFTP |
|
||||
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
|
||||
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
|
||||
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
|
||||
| `remotePath` | string | Sí | Directorio de destino en el servidor remoto |
|
||||
| `files` | file[] | No | Archivos para subir |
|
||||
| `fileContent` | string | No | Contenido directo del archivo para subir \(para archivos de texto\) |
|
||||
| `fileName` | string | No | Nombre del archivo cuando se usa contenido directo |
|
||||
| `overwrite` | boolean | No | Si se deben sobrescribir archivos existentes \(predeterminado: true\) |
|
||||
| `permissions` | string | No | Permisos del archivo \(p. ej., 0644\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Si la subida fue exitosa |
|
||||
| `uploadedFiles` | json | Array de detalles de archivos subidos \(nombre, rutaRemota, tamaño\) |
|
||||
| `message` | string | Mensaje de estado de la operación |
|
||||
|
||||
### `sftp_download`
|
||||
|
||||
Descargar un archivo desde un servidor SFTP remoto
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
|
||||
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
|
||||
| `username` | string | Sí | Nombre de usuario SFTP |
|
||||
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
|
||||
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
|
||||
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
|
||||
| `remotePath` | string | Sí | Ruta al archivo en el servidor remoto |
|
||||
| `encoding` | string | No | Codificación de salida: utf-8 para texto, base64 para binario \(predeterminado: utf-8\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Si la descarga fue exitosa |
|
||||
| `fileName` | string | Nombre del archivo descargado |
|
||||
| `content` | string | Contenido del archivo \(texto o codificado en base64\) |
|
||||
| `size` | number | Tamaño del archivo en bytes |
|
||||
| `encoding` | string | Codificación del contenido \(utf-8 o base64\) |
|
||||
| `message` | string | Mensaje de estado de la operación |
|
||||
|
||||
### `sftp_list`
|
||||
|
||||
Listar archivos y directorios en un servidor SFTP remoto
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
|
||||
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
|
||||
| `username` | string | Sí | Nombre de usuario SFTP |
|
||||
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
|
||||
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
|
||||
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
|
||||
| `remotePath` | string | Sí | Ruta del directorio en el servidor remoto |
|
||||
| `detailed` | boolean | No | Incluir información detallada de archivos \(tamaño, permisos, fecha de modificación\) |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Si la operación fue exitosa |
|
||||
| `path` | string | Ruta del directorio que fue listado |
|
||||
| `entries` | json | Array de entradas del directorio con nombre, tipo, tamaño, permisos, modifiedAt |
|
||||
| `count` | number | Número de entradas en el directorio |
|
||||
| `message` | string | Mensaje de estado de la operación |
|
||||
|
||||
### `sftp_delete`
|
||||
|
||||
Eliminar un archivo o directorio en un servidor SFTP remoto
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Requerido | Descripción |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
|
||||
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
|
||||
| `username` | string | Sí | Nombre de usuario SFTP |
|
||||
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
|
||||
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
|
||||
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
|
||||
| `remotePath` | string | Sí | Ruta al archivo o directorio a eliminar |
|
||||
| `recursive` | boolean | No | Eliminar directorios recursivamente |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Si la eliminación fue exitosa |
|
||||
| `deletedPath` | string | Ruta que fue eliminada |
|
||||
| `message` | string | Mensaje de estado de la operación |
|
||||
|
||||
### `sftp_mkdir`
|
||||
|
||||
Crear un directorio en un servidor SFTP remoto
|
||||
|
||||
#### Entrada
|
||||
|
||||
| Parámetro | Tipo | Obligatorio | Descripción |
|
||||
| --------- | ---- | ----------- | ----------- |
|
||||
| `host` | string | Sí | Nombre de host o dirección IP del servidor SFTP |
|
||||
| `port` | number | Sí | Puerto del servidor SFTP \(predeterminado: 22\) |
|
||||
| `username` | string | Sí | Nombre de usuario SFTP |
|
||||
| `password` | string | No | Contraseña para autenticación \(si no se usa clave privada\) |
|
||||
| `privateKey` | string | No | Clave privada para autenticación \(formato OpenSSH\) |
|
||||
| `passphrase` | string | No | Frase de contraseña para clave privada cifrada |
|
||||
| `remotePath` | string | Sí | Ruta para el nuevo directorio |
|
||||
| `recursive` | boolean | No | Crear directorios principales si no existen |
|
||||
|
||||
#### Salida
|
||||
|
||||
| Parámetro | Tipo | Descripción |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Si el directorio se creó correctamente |
|
||||
| `createdPath` | string | Ruta del directorio creado |
|
||||
| `message` | string | Mensaje de estado de la operación |
|
||||
|
||||
## Notas
|
||||
|
||||
- Categoría: `tools`
|
||||
- Tipo: `sftp`
|
||||
@@ -30,6 +30,9 @@ Utiliza el bloque Start para todo lo que se origina desde el editor, despliegue
|
||||
<Card title="Schedule" href="/triggers/schedule">
|
||||
Ejecución basada en cron o intervalos
|
||||
</Card>
|
||||
<Card title="RSS Feed" href="/triggers/rss">
|
||||
Monitorea feeds RSS y Atom para nuevo contenido
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Comparación rápida
|
||||
@@ -39,6 +42,7 @@ Utiliza el bloque Start para todo lo que se origina desde el editor, despliegue
|
||||
| **Start** | Ejecuciones del editor, solicitudes de despliegue a API o mensajes de chat |
|
||||
| **Schedule** | Temporizador gestionado en el bloque de programación |
|
||||
| **Webhook** | Al recibir una solicitud HTTP entrante |
|
||||
| **RSS Feed** | Nuevo elemento publicado en el feed |
|
||||
|
||||
> El bloque Start siempre expone los campos `input`, `conversationId` y `files`. Añade campos personalizados al formato de entrada para datos estructurados adicionales.
|
||||
|
||||
|
||||
49
apps/docs/content/docs/es/triggers/rss.mdx
Normal file
49
apps/docs/content/docs/es/triggers/rss.mdx
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Feed RSS
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
El bloque de Feed RSS monitorea feeds RSS y Atom – cuando se publican nuevos elementos, tu flujo de trabajo se activa automáticamente.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/rss.png"
|
||||
alt="Bloque de Feed RSS"
|
||||
width={500}
|
||||
height={400}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
## Configuración
|
||||
|
||||
1. **Añadir bloque de Feed RSS** - Arrastra el bloque de Feed RSS para iniciar tu flujo de trabajo
|
||||
2. **Introducir URL del feed** - Pega la URL de cualquier feed RSS o Atom
|
||||
3. **Implementar** - Implementa tu flujo de trabajo para activar el sondeo
|
||||
|
||||
Una vez implementado, el feed se comprueba cada minuto en busca de nuevos elementos.
|
||||
|
||||
## Campos de salida
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|-------|------|-------------|
|
||||
| `title` | string | Título del elemento |
|
||||
| `link` | string | Enlace del elemento |
|
||||
| `pubDate` | string | Fecha de publicación |
|
||||
| `item` | object | Elemento en bruto con todos los campos |
|
||||
| `feed` | object | Metadatos en bruto del feed |
|
||||
|
||||
Accede a los campos mapeados directamente (`<rss.title>`) o utiliza los objetos en bruto para cualquier campo (`<rss.item.author>`, `<rss.feed.language>`).
|
||||
|
||||
## Casos de uso
|
||||
|
||||
- **Monitoreo de contenido** - Sigue blogs, sitios de noticias o actualizaciones de competidores
|
||||
- **Automatización de podcasts** - Activa flujos de trabajo cuando se publican nuevos episodios
|
||||
- **Seguimiento de lanzamientos** - Monitorea lanzamientos de GitHub, registros de cambios o actualizaciones de productos
|
||||
- **Agregación social** - Recopila contenido de plataformas que exponen feeds RSS
|
||||
|
||||
<Callout>
|
||||
Los disparadores RSS solo se activan para elementos publicados después de guardar el disparador. Los elementos existentes en el feed no se procesan.
|
||||
</Callout>
|
||||
@@ -21,24 +21,28 @@ import { Image } from '@/components/ui/image'
|
||||
Utilisez le bloc Démarrer pour tout ce qui provient de l'éditeur, du déploiement vers l'API ou des expériences de déploiement vers le chat. D'autres déclencheurs restent disponibles pour les flux de travail basés sur des événements :
|
||||
|
||||
<Cards>
|
||||
<Card title="Démarrer" href="/triggers/start">
|
||||
<Card title="Start" href="/triggers/start">
|
||||
Point d'entrée unifié qui prend en charge les exécutions de l'éditeur, les déploiements d'API et les déploiements de chat
|
||||
</Card>
|
||||
<Card title="Webhook" href="/triggers/webhook">
|
||||
Recevoir des charges utiles de webhook externes
|
||||
</Card>
|
||||
<Card title="Planification" href="/triggers/schedule">
|
||||
<Card title="Schedule" href="/triggers/schedule">
|
||||
Exécution basée sur cron ou intervalle
|
||||
</Card>
|
||||
<Card title="RSS Feed" href="/triggers/rss">
|
||||
Surveiller les flux RSS et Atom pour du nouveau contenu
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## Comparaison rapide
|
||||
|
||||
| Déclencheur | Condition de démarrage |
|
||||
|---------|-----------------|
|
||||
| **Démarrer** | Exécutions de l'éditeur, requêtes de déploiement vers l'API ou messages de chat |
|
||||
| **Planification** | Minuteur géré dans le bloc de planification |
|
||||
| **Start** | Exécutions de l'éditeur, requêtes de déploiement d'API ou messages de chat |
|
||||
| **Schedule** | Minuteur géré dans le bloc de planification |
|
||||
| **Webhook** | Sur requête HTTP entrante |
|
||||
| **RSS Feed** | Nouvel élément publié dans le flux |
|
||||
|
||||
> Le bloc Démarrer expose toujours les champs `input`, `conversationId` et `files`. Ajoutez des champs personnalisés au format d'entrée pour des données structurées supplémentaires.
|
||||
|
||||
|
||||
49
apps/docs/content/docs/fr/triggers/rss.mdx
Normal file
49
apps/docs/content/docs/fr/triggers/rss.mdx
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Flux RSS
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
Le bloc Flux RSS surveille les flux RSS et Atom – lorsque de nouveaux éléments sont publiés, votre workflow se déclenche automatiquement.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/rss.png"
|
||||
alt="Bloc Flux RSS"
|
||||
width={500}
|
||||
height={400}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
## Configuration
|
||||
|
||||
1. **Ajouter le bloc Flux RSS** - Faites glisser le bloc Flux RSS pour démarrer votre workflow
|
||||
2. **Saisir l'URL du flux** - Collez l'URL de n'importe quel flux RSS ou Atom
|
||||
3. **Déployer** - Déployez votre workflow pour activer l'interrogation
|
||||
|
||||
Une fois déployé, le flux est vérifié chaque minute pour détecter de nouveaux éléments.
|
||||
|
||||
## Champs de sortie
|
||||
|
||||
| Champ | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `title` | string | Titre de l'élément |
|
||||
| `link` | string | Lien de l'élément |
|
||||
| `pubDate` | string | Date de publication |
|
||||
| `item` | object | Élément brut avec tous les champs |
|
||||
| `feed` | object | Métadonnées brutes du flux |
|
||||
|
||||
Accédez directement aux champs mappés (`<rss.title>`) ou utilisez les objets bruts pour n'importe quel champ (`<rss.item.author>`, `<rss.feed.language>`).
|
||||
|
||||
## Cas d'utilisation
|
||||
|
||||
- **Surveillance de contenu** - Suivez les blogs, sites d'actualités ou mises à jour des concurrents
|
||||
- **Automatisation de podcast** - Déclenchez des workflows lors de la sortie de nouveaux épisodes
|
||||
- **Suivi des versions** - Surveillez les versions GitHub, les journaux de modifications ou les mises à jour de produits
|
||||
- **Agrégation sociale** - Collectez du contenu à partir de plateformes qui exposent des flux RSS
|
||||
|
||||
<Callout>
|
||||
Les déclencheurs RSS ne s'activent que pour les éléments publiés après l'enregistrement du déclencheur. Les éléments existants du flux ne sont pas traités.
|
||||
</Callout>
|
||||
@@ -21,24 +21,28 @@ import { Image } from '@/components/ui/image'
|
||||
エディタ、APIへのデプロイ、またはチャットへのデプロイエクスペリエンスから始まるすべてのものにはスタートブロックを使用します。イベント駆動型ワークフローには他のトリガーも利用可能です:
|
||||
|
||||
<Cards>
|
||||
<Card title="スタート" href="/triggers/start">
|
||||
<Card title="Start" href="/triggers/start">
|
||||
エディタ実行、APIデプロイメント、チャットデプロイメントをサポートする統合エントリーポイント
|
||||
</Card>
|
||||
<Card title="ウェブフック" href="/triggers/webhook">
|
||||
外部ウェブフックペイロードを受信
|
||||
<Card title="Webhook" href="/triggers/webhook">
|
||||
外部のwebhookペイロードを受信
|
||||
</Card>
|
||||
<Card title="スケジュール" href="/triggers/schedule">
|
||||
<Card title="Schedule" href="/triggers/schedule">
|
||||
Cronまたは間隔ベースの実行
|
||||
</Card>
|
||||
<Card title="RSS Feed" href="/triggers/rss">
|
||||
新しいコンテンツのRSSとAtomフィードを監視
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## クイック比較
|
||||
|
||||
| トリガー | 開始条件 |
|
||||
|---------|-----------------|
|
||||
| **スタート** | エディタ実行、APIへのデプロイリクエスト、またはチャットメッセージ |
|
||||
| **スケジュール** | スケジュールブロックで管理されるタイマー |
|
||||
| **ウェブフック** | 受信HTTPリクエスト時 |
|
||||
| **Start** | エディタ実行、APIへのデプロイリクエスト、またはチャットメッセージ |
|
||||
| **Schedule** | スケジュールブロックで管理されるタイマー |
|
||||
| **Webhook** | 受信HTTPリクエスト時 |
|
||||
| **RSS Feed** | フィードに新しいアイテムが公開された時 |
|
||||
|
||||
> スタートブロックは常に `input`、`conversationId`、および `files` フィールドを公開します。追加の構造化データには入力フォーマットにカスタムフィールドを追加してください。
|
||||
|
||||
|
||||
49
apps/docs/content/docs/ja/triggers/rss.mdx
Normal file
49
apps/docs/content/docs/ja/triggers/rss.mdx
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: RSSフィード
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
RSSフィードブロックはRSSとAtomフィードを監視します - 新しいアイテムが公開されると、ワークフローが自動的にトリガーされます。
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/rss.png"
|
||||
alt="RSSフィードブロック"
|
||||
width={500}
|
||||
height={400}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
## 設定
|
||||
|
||||
1. **RSSフィードブロックを追加** - RSSフィードブロックをドラッグしてワークフローを開始
|
||||
2. **フィードURLを入力** - 任意のRSSまたはAtomフィードのURLを貼り付け
|
||||
3. **デプロイ** - ワークフローをデプロイしてポーリングを有効化
|
||||
|
||||
デプロイ後、フィードは1分ごとに新しいアイテムをチェックします。
|
||||
|
||||
## 出力フィールド
|
||||
|
||||
| フィールド | 型 | 説明 |
|
||||
|-------|------|-------------|
|
||||
| `title` | string | アイテムのタイトル |
|
||||
| `link` | string | アイテムのリンク |
|
||||
| `pubDate` | string | 公開日 |
|
||||
| `item` | object | すべてのフィールドを含む生のアイテム |
|
||||
| `feed` | object | 生のフィードメタデータ |
|
||||
|
||||
マッピングされたフィールドに直接アクセスするか(`<rss.title>`)、任意のフィールドに生のオブジェクトを使用します(`<rss.item.author>`、`<rss.feed.language>`)。
|
||||
|
||||
## ユースケース
|
||||
|
||||
- **コンテンツ監視** - ブログ、ニュースサイト、または競合他社の更新を追跡
|
||||
- **ポッドキャスト自動化** - 新しいエピソードが公開されたときにワークフローをトリガー
|
||||
- **リリース追跡** - GitHubリリース、変更ログ、または製品アップデートを監視
|
||||
- **ソーシャルアグリゲーション** - RSSフィードを公開しているプラットフォームからコンテンツを収集
|
||||
|
||||
<Callout>
|
||||
RSSトリガーは、トリガーを保存した後に公開されたアイテムに対してのみ実行されます。既存のフィードアイテムは処理されません。
|
||||
</Callout>
|
||||
@@ -21,24 +21,28 @@ import { Image } from '@/components/ui/image'
|
||||
使用 Start 块处理从编辑器、部署到 API 或部署到聊天的所有操作。其他触发器可用于事件驱动的工作流:
|
||||
|
||||
<Cards>
|
||||
<Card title="Start" href="/triggers/start">
|
||||
<Card title="开始" href="/triggers/start">
|
||||
支持编辑器运行、API 部署和聊天部署的统一入口点
|
||||
</Card>
|
||||
<Card title="Webhook" href="/triggers/webhook">
|
||||
接收外部 webhook 负载
|
||||
</Card>
|
||||
<Card title="Schedule" href="/triggers/schedule">
|
||||
<Card title="计划" href="/triggers/schedule">
|
||||
基于 Cron 或间隔的执行
|
||||
</Card>
|
||||
<Card title="RSS 源" href="/triggers/rss">
|
||||
监控 RSS 和 Atom 源的新内容
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
## 快速对比
|
||||
|
||||
| 触发器 | 启动条件 |
|
||||
|---------|-----------------|
|
||||
| **Start** | 编辑器运行、部署到 API 请求或聊天消息 |
|
||||
| **Schedule** | 在 Schedule 块中管理的计时器 |
|
||||
| **Webhook** | 收到入站 HTTP 请求 |
|
||||
| **开始** | 编辑器运行、部署到 API 请求或聊天消息 |
|
||||
| **计划** | 在计划块中管理的计时器 |
|
||||
| **Webhook** | 收到入站 HTTP 请求时 |
|
||||
| **RSS 源** | 源中发布了新项目 |
|
||||
|
||||
> Start 块始终公开 `input`、`conversationId` 和 `files` 字段。通过向输入格式添加自定义字段来增加结构化数据。
|
||||
|
||||
|
||||
49
apps/docs/content/docs/zh/triggers/rss.mdx
Normal file
49
apps/docs/content/docs/zh/triggers/rss.mdx
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: RSS 订阅源
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
import { Image } from '@/components/ui/image'
|
||||
|
||||
RSS 订阅源模块监控 RSS 和 Atom 订阅源——当有新内容发布时,您的工作流会自动触发。
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/static/blocks/rss.png"
|
||||
alt="RSS 订阅源模块"
|
||||
width={500}
|
||||
height={400}
|
||||
className="my-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
## 配置
|
||||
|
||||
1. **添加 RSS 订阅源模块** - 拖动 RSS 订阅源模块以开始您的工作流
|
||||
2. **输入订阅源 URL** - 粘贴任意 RSS 或 Atom 订阅源的 URL
|
||||
3. **部署** - 部署您的工作流以激活轮询
|
||||
|
||||
部署后,订阅源每分钟检查一次是否有新内容。
|
||||
|
||||
## 输出字段
|
||||
|
||||
| 字段 | 类型 | 描述 |
|
||||
|-------|------|-------------|
|
||||
| `title` | string | 内容标题 |
|
||||
| `link` | string | 内容链接 |
|
||||
| `pubDate` | string | 发布日期 |
|
||||
| `item` | object | 包含所有字段的原始内容 |
|
||||
| `feed` | object | 原始订阅源元数据 |
|
||||
|
||||
可以直接访问映射字段 (`<rss.title>`),或者使用原始对象访问任意字段 (`<rss.item.author>`, `<rss.feed.language>`)。
|
||||
|
||||
## 使用场景
|
||||
|
||||
- **内容监控** - 跟踪博客、新闻网站或竞争对手的更新
|
||||
- **播客自动化** - 当新剧集发布时触发工作流
|
||||
- **版本跟踪** - 监控 GitHub 发布、更新日志或产品更新
|
||||
- **社交聚合** - 收集支持 RSS 订阅源的平台内容
|
||||
|
||||
<Callout>
|
||||
RSS 触发器仅对您保存触发器后发布的内容生效。现有的订阅源内容不会被处理。
|
||||
</Callout>
|
||||
@@ -5760,9 +5760,9 @@ checksums:
|
||||
content/1: e71056df0f7b2eb3b2f271f21d0052cc
|
||||
content/2: da2b445db16c149f56558a4ea876a5f0
|
||||
content/3: cec18f48b2cd7974eb556880e6604f7f
|
||||
content/4: c187ae3362455acfe43282399f0d163a
|
||||
content/4: b200402d6a01ab565fd56d113c530ef6
|
||||
content/5: 4c3a5708af82c1ee42a12d14fd34e950
|
||||
content/6: 12a43b499c1e8bb06b050964053ebde3
|
||||
content/6: 64fbd5b16f4cff18ba976492a275c05e
|
||||
content/7: a28151eeb5ba3518b33809055b04f0f6
|
||||
content/8: cffe5b901d78ebf2000d07dc7579533e
|
||||
content/9: 73486253d24eeff7ac44dfd0c8868d87
|
||||
@@ -49300,3 +49300,17 @@ checksums:
|
||||
content/42: dc2cfed837ea55adfa23bd7c87d5299d
|
||||
content/43: b3f310d5ef115bea5a8b75bf25d7ea9a
|
||||
content/44: df2ef65659b8ea0a13916358943f965b
|
||||
ebed3bd73520bf81399749586796f9d0:
|
||||
meta/title: 1763bebd6001500cdfc1b5127b0c1cde
|
||||
content/0: eb0ed7078f192304703144f4cac3442f
|
||||
content/1: ba5ba29787a0eb35c46dacb3544bafe1
|
||||
content/2: 5ed74bf0e91235f71eeceb25712ad2d3
|
||||
content/3: 0441638444240cd20a6c69ea1d3afbb1
|
||||
content/4: ef102e10f1402df7290680c1e9df8a5e
|
||||
content/5: 95afa83a30cb01724b932b19dd69f20b
|
||||
content/6: 8ebc5e005f61d253c006824168abaf22
|
||||
content/7: df81a49b54d378523fb74aa0b0fb8be1
|
||||
content/8: c5fb77d31bae86aa85f2b2b84ce0beab
|
||||
content/9: 7a3be8a3771ee428ecf09008e42c0e2e
|
||||
content/10: 42e4caf9b036a8d7726a8968f3ed201f
|
||||
content/11: e74f8ee79105babdaa8dfec520ecdf74
|
||||
|
||||
BIN
apps/docs/public/static/blocks/rss.png
Normal file
BIN
apps/docs/public/static/blocks/rss.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -1,9 +1,10 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription } from '@sim/db/schema'
|
||||
import { member, organization, subscription } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -172,6 +173,39 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
})
|
||||
.where(eq(subscription.id, orgSubscription.id))
|
||||
|
||||
// Update orgUsageLimit to reflect new seat count (seats × basePrice as minimum)
|
||||
const { basePrice } = getPlanPricing('team')
|
||||
const newMinimumLimit = newSeatCount * basePrice
|
||||
|
||||
const orgData = await db
|
||||
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
const currentOrgLimit =
|
||||
orgData.length > 0 && orgData[0].orgUsageLimit
|
||||
? Number.parseFloat(orgData[0].orgUsageLimit)
|
||||
: 0
|
||||
|
||||
// Update if new minimum is higher than current limit
|
||||
if (newMinimumLimit > currentOrgLimit) {
|
||||
await db
|
||||
.update(organization)
|
||||
.set({
|
||||
orgUsageLimit: newMinimumLimit.toFixed(2),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(organization.id, organizationId))
|
||||
|
||||
logger.info('Updated organization usage limit for seat change', {
|
||||
organizationId,
|
||||
newSeatCount,
|
||||
newMinimumLimit,
|
||||
previousLimit: currentOrgLimit,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Successfully updated seat count', {
|
||||
organizationId,
|
||||
stripeSubscriptionId: orgSubscription.stripeSubscriptionId,
|
||||
|
||||
@@ -4,9 +4,9 @@ import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage'
|
||||
import { getUserStorageLimit, getUserStorageUsage } from '@/lib/billing/storage'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createErrorResponse } from '@/app/api/workflows/utils'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
|
||||
const logger = createLogger('UsageLimitsAPI')
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
|
||||
export interface UserLimits {
|
||||
workflowExecutionRateLimit: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { authenticateV1Request } from '@/app/api/v1/auth'
|
||||
import { RateLimiter } from '@/services/queue/RateLimiter'
|
||||
|
||||
const logger = createLogger('V1Middleware')
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
66
apps/sim/app/api/webhooks/poll/rss/route.ts
Normal file
66
apps/sim/app/api/webhooks/poll/rss/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
import { acquireLock, releaseLock } from '@/lib/core/config/redis'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { pollRssWebhooks } from '@/lib/webhooks/rss-polling-service'
|
||||
|
||||
const logger = createLogger('RssPollingAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 180 // Allow up to 3 minutes for polling to complete
|
||||
|
||||
const LOCK_KEY = 'rss-polling-lock'
|
||||
const LOCK_TTL_SECONDS = 180 // Same as maxDuration (3 min)
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = nanoid()
|
||||
logger.info(`RSS webhook polling triggered (${requestId})`)
|
||||
|
||||
let lockValue: string | undefined
|
||||
|
||||
try {
|
||||
const authError = verifyCronAuth(request, 'RSS webhook polling')
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
lockValue = requestId
|
||||
const locked = await acquireLock(LOCK_KEY, lockValue, LOCK_TTL_SECONDS)
|
||||
|
||||
if (!locked) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Polling already in progress – skipped',
|
||||
requestId,
|
||||
status: 'skip',
|
||||
},
|
||||
{ status: 202 }
|
||||
)
|
||||
}
|
||||
|
||||
const results = await pollRssWebhooks()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'RSS polling completed',
|
||||
requestId,
|
||||
status: 'completed',
|
||||
...results,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Error during RSS polling (${requestId}):`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'RSS polling failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
requestId,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
} finally {
|
||||
await releaseLock(LOCK_KEY).catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -544,6 +544,43 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
// --- End Outlook specific logic ---
|
||||
|
||||
// --- RSS webhook setup ---
|
||||
if (savedWebhook && provider === 'rss') {
|
||||
logger.info(`[${requestId}] RSS provider detected. Setting up RSS webhook configuration.`)
|
||||
try {
|
||||
const { configureRssPolling } = await import('@/lib/webhooks/utils.server')
|
||||
const success = await configureRssPolling(savedWebhook, requestId)
|
||||
|
||||
if (!success) {
|
||||
logger.error(`[${requestId}] Failed to configure RSS polling, rolling back webhook`)
|
||||
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to configure RSS polling',
|
||||
details: 'Please try again',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully configured RSS polling`)
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[${requestId}] Error setting up RSS webhook configuration, rolling back webhook`,
|
||||
err
|
||||
)
|
||||
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to configure RSS webhook',
|
||||
details: err instanceof Error ? err.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
// --- End RSS specific logic ---
|
||||
|
||||
const status = targetWebhookId ? 200 : 201
|
||||
return NextResponse.json({ webhook: savedWebhook }, { status })
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -140,7 +140,7 @@ vi.mock('@/lib/workspaces/utils', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/queue', () => ({
|
||||
vi.mock('@/lib/core/rate-limiter', () => ({
|
||||
RateLimiter: vi.fn().mockImplementation(() => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
|
||||
@@ -395,8 +395,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
triggerType: loggingTriggerType,
|
||||
executionId,
|
||||
requestId,
|
||||
checkRateLimit: false, // Manual executions bypass rate limits
|
||||
checkDeployment: !shouldUseDraftState, // Check deployment unless using draft
|
||||
checkDeployment: !shouldUseDraftState,
|
||||
loggingSession,
|
||||
})
|
||||
|
||||
|
||||
@@ -363,6 +363,8 @@ export function Dropdown({
|
||||
)
|
||||
}, [multiSelect, multiValues, optionMap])
|
||||
|
||||
const isSearchable = subBlockId === 'operation'
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
@@ -375,7 +377,6 @@ export function Dropdown({
|
||||
editable={false}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
// Fetch options when the dropdown is opened to ensure freshness
|
||||
void fetchOptionsIfNeeded()
|
||||
}
|
||||
}}
|
||||
@@ -383,6 +384,8 @@ export function Dropdown({
|
||||
multiSelect={multiSelect}
|
||||
isLoading={isLoadingOptions}
|
||||
error={fetchError}
|
||||
searchable={isSearchable}
|
||||
searchPlaceholder='Search operations...'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ import { and, eq, isNull, lte, or, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
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 { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import type { AlertConfig } from '@/lib/notifications/alert-rules'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
|
||||
const logger = createLogger('WorkspaceNotificationDelivery')
|
||||
|
||||
|
||||
36
apps/sim/blocks/blocks/rss.ts
Normal file
36
apps/sim/blocks/blocks/rss.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { RssIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { getTrigger } from '@/triggers'
|
||||
|
||||
export const RssBlock: BlockConfig = {
|
||||
type: 'rss',
|
||||
name: 'RSS Feed',
|
||||
description: 'Monitor RSS feeds and trigger workflows when new items are published',
|
||||
longDescription:
|
||||
'Subscribe to any RSS or Atom feed and automatically trigger your workflow when new content is published. Perfect for monitoring blogs, news sites, podcasts, and any content that publishes an RSS feed.',
|
||||
category: 'triggers',
|
||||
bgColor: '#F97316',
|
||||
icon: RssIcon,
|
||||
triggerAllowed: true,
|
||||
|
||||
subBlocks: [...getTrigger('rss_poller').subBlocks],
|
||||
|
||||
tools: {
|
||||
access: [], // Trigger-only for now
|
||||
},
|
||||
|
||||
inputs: {},
|
||||
|
||||
outputs: {
|
||||
title: { type: 'string', description: 'Item title' },
|
||||
link: { type: 'string', description: 'Item link' },
|
||||
pubDate: { type: 'string', description: 'Publication date' },
|
||||
item: { type: 'json', description: 'Raw item object with all fields' },
|
||||
feed: { type: 'json', description: 'Raw feed object with all fields' },
|
||||
},
|
||||
|
||||
triggers: {
|
||||
enabled: true,
|
||||
available: ['rss_poller'],
|
||||
},
|
||||
}
|
||||
@@ -89,6 +89,7 @@ import { RedditBlock } from '@/blocks/blocks/reddit'
|
||||
import { ResendBlock } from '@/blocks/blocks/resend'
|
||||
import { ResponseBlock } from '@/blocks/blocks/response'
|
||||
import { RouterBlock } from '@/blocks/blocks/router'
|
||||
import { RssBlock } from '@/blocks/blocks/rss'
|
||||
import { S3Block } from '@/blocks/blocks/s3'
|
||||
import { SalesforceBlock } from '@/blocks/blocks/salesforce'
|
||||
import { ScheduleBlock } from '@/blocks/blocks/schedule'
|
||||
@@ -229,6 +230,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
reddit: RedditBlock,
|
||||
resend: ResendBlock,
|
||||
response: ResponseBlock,
|
||||
rss: RssBlock,
|
||||
router: RouterBlock,
|
||||
s3: S3Block,
|
||||
salesforce: SalesforceBlock,
|
||||
|
||||
@@ -4170,3 +4170,32 @@ export function DuckDuckGoIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function RssIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M4 11C6.38695 11 8.67613 11.9482 10.364 13.636C12.0518 15.3239 13 17.6131 13 20'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M4 4C8.24346 4 12.3131 5.68571 15.3137 8.68629C18.3143 11.6869 20 15.7565 20 20'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<circle cx='5' cy='19' r='1' fill='currentColor' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, userStats } from '@sim/db/schema'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { getUserUsageLimit } from '@/lib/billing/core/usage'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -108,19 +107,10 @@ export async function checkUsageStatus(userId: string): Promise<UsageData> {
|
||||
)
|
||||
}
|
||||
}
|
||||
// Determine org cap
|
||||
let orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0
|
||||
// Determine org cap from orgUsageLimit (should always be set for team/enterprise)
|
||||
const orgCap = org.orgUsageLimit ? Number.parseFloat(String(org.orgUsageLimit)) : 0
|
||||
if (!orgCap || Number.isNaN(orgCap)) {
|
||||
// Fall back to minimum billing amount from Stripe subscription
|
||||
const orgSub = await getOrganizationSubscription(org.id)
|
||||
if (orgSub?.seats) {
|
||||
const { basePrice } = getPlanPricing(orgSub.plan)
|
||||
orgCap = (orgSub.seats ?? 0) * basePrice
|
||||
} else {
|
||||
// If no subscription, use team default
|
||||
const { basePrice } = getPlanPricing('team')
|
||||
orgCap = basePrice // Default to 1 seat minimum
|
||||
}
|
||||
logger.warn('Organization missing usage limit', { orgId: org.id })
|
||||
}
|
||||
if (pooledUsage >= orgCap) {
|
||||
isExceeded = true
|
||||
|
||||
@@ -22,6 +22,56 @@ import { getEmailPreferences } from '@/lib/messaging/email/unsubscribe'
|
||||
|
||||
const logger = createLogger('UsageManagement')
|
||||
|
||||
export interface OrgUsageLimitResult {
|
||||
limit: number
|
||||
minimum: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the effective usage limit for a team or enterprise organization.
|
||||
* - Enterprise: Uses orgUsageLimit directly (fixed pricing)
|
||||
* - Team: Uses orgUsageLimit but never below seats × basePrice
|
||||
*/
|
||||
export async function getOrgUsageLimit(
|
||||
organizationId: string,
|
||||
plan: string,
|
||||
seats: number | null
|
||||
): Promise<OrgUsageLimitResult> {
|
||||
const orgData = await db
|
||||
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
const configured =
|
||||
orgData.length > 0 && orgData[0].orgUsageLimit
|
||||
? Number.parseFloat(orgData[0].orgUsageLimit)
|
||||
: null
|
||||
|
||||
if (plan === 'enterprise') {
|
||||
// Enterprise: Use configured limit directly (no per-seat minimum)
|
||||
if (configured !== null) {
|
||||
return { limit: configured, minimum: configured }
|
||||
}
|
||||
logger.warn('Enterprise org missing usage limit', { orgId: organizationId })
|
||||
return { limit: 0, minimum: 0 }
|
||||
}
|
||||
|
||||
const { basePrice } = getPlanPricing(plan)
|
||||
const minimum = (seats ?? 0) * basePrice
|
||||
|
||||
if (configured !== null) {
|
||||
return { limit: Math.max(configured, minimum), minimum }
|
||||
}
|
||||
|
||||
logger.warn('Team org missing usage limit, using seats × basePrice fallback', {
|
||||
orgId: organizationId,
|
||||
seats,
|
||||
minimum,
|
||||
})
|
||||
return { limit: minimum, minimum }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new user setup when they join the platform
|
||||
* Creates userStats record with default free credits
|
||||
@@ -87,22 +137,13 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
|
||||
? Number.parseFloat(stats.currentUsageLimit)
|
||||
: getFreeTierLimit()
|
||||
} else {
|
||||
// Team/Enterprise: Use organization limit but never below minimum (seats × cost per seat)
|
||||
const orgData = await db
|
||||
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, subscription.referenceId))
|
||||
.limit(1)
|
||||
|
||||
const { basePrice } = getPlanPricing(subscription.plan)
|
||||
const minimum = (subscription.seats ?? 0) * basePrice
|
||||
|
||||
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
|
||||
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
|
||||
limit = Math.max(configured, minimum)
|
||||
} else {
|
||||
limit = minimum
|
||||
}
|
||||
// Team/Enterprise: Use organization limit
|
||||
const orgLimit = await getOrgUsageLimit(
|
||||
subscription.referenceId,
|
||||
subscription.plan,
|
||||
subscription.seats
|
||||
)
|
||||
limit = orgLimit.limit
|
||||
}
|
||||
|
||||
const percentUsed = limit > 0 ? Math.min((currentUsage / limit) * 100, 100) : 0
|
||||
@@ -159,24 +200,15 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
|
||||
minimumLimit = getPerUserMinimumLimit(subscription)
|
||||
canEdit = canEditUsageLimit(subscription)
|
||||
} else {
|
||||
// Team/Enterprise: Use organization limits (users cannot edit)
|
||||
const orgData = await db
|
||||
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, subscription.referenceId))
|
||||
.limit(1)
|
||||
|
||||
const { basePrice } = getPlanPricing(subscription.plan)
|
||||
const minimum = (subscription.seats ?? 0) * basePrice
|
||||
|
||||
if (orgData.length > 0 && orgData[0].orgUsageLimit) {
|
||||
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
|
||||
currentLimit = Math.max(configured, minimum)
|
||||
} else {
|
||||
currentLimit = minimum
|
||||
}
|
||||
minimumLimit = minimum
|
||||
canEdit = false // Team/enterprise members cannot edit limits
|
||||
// Team/Enterprise: Use organization limits
|
||||
const orgLimit = await getOrgUsageLimit(
|
||||
subscription.referenceId,
|
||||
subscription.plan,
|
||||
subscription.seats
|
||||
)
|
||||
currentLimit = orgLimit.limit
|
||||
minimumLimit = orgLimit.minimum
|
||||
canEdit = false
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -323,27 +355,23 @@ export async function getUserUsageLimit(userId: string): Promise<number> {
|
||||
|
||||
return Number.parseFloat(userStatsQuery[0].currentUsageLimit)
|
||||
}
|
||||
// Team/Enterprise: Use organization limit but never below minimum
|
||||
const orgData = await db
|
||||
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||
// Team/Enterprise: Verify org exists then use organization limit
|
||||
const orgExists = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, subscription.referenceId))
|
||||
.limit(1)
|
||||
|
||||
if (orgData.length === 0) {
|
||||
if (orgExists.length === 0) {
|
||||
throw new Error(`Organization not found: ${subscription.referenceId} for user: ${userId}`)
|
||||
}
|
||||
|
||||
if (orgData[0].orgUsageLimit) {
|
||||
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
|
||||
const { basePrice } = getPlanPricing(subscription.plan)
|
||||
const minimum = (subscription.seats ?? 0) * basePrice
|
||||
return Math.max(configured, minimum)
|
||||
}
|
||||
|
||||
// If org hasn't set a custom limit, use minimum (seats × cost per seat)
|
||||
const { basePrice } = getPlanPricing(subscription.plan)
|
||||
return (subscription.seats ?? 0) * basePrice
|
||||
const orgLimit = await getOrgUsageLimit(
|
||||
subscription.referenceId,
|
||||
subscription.plan,
|
||||
subscription.seats
|
||||
)
|
||||
return orgLimit.limit
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import * as schema from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
@@ -145,11 +146,52 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
|
||||
plan: subscription.plan,
|
||||
})
|
||||
} else {
|
||||
// Organization subscription - sync usage limits for all members
|
||||
// Organization subscription - set org usage limit and sync member limits
|
||||
const organizationId = subscription.referenceId
|
||||
|
||||
// Set orgUsageLimit for team plans (enterprise is set via webhook with custom pricing)
|
||||
if (subscription.plan === 'team') {
|
||||
const { basePrice } = getPlanPricing(subscription.plan)
|
||||
const seats = subscription.seats ?? 1
|
||||
const orgLimit = seats * basePrice
|
||||
|
||||
// Only set if not already set or if updating to a higher value based on seats
|
||||
const orgData = await db
|
||||
.select({ orgUsageLimit: schema.organization.orgUsageLimit })
|
||||
.from(schema.organization)
|
||||
.where(eq(schema.organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
const currentLimit =
|
||||
orgData.length > 0 && orgData[0].orgUsageLimit
|
||||
? Number.parseFloat(orgData[0].orgUsageLimit)
|
||||
: 0
|
||||
|
||||
// Update if no limit set, or if new seat-based minimum is higher
|
||||
if (currentLimit < orgLimit) {
|
||||
await db
|
||||
.update(schema.organization)
|
||||
.set({
|
||||
orgUsageLimit: orgLimit.toFixed(2),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.organization.id, organizationId))
|
||||
|
||||
logger.info('Set organization usage limit for team plan', {
|
||||
organizationId,
|
||||
seats,
|
||||
basePrice,
|
||||
orgLimit,
|
||||
previousLimit: currentLimit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sync usage limits for all members
|
||||
const members = await db
|
||||
.select({ userId: schema.member.userId })
|
||||
.from(schema.member)
|
||||
.where(eq(schema.member.organizationId, subscription.referenceId))
|
||||
.where(eq(schema.member.organizationId, organizationId))
|
||||
|
||||
if (members.length > 0) {
|
||||
for (const member of members) {
|
||||
@@ -158,7 +200,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
|
||||
} catch (memberError) {
|
||||
logger.error('Failed to sync usage limits for organization member', {
|
||||
userId: member.userId,
|
||||
organizationId: subscription.referenceId,
|
||||
organizationId,
|
||||
subscriptionId: subscription.id,
|
||||
error: memberError,
|
||||
})
|
||||
@@ -166,7 +208,7 @@ export async function syncSubscriptionUsageLimits(subscription: SubscriptionData
|
||||
}
|
||||
|
||||
logger.info('Synced usage limits for organization members', {
|
||||
organizationId: subscription.referenceId,
|
||||
organizationId,
|
||||
memberCount: members.length,
|
||||
subscriptionId: subscription.id,
|
||||
plan: subscription.plan,
|
||||
|
||||
7
apps/sim/lib/core/rate-limiter/index.ts
Normal file
7
apps/sim/lib/core/rate-limiter/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
|
||||
export type {
|
||||
RateLimitConfig,
|
||||
SubscriptionPlan,
|
||||
TriggerType,
|
||||
} from '@/lib/core/rate-limiter/types'
|
||||
export { RATE_LIMITS, RateLimitError } from '@/lib/core/rate-limiter/types'
|
||||
309
apps/sim/lib/core/rate-limiter/rate-limiter.test.ts
Normal file
309
apps/sim/lib/core/rate-limiter/rate-limiter.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
|
||||
import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS } from '@/lib/core/rate-limiter/types'
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((field, value) => ({ field, value })),
|
||||
sql: vi.fn((strings, ...values) => ({ sql: strings.join('?'), values })),
|
||||
and: vi.fn((...conditions) => ({ and: conditions })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/redis', () => ({
|
||||
getRedisClient: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
|
||||
import { db } from '@sim/db'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
|
||||
describe('RateLimiter', () => {
|
||||
const rateLimiter = new RateLimiter()
|
||||
const testUserId = 'test-user-123'
|
||||
const freeSubscription = { plan: 'free', referenceId: testUserId }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getRedisClient).mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe('checkRateLimitWithSubscription', () => {
|
||||
it('should allow unlimited requests for manual trigger type', async () => {
|
||||
const result = await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
freeSubscription,
|
||||
'manual',
|
||||
false
|
||||
)
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.remaining).toBe(MANUAL_EXECUTION_LIMIT)
|
||||
expect(result.resetAt).toBeInstanceOf(Date)
|
||||
expect(db.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow first API request for sync execution (DB fallback)', async () => {
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
vi.mocked(db.insert).mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoUpdate: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([
|
||||
{
|
||||
syncApiRequests: 1,
|
||||
asyncApiRequests: 0,
|
||||
apiEndpointRequests: 0,
|
||||
windowStart: new Date(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
freeSubscription,
|
||||
'api',
|
||||
false
|
||||
)
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 1)
|
||||
expect(result.resetAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('should allow first API request for async execution (DB fallback)', async () => {
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
vi.mocked(db.insert).mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoUpdate: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([
|
||||
{
|
||||
syncApiRequests: 0,
|
||||
asyncApiRequests: 1,
|
||||
apiEndpointRequests: 0,
|
||||
windowStart: new Date(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
freeSubscription,
|
||||
'api',
|
||||
true
|
||||
)
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.remaining).toBe(RATE_LIMITS.free.asyncApiExecutionsPerMinute - 1)
|
||||
expect(result.resetAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('should work for all trigger types except manual (DB fallback)', async () => {
|
||||
const triggerTypes = ['api', 'webhook', 'schedule', 'chat'] as const
|
||||
|
||||
for (const triggerType of triggerTypes) {
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
vi.mocked(db.insert).mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoUpdate: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([
|
||||
{
|
||||
syncApiRequests: 1,
|
||||
asyncApiRequests: 0,
|
||||
apiEndpointRequests: 0,
|
||||
windowStart: new Date(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
freeSubscription,
|
||||
triggerType,
|
||||
false
|
||||
)
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 1)
|
||||
}
|
||||
})
|
||||
|
||||
it('should use Redis when available', async () => {
|
||||
const mockRedis = {
|
||||
eval: vi.fn().mockResolvedValue(1), // Lua script returns count after INCR
|
||||
}
|
||||
vi.mocked(getRedisClient).mockReturnValue(mockRedis as any)
|
||||
|
||||
const result = await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
freeSubscription,
|
||||
'api',
|
||||
false
|
||||
)
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 1)
|
||||
expect(mockRedis.eval).toHaveBeenCalled()
|
||||
expect(db.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should deny requests when Redis rate limit exceeded', async () => {
|
||||
const mockRedis = {
|
||||
eval: vi.fn().mockResolvedValue(RATE_LIMITS.free.syncApiExecutionsPerMinute + 1),
|
||||
}
|
||||
vi.mocked(getRedisClient).mockReturnValue(mockRedis as any)
|
||||
|
||||
const result = await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
freeSubscription,
|
||||
'api',
|
||||
false
|
||||
)
|
||||
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.remaining).toBe(0)
|
||||
})
|
||||
|
||||
it('should fall back to DB when Redis fails', async () => {
|
||||
const mockRedis = {
|
||||
eval: vi.fn().mockRejectedValue(new Error('Redis connection failed')),
|
||||
}
|
||||
vi.mocked(getRedisClient).mockReturnValue(mockRedis as any)
|
||||
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
vi.mocked(db.insert).mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoUpdate: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([
|
||||
{
|
||||
syncApiRequests: 1,
|
||||
asyncApiRequests: 0,
|
||||
apiEndpointRequests: 0,
|
||||
windowStart: new Date(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await rateLimiter.checkRateLimitWithSubscription(
|
||||
testUserId,
|
||||
freeSubscription,
|
||||
'api',
|
||||
false
|
||||
)
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(db.select).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRateLimitStatusWithSubscription', () => {
|
||||
it('should return unlimited for manual trigger type', async () => {
|
||||
const status = await rateLimiter.getRateLimitStatusWithSubscription(
|
||||
testUserId,
|
||||
freeSubscription,
|
||||
'manual',
|
||||
false
|
||||
)
|
||||
|
||||
expect(status.used).toBe(0)
|
||||
expect(status.limit).toBe(MANUAL_EXECUTION_LIMIT)
|
||||
expect(status.remaining).toBe(MANUAL_EXECUTION_LIMIT)
|
||||
expect(status.resetAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('should return sync API limits for API trigger type (DB fallback)', async () => {
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const status = await rateLimiter.getRateLimitStatusWithSubscription(
|
||||
testUserId,
|
||||
freeSubscription,
|
||||
'api',
|
||||
false
|
||||
)
|
||||
|
||||
expect(status.used).toBe(0)
|
||||
expect(status.limit).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute)
|
||||
expect(status.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute)
|
||||
expect(status.resetAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('should use Redis for status when available', async () => {
|
||||
const mockRedis = {
|
||||
get: vi.fn().mockResolvedValue('5'),
|
||||
}
|
||||
vi.mocked(getRedisClient).mockReturnValue(mockRedis as any)
|
||||
|
||||
const status = await rateLimiter.getRateLimitStatusWithSubscription(
|
||||
testUserId,
|
||||
freeSubscription,
|
||||
'api',
|
||||
false
|
||||
)
|
||||
|
||||
expect(status.used).toBe(5)
|
||||
expect(status.limit).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute)
|
||||
expect(status.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 5)
|
||||
expect(mockRedis.get).toHaveBeenCalled()
|
||||
expect(db.select).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetRateLimit', () => {
|
||||
it('should delete rate limit record for user', async () => {
|
||||
vi.mocked(db.delete).mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue({}),
|
||||
} as any)
|
||||
|
||||
await rateLimiter.resetRateLimit(testUserId)
|
||||
|
||||
expect(db.delete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { db } from '@sim/db'
|
||||
import { userRateLimits } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type Redis from 'ioredis'
|
||||
import { getRedisClient } from '@/lib/core/config/redis'
|
||||
import {
|
||||
MANUAL_EXECUTION_LIMIT,
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
type RateLimitCounterType,
|
||||
type SubscriptionPlan,
|
||||
type TriggerType,
|
||||
} from '@/services/queue/types'
|
||||
} from '@/lib/core/rate-limiter/types'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('RateLimiter')
|
||||
|
||||
@@ -88,6 +89,69 @@ export class RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit using Redis (faster, single atomic operation)
|
||||
* Uses fixed window algorithm with INCR + EXPIRE
|
||||
*/
|
||||
private async checkRateLimitRedis(
|
||||
redis: Redis,
|
||||
rateLimitKey: string,
|
||||
counterType: RateLimitCounterType,
|
||||
limit: number
|
||||
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
|
||||
const windowMs = RATE_LIMIT_WINDOW_MS
|
||||
const windowKey = Math.floor(Date.now() / windowMs)
|
||||
const key = `ratelimit:${rateLimitKey}:${counterType}:${windowKey}`
|
||||
const ttlSeconds = Math.ceil(windowMs / 1000)
|
||||
|
||||
// Atomic increment + expire
|
||||
const count = (await redis.eval(
|
||||
'local c = redis.call("INCR", KEYS[1]) if c == 1 then redis.call("EXPIRE", KEYS[1], ARGV[1]) end return c',
|
||||
1,
|
||||
key,
|
||||
ttlSeconds
|
||||
)) as number
|
||||
|
||||
const resetAt = new Date((windowKey + 1) * windowMs)
|
||||
|
||||
if (count > limit) {
|
||||
logger.info(`Rate limit exceeded (Redis) - request ${count} > limit ${limit}`, {
|
||||
rateLimitKey,
|
||||
counterType,
|
||||
limit,
|
||||
count,
|
||||
})
|
||||
return { allowed: false, remaining: 0, resetAt }
|
||||
}
|
||||
|
||||
return { allowed: true, remaining: limit - count, resetAt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit status using Redis (read-only, doesn't increment)
|
||||
*/
|
||||
private async getRateLimitStatusRedis(
|
||||
redis: Redis,
|
||||
rateLimitKey: string,
|
||||
counterType: RateLimitCounterType,
|
||||
limit: number
|
||||
): Promise<{ used: number; limit: number; remaining: number; resetAt: Date }> {
|
||||
const windowMs = RATE_LIMIT_WINDOW_MS
|
||||
const windowKey = Math.floor(Date.now() / windowMs)
|
||||
const key = `ratelimit:${rateLimitKey}:${counterType}:${windowKey}`
|
||||
|
||||
const countStr = await redis.get(key)
|
||||
const used = countStr ? Number.parseInt(countStr, 10) : 0
|
||||
const resetAt = new Date((windowKey + 1) * windowMs)
|
||||
|
||||
return {
|
||||
used,
|
||||
limit,
|
||||
remaining: Math.max(0, limit - used),
|
||||
resetAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can execute a workflow with organization-aware rate limiting
|
||||
* Manual executions bypass rate limiting entirely
|
||||
@@ -114,6 +178,18 @@ export class RateLimiter {
|
||||
const counterType = this.getCounterType(triggerType, isAsync)
|
||||
const execLimit = this.getRateLimitForCounter(limit, counterType)
|
||||
|
||||
// Try Redis first for faster rate limiting
|
||||
const redis = getRedisClient()
|
||||
if (redis) {
|
||||
try {
|
||||
return await this.checkRateLimitRedis(redis, rateLimitKey, counterType, execLimit)
|
||||
} catch (error) {
|
||||
logger.warn('Redis rate limit check failed, falling back to DB:', { error })
|
||||
// Fall through to DB implementation
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to DB implementation
|
||||
const now = new Date()
|
||||
const windowStart = new Date(now.getTime() - RATE_LIMIT_WINDOW_MS)
|
||||
|
||||
@@ -273,21 +349,6 @@ export class RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method - for backward compatibility
|
||||
* @deprecated Use checkRateLimitWithSubscription instead
|
||||
*/
|
||||
async checkRateLimit(
|
||||
userId: string,
|
||||
subscriptionPlan: SubscriptionPlan = 'free',
|
||||
triggerType: TriggerType = 'manual',
|
||||
isAsync = false
|
||||
): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> {
|
||||
// For backward compatibility, fetch the subscription
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
return this.checkRateLimitWithSubscription(userId, subscription, triggerType, isAsync)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status with organization awareness
|
||||
* Only applies to API executions
|
||||
@@ -315,6 +376,18 @@ export class RateLimiter {
|
||||
const counterType = this.getCounterType(triggerType, isAsync)
|
||||
const execLimit = this.getRateLimitForCounter(limit, counterType)
|
||||
|
||||
// Try Redis first for faster status check
|
||||
const redis = getRedisClient()
|
||||
if (redis) {
|
||||
try {
|
||||
return await this.getRateLimitStatusRedis(redis, rateLimitKey, counterType, execLimit)
|
||||
} catch (error) {
|
||||
logger.warn('Redis rate limit status check failed, falling back to DB:', { error })
|
||||
// Fall through to DB implementation
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to DB implementation
|
||||
const now = new Date()
|
||||
const windowStart = new Date(now.getTime() - RATE_LIMIT_WINDOW_MS)
|
||||
|
||||
@@ -355,21 +428,6 @@ export class RateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method - for backward compatibility
|
||||
* @deprecated Use getRateLimitStatusWithSubscription instead
|
||||
*/
|
||||
async getRateLimitStatus(
|
||||
userId: string,
|
||||
subscriptionPlan: SubscriptionPlan = 'free',
|
||||
triggerType: TriggerType = 'manual',
|
||||
isAsync = false
|
||||
): Promise<{ used: number; limit: number; remaining: number; resetAt: Date }> {
|
||||
// For backward compatibility, fetch the subscription
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
return this.getRateLimitStatusWithSubscription(userId, subscription, triggerType, isAsync)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rate limit for a user or organization
|
||||
*/
|
||||
@@ -3,10 +3,10 @@ import { workflow } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import { RateLimiter } from '@/services/queue/RateLimiter'
|
||||
|
||||
const logger = createLogger('ExecutionPreprocessing')
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
member,
|
||||
organization,
|
||||
userStats,
|
||||
user as userTable,
|
||||
workflow,
|
||||
@@ -10,7 +9,11 @@ import {
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { checkUsageStatus, maybeSendUsageThresholdEmail } from '@/lib/billing/core/usage'
|
||||
import {
|
||||
checkUsageStatus,
|
||||
getOrgUsageLimit,
|
||||
maybeSendUsageThresholdEmail,
|
||||
} from '@/lib/billing/core/usage'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
@@ -386,21 +389,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
limit,
|
||||
})
|
||||
} else if (sub?.referenceId) {
|
||||
let orgLimit = 0
|
||||
const orgRows = await db
|
||||
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, sub.referenceId))
|
||||
.limit(1)
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(sub.plan)
|
||||
const minimum = (sub.seats || 1) * basePrice
|
||||
if (orgRows.length > 0 && orgRows[0].orgUsageLimit) {
|
||||
const configured = Number.parseFloat(orgRows[0].orgUsageLimit)
|
||||
orgLimit = Math.max(configured, minimum)
|
||||
} else {
|
||||
orgLimit = minimum
|
||||
}
|
||||
// Get org usage limit using shared helper
|
||||
const { limit: orgLimit } = await getOrgUsageLimit(sub.referenceId, sub.plan, sub.seats)
|
||||
|
||||
const [{ sum: orgUsageBefore }] = await db
|
||||
.select({ sum: sql`COALESCE(SUM(${userStats.currentPeriodCost}), 0)` })
|
||||
|
||||
414
apps/sim/lib/webhooks/rss-polling-service.ts
Normal file
414
apps/sim/lib/webhooks/rss-polling-service.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { db } from '@sim/db'
|
||||
import { webhook, workflow } from '@sim/db/schema'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import Parser from 'rss-parser'
|
||||
import { pollingIdempotency } from '@/lib/core/idempotency/service'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('RssPollingService')
|
||||
|
||||
const MAX_CONSECUTIVE_FAILURES = 10
|
||||
const MAX_GUIDS_TO_TRACK = 100 // Track recent guids to prevent duplicates
|
||||
|
||||
interface RssWebhookConfig {
|
||||
feedUrl: string
|
||||
lastCheckedTimestamp?: string
|
||||
lastSeenGuids?: string[]
|
||||
etag?: string
|
||||
lastModified?: string
|
||||
}
|
||||
|
||||
interface RssItem {
|
||||
title?: string
|
||||
link?: string
|
||||
pubDate?: string
|
||||
guid?: string
|
||||
description?: string
|
||||
content?: string
|
||||
contentSnippet?: string
|
||||
author?: string
|
||||
creator?: string
|
||||
categories?: string[]
|
||||
enclosure?: {
|
||||
url: string
|
||||
type?: string
|
||||
length?: string | number
|
||||
}
|
||||
isoDate?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface RssFeed {
|
||||
title?: string
|
||||
link?: string
|
||||
description?: string
|
||||
items: RssItem[]
|
||||
}
|
||||
|
||||
export interface RssWebhookPayload {
|
||||
item: RssItem
|
||||
feed: {
|
||||
title?: string
|
||||
link?: string
|
||||
description?: string
|
||||
}
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
const parser = new Parser({
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'User-Agent': 'SimStudio/1.0 RSS Poller',
|
||||
},
|
||||
})
|
||||
|
||||
async function markWebhookFailed(webhookId: string) {
|
||||
try {
|
||||
const result = await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
failedCount: sql`COALESCE(${webhook.failedCount}, 0) + 1`,
|
||||
lastFailedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(webhook.id, webhookId))
|
||||
.returning({ failedCount: webhook.failedCount })
|
||||
|
||||
const newFailedCount = result[0]?.failedCount || 0
|
||||
const shouldDisable = newFailedCount >= MAX_CONSECUTIVE_FAILURES
|
||||
|
||||
if (shouldDisable) {
|
||||
await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
isActive: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(webhook.id, webhookId))
|
||||
|
||||
logger.warn(
|
||||
`Webhook ${webhookId} auto-disabled after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to mark webhook ${webhookId} as failed:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
async function markWebhookSuccess(webhookId: string) {
|
||||
try {
|
||||
await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
failedCount: 0,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(webhook.id, webhookId))
|
||||
} catch (err) {
|
||||
logger.error(`Failed to mark webhook ${webhookId} as successful:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollRssWebhooks() {
|
||||
logger.info('Starting RSS webhook polling')
|
||||
|
||||
try {
|
||||
const activeWebhooksResult = await db
|
||||
.select({ webhook })
|
||||
.from(webhook)
|
||||
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
|
||||
.where(
|
||||
and(eq(webhook.provider, 'rss'), eq(webhook.isActive, true), eq(workflow.isDeployed, true))
|
||||
)
|
||||
|
||||
const activeWebhooks = activeWebhooksResult.map((r) => r.webhook)
|
||||
|
||||
if (!activeWebhooks.length) {
|
||||
logger.info('No active RSS webhooks found')
|
||||
return { total: 0, successful: 0, failed: 0, details: [] }
|
||||
}
|
||||
|
||||
logger.info(`Found ${activeWebhooks.length} active RSS webhooks`)
|
||||
|
||||
const CONCURRENCY = 10
|
||||
const running: Promise<void>[] = []
|
||||
let successCount = 0
|
||||
let failureCount = 0
|
||||
|
||||
const enqueue = async (webhookData: (typeof activeWebhooks)[number]) => {
|
||||
const webhookId = webhookData.id
|
||||
const requestId = nanoid()
|
||||
|
||||
try {
|
||||
const config = webhookData.providerConfig as unknown as RssWebhookConfig
|
||||
|
||||
if (!config?.feedUrl) {
|
||||
logger.error(`[${requestId}] Missing feedUrl for webhook ${webhookId}`)
|
||||
await markWebhookFailed(webhookId)
|
||||
failureCount++
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const { feed, items: newItems } = await fetchNewRssItems(config, requestId)
|
||||
|
||||
if (!newItems.length) {
|
||||
await updateWebhookConfig(webhookId, config, now.toISOString(), [])
|
||||
await markWebhookSuccess(webhookId)
|
||||
logger.info(`[${requestId}] No new items found for webhook ${webhookId}`)
|
||||
successCount++
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Found ${newItems.length} new items for webhook ${webhookId}`)
|
||||
|
||||
const { processedCount, failedCount: itemFailedCount } = await processRssItems(
|
||||
newItems,
|
||||
feed,
|
||||
webhookData,
|
||||
requestId
|
||||
)
|
||||
|
||||
// Collect guids from processed items
|
||||
const newGuids = newItems
|
||||
.map((item) => item.guid || item.link || '')
|
||||
.filter((guid) => guid.length > 0)
|
||||
|
||||
await updateWebhookConfig(webhookId, config, now.toISOString(), newGuids)
|
||||
|
||||
if (itemFailedCount > 0 && processedCount === 0) {
|
||||
await markWebhookFailed(webhookId)
|
||||
failureCount++
|
||||
logger.warn(
|
||||
`[${requestId}] All ${itemFailedCount} items failed to process for webhook ${webhookId}`
|
||||
)
|
||||
} else {
|
||||
await markWebhookSuccess(webhookId)
|
||||
successCount++
|
||||
logger.info(
|
||||
`[${requestId}] Successfully processed ${processedCount} items for webhook ${webhookId}${itemFailedCount > 0 ? ` (${itemFailedCount} failed)` : ''}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error processing RSS webhook ${webhookId}:`, error)
|
||||
await markWebhookFailed(webhookId)
|
||||
failureCount++
|
||||
}
|
||||
}
|
||||
|
||||
for (const webhookData of activeWebhooks) {
|
||||
const promise = enqueue(webhookData)
|
||||
.then(() => {})
|
||||
.catch((err) => {
|
||||
logger.error('Unexpected error in webhook processing:', err)
|
||||
failureCount++
|
||||
})
|
||||
|
||||
running.push(promise)
|
||||
|
||||
if (running.length >= CONCURRENCY) {
|
||||
const completedIdx = await Promise.race(running.map((p, i) => p.then(() => i)))
|
||||
running.splice(completedIdx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(running)
|
||||
|
||||
const summary = {
|
||||
total: activeWebhooks.length,
|
||||
successful: successCount,
|
||||
failed: failureCount,
|
||||
details: [],
|
||||
}
|
||||
|
||||
logger.info('RSS polling completed', {
|
||||
total: summary.total,
|
||||
successful: summary.successful,
|
||||
failed: summary.failed,
|
||||
})
|
||||
|
||||
return summary
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error('Error in RSS polling service:', errorMessage)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNewRssItems(
|
||||
config: RssWebhookConfig,
|
||||
requestId: string
|
||||
): Promise<{ feed: RssFeed; items: RssItem[] }> {
|
||||
try {
|
||||
logger.debug(`[${requestId}] Fetching RSS feed: ${config.feedUrl}`)
|
||||
|
||||
// Parse the RSS feed
|
||||
const feed = await parser.parseURL(config.feedUrl)
|
||||
|
||||
if (!feed.items || !feed.items.length) {
|
||||
logger.debug(`[${requestId}] No items in feed`)
|
||||
return { feed: feed as RssFeed, items: [] }
|
||||
}
|
||||
|
||||
// Filter new items based on timestamp and guids
|
||||
const lastCheckedTime = config.lastCheckedTimestamp
|
||||
? new Date(config.lastCheckedTimestamp)
|
||||
: null
|
||||
const lastSeenGuids = new Set(config.lastSeenGuids || [])
|
||||
|
||||
const newItems = feed.items.filter((item) => {
|
||||
const itemGuid = item.guid || item.link || ''
|
||||
|
||||
// Check if we've already seen this item by guid
|
||||
if (itemGuid && lastSeenGuids.has(itemGuid)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the item is newer than our last check
|
||||
if (lastCheckedTime && item.isoDate) {
|
||||
const itemDate = new Date(item.isoDate)
|
||||
if (itemDate <= lastCheckedTime) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Sort by date, newest first
|
||||
newItems.sort((a, b) => {
|
||||
const dateA = a.isoDate ? new Date(a.isoDate).getTime() : 0
|
||||
const dateB = b.isoDate ? new Date(b.isoDate).getTime() : 0
|
||||
return dateB - dateA
|
||||
})
|
||||
|
||||
// Limit to 25 items per poll to prevent overwhelming the system
|
||||
const limitedItems = newItems.slice(0, 25)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Found ${newItems.length} new items (processing ${limitedItems.length})`
|
||||
)
|
||||
|
||||
return { feed: feed as RssFeed, items: limitedItems as RssItem[] }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(`[${requestId}] Error fetching RSS feed:`, errorMessage)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function processRssItems(
|
||||
items: RssItem[],
|
||||
feed: RssFeed,
|
||||
webhookData: any,
|
||||
requestId: string
|
||||
): Promise<{ processedCount: number; failedCount: number }> {
|
||||
let processedCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
const itemGuid = item.guid || item.link || `${item.title}-${item.pubDate}`
|
||||
|
||||
await pollingIdempotency.executeWithIdempotency(
|
||||
'rss',
|
||||
`${webhookData.id}:${itemGuid}`,
|
||||
async () => {
|
||||
const payload: RssWebhookPayload = {
|
||||
item: {
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
pubDate: item.pubDate,
|
||||
guid: item.guid,
|
||||
description: item.description,
|
||||
content: item.content,
|
||||
contentSnippet: item.contentSnippet,
|
||||
author: item.author || item.creator,
|
||||
categories: item.categories,
|
||||
enclosure: item.enclosure,
|
||||
isoDate: item.isoDate,
|
||||
},
|
||||
feed: {
|
||||
title: feed.title,
|
||||
link: feed.link,
|
||||
description: feed.description,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Secret': webhookData.secret || '',
|
||||
'User-Agent': 'SimStudio/1.0',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error(
|
||||
`[${requestId}] Failed to trigger webhook for item ${itemGuid}:`,
|
||||
response.status,
|
||||
errorText
|
||||
)
|
||||
throw new Error(`Webhook request failed: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
return {
|
||||
itemGuid,
|
||||
webhookStatus: response.status,
|
||||
processed: true,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully processed item ${item.title || itemGuid} for webhook ${webhookData.id}`
|
||||
)
|
||||
processedCount++
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
logger.error(`[${requestId}] Error processing item:`, errorMessage)
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return { processedCount, failedCount }
|
||||
}
|
||||
|
||||
async function updateWebhookConfig(
|
||||
webhookId: string,
|
||||
_config: RssWebhookConfig,
|
||||
timestamp: string,
|
||||
newGuids: string[]
|
||||
) {
|
||||
try {
|
||||
const result = await db.select().from(webhook).where(eq(webhook.id, webhookId))
|
||||
const existingConfig = (result[0]?.providerConfig as Record<string, any>) || {}
|
||||
|
||||
// Merge new guids with existing ones, keeping only the most recent
|
||||
const existingGuids = existingConfig.lastSeenGuids || []
|
||||
const allGuids = [...newGuids, ...existingGuids].slice(0, MAX_GUIDS_TO_TRACK)
|
||||
|
||||
await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
providerConfig: {
|
||||
...existingConfig,
|
||||
lastCheckedTimestamp: timestamp,
|
||||
lastSeenGuids: allGuids,
|
||||
} as any,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(webhook.id, webhookId))
|
||||
} catch (err) {
|
||||
logger.error(`Failed to update webhook ${webhookId} config:`, err)
|
||||
}
|
||||
}
|
||||
@@ -795,6 +795,33 @@ export async function formatWebhookInput(
|
||||
return body
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'rss') {
|
||||
if (body && typeof body === 'object' && 'item' in body) {
|
||||
const item = body.item as Record<string, any>
|
||||
const feed = body.feed as Record<string, any>
|
||||
|
||||
return {
|
||||
title: item?.title,
|
||||
link: item?.link,
|
||||
pubDate: item?.pubDate,
|
||||
item,
|
||||
feed,
|
||||
webhook: {
|
||||
data: {
|
||||
provider: 'rss',
|
||||
path: foundWebhook.path,
|
||||
providerConfig: foundWebhook.providerConfig,
|
||||
payload: body,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
method: request.method,
|
||||
},
|
||||
},
|
||||
workflowId: foundWorkflow.id,
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
if (foundWebhook.provider === 'hubspot') {
|
||||
const events = Array.isArray(body) ? body : [body]
|
||||
const event = events[0]
|
||||
@@ -2344,6 +2371,41 @@ export async function configureOutlookPolling(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure RSS polling for a webhook
|
||||
*/
|
||||
export async function configureRssPolling(webhookData: any, requestId: string): Promise<boolean> {
|
||||
const logger = createLogger('RssWebhookSetup')
|
||||
logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`)
|
||||
|
||||
try {
|
||||
const providerConfig = (webhookData.providerConfig as Record<string, any>) || {}
|
||||
const now = new Date()
|
||||
|
||||
await db
|
||||
.update(webhook)
|
||||
.set({
|
||||
providerConfig: {
|
||||
...providerConfig,
|
||||
lastCheckedTimestamp: now.toISOString(),
|
||||
lastSeenGuids: [],
|
||||
setupCompleted: true,
|
||||
},
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(webhook.id, webhookData.id))
|
||||
|
||||
logger.info(`[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}`)
|
||||
return true
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Failed to configure RSS polling`, {
|
||||
webhookId: webhookData.id,
|
||||
error: error.message,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function convertSquareBracketsToTwiML(twiml: string | undefined): string | undefined {
|
||||
if (!twiml) {
|
||||
return twiml
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RateLimiter } from '@/services/queue/RateLimiter'
|
||||
import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS } from '@/services/queue/types'
|
||||
|
||||
// Mock the database module
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock drizzle-orm
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((field, value) => ({ field, value })),
|
||||
sql: vi.fn((strings, ...values) => ({ sql: strings.join('?'), values })),
|
||||
and: vi.fn((...conditions) => ({ and: conditions })),
|
||||
}))
|
||||
|
||||
// Mock getHighestPrioritySubscription
|
||||
vi.mock('@/lib/billing/core/subscription', () => ({
|
||||
getHighestPrioritySubscription: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
import { db } from '@sim/db'
|
||||
|
||||
describe('RateLimiter', () => {
|
||||
const rateLimiter = new RateLimiter()
|
||||
const testUserId = 'test-user-123'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
it('should allow unlimited requests for manual trigger type', async () => {
|
||||
const result = await rateLimiter.checkRateLimit(testUserId, 'free', 'manual', false)
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.remaining).toBe(MANUAL_EXECUTION_LIMIT)
|
||||
expect(result.resetAt).toBeInstanceOf(Date)
|
||||
expect(db.select).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow first API request for sync execution', async () => {
|
||||
// Mock select to return empty array (no existing record)
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]), // No existing record
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
// Mock insert to return the expected structure
|
||||
vi.mocked(db.insert).mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoUpdate: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([
|
||||
{
|
||||
syncApiRequests: 1,
|
||||
asyncApiRequests: 0,
|
||||
windowStart: new Date(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await rateLimiter.checkRateLimit(testUserId, 'free', 'api', false)
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 1)
|
||||
expect(result.resetAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('should allow first API request for async execution', async () => {
|
||||
// Mock select to return empty array (no existing record)
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]), // No existing record
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
// Mock insert to return the expected structure
|
||||
vi.mocked(db.insert).mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoUpdate: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([
|
||||
{
|
||||
syncApiRequests: 0,
|
||||
asyncApiRequests: 1,
|
||||
windowStart: new Date(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await rateLimiter.checkRateLimit(testUserId, 'free', 'api', true)
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.remaining).toBe(RATE_LIMITS.free.asyncApiExecutionsPerMinute - 1)
|
||||
expect(result.resetAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('should work for all trigger types except manual', async () => {
|
||||
const triggerTypes = ['api', 'webhook', 'schedule', 'chat'] as const
|
||||
|
||||
for (const triggerType of triggerTypes) {
|
||||
// Mock select to return empty array (no existing record)
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]), // No existing record
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
// Mock insert to return the expected structure
|
||||
vi.mocked(db.insert).mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
onConflictDoUpdate: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([
|
||||
{
|
||||
syncApiRequests: 1,
|
||||
asyncApiRequests: 0,
|
||||
windowStart: new Date(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
} as any)
|
||||
|
||||
const result = await rateLimiter.checkRateLimit(testUserId, 'free', triggerType, false)
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute - 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRateLimitStatus', () => {
|
||||
it('should return unlimited for manual trigger type', async () => {
|
||||
const status = await rateLimiter.getRateLimitStatus(testUserId, 'free', 'manual', false)
|
||||
|
||||
expect(status.used).toBe(0)
|
||||
expect(status.limit).toBe(MANUAL_EXECUTION_LIMIT)
|
||||
expect(status.remaining).toBe(MANUAL_EXECUTION_LIMIT)
|
||||
expect(status.resetAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('should return sync API limits for API trigger type', async () => {
|
||||
const mockSelect = vi.fn().mockReturnThis()
|
||||
const mockFrom = vi.fn().mockReturnThis()
|
||||
const mockWhere = vi.fn().mockReturnThis()
|
||||
const mockLimit = vi.fn().mockResolvedValue([])
|
||||
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: mockFrom,
|
||||
where: mockWhere,
|
||||
limit: mockLimit,
|
||||
} as any)
|
||||
|
||||
const status = await rateLimiter.getRateLimitStatus(testUserId, 'free', 'api', false)
|
||||
|
||||
expect(status.used).toBe(0)
|
||||
expect(status.limit).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute)
|
||||
expect(status.remaining).toBe(RATE_LIMITS.free.syncApiExecutionsPerMinute)
|
||||
expect(status.resetAt).toBeInstanceOf(Date)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetRateLimit', () => {
|
||||
it('should delete rate limit record for user', async () => {
|
||||
vi.mocked(db.delete).mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue({}),
|
||||
} as any)
|
||||
|
||||
await rateLimiter.resetRateLimit(testUserId)
|
||||
|
||||
expect(db.delete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
export { RateLimiter } from '@/services/queue/RateLimiter'
|
||||
export type {
|
||||
RateLimitConfig,
|
||||
SubscriptionPlan,
|
||||
TriggerType,
|
||||
} from '@/services/queue/types'
|
||||
export { RATE_LIMITS, RateLimitError } from '@/services/queue/types'
|
||||
@@ -16,6 +16,84 @@ import {
|
||||
|
||||
const logger = createLogger('Tools')
|
||||
|
||||
/**
|
||||
* Maximum request body size in bytes before we warn/error about size limits.
|
||||
* Next.js 16 has a default middleware/proxy body limit of 10MB.
|
||||
*/
|
||||
const MAX_REQUEST_BODY_SIZE_BYTES = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
/**
|
||||
* User-friendly error message for body size limit exceeded
|
||||
*/
|
||||
const BODY_SIZE_LIMIT_ERROR_MESSAGE =
|
||||
'Request body size limit exceeded (10MB). The workflow data is too large to process. Try reducing the size of variables, inputs, or data being passed between blocks.'
|
||||
|
||||
/**
|
||||
* Validates request body size and throws a user-friendly error if exceeded
|
||||
* @param body - The request body string to check
|
||||
* @param requestId - Request ID for logging
|
||||
* @param context - Context string for logging (e.g., toolId)
|
||||
* @throws Error if body size exceeds the limit
|
||||
*/
|
||||
function validateRequestBodySize(
|
||||
body: string | undefined,
|
||||
requestId: string,
|
||||
context: string
|
||||
): void {
|
||||
if (!body) return
|
||||
|
||||
const bodySize = Buffer.byteLength(body, 'utf8')
|
||||
if (bodySize > MAX_REQUEST_BODY_SIZE_BYTES) {
|
||||
const bodySizeMB = (bodySize / (1024 * 1024)).toFixed(2)
|
||||
const maxSizeMB = (MAX_REQUEST_BODY_SIZE_BYTES / (1024 * 1024)).toFixed(0)
|
||||
logger.error(`[${requestId}] Request body size exceeds limit for ${context}:`, {
|
||||
bodySize,
|
||||
bodySizeMB: `${bodySizeMB}MB`,
|
||||
maxSize: MAX_REQUEST_BODY_SIZE_BYTES,
|
||||
maxSizeMB: `${maxSizeMB}MB`,
|
||||
})
|
||||
throw new Error(BODY_SIZE_LIMIT_ERROR_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an error message indicates a body size limit issue
|
||||
* @param errorMessage - The error message to check
|
||||
* @returns true if the error is related to body size limits
|
||||
*/
|
||||
function isBodySizeLimitError(errorMessage: string): boolean {
|
||||
const lowerMessage = errorMessage.toLowerCase()
|
||||
return (
|
||||
lowerMessage.includes('body size') ||
|
||||
lowerMessage.includes('payload too large') ||
|
||||
lowerMessage.includes('entity too large') ||
|
||||
lowerMessage.includes('request entity too large') ||
|
||||
lowerMessage.includes('body_not_allowed') ||
|
||||
lowerMessage.includes('request body larger than')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles body size limit errors by logging and throwing a user-friendly error
|
||||
* @param error - The original error
|
||||
* @param requestId - Request ID for logging
|
||||
* @param context - Context string for logging (e.g., toolId)
|
||||
* @throws Error with user-friendly message if it's a size limit error
|
||||
* @returns false if not a size limit error (caller should continue handling)
|
||||
*/
|
||||
function handleBodySizeLimitError(error: unknown, requestId: string, context: string): boolean {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (isBodySizeLimitError(errorMessage)) {
|
||||
logger.error(`[${requestId}] Request body size limit exceeded for ${context}:`, {
|
||||
originalError: errorMessage,
|
||||
})
|
||||
throw new Error(BODY_SIZE_LIMIT_ERROR_MESSAGE)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* System parameters that should be filtered out when extracting tool arguments
|
||||
* These are internal parameters used by the execution framework, not tool inputs
|
||||
@@ -537,6 +615,9 @@ async function handleInternalRequest(
|
||||
const headers = new Headers(requestParams.headers)
|
||||
await addInternalAuthIfNeeded(headers, isInternalRoute, requestId, toolId)
|
||||
|
||||
// Check request body size before sending to detect potential size limit issues
|
||||
validateRequestBodySize(requestParams.body, requestId, toolId)
|
||||
|
||||
// Prepare request options
|
||||
const requestOptions = {
|
||||
method: requestParams.method,
|
||||
@@ -548,6 +629,15 @@ async function handleInternalRequest(
|
||||
|
||||
// For non-OK responses, attempt JSON first; if parsing fails, fall back to text
|
||||
if (!response.ok) {
|
||||
// Check for 413 (Entity Too Large) - body size limit exceeded
|
||||
if (response.status === 413) {
|
||||
logger.error(`[${requestId}] Request body too large for ${toolId} (HTTP 413):`, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
throw new Error(BODY_SIZE_LIMIT_ERROR_MESSAGE)
|
||||
}
|
||||
|
||||
let errorData: any
|
||||
try {
|
||||
errorData = await response.json()
|
||||
@@ -645,6 +735,9 @@ async function handleInternalRequest(
|
||||
error: undefined,
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Check if this is a body size limit error and throw user-friendly message
|
||||
handleBodySizeLimitError(error, requestId, toolId)
|
||||
|
||||
logger.error(`[${requestId}] Internal request error for ${toolId}:`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
@@ -737,13 +830,24 @@ async function handleProxyRequest(
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
await addInternalAuthIfNeeded(headers, true, requestId, `proxy:${toolId}`)
|
||||
|
||||
const body = JSON.stringify({ toolId, params, executionContext })
|
||||
|
||||
// Check request body size before sending
|
||||
validateRequestBodySize(body, requestId, `proxy:${toolId}`)
|
||||
|
||||
const response = await fetch(proxyUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ toolId, params, executionContext }),
|
||||
body,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Check for 413 (Entity Too Large) - body size limit exceeded
|
||||
if (response.status === 413) {
|
||||
logger.error(`[${requestId}] Request body too large for proxy:${toolId} (HTTP 413)`)
|
||||
throw new Error(BODY_SIZE_LIMIT_ERROR_MESSAGE)
|
||||
}
|
||||
|
||||
const errorText = await response.text()
|
||||
logger.error(`[${requestId}] Proxy request failed for ${toolId}:`, {
|
||||
status: response.status,
|
||||
@@ -783,6 +887,9 @@ async function handleProxyRequest(
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error: any) {
|
||||
// Check if this is a body size limit error and throw user-friendly message
|
||||
handleBodySizeLimitError(error, requestId, `proxy:${toolId}`)
|
||||
|
||||
logger.error(`[${requestId}] Proxy request error for ${toolId}:`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
@@ -880,6 +987,11 @@ async function executeMcpTool(
|
||||
workspaceId, // Pass workspace context for scoping
|
||||
}
|
||||
|
||||
const body = JSON.stringify(requestBody)
|
||||
|
||||
// Check request body size before sending
|
||||
validateRequestBodySize(body, actualRequestId, `mcp:${toolId}`)
|
||||
|
||||
logger.info(`[${actualRequestId}] Making MCP tool request to ${toolName} on ${serverId}`, {
|
||||
hasWorkspaceId: !!workspaceId,
|
||||
hasWorkflowId: !!workflowId,
|
||||
@@ -888,7 +1000,7 @@ async function executeMcpTool(
|
||||
const response = await fetch(`${baseUrl}/api/mcp/tools/execute`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
body,
|
||||
})
|
||||
|
||||
const endTime = new Date()
|
||||
@@ -896,6 +1008,21 @@ async function executeMcpTool(
|
||||
const duration = endTime.getTime() - new Date(actualStartTime).getTime()
|
||||
|
||||
if (!response.ok) {
|
||||
// Check for 413 (Entity Too Large) - body size limit exceeded
|
||||
if (response.status === 413) {
|
||||
logger.error(`[${actualRequestId}] Request body too large for mcp:${toolId} (HTTP 413)`)
|
||||
return {
|
||||
success: false,
|
||||
output: {},
|
||||
error: BODY_SIZE_LIMIT_ERROR_MESSAGE,
|
||||
timing: {
|
||||
startTime: actualStartTime,
|
||||
endTime: endTimeISO,
|
||||
duration,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let errorMessage = `MCP tool execution failed: ${response.status} ${response.statusText}`
|
||||
|
||||
try {
|
||||
@@ -950,6 +1077,24 @@ async function executeMcpTool(
|
||||
const endTimeISO = endTime.toISOString()
|
||||
const duration = endTime.getTime() - new Date(actualStartTime).getTime()
|
||||
|
||||
// Check if this is a body size limit error
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
if (isBodySizeLimitError(errorMsg)) {
|
||||
logger.error(`[${actualRequestId}] Request body size limit exceeded for mcp:${toolId}:`, {
|
||||
originalError: errorMsg,
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
output: {},
|
||||
error: BODY_SIZE_LIMIT_ERROR_MESSAGE,
|
||||
timing: {
|
||||
startTime: actualStartTime,
|
||||
endTime: endTimeISO,
|
||||
duration,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`[${actualRequestId}] Error executing MCP tool ${toolId}:`, error)
|
||||
|
||||
const errorMessage =
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
microsoftTeamsWebhookTrigger,
|
||||
} from '@/triggers/microsoftteams'
|
||||
import { outlookPollingTrigger } from '@/triggers/outlook'
|
||||
import { rssPollingTrigger } from '@/triggers/rss'
|
||||
import { slackWebhookTrigger } from '@/triggers/slack'
|
||||
import { stripeWebhookTrigger } from '@/triggers/stripe'
|
||||
import { telegramWebhookTrigger } from '@/triggers/telegram'
|
||||
@@ -131,6 +132,7 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
|
||||
microsoftteams_webhook: microsoftTeamsWebhookTrigger,
|
||||
microsoftteams_chat_subscription: microsoftTeamsChatSubscriptionTrigger,
|
||||
outlook_poller: outlookPollingTrigger,
|
||||
rss_poller: rssPollingTrigger,
|
||||
stripe_webhook: stripeWebhookTrigger,
|
||||
telegram_webhook: telegramWebhookTrigger,
|
||||
typeform_webhook: typeformWebhookTrigger,
|
||||
|
||||
1
apps/sim/triggers/rss/index.ts
Normal file
1
apps/sim/triggers/rss/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { rssPollingTrigger } from './poller'
|
||||
115
apps/sim/triggers/rss/poller.ts
Normal file
115
apps/sim/triggers/rss/poller.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { RssIcon } from '@/components/icons'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
export const rssPollingTrigger: TriggerConfig = {
|
||||
id: 'rss_poller',
|
||||
name: 'RSS Feed Trigger',
|
||||
provider: 'rss',
|
||||
description: 'Triggers when new items are published to an RSS feed',
|
||||
version: '1.0.0',
|
||||
icon: RssIcon,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'feedUrl',
|
||||
title: 'Feed URL',
|
||||
type: 'short-input',
|
||||
placeholder: 'https://example.com/feed.xml',
|
||||
description: 'The URL of the RSS or Atom feed to monitor',
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'triggerInstructions',
|
||||
title: 'Setup Instructions',
|
||||
hideFromPreview: true,
|
||||
type: 'text',
|
||||
defaultValue: [
|
||||
'Enter the URL of any RSS or Atom feed you want to monitor',
|
||||
'The feed will be checked every minute for new items',
|
||||
'When a new item is published, your workflow will be triggered with the item data',
|
||||
]
|
||||
.map(
|
||||
(instruction, index) =>
|
||||
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
|
||||
)
|
||||
.join(''),
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'triggerSave',
|
||||
title: '',
|
||||
type: 'trigger-save',
|
||||
hideFromPreview: true,
|
||||
mode: 'trigger',
|
||||
triggerId: 'rss_poller',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: {
|
||||
item: {
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Item title',
|
||||
},
|
||||
link: {
|
||||
type: 'string',
|
||||
description: 'Item link/URL',
|
||||
},
|
||||
pubDate: {
|
||||
type: 'string',
|
||||
description: 'Publication date',
|
||||
},
|
||||
guid: {
|
||||
type: 'string',
|
||||
description: 'Unique identifier',
|
||||
},
|
||||
summary: {
|
||||
type: 'string',
|
||||
description: 'Item description/summary',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'Full content (content:encoded)',
|
||||
},
|
||||
contentSnippet: {
|
||||
type: 'string',
|
||||
description: 'Content snippet without HTML',
|
||||
},
|
||||
author: {
|
||||
type: 'string',
|
||||
description: 'Author name',
|
||||
},
|
||||
categories: {
|
||||
type: 'json',
|
||||
description: 'Categories/tags array',
|
||||
},
|
||||
enclosure: {
|
||||
type: 'json',
|
||||
description: 'Media attachment info (url, type, length)',
|
||||
},
|
||||
isoDate: {
|
||||
type: 'string',
|
||||
description: 'Publication date in ISO format',
|
||||
},
|
||||
},
|
||||
feed: {
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Feed title',
|
||||
},
|
||||
link: {
|
||||
type: 'string',
|
||||
description: 'Feed website link',
|
||||
},
|
||||
feedDescription: {
|
||||
type: 'string',
|
||||
description: 'Feed description',
|
||||
},
|
||||
},
|
||||
timestamp: {
|
||||
type: 'string',
|
||||
description: 'Event timestamp',
|
||||
},
|
||||
},
|
||||
}
|
||||
12
bun.lock
12
bun.lock
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "simstudio",
|
||||
@@ -20,6 +21,7 @@
|
||||
"onedollarstats": "0.0.10",
|
||||
"postgres": "^3.4.5",
|
||||
"remark-gfm": "4.0.1",
|
||||
"rss-parser": "3.13.0",
|
||||
"socket.io-client": "4.8.1",
|
||||
"twilio": "5.9.0",
|
||||
},
|
||||
@@ -2893,6 +2895,8 @@
|
||||
|
||||
"rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
|
||||
|
||||
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
|
||||
|
||||
"run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="],
|
||||
|
||||
"run-exclusive": ["run-exclusive@2.2.19", "", { "dependencies": { "minimal-polyfills": "^2.2.3" } }, "sha512-K3mdoAi7tjJ/qT7Flj90L7QyPozwUaAG+CVhkdDje4HLKXUYC3N/Jzkau3flHVDLQVhiHBtcimVodMjN9egYbA=="],
|
||||
@@ -2911,6 +2915,8 @@
|
||||
|
||||
"satori": ["satori@0.12.2", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.16", "css-to-react-native": "^3.0.0", "emoji-regex": "^10.2.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-wasm-web": "^0.3.3" } }, "sha512-3C/laIeE6UUe9A+iQ0A48ywPVCCMKCNSTU5Os101Vhgsjd3AAxGNjyq0uAA8kulMPK5n0csn8JlxPN9riXEjLA=="],
|
||||
|
||||
"sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="],
|
||||
|
||||
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
@@ -3291,6 +3297,8 @@
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@13.0.2", "", {}, "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="],
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
@@ -3777,6 +3785,8 @@
|
||||
|
||||
"rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"rss-parser/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
|
||||
|
||||
"samlify/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"sim/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
|
||||
@@ -3835,6 +3845,8 @@
|
||||
|
||||
"xml-crypto/xpath": ["xpath@0.0.33", "", {}, "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA=="],
|
||||
|
||||
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
|
||||
"@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"@anthropic-ai/sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
@@ -652,7 +652,16 @@ cronjobs:
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 1
|
||||
|
||||
|
||||
rssWebhookPoll:
|
||||
enabled: true
|
||||
name: rss-webhook-poll
|
||||
schedule: "*/1 * * * *"
|
||||
path: "/api/webhooks/poll/rss"
|
||||
concurrencyPolicy: Forbid
|
||||
successfulJobsHistoryLimit: 3
|
||||
failedJobsHistoryLimit: 1
|
||||
|
||||
renewSubscriptions:
|
||||
enabled: true
|
||||
name: renew-subscriptions
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"onedollarstats": "0.0.10",
|
||||
"postgres": "^3.4.5",
|
||||
"remark-gfm": "4.0.1",
|
||||
"rss-parser": "3.13.0",
|
||||
"socket.io-client": "4.8.1",
|
||||
"twilio": "5.9.0"
|
||||
},
|
||||
|
||||
@@ -82,7 +82,11 @@ async function generateIconMapping(): Promise<Record<string, string>> {
|
||||
}
|
||||
|
||||
// Skip blocks that don't have documentation (same logic as generateBlockDoc)
|
||||
if (blockConfig.type.includes('_trigger') || blockConfig.type.includes('_webhook')) {
|
||||
if (
|
||||
blockConfig.type.includes('_trigger') ||
|
||||
blockConfig.type.includes('_webhook') ||
|
||||
blockConfig.type.includes('rss')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -95,7 +99,8 @@ async function generateIconMapping(): Promise<Record<string, string>> {
|
||||
blockConfig.type === 'webhook' ||
|
||||
blockConfig.type === 'schedule' ||
|
||||
blockConfig.type === 'mcp' ||
|
||||
blockConfig.type === 'generic_webhook'
|
||||
blockConfig.type === 'generic_webhook' ||
|
||||
blockConfig.type === 'rss'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -910,7 +915,11 @@ async function generateBlockDoc(blockPath: string) {
|
||||
return
|
||||
}
|
||||
|
||||
if (blockConfig.type.includes('_trigger') || blockConfig.type.includes('_webhook')) {
|
||||
if (
|
||||
blockConfig.type.includes('_trigger') ||
|
||||
blockConfig.type.includes('_webhook') ||
|
||||
blockConfig.type.includes('rss')
|
||||
) {
|
||||
console.log(`Skipping ${blockConfig.type} - contains '_trigger'`)
|
||||
return
|
||||
}
|
||||
@@ -924,7 +933,8 @@ async function generateBlockDoc(blockPath: string) {
|
||||
blockConfig.type === 'webhook' ||
|
||||
blockConfig.type === 'schedule' ||
|
||||
blockConfig.type === 'mcp' ||
|
||||
blockConfig.type === 'generic_webhook'
|
||||
blockConfig.type === 'generic_webhook' ||
|
||||
blockConfig.type === 'rss'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user