From 68b5b4e074c882fc288d72aed860ed718755b9e0 Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Thu, 9 Oct 2025 12:40:40 -0700 Subject: [PATCH 01/25] adding file logic and chat trigger --- apps/sim/app/api/webhooks/route.ts | 33 +++++ .../app/api/webhooks/trigger/[path]/route.ts | 28 ++++ .../components/trigger-config-section.tsx | 48 ++++++- .../components/trigger-modal.tsx | 62 ++++++++- .../trigger-config/trigger-config.tsx | 14 +- apps/sim/background/webhook-execution.ts | 2 +- apps/sim/blocks/blocks/microsoft_teams.ts | 5 +- apps/sim/lib/webhooks/teams-subscriptions.ts | 99 ++++++++++++++ apps/sim/lib/webhooks/utils.ts | 129 +++++++++++++++++- .../sim/tools/microsoft_teams/read_channel.ts | 39 +++++- apps/sim/tools/microsoft_teams/read_chat.ts | 34 ++++- apps/sim/tools/microsoft_teams/types.ts | 15 ++ apps/sim/tools/microsoft_teams/utils.ts | 70 ++++++++++ apps/sim/triggers/index.ts | 3 +- .../microsoftteams/chat_subscription.ts | 79 +++++++++++ .../triggers/microsoftteams/chat_webhook.ts | 81 +++++++++++ apps/sim/triggers/microsoftteams/index.ts | 1 + apps/sim/triggers/microsoftteams/webhook.ts | 4 +- apps/sim/triggers/types.ts | 4 +- 19 files changed, 732 insertions(+), 18 deletions(-) create mode 100644 apps/sim/lib/webhooks/teams-subscriptions.ts create mode 100644 apps/sim/triggers/microsoftteams/chat_subscription.ts create mode 100644 apps/sim/triggers/microsoftteams/chat_webhook.ts diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 912406eced8..342bca80e90 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -326,6 +326,39 @@ export async function POST(request: NextRequest) { } // --- End Telegram specific logic --- + // --- Microsoft Teams chat subscription setup --- + if (savedWebhook && provider === 'microsoftteams') { + try { + const cfg = (savedWebhook.providerConfig as Record) || {} + // Check if this is a chat subscription trigger (not outgoing webhook) + if (cfg.triggerId === 'microsoftteams_chat_subscription') { + logger.info( + `[${requestId}] Microsoft Teams chat subscription requested. Creating Graph subscription.` + ) + const { createMicrosoftTeamsChatSubscription } = await import('@/lib/webhooks/teams-subscriptions.ts') + const created = await createMicrosoftTeamsChatSubscription(request, userId, savedWebhook, requestId) + if (!created) { + logger.error(`[${requestId}] Failed to create Microsoft Teams chat subscription`) + return NextResponse.json( + { + error: 'Failed to create Microsoft Teams chat subscription', + }, + { status: 500 } + ) + } + } + } catch (err) { + logger.error(`[${requestId}] Error setting up Microsoft Teams chat subscription`, err) + return NextResponse.json( + { + error: 'Failed to configure Microsoft Teams chat subscription', + details: err instanceof Error ? err.message : 'Unknown error', + }, + { status: 500 } + ) + } + } + // --- Gmail webhook setup --- if (savedWebhook && provider === 'gmail') { logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index e341e7098e5..1a3a78657e5 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -18,6 +18,34 @@ 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 }> } 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 012635f8a7f..33b6c419c8e 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 @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Check, ChevronDown, Copy, Eye, EyeOff, Info } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -26,6 +26,8 @@ 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 { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { CredentialSelector } from '../../credential-selector/credential-selector' interface TriggerConfigSectionProps { blockId: string @@ -47,6 +49,26 @@ export function TriggerConfigSection({ const [showSecrets, setShowSecrets] = useState>({}) const [copied, setCopied] = useState(null) const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) + + // Sync credential field values from subblock store to config + useEffect(() => { + const credentialFields = Object.entries(triggerDef.configFields).filter( + ([, field]) => field.type === 'credential' + ) + + if (credentialFields.length === 0) return + + const unsubscribe = useSubBlockStore.subscribe((state) => { + credentialFields.forEach(([fieldId]) => { + const credentialValue = state.getValue(blockId, fieldId) as string | null + if (credentialValue && credentialValue !== config[fieldId]) { + onChange(fieldId, credentialValue) + } + }) + }) + + return unsubscribe + }, [blockId, triggerDef.configFields, config, onChange]) const copyToClipboard = (text: string, type: string) => { navigator.clipboard.writeText(text) @@ -222,6 +244,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 4a36261ad35..84d18263441 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 @@ -15,7 +15,15 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { CredentialSelector } from '../../credential-selector/credential-selector' import { TriggerConfigSection } from './trigger-config-section' import { TriggerInstructions } from './trigger-instructions' @@ -32,19 +40,28 @@ 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) @@ -445,6 +462,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 4dbd774d8dc..d56358ef7cd 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 }, }), }) @@ -409,6 +414,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 b60b6740b39..b6b52db1e28 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -293,7 +293,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(mockWebhook, 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 cafbaf4a951..678f6601411 100644 --- a/apps/sim/blocks/blocks/microsoft_teams.ts +++ b/apps/sim/blocks/blocks/microsoft_teams.ts @@ -51,6 +51,9 @@ export const MicrosoftTeamsBlock: BlockConfig = { 'Group.ReadWrite.All', 'Team.ReadBasic.All', 'offline_access', + // For downloading reference attachments stored in SharePoint/OneDrive when needed + 'Files.Read.All', + 'Sites.Read.All', ], placeholder: 'Select Microsoft account', required: true, @@ -142,7 +145,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/lib/webhooks/teams-subscriptions.ts b/apps/sim/lib/webhooks/teams-subscriptions.ts new file mode 100644 index 00000000000..1aa2a797b56 --- /dev/null +++ b/apps/sim/lib/webhooks/teams-subscriptions.ts @@ -0,0 +1,99 @@ +import { db } from '@sim/db' +import { webhook as webhookTable } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { 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 logger = createLogger('TeamsSubscriptions') + +export async function createMicrosoftTeamsChatSubscription( + request: NextRequest, + userId: string, + webhookData: any, + requestId: string +): Promise { + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const credentialId: string | undefined = providerConfig.credentialId + const subscriptionScope: 'chat' | 'all-chats' = providerConfig.subscriptionScope || 'chat' + const chatId: string | undefined = providerConfig.chatId + + if (!credentialId) { + logger.warn(`[${requestId}] Missing credentialId for Teams chat subscription creation.`) + return false + } + + const accessToken = await refreshAccessTokenIfNeeded(credentialId, userId, requestId) + if (!accessToken) { + logger.warn(`[${requestId}] Could not retrieve Teams access token for user ${userId}`) + return false + } + + 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/${webhookData.path}` + const resource = + subscriptionScope === 'all-chats' + ? '/chats/getAllMessages' + : chatId + ? `/chats/${encodeURIComponent(chatId)}/messages` + : null + + if (!resource) { + logger.warn(`[${requestId}] Missing chatId for chat scope subscription.`) + return false + } + + // Set expiration (max varies; set short and require renewal job later) + const expirationDateTime = new Date(Date.now() + 60 * 60 * 1000).toISOString() // 1 hour + + // For includeResourceData=true we must provide an encryption cert. For now, use false as MVP. + const body = { + changeType: 'created,updated', + notificationUrl, + resource, + includeResourceData: false, + expirationDateTime, + clientState: webhookData.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) { + logger.error(`[${requestId}] Failed to create Teams subscription`, { status: res.status, payload }) + return false + } + + // Persist subscription id and expiration in providerConfig + const updatedConfig = { + ...providerConfig, + externalSubscriptionId: payload.id, + subscriptionExpiration: payload.expirationDateTime, + } + await db + .update(webhookTable) + .set({ providerConfig: updatedConfig, updatedAt: new Date() }) + .where(eq(webhookTable.id, webhookData.id)) + + logger.info(`[${requestId}] Created Teams chat subscription ${payload.id}`) + return true + } catch (error) { + logger.error('Error creating Teams subscription:', error) + return false + } +} + + diff --git a/apps/sim/lib/webhooks/utils.ts b/apps/sim/lib/webhooks/utils.ts index f0755de1490..a48c5b45238 100644 --- a/apps/sim/lib/webhooks/utils.ts +++ b/apps/sim/lib/webhooks/utils.ts @@ -139,15 +139,134 @@ 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] // Process first notification + const changeType = notification.changeType || 'created' + const resource = notification.resource || '' + const subscriptionId = notification.subscriptionId || '' + + // Extract chatId and messageId from resource path + // Format: "chats/{chatId}/messages/{messageId}" + const resourceMatch = resource.match(/chats\/([^/]+)\/messages\/([^/]+)/) + if (!resourceMatch) { + logger.warn('Could not parse Teams Graph notification resource', { resource }) + 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 [, chatId, messageId] = resourceMatch + const providerConfig = (foundWebhook.providerConfig as Record) || {} + const credentialId = providerConfig.credentialId + const includeAttachments = providerConfig.includeAttachments !== false + + // Fetch full message details + let message: any = null + let uploadedFiles: any[] = [] + + if (credentialId) { + try { + const accessToken = await refreshAccessTokenIfNeeded(credentialId, foundWorkflow.userId, 'teams-graph-notification') + if (accessToken) { + // Fetch message + const msgRes = await fetch( + `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ) + if (msgRes.ok) { + message = await msgRes.json() + + // Fetch hosted contents if requested + if (includeAttachments) { + const { fetchHostedContentsForChatMessage } = await import('@/tools/microsoft_teams/utils') + uploadedFiles = await fetchHostedContentsForChatMessage({ + accessToken, + chatId, + messageId, + }) + } + } + } + } catch (error) { + logger.error('Error fetching Teams message from Graph notification:', error) + } + } + + const messageText = message?.body?.content || '' + const from = message?.from?.user || {} + const createdAt = message?.createdDateTime || notification.resourceData?.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: uploadedFiles, + 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,6 +478,12 @@ 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) { + // Graph subscription notification + return await formatTeamsGraphNotification(body, foundWebhook, foundWorkflow, request) + } + // Microsoft Teams outgoing webhook - Teams sending data to us const messageText = body?.text || '' const messageId = body?.id || '' diff --git a/apps/sim/tools/microsoft_teams/read_channel.ts b/apps/sim/tools/microsoft_teams/read_channel.ts index 3a204c4d2df..3172a19a24e 100644 --- a/apps/sim/tools/microsoft_teams/read_channel.ts +++ b/apps/sim/tools/microsoft_teams/read_channel.ts @@ -3,7 +3,7 @@ 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 +38,12 @@ export const readChannelTool: ToolConfig { + const processedMessages = await Promise.all(messages.map(async (message: any, index: number) => { try { const content = message.body?.content || 'No content' const messageId = message.id @@ -112,6 +118,27 @@ export const readChannelTool: ToolConfig m.uploadedFiles || []) + return { success: true, output: { content: formattedMessages, metadata, + attachments: flattenedUploads, }, } }, @@ -189,5 +221,6 @@ export const readChannelTool: ToolConfig = { @@ -29,6 +29,12 @@ export const readChatTool: ToolConfig { + 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, // Attachments only stored here + 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 +152,15 @@ export const readChatTool: ToolConfig m.uploadedFiles || []) + return { success: true, output: { content: formattedMessages, metadata, + attachments: flattenedUploads, }, } }, @@ -148,5 +173,6 @@ 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 10bc3227ed7..3984f794ee0 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,69 @@ 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 9d3050b925b..12afd01a2e3 100644 --- a/apps/sim/triggers/index.ts +++ b/apps/sim/triggers/index.ts @@ -5,7 +5,7 @@ import { genericWebhookTrigger } from './generic' import { githubWebhookTrigger } from './github' import { gmailPollingTrigger } from './gmail' import { googleFormsWebhookTrigger } from './googleforms/webhook' -import { microsoftTeamsWebhookTrigger } from './microsoftteams' +import { microsoftTeamsWebhookTrigger, microsoftTeamsChatSubscriptionTrigger } from './microsoftteams' import { outlookPollingTrigger } from './outlook' import { slackWebhookTrigger } from './slack' import { stripeWebhookTrigger } from './stripe/webhook' @@ -21,6 +21,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_subscription.ts b/apps/sim/triggers/microsoftteams/chat_subscription.ts new file mode 100644 index 00000000000..a970ab14059 --- /dev/null +++ b/apps/sim/triggers/microsoftteams/chat_subscription.ts @@ -0,0 +1,79 @@ +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, + + configFields: { + credentialId: { + type: 'credential', + label: 'Microsoft Teams Account', + placeholder: 'Select Microsoft Teams credential', + required: true, + provider: 'microsoft-teams', + requiredScopes: [ + 'openid', + 'profile', + 'email', + 'User.Read', + 'Chat.Read', + 'Chat.ReadWrite', + 'Chat.ReadBasic', + 'offline_access', + ], + }, + subscriptionScope: { + type: 'select', + label: 'Scope', + options: ['chat', 'all-chats'], + defaultValue: 'chat', + description: 'Subscribe to a single chat or all chats (requires higher permissions)', + required: true, + }, + chatId: { + type: 'string', + label: 'Chat ID', + placeholder: 'Enter chat ID', + description: 'Required when scope is chat', + required: false, + }, + includeAttachments: { + type: 'boolean', + label: 'Include Attachments', + defaultValue: true, + description: 'Fetch hosted contents and upload to storage', + required: false, + }, + }, + + 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: 'json', description: 'Uploaded attachments metadata' }, + }, + + instructions: [ + 'Connect your Microsoft Teams account and grant the required permissions.', + 'Choose the subscription scope: a single chat or all chats in the tenant.', + 'For chat scope, provide the Chat ID to subscribe to.', + '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/chat_webhook.ts b/apps/sim/triggers/microsoftteams/chat_webhook.ts new file mode 100644 index 00000000000..6b5f02dafab --- /dev/null +++ b/apps/sim/triggers/microsoftteams/chat_webhook.ts @@ -0,0 +1,81 @@ +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, + + configFields: { + credentialId: { + type: 'credential', + label: 'Microsoft Teams Account', + placeholder: 'Select Microsoft Teams credential', + required: true, + provider: 'microsoft-teams', + requiredScopes: [ + 'openid', + 'profile', + 'email', + 'User.Read', + 'Chat.Read', + 'Chat.ReadWrite', + 'Chat.ReadBasic', + 'offline_access', + ], + }, + subscriptionScope: { + type: 'select', + label: 'Scope', + options: ['chat', 'all-chats'], + defaultValue: 'chat', + description: 'Subscribe to a single chat or all chats (requires higher permissions)', + required: true, + }, + chatId: { + type: 'string', + label: 'Chat ID', + placeholder: 'Enter chat ID', + description: 'Required when scope is chat', + required: false, + }, + includeAttachments: { + type: 'boolean', + label: 'Include Attachments', + defaultValue: true, + description: 'Fetch hosted contents and upload to storage', + required: false, + }, + }, + + 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: 'json', description: 'Uploaded attachments metadata' }, + }, + + instructions: [ + 'Connect your Microsoft Teams account and grant the required permissions.', + 'Choose the subscription scope: a single chat or all chats in the tenant.', + 'For chat scope, provide the Chat ID to subscribe to.', + '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 e9cfa2876fe..19b8af2c247 100644 --- a/apps/sim/triggers/microsoftteams/index.ts +++ b/apps/sim/triggers/microsoftteams/index.ts @@ -1 +1,2 @@ export { microsoftTeamsWebhookTrigger } from './webhook' +export { microsoftTeamsChatSubscriptionTrigger } from './chat_subscription' diff --git a/apps/sim/triggers/microsoftteams/webhook.ts b/apps/sim/triggers/microsoftteams/webhook.ts index 3e1e7bfe764..7a83b0a2a4f 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 7e54251aa4e..8e6b8584c29 100644 --- a/apps/sim/triggers/types.ts +++ b/apps/sim/triggers/types.ts @@ -1,4 +1,4 @@ -export type TriggerFieldType = 'string' | 'boolean' | 'select' | 'number' | 'multiselect' +export type TriggerFieldType = 'string' | 'boolean' | 'select' | 'number' | 'multiselect' | 'credential' export interface TriggerConfigField { type: TriggerFieldType @@ -9,6 +9,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 { From 07fcf0498e7b17840238be8b2c08f64783c29e8e Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Thu, 9 Oct 2025 19:27:11 -0700 Subject: [PATCH 02/25] working trig --- apps/sim/app/api/webhooks/[id]/route.ts | 62 ++++ apps/sim/app/api/webhooks/route.ts | 28 +- .../app/api/webhooks/trigger/[path]/route.ts | 37 +- .../components/trigger-config-section.tsx | 19 +- .../components/trigger-modal.tsx | 18 +- .../trigger-config/trigger-config.tsx | 9 +- .../background/teams-subscription-renewal.ts | 242 ++++++++++++ apps/sim/background/webhook-execution.ts | 15 +- apps/sim/blocks/blocks/microsoft_teams.ts | 3 +- apps/sim/lib/webhooks/processor.ts | 31 +- apps/sim/lib/webhooks/teams-subscriptions.ts | 84 +++-- apps/sim/lib/webhooks/utils.ts | 351 ++++++++++++++++-- .../sim/tools/microsoft_teams/read_channel.ts | 116 +++--- apps/sim/tools/microsoft_teams/read_chat.ts | 70 ++-- apps/sim/tools/microsoft_teams/utils.ts | 6 +- apps/sim/triggers/index.ts | 5 +- .../microsoftteams/chat_subscription.ts | 79 ---- .../triggers/microsoftteams/chat_webhook.ts | 7 +- apps/sim/triggers/microsoftteams/index.ts | 2 +- apps/sim/triggers/types.ts | 8 +- 20 files changed, 900 insertions(+), 292 deletions(-) create mode 100644 apps/sim/background/teams-subscription-renewal.ts delete mode 100644 apps/sim/triggers/microsoftteams/chat_subscription.ts diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 6561b4532f6..22a12e8c19c 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -408,6 +408,68 @@ export async function DELETE( } } + // If it's a Microsoft Teams webhook with a subscription, delete the Graph subscription + if (foundWebhook.provider === 'microsoftteams') { + const providerConfig = (foundWebhook.providerConfig as Record) || {} + const externalSubscriptionId = providerConfig.externalSubscriptionId + const credentialId = providerConfig.credentialId + const triggerId = providerConfig.triggerId + + // Only attempt deletion for chat subscription triggers that have an external subscription + if ( + triggerId === 'microsoftteams_chat_subscription' && + externalSubscriptionId && + credentialId + ) { + try { + logger.info( + `[${requestId}] Deleting Microsoft Teams subscription ${externalSubscriptionId}` + ) + + // Get access token for the user + const { refreshAccessTokenIfNeeded } = await import('@/app/api/auth/oauth/utils') + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + webhookData.workflow.userId, + requestId + ) + + if (accessToken) { + const deleteResponse = await fetch( + `https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + + if (deleteResponse.ok || deleteResponse.status === 404) { + logger.info( + `[${requestId}] Successfully deleted Teams subscription ${externalSubscriptionId} (status: ${deleteResponse.status})` + ) + } else { + const errorBody = await deleteResponse.text() + logger.warn( + `[${requestId}] Failed to delete Teams subscription ${externalSubscriptionId}. Status: ${deleteResponse.status}, Error: ${errorBody}` + ) + // Don't fail the webhook deletion if subscription cleanup fails + } + } else { + logger.warn(`[${requestId}] Could not get access token to delete Teams subscription`) + } + } catch (error: any) { + logger.error(`[${requestId}] Error deleting Teams subscription`, { + webhookId: id, + subscriptionId: externalSubscriptionId, + error: error.message, + }) + // Don't fail the webhook deletion if subscription cleanup fails + } + } + } + // If it's a Telegram webhook, delete it from Telegram first if (foundWebhook.provider === 'telegram') { try { diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 342bca80e90..05cce92f1be 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`, { @@ -326,19 +331,21 @@ export async function POST(request: NextRequest) { } // --- End Telegram specific logic --- - // --- Microsoft Teams chat subscription setup --- + // Microsoft Teams chat subscription setup if (savedWebhook && provider === 'microsoftteams') { try { const cfg = (savedWebhook.providerConfig as Record) || {} - // Check if this is a chat subscription trigger (not outgoing webhook) if (cfg.triggerId === 'microsoftteams_chat_subscription') { - logger.info( - `[${requestId}] Microsoft Teams chat subscription requested. Creating Graph subscription.` + const { createMicrosoftTeamsChatSubscription } = await import( + '@/lib/webhooks/teams-subscriptions' + ) + const created = await createMicrosoftTeamsChatSubscription( + request, + userId, + savedWebhook, + requestId ) - const { createMicrosoftTeamsChatSubscription } = await import('@/lib/webhooks/teams-subscriptions.ts') - const created = await createMicrosoftTeamsChatSubscription(request, userId, savedWebhook, requestId) if (!created) { - logger.error(`[${requestId}] Failed to create Microsoft Teams chat subscription`) return NextResponse.json( { error: 'Failed to create Microsoft Teams chat subscription', @@ -348,7 +355,6 @@ export async function POST(request: NextRequest) { } } } catch (err) { - logger.error(`[${requestId}] Error setting up Microsoft Teams chat subscription`, err) return NextResponse.json( { error: 'Failed to configure Microsoft Teams chat subscription', diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 1a3a78657e5..8a44029b03c 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -18,17 +18,14 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 60 -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ path: string }> } -) { +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, { @@ -36,13 +33,13 @@ export async function GET( 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 }) } @@ -53,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 @@ -71,11 +83,20 @@ export async function POST( if (!findResult) { logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`) + return new NextResponse('Not Found', { status: 404 }) } const { webhook: foundWebhook, workflow: foundWorkflow } = findResult + // Log successful webhook lookup for debugging + logger.info(`[${requestId}] Found webhook for path: ${path}`, { + webhookId: foundWebhook.id, + provider: foundWebhook.provider, + workflowId: foundWorkflow.id, + blockId: foundWebhook.blockId, + }) + const authError = await verifyProviderAuth(foundWebhook, request, rawBody, requestId) if (authError) { return authError 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 33b6c419c8e..eea9500911c 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 @@ -25,8 +25,8 @@ import { Switch } from '@/components/ui/switch' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' 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 { useSubBlockStore } from '@/stores/workflows/subblock/store' +import type { TriggerConfig } from '@/triggers/types' import { CredentialSelector } from '../../credential-selector/credential-selector' interface TriggerConfigSectionProps { @@ -49,15 +49,15 @@ export function TriggerConfigSection({ const [showSecrets, setShowSecrets] = useState>({}) const [copied, setCopied] = useState(null) const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) - + // Sync credential field values from subblock store to config useEffect(() => { const credentialFields = Object.entries(triggerDef.configFields).filter( ([, field]) => field.type === 'credential' ) - + if (credentialFields.length === 0) return - + const unsubscribe = useSubBlockStore.subscribe((state) => { credentialFields.forEach(([fieldId]) => { const credentialValue = state.getValue(blockId, fieldId) as string | null @@ -66,7 +66,7 @@ export function TriggerConfigSection({ } }) }) - + return unsubscribe }, [blockId, triggerDef.configFields, config, onChange]) @@ -101,7 +101,13 @@ export function TriggerConfigSection({
) - case 'select': + case 'select': { + // Hide Scope selector for microsoftteams_chat_subscription to simplify UI (single-chat only) + const isHiddenScope = + (triggerDef.id === 'microsoftteams_chat_subscription' && + fieldId === 'subscriptionScope') || + false + if (isHiddenScope) return null return (
) + } case 'multiselect': { const selectedValues = Array.isArray(value) ? value : [] 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 84d18263441..cd249249963 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,12 +11,6 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -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 { Select, SelectContent, @@ -24,6 +18,12 @@ import { 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' import { TriggerInstructions } from './trigger-instructions' @@ -60,8 +60,10 @@ export function TriggerModal({ onTriggerChange, }: TriggerModalProps) { // Use selectedTriggerId to get the current trigger definition dynamically - const triggerDef = selectedTriggerId ? getTrigger(selectedTriggerId) || propTriggerDef : propTriggerDef - + const triggerDef = selectedTriggerId + ? getTrigger(selectedTriggerId) || propTriggerDef + : propTriggerDef + const [config, setConfig] = useState>(initialConfig) const [isSaving, setIsSaving] = useState(false) 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 d56358ef7cd..7aa6823570e 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 @@ -223,6 +223,7 @@ export function TriggerConfig({ provider: webhookProvider, providerConfig: { ...config, + triggerId: effectiveTriggerId, // Include the trigger ID to identify the trigger type ...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}), }, }), @@ -241,14 +242,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) diff --git a/apps/sim/background/teams-subscription-renewal.ts b/apps/sim/background/teams-subscription-renewal.ts new file mode 100644 index 00000000000..8da4f117ddc --- /dev/null +++ b/apps/sim/background/teams-subscription-renewal.ts @@ -0,0 +1,242 @@ +import { db } from '@sim/db' +import { webhook as webhookTable, workflow as workflowTable } from '@sim/db/schema' +import { task } from '@trigger.dev/sdk/v3' +import { and, eq, sql } from 'drizzle-orm' +import { env } from '@/lib/env' +import { createLogger } from '@/lib/logs/console/logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('TeamsSubscriptionRenewal') + +/** + * Background job to renew Microsoft Teams Graph API subscriptions before they expire. + * Runs periodically to check for subscriptions expiring soon and renews them. + */ +export const renewTeamsSubscriptions = task({ + id: 'renew-teams-subscriptions', + // Run every 2 days to catch subscriptions that expire in ~3 days + run: async (_payload: Record) => { + logger.info('Starting Teams subscription renewal job') + + try { + // Find all Microsoft Teams webhooks with chat subscriptions that expire soon + // Check for subscriptions expiring within the next 24 hours + const expirationThreshold = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + + const webhooksWithWorkflows = await db + .select({ + webhook: webhookTable, + workflow: workflowTable, + }) + .from(webhookTable) + .innerJoin(workflowTable, eq(webhookTable.workflowId, workflowTable.id)) + .where( + and( + eq(webhookTable.provider, 'microsoftteams'), + // Check if subscription expiration is approaching + sql`${webhookTable.providerConfig}->>'subscriptionExpiration' < ${expirationThreshold}`, + sql`${webhookTable.providerConfig}->>'triggerId' = 'microsoftteams_chat_subscription'` + ) + ) + + logger.info(`Found ${webhooksWithWorkflows.length} Teams subscriptions to renew`) + + let renewed = 0 + let failed = 0 + + for (const { webhook, workflow } of webhooksWithWorkflows) { + const providerConfig = (webhook.providerConfig as Record) || {} + const externalSubscriptionId = providerConfig.externalSubscriptionId + const credentialId = providerConfig.credentialId + const chatId = providerConfig.chatId + const subscriptionScope = providerConfig.subscriptionScope || 'chat' + + if (!externalSubscriptionId || !credentialId) { + logger.warn( + `Webhook ${webhook.id} missing subscription ID or credential, skipping renewal` + ) + failed++ + continue + } + + try { + logger.info(`Renewing subscription ${externalSubscriptionId} for webhook ${webhook.id}`) + + // Get fresh access token + const accessToken = await refreshAccessTokenIfNeeded( + credentialId, + workflow.userId, + `renewal-${webhook.id}` + ) + if (!accessToken) { + logger.error(`Could not get access token for webhook ${webhook.id}`) + failed++ + continue + } + + // Set new expiration to maximum allowed (4230 minutes = ~3 days) + const maxLifetimeMinutes = 4230 + const newExpirationDateTime = new Date( + Date.now() + maxLifetimeMinutes * 60 * 1000 + ).toISOString() + + // Renew the subscription using PATCH + 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 subscription ${externalSubscriptionId} for webhook ${webhook.id}`, + { + status: res.status, + error: error.error, + } + ) + + // If subscription not found, try to create a new one + if (res.status === 404) { + logger.info(`Subscription not found, creating new one for webhook ${webhook.id}`) + const created = await recreateSubscription( + webhook, + accessToken, + chatId, + subscriptionScope + ) + if (created) { + renewed++ + } else { + failed++ + } + } else { + failed++ + } + continue + } + + const payload = await res.json() + + // Update the expiration time in the database + const updatedConfig = { + ...providerConfig, + subscriptionExpiration: payload.expirationDateTime, + } + + await db + .update(webhookTable) + .set({ providerConfig: updatedConfig, updatedAt: new Date() }) + .where(eq(webhookTable.id, webhook.id)) + + logger.info( + `Successfully renewed subscription ${externalSubscriptionId} for webhook ${webhook.id}. New expiration: ${payload.expirationDateTime}` + ) + renewed++ + } catch (error) { + logger.error(`Error renewing subscription for webhook ${webhook.id}:`, error) + failed++ + } + } + + logger.info( + `Teams subscription renewal job completed. Renewed: ${renewed}, Failed: ${failed}` + ) + + return { + success: true, + renewed, + failed, + total: webhooksWithWorkflows.length, + } + } catch (error) { + logger.error('Error in Teams subscription renewal job:', error) + throw error + } + }, +}) + +/** + * Recreate a subscription if the original was deleted + */ +async function recreateSubscription( + webhook: any, + accessToken: string, + chatId: string | undefined, + subscriptionScope: 'chat' | 'all-chats' +): Promise { + try { + const providerConfig = (webhook.providerConfig as Record) || {} + + const notificationUrl = `${env.NEXT_PUBLIC_APP_URL}/api/webhooks/trigger/${webhook.path}` + const resource = + subscriptionScope === 'all-chats' + ? '/chats/getAllMessages' + : chatId + ? `/chats/${encodeURIComponent(chatId)}/messages` + : null + + if (!resource) { + logger.error(`Cannot recreate subscription: missing chat ID for webhook ${webhook.id}`) + return false + } + + 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) { + logger.error(`Failed to recreate subscription for webhook ${webhook.id}`, { + status: res.status, + error: payload.error, + }) + return false + } + + // Update with new subscription ID + const updatedConfig = { + ...providerConfig, + externalSubscriptionId: payload.id, + subscriptionExpiration: payload.expirationDateTime, + } + + await db + .update(webhookTable) + .set({ providerConfig: updatedConfig, updatedAt: new Date() }) + .where(eq(webhookTable.id, webhook.id)) + + logger.info(`Recreated subscription ${payload.id} for webhook ${webhook.id}`) + return true + } catch (error) { + logger.error(`Error recreating subscription for webhook ${webhook.id}:`, error) + return false + } +} diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index b6b52db1e28..29d358e3424 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -280,11 +280,20 @@ async function executeWebhookJobInternal( } } - // Format input for standard webhooks - const mockWebhook = { + const webhookData = await db + .select() + .from(webhook) + .where(eq(webhook.id, payload.webhookId)) + .limit(1) + + const actualWebhook = webhookData[0] || { + id: payload.webhookId, provider: payload.provider, blockId: payload.blockId, + path: payload.path, + providerConfig: {}, } + const mockWorkflow = { id: payload.workflowId, userId: payload.userId, @@ -293,7 +302,7 @@ async function executeWebhookJobInternal( headers: new Map(Object.entries(payload.headers)), } as any - const input = await 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 678f6601411..62da0e80b31 100644 --- a/apps/sim/blocks/blocks/microsoft_teams.ts +++ b/apps/sim/blocks/blocks/microsoft_teams.ts @@ -51,8 +51,7 @@ export const MicrosoftTeamsBlock: BlockConfig = { 'Group.ReadWrite.All', 'Team.ReadBasic.All', 'offline_access', - // For downloading reference attachments stored in SharePoint/OneDrive when needed - 'Files.Read.All', + 'Files.Read', 'Sites.Read.All', ], placeholder: 'Select Microsoft account', diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 73730df2d90..e8106d18f65 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -129,6 +129,27 @@ export async function findWebhookAndWorkflow( } if (options.path) { + // First, check if any webhook exists with this path (regardless of isActive status) + const allWebhooksWithPath = await db + .select({ + webhook: webhook, + workflow: workflow, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .where(eq(webhook.path, options.path)) + .limit(1) + + if (allWebhooksWithPath.length > 0 && !allWebhooksWithPath[0].webhook.isActive) { + logger.warn(`[${options.requestId}] Found inactive webhook for path: ${options.path}`, { + webhookId: allWebhooksWithPath[0].webhook.id, + provider: allWebhooksWithPath[0].webhook.provider, + workflowId: allWebhooksWithPath[0].workflow.id, + isActive: false, + }) + } + + // Now check for active webhooks only const results = await db .select({ webhook: webhook, @@ -140,7 +161,10 @@ export async function findWebhookAndWorkflow( .limit(1) if (results.length === 0) { - logger.warn(`[${options.requestId}] No active webhook found for path: ${options.path}`) + logger.warn(`[${options.requestId}] No active webhook found for path: ${options.path}`, { + path: options.path, + hasInactiveWebhook: allWebhooksWithPath.length > 0, + }) return null } @@ -416,10 +440,7 @@ export async function queueWebhookExecution( } if (foundWebhook.provider === 'microsoftteams') { - return NextResponse.json({ - type: 'message', - text: 'Sim', - }) + return new NextResponse(null, { status: 202 }) } return NextResponse.json({ message: 'Webhook processed' }) diff --git a/apps/sim/lib/webhooks/teams-subscriptions.ts b/apps/sim/lib/webhooks/teams-subscriptions.ts index 1aa2a797b56..d42ad859deb 100644 --- a/apps/sim/lib/webhooks/teams-subscriptions.ts +++ b/apps/sim/lib/webhooks/teams-subscriptions.ts @@ -1,13 +1,10 @@ import { db } from '@sim/db' import { webhook as webhookTable } from '@sim/db/schema' import { eq } from 'drizzle-orm' -import { NextRequest } from 'next/server' +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 logger = createLogger('TeamsSubscriptions') - export async function createMicrosoftTeamsChatSubscription( request: NextRequest, userId: string, @@ -17,19 +14,12 @@ export async function createMicrosoftTeamsChatSubscription( try { const providerConfig = (webhookData.providerConfig as Record) || {} const credentialId: string | undefined = providerConfig.credentialId - const subscriptionScope: 'chat' | 'all-chats' = providerConfig.subscriptionScope || 'chat' const chatId: string | undefined = providerConfig.chatId - if (!credentialId) { - logger.warn(`[${requestId}] Missing credentialId for Teams chat subscription creation.`) - return false - } + if (!credentialId) return false const accessToken = await refreshAccessTokenIfNeeded(credentialId, userId, requestId) - if (!accessToken) { - logger.warn(`[${requestId}] Could not retrieve Teams access token for user ${userId}`) - return false - } + if (!accessToken) return false const requestOrigin = new URL(request.url).origin const effectiveOrigin = requestOrigin.includes('localhost') @@ -37,25 +27,51 @@ export async function createMicrosoftTeamsChatSubscription( : requestOrigin const notificationUrl = `${effectiveOrigin}/api/webhooks/trigger/${webhookData.path}` - const resource = - subscriptionScope === 'all-chats' - ? '/chats/getAllMessages' - : chatId - ? `/chats/${encodeURIComponent(chatId)}/messages` - : null - - if (!resource) { - logger.warn(`[${requestId}] Missing chatId for chat scope subscription.`) - return false - } + const resource = chatId ? `/chats/${chatId}/messages` : null - // Set expiration (max varies; set short and require renewal job later) - const expirationDateTime = new Date(Date.now() + 60 * 60 * 1000).toISOString() // 1 hour + if (!resource) return false + + // Clean up existing Teams chat subscriptions for this credential + try { + const listResponse = await fetch('https://graph.microsoft.com/v1.0/subscriptions', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (listResponse.ok) { + const data = await listResponse.json() + const allSubscriptions = data.value || [] + const chatSubscriptions = allSubscriptions.filter((sub: any) => + sub.resource?.includes('/chats/') + ) + + for (const sub of chatSubscriptions) { + try { + const notificationUrl = sub.notificationUrl || '' + const isOurSubscription = notificationUrl.includes('/api/webhooks/trigger/') + + if (isOurSubscription) { + await fetch(`https://graph.microsoft.com/v1.0/subscriptions/${sub.id}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + } + } catch {} + } + } + } catch {} + + const maxLifetimeMinutes = 4230 + const expirationDateTime = new Date(Date.now() + maxLifetimeMinutes * 60 * 1000).toISOString() - // For includeResourceData=true we must provide an encryption cert. For now, use false as MVP. const body = { changeType: 'created,updated', notificationUrl, + lifecycleNotificationUrl: notificationUrl, resource, includeResourceData: false, expirationDateTime, @@ -73,11 +89,9 @@ export async function createMicrosoftTeamsChatSubscription( const payload = await res.json() if (!res.ok) { - logger.error(`[${requestId}] Failed to create Teams subscription`, { status: res.status, payload }) return false } - // Persist subscription id and expiration in providerConfig const updatedConfig = { ...providerConfig, externalSubscriptionId: payload.id, @@ -85,15 +99,15 @@ export async function createMicrosoftTeamsChatSubscription( } await db .update(webhookTable) - .set({ providerConfig: updatedConfig, updatedAt: new Date() }) + .set({ + providerConfig: updatedConfig, + isActive: true, + updatedAt: new Date(), + }) .where(eq(webhookTable.id, webhookData.id)) - logger.info(`[${requestId}] Created Teams chat subscription ${payload.id}`) return true - } catch (error) { - logger.error('Error creating Teams subscription:', error) + } catch { return false } } - - diff --git a/apps/sim/lib/webhooks/utils.ts b/apps/sim/lib/webhooks/utils.ts index a48c5b45238..02fa70c8a07 100644 --- a/apps/sim/lib/webhooks/utils.ts +++ b/apps/sim/lib/webhooks/utils.ts @@ -3,6 +3,8 @@ 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 { getPresignedUrlWithConfig, uploadToS3 } from '@/lib/uploads/s3/s3-client' +import { S3_EXECUTION_FILES_CONFIG } from '@/lib/uploads/setup' import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' const logger = createLogger('WebhookUtils') @@ -148,23 +150,61 @@ async function formatTeamsGraphNotification( foundWorkflow: any, request: NextRequest ): Promise { - const notification = body.value[0] // Process first notification + 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 - // Format: "chats/{chatId}/messages/{messageId}" - const resourceMatch = resource.match(/chats\/([^/]+)\/messages\/([^/]+)/) - if (!resourceMatch) { - logger.warn('Could not parse Teams Graph notification resource', { resource }) + 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, + path: foundWebhook?.path || '', + providerConfig: foundWebhook?.providerConfig || {}, payload: body, headers: Object.fromEntries(request.headers.entries()), method: request.method, @@ -173,45 +213,288 @@ async function formatTeamsGraphNotification( workflowId: foundWorkflow.id, } } - - const [, chatId, messageId] = resourceMatch - const providerConfig = (foundWebhook.providerConfig as Record) || {} + 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 - // Fetch full message details let message: any = null let uploadedFiles: any[] = [] + let accessToken: string | null = null if (credentialId) { try { - const accessToken = await refreshAccessTokenIfNeeded(credentialId, foundWorkflow.userId, 'teams-graph-notification') + let effectiveUserId: string | null = null + try { + const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + effectiveUserId = rows.length ? rows[0].userId : null + } catch { + effectiveUserId = null + } + accessToken = await refreshAccessTokenIfNeeded( + credentialId, + effectiveUserId || foundWorkflow.userId, + 'teams-graph-notification' + ) + if (!accessToken) { + try { + accessToken = await getOAuthToken( + effectiveUserId || foundWorkflow.userId, + 'microsoft-teams' + ) + } catch { + accessToken = null + } + } if (accessToken) { - // Fetch message - const msgRes = await fetch( - `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}`, - { headers: { Authorization: `Bearer ${accessToken}` } } - ) - if (msgRes.ok) { - message = await msgRes.json() - - // Fetch hosted contents if requested - if (includeAttachments) { - const { fetchHostedContentsForChatMessage } = await import('@/tools/microsoft_teams/utils') - uploadedFiles = await fetchHostedContentsForChatMessage({ + 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 { fetchHostedContentsForChatMessage } = await import( + '@/tools/microsoft_teams/utils' + ) + const hosted = await fetchHostedContentsForChatMessage({ accessToken, - chatId, - messageId, + chatId: resolvedChatId, + messageId: resolvedMessageId, }) + uploadedFiles = Array.isArray(hosted) ? hosted.slice() : [] + + 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 + const fileInfo = await uploadToS3( + buffer, + attachmentName, + mimeType, + { + bucket: S3_EXECUTION_FILES_CONFIG.bucket, + region: S3_EXECUTION_FILES_CONFIG.region, + }, + size, + true + ) + + let url: string | undefined + try { + url = await getPresignedUrlWithConfig( + fileInfo.key, + { + bucket: S3_EXECUTION_FILES_CONFIG.bucket, + region: S3_EXECUTION_FILES_CONFIG.region, + }, + 60 * 60 + ) + } catch { + url = undefined + } + + uploadedFiles.push({ + name: attachmentName, + mimeType, + size, + key: fileInfo.key, + path: fileInfo.path, + url, + sourceUrl: contentUrl, + }) + } catch {} + } } } } - } catch (error) { - logger.error('Error fetching Teams message from Graph notification:', error) - } + } catch {} + } else { + try { + accessToken = await getOAuthToken(foundWorkflow.userId, 'microsoft-teams') + 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() + } else { + const listUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(resolvedChatId)}/messages?$top=20&$orderby=createdDateTime desc` + const listRes = await fetch(listUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (listRes.ok) { + const listData = await listRes.json() + const items: any[] = Array.isArray(listData?.value) ? listData.value : [] + message = items.find((m) => m?.id === resolvedMessageId) || items[0] || null + } + } + } + } catch {} } - const messageText = message?.body?.content || '' + // Prefer body.content; fall back to summary or empty string + const messageText = + (message?.body?.content as string | undefined) || (message?.summary as string | undefined) || '' const from = message?.from?.user || {} const createdAt = message?.createdDateTime || notification.resourceData?.createdDateTime || '' @@ -247,8 +530,8 @@ async function formatTeamsGraphNotification( webhook: { data: { provider: 'microsoftteams', - path: foundWebhook.path, - providerConfig: foundWebhook.providerConfig, + path: foundWebhook?.path || '', + providerConfig: foundWebhook?.providerConfig || {}, payload: body, headers: Object.fromEntries(request.headers.entries()), method: request.method, @@ -480,11 +763,11 @@ export async 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) { - // Graph subscription notification 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 || '' diff --git a/apps/sim/tools/microsoft_teams/read_channel.ts b/apps/sim/tools/microsoft_teams/read_channel.ts index 3172a19a24e..c01a7c32737 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, fetchHostedContentsForChannelMessage } from '@/tools/microsoft_teams/utils' +import { + extractMessageAttachments, + fetchHostedContentsForChannelMessage, +} from '@/tools/microsoft_teams/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('MicrosoftTeamsReadChannel') @@ -104,62 +107,64 @@ 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' - } + 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 = [] + // 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: [], + 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 @@ -221,6 +226,9 @@ export const readChannelTool: ToolConfig = { @@ -83,37 +86,39 @@ 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) - - // 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 = [] + 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) - } - })) + 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 @@ -173,6 +178,9 @@ export const readChatTool: ToolConfig Date: Thu, 9 Oct 2025 20:13:47 -0700 Subject: [PATCH 03/25] teams specific logic --- apps/sim/background/webhook-execution.ts | 33 +++++++++++++++--------- apps/sim/lib/idempotency/service.ts | 3 ++- apps/sim/lib/webhooks/processor.ts | 20 +++++++++++++- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index 29d358e3424..3a23513fdf3 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -280,18 +280,27 @@ async function executeWebhookJobInternal( } } - const webhookData = await db - .select() - .from(webhook) - .where(eq(webhook.id, payload.webhookId)) - .limit(1) + // Format input for standard webhooks + // For Teams, we need the full webhook data to get credentialId for attachment processing + let mockWebhook: any + if (payload.provider === 'microsoftteams') { + const webhookData = await db + .select() + .from(webhook) + .where(eq(webhook.id, payload.webhookId)) + .limit(1) - const actualWebhook = webhookData[0] || { - id: payload.webhookId, - provider: payload.provider, - blockId: payload.blockId, - path: payload.path, - providerConfig: {}, + mockWebhook = webhookData[0] || { + id: payload.webhookId, + provider: payload.provider, + blockId: payload.blockId, + providerConfig: {}, + } + } else { + mockWebhook = { + provider: payload.provider, + blockId: payload.blockId, + } } const mockWorkflow = { @@ -302,7 +311,7 @@ async function executeWebhookJobInternal( headers: new Map(Object.entries(payload.headers)), } as any - const input = await formatWebhookInput(actualWebhook, mockWorkflow, payload.body, mockRequest) + const input = await formatWebhookInput(mockWebhook, mockWorkflow, payload.body, mockRequest) if (!input && payload.provider === 'whatsapp') { logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`) diff --git a/apps/sim/lib/idempotency/service.ts b/apps/sim/lib/idempotency/service.ts index 0571ba7d86f..e20dc6dbfea 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/processor.ts b/apps/sim/lib/webhooks/processor.ts index e8106d18f65..fe533a3ebe0 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -406,13 +406,31 @@ 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}` + } + } + 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, From 3aca1c90318e18b10dfdd604b6f2d965ecf32fe2 Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Sat, 11 Oct 2025 19:15:59 -0700 Subject: [PATCH 04/25] greptile comments --- apps/sim/background/teams-subscription-renewal.ts | 6 +++--- apps/sim/lib/webhooks/teams-subscriptions.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/background/teams-subscription-renewal.ts b/apps/sim/background/teams-subscription-renewal.ts index 8da4f117ddc..250e205bc3e 100644 --- a/apps/sim/background/teams-subscription-renewal.ts +++ b/apps/sim/background/teams-subscription-renewal.ts @@ -14,14 +14,14 @@ const logger = createLogger('TeamsSubscriptionRenewal') */ export const renewTeamsSubscriptions = task({ id: 'renew-teams-subscriptions', - // Run every 2 days to catch subscriptions that expire in ~3 days + // Job typically runs every 2 days; use a 48h renewal window so subs don't expire between runs run: async (_payload: Record) => { logger.info('Starting Teams subscription renewal job') try { // Find all Microsoft Teams webhooks with chat subscriptions that expire soon - // Check for subscriptions expiring within the next 24 hours - const expirationThreshold = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() + // Check for subscriptions expiring within the next 48 hours to align with a 2-day cadence + const expirationThreshold = new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString() const webhooksWithWorkflows = await db .select({ diff --git a/apps/sim/lib/webhooks/teams-subscriptions.ts b/apps/sim/lib/webhooks/teams-subscriptions.ts index d42ad859deb..40e1b7fa42c 100644 --- a/apps/sim/lib/webhooks/teams-subscriptions.ts +++ b/apps/sim/lib/webhooks/teams-subscriptions.ts @@ -44,7 +44,7 @@ export async function createMicrosoftTeamsChatSubscription( const data = await listResponse.json() const allSubscriptions = data.value || [] const chatSubscriptions = allSubscriptions.filter((sub: any) => - sub.resource?.includes('/chats/') + sub.resource?.includes('/chats/') && sub.clientState === webhookData.id ) for (const sub of chatSubscriptions) { From 66b41431e8ba4450556f41540245077059cb434e Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Sat, 11 Oct 2025 19:29:44 -0700 Subject: [PATCH 05/25] lint --- apps/sim/lib/webhooks/teams-subscriptions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/webhooks/teams-subscriptions.ts b/apps/sim/lib/webhooks/teams-subscriptions.ts index 40e1b7fa42c..d8260394520 100644 --- a/apps/sim/lib/webhooks/teams-subscriptions.ts +++ b/apps/sim/lib/webhooks/teams-subscriptions.ts @@ -43,8 +43,8 @@ export async function createMicrosoftTeamsChatSubscription( if (listResponse.ok) { const data = await listResponse.json() const allSubscriptions = data.value || [] - const chatSubscriptions = allSubscriptions.filter((sub: any) => - sub.resource?.includes('/chats/') && sub.clientState === webhookData.id + const chatSubscriptions = allSubscriptions.filter( + (sub: any) => sub.resource?.includes('/chats/') && sub.clientState === webhookData.id ) for (const sub of chatSubscriptions) { From a0018ce6e0f35f177033756279b6c7e4057982b2 Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Mon, 13 Oct 2025 13:14:01 -0700 Subject: [PATCH 06/25] cleaned up --- .../app/api/webhooks/trigger/[path]/route.ts | 8 --- .../components/trigger-config-section.tsx | 6 --- apps/sim/components/ui/tag-dropdown.tsx | 51 +++++-------------- 3 files changed, 12 insertions(+), 53 deletions(-) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 8a44029b03c..3a9a628b9da 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -89,14 +89,6 @@ export async function POST( const { webhook: foundWebhook, workflow: foundWorkflow } = findResult - // Log successful webhook lookup for debugging - logger.info(`[${requestId}] Found webhook for path: ${path}`, { - webhookId: foundWebhook.id, - provider: foundWebhook.provider, - workflowId: foundWorkflow.id, - blockId: foundWebhook.blockId, - }) - const authError = await verifyProviderAuth(foundWebhook, request, rawBody, requestId) if (authError) { return authError 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 eea9500911c..94f74dcb881 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 @@ -102,12 +102,6 @@ export function TriggerConfigSection({ ) case 'select': { - // Hide Scope selector for microsoftteams_chat_subscription to simplify UI (single-chat only) - const isHiddenScope = - (triggerDef.id === 'microsoftteams_chat_subscription' && - fieldId === 'subscriptionScope') || - false - if (isHiddenScope) return null return (