diff --git a/cdk/src/constructs/slack-installation-table.ts b/cdk/src/constructs/slack-installation-table.ts new file mode 100644 index 0000000..3273621 --- /dev/null +++ b/cdk/src/constructs/slack-installation-table.ts @@ -0,0 +1,77 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for SlackInstallationTable construct. + */ +export interface SlackInstallationTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table for Slack workspace installations. + * + * Schema: team_id (PK) — one record per installed Slack workspace. + * Stores workspace metadata and a pointer to the bot token in Secrets Manager. + * Bot tokens are stored in Secrets Manager at `bgagent/slack/{team_id}`. + */ +export class SlackInstallationTable extends Construct { + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: SlackInstallationTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'team_id', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + } +} diff --git a/cdk/src/constructs/slack-integration.ts b/cdk/src/constructs/slack-integration.ts new file mode 100644 index 0000000..e75351d --- /dev/null +++ b/cdk/src/constructs/slack-integration.ts @@ -0,0 +1,451 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as path from 'path'; +import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Runtime, Architecture, StartingPosition, FilterCriteria, FilterRule } from 'aws-cdk-lib/aws-lambda'; +import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources'; +import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; +import { SlackInstallationTable } from './slack-installation-table'; +import { SlackUserMappingTable } from './slack-user-mapping-table'; + +/** + * Properties for SlackIntegration construct. + */ +export interface SlackIntegrationProps { + /** The existing REST API to add Slack routes to. */ + readonly api: apigw.RestApi; + + /** Cognito user pool for the /slack/link endpoint (Cognito-authenticated). */ + readonly userPool: cognito.IUserPool; + + /** The DynamoDB task table. */ + readonly taskTable: dynamodb.ITable; + + /** The DynamoDB task events table (must have DynamoDB Streams enabled). */ + readonly taskEventsTable: dynamodb.ITable; + + /** The DynamoDB repo config table (optional — for repo onboarding checks). */ + readonly repoTable?: dynamodb.ITable; + + /** Orchestrator Lambda function ARN for async task invocation. */ + readonly orchestratorFunctionArn?: string; + + /** Bedrock Guardrail ID for input screening. */ + readonly guardrailId?: string; + + /** Bedrock Guardrail version for input screening. */ + readonly guardrailVersion?: string; + + /** Task retention in days for TTL computation. */ + readonly taskRetentionDays?: number; + + /** Removal policy for Slack DynamoDB tables. */ + readonly removalPolicy?: RemovalPolicy; +} + +/** + * CDK construct that adds Slack integration to the ABCA platform. + * + * Creates: + * - SlackInstallationTable (per-workspace installation records) + * - SlackUserMappingTable (Slack user → platform user mappings) + * - Lambda handlers for OAuth, slash commands, events, notifications, and account linking + * - API Gateway routes under /slack/* + * - DynamoDB Streams event source for outbound notifications + */ +export class SlackIntegration extends Construct { + /** The Slack installation table. */ + public readonly installationTable: dynamodb.Table; + + /** The Slack user mapping table. */ + public readonly userMappingTable: dynamodb.Table; + + /** The Slack signing secret (placeholder — user populates after creating the Slack App). */ + public readonly signingSecret: secretsmanager.Secret; + + /** The Slack client secret (placeholder — user populates after creating the Slack App). */ + public readonly clientSecret: secretsmanager.Secret; + + /** The Slack client ID secret (placeholder — user populates after creating the Slack App). */ + public readonly clientIdSecret: secretsmanager.Secret; + + constructor(scope: Construct, id: string, props: SlackIntegrationProps) { + super(scope, id); + + const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; + + // --- DynamoDB Tables --- + const installationTable = new SlackInstallationTable(this, 'InstallationTable', { removalPolicy }); + const userMappingTable = new SlackUserMappingTable(this, 'UserMappingTable', { removalPolicy }); + this.installationTable = installationTable.table; + this.userMappingTable = userMappingTable.table; + + // --- Slack App Secrets (CDK-created placeholders) --- + // Users populate these after creating the Slack App via the SlackAppCreateUrl output. + this.signingSecret = new secretsmanager.Secret(this, 'SigningSecret', { + description: 'Slack App signing secret — populate after creating the Slack App', + removalPolicy, + }); + this.clientSecret = new secretsmanager.Secret(this, 'ClientSecret', { + description: 'Slack App client secret (OAuth) — populate after creating the Slack App', + removalPolicy, + }); + this.clientIdSecret = new secretsmanager.Secret(this, 'ClientIdSecret', { + description: 'Slack App client ID — populate after creating the Slack App', + removalPolicy, + }); + + // --- Shared Lambda configuration --- + const handlersDir = path.join(__dirname, '..', 'handlers'); + const commonBundling: lambda.BundlingOptions = { + externalModules: ['@aws-sdk/*'], + }; + + // Secrets Manager ARN prefix for Slack secrets (bgagent/slack/*) + const slackSecretArnPrefix = Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + resourceName: 'bgagent/slack/*', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + }); + + // IAM policy for reading Slack secrets from Secrets Manager + const readSlackSecretsPolicy = new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: [slackSecretArnPrefix], + }); + + // --- Cognito Authorizer (for /slack/link endpoint) --- + const cognitoAuthorizer = new apigw.CognitoUserPoolsAuthorizer(this, 'SlackCognitoAuthorizer', { + cognitoUserPools: [props.userPool], + }); + + const cognitoAuthOptions: apigw.MethodOptions = { + authorizer: cognitoAuthorizer, + authorizationType: apigw.AuthorizationType.COGNITO, + }; + + const noneAuthOptions: apigw.MethodOptions = { + authorizationType: apigw.AuthorizationType.NONE, + }; + + // --- Task creation environment (matches TaskApi createTaskEnv pattern) --- + const createTaskEnv: Record = { + TASK_TABLE_NAME: props.taskTable.tableName, + TASK_EVENTS_TABLE_NAME: props.taskEventsTable.tableName, + TASK_RETENTION_DAYS: String(props.taskRetentionDays ?? 90), + }; + if (props.repoTable) { + createTaskEnv.REPO_TABLE_NAME = props.repoTable.tableName; + } + if (props.orchestratorFunctionArn) { + createTaskEnv.ORCHESTRATOR_FUNCTION_ARN = props.orchestratorFunctionArn; + } + if (props.guardrailId && props.guardrailVersion) { + createTaskEnv.GUARDRAIL_ID = props.guardrailId; + createTaskEnv.GUARDRAIL_VERSION = props.guardrailVersion; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Lambda Handlers + // ═══════════════════════════════════════════════════════════════════════════ + + // --- OAuth Callback --- + const oauthCallbackFn = new lambda.NodejsFunction(this, 'OAuthCallbackFn', { + entry: path.join(handlersDir, 'slack-oauth-callback.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(15), + environment: { + SLACK_INSTALLATION_TABLE_NAME: this.installationTable.tableName, + SLACK_CLIENT_ID_SECRET_ARN: this.clientIdSecret.secretArn, + SLACK_CLIENT_SECRET_ARN: this.clientSecret.secretArn, + }, + bundling: commonBundling, + }); + this.installationTable.grantWriteData(oauthCallbackFn); + this.clientIdSecret.grantRead(oauthCallbackFn); + this.clientSecret.grantRead(oauthCallbackFn); + oauthCallbackFn.addToRolePolicy(readSlackSecretsPolicy); + // CreateSecret + UpdateSecret for bot tokens + oauthCallbackFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:CreateSecret'], + resources: ['*'], + conditions: { + StringLike: { 'secretsmanager:Name': 'bgagent/slack/*' }, + }, + })); + oauthCallbackFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:UpdateSecret', 'secretsmanager:TagResource', 'secretsmanager:RestoreSecret'], + resources: [slackSecretArnPrefix], + })); + + // --- Slack Events --- + // Note: SLACK_COMMAND_PROCESSOR_FUNCTION_NAME is set below after commandProcessorFn is created. + const slackEventsFn = new lambda.NodejsFunction(this, 'SlackEventsFn', { + entry: path.join(handlersDir, 'slack-events.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + SLACK_INSTALLATION_TABLE_NAME: this.installationTable.tableName, + SLACK_SIGNING_SECRET_ARN: this.signingSecret.secretArn, + }, + bundling: commonBundling, + }); + + // Keep one instance warm — Slack's URL verification during app creation + // times out on cold starts, and the retry UX is poor. + const slackEventsAlias = slackEventsFn.addAlias('live', { + provisionedConcurrentExecutions: 1, + }); + this.installationTable.grantReadWriteData(slackEventsFn); + this.signingSecret.grantRead(slackEventsFn); + slackEventsFn.addToRolePolicy(readSlackSecretsPolicy); + slackEventsFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:DeleteSecret'], + resources: [slackSecretArnPrefix], + })); + + // --- Slash Command Processor (async worker) --- + const commandProcessorFn = new lambda.NodejsFunction(this, 'CommandProcessorFn', { + entry: path.join(handlersDir, 'slack-command-processor.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(30), + environment: { + ...createTaskEnv, + SLACK_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName, + SLACK_INSTALLATION_TABLE_NAME: this.installationTable.tableName, + }, + bundling: commonBundling, + }); + this.userMappingTable.grantReadWriteData(commandProcessorFn); + this.installationTable.grantReadData(commandProcessorFn); + commandProcessorFn.addToRolePolicy(readSlackSecretsPolicy); + props.taskTable.grantReadWriteData(commandProcessorFn); + props.taskEventsTable.grantReadWriteData(commandProcessorFn); + if (props.repoTable) { + props.repoTable.grantReadData(commandProcessorFn); + } + if (props.orchestratorFunctionArn) { + commandProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['lambda:InvokeFunction'], + resources: [props.orchestratorFunctionArn], + })); + } + if (props.guardrailId) { + commandProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['bedrock:ApplyGuardrail'], + resources: [ + Stack.of(this).formatArn({ + service: 'bedrock', + resource: 'guardrail', + resourceName: props.guardrailId, + }), + ], + })); + } + + // Wire events handler to command processor for @mention forwarding. + slackEventsFn.addEnvironment('SLACK_COMMAND_PROCESSOR_FUNCTION_NAME', commandProcessorFn.functionName); + commandProcessorFn.grantInvoke(slackEventsFn); + + // --- Slack Interactions (Block Kit button actions) --- + const slackInteractionsFn = new lambda.NodejsFunction(this, 'SlackInteractionsFn', { + entry: path.join(handlersDir, 'slack-interactions.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + SLACK_SIGNING_SECRET_ARN: this.signingSecret.secretArn, + TASK_TABLE_NAME: props.taskTable.tableName, + SLACK_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName, + }, + bundling: commonBundling, + }); + this.signingSecret.grantRead(slackInteractionsFn); + slackInteractionsFn.addToRolePolicy(readSlackSecretsPolicy); + props.taskTable.grantReadWriteData(slackInteractionsFn); + this.userMappingTable.grantReadData(slackInteractionsFn); + + // --- Slash Command Acknowledger --- + const slackCommandsFn = new lambda.NodejsFunction(this, 'SlackCommandsFn', { + entry: path.join(handlersDir, 'slack-commands.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(3), + environment: { + SLACK_SIGNING_SECRET_ARN: this.signingSecret.secretArn, + SLACK_COMMAND_PROCESSOR_FUNCTION_NAME: commandProcessorFn.functionName, + }, + bundling: commonBundling, + }); + this.signingSecret.grantRead(slackCommandsFn); + slackCommandsFn.addToRolePolicy(readSlackSecretsPolicy); + commandProcessorFn.grantInvoke(slackCommandsFn); + + // --- Account Linking (Cognito-authenticated) --- + const slackLinkFn = new lambda.NodejsFunction(this, 'SlackLinkFn', { + entry: path.join(handlersDir, 'slack-link.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + SLACK_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName, + }, + bundling: commonBundling, + }); + this.userMappingTable.grantReadWriteData(slackLinkFn); + + // --- Outbound Notification Handler (DynamoDB Streams trigger) --- + const slackNotifyFn = new lambda.NodejsFunction(this, 'SlackNotifyFn', { + entry: path.join(handlersDir, 'slack-notify.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(30), + environment: { + TASK_TABLE_NAME: props.taskTable.tableName, + }, + bundling: commonBundling, + }); + props.taskTable.grantReadWriteData(slackNotifyFn); + slackNotifyFn.addToRolePolicy(readSlackSecretsPolicy); + + // DynamoDB Streams event source with filtering + slackNotifyFn.addEventSource(new lambdaEventSources.DynamoEventSource(props.taskEventsTable, { + startingPosition: StartingPosition.LATEST, + batchSize: 10, + maxBatchingWindow: Duration.seconds(0), + retryAttempts: 3, + bisectBatchOnError: true, + filters: [ + FilterCriteria.filter({ + eventName: FilterRule.isEqual('INSERT'), + }), + ], + })); + + // ═══════════════════════════════════════════════════════════════════════════ + // API Gateway Routes + // ═══════════════════════════════════════════════════════════════════════════ + + const slack = props.api.root.addResource('slack'); + + // OAuth callback: GET /v1/slack/oauth/callback + const oauthResource = slack.addResource('oauth'); + const oauthCallbackResource = oauthResource.addResource('callback'); + const oauthCallbackMethod = oauthCallbackResource.addMethod( + 'GET', + new apigw.LambdaIntegration(oauthCallbackFn), + noneAuthOptions, + ); + + // Slack events: POST /v1/slack/events + const eventsResource = slack.addResource('events'); + const eventsMethod = eventsResource.addMethod( + 'POST', + new apigw.LambdaIntegration(slackEventsAlias), + noneAuthOptions, + ); + + // Slash commands: POST /v1/slack/commands + const commandsResource = slack.addResource('commands'); + const commandsMethod = commandsResource.addMethod( + 'POST', + new apigw.LambdaIntegration(slackCommandsFn), + noneAuthOptions, + ); + + // Block Kit interactions: POST /v1/slack/interactions + const interactionsResource = slack.addResource('interactions'); + const interactionsMethod = interactionsResource.addMethod( + 'POST', + new apigw.LambdaIntegration(slackInteractionsFn), + noneAuthOptions, + ); + + // Account linking: POST /v1/slack/link (Cognito-authenticated) + const linkResource = slack.addResource('link'); + linkResource.addMethod( + 'POST', + new apigw.LambdaIntegration(slackLinkFn), + cognitoAuthOptions, + ); + + // ═══════════════════════════════════════════════════════════════════════════ + // cdk-nag suppressions + // ═══════════════════════════════════════════════════════════════════════════ + + // Suppress APIG4 and COG4 on routes that use Slack signing secret instead of Cognito + const slackVerifiedMethods = [oauthCallbackMethod, eventsMethod, commandsMethod, interactionsMethod]; + for (const method of slackVerifiedMethods) { + NagSuppressions.addResourceSuppressions(method, [ + { + id: 'AwsSolutions-APIG4', + reason: 'Slack endpoint uses Slack signing secret verification instead of Cognito — by design for Slack API integration', + }, + { + id: 'AwsSolutions-COG4', + reason: 'Slack endpoint uses Slack signing secret verification instead of Cognito — by design for Slack API integration', + }, + ]); + } + + // Slack secrets are managed externally (populated by the user after creating the Slack App) + for (const secret of [this.signingSecret, this.clientSecret, this.clientIdSecret]) { + NagSuppressions.addResourceSuppressions(secret, [ + { + id: 'AwsSolutions-SMG4', + reason: 'Slack App credentials are managed externally — automatic rotation is not applicable', + }, + ]); + } + + // Standard Lambda suppressions + const allFunctions = [oauthCallbackFn, slackEventsFn, slackCommandsFn, commandProcessorFn, slackLinkFn, slackNotifyFn, slackInteractionsFn]; + for (const fn of allFunctions) { + NagSuppressions.addResourceSuppressions(fn, [ + { + id: 'AwsSolutions-IAM4', + reason: 'AWSLambdaBasicExecutionRole is the AWS-recommended managed policy for Lambda functions', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'Wildcard permissions are scoped by condition (secretsmanager:Name prefix) or by DynamoDB index ARN patterns', + }, + ], true); + } + } +} diff --git a/cdk/src/constructs/slack-user-mapping-table.ts b/cdk/src/constructs/slack-user-mapping-table.ts new file mode 100644 index 0000000..e5852e3 --- /dev/null +++ b/cdk/src/constructs/slack-user-mapping-table.ts @@ -0,0 +1,92 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for SlackUserMappingTable construct. + */ +export interface SlackUserMappingTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table for mapping Slack user identities to platform user IDs. + * + * Schema: slack_identity (PK) — composite key `{team_id}#{user_id}`. + * Also used for pending link records (with status='pending' and TTL). + * + * GSIs: + * - PlatformUserIndex (PK: platform_user_id, SK: linked_at) — list linked Slack accounts for a user + */ +export class SlackUserMappingTable extends Construct { + /** + * GSI name for querying mappings by platform user. + * PK: platform_user_id, SK: linked_at. + */ + public static readonly PLATFORM_USER_INDEX = 'PlatformUserIndex'; + + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: SlackUserMappingTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'slack_identity', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + + this.table.addGlobalSecondaryIndex({ + indexName: SlackUserMappingTable.PLATFORM_USER_INDEX, + partitionKey: { name: 'platform_user_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'linked_at', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + }); + } +} diff --git a/cdk/src/constructs/task-events-table.ts b/cdk/src/constructs/task-events-table.ts index 61cb209..254bf55 100644 --- a/cdk/src/constructs/task-events-table.ts +++ b/cdk/src/constructs/task-events-table.ts @@ -76,6 +76,7 @@ export class TaskEventsTable extends Construct { pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, }, + stream: dynamodb.StreamViewType.NEW_IMAGE, removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, }); } diff --git a/cdk/src/handlers/shared/create-task-core.ts b/cdk/src/handlers/shared/create-task-core.ts index 5f003c4..5f119ba 100644 --- a/cdk/src/handlers/shared/create-task-core.ts +++ b/cdk/src/handlers/shared/create-task-core.ts @@ -39,7 +39,7 @@ import { TaskStatus } from '../../constructs/task-status'; */ export interface TaskCreationContext { readonly userId: string; - readonly channelSource: 'api' | 'webhook'; + readonly channelSource: 'api' | 'webhook' | 'slack'; readonly channelMetadata: Record; readonly idempotencyKey?: string; } diff --git a/cdk/src/handlers/shared/slack-blocks.ts b/cdk/src/handlers/shared/slack-blocks.ts new file mode 100644 index 0000000..b677d8d --- /dev/null +++ b/cdk/src/handlers/shared/slack-blocks.ts @@ -0,0 +1,210 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { TaskRecord } from './types'; + +/** A Slack Block Kit block element. */ +export interface SlackBlock { + readonly type: string; + readonly text?: { readonly type: string; readonly text: string }; + readonly elements?: ReadonlyArray>; + readonly block_id?: string; +} + +/** A Slack message payload suitable for chat.postMessage. */ +export interface SlackMessage { + /** Fallback plain-text for notifications. */ + readonly text: string; + /** Block Kit blocks for rich rendering. */ + readonly blocks: SlackBlock[]; + /** If set, post as a threaded reply. */ + readonly thread_ts?: string; +} + +/** + * Render a task event as a Slack Block Kit message. + * + * @param eventType - the task event type (e.g. 'task_created', 'task_completed'). + * @param task - the task record with current state. + * @param eventMetadata - optional metadata from the event record. + * @returns a SlackMessage payload. + */ +export function renderSlackBlocks( + eventType: string, + task: Pick, + eventMetadata?: Record, +): SlackMessage { + switch (eventType) { + case 'task_created': + return taskCreatedMessage(task); + case 'session_started': + return sessionStartedMessage(task); + case 'task_completed': + return taskCompletedMessage(task); + case 'task_failed': + return taskFailedMessage(task, eventMetadata); + case 'task_cancelled': + return simpleStatusMessage(task, ':no_entry_sign: Task cancelled'); + case 'task_timed_out': + return taskTimedOutMessage(task); + default: + return simpleStatusMessage(task, `Event: ${eventType}`); + } +} + +function taskCreatedMessage( + task: Pick, +): SlackMessage { + const desc = task.task_description + ? `\n${truncate(task.task_description, 200)}` + : ''; + const text = `:rocket: *Task submitted* for \`${task.repo}\`${desc}\n_ID:_ \`${task.task_id}\``; + return { + text: `Task submitted for ${task.repo}`, + blocks: [section(text)], + }; +} + +function taskCompletedMessage( + task: Pick, +): SlackMessage { + const parts = [`:white_check_mark: *Task completed* for \`${task.repo}\``]; + const stats: string[] = []; + if (task.duration_s != null) stats.push(formatDuration(Number(task.duration_s))); + if (task.cost_usd != null) stats.push(`$${Number(task.cost_usd).toFixed(2)}`); + if (stats.length > 0) parts.push(stats.join(' · ')); + const text = parts.join('\n'); + + const blocks: SlackBlock[] = [section(text)]; + + // "View PR" button — no inline link text, so Slack won't unfurl a big preview card. + if (task.pr_url) { + blocks.push(actions(task.task_id, [ + linkButton(`View PR ${prLabel(task.pr_url)}`, task.pr_url), + ])); + } + + return { + text: `Task completed for ${task.repo}`, + blocks, + }; +} + +function taskFailedMessage( + task: Pick, + eventMetadata?: Record, +): SlackMessage { + const reason = task.error_message + ?? (eventMetadata?.error as string | undefined) + ?? 'Unknown error'; + const text = `:x: *Task failed* for \`${task.repo}\`\n_Reason:_ ${truncate(reason, 300)}`; + return { + text: `Task failed for ${task.repo}`, + blocks: [section(text)], + }; +} + +function taskTimedOutMessage( + task: Pick, +): SlackMessage { + const duration = task.duration_s != null ? ` after ${formatDuration(task.duration_s)}` : ''; + const text = `:hourglass: *Task timed out* for \`${task.repo}\`${duration}`; + return { + text: `Task timed out for ${task.repo}`, + blocks: [section(text)], + }; +} + +function sessionStartedMessage( + task: Pick, +): SlackMessage { + const text = `:hourglass_flowing_sand: Agent started working on \`${task.repo}\``; + return { + text: `Agent started working on ${task.repo}`, + blocks: [ + section(text), + actions(task.task_id, [ + dangerButton('Cancel Task', `cancel_task:${task.task_id}`), + ]), + ], + }; +} + +function simpleStatusMessage( + task: Pick, + label: string, +): SlackMessage { + const text = `${label} for \`${task.repo}\`\n_ID:_ \`${task.task_id}\``; + return { + text: `${label} for ${task.repo}`, + blocks: [section(text)], + }; +} + +function section(text: string): SlackBlock { + return { type: 'section', text: { type: 'mrkdwn', text } }; +} + +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen - 3) + '...'; +} + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${Math.round(seconds)}s`; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + if (m < 60) return s > 0 ? `${m}m ${s}s` : `${m}m`; + const h = Math.floor(m / 60); + const remainM = m % 60; + return remainM > 0 ? `${h}h ${remainM}m` : `${h}h`; +} + +function actions(blockId: string, elements: Record[]): SlackBlock { + return { type: 'actions', block_id: blockId, elements } as unknown as SlackBlock; +} + +function linkButton(label: string, url: string): Record { + return { + type: 'button', + text: { type: 'plain_text', text: label }, + url, + style: 'primary', + }; +} + +function dangerButton(label: string, actionId: string): Record { + return { + type: 'button', + text: { type: 'plain_text', text: label }, + action_id: actionId, + style: 'danger', + confirm: { + title: { type: 'plain_text', text: 'Cancel task?' }, + text: { type: 'mrkdwn', text: 'This will stop the running agent.' }, + confirm: { type: 'plain_text', text: 'Cancel' }, + deny: { type: 'plain_text', text: 'Keep running' }, + }, + }; +} + +function prLabel(prUrl: string): string { + const match = prUrl.match(/\/pull\/(\d+)$/); + return match ? `#${match[1]}` : 'Pull Request'; +} diff --git a/cdk/src/handlers/shared/slack-verify.ts b/cdk/src/handlers/shared/slack-verify.ts new file mode 100644 index 0000000..11c8052 --- /dev/null +++ b/cdk/src/handlers/shared/slack-verify.ts @@ -0,0 +1,111 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { logger } from './logger'; + +const sm = new SecretsManagerClient({}); + +/** Prefix for Slack-related secrets in Secrets Manager. */ +export const SLACK_SECRET_PREFIX = 'bgagent/slack/'; + +// In-memory secret cache with 5-minute TTL (same pattern as webhook handler). +const secretCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** Maximum age of a Slack request timestamp before it is rejected (replay protection). */ +const MAX_TIMESTAMP_AGE_S = 5 * 60; + +/** + * Fetch a secret from Secrets Manager with in-memory caching. + * @param secretId - the full Secrets Manager secret ID or ARN. + * @returns the secret string, or null if not found. + */ +export async function getSlackSecret(secretId: string): Promise { + const now = Date.now(); + const cached = secretCache.get(secretId); + if (cached && cached.expiresAt > now) { + return cached.secret; + } + + try { + const result = await sm.send(new GetSecretValueCommand({ SecretId: secretId })); + if (!result.SecretString) return null; + secretCache.set(secretId, { secret: result.SecretString, expiresAt: now + CACHE_TTL_MS }); + return result.SecretString; + } catch (err) { + const errorName = (err as Error)?.name; + if (errorName === 'ResourceNotFoundException') { + logger.error('Slack secret not found in Secrets Manager', { secret_id: secretId }); + return null; + } + logger.error('Failed to fetch Slack secret from Secrets Manager', { + secret_id: secretId, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} + +/** + * Verify a Slack request signature. + * + * Slack signs every request with HMAC-SHA256 using the app signing secret. + * Signature format: `v0={hex}` where the HMAC input is `v0:{timestamp}:{body}`. + * + * @param signingSecret - the Slack app signing secret. + * @param signature - the `X-Slack-Signature` header value. + * @param timestamp - the `X-Slack-Request-Timestamp` header value. + * @param body - the raw request body string. + * @returns true if the signature is valid and the timestamp is recent. + */ +export function verifySlackSignature( + signingSecret: string, + signature: string, + timestamp: string, + body: string, +): boolean { + // Reject requests with stale timestamps (replay protection). + const ts = parseInt(timestamp, 10); + if (isNaN(ts)) { + logger.warn('Invalid Slack request timestamp', { timestamp }); + return false; + } + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - ts) > MAX_TIMESTAMP_AGE_S) { + logger.warn('Slack request timestamp too old', { timestamp, now: String(now) }); + return false; + } + + // Compute expected signature: v0=HMAC-SHA256(signing_secret, "v0:{ts}:{body}") + const sigBasestring = `v0:${timestamp}:${body}`; + const expected = 'v0=' + crypto.createHmac('sha256', signingSecret).update(sigBasestring).digest('hex'); + + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); + } catch (err) { + logger.warn('Slack signature comparison failed', { + error: err instanceof Error ? err.message : String(err), + expected_length: expected.length, + provided_length: signature.length, + }); + return false; + } +} diff --git a/cdk/src/handlers/slack-command-processor.ts b/cdk/src/handlers/slack-command-processor.ts new file mode 100644 index 0000000..6febe40 --- /dev/null +++ b/cdk/src/handlers/slack-command-processor.ts @@ -0,0 +1,480 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import { createTaskCore } from './shared/create-task-core'; +import { logger } from './shared/logger'; +import { getSlackSecret, SLACK_SECRET_PREFIX } from './shared/slack-verify'; +import type { SlackCommandPayload } from './slack-commands'; + +/** Extended payload for mention-sourced commands (no response_url available). */ +interface MentionPayload extends SlackCommandPayload { + readonly source?: 'mention'; + readonly mention_thread_ts?: string; +} + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +const USER_MAPPING_TABLE = process.env.SLACK_USER_MAPPING_TABLE_NAME!; +const INSTALLATION_TABLE = process.env.SLACK_INSTALLATION_TABLE_NAME!; +const TASK_TABLE = process.env.TASK_TABLE_NAME!; + +/** Link code length and TTL. */ +const LINK_CODE_LENGTH = 6; +const LINK_CODE_TTL_S = 10 * 60; // 10 minutes + +/** + * Async processor for Slack slash commands and @mention triggers. + * + * Invoked asynchronously by the slash command acknowledger or the events handler. + * Posts results back to Slack via `response_url` (slash commands) or + * `chat.postMessage` (@mentions). + */ +export async function handler(event: MentionPayload): Promise { + const text = (event.text ?? '').trim(); + const parts = text.split(/\s+/); + const subcommand = parts[0]?.toLowerCase() ?? ''; + + // Build a reply function that handles both response_url and mention modes. + const reply = event.source === 'mention' + ? buildMentionReply(event) + : (msg: string) => postToSlack(event.response_url, msg); + + try { + switch (subcommand) { + case 'submit': + // Submit is only used via @mentions — slash commands show usage guidance. + if (event.source === 'mention') { + await handleSubmit(event, parts.slice(1), reply); + } else { + await reply('Use `@Shoof` to submit tasks — e.g. `@Shoof fix the bug in org/repo#42`\nFor private submissions, DM Shoof directly.'); + } + break; + case 'link': + await handleLink(event, reply); + break; + case 'help': + await reply( + '*Using Shoof*\n\n' + + '*Submit a task:* Mention `@Shoof` in any channel:\n' + + '> `@Shoof fix the login bug in org/repo#42`\n' + + '> `@Shoof update the README in org/repo`\n\n' + + '*Private submissions:* DM Shoof directly.\n\n' + + '*Cancel a task:* Use the Cancel button in the thread.\n\n' + + '*Link your account:* `/bgagent link` — one-time setup.\n\n' + + 'Reactions on your message show progress: :eyes: → :hourglass_flowing_sand: → :white_check_mark:', + ); + break; + default: + await reply('Use `@Shoof` to submit tasks, or `/bgagent link` to link your account.\nTry `/bgagent help` for more info.'); + } + } catch (err) { + logger.error('Slack command processing failed', { + subcommand, + error: err instanceof Error ? err.message : String(err), + team_id: event.team_id, + user_id: event.user_id, + }); + await reply(':warning: Something went wrong. Please try again.'); + } +} + +type ReplyFn = (text: string) => Promise; + +/** Build a reply function that posts in-thread via chat.postMessage for @mentions. */ +function buildMentionReply(event: MentionPayload): ReplyFn { + return async (text: string) => { + const botToken = await getBotToken(event.team_id); + if (!botToken) { + logger.warn('Cannot reply to mention: bot token not found', { team_id: event.team_id }); + return; + } + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify({ + channel: event.channel_id, + text, + thread_ts: event.mention_thread_ts, + }), + }); + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok) { + logger.warn('Failed to post mention reply', { error: result.error, channel: event.channel_id }); + } + }; +} + +// ─── Submit ─────────────────────────────────────────────────────────────────── + +async function handleSubmit(event: MentionPayload, args: string[], reply: ReplyFn): Promise { + if (args.length === 0) { + await reply('Usage: `/bgagent submit org/repo#42 description`'); + return; + } + + // Resolve platform user. + const platformUserId = await lookupPlatformUser(event.team_id, event.user_id); + if (!platformUserId) { + await reply(':link: Your Slack account is not linked. Run `/bgagent link` first.'); + if (event.source === 'mention' && event.mention_thread_ts) { + await swapReaction(event.team_id, event.channel_id, event.mention_thread_ts, 'eyes', 'x'); + } + return; + } + + // Parse repo and optional issue number from first arg: "org/repo#42" or "org/repo". + const repoArg = args[0]; + const { repo, issueNumber } = parseRepoArg(repoArg); + if (!repo) { + await reply(`Invalid repo format: \`${repoArg}\`. Expected \`org/repo\` or \`org/repo#42\`.`); + if (event.source === 'mention' && event.mention_thread_ts) { + await swapReaction(event.team_id, event.channel_id, event.mention_thread_ts, 'eyes', 'x'); + } + return; + } + + // Check if the bot can post to this channel (private channels need an invite). + const channelCheck = await checkChannelAccess(event.team_id, event.channel_id); + if (!channelCheck.ok) { + await reply(channelCheck.error!); + return; + } + + // Remaining args are the task description. + const description = args.slice(1).join(' ') || undefined; + + // For @mentions, include the thread_ts so notifications thread under the mention. + const channelMetadata: Record = { + slack_team_id: event.team_id, + slack_channel_id: event.channel_id, + slack_user_id: event.user_id, + slack_response_url: event.response_url, + }; + if (event.source === 'mention' && event.mention_thread_ts) { + channelMetadata.slack_thread_ts = event.mention_thread_ts; + } + + // Create the task through the shared core. + const result = await createTaskCore( + { + repo, + issue_number: issueNumber, + task_description: description, + }, + { + userId: platformUserId, + channelSource: 'slack', + channelMetadata, + }, + crypto.randomUUID(), + ); + + // Extract task info from the response. + const body = JSON.parse(result.body); + if (result.statusCode === 201 && body.data) { + // For @mentions, the notify handler posts the task_created message in-thread — + // don't duplicate it here. Only reply for slash commands (which have a response_url). + if (event.source !== 'mention') { + const task = body.data; + await reply( + `:white_check_mark: Task created!\n*ID:* \`${task.task_id}\`\n*Repo:* \`${task.repo}\`\n*Status:* ${task.status}`, + ); + } + } else { + const errMsg = body.error?.message ?? 'Unknown error'; + await reply(`:x: Failed to create task: ${errMsg}`); + // Swap reaction to :x: on the mention message. + if (event.source === 'mention' && event.mention_thread_ts) { + await swapReaction(event.team_id, event.channel_id, event.mention_thread_ts, 'eyes', 'x'); + } + } +} + +function parseRepoArg(arg: string): { repo: string | null; issueNumber?: number } { + // Match "org/repo#42" or "org/repo" + const match = arg.match(/^([a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+)(?:#(\d+))?$/); + if (!match) return { repo: null }; + return { + repo: match[1], + issueNumber: match[2] ? parseInt(match[2], 10) : undefined, + }; +} + +// ─── Status ─────────────────────────────────────────────────────────────────── + +async function handleStatus(event: MentionPayload, taskId: string | undefined, reply: ReplyFn): Promise { + if (!taskId) { + await reply('Usage: `/bgagent status `'); + return; + } + + const result = await ddb.send(new GetCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + })); + + if (!result.Item) { + await reply(`:mag: Task \`${taskId}\` not found.`); + return; + } + + const task = result.Item; + const lines = [ + ':clipboard: *Task Status*', + `*ID:* \`${task.task_id}\``, + `*Repo:* \`${task.repo}\``, + `*Status:* ${statusEmoji(task.status as string)} ${task.status}`, + ]; + if (task.task_description) lines.push(`*Description:* ${truncate(task.task_description as string, 200)}`); + if (task.pr_url) lines.push(`*PR:* <${task.pr_url}|Pull Request>`); + if (task.error_message) lines.push(`*Error:* ${truncate(task.error_message as string, 200)}`); + if (task.duration_s != null) lines.push(`*Duration:* ${formatDuration(Number(task.duration_s))}`); + if (task.cost_usd != null) lines.push(`*Cost:* $${Number(task.cost_usd).toFixed(2)}`); + + await reply(lines.join('\n')); +} + +// ─── Cancel ─────────────────────────────────────────────────────────────────── + +async function handleCancel(event: MentionPayload, taskId: string | undefined, reply: ReplyFn): Promise { + if (!taskId) { + await reply('Usage: `/bgagent cancel `'); + return; + } + + const platformUserId = await lookupPlatformUser(event.team_id, event.user_id); + if (!platformUserId) { + await reply(':link: Your Slack account is not linked. Run `/bgagent link` first.'); + return; + } + + // Load the task to verify ownership. + const result = await ddb.send(new GetCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + })); + + if (!result.Item) { + await reply(`:mag: Task \`${taskId}\` not found.`); + return; + } + + if (result.Item.user_id !== platformUserId) { + await reply(':no_entry: You can only cancel your own tasks.'); + return; + } + + // Attempt to mark as cancelled via conditional update. + const ACTIVE_STATUSES = ['SUBMITTED', 'HYDRATING', 'RUNNING', 'FINALIZING']; + try { + await ddb.send(new UpdateCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + UpdateExpression: 'SET #s = :cancelled, updated_at = :now', + ConditionExpression: '#s IN (:s1, :s2, :s3, :s4)', + ExpressionAttributeNames: { '#s': 'status' }, + ExpressionAttributeValues: { + ':cancelled': 'CANCELLED', + ':now': new Date().toISOString(), + ':s1': ACTIVE_STATUSES[0], + ':s2': ACTIVE_STATUSES[1], + ':s3': ACTIVE_STATUSES[2], + ':s4': ACTIVE_STATUSES[3], + }, + })); + await reply(`:no_entry_sign: Task \`${taskId}\` has been cancelled.`); + } catch (err) { + const errorName = (err as Error)?.name; + if (errorName === 'ConditionalCheckFailedException') { + await reply(`:warning: Task \`${taskId}\` is already in a terminal state.`); + } else { + throw err; + } + } +} + +// ─── Link ───────────────────────────────────────────────────────────────────── + +async function handleLink(event: MentionPayload, reply: ReplyFn): Promise { + // Generate a 6-character alphanumeric code. + const code = crypto.randomBytes(3).toString('hex').toUpperCase(); + const now = new Date().toISOString(); + const ttl = Math.floor(Date.now() / 1000) + LINK_CODE_TTL_S; + + // Store the pending link record. + await ddb.send(new PutCommand({ + TableName: USER_MAPPING_TABLE, + Item: { + slack_identity: `pending#${code}`, + slack_team_id: event.team_id, + slack_user_id: event.user_id, + link_method: 'slash_command', + linked_at: now, + status: 'pending', + ttl, + }, + })); + + await reply( + `:link: *Link your account*\n\nRun this command in your terminal:\n\`\`\`bgagent slack link ${code}\`\`\`\n_This code expires in 10 minutes._`, + ); +} + +// ─── Channel Access ────────────────────────────────────────────────────────── + +async function getBotToken(teamId: string): Promise { + const installation = await ddb.send(new GetCommand({ + TableName: INSTALLATION_TABLE, + Key: { team_id: teamId }, + })); + if (!installation.Item || installation.Item.status !== 'active') return null; + return getSlackSecret(`${SLACK_SECRET_PREFIX}${teamId}`); +} + +async function checkChannelAccess(teamId: string, channelId: string): Promise<{ ok: boolean; error?: string }> { + // DM channels always work — notifications fall back to user ID. + if (channelId.startsWith('D')) return { ok: true }; + + const botToken = await getBotToken(teamId); + if (!botToken) return { ok: true }; // Can't check, allow and let notify handle errors. + + try { + const response = await fetch(`https://slack.com/api/conversations.info?channel=${channelId}`, { + headers: { Authorization: `Bearer ${botToken}` }, + }); + const result = await response.json() as { ok: boolean; channel?: { is_private: boolean; is_member: boolean }; error?: string }; + + if (!result.ok) { + // channel_not_found means the bot can't see it — private channel, not invited. + if (result.error === 'channel_not_found') { + return { ok: false, error: ':lock: This is a private channel and the bot is not a member. Invite the bot first with `/invite @bgagent`, or submit from a public channel or DM.' }; + } + return { ok: true }; // Unknown error, allow and let notify handle it. + } + + if (result.channel?.is_private && !result.channel?.is_member) { + return { ok: false, error: ':lock: This is a private channel and the bot is not a member. Invite the bot first with `/invite @bgagent`, or submit from a public channel or DM.' }; + } + + return { ok: true }; + } catch (err) { + logger.warn('Channel access check failed', { error: err instanceof Error ? err.message : String(err) }); + return { ok: true }; // Fail open — don't block submit on a check failure. + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function lookupPlatformUser(teamId: string, userId: string): Promise { + const key = `${teamId}#${userId}`; + logger.info('Looking up platform user', { slack_identity: key, table: USER_MAPPING_TABLE }); + const result = await ddb.send(new GetCommand({ + TableName: USER_MAPPING_TABLE, + Key: { slack_identity: key }, + })); + + if (!result.Item) { + logger.warn('No user mapping found', { slack_identity: key }); + return null; + } + if (result.Item.status === 'pending') { + logger.warn('User mapping is pending', { slack_identity: key }); + return null; + } + logger.info('Found platform user', { slack_identity: key, platform_user_id: result.Item.platform_user_id }); + return (result.Item.platform_user_id as string) ?? null; +} + +async function postToSlack(responseUrl: string, text: string): Promise { + logger.info('Posting to Slack response_url', { + response_url: responseUrl.substring(0, 80), + text_length: text.length, + }); + try { + const response = await fetch(responseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ response_type: 'ephemeral', text }), + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + logger.warn('Failed to post to Slack response_url', { + status: response.status, + response_url: responseUrl.substring(0, 80), + body, + }); + } else { + logger.info('Slack response_url post succeeded', { status: response.status }); + } + } catch (err) { + logger.warn('Error posting to Slack response_url', { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +function statusEmoji(status: string): string { + switch (status) { + case 'SUBMITTED': return ':inbox_tray:'; + case 'HYDRATING': return ':droplet:'; + case 'RUNNING': return ':gear:'; + case 'FINALIZING': return ':hourglass:'; + case 'COMPLETED': return ':white_check_mark:'; + case 'FAILED': return ':x:'; + case 'CANCELLED': return ':no_entry_sign:'; + case 'TIMED_OUT': return ':hourglass:'; + default: return ':grey_question:'; + } +} + +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen - 3) + '...'; +} + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${Math.round(seconds)}s`; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + if (m < 60) return s > 0 ? `${m}m ${s}s` : `${m}m`; + const h = Math.floor(m / 60); + const remainM = m % 60; + return remainM > 0 ? `${h}h ${remainM}m` : `${h}h`; +} + +async function swapReaction(teamId: string, channelId: string, messageTs: string, remove: string, add: string): Promise { + const botToken = await getBotToken(teamId); + if (!botToken) return; + await fetch('https://slack.com/api/reactions.remove', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8', 'Authorization': `Bearer ${botToken}` }, + body: JSON.stringify({ channel: channelId, timestamp: messageTs, name: remove }), + }).catch(() => {}); + await fetch('https://slack.com/api/reactions.add', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8', 'Authorization': `Bearer ${botToken}` }, + body: JSON.stringify({ channel: channelId, timestamp: messageTs, name: add }), + }).catch(() => {}); +} diff --git a/cdk/src/handlers/slack-commands.ts b/cdk/src/handlers/slack-commands.ts new file mode 100644 index 0000000..1de6fa7 --- /dev/null +++ b/cdk/src/handlers/slack-commands.ts @@ -0,0 +1,146 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { logger } from './shared/logger'; +import { getSlackSecret, verifySlackSignature } from './shared/slack-verify'; + +const lambdaClient = new LambdaClient({}); + +const SIGNING_SECRET_ARN = process.env.SLACK_SIGNING_SECRET_ARN!; +const PROCESSOR_FUNCTION_NAME = process.env.SLACK_COMMAND_PROCESSOR_FUNCTION_NAME!; + +/** Parsed Slack slash command payload (URL-encoded form data). */ +export interface SlackCommandPayload { + readonly command: string; + readonly text: string; + readonly response_url: string; + readonly trigger_id: string; + readonly user_id: string; + readonly user_name: string; + readonly team_id: string; + readonly team_domain: string; + readonly channel_id: string; + readonly channel_name: string; +} + +/** + * POST /v1/slack/commands — Handle Slack slash commands. + * + * Must respond within 3 seconds. Verifies the signing secret, parses the + * command, acknowledges immediately, and async-invokes the processor Lambda. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + if (!event.body) { + return slackResponse('Request body is required.'); + } + + // Verify Slack signing secret. + const signingSecret = await getSlackSecret(SIGNING_SECRET_ARN); + if (!signingSecret) { + logger.error('Slack signing secret not found'); + return slackResponse('Internal configuration error.'); + } + + const signature = event.headers['X-Slack-Signature'] ?? event.headers['x-slack-signature'] ?? ''; + const timestamp = event.headers['X-Slack-Request-Timestamp'] ?? event.headers['x-slack-request-timestamp'] ?? ''; + + if (!verifySlackSignature(signingSecret, signature, timestamp, event.body)) { + logger.warn('Invalid Slack command signature'); + return { statusCode: 401, headers: { 'Content-Type': 'text/plain' }, body: 'Invalid signature' }; + } + + // Parse URL-encoded form body. + const payload = parseFormBody(event.body); + const subcommand = (payload.text ?? '').trim().split(/\s+/)[0]?.toLowerCase() ?? ''; + + // For 'help' we can respond inline (no async processing needed). + if (subcommand === 'help' || subcommand === '') { + return slackResponse(HELP_TEXT); + } + + // Async-invoke the processor Lambda for all other subcommands. + try { + await lambdaClient.send(new InvokeCommand({ + FunctionName: PROCESSOR_FUNCTION_NAME, + InvocationType: 'Event', + Payload: new TextEncoder().encode(JSON.stringify(payload)), + })); + } catch (err) { + logger.error('Failed to invoke Slack command processor', { + error: err instanceof Error ? err.message : String(err), + subcommand, + }); + return slackResponse('Failed to process command. Please try again.'); + } + + // Acknowledge immediately — the processor will follow up via response_url. + const ackMessage = ACK_MESSAGES[subcommand] ?? `Processing \`${subcommand}\`...`; + return slackResponse(ackMessage); + } catch (err) { + logger.error('Slack command handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + return slackResponse('An unexpected error occurred. Please try again.'); + } +} + +function parseFormBody(body: string): SlackCommandPayload { + const params = new URLSearchParams(body); + return { + command: params.get('command') ?? '', + text: params.get('text') ?? '', + response_url: params.get('response_url') ?? '', + trigger_id: params.get('trigger_id') ?? '', + user_id: params.get('user_id') ?? '', + user_name: params.get('user_name') ?? '', + team_id: params.get('team_id') ?? '', + team_domain: params.get('team_domain') ?? '', + channel_id: params.get('channel_id') ?? '', + channel_name: params.get('channel_name') ?? '', + }; +} + +function slackResponse(text: string): APIGatewayProxyResult { + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ response_type: 'ephemeral', text }), + }; +} + +const ACK_MESSAGES: Record = { + link: ':link: Generating link code...', +}; + +const HELP_TEXT = `*Using Shoof* + +*Submit a task:* Mention \`@Shoof\` in any channel: +> \`@Shoof fix the login bug in org/repo#42\` +> \`@Shoof update the README in org/repo\` + +*Private submissions:* DM Shoof directly. + +*Cancel a task:* Use the Cancel button in the thread. + +*Link your account:* \`/bgagent link\` — one-time setup. + +Reactions on your message show progress: :eyes: → :hourglass_flowing_sand: → :white_check_mark:`; diff --git a/cdk/src/handlers/slack-events.ts b/cdk/src/handlers/slack-events.ts new file mode 100644 index 0000000..60554d7 --- /dev/null +++ b/cdk/src/handlers/slack-events.ts @@ -0,0 +1,288 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { DeleteSecretCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { logger } from './shared/logger'; +import { getSlackSecret, SLACK_SECRET_PREFIX, verifySlackSignature } from './shared/slack-verify'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const sm = new SecretsManagerClient({}); +const lambdaClient = new LambdaClient({}); + +const TABLE_NAME = process.env.SLACK_INSTALLATION_TABLE_NAME!; +const SIGNING_SECRET_ARN = process.env.SLACK_SIGNING_SECRET_ARN!; +const PROCESSOR_FUNCTION_NAME = process.env.SLACK_COMMAND_PROCESSOR_FUNCTION_NAME; + +/** Secret recovery window for revoked installations. */ +const SECRET_RECOVERY_DAYS = 7; + +interface SlackEventPayload { + readonly type: string; + readonly challenge?: string; + readonly token?: string; + readonly team_id?: string; + readonly event?: { + readonly type: string; + readonly user?: string; + readonly text?: string; + readonly channel?: string; + readonly ts?: string; + readonly thread_ts?: string; + readonly [key: string]: unknown; + }; +} + +/** + * POST /v1/slack/events — Handle Slack Events API requests. + * + * Handles: + * - `url_verification` challenge (Slack sends this when the event URL is configured) + * - `app_uninstalled` event (mark installation revoked, delete bot token) + * - `tokens_revoked` event (same cleanup) + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + if (!event.body) { + return jsonResponse(400, { error: 'Request body is required' }); + } + + // Slack retries events if we don't respond within 3 seconds. Ack retries + // immediately to prevent duplicate task creation. + const retryNum = event.headers['X-Slack-Retry-Num'] ?? event.headers['x-slack-retry-num']; + if (retryNum) { + logger.info('Acknowledging Slack retry', { retry_num: retryNum }); + return jsonResponse(200, { ok: true }); + } + + // Parse the payload first — url_verification must respond before signature check + // to complete the Slack app setup flow. + const payload: SlackEventPayload = JSON.parse(event.body); + + // URL verification challenge — Slack sends this when configuring the event URL. + if (payload.type === 'url_verification' && payload.challenge) { + return jsonResponse(200, { challenge: payload.challenge }); + } + + // Verify Slack signing secret for all other event types. + const signingSecret = await getSlackSecret(SIGNING_SECRET_ARN); + if (!signingSecret) { + logger.error('Slack signing secret not found'); + return jsonResponse(500, { error: 'Internal configuration error' }); + } + + const signature = event.headers['X-Slack-Signature'] ?? event.headers['x-slack-signature'] ?? ''; + const timestamp = event.headers['X-Slack-Request-Timestamp'] ?? event.headers['x-slack-request-timestamp'] ?? ''; + + if (!verifySlackSignature(signingSecret, signature, timestamp, event.body)) { + logger.warn('Invalid Slack event signature'); + return jsonResponse(401, { error: 'Invalid signature' }); + } + + // Dispatch by event type. + if (payload.type === 'event_callback' && payload.event) { + const eventType = payload.event.type; + const teamId = payload.team_id; + + if ((eventType === 'app_uninstalled' || eventType === 'tokens_revoked') && teamId) { + await revokeInstallation(teamId); + } else if (eventType === 'app_mention' && teamId) { + await handleAppMention(payload.event, teamId); + } else if (eventType === 'message' && teamId && payload.event.channel_type === 'im') { + // DMs to the bot — skip bot's own messages to avoid loops. + if (!payload.event.bot_id) { + await handleAppMention(payload.event, teamId); + } + } else { + logger.info('Unhandled Slack event type', { event_type: eventType, team_id: teamId }); + } + } + + return jsonResponse(200, { ok: true }); + } catch (err) { + logger.error('Slack event handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(500, { error: 'Internal server error' }); + } +} + +async function handleAppMention( + event: NonNullable, + teamId: string, +): Promise { + if (!PROCESSOR_FUNCTION_NAME) { + logger.warn('SLACK_COMMAND_PROCESSOR_FUNCTION_NAME not set, ignoring app_mention'); + return; + } + + const userId = event.user; + const channelId = event.channel; + const rawText = event.text ?? ''; + const messageTs = event.ts; + const threadTs = event.thread_ts; + + if (!userId || !channelId) { + logger.warn('app_mention missing user or channel', { event }); + return; + } + + // Strip the @mention prefix (e.g. "<@U12345> fix the bug" → "fix the bug"). + const text = rawText.replace(/<@[A-Z0-9]+>/g, '').trim(); + + if (!text) { + logger.info('app_mention with empty text after stripping mention, ignoring'); + return; + } + + // Build a payload compatible with the command processor. + // Use source: 'mention' so the processor knows there's no response_url — + // it should use chat.postMessage with the bot token instead. + // + // For natural language mentions like "@Shoof fix the bug in org/repo#42", + // extract the repo pattern and reorder so submit gets "org/repo#42 fix the bug". + // The submit handler expects: submit + const repoPattern = /\b([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(?:#\d+)?)\b/; + const repoMatch = text.match(repoPattern); + if (!repoMatch) { + // No repo found — reply with a helpful error instead of a broken submit. + const botToken = await getSlackSecret(`${SLACK_SECRET_PREFIX}${teamId}`); + if (botToken) { + const mentionTs = threadTs ?? messageTs; + // Swap :eyes: to :x: on the mention + if (mentionTs) { + await fetch('https://slack.com/api/reactions.remove', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8', 'Authorization': `Bearer ${botToken}` }, + body: JSON.stringify({ channel: channelId, timestamp: mentionTs, name: 'eyes' }), + }).catch(() => {}); + await fetch('https://slack.com/api/reactions.add', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8', 'Authorization': `Bearer ${botToken}` }, + body: JSON.stringify({ channel: channelId, timestamp: mentionTs, name: 'x' }), + }).catch(() => {}); + } + await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8', 'Authorization': `Bearer ${botToken}` }, + body: JSON.stringify({ + channel: channelId, + thread_ts: mentionTs, + text: ':x: Please include a repo — e.g. `@Shoof fix the bug in org/repo#42`', + }), + }).catch(() => {}); + } + return; + } + + const repo = repoMatch[0]; + const description = text.replace(repo, '').replace(/\s+/g, ' ').trim(); + const commandText = `submit ${repo} ${description}`; + + const mentionPayload = { + command: '/bgagent', + text: commandText, + response_url: '', + trigger_id: '', + user_id: userId, + user_name: '', + team_id: teamId, + team_domain: '', + channel_id: channelId, + channel_name: '', + source: 'mention' as const, + mention_thread_ts: threadTs ?? messageTs, + }; + + // React with :eyes: immediately so the user knows the bot saw their message. + const mentionTs = threadTs ?? messageTs; + if (mentionTs) { + const botToken = await getSlackSecret(`${SLACK_SECRET_PREFIX}${teamId}`); + if (botToken) { + await fetch('https://slack.com/api/reactions.add', { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8', 'Authorization': `Bearer ${botToken}` }, + body: JSON.stringify({ channel: channelId, timestamp: mentionTs, name: 'eyes' }), + }).catch(() => {}); + } + } + + try { + await lambdaClient.send(new InvokeCommand({ + FunctionName: PROCESSOR_FUNCTION_NAME, + InvocationType: 'Event', + Payload: new TextEncoder().encode(JSON.stringify(mentionPayload)), + })); + logger.info('app_mention forwarded to command processor', { + team_id: teamId, + user_id: userId, + channel_id: channelId, + text_length: text.length, + }); + } catch (err) { + logger.error('Failed to invoke command processor for app_mention', { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +async function revokeInstallation(teamId: string): Promise { + const now = new Date().toISOString(); + + // Mark the installation as revoked. + try { + await ddb.send(new UpdateCommand({ + TableName: TABLE_NAME, + Key: { team_id: teamId }, + UpdateExpression: 'SET #s = :revoked, updated_at = :now, revoked_at = :now', + ExpressionAttributeNames: { '#s': 'status' }, + ExpressionAttributeValues: { ':revoked': 'revoked', ':now': now }, + })); + } catch (err) { + logger.error('Failed to revoke Slack installation', { + team_id: teamId, + error: err instanceof Error ? err.message : String(err), + }); + } + + // Schedule the bot token secret for deletion. + try { + await sm.send(new DeleteSecretCommand({ + SecretId: `${SLACK_SECRET_PREFIX}${teamId}`, + RecoveryWindowInDays: SECRET_RECOVERY_DAYS, + })); + logger.info('Slack installation revoked', { team_id: teamId }); + } catch (err) { + logger.warn('Failed to delete Slack bot token secret', { + team_id: teamId, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +function jsonResponse(statusCode: number, body: Record): APIGatewayProxyResult { + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; +} diff --git a/cdk/src/handlers/slack-interactions.ts b/cdk/src/handlers/slack-interactions.ts new file mode 100644 index 0000000..a726b86 --- /dev/null +++ b/cdk/src/handlers/slack-interactions.ts @@ -0,0 +1,244 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { logger } from './shared/logger'; +import { getSlackSecret, SLACK_SECRET_PREFIX, verifySlackSignature } from './shared/slack-verify'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +const SIGNING_SECRET_ARN = process.env.SLACK_SIGNING_SECRET_ARN!; +const TASK_TABLE = process.env.TASK_TABLE_NAME!; +const USER_MAPPING_TABLE = process.env.SLACK_USER_MAPPING_TABLE_NAME!; + +interface SlackInteractionPayload { + readonly type: string; + readonly user: { readonly id: string; readonly username: string; readonly team_id: string }; + readonly actions?: ReadonlyArray<{ + readonly action_id: string; + readonly block_id: string; + readonly value?: string; + }>; + readonly response_url: string; + readonly trigger_id: string; + readonly channel?: { readonly id: string }; +} + +/** + * POST /v1/slack/interactions — Handle Slack Block Kit interactive actions. + * + * Slack sends interaction payloads as a URL-encoded `payload` field in the body. + * Currently handles: + * - `cancel_task:{task_id}` — Cancel a running task via the "Cancel Task" button. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + if (!event.body) { + return jsonResponse(400, { error: 'Request body is required' }); + } + + // Verify Slack signing secret. + const signingSecret = await getSlackSecret(SIGNING_SECRET_ARN); + if (!signingSecret) { + logger.error('Slack signing secret not found'); + return jsonResponse(500, { error: 'Internal configuration error' }); + } + + const signature = event.headers['X-Slack-Signature'] ?? event.headers['x-slack-signature'] ?? ''; + const timestamp = event.headers['X-Slack-Request-Timestamp'] ?? event.headers['x-slack-request-timestamp'] ?? ''; + + if (!verifySlackSignature(signingSecret, signature, timestamp, event.body)) { + logger.warn('Invalid Slack interaction signature'); + return jsonResponse(401, { error: 'Invalid signature' }); + } + + // Parse the payload — Slack sends it as URL-encoded `payload=`. + const params = new URLSearchParams(event.body); + const payloadStr = params.get('payload'); + if (!payloadStr) { + return jsonResponse(400, { error: 'Missing payload' }); + } + + const payload: SlackInteractionPayload = JSON.parse(payloadStr); + + if (payload.type === 'block_actions' && payload.actions) { + for (const action of payload.actions) { + if (action.action_id.startsWith('cancel_task:')) { + await handleCancelAction(payload, action.action_id); + } + } + } + + // Slack expects a 200 response within 3 seconds. + return jsonResponse(200, {}); + } catch (err) { + logger.error('Slack interaction handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(200, {}); // Still return 200 to avoid Slack retries. + } +} + +async function handleCancelAction(payload: SlackInteractionPayload, actionId: string): Promise { + const taskId = actionId.replace('cancel_task:', ''); + const teamId = payload.user.team_id; + const userId = payload.user.id; + + // Look up platform user. + const mappingResult = await ddb.send(new GetCommand({ + TableName: USER_MAPPING_TABLE, + Key: { slack_identity: `${teamId}#${userId}` }, + })); + + if (!mappingResult.Item || mappingResult.Item.status === 'pending') { + await postToResponseUrl(payload.response_url, ':link: Your Slack account is not linked.'); + return; + } + + const platformUserId = mappingResult.Item.platform_user_id as string; + + // Load the task. + const taskResult = await ddb.send(new GetCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + })); + + if (!taskResult.Item) { + await postToResponseUrl(payload.response_url, `:mag: Task \`${taskId}\` not found.`); + return; + } + + if (taskResult.Item.user_id !== platformUserId) { + await postToResponseUrl(payload.response_url, ':no_entry: You can only cancel your own tasks.'); + return; + } + + // Attempt to cancel. + const ACTIVE_STATUSES = ['SUBMITTED', 'HYDRATING', 'RUNNING', 'FINALIZING']; + try { + await ddb.send(new UpdateCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + UpdateExpression: 'SET #s = :cancelled, updated_at = :now', + ConditionExpression: '#s IN (:s1, :s2, :s3, :s4)', + ExpressionAttributeNames: { '#s': 'status' }, + ExpressionAttributeValues: { + ':cancelled': 'CANCELLED', + ':now': new Date().toISOString(), + ':s1': ACTIVE_STATUSES[0], + ':s2': ACTIVE_STATUSES[1], + ':s3': ACTIVE_STATUSES[2], + ':s4': ACTIVE_STATUSES[3], + }, + })); + + // Instant feedback: replace the Cancel button message with "Cancelling..." + // then clean up all intermediate messages. + const channelMeta = taskResult.Item.channel_metadata as Record | undefined; + const channelId = payload.channel?.id ?? channelMeta?.slack_channel_id; + if (channelMeta && channelId) { + const botToken = await getSlackSecret(`${SLACK_SECRET_PREFIX}${teamId}`); + if (botToken) { + if (channelMeta.slack_session_msg_ts) { + await updateSlackMessage(botToken, channelId, channelMeta.slack_session_msg_ts, + ':hourglass_flowing_sand: Cancelling...', channelMeta.slack_thread_ts); + } + const toDelete = [channelMeta.slack_created_msg_ts].filter(Boolean); + for (const ts of toDelete) { + await deleteSlackMessage(botToken, channelId, ts!); + } + } + } + } catch (err) { + if ((err as Error)?.name === 'ConditionalCheckFailedException') { + await postToResponseUrl(payload.response_url, ':warning: Task is already in a terminal state.'); + } else { + throw err; + } + } +} + +async function updateSlackMessage(botToken: string, channel: string, ts: string, text: string, threadTs?: string): Promise { + try { + const payload: Record = { + channel, + ts, + text, + blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }], + }; + if (threadTs) payload.thread_ts = threadTs; + const response = await fetch('https://slack.com/api/chat.update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify(payload), + }); + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok) { + logger.warn('Failed to update Slack message', { error: result.error, ts }); + } + } catch (err) { + logger.warn('Error updating Slack message', { error: err instanceof Error ? err.message : String(err) }); + } +} + +async function deleteSlackMessage(botToken: string, channel: string, ts: string): Promise { + try { + const response = await fetch('https://slack.com/api/chat.delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify({ channel, ts }), + }); + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok) { + logger.warn('Failed to delete Slack message', { error: result.error, ts }); + } + } catch (err) { + logger.warn('Error deleting Slack message', { error: err instanceof Error ? err.message : String(err) }); + } +} + +async function postToResponseUrl(responseUrl: string, text: string): Promise { + try { + await fetch(responseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ response_type: 'ephemeral', text, replace_original: false }), + }); + } catch (err) { + logger.warn('Failed to post to interaction response_url', { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +function jsonResponse(statusCode: number, body: Record): APIGatewayProxyResult { + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; +} diff --git a/cdk/src/handlers/slack-link.ts b/cdk/src/handlers/slack-link.ts new file mode 100644 index 0000000..60ba20d --- /dev/null +++ b/cdk/src/handlers/slack-link.ts @@ -0,0 +1,112 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import { parseBody } from './shared/validation'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +const USER_MAPPING_TABLE = process.env.SLACK_USER_MAPPING_TABLE_NAME!; + +interface LinkRequest { + readonly code: string; +} + +/** + * POST /v1/slack/link — Complete Slack account linking. + * + * Called from the CLI (`bgagent slack link `) with a Cognito JWT. + * Looks up the pending link record, maps the Slack identity to the + * authenticated platform user, and cleans up the pending record. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Authentication required.', requestId); + } + + const body = parseBody(event.body ?? null); + if (!body?.code) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Request body must include a "code" field.', requestId); + } + + const code = body.code.trim().toUpperCase(); + + // Look up the pending link record. + const pending = await ddb.send(new GetCommand({ + TableName: USER_MAPPING_TABLE, + Key: { slack_identity: `pending#${code}` }, + })); + + if (!pending.Item || pending.Item.status !== 'pending') { + return errorResponse(404, ErrorCode.VALIDATION_ERROR, 'Invalid or expired link code.', requestId); + } + + const teamId = pending.Item.slack_team_id as string; + const slackUserId = pending.Item.slack_user_id as string; + const now = new Date().toISOString(); + + // Write the confirmed mapping. + await ddb.send(new PutCommand({ + TableName: USER_MAPPING_TABLE, + Item: { + slack_identity: `${teamId}#${slackUserId}`, + platform_user_id: userId, + slack_team_id: teamId, + slack_user_id: slackUserId, + linked_at: now, + link_method: 'slash_command', + }, + })); + + // Clean up the pending record. + await ddb.send(new DeleteCommand({ + TableName: USER_MAPPING_TABLE, + Key: { slack_identity: `pending#${code}` }, + })); + + logger.info('Slack account linked', { + platform_user_id: userId, + slack_team_id: teamId, + slack_user_id: slackUserId, + }); + + return successResponse(200, { + message: 'Slack account linked successfully.', + slack_team_id: teamId, + slack_user_id: slackUserId, + linked_at: now, + }, requestId); + } catch (err) { + logger.error('Slack link handler failed', { + error: err instanceof Error ? err.message : String(err), + request_id: requestId, + }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} diff --git a/cdk/src/handlers/slack-notify.ts b/cdk/src/handlers/slack-notify.ts new file mode 100644 index 0000000..a9ca5ee --- /dev/null +++ b/cdk/src/handlers/slack-notify.ts @@ -0,0 +1,333 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import type { DynamoDBStreamEvent, DynamoDBRecord } from 'aws-lambda'; +import { logger } from './shared/logger'; +import { renderSlackBlocks } from './shared/slack-blocks'; +import { getSlackSecret, SLACK_SECRET_PREFIX } from './shared/slack-verify'; +import type { TaskRecord } from './shared/types'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +const TASK_TABLE = process.env.TASK_TABLE_NAME!; + +const TERMINAL_EVENTS = new Set(['task_completed', 'task_failed', 'task_cancelled', 'task_timed_out']); + +/** Event types that trigger Slack notifications. */ +const NOTIFIABLE_EVENTS = new Set([ + 'task_created', + 'session_started', + 'task_completed', + 'task_failed', + 'task_cancelled', + 'task_timed_out', +]); + +/** + * Slack notification handler triggered by DynamoDB Streams on TaskEventsTable. + * + * For each task event: + * 1. Load the task record to check channel_source and channel_metadata. + * 2. If channel_source is 'slack', render a Block Kit message and post to Slack. + * 3. Thread replies under the initial message using stored slack_thread_ts. + * + * Notifications are best-effort — failures are logged but never fail the stream. + */ +export async function handler(event: DynamoDBStreamEvent): Promise { + for (const record of event.Records) { + try { + await processRecord(record); + } catch (err) { + logger.warn('Failed to process Slack notification for stream record', { + error: err instanceof Error ? err.message : String(err), + event_id: record.eventID, + }); + } + } +} + +async function processRecord(record: DynamoDBRecord): Promise { + if (record.eventName !== 'INSERT' || !record.dynamodb?.NewImage) return; + + const newImage = record.dynamodb.NewImage; + const eventType = newImage.event_type?.S; + const taskId = newImage.task_id?.S; + + if (!eventType || !taskId || !NOTIFIABLE_EVENTS.has(eventType)) return; + + // Deduplicate terminal notifications — the orchestrator may write multiple + // failure/completion events (retries). Use a conditional update to claim + // the right to send the terminal notification. + + if (TERMINAL_EVENTS.has(eventType)) { + try { + await ddb.send(new UpdateCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + UpdateExpression: 'SET channel_metadata.slack_notified_terminal = :t', + ConditionExpression: 'attribute_not_exists(channel_metadata.slack_notified_terminal)', + ExpressionAttributeValues: { ':t': true }, + })); + } catch (err) { + if ((err as Error)?.name === 'ConditionalCheckFailedException') { + logger.info('Terminal notification already sent, skipping duplicate', { task_id: taskId, event_type: eventType }); + return; + } + throw err; + } + } + + // Load the task record. + const taskResult = await ddb.send(new GetCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + })); + + const task = taskResult.Item as TaskRecord | undefined; + if (!task || task.channel_source !== 'slack') return; + + const channelMeta = task.channel_metadata; + if (!channelMeta?.slack_team_id || !channelMeta?.slack_channel_id) { + logger.warn('Slack task missing channel metadata', { task_id: taskId }); + return; + } + + // Fetch the bot token for this workspace. + const botToken = await getSlackSecret(`${SLACK_SECRET_PREFIX}${channelMeta.slack_team_id}`); + if (!botToken) { + logger.warn('Bot token not found for Slack workspace', { + team_id: channelMeta.slack_team_id, + task_id: taskId, + }); + return; + } + + // Parse event metadata if present. + const eventMetadata = newImage.metadata?.S + ? safeJsonParse(newImage.metadata.S) + : undefined; + + // Render the Slack message. + const message = renderSlackBlocks(eventType, task, eventMetadata ?? undefined); + + // For task_created, post a new message. For subsequent events, reply in thread. + const threadTs = channelMeta.slack_thread_ts; + + // For DM channels (prefix 'D'), post to the user ID instead — chat.postMessage + // opens a DM automatically when given a user ID, which avoids the channel_not_found + // error that occurs with ephemeral DM channel IDs from slash commands. + const channel = channelMeta.slack_channel_id.startsWith('D') && channelMeta.slack_user_id + ? channelMeta.slack_user_id + : channelMeta.slack_channel_id; + + const slackPayload: Record = { + channel, + text: message.text, + blocks: message.blocks, + }; + + // Thread all messages under the original. For @mentions, threadTs is set to the + // user's mention message by the command processor. For slash commands, threadTs + // is set to the task_created message after it's posted (see below). + if (threadTs) { + slackPayload.thread_ts = threadTs; + } + + // Suppress link unfurls — the View PR button is the clean way to access it. + slackPayload.unfurl_links = false; + + // Post to Slack. + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify(slackPayload), + }); + + const result = await response.json() as { ok: boolean; ts?: string; error?: string }; + + if (!result.ok) { + logger.warn('Slack API returned error', { + error: result.error, + task_id: taskId, + event_type: eventType, + }); + return; + } + + // Emoji reaction on the root message — the user's @mention or the task_created message. + // Reactions always use the real channel ID (not user ID), even for DMs. + const reactionChannel = channelMeta.slack_channel_id; + const reactionTarget = threadTs ?? result.ts; + if (reactionTarget) { + await updateReaction(botToken, reactionChannel, reactionTarget, eventType); + } + + // Store message timestamps for later updates. + if (result.ts) { + if (eventType === 'task_created') { + const updates: string[] = ['channel_metadata.slack_created_msg_ts = :created_ts']; + const values: Record = { ':created_ts': result.ts }; + if (!threadTs) { + // Slash commands: also store thread_ts (mentions already have it). + updates.push('channel_metadata.slack_thread_ts = :created_ts'); + } + try { + await ddb.send(new UpdateCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + UpdateExpression: `SET ${updates.join(', ')}`, + ExpressionAttributeValues: values, + })); + } catch (err) { + logger.warn('Failed to store task_created message ts', { + task_id: taskId, + error: err instanceof Error ? err.message : String(err), + }); + } + } else if (eventType === 'session_started') { + try { + await ddb.send(new UpdateCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + UpdateExpression: 'SET channel_metadata.slack_session_msg_ts = :ts', + ExpressionAttributeValues: { ':ts': result.ts }, + })); + } catch (err) { + logger.warn('Failed to store session message ts', { + task_id: taskId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + // On terminal events, clean up intermediate messages — only the final + // result message stays in the thread. + if (TERMINAL_EVENTS.has(eventType)) { + if (channelMeta.slack_session_msg_ts) { + await deleteMessage(botToken, channel, channelMeta.slack_session_msg_ts); + } + if (channelMeta.slack_created_msg_ts) { + await deleteMessage(botToken, channel, channelMeta.slack_created_msg_ts); + } + } + + logger.info('Slack notification sent', { + task_id: taskId, + event_type: eventType, + team_id: channelMeta.slack_team_id, + channel_id: channelMeta.slack_channel_id, + }); +} + +/** Map event types to the emoji reaction that should be on the original message. */ +const EVENT_REACTIONS: Record = { + task_created: 'eyes', + session_started: 'hourglass_flowing_sand', + task_completed: 'white_check_mark', + task_failed: 'x', + task_cancelled: 'no_entry_sign', + task_timed_out: 'hourglass', +}; + +/** Reactions to remove when transitioning to a new state. */ +const STALE_REACTIONS = ['eyes', 'hourglass_flowing_sand']; + +async function addReaction(botToken: string, channel: string, timestamp: string, emoji: string): Promise { + try { + const response = await fetch('https://slack.com/api/reactions.add', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify({ channel, timestamp, name: emoji }), + }); + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok && result.error !== 'already_reacted') { + logger.warn('Failed to add Slack reaction', { emoji, error: result.error }); + } + } catch (err) { + logger.warn('Error adding Slack reaction', { emoji, error: err instanceof Error ? err.message : String(err) }); + } +} + +async function removeReaction(botToken: string, channel: string, timestamp: string, emoji: string): Promise { + try { + const response = await fetch('https://slack.com/api/reactions.remove', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify({ channel, timestamp, name: emoji }), + }); + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok && result.error !== 'no_reaction') { + logger.warn('Failed to remove Slack reaction', { emoji, error: result.error }); + } + } catch (err) { + logger.warn('Error removing Slack reaction', { emoji, error: err instanceof Error ? err.message : String(err) }); + } +} + +async function updateReaction(botToken: string, channel: string, threadTs: string, eventType: string): Promise { + const newEmoji = EVENT_REACTIONS[eventType]; + if (!newEmoji) return; + + // Remove stale reactions first, then add the new one. + for (const stale of STALE_REACTIONS) { + if (stale !== newEmoji) { + await removeReaction(botToken, channel, threadTs, stale); + } + } + await addReaction(botToken, channel, threadTs, newEmoji); +} + +async function deleteMessage(botToken: string, channel: string, messageTs: string): Promise { + try { + const response = await fetch('https://slack.com/api/chat.delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify({ channel, ts: messageTs }), + }); + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok) { + logger.warn('Failed to delete session message', { error: result.error }); + } + } catch (err) { + logger.warn('Error deleting session message', { error: err instanceof Error ? err.message : String(err) }); + } +} + +function safeJsonParse(text: string): Record | null { + try { + return JSON.parse(text); + } catch { + return null; + } +} diff --git a/cdk/src/handlers/slack-oauth-callback.ts b/cdk/src/handlers/slack-oauth-callback.ts new file mode 100644 index 0000000..e7f1d3b --- /dev/null +++ b/cdk/src/handlers/slack-oauth-callback.ts @@ -0,0 +1,195 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { CreateSecretCommand, RestoreSecretCommand, SecretsManagerClient, UpdateSecretCommand, ResourceNotFoundException, InvalidRequestException } from '@aws-sdk/client-secrets-manager'; +import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { logger } from './shared/logger'; +import { getSlackSecret, SLACK_SECRET_PREFIX } from './shared/slack-verify'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const sm = new SecretsManagerClient({}); + +const TABLE_NAME = process.env.SLACK_INSTALLATION_TABLE_NAME!; +const CLIENT_ID_SECRET_ARN = process.env.SLACK_CLIENT_ID_SECRET_ARN!; +const CLIENT_SECRET_ARN = process.env.SLACK_CLIENT_SECRET_ARN!; + +interface SlackOAuthResponse { + readonly ok: boolean; + readonly error?: string; + readonly app_id?: string; + readonly team?: { readonly id: string; readonly name: string }; + readonly bot_user_id?: string; + readonly access_token?: string; + readonly scope?: string; + readonly authed_user?: { readonly id: string }; +} + +/** + * GET /v1/slack/oauth/callback — Handle Slack OAuth V2 redirect. + * + * After a workspace admin authorizes the Slack App, Slack redirects here + * with a `code` query parameter. This handler exchanges the code for a + * bot token, stores it in Secrets Manager, and records the installation. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + const code = event.queryStringParameters?.code; + if (!code) { + return htmlResponse(400, 'Missing authorization code. Please try the install flow again.'); + } + + // Fetch the Slack App client ID and client secret from Secrets Manager. + const clientId = await getSlackSecret(CLIENT_ID_SECRET_ARN); + if (!clientId) { + logger.error('Slack client ID not found', { secret_arn: CLIENT_ID_SECRET_ARN }); + return htmlResponse(500, 'Slack client ID not configured. Populate the secret in Secrets Manager.'); + } + + const clientSecret = await getSlackSecret(CLIENT_SECRET_ARN); + if (!clientSecret) { + logger.error('Slack client secret not found', { secret_arn: CLIENT_SECRET_ARN }); + return htmlResponse(500, 'Slack client secret not configured. Populate the secret in Secrets Manager.'); + } + + // Exchange the code for an access token. + const redirectUri = buildRedirectUri(event); + const tokenResponse = await exchangeCode(code, clientId, clientSecret, redirectUri); + if (!tokenResponse.ok || !tokenResponse.access_token || !tokenResponse.team) { + logger.error('Slack OAuth token exchange failed', { + error: tokenResponse.error ?? 'unknown', + }); + return htmlResponse(400, `Slack authorization failed: ${tokenResponse.error ?? 'unknown error'}`); + } + + const teamId = tokenResponse.team.id; + const teamName = tokenResponse.team.name; + const botToken = tokenResponse.access_token; + const now = new Date().toISOString(); + + // Store the bot token in Secrets Manager. + const secretName = `${SLACK_SECRET_PREFIX}${teamId}`; + await upsertSecret(secretName, botToken, teamId); + + // Write installation record to DynamoDB. + await ddb.send(new PutCommand({ + TableName: TABLE_NAME, + Item: { + team_id: teamId, + team_name: teamName, + bot_token_secret_arn: secretName, + bot_user_id: tokenResponse.bot_user_id ?? '', + app_id: tokenResponse.app_id ?? '', + scope: tokenResponse.scope ?? '', + installed_by: tokenResponse.authed_user?.id ?? '', + installed_at: now, + updated_at: now, + status: 'active', + }, + })); + + logger.info('Slack workspace installed', { team_id: teamId, team_name: teamName }); + + return htmlResponse(200, ` +

Successfully installed!

+

ABCA Background Agent has been added to the ${escapeHtml(teamName)} workspace.

+

Team members can now link their accounts with /bgagent link and start submitting tasks.

+ `); + } catch (err) { + logger.error('Slack OAuth callback failed', { + error: err instanceof Error ? err.message : String(err), + }); + return htmlResponse(500, 'An unexpected error occurred. Please try again.'); + } +} + +async function exchangeCode( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise { + const params = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }); + + const response = await fetch('https://slack.com/api/oauth.v2.access', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + + return await response.json() as SlackOAuthResponse; +} + +async function upsertSecret(secretName: string, secretValue: string, teamId: string): Promise { + try { + await sm.send(new UpdateSecretCommand({ + SecretId: secretName, + SecretString: secretValue, + })); + } catch (err) { + if (err instanceof ResourceNotFoundException) { + await sm.send(new CreateSecretCommand({ + Name: secretName, + SecretString: secretValue, + Description: `Slack bot token for workspace ${teamId}`, + Tags: [ + { Key: 'team_id', Value: teamId }, + { Key: 'service', Value: 'bgagent-slack' }, + ], + })); + } else if (err instanceof InvalidRequestException && String(err.message).includes('marked for deletion')) { + // Secret was scheduled for deletion during app uninstall — restore it and update. + await sm.send(new RestoreSecretCommand({ SecretId: secretName })); + await sm.send(new UpdateSecretCommand({ + SecretId: secretName, + SecretString: secretValue, + })); + } else { + throw err; + } + } +} + +function buildRedirectUri(event: APIGatewayProxyEvent): string { + const host = event.headers.Host ?? event.headers.host ?? ''; + const stage = event.requestContext.stage ?? ''; + return `https://${host}/${stage}/slack/oauth/callback`; +} + +function htmlResponse(statusCode: number, body: string): APIGatewayProxyResult { + const html = ` +ABCA Slack Integration + +${body}`; + return { + statusCode, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + body: html, + }; +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index da99401..360199b 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -21,7 +21,7 @@ import * as path from 'path'; import * as agentcore from '@aws-cdk/aws-bedrock-agentcore-alpha'; import * as bedrock from '@aws-cdk/aws-bedrock-alpha'; import * as agentcoremixins from '@aws-cdk/mixins-preview/aws-bedrockagentcore'; -import { Stack, StackProps, RemovalPolicy, CfnOutput, CfnResource } from 'aws-cdk-lib'; +import { Stack, StackProps, RemovalPolicy, CfnOutput, CfnResource, Fn } from 'aws-cdk-lib'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; // ecr_assets import is only needed when the ECS block below is uncommented // import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets'; @@ -38,6 +38,7 @@ import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler'; import { DnsFirewall } from '../constructs/dns-firewall'; // import { EcsAgentCluster } from '../constructs/ecs-agent-cluster'; import { RepoTable } from '../constructs/repo-table'; +import { SlackIntegration } from '../constructs/slack-integration'; import { TaskApi } from '../constructs/task-api'; import { TaskDashboard } from '../constructs/task-dashboard'; import { TaskEventsTable } from '../constructs/task-events-table'; @@ -62,8 +63,9 @@ export class AgentStack extends Stack { const repoTable = new RepoTable(this, 'RepoTable'); // --- Repository onboarding --- + const blueprintRepo = process.env.BLUEPRINT_REPO ?? this.node.tryGetContext('blueprintRepo') ?? 'awslabs/agent-plugins'; const agentPluginsBlueprint = new Blueprint(this, 'AgentPluginsBlueprint', { - repo: 'krokoko/agent-plugins', + repo: blueprintRepo, repoTable: repoTable.table, }); @@ -230,7 +232,9 @@ export class AgentStack extends Stack { // Runtime logs and traces runtime.with(agentcoremixins.mixins.CfnRuntimeLogsMixin.APPLICATION_LOGS.toLogGroup(applicationLogGroup)); - runtime.with(agentcoremixins.mixins.CfnRuntimeLogsMixin.TRACES.toXRay()); + // X-Ray tracing disabled — requires account-level UpdateTraceSegmentDestination + // which needs CloudWatch Logs resource policy propagation. Re-enable once resolved. + // runtime.with(agentcoremixins.mixins.CfnRuntimeLogsMixin.TRACES.toXRay()); runtime.with(agentcoremixins.mixins.CfnRuntimeLogsMixin.USAGE_LOGS.toLogGroup(usageLogGroup)); NagSuppressions.addResourceSuppressions(runtime, [ @@ -363,6 +367,69 @@ export class AgentStack extends Stack { runtimeArn: runtime.agentRuntimeArn, }); + // --- Slack integration (always deployed — secrets populated post-deploy) --- + const slackIntegration = new SlackIntegration(this, 'SlackIntegration', { + api: taskApi.api, + userPool: taskApi.userPool, + taskTable: taskTable.table, + taskEventsTable: taskEventsTable.table, + repoTable: repoTable.table, + orchestratorFunctionArn: orchestrator.alias.functionArn, + guardrailId: inputGuardrail.guardrailId, + guardrailVersion: inputGuardrail.guardrailVersion, + }); + + // --- Slack App setup outputs --- + // Pre-filled manifest URL: opens Slack's "Create New App" page with all + // URLs, scopes, and events pre-configured. User just clicks Create. + const apiHost = Fn.select(2, Fn.split('/', taskApi.api.url)); + const apiStage = Fn.select(3, Fn.split('/', taskApi.api.url)); + const apiBase = Fn.join('', ['https://', apiHost, '/', apiStage]); + + // Build the YAML manifest as a string using Fn.join (API URL tokens resolve at deploy time). + // Slack's ?new_app=1&manifest_json= endpoint accepts URL-encoded JSON. + const manifestJson = Fn.join('', [ + '{"_metadata":{"major_version":1,"minor_version":1},', + '"display_information":{"name":"Shoof","description":"Submit coding tasks to autonomous background agents","background_color":"#1a1a2e"},', + '"features":{"app_home":{"messages_tab_enabled":true,"messages_tab_read_only_enabled":false},"bot_user":{"display_name":"Shoof","always_online":true},', + '"slash_commands":[{"command":"/bgagent","url":"', apiBase, '/slack/commands","description":"Link your account or get help with Shoof","usage_hint":"link | help","should_escape":false}]},', + '"oauth_config":{"scopes":{"bot":["app_mentions:read","commands","chat:write","chat:write.public","channels:read","groups:read","im:history","im:write","users:read","reactions:write"]},', + '"redirect_urls":["', apiBase, '/slack/oauth/callback"]},', + '"settings":{"event_subscriptions":{"request_url":"', apiBase, '/slack/events","bot_events":["app_mention","message.im","app_uninstalled","tokens_revoked"]},', + '"interactivity":{"is_enabled":true,"request_url":"', apiBase, '/slack/interactions"},', + '"org_deploy_enabled":false,"socket_mode_enabled":false,"token_rotation_enabled":false}}', + ]); + + new CfnOutput(this, 'SlackAppManifestJson', { + value: manifestJson, + description: 'Slack App manifest JSON — the CLI URL-encodes this into the create URL', + }); + + new CfnOutput(this, 'SlackSigningSecretArn', { + value: slackIntegration.signingSecret.secretArn, + description: 'Secrets Manager ARN for the Slack signing secret — populate after creating the Slack App', + }); + + new CfnOutput(this, 'SlackClientSecretArn', { + value: slackIntegration.clientSecret.secretArn, + description: 'Secrets Manager ARN for the Slack client secret — populate after creating the Slack App', + }); + + new CfnOutput(this, 'SlackClientIdSecretArn', { + value: slackIntegration.clientIdSecret.secretArn, + description: 'Secrets Manager ARN for the Slack client ID — populate after creating the Slack App', + }); + + new CfnOutput(this, 'SlackInstallationTableName', { + value: slackIntegration.installationTable.tableName, + description: 'Name of the DynamoDB Slack installation table', + }); + + new CfnOutput(this, 'SlackUserMappingTableName', { + value: slackIntegration.userMappingTable.tableName, + description: 'Name of the DynamoDB Slack user mapping table', + }); + // --- Bedrock model invocation logging (account-level) --- const invocationLogGroup = new logs.LogGroup(this, 'ModelInvocationLogGroup', { logGroupName: '/aws/bedrock/model-invocation-logs', diff --git a/cdk/test/constructs/slack-installation-table.test.ts b/cdk/test/constructs/slack-installation-table.test.ts new file mode 100644 index 0000000..a2cc546 --- /dev/null +++ b/cdk/test/constructs/slack-installation-table.test.ts @@ -0,0 +1,68 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, Stack } from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { SlackInstallationTable } from '../../src/constructs/slack-installation-table'; + +describe('SlackInstallationTable construct', () => { + let template: Template; + + beforeAll(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new SlackInstallationTable(stack, 'SlackInstallationTable'); + template = Template.fromStack(stack); + }); + + test('creates a DynamoDB table', () => { + template.resourceCountIs('AWS::DynamoDB::Table', 1); + }); + + test('table has team_id as partition key', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [ + { AttributeName: 'team_id', KeyType: 'HASH' }, + ], + }); + }); + + test('table uses PAY_PER_REQUEST billing', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + BillingMode: 'PAY_PER_REQUEST', + }); + }); + + test('table has point-in-time recovery enabled', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + PointInTimeRecoverySpecification: { + PointInTimeRecoveryEnabled: true, + }, + }); + }); + + test('enables TTL on ttl attribute', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + TimeToLiveSpecification: { + AttributeName: 'ttl', + Enabled: true, + }, + }); + }); +}); diff --git a/cdk/test/constructs/slack-integration.test.ts b/cdk/test/constructs/slack-integration.test.ts new file mode 100644 index 0000000..33f14a3 --- /dev/null +++ b/cdk/test/constructs/slack-integration.test.ts @@ -0,0 +1,135 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, Stack } from 'aws-cdk-lib'; +import { Template, Match } from 'aws-cdk-lib/assertions'; +import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { SlackIntegration } from '../../src/constructs/slack-integration'; + +describe('SlackIntegration construct', () => { + let template: Template; + + beforeAll(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + + const api = new apigw.RestApi(stack, 'TestApi'); + const userPool = new cognito.UserPool(stack, 'TestUserPool'); + const taskTable = new dynamodb.Table(stack, 'TaskTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + }); + const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, + stream: dynamodb.StreamViewType.NEW_IMAGE, + }); + + new SlackIntegration(stack, 'SlackIntegration', { + api, + userPool, + taskTable, + taskEventsTable, + }); + + template = Template.fromStack(stack); + }); + + test('creates two DynamoDB tables (installation + user mapping)', () => { + // TaskTable + TaskEventsTable + SlackInstallation + SlackUserMapping = 4 + template.resourceCountIs('AWS::DynamoDB::Table', 4); + }); + + test('creates 7 Lambda functions', () => { + // oauth-callback, events, commands, command-processor, link, notify, interactions + template.resourceCountIs('AWS::Lambda::Function', 7); + }); + + test('creates API Gateway resources under /slack', () => { + // Verify /slack/* routes exist + template.hasResourceProperties('AWS::ApiGateway::Resource', { + PathPart: 'slack', + }); + template.hasResourceProperties('AWS::ApiGateway::Resource', { + PathPart: 'commands', + }); + template.hasResourceProperties('AWS::ApiGateway::Resource', { + PathPart: 'events', + }); + template.hasResourceProperties('AWS::ApiGateway::Resource', { + PathPart: 'link', + }); + template.hasResourceProperties('AWS::ApiGateway::Resource', { + PathPart: 'interactions', + }); + }); + + test('slash command handler has 3-second timeout', () => { + template.hasResourceProperties('AWS::Lambda::Function', { + Timeout: 3, + Environment: { + Variables: Match.objectLike({ + SLACK_SIGNING_SECRET_ARN: Match.anyValue(), + SLACK_COMMAND_PROCESSOR_FUNCTION_NAME: Match.anyValue(), + }), + }, + }); + }); + + test('notification handler has DynamoDB Streams event source', () => { + template.hasResourceProperties('AWS::Lambda::EventSourceMapping', { + EventSourceArn: Match.anyValue(), + StartingPosition: 'LATEST', + BatchSize: 10, + MaximumBatchingWindowInSeconds: 0, + MaximumRetryAttempts: 3, + BisectBatchOnFunctionError: true, + }); + }); + + test('creates 3 Secrets Manager secrets for Slack App credentials', () => { + template.resourceCountIs('AWS::SecretsManager::Secret', 3); + template.hasResourceProperties('AWS::SecretsManager::Secret', { + Description: Match.stringLikeRegexp('signing secret'), + }); + template.hasResourceProperties('AWS::SecretsManager::Secret', { + Description: Match.stringLikeRegexp('client secret'), + }); + template.hasResourceProperties('AWS::SecretsManager::Secret', { + Description: Match.stringLikeRegexp('client ID'), + }); + }); + + test('OAuth callback has Secrets Manager permissions', () => { + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'secretsmanager:CreateSecret', + Effect: 'Allow', + Condition: { + StringLike: { 'secretsmanager:Name': 'bgagent/slack/*' }, + }, + }), + ]), + }, + }); + }); +}); diff --git a/cdk/test/constructs/slack-user-mapping-table.test.ts b/cdk/test/constructs/slack-user-mapping-table.test.ts new file mode 100644 index 0000000..761ac51 --- /dev/null +++ b/cdk/test/constructs/slack-user-mapping-table.test.ts @@ -0,0 +1,83 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, Stack } from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { SlackUserMappingTable } from '../../src/constructs/slack-user-mapping-table'; + +describe('SlackUserMappingTable construct', () => { + let template: Template; + + beforeAll(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new SlackUserMappingTable(stack, 'SlackUserMappingTable'); + template = Template.fromStack(stack); + }); + + test('creates a DynamoDB table', () => { + template.resourceCountIs('AWS::DynamoDB::Table', 1); + }); + + test('table has slack_identity as partition key', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [ + { AttributeName: 'slack_identity', KeyType: 'HASH' }, + ], + }); + }); + + test('table uses PAY_PER_REQUEST billing', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + BillingMode: 'PAY_PER_REQUEST', + }); + }); + + test('table has point-in-time recovery enabled', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + PointInTimeRecoverySpecification: { + PointInTimeRecoveryEnabled: true, + }, + }); + }); + + test('enables TTL on ttl attribute', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + TimeToLiveSpecification: { + AttributeName: 'ttl', + Enabled: true, + }, + }); + }); + + test('table has PlatformUserIndex GSI', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: [ + { + IndexName: 'PlatformUserIndex', + KeySchema: [ + { AttributeName: 'platform_user_id', KeyType: 'HASH' }, + { AttributeName: 'linked_at', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + }, + ], + }); + }); +}); diff --git a/cdk/test/handlers/shared/slack-blocks.test.ts b/cdk/test/handlers/shared/slack-blocks.test.ts new file mode 100644 index 0000000..21a260a --- /dev/null +++ b/cdk/test/handlers/shared/slack-blocks.test.ts @@ -0,0 +1,110 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { TaskStatus, type TaskStatusType } from '../../../src/constructs/task-status'; +import { renderSlackBlocks } from '../../../src/handlers/shared/slack-blocks'; + +describe('renderSlackBlocks', () => { + const baseTask = { + task_id: '01HXYZ123', + repo: 'org/repo', + task_description: 'Fix the login bug', + pr_url: undefined as string | undefined, + error_message: undefined as string | undefined, + cost_usd: undefined as number | undefined, + duration_s: undefined as number | undefined, + status: TaskStatus.SUBMITTED as TaskStatusType, + }; + + test('renders task_created message', () => { + const msg = renderSlackBlocks('task_created', baseTask); + expect(msg.text).toContain('org/repo'); + expect(msg.blocks).toHaveLength(1); + expect(msg.blocks[0].type).toBe('section'); + expect(msg.blocks[0].text?.text).toContain(':rocket:'); + expect(msg.blocks[0].text?.text).toContain('Fix the login bug'); + expect(msg.blocks[0].text?.text).toContain('01HXYZ123'); + }); + + test('renders task_completed message with PR URL', () => { + const task = { ...baseTask, status: TaskStatus.COMPLETED as TaskStatusType, pr_url: 'https://github.com/org/repo/pull/42', cost_usd: 0.47, duration_s: 272 }; + const msg = renderSlackBlocks('task_completed', task); + expect(msg.text).toContain('completed'); + expect(msg.blocks[0].text?.text).toContain('$0.47'); + expect(msg.blocks[0].text?.text).toContain('4m 32s'); + // PR link is in the button, not inline text (avoids Slack unfurl cards) + const actionsBlock = msg.blocks[1] as unknown as { type: string; elements: Array<{ url: string; text: { text: string } }> }; + expect(actionsBlock.type).toBe('actions'); + expect(actionsBlock.elements[0].url).toBe('https://github.com/org/repo/pull/42'); + expect(actionsBlock.elements[0].text.text).toContain('#42'); + }); + + test('renders task_failed message with error', () => { + const task = { ...baseTask, status: TaskStatus.FAILED as TaskStatusType, error_message: 'Repo not found' }; + const msg = renderSlackBlocks('task_failed', task); + expect(msg.text).toContain('failed'); + expect(msg.blocks[0].text?.text).toContain('Repo not found'); + }); + + test('renders task_failed message with metadata error', () => { + const task = { ...baseTask, status: TaskStatus.FAILED as TaskStatusType }; + const msg = renderSlackBlocks('task_failed', task, { error: 'timeout' }); + expect(msg.blocks[0].text?.text).toContain('timeout'); + }); + + test('renders task_cancelled message', () => { + const msg = renderSlackBlocks('task_cancelled', baseTask); + expect(msg.blocks[0].text?.text).toContain(':no_entry_sign:'); + }); + + test('renders task_timed_out message with duration', () => { + const task = { ...baseTask, duration_s: 28800 }; + const msg = renderSlackBlocks('task_timed_out', task); + expect(msg.blocks[0].text?.text).toContain('8h'); + }); + + test('renders session_started message', () => { + const msg = renderSlackBlocks('session_started', baseTask); + expect(msg.blocks[0].text?.text).toContain(':hourglass_flowing_sand:'); + }); + + test('renders unknown event type gracefully', () => { + const msg = renderSlackBlocks('hydration_complete', baseTask); + expect(msg.blocks[0].text?.text).toContain('hydration_complete'); + }); + + test('truncates long descriptions', () => { + const task = { ...baseTask, task_description: 'A'.repeat(300) }; + const msg = renderSlackBlocks('task_created', task); + expect(msg.blocks[0].text?.text.length).toBeLessThan(350); + expect(msg.blocks[0].text?.text).toContain('...'); + }); + + test('formats duration in hours', () => { + const task = { ...baseTask, status: TaskStatus.COMPLETED as TaskStatusType, duration_s: 3661 }; + const msg = renderSlackBlocks('task_completed', task); + expect(msg.blocks[0].text?.text).toContain('1h 1m'); + }); + + test('formats duration in minutes and seconds', () => { + const task = { ...baseTask, status: TaskStatus.COMPLETED as TaskStatusType, duration_s: 125 }; + const msg = renderSlackBlocks('task_completed', task); + expect(msg.blocks[0].text?.text).toContain('2m 5s'); + }); +}); diff --git a/cdk/test/handlers/shared/slack-verify.test.ts b/cdk/test/handlers/shared/slack-verify.test.ts new file mode 100644 index 0000000..ec7d5b3 --- /dev/null +++ b/cdk/test/handlers/shared/slack-verify.test.ts @@ -0,0 +1,75 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { verifySlackSignature } from '../../../src/handlers/shared/slack-verify'; + +describe('verifySlackSignature', () => { + const signingSecret = 'test-signing-secret-abc123'; + + function makeSignature(timestamp: string, body: string): string { + const basestring = `v0:${timestamp}:${body}`; + return 'v0=' + crypto.createHmac('sha256', signingSecret).update(basestring).digest('hex'); + } + + function currentTimestamp(): string { + return String(Math.floor(Date.now() / 1000)); + } + + test('accepts valid signature with current timestamp', () => { + const ts = currentTimestamp(); + const body = 'token=abc&command=/bgagent&text=help'; + const sig = makeSignature(ts, body); + + expect(verifySlackSignature(signingSecret, sig, ts, body)).toBe(true); + }); + + test('rejects invalid signature', () => { + const ts = currentTimestamp(); + const body = 'token=abc&command=/bgagent&text=help'; + const sig = 'v0=0000000000000000000000000000000000000000000000000000000000000000'; + + expect(verifySlackSignature(signingSecret, sig, ts, body)).toBe(false); + }); + + test('rejects stale timestamp (older than 5 minutes)', () => { + const staleTs = String(Math.floor(Date.now() / 1000) - 400); + const body = 'test-body'; + const sig = makeSignature(staleTs, body); + + expect(verifySlackSignature(signingSecret, sig, staleTs, body)).toBe(false); + }); + + test('rejects non-numeric timestamp', () => { + expect(verifySlackSignature(signingSecret, 'v0=abc', 'not-a-number', 'body')).toBe(false); + }); + + test('rejects signature with wrong length', () => { + const ts = currentTimestamp(); + expect(verifySlackSignature(signingSecret, 'v0=short', ts, 'body')).toBe(false); + }); + + test('rejects modified body', () => { + const ts = currentTimestamp(); + const body = 'original-body'; + const sig = makeSignature(ts, body); + + expect(verifySlackSignature(signingSecret, sig, ts, 'tampered-body')).toBe(false); + }); +}); diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index de54c29..0ece647 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -36,8 +36,9 @@ describe('AgentStack', () => { expect(template).toBeDefined(); }); - test('creates exactly 5 DynamoDB tables', () => { - template.resourceCountIs('AWS::DynamoDB::Table', 5); + test('creates exactly 7 DynamoDB tables', () => { + // task, task-events, repo, user-concurrency, webhook + slack-installation, slack-user-mapping + template.resourceCountIs('AWS::DynamoDB::Table', 7); }); test('outputs TaskTableName', () => { diff --git a/cli/package.json b/cli/package.json index de5046f..231a390 100644 --- a/cli/package.json +++ b/cli/package.json @@ -30,7 +30,9 @@ "typescript": "^5.9.3" }, "dependencies": { + "@aws-sdk/client-cloudformation": "3.1024.0", "@aws-sdk/client-cognito-identity-provider": "^3.1021.0", + "@aws-sdk/client-secrets-manager": "3.1024.0", "commander": "^14.0.3" }, "resolutions": { diff --git a/cli/src/api-client.ts b/cli/src/api-client.ts index 94939de..abce8f4 100644 --- a/cli/src/api-client.ts +++ b/cli/src/api-client.ts @@ -27,6 +27,7 @@ import { CreateWebhookRequest, CreateWebhookResponse, ErrorResponse, + SlackLinkResponse, PaginatedResponse, SuccessResponse, TaskDetail, @@ -174,4 +175,10 @@ export class ApiClient { const res = await this.request>('DELETE', `/webhooks/${encodeURIComponent(webhookId)}`); return res.data; } + + /** POST /slack/link — link a Slack account using a verification code. */ + async slackLink(code: string): Promise { + const res = await this.request>('POST', '/slack/link', { code }); + return res.data; + } } diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index 982b1ab..663f424 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -25,6 +25,7 @@ import { makeConfigureCommand } from '../commands/configure'; import { makeEventsCommand } from '../commands/events'; import { makeListCommand } from '../commands/list'; import { makeLoginCommand } from '../commands/login'; +import { makeSlackCommand } from '../commands/slack'; import { makeStatusCommand } from '../commands/status'; import { makeSubmitCommand } from '../commands/submit'; import { makeWebhookCommand } from '../commands/webhook'; @@ -53,6 +54,7 @@ program.addCommand(makeListCommand()); program.addCommand(makeStatusCommand()); program.addCommand(makeCancelCommand()); program.addCommand(makeEventsCommand()); +program.addCommand(makeSlackCommand()); program.addCommand(makeWebhookCommand()); program.parseAsync(process.argv).catch((err: unknown) => { diff --git a/cli/src/commands/slack.ts b/cli/src/commands/slack.ts new file mode 100644 index 0000000..7e201fc --- /dev/null +++ b/cli/src/commands/slack.ts @@ -0,0 +1,356 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as readline from 'readline'; +import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; +import { PutSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { Command } from 'commander'; +import { ApiClient } from '../api-client'; +import { loadConfig } from '../config'; +import { formatJson } from '../format'; + +export function makeSlackCommand(): Command { + const slack = new Command('slack') + .description('Manage Slack integration'); + + slack.addCommand( + new Command('link') + .description('Link your Slack account using a verification code from /bgagent link') + .argument('', 'Verification code from Slack') + .option('--output ', 'Output format (text or json)', 'text') + .action(async (code: string, opts) => { + const client = new ApiClient(); + const result = await client.slackLink(code); + + if (opts.output === 'json') { + console.log(formatJson(result)); + } else { + console.log('Slack account linked successfully.'); + console.log(` Workspace: ${result.slack_team_id}`); + console.log(` User: ${result.slack_user_id}`); + console.log(` Linked at: ${result.linked_at}`); + } + }), + ); + + slack.addCommand( + new Command('setup') + .description('Create a Slack App and store credentials (interactive)') + .option('--region ', 'AWS region (defaults to configured region)') + .option('--stack-name ', 'CloudFormation stack name', 'backgroundagent-dev') + .action(async (opts) => { + const config = loadConfig(); + const region = opts.region || config.region; + + // Step 1: Fetch the manifest JSON from stack outputs. + console.log('Fetching Slack App manifest from stack outputs...\n'); + let manifestJson = await getStackOutput(region, opts.stackName, 'SlackAppManifestJson'); + + if (!manifestJson) { + console.log('Stack has not been deployed yet.'); + const shouldDeploy = await promptConfirm('Deploy now? (y/n) '); + if (shouldDeploy) { + console.log('\nDeploying stack (this may take a few minutes)...\n'); + try { + const repoRoot = findRepoRoot(); + console.log(`Deploying from ${repoRoot}\n`); + execSync('MISE_EXPERIMENTAL=1 mise run //cdk:deploy', { + cwd: repoRoot, + stdio: 'inherit', + }); + console.log(''); + manifestJson = await getStackOutput(region, opts.stackName, 'SlackAppManifestJson'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`\nDeploy failed: ${msg}`); + process.exit(1); + } + } + } + + if (manifestJson) { + const createUrl = `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(manifestJson).replace(/%2F/g, '/')}`; + console.log('Open this URL to create your Slack App (all settings pre-filled):'); + console.log(` ${createUrl}\n`); + } else { + console.log('Create the app manually at https://api.slack.com/apps\n'); + } + + // Step 2: Fetch secret ARNs from stack outputs (CDK creates the placeholders). + const arns = await fetchSecretArns(region, opts.stackName); + + // Step 3: Prompt for credentials and store. + const clientId = await promptAndStoreCredentials(region, arns); + + // Step 4: Next steps. + const hasEvents = manifestJson?.includes('event_subscriptions') ?? false; + const hasInteractivity = manifestJson?.includes('interactivity') ?? false; + + console.log('Next steps:\n'); + + let step = 1; + if (!hasEvents) { + const apiBaseMatch = manifestJson?.match(/https:\/\/[^/]+\/[^/]+/); + const apiBaseUrl = apiBaseMatch ? apiBaseMatch[0] : ''; + console.log(` ${step}. In the Slack App dashboard, go to "Event Subscriptions":`); + console.log(' - Toggle ON'); + console.log(` - Request URL: ${apiBaseUrl}/slack/events`); + console.log(' - Subscribe to bot events: app_mention, message.im, app_uninstalled, tokens_revoked'); + console.log(' - Save Changes\n'); + step++; + } + if (!hasInteractivity) { + const apiBaseMatch = manifestJson?.match(/https:\/\/[^/]+\/[^/]+/); + const apiBaseUrl = apiBaseMatch ? apiBaseMatch[0] : ''; + console.log(` ${step}. Go to "Interactivity & Shortcuts":`); + console.log(' - Toggle ON'); + console.log(` - Request URL: ${apiBaseUrl}/slack/interactions`); + console.log(' - Save Changes\n'); + step++; + } + if (hasEvents) { + console.log(` ${step}. In the Slack App dashboard, go to "Event Subscriptions":`); + console.log(' - The Request URL may show "Your URL didn\'t respond" — click Retry'); + console.log(' - Wait for the green "Verified" checkmark'); + console.log(' - Click Save Changes\n'); + step++; + } + // Build the OAuth install URL using the client ID and redirect URI from the manifest. + const redirectMatch = manifestJson?.match(/"redirect_urls":\["([^"]+)"\]/); + const redirectUri = redirectMatch ? redirectMatch[1] : ''; + const scopes = 'app_mentions:read,commands,chat:write,chat:write.public,channels:read,groups:read,im:history,im:write,users:read,reactions:write'; + const installUrl = `https://slack.com/oauth/v2/authorize?client_id=${encodeURIComponent(clientId)}&scope=${encodeURIComponent(scopes)}&redirect_uri=${encodeURIComponent(redirectUri)}`; + + console.log(` ${step}. Install the app to your workspace using this link:\n`); + console.log(` ${installUrl}\n`); + console.log(' (Do NOT use the "Install App" button in the dashboard — it won\'t connect to your backend)\n'); + step++; + console.log(` ${step}. In Slack, run /bgagent link, then in your terminal: bgagent slack link \n`); + step++; + console.log(` ${step}. Try @Shoof in a channel to submit a task\n`); + console.log('If @Shoof does not respond, delete the app and re-run `bgagent slack setup`.'); + }), + ); + + return slack; +} + +// ─── Shared credential logic ───────────────────────────────────────────────── + +interface SecretArns { + signingSecretArn: string; + clientSecretArn: string; + clientIdSecretArn: string; +} + +async function fetchSecretArns(region: string, stackName: string): Promise { + const signingSecretArn = await getStackOutput(region, stackName, 'SlackSigningSecretArn'); + const clientSecretArn = await getStackOutput(region, stackName, 'SlackClientSecretArn'); + const clientIdSecretArn = await getStackOutput(region, stackName, 'SlackClientIdSecretArn'); + + if (!signingSecretArn || !clientSecretArn || !clientIdSecretArn) { + console.error('Could not find Slack secret ARNs in stack outputs. Deploy the stack first.'); + process.exit(1); + } + + return { signingSecretArn, clientSecretArn, clientIdSecretArn }; +} + +async function promptAndStoreCredentials(region: string, arns: SecretArns): Promise { + for (;;) { + console.log('Enter the credentials from Basic Information → App Credentials:\n'); + + const signingSecret = await promptSecret('Signing Secret: '); + const clientSecret = await promptSecret('Client Secret: '); + const clientId = await promptVisible('Client ID: '); + + if (!signingSecret || !clientSecret || !clientId) { + console.error('\n✗ All three values are required. Try again.\n'); + continue; + } + + let valid = true; + if (!/^[0-9a-f]{32}$/i.test(signingSecret)) { + console.error('\n✗ Signing Secret must be 32 hex characters.'); + valid = false; + } + if (!/^[0-9a-f]{32}$/i.test(clientSecret)) { + console.error('✗ Client Secret must be 32 hex characters.'); + valid = false; + } + if (!/^\d+\.\d+$/.test(clientId)) { + console.error('✗ Client ID should be numeric (e.g. 12345.67890).'); + valid = false; + } + if (!valid) { + console.error('\nCheck Basic Information → App Credentials and try again.\n'); + continue; + } + + // Store in Secrets Manager. + console.log(''); + const sm = new SecretsManagerClient({ region }); + + const secrets = [ + { id: arns.signingSecretArn, value: signingSecret, label: 'signing secret' }, + { id: arns.clientSecretArn, value: clientSecret, label: 'client secret' }, + { id: arns.clientIdSecretArn, value: clientId, label: 'client ID' }, + ]; + + for (const secret of secrets) { + await sm.send(new PutSecretValueCommand({ + SecretId: secret.id, + SecretString: secret.value, + })); + console.log(` ✓ Stored ${secret.label}`); + } + + console.log('\nCredentials stored. They are verified automatically:'); + console.log(' - Client ID & Secret: when you install the app to your workspace'); + console.log(' - Signing Secret: when @Shoof receives its first message\n'); + + return clientId; + } +} + +// ─── Prompts ───────────────────────────────────────────────────────────────── + +function promptSecret(label: string): Promise { + return new Promise((resolve, reject) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + terminal: false, + }); + + process.stderr.write(label); + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + process.stdin.resume(); + + let value = ''; + + const onData = (chunk: Buffer) => { + const str = chunk.toString(); + for (const char of str) { + if (char === '\n' || char === '\r') { + cleanup(); + process.stderr.write('\n'); + resolve(value.trim()); + return; + } else if (char === '\u0003') { + cleanup(); + process.stderr.write('\n'); + reject(new Error('Cancelled.')); + return; + } else if (char === '\u007f' || char === '\b') { + if (value.length > 0) { + value = value.slice(0, -1); + process.stderr.write('\b \b'); + } + } else { + value += char; + process.stderr.write('*'); + } + } + }; + + const cleanup = () => { + process.stdin.removeListener('data', onData); + process.stdin.setRawMode(false); + process.stdin.pause(); + rl.close(); + }; + + process.stdin.on('data', onData); + } else { + rl.once('line', (line) => { + rl.close(); + resolve(line.trim()); + }); + rl.once('close', () => reject(new Error('No input provided.'))); + } + }); +} + +function promptConfirm(label: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + rl.question(label, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase().startsWith('y')); + }); + }); +} + +function promptVisible(label: string): Promise { + return new Promise((resolve) => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + + rl.question(label, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function findRepoRoot(): string { + const startDirs = [ + process.cwd(), + path.resolve(__dirname, '..', '..', '..'), + ]; + + for (const start of startDirs) { + let dir = start; + while (true) { + if (fs.existsSync(path.join(dir, 'mise.toml')) && fs.existsSync(path.join(dir, 'cdk', 'cdk.json'))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + + throw new Error('Could not find project root. Run this command from the repository directory.'); +} + +async function getStackOutput(region: string, stackName: string, outputKey: string): Promise { + try { + const cfn = new CloudFormationClient({ region }); + const result = await cfn.send(new DescribeStacksCommand({ StackName: stackName })); + const outputs = result.Stacks?.[0]?.Outputs ?? []; + const output = outputs.find((o) => o.OutputKey === outputKey); + return output?.OutputValue ?? null; + } catch { + return null; + } +} diff --git a/cli/src/types.ts b/cli/src/types.ts index 59b7c2a..6fad422 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -134,6 +134,13 @@ export interface CreateWebhookResponse { readonly created_at: string; } +/** Slack link response from POST /v1/slack/link. */ +export interface SlackLinkResponse { + readonly slack_team_id: string; + readonly slack_user_id: string; + readonly linked_at: string; +} + /** CLI config stored in ~/.bgagent/config.json. */ export interface CliConfig { readonly api_url: string; diff --git a/cli/test/commands/slack.test.ts b/cli/test/commands/slack.test.ts new file mode 100644 index 0000000..59e8c94 --- /dev/null +++ b/cli/test/commands/slack.test.ts @@ -0,0 +1,87 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ApiClient } from '../../src/api-client'; +import { makeSlackCommand } from '../../src/commands/slack'; + +jest.mock('../../src/api-client'); + +describe('slack command', () => { + let consoleSpy: jest.SpiedFunction; + const mockSlackLink = jest.fn(); + + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + mockSlackLink.mockReset(); + (ApiClient as jest.MockedClass).mockImplementation(() => ({ + createTask: jest.fn(), + listTasks: jest.fn(), + getTask: jest.fn(), + cancelTask: jest.fn(), + getTaskEvents: jest.fn(), + createWebhook: jest.fn(), + listWebhooks: jest.fn(), + revokeWebhook: jest.fn(), + slackLink: mockSlackLink, + }) as unknown as ApiClient); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + describe('slack link', () => { + const linkResponse = { + slack_team_id: 'T0123ABC', + slack_user_id: 'U0456DEF', + linked_at: '2026-04-14T12:00:00Z', + }; + + test('links a Slack account with a verification code', async () => { + mockSlackLink.mockResolvedValue(linkResponse); + + const cmd = makeSlackCommand(); + await cmd.parseAsync(['node', 'test', 'link', 'A1B2C3']); + + expect(mockSlackLink).toHaveBeenCalledWith('A1B2C3'); + const calls = consoleSpy.mock.calls.map(c => c[0]) as string[]; + expect(calls.some(c => c.includes('linked successfully'))).toBe(true); + expect(calls.some(c => c.includes('T0123ABC'))).toBe(true); + expect(calls.some(c => c.includes('U0456DEF'))).toBe(true); + }); + + test('outputs JSON when --output json', async () => { + mockSlackLink.mockResolvedValue(linkResponse); + + const cmd = makeSlackCommand(); + await cmd.parseAsync(['node', 'test', 'link', 'A1B2C3', '--output', 'json']); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(linkResponse, null, 2)); + }); + + test('passes the code argument to the API client', async () => { + mockSlackLink.mockResolvedValue(linkResponse); + + const cmd = makeSlackCommand(); + await cmd.parseAsync(['node', 'test', 'link', 'XYZ789']); + + expect(mockSlackLink).toHaveBeenCalledWith('XYZ789'); + }); + }); +}); diff --git a/docs/guides/DEVELOPER_GUIDE.md b/docs/guides/DEVELOPER_GUIDE.md index cbc52c3..e0bbbc0 100644 --- a/docs/guides/DEVELOPER_GUIDE.md +++ b/docs/guides/DEVELOPER_GUIDE.md @@ -42,9 +42,22 @@ After deployment, the orchestrator **pre-flight** step calls the GitHub API to v The Task API only accepts tasks for repositories that are **onboarded** — each one is a `Blueprint` construct in `cdk/src/stacks/agent.ts` that writes a `RepoConfig` row to DynamoDB. +**Quick method** — pass the repo as a CDK context variable or environment variable (no code edits needed): + +```bash +# Context variable (preferred) +MISE_EXPERIMENTAL=1 mise //cdk:deploy -- -c blueprintRepo=your-org/your-repo + +# Or environment variable +BLUEPRINT_REPO=your-org/your-repo MISE_EXPERIMENTAL=1 mise //cdk:deploy +``` + +The default is `awslabs/agent-plugins`. For a quick end-to-end test, fork that repo and pass your fork (e.g. `-c blueprintRepo=jane-doe/agent-plugins`). + +**Multiple repositories** — edit `cdk/src/stacks/agent.ts` directly: + 1. Open **`cdk/src/stacks/agent.ts`** and locate the `Blueprint` block (for example `AgentPluginsBlueprint`). -2. Set **`repo`** to your repository in **`owner/repo`** form. For a quick end-to-end test, use your **fork** of the sample plugin repo (e.g. `jane-doe/agent-plugins` after forking `awslabs/agent-plugins`). For your own services, use something like `acme/my-service`. This must match the `repo` field users pass in the CLI or API. -3. **Multiple repositories:** add another `new Blueprint(this, 'YourBlueprintId', { repo: 'owner/other-repo', repoTable: repoTable.table, ... })` and append it to the **`blueprints`** array. That array is used to aggregate per-repo **DNS egress** allowlists; skipping it can block the agent from reaching domains your Blueprint declares. +2. Add another `new Blueprint(this, 'YourBlueprintId', { repo: 'owner/other-repo', repoTable: repoTable.table, ... })` and append it to the **`blueprints`** array. That array is used to aggregate per-repo **DNS egress** allowlists; skipping it can block the agent from reaching domains your Blueprint declares. Optional per-repo overrides (same file / `Blueprint` props) include a different AgentCore **`runtimeArn`**, **`modelId`**, **`maxTurns`**, **`systemPromptOverrides`**, or a **`githubTokenSecretArn`** for a dedicated PAT. If you use a custom `runtimeArn` or secret per repo, you must also pass the corresponding ARNs into **`TaskOrchestrator`** via **`additionalRuntimeArns`** and **`additionalSecretArns`** so the orchestrator Lambda’s IAM policy allows them (see [Repo onboarding](../design/REPO_ONBOARDING.md) for the full model). @@ -142,13 +155,43 @@ You do **not** need standalone installs of Node or Yarn from nodejs.org or the Y #### One-time AWS account setup -The stack routes AgentCore Runtime traces to X-Ray, which requires CloudWatch Logs as a trace segment destination. Run this **once per account** before your first deployment: +The stack routes AgentCore Runtime traces to X-Ray, which requires CloudWatch Logs as a trace segment destination. Run these commands **once per account** before your first deployment: + +**1. Grant X-Ray permission to write to the `aws/spans` log group** via a CloudWatch Logs resource policy. The log group doesn't need to exist yet — X-Ray creates it automatically in step 2: ```bash -aws xray update-trace-segment-destination --destination CloudWatchLogs +aws logs put-resource-policy \ + --policy-name xray-spans-policy \ + --policy-document '{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "xray.amazonaws.com" + }, + "Action": [ + "logs:PutLogEvents", + "logs:CreateLogStream" + ], + "Resource": "arn:aws:logs:us-east-1:*:log-group:aws/spans:*" + } + ] + }' \ + --region us-east-1 ``` -Without this, `cdk deploy` will fail with: *"X-Ray Delivery Destination is supported with CloudWatch Logs as a Trace Segment Destination."* +Replace `us-east-1` with your deployment Region if different. + +**2. Set CloudWatch Logs as the trace segment destination** (this also creates the `aws/spans` log group): + +```bash +aws xray update-trace-segment-destination --destination CloudWatchLogs --region us-east-1 +``` + +Without step 1, step 2 will fail with: *"XRay does not have permission to call PutLogEvents on the aws/spans Log Group."* Without step 2, `cdk deploy` will fail with: *"X-Ray Delivery Destination is supported with CloudWatch Logs as a Trace Segment Destination."* + +> **Note:** Do not try to create the `aws/spans` log group manually — log group names starting with `aws/` are reserved and AWS will reject the call. X-Ray creates it automatically when you run step 2. ### Set up your toolchain diff --git a/docs/guides/SLACK_SETUP_GUIDE.md b/docs/guides/SLACK_SETUP_GUIDE.md new file mode 100644 index 0000000..b56fc5c --- /dev/null +++ b/docs/guides/SLACK_SETUP_GUIDE.md @@ -0,0 +1,170 @@ +# Slack integration setup guide + +This guide walks through setting up the ABCA Slack integration. Once configured, your team can submit tasks by mentioning `@Shoof` in any channel and receive real-time notifications as tasks progress. + +## Prerequisites + +- ABCA CDK stack deployed (see [Developer guide](./DEVELOPER_GUIDE.md)) +- A Cognito user account configured (see [User guide](./USER_GUIDE.md)) +- A Slack workspace where you can install apps (use a personal free workspace if your corporate Slack restricts app installs) +- AWS CLI configured with credentials for your ABCA account + +## Quick start + +```bash +bgagent slack setup +``` + +This single command handles everything: deploying the stack (if needed), generating the Slack App manifest URL, prompting for credentials, and showing the install link. Follow the on-screen instructions. + +## How it works + +- **@Shoof mentions**: `@Shoof fix the bug in org/repo#42` submits a task. Reactions on your message show progress: :eyes: (received) → :hourglass_flowing_sand: (working) → :white_check_mark: (done) +- **DMs**: Message Shoof directly for private task submissions +- **Notifications**: Threaded messages show task_created → completed (with PR link, duration, cost). The Cancel button lets you stop a running task. +- **Multi-workspace**: Each workspace installs via OAuth and gets its own bot token + +## Step-by-step setup + +### Step 1: Run the setup wizard + +```bash +bgagent slack setup +``` + +If the stack isn't deployed yet, it will offer to deploy for you. + +### Step 2: Create the Slack App + +The wizard outputs a URL that opens Slack's "Create New App" page with everything pre-filled (scopes, events, commands, URLs). Click the link, select your workspace, and create the app. + +### Step 3: Enter credentials + +The wizard prompts for three values from your new app's **Basic Information → App Credentials** page: + +| Field | Format | +|-------|--------| +| Signing Secret | 32 hex characters | +| Client Secret | 32 hex characters | +| Client ID | Numeric (e.g. 12345.67890) | + +![App Credentials location](../imgs/find-credentials.png) + +Format validation catches obvious typos (wrong length, non-hex characters). If the format is wrong, it loops back to re-enter. Note: the actual values cannot be verified until the app is installed — if you paste the wrong secret by mistake, you'll get an error at install time. + +### Step 4: Verify Event Subscriptions + +In the Slack App dashboard, go to **Event Subscriptions**: + +![Finding Event Subscriptions](../imgs/find-even-subscriptions.png) + +1. The Request URL may show "Your URL didn't respond" — click **Retry** +2. Wait for the green "Verified" checkmark +3. Click **Save Changes** + +![Event Subscriptions before](../imgs/enable-events-before.png) + +Click **Retry** and wait for the green checkmark: + +![Event Subscriptions after](../imgs/enable-events-after.png) + +The first attempt may time out due to Lambda cold start. The retry always succeeds. + +### Step 5: Install the app + +The wizard outputs an OAuth install URL. Open it in your browser — do **not** use the "Install App" button in the Slack dashboard (it won't connect to your backend). + +After clicking **Allow**, you'll see a success page. The bot token is now stored and Shoof can respond to messages. + +### Step 6: Link your account + +In Slack: +``` +/bgagent link +``` + +In your terminal: +```bash +bgagent slack link +``` + +This one-time step connects your Slack identity to your ABCA (Cognito) account. The code expires in 10 minutes. + +### Step 7: Test it + +In any channel where Shoof is added: +``` +@Shoof fix the README typo in org/repo#1 +``` + +You should see: +- :eyes: reaction on your message immediately +- A "Task submitted" message in the thread +- :hourglass_flowing_sand: reaction when the agent starts +- :white_check_mark: reaction and "Task completed" with a View PR button when done + +## Usage + +### Submit a task + +Mention Shoof with a repo and description: +``` +@Shoof fix the login bug in org/repo#42 +@Shoof update the README in org/repo +``` + +For private submissions, DM Shoof directly: +``` +fix the login bug in org/repo#42 +``` + +### Cancel a task + +Click the **Cancel Task** button in the thread while the agent is working. + +### Get help + +``` +/bgagent help +``` + +### Slash commands + +| Command | Purpose | +|---------|---------| +| `/bgagent link` | Link your Slack account (one-time) | +| `/bgagent help` | Show usage instructions | + +## Troubleshooting + +### @Shoof doesn't respond + +1. Is Shoof added to the channel? Use `/invite @Shoof` or add via channel settings. +2. Were credentials entered correctly? Delete the app and re-run `bgagent slack setup`. +3. Check CloudWatch logs for the `SlackEventsFn` Lambda — look for "Invalid Slack event signature" (wrong signing secret). + +### "Your Slack account is not linked" + +Run `/bgagent link` in Slack, then `bgagent slack link ` in your terminal. + +### "Repository not onboarded" + +The repo must be registered with a Blueprint before submitting tasks. See the [User guide](./USER_GUIDE.md). + +### OAuth install fails (bad_client_secret) + +The credentials stored don't match your app. Delete the app and re-run `bgagent slack setup`, making sure to paste the correct values. + +### Event Subscriptions URL doesn't verify + +Click **Retry** — the first attempt times out due to Lambda cold start. The retry succeeds. + +## Multi-workspace support + +The integration supports multiple Slack workspaces. Each workspace admin opens the OAuth install URL from `bgagent slack setup` output. Per-workspace bot tokens are stored separately. Users in each workspace link their accounts independently. + +## Removing the integration + +To uninstall from a workspace: **Slack → Settings → Manage Apps → Shoof → Remove App**. The bot token is automatically scheduled for deletion. + +To remove the Slack integration from your ABCA deployment entirely, delete the Slack App and redeploy without it. diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 8875133..8064580 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -6,11 +6,12 @@ This guide covers how to use ABCA to submit coding tasks and monitor their progr ABCA is a platform for running autonomous background coding agents on AWS. You submit a task (a GitHub repository + a task description or issue number), an agent works autonomously in an isolated environment, and delivers a pull request when done. -There are three ways to interact with the platform: +There are four ways to interact with the platform: 1. **CLI** (recommended) — The `bgagent` CLI authenticates via Cognito and calls the Task API. Handles login, token caching, and output formatting. 2. **REST API** (direct) — Call the Task API endpoints directly with a JWT token. Full validation, audit logging, and idempotency support. 3. **Webhook** — External systems (CI pipelines, GitHub Actions) can create tasks via HMAC-authenticated HTTP requests. No Cognito credentials needed; uses a shared secret per integration. +4. **Slack** — Submit tasks, check status, and receive notifications directly in Slack via the `/bgagent` slash command. See the [Slack setup guide](./SLACK_SETUP_GUIDE.md). ## Prerequisites diff --git a/docs/imgs/enable-events-after.png b/docs/imgs/enable-events-after.png new file mode 100644 index 0000000..83b3a5f Binary files /dev/null and b/docs/imgs/enable-events-after.png differ diff --git a/docs/imgs/enable-events-before.png b/docs/imgs/enable-events-before.png new file mode 100644 index 0000000..2aee2da Binary files /dev/null and b/docs/imgs/enable-events-before.png differ diff --git a/docs/imgs/find-credentials.png b/docs/imgs/find-credentials.png new file mode 100644 index 0000000..74bd252 Binary files /dev/null and b/docs/imgs/find-credentials.png differ diff --git a/docs/imgs/find-even-subscriptions.png b/docs/imgs/find-even-subscriptions.png new file mode 100644 index 0000000..8629b5d Binary files /dev/null and b/docs/imgs/find-even-subscriptions.png differ diff --git a/docs/src/content/docs/developer-guide/Installation.md b/docs/src/content/docs/developer-guide/Installation.md index 50380c8..4e0f7b5 100644 --- a/docs/src/content/docs/developer-guide/Installation.md +++ b/docs/src/content/docs/developer-guide/Installation.md @@ -37,13 +37,43 @@ You do **not** need standalone installs of Node or Yarn from nodejs.org or the Y #### One-time AWS account setup -The stack routes AgentCore Runtime traces to X-Ray, which requires CloudWatch Logs as a trace segment destination. Run this **once per account** before your first deployment: +The stack routes AgentCore Runtime traces to X-Ray, which requires CloudWatch Logs as a trace segment destination. Run these commands **once per account** before your first deployment: + +**1. Grant X-Ray permission to write to the `aws/spans` log group** via a CloudWatch Logs resource policy. The log group doesn't need to exist yet — X-Ray creates it automatically in step 2: ```bash -aws xray update-trace-segment-destination --destination CloudWatchLogs +aws logs put-resource-policy \ + --policy-name xray-spans-policy \ + --policy-document '{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "xray.amazonaws.com" + }, + "Action": [ + "logs:PutLogEvents", + "logs:CreateLogStream" + ], + "Resource": "arn:aws:logs:us-east-1:*:log-group:aws/spans:*" + } + ] + }' \ + --region us-east-1 ``` -Without this, `cdk deploy` will fail with: *"X-Ray Delivery Destination is supported with CloudWatch Logs as a Trace Segment Destination."* +Replace `us-east-1` with your deployment Region if different. + +**2. Set CloudWatch Logs as the trace segment destination** (this also creates the `aws/spans` log group): + +```bash +aws xray update-trace-segment-destination --destination CloudWatchLogs --region us-east-1 +``` + +Without step 1, step 2 will fail with: *"XRay does not have permission to call PutLogEvents on the aws/spans Log Group."* Without step 2, `cdk deploy` will fail with: *"X-Ray Delivery Destination is supported with CloudWatch Logs as a Trace Segment Destination."* + +> **Note:** Do not try to create the `aws/spans` log group manually — log group names starting with `aws/` are reserved and AWS will reject the call. X-Ray creates it automatically when you run step 2. ### Set up your toolchain diff --git a/docs/src/content/docs/developer-guide/Repository-preparation.md b/docs/src/content/docs/developer-guide/Repository-preparation.md index 4ddefda..8e193b8 100644 --- a/docs/src/content/docs/developer-guide/Repository-preparation.md +++ b/docs/src/content/docs/developer-guide/Repository-preparation.md @@ -14,9 +14,22 @@ After deployment, the orchestrator **pre-flight** step calls the GitHub API to v The Task API only accepts tasks for repositories that are **onboarded** — each one is a `Blueprint` construct in `cdk/src/stacks/agent.ts` that writes a `RepoConfig` row to DynamoDB. +**Quick method** — pass the repo as a CDK context variable or environment variable (no code edits needed): + +```bash +# Context variable (preferred) +MISE_EXPERIMENTAL=1 mise //cdk:deploy -- -c blueprintRepo=your-org/your-repo + +# Or environment variable +BLUEPRINT_REPO=your-org/your-repo MISE_EXPERIMENTAL=1 mise //cdk:deploy +``` + +The default is `awslabs/agent-plugins`. For a quick end-to-end test, fork that repo and pass your fork (e.g. `-c blueprintRepo=jane-doe/agent-plugins`). + +**Multiple repositories** — edit `cdk/src/stacks/agent.ts` directly: + 1. Open **`cdk/src/stacks/agent.ts`** and locate the `Blueprint` block (for example `AgentPluginsBlueprint`). -2. Set **`repo`** to your repository in **`owner/repo`** form. For a quick end-to-end test, use your **fork** of the sample plugin repo (e.g. `jane-doe/agent-plugins` after forking `awslabs/agent-plugins`). For your own services, use something like `acme/my-service`. This must match the `repo` field users pass in the CLI or API. -3. **Multiple repositories:** add another `new Blueprint(this, 'YourBlueprintId', { repo: 'owner/other-repo', repoTable: repoTable.table, ... })` and append it to the **`blueprints`** array. That array is used to aggregate per-repo **DNS egress** allowlists; skipping it can block the agent from reaching domains your Blueprint declares. +2. Add another `new Blueprint(this, 'YourBlueprintId', { repo: 'owner/other-repo', repoTable: repoTable.table, ... })` and append it to the **`blueprints`** array. That array is used to aggregate per-repo **DNS egress** allowlists; skipping it can block the agent from reaching domains your Blueprint declares. Optional per-repo overrides (same file / `Blueprint` props) include a different AgentCore **`runtimeArn`**, **`modelId`**, **`maxTurns`**, **`systemPromptOverrides`**, or a **`githubTokenSecretArn`** for a dedicated PAT. If you use a custom `runtimeArn` or secret per repo, you must also pass the corresponding ARNs into **`TaskOrchestrator`** via **`additionalRuntimeArns`** and **`additionalSecretArns`** so the orchestrator Lambda’s IAM policy allows them (see [Repo onboarding](/design/repo-onboarding) for the full model). diff --git a/docs/src/content/docs/user-guide/Overview.md b/docs/src/content/docs/user-guide/Overview.md index aae436b..d44eab0 100644 --- a/docs/src/content/docs/user-guide/Overview.md +++ b/docs/src/content/docs/user-guide/Overview.md @@ -4,8 +4,9 @@ title: Overview ABCA is a platform for running autonomous background coding agents on AWS. You submit a task (a GitHub repository + a task description or issue number), an agent works autonomously in an isolated environment, and delivers a pull request when done. -There are three ways to interact with the platform: +There are four ways to interact with the platform: 1. **CLI** (recommended) — The `bgagent` CLI authenticates via Cognito and calls the Task API. Handles login, token caching, and output formatting. 2. **REST API** (direct) — Call the Task API endpoints directly with a JWT token. Full validation, audit logging, and idempotency support. -3. **Webhook** — External systems (CI pipelines, GitHub Actions) can create tasks via HMAC-authenticated HTTP requests. No Cognito credentials needed; uses a shared secret per integration. \ No newline at end of file +3. **Webhook** — External systems (CI pipelines, GitHub Actions) can create tasks via HMAC-authenticated HTTP requests. No Cognito credentials needed; uses a shared secret per integration. +4. **Slack** — Submit tasks, check status, and receive notifications directly in Slack via the `/bgagent` slash command. See the [Slack setup guide](/design/slack-setup-guide). \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 86a7e7e..12c5e7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -361,6 +361,52 @@ "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" +"@aws-sdk/client-cloudformation@3.1024.0": + version "3.1024.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-cloudformation/-/client-cloudformation-3.1024.0.tgz#33d317a8be859a53f0e1bc781cc1494cf25eaabe" + integrity sha512-IehDPCok2Qr3mXKryc541EGRHV5axZ0Ym3iYtAdf9I/Fuy/qOsvUxeBW0EP5YsfWk8xY7pXhmF9xAX0ZjDjgDA== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.973.26" + "@aws-sdk/credential-provider-node" "^3.972.29" + "@aws-sdk/middleware-host-header" "^3.972.8" + "@aws-sdk/middleware-logger" "^3.972.8" + "@aws-sdk/middleware-recursion-detection" "^3.972.9" + "@aws-sdk/middleware-user-agent" "^3.972.28" + "@aws-sdk/region-config-resolver" "^3.972.10" + "@aws-sdk/types" "^3.973.6" + "@aws-sdk/util-endpoints" "^3.996.5" + "@aws-sdk/util-user-agent-browser" "^3.972.8" + "@aws-sdk/util-user-agent-node" "^3.973.14" + "@smithy/config-resolver" "^4.4.13" + "@smithy/core" "^3.23.13" + "@smithy/fetch-http-handler" "^5.3.15" + "@smithy/hash-node" "^4.2.12" + "@smithy/invalid-dependency" "^4.2.12" + "@smithy/middleware-content-length" "^4.2.12" + "@smithy/middleware-endpoint" "^4.4.28" + "@smithy/middleware-retry" "^4.4.46" + "@smithy/middleware-serde" "^4.2.16" + "@smithy/middleware-stack" "^4.2.12" + "@smithy/node-config-provider" "^4.3.12" + "@smithy/node-http-handler" "^4.5.1" + "@smithy/protocol-http" "^5.3.12" + "@smithy/smithy-client" "^4.12.8" + "@smithy/types" "^4.13.1" + "@smithy/url-parser" "^4.2.12" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.44" + "@smithy/util-defaults-mode-node" "^4.2.48" + "@smithy/util-endpoints" "^3.3.3" + "@smithy/util-middleware" "^4.2.12" + "@smithy/util-retry" "^4.2.13" + "@smithy/util-utf8" "^4.2.2" + "@smithy/util-waiter" "^4.2.14" + tslib "^2.6.2" + "@aws-sdk/client-cognito-identity-provider@^3.1021.0": version "3.1024.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1024.0.tgz#9a9c02214d8483e7585daff0eabcb2bb5f0babe0" @@ -550,6 +596,51 @@ "@smithy/util-waiter" "^4.2.14" tslib "^2.6.2" +"@aws-sdk/client-secrets-manager@3.1024.0": + version "3.1024.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1024.0.tgz#245e85dc306e8168d91abaa5ea6c6f1d31d7e043" + integrity sha512-EXbgMqueA5gw/jqpE2zMWAfBnzn6cZWqCISGdfn1201Um9IAIoTcHjyWoQMALQm0f8Lu1NF6yRtngs6zpZcagQ== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.973.26" + "@aws-sdk/credential-provider-node" "^3.972.29" + "@aws-sdk/middleware-host-header" "^3.972.8" + "@aws-sdk/middleware-logger" "^3.972.8" + "@aws-sdk/middleware-recursion-detection" "^3.972.9" + "@aws-sdk/middleware-user-agent" "^3.972.28" + "@aws-sdk/region-config-resolver" "^3.972.10" + "@aws-sdk/types" "^3.973.6" + "@aws-sdk/util-endpoints" "^3.996.5" + "@aws-sdk/util-user-agent-browser" "^3.972.8" + "@aws-sdk/util-user-agent-node" "^3.973.14" + "@smithy/config-resolver" "^4.4.13" + "@smithy/core" "^3.23.13" + "@smithy/fetch-http-handler" "^5.3.15" + "@smithy/hash-node" "^4.2.12" + "@smithy/invalid-dependency" "^4.2.12" + "@smithy/middleware-content-length" "^4.2.12" + "@smithy/middleware-endpoint" "^4.4.28" + "@smithy/middleware-retry" "^4.4.46" + "@smithy/middleware-serde" "^4.2.16" + "@smithy/middleware-stack" "^4.2.12" + "@smithy/node-config-provider" "^4.3.12" + "@smithy/node-http-handler" "^4.5.1" + "@smithy/protocol-http" "^5.3.12" + "@smithy/smithy-client" "^4.12.8" + "@smithy/types" "^4.13.1" + "@smithy/url-parser" "^4.2.12" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.44" + "@smithy/util-defaults-mode-node" "^4.2.48" + "@smithy/util-endpoints" "^3.3.3" + "@smithy/util-middleware" "^4.2.12" + "@smithy/util-retry" "^4.2.13" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + "@aws-sdk/client-secrets-manager@^3.1021.0": version "3.1021.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1021.0.tgz#57c6348c63146642132ffa7e885a2abba08c6ff4" @@ -4591,9 +4682,9 @@ baseline-browser-mapping@^2.10.12: integrity sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw== basic-ftp@^5.0.2, basic-ftp@^5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.2.2.tgz#4cb2422deddf432896bdb3c9b8f13b944ad4842c" - integrity sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw== + version "5.3.0" + resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.3.0.tgz#88f057d1ba8442643c505c4c83bbaa4442b15cfd" + integrity sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w== bcp-47-match@^2.0.0: version "2.0.3"