mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 06:58:07 -05:00
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:
154
apps/sim/app/api/cron/renew-subscriptions/route.ts
Normal file
154
apps/sim/app/api/cron/renew-subscriptions/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>) || {}
|
||||
|
||||
305
apps/sim/lib/webhooks/webhook-helpers.ts
Normal file
305
apps/sim/lib/webhooks/webhook-helpers.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
65
apps/sim/triggers/microsoftteams/chat_webhook.ts
Normal file
65
apps/sim/triggers/microsoftteams/chat_webhook.ts
Normal 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: [],
|
||||
},
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { microsoftTeamsChatSubscriptionTrigger } from './chat_webhook'
|
||||
export { microsoftTeamsWebhookTrigger } from './webhook'
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user