d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
375 lines
10 KiB
TypeScript
375 lines
10 KiB
TypeScript
import type { Logger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
import Parser from 'rss-parser'
|
|
import { pollingIdempotency } from '@/lib/core/idempotency/service'
|
|
import {
|
|
secureFetchWithPinnedIP,
|
|
validateUrlWithDNS,
|
|
} from '@/lib/core/security/input-validation.server'
|
|
import {
|
|
getProviderConfig,
|
|
type PollingProviderHandler,
|
|
type PollWebhookContext,
|
|
} from '@/lib/webhooks/polling/types'
|
|
import {
|
|
markWebhookFailed,
|
|
markWebhookSuccess,
|
|
updateWebhookProviderConfig,
|
|
} from '@/lib/webhooks/polling/utils'
|
|
import { processPolledWebhookEvent } from '@/lib/webhooks/processor'
|
|
|
|
const MAX_GUIDS_TO_TRACK = 500
|
|
|
|
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]: unknown
|
|
}
|
|
|
|
interface RssFeed {
|
|
title?: string
|
|
link?: string
|
|
description?: string
|
|
items: RssItem[]
|
|
}
|
|
|
|
interface RssWebhookPayload {
|
|
title?: string
|
|
link?: string
|
|
pubDate?: string
|
|
item: RssItem
|
|
feed: {
|
|
title?: string
|
|
link?: string
|
|
description?: string
|
|
}
|
|
timestamp: string
|
|
}
|
|
|
|
const parser = new Parser({
|
|
timeout: 30000,
|
|
headers: {
|
|
'User-Agent': 'Sim/1.0 RSS Poller',
|
|
},
|
|
})
|
|
|
|
export const rssPollingHandler: PollingProviderHandler = {
|
|
provider: 'rss',
|
|
label: 'RSS',
|
|
|
|
async pollWebhook(ctx: PollWebhookContext): Promise<'success' | 'failure'> {
|
|
const { webhookData, workflowData, requestId, logger } = ctx
|
|
const webhookId = webhookData.id
|
|
|
|
try {
|
|
const config = getProviderConfig<RssWebhookConfig>(webhookData.providerConfig)
|
|
|
|
if (!config?.feedUrl) {
|
|
logger.error(`[${requestId}] Missing feedUrl for webhook ${webhookId}`)
|
|
await markWebhookFailed(webhookId, logger)
|
|
return 'failure'
|
|
}
|
|
|
|
const now = new Date()
|
|
const {
|
|
feed,
|
|
items: newItems,
|
|
etag,
|
|
lastModified,
|
|
} = await fetchNewRssItems(config, requestId, logger)
|
|
|
|
if (!newItems.length) {
|
|
await updateRssState(webhookId, now.toISOString(), [], config, logger, etag, lastModified)
|
|
await markWebhookSuccess(webhookId, logger)
|
|
logger.info(`[${requestId}] No new items found for webhook ${webhookId}`)
|
|
return 'success'
|
|
}
|
|
|
|
logger.info(`[${requestId}] Found ${newItems.length} new items for webhook ${webhookId}`)
|
|
|
|
const { processedCount, failedCount } = await processRssItems(
|
|
newItems,
|
|
feed,
|
|
webhookData,
|
|
workflowData,
|
|
requestId,
|
|
logger
|
|
)
|
|
|
|
const newGuids = newItems
|
|
.map(
|
|
(item) =>
|
|
item.guid ||
|
|
item.link ||
|
|
(item.title && item.pubDate ? `${item.title}-${item.pubDate}` : '')
|
|
)
|
|
.filter((guid) => guid.length > 0)
|
|
|
|
await updateRssState(
|
|
webhookId,
|
|
now.toISOString(),
|
|
newGuids,
|
|
config,
|
|
logger,
|
|
etag,
|
|
lastModified
|
|
)
|
|
|
|
if (failedCount > 0 && processedCount === 0) {
|
|
await markWebhookFailed(webhookId, logger)
|
|
logger.warn(
|
|
`[${requestId}] All ${failedCount} items failed to process for webhook ${webhookId}`
|
|
)
|
|
return 'failure'
|
|
}
|
|
|
|
await markWebhookSuccess(webhookId, logger)
|
|
logger.info(
|
|
`[${requestId}] Successfully processed ${processedCount} items for webhook ${webhookId}${failedCount > 0 ? ` (${failedCount} failed)` : ''}`
|
|
)
|
|
return 'success'
|
|
} catch (error) {
|
|
logger.error(`[${requestId}] Error processing RSS webhook ${webhookId}:`, error)
|
|
await markWebhookFailed(webhookId, logger)
|
|
return 'failure'
|
|
}
|
|
},
|
|
}
|
|
|
|
async function updateRssState(
|
|
webhookId: string,
|
|
timestamp: string,
|
|
newGuids: string[],
|
|
config: RssWebhookConfig,
|
|
logger: Logger,
|
|
etag?: string,
|
|
lastModified?: string
|
|
) {
|
|
const existingGuids = config.lastSeenGuids || []
|
|
const allGuids = [...newGuids, ...existingGuids].slice(0, MAX_GUIDS_TO_TRACK)
|
|
|
|
await updateWebhookProviderConfig(
|
|
webhookId,
|
|
{
|
|
lastCheckedTimestamp: timestamp,
|
|
lastSeenGuids: allGuids,
|
|
...(etag !== undefined ? { etag } : {}),
|
|
...(lastModified !== undefined ? { lastModified } : {}),
|
|
},
|
|
logger
|
|
)
|
|
}
|
|
|
|
async function fetchNewRssItems(
|
|
config: RssWebhookConfig,
|
|
requestId: string,
|
|
logger: Logger
|
|
): Promise<{ feed: RssFeed; items: RssItem[]; etag?: string; lastModified?: string }> {
|
|
try {
|
|
const urlValidation = await validateUrlWithDNS(config.feedUrl, 'feedUrl')
|
|
if (!urlValidation.isValid) {
|
|
logger.error(`[${requestId}] Invalid RSS feed URL: ${urlValidation.error}`)
|
|
throw new Error(`Invalid RSS feed URL: ${urlValidation.error}`)
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
'User-Agent': 'Sim/1.0 RSS Poller',
|
|
Accept: 'application/rss+xml, application/xml, text/xml, */*',
|
|
}
|
|
if (config.etag) {
|
|
headers['If-None-Match'] = config.etag
|
|
}
|
|
if (config.lastModified) {
|
|
headers['If-Modified-Since'] = config.lastModified
|
|
}
|
|
|
|
const response = await secureFetchWithPinnedIP(config.feedUrl, urlValidation.resolvedIP!, {
|
|
headers,
|
|
timeout: 30000,
|
|
})
|
|
|
|
if (response.status === 304) {
|
|
logger.info(`[${requestId}] RSS feed not modified (304) for ${config.feedUrl}`)
|
|
return {
|
|
feed: { items: [] } as RssFeed,
|
|
items: [],
|
|
etag: response.headers.get('etag') ?? config.etag,
|
|
lastModified: response.headers.get('last-modified') ?? config.lastModified,
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
await response.text().catch(() => {})
|
|
throw new Error(`Failed to fetch RSS feed: ${response.status} ${response.statusText}`)
|
|
}
|
|
|
|
const newEtag = response.headers.get('etag') ?? undefined
|
|
const newLastModified = response.headers.get('last-modified') ?? undefined
|
|
|
|
const xmlContent = await response.text()
|
|
const feed = await parser.parseString(xmlContent)
|
|
|
|
if (!feed.items || !feed.items.length) {
|
|
return { feed: feed as RssFeed, items: [], etag: newEtag, lastModified: newLastModified }
|
|
}
|
|
|
|
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 ||
|
|
(item.title && item.pubDate ? `${item.title}-${item.pubDate}` : '')
|
|
|
|
if (itemGuid && lastSeenGuids.has(itemGuid)) {
|
|
return false
|
|
}
|
|
|
|
if (lastCheckedTime && item.isoDate) {
|
|
const itemDate = new Date(item.isoDate)
|
|
if (itemDate <= lastCheckedTime) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
})
|
|
|
|
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
|
|
})
|
|
|
|
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[],
|
|
etag: newEtag,
|
|
lastModified: newLastModified,
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = getErrorMessage(error, 'Unknown error')
|
|
logger.error(`[${requestId}] Error fetching RSS feed:`, errorMessage)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function processRssItems(
|
|
items: RssItem[],
|
|
feed: RssFeed,
|
|
webhookData: PollWebhookContext['webhookData'],
|
|
workflowData: PollWebhookContext['workflowData'],
|
|
requestId: string,
|
|
logger: Logger
|
|
): 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 ? `${item.title}-${item.pubDate}` : '')
|
|
|
|
if (!itemGuid) {
|
|
logger.warn(
|
|
`[${requestId}] Skipping RSS item with no identifiable GUID for webhook ${webhookData.id}`
|
|
)
|
|
continue
|
|
}
|
|
|
|
await pollingIdempotency.executeWithIdempotency(
|
|
'rss',
|
|
`${webhookData.id}:${itemGuid}`,
|
|
async () => {
|
|
const payload: RssWebhookPayload = {
|
|
title: item.title,
|
|
link: item.link,
|
|
pubDate: item.pubDate,
|
|
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 result = await processPolledWebhookEvent(
|
|
webhookData,
|
|
workflowData,
|
|
payload,
|
|
requestId
|
|
)
|
|
|
|
if (!result.success) {
|
|
logger.error(
|
|
`[${requestId}] Failed to process webhook for item ${itemGuid}:`,
|
|
result.statusCode,
|
|
result.error
|
|
)
|
|
throw new Error(`Webhook processing failed (${result.statusCode}): ${result.error}`)
|
|
}
|
|
|
|
return { itemGuid, processed: true }
|
|
}
|
|
)
|
|
|
|
logger.info(
|
|
`[${requestId}] Successfully processed item ${item.title || itemGuid} for webhook ${webhookData.id}`
|
|
)
|
|
processedCount++
|
|
} catch (error) {
|
|
const errorMessage = getErrorMessage(error, 'Unknown error')
|
|
logger.error(`[${requestId}] Error processing item:`, errorMessage)
|
|
failedCount++
|
|
}
|
|
}
|
|
|
|
return { processedCount, failedCount }
|
|
}
|