UNPKG

n8n

Version:

n8n Workflow Automation Tool

322 lines • 13.6 kB
"use strict"; var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SlackAppSetupService = void 0; const node_crypto_1 = require("node:crypto"); const db_1 = require("@n8n/db"); const di_1 = require("@n8n/di"); const n8n_core_1 = require("n8n-core"); const n8n_workflow_1 = require("n8n-workflow"); const credentials_service_1 = require("../../../credentials/credentials.service"); const bad_request_error_1 = require("../../../errors/response-errors/bad-request.error"); const conflict_error_1 = require("../../../errors/response-errors/conflict.error"); const not_found_error_1 = require("../../../errors/response-errors/not-found.error"); const cache_service_1 = require("../../../services/cache/cache.service"); const url_service_1 = require("../../../services/url.service"); const agents_service_1 = require("../agents.service"); const agent_repository_1 = require("../repositories/agent.repository"); const chat_integration_service_1 = require("./chat-integration.service"); const SLACK_APP_SETUP_CACHE_PREFIX = 'agents:slack-app-setup:'; const SLACK_APP_SETUP_TTL_MS = 60 * 60 * 1000; const DEFAULT_SLACK_APP_NAME = 'n8n Agent'; const SLACK_CREDENTIAL_TYPE = 'slackApi'; const REQUIRED_BOT_EVENTS = [ 'app_mention', 'assistant_thread_started', 'assistant_thread_context_changed', 'message.channels', 'message.groups', 'message.im', 'message.mpim', ]; const REQUIRED_BOT_SCOPES = [ 'app_mentions:read', 'assistant:write', 'channels:history', 'channels:join', 'channels:manage', 'channels:read', 'chat:write', 'chat:write.customize', 'files:read', 'files:write', 'groups:history', 'groups:read', 'im:history', 'im:read', 'im:write', 'mpim:history', 'mpim:read', 'mpim:write', 'search:read.public', 'users:read', 'users:read.email', ]; function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } function childRecord(record, key) { const child = record[key]; return isRecord(child) ? child : undefined; } function stringProperty(record, key) { const value = record?.[key]; return typeof value === 'string' ? value : undefined; } function hasSessionShape(value) { const keys = [ 'projectId', 'agentId', 'userId', 'appId', 'clientId', 'clientSecret', 'signingSecret', 'redirectUrl', ]; return isRecord(value) && keys.every((k) => typeof value[k] === 'string'); } let SlackAppSetupService = class SlackAppSetupService { constructor(cacheService, cipher, credentialsService, userRepository, agentRepository, agentsService, chatIntegrationService, urlService) { this.cacheService = cacheService; this.cipher = cipher; this.credentialsService = credentialsService; this.userRepository = userRepository; this.agentRepository = agentRepository; this.agentsService = agentsService; this.chatIntegrationService = chatIntegrationService; this.urlService = urlService; } async createApp(options) { const agent = await this.getAgent(options.agentId, options.projectId, true); const appConfigurationToken = options.appConfigurationToken.trim(); if (!appConfigurationToken) { throw new bad_request_error_1.BadRequestError('Slack app configuration token is required'); } const redirectUrl = this.callbackUrl(options.projectId, options.agentId); const manifest = this.buildManifest(agent.name, options.projectId, options.agentId, { redirectUrl, }); const response = await this.callSlackApi('apps.manifest.create', { token: appConfigurationToken, manifest: JSON.stringify(manifest), }); if (response.ok !== true) { throw this.slackError('create the Slack app', response); } const credentials = childRecord(response, 'credentials'); const appId = stringProperty(response, 'app_id'); const clientId = stringProperty(credentials, 'client_id'); const clientSecret = stringProperty(credentials, 'client_secret'); const signingSecret = stringProperty(credentials, 'signing_secret'); const oauthAuthorizeUrl = stringProperty(response, 'oauth_authorize_url'); if (!appId || !clientId || !clientSecret || !signingSecret || !oauthAuthorizeUrl) { throw new bad_request_error_1.BadRequestError('Slack returned an incomplete app setup response'); } const state = (0, node_crypto_1.randomBytes)(32).toString('hex'); const setupSession = { projectId: options.projectId, agentId: options.agentId, userId: options.user.id, appId, clientId, clientSecret, signingSecret, redirectUrl, }; await this.cacheService.set(this.cacheKey(state), await this.cipher.encryptV2(JSON.stringify(setupSession)), SLACK_APP_SETUP_TTL_MS); return { appId, installUrl: this.installUrl(oauthAuthorizeUrl, state, redirectUrl), }; } async getManualManifest(options) { const agent = await this.getAgent(options.agentId, options.projectId); return { manifest: this.buildManifest(agent.name, options.projectId, options.agentId), }; } async completeInstall(options) { const session = await this.consumeSession(options.state); if (session.projectId !== options.projectId || session.agentId !== options.agentId) { throw new bad_request_error_1.BadRequestError('Slack app setup state does not match this agent'); } const agent = await this.getAgent(session.agentId, session.projectId, true); const user = await this.userRepository.findOne({ where: { id: session.userId }, relations: ['role'], }); if (!user) { throw new not_found_error_1.NotFoundError(`User "${session.userId}" not found`); } const tokenResponse = await this.callSlackApi('oauth.v2.access', { code: options.code, redirect_uri: session.redirectUrl, }, { Authorization: `Basic ${Buffer.from(`${session.clientId}:${session.clientSecret}`).toString('base64')}`, }); if (tokenResponse.ok !== true) { throw this.slackError('finish Slack app installation', tokenResponse); } const accessToken = stringProperty(tokenResponse, 'access_token'); if (!accessToken?.startsWith('xoxb-')) { throw new bad_request_error_1.BadRequestError('Slack did not return a Bot User OAuth Token'); } const credential = await this.credentialsService.createUnmanagedCredential({ name: this.credentialName(agent.name), type: SLACK_CREDENTIAL_TYPE, data: { accessToken, signatureSecret: session.signingSecret, }, projectId: session.projectId, }, user); const integration = { type: 'slack', credentialId: credential.id, }; await this.chatIntegrationService.connect(session.agentId, integration, session.userId, session.projectId); await this.agentsService.saveCredentialIntegration(agent, integration); } async getAgent(agentId, projectId, requirePublished = false) { const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId); if (!agent) throw new not_found_error_1.NotFoundError(`Agent "${agentId}" not found`); if (requirePublished && !agent.activeVersionId) { throw new conflict_error_1.ConflictError(`Agent "${agentId}" must be published before connecting an integration`); } return agent; } buildManifest(agentName, projectId, agentId, options = {}) { const slackAppName = this.sanitiseSlackAppName(agentName); const webhookUrl = this.webhookUrl(projectId, agentId); return { display_information: { name: slackAppName, }, features: { app_home: { home_tab_enabled: true, messages_tab_enabled: true, messages_tab_read_only_enabled: false, }, bot_user: { display_name: slackAppName, always_online: true, }, }, oauth_config: { ...(options.redirectUrl ? { redirect_urls: [options.redirectUrl] } : {}), scopes: { bot: [...REQUIRED_BOT_SCOPES], }, }, settings: { event_subscriptions: { request_url: webhookUrl, bot_events: [...REQUIRED_BOT_EVENTS], }, interactivity: { is_enabled: true, request_url: webhookUrl, }, org_deploy_enabled: false, socket_mode_enabled: false, token_rotation_enabled: false, }, }; } webhookUrl(projectId, agentId) { return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/webhooks/slack`; } callbackUrl(projectId, agentId) { return `${this.urlService.getWebhookBaseUrl()}rest/projects/${projectId}/agents/v2/${agentId}/integrations/slack/oauth/callback`; } installUrl(oauthAuthorizeUrl, state, redirectUrl) { try { const url = new URL(oauthAuthorizeUrl); url.searchParams.set('state', state); url.searchParams.set('redirect_uri', redirectUrl); return url.toString(); } catch { throw new bad_request_error_1.BadRequestError('Slack returned an invalid installation URL'); } } async consumeSession(state) { const key = this.cacheKey(state); const cached = await this.cacheService.get(key); await this.cacheService.delete(key); if (typeof cached !== 'string') { throw new bad_request_error_1.BadRequestError('Slack app setup state has expired or is invalid'); } try { const decrypted = await this.cipher.decryptV2(cached); const session = (0, n8n_workflow_1.jsonParse)(decrypted, { fallbackValue: null }); if (hasSessionShape(session)) { return session; } } catch { } throw new bad_request_error_1.BadRequestError('Slack app setup state has expired or is invalid'); } cacheKey(state) { return `${SLACK_APP_SETUP_CACHE_PREFIX}${state}`; } credentialName(agentName) { return `Slack - ${agentName || DEFAULT_SLACK_APP_NAME}`.slice(0, 128); } sanitiseSlackAppName(raw) { const cleaned = raw .replace(/[^a-zA-Z0-9 ._-]/g, '') .replace(/\s+/g, ' ') .trim() .slice(0, 35); return cleaned.length > 0 ? cleaned : DEFAULT_SLACK_APP_NAME; } async callSlackApi(method, params, headers = {}) { try { const response = await fetch(`https://slack.com/api/${method}`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams(params).toString(), }); const data = await response.json(); if (!isRecord(data)) { return { ok: false, error: 'invalid_response' }; } return data; } catch { return { ok: false, error: 'slack_request_failed' }; } } slackError(action, response) { const error = stringProperty(response, 'error') ?? 'unknown_error'; return new bad_request_error_1.BadRequestError(`Slack could not ${action}: ${error}`); } }; exports.SlackAppSetupService = SlackAppSetupService; exports.SlackAppSetupService = SlackAppSetupService = __decorate([ (0, di_1.Service)(), __metadata("design:paramtypes", [cache_service_1.CacheService, n8n_core_1.Cipher, credentials_service_1.CredentialsService, db_1.UserRepository, agent_repository_1.AgentRepository, agents_service_1.AgentsService, chat_integration_service_1.ChatIntegrationService, url_service_1.UrlService]) ], SlackAppSetupService); //# sourceMappingURL=slack-app-setup.service.js.map