diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.ts b/apps/sim/app/api/cron/renew-subscriptions/route.ts new file mode 100644 index 0000000000..2f58f5faa3 --- /dev/null +++ b/apps/sim/app/api/cron/renew-subscriptions/route.ts @@ -0,0 +1,154 @@ +import { db } from '@sim/db' +import { webhook as webhookTable, workflow as workflowTable } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { createLogger } from '@/lib/logs/console/logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('TeamsSubscriptionRenewal') + +/** + * Cron endpoint to renew Microsoft Teams chat subscriptions before they expire + * + * Teams subscriptions expire after ~3 days and must be renewed. + * Configured in helm/sim/values.yaml under cronjobs.jobs.renewSubscriptions + */ +export async function GET(request: NextRequest) { + try { + const authError = verifyCronAuth(request, 'Teams subscription renewal') + if (authError) { + return authError + } + + logger.info('Starting Teams subscription renewal job') + + let totalRenewed = 0 + let totalFailed = 0 + let totalChecked = 0 + + // Get all active Microsoft Teams webhooks with their workflows + const webhooksWithWorkflows = await db + .select({ + webhook: webhookTable, + workflow: workflowTable, + }) + .from(webhookTable) + .innerJoin(workflowTable, eq(webhookTable.workflowId, workflowTable.id)) + .where(and(eq(webhookTable.isActive, true), eq(webhookTable.provider, 'microsoftteams'))) + + logger.info( + `Found ${webhooksWithWorkflows.length} active Teams webhooks, checking for expiring subscriptions` + ) + + // Renewal threshold: 48 hours before expiration + const renewalThreshold = new Date(Date.now() + 48 * 60 * 60 * 1000) + + for (const { webhook, workflow } of webhooksWithWorkflows) { + const config = (webhook.providerConfig as Record) || {} + + // Check if this is a Teams chat subscription that needs renewal + if (config.triggerId !== 'microsoftteams_chat_subscription') continue + + const expirationStr = config.subscriptionExpiration as string | undefined + if (!expirationStr) continue + + const expiresAt = new Date(expirationStr) + if (expiresAt > renewalThreshold) continue // Not expiring soon + + totalChecked++ + + try { + logger.info( + `Renewing Teams subscription for webhook ${webhook.id} (expires: ${expiresAt.toISOString()})` + ) + + const credentialId = config.credentialId as string | undefined + const externalSubscriptionId = config.externalSubscriptionId as string | undefined + + if (!credentialId || !externalSubscriptionId) { + logger.error(`Missing credentialId or externalSubscriptionId for webhook ${webhook.id}`) + totalFailed++ + continue + } + + // Get fresh access token + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + workflow.userId, + `renewal-${webhook.id}` + ) + + if (!accessToken) { + logger.error(`Failed to get access token for webhook ${webhook.id}`) + totalFailed++ + continue + } + + // Extend subscription to maximum lifetime (4230 minutes = ~3 days) + const maxLifetimeMinutes = 4230 + const newExpirationDateTime = new Date( + Date.now() + maxLifetimeMinutes * 60 * 1000 + ).toISOString() + + const res = await fetch( + `https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ expirationDateTime: newExpirationDateTime }), + } + ) + + if (!res.ok) { + const error = await res.json() + logger.error( + `Failed to renew Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}`, + { status: res.status, error: error.error } + ) + totalFailed++ + continue + } + + const payload = await res.json() + + // Update webhook config with new expiration + const updatedConfig = { + ...config, + subscriptionExpiration: payload.expirationDateTime, + } + + await db + .update(webhookTable) + .set({ providerConfig: updatedConfig, updatedAt: new Date() }) + .where(eq(webhookTable.id, webhook.id)) + + logger.info( + `Successfully renewed Teams subscription for webhook ${webhook.id}. New expiration: ${payload.expirationDateTime}` + ) + totalRenewed++ + } catch (error) { + logger.error(`Error renewing subscription for webhook ${webhook.id}:`, error) + totalFailed++ + } + } + + logger.info( + `Teams subscription renewal job completed. Checked: ${totalChecked}, Renewed: ${totalRenewed}, Failed: ${totalFailed}` + ) + + return NextResponse.json({ + success: true, + checked: totalChecked, + renewed: totalRenewed, + failed: totalFailed, + total: webhooksWithWorkflows.length, + }) + } catch (error) { + logger.error('Error in Teams subscription renewal job:', error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 6561b4532f..1154ef1d89 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -408,10 +408,18 @@ export async function DELETE( } } - // If it's a Telegram webhook, delete it from Telegram first + // Delete Microsoft Teams subscription if applicable + if (foundWebhook.provider === 'microsoftteams') { + const { deleteTeamsSubscription } = await import('@/lib/webhooks/webhook-helpers') + logger.info(`[${requestId}] Deleting Teams subscription for webhook ${id}`) + await deleteTeamsSubscription(foundWebhook, webhookData.workflow, requestId) + // Don't fail webhook deletion if subscription cleanup fails + } + + // Delete Telegram webhook if applicable if (foundWebhook.provider === 'telegram') { try { - const { botToken } = foundWebhook.providerConfig as { botToken: string } + const { botToken } = (foundWebhook.providerConfig || {}) as { botToken?: string } if (!botToken) { logger.warn(`[${requestId}] Missing botToken for Telegram webhook deletion.`, { @@ -426,9 +434,7 @@ export async function DELETE( const telegramApiUrl = `https://api.telegram.org/bot${botToken}/deleteWebhook` const telegramResponse = await fetch(telegramApiUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, }) const responseBody = await telegramResponse.json() @@ -436,9 +442,7 @@ export async function DELETE( const errorMessage = responseBody.description || `Failed to delete Telegram webhook. Status: ${telegramResponse.status}` - logger.error(`[${requestId}] ${errorMessage}`, { - response: responseBody, - }) + logger.error(`[${requestId}] ${errorMessage}`, { response: responseBody }) return NextResponse.json( { error: 'Failed to delete webhook from Telegram', details: errorMessage }, { status: 500 } @@ -453,10 +457,7 @@ export async function DELETE( stack: error.stack, }) return NextResponse.json( - { - error: 'Failed to delete webhook from Telegram', - details: error.message, - }, + { error: 'Failed to delete webhook from Telegram', details: error.message }, { status: 500 } ) } diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 912406eced..d9b39e66c5 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -136,10 +136,15 @@ export async function POST(request: NextRequest) { let finalPath = path const credentialBasedProviders = ['gmail', 'outlook'] const isCredentialBased = credentialBasedProviders.includes(provider) + // Treat Microsoft Teams chat subscription as credential-based for path generation purposes + const isMicrosoftTeamsChatSubscription = + provider === 'microsoftteams' && + typeof providerConfig === 'object' && + providerConfig?.triggerId === 'microsoftteams_chat_subscription' // If path is missing if (!finalPath || finalPath.trim() === '') { - if (isCredentialBased) { + if (isCredentialBased || isMicrosoftTeamsChatSubscription) { // Try to reuse existing path for this workflow+block if one exists if (blockId) { const existingForBlock = await db @@ -151,7 +156,7 @@ export async function POST(request: NextRequest) { if (existingForBlock.length > 0) { finalPath = existingForBlock[0].path logger.info( - `[${requestId}] Reusing existing dummy path for ${provider} trigger: ${finalPath}` + `[${requestId}] Reusing existing generated path for ${provider} trigger: ${finalPath}` ) } } @@ -159,7 +164,7 @@ export async function POST(request: NextRequest) { // If still no path, generate a new dummy path (first-time save) if (!finalPath || finalPath.trim() === '') { finalPath = `${provider}-${crypto.randomUUID()}` - logger.info(`[${requestId}] Generated dummy path for ${provider} trigger: ${finalPath}`) + logger.info(`[${requestId}] Generated webhook path for ${provider} trigger: ${finalPath}`) } } else { logger.warn(`[${requestId}] Missing path for webhook creation`, { @@ -252,7 +257,12 @@ export async function POST(request: NextRequest) { const finalProviderConfig = providerConfig if (targetWebhookId) { - logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`) + logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`, { + webhookId: targetWebhookId, + provider, + hasCredentialId: !!(finalProviderConfig as any)?.credentialId, + credentialId: (finalProviderConfig as any)?.credentialId, + }) const updatedResult = await db .update(webhook) .set({ @@ -265,6 +275,10 @@ export async function POST(request: NextRequest) { .where(eq(webhook.id, targetWebhookId)) .returning() savedWebhook = updatedResult[0] + logger.info(`[${requestId}] Webhook updated successfully`, { + webhookId: savedWebhook.id, + savedProviderConfig: savedWebhook.providerConfig, + }) } else { // Create a new webhook const webhookId = nanoid() @@ -306,33 +320,54 @@ export async function POST(request: NextRequest) { } // --- End Airtable specific logic --- - // --- Attempt to create webhook in Telegram if provider is 'telegram' --- - if (savedWebhook && provider === 'telegram') { - logger.info( - `[${requestId}] Telegram provider detected. Attempting to create webhook in Telegram.` + // --- Microsoft Teams subscription setup --- + if (savedWebhook && provider === 'microsoftteams') { + const { createTeamsSubscription } = await import('@/lib/webhooks/webhook-helpers') + logger.info(`[${requestId}] Creating Teams subscription for webhook ${savedWebhook.id}`) + + const success = await createTeamsSubscription( + request, + savedWebhook, + workflowRecord, + requestId ) - try { - await createTelegramWebhookSubscription(request, userId, savedWebhook, requestId) - } catch (err) { - logger.error(`[${requestId}] Error creating Telegram webhook`, err) + + if (!success) { return NextResponse.json( { - error: 'Failed to create webhook in Telegram', - details: err instanceof Error ? err.message : 'Unknown error', + error: 'Failed to create Teams subscription', + details: 'Could not create subscription with Microsoft Graph API', + }, + { status: 500 } + ) + } + } + // --- End Teams subscription setup --- + + // --- Telegram webhook setup --- + if (savedWebhook && provider === 'telegram') { + const { createTelegramWebhook } = await import('@/lib/webhooks/webhook-helpers') + logger.info(`[${requestId}] Creating Telegram webhook for webhook ${savedWebhook.id}`) + + const success = await createTelegramWebhook(request, savedWebhook, requestId) + + if (!success) { + return NextResponse.json( + { + error: 'Failed to create Telegram webhook', }, { status: 500 } ) } } - // --- End Telegram specific logic --- + // --- End Telegram webhook setup --- // --- Gmail webhook setup --- if (savedWebhook && provider === 'gmail') { logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`) try { const { configureGmailPolling } = await import('@/lib/webhooks/utils') - // Pass workflow owner for backward-compat fallback (utils prefers credentialId if present) - const success = await configureGmailPolling(workflowRecord.userId, savedWebhook, requestId) + const success = await configureGmailPolling(savedWebhook, requestId) if (!success) { logger.error(`[${requestId}] Failed to configure Gmail polling`) @@ -366,12 +401,7 @@ export async function POST(request: NextRequest) { ) try { const { configureOutlookPolling } = await import('@/lib/webhooks/utils') - // Pass workflow owner for backward-compat fallback (utils prefers credentialId if present) - const success = await configureOutlookPolling( - workflowRecord.userId, - savedWebhook, - requestId - ) + const success = await configureOutlookPolling(savedWebhook, requestId) if (!success) { logger.error(`[${requestId}] Failed to configure Outlook polling`) @@ -525,95 +555,3 @@ async function createAirtableWebhookSubscription( ) } } - -// Helper function to create the webhook subscription in Telegram -async function createTelegramWebhookSubscription( - request: NextRequest, - userId: string, - webhookData: any, - requestId: string -) { - try { - const { path, providerConfig } = webhookData - const { botToken } = providerConfig || {} - - if (!botToken) { - logger.warn(`[${requestId}] Missing botToken for Telegram webhook creation.`, { - webhookId: webhookData.id, - }) - return // Cannot proceed without botToken - } - - if (!env.NEXT_PUBLIC_APP_URL) { - logger.error( - `[${requestId}] NEXT_PUBLIC_APP_URL not configured, cannot register Telegram webhook` - ) - throw new Error('NEXT_PUBLIC_APP_URL must be configured for Telegram webhook registration') - } - - const notificationUrl = `${env.NEXT_PUBLIC_APP_URL}/api/webhooks/trigger/${path}` - - const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook` - - const requestBody: any = { - url: notificationUrl, - allowed_updates: ['message'], - } - - // Configure user-agent header to ensure Telegram can identify itself to our middleware - const telegramResponse = await fetch(telegramApiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'TelegramBot/1.0', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await telegramResponse.json() - if (!telegramResponse.ok || !responseBody.ok) { - const errorMessage = - responseBody.description || - `Failed to create Telegram webhook. Status: ${telegramResponse.status}` - logger.error(`[${requestId}] ${errorMessage}`, { - response: responseBody, - }) - throw new Error(errorMessage) - } - - logger.info( - `[${requestId}] Successfully created Telegram webhook for webhook ${webhookData.id}.` - ) - - // Get webhook info to ensure it's properly set up - try { - const webhookInfoUrl = `https://api.telegram.org/bot${botToken}/getWebhookInfo` - const webhookInfo = await fetch(webhookInfoUrl, { - headers: { - 'User-Agent': 'TelegramBot/1.0', - }, - }) - const webhookInfoJson = await webhookInfo.json() - - if (webhookInfoJson.ok) { - logger.info(`[${requestId}] Telegram webhook info:`, { - url: webhookInfoJson.result.url, - has_custom_certificate: webhookInfoJson.result.has_custom_certificate, - pending_update_count: webhookInfoJson.result.pending_update_count, - webhookId: webhookData.id, - }) - } - } catch (error) { - // Non-critical error, just log - logger.warn(`[${requestId}] Failed to get webhook info`, error) - } - } catch (error: any) { - logger.error( - `[${requestId}] Exception during Telegram webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - } -} diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index e341e7098e..3a9a628b9d 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -18,6 +18,31 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 60 +export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string }> }) { + const requestId = generateRequestId() + const { path } = await params + + // Handle Microsoft Graph subscription validation + const url = new URL(request.url) + const validationToken = url.searchParams.get('validationToken') + + if (validationToken) { + logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`) + return new NextResponse(validationToken, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }) + } + + // Handle other GET-based verifications if needed + const challengeResponse = await handleProviderChallenges({}, request, requestId, path) + if (challengeResponse) { + return challengeResponse + } + + return new NextResponse('Method not allowed', { status: 405 }) +} + export async function POST( request: NextRequest, { params }: { params: Promise<{ path: string }> } @@ -25,6 +50,21 @@ export async function POST( const requestId = generateRequestId() const { path } = await params + // Handle Microsoft Graph subscription validation (some environments send POST with validationToken) + try { + const url = new URL(request.url) + const validationToken = url.searchParams.get('validationToken') + if (validationToken) { + logger.info(`[${requestId}] Microsoft Graph subscription validation (POST) for path: ${path}`) + return new NextResponse(validationToken, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }) + } + } catch { + // ignore URL parsing errors; proceed to normal handling + } + const parseResult = await parseWebhookBody(request, requestId) // Check if parseWebhookBody returned an error response @@ -43,6 +83,7 @@ export async function POST( if (!findResult) { logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`) + return new NextResponse('Not Found', { status: 404 }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/components/trigger-config-section.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/components/trigger-config-section.tsx index 012635f8a7..881cc8c4d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/components/trigger-config-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/components/trigger-config-section.tsx @@ -26,6 +26,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { cn } from '@/lib/utils' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import type { TriggerConfig } from '@/triggers/types' +import { CredentialSelector } from '../../credential-selector/credential-selector' interface TriggerConfigSectionProps { blockId: string @@ -79,7 +80,7 @@ export function TriggerConfigSection({ ) - case 'select': + case 'select': { return (
) + } case 'multiselect': { const selectedValues = Array.isArray(value) ? value : [] @@ -222,6 +224,30 @@ export function TriggerConfigSection({ ) + case 'credential': + return ( +
+ + + {fieldDef.description && ( +

{fieldDef.description}

+ )} +
+ ) + default: // string return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/components/trigger-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/components/trigger-modal.tsx index 4a36261ad3..73863f3f90 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/components/trigger-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/components/trigger-modal.tsx @@ -11,10 +11,18 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { createLogger } from '@/lib/logs/console/logger' import { cn } from '@/lib/utils' import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { getTrigger } from '@/triggers' import type { TriggerConfig } from '@/triggers/types' import { CredentialSelector } from '../../credential-selector/credential-selector' import { TriggerConfigSection } from './trigger-config-section' @@ -32,19 +40,30 @@ interface TriggerModalProps { onDelete?: () => Promise triggerId?: string blockId: string + availableTriggers?: string[] + selectedTriggerId?: string | null + onTriggerChange?: (triggerId: string) => void } export function TriggerModal({ isOpen, onClose, triggerPath, - triggerDef, + triggerDef: propTriggerDef, triggerConfig: initialConfig, onSave, onDelete, triggerId, blockId, + availableTriggers = [], + selectedTriggerId, + onTriggerChange, }: TriggerModalProps) { + // Use selectedTriggerId to get the current trigger definition dynamically + const triggerDef = selectedTriggerId + ? getTrigger(selectedTriggerId) || propTriggerDef + : propTriggerDef + const [config, setConfig] = useState>(initialConfig) const [isSaving, setIsSaving] = useState(false) @@ -115,6 +134,8 @@ export function TriggerModal({ // Only update if there are actually default values to apply if (Object.keys(defaultConfig).length > 0) { setConfig(mergedConfig) + // Reset dirty snapshot when defaults are applied to avoid false-disabled Save + initialConfigRef.current = mergedConfig } }, [triggerDef.configFields, initialConfig]) @@ -398,12 +419,13 @@ export function TriggerModal({ return false } - // Check required fields + // Check required fields (skip credential fields - they're stored separately in subblock store) for (const [fieldId, fieldDef] of Object.entries(triggerDef.configFields)) { - if (fieldDef.required && !config[fieldId]) { + if (fieldDef.required && fieldDef.type !== 'credential' && !config[fieldId]) { return false } } + return true } @@ -445,6 +467,49 @@ export function TriggerModal({
+ {/* Trigger Type Selector - only show if multiple triggers available */} + {availableTriggers && availableTriggers.length > 1 && onTriggerChange && ( +
+ +

+ Choose how this workflow should be triggered +

+ + {triggerId && ( +

+ Delete the trigger to change the trigger type +

+ )} +
+ )} + {triggerDef.requiresCredentials && triggerDef.credentialProvider && (

Credentials

diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/trigger-config.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/trigger-config.tsx index 4dbd774d8d..3307556864 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/trigger-config.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/trigger-config/trigger-config.tsx @@ -153,7 +153,11 @@ export function TriggerConfig({ setStoredTriggerId(effectiveTriggerId) // Map trigger ID to webhook provider name - const webhookProvider = effectiveTriggerId.replace(/_webhook|_poller$/, '') // e.g., 'slack_webhook' -> 'slack', 'gmail_poller' -> 'gmail' + const webhookProvider = effectiveTriggerId + .replace(/_chat_subscription$/, '') + .replace(/_webhook$/, '') + .replace(/_poller$/, '') + .replace(/_subscription$/, '') // e.g., 'slack_webhook' -> 'slack', 'gmail_poller' -> 'gmail', 'microsoftteams_chat_subscription' -> 'microsoftteams' // Include selected credential from the modal (if any) const selectedCredentialId = @@ -176,6 +180,7 @@ export function TriggerConfig({ providerConfig: { ...config, ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}), + triggerId: effectiveTriggerId, // Include trigger ID to determine subscription vs polling }, }), }) @@ -206,6 +211,20 @@ export function TriggerConfig({ } // Save as webhook using existing webhook API (for webhook-based triggers) + const webhookConfig = { + ...config, + ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}), + triggerId: effectiveTriggerId, + } + + logger.info('Saving webhook-based trigger', { + triggerId: effectiveTriggerId, + provider: webhookProvider, + hasCredential: !!selectedCredentialId, + credentialId: selectedCredentialId, + webhookConfig, + }) + const response = await fetch('/api/webhooks', { method: 'POST', headers: { @@ -216,10 +235,7 @@ export function TriggerConfig({ blockId, path, provider: webhookProvider, - providerConfig: { - ...config, - ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}), - }, + providerConfig: webhookConfig, }), }) @@ -236,14 +252,6 @@ export function TriggerConfig({ const savedWebhookId = data.webhook.id setTriggerId(savedWebhookId) - logger.info('Trigger saved successfully as webhook', { - webhookId: savedWebhookId, - triggerDefId: effectiveTriggerId, - provider: webhookProvider, - path, - blockId, - }) - // Update the actual trigger after saving setActualTriggerId(webhookProvider) @@ -409,6 +417,13 @@ export function TriggerConfig({ onDelete={handleDeleteTrigger} triggerId={triggerId || undefined} blockId={blockId} + availableTriggers={availableTriggers} + selectedTriggerId={selectedTriggerId} + onTriggerChange={(newTriggerId) => { + setStoredTriggerId(newTriggerId) + // Clear config when changing trigger type + setTriggerConfig({}) + }} /> )}
diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index 16105e123e..71bb93462b 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -92,6 +92,7 @@ export type WebhookExecutionPayload = { blockId?: string testMode?: boolean executionTarget?: 'deployed' | 'live' + credentialId?: string } export async function executeWebhookJob(payload: WebhookExecutionPayload) { @@ -340,10 +341,22 @@ async function executeWebhookJobInternal( } // Format input for standard webhooks - const mockWebhook = { - provider: payload.provider, - blockId: payload.blockId, - } + // Load the actual webhook to get providerConfig (needed for Teams credentialId) + const webhookRows = await db + .select() + .from(webhook) + .where(eq(webhook.id, payload.webhookId)) + .limit(1) + + const actualWebhook = + webhookRows.length > 0 + ? webhookRows[0] + : { + provider: payload.provider, + blockId: payload.blockId, + providerConfig: {}, + } + const mockWorkflow = { id: payload.workflowId, userId: payload.userId, @@ -352,7 +365,7 @@ async function executeWebhookJobInternal( headers: new Map(Object.entries(payload.headers)), } as any - const input = formatWebhookInput(mockWebhook, mockWorkflow, payload.body, mockRequest) + const input = await formatWebhookInput(actualWebhook, mockWorkflow, payload.body, mockRequest) if (!input && payload.provider === 'whatsapp') { logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`) diff --git a/apps/sim/blocks/blocks/microsoft_teams.ts b/apps/sim/blocks/blocks/microsoft_teams.ts index cafbaf4a95..62da0e80b3 100644 --- a/apps/sim/blocks/blocks/microsoft_teams.ts +++ b/apps/sim/blocks/blocks/microsoft_teams.ts @@ -51,6 +51,8 @@ export const MicrosoftTeamsBlock: BlockConfig = { 'Group.ReadWrite.All', 'Team.ReadBasic.All', 'offline_access', + 'Files.Read', + 'Sites.Read.All', ], placeholder: 'Select Microsoft account', required: true, @@ -142,7 +144,7 @@ export const MicrosoftTeamsBlock: BlockConfig = { type: 'trigger-config', layout: 'full', triggerProvider: 'microsoftteams', - availableTriggers: ['microsoftteams_webhook'], + availableTriggers: ['microsoftteams_webhook', 'microsoftteams_chat_subscription'], }, ], tools: { diff --git a/apps/sim/components/ui/tag-dropdown.tsx b/apps/sim/components/ui/tag-dropdown.tsx index 6081801110..6b82a7223e 100644 --- a/apps/sim/components/ui/tag-dropdown.tsx +++ b/apps/sim/components/ui/tag-dropdown.tsx @@ -14,7 +14,6 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { BlockState } from '@/stores/workflows/workflow/types' import { getTool } from '@/tools/utils' -import { getTrigger, getTriggersByProvider } from '@/triggers' interface BlockTagGroup { blockName: string @@ -126,26 +125,10 @@ const getOutputTypeForPath = ( mergedSubBlocksOverride?: Record ): string => { if (block?.triggerMode && blockConfig?.triggers?.enabled) { - const triggerId = blockConfig?.triggers?.available?.[0] - const firstTrigger = triggerId ? getTrigger(triggerId) : getTriggersByProvider(block.type)[0] - - if (firstTrigger?.outputs) { - const pathParts = outputPath.split('.') - let currentObj: any = firstTrigger.outputs - - for (const part of pathParts) { - if (currentObj && typeof currentObj === 'object') { - currentObj = currentObj[part] - } else { - break - } - } - - if (currentObj && typeof currentObj === 'object' && 'type' in currentObj && currentObj.type) { - return currentObj.type - } - } - } else if (block?.type === 'starter') { + // When in trigger mode, derive types from the selected trigger's outputs + return getBlockOutputType(block.type, outputPath, mergedSubBlocksOverride, true) + } + if (block?.type === 'starter') { // Handle starter block specific outputs const startWorkflowValue = mergedSubBlocksOverride?.startWorkflow?.value ?? getSubBlockValue(blockId, 'startWorkflow') @@ -487,15 +470,10 @@ export const TagDropdown: React.FC = ({ blockTags = [] } } else if (sourceBlock?.triggerMode && blockConfig.triggers?.enabled) { - const triggerId = blockConfig?.triggers?.available?.[0] - const firstTrigger = triggerId - ? getTrigger(triggerId) - : getTriggersByProvider(sourceBlock.type)[0] - - if (firstTrigger?.outputs) { - // Use trigger outputs instead of block outputs - const outputPaths = generateOutputPaths(firstTrigger.outputs) - blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) + // Use selected trigger from subblocks to determine outputs + const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks, true) + if (dynamicOutputs.length > 0) { + blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`) } else { const outputPaths = generateOutputPaths(blockConfig.outputs || {}) blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) @@ -759,15 +737,10 @@ export const TagDropdown: React.FC = ({ } else { const blockState = blocks[accessibleBlockId] if (blockState?.triggerMode && blockConfig.triggers?.enabled) { - const triggerId = blockConfig?.triggers?.available?.[0] - const firstTrigger = triggerId - ? getTrigger(triggerId) - : getTriggersByProvider(blockState.type)[0] - - if (firstTrigger?.outputs) { - // Use trigger outputs instead of block outputs - const outputPaths = generateOutputPaths(firstTrigger.outputs) - blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) + // Use selected trigger (from subblocks) rather than defaulting to the first one + const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks, true) + if (dynamicOutputs.length > 0) { + blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`) } else { const outputPaths = generateOutputPaths(blockConfig.outputs || {}) blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) diff --git a/apps/sim/lib/idempotency/service.ts b/apps/sim/lib/idempotency/service.ts index 0571ba7d86..e20dc6dbfe 100644 --- a/apps/sim/lib/idempotency/service.ts +++ b/apps/sim/lib/idempotency/service.ts @@ -463,7 +463,8 @@ export class IdempotencyService { normalizedHeaders?.['x-webhook-id'] || normalizedHeaders?.['x-shopify-webhook-id'] || normalizedHeaders?.['x-github-delivery'] || - normalizedHeaders?.['x-event-id'] + normalizedHeaders?.['x-event-id'] || + normalizedHeaders?.['x-teams-notification-id'] if (webhookIdHeader) { return `${webhookId}:${webhookIdHeader}` diff --git a/apps/sim/lib/webhooks/attachment-processor.ts b/apps/sim/lib/webhooks/attachment-processor.ts index 7eb2841b6d..028a36287e 100644 --- a/apps/sim/lib/webhooks/attachment-processor.ts +++ b/apps/sim/lib/webhooks/attachment-processor.ts @@ -74,14 +74,25 @@ export class WebhookAttachmentProcessor { requestId: string } ): Promise { + // Convert data to Buffer (handle both raw and serialized formats) + let buffer: Buffer const data = attachment.data as any - if (!data || typeof data !== 'object' || data.type !== 'Buffer' || !Array.isArray(data.data)) { - throw new Error(`Attachment '${attachment.name}' data must be a serialized Buffer`) + if (Buffer.isBuffer(data)) { + // Raw Buffer (e.g., Teams in-memory processing) + buffer = data + } else if ( + data && + typeof data === 'object' && + data.type === 'Buffer' && + Array.isArray(data.data) + ) { + // Serialized Buffer (e.g., Gmail/Outlook after JSON roundtrip) + buffer = Buffer.from(data.data) + } else { + throw new Error(`Attachment '${attachment.name}' data must be a Buffer or serialized Buffer`) } - const buffer = Buffer.from(data.data) - if (buffer.length === 0) { throw new Error(`Attachment '${attachment.name}' has zero bytes`) } diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 73730df2d9..5301756162 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -382,17 +382,40 @@ export async function queueWebhookExecution( return NextResponse.json({ message: 'Pinned API key required' }, { status: 200 }) } + const headers = Object.fromEntries(request.headers.entries()) + + // For Microsoft Teams Graph notifications, extract unique identifiers for idempotency + if ( + foundWebhook.provider === 'microsoftteams' && + body?.value && + Array.isArray(body.value) && + body.value.length > 0 + ) { + const notification = body.value[0] + const subscriptionId = notification.subscriptionId + const messageId = notification.resourceData?.id + + if (subscriptionId && messageId) { + headers['x-teams-notification-id'] = `${subscriptionId}:${messageId}` + } + } + + // Extract credentialId from webhook config for credential-based webhooks + const providerConfig = (foundWebhook.providerConfig as Record) || {} + const credentialId = providerConfig.credentialId as string | undefined + const payload = { webhookId: foundWebhook.id, workflowId: foundWorkflow.id, userId: actorUserId, provider: foundWebhook.provider, body, - headers: Object.fromEntries(request.headers.entries()), + headers, path: options.path || foundWebhook.path, blockId: foundWebhook.blockId, testMode: options.testMode, executionTarget: options.executionTarget, + ...(credentialId ? { credentialId } : {}), } const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED) @@ -416,6 +439,15 @@ export async function queueWebhookExecution( } if (foundWebhook.provider === 'microsoftteams') { + const providerConfig = (foundWebhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + + // Chat subscription (Graph API) returns 202 + if (triggerId === 'microsoftteams_chat_subscription') { + return new NextResponse(null, { status: 202 }) + } + + // Channel webhook (outgoing webhook) returns message response return NextResponse.json({ type: 'message', text: 'Sim', diff --git a/apps/sim/lib/webhooks/utils.ts b/apps/sim/lib/webhooks/utils.ts index f0755de149..34795018b7 100644 --- a/apps/sim/lib/webhooks/utils.ts +++ b/apps/sim/lib/webhooks/utils.ts @@ -3,7 +3,7 @@ import { account, webhook } from '@sim/db/schema' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' -import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookUtils') @@ -139,15 +139,401 @@ export async function validateSlackSignature( } } +/** + * Format Microsoft Teams Graph change notification + */ +async function formatTeamsGraphNotification( + body: any, + foundWebhook: any, + foundWorkflow: any, + request: NextRequest +): Promise { + const notification = body.value[0] + const changeType = notification.changeType || 'created' + const resource = notification.resource || '' + const subscriptionId = notification.subscriptionId || '' + + // Extract chatId and messageId from resource path + let chatId: string | null = null + let messageId: string | null = null + + const fullMatch = resource.match(/chats\/([^/]+)\/messages\/([^/]+)/) + if (fullMatch) { + chatId = fullMatch[1] + messageId = fullMatch[2] + } + + if (!chatId || !messageId) { + const quotedMatch = resource.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) + if (quotedMatch) { + chatId = quotedMatch[1] + messageId = quotedMatch[2] + } + } + + if (!chatId || !messageId) { + const collectionMatch = resource.match(/chats\/([^/]+)\/messages$/) + const rdId = body?.value?.[0]?.resourceData?.id + if (collectionMatch && rdId) { + chatId = collectionMatch[1] + messageId = rdId + } + } + + if ((!chatId || !messageId) && body?.value?.[0]?.resourceData?.['@odata.id']) { + const odataId = String(body.value[0].resourceData['@odata.id']) + const odataMatch = odataId.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) + if (odataMatch) { + chatId = odataMatch[1] + messageId = odataMatch[2] + } + } + + if (!chatId || !messageId) { + logger.warn('Could not resolve chatId/messageId from Teams notification', { + resource, + hasResourceDataId: Boolean(body?.value?.[0]?.resourceData?.id), + valueLength: Array.isArray(body?.value) ? body.value.length : 0, + keys: Object.keys(body || {}), + }) + return { + input: 'Teams notification received', + webhook: { + data: { + provider: 'microsoftteams', + path: foundWebhook?.path || '', + providerConfig: foundWebhook?.providerConfig || {}, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + const resolvedChatId = chatId as string + const resolvedMessageId = messageId as string + const providerConfig = (foundWebhook?.providerConfig as Record) || {} + const credentialId = providerConfig.credentialId + const includeAttachments = providerConfig.includeAttachments !== false + + let message: any = null + const rawAttachments: Array<{ name: string; data: Buffer; contentType: string; size: number }> = + [] + let accessToken: string | null = null + + // Teams chat subscriptions require credentials + if (!credentialId) { + logger.error('Missing credentialId for Teams chat subscription', { + chatId: resolvedChatId, + messageId: resolvedMessageId, + webhookId: foundWebhook?.id, + blockId: foundWebhook?.blockId, + providerConfig, + }) + } else { + try { + // Get userId from credential + const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (rows.length === 0) { + logger.error('Teams credential not found', { credentialId, chatId: resolvedChatId }) + // Continue without message data + } else { + const effectiveUserId = rows[0].userId + accessToken = await refreshAccessTokenIfNeeded( + credentialId, + effectiveUserId, + 'teams-graph-notification' + ) + } + + if (accessToken) { + const msgUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(resolvedChatId)}/messages/${encodeURIComponent(resolvedMessageId)}` + const res = await fetch(msgUrl, { headers: { Authorization: `Bearer ${accessToken}` } }) + if (res.ok) { + message = await res.json() + + if (includeAttachments && message?.attachments?.length > 0) { + const attachments = Array.isArray(message?.attachments) ? message.attachments : [] + for (const att of attachments) { + try { + const contentUrl = + typeof att?.contentUrl === 'string' ? (att.contentUrl as string) : undefined + const contentTypeHint = + typeof att?.contentType === 'string' ? (att.contentType as string) : undefined + let attachmentName = (att?.name as string) || 'teams-attachment' + + if (!contentUrl) continue + + let buffer: Buffer | null = null + let mimeType = 'application/octet-stream' + + if (contentUrl.includes('sharepoint.com') || contentUrl.includes('onedrive')) { + try { + const directRes = await fetch(contentUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: 'follow', + }) + + if (directRes.ok) { + const arrayBuffer = await directRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + directRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else { + const encodedUrl = Buffer.from(contentUrl) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + + const graphUrl = `https://graph.microsoft.com/v1.0/shares/u!${encodedUrl}/driveItem/content` + const graphRes = await fetch(graphUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: 'follow', + }) + + if (graphRes.ok) { + const arrayBuffer = await graphRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + graphRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else { + continue + } + } + } catch { + continue + } + } else if ( + contentUrl.includes('1drv.ms') || + contentUrl.includes('onedrive.live.com') || + contentUrl.includes('onedrive.com') || + contentUrl.includes('my.microsoftpersonalcontent.com') + ) { + try { + let shareToken: string | null = null + + if (contentUrl.includes('1drv.ms')) { + const urlParts = contentUrl.split('/').pop() + if (urlParts) shareToken = urlParts + } else if (contentUrl.includes('resid=')) { + const urlParams = new URL(contentUrl).searchParams + const resId = urlParams.get('resid') + if (resId) shareToken = resId + } + + if (!shareToken) { + const base64Url = Buffer.from(contentUrl, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + shareToken = `u!${base64Url}` + } else if (!shareToken.startsWith('u!')) { + const base64Url = Buffer.from(shareToken, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + shareToken = `u!${base64Url}` + } + + const metadataUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem` + const metadataRes = await fetch(metadataUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!metadataRes.ok) { + const directUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem/content` + const directRes = await fetch(directUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: 'follow', + }) + + if (directRes.ok) { + const arrayBuffer = await directRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + directRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else { + continue + } + } else { + const metadata = await metadataRes.json() + const downloadUrl = metadata['@microsoft.graph.downloadUrl'] + + if (downloadUrl) { + const downloadRes = await fetch(downloadUrl) + + if (downloadRes.ok) { + const arrayBuffer = await downloadRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + downloadRes.headers.get('content-type') || + metadata.file?.mimeType || + contentTypeHint || + 'application/octet-stream' + + if (metadata.name && metadata.name !== attachmentName) { + attachmentName = metadata.name + } + } else { + continue + } + } else { + continue + } + } + } catch { + continue + } + } else { + try { + const ares = await fetch(contentUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (ares.ok) { + const arrayBuffer = await ares.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + ares.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } + } catch { + continue + } + } + + if (!buffer) continue + + const size = buffer.length + + // Store raw attachment (will be uploaded to execution storage later) + rawAttachments.push({ + name: attachmentName, + data: buffer, + contentType: mimeType, + size, + }) + } catch {} + } + } + } + } + } catch (error) { + logger.error('Failed to fetch Teams message', { + error, + chatId: resolvedChatId, + messageId: resolvedMessageId, + }) + } + } + + // If no message was fetched, return minimal data + if (!message) { + logger.warn('No message data available for Teams notification', { + chatId: resolvedChatId, + messageId: resolvedMessageId, + hasCredential: !!credentialId, + }) + return { + input: '', + message_id: messageId, + chat_id: chatId, + from_name: 'Unknown', + text: '', + created_at: notification.resourceData?.createdDateTime || '', + change_type: changeType, + subscription_id: subscriptionId, + attachments: [], + microsoftteams: { + message: { id: messageId, text: '', timestamp: '', chatId, raw: null }, + from: { id: '', name: 'Unknown', aadObjectId: '' }, + notification: { changeType, subscriptionId, resource }, + }, + webhook: { + data: { + provider: 'microsoftteams', + path: foundWebhook?.path || '', + providerConfig: foundWebhook?.providerConfig || {}, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + + // Extract data from message - we know it exists now + // body.content is the HTML/text content, summary is a plain text preview (max 280 chars) + const messageText = message.body?.content || '' + const from = message.from?.user || {} + const createdAt = message.createdDateTime || '' + + return { + input: messageText, + message_id: messageId, + chat_id: chatId, + from_name: from.displayName || 'Unknown', + text: messageText, + created_at: createdAt, + change_type: changeType, + subscription_id: subscriptionId, + attachments: rawAttachments, + microsoftteams: { + message: { + id: messageId, + text: messageText, + timestamp: createdAt, + chatId, + raw: message, + }, + from: { + id: from.id, + name: from.displayName, + aadObjectId: from.aadObjectId, + }, + notification: { + changeType, + subscriptionId, + resource, + }, + }, + webhook: { + data: { + provider: 'microsoftteams', + path: foundWebhook?.path || '', + providerConfig: foundWebhook?.providerConfig || {}, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } +} + /** * Format webhook input based on provider */ -export function formatWebhookInput( +export async function formatWebhookInput( foundWebhook: any, foundWorkflow: any, body: any, request: NextRequest -): any { +): Promise { if (foundWebhook.provider === 'whatsapp') { const data = body?.entry?.[0]?.changes?.[0]?.value const messages = data?.messages || [] @@ -359,7 +745,13 @@ export function formatWebhookInput( } if (foundWebhook.provider === 'microsoftteams') { + // Check if this is a Microsoft Graph change notification + if (body?.value && Array.isArray(body.value) && body.value.length > 0) { + return await formatTeamsGraphNotification(body, foundWebhook, foundWorkflow, request) + } + // Microsoft Teams outgoing webhook - Teams sending data to us + // const messageText = body?.text || '' const messageId = body?.id || '' const timestamp = body?.timestamp || body?.localTimestamp || '' @@ -1308,54 +1700,35 @@ export interface AirtableChange { /** * Configure Gmail polling for a webhook */ -export async function configureGmailPolling( - userId: string, - webhookData: any, - requestId: string -): Promise { +export async function configureGmailPolling(webhookData: any, requestId: string): Promise { const logger = createLogger('GmailWebhookSetup') logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`) try { const providerConfig = (webhookData.providerConfig as Record) || {} - const credentialId: string | undefined = providerConfig.credentialId - let effectiveUserId: string | null = null - let accessToken: string | null = null + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) + return false + } - if (credentialId) { - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` - ) - return false - } - effectiveUserId = rows[0].userId - accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` - ) - return false - } - } else { - // Backward-compat: fall back to workflow owner - if (!userId) { - logger.error( - `[${requestId}] Missing credentialId and userId for Gmail webhook ${webhookData.id}` - ) - return false - } - effectiveUserId = userId - accessToken = await getOAuthToken(effectiveUserId, 'google-email') - if (!accessToken) { - logger.error( - `[${requestId}] Failed to obtain Gmail token for user ${effectiveUserId} (fallback)` - ) - return false - } + // Get userId from credential + const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` + ) + return false + } + + const effectiveUserId = rows[0].userId + const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` + ) + return false } const maxEmailsPerPoll = @@ -1408,54 +1781,37 @@ export async function configureGmailPolling( * Configure Outlook polling for a webhook */ export async function configureOutlookPolling( - userId: string, webhookData: any, requestId: string ): Promise { const logger = createLogger('OutlookWebhookSetup') logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) - logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) try { const providerConfig = (webhookData.providerConfig as Record) || {} - const credentialId: string | undefined = providerConfig.credentialId - let effectiveUserId: string | null = null - let accessToken: string | null = null + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) + return false + } - if (credentialId) { - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` - ) - return false - } - effectiveUserId = rows[0].userId - accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` - ) - return false - } - } else { - // Backward-compat: fall back to workflow owner - if (!userId) { - logger.error( - `[${requestId}] Missing credentialId and userId for Outlook webhook ${webhookData.id}` - ) - return false - } - effectiveUserId = userId - accessToken = await getOAuthToken(effectiveUserId, 'outlook') - if (!accessToken) { - logger.error( - `[${requestId}] Failed to obtain Outlook token for user ${effectiveUserId} (fallback)` - ) - return false - } + // Get userId from credential + const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` + ) + return false + } + + const effectiveUserId = rows[0].userId + const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` + ) + return false } const providerCfg = (webhookData.providerConfig as Record) || {} diff --git a/apps/sim/lib/webhooks/webhook-helpers.ts b/apps/sim/lib/webhooks/webhook-helpers.ts new file mode 100644 index 0000000000..850e292a61 --- /dev/null +++ b/apps/sim/lib/webhooks/webhook-helpers.ts @@ -0,0 +1,305 @@ +import { db } from '@sim/db' +import { webhook as webhookTable } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { env } from '@/lib/env' +import { createLogger } from '@/lib/logs/console/logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const teamsLogger = createLogger('TeamsSubscription') +const telegramLogger = createLogger('TelegramWebhook') + +/** + * Create a Microsoft Teams chat subscription + * Returns true if successful, false otherwise + */ +export async function createTeamsSubscription( + request: NextRequest, + webhook: any, + workflow: any, + requestId: string +): Promise { + try { + const config = (webhook.providerConfig as Record) || {} + + // Only handle Teams chat subscriptions + if (config.triggerId !== 'microsoftteams_chat_subscription') { + return true // Not a Teams subscription, no action needed + } + + const credentialId = config.credentialId as string | undefined + const chatId = config.chatId as string | undefined + + if (!credentialId) { + teamsLogger.warn( + `[${requestId}] Missing credentialId for Teams chat subscription ${webhook.id}` + ) + return false + } + + if (!chatId) { + teamsLogger.warn(`[${requestId}] Missing chatId for Teams chat subscription ${webhook.id}`) + return false + } + + // Get access token + const accessToken = await refreshAccessTokenIfNeeded(credentialId, workflow.userId, requestId) + if (!accessToken) { + teamsLogger.error( + `[${requestId}] Failed to get access token for Teams subscription ${webhook.id}` + ) + return false + } + + // Check if subscription already exists + const existingSubscriptionId = config.externalSubscriptionId as string | undefined + if (existingSubscriptionId) { + try { + const checkRes = await fetch( + `https://graph.microsoft.com/v1.0/subscriptions/${existingSubscriptionId}`, + { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } } + ) + if (checkRes.ok) { + teamsLogger.info( + `[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}` + ) + return true + } + } catch { + teamsLogger.debug(`[${requestId}] Existing subscription check failed, will create new one`) + } + } + + // Build notification URL + const requestOrigin = new URL(request.url).origin + const effectiveOrigin = requestOrigin.includes('localhost') + ? env.NEXT_PUBLIC_APP_URL || requestOrigin + : requestOrigin + const notificationUrl = `${effectiveOrigin}/api/webhooks/trigger/${webhook.path}` + + // Subscribe to the specified chat + const resource = `/chats/${chatId}/messages` + + // Create subscription with max lifetime (4230 minutes = ~3 days) + const maxLifetimeMinutes = 4230 + const expirationDateTime = new Date(Date.now() + maxLifetimeMinutes * 60 * 1000).toISOString() + + const body = { + changeType: 'created,updated', + notificationUrl, + lifecycleNotificationUrl: notificationUrl, + resource, + includeResourceData: false, + expirationDateTime, + clientState: webhook.id, + } + + const res = await fetch('https://graph.microsoft.com/v1.0/subscriptions', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + + const payload = await res.json() + if (!res.ok) { + teamsLogger.error( + `[${requestId}] Failed to create Teams subscription for webhook ${webhook.id}`, + { + status: res.status, + error: payload.error, + } + ) + return false + } + + // Update webhook config with subscription details + const updatedConfig = { + ...config, + externalSubscriptionId: payload.id, + subscriptionExpiration: payload.expirationDateTime, + } + + await db + .update(webhookTable) + .set({ providerConfig: updatedConfig, updatedAt: new Date() }) + .where(eq(webhookTable.id, webhook.id)) + + teamsLogger.info( + `[${requestId}] Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}` + ) + return true + } catch (error) { + teamsLogger.error( + `[${requestId}] Error creating Teams subscription for webhook ${webhook.id}`, + error + ) + return false + } +} + +/** + * Delete a Microsoft Teams chat subscription + * Always returns true (don't fail webhook deletion if cleanup fails) + */ +export async function deleteTeamsSubscription( + webhook: any, + workflow: any, + requestId: string +): Promise { + try { + const config = (webhook.providerConfig as Record) || {} + + // Only handle Teams chat subscriptions + if (config.triggerId !== 'microsoftteams_chat_subscription') { + return // Not a Teams subscription, no action needed + } + + const externalSubscriptionId = config.externalSubscriptionId as string | undefined + const credentialId = config.credentialId as string | undefined + + if (!externalSubscriptionId || !credentialId) { + teamsLogger.info( + `[${requestId}] No external subscription to delete for webhook ${webhook.id}` + ) + return + } + + // Get access token + const accessToken = await refreshAccessTokenIfNeeded(credentialId, workflow.userId, requestId) + if (!accessToken) { + teamsLogger.warn( + `[${requestId}] Could not get access token to delete Teams subscription for webhook ${webhook.id}` + ) + return // Don't fail deletion + } + + const res = await fetch( + `https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${accessToken}` }, + } + ) + + if (res.ok || res.status === 404) { + teamsLogger.info( + `[${requestId}] Successfully deleted Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}` + ) + } else { + const errorBody = await res.text() + teamsLogger.warn( + `[${requestId}] Failed to delete Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}. Status: ${res.status}` + ) + } + } catch (error) { + teamsLogger.error( + `[${requestId}] Error deleting Teams subscription for webhook ${webhook.id}`, + error + ) + // Don't fail webhook deletion + } +} + +/** + * Create a Telegram bot webhook + * Returns true if successful, false otherwise + */ +export async function createTelegramWebhook( + request: NextRequest, + webhook: any, + requestId: string +): Promise { + try { + const config = (webhook.providerConfig as Record) || {} + const botToken = config.botToken as string | undefined + + if (!botToken) { + telegramLogger.warn(`[${requestId}] Missing botToken for Telegram webhook ${webhook.id}`) + return false + } + + if (!env.NEXT_PUBLIC_APP_URL) { + telegramLogger.error( + `[${requestId}] NEXT_PUBLIC_APP_URL not configured, cannot register Telegram webhook` + ) + return false + } + + const notificationUrl = `${env.NEXT_PUBLIC_APP_URL}/api/webhooks/trigger/${webhook.path}` + + const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook` + const telegramResponse = await fetch(telegramApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'TelegramBot/1.0', + }, + body: JSON.stringify({ url: notificationUrl }), + }) + + const responseBody = await telegramResponse.json() + if (!telegramResponse.ok || !responseBody.ok) { + const errorMessage = + responseBody.description || + `Failed to create Telegram webhook. Status: ${telegramResponse.status}` + telegramLogger.error(`[${requestId}] ${errorMessage}`, { response: responseBody }) + return false + } + + telegramLogger.info( + `[${requestId}] Successfully created Telegram webhook for webhook ${webhook.id}` + ) + return true + } catch (error) { + telegramLogger.error( + `[${requestId}] Error creating Telegram webhook for webhook ${webhook.id}`, + error + ) + return false + } +} + +/** + * Delete a Telegram bot webhook + * Always returns void (don't fail webhook deletion if cleanup fails) + */ +export async function deleteTelegramWebhook(webhook: any, requestId: string): Promise { + try { + const config = (webhook.providerConfig as Record) || {} + const botToken = config.botToken as string | undefined + + if (!botToken) { + telegramLogger.warn( + `[${requestId}] Missing botToken for Telegram webhook deletion ${webhook.id}` + ) + return + } + + const telegramApiUrl = `https://api.telegram.org/bot${botToken}/deleteWebhook` + const telegramResponse = await fetch(telegramApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + + const responseBody = await telegramResponse.json() + if (!telegramResponse.ok || !responseBody.ok) { + const errorMessage = + responseBody.description || + `Failed to delete Telegram webhook. Status: ${telegramResponse.status}` + telegramLogger.error(`[${requestId}] ${errorMessage}`, { response: responseBody }) + } else { + telegramLogger.info( + `[${requestId}] Successfully deleted Telegram webhook for webhook ${webhook.id}` + ) + } + } catch (error) { + telegramLogger.error( + `[${requestId}] Error deleting Telegram webhook for webhook ${webhook.id}`, + error + ) + // Don't fail webhook deletion + } +} diff --git a/apps/sim/tools/microsoft_teams/read_channel.ts b/apps/sim/tools/microsoft_teams/read_channel.ts index 3a204c4d2d..c01a7c3273 100644 --- a/apps/sim/tools/microsoft_teams/read_channel.ts +++ b/apps/sim/tools/microsoft_teams/read_channel.ts @@ -3,7 +3,10 @@ import type { MicrosoftTeamsReadResponse, MicrosoftTeamsToolParams, } from '@/tools/microsoft_teams/types' -import { extractMessageAttachments } from '@/tools/microsoft_teams/utils' +import { + extractMessageAttachments, + fetchHostedContentsForChannelMessage, +} from '@/tools/microsoft_teams/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('MicrosoftTeamsReadChannel') @@ -38,6 +41,12 @@ export const readChannelTool: ToolConfig { - try { - const content = message.body?.content || 'No content' - const messageId = message.id - - const attachments = extractMessageAttachments(message) - - let sender = 'Unknown' - if (message.from?.user?.displayName) { - sender = message.from.user.displayName - } else if (message.messageType === 'systemEventMessage') { - sender = 'System' - } - - return { - id: messageId, - content: content, - sender, - timestamp: message.createdDateTime, - messageType: message.messageType || 'message', - attachments, - } - } catch (error) { - logger.error(`Error processing message at index ${index}:`, error) - return { - id: message.id || `unknown-${index}`, - content: 'Error processing message', - sender: 'Unknown', - timestamp: message.createdDateTime || new Date().toISOString(), - messageType: 'error', - attachments: [], + const processedMessages = await Promise.all( + messages.map(async (message: any, index: number) => { + try { + const content = message.body?.content || 'No content' + const messageId = message.id + + const attachments = extractMessageAttachments(message) + + let sender = 'Unknown' + if (message.from?.user?.displayName) { + sender = message.from.user.displayName + } else if (message.messageType === 'systemEventMessage') { + sender = 'System' + } + + // Optionally fetch and upload hosted contents + let uploaded: any[] = [] + if ( + params?.includeAttachments && + params.accessToken && + params.teamId && + params.channelId && + messageId + ) { + try { + uploaded = await fetchHostedContentsForChannelMessage({ + accessToken: params.accessToken, + teamId: params.teamId, + channelId: params.channelId, + messageId, + }) + } catch (_e) { + uploaded = [] + } + } + + return { + id: messageId, + content: content, + sender, + timestamp: message.createdDateTime, + messageType: message.messageType || 'message', + attachments, + uploadedFiles: uploaded, + } + } catch (error) { + logger.error(`Error processing message at index ${index}:`, error) + return { + id: message.id || `unknown-${index}`, + content: 'Error processing message', + sender: 'Unknown', + timestamp: message.createdDateTime || new Date().toISOString(), + messageType: 'error', + attachments: [], + } } - } - }) + }) + ) // Format the messages into a readable text (no attachment info in content) const formattedMessages = processedMessages @@ -171,11 +204,15 @@ export const readChannelTool: ToolConfig m.uploadedFiles || []) + return { success: true, output: { content: formattedMessages, metadata, + attachments: flattenedUploads, }, } }, @@ -189,5 +226,9 @@ export const readChannelTool: ToolConfig = { @@ -29,6 +32,12 @@ export const readChatTool: ToolConfig { - const content = message.body?.content || 'No content' - const messageId = message.id - - // Extract attachments without any content processing - const attachments = extractMessageAttachments(message) - - return { - id: messageId, - content: content, // Keep original content without modification - sender: message.from?.user?.displayName || 'Unknown', - timestamp: message.createdDateTime, - messageType: message.messageType || 'message', - attachments, // Attachments only stored here - } - }) + const processedMessages = await Promise.all( + messages.map(async (message: any) => { + const content = message.body?.content || 'No content' + const messageId = message.id + + // Extract attachments without any content processing + const attachments = extractMessageAttachments(message) + + // Optionally fetch and upload hosted contents + let uploaded: any[] = [] + if (params?.includeAttachments && params.accessToken && params.chatId && messageId) { + try { + uploaded = await fetchHostedContentsForChatMessage({ + accessToken: params.accessToken, + chatId: params.chatId, + messageId, + }) + } catch (_e) { + uploaded = [] + } + } + + return { + id: messageId, + content: content, // Keep original content without modification + sender: message.from?.user?.displayName || 'Unknown', + timestamp: message.createdDateTime, + messageType: message.messageType || 'message', + attachments, // Raw attachment metadata + uploadedFiles: uploaded, // Uploaded file infos (paths/keys) + } + }) + ) // Format the messages into a readable text (no attachment info in content) const formattedMessages = processedMessages @@ -131,11 +157,15 @@ export const readChatTool: ToolConfig m.uploadedFiles || []) + return { success: true, output: { content: formattedMessages, metadata, + attachments: flattenedUploads, }, } }, @@ -148,5 +178,9 @@ export const readChatTool: ToolConfig // Global attachments summary totalAttachments?: number @@ -39,6 +46,13 @@ export interface MicrosoftTeamsReadResponse extends ToolResponse { output: { content: string metadata: MicrosoftTeamsMetadata + attachments?: Array<{ + path: string + key: string + name: string + size: number + type: string + }> } } @@ -56,6 +70,7 @@ export interface MicrosoftTeamsToolParams { channelId?: string teamId?: string content?: string + includeAttachments?: boolean } export type MicrosoftTeamsResponse = MicrosoftTeamsReadResponse | MicrosoftTeamsWriteResponse diff --git a/apps/sim/tools/microsoft_teams/utils.ts b/apps/sim/tools/microsoft_teams/utils.ts index 10bc3227ed..27e25489dc 100644 --- a/apps/sim/tools/microsoft_teams/utils.ts +++ b/apps/sim/tools/microsoft_teams/utils.ts @@ -1,4 +1,8 @@ +import { createLogger } from '@/lib/logs/console/logger' import type { MicrosoftTeamsAttachment } from '@/tools/microsoft_teams/types' +import type { ToolFileData } from '@/tools/types' + +const logger = createLogger('MicrosoftTeamsUtils') /** * Transform raw attachment data from Microsoft Graph API @@ -27,3 +31,71 @@ export function extractMessageAttachments(message: any): MicrosoftTeamsAttachmen return attachments } + +/** + * Fetch hostedContents for a chat message, upload each item to storage, and return uploaded file infos. + * Hosted contents expose base64 contentBytes via Microsoft Graph. + */ +export async function fetchHostedContentsForChatMessage(params: { + accessToken: string + chatId: string + messageId: string +}): Promise { + const { accessToken, chatId, messageId } = params + try { + const url = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}/hostedContents` + const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } }) + if (!res.ok) { + return [] + } + const data = await res.json() + const items = Array.isArray(data.value) ? data.value : [] + const results: ToolFileData[] = [] + for (const item of items) { + const base64: string | undefined = item.contentBytes + if (!base64) continue + const contentType: string = + typeof item.contentType === 'string' ? item.contentType : 'application/octet-stream' + const name: string = item.id ? `teams-hosted-${item.id}` : 'teams-hosted-content' + results.push({ name, mimeType: contentType, data: base64 }) + } + return results + } catch (error) { + logger.error('Error fetching/uploading hostedContents for chat message:', error) + return [] + } +} + +/** + * Fetch hostedContents for a channel message, upload each item to storage, and return uploaded file infos. + */ +export async function fetchHostedContentsForChannelMessage(params: { + accessToken: string + teamId: string + channelId: string + messageId: string +}): Promise { + const { accessToken, teamId, channelId, messageId } = params + try { + const url = `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/hostedContents` + const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } }) + if (!res.ok) { + return [] + } + const data = await res.json() + const items = Array.isArray(data.value) ? data.value : [] + const results: ToolFileData[] = [] + for (const item of items) { + const base64: string | undefined = item.contentBytes + if (!base64) continue + const contentType: string = + typeof item.contentType === 'string' ? item.contentType : 'application/octet-stream' + const name: string = item.id ? `teams-hosted-${item.id}` : 'teams-hosted-content' + results.push({ name, mimeType: contentType, data: base64 }) + } + return results + } catch (error) { + logger.error('Error fetching/uploading hostedContents for channel message:', error) + return [] + } +} diff --git a/apps/sim/triggers/index.ts b/apps/sim/triggers/index.ts index 9d3050b925..3c2c9cd0cb 100644 --- a/apps/sim/triggers/index.ts +++ b/apps/sim/triggers/index.ts @@ -5,7 +5,10 @@ import { genericWebhookTrigger } from './generic' import { githubWebhookTrigger } from './github' import { gmailPollingTrigger } from './gmail' import { googleFormsWebhookTrigger } from './googleforms/webhook' -import { microsoftTeamsWebhookTrigger } from './microsoftteams' +import { + microsoftTeamsChatSubscriptionTrigger, + microsoftTeamsWebhookTrigger, +} from './microsoftteams' import { outlookPollingTrigger } from './outlook' import { slackWebhookTrigger } from './slack' import { stripeWebhookTrigger } from './stripe/webhook' @@ -21,6 +24,7 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { github_webhook: githubWebhookTrigger, gmail_poller: gmailPollingTrigger, microsoftteams_webhook: microsoftTeamsWebhookTrigger, + microsoftteams_chat_subscription: microsoftTeamsChatSubscriptionTrigger, outlook_poller: outlookPollingTrigger, stripe_webhook: stripeWebhookTrigger, telegram_webhook: telegramWebhookTrigger, diff --git a/apps/sim/triggers/microsoftteams/chat_webhook.ts b/apps/sim/triggers/microsoftteams/chat_webhook.ts new file mode 100644 index 0000000000..b62aeed0cb --- /dev/null +++ b/apps/sim/triggers/microsoftteams/chat_webhook.ts @@ -0,0 +1,65 @@ +import { MicrosoftTeamsIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' + +export const microsoftTeamsChatSubscriptionTrigger: TriggerConfig = { + id: 'microsoftteams_chat_subscription', + name: 'Microsoft Teams Chat', + provider: 'microsoftteams', + description: + 'Trigger workflow from new messages in Microsoft Teams chats via Microsoft Graph subscriptions', + version: '1.0.0', + icon: MicrosoftTeamsIcon, + + // Credentials are handled by requiresCredentials below, not in configFields + configFields: { + chatId: { + type: 'string', + label: 'Chat ID', + placeholder: 'Enter chat ID', + description: 'The ID of the Teams chat to monitor', + required: true, + }, + includeAttachments: { + type: 'boolean', + label: 'Include Attachments', + defaultValue: true, + description: 'Fetch hosted contents and upload to storage', + required: false, + }, + }, + + // Require Microsoft Teams OAuth credentials + requiresCredentials: true, + credentialProvider: 'microsoft-teams', + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, + + outputs: { + // Core message fields + message_id: { type: 'string', description: 'Message ID' }, + chat_id: { type: 'string', description: 'Chat ID' }, + from_name: { type: 'string', description: 'Sender display name' }, + text: { type: 'string', description: 'Message body (HTML or text)' }, + created_at: { type: 'string', description: 'Message timestamp' }, + attachments: { type: 'file[]', description: 'Uploaded attachments as files' }, + }, + + instructions: [ + 'Connect your Microsoft Teams account and grant the required permissions.', + 'Enter the Chat ID of the Teams chat you want to monitor.', + 'We will create a Microsoft Graph change notification subscription that delivers chat message events to your Sim webhook URL.', + ], + + samplePayload: { + message_id: '1708709741557', + chat_id: '19:abcxyz@unq.gbl.spaces', + from_name: 'Adele Vance', + text: 'Hello from Teams!', + created_at: '2025-01-01T10:00:00Z', + attachments: [], + }, +} diff --git a/apps/sim/triggers/microsoftteams/index.ts b/apps/sim/triggers/microsoftteams/index.ts index e9cfa2876f..d1f00fb0cb 100644 --- a/apps/sim/triggers/microsoftteams/index.ts +++ b/apps/sim/triggers/microsoftteams/index.ts @@ -1 +1,2 @@ +export { microsoftTeamsChatSubscriptionTrigger } from './chat_webhook' export { microsoftTeamsWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/microsoftteams/webhook.ts b/apps/sim/triggers/microsoftteams/webhook.ts index 3e1e7bfe76..7a83b0a2a4 100644 --- a/apps/sim/triggers/microsoftteams/webhook.ts +++ b/apps/sim/triggers/microsoftteams/webhook.ts @@ -3,9 +3,9 @@ import type { TriggerConfig } from '../types' export const microsoftTeamsWebhookTrigger: TriggerConfig = { id: 'microsoftteams_webhook', - name: 'Microsoft Teams Webhook', + name: 'Microsoft Teams Channel', provider: 'microsoftteams', - description: 'Trigger workflow from Microsoft Teams events like messages and mentions', + description: 'Trigger workflow from Microsoft Teams channel messages via outgoing webhooks', version: '1.0.0', icon: MicrosoftTeamsIcon, diff --git a/apps/sim/triggers/types.ts b/apps/sim/triggers/types.ts index 7e54251aa4..a3dd6f36a7 100644 --- a/apps/sim/triggers/types.ts +++ b/apps/sim/triggers/types.ts @@ -1,4 +1,10 @@ -export type TriggerFieldType = 'string' | 'boolean' | 'select' | 'number' | 'multiselect' +export type TriggerFieldType = + | 'string' + | 'boolean' + | 'select' + | 'number' + | 'multiselect' + | 'credential' export interface TriggerConfigField { type: TriggerFieldType @@ -9,6 +15,8 @@ export interface TriggerConfigField { description?: string required?: boolean isSecret?: boolean + provider?: string // OAuth provider for credential type fields + requiredScopes?: string[] // Required OAuth scopes for credential type fields } export interface TriggerOutput { diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 954746a3a7..4be9a853ce 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -644,6 +644,15 @@ cronjobs: successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 + renewSubscriptions: + enabled: true + name: renew-subscriptions + schedule: "0 */12 * * *" + path: "/api/cron/renew-subscriptions" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + # Global CronJob settings image: