d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
870 lines
30 KiB
TypeScript
870 lines
30 KiB
TypeScript
import { db } from '@sim/db'
|
|
import { account } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { safeCompare } from '@sim/security/compare'
|
|
import { hmacSha256Base64 } from '@sim/security/hmac'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { isRecordLike } from '@sim/utils/object'
|
|
import { eq } from 'drizzle-orm'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
import { isMicrosoftContentUrl } from '@/lib/core/security/input-validation'
|
|
import {
|
|
type SecureFetchResponse,
|
|
secureFetchWithPinnedIP,
|
|
validateUrlWithDNS,
|
|
} from '@/lib/core/security/input-validation.server'
|
|
import { sanitizeUrlForLog } from '@/lib/core/utils/logging'
|
|
import {
|
|
getCredentialOwner,
|
|
getNotificationUrl,
|
|
getProviderConfig,
|
|
} from '@/lib/webhooks/provider-subscription-utils'
|
|
import type {
|
|
AuthContext,
|
|
DeleteSubscriptionContext,
|
|
EventFilterContext,
|
|
FormatInputContext,
|
|
FormatInputResult,
|
|
SubscriptionContext,
|
|
SubscriptionResult,
|
|
WebhookProviderHandler,
|
|
} from '@/lib/webhooks/providers/types'
|
|
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
|
|
|
|
const logger = createLogger('WebhookProvider:MicrosoftTeams')
|
|
|
|
function validateMicrosoftTeamsSignature(
|
|
hmacSecret: string,
|
|
signature: string,
|
|
body: string
|
|
): boolean {
|
|
try {
|
|
if (!hmacSecret || !signature || !body) {
|
|
return false
|
|
}
|
|
if (!signature.startsWith('HMAC ')) {
|
|
return false
|
|
}
|
|
const providedSignature = signature.substring(5)
|
|
const secretBytes = Buffer.from(hmacSecret, 'base64')
|
|
const computedHash = hmacSha256Base64(body, secretBytes)
|
|
return safeCompare(computedHash, providedSignature)
|
|
} catch (error) {
|
|
logger.error('Error validating Microsoft Teams signature:', error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Derive a stable per-delivery identifier from a Teams webhook body.
|
|
*
|
|
* Graph change notifications (chat subscriptions) are keyed by
|
|
* `subscriptionId:messageId`. Outgoing webhook activities (channel
|
|
* @mentions) carry no `value` array — they're keyed by the Bot Framework
|
|
* Activity's own unique `id` instead.
|
|
*/
|
|
function extractNotificationKey(body: unknown): string | null {
|
|
if (!isRecordLike(body)) {
|
|
return null
|
|
}
|
|
|
|
const value = body.value
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
const notification = value[0]
|
|
if (!isRecordLike(notification)) {
|
|
return null
|
|
}
|
|
const subscriptionId = notification.subscriptionId
|
|
const resourceData = notification.resourceData
|
|
const messageId = isRecordLike(resourceData) ? resourceData.id : undefined
|
|
|
|
if (typeof subscriptionId === 'string' && typeof messageId === 'string') {
|
|
return `${subscriptionId}:${messageId}`
|
|
}
|
|
return null
|
|
}
|
|
|
|
const activityId = body.id
|
|
return typeof activityId === 'string' && activityId ? activityId : null
|
|
}
|
|
|
|
async function fetchWithDNSPinning(
|
|
url: string,
|
|
accessToken: string,
|
|
requestId: string
|
|
): Promise<SecureFetchResponse | null> {
|
|
try {
|
|
const urlValidation = await validateUrlWithDNS(url, 'contentUrl')
|
|
if (!urlValidation.isValid) {
|
|
logger.warn(`[${requestId}] Invalid content URL: ${urlValidation.error}`, { url })
|
|
return null
|
|
}
|
|
const headers: Record<string, string> = {}
|
|
if (accessToken) {
|
|
headers.Authorization = `Bearer ${accessToken}`
|
|
}
|
|
const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { headers })
|
|
return response
|
|
} catch (error) {
|
|
logger.error(`[${requestId}] Error fetching URL with DNS pinning`, {
|
|
error: toError(error).message,
|
|
url: sanitizeUrlForLog(url),
|
|
})
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format Microsoft Teams Graph change notification
|
|
*/
|
|
async function formatTeamsGraphNotification(
|
|
body: Record<string, unknown>,
|
|
foundWebhook: Record<string, unknown>,
|
|
foundWorkflow: { id: string; userId: string }
|
|
): Promise<unknown> {
|
|
const notification = (body.value as unknown[])?.[0] as Record<string, unknown> | undefined
|
|
if (!notification) {
|
|
logger.warn('Received empty Teams notification body')
|
|
return null
|
|
}
|
|
const changeType = (notification.changeType as string) || 'created'
|
|
const resource = (notification.resource as string) || ''
|
|
const subscriptionId = (notification.subscriptionId as string) || ''
|
|
|
|
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 as unknown[])?.[0] as Record<string, unknown>)?.resourceData as
|
|
| Record<string, unknown>
|
|
| undefined
|
|
const rdIdValue = rdId?.id as string | undefined
|
|
if (collectionMatch && rdIdValue) {
|
|
chatId = collectionMatch[1]
|
|
messageId = rdIdValue
|
|
}
|
|
}
|
|
|
|
if (
|
|
(!chatId || !messageId) &&
|
|
((body?.value as unknown[])?.[0] as Record<string, unknown>)?.resourceData
|
|
) {
|
|
const resourceData = ((body.value as unknown[])[0] as Record<string, unknown>)
|
|
.resourceData as Record<string, unknown>
|
|
const odataId = resourceData['@odata.id']
|
|
if (typeof odataId === 'string') {
|
|
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 as unknown[])?.[0] as Record<string, unknown>)?.resourceData
|
|
),
|
|
valueLength: Array.isArray(body?.value) ? (body.value as unknown[]).length : 0,
|
|
keys: Object.keys(body || {}),
|
|
})
|
|
return {
|
|
from: null,
|
|
message: { raw: body },
|
|
activity: body,
|
|
conversation: null,
|
|
}
|
|
}
|
|
const resolvedChatId = chatId as string
|
|
const resolvedMessageId = messageId as string
|
|
const providerConfig = (foundWebhook?.providerConfig as Record<string, unknown>) || {}
|
|
const credentialId = providerConfig.credentialId
|
|
const includeAttachments = providerConfig.includeAttachments !== false
|
|
|
|
let message: Record<string, unknown> | null = null
|
|
const rawAttachments: Array<{ name: string; data: Buffer; contentType: string; size: number }> =
|
|
[]
|
|
let accessToken: string | null = null
|
|
|
|
if (!credentialId) {
|
|
logger.error('Missing credentialId for Teams chat subscription', {
|
|
chatId: resolvedChatId,
|
|
messageId: resolvedMessageId,
|
|
webhookId: foundWebhook?.id,
|
|
blockId: foundWebhook?.blockId,
|
|
providerConfig,
|
|
})
|
|
} else {
|
|
try {
|
|
const resolved = await resolveOAuthAccountId(credentialId as string)
|
|
if (!resolved) {
|
|
logger.error('Teams credential could not be resolved', { credentialId })
|
|
} else {
|
|
const rows = await db
|
|
.select()
|
|
.from(account)
|
|
.where(eq(account.id, resolved.accountId))
|
|
.limit(1)
|
|
if (rows.length === 0) {
|
|
logger.error('Teams credential not found', { credentialId, chatId: resolvedChatId })
|
|
} else {
|
|
const effectiveUserId = rows[0].userId
|
|
accessToken = await refreshAccessTokenIfNeeded(
|
|
resolved.accountId,
|
|
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()) as Record<string, unknown>
|
|
|
|
if (includeAttachments && (message?.attachments as unknown[] | undefined)?.length) {
|
|
const attachments = Array.isArray(message?.attachments)
|
|
? (message.attachments as Record<string, unknown>[])
|
|
: []
|
|
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 parsedContentUrl: URL
|
|
try {
|
|
parsedContentUrl = new URL(contentUrl)
|
|
} catch {
|
|
continue
|
|
}
|
|
const contentHost = parsedContentUrl.hostname.toLowerCase()
|
|
|
|
let buffer: Buffer | null = null
|
|
let mimeType = 'application/octet-stream'
|
|
|
|
const isOneDriveShareLink =
|
|
contentHost === '1drv.ms' ||
|
|
contentHost === '1drv.com' ||
|
|
contentHost === 'microsoftpersonalcontent.com' ||
|
|
contentHost.endsWith('.microsoftpersonalcontent.com')
|
|
|
|
if (isMicrosoftContentUrl(contentUrl) && !isOneDriveShareLink) {
|
|
try {
|
|
const directRes = await fetchWithDNSPinning(
|
|
contentUrl,
|
|
accessToken,
|
|
'teams-attachment'
|
|
)
|
|
|
|
if (directRes?.ok) {
|
|
const arrayBuffer = await directRes.arrayBuffer()
|
|
buffer = Buffer.from(arrayBuffer)
|
|
mimeType =
|
|
directRes.headers.get('content-type') ||
|
|
contentTypeHint ||
|
|
'application/octet-stream'
|
|
} else if (directRes) {
|
|
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 (isOneDriveShareLink) {
|
|
try {
|
|
let shareToken: string | null = null
|
|
|
|
if (contentHost === '1drv.ms') {
|
|
const lastSegment = parsedContentUrl.pathname.split('/').pop()
|
|
if (lastSegment) shareToken = lastSegment
|
|
} else if (parsedContentUrl.searchParams.has('resid')) {
|
|
shareToken = parsedContentUrl.searchParams.get('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()) as Record<string, unknown>
|
|
const downloadUrl = metadata['@microsoft.graph.downloadUrl'] as
|
|
| string
|
|
| undefined
|
|
|
|
if (downloadUrl) {
|
|
const downloadRes = await fetchWithDNSPinning(
|
|
downloadUrl,
|
|
'',
|
|
'teams-onedrive-download'
|
|
)
|
|
|
|
if (downloadRes?.ok) {
|
|
const arrayBuffer = await downloadRes.arrayBuffer()
|
|
buffer = Buffer.from(arrayBuffer)
|
|
const fileInfo = metadata.file as Record<string, unknown> | undefined
|
|
mimeType =
|
|
downloadRes.headers.get('content-type') ||
|
|
(fileInfo?.mimeType as string | undefined) ||
|
|
contentTypeHint ||
|
|
'application/octet-stream'
|
|
|
|
if (metadata.name && metadata.name !== attachmentName) {
|
|
attachmentName = metadata.name as string
|
|
}
|
|
} else {
|
|
continue
|
|
}
|
|
} else {
|
|
continue
|
|
}
|
|
}
|
|
} catch {
|
|
continue
|
|
}
|
|
} else {
|
|
try {
|
|
const ares = await fetchWithDNSPinning(
|
|
contentUrl,
|
|
accessToken,
|
|
'teams-attachment-generic'
|
|
)
|
|
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
|
|
|
|
rawAttachments.push({
|
|
name: attachmentName,
|
|
data: buffer,
|
|
contentType: mimeType,
|
|
size,
|
|
})
|
|
} catch {
|
|
/* skip attachment on error */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to fetch Teams message', {
|
|
error,
|
|
chatId: resolvedChatId,
|
|
messageId: resolvedMessageId,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (!message) {
|
|
logger.warn('No message data available for Teams notification', {
|
|
chatId: resolvedChatId,
|
|
messageId: resolvedMessageId,
|
|
hasCredential: !!credentialId,
|
|
})
|
|
return {
|
|
message_id: resolvedMessageId,
|
|
chat_id: resolvedChatId,
|
|
from_name: '',
|
|
text: '',
|
|
created_at: '',
|
|
attachments: [],
|
|
}
|
|
}
|
|
|
|
const messageText = (message.body as Record<string, unknown>)?.content || ''
|
|
const from = ((message.from as Record<string, unknown>)?.user as Record<string, unknown>) || {}
|
|
const createdAt = (message.createdDateTime as string) || ''
|
|
|
|
return {
|
|
message_id: resolvedMessageId,
|
|
chat_id: resolvedChatId,
|
|
from_name: (from.displayName as string) || '',
|
|
text: messageText,
|
|
created_at: createdAt,
|
|
attachments: rawAttachments,
|
|
}
|
|
}
|
|
|
|
export const microsoftTeamsHandler: WebhookProviderHandler = {
|
|
handleChallenge(_body: unknown, request: NextRequest, requestId: string, path: string) {
|
|
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' },
|
|
})
|
|
}
|
|
return null
|
|
},
|
|
|
|
verifyAuth({ webhook, request, rawBody, requestId, providerConfig }: AuthContext) {
|
|
if (providerConfig.triggerId !== 'microsoftteams_chat_subscription') {
|
|
const hmacSecret = providerConfig.hmacSecret as string | undefined
|
|
if (!hmacSecret) {
|
|
logger.error(
|
|
`[${requestId}] Microsoft Teams outgoing webhook missing configured HMAC secret`
|
|
)
|
|
return new NextResponse('Unauthorized - Missing HMAC secret configuration', {
|
|
status: 401,
|
|
})
|
|
}
|
|
|
|
const authHeader = request.headers.get('authorization')
|
|
|
|
if (!authHeader || !authHeader.startsWith('HMAC ')) {
|
|
logger.warn(
|
|
`[${requestId}] Microsoft Teams outgoing webhook missing HMAC authorization header`
|
|
)
|
|
return new NextResponse('Unauthorized - Missing HMAC signature', { status: 401 })
|
|
}
|
|
|
|
if (!validateMicrosoftTeamsSignature(hmacSecret, authHeader, rawBody)) {
|
|
logger.warn(`[${requestId}] Microsoft Teams HMAC signature verification failed`)
|
|
return new NextResponse('Unauthorized - Invalid HMAC signature', { status: 401 })
|
|
}
|
|
}
|
|
|
|
if (providerConfig.triggerId === 'microsoftteams_chat_subscription') {
|
|
const expectedClientState = String(webhook.id ?? '')
|
|
if (!expectedClientState) {
|
|
logger.warn(
|
|
`[${requestId}] Microsoft Teams chat subscription webhook missing id for clientState verification`
|
|
)
|
|
return new NextResponse('Unauthorized - Invalid clientState', { status: 401 })
|
|
}
|
|
|
|
let notifications: unknown[] = []
|
|
try {
|
|
const parsed = JSON.parse(rawBody) as Record<string, unknown>
|
|
if (Array.isArray(parsed?.value)) {
|
|
notifications = parsed.value
|
|
}
|
|
} catch {
|
|
notifications = []
|
|
}
|
|
|
|
if (notifications.length === 0) {
|
|
logger.warn(
|
|
`[${requestId}] Microsoft Teams chat subscription notification missing value array`
|
|
)
|
|
return new NextResponse('Unauthorized - Invalid notification payload', { status: 401 })
|
|
}
|
|
|
|
for (const notification of notifications) {
|
|
const clientState = (notification as Record<string, unknown>)?.clientState
|
|
if (typeof clientState !== 'string' || !safeCompare(clientState, expectedClientState)) {
|
|
logger.warn(
|
|
`[${requestId}] Microsoft Teams chat subscription clientState verification failed`
|
|
)
|
|
return new NextResponse('Unauthorized - Invalid clientState', { status: 401 })
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
},
|
|
|
|
formatErrorResponse(error: string, status: number) {
|
|
return NextResponse.json({ type: 'message', text: error }, { status })
|
|
},
|
|
|
|
enrichHeaders({ body }: EventFilterContext, headers: Record<string, string>) {
|
|
const key = extractNotificationKey(body)
|
|
if (key) {
|
|
headers['x-teams-notification-id'] = key
|
|
}
|
|
},
|
|
|
|
extractIdempotencyId(body: unknown) {
|
|
return extractNotificationKey(body)
|
|
},
|
|
|
|
formatSuccessResponse(providerConfig: Record<string, unknown>) {
|
|
if (providerConfig.triggerId === 'microsoftteams_chat_subscription') {
|
|
return new NextResponse(null, { status: 202 })
|
|
}
|
|
|
|
return NextResponse.json({ type: 'message', text: 'Sim' })
|
|
},
|
|
|
|
formatQueueErrorResponse() {
|
|
return NextResponse.json(
|
|
{ type: 'message', text: 'Webhook processing failed' },
|
|
{ status: 500 }
|
|
)
|
|
},
|
|
|
|
async createSubscription({
|
|
webhook,
|
|
workflow,
|
|
userId,
|
|
requestId,
|
|
request,
|
|
}: SubscriptionContext): Promise<SubscriptionResult | undefined> {
|
|
const config = getProviderConfig(webhook)
|
|
|
|
if (config.triggerId !== 'microsoftteams_chat_subscription') {
|
|
return undefined
|
|
}
|
|
|
|
const credentialId = config.credentialId as string | undefined
|
|
const chatId = config.chatId as string | undefined
|
|
|
|
if (!credentialId) {
|
|
logger.warn(`[${requestId}] Missing credentialId for Teams chat subscription ${webhook.id}`)
|
|
throw new Error(
|
|
'Microsoft Teams credentials are required. Please connect your Microsoft account in the trigger configuration.'
|
|
)
|
|
}
|
|
|
|
if (!chatId) {
|
|
logger.warn(`[${requestId}] Missing chatId for Teams chat subscription ${webhook.id}`)
|
|
throw new Error(
|
|
'Chat ID is required to create a Teams subscription. Please provide a valid chat ID.'
|
|
)
|
|
}
|
|
|
|
const credentialOwner = await getCredentialOwner(credentialId, requestId)
|
|
const accessToken = credentialOwner
|
|
? await refreshAccessTokenIfNeeded(
|
|
credentialOwner.accountId,
|
|
credentialOwner.userId,
|
|
requestId
|
|
)
|
|
: null
|
|
if (!accessToken) {
|
|
logger.error(`[${requestId}] Failed to get access token for Teams subscription ${webhook.id}`)
|
|
throw new Error(
|
|
'Failed to authenticate with Microsoft Teams. Please reconnect your Microsoft account and try again.'
|
|
)
|
|
}
|
|
|
|
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) {
|
|
const existingPayload = await checkRes.json()
|
|
logger.info(
|
|
`[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}`
|
|
)
|
|
return {
|
|
providerConfigUpdates: {
|
|
externalSubscriptionId: existingSubscriptionId,
|
|
subscriptionExpiration: existingPayload.expirationDateTime,
|
|
},
|
|
}
|
|
}
|
|
} catch {
|
|
logger.debug(`[${requestId}] Existing subscription check failed, will create new one`)
|
|
}
|
|
}
|
|
|
|
const notificationUrl = getNotificationUrl(webhook)
|
|
const resource = `/chats/${chatId}/messages`
|
|
|
|
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,
|
|
}
|
|
|
|
try {
|
|
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) {
|
|
const errorMessage =
|
|
payload.error?.message || payload.error?.code || 'Unknown Microsoft Graph API error'
|
|
logger.error(
|
|
`[${requestId}] Failed to create Teams subscription for webhook ${webhook.id}`,
|
|
{
|
|
status: res.status,
|
|
error: payload.error,
|
|
}
|
|
)
|
|
|
|
let userFriendlyMessage = 'Failed to create Teams subscription'
|
|
if (res.status === 401 || res.status === 403) {
|
|
userFriendlyMessage =
|
|
'Authentication failed. Please reconnect your Microsoft Teams account and ensure you have the necessary permissions.'
|
|
} else if (res.status === 404) {
|
|
userFriendlyMessage =
|
|
'Chat not found. Please verify that the Chat ID is correct and that you have access to the specified chat.'
|
|
} else if (errorMessage && errorMessage !== 'Unknown Microsoft Graph API error') {
|
|
userFriendlyMessage = `Teams error: ${errorMessage}`
|
|
}
|
|
|
|
throw new Error(userFriendlyMessage)
|
|
}
|
|
|
|
logger.info(
|
|
`[${requestId}] Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}`
|
|
)
|
|
return {
|
|
providerConfigUpdates: {
|
|
externalSubscriptionId: payload.id as string,
|
|
subscriptionExpiration: payload.expirationDateTime as string,
|
|
},
|
|
}
|
|
} catch (error: unknown) {
|
|
if (
|
|
error instanceof Error &&
|
|
(error.message.includes('credentials') ||
|
|
error.message.includes('Chat ID') ||
|
|
error.message.includes('authenticate'))
|
|
) {
|
|
throw error
|
|
}
|
|
|
|
logger.error(
|
|
`[${requestId}] Error creating Teams subscription for webhook ${webhook.id}`,
|
|
error
|
|
)
|
|
throw new Error(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Failed to create Teams subscription. Please try again.'
|
|
)
|
|
}
|
|
},
|
|
|
|
async deleteSubscription({
|
|
webhook,
|
|
workflow,
|
|
requestId,
|
|
strict,
|
|
}: DeleteSubscriptionContext): Promise<void> {
|
|
try {
|
|
const config = getProviderConfig(webhook)
|
|
|
|
if (config.triggerId !== 'microsoftteams_chat_subscription') {
|
|
return
|
|
}
|
|
|
|
const externalSubscriptionId = config.externalSubscriptionId as string | undefined
|
|
const credentialId = config.credentialId as string | undefined
|
|
|
|
if (!externalSubscriptionId || !credentialId) {
|
|
logger.info(`[${requestId}] No external subscription to delete for webhook ${webhook.id}`)
|
|
if (strict) throw new Error('Missing Teams subscription cleanup configuration')
|
|
return
|
|
}
|
|
|
|
const credentialOwner = await getCredentialOwner(credentialId, requestId)
|
|
const accessToken = credentialOwner
|
|
? await refreshAccessTokenIfNeeded(
|
|
credentialOwner.accountId,
|
|
credentialOwner.userId,
|
|
requestId
|
|
)
|
|
: null
|
|
if (!accessToken) {
|
|
logger.warn(
|
|
`[${requestId}] Could not get access token to delete Teams subscription for webhook ${webhook.id}`
|
|
)
|
|
if (strict) throw new Error('Missing Teams access token for subscription deletion')
|
|
return
|
|
}
|
|
|
|
const res = await fetch(
|
|
`https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`,
|
|
{
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
}
|
|
)
|
|
|
|
if (res.ok || res.status === 404) {
|
|
logger.info(
|
|
`[${requestId}] Successfully deleted Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}`
|
|
)
|
|
} else {
|
|
const errorBody = await res.text()
|
|
logger.warn(
|
|
`[${requestId}] Failed to delete Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}. Status: ${res.status}`
|
|
)
|
|
if (strict) throw new Error(`Failed to delete Teams subscription: ${res.status}`)
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
`[${requestId}] Error deleting Teams subscription for webhook ${webhook.id}`,
|
|
error
|
|
)
|
|
if (strict) throw error
|
|
}
|
|
},
|
|
|
|
async formatInput({
|
|
body,
|
|
webhook,
|
|
workflow,
|
|
requestId,
|
|
}: FormatInputContext): Promise<FormatInputResult> {
|
|
const b = body as Record<string, unknown>
|
|
const value = b?.value as unknown[] | undefined
|
|
|
|
if (value && Array.isArray(value) && value.length > 0) {
|
|
const result = await formatTeamsGraphNotification(b, webhook, workflow)
|
|
return { input: result }
|
|
}
|
|
|
|
const messageText = (b?.text as string) || ''
|
|
const messageId = (b?.id as string) || ''
|
|
const timestamp = (b?.timestamp as string) || (b?.localTimestamp as string) || ''
|
|
const from = (b?.from || {}) as Record<string, unknown>
|
|
const conversation = (b?.conversation || {}) as Record<string, unknown>
|
|
const channelData = (b?.channelData || {}) as Record<string, unknown>
|
|
const channelDataTeam = (channelData.team || {}) as Record<string, unknown>
|
|
const channelDataChannel = (channelData.channel || {}) as Record<string, unknown>
|
|
const channelDataTenant = (channelData.tenant || {}) as Record<string, unknown>
|
|
|
|
return {
|
|
input: {
|
|
from: {
|
|
id: (from.id || '') as string,
|
|
name: (from.name || '') as string,
|
|
aadObjectId: (from.aadObjectId || '') as string,
|
|
},
|
|
message: {
|
|
raw: {
|
|
attachments: b?.attachments || [],
|
|
channelData: {
|
|
team: { id: (channelDataTeam.id || '') as string },
|
|
tenant: { id: (channelDataTenant.id || '') as string },
|
|
channel: { id: (channelDataChannel.id || '') as string },
|
|
teamsTeamId: (channelData.teamsTeamId || channelDataTeam.id || '') as string,
|
|
teamsChannelId: (channelData.teamsChannelId || channelDataChannel.id || '') as string,
|
|
},
|
|
conversation: b?.conversation || {},
|
|
text: messageText,
|
|
messageType: (b?.type || 'message') as string,
|
|
channelId: (b?.channelId || '') as string,
|
|
timestamp,
|
|
},
|
|
},
|
|
activity: b || {},
|
|
conversation: {
|
|
id: (conversation.id || '') as string,
|
|
name: (conversation.name || '') as string,
|
|
isGroup: (conversation.isGroup || false) as boolean,
|
|
tenantId: (conversation.tenantId || '') as string,
|
|
aadObjectId: (conversation.aadObjectId || '') as string,
|
|
conversationType: (conversation.conversationType || '') as string,
|
|
},
|
|
},
|
|
}
|
|
},
|
|
}
|