diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 84e90211bd8..f9c4a9be41f 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -73,10 +73,7 @@ reviews: - Keep the entire summary under 300 words. auto_review: enabled: true - drafts: false - ignore_title_keywords: - - "WIP" - - "DO NOT MERGE" + drafts: true auto_pause_after_reviewed_commits: 5 path_instructions: - path: "apps/api/**" diff --git a/apps/api/src/app/agents/agents.module.ts b/apps/api/src/app/agents/agents.module.ts index c1f17ad03b3..bcde8fd772f 100644 --- a/apps/api/src/app/agents/agents.module.ts +++ b/apps/api/src/app/agents/agents.module.ts @@ -11,6 +11,7 @@ import { EventsModule } from '../events/events.module'; import { SharedModule } from '../shared/shared.module'; import { AgentsController } from './agents.controller'; import { AgentsWebhookController } from './agents-webhook.controller'; +import { AgentAttachmentStorage } from './services/agent-attachment-storage.service'; import { AgentConfigResolver } from './services/agent-config-resolver.service'; import { AgentConversationService } from './services/agent-conversation.service'; import { AgentInboundHandler } from './services/agent-inbound-handler.service'; @@ -28,6 +29,7 @@ import { USE_CASES } from './usecases'; ChannelEndpointRepository, ConversationRepository, ConversationActivityRepository, + AgentAttachmentStorage, AgentConfigResolver, AgentSubscriberResolver, AgentConversationService, diff --git a/apps/api/src/app/agents/services/agent-attachment-storage.service.spec.ts b/apps/api/src/app/agents/services/agent-attachment-storage.service.spec.ts new file mode 100644 index 00000000000..00f597d3867 --- /dev/null +++ b/apps/api/src/app/agents/services/agent-attachment-storage.service.spec.ts @@ -0,0 +1,282 @@ +import type { StorageService } from '@novu/application-generic'; +import { expect } from 'chai'; +import type { Attachment } from 'chat'; +import sinon from 'sinon'; +import { AgentAttachmentStorage, READ_URL_TTL_SECONDS } from './agent-attachment-storage.service'; + +describe('AgentAttachmentStorage', () => { + const mb = 1024 * 1024; + const ctx = { + organizationId: 'org1', + environmentId: 'env1', + conversationId: 'conv1', + platformMessageId: 'msg1', + }; + + function makeLogger() { + return { + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), + info: sinon.stub(), + setContext: sinon.stub(), + }; + } + + function makeStorageService() { + return { + uploadFile: sinon.stub().resolves({}), + getReadSignedUrl: sinon.stub().resolves('https://signed/read'), + fileExists: sinon.stub(), + } as unknown as StorageService; + } + + it('should upload and return signed url for fetchData attachment', async () => { + const uploadFile = sinon.stub().resolves({}); + const getReadSignedUrl = sinon.stub().resolves('https://signed/read'); + const storageService = { + uploadFile, + getReadSignedUrl, + fileExists: sinon.stub(), + } as unknown as StorageService; + + const service = new AgentAttachmentStorage(storageService, makeLogger() as any); + + const attachment: Attachment = { + type: 'file', + name: 'doc.pdf', + mimeType: 'application/pdf', + size: 10, + fetchData: async () => Buffer.from('hello'), + }; + + const result = await service.storeInbound([attachment], ctx); + + expect(result).to.have.length(1); + expect(result[0].url).to.equal('https://signed/read'); + expect(result[0].storageKey).to.include('org1/env1/agents/conv1/msg1/0-doc.pdf'); + expect(uploadFile.calledOnce).to.equal(true); + expect(getReadSignedUrl.calledOnce).to.equal(true); + expect(getReadSignedUrl.firstCall.args[1]).to.equal(READ_URL_TTL_SECONDS); + }); + + it('should keep uploaded attachment metadata when signing fails', async () => { + const uploadFile = sinon.stub().resolves({}); + const getReadSignedUrl = sinon.stub().rejects(new Error('signing unavailable')); + const storageService = { + uploadFile, + getReadSignedUrl, + fileExists: sinon.stub(), + } as unknown as StorageService; + const logger = makeLogger(); + + const service = new AgentAttachmentStorage(storageService, logger as any); + + const attachment: Attachment = { + type: 'file', + name: 'doc.pdf', + mimeType: 'application/pdf', + size: 10, + fetchData: async () => Buffer.from('hello'), + }; + + const result = await service.storeInbound([attachment], ctx); + + expect(result).to.have.length(1); + expect(result[0]).to.include({ + type: 'file', + name: 'doc.pdf', + mimeType: 'application/pdf', + size: 10, + }); + expect(result[0].storageKey).to.include('org1/env1/agents/conv1/msg1/0-doc.pdf'); + expect(result[0].url).to.equal(undefined); + expect(uploadFile.calledOnce).to.equal(true); + expect(getReadSignedUrl.calledOnce).to.equal(true); + expect(logger.warn.calledOnce).to.equal(true); + }); + + it('should process at most 15 inbound attachments and preserve original indexes', async () => { + const storageService = makeStorageService(); + const logger = makeLogger(); + const service = new AgentAttachmentStorage(storageService, logger as any); + const fetchDataStubs = Array.from({ length: 16 }, () => sinon.stub().resolves(Buffer.from('x'))); + const attachments = fetchDataStubs.map((fetchData, index) => ({ + type: 'file', + name: `file-${index}.txt`, + mimeType: 'text/plain', + size: 1, + fetchData, + })) as Attachment[]; + + const result = await service.storeInbound(attachments, ctx); + + expect(result).to.have.length(15); + expect(storageService.uploadFile.callCount).to.equal(15); + expect(fetchDataStubs[15].called).to.equal(false); + expect(result[14].storageKey).to.include('org1/env1/agents/conv1/msg1/14-file-14.txt'); + expect(logger.warn.calledWithMatch({ attachmentCount: 16, cap: 15 })).to.equal(true); + }); + + it('should skip known-size attachments that would exceed the aggregate byte cap before fetch', async () => { + const storageService = makeStorageService(); + const logger = makeLogger(); + const service = new AgentAttachmentStorage(storageService, logger as any); + const fetchDataStubs = [ + sinon.stub().resolves(Buffer.from('a')), + sinon.stub().resolves(Buffer.from('b')), + sinon.stub().resolves(Buffer.from('c')), + ]; + const attachments = fetchDataStubs.map((fetchData, index) => ({ + type: 'file', + name: `known-${index}.txt`, + mimeType: 'text/plain', + size: 20 * mb, + fetchData, + })) as Attachment[]; + + const result = await service.storeInbound(attachments, ctx); + + expect(result).to.have.length(2); + expect(storageService.uploadFile.callCount).to.equal(2); + expect(fetchDataStubs[2].called).to.equal(false); + expect(logger.warn.calledWithMatch({ size: 20 * mb, aggregateCap: 50 * mb })).to.equal(true); + }); + + it('should skip fetchData attachments without size metadata before downloading', async () => { + const storageService = makeStorageService(); + const logger = makeLogger(); + const service = new AgentAttachmentStorage(storageService, logger as any); + const fetchData = sinon.stub().resolves(Buffer.from('x')); + const attachments = [{ type: 'file', name: 'unknown.bin', fetchData }] as Attachment[]; + + const result = await service.storeInbound(attachments, ctx); + + expect(result).to.have.length(0); + expect(fetchData.called).to.equal(false); + expect(storageService.uploadFile.called).to.equal(false); + expect(logger.warn.called).to.equal(true); + }); + + it('should skip blob attachments when trusted size metadata is missing', async () => { + const storageService = makeStorageService(); + const logger = makeLogger(); + const service = new AgentAttachmentStorage(storageService, logger as any); + const blob = new Blob([Buffer.from('x')]); + const attachment = { + type: 'file', + name: 'blob.bin', + data: blob, + } as Attachment; + + const result = await service.storeInbound([attachment], ctx); + + expect(result).to.have.length(0); + expect(storageService.uploadFile.called).to.equal(false); + expect(logger.warn.called).to.equal(true); + }); + + it('should skip attachments that exceed aggregate cap after fetch when size metadata is inaccurate', async () => { + const storageService = makeStorageService(); + const logger = makeLogger(); + const service = new AgentAttachmentStorage(storageService, logger as any); + const attachments = [ + { + type: 'file', + name: 'file-0.bin', + size: 24 * mb, + fetchData: async () => Buffer.alloc(24 * mb), + }, + { + type: 'file', + name: 'file-1.bin', + size: 25 * mb, + fetchData: async () => Buffer.alloc(25 * mb), + }, + { + type: 'file', + name: 'file-2.bin', + size: 1, + fetchData: async () => Buffer.alloc(2 * mb), + }, + ] as Attachment[]; + + const result = await service.storeInbound(attachments, ctx); + + expect(result).to.have.length(2); + expect(storageService.uploadFile.callCount).to.equal(2); + expect(logger.warn.calledWithMatch({ byteLength: 2 * mb, aggregateCap: 50 * mb })).to.equal(true); + }); + + it('should skip attachment over pre-fetch size limit', async () => { + const storageService = { + uploadFile: sinon.stub(), + getReadSignedUrl: sinon.stub(), + fileExists: sinon.stub(), + } as unknown as StorageService; + + const logger = makeLogger(); + const service = new AgentAttachmentStorage(storageService, logger as any); + + const attachment: Attachment = { + type: 'file', + size: 26 * 1024 * 1024, + fetchData: async () => Buffer.from('x'), + }; + + const result = await service.storeInbound([attachment], ctx); + + expect(result).to.have.length(0); + expect(storageService.uploadFile.called).to.equal(false); + expect(logger.warn.calledOnce).to.equal(true); + }); + + it('should skip attachment over post-fetch size limit when size metadata is inaccurate', async () => { + const storageService = { + uploadFile: sinon.stub(), + getReadSignedUrl: sinon.stub(), + fileExists: sinon.stub(), + } as unknown as StorageService; + + const logger = makeLogger(); + const service = new AgentAttachmentStorage(storageService, logger as any); + + const huge = Buffer.alloc(26 * 1024 * 1024); + const attachment: Attachment = { + type: 'file', + size: 1, + fetchData: async () => huge, + }; + + const result = await service.storeInbound([attachment], ctx); + + expect(result).to.have.length(0); + expect(storageService.uploadFile.called).to.equal(false); + }); + + it('should signRead when object exists', async () => { + const storageService = { + fileExists: sinon.stub().resolves(true), + getReadSignedUrl: sinon.stub().resolves('https://read'), + } as unknown as StorageService; + + const service = new AgentAttachmentStorage(storageService, makeLogger() as any); + const url = await service.signRead('org/env/agents/conv/msg/0-f.txt'); + + expect(url).to.equal('https://read'); + expect(storageService.fileExists.calledOnce).to.equal(true); + }); + + it('should return null from signRead when object missing', async () => { + const storageService = { + fileExists: sinon.stub().resolves(false), + getReadSignedUrl: sinon.stub(), + } as unknown as StorageService; + + const service = new AgentAttachmentStorage(storageService, makeLogger() as any); + const url = await service.signRead('missing-key'); + + expect(url).to.equal(null); + expect(storageService.getReadSignedUrl.called).to.equal(false); + }); +}); diff --git a/apps/api/src/app/agents/services/agent-attachment-storage.service.ts b/apps/api/src/app/agents/services/agent-attachment-storage.service.ts new file mode 100644 index 00000000000..e1e82b9d2bc --- /dev/null +++ b/apps/api/src/app/agents/services/agent-attachment-storage.service.ts @@ -0,0 +1,257 @@ +import { Injectable } from '@nestjs/common'; +import { PinoLogger, StorageService } from '@novu/application-generic'; +import type { Attachment } from 'chat'; + +export interface StoredAttachment { + type: string; + name?: string; + mimeType?: string; + size?: number; + storageKey: string; + url?: string; +} + +export interface StoreInboundAttachmentContext { + organizationId: string; + environmentId: string; + conversationId: string; + platformMessageId: string; +} + +const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; +const MAX_ATTACHMENTS_PER_MESSAGE = 15; +const MAX_AGGREGATE_ATTACHMENT_BYTES = 50 * 1024 * 1024; +export const READ_URL_TTL_SECONDS = 15 * 60; +const AGENTS_FOLDER = 'agents'; + +function sanitizeFilenameSegment(name: string): string { + const base = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 100); + + return base || 'file'; +} + +function buildStorageKey(params: { + organizationId: string; + environmentId: string; + conversationId: string; + platformMessageId: string; + index: number; + filename: string; +}): string { + const safeMessageId = String(params.platformMessageId).replace(/\//g, '_'); + + return `${params.organizationId}/${params.environmentId}/${AGENTS_FOLDER}/${params.conversationId}/${safeMessageId}/${params.index}-${params.filename}`; +} + +async function bufferFromAttachment(attachment: Attachment): Promise { + if (!attachment.data) { + if (attachment.size == null) { + throw new Error('Inbound attachment size is required before download'); + } + + if (attachment.size > MAX_ATTACHMENT_BYTES) { + throw new Error('Inbound attachment exceeds size limit'); + } + + if (typeof attachment.fetchData === 'function') { + return await attachment.fetchData(); + } + + return null; + } + + if (Buffer.isBuffer(attachment.data)) { + if (attachment.data.length > MAX_ATTACHMENT_BYTES) { + throw new Error('Inbound attachment buffer exceeds size limit'); + } + + return attachment.data; + } + + const blob = attachment.data as Blob; + + if (typeof blob.arrayBuffer === 'function') { + if (attachment.size == null) { + throw new Error('Inbound attachment size is required before reading blob data'); + } + + if (attachment.size > MAX_ATTACHMENT_BYTES) { + throw new Error('Inbound attachment exceeds size limit'); + } + + if (blob.size !== attachment.size) { + throw new Error('Inbound attachment blob size does not match trusted size metadata'); + } + + const ab = await blob.arrayBuffer(); + + return Buffer.from(ab); + } + + return null; +} + +@Injectable() +export class AgentAttachmentStorage { + constructor( + private readonly storageService: StorageService, + private readonly logger: PinoLogger + ) { + this.logger.setContext(this.constructor.name); + } + + async storeInbound( + attachments: Attachment[] | undefined, + ctx: StoreInboundAttachmentContext + ): Promise { + if (!attachments?.length) { + return []; + } + + const result: StoredAttachment[] = []; + const attachmentsToProcess = attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE); + let processedBytes = 0; + + if (attachments.length > MAX_ATTACHMENTS_PER_MESSAGE) { + this.logger.warn( + { attachmentCount: attachments.length, cap: MAX_ATTACHMENTS_PER_MESSAGE }, + 'Skipping inbound attachments over count limit' + ); + } + + for (const [index, attachment] of attachmentsToProcess.entries()) { + try { + const knownSize = attachment.size; + + if ( + knownSize != null && + knownSize <= MAX_ATTACHMENT_BYTES && + processedBytes + knownSize > MAX_AGGREGATE_ATTACHMENT_BYTES + ) { + this.logger.warn( + { + size: knownSize, + processedBytes, + aggregateCap: MAX_AGGREGATE_ATTACHMENT_BYTES, + name: attachment.name, + }, + 'Skipping inbound attachment over aggregate size limit' + ); + + continue; + } + + const stored = await this.storeOne(attachment, ctx, index, processedBytes); + + if (stored) { + processedBytes += stored.size ?? 0; + result.push(stored); + } + } catch (err) { + this.logger.warn(err, 'Inbound attachment processing failed'); + } + } + + return result; + } + + async signRead(storageKey: string): Promise { + const exists = await this.storageService.fileExists(storageKey); + + if (!exists) { + return null; + } + + return await this.storageService.getReadSignedUrl(storageKey, READ_URL_TTL_SECONDS); + } + + private async storeOne( + attachment: Attachment, + ctx: StoreInboundAttachmentContext, + index: number, + processedBytes: number + ): Promise { + try { + if (attachment.size != null && attachment.size > MAX_ATTACHMENT_BYTES) { + this.logger.warn( + { size: attachment.size, name: attachment.name }, + 'Skipping inbound attachment over size limit' + ); + + return null; + } + + const buffer = await bufferFromAttachment(attachment); + + if (!buffer) { + this.logger.warn({ name: attachment.name }, 'Inbound attachment has neither fetchData nor data'); + + return null; + } + + if (buffer.length > MAX_ATTACHMENT_BYTES) { + this.logger.warn( + { byteLength: buffer.length, name: attachment.name }, + 'Skipping inbound attachment over size limit after fetch' + ); + + return null; + } + + if (processedBytes + buffer.length > MAX_AGGREGATE_ATTACHMENT_BYTES) { + this.logger.warn( + { + byteLength: buffer.length, + processedBytes, + aggregateCap: MAX_AGGREGATE_ATTACHMENT_BYTES, + name: attachment.name, + }, + 'Skipping inbound attachment over aggregate size limit after fetch' + ); + + return null; + } + + const rawName = attachment.name ?? `file-${index}`; + const filename = sanitizeFilenameSegment(rawName); + const mimeType = attachment.mimeType ?? 'application/octet-stream'; + + const storageKey = buildStorageKey({ + organizationId: ctx.organizationId, + environmentId: ctx.environmentId, + conversationId: ctx.conversationId, + platformMessageId: ctx.platformMessageId, + index, + filename, + }); + + try { + await this.storageService.uploadFile(storageKey, buffer, mimeType); + } catch (err) { + this.logger.warn(err, 'Failed to upload inbound attachment'); + + return null; + } + + let url: string | undefined; + try { + url = await this.storageService.getReadSignedUrl(storageKey, READ_URL_TTL_SECONDS); + } catch (err) { + this.logger.warn(err, 'Failed to sign inbound attachment after upload'); + } + + return { + type: attachment.type, + name: attachment.name, + mimeType: attachment.mimeType, + size: attachment.size ?? buffer.length, + storageKey, + url, + }; + } catch (err) { + this.logger.warn(err, 'Failed to store inbound attachment'); + + return null; + } + } +} diff --git a/apps/api/src/app/agents/services/agent-inbound-handler.service.spec.ts b/apps/api/src/app/agents/services/agent-inbound-handler.service.spec.ts new file mode 100644 index 00000000000..02a4017fe16 --- /dev/null +++ b/apps/api/src/app/agents/services/agent-inbound-handler.service.spec.ts @@ -0,0 +1,156 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { AgentEventEnum } from '../dtos/agent-event.enum'; +import { AgentInboundHandler } from './agent-inbound-handler.service'; + +describe('AgentInboundHandler', () => { + const config = { + environmentId: 'env1', + organizationId: 'org1', + platform: 'slack', + integrationIdentifier: 'slack-main', + integrationId: 'integration1', + agentIdentifier: 'support-agent', + acknowledgeOnReceived: false, + }; + + const conversation = { + _id: 'conversation1', + channels: [{ platformThreadId: 'thread1', platform: 'slack', _integrationId: 'integration1' }], + }; + + function makeLogger() { + return { + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), + info: sinon.stub(), + setContext: sinon.stub(), + }; + } + + function makeHandler(overrides: { history?: any[]; storedAttachments?: any[] } = {}) { + const logger = makeLogger(); + const subscriberResolver = { + resolve: sinon.stub().resolves(null), + }; + const conversationService = { + findByPlatformThread: sinon.stub().resolves(conversation), + getHistory: sinon.stub().resolves(overrides.history ?? []), + }; + const bridgeExecutor = { + execute: sinon.stub().resolves(undefined), + }; + const subscriberRepository = { + findBySubscriberId: sinon.stub(), + }; + const analyticsService = { + track: sinon.stub(), + }; + const attachmentStorage = { + storeInbound: sinon.stub().resolves(overrides.storedAttachments ?? []), + }; + const handler = new AgentInboundHandler( + logger as any, + subscriberResolver as any, + conversationService as any, + bridgeExecutor as any, + subscriberRepository as any, + analyticsService as any, + attachmentStorage as any + ); + + return { handler, attachmentStorage, bridgeExecutor }; + } + + function makeReactionEvent() { + return { + emoji: { name: 'thumbs_up', toJSON: () => 'thumbs_up', toString: () => 'thumbs_up' }, + added: true, + messageId: 'source-msg', + message: { + id: 'source-msg', + text: 'Message with attachment', + author: { + userId: 'user1', + fullName: 'User One', + userName: 'userone', + isBot: false, + }, + attachments: [ + { + type: 'image', + name: 'image.png', + mimeType: 'image/png', + size: 123, + }, + ], + }, + thread: { + id: 'thread1', + channelId: 'channel1', + isDM: false, + }, + }; + } + + describe('handleReaction', () => { + it('should reuse stored source message attachments from history', async () => { + const { handler, attachmentStorage, bridgeExecutor } = makeHandler({ + history: [ + { + platformMessageId: 'source-msg', + richContent: { + attachments: [ + { + type: 'image', + name: 'image.png', + mimeType: 'image/png', + size: 123, + storageKey: 'org1/env1/agents/conversation1/source-msg/0-image.png', + }, + ], + }, + }, + ], + }); + + await handler.handleReaction('agent1', config as any, makeReactionEvent() as any); + + expect(attachmentStorage.storeInbound.called).to.equal(false); + const params = bridgeExecutor.execute.firstCall.args[0]; + expect(params.event).to.equal(AgentEventEnum.ON_REACTION); + expect(params.reaction.sourceMessageStoredAttachments).to.deep.equal([ + { + type: 'image', + name: 'image.png', + mimeType: 'image/png', + size: 123, + storageKey: 'org1/env1/agents/conversation1/source-msg/0-image.png', + url: undefined, + }, + ]); + }); + + it('should store source message attachments when history has no stored metadata', async () => { + const storedAttachments = [ + { + type: 'image', + name: 'image.png', + mimeType: 'image/png', + size: 123, + storageKey: 'org1/env1/agents/conversation1/source-msg/0-image.png', + url: 'https://signed/read', + }, + ]; + const { handler, attachmentStorage, bridgeExecutor } = makeHandler({ storedAttachments }); + + await handler.handleReaction('agent1', config as any, makeReactionEvent() as any); + + expect(attachmentStorage.storeInbound.calledOnce).to.equal(true); + expect(attachmentStorage.storeInbound.firstCall.args[1].platformMessageId).to.equal('source-msg'); + const params = bridgeExecutor.execute.firstCall.args[0]; + expect(params.reaction.sourceMessageStoredAttachments).to.deep.equal(storedAttachments); + }); + }); +}); diff --git a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts b/apps/api/src/app/agents/services/agent-inbound-handler.service.ts index 9f0aef69e3b..4701c15aff0 100644 --- a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts +++ b/apps/api/src/app/agents/services/agent-inbound-handler.service.ts @@ -1,11 +1,17 @@ import { Injectable } from '@nestjs/common'; import { AnalyticsService, PinoLogger } from '@novu/application-generic'; -import { ConversationActivitySenderTypeEnum, ConversationParticipantTypeEnum, SubscriberRepository } from '@novu/dal'; +import { + ConversationActivityEntity, + ConversationActivitySenderTypeEnum, + ConversationParticipantTypeEnum, + SubscriberRepository, +} from '@novu/dal'; import type { AgentAction } from '@novu/framework'; import type { EmojiValue, Message, Thread } from 'chat'; import { trackAgentInboundAction, trackAgentInboundMessage, trackAgentInboundReaction } from '../agent-analytics'; import { AgentEventEnum } from '../dtos/agent-event.enum'; import { PLATFORMS_WITH_TYPING_INDICATOR } from '../dtos/agent-platform.enum'; +import { AgentAttachmentStorage, type StoredAttachment } from './agent-attachment-storage.service'; import { ResolvedAgentConfig } from './agent-config-resolver.service'; import { AgentConversationService } from './agent-conversation.service'; import { AgentSubscriberResolver } from './agent-subscriber-resolver.service'; @@ -17,6 +23,60 @@ const ONBOARDING_NO_BRIDGE_REPLY_MARKDOWN = `*You're connected to Novu* Your bot is linked successfully. Go back to the *Novu dashboard* to complete onboarding.`; +function mapStoredAttachmentsFromRichContent(richContent?: Record): StoredAttachment[] { + const rawAttachments = richContent?.attachments; + + if (!Array.isArray(rawAttachments)) { + return []; + } + + return rawAttachments.flatMap((item) => { + if (!item || typeof item !== 'object') { + return []; + } + + const attachment = item as Record; + const storageKey = attachment.storageKey; + + if (typeof storageKey !== 'string' || storageKey.length === 0) { + return []; + } + + return [ + { + type: typeof attachment.type === 'string' ? attachment.type : 'file', + name: typeof attachment.name === 'string' ? attachment.name : undefined, + mimeType: typeof attachment.mimeType === 'string' ? attachment.mimeType : undefined, + size: typeof attachment.size === 'number' ? attachment.size : undefined, + storageKey, + url: typeof attachment.url === 'string' ? attachment.url : undefined, + }, + ]; + }); +} + +function findSourceMessageStoredAttachments( + history: ConversationActivityEntity[], + messageIds: string[] +): StoredAttachment[] | undefined { + const messageIdSet = new Set(messageIds); + const sourceActivity = history.find( + (activity) => activity.platformMessageId && messageIdSet.has(activity.platformMessageId) + ); + + if (!sourceActivity) { + return undefined; + } + + const storedAttachments = mapStoredAttachmentsFromRichContent(sourceActivity.richContent); + + if (!storedAttachments.length) { + return undefined; + } + + return storedAttachments; +} + export interface InboundReactionEvent { emoji: EmojiValue; added: boolean; @@ -34,7 +94,8 @@ export class AgentInboundHandler { private readonly conversationService: AgentConversationService, private readonly bridgeExecutor: BridgeExecutorService, private readonly subscriberRepository: SubscriberRepository, - private readonly analyticsService: AnalyticsService + private readonly analyticsService: AnalyticsService, + private readonly attachmentStorage: AgentAttachmentStorage ) { this.logger.setContext(this.constructor.name); } @@ -82,14 +143,25 @@ export class AgentInboundHandler { ? ConversationActivitySenderTypeEnum.SUBSCRIBER : ConversationActivitySenderTypeEnum.PLATFORM_USER; - const richContent = message.attachments?.length + let storedAttachments: StoredAttachment[] | undefined; + + if (message.attachments?.length) { + storedAttachments = await this.attachmentStorage.storeInbound(message.attachments, { + organizationId: config.organizationId, + environmentId: config.environmentId, + conversationId: String(conversation._id), + platformMessageId: message.id ?? `unknown-${Date.now()}`, + }); + } + + const richContent = storedAttachments?.length ? { - attachments: message.attachments.map((a) => ({ - type: a.type, - url: a.url, - name: a.name, - mimeType: a.mimeType, - size: a.size, + attachments: storedAttachments.map(({ type, name, mimeType, size, storageKey }) => ({ + type, + name, + mimeType, + size, + storageKey, })), } : undefined; @@ -176,6 +248,7 @@ export class AgentInboundHandler { channelId: thread.channelId, isDM: thread.isDM, }, + storedAttachments: message.attachments?.length ? storedAttachments : undefined, }); } catch (err) { if (err instanceof NoBridgeUrlError) { @@ -254,11 +327,26 @@ export class AgentInboundHandler { this.conversationService.getHistory(config.environmentId, conversation._id), ]); - const reaction: BridgeReaction = { + const sourceMessageIds = [event.messageId, event.message?.id].filter((id): id is string => Boolean(id)); + let sourceMessageStoredAttachments = findSourceMessageStoredAttachments(history, sourceMessageIds); + + if (!sourceMessageStoredAttachments && event.message?.attachments?.length) { + sourceMessageStoredAttachments = await this.attachmentStorage.storeInbound(event.message.attachments, { + organizationId: config.organizationId, + environmentId: config.environmentId, + conversationId: String(conversation._id), + platformMessageId: event.message.id ?? event.messageId ?? `unknown-${Date.now()}`, + }); + } + + const reactionPayload: BridgeReaction = { emoji: event.emoji.name, added: event.added, messageId: event.messageId, sourceMessage: event.message, + sourceMessageStoredAttachments: sourceMessageStoredAttachments?.length + ? sourceMessageStoredAttachments + : undefined, }; await this.bridgeExecutor.execute({ @@ -273,7 +361,7 @@ export class AgentInboundHandler { channelId: event.thread?.channelId ?? '', isDM: event.thread?.isDM ?? false, }, - reaction, + reaction: reactionPayload, }); } diff --git a/apps/api/src/app/agents/services/bridge-executor.service.spec.ts b/apps/api/src/app/agents/services/bridge-executor.service.spec.ts new file mode 100644 index 00000000000..66153da0943 --- /dev/null +++ b/apps/api/src/app/agents/services/bridge-executor.service.spec.ts @@ -0,0 +1,289 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { BridgeExecutorService } from './bridge-executor.service'; + +describe('BridgeExecutorService', () => { + function makeLogger() { + return { + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), + info: sinon.stub(), + setContext: sinon.stub(), + }; + } + + function makeActivity(overrides: Record = {}) { + return { + _id: 'activity-id', + _organizationId: 'org', + _environmentId: 'env', + _conversationId: 'conv', + ...overrides, + }; + } + + function makeMessage() { + return { + id: 'message-id', + text: 'hello', + author: { + userId: 'user-id', + fullName: 'User', + userName: 'user', + isBot: false, + }, + metadata: { + dateSent: new Date('2026-01-01T00:00:00.000Z'), + }, + }; + } + + describe('mapRichContentForBridge', () => { + it('should omit an attachment when signing fails without throwing', async () => { + const logger = makeLogger(); + const attachmentStorage = { + signRead: sinon.stub().rejects(new Error('storage unavailable')), + }; + const service = new BridgeExecutorService({} as any, logger as any, attachmentStorage as any); + + const result = await (service as any).mapRichContentForBridge( + { + attachments: [ + { + type: 'image', + storageKey: 'org/env/agents/conv/message/0-image.png', + name: 'image.png', + mimeType: 'image/png', + size: 123, + }, + ], + }, + makeActivity() + ); + + expect(result).to.deep.equal({ attachments: [] }); + expect(logger.warn.calledOnce).to.equal(true); + }); + + it('should omit an attachment when storageKey is outside the activity namespace', async () => { + const logger = makeLogger(); + const attachmentStorage = { + signRead: sinon.stub().resolves('https://signed/read'), + }; + const service = new BridgeExecutorService({} as any, logger as any, attachmentStorage as any); + + const result = await (service as any).mapRichContentForBridge( + { + attachments: [ + { + type: 'image', + storageKey: 'other-org/env/agents/conv/message/0-image.png', + name: 'image.png', + mimeType: 'image/png', + size: 123, + }, + ], + }, + makeActivity() + ); + + expect(result).to.deep.equal({ attachments: [] }); + expect(attachmentStorage.signRead.called).to.equal(false); + expect(logger.warn.calledWithMatch({ expectedPrefix: 'org/env/agents/conv/' })).to.equal(true); + }); + + it('should omit malformed attachment entries without throwing', async () => { + const logger = makeLogger(); + const attachmentStorage = { + signRead: sinon.stub().resolves('https://signed/read'), + }; + const service = new BridgeExecutorService({} as any, logger as any, attachmentStorage as any); + + const result = await (service as any).mapRichContentForBridge( + { + attachments: [null, 'bad-entry'], + }, + makeActivity() + ); + + expect(result).to.deep.equal({ attachments: [] }); + expect(attachmentStorage.signRead.called).to.equal(false); + expect(logger.warn.callCount).to.equal(2); + }); + + it('should limit concurrent history attachment signing', async () => { + let active = 0; + let maxActive = 0; + const logger = makeLogger(); + const attachmentStorage = { + signRead: sinon.stub().callsFake(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + + return 'https://signed/read'; + }), + }; + const service = new BridgeExecutorService({} as any, logger as any, attachmentStorage as any); + + await (service as any).mapRichContentForBridge( + { + attachments: Array.from({ length: 10 }, (_, index) => ({ + type: 'image', + storageKey: `org/env/agents/conv/message/${index}-image.png`, + })), + }, + makeActivity() + ); + + expect(maxActive).to.be.at.most(4); + expect(attachmentStorage.signRead.callCount).to.equal(10); + }); + + it('should not multiply attachment signing concurrency across history entries', async () => { + let active = 0; + let maxActive = 0; + const logger = makeLogger(); + const attachmentStorage = { + signRead: sinon.stub().callsFake(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + + return 'https://signed/read'; + }), + }; + const service = new BridgeExecutorService({} as any, logger as any, attachmentStorage as any); + + await (service as any).mapHistory([ + makeActivity({ + _id: 'activity-1', + richContent: { + attachments: Array.from({ length: 10 }, (_, index) => ({ + type: 'image', + storageKey: `org/env/agents/conv/message-a/${index}-image.png`, + })), + }, + }), + makeActivity({ + _id: 'activity-2', + richContent: { + attachments: Array.from({ length: 10 }, (_, index) => ({ + type: 'image', + storageKey: `org/env/agents/conv/message-b/${index}-image.png`, + })), + }, + }), + ]); + + expect(maxActive).to.be.at.most(4); + expect(attachmentStorage.signRead.callCount).to.equal(20); + }); + }); + + describe('mapMessage', () => { + it('should re-sign stored attachments instead of reusing stored urls', async () => { + const logger = makeLogger(); + const attachmentStorage = { + signRead: sinon.stub().resolves('https://fresh-signed/read'), + }; + const service = new BridgeExecutorService({} as any, logger as any, attachmentStorage as any); + + const result = await (service as any).mapMessage( + makeMessage(), + [ + { + type: 'image', + storageKey: 'org/env/agents/conv/message/0-image.png', + url: 'https://stale-signed/read', + name: 'image.png', + mimeType: 'image/png', + size: 123, + }, + ], + { + organizationId: 'org', + environmentId: 'env', + conversationId: 'conv', + } + ); + + expect(result.attachments).to.deep.equal([ + { + type: 'image', + url: 'https://fresh-signed/read', + name: 'image.png', + mimeType: 'image/png', + size: 123, + }, + ]); + expect(attachmentStorage.signRead.calledOnceWithExactly('org/env/agents/conv/message/0-image.png')).to.equal( + true + ); + }); + + it('should omit stored attachments that cannot be signed', async () => { + const logger = makeLogger(); + const attachmentStorage = { + signRead: sinon.stub().rejects(new Error('sign failed')), + }; + const service = new BridgeExecutorService({} as any, logger as any, attachmentStorage as any); + + const result = await (service as any).mapMessage( + makeMessage(), + [ + { + type: 'image', + storageKey: 'org/env/agents/conv/message/0-image.png', + url: 'https://stale-signed/read', + }, + ], + { + organizationId: 'org', + environmentId: 'env', + conversationId: 'conv', + } + ); + + expect(result.attachments).to.deep.equal([]); + expect(logger.warn.calledOnce).to.equal(true); + }); + + it('should limit concurrent stored attachment signing', async () => { + let active = 0; + let maxActive = 0; + const logger = makeLogger(); + const attachmentStorage = { + signRead: sinon.stub().callsFake(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + + return 'https://fresh-signed/read'; + }), + }; + const service = new BridgeExecutorService({} as any, logger as any, attachmentStorage as any); + + const result = await (service as any).mapMessage( + makeMessage(), + Array.from({ length: 10 }, (_, index) => ({ + type: 'image', + storageKey: `org/env/agents/conv/message/${index}-image.png`, + })), + { + organizationId: 'org', + environmentId: 'env', + conversationId: 'conv', + } + ); + + expect(result.attachments).to.have.length(10); + expect(maxActive).to.be.at.most(4); + expect(attachmentStorage.signRead.callCount).to.equal(10); + }); + }); +}); diff --git a/apps/api/src/app/agents/services/bridge-executor.service.ts b/apps/api/src/app/agents/services/bridge-executor.service.ts index d10647553d3..521698ca67c 100644 --- a/apps/api/src/app/agents/services/bridge-executor.service.ts +++ b/apps/api/src/app/agents/services/bridge-executor.service.ts @@ -20,16 +20,47 @@ import type { import { AgentEventEnum } from '@novu/framework'; import { HttpHeaderKeysEnum } from '@novu/framework/internal'; import type { Message } from 'chat'; +import { AgentAttachmentStorage, type StoredAttachment } from './agent-attachment-storage.service'; import { ResolvedAgentConfig } from './agent-config-resolver.service'; const MAX_RETRIES = 2; const RETRY_BASE_DELAY_MS = 500; +const AGENTS_STORAGE_FOLDER = 'agents'; +const ATTACHMENT_SIGNING_CONCURRENCY = 4; + +interface AttachmentSigningContext { + organizationId: string; + environmentId: string; + conversationId: string; +} + +async function mapWithConcurrency( + items: T[], + concurrency: number, + mapper: (item: T, index: number) => Promise +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + + const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (nextIndex < items.length) { + const currentIndex = nextIndex; + nextIndex += 1; + results[currentIndex] = await mapper(items[currentIndex], currentIndex); + } + }); + + await Promise.all(workers); + + return results; +} export interface BridgeReaction { emoji: string; added: boolean; messageId: string; sourceMessage?: Message; + sourceMessageStoredAttachments?: StoredAttachment[]; } export interface BridgeExecutorParams { @@ -42,6 +73,7 @@ export interface BridgeExecutorParams { platformContext: AgentPlatformContext; action?: AgentAction; reaction?: BridgeReaction; + storedAttachments?: StoredAttachment[]; } export class NoBridgeUrlError extends Error { @@ -55,7 +87,8 @@ export class NoBridgeUrlError extends Error { export class BridgeExecutorService { constructor( private readonly getDecryptedSecretKey: GetDecryptedSecretKey, - private readonly logger: PinoLogger + private readonly logger: PinoLogger, + private readonly attachmentStorage: AgentAttachmentStorage ) { this.logger.setContext(this.constructor.name); } @@ -78,7 +111,7 @@ export class BridgeExecutorService { }) ); - const payload = this.buildPayload(params); + const payload = await this.buildPayload(params); const signatureHeader = buildNovuSignatureHeader(secretKey, payload); this.fireWithRetries(bridgeUrl, payload, signatureHeader, agentIdentifier).catch((err) => { @@ -178,7 +211,7 @@ export class BridgeExecutorService { return url.toString(); } - private buildPayload(params: BridgeExecutorParams): AgentBridgeRequest { + private async buildPayload(params: BridgeExecutorParams): Promise { const { event, config, conversation, subscriber, history, message, platformContext, action, reaction } = params; const agentIdentifier = config.agentIdentifier; @@ -207,18 +240,28 @@ export class BridgeExecutorService { replyUrl, conversationId: conversation._id, integrationIdentifier: config.integrationIdentifier, - message: message ? this.mapMessage(message) : null, + message: message + ? await this.mapMessage(message, params.storedAttachments, { + organizationId: config.organizationId, + environmentId: config.environmentId, + conversationId: conversation._id, + }) + : null, conversation: this.mapConversation(conversation), subscriber: this.mapSubscriber(subscriber), - history: this.mapHistory(history), + history: await this.mapHistory(history), platform: config.platform, platformContext, action: action ?? null, - reaction: reaction ? this.mapReaction(reaction) : null, + reaction: reaction ? await this.mapReaction(reaction, config, conversation) : null, }; } - private mapMessage(message: Message): AgentMessage { + private async mapMessage( + message: Message, + storedAttachments?: StoredAttachment[], + signingContext?: AttachmentSigningContext + ): Promise { const mapped: AgentMessage = { text: message.text, platformMessageId: message.id, @@ -231,6 +274,14 @@ export class BridgeExecutorService { timestamp: message.metadata?.dateSent?.toISOString() ?? new Date().toISOString(), }; + if (storedAttachments !== undefined) { + mapped.attachments = signingContext + ? await this.mapStoredAttachmentsForBridge(storedAttachments, signingContext) + : []; + + return mapped; + } + if (message.attachments?.length) { mapped.attachments = message.attachments.map((a) => ({ type: a.type, @@ -272,24 +323,194 @@ export class BridgeExecutorService { }; } - private mapReaction(reaction: BridgeReaction): AgentReaction { + private async mapReaction( + reaction: BridgeReaction, + config: ResolvedAgentConfig, + conversation: ConversationEntity + ): Promise { return { messageId: reaction.messageId, emoji: { name: reaction.emoji }, added: reaction.added, - message: reaction.sourceMessage ? this.mapMessage(reaction.sourceMessage) : null, + message: reaction.sourceMessage + ? await this.mapMessage(reaction.sourceMessage, reaction.sourceMessageStoredAttachments, { + organizationId: config.organizationId, + environmentId: config.environmentId, + conversationId: conversation._id, + }) + : null, }; } - private mapHistory(activities: ConversationActivityEntity[]): AgentHistoryEntry[] { - return [...activities].reverse().map((activity) => ({ - role: activity.senderType, - type: activity.type, - content: activity.content, - richContent: activity.richContent || undefined, - senderName: activity.senderName || undefined, - signalData: activity.signalData || undefined, - createdAt: activity.createdAt, - })); + private async mapHistory(activities: ConversationActivityEntity[]): Promise { + const reversed = [...activities].reverse(); + const mapped: AgentHistoryEntry[] = []; + + for (const activity of reversed) { + mapped.push({ + role: activity.senderType, + type: activity.type, + content: activity.content, + richContent: await this.mapRichContentForBridge(activity.richContent, activity), + senderName: activity.senderName || undefined, + signalData: activity.signalData || undefined, + createdAt: activity.createdAt, + }); + } + + return mapped; + } + + private async mapRichContentForBridge( + richContent: Record | undefined, + activity: ConversationActivityEntity + ): Promise | undefined> { + if (!richContent) { + return undefined; + } + + const rawAttachments = richContent.attachments; + + if (!Array.isArray(rawAttachments)) { + return richContent; + } + + const mapped = await mapWithConcurrency( + rawAttachments, + ATTACHMENT_SIGNING_CONCURRENCY, + async (item) => { + if (!item || typeof item !== 'object') { + this.logger.warn({ activityId: activity._id?.toString() }, 'History attachment is malformed; omitting'); + + return null; + } + + const att = item as Record; + const storageKey = att.storageKey; + + if (typeof storageKey === 'string' && storageKey.length > 0) { + const url = await this.signAttachmentForHistory(storageKey, activity); + + if (!url) { + return null; + } + + return { + type: att.type, + url, + name: att.name, + mimeType: att.mimeType, + size: att.size, + }; + } + + this.logger.warn({ activityId: activity._id?.toString() }, 'History attachment missing storageKey; omitting'); + + return null; + } + ); + + const attachments = mapped.flatMap((entry) => (entry ? [entry] : [])); + + return { + ...richContent, + attachments, + }; + } + + private async signAttachmentForHistory( + storageKey: string, + activity: ConversationActivityEntity + ): Promise { + const activityId = activity._id?.toString(); + const expectedPrefix = this.getAttachmentStoragePrefix({ + organizationId: activity._organizationId, + environmentId: activity._environmentId, + conversationId: activity._conversationId, + }); + + if (!storageKey.startsWith(expectedPrefix)) { + this.logger.warn( + { storageKey, activityId, expectedPrefix }, + 'History attachment storageKey outside expected namespace; omitting from bridge payload' + ); + + return null; + } + + try { + const url = await this.attachmentStorage.signRead(storageKey); + + if (!url) { + this.logger.warn({ storageKey, activityId }, 'Agent attachment missing from storage; omitting from history'); + } + + return url; + } catch (err) { + this.logger.warn(err, 'Failed to sign agent attachment for history; omitting from bridge payload'); + + return null; + } + } + + private async mapStoredAttachmentsForBridge( + storedAttachments: StoredAttachment[], + signingContext: AttachmentSigningContext + ) { + const mapped = await mapWithConcurrency( + storedAttachments, + ATTACHMENT_SIGNING_CONCURRENCY, + async (attachment) => { + const url = await this.signStoredAttachmentForBridge(attachment.storageKey, signingContext); + + if (!url) { + return null; + } + + return { + type: attachment.type, + url, + name: attachment.name, + mimeType: attachment.mimeType, + size: attachment.size, + }; + } + ); + + return mapped.flatMap((entry) => (entry ? [entry] : [])); + } + + private async signStoredAttachmentForBridge( + storageKey: string, + signingContext: AttachmentSigningContext + ): Promise { + const expectedPrefix = this.getAttachmentStoragePrefix(signingContext); + + if (!storageKey.startsWith(expectedPrefix)) { + this.logger.warn( + { storageKey, expectedPrefix }, + 'Stored attachment storageKey outside expected namespace; omitting from bridge payload' + ); + + return null; + } + + try { + const url = await this.attachmentStorage.signRead(storageKey); + + if (!url) { + this.logger.warn({ storageKey }, 'Stored attachment missing from storage; omitting from bridge payload'); + } + + return url; + } catch (err) { + this.logger.warn(err, 'Failed to sign stored attachment; omitting from bridge payload'); + + return null; + } + } + + private getAttachmentStoragePrefix(context: AttachmentSigningContext): string { + return `${context.organizationId}/${context.environmentId}/${AGENTS_STORAGE_FOLDER}/${context.conversationId}/`; } } diff --git a/libs/application-generic/src/services/storage/storage.service.ts b/libs/application-generic/src/services/storage/storage.service.ts index 02ac6eb4205..a6aa3f70cfd 100644 --- a/libs/application-generic/src/services/storage/storage.service.ts +++ b/libs/application-generic/src/services/storage/storage.service.ts @@ -1,6 +1,7 @@ import { DeleteObjectCommand, GetObjectCommand, + HeadObjectCommand, PutObjectCommand, PutObjectCommandOutput, S3Client, @@ -33,6 +34,8 @@ export abstract class StorageService { path: string; additionalHeaders?: Record; }>; + abstract getReadSignedUrl(key: string, ttlSeconds: number): Promise; + abstract fileExists(key: string): Promise; abstract uploadFile(key: string, file: Buffer, contentType: string): Promise; abstract getFile(key: string): Promise; abstract deleteFile(key: string): Promise; @@ -105,6 +108,39 @@ export class S3StorageService implements StorageService { return { signedUrl, path }; } + + async getReadSignedUrl(key: string, ttlSeconds: number): Promise { + const command = new GetObjectCommand({ + Bucket: process.env.S3_BUCKET_NAME, + Key: key, + }); + + return await getSignedUrl(this.s3, command, { expiresIn: ttlSeconds }); + } + + async fileExists(key: string): Promise { + try { + await this.s3.send( + new HeadObjectCommand({ + Bucket: process.env.S3_BUCKET_NAME, + Key: key, + }) + ); + + return true; + } catch (error: any) { + if ( + error.name === 'NotFound' || + error.Code === 'NotFound' || + error.Code === 'NoSuchKey' || + error.$metadata?.httpStatusCode === 404 + ) { + return false; + } + + throw error; + } + } } export class GCSStorageService implements StorageService { @@ -118,9 +154,6 @@ export class GCSStorageService implements StorageService { return (await fileObject.save(file, { contentType, - metadata: { - cacheControl: 'public, max-age=31536000', - }, })) as unknown as PutObjectCommandOutput; } @@ -169,6 +202,29 @@ export class GCSStorageService implements StorageService { return { signedUrl, path }; } + + async getReadSignedUrl(key: string, ttlSeconds: number): Promise { + if (!process.env.GCS_BUCKET_NAME) throw new Error('GCS_BUCKET_NAME is not defined as env variable'); + + const [signedUrl] = await this.gcs + .bucket(process.env.GCS_BUCKET_NAME) + .file(key) + .getSignedUrl({ + version: 'v4', + action: 'read', + expires: Date.now() + ttlSeconds * 1000, + }); + + return signedUrl; + } + + async fileExists(key: string): Promise { + if (!process.env.GCS_BUCKET_NAME) throw new Error('GCS_BUCKET_NAME is not defined as env variable'); + + const [exists] = await this.gcs.bucket(process.env.GCS_BUCKET_NAME).file(key).exists(); + + return exists; + } } export class AzureBlobStorageService implements StorageService { @@ -248,4 +304,33 @@ export class AzureBlobStorageService implements StorageService { additionalHeaders, }; } + + async getReadSignedUrl(key: string, ttlSeconds: number): Promise { + const containerName = process.env.AZURE_CONTAINER_NAME || 'novu'; + const blobName = key; + const containerClient = this.blobServiceClient.getContainerClient(containerName); + const blobClient = containerClient.getBlobClient(blobName); + const blobSAS = generateBlobSASQueryParameters( + { + containerName, + blobName, + permissions: BlobSASPermissions.parse('r'), + startsOn: new Date(), + expiresOn: new Date(Date.now() + ttlSeconds * 1000), + protocol: SASProtocol.Https, + }, + this.sharedKeyCredential + ).toString(); + + return `${blobClient.url}?${blobSAS}`; + } + + async fileExists(key: string): Promise { + if (!process.env.AZURE_CONTAINER_NAME) throw new Error('AZURE_CONTAINER_NAME is not defined as env variable'); + + const containerClient = this.blobServiceClient.getContainerClient(process.env.AZURE_CONTAINER_NAME); + const blockBlobClient = containerClient.getBlockBlobClient(key); + + return await blockBlobClient.exists(); + } } diff --git a/packages/shared/src/consts/slack-agent-oauth-scopes.ts b/packages/shared/src/consts/slack-agent-oauth-scopes.ts index 4d6186ef52c..ba778bcc589 100644 --- a/packages/shared/src/consts/slack-agent-oauth-scopes.ts +++ b/packages/shared/src/consts/slack-agent-oauth-scopes.ts @@ -7,6 +7,7 @@ export const SLACK_AGENT_OAUTH_SCOPES = [ 'channels:history', 'channels:read', 'chat:write', + 'files:read', 'groups:history', 'groups:read', 'im:history',