chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { slackAddReactionContract } from '@/lib/api/contracts/tools/communication/slack'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(slackAddReactionContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
const slackResponse = await fetch('https://slack.com/api/reactions.add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: validatedData.channel,
|
||||
timestamp: validatedData.timestamp,
|
||||
name: validatedData.name,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await slackResponse.json()
|
||||
|
||||
if (!data.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: data.error || 'Failed to add reaction',
|
||||
},
|
||||
{ status: slackResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
content: `Successfully added :${validatedData.name}: reaction`,
|
||||
metadata: {
|
||||
channel: validatedData.channel,
|
||||
timestamp: validatedData.timestamp,
|
||||
reaction: validatedData.name,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Unknown error occurred'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,319 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { slackChannelsSelectorContract } from '@/lib/api/contracts/selectors/slack'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SlackChannelsAPI')
|
||||
|
||||
interface SlackChannel {
|
||||
id: string
|
||||
name: string
|
||||
is_private: boolean
|
||||
is_archived: boolean
|
||||
is_member: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the installing user's Slack id from credentials connected after the
|
||||
* privacy fix, which `auth.ts` tags with a `usr_` marker
|
||||
* (`${teamId}-usr_${installerUserId}-${uuid}`). Legacy credentials encode the
|
||||
* bot id with no marker and return null, so the caller keeps the existing
|
||||
* `is_member` filter — no regression.
|
||||
*/
|
||||
const SCOPED_USER_ID_PATTERN =
|
||||
/-usr_([UW][A-Z0-9]+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
function parseScopedSlackUserId(accountId: string): string | null {
|
||||
const match = SCOPED_USER_ID_PATTERN.exec(accountId)
|
||||
if (match) return match[1]
|
||||
// Marker present but unparseable — surface it rather than silently falling
|
||||
// back to the bot `is_member` filter and bypassing the privacy scope.
|
||||
if (accountId.includes('-usr_')) {
|
||||
logger.warn('Slack accountId carries usr_ marker but did not parse; using is_member fallback', {
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const requestId = generateRequestId()
|
||||
const parsed = await parseRequest(slackChannelsSelectorContract, request, {})
|
||||
if (!parsed.success) {
|
||||
logger.error('Missing credential in request')
|
||||
return parsed.response
|
||||
}
|
||||
const { credential, workflowId } = parsed.data.body
|
||||
|
||||
let accessToken: string
|
||||
let isBotToken = false
|
||||
let scopedUserId: string | null = null
|
||||
|
||||
if (credential.startsWith('xoxb-')) {
|
||||
accessToken = credential
|
||||
isBotToken = true
|
||||
logger.info('Using direct bot token for Slack API')
|
||||
} else {
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId: credential,
|
||||
workflowId: workflowId ?? undefined,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
const resolvedToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!resolvedToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
authRequired: true,
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
accessToken = resolvedToken
|
||||
|
||||
// resolvedCredentialId is an account.id only for OAuth credentials
|
||||
// (the service_account path returns a credential.id).
|
||||
if (authz.credentialType === 'oauth' && authz.resolvedCredentialId) {
|
||||
logger.info('Using OAuth token for Slack API')
|
||||
const [accountRow] = await db
|
||||
.select({ accountId: account.accountId })
|
||||
.from(account)
|
||||
.where(eq(account.id, authz.resolvedCredentialId))
|
||||
.limit(1)
|
||||
if (accountRow) {
|
||||
scopedUserId = parseScopedSlackUserId(accountRow.accountId)
|
||||
}
|
||||
} else {
|
||||
// A custom-bot service_account credential resolves to a bot token with
|
||||
// no scoped user; treat it like a direct bot token so the private ->
|
||||
// public channel fallback applies.
|
||||
isBotToken = true
|
||||
logger.info('Using custom bot token for Slack API')
|
||||
}
|
||||
}
|
||||
|
||||
let data: SlackConversationsResult
|
||||
try {
|
||||
data = await fetchSlackChannels(accessToken, true)
|
||||
if (data.truncated) {
|
||||
logger.warn('conversations.list hit pagination cap; channel list may be incomplete')
|
||||
}
|
||||
logger.info('Successfully fetched channels including private channels')
|
||||
} catch (error) {
|
||||
if (isBotToken) {
|
||||
logger.warn(
|
||||
'Failed to fetch private channels with bot token, falling back to public channels only:',
|
||||
(error as Error).message
|
||||
)
|
||||
try {
|
||||
data = await fetchSlackChannels(accessToken, false)
|
||||
logger.info('Successfully fetched public channels only')
|
||||
} catch (fallbackError) {
|
||||
logger.error('Failed to fetch channels even with public-only fallback:', fallbackError)
|
||||
return NextResponse.json(
|
||||
{ error: `Slack API error: ${(fallbackError as Error).message}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logger.error('Slack API error with OAuth token:', error)
|
||||
return NextResponse.json(
|
||||
{ error: `Slack API error: ${(error as Error).message}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack Marketplace privacy: a private channel may only be shown to a user
|
||||
* whose own Slack account is a member, even when the bot has been invited.
|
||||
* `users.conversations?user=` returns the channels the bot AND that user
|
||||
* share, giving us the allowed set. Public channels are never restricted.
|
||||
* Without a scoped user id (legacy credentials), fall back to bot membership.
|
||||
*/
|
||||
let allowedPrivateChannelIds: Set<string> | null = null
|
||||
if (scopedUserId) {
|
||||
try {
|
||||
const userPrivate = await fetchUserPrivateChannels(accessToken, scopedUserId)
|
||||
allowedPrivateChannelIds = new Set(userPrivate.channels.map((c) => c.id))
|
||||
if (userPrivate.truncated) {
|
||||
logger.warn(
|
||||
'users.conversations hit pagination cap; some private channels the user belongs to may be hidden',
|
||||
{ scopedUserId }
|
||||
)
|
||||
}
|
||||
logger.info('Scoped private channels to installing user membership', {
|
||||
scopedUserId,
|
||||
allowedCount: allowedPrivateChannelIds.size,
|
||||
})
|
||||
} catch (scopeError) {
|
||||
// Fail closed: if membership can't be verified, hide all private channels.
|
||||
logger.warn('Failed to scope private channels to user, hiding all private channels', {
|
||||
error: (scopeError as Error).message,
|
||||
})
|
||||
allowedPrivateChannelIds = new Set()
|
||||
}
|
||||
}
|
||||
|
||||
const channels = (data.channels || [])
|
||||
.filter((channel: SlackChannel) => {
|
||||
if (channel.is_archived) return false
|
||||
|
||||
if (channel.is_private) {
|
||||
if (allowedPrivateChannelIds) {
|
||||
return allowedPrivateChannelIds.has(channel.id)
|
||||
}
|
||||
return channel.is_member
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.filter((channel: SlackChannel) => {
|
||||
const validation = validateAlphanumericId(channel.id, 'channelId', 50)
|
||||
|
||||
if (!validation.isValid) {
|
||||
logger.warn('Invalid channel ID received from Slack API', {
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
error: validation.error,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!/^[CDG][A-Z0-9]+$/i.test(channel.id)) {
|
||||
logger.warn('Channel ID does not match Slack format', {
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
.map((channel: SlackChannel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
isPrivate: channel.is_private,
|
||||
}))
|
||||
|
||||
logger.info(`Successfully fetched ${channels.length} Slack channels`, {
|
||||
total: data.channels?.length || 0,
|
||||
private: channels.filter((c: { isPrivate: boolean }) => c.isPrivate).length,
|
||||
public: channels.filter((c: { isPrivate: boolean }) => !c.isPrivate).length,
|
||||
tokenType: isBotToken ? 'bot_token' : 'oauth',
|
||||
userScoped: !!scopedUserId,
|
||||
})
|
||||
return NextResponse.json({ channels })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Slack channels request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Slack channels', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const SLACK_PAGE_LIMIT = 200
|
||||
const SLACK_MAX_PAGES = 10
|
||||
|
||||
interface SlackConversationsResult {
|
||||
channels: SlackChannel[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists Slack conversations, following `response_metadata.next_cursor` so the
|
||||
* full set is returned. Bounded by `SLACK_MAX_PAGES`; sets `truncated` rather
|
||||
* than silently dropping channels when the cap is hit.
|
||||
*/
|
||||
async function fetchAllConversations(
|
||||
method: 'conversations.list' | 'users.conversations',
|
||||
accessToken: string,
|
||||
params: Record<string, string>
|
||||
): Promise<SlackConversationsResult> {
|
||||
const channels: SlackChannel[] = []
|
||||
let cursor: string | undefined
|
||||
let truncated = false
|
||||
|
||||
for (let page = 0; page < SLACK_MAX_PAGES; page++) {
|
||||
const url = new URL(`https://slack.com/api/${method}`)
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.append(key, value)
|
||||
}
|
||||
url.searchParams.append('limit', String(SLACK_PAGE_LIMIT))
|
||||
if (cursor) {
|
||||
url.searchParams.append('cursor', cursor)
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Slack API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error || `Failed to fetch ${method}`)
|
||||
}
|
||||
|
||||
if (Array.isArray(data.channels)) {
|
||||
channels.push(...data.channels)
|
||||
}
|
||||
|
||||
cursor = data.response_metadata?.next_cursor?.trim() || undefined
|
||||
if (!cursor) {
|
||||
return { channels, truncated }
|
||||
}
|
||||
if (page === SLACK_MAX_PAGES - 1) {
|
||||
truncated = true
|
||||
}
|
||||
}
|
||||
|
||||
return { channels, truncated }
|
||||
}
|
||||
|
||||
async function fetchSlackChannels(
|
||||
accessToken: string,
|
||||
includePrivate = true
|
||||
): Promise<SlackConversationsResult> {
|
||||
return fetchAllConversations('conversations.list', accessToken, {
|
||||
types: includePrivate ? 'public_channel,private_channel' : 'public_channel',
|
||||
exclude_archived: 'true',
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchUserPrivateChannels(
|
||||
accessToken: string,
|
||||
userId: string
|
||||
): Promise<SlackConversationsResult> {
|
||||
return fetchAllConversations('users.conversations', accessToken, {
|
||||
user: userId,
|
||||
types: 'private_channel',
|
||||
exclude_archived: 'true',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { slackDeleteMessageContract } from '@/lib/api/contracts/tools/communication/slack'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(slackDeleteMessageContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
const slackResponse = await fetch('https://slack.com/api/chat.delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: validatedData.channel,
|
||||
ts: validatedData.timestamp,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await slackResponse.json()
|
||||
|
||||
if (!data.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: data.error || 'Failed to delete message',
|
||||
},
|
||||
{ status: slackResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
content: 'Message deleted successfully',
|
||||
metadata: {
|
||||
channel: data.channel,
|
||||
timestamp: data.ts,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Unknown error occurred'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
createMockRequest,
|
||||
hybridAuthMockFns,
|
||||
inputValidationMock,
|
||||
inputValidationMockFns,
|
||||
} from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
|
||||
|
||||
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
|
||||
|
||||
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
|
||||
import { POST } from '@/app/api/tools/slack/download/route'
|
||||
|
||||
const { mockValidateUrlWithDNS, mockSecureFetchWithPinnedIP } = inputValidationMockFns
|
||||
|
||||
const PINNED_IP = '93.184.216.34'
|
||||
|
||||
const baseBody = {
|
||||
accessToken: 'token-123',
|
||||
fileId: 'file-abc',
|
||||
}
|
||||
|
||||
function fileResponse(bytes: number) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: '',
|
||||
headers: new Headers(),
|
||||
body: null,
|
||||
text: async () => '',
|
||||
json: async () => ({}),
|
||||
arrayBuffer: async () => new ArrayBuffer(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
const originalFetch = global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'internal_jwt',
|
||||
})
|
||||
mockValidateUrlWithDNS.mockResolvedValue({
|
||||
isValid: true,
|
||||
resolvedIP: PINNED_IP,
|
||||
originalHostname: 'files.slack.com',
|
||||
})
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
file: {
|
||||
name: 'report.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
url_private: 'https://files.slack.com/files-pri/T000-F000/report.pdf',
|
||||
},
|
||||
}),
|
||||
}) as unknown as typeof fetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
|
||||
describe('POST /api/tools/slack/download', () => {
|
||||
it('downloads a normal file under the size cap', async () => {
|
||||
mockSecureFetchWithPinnedIP.mockResolvedValueOnce(fileResponse(1024))
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(200)
|
||||
const data = (await response.json()) as { success: boolean; output: { file: { size: number } } }
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.file.size).toBe(1024)
|
||||
|
||||
const downloadCall = mockSecureFetchWithPinnedIP.mock.calls[0]
|
||||
expect(downloadCall[2]).toMatchObject({ maxResponseBytes: MAX_FILE_SIZE })
|
||||
})
|
||||
|
||||
it('surfaces a clean 413 when the streamed content exceeds the cap', async () => {
|
||||
mockSecureFetchWithPinnedIP.mockRejectedValueOnce(
|
||||
new PayloadSizeLimitError({
|
||||
label: 'response body',
|
||||
maxBytes: MAX_FILE_SIZE,
|
||||
observedBytes: MAX_FILE_SIZE + 1,
|
||||
})
|
||||
)
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(413)
|
||||
const data = (await response.json()) as { success: boolean }
|
||||
expect(data.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { slackDownloadContract } from '@/lib/api/contracts/tools/communication/slack'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SlackDownloadAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Slack download attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Authenticated Slack download request via ${authResult.authType}`, {
|
||||
userId: authResult.userId,
|
||||
})
|
||||
|
||||
const parsed = await parseRequest(slackDownloadContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { accessToken, fileId, fileName } = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Getting file info from Slack`, { fileId })
|
||||
|
||||
const infoResponse = await fetch(`https://slack.com/api/files.info?file=${fileId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!infoResponse.ok) {
|
||||
const errorDetails = await infoResponse.json().catch(() => ({}))
|
||||
logger.error(`[${requestId}] Failed to get file info from Slack`, {
|
||||
status: infoResponse.status,
|
||||
statusText: infoResponse.statusText,
|
||||
error: errorDetails,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorDetails.error || 'Failed to get file info',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await infoResponse.json()
|
||||
|
||||
if (!data.ok) {
|
||||
logger.error(`[${requestId}] Slack API returned error`, { error: data.error })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: data.error || 'Slack API error',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const file = data.file
|
||||
const resolvedFileName = fileName || file.name || 'download'
|
||||
const mimeType = file.mimetype || 'application/octet-stream'
|
||||
const urlPrivate = file.url_private
|
||||
|
||||
if (!urlPrivate) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'File does not have a download URL',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const urlValidation = await validateUrlWithDNS(urlPrivate, 'urlPrivate')
|
||||
if (!urlValidation.isValid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: urlValidation.error,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Downloading file from Slack`, {
|
||||
fileId,
|
||||
fileName: resolvedFileName,
|
||||
mimeType,
|
||||
})
|
||||
|
||||
const downloadResponse = await secureFetchWithPinnedIP(urlPrivate, urlValidation.resolvedIP!, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
maxResponseBytes: MAX_FILE_SIZE,
|
||||
})
|
||||
|
||||
if (!downloadResponse.ok) {
|
||||
logger.error(`[${requestId}] Failed to download file content`, {
|
||||
status: downloadResponse.status,
|
||||
statusText: downloadResponse.statusText,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to download file content',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const arrayBuffer = await downloadResponse.arrayBuffer()
|
||||
const fileBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
logger.info(`[${requestId}] File downloaded successfully`, {
|
||||
fileId,
|
||||
name: resolvedFileName,
|
||||
size: fileBuffer.length,
|
||||
mimeType,
|
||||
})
|
||||
|
||||
const base64Data = fileBuffer.toString('base64')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
file: {
|
||||
name: resolvedFileName,
|
||||
mimeType,
|
||||
data: base64Data,
|
||||
size: fileBuffer.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error downloading Slack file:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Unknown error occurred'),
|
||||
},
|
||||
{ status: isPayloadSizeLimitError(error) ? 413 : 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { slackReadMessagesContract } from '@/lib/api/contracts/tools/communication/slack'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { openDMChannel } from '../utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SlackReadMessagesAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Slack read messages attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Authenticated Slack read messages request via ${authResult.authType}`,
|
||||
{
|
||||
userId: authResult.userId,
|
||||
}
|
||||
)
|
||||
|
||||
const parsed = await parseRequest(slackReadMessagesContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
let channel = validatedData.channel
|
||||
if (!channel && validatedData.userId) {
|
||||
logger.info(`[${requestId}] Opening DM channel for user: ${validatedData.userId}`)
|
||||
channel = await openDMChannel(
|
||||
validatedData.accessToken,
|
||||
validatedData.userId,
|
||||
requestId,
|
||||
logger
|
||||
)
|
||||
}
|
||||
|
||||
const url = new URL('https://slack.com/api/conversations.history')
|
||||
url.searchParams.append('channel', channel!)
|
||||
const limit = validatedData.limit ?? 10
|
||||
url.searchParams.append('limit', String(limit))
|
||||
|
||||
if (validatedData.oldest) {
|
||||
url.searchParams.append('oldest', validatedData.oldest)
|
||||
}
|
||||
if (validatedData.latest) {
|
||||
url.searchParams.append('latest', validatedData.latest)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Reading Slack messages`, {
|
||||
channel,
|
||||
limit,
|
||||
})
|
||||
|
||||
const slackResponse = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
const data = await slackResponse.json()
|
||||
|
||||
if (!data.ok) {
|
||||
logger.error(`[${requestId}] Slack API error:`, data)
|
||||
|
||||
if (data.error === 'not_in_channel') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
'Bot is not in the channel. Please invite the Sim bot to your Slack channel by typing: /invite @Sim Studio',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (data.error === 'channel_not_found') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Channel not found. Please check the channel ID and try again.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (data.error === 'missing_scope') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
'Missing required permissions. Reconnect your Slack account to grant channel history access (channels:history, groups:history). Reading direct message history is not supported with the Sim bot.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: data.error || 'Failed to fetch messages',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const messages = (data.messages || []).map((message: any) => ({
|
||||
type: message.type || 'message',
|
||||
ts: message.ts,
|
||||
text: message.text || '',
|
||||
user: message.user,
|
||||
bot_id: message.bot_id,
|
||||
username: message.username,
|
||||
channel: message.channel,
|
||||
team: message.team,
|
||||
thread_ts: message.thread_ts,
|
||||
parent_user_id: message.parent_user_id,
|
||||
reply_count: message.reply_count,
|
||||
reply_users_count: message.reply_users_count,
|
||||
latest_reply: message.latest_reply,
|
||||
subscribed: message.subscribed,
|
||||
last_read: message.last_read,
|
||||
unread_count: message.unread_count,
|
||||
subtype: message.subtype,
|
||||
reactions: message.reactions?.map((reaction: any) => ({
|
||||
name: reaction.name,
|
||||
count: reaction.count,
|
||||
users: reaction.users || [],
|
||||
})),
|
||||
is_starred: message.is_starred,
|
||||
pinned_to: message.pinned_to,
|
||||
files: message.files?.map((file: any) => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
mimetype: file.mimetype,
|
||||
size: file.size,
|
||||
url_private: file.url_private,
|
||||
permalink: file.permalink,
|
||||
mode: file.mode,
|
||||
})),
|
||||
attachments: message.attachments,
|
||||
blocks: message.blocks,
|
||||
edited: message.edited
|
||||
? {
|
||||
user: message.edited.user,
|
||||
ts: message.edited.ts,
|
||||
}
|
||||
: undefined,
|
||||
permalink: message.permalink,
|
||||
}))
|
||||
|
||||
logger.info(`[${requestId}] Successfully read ${messages.length} messages`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
messages,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error reading Slack messages:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Unknown error occurred'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { slackRemoveReactionContract } from '@/lib/api/contracts/tools/communication/slack'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(slackRemoveReactionContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
const slackResponse = await fetch('https://slack.com/api/reactions.remove', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: validatedData.channel,
|
||||
timestamp: validatedData.timestamp,
|
||||
name: validatedData.name,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await slackResponse.json()
|
||||
|
||||
if (!data.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: data.error || 'Failed to remove reaction',
|
||||
},
|
||||
{ status: slackResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
content: `Successfully removed :${validatedData.name}: reaction`,
|
||||
metadata: {
|
||||
channel: validatedData.channel,
|
||||
timestamp: validatedData.timestamp,
|
||||
reaction: validatedData.name,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Unknown error occurred'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { slackSendEphemeralContract } from '@/lib/api/contracts/tools/communication/slack'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SlackSendEphemeralAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Slack ephemeral send attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Authenticated Slack ephemeral send request via ${authResult.authType}`,
|
||||
{ userId: authResult.userId }
|
||||
)
|
||||
|
||||
const parsed = await parseRequest(slackSendEphemeralContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Sending ephemeral message`, {
|
||||
channel: validatedData.channel,
|
||||
user: validatedData.user,
|
||||
threadTs: validatedData.thread_ts ?? undefined,
|
||||
})
|
||||
|
||||
const response = await fetch('https://slack.com/api/chat.postEphemeral', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: validatedData.channel,
|
||||
user: validatedData.user,
|
||||
text: validatedData.text,
|
||||
...(validatedData.thread_ts && { thread_ts: validatedData.thread_ts }),
|
||||
...(validatedData.blocks &&
|
||||
validatedData.blocks.length > 0 && { blocks: validatedData.blocks }),
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
logger.error(`[${requestId}] Slack API error:`, data.error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: data.error || 'Failed to send ephemeral message' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Ephemeral message sent successfully`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
messageTs: data.message_ts,
|
||||
channel: validatedData.channel,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error sending ephemeral message:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Unknown error occurred'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { slackSendMessageContract } from '@/lib/api/contracts/tools/communication/slack'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
|
||||
import { FileAccessDeniedError } from '@/app/api/files/authorization'
|
||||
import { sendSlackMessage } from '@/app/api/tools/slack/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SlackSendMessageAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Slack send attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const userId = authResult.userId
|
||||
logger.info(`[${requestId}] Authenticated Slack send request via ${authResult.authType}`, {
|
||||
userId,
|
||||
})
|
||||
|
||||
const parsed = await parseRequest(slackSendMessageContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
const isDM = !!validatedData.userId
|
||||
logger.info(`[${requestId}] Sending Slack message`, {
|
||||
channel: validatedData.channel,
|
||||
userId: validatedData.userId,
|
||||
isDM,
|
||||
hasFiles: !!(validatedData.files && validatedData.files.length > 0),
|
||||
fileCount: validatedData.files?.length || 0,
|
||||
})
|
||||
|
||||
const result = await sendSlackMessage(
|
||||
{
|
||||
accessToken: validatedData.accessToken,
|
||||
channel: validatedData.channel ?? undefined,
|
||||
userId: validatedData.userId ?? undefined,
|
||||
ownerUserId: userId,
|
||||
text: validatedData.text,
|
||||
threadTs: validatedData.thread_ts ?? undefined,
|
||||
blocks: validatedData.blocks ?? undefined,
|
||||
files: validatedData.files ?? undefined,
|
||||
},
|
||||
requestId,
|
||||
logger
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ success: false, error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, output: result.output })
|
||||
} catch (error) {
|
||||
if (error instanceof FileAccessDeniedError) {
|
||||
return NextResponse.json({ success: false, error: 'File not found' }, { status: 404 })
|
||||
}
|
||||
const notReady = docNotReadyResponse(error)
|
||||
if (notReady) return notReady
|
||||
logger.error(`[${requestId}] Error sending Slack message:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Unknown error occurred'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { slackUpdateMessageContract } from '@/lib/api/contracts/tools/communication/slack'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SlackUpdateMessageAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized Slack update message attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: authResult.error || 'Authentication required',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Authenticated Slack update message request via ${authResult.authType}`,
|
||||
{
|
||||
userId: authResult.userId,
|
||||
}
|
||||
)
|
||||
|
||||
const parsed = await parseRequest(slackUpdateMessageContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Updating Slack message`, {
|
||||
channel: validatedData.channel,
|
||||
timestamp: validatedData.timestamp,
|
||||
})
|
||||
|
||||
const slackResponse = await fetch('https://slack.com/api/chat.update', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${validatedData.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: validatedData.channel,
|
||||
ts: validatedData.timestamp,
|
||||
text: validatedData.text,
|
||||
...(validatedData.blocks &&
|
||||
validatedData.blocks.length > 0 && { blocks: validatedData.blocks }),
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await slackResponse.json()
|
||||
|
||||
if (!data.ok) {
|
||||
logger.error(`[${requestId}] Slack API error:`, data)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: data.error || 'Failed to update message',
|
||||
},
|
||||
{ status: slackResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Message updated successfully`, {
|
||||
channel: data.channel,
|
||||
timestamp: data.ts,
|
||||
})
|
||||
|
||||
const messageObj = data.message || {
|
||||
type: 'message',
|
||||
ts: data.ts,
|
||||
text: data.text || validatedData.text,
|
||||
channel: data.channel,
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
message: messageObj,
|
||||
content: 'Message updated successfully',
|
||||
metadata: {
|
||||
channel: data.channel,
|
||||
timestamp: data.ts,
|
||||
text: data.text || validatedData.text,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating Slack message:`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getErrorMessage(error, 'Unknown error occurred'),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { slackUsersListOrDetailContract } from '@/lib/api/contracts/selectors/slack'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('SlackUsersAPI')
|
||||
|
||||
const SLACK_PAGE_LIMIT = 200
|
||||
const SLACK_MAX_USER_PAGES = 10
|
||||
|
||||
interface SlackUser {
|
||||
id: string
|
||||
name: string
|
||||
real_name: string
|
||||
deleted: boolean
|
||||
is_bot: boolean
|
||||
}
|
||||
|
||||
interface SlackUsersResult {
|
||||
members: SlackUser[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const requestId = generateRequestId()
|
||||
const parsed = await parseRequest(slackUsersListOrDetailContract, request, {})
|
||||
if (!parsed.success) {
|
||||
logger.error('Missing credential in request')
|
||||
return parsed.response
|
||||
}
|
||||
const { credential, workflowId, userId } = parsed.data.body
|
||||
|
||||
if (userId !== undefined && userId !== null) {
|
||||
const validation = validateAlphanumericId(userId, 'userId', 100)
|
||||
if (!validation.isValid) {
|
||||
logger.warn('Invalid Slack user ID', { userId, error: validation.error })
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
let accessToken: string
|
||||
const isBotToken = credential.startsWith('xoxb-')
|
||||
|
||||
if (isBotToken) {
|
||||
accessToken = credential
|
||||
logger.info('Using direct bot token for Slack API')
|
||||
} else {
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
const resolvedToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!resolvedToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
authRequired: true,
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
accessToken = resolvedToken
|
||||
logger.info('Using OAuth token for Slack API')
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const userData = await fetchSlackUser(accessToken, userId)
|
||||
const user = {
|
||||
id: userData.user.id,
|
||||
name: userData.user.name,
|
||||
real_name: userData.user.real_name || userData.user.name,
|
||||
}
|
||||
logger.info(`Successfully fetched Slack user: ${userId}`)
|
||||
return NextResponse.json({ user })
|
||||
}
|
||||
|
||||
const data = await fetchSlackUsers(accessToken)
|
||||
if (data.truncated) {
|
||||
logger.warn('users.list hit pagination cap; user list may be incomplete')
|
||||
}
|
||||
|
||||
const users = (data.members || [])
|
||||
.filter((user: SlackUser) => !user.deleted && !user.is_bot)
|
||||
.map((user: SlackUser) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
real_name: user.real_name || user.name,
|
||||
}))
|
||||
|
||||
logger.info(`Successfully fetched ${users.length} Slack users`, {
|
||||
total: data.members?.length || 0,
|
||||
tokenType: isBotToken ? 'bot_token' : 'oauth',
|
||||
})
|
||||
return NextResponse.json({ users })
|
||||
} catch (error) {
|
||||
logger.error('Error processing Slack users request:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to retrieve Slack users', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchSlackUser(accessToken: string, userId: string) {
|
||||
const url = new URL('https://slack.com/api/users.info')
|
||||
url.searchParams.append('user', userId)
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Slack API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch user')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists Slack workspace members, following `response_metadata.next_cursor` so
|
||||
* the full set is returned. Bounded by `SLACK_MAX_USER_PAGES`; sets `truncated`
|
||||
* rather than silently dropping members when the cap is hit.
|
||||
*/
|
||||
async function fetchSlackUsers(accessToken: string): Promise<SlackUsersResult> {
|
||||
const members: SlackUser[] = []
|
||||
let cursor: string | undefined
|
||||
let truncated = false
|
||||
|
||||
for (let page = 0; page < SLACK_MAX_USER_PAGES; page++) {
|
||||
const url = new URL('https://slack.com/api/users.list')
|
||||
url.searchParams.append('limit', String(SLACK_PAGE_LIMIT))
|
||||
if (cursor) {
|
||||
url.searchParams.append('cursor', cursor)
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Slack API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch users')
|
||||
}
|
||||
|
||||
if (Array.isArray(data.members)) {
|
||||
members.push(...data.members)
|
||||
}
|
||||
|
||||
cursor = data.response_metadata?.next_cursor?.trim() || undefined
|
||||
if (!cursor) {
|
||||
return { members, truncated }
|
||||
}
|
||||
if (page === SLACK_MAX_USER_PAGES - 1) {
|
||||
truncated = true
|
||||
}
|
||||
}
|
||||
|
||||
return { members, truncated }
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import type { Logger } from '@sim/logger'
|
||||
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
|
||||
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import { FileAccessDeniedError, verifyFileAccess } from '@/app/api/files/authorization'
|
||||
import type { ToolFileData } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Sends a message to a Slack channel using chat.postMessage
|
||||
*/
|
||||
async function postSlackMessage(
|
||||
accessToken: string,
|
||||
channel: string,
|
||||
text: string,
|
||||
threadTs?: string | null,
|
||||
blocks?: unknown[] | null
|
||||
): Promise<{ ok: boolean; ts?: string; channel?: string; message?: any; error?: string }> {
|
||||
const response = await fetch('https://slack.com/api/chat.postMessage', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel,
|
||||
text,
|
||||
...(threadTs && { thread_ts: threadTs }),
|
||||
...(blocks && blocks.length > 0 && { blocks }),
|
||||
}),
|
||||
})
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a default message object when the API doesn't return one
|
||||
*/
|
||||
export function createDefaultMessageObject(
|
||||
ts: string,
|
||||
text: string,
|
||||
channel: string
|
||||
): Record<string, any> {
|
||||
return {
|
||||
type: 'message',
|
||||
ts,
|
||||
text,
|
||||
channel,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the success response for a sent message
|
||||
*/
|
||||
export function formatMessageSuccessResponse(
|
||||
data: any,
|
||||
text: string
|
||||
): {
|
||||
message: any
|
||||
ts: string
|
||||
channel: string
|
||||
} {
|
||||
const messageObj = data.message || createDefaultMessageObject(data.ts, text, data.channel)
|
||||
return {
|
||||
message: messageObj,
|
||||
ts: data.ts,
|
||||
channel: data.channel,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads files to Slack and returns the uploaded file IDs
|
||||
*/
|
||||
async function uploadFilesToSlack(
|
||||
files: any[],
|
||||
accessToken: string,
|
||||
requestId: string,
|
||||
logger: Logger,
|
||||
ownerUserId: string
|
||||
): Promise<{ fileIds: string[]; files: ToolFileData[] }> {
|
||||
const userFiles = processFilesToUserFiles(files, requestId, logger)
|
||||
const uploadedFileIds: string[] = []
|
||||
const uploadedFiles: ToolFileData[] = []
|
||||
|
||||
for (const userFile of userFiles) {
|
||||
logger.info(`[${requestId}] Uploading file: ${userFile.name}`)
|
||||
|
||||
const hasAccess = await verifyFileAccess(userFile.key, ownerUserId)
|
||||
if (!hasAccess) {
|
||||
throw new FileAccessDeniedError()
|
||||
}
|
||||
|
||||
const { buffer, contentType } = await downloadServableFileFromStorage(
|
||||
userFile,
|
||||
requestId,
|
||||
logger
|
||||
)
|
||||
|
||||
const getUrlResponse = await fetch('https://slack.com/api/files.getUploadURLExternal', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
filename: userFile.name,
|
||||
length: buffer.length.toString(),
|
||||
}),
|
||||
})
|
||||
|
||||
const urlData = await getUrlResponse.json()
|
||||
|
||||
if (!urlData.ok) {
|
||||
logger.error(`[${requestId}] Failed to get upload URL:`, urlData.error)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Got upload URL for ${userFile.name}, file_id: ${urlData.file_id}`)
|
||||
|
||||
const uploadResponse = await secureFetchWithValidation(
|
||||
urlData.upload_url,
|
||||
{
|
||||
method: 'POST',
|
||||
body: buffer,
|
||||
},
|
||||
'uploadUrl'
|
||||
)
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
logger.error(`[${requestId}] Failed to upload file data: ${uploadResponse.status}`)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] File data uploaded successfully`)
|
||||
uploadedFileIds.push(urlData.file_id)
|
||||
// Only add to uploadedFiles after successful upload to keep arrays in sync
|
||||
uploadedFiles.push({
|
||||
name: userFile.name,
|
||||
mimeType: contentType || userFile.type || 'application/octet-stream',
|
||||
data: buffer.toString('base64'),
|
||||
size: buffer.length,
|
||||
})
|
||||
}
|
||||
|
||||
return { fileIds: uploadedFileIds, files: uploadedFiles }
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes the file upload process by associating files with a channel
|
||||
*/
|
||||
async function completeSlackFileUpload(
|
||||
uploadedFileIds: string[],
|
||||
channel: string,
|
||||
text: string,
|
||||
accessToken: string,
|
||||
threadTs?: string | null,
|
||||
blocks?: unknown[] | null
|
||||
): Promise<{ ok: boolean; files?: any[]; error?: string }> {
|
||||
const response = await fetch('https://slack.com/api/files.completeUploadExternal', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: uploadedFileIds.map((id) => ({ id })),
|
||||
channel_id: channel,
|
||||
// Per Slack docs for files.completeUploadExternal: if `initial_comment`
|
||||
// is provided, `blocks` is silently ignored. So when blocks are present
|
||||
// we omit initial_comment and let blocks render instead.
|
||||
...(blocks && blocks.length > 0 ? { blocks } : { initial_comment: text }),
|
||||
...(threadTs && { thread_ts: threadTs }),
|
||||
}),
|
||||
})
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message object for file uploads
|
||||
*/
|
||||
export function createFileMessageObject(
|
||||
text: string,
|
||||
channel: string,
|
||||
files: any[]
|
||||
): Record<string, any> {
|
||||
const fileTs = files?.[0]?.created?.toString() || (Date.now() / 1000).toString()
|
||||
return {
|
||||
type: 'message',
|
||||
ts: fileTs,
|
||||
text,
|
||||
channel,
|
||||
files: files?.map((file: any) => ({
|
||||
id: file?.id,
|
||||
name: file?.name,
|
||||
mimetype: file?.mimetype,
|
||||
size: file?.size,
|
||||
url_private: file?.url_private,
|
||||
permalink: file?.permalink,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a DM channel with a user and returns the channel ID
|
||||
*/
|
||||
export async function openDMChannel(
|
||||
accessToken: string,
|
||||
userId: string,
|
||||
requestId: string,
|
||||
logger: Logger
|
||||
): Promise<string> {
|
||||
const response = await fetch('https://slack.com/api/conversations.open', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
users: userId,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
logger.error(`[${requestId}] Failed to open DM channel:`, data.error)
|
||||
throw new Error(data.error || 'Failed to open DM channel with user')
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Opened DM channel: ${data.channel.id}`)
|
||||
return data.channel.id
|
||||
}
|
||||
|
||||
export interface SlackMessageParams {
|
||||
accessToken: string
|
||||
channel?: string
|
||||
userId?: string
|
||||
ownerUserId: string
|
||||
text: string
|
||||
threadTs?: string | null
|
||||
blocks?: unknown[] | null
|
||||
files?: any[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a Slack message with optional file attachments
|
||||
* Supports both channel messages and direct messages via userId
|
||||
*/
|
||||
export async function sendSlackMessage(
|
||||
params: SlackMessageParams,
|
||||
requestId: string,
|
||||
logger: Logger
|
||||
): Promise<{
|
||||
success: boolean
|
||||
output?: {
|
||||
message: any
|
||||
ts: string
|
||||
channel: string
|
||||
fileCount?: number
|
||||
files?: ToolFileData[]
|
||||
}
|
||||
error?: string
|
||||
}> {
|
||||
const { accessToken, text, threadTs, blocks, files, ownerUserId } = params
|
||||
let { channel } = params
|
||||
|
||||
if (!channel && params.userId) {
|
||||
logger.info(`[${requestId}] Opening DM channel for user: ${params.userId}`)
|
||||
channel = await openDMChannel(accessToken, params.userId, requestId, logger)
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
return { success: false, error: 'Either channel or userId is required' }
|
||||
}
|
||||
|
||||
// No files - simple message
|
||||
if (!files || files.length === 0) {
|
||||
logger.info(`[${requestId}] No files, using chat.postMessage`)
|
||||
|
||||
const data = await postSlackMessage(accessToken, channel, text, threadTs, blocks)
|
||||
|
||||
if (!data.ok) {
|
||||
logger.error(`[${requestId}] Slack API error:`, data.error)
|
||||
return { success: false, error: data.error || 'Failed to send message' }
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Message sent successfully`)
|
||||
return { success: true, output: formatMessageSuccessResponse(data, text) }
|
||||
}
|
||||
|
||||
// Process files
|
||||
logger.info(`[${requestId}] Processing ${files.length} file(s)`)
|
||||
const { fileIds, files: uploadedFiles } = await uploadFilesToSlack(
|
||||
files,
|
||||
accessToken,
|
||||
requestId,
|
||||
logger,
|
||||
ownerUserId
|
||||
)
|
||||
|
||||
// No valid files uploaded - send text-only
|
||||
if (fileIds.length === 0) {
|
||||
logger.warn(`[${requestId}] No valid files to upload, sending text-only message`)
|
||||
|
||||
const data = await postSlackMessage(accessToken, channel, text, threadTs, blocks)
|
||||
|
||||
if (!data.ok) {
|
||||
return { success: false, error: data.error || 'Failed to send message' }
|
||||
}
|
||||
|
||||
return { success: true, output: formatMessageSuccessResponse(data, text) }
|
||||
}
|
||||
|
||||
// Complete file upload with thread support
|
||||
const completeData = await completeSlackFileUpload(
|
||||
fileIds,
|
||||
channel,
|
||||
text,
|
||||
accessToken,
|
||||
threadTs,
|
||||
blocks
|
||||
)
|
||||
|
||||
if (!completeData.ok) {
|
||||
logger.error(`[${requestId}] Failed to complete upload:`, completeData.error)
|
||||
return { success: false, error: completeData.error || 'Failed to complete file upload' }
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Files uploaded and shared successfully`)
|
||||
|
||||
const fileMessage = createFileMessageObject(text, channel, completeData.files || [])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: fileMessage,
|
||||
ts: fileMessage.ts,
|
||||
channel,
|
||||
fileCount: fileIds.length,
|
||||
files: uploadedFiles,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user