Feat(microsoftteams-file): new trigger + file upload (#1590)

* adding file logic and chat trigger

* working trig

* teams specific logic

* greptile comments

* lint

* cleaned up

* save modal changes

* created a interface for subscriptions

* removed trigger task

* reduce comments

* removed trig task

* removed comment

* simplified

* added tele logic back

* addressed some more comments

* simplified db call

* cleaned up utils

* helper telegram

* removed fallback

* removed scope

* simplify to use helpers

* fix credential resolution

* add logs

* fix

* fix attachment case

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
Adam Gough
2025-10-14 19:58:47 -07:00
committed by GitHub
parent b296323716
commit 6c9fce5da4
25 changed files with 1507 additions and 325 deletions

View File

@@ -0,0 +1,154 @@
import { db } from '@sim/db'
import { webhook as webhookTable, workflow as workflowTable } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('TeamsSubscriptionRenewal')
/**
* Cron endpoint to renew Microsoft Teams chat subscriptions before they expire
*
* Teams subscriptions expire after ~3 days and must be renewed.
* Configured in helm/sim/values.yaml under cronjobs.jobs.renewSubscriptions
*/
export async function GET(request: NextRequest) {
try {
const authError = verifyCronAuth(request, 'Teams subscription renewal')
if (authError) {
return authError
}
logger.info('Starting Teams subscription renewal job')
let totalRenewed = 0
let totalFailed = 0
let totalChecked = 0
// Get all active Microsoft Teams webhooks with their workflows
const webhooksWithWorkflows = await db
.select({
webhook: webhookTable,
workflow: workflowTable,
})
.from(webhookTable)
.innerJoin(workflowTable, eq(webhookTable.workflowId, workflowTable.id))
.where(and(eq(webhookTable.isActive, true), eq(webhookTable.provider, 'microsoftteams')))
logger.info(
`Found ${webhooksWithWorkflows.length} active Teams webhooks, checking for expiring subscriptions`
)
// Renewal threshold: 48 hours before expiration
const renewalThreshold = new Date(Date.now() + 48 * 60 * 60 * 1000)
for (const { webhook, workflow } of webhooksWithWorkflows) {
const config = (webhook.providerConfig as Record<string, any>) || {}
// Check if this is a Teams chat subscription that needs renewal
if (config.triggerId !== 'microsoftteams_chat_subscription') continue
const expirationStr = config.subscriptionExpiration as string | undefined
if (!expirationStr) continue
const expiresAt = new Date(expirationStr)
if (expiresAt > renewalThreshold) continue // Not expiring soon
totalChecked++
try {
logger.info(
`Renewing Teams subscription for webhook ${webhook.id} (expires: ${expiresAt.toISOString()})`
)
const credentialId = config.credentialId as string | undefined
const externalSubscriptionId = config.externalSubscriptionId as string | undefined
if (!credentialId || !externalSubscriptionId) {
logger.error(`Missing credentialId or externalSubscriptionId for webhook ${webhook.id}`)
totalFailed++
continue
}
// Get fresh access token
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
workflow.userId,
`renewal-${webhook.id}`
)
if (!accessToken) {
logger.error(`Failed to get access token for webhook ${webhook.id}`)
totalFailed++
continue
}
// Extend subscription to maximum lifetime (4230 minutes = ~3 days)
const maxLifetimeMinutes = 4230
const newExpirationDateTime = new Date(
Date.now() + maxLifetimeMinutes * 60 * 1000
).toISOString()
const res = await fetch(
`https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ expirationDateTime: newExpirationDateTime }),
}
)
if (!res.ok) {
const error = await res.json()
logger.error(
`Failed to renew Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}`,
{ status: res.status, error: error.error }
)
totalFailed++
continue
}
const payload = await res.json()
// Update webhook config with new expiration
const updatedConfig = {
...config,
subscriptionExpiration: payload.expirationDateTime,
}
await db
.update(webhookTable)
.set({ providerConfig: updatedConfig, updatedAt: new Date() })
.where(eq(webhookTable.id, webhook.id))
logger.info(
`Successfully renewed Teams subscription for webhook ${webhook.id}. New expiration: ${payload.expirationDateTime}`
)
totalRenewed++
} catch (error) {
logger.error(`Error renewing subscription for webhook ${webhook.id}:`, error)
totalFailed++
}
}
logger.info(
`Teams subscription renewal job completed. Checked: ${totalChecked}, Renewed: ${totalRenewed}, Failed: ${totalFailed}`
)
return NextResponse.json({
success: true,
checked: totalChecked,
renewed: totalRenewed,
failed: totalFailed,
total: webhooksWithWorkflows.length,
})
} catch (error) {
logger.error('Error in Teams subscription renewal job:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -408,10 +408,18 @@ export async function DELETE(
}
}
// If it's a Telegram webhook, delete it from Telegram first
// Delete Microsoft Teams subscription if applicable
if (foundWebhook.provider === 'microsoftteams') {
const { deleteTeamsSubscription } = await import('@/lib/webhooks/webhook-helpers')
logger.info(`[${requestId}] Deleting Teams subscription for webhook ${id}`)
await deleteTeamsSubscription(foundWebhook, webhookData.workflow, requestId)
// Don't fail webhook deletion if subscription cleanup fails
}
// Delete Telegram webhook if applicable
if (foundWebhook.provider === 'telegram') {
try {
const { botToken } = foundWebhook.providerConfig as { botToken: string }
const { botToken } = (foundWebhook.providerConfig || {}) as { botToken?: string }
if (!botToken) {
logger.warn(`[${requestId}] Missing botToken for Telegram webhook deletion.`, {
@@ -426,9 +434,7 @@ export async function DELETE(
const telegramApiUrl = `https://api.telegram.org/bot${botToken}/deleteWebhook`
const telegramResponse = await fetch(telegramApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
})
const responseBody = await telegramResponse.json()
@@ -436,9 +442,7 @@ export async function DELETE(
const errorMessage =
responseBody.description ||
`Failed to delete Telegram webhook. Status: ${telegramResponse.status}`
logger.error(`[${requestId}] ${errorMessage}`, {
response: responseBody,
})
logger.error(`[${requestId}] ${errorMessage}`, { response: responseBody })
return NextResponse.json(
{ error: 'Failed to delete webhook from Telegram', details: errorMessage },
{ status: 500 }
@@ -453,10 +457,7 @@ export async function DELETE(
stack: error.stack,
})
return NextResponse.json(
{
error: 'Failed to delete webhook from Telegram',
details: error.message,
},
{ error: 'Failed to delete webhook from Telegram', details: error.message },
{ status: 500 }
)
}

View File

@@ -136,10 +136,15 @@ export async function POST(request: NextRequest) {
let finalPath = path
const credentialBasedProviders = ['gmail', 'outlook']
const isCredentialBased = credentialBasedProviders.includes(provider)
// Treat Microsoft Teams chat subscription as credential-based for path generation purposes
const isMicrosoftTeamsChatSubscription =
provider === 'microsoftteams' &&
typeof providerConfig === 'object' &&
providerConfig?.triggerId === 'microsoftteams_chat_subscription'
// If path is missing
if (!finalPath || finalPath.trim() === '') {
if (isCredentialBased) {
if (isCredentialBased || isMicrosoftTeamsChatSubscription) {
// Try to reuse existing path for this workflow+block if one exists
if (blockId) {
const existingForBlock = await db
@@ -151,7 +156,7 @@ export async function POST(request: NextRequest) {
if (existingForBlock.length > 0) {
finalPath = existingForBlock[0].path
logger.info(
`[${requestId}] Reusing existing dummy path for ${provider} trigger: ${finalPath}`
`[${requestId}] Reusing existing generated path for ${provider} trigger: ${finalPath}`
)
}
}
@@ -159,7 +164,7 @@ export async function POST(request: NextRequest) {
// If still no path, generate a new dummy path (first-time save)
if (!finalPath || finalPath.trim() === '') {
finalPath = `${provider}-${crypto.randomUUID()}`
logger.info(`[${requestId}] Generated dummy path for ${provider} trigger: ${finalPath}`)
logger.info(`[${requestId}] Generated webhook path for ${provider} trigger: ${finalPath}`)
}
} else {
logger.warn(`[${requestId}] Missing path for webhook creation`, {
@@ -252,7 +257,12 @@ export async function POST(request: NextRequest) {
const finalProviderConfig = providerConfig
if (targetWebhookId) {
logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`)
logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`, {
webhookId: targetWebhookId,
provider,
hasCredentialId: !!(finalProviderConfig as any)?.credentialId,
credentialId: (finalProviderConfig as any)?.credentialId,
})
const updatedResult = await db
.update(webhook)
.set({
@@ -265,6 +275,10 @@ export async function POST(request: NextRequest) {
.where(eq(webhook.id, targetWebhookId))
.returning()
savedWebhook = updatedResult[0]
logger.info(`[${requestId}] Webhook updated successfully`, {
webhookId: savedWebhook.id,
savedProviderConfig: savedWebhook.providerConfig,
})
} else {
// Create a new webhook
const webhookId = nanoid()
@@ -306,33 +320,54 @@ export async function POST(request: NextRequest) {
}
// --- End Airtable specific logic ---
// --- Attempt to create webhook in Telegram if provider is 'telegram' ---
if (savedWebhook && provider === 'telegram') {
logger.info(
`[${requestId}] Telegram provider detected. Attempting to create webhook in Telegram.`
// --- Microsoft Teams subscription setup ---
if (savedWebhook && provider === 'microsoftteams') {
const { createTeamsSubscription } = await import('@/lib/webhooks/webhook-helpers')
logger.info(`[${requestId}] Creating Teams subscription for webhook ${savedWebhook.id}`)
const success = await createTeamsSubscription(
request,
savedWebhook,
workflowRecord,
requestId
)
try {
await createTelegramWebhookSubscription(request, userId, savedWebhook, requestId)
} catch (err) {
logger.error(`[${requestId}] Error creating Telegram webhook`, err)
if (!success) {
return NextResponse.json(
{
error: 'Failed to create webhook in Telegram',
details: err instanceof Error ? err.message : 'Unknown error',
error: 'Failed to create Teams subscription',
details: 'Could not create subscription with Microsoft Graph API',
},
{ status: 500 }
)
}
}
// --- End Telegram specific logic ---
// --- End Teams subscription setup ---
// --- Telegram webhook setup ---
if (savedWebhook && provider === 'telegram') {
const { createTelegramWebhook } = await import('@/lib/webhooks/webhook-helpers')
logger.info(`[${requestId}] Creating Telegram webhook for webhook ${savedWebhook.id}`)
const success = await createTelegramWebhook(request, savedWebhook, requestId)
if (!success) {
return NextResponse.json(
{
error: 'Failed to create Telegram webhook',
},
{ status: 500 }
)
}
}
// --- End Telegram webhook setup ---
// --- Gmail webhook setup ---
if (savedWebhook && provider === 'gmail') {
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
try {
const { configureGmailPolling } = await import('@/lib/webhooks/utils')
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
const success = await configureGmailPolling(workflowRecord.userId, savedWebhook, requestId)
const success = await configureGmailPolling(savedWebhook, requestId)
if (!success) {
logger.error(`[${requestId}] Failed to configure Gmail polling`)
@@ -366,12 +401,7 @@ export async function POST(request: NextRequest) {
)
try {
const { configureOutlookPolling } = await import('@/lib/webhooks/utils')
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
const success = await configureOutlookPolling(
workflowRecord.userId,
savedWebhook,
requestId
)
const success = await configureOutlookPolling(savedWebhook, requestId)
if (!success) {
logger.error(`[${requestId}] Failed to configure Outlook polling`)
@@ -525,95 +555,3 @@ async function createAirtableWebhookSubscription(
)
}
}
// Helper function to create the webhook subscription in Telegram
async function createTelegramWebhookSubscription(
request: NextRequest,
userId: string,
webhookData: any,
requestId: string
) {
try {
const { path, providerConfig } = webhookData
const { botToken } = providerConfig || {}
if (!botToken) {
logger.warn(`[${requestId}] Missing botToken for Telegram webhook creation.`, {
webhookId: webhookData.id,
})
return // Cannot proceed without botToken
}
if (!env.NEXT_PUBLIC_APP_URL) {
logger.error(
`[${requestId}] NEXT_PUBLIC_APP_URL not configured, cannot register Telegram webhook`
)
throw new Error('NEXT_PUBLIC_APP_URL must be configured for Telegram webhook registration')
}
const notificationUrl = `${env.NEXT_PUBLIC_APP_URL}/api/webhooks/trigger/${path}`
const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook`
const requestBody: any = {
url: notificationUrl,
allowed_updates: ['message'],
}
// Configure user-agent header to ensure Telegram can identify itself to our middleware
const telegramResponse = await fetch(telegramApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'TelegramBot/1.0',
},
body: JSON.stringify(requestBody),
})
const responseBody = await telegramResponse.json()
if (!telegramResponse.ok || !responseBody.ok) {
const errorMessage =
responseBody.description ||
`Failed to create Telegram webhook. Status: ${telegramResponse.status}`
logger.error(`[${requestId}] ${errorMessage}`, {
response: responseBody,
})
throw new Error(errorMessage)
}
logger.info(
`[${requestId}] Successfully created Telegram webhook for webhook ${webhookData.id}.`
)
// Get webhook info to ensure it's properly set up
try {
const webhookInfoUrl = `https://api.telegram.org/bot${botToken}/getWebhookInfo`
const webhookInfo = await fetch(webhookInfoUrl, {
headers: {
'User-Agent': 'TelegramBot/1.0',
},
})
const webhookInfoJson = await webhookInfo.json()
if (webhookInfoJson.ok) {
logger.info(`[${requestId}] Telegram webhook info:`, {
url: webhookInfoJson.result.url,
has_custom_certificate: webhookInfoJson.result.has_custom_certificate,
pending_update_count: webhookInfoJson.result.pending_update_count,
webhookId: webhookData.id,
})
}
} catch (error) {
// Non-critical error, just log
logger.warn(`[${requestId}] Failed to get webhook info`, error)
}
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Telegram webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
}
}

View File

@@ -18,6 +18,31 @@ export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export const maxDuration = 60
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string }> }) {
const requestId = generateRequestId()
const { path } = await params
// Handle Microsoft Graph subscription validation
const url = new URL(request.url)
const validationToken = url.searchParams.get('validationToken')
if (validationToken) {
logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`)
return new NextResponse(validationToken, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
})
}
// Handle other GET-based verifications if needed
const challengeResponse = await handleProviderChallenges({}, request, requestId, path)
if (challengeResponse) {
return challengeResponse
}
return new NextResponse('Method not allowed', { status: 405 })
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string }> }
@@ -25,6 +50,21 @@ export async function POST(
const requestId = generateRequestId()
const { path } = await params
// Handle Microsoft Graph subscription validation (some environments send POST with validationToken)
try {
const url = new URL(request.url)
const validationToken = url.searchParams.get('validationToken')
if (validationToken) {
logger.info(`[${requestId}] Microsoft Graph subscription validation (POST) for path: ${path}`)
return new NextResponse(validationToken, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
})
}
} catch {
// ignore URL parsing errors; proceed to normal handling
}
const parseResult = await parseWebhookBody(request, requestId)
// Check if parseWebhookBody returned an error response
@@ -43,6 +83,7 @@ export async function POST(
if (!findResult) {
logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`)
return new NextResponse('Not Found', { status: 404 })
}

View File

@@ -26,6 +26,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { cn } from '@/lib/utils'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import type { TriggerConfig } from '@/triggers/types'
import { CredentialSelector } from '../../credential-selector/credential-selector'
interface TriggerConfigSectionProps {
blockId: string
@@ -79,7 +80,7 @@ export function TriggerConfigSection({
</div>
)
case 'select':
case 'select': {
return (
<div className='space-y-2'>
<Label htmlFor={fieldId}>
@@ -103,6 +104,7 @@ export function TriggerConfigSection({
)}
</div>
)
}
case 'multiselect': {
const selectedValues = Array.isArray(value) ? value : []
@@ -222,6 +224,30 @@ export function TriggerConfigSection({
</div>
)
case 'credential':
return (
<div className='space-y-2'>
<Label htmlFor={fieldId}>
{fieldDef.label}
{fieldDef.required && <span className='ml-1 text-red-500'>*</span>}
</Label>
<CredentialSelector
blockId={blockId}
subBlock={{
id: fieldId,
type: 'oauth-input' as const,
placeholder: fieldDef.placeholder || `Select ${fieldDef.provider} credential`,
provider: fieldDef.provider as any,
requiredScopes: fieldDef.requiredScopes || [],
}}
previewValue={value}
/>
{fieldDef.description && (
<p className='text-muted-foreground text-sm'>{fieldDef.description}</p>
)}
</div>
)
default: // string
return (
<div className='mb-4 space-y-1'>

View File

@@ -11,10 +11,18 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getTrigger } from '@/triggers'
import type { TriggerConfig } from '@/triggers/types'
import { CredentialSelector } from '../../credential-selector/credential-selector'
import { TriggerConfigSection } from './trigger-config-section'
@@ -32,19 +40,30 @@ interface TriggerModalProps {
onDelete?: () => Promise<boolean>
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<Record<string, any>>(initialConfig)
const [isSaving, setIsSaving] = useState(false)
@@ -115,6 +134,8 @@ export function TriggerModal({
// Only update if there are actually default values to apply
if (Object.keys(defaultConfig).length > 0) {
setConfig(mergedConfig)
// Reset dirty snapshot when defaults are applied to avoid false-disabled Save
initialConfigRef.current = mergedConfig
}
}, [triggerDef.configFields, initialConfig])
@@ -398,12 +419,13 @@ export function TriggerModal({
return false
}
// Check required fields
// Check required fields (skip credential fields - they're stored separately in subblock store)
for (const [fieldId, fieldDef] of Object.entries(triggerDef.configFields)) {
if (fieldDef.required && !config[fieldId]) {
if (fieldDef.required && fieldDef.type !== 'credential' && !config[fieldId]) {
return false
}
}
return true
}
@@ -445,6 +467,49 @@ export function TriggerModal({
<div className='flex-1 overflow-y-auto px-6 py-6'>
<div className='space-y-6'>
{/* Trigger Type Selector - only show if multiple triggers available */}
{availableTriggers && availableTriggers.length > 1 && onTriggerChange && (
<div className='space-y-2 rounded-md border border-border bg-card p-4 shadow-sm'>
<Label htmlFor='trigger-type-select' className='font-medium text-sm'>
Trigger Type
</Label>
<p className='text-muted-foreground text-sm'>
Choose how this workflow should be triggered
</p>
<Select
value={selectedTriggerId || availableTriggers[0]}
onValueChange={(value) => {
if (onTriggerChange && value !== selectedTriggerId) {
onTriggerChange(value)
}
}}
disabled={!!triggerId}
>
<SelectTrigger id='trigger-type-select' className='h-10'>
<SelectValue placeholder='Select trigger type' />
</SelectTrigger>
<SelectContent>
{availableTriggers.map((triggerId) => {
const trigger = getTrigger(triggerId)
return (
<SelectItem key={triggerId} value={triggerId}>
<div className='flex items-center gap-2'>
{trigger?.icon && <trigger.icon className='h-4 w-4' />}
<span>{trigger?.name || triggerId}</span>
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
{triggerId && (
<p className='text-muted-foreground text-xs'>
Delete the trigger to change the trigger type
</p>
)}
</div>
)}
{triggerDef.requiresCredentials && triggerDef.credentialProvider && (
<div className='space-y-2 rounded-md border border-border bg-card p-4 shadow-sm'>
<h3 className='font-medium text-sm'>Credentials</h3>

View File

@@ -153,7 +153,11 @@ export function TriggerConfig({
setStoredTriggerId(effectiveTriggerId)
// Map trigger ID to webhook provider name
const webhookProvider = effectiveTriggerId.replace(/_webhook|_poller$/, '') // e.g., 'slack_webhook' -> 'slack', 'gmail_poller' -> 'gmail'
const webhookProvider = effectiveTriggerId
.replace(/_chat_subscription$/, '')
.replace(/_webhook$/, '')
.replace(/_poller$/, '')
.replace(/_subscription$/, '') // e.g., 'slack_webhook' -> 'slack', 'gmail_poller' -> 'gmail', 'microsoftteams_chat_subscription' -> 'microsoftteams'
// Include selected credential from the modal (if any)
const selectedCredentialId =
@@ -176,6 +180,7 @@ export function TriggerConfig({
providerConfig: {
...config,
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
triggerId: effectiveTriggerId, // Include trigger ID to determine subscription vs polling
},
}),
})
@@ -206,6 +211,20 @@ export function TriggerConfig({
}
// Save as webhook using existing webhook API (for webhook-based triggers)
const webhookConfig = {
...config,
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
triggerId: effectiveTriggerId,
}
logger.info('Saving webhook-based trigger', {
triggerId: effectiveTriggerId,
provider: webhookProvider,
hasCredential: !!selectedCredentialId,
credentialId: selectedCredentialId,
webhookConfig,
})
const response = await fetch('/api/webhooks', {
method: 'POST',
headers: {
@@ -216,10 +235,7 @@ export function TriggerConfig({
blockId,
path,
provider: webhookProvider,
providerConfig: {
...config,
...(selectedCredentialId ? { credentialId: selectedCredentialId } : {}),
},
providerConfig: webhookConfig,
}),
})
@@ -236,14 +252,6 @@ export function TriggerConfig({
const savedWebhookId = data.webhook.id
setTriggerId(savedWebhookId)
logger.info('Trigger saved successfully as webhook', {
webhookId: savedWebhookId,
triggerDefId: effectiveTriggerId,
provider: webhookProvider,
path,
blockId,
})
// Update the actual trigger after saving
setActualTriggerId(webhookProvider)
@@ -409,6 +417,13 @@ export function TriggerConfig({
onDelete={handleDeleteTrigger}
triggerId={triggerId || undefined}
blockId={blockId}
availableTriggers={availableTriggers}
selectedTriggerId={selectedTriggerId}
onTriggerChange={(newTriggerId) => {
setStoredTriggerId(newTriggerId)
// Clear config when changing trigger type
setTriggerConfig({})
}}
/>
)}
</div>

View File

@@ -92,6 +92,7 @@ export type WebhookExecutionPayload = {
blockId?: string
testMode?: boolean
executionTarget?: 'deployed' | 'live'
credentialId?: string
}
export async function executeWebhookJob(payload: WebhookExecutionPayload) {
@@ -340,10 +341,22 @@ async function executeWebhookJobInternal(
}
// Format input for standard webhooks
const mockWebhook = {
provider: payload.provider,
blockId: payload.blockId,
}
// Load the actual webhook to get providerConfig (needed for Teams credentialId)
const webhookRows = await db
.select()
.from(webhook)
.where(eq(webhook.id, payload.webhookId))
.limit(1)
const actualWebhook =
webhookRows.length > 0
? webhookRows[0]
: {
provider: payload.provider,
blockId: payload.blockId,
providerConfig: {},
}
const mockWorkflow = {
id: payload.workflowId,
userId: payload.userId,
@@ -352,7 +365,7 @@ async function executeWebhookJobInternal(
headers: new Map(Object.entries(payload.headers)),
} as any
const input = formatWebhookInput(mockWebhook, mockWorkflow, payload.body, mockRequest)
const input = await formatWebhookInput(actualWebhook, mockWorkflow, payload.body, mockRequest)
if (!input && payload.provider === 'whatsapp') {
logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`)

View File

@@ -51,6 +51,8 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
'Group.ReadWrite.All',
'Team.ReadBasic.All',
'offline_access',
'Files.Read',
'Sites.Read.All',
],
placeholder: 'Select Microsoft account',
required: true,
@@ -142,7 +144,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
type: 'trigger-config',
layout: 'full',
triggerProvider: 'microsoftteams',
availableTriggers: ['microsoftteams_webhook'],
availableTriggers: ['microsoftteams_webhook', 'microsoftteams_chat_subscription'],
},
],
tools: {

View File

@@ -14,7 +14,6 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { getTool } from '@/tools/utils'
import { getTrigger, getTriggersByProvider } from '@/triggers'
interface BlockTagGroup {
blockName: string
@@ -126,26 +125,10 @@ const getOutputTypeForPath = (
mergedSubBlocksOverride?: Record<string, any>
): string => {
if (block?.triggerMode && blockConfig?.triggers?.enabled) {
const triggerId = blockConfig?.triggers?.available?.[0]
const firstTrigger = triggerId ? getTrigger(triggerId) : getTriggersByProvider(block.type)[0]
if (firstTrigger?.outputs) {
const pathParts = outputPath.split('.')
let currentObj: any = firstTrigger.outputs
for (const part of pathParts) {
if (currentObj && typeof currentObj === 'object') {
currentObj = currentObj[part]
} else {
break
}
}
if (currentObj && typeof currentObj === 'object' && 'type' in currentObj && currentObj.type) {
return currentObj.type
}
}
} else if (block?.type === 'starter') {
// When in trigger mode, derive types from the selected trigger's outputs
return getBlockOutputType(block.type, outputPath, mergedSubBlocksOverride, true)
}
if (block?.type === 'starter') {
// Handle starter block specific outputs
const startWorkflowValue =
mergedSubBlocksOverride?.startWorkflow?.value ?? getSubBlockValue(blockId, 'startWorkflow')
@@ -487,15 +470,10 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
blockTags = []
}
} else if (sourceBlock?.triggerMode && blockConfig.triggers?.enabled) {
const triggerId = blockConfig?.triggers?.available?.[0]
const firstTrigger = triggerId
? getTrigger(triggerId)
: getTriggersByProvider(sourceBlock.type)[0]
if (firstTrigger?.outputs) {
// Use trigger outputs instead of block outputs
const outputPaths = generateOutputPaths(firstTrigger.outputs)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
// Use selected trigger from subblocks to determine outputs
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks, true)
if (dynamicOutputs.length > 0) {
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
} else {
const outputPaths = generateOutputPaths(blockConfig.outputs || {})
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
@@ -759,15 +737,10 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
} else {
const blockState = blocks[accessibleBlockId]
if (blockState?.triggerMode && blockConfig.triggers?.enabled) {
const triggerId = blockConfig?.triggers?.available?.[0]
const firstTrigger = triggerId
? getTrigger(triggerId)
: getTriggersByProvider(blockState.type)[0]
if (firstTrigger?.outputs) {
// Use trigger outputs instead of block outputs
const outputPaths = generateOutputPaths(firstTrigger.outputs)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
// Use selected trigger (from subblocks) rather than defaulting to the first one
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks, true)
if (dynamicOutputs.length > 0) {
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
} else {
const outputPaths = generateOutputPaths(blockConfig.outputs || {})
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)

View File

@@ -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}`

View File

@@ -74,14 +74,25 @@ export class WebhookAttachmentProcessor {
requestId: string
}
): Promise<UserFile> {
// Convert data to Buffer (handle both raw and serialized formats)
let buffer: Buffer
const data = attachment.data as any
if (!data || typeof data !== 'object' || data.type !== 'Buffer' || !Array.isArray(data.data)) {
throw new Error(`Attachment '${attachment.name}' data must be a serialized Buffer`)
if (Buffer.isBuffer(data)) {
// Raw Buffer (e.g., Teams in-memory processing)
buffer = data
} else if (
data &&
typeof data === 'object' &&
data.type === 'Buffer' &&
Array.isArray(data.data)
) {
// Serialized Buffer (e.g., Gmail/Outlook after JSON roundtrip)
buffer = Buffer.from(data.data)
} else {
throw new Error(`Attachment '${attachment.name}' data must be a Buffer or serialized Buffer`)
}
const buffer = Buffer.from(data.data)
if (buffer.length === 0) {
throw new Error(`Attachment '${attachment.name}' has zero bytes`)
}

View File

@@ -382,17 +382,40 @@ export async function queueWebhookExecution(
return NextResponse.json({ message: 'Pinned API key required' }, { status: 200 })
}
const headers = Object.fromEntries(request.headers.entries())
// For Microsoft Teams Graph notifications, extract unique identifiers for idempotency
if (
foundWebhook.provider === 'microsoftteams' &&
body?.value &&
Array.isArray(body.value) &&
body.value.length > 0
) {
const notification = body.value[0]
const subscriptionId = notification.subscriptionId
const messageId = notification.resourceData?.id
if (subscriptionId && messageId) {
headers['x-teams-notification-id'] = `${subscriptionId}:${messageId}`
}
}
// Extract credentialId from webhook config for credential-based webhooks
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const credentialId = providerConfig.credentialId as string | undefined
const payload = {
webhookId: foundWebhook.id,
workflowId: foundWorkflow.id,
userId: actorUserId,
provider: foundWebhook.provider,
body,
headers: Object.fromEntries(request.headers.entries()),
headers,
path: options.path || foundWebhook.path,
blockId: foundWebhook.blockId,
testMode: options.testMode,
executionTarget: options.executionTarget,
...(credentialId ? { credentialId } : {}),
}
const useTrigger = isTruthy(env.TRIGGER_DEV_ENABLED)
@@ -416,6 +439,15 @@ export async function queueWebhookExecution(
}
if (foundWebhook.provider === 'microsoftteams') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const triggerId = providerConfig.triggerId as string | undefined
// Chat subscription (Graph API) returns 202
if (triggerId === 'microsoftteams_chat_subscription') {
return new NextResponse(null, { status: 202 })
}
// Channel webhook (outgoing webhook) returns message response
return NextResponse.json({
type: 'message',
text: 'Sim',

View File

@@ -3,7 +3,7 @@ import { account, webhook } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebhookUtils')
@@ -139,15 +139,401 @@ export async function validateSlackSignature(
}
}
/**
* Format Microsoft Teams Graph change notification
*/
async function formatTeamsGraphNotification(
body: any,
foundWebhook: any,
foundWorkflow: any,
request: NextRequest
): Promise<any> {
const notification = body.value[0]
const changeType = notification.changeType || 'created'
const resource = notification.resource || ''
const subscriptionId = notification.subscriptionId || ''
// Extract chatId and messageId from resource path
let chatId: string | null = null
let messageId: string | null = null
const fullMatch = resource.match(/chats\/([^/]+)\/messages\/([^/]+)/)
if (fullMatch) {
chatId = fullMatch[1]
messageId = fullMatch[2]
}
if (!chatId || !messageId) {
const quotedMatch = resource.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/)
if (quotedMatch) {
chatId = quotedMatch[1]
messageId = quotedMatch[2]
}
}
if (!chatId || !messageId) {
const collectionMatch = resource.match(/chats\/([^/]+)\/messages$/)
const rdId = body?.value?.[0]?.resourceData?.id
if (collectionMatch && rdId) {
chatId = collectionMatch[1]
messageId = rdId
}
}
if ((!chatId || !messageId) && body?.value?.[0]?.resourceData?.['@odata.id']) {
const odataId = String(body.value[0].resourceData['@odata.id'])
const odataMatch = odataId.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/)
if (odataMatch) {
chatId = odataMatch[1]
messageId = odataMatch[2]
}
}
if (!chatId || !messageId) {
logger.warn('Could not resolve chatId/messageId from Teams notification', {
resource,
hasResourceDataId: Boolean(body?.value?.[0]?.resourceData?.id),
valueLength: Array.isArray(body?.value) ? body.value.length : 0,
keys: Object.keys(body || {}),
})
return {
input: 'Teams notification received',
webhook: {
data: {
provider: 'microsoftteams',
path: foundWebhook?.path || '',
providerConfig: foundWebhook?.providerConfig || {},
payload: body,
headers: Object.fromEntries(request.headers.entries()),
method: request.method,
},
},
workflowId: foundWorkflow.id,
}
}
const resolvedChatId = chatId as string
const resolvedMessageId = messageId as string
const providerConfig = (foundWebhook?.providerConfig as Record<string, any>) || {}
const credentialId = providerConfig.credentialId
const includeAttachments = providerConfig.includeAttachments !== false
let message: any = null
const rawAttachments: Array<{ name: string; data: Buffer; contentType: string; size: number }> =
[]
let accessToken: string | null = null
// Teams chat subscriptions require credentials
if (!credentialId) {
logger.error('Missing credentialId for Teams chat subscription', {
chatId: resolvedChatId,
messageId: resolvedMessageId,
webhookId: foundWebhook?.id,
blockId: foundWebhook?.blockId,
providerConfig,
})
} else {
try {
// Get userId from credential
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (rows.length === 0) {
logger.error('Teams credential not found', { credentialId, chatId: resolvedChatId })
// Continue without message data
} else {
const effectiveUserId = rows[0].userId
accessToken = await refreshAccessTokenIfNeeded(
credentialId,
effectiveUserId,
'teams-graph-notification'
)
}
if (accessToken) {
const msgUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(resolvedChatId)}/messages/${encodeURIComponent(resolvedMessageId)}`
const res = await fetch(msgUrl, { headers: { Authorization: `Bearer ${accessToken}` } })
if (res.ok) {
message = await res.json()
if (includeAttachments && message?.attachments?.length > 0) {
const attachments = Array.isArray(message?.attachments) ? message.attachments : []
for (const att of attachments) {
try {
const contentUrl =
typeof att?.contentUrl === 'string' ? (att.contentUrl as string) : undefined
const contentTypeHint =
typeof att?.contentType === 'string' ? (att.contentType as string) : undefined
let attachmentName = (att?.name as string) || 'teams-attachment'
if (!contentUrl) continue
let buffer: Buffer | null = null
let mimeType = 'application/octet-stream'
if (contentUrl.includes('sharepoint.com') || contentUrl.includes('onedrive')) {
try {
const directRes = await fetch(contentUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
redirect: 'follow',
})
if (directRes.ok) {
const arrayBuffer = await directRes.arrayBuffer()
buffer = Buffer.from(arrayBuffer)
mimeType =
directRes.headers.get('content-type') ||
contentTypeHint ||
'application/octet-stream'
} else {
const encodedUrl = Buffer.from(contentUrl)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
const graphUrl = `https://graph.microsoft.com/v1.0/shares/u!${encodedUrl}/driveItem/content`
const graphRes = await fetch(graphUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
redirect: 'follow',
})
if (graphRes.ok) {
const arrayBuffer = await graphRes.arrayBuffer()
buffer = Buffer.from(arrayBuffer)
mimeType =
graphRes.headers.get('content-type') ||
contentTypeHint ||
'application/octet-stream'
} else {
continue
}
}
} catch {
continue
}
} else if (
contentUrl.includes('1drv.ms') ||
contentUrl.includes('onedrive.live.com') ||
contentUrl.includes('onedrive.com') ||
contentUrl.includes('my.microsoftpersonalcontent.com')
) {
try {
let shareToken: string | null = null
if (contentUrl.includes('1drv.ms')) {
const urlParts = contentUrl.split('/').pop()
if (urlParts) shareToken = urlParts
} else if (contentUrl.includes('resid=')) {
const urlParams = new URL(contentUrl).searchParams
const resId = urlParams.get('resid')
if (resId) shareToken = resId
}
if (!shareToken) {
const base64Url = Buffer.from(contentUrl, 'utf-8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
shareToken = `u!${base64Url}`
} else if (!shareToken.startsWith('u!')) {
const base64Url = Buffer.from(shareToken, 'utf-8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
shareToken = `u!${base64Url}`
}
const metadataUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem`
const metadataRes = await fetch(metadataUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!metadataRes.ok) {
const directUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem/content`
const directRes = await fetch(directUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
redirect: 'follow',
})
if (directRes.ok) {
const arrayBuffer = await directRes.arrayBuffer()
buffer = Buffer.from(arrayBuffer)
mimeType =
directRes.headers.get('content-type') ||
contentTypeHint ||
'application/octet-stream'
} else {
continue
}
} else {
const metadata = await metadataRes.json()
const downloadUrl = metadata['@microsoft.graph.downloadUrl']
if (downloadUrl) {
const downloadRes = await fetch(downloadUrl)
if (downloadRes.ok) {
const arrayBuffer = await downloadRes.arrayBuffer()
buffer = Buffer.from(arrayBuffer)
mimeType =
downloadRes.headers.get('content-type') ||
metadata.file?.mimeType ||
contentTypeHint ||
'application/octet-stream'
if (metadata.name && metadata.name !== attachmentName) {
attachmentName = metadata.name
}
} else {
continue
}
} else {
continue
}
}
} catch {
continue
}
} else {
try {
const ares = await fetch(contentUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (ares.ok) {
const arrayBuffer = await ares.arrayBuffer()
buffer = Buffer.from(arrayBuffer)
mimeType =
ares.headers.get('content-type') ||
contentTypeHint ||
'application/octet-stream'
}
} catch {
continue
}
}
if (!buffer) continue
const size = buffer.length
// Store raw attachment (will be uploaded to execution storage later)
rawAttachments.push({
name: attachmentName,
data: buffer,
contentType: mimeType,
size,
})
} catch {}
}
}
}
}
} catch (error) {
logger.error('Failed to fetch Teams message', {
error,
chatId: resolvedChatId,
messageId: resolvedMessageId,
})
}
}
// If no message was fetched, return minimal data
if (!message) {
logger.warn('No message data available for Teams notification', {
chatId: resolvedChatId,
messageId: resolvedMessageId,
hasCredential: !!credentialId,
})
return {
input: '',
message_id: messageId,
chat_id: chatId,
from_name: 'Unknown',
text: '',
created_at: notification.resourceData?.createdDateTime || '',
change_type: changeType,
subscription_id: subscriptionId,
attachments: [],
microsoftteams: {
message: { id: messageId, text: '', timestamp: '', chatId, raw: null },
from: { id: '', name: 'Unknown', aadObjectId: '' },
notification: { changeType, subscriptionId, resource },
},
webhook: {
data: {
provider: 'microsoftteams',
path: foundWebhook?.path || '',
providerConfig: foundWebhook?.providerConfig || {},
payload: body,
headers: Object.fromEntries(request.headers.entries()),
method: request.method,
},
},
workflowId: foundWorkflow.id,
}
}
// Extract data from message - we know it exists now
// body.content is the HTML/text content, summary is a plain text preview (max 280 chars)
const messageText = message.body?.content || ''
const from = message.from?.user || {}
const createdAt = message.createdDateTime || ''
return {
input: messageText,
message_id: messageId,
chat_id: chatId,
from_name: from.displayName || 'Unknown',
text: messageText,
created_at: createdAt,
change_type: changeType,
subscription_id: subscriptionId,
attachments: rawAttachments,
microsoftteams: {
message: {
id: messageId,
text: messageText,
timestamp: createdAt,
chatId,
raw: message,
},
from: {
id: from.id,
name: from.displayName,
aadObjectId: from.aadObjectId,
},
notification: {
changeType,
subscriptionId,
resource,
},
},
webhook: {
data: {
provider: 'microsoftteams',
path: foundWebhook?.path || '',
providerConfig: foundWebhook?.providerConfig || {},
payload: body,
headers: Object.fromEntries(request.headers.entries()),
method: request.method,
},
},
workflowId: foundWorkflow.id,
}
}
/**
* Format webhook input based on provider
*/
export function formatWebhookInput(
export async function formatWebhookInput(
foundWebhook: any,
foundWorkflow: any,
body: any,
request: NextRequest
): any {
): Promise<any> {
if (foundWebhook.provider === 'whatsapp') {
const data = body?.entry?.[0]?.changes?.[0]?.value
const messages = data?.messages || []
@@ -359,7 +745,13 @@ export function formatWebhookInput(
}
if (foundWebhook.provider === 'microsoftteams') {
// Check if this is a Microsoft Graph change notification
if (body?.value && Array.isArray(body.value) && body.value.length > 0) {
return await formatTeamsGraphNotification(body, foundWebhook, foundWorkflow, request)
}
// Microsoft Teams outgoing webhook - Teams sending data to us
//
const messageText = body?.text || ''
const messageId = body?.id || ''
const timestamp = body?.timestamp || body?.localTimestamp || ''
@@ -1308,54 +1700,35 @@ export interface AirtableChange {
/**
* Configure Gmail polling for a webhook
*/
export async function configureGmailPolling(
userId: string,
webhookData: any,
requestId: string
): Promise<boolean> {
export async function configureGmailPolling(webhookData: any, requestId: string): Promise<boolean> {
const logger = createLogger('GmailWebhookSetup')
logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`)
try {
const providerConfig = (webhookData.providerConfig as Record<string, any>) || {}
const credentialId: string | undefined = providerConfig.credentialId
let effectiveUserId: string | null = null
let accessToken: string | null = null
if (!credentialId) {
logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`)
return false
}
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (rows.length === 0) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}`
)
return false
}
effectiveUserId = rows[0].userId
accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
if (!accessToken) {
logger.error(
`[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}`
)
return false
}
} else {
// Backward-compat: fall back to workflow owner
if (!userId) {
logger.error(
`[${requestId}] Missing credentialId and userId for Gmail webhook ${webhookData.id}`
)
return false
}
effectiveUserId = userId
accessToken = await getOAuthToken(effectiveUserId, 'google-email')
if (!accessToken) {
logger.error(
`[${requestId}] Failed to obtain Gmail token for user ${effectiveUserId} (fallback)`
)
return false
}
// Get userId from credential
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (rows.length === 0) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}`
)
return false
}
const effectiveUserId = rows[0].userId
const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
if (!accessToken) {
logger.error(
`[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}`
)
return false
}
const maxEmailsPerPoll =
@@ -1408,54 +1781,37 @@ export async function configureGmailPolling(
* Configure Outlook polling for a webhook
*/
export async function configureOutlookPolling(
userId: string,
webhookData: any,
requestId: string
): Promise<boolean> {
const logger = createLogger('OutlookWebhookSetup')
logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`)
logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`)
try {
const providerConfig = (webhookData.providerConfig as Record<string, any>) || {}
const credentialId: string | undefined = providerConfig.credentialId
let effectiveUserId: string | null = null
let accessToken: string | null = null
if (!credentialId) {
logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`)
return false
}
if (credentialId) {
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (rows.length === 0) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}`
)
return false
}
effectiveUserId = rows[0].userId
accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
if (!accessToken) {
logger.error(
`[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}`
)
return false
}
} else {
// Backward-compat: fall back to workflow owner
if (!userId) {
logger.error(
`[${requestId}] Missing credentialId and userId for Outlook webhook ${webhookData.id}`
)
return false
}
effectiveUserId = userId
accessToken = await getOAuthToken(effectiveUserId, 'outlook')
if (!accessToken) {
logger.error(
`[${requestId}] Failed to obtain Outlook token for user ${effectiveUserId} (fallback)`
)
return false
}
// Get userId from credential
const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (rows.length === 0) {
logger.error(
`[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}`
)
return false
}
const effectiveUserId = rows[0].userId
const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId)
if (!accessToken) {
logger.error(
`[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}`
)
return false
}
const providerCfg = (webhookData.providerConfig as Record<string, any>) || {}

View File

@@ -0,0 +1,305 @@
import { db } from '@sim/db'
import { webhook as webhookTable } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const teamsLogger = createLogger('TeamsSubscription')
const telegramLogger = createLogger('TelegramWebhook')
/**
* Create a Microsoft Teams chat subscription
* Returns true if successful, false otherwise
*/
export async function createTeamsSubscription(
request: NextRequest,
webhook: any,
workflow: any,
requestId: string
): Promise<boolean> {
try {
const config = (webhook.providerConfig as Record<string, any>) || {}
// Only handle Teams chat subscriptions
if (config.triggerId !== 'microsoftteams_chat_subscription') {
return true // Not a Teams subscription, no action needed
}
const credentialId = config.credentialId as string | undefined
const chatId = config.chatId as string | undefined
if (!credentialId) {
teamsLogger.warn(
`[${requestId}] Missing credentialId for Teams chat subscription ${webhook.id}`
)
return false
}
if (!chatId) {
teamsLogger.warn(`[${requestId}] Missing chatId for Teams chat subscription ${webhook.id}`)
return false
}
// Get access token
const accessToken = await refreshAccessTokenIfNeeded(credentialId, workflow.userId, requestId)
if (!accessToken) {
teamsLogger.error(
`[${requestId}] Failed to get access token for Teams subscription ${webhook.id}`
)
return false
}
// Check if subscription already exists
const existingSubscriptionId = config.externalSubscriptionId as string | undefined
if (existingSubscriptionId) {
try {
const checkRes = await fetch(
`https://graph.microsoft.com/v1.0/subscriptions/${existingSubscriptionId}`,
{ method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } }
)
if (checkRes.ok) {
teamsLogger.info(
`[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}`
)
return true
}
} catch {
teamsLogger.debug(`[${requestId}] Existing subscription check failed, will create new one`)
}
}
// Build notification URL
const requestOrigin = new URL(request.url).origin
const effectiveOrigin = requestOrigin.includes('localhost')
? env.NEXT_PUBLIC_APP_URL || requestOrigin
: requestOrigin
const notificationUrl = `${effectiveOrigin}/api/webhooks/trigger/${webhook.path}`
// Subscribe to the specified chat
const resource = `/chats/${chatId}/messages`
// Create subscription with max lifetime (4230 minutes = ~3 days)
const maxLifetimeMinutes = 4230
const expirationDateTime = new Date(Date.now() + maxLifetimeMinutes * 60 * 1000).toISOString()
const body = {
changeType: 'created,updated',
notificationUrl,
lifecycleNotificationUrl: notificationUrl,
resource,
includeResourceData: false,
expirationDateTime,
clientState: webhook.id,
}
const res = await fetch('https://graph.microsoft.com/v1.0/subscriptions', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
const payload = await res.json()
if (!res.ok) {
teamsLogger.error(
`[${requestId}] Failed to create Teams subscription for webhook ${webhook.id}`,
{
status: res.status,
error: payload.error,
}
)
return false
}
// Update webhook config with subscription details
const updatedConfig = {
...config,
externalSubscriptionId: payload.id,
subscriptionExpiration: payload.expirationDateTime,
}
await db
.update(webhookTable)
.set({ providerConfig: updatedConfig, updatedAt: new Date() })
.where(eq(webhookTable.id, webhook.id))
teamsLogger.info(
`[${requestId}] Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}`
)
return true
} catch (error) {
teamsLogger.error(
`[${requestId}] Error creating Teams subscription for webhook ${webhook.id}`,
error
)
return false
}
}
/**
* Delete a Microsoft Teams chat subscription
* Always returns true (don't fail webhook deletion if cleanup fails)
*/
export async function deleteTeamsSubscription(
webhook: any,
workflow: any,
requestId: string
): Promise<void> {
try {
const config = (webhook.providerConfig as Record<string, any>) || {}
// Only handle Teams chat subscriptions
if (config.triggerId !== 'microsoftteams_chat_subscription') {
return // Not a Teams subscription, no action needed
}
const externalSubscriptionId = config.externalSubscriptionId as string | undefined
const credentialId = config.credentialId as string | undefined
if (!externalSubscriptionId || !credentialId) {
teamsLogger.info(
`[${requestId}] No external subscription to delete for webhook ${webhook.id}`
)
return
}
// Get access token
const accessToken = await refreshAccessTokenIfNeeded(credentialId, workflow.userId, requestId)
if (!accessToken) {
teamsLogger.warn(
`[${requestId}] Could not get access token to delete Teams subscription for webhook ${webhook.id}`
)
return // Don't fail deletion
}
const res = await fetch(
`https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
}
)
if (res.ok || res.status === 404) {
teamsLogger.info(
`[${requestId}] Successfully deleted Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}`
)
} else {
const errorBody = await res.text()
teamsLogger.warn(
`[${requestId}] Failed to delete Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}. Status: ${res.status}`
)
}
} catch (error) {
teamsLogger.error(
`[${requestId}] Error deleting Teams subscription for webhook ${webhook.id}`,
error
)
// Don't fail webhook deletion
}
}
/**
* Create a Telegram bot webhook
* Returns true if successful, false otherwise
*/
export async function createTelegramWebhook(
request: NextRequest,
webhook: any,
requestId: string
): Promise<boolean> {
try {
const config = (webhook.providerConfig as Record<string, any>) || {}
const botToken = config.botToken as string | undefined
if (!botToken) {
telegramLogger.warn(`[${requestId}] Missing botToken for Telegram webhook ${webhook.id}`)
return false
}
if (!env.NEXT_PUBLIC_APP_URL) {
telegramLogger.error(
`[${requestId}] NEXT_PUBLIC_APP_URL not configured, cannot register Telegram webhook`
)
return false
}
const notificationUrl = `${env.NEXT_PUBLIC_APP_URL}/api/webhooks/trigger/${webhook.path}`
const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook`
const telegramResponse = await fetch(telegramApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'TelegramBot/1.0',
},
body: JSON.stringify({ url: notificationUrl }),
})
const responseBody = await telegramResponse.json()
if (!telegramResponse.ok || !responseBody.ok) {
const errorMessage =
responseBody.description ||
`Failed to create Telegram webhook. Status: ${telegramResponse.status}`
telegramLogger.error(`[${requestId}] ${errorMessage}`, { response: responseBody })
return false
}
telegramLogger.info(
`[${requestId}] Successfully created Telegram webhook for webhook ${webhook.id}`
)
return true
} catch (error) {
telegramLogger.error(
`[${requestId}] Error creating Telegram webhook for webhook ${webhook.id}`,
error
)
return false
}
}
/**
* Delete a Telegram bot webhook
* Always returns void (don't fail webhook deletion if cleanup fails)
*/
export async function deleteTelegramWebhook(webhook: any, requestId: string): Promise<void> {
try {
const config = (webhook.providerConfig as Record<string, any>) || {}
const botToken = config.botToken as string | undefined
if (!botToken) {
telegramLogger.warn(
`[${requestId}] Missing botToken for Telegram webhook deletion ${webhook.id}`
)
return
}
const telegramApiUrl = `https://api.telegram.org/bot${botToken}/deleteWebhook`
const telegramResponse = await fetch(telegramApiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
const responseBody = await telegramResponse.json()
if (!telegramResponse.ok || !responseBody.ok) {
const errorMessage =
responseBody.description ||
`Failed to delete Telegram webhook. Status: ${telegramResponse.status}`
telegramLogger.error(`[${requestId}] ${errorMessage}`, { response: responseBody })
} else {
telegramLogger.info(
`[${requestId}] Successfully deleted Telegram webhook for webhook ${webhook.id}`
)
}
} catch (error) {
telegramLogger.error(
`[${requestId}] Error deleting Telegram webhook for webhook ${webhook.id}`,
error
)
// Don't fail webhook deletion
}
}

View File

@@ -3,7 +3,10 @@ import type {
MicrosoftTeamsReadResponse,
MicrosoftTeamsToolParams,
} from '@/tools/microsoft_teams/types'
import { extractMessageAttachments } from '@/tools/microsoft_teams/utils'
import {
extractMessageAttachments,
fetchHostedContentsForChannelMessage,
} from '@/tools/microsoft_teams/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('MicrosoftTeamsReadChannel')
@@ -38,6 +41,12 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
visibility: 'user-only',
description: 'The ID of the channel to read from',
},
includeAttachments: {
type: 'boolean',
required: false,
visibility: 'user-only',
description: 'Download and include message attachments (hosted contents) into storage',
},
},
request: {
@@ -98,40 +107,64 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
}
// Process messages with attachments
const processedMessages = messages.map((message: any, index: number) => {
try {
const content = message.body?.content || 'No content'
const messageId = message.id
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)
const attachments = extractMessageAttachments(message)
let sender = 'Unknown'
if (message.from?.user?.displayName) {
sender = message.from.user.displayName
} else if (message.messageType === 'systemEventMessage') {
sender = 'System'
}
let sender = 'Unknown'
if (message.from?.user?.displayName) {
sender = message.from.user.displayName
} else if (message.messageType === 'systemEventMessage') {
sender = 'System'
}
return {
id: messageId,
content: content,
sender,
timestamp: message.createdDateTime,
messageType: message.messageType || 'message',
attachments,
// 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: [],
}
}
} catch (error) {
logger.error(`Error processing message at index ${index}:`, error)
return {
id: message.id || `unknown-${index}`,
content: 'Error processing message',
sender: 'Unknown',
timestamp: message.createdDateTime || new Date().toISOString(),
messageType: 'error',
attachments: [],
}
}
})
})
)
// Format the messages into a readable text (no attachment info in content)
const formattedMessages = processedMessages
@@ -171,11 +204,15 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
messages: processedMessages,
}
// Flatten uploaded files across all messages for convenience
const flattenedUploads = processedMessages.flatMap((m: any) => m.uploadedFiles || [])
return {
success: true,
output: {
content: formattedMessages,
metadata,
attachments: flattenedUploads,
},
}
},
@@ -189,5 +226,9 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
attachmentCount: { type: 'number', description: 'Total number of attachments found' },
attachmentTypes: { type: 'array', description: 'Types of attachments found' },
content: { type: 'string', description: 'Formatted content of channel messages' },
attachments: {
type: 'file[]',
description: 'Uploaded attachments for convenience (flattened)',
},
},
}

View File

@@ -2,7 +2,10 @@ import type {
MicrosoftTeamsReadResponse,
MicrosoftTeamsToolParams,
} from '@/tools/microsoft_teams/types'
import { extractMessageAttachments } from '@/tools/microsoft_teams/utils'
import {
extractMessageAttachments,
fetchHostedContentsForChatMessage,
} from '@/tools/microsoft_teams/utils'
import type { ToolConfig } from '@/tools/types'
export const readChatTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsReadResponse> = {
@@ -29,6 +32,12 @@ export const readChatTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsRe
visibility: 'user-only',
description: 'The ID of the chat to read from',
},
includeAttachments: {
type: 'boolean',
required: false,
visibility: 'user-only',
description: 'Download and include message attachments (hosted contents) into storage',
},
},
request: {
@@ -77,22 +86,39 @@ export const readChatTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsRe
}
// Process messages with attachments
const processedMessages = messages.map((message: any) => {
const content = message.body?.content || 'No content'
const messageId = message.id
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)
// Extract attachments without any content processing
const attachments = extractMessageAttachments(message)
return {
id: messageId,
content: content, // Keep original content without modification
sender: message.from?.user?.displayName || 'Unknown',
timestamp: message.createdDateTime,
messageType: message.messageType || 'message',
attachments, // Attachments only stored here
}
})
// Optionally fetch and upload hosted contents
let uploaded: any[] = []
if (params?.includeAttachments && params.accessToken && params.chatId && messageId) {
try {
uploaded = await fetchHostedContentsForChatMessage({
accessToken: params.accessToken,
chatId: params.chatId,
messageId,
})
} catch (_e) {
uploaded = []
}
}
return {
id: messageId,
content: content, // Keep original content without modification
sender: message.from?.user?.displayName || 'Unknown',
timestamp: message.createdDateTime,
messageType: message.messageType || 'message',
attachments, // Raw attachment metadata
uploadedFiles: uploaded, // Uploaded file infos (paths/keys)
}
})
)
// Format the messages into a readable text (no attachment info in content)
const formattedMessages = processedMessages
@@ -131,11 +157,15 @@ export const readChatTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsRe
messages: processedMessages,
}
// Flatten uploaded files across all messages for convenience
const flattenedUploads = processedMessages.flatMap((m: any) => m.uploadedFiles || [])
return {
success: true,
output: {
content: formattedMessages,
metadata,
attachments: flattenedUploads,
},
}
},
@@ -148,5 +178,9 @@ export const readChatTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsRe
attachmentCount: { type: 'number', description: 'Total number of attachments found' },
attachmentTypes: { type: 'array', description: 'Types of attachments found' },
content: { type: 'string', description: 'Formatted content of chat messages' },
attachments: {
type: 'file[]',
description: 'Uploaded attachments for convenience (flattened)',
},
},
}

View File

@@ -29,6 +29,13 @@ export interface MicrosoftTeamsMetadata {
timestamp: string
messageType: string
attachments?: MicrosoftTeamsAttachment[]
uploadedFiles?: {
path: string
key: string
name: string
size: number
type: string
}[]
}>
// 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

View File

@@ -1,4 +1,8 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { MicrosoftTeamsAttachment } from '@/tools/microsoft_teams/types'
import type { ToolFileData } from '@/tools/types'
const logger = createLogger('MicrosoftTeamsUtils')
/**
* Transform raw attachment data from Microsoft Graph API
@@ -27,3 +31,71 @@ export function extractMessageAttachments(message: any): MicrosoftTeamsAttachmen
return attachments
}
/**
* Fetch hostedContents for a chat message, upload each item to storage, and return uploaded file infos.
* Hosted contents expose base64 contentBytes via Microsoft Graph.
*/
export async function fetchHostedContentsForChatMessage(params: {
accessToken: string
chatId: string
messageId: string
}): Promise<ToolFileData[]> {
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<ToolFileData[]> {
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 []
}
}

View File

@@ -5,7 +5,10 @@ import { genericWebhookTrigger } from './generic'
import { githubWebhookTrigger } from './github'
import { gmailPollingTrigger } from './gmail'
import { googleFormsWebhookTrigger } from './googleforms/webhook'
import { microsoftTeamsWebhookTrigger } from './microsoftteams'
import {
microsoftTeamsChatSubscriptionTrigger,
microsoftTeamsWebhookTrigger,
} from './microsoftteams'
import { outlookPollingTrigger } from './outlook'
import { slackWebhookTrigger } from './slack'
import { stripeWebhookTrigger } from './stripe/webhook'
@@ -21,6 +24,7 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
github_webhook: githubWebhookTrigger,
gmail_poller: gmailPollingTrigger,
microsoftteams_webhook: microsoftTeamsWebhookTrigger,
microsoftteams_chat_subscription: microsoftTeamsChatSubscriptionTrigger,
outlook_poller: outlookPollingTrigger,
stripe_webhook: stripeWebhookTrigger,
telegram_webhook: telegramWebhookTrigger,

View File

@@ -0,0 +1,65 @@
import { MicrosoftTeamsIcon } from '@/components/icons'
import type { TriggerConfig } from '@/triggers/types'
export const microsoftTeamsChatSubscriptionTrigger: TriggerConfig = {
id: 'microsoftteams_chat_subscription',
name: 'Microsoft Teams Chat',
provider: 'microsoftteams',
description:
'Trigger workflow from new messages in Microsoft Teams chats via Microsoft Graph subscriptions',
version: '1.0.0',
icon: MicrosoftTeamsIcon,
// Credentials are handled by requiresCredentials below, not in configFields
configFields: {
chatId: {
type: 'string',
label: 'Chat ID',
placeholder: 'Enter chat ID',
description: 'The ID of the Teams chat to monitor',
required: true,
},
includeAttachments: {
type: 'boolean',
label: 'Include Attachments',
defaultValue: true,
description: 'Fetch hosted contents and upload to storage',
required: false,
},
},
// Require Microsoft Teams OAuth credentials
requiresCredentials: true,
credentialProvider: 'microsoft-teams',
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
outputs: {
// Core message fields
message_id: { type: 'string', description: 'Message ID' },
chat_id: { type: 'string', description: 'Chat ID' },
from_name: { type: 'string', description: 'Sender display name' },
text: { type: 'string', description: 'Message body (HTML or text)' },
created_at: { type: 'string', description: 'Message timestamp' },
attachments: { type: 'file[]', description: 'Uploaded attachments as files' },
},
instructions: [
'Connect your Microsoft Teams account and grant the required permissions.',
'Enter the Chat ID of the Teams chat you want to monitor.',
'We will create a Microsoft Graph change notification subscription that delivers chat message events to your Sim webhook URL.',
],
samplePayload: {
message_id: '1708709741557',
chat_id: '19:abcxyz@unq.gbl.spaces',
from_name: 'Adele Vance',
text: 'Hello from Teams!',
created_at: '2025-01-01T10:00:00Z',
attachments: [],
},
}

View File

@@ -1 +1,2 @@
export { microsoftTeamsChatSubscriptionTrigger } from './chat_webhook'
export { microsoftTeamsWebhookTrigger } from './webhook'

View File

@@ -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,

View File

@@ -1,4 +1,10 @@
export type TriggerFieldType = 'string' | 'boolean' | 'select' | 'number' | 'multiselect'
export type TriggerFieldType =
| 'string'
| 'boolean'
| 'select'
| 'number'
| 'multiselect'
| 'credential'
export interface TriggerConfigField {
type: TriggerFieldType
@@ -9,6 +15,8 @@ export interface TriggerConfigField {
description?: string
required?: boolean
isSecret?: boolean
provider?: string // OAuth provider for credential type fields
requiredScopes?: string[] // Required OAuth scopes for credential type fields
}
export interface TriggerOutput {

View File

@@ -644,6 +644,15 @@ cronjobs:
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
renewSubscriptions:
enabled: true
name: renew-subscriptions
schedule: "0 */12 * * *"
path: "/api/cron/renew-subscriptions"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
# Global CronJob settings
image: